Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
707f0bc848 | ||
|
|
b2e93e9164 | ||
|
|
eb06cc29b0 | ||
|
|
d25edd114c | ||
|
|
f090e845ff | ||
|
|
d2e62debe8 | ||
|
|
8359caab16 | ||
|
|
f998023a28 | ||
|
|
3b6269978c | ||
|
|
052fc4cb80 | ||
|
|
2615365684 | ||
|
|
580e5782f8 | ||
|
|
3ce74ef8d7 | ||
|
|
3af6299f32 | ||
|
|
136c5d5b6b | ||
|
|
d8c9f05d10 | ||
|
|
a88e871640 | ||
|
|
ce7b3686f5 | ||
|
|
b0a973d390 | ||
|
|
e2fe406445 | ||
|
|
3c93684b2b | ||
|
|
3dad00f8ad | ||
|
|
b8491f3038 | ||
|
|
5ddc5e38a9 | ||
|
|
8f643b5b67 | ||
|
|
37b798e9f5 | ||
|
|
3ac6f7e49c | ||
|
|
0add56fb2d | ||
|
|
20b4431502 | ||
|
|
660b1bfa1f | ||
|
|
b15055ba4a | ||
|
|
8f17416eca | ||
|
|
86fd03fd6b | ||
|
|
fcf00e04f4 | ||
|
|
543550c716 | ||
|
|
ca81d4fc68 | ||
|
|
8b1dff581b | ||
|
|
f05e6290df | ||
|
|
a89be4b86e | ||
|
|
dbeabc1bae | ||
|
|
6f1c998a26 | ||
|
|
d185b10559 | ||
|
|
41a2a38025 | ||
|
|
f8f8de4a30 | ||
|
|
82cd5c5d0b | ||
|
|
612e265837 | ||
|
|
3415e12363 | ||
|
|
e0b110392a | ||
|
|
38da904f4b | ||
|
|
b7d1eddfa0 | ||
|
|
7208efbba6 | ||
|
|
2414b2077f | ||
|
|
1cbf3ecc4b | ||
|
|
ccae8599eb | ||
|
|
cdd91ab7e6 | ||
|
|
5b7469ae44 | ||
|
|
3c7ea2d894 | ||
|
|
55fbfa4499 | ||
|
|
b655cd9631 | ||
|
|
bc880ef6bd | ||
|
|
7ff0c2ac69 | ||
|
|
fa6e30545a | ||
|
|
3e206268b4 | ||
|
|
659e33676a | ||
|
|
cf44b37bf4 | ||
|
|
37dadeda84 | ||
|
|
b478cbfd2a | ||
|
|
76022ff91c | ||
|
|
b918a8395b | ||
|
|
2283734210 | ||
|
|
9dfa6f7d39 | ||
|
|
3ed48336af | ||
|
|
7f03b046ab | ||
|
|
90e363f49e | ||
|
|
465481f8f1 | ||
|
|
680bf410fe | ||
|
|
1009d06a4c | ||
|
|
7a84f00060 | ||
|
|
2d71351080 | ||
|
|
b0f76a8ba1 | ||
|
|
ed062a040c | ||
|
|
0430aab78e | ||
|
|
5d526d29db | ||
|
|
09c4358626 | ||
|
|
9ce7cf3b69 | ||
|
|
3745d23339 | ||
|
|
23f324e606 | ||
|
|
44a18ec799 | ||
|
|
1e003bbdb7 | ||
|
|
421bc372ee | ||
|
|
c8c48d408f | ||
|
|
d001616767 | ||
|
|
07ee48e20c | ||
|
|
9614e3498a | ||
|
|
0f082e1301 | ||
|
|
2808bead97 | ||
|
|
e8f9e68759 | ||
|
|
f2168d339b | ||
|
|
507d1f0882 | ||
|
|
381a7fdc40 | ||
|
|
c8e2e3a29f | ||
|
|
92a3189784 | ||
|
|
0af6cdedcd | ||
|
|
ab1b45da07 | ||
|
|
ed66e9394b | ||
|
|
f56630d7d6 | ||
|
|
50e97a8e0d | ||
|
|
e0bba21ecc | ||
|
|
28f784fe88 | ||
|
|
b421da165a | ||
|
|
8b59954ce0 | ||
|
|
70ada49776 | ||
|
|
e20f32c918 | ||
|
|
71adbfd8ff | ||
|
|
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 | ||
|
|
f50bbc005c | ||
|
|
e8dfa0eaaf | ||
|
|
b53c56d508 | ||
|
|
67c85be23c | ||
|
|
e41a3a4c1c | ||
|
|
e4276ff66e | ||
|
|
1e87081058 | ||
|
|
12b0a861e0 | ||
|
|
712d83a012 | ||
|
|
bd540ef18b | ||
|
|
2f2fced15c | ||
|
|
97ae05d688 | ||
|
|
df1d2767cd | ||
|
|
ed099a660e | ||
|
|
6536d140ba | ||
|
|
6a6b7ad6c2 | ||
|
|
01a23ccb77 | ||
|
|
9b8168370f | ||
|
|
b2b93e2839 | ||
|
|
f9113b6ad3 | ||
|
|
e168ab4148 | ||
|
|
e4412955f0 | ||
|
|
78eb766789 | ||
|
|
d8064cc426 | ||
|
|
bd4f1b2325 | ||
|
|
b38b9030f0 | ||
|
|
efa711af78 | ||
|
|
ea302966d5 | ||
|
|
ea5c2a56cf | ||
|
|
6b0fc48389 | ||
|
|
a27fa08e4e | ||
|
|
27d0952d01 | ||
|
|
4e9b1eebe9 | ||
|
|
0848c275d2 | ||
|
|
ea0789b191 | ||
|
|
1f94d0ef08 | ||
|
|
dd77e7f467 | ||
|
|
60dad353ce | ||
|
|
1b63483596 | ||
|
|
56c103140c | ||
|
|
b60e8285a4 | ||
|
|
0f1424335e | ||
|
|
506a1e6e4c | ||
|
|
8b7756beb4 | ||
|
|
dee4ffce2a | ||
|
|
fbf3cd116b | ||
|
|
b2165173cf | ||
|
|
912d727d7a | ||
|
|
32036a93ed | ||
|
|
7f4b0b78c0 | ||
|
|
775daa233e | ||
|
|
4fb013701b | ||
|
|
2dd68da284 | ||
|
|
65991be09d | ||
|
|
1c4697ecb2 | ||
|
|
ab89611eca | ||
|
|
b952e074f4 | ||
|
|
e01fc39abc | ||
|
|
deabb74393 | ||
|
|
803f4dc4ed | ||
|
|
20784c2fb1 | ||
|
|
83dbdf0539 | ||
|
|
c9d46cfab9 | ||
|
|
c7140fd639 | ||
|
|
c887b77b5f | ||
|
|
b3e398b8d6 | ||
|
|
f4a72da118 | ||
|
|
086581851b | ||
|
|
f8fdfab031 | ||
|
|
3f82da3c18 | ||
|
|
cd557acf81 | ||
|
|
036dc53fe8 | ||
|
|
73806cbd70 | ||
|
|
ee148732dd | ||
|
|
3014796c1d | ||
|
|
405b0e24cf |
@@ -0,0 +1,112 @@
|
||||
# Building OpsLog on Linux
|
||||
|
||||
OpsLog is developed on Windows. The Linux build shares every line of the
|
||||
frontend and all but a handful of Go files; what differs is listed at the bottom
|
||||
of this page.
|
||||
|
||||
**It cannot be cross-compiled from Windows.** Wails links against the system
|
||||
WebKit on Linux, which needs cgo and the GTK/WebKit headers, so the binary has
|
||||
to be produced on a Linux machine (or a container). What *can* be checked from
|
||||
Windows — and is, at every release — is that the Go half still compiles:
|
||||
|
||||
```bash
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build ./...
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go vet ./...
|
||||
```
|
||||
|
||||
## The short way
|
||||
|
||||
```bash
|
||||
./scripts/linux-setup.sh
|
||||
```
|
||||
|
||||
It checks everything below, prints the one install command your distribution
|
||||
needs if something is missing, and builds when nothing is. The rest of this page
|
||||
is what it checks, for when you would rather do it by hand.
|
||||
|
||||
## Dependencies
|
||||
|
||||
```bash
|
||||
# Debian / Ubuntu
|
||||
sudo apt install build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev nodejs npm
|
||||
|
||||
# Fedora
|
||||
sudo dnf install gcc-c++ pkgconf-pkg-config gtk3-devel webkit2gtk4.1-devel nodejs npm
|
||||
|
||||
# Arch
|
||||
sudo pacman -S base-devel pkgconf gtk3 webkit2gtk-4.1 nodejs npm
|
||||
```
|
||||
|
||||
**Go and node do not come from the package manager.** No current distribution
|
||||
ships a Go new enough for `go.mod` (Ubuntu 24.04 / Mint 22 have 1.22, Ubuntu
|
||||
22.04 / Mint 21 have 1.18), and Ubuntu 22.04 / Mint 21 ship node 12 where Vite
|
||||
needs 18. Both are the usual reason a first build fails with an error that
|
||||
points somewhere else entirely:
|
||||
|
||||
```bash
|
||||
# Go, from go.dev
|
||||
wget https://go.dev/dl/go1.25.1.linux-amd64.tar.gz
|
||||
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.25.1.linux-amd64.tar.gz
|
||||
echo 'export PATH=/usr/local/go/bin:$HOME/go/bin:$PATH' >> ~/.profile # log out and back in
|
||||
|
||||
# node 20, only if `node -v` is below 18
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - && sudo apt install nodejs
|
||||
```
|
||||
|
||||
Then the Wails CLI:
|
||||
|
||||
```bash
|
||||
go install github.com/wailsapp/wails/v2/cmd/[email protected]
|
||||
wails doctor # says what is still missing
|
||||
```
|
||||
|
||||
`libwebkit2gtk-4.0` also works; pass `-tags webkit2_40` to `wails build` if your
|
||||
distribution only has the older one.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
wails build # → build/bin/OpsLog
|
||||
./build/bin/OpsLog
|
||||
```
|
||||
|
||||
`wails dev` works the same as on Windows.
|
||||
|
||||
## Runtime requirements
|
||||
|
||||
- **PulseAudio or PipeWire** for the voice keyer, the QSO recorder and the CW
|
||||
decoder. PipeWire is fine — OpsLog speaks the PulseAudio protocol, which
|
||||
`pipewire-pulse` answers. Without a sound server those three features report
|
||||
"cannot reach the sound server" and everything else works normally.
|
||||
- **Serial port access** for CAT, keyers, rotators and amplifiers. Ports appear
|
||||
as `/dev/ttyUSB0`, `/dev/ttyACM0`… and on most distributions belong to the
|
||||
`dialout` group:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG dialout $USER # log out and back in
|
||||
```
|
||||
|
||||
This is the single most common reason a rig that works in WSJT-X shows
|
||||
"permission denied" in OpsLog.
|
||||
- **TrustedQSL** (`tqsl`) for LoTW uploads, from your package manager. OpsLog
|
||||
finds it on `PATH`.
|
||||
|
||||
## Where OpsLog keeps its data
|
||||
|
||||
Next to the binary, in `data/` — the same portable layout as on Windows, so a
|
||||
folder in your home directory carries the logbook with it.
|
||||
|
||||
If the binary sits somewhere you cannot write (`/usr/bin`, `/opt`), OpsLog uses
|
||||
`~/.local/share/OpsLog/data` instead and says so in `startup.log`. The startup
|
||||
log itself lives in `~/.cache/OpsLog/startup.log`.
|
||||
|
||||
## What is different from the Windows build
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **OmniRig** | Not available — it is Windows COM automation. Use a native backend instead: Icom CI-V (USB and network), Yaesu, Kenwood/Elecraft, FlexRadio, TCI, Xiegu. |
|
||||
| **Denkovi USB relay** | Not available — it needs FTDI's `ftd2xx.dll`. The other relay backends work. |
|
||||
| **Audio** | PulseAudio/PipeWire instead of WASAPI. Same devices, same fixed 16 kHz mono format. |
|
||||
| **Auto-update** | Works, and is simpler: Linux lets a running binary be replaced, so none of the Windows deferred-swap machinery is needed. |
|
||||
| **Window placement** | OpsLog cannot read the monitor layout, so a saved window position is always trusted rather than clamped onto a visible screen. |
|
||||
| **Single instance** | An `flock` on `$XDG_RUNTIME_DIR/OpsLog/instance.lock` instead of a named mutex. It cannot raise the existing window, only refuse to start a second one. |
|
||||
@@ -1,6 +1,6 @@
|
||||
# OpsLog
|
||||
|
||||
<a href="https://discord.gg/FYM8yw5pT" target="_blank">
|
||||
<a href="https://discord.gg/tmVPdQ5A8" target="_blank">
|
||||
<img src="https://img.shields.io/badge/Discord-Join%20the%20server-5865F2?logo=discord&logoColor=white" alt="Join our Discord" />
|
||||
</a>
|
||||
|
||||
@@ -39,6 +39,9 @@ Developed by **F4BPO**.
|
||||
- **Offline DXCC** resolution from `cty.dat` (country, CQ/ITU zones, continent),
|
||||
with `/MM` `/AM` `/B` (beacon) and call-area (`/8`, `/W6`) handling, plus
|
||||
ClubLog DXpedition date overrides.
|
||||
- **Editable lists** for bands, modes (with their default RST) and **satellites**
|
||||
— the satellite name on the entry form is offered as a dropdown, because
|
||||
SAT_NAME is compared character for character by the awards and by LoTW.
|
||||
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
|
||||
from cty/QRZ/ClubLog, bulk send to QSL services. A live **selection count**, a
|
||||
**Select all / Unselect all** toggle, and a row limit that keeps the full log
|
||||
@@ -57,11 +60,13 @@ Developed by **F4BPO**.
|
||||
|
||||
- **Main view = two configurable panes** (per profile, Settings → General →
|
||||
*Main view*): great-circle map, locator (street) map, the cluster grid, the
|
||||
worked-before grid, recent QSOs, the **FlexRadio controls**, the **Icom
|
||||
console** or the **Net control** panel.
|
||||
worked-before grid, recent QSOs, the **FT decodes**, or any of the radio
|
||||
consoles — **FlexRadio**, **Icom**, **Yaesu**, **Elecraft K3/K4**, **SunSDR
|
||||
(TCI)** — and the **Net control** panel. Up to four panes, in columns or a
|
||||
quadrant.
|
||||
- **Great-circle map** with short/long-path distance & azimuth, selectable
|
||||
basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and
|
||||
the **antenna beam lobe(s)** drawn from the rotor azimuth.
|
||||
basemaps (Light / Topo / Street / Satellite — Esri tiles, key-free and
|
||||
labelled) and the **antenna beam lobe(s)** drawn from the rotor azimuth.
|
||||
- **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by
|
||||
**PstRotator** (UDP), a **4O3A Rotator Genius** (native TCP) or a **microHAM
|
||||
ARCO** controlled natively — no PstRotator needed — over the **LAN** or **USB**
|
||||
@@ -87,8 +92,10 @@ Developed by **F4BPO**.
|
||||
|
||||
## CAT control
|
||||
|
||||
Four native backends (Settings → CAT), each with auto-reconnect and a fast,
|
||||
non-blocking connect so a powered-off radio never freezes the app:
|
||||
Six native backends (Settings → CAT), each with auto-reconnect and a fast,
|
||||
non-blocking connect so a powered-off radio never freezes the app. The settings
|
||||
ask two questions rather than one — **which radio**, then **how it is
|
||||
connected** — and only offer the links that radio actually has:
|
||||
|
||||
- **OmniRig** (Rig 1/2, hot-swap) — works with any OmniRig-supported rig.
|
||||
- **FlexRadio (SmartSDR)** over the radio's TCP API — real-time slice freq /
|
||||
@@ -98,8 +105,17 @@ non-blocking connect so a powered-off radio never freezes the app:
|
||||
- **Icom CI-V** — native, over the radio's **USB** port *or* over the internet
|
||||
via the radio's **built-in LAN server** (see *Remote Icom* below). No RS-BA1 or
|
||||
Remote Utility needed.
|
||||
- **TCI** (WebSocket) — SunSDR / ExpertSDR2 and any TCI-compatible server:
|
||||
freq / mode / PTT / split, plus optional panorama spots.
|
||||
- **Kenwood / Elecraft** — native ASCII CAT over USB or an RS232-to-Ethernet
|
||||
bridge, for the K3 / K4 and the Kenwood dialect they share.
|
||||
- **Yaesu** — native ASCII CAT (FA/FB/MD/VS…), which replaces OmniRig for an
|
||||
FTDX and gives the Yaesu console below.
|
||||
- **TCI** (WebSocket) — SunSDR / ExpertSDR3 and any TCI-compatible server:
|
||||
freq / mode / PTT / split, panorama spots, **audio in both directions** and a
|
||||
full console. See *TCI audio* below.
|
||||
|
||||
**CAT sharing:** OpsLog can hand the radio on to other programs while it holds
|
||||
the port — a **Hamlib NET rigctl** server (WSJT-X, JTDX, MSHV, fldigi…) or a
|
||||
**TCI** server. One or the other, on a port you choose.
|
||||
|
||||
Mode is taken from the radio; the digital sub-mode (FT4 vs FT8) is inferred from
|
||||
the frequency. **Per-band Flex RX/TX antennas** can be configured and are applied
|
||||
@@ -124,6 +140,32 @@ Shown only when the CAT backend is a FlexRadio:
|
||||
- **Live meters** over the UDP VITA-49 stream: S-meter (S-units), forward power
|
||||
(W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters.
|
||||
|
||||
### Elecraft K3 / K4 control tab
|
||||
|
||||
Shown for the Elecraft and Kenwood backends: power, AF/RF gain, mic gain,
|
||||
squelch, preamp/attenuator, NB/NR/AGC, filter widths (200 Hz to 4.0 kHz, the
|
||||
K3 maximum and the one FT8 wants), antenna selection, MOX and the ATU
|
||||
(tap to tune, hold for in/out), RIT/XIT with ±10 / ±100 steps, key speed, and
|
||||
live S / power / SWR meters. The SWR is read from the radio's own `SW;`.
|
||||
|
||||
### Yaesu control tab
|
||||
|
||||
Shown for the Yaesu backend: power, mic gain, AGC, NB/NR, preamp/attenuator,
|
||||
filter width, RIT/XIT and the meters — the same shape as the others, driven by
|
||||
the FTDX's own CAT set.
|
||||
|
||||
### SunSDR console (TCI)
|
||||
|
||||
Shown for the TCI backend. Drive and tune drive, mic gain, TUNE, power and SWR
|
||||
while transmitting, volume, mute, squelch and its threshold, NB / NR / ANF,
|
||||
APF in CW, AGC speed, filter widths **offered per mode**, RIT and XIT, and the
|
||||
VFO lock. Levels can be dragged, scrolled, stepped or typed. The S-meter reads
|
||||
real dBm, so its S-units are arithmetic rather than a calibration guess.
|
||||
|
||||
It costs nothing to keep open: TCI **pushes** every setting as it changes —
|
||||
including changes made in the radio's own window, which the console follows
|
||||
without asking anything.
|
||||
|
||||
### Icom control tab
|
||||
|
||||
Shown when the CAT backend is Icom (USB or network). A full RS-BA1-style console:
|
||||
@@ -169,6 +211,17 @@ as Mumble.)
|
||||
- **QSO audio recording:** continuous rolling capture; on *Log QSO* the contact
|
||||
is saved to a per-QSO WAV (`CALL_YYYYMMDD_HHMMSS.wav`); mixes RX + mic.
|
||||
|
||||
### TCI audio — no virtual cable
|
||||
|
||||
A SunSDR carries its audio on the same WebSocket as its commands, so OpsLog can
|
||||
take it directly. Pick **Radio (TCI network audio)** as the *From radio* or
|
||||
*To radio* device and the QSO recorder and the voice keyer work with no virtual
|
||||
audio cable, no second sound card and nothing to set in the Windows mixer.
|
||||
|
||||
Transmit needs no setting up in ExpertSDR3 either: the audio source is named on
|
||||
each key-down, so it works in SSB as well as the digital modes, and the
|
||||
microphone stays the source for every transmission that is not a voice message.
|
||||
|
||||
## Amplifiers & switches
|
||||
|
||||
- **Amplifiers** — configure **one or several** amps (Settings → Amplifier is a
|
||||
@@ -182,12 +235,24 @@ as Mumble.)
|
||||
**network** (RS232-to-Ethernet bridge) — operate/standby, ON/OFF,
|
||||
Low / Mid / High output level, an output-power bar and live status (band,
|
||||
SWR, PA current, temperature, warnings/alarms).
|
||||
- **ACOM** (500S / 600S / 700S / 1200S / 2020S) over **USB** or the **network**
|
||||
- **Acom** (500S / 600S / 700S / 1200S / 2020S) over **USB** or the **network**
|
||||
(RS232-to-Ethernet bridge) — operate/standby/off and live telemetry (forward
|
||||
& reflected power, SWR, PA temperature, band, fan, faults). Power-ON works
|
||||
over a serial cable that wires the DTR/RTS lines.
|
||||
- **Elecraft KPA500 / KPA1500** over **USB** (serial) or — on the KPA1500 —
|
||||
over the **network**. Operate/standby, on/off, forward power, SWR,
|
||||
temperature, supply voltage and current, the band, and faults named in plain
|
||||
words rather than as a code. The amplifier follows the radio's band by
|
||||
itself, on the link already open — it has a band command of its own, so no
|
||||
second serial port is involved.
|
||||
|
||||
An amplifier that takes its band from a transceiver instead (Acom, SPE) can be
|
||||
followed a second way: OpsLog answers its frequency polls as a Kenwood rig on
|
||||
a **second COM port**, independent of the control link.
|
||||
- **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
|
||||
antenna-switch widget.
|
||||
antenna-switch widget, and an option to write the **selected antenna into
|
||||
MY_ANTENNA** under the name the switch itself carries.
|
||||
- **Tuner Genius XL** (4O3A) over TCP — SWR and power, TUNE / BYPASS / OPERATE.
|
||||
- **Station Control** panel (dockable, drag-to-reorder widgets): the **rotator**,
|
||||
**Ultrabeam** element control and **relay boards** — WebSwitch 1216H, KMTronic,
|
||||
**Denkovi** USB (FT245 D2XX bit-bang, 4 or 8 relays) and generic USB-serial
|
||||
@@ -204,8 +269,18 @@ as Mumble.)
|
||||
reference assignment, live reference detection on call entry, **reference-list
|
||||
import** for totals/names, and a **Rescan** that re-pulls the logbook (picks up
|
||||
fresh LoTW/QRZ confirmations).
|
||||
- **QSL services:** ClubLog (batched ADIF upload), LoTW, QRZ.com, eQSL — upload
|
||||
and **confirmation download** (which auto-refreshes the award stats).
|
||||
- **Award statistics** by band and by mode class, with the **DXCC Challenge**
|
||||
worked out where it can be compared to LoTW's own figure — it counts confirmed
|
||||
band-slots on ten bands, and 60 m is not one of them, which is the whole of the
|
||||
usual discrepancy. The band columns follow the bands you actually have contacts
|
||||
on, up to and beyond 23 cm.
|
||||
- **Russian districts (RDA):** an offline district database that knows where a
|
||||
callsign was **on the day of the contact**, a bulk fill for existing QSOs, and
|
||||
a comparison against the district an imported log carries — each disagreement
|
||||
settled on its own row, with the choice written into the contact.
|
||||
- **QSL services:** ClubLog (batched ADIF upload), LoTW, QRZ.com, eQSL,
|
||||
**Cloudlog / Wavelog** and **HAMLOG.online** — upload and **confirmation
|
||||
download** (which auto-refreshes the award stats).
|
||||
- **QSL Card Designer** (see below).
|
||||
- **E-mail eQSL:** right-click a QSO → *Send eQSL by e-mail* via the configured
|
||||
SMTP account. (Outlook/Hotmail disable basic-auth SMTP — use Gmail with an app
|
||||
@@ -266,6 +341,10 @@ only writes to the DB — it is not a web server.
|
||||
- **UDP emitters:** push the current frequency to **PstRotator**, radio info in
|
||||
**N1MM `RadioInfo`** format, or an **ADIF record on each logged QSO** — so
|
||||
external tools (rotator control, digital apps, other loggers) stay in sync.
|
||||
- **CAT sharing:** a **Hamlib NET rigctl** or **TCI** server, so WSJT-X, JTDX,
|
||||
MSHV, fldigi and the rest still reach the radio while OpsLog holds the port.
|
||||
- **WSJT-X / JTDX / MSHV** decodes over UDP: the heard stations feed the band
|
||||
map, the alerts and — on a FlexRadio — the panadapter.
|
||||
|
||||
## Other
|
||||
|
||||
@@ -309,6 +388,19 @@ commonly-worked DXCC entities.
|
||||
|
||||
---
|
||||
|
||||
## Importing a log
|
||||
|
||||
- **ADIF import** with duplicate handling (skip / update / import anyway) and an
|
||||
optional field mapping for exports that use their own names.
|
||||
- Optionally recompute country / continent / DXCC / zones from **cty.dat** and
|
||||
the **ClubLog date-ranged exceptions** — which know that a callsign belonged to
|
||||
a different entity on an older date.
|
||||
- **Deleted DXCC entities are left alone.** cty.dat only knows where a callsign
|
||||
is *today*: R1MVI resolves to European Russia now, but a 2004 contact was Malyj
|
||||
Vysotskij — an entity the ARRL deleted in 2012 and which can never be worked
|
||||
again. Where a record names one of the sixty-odd deleted entities, its own
|
||||
number is kept.
|
||||
|
||||
## Data & storage
|
||||
|
||||
- **Config** (settings, profiles, rigs/antennas, cluster nodes, lookup cache,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
// MY_ANTENNA from the Antenna Genius.
|
||||
//
|
||||
// A station with a switch has a truth the log does not: which antenna is
|
||||
// actually connected right now. The working conditions hold what was PLANNED
|
||||
// for the band — the default antenna ticked for 20 m — and that is right until
|
||||
// the operator throws the switch, at which point the log quietly keeps claiming
|
||||
// the other antenna for the rest of the session.
|
||||
//
|
||||
// So, as an option: when the Antenna Genius knows which antenna is selected,
|
||||
// its name wins. The name is the one configured on the device, because that is
|
||||
// the name the operator gave it and the one they will look for in the log.
|
||||
//
|
||||
// WHICH PORT is the whole difficulty. An Antenna Genius has two, A and B, and a
|
||||
// FlexRadio has two transmit antenna jacks, ANT1 and ANT2. The QSO was made on
|
||||
// exactly one of them, and stamping the wrong port's antenna would be worse
|
||||
// than stamping the band default — a wrong answer that looks authoritative. The
|
||||
// radio's own TX antenna selection is what decides.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/antgenius"
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
const (
|
||||
keyAntGeniusMyAnt = "antgenius.my_antenna" // use the selected antenna as MY_ANTENNA
|
||||
keyAntGeniusPortForA1 = "antgenius.port_for_ant1" // which AG port ANT1 is wired to (1=A, 2=B)
|
||||
)
|
||||
|
||||
// antGeniusPortFor maps the radio's transmit antenna jack to an Antenna Genius
|
||||
// port.
|
||||
//
|
||||
// The wiring is the station's, not something that can be read from either
|
||||
// device: ANT1 usually goes to port A and ANT2 to port B, which is what the
|
||||
// setting defaults to, but a station wired the other way round would otherwise
|
||||
// log every QSO with the other antenna's name.
|
||||
func (a *App) antGeniusPortFor(txAnt string) int {
|
||||
ant1Port := 1
|
||||
if a.settingOr(keyAntGeniusPortForA1, "1") == "2" {
|
||||
ant1Port = 2
|
||||
}
|
||||
other := 3 - ant1Port
|
||||
switch strings.ToUpper(strings.TrimSpace(txAnt)) {
|
||||
case "ANT1", "ANT 1", "1", "A":
|
||||
return ant1Port
|
||||
case "ANT2", "ANT 2", "2", "B":
|
||||
return other
|
||||
}
|
||||
return 0 // XVTR, a rig with one jack, or nothing reported: no port to name
|
||||
}
|
||||
|
||||
// antGeniusAntennaName returns the name of the antenna currently selected on
|
||||
// the port the radio is transmitting through, or "" when it cannot be told.
|
||||
//
|
||||
// Deliberately silent rather than approximate. Every "" here means the log
|
||||
// keeps the band default it would have had anyway, which is a defensible
|
||||
// answer; a guessed port is not.
|
||||
func (a *App) antGeniusAntennaName() string {
|
||||
if a.antgenius == nil {
|
||||
return ""
|
||||
}
|
||||
st := a.antgenius.GetStatus()
|
||||
if !st.Connected {
|
||||
return ""
|
||||
}
|
||||
// The jack in use comes from the FlexRadio's own state — TXAnt lives on the
|
||||
// Flex panel state, not on the backend-agnostic RigState, because only a
|
||||
// Flex has named antenna jacks to report.
|
||||
txAnt := ""
|
||||
if a.cat != nil {
|
||||
if fs, ok := a.cat.FlexState(); ok {
|
||||
txAnt = fs.TXAnt
|
||||
}
|
||||
}
|
||||
port := a.antGeniusPortFor(txAnt)
|
||||
if port == 0 {
|
||||
// One switch port in use and no ambiguity about which: a station whose
|
||||
// radio has a single jack still deserves the name. Two ports carrying
|
||||
// different antennas with nothing to choose between them does not.
|
||||
if st.PortA > 0 && st.PortB == 0 {
|
||||
port = 1
|
||||
} else if st.PortB > 0 && st.PortA == 0 {
|
||||
port = 2
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
idx := st.PortA
|
||||
if port == 2 {
|
||||
idx = st.PortB
|
||||
}
|
||||
if idx <= 0 {
|
||||
return ""
|
||||
}
|
||||
return antGeniusNameOf(st, idx)
|
||||
}
|
||||
|
||||
// antGeniusNameOf looks an antenna index up in the device's own list.
|
||||
func antGeniusNameOf(st antgenius.Status, idx int) string {
|
||||
for _, ant := range st.Antennas {
|
||||
if ant.Index == idx {
|
||||
return strings.TrimSpace(ant.Name)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// applyAntGeniusAntenna overrides MY_ANTENNA with the switch's selection, when
|
||||
// the option is on.
|
||||
//
|
||||
// Called at log time rather than while the entry form is open: an operator who
|
||||
// changes antenna mid-QSO is telling us what the contact was actually made on,
|
||||
// and the value that matters is the one at the moment it is logged.
|
||||
func (a *App) applyAntGeniusAntenna(myAntenna *string) {
|
||||
if myAntenna == nil || a.settingOr(keyAntGeniusMyAnt, "") != "1" {
|
||||
return
|
||||
}
|
||||
name := a.antGeniusAntennaName()
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
if *myAntenna != name {
|
||||
applog.Printf("antgenius: MY_ANTENNA %q → %q (the antenna selected on the switch)", *myAntenna, name)
|
||||
}
|
||||
*myAntenna = name
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/antgenius"
|
||||
)
|
||||
|
||||
// The switch has two ports and the radio two antenna jacks; a QSO was made
|
||||
// through exactly one of them. Naming the wrong port's antenna would be worse
|
||||
// than naming the band default — it is a wrong answer that looks authoritative.
|
||||
func TestAntennaNameComesFromTheDeviceList(t *testing.T) {
|
||||
st := antgenius.Status{
|
||||
Connected: true,
|
||||
PortA: 2,
|
||||
PortB: 5,
|
||||
Antennas: []antgenius.Antenna{
|
||||
{Index: 2, Name: "OB11-5 20/15/10"},
|
||||
{Index: 5, Name: "Vertical 80m"},
|
||||
},
|
||||
}
|
||||
if got := antGeniusNameOf(st, 2); got != "OB11-5 20/15/10" {
|
||||
t.Fatalf("port A antenna = %q", got)
|
||||
}
|
||||
if got := antGeniusNameOf(st, 5); got != "Vertical 80m" {
|
||||
t.Fatalf("port B antenna = %q", got)
|
||||
}
|
||||
// An index the device never described has no name to give, and inventing
|
||||
// one ("Antenna 7") would put a label in the log that exists nowhere else.
|
||||
if got := antGeniusNameOf(st, 7); got != "" {
|
||||
t.Fatalf("unknown index produced %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The jack-to-port wiring is the station's own. Defaulting ANT1 to port A is a
|
||||
// convention, not a fact, so it is a setting — and the mapping has to hold both
|
||||
// ways round.
|
||||
func TestJackToPortMapping(t *testing.T) {
|
||||
app := &App{}
|
||||
// No settings store: settingOr falls back, which is ANT1 → port A.
|
||||
if got := app.antGeniusPortFor("ANT1"); got != 1 {
|
||||
t.Fatalf("ANT1 mapped to port %d, want A(1)", got)
|
||||
}
|
||||
if got := app.antGeniusPortFor("ANT2"); got != 2 {
|
||||
t.Fatalf("ANT2 mapped to port %d, want B(2)", got)
|
||||
}
|
||||
// A jack that is neither — a transverter port, or a rig that reports
|
||||
// nothing — has no port to name, and saying so beats guessing.
|
||||
if got := app.antGeniusPortFor("XVTR"); got != 0 {
|
||||
t.Fatalf("XVTR mapped to port %d, want none", got)
|
||||
}
|
||||
if got := app.antGeniusPortFor(""); got != 0 {
|
||||
t.Fatalf("an unreported jack mapped to port %d, want none", got)
|
||||
}
|
||||
}
|
||||
+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()
|
||||
}
|
||||
@@ -19,6 +19,8 @@ var deniedCallHashes = map[string]struct{}{
|
||||
"d1ae6212ec057f9d5c6f379fb57acedac7c94142c9d49667dc04b2016d03d1b6": {},
|
||||
"0741c9e394b42f43191899105553b47155ddc3026da12b5360701f9c181ff123": {},
|
||||
"ab4926a3a0ab76d41b5b99cd3ad0683584970c341c29427c1dfa4b3c329ce415": {},
|
||||
"9d17c9c213a6cc89c12d7520bcf21c86a0cf43d17e82e74f33f3a77cb865d28a": {},
|
||||
"94ee059335e587e501cc4bf90613e0814f00a7b08bc7c648fd865a2af6a22cc2": {},
|
||||
}
|
||||
|
||||
// callDenied reports whether a callsign is on deniedCallHashes. The call is
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
// ── Who hears me (PSK Reporter) ────────────────────────────────────────────
|
||||
//
|
||||
// The FT map draws what this station decodes. This is the other direction:
|
||||
// which stations are reporting our own transmissions, which is the half an
|
||||
// operator cannot see from their own receiver and the half that decides whether
|
||||
// calling is worth the cycle.
|
||||
//
|
||||
// See internal/pskrme for why it costs almost nothing — the operator's callsign
|
||||
// goes in the topic's TRANSMIT level, so the broker sends nothing else.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/pskrme"
|
||||
)
|
||||
|
||||
// keyHearMeOn is per-profile, not global: a second profile is usually a second
|
||||
// callsign, and the answer to "who hears me" is not the same one.
|
||||
const keyHearMeOn = "hearme.on"
|
||||
|
||||
// GetHearMe reports whether the feed is wanted.
|
||||
func (a *App) GetHearMe() bool {
|
||||
if a.settings == nil || !a.settingsScoped.Load() {
|
||||
return false
|
||||
}
|
||||
v, _ := a.settings.Get(a.ctx, keyHearMeOn)
|
||||
return v == "1"
|
||||
}
|
||||
|
||||
// SetHearMe turns the feed on or off and brings the subscription with it.
|
||||
func (a *App) SetHearMe(on bool) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if err := a.settings.Set(a.ctx, keyHearMeOn, boolStr(on)); err != nil {
|
||||
return err
|
||||
}
|
||||
a.startHearMe()
|
||||
if on {
|
||||
// Not an error: the feed is wanted and will come up as soon as there is
|
||||
// a callsign to watch for, and saying so beats a silent switch.
|
||||
if strings.TrimSpace(a.opCall) == "" {
|
||||
return fmt.Errorf("set the station callsign first — the feed watches for it")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// startHearMe rebuilds the watcher from the setting. Called at startup, when
|
||||
// the setting changes, and on a profile switch — the callsign is what it
|
||||
// subscribes to, so a new profile needs a new subscription.
|
||||
func (a *App) startHearMe() {
|
||||
if a.hearMe != nil {
|
||||
a.hearMe.Stop()
|
||||
a.hearMe = nil
|
||||
}
|
||||
if !a.GetHearMe() {
|
||||
return
|
||||
}
|
||||
call := strings.ToUpper(strings.TrimSpace(a.opCall))
|
||||
if call == "" {
|
||||
applog.Printf("pskrme: no station callsign — nothing to watch for")
|
||||
return
|
||||
}
|
||||
w := pskrme.New(pskrme.Config{MyCall: call, Logf: applog.Printf})
|
||||
if err := w.Start(); err != nil {
|
||||
applog.Printf("pskrme: feed did not start: %v", err)
|
||||
return
|
||||
}
|
||||
a.hearMe = w
|
||||
}
|
||||
|
||||
// GetWhoHearsMe is the map layer: one entry per station that has reported us
|
||||
// inside the window, carrying its own square so the arc is arithmetic.
|
||||
func (a *App) GetWhoHearsMe() []pskrme.Report {
|
||||
if a.hearMe == nil {
|
||||
return nil
|
||||
}
|
||||
return a.hearMe.Reports()
|
||||
}
|
||||
|
||||
// GetHearMeStatus tells a working feed from a silent one: a connection that is
|
||||
// up and reporting nothing looks exactly like a broken one until you can see a
|
||||
// number moving.
|
||||
func (a *App) GetHearMeStatus() pskrme.Status {
|
||||
if a.hearMe == nil {
|
||||
return pskrme.Status{Watching: strings.ToUpper(strings.TrimSpace(a.opCall))}
|
||||
}
|
||||
return a.hearMe.Status()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
// Elecraft K3/K4 panel bindings.
|
||||
//
|
||||
// The controls the operator asked for and nothing else: power, volume, the
|
||||
// S-meter and SWR, MOX, and an ATU tune. A K3 exposes dozens of commands, but a
|
||||
// panel earns its place by covering what is reached for during a QSO — and each
|
||||
// one added without a radio to test it against is a control that may or may not
|
||||
// do what its label says. See internal/cat/kenwood_panel.go.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"hamlog/internal/cat"
|
||||
)
|
||||
|
||||
// GetKenwoodState returns the K3/K4 panel snapshot. Zero value when the active
|
||||
// CAT backend is something else, which is how the UI knows to hide the panel.
|
||||
func (a *App) GetKenwoodState() cat.KenwoodTXState {
|
||||
if a.cat == nil {
|
||||
return cat.KenwoodTXState{}
|
||||
}
|
||||
st, _ := a.cat.KenwoodState()
|
||||
return st
|
||||
}
|
||||
|
||||
// SetKenwoodPanelMode sets the operating mode from the panel's mode row —
|
||||
// CW / USB / LSB / DATA (soundcard, DT0) / RTTY (FSK D, DT2).
|
||||
func (a *App) SetKenwoodPanelMode(mode string) error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodPanelMode(mode) })
|
||||
}
|
||||
|
||||
// SetKenwoodRITOffset sets the RIT/XIT offset to an absolute value in Hz.
|
||||
func (a *App) SetKenwoodRITOffset(hz int) error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodRITOffset(hz) })
|
||||
}
|
||||
|
||||
func (a *App) SetKenwoodPower(w int) error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodPower(w) })
|
||||
}
|
||||
|
||||
func (a *App) SetKenwoodAFGain(p int) error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodAFGain(p) })
|
||||
}
|
||||
|
||||
func (a *App) SetKenwoodRFGain(p int) error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodRFGain(p) })
|
||||
}
|
||||
|
||||
func (a *App) SetKenwoodMicGain(p int) error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodMicGain(p) })
|
||||
}
|
||||
|
||||
func (a *App) SetKenwoodSquelch(p int) error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodSquelch(p) })
|
||||
}
|
||||
|
||||
func (a *App) SetKenwoodPreamp(on bool) error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodPreamp(on) })
|
||||
}
|
||||
|
||||
func (a *App) SetKenwoodAtt(on bool) error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodAtt(on) })
|
||||
}
|
||||
|
||||
func (a *App) SetKenwoodNB(on bool) error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodNB(on) })
|
||||
}
|
||||
|
||||
func (a *App) SetKenwoodNR(on bool) error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodNR(on) })
|
||||
}
|
||||
|
||||
func (a *App) SetKenwoodAGC(name string) error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodAGC(name) })
|
||||
}
|
||||
|
||||
func (a *App) SetKenwoodFilter(hz int) error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodFilter(hz) })
|
||||
}
|
||||
|
||||
func (a *App) SetKenwoodAntenna(n int) error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodAntenna(n) })
|
||||
}
|
||||
|
||||
func (a *App) SetKenwoodRIT(on bool) error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodRIT(on) })
|
||||
}
|
||||
|
||||
func (a *App) SetKenwoodXIT(on bool) error {
|
||||
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
|
||||
// radio's own RC does and what an operator means by "clear it".
|
||||
func (a *App) ClearKenwoodRIT() error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.ClearKenwoodRIT() })
|
||||
}
|
||||
|
||||
// SetKenwoodTX is the panel's MOX.
|
||||
func (a *App) SetKenwoodTX(on bool) error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodTX(on) })
|
||||
}
|
||||
|
||||
// ToggleKenwoodATU puts the tuner in line or bypasses it (the HOLD of the same
|
||||
// front-panel switch the tune uses).
|
||||
func (a *App) ToggleKenwoodATU() error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.ToggleKenwoodATU() })
|
||||
}
|
||||
|
||||
func (a *App) TuneKenwoodATU() error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.TuneKenwoodATU() })
|
||||
}
|
||||
|
||||
// RefreshKenwood re-reads the settings on the next poll — for when a knob was
|
||||
// turned on the radio itself.
|
||||
func (a *App) RefreshKenwood() error {
|
||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.RefreshKenwood() })
|
||||
}
|
||||
|
||||
func (a *App) kenwoodPanelDo(fn func(cat.KenwoodPanelController) error) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("CAT not initialized")
|
||||
}
|
||||
return a.cat.KenwoodPanelDo(fn)
|
||||
}
|
||||
+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()
|
||||
}
|
||||
+1085
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,313 @@
|
||||
package main
|
||||
|
||||
// How a satellite station points its antenna.
|
||||
//
|
||||
// It does NOT configure a rotator. Every rotator interface OpsLog knows lives in
|
||||
// Settings ▸ Rotator, once, and the satellite page only CHOOSES one of them.
|
||||
// The two used to be separate: EasyComm and PstRotator were described inside the
|
||||
// satellite settings while five other backends were described in the rotator
|
||||
// list, so an operator with one mast described it twice — and could describe it
|
||||
// differently the second time, which is a station that works on HF and not on a
|
||||
// pass, for no reason anyone can see.
|
||||
//
|
||||
// What remains here is the adapter: turning whichever backend the operator
|
||||
// picked into the three things a pass needs — point it, ask where it is, let go
|
||||
// of it at the end.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"hamlog/internal/rotator/gs232"
|
||||
"hamlog/internal/rotator/pst"
|
||||
"hamlog/internal/rotator/spid"
|
||||
)
|
||||
|
||||
// satRotator is what the tracker needs of an antenna: point it, ask where it
|
||||
// is, and let go of it at the end of the pass.
|
||||
type satRotator interface {
|
||||
Point(az, el float64) error
|
||||
// Heading reports where the antenna is. live is false when the answer is
|
||||
// the last commanded position rather than a reading — a stuck rotator must
|
||||
// not be able to hide behind an order it never carried out.
|
||||
Heading() (az, el float64, live bool, err error)
|
||||
Close()
|
||||
}
|
||||
|
||||
// The legacy satellite-only rotator kinds. They are no longer stored; they
|
||||
// survive only so migrateSatRotator can read what an operator configured before
|
||||
// the rotator list existed.
|
||||
const (
|
||||
satRotEasycomm = "easycomm"
|
||||
satRotPst = "pstrotator"
|
||||
)
|
||||
|
||||
// newSatRotator builds a controller for the rotor the satellite page selected.
|
||||
func (a *App) newSatRotator(s SatSettings) (satRotator, error) {
|
||||
if strings.TrimSpace(s.RotID) == "" {
|
||||
return nil, fmt.Errorf("no rotator chosen for satellite tracking — pick one in Settings ▸ Satellite")
|
||||
}
|
||||
lr, ok := a.rotorByKey(s.RotID)
|
||||
if !ok {
|
||||
// The rotor was deleted from the list after being chosen here. Say that,
|
||||
// rather than failing to connect to an address nobody can see any more.
|
||||
return nil, fmt.Errorf("the rotator chosen for satellite tracking no longer exists in Settings ▸ Rotator")
|
||||
}
|
||||
// Azimuth only: any rotor will do, including the tower the operator already
|
||||
// turns for HF. See SatSettings.RotAzOnly for why this is the common case
|
||||
// rather than a fallback.
|
||||
if s.RotAzOnly {
|
||||
return &azOnlySatRotator{link: lr.Link}, nil
|
||||
}
|
||||
if !lr.HasEl {
|
||||
name := strings.TrimSpace(lr.Name)
|
||||
if name == "" {
|
||||
name = "this rotator"
|
||||
}
|
||||
return nil, fmt.Errorf("%s has no elevation axis — tick \"follow the azimuth only\" in Settings ▸ Satellite, or pick an az/el rotator", name)
|
||||
}
|
||||
l := lr.Link
|
||||
switch l.Type {
|
||||
case "pst":
|
||||
return &pstSatRotator{c: pst.New(l.Host, l.Port), maxAz: l.MaxAz}, nil
|
||||
case "easycomm":
|
||||
return easycommClient(l), nil
|
||||
case "erc":
|
||||
return &gs232SatRotator{c: ercClient(l), maxAz: l.MaxAz}, nil
|
||||
case "spid":
|
||||
return &spidSatRotator{c: spidClient(l)}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("the %s backend cannot be pointed in elevation", l.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// SatelliteRotorChoice is one entry in the satellite page's rotator dropdown.
|
||||
type SatelliteRotorChoice struct {
|
||||
Key string `json:"key"`
|
||||
// Name is the operator's label; Type is the backend's, for the rotors left
|
||||
// unnamed (a list of three blank rows is a list of one rotor as far as
|
||||
// anybody can tell).
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
HasEl bool `json:"has_el"`
|
||||
}
|
||||
|
||||
// ListSatelliteRotors returns every configured rotor, elevation-capable or not.
|
||||
//
|
||||
// Never filtered. Which of them can be USED depends on the azimuth-only switch,
|
||||
// and that is a question for the panel: with it off an azimuth rotor is shown
|
||||
// greyed and says why, with it on every rotor is fair game. Hiding them
|
||||
// outright would only teach an operator with one mast that OpsLog cannot find
|
||||
// it.
|
||||
func (a *App) ListSatelliteRotors() ([]SatelliteRotorChoice, error) {
|
||||
devs, err := a.GetRotators()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []SatelliteRotorChoice{}
|
||||
for _, r := range flattenRotors(devs) {
|
||||
out = append(out, SatelliteRotorChoice{
|
||||
Key: r.Key, Name: r.Name, Type: rotorTypeInfo(r.Link.Type).Label, HasEl: r.HasEl,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// gs232SatRotator points an ERC-M (or any GS-232 az/el controller) through the
|
||||
// W command.
|
||||
//
|
||||
// The 450° overlap is handled HERE and not in the package, the same way the
|
||||
// EasyComm client does it: a controller reports 0-450 and takes 0-450, but the
|
||||
// tracker works in true bearings, and which of the two ways round to reach 010°
|
||||
// depends on where the mast currently is.
|
||||
type gs232SatRotator struct {
|
||||
c *gs232.Client
|
||||
maxAz int
|
||||
}
|
||||
|
||||
func (g *gs232SatRotator) Point(az, el float64) error {
|
||||
return g.c.GoToAzEl(int(math.Round(satWrapAz(az, g.maxAz))), int(math.Round(clampEl(el))))
|
||||
}
|
||||
|
||||
func (g *gs232SatRotator) Heading() (float64, float64, bool, error) {
|
||||
az, el, _, err := g.c.Position()
|
||||
if err != nil {
|
||||
return 0, 0, false, err
|
||||
}
|
||||
return float64(az), float64(el), true, nil
|
||||
}
|
||||
|
||||
// Close: nothing to release. The serial port is held by the gs232 package, which
|
||||
// keeps it open across the whole session on purpose — an Arduino-based
|
||||
// controller reboots every time its port is opened.
|
||||
func (g *gs232SatRotator) Close() {}
|
||||
|
||||
// spidSatRotator points a SPID Rot2Prog. Its protocol is absolute and binary,
|
||||
// with no overlap notion to manage: the controller is told a bearing and a
|
||||
// resolution and works out its own path.
|
||||
type spidSatRotator struct{ c *spid.Client }
|
||||
|
||||
func (s *spidSatRotator) Point(az, el float64) error {
|
||||
a := math.Mod(az, 360)
|
||||
if a < 0 {
|
||||
a += 360
|
||||
}
|
||||
return s.c.GoTo(int(math.Round(a)), int(math.Round(clampEl(el))))
|
||||
}
|
||||
|
||||
func (s *spidSatRotator) Heading() (float64, float64, bool, error) {
|
||||
az, el, err := s.c.Heading()
|
||||
if err != nil {
|
||||
return 0, 0, false, err
|
||||
}
|
||||
return float64(az), float64(el), true, nil
|
||||
}
|
||||
|
||||
func (s *spidSatRotator) Close() {}
|
||||
|
||||
// satWrapAz maps a true bearing onto what the controller accepts. On a 450°
|
||||
// mast the far end of the overlap is reachable two ways and the higher number is
|
||||
// chosen for the last 90°, which is what keeps a pass crossing north from
|
||||
// unwinding the cable in the middle of it.
|
||||
func satWrapAz(az float64, maxAz int) float64 {
|
||||
a := math.Mod(az, 360)
|
||||
if a < 0 {
|
||||
a += 360
|
||||
}
|
||||
if maxAz == 450 && a < 90 {
|
||||
return a + 360
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// clampEl keeps the elevation inside what a mast will accept. 180 and not 90: a
|
||||
// G-5500 goes past the zenith and keeps counting, which is how an overhead pass
|
||||
// is followed without swinging the azimuth 180° through the middle of it.
|
||||
func clampEl(el float64) float64 {
|
||||
if el < 0 {
|
||||
return 0
|
||||
}
|
||||
if el > 180 {
|
||||
return 180
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
// pstSatRotator points the antenna through PstRotator.
|
||||
//
|
||||
// PstRotator takes whole degrees and does its own overlap handling for a 450°
|
||||
// rotator — it knows which controller is on the other end, and OpsLog does not.
|
||||
// So the azimuth is sent plainly, and the 450° logic that the direct backends
|
||||
// need is deliberately NOT applied here: two programs each deciding to go the
|
||||
// long way round is how an antenna ends up unwinding in the middle of a pass.
|
||||
type pstSatRotator struct {
|
||||
c *pst.Client
|
||||
maxAz int
|
||||
|
||||
mu sync.Mutex
|
||||
// lastAz/lastEl are what was commanded, for the display when PstRotator
|
||||
// does not answer a position query — which is the usual case for the many
|
||||
// setups whose controller reports nothing back to it either.
|
||||
lastAz, lastEl float64
|
||||
commanded bool
|
||||
azSilent bool // the azimuth query went unanswered; stop asking
|
||||
elSilent bool // likewise for elevation, and far more common
|
||||
}
|
||||
|
||||
func (p *pstSatRotator) Point(az, el float64) error {
|
||||
a := math.Mod(az, 360)
|
||||
if a < 0 {
|
||||
a += 360
|
||||
}
|
||||
el = clampEl(el)
|
||||
if err := p.c.GoTo(int(math.Round(a)), true, int(math.Round(el))); err != nil {
|
||||
return err
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.lastAz, p.lastEl, p.commanded = a, el, true
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *pstSatRotator) Heading() (float64, float64, bool, error) {
|
||||
p.mu.Lock()
|
||||
azSilent, elSilent, la, le, commanded := p.azSilent, p.elSilent, p.lastAz, p.lastEl, p.commanded
|
||||
p.mu.Unlock()
|
||||
|
||||
az, el, live := la, le, false
|
||||
if !azSilent {
|
||||
if v, _, err := p.c.Heading(); err == nil {
|
||||
az, live = float64(v), true
|
||||
} else {
|
||||
// One silence is enough. Each query binds a socket and waits a second
|
||||
// and a half; repeating that every few seconds for a setup that will
|
||||
// never answer is a stall per poll for nothing.
|
||||
p.mu.Lock()
|
||||
p.azSilent = true
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
if !elSilent {
|
||||
if v, _, err := p.c.Elevation(); err == nil {
|
||||
el = float64(v)
|
||||
} else {
|
||||
p.mu.Lock()
|
||||
p.elSilent = true
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
if !live && !commanded {
|
||||
return 0, 0, false, fmt.Errorf("PstRotator does not report the antenna position")
|
||||
}
|
||||
return az, el, live, nil
|
||||
}
|
||||
|
||||
// Close: nothing to release. Every PstRotator command is one datagram, and the
|
||||
// socket lives for the length of a single write.
|
||||
func (p *pstSatRotator) Close() {}
|
||||
|
||||
// azOnlySatRotator follows the satellite in azimuth and never touches the
|
||||
// elevation axis, whatever the rotor happens to have.
|
||||
//
|
||||
// It works because of the geometry, not in spite of it: a pass at the far edge
|
||||
// of the footprint stays between the horizon and about fifteen degrees for its
|
||||
// whole length, and a yagi's beamwidth swallows that. What it costs is the high
|
||||
// passes — a bird straight overhead is a moving azimuth and a useless bearing —
|
||||
// and that is the operator's trade to make, which is why it is a switch and not
|
||||
// a silent fallback.
|
||||
//
|
||||
// It drives whichever rotor was chosen through the same per-backend dispatch the
|
||||
// compass uses, so a PstRotator, a Rotator Genius, an ARCO, a DCU-1, a SPID and
|
||||
// the az/el ones all work here without a second implementation of each.
|
||||
type azOnlySatRotator struct{ link rotorLink }
|
||||
|
||||
// Point sends the azimuth alone. The elevation is passed as -1, the callers'
|
||||
// "no opinion", so a rotor that HAS an elevation axis is left where it is rather
|
||||
// than being driven to the horizon.
|
||||
func (r *azOnlySatRotator) Point(az, _ float64) error {
|
||||
a := math.Mod(az, 360)
|
||||
if a < 0 {
|
||||
a += 360
|
||||
}
|
||||
return linkGoTo(r.link, int(math.Round(a)), -1)
|
||||
}
|
||||
|
||||
// Heading reports the azimuth. The elevation comes back as whatever the
|
||||
// controller said, which for an azimuth rotor is zero — the panel is told
|
||||
// separately not to draw it (SatTrackStatus.RotAzOnly), because zero is a real
|
||||
// bearing and not the absence of one.
|
||||
//
|
||||
// live stays true when the AZIMUTH was genuinely read: it means "this is a
|
||||
// reading and not the last command", and that answer is honest whatever the
|
||||
// other axis does or does not do.
|
||||
func (r *azOnlySatRotator) Heading() (float64, float64, bool, error) {
|
||||
az, el, _, _, err := linkHeading(r.link)
|
||||
if err != nil {
|
||||
return 0, 0, false, err
|
||||
}
|
||||
return az, el, true, nil
|
||||
}
|
||||
|
||||
func (r *azOnlySatRotator) Close() {}
|
||||
@@ -0,0 +1,800 @@
|
||||
package main
|
||||
|
||||
// Doppler tracking — walking the radio through a pass.
|
||||
//
|
||||
// The hard part of satellite tuning is not the arithmetic, it is deciding who
|
||||
// owns the dial. A tracker that simply forces both frequencies fights the
|
||||
// operator every time they turn the knob to follow a station across a linear
|
||||
// transponder, and one that never touches the receiver leaves them chasing a
|
||||
// signal that slides 9 kHz across a 70 cm pass.
|
||||
//
|
||||
// So: the operator owns the receiver, and the tracker follows them. Every tick
|
||||
// it asks the radio where the receiver actually is. If that is where the tracker
|
||||
// put it, nothing has changed and it keeps correcting from the same NOMINAL
|
||||
// frequency. If it has moved further than a dial-turn's tolerance, the operator
|
||||
// has chosen a new station: the tracker converts what they landed on back into a
|
||||
// nominal frequency and carries on from there. The transmitter is derived from
|
||||
// the nominal and never argued with — which is exactly the division of labour on
|
||||
// a linear bird, where the operator listens and the radio does the sums.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/cat"
|
||||
"hamlog/internal/qso"
|
||||
"hamlog/internal/sat"
|
||||
)
|
||||
|
||||
// satTickEvery is how often the radio is re-pointed. One second: at the middle
|
||||
// of a 70 cm pass the downlink moves about 60 Hz a second, which is audible on
|
||||
// SSB within two or three of them and inaudible within one.
|
||||
const satTickEvery = time.Second
|
||||
|
||||
// satDialTolerance is how far the receiver may differ from where the tracker put
|
||||
// it before that difference is read as the operator tuning.
|
||||
//
|
||||
// 200 Hz is comfortably more than the rounding and the round-trip lag between
|
||||
// setting a frequency and reading it back, and comfortably less than the
|
||||
// smallest deliberate move anybody makes hunting a station on a transponder.
|
||||
const satDialTolerance = 200
|
||||
|
||||
// satLightKmS is the speed of light in km/s, for turning a heard frequency back
|
||||
// into a nominal one. The same constant internal/sat corrects with.
|
||||
const satLightKmS = 299792.458
|
||||
|
||||
type satTracker struct {
|
||||
name string
|
||||
tp int
|
||||
|
||||
mu sync.Mutex
|
||||
// nominalDown is where the operator is, expressed as if the satellite were
|
||||
// standing still. Everything else is derived from it, and it is the only
|
||||
// thing a dial movement changes.
|
||||
nominalDown int64
|
||||
lastDown int64 // what was last sent to the radio
|
||||
lastUp int64
|
||||
status SatTrackStatus
|
||||
fails int
|
||||
|
||||
// The az/el rotator, built once at the start of the pass so a serial port is
|
||||
// opened once rather than on every command. nil when none is configured.
|
||||
rot satRotator
|
||||
rotStep float64
|
||||
rotMinE float64
|
||||
rotPark bool
|
||||
rotAzOnly bool
|
||||
rotAz float64 // last commanded, so a step smaller than the beamwidth costs nothing
|
||||
rotEl float64
|
||||
rotSent bool
|
||||
rotReadAt time.Time // when the controller was last asked where it is
|
||||
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
// wake makes the loop take a step NOW instead of at the next tick. Changing
|
||||
// satellite has to move the radio at once: a second of the old bird's
|
||||
// frequencies is a second of the wrong pass.
|
||||
wake chan struct{}
|
||||
}
|
||||
|
||||
// SatTrackStatus is what the tracker is doing, for the panel.
|
||||
type SatTrackStatus struct {
|
||||
On bool `json:"on"`
|
||||
Name string `json:"name"`
|
||||
Transponder string `json:"transponder"`
|
||||
Mode string `json:"mode"`
|
||||
NominalDown int64 `json:"nominal_down"`
|
||||
NominalUp int64 `json:"nominal_up"`
|
||||
DownHz int64 `json:"down_hz"`
|
||||
UpHz int64 `json:"up_hz"`
|
||||
Az float64 `json:"az"`
|
||||
El float64 `json:"el"`
|
||||
Visible bool `json:"visible"`
|
||||
// Where the satellite is, as opposed to where to point: an operator reads
|
||||
// the distance to know whether a pass is worth calling on, and the altitude
|
||||
// to know how long it will last.
|
||||
RangeKm float64 `json:"range_km"`
|
||||
AltKm float64 `json:"alt_km"`
|
||||
Radio string `json:"radio"` // what the rig is doing: "sat", "downlink-only", ""
|
||||
Error string `json:"error"`
|
||||
|
||||
// Where the antenna is. RotLive distinguishes a reading from the controller
|
||||
// from the last position it was TOLD to go to — a stuck rotator must not be
|
||||
// able to hide behind a command it never carried out.
|
||||
RotOn bool `json:"rot_on"`
|
||||
RotAz float64 `json:"rot_az"`
|
||||
RotEl float64 `json:"rot_el"`
|
||||
RotLive bool `json:"rot_live"`
|
||||
// RotAzOnly: the elevation is not being driven and RotEl means nothing.
|
||||
// Sent so the panel can leave it out rather than draw an antenna lying on
|
||||
// the horizon, which is what an undriven zero looks like.
|
||||
RotAzOnly bool `json:"rot_az_only"`
|
||||
}
|
||||
|
||||
// StartSatelliteTracking arms the radio and starts following the satellite.
|
||||
func (a *App) StartSatelliteTracking(name string, transponder int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("CAT is not running")
|
||||
}
|
||||
_, birds, _ := a.satParts()
|
||||
b, ok := birds.Find(name)
|
||||
if !ok || len(b.Transponders) == 0 {
|
||||
return fmt.Errorf("%s has no frequency plan to tune to", name)
|
||||
}
|
||||
if transponder < 0 || transponder >= len(b.Transponders) {
|
||||
transponder = 0
|
||||
}
|
||||
a.StopSatelliteTracking()
|
||||
|
||||
t := &satTracker{
|
||||
name: b.Name,
|
||||
tp: transponder,
|
||||
nominalDown: b.Transponders[transponder].Centre(),
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
wake: make(chan struct{}, 1),
|
||||
}
|
||||
t.status = SatTrackStatus{On: true, Name: b.Name, Transponder: b.Transponders[transponder].Label, Mode: b.Transponders[transponder].Mode}
|
||||
|
||||
// The rotator, if there is one. A geostationary bird is pointed at once and
|
||||
// left alone, so it gets one command rather than a loop.
|
||||
set := a.satSettings()
|
||||
if set.RotOn {
|
||||
r, rerr := a.newSatRotator(set)
|
||||
if rerr != nil {
|
||||
applog.Printf("sat: no rotator: %v", rerr)
|
||||
t.status.Error = rerr.Error()
|
||||
} else {
|
||||
t.rot = r
|
||||
t.rotStep, t.rotMinE, t.rotPark = float64(set.RotStep), float64(set.RotMinEl), set.RotPark
|
||||
t.rotAzOnly = set.RotAzOnly
|
||||
t.status.RotAzOnly = set.RotAzOnly
|
||||
}
|
||||
}
|
||||
|
||||
// Arm the radio for the pair. A rig that cannot hold one is NOT a failure:
|
||||
// it can still be tuned to the downlink, which is most of a receive-heavy
|
||||
// pass, and saying so beats refusing to track at all.
|
||||
radio := "downlink-only"
|
||||
if a.cat.SatCapable() {
|
||||
if err := a.cat.SatDo(func(st cat.SatTuner) error { return st.SetSatellite(true) }); err != nil {
|
||||
applog.Printf("sat: could not arm satellite mode: %v", err)
|
||||
t.status.Error = err.Error()
|
||||
} else {
|
||||
radio = "sat"
|
||||
a.applySatRadio(b.Transponders[transponder])
|
||||
}
|
||||
}
|
||||
t.status.Radio = radio
|
||||
|
||||
a.satTrackMu.Lock()
|
||||
a.satTrack = t
|
||||
a.satTrackMu.Unlock()
|
||||
go a.satTrackLoop(t)
|
||||
applog.Printf("sat: tracking %s (%s), radio %s", t.name, t.status.Transponder, radio)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopSatelliteTracking hands the radio back.
|
||||
func (a *App) StopSatelliteTracking() {
|
||||
a.satTrackMu.Lock()
|
||||
t := a.satTrack
|
||||
a.satTrack = nil
|
||||
a.satTrackMu.Unlock()
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
close(t.stop)
|
||||
<-t.done
|
||||
if a.cat != nil && a.cat.SatCapable() {
|
||||
if err := a.cat.SatDo(func(st cat.SatTuner) error { return st.SetSatellite(false) }); err != nil {
|
||||
applog.Printf("sat: could not disarm satellite mode: %v", err)
|
||||
}
|
||||
}
|
||||
applog.Printf("sat: tracking stopped (%s)", t.name)
|
||||
a.emitSatTrack(SatTrackStatus{})
|
||||
}
|
||||
|
||||
// TestSatelliteRotator opens the configured controller and asks it where it is.
|
||||
//
|
||||
// The one question worth asking before a pass: is this port the rotator, and
|
||||
// does it talk back? A controller that accepts commands silently is a normal,
|
||||
// working one — so that answer is a success with a caveat, not a failure.
|
||||
func (a *App) TestSatelliteRotator() (string, error) {
|
||||
set := a.satSettings()
|
||||
if !set.RotOn {
|
||||
return "", fmt.Errorf("the satellite rotator is switched off")
|
||||
}
|
||||
c, err := a.newSatRotator(set)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer c.Close()
|
||||
az, el, live, err := c.Heading()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !live {
|
||||
return "The controller accepted the command but does not report its position — normal for many controllers. It will still be driven.", nil
|
||||
}
|
||||
return fmt.Sprintf("The rotator is at %.1f° azimuth, %.1f° elevation.", az, el), nil
|
||||
}
|
||||
|
||||
// GetSatelliteTracking reports what the tracker is doing.
|
||||
func (a *App) GetSatelliteTracking() SatTrackStatus {
|
||||
a.satTrackMu.Lock()
|
||||
t := a.satTrack
|
||||
a.satTrackMu.Unlock()
|
||||
if t == nil {
|
||||
return SatTrackStatus{}
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.status
|
||||
}
|
||||
|
||||
// satTrackedNominal is the nominal downlink the tracker is currently working
|
||||
// from, or 0 when it is not tracking this satellite and transponder.
|
||||
func (a *App) satTrackedNominal(name string, transponder int) int64 {
|
||||
a.satTrackMu.Lock()
|
||||
t := a.satTrack
|
||||
a.satTrackMu.Unlock()
|
||||
if t == nil || t.tp != transponder || !strings.EqualFold(t.name, name) {
|
||||
return 0
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.nominalDown
|
||||
}
|
||||
|
||||
func (a *App) emitSatTrack(s SatTrackStatus) {
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "sat:track", s)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) satTrackLoop(t *satTracker) {
|
||||
defer close(t.done)
|
||||
defer t.releaseRotator()
|
||||
tick := time.NewTicker(satTickEvery)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
a.satTrackStep(t)
|
||||
select {
|
||||
case <-t.stop:
|
||||
return
|
||||
case <-t.wake:
|
||||
case <-tick.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// satTrackStep is one pass of the loop: read the dial, work out the pair, send
|
||||
// what changed.
|
||||
func (a *App) satTrackStep(t *satTracker) {
|
||||
_, birds, _ := a.satParts()
|
||||
b, ok := birds.Find(t.name)
|
||||
if !ok || t.tp >= len(b.Transponders) {
|
||||
return
|
||||
}
|
||||
tp := b.Transponders[t.tp]
|
||||
|
||||
t.mu.Lock()
|
||||
nominal := t.nominalDown
|
||||
lastDown, lastUp := t.lastDown, t.lastUp
|
||||
t.mu.Unlock()
|
||||
|
||||
// Where the satellite is, and how fast it is running away. A geostationary
|
||||
// bird is neither: its range rate is zero, so the zero position below gives
|
||||
// a zero shift without a special case, and asking for a look angle we do not
|
||||
// need would only fail on a station with no locator.
|
||||
var pos sat.Position
|
||||
visible := true
|
||||
if !b.Geostationary {
|
||||
obs, err := a.satObserver()
|
||||
if err != nil {
|
||||
t.setError(err.Error())
|
||||
return
|
||||
}
|
||||
real, ok := a.satResolve(t.name)
|
||||
if !ok {
|
||||
t.setError(fmt.Sprintf("%s is not in the element set", t.name))
|
||||
return
|
||||
}
|
||||
store, _, _ := a.satParts()
|
||||
p, err := store.Track(real, obs, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.setError(err.Error())
|
||||
return
|
||||
}
|
||||
pos = p
|
||||
visible = p.Visible()
|
||||
}
|
||||
// The fractional shift, positive when the satellite is approaching. Only the
|
||||
// dial arithmetic below needs it as a number; the pair itself comes from
|
||||
// sat.Doppler, so there is exactly one place where the sign of a correction
|
||||
// is decided.
|
||||
factor := -pos.RangeRate / satLightKmS
|
||||
|
||||
// Where did the operator leave the receiver? If it is not where the tracker
|
||||
// put it, they have moved to another station and that is the new nominal.
|
||||
if lastDown > 0 && tp.Linear() {
|
||||
if actual, err := a.satReceiveHz(); err == nil && actual > 0 {
|
||||
if abs64i(actual-lastDown) > satDialTolerance {
|
||||
moved := satNominalFromDial(actual, factor)
|
||||
if moved >= tp.DownLo && moved <= tp.DownHi {
|
||||
nominal = moved
|
||||
t.mu.Lock()
|
||||
t.nominalDown = moved
|
||||
t.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nomUp := tp.UplinkFor(nominal)
|
||||
sh := sat.Doppler(pos, nominal, nomUp)
|
||||
down, up := sh.DownHz, sh.UpHz
|
||||
|
||||
t.mu.Lock()
|
||||
// Rebuilt from the old one, not from nothing.
|
||||
//
|
||||
// The rotator fields are written by readRotator, which runs at most every
|
||||
// three seconds — a controller query binds a socket and waits. Building a
|
||||
// fresh status here dropped them on every OTHER tick, so the antenna
|
||||
// readout appeared for one second in three and vanished again, which reads
|
||||
// as a rotator that keeps disconnecting.
|
||||
st := t.status
|
||||
st.On, st.Name, st.Transponder, st.Mode = true, b.Name, tp.Label, tp.Mode
|
||||
st.NominalDown, st.NominalUp = nominal, nomUp
|
||||
st.DownHz, st.UpHz = down, up
|
||||
st.Az, st.El, st.Visible = pos.Az, pos.El, visible
|
||||
st.RangeKm, st.AltKm = pos.RangeKm, pos.AltKm
|
||||
t.status = st
|
||||
t.mu.Unlock()
|
||||
|
||||
t.pointRotator(pos, b.Geostationary)
|
||||
t.readRotator()
|
||||
|
||||
t.mu.Lock()
|
||||
out := t.status
|
||||
t.mu.Unlock()
|
||||
a.emitSatTrack(out)
|
||||
|
||||
// Only send what has actually moved. The step is the smallest change worth a
|
||||
// command: on SSB a listener hears twenty hertz, on an FM channel nothing
|
||||
// under a couple of hundred matters at all.
|
||||
step := int64(20)
|
||||
if strings.EqualFold(tp.Mode, "FM") {
|
||||
step = 200
|
||||
}
|
||||
if abs64i(down-lastDown) < step && abs64i(up-lastUp) < step {
|
||||
return
|
||||
}
|
||||
|
||||
downMode, upMode := satSidebands(tp)
|
||||
if lastDown != 0 {
|
||||
// Set once, at the start of the pass — see satMode/satSetMode.
|
||||
downMode, upMode = "", ""
|
||||
}
|
||||
err := a.satTune(down, up, downMode, upMode)
|
||||
t.mu.Lock()
|
||||
if err == nil {
|
||||
t.lastDown, t.lastUp, t.fails = down, up, 0
|
||||
t.status.Error = ""
|
||||
} else {
|
||||
t.fails++
|
||||
t.status.Error = err.Error()
|
||||
}
|
||||
fails := t.fails
|
||||
t.mu.Unlock()
|
||||
if err != nil && (fails == 1 || fails%30 == 0) {
|
||||
// Once, then once every half minute: a radio that has gone away must be
|
||||
// visible in the log without filling it.
|
||||
applog.Printf("sat: tuning %s failed (%d in a row): %v", t.name, fails, err)
|
||||
}
|
||||
}
|
||||
|
||||
// satNominalFromDial turns a frequency the operator tuned to into the nominal
|
||||
// one it corresponds to.
|
||||
//
|
||||
// The inverse of the downlink correction: what comes out of the transponder at
|
||||
// nominal arrives at heard = nominal × (1 + f). Doing this is what lets the
|
||||
// operator hunt across a linear passband without the tracker dragging them back
|
||||
// — where they land becomes the new truth, and the uplink follows it.
|
||||
func satNominalFromDial(heardHz int64, factor float64) int64 {
|
||||
if heardHz <= 0 || factor <= -1 {
|
||||
return heardHz
|
||||
}
|
||||
return int64(math.Round(float64(heardHz) / (1 + factor)))
|
||||
}
|
||||
|
||||
// pointRotator keeps the antenna on the satellite.
|
||||
//
|
||||
// Below the configured elevation the rotator is left alone. Not because the
|
||||
// numbers stop being right — they are right all the way round the orbit — but
|
||||
// because a rotator that chases a satellite through the far side of the earth
|
||||
// spends the whole night turning, and a mast is a mechanical thing with a
|
||||
// finite number of turns in it.
|
||||
func (t *satTracker) pointRotator(pos sat.Position, geostationary bool) {
|
||||
if t.rot == nil {
|
||||
return
|
||||
}
|
||||
if !geostationary && pos.El < t.rotMinE {
|
||||
return
|
||||
}
|
||||
// A step below the beamwidth is a command for nothing. Compared against what
|
||||
// was last COMMANDED rather than where the rotator says it is: a rotator in
|
||||
// motion is always somewhere between the two, and comparing against that
|
||||
// would order a fresh move on every tick of a slew.
|
||||
az, el := pos.Az, pos.El
|
||||
if geostationary {
|
||||
// A satellite that does not move needs pointing once. Its own az/el were
|
||||
// not computed (there is nothing to compute), so leave the rotator where
|
||||
// the operator put it.
|
||||
if t.rotSent {
|
||||
return
|
||||
}
|
||||
}
|
||||
// In azimuth-only mode the elevation is never commanded, so comparing it
|
||||
// would find a difference on every tick and send a command for nothing —
|
||||
// the antenna ordered to the same bearing once a second for the whole pass.
|
||||
moved := math.Abs(az-t.rotAz) >= t.rotStep
|
||||
if !t.rotAzOnly {
|
||||
moved = moved || math.Abs(el-t.rotEl) >= t.rotStep
|
||||
}
|
||||
if t.rotSent && !moved {
|
||||
return
|
||||
}
|
||||
if err := t.rot.Point(az, el); err != nil {
|
||||
t.setError(err.Error())
|
||||
return
|
||||
}
|
||||
t.rotAz, t.rotEl, t.rotSent = az, el, true
|
||||
}
|
||||
|
||||
// readRotator asks the controller where it actually is, for the display.
|
||||
//
|
||||
// Separate from the pointing, and it runs on every tick rather than only when a
|
||||
// command was sent: watching the antenna crawl towards the bearing is how an
|
||||
// operator sees a rotator that is slow, stalled, or turning the wrong way. A
|
||||
// controller that does not answer says so once and is not asked again.
|
||||
func (t *satTracker) readRotator() {
|
||||
if t.rot == nil {
|
||||
return
|
||||
}
|
||||
// Not on every tick. A PstRotator query binds a socket and waits up to a
|
||||
// second and a half for an answer, and a held serial port still costs a
|
||||
// round trip; three seconds is often enough to watch an antenna slew and
|
||||
// rare enough not to sit in the way of the tuning.
|
||||
if time.Since(t.rotReadAt) < 3*time.Second {
|
||||
return
|
||||
}
|
||||
t.rotReadAt = time.Now()
|
||||
az, el, live, err := t.rot.Heading()
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if err != nil {
|
||||
t.status.RotOn = true
|
||||
return
|
||||
}
|
||||
t.status.RotOn, t.status.RotAz, t.status.RotEl, t.status.RotLive = true, az, el, live
|
||||
}
|
||||
|
||||
// releaseRotator hands the mast back at the end of a pass.
|
||||
func (t *satTracker) releaseRotator() {
|
||||
if t.rot == nil {
|
||||
return
|
||||
}
|
||||
if t.rotPark && t.rotSent {
|
||||
// Elevation down first and azimuth to north: a dish or a pair of yagis
|
||||
// left pointing at the sky is what a gale takes away.
|
||||
if err := t.rot.Point(0, 0); err != nil {
|
||||
applog.Printf("sat: could not park the rotator: %v", err)
|
||||
}
|
||||
}
|
||||
t.rot.Close()
|
||||
t.rot = nil
|
||||
}
|
||||
|
||||
func (t *satTracker) setError(msg string) {
|
||||
t.mu.Lock()
|
||||
t.status.Error = msg
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// satTune sends the pair to whichever radio is connected.
|
||||
func (a *App) satTune(downHz, upHz int64, downMode, upMode string) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("CAT is not running")
|
||||
}
|
||||
if a.cat.SatCapable() {
|
||||
return a.cat.SatDo(func(st cat.SatTuner) error {
|
||||
return st.TuneSatellite(downHz, upHz, downMode, upMode)
|
||||
})
|
||||
}
|
||||
// No satellite pair on this backend: the downlink is what it can do, and the
|
||||
// operator was told so when tracking started (Radio = "downlink-only").
|
||||
if err := a.cat.SetFrequency(downHz); err != nil {
|
||||
return err
|
||||
}
|
||||
if downMode != "" {
|
||||
return a.cat.SetMode(downMode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// satReceiveHz is where the receiver is, asked of the backend that knows.
|
||||
func (a *App) satReceiveHz() (int64, error) {
|
||||
if a.cat == nil {
|
||||
return 0, fmt.Errorf("CAT is not running")
|
||||
}
|
||||
if a.cat.SatCapable() {
|
||||
var hz int64
|
||||
err := a.cat.SatDo(func(st cat.SatTuner) error {
|
||||
v, e := st.SatReceiveHz()
|
||||
hz = v
|
||||
return e
|
||||
})
|
||||
return hz, err
|
||||
}
|
||||
st := a.cat.State()
|
||||
if st.RxFreqHz > 0 {
|
||||
return st.RxFreqHz, nil
|
||||
}
|
||||
return st.FreqHz, nil
|
||||
}
|
||||
|
||||
func abs64i(v int64) int64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ── What goes in the log ────────────────────────────────────────────────────
|
||||
|
||||
// applySatellite stamps a QSO made through a satellite.
|
||||
//
|
||||
// The NOMINAL frequencies are logged, never the Doppler-corrected ones. Two
|
||||
// stations working each other through a transponder read different numbers off
|
||||
// their dials at the same instant — that is what Doppler means — and the only
|
||||
// figure they can both agree on, and the only one that means anything to
|
||||
// somebody reading the log later, is the transponder's own. LoTW matches on the
|
||||
// band, so nothing is lost; a log full of 435.847 231 would simply be a record
|
||||
// of where one radio happened to be.
|
||||
func (a *App) applySatellite(q *qso.QSO) {
|
||||
a.satTrackMu.Lock()
|
||||
t := a.satTrack
|
||||
a.satTrackMu.Unlock()
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
name, down, up := t.status.Name, t.status.NominalDown, t.status.NominalUp
|
||||
az, el := t.status.Az, t.status.El
|
||||
t.mu.Unlock()
|
||||
if name == "" || down <= 0 {
|
||||
return
|
||||
}
|
||||
// Nothing the operator filled in is overwritten. A QSO edited by hand, or
|
||||
// imported, or logged from a second radio while the tracker happened to be
|
||||
// running, keeps what it was given.
|
||||
if strings.TrimSpace(q.PropMode) == "" {
|
||||
q.PropMode = "SAT"
|
||||
}
|
||||
if q.PropMode != "SAT" {
|
||||
return // they said it was something else — meteor scatter, EME
|
||||
}
|
||||
if strings.TrimSpace(q.SatName) == "" {
|
||||
q.SatName = name
|
||||
}
|
||||
if strings.TrimSpace(q.SatMode) == "" {
|
||||
q.SatMode = satModeLetters(up, down)
|
||||
}
|
||||
// The transmit frequency is the uplink and the receive frequency the
|
||||
// downlink — which is the one place a satellite QSO differs from every other
|
||||
// kind, and the reason FREQ alone cannot describe one.
|
||||
if up > 0 {
|
||||
q.FreqHz = &up
|
||||
if b := bandForHz(up); b != "" {
|
||||
q.Band = b
|
||||
}
|
||||
}
|
||||
d := down
|
||||
q.FreqRXHz = &d
|
||||
if b := bandForHz(down); b != "" {
|
||||
q.BandRX = b
|
||||
}
|
||||
if q.AntAz == nil && (az != 0 || el != 0) {
|
||||
v := az
|
||||
q.AntAz = &v
|
||||
}
|
||||
if q.AntEl == nil && el != 0 {
|
||||
v := el
|
||||
q.AntEl = &v
|
||||
}
|
||||
}
|
||||
|
||||
// satModeLetters is the ADIF SAT_MODE: the uplink band's letter, then the
|
||||
// downlink's — "U/V" for 435 up, 145 down. The letters are AMSAT's, and they
|
||||
// are what every satellite operator writes on a QSL card.
|
||||
func satModeLetters(upHz, downHz int64) string {
|
||||
u, d := satBandLetter(upHz), satBandLetter(downHz)
|
||||
if u == "" || d == "" {
|
||||
return ""
|
||||
}
|
||||
return u + "/" + d
|
||||
}
|
||||
|
||||
func satBandLetter(hz int64) string {
|
||||
switch {
|
||||
case hz <= 0:
|
||||
return ""
|
||||
case hz < 30_000_000:
|
||||
return "A" // 10 m — mode A's downlink
|
||||
case hz < 148_000_000:
|
||||
return "V" // 2 m
|
||||
case hz < 450_000_000:
|
||||
return "U" // 70 cm
|
||||
case hz < 1_300_000_000:
|
||||
return "L" // 23 cm
|
||||
case hz < 2_500_000_000:
|
||||
return "S" // 13 cm
|
||||
case hz < 6_000_000_000:
|
||||
return "C" // 6 cm
|
||||
case hz < 11_000_000_000:
|
||||
return "X" // 3 cm
|
||||
}
|
||||
return "K" // 24 GHz and above
|
||||
}
|
||||
|
||||
// flexBandAntKey is the key a frequency has in the per-band antenna and power
|
||||
// maps.
|
||||
//
|
||||
// Those maps are keyed by the band name UPPERCASED ("70CM"), because that is
|
||||
// how the settings panel writes them; bandForHz returns the band plan's own
|
||||
// spelling ("70cm"). Every other caller happened to uppercase on the way in,
|
||||
// the satellite tracker did not, and so it read an empty antenna out of a map
|
||||
// the operator had filled in — which is not a mistake worth making twice.
|
||||
func flexBandAntKey(hz int64) string {
|
||||
return strings.ToUpper(bandForHz(hz))
|
||||
}
|
||||
|
||||
// applySatRadio puts each satellite slice on the antenna configured for ITS
|
||||
// band, and sets the uplink tone.
|
||||
//
|
||||
// Settings ▸ FlexRadio already holds a per-band RX/TX antenna map, and it was
|
||||
// only ever applied by the entry form on a band change — to the active slice.
|
||||
// A pass never goes through that path: the tracker arms two slices itself, on
|
||||
// two different bands, and both were left on whatever the radio last used. A
|
||||
// station with transverters (XVTA on 2 m, XVTB on 70 cm) therefore heard
|
||||
// nothing at all, having configured exactly the thing that was being ignored.
|
||||
//
|
||||
// The bands come from the NOMINAL frequencies, not the Doppler-corrected ones:
|
||||
// a correction of ten kilohertz cannot change the band, and the nominal pair is
|
||||
// what the operator's configuration is written against.
|
||||
func (a *App) applySatRadio(tp sat.Transponder) {
|
||||
if a.cat == nil || !a.cat.SatCapable() {
|
||||
return
|
||||
}
|
||||
// The CTCSS tone first: an FM bird will not answer without it, and it is the
|
||||
// one setting an operator cannot make from the front panel once a pass has
|
||||
// started. Zero turns it off, which is what a linear bird needs.
|
||||
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
|
||||
return fc.SatTone(tp.CTCSS)
|
||||
}); err != nil {
|
||||
applog.Printf("sat: could not set the uplink tone: %v", err)
|
||||
}
|
||||
|
||||
m, err := a.GetFlexBandAntennas()
|
||||
if err != nil || len(m) == 0 {
|
||||
return
|
||||
}
|
||||
// The downlink is received, so it takes that band's RX antenna; the uplink
|
||||
// is transmitted, so it takes that band's TX antenna.
|
||||
downBand, upBand := flexBandAntKey(tp.DownLo), flexBandAntKey(tp.UpLo)
|
||||
rxAnt := m[downBand].RX
|
||||
txAnt := m[upBand].TX
|
||||
if strings.TrimSpace(rxAnt) == "" && strings.TrimSpace(txAnt) == "" {
|
||||
// Worth a line: an operator who HAS configured the pair and still sees
|
||||
// the wrong antenna has no other way to tell a setting he never made
|
||||
// from a lookup that missed.
|
||||
applog.Printf("sat: no antenna configured for this pass (down %s, up %s)", downBand, upBand)
|
||||
return
|
||||
}
|
||||
applog.Printf("sat: antennas rx=%q (%s) tx=%q (%s)", rxAnt, downBand, txAnt, upBand)
|
||||
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
|
||||
return fc.SatAntennas(rxAnt, txAnt)
|
||||
}); err != nil {
|
||||
// Not fatal: a rig that is not a Flex has no such thing, and a pass with
|
||||
// the wrong antenna is still a pass.
|
||||
applog.Printf("sat: could not set the satellite antennas: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// satSidebands is which sideband to set on each side of a linear transponder.
|
||||
//
|
||||
// The two are NOT the same when the transponder inverts, and FO-29, RS-44 and
|
||||
// AO-73 all do: the passband is turned over, so a signal transmitted on lower
|
||||
// sideband comes back on upper. Setting USB at both ends — which is what
|
||||
// happened until now — put the operator's own audio through the transponder
|
||||
// upside down, which is unreadable at the far end and sounds like nothing much
|
||||
// at ours.
|
||||
//
|
||||
// Anything that is not SSB is the same on both sides: an FM repeater is FM up
|
||||
// and FM down, and CW is CW whichever way round the passband runs.
|
||||
func satSidebands(tp sat.Transponder) (downMode, upMode string) {
|
||||
if !strings.EqualFold(strings.TrimSpace(tp.Mode), "SSB") {
|
||||
return tp.Mode, tp.Mode
|
||||
}
|
||||
// Every satellite is above 30 MHz, so the downlink is upper sideband — even
|
||||
// on the AO-7 10 m downlink, which would be lower sideband on HF.
|
||||
if tp.Inverting {
|
||||
return "USB", "LSB"
|
||||
}
|
||||
return "USB", "USB"
|
||||
}
|
||||
|
||||
// RetargetSatelliteTracking points the tracker at a different satellite without
|
||||
// letting go of the radio.
|
||||
//
|
||||
// Two birds are often up at once, and an operator switching between them found
|
||||
// the frequencies stayed on the first: the panel's selection is the DISPLAY's,
|
||||
// while the tracker held its own name and went on following what it was started
|
||||
// with. Stopping and starting worked, which is how it was discovered, and is
|
||||
// also how a Flex loses and rebuilds both its slices for no reason.
|
||||
//
|
||||
// So the radio stays armed and the rotator stays open, and only what is being
|
||||
// followed changes. Everything derived from the old satellite is cleared so the
|
||||
// next step sets it afresh: the frequencies, the mode on both slices (set once
|
||||
// per satellite, not per tick), the antennas and the tone — the new bird may be
|
||||
// U/V where the old one was V/U, which swaps which slice is on which band.
|
||||
func (a *App) RetargetSatelliteTracking(name string, transponder int) error {
|
||||
a.satTrackMu.Lock()
|
||||
t := a.satTrack
|
||||
a.satTrackMu.Unlock()
|
||||
if t == nil {
|
||||
// Not tracking: this is simply a start.
|
||||
return a.StartSatelliteTracking(name, transponder)
|
||||
}
|
||||
|
||||
_, birds, _ := a.satParts()
|
||||
b, ok := birds.Find(name)
|
||||
if !ok || len(b.Transponders) == 0 {
|
||||
return fmt.Errorf("%s has no frequency plan to tune to", name)
|
||||
}
|
||||
if transponder < 0 || transponder >= len(b.Transponders) {
|
||||
transponder = 0
|
||||
}
|
||||
tp := b.Transponders[transponder]
|
||||
|
||||
t.mu.Lock()
|
||||
t.name, t.tp = b.Name, transponder
|
||||
t.nominalDown = tp.Centre()
|
||||
// Zeroed so the next step tunes and sets the mode again rather than deciding
|
||||
// nothing has changed.
|
||||
t.lastDown, t.lastUp, t.fails = 0, 0, 0
|
||||
// And so the antenna is commanded at once instead of waiting for the new
|
||||
// satellite to drift a step away from where the old one happened to be.
|
||||
t.rotSent = false
|
||||
t.status.Name, t.status.Transponder, t.status.Mode = b.Name, tp.Label, tp.Mode
|
||||
t.status.Error = ""
|
||||
t.mu.Unlock()
|
||||
|
||||
if a.cat != nil && a.cat.SatCapable() {
|
||||
a.applySatRadio(tp)
|
||||
}
|
||||
select {
|
||||
case t.wake <- struct{}{}:
|
||||
default: // a step is already pending; it will pick this up
|
||||
}
|
||||
applog.Printf("sat: now tracking %s (%s)", b.Name, tp.Label)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/sat"
|
||||
)
|
||||
|
||||
// The dial arithmetic has to be the exact inverse of the correction, or every
|
||||
// touch of the knob would nudge the nominal frequency a little further off and
|
||||
// the uplink would walk across the passband over a pass.
|
||||
func TestSatNominalFromDialRoundTrip(t *testing.T) {
|
||||
// A range of range rates: hard approach, drifting, hard recession. ±8 km/s
|
||||
// covers a low orbit overhead.
|
||||
for _, rate := range []float64{-8, -3.2, -0.4, 0, 0.4, 3.2, 8} {
|
||||
p := sat.Position{RangeRate: rate}
|
||||
for _, nominal := range []int64{29_450_000, 145_900_000, 435_850_000, 10_489_675_000} {
|
||||
sh := sat.Doppler(p, nominal, 0)
|
||||
factor := -rate / satLightKmS
|
||||
got := satNominalFromDial(sh.DownHz, factor)
|
||||
if diff := got - nominal; diff > 1 || diff < -1 {
|
||||
t.Errorf("rate %.1f km/s, %d Hz: heard %d, came back as %d (%+d)",
|
||||
rate, nominal, sh.DownHz, got, diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SAT_MODE is what goes on a QSL card, and the letters are the uplink's then
|
||||
// the downlink's — the order operators write and the order ADIF wants.
|
||||
func TestSatModeLetters(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
up, down int64
|
||||
want string
|
||||
}{
|
||||
{"FO-29: 2 m up, 70 cm down", 145_950_000, 435_850_000, "V/U"},
|
||||
{"AO-91: 70 cm up, 2 m down", 435_250_000, 145_960_000, "U/V"},
|
||||
{"AO-7 mode A: 2 m up, 10 m down", 145_900_000, 29_450_000, "V/A"},
|
||||
{"QO-100: 13 cm up, 3 cm down", 2_400_175_000, 10_489_675_000, "S/X"},
|
||||
{"receive only", 0, 145_800_000, ""},
|
||||
} {
|
||||
if got := satModeLetters(tc.up, tc.down); got != tc.want {
|
||||
t.Errorf("%s: got %q, wanted %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Azimuth-only tracking must not command the rotor once a second.
|
||||
//
|
||||
// The step check used to compare BOTH axes, so with the elevation never
|
||||
// commanded its difference stayed above the step for the whole pass and every
|
||||
// tick sent the antenna to the bearing it was already on. A rotator is a
|
||||
// mechanical thing with a finite number of turns in it.
|
||||
func TestPointRotatorAzOnlyIgnoresElevation(t *testing.T) {
|
||||
rec := &countingRotator{}
|
||||
tr := &satTracker{rot: rec, rotStep: 5, rotAzOnly: true}
|
||||
|
||||
// The satellite climbs while the bearing barely moves — a pass going
|
||||
// overhead from the side, which is the shape that provoked this.
|
||||
for _, p := range []sat.Position{
|
||||
{Az: 100, El: 5},
|
||||
{Az: 101, El: 20},
|
||||
{Az: 102, El: 45},
|
||||
{Az: 103, El: 70},
|
||||
} {
|
||||
tr.pointRotator(p, false)
|
||||
}
|
||||
if rec.n != 1 {
|
||||
t.Errorf("azimuth-only sent %d commands for 3° of bearing, want 1", rec.n)
|
||||
}
|
||||
if rec.lastAz != 100 {
|
||||
t.Errorf("commanded azimuth %v, want the first one", rec.lastAz)
|
||||
}
|
||||
|
||||
// And it still follows the azimuth when the azimuth actually moves.
|
||||
tr.pointRotator(sat.Position{Az: 130, El: 70}, false)
|
||||
if rec.n != 2 {
|
||||
t.Errorf("a 30° swing was not followed: %d commands", rec.n)
|
||||
}
|
||||
}
|
||||
|
||||
// With an elevation axis, a climb is still followed.
|
||||
func TestPointRotatorFollowsElevationWhenItCan(t *testing.T) {
|
||||
rec := &countingRotator{}
|
||||
tr := &satTracker{rot: rec, rotStep: 5}
|
||||
tr.pointRotator(sat.Position{Az: 100, El: 5}, false)
|
||||
tr.pointRotator(sat.Position{Az: 101, El: 40}, false)
|
||||
if rec.n != 2 {
|
||||
t.Errorf("a 35° climb was not followed: %d commands", rec.n)
|
||||
}
|
||||
if rec.lastEl != 40 {
|
||||
t.Errorf("commanded elevation %v, want 40", rec.lastEl)
|
||||
}
|
||||
}
|
||||
|
||||
type countingRotator struct {
|
||||
n int
|
||||
lastAz, lastEl float64
|
||||
}
|
||||
|
||||
func (c *countingRotator) Point(az, el float64) error {
|
||||
c.n++
|
||||
c.lastAz, c.lastEl = az, el
|
||||
return nil
|
||||
}
|
||||
func (c *countingRotator) Heading() (float64, float64, bool, error) { return 0, 0, false, nil }
|
||||
func (c *countingRotator) Close() {}
|
||||
|
||||
// Which sideband goes on each slice.
|
||||
//
|
||||
// An inverting transponder turns the passband over, so a signal transmitted on
|
||||
// lower sideband comes back on upper. Setting USB at both ends put the
|
||||
// operator's own audio through upside down — unreadable at the far end, and on
|
||||
// FO-29, RS-44 and AO-73 that is every contact attempted.
|
||||
func TestSatSidebands(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
tp sat.Transponder
|
||||
wantDown, want string
|
||||
}{
|
||||
{"inverting linear: LSB up, USB down",
|
||||
sat.Transponder{Mode: "SSB", Inverting: true}, "USB", "LSB"},
|
||||
{"non-inverting linear: USB both ways",
|
||||
sat.Transponder{Mode: "SSB"}, "USB", "USB"},
|
||||
// A tone is transmitted and received in FM whichever way the passband
|
||||
// runs, and CW is CW.
|
||||
{"FM is FM both ways", sat.Transponder{Mode: "FM"}, "FM", "FM"},
|
||||
{"CW ignores inversion", sat.Transponder{Mode: "CW", Inverting: true}, "CW", "CW"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
down, up := satSidebands(c.tp)
|
||||
if down != c.wantDown || up != c.want {
|
||||
t.Errorf("%s: got %s/%s, want %s/%s", c.name, down, up, c.wantDown, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The per-band antenna map is keyed by the UPPERCASED band name, because that
|
||||
// is what the settings panel writes. The satellite tracker looked its two bands
|
||||
// up with the band plan's own spelling, matched nothing, and ran the pass on
|
||||
// whichever antenna the radio was last left on — a 70 cm downlink through a 2 m
|
||||
// transverter, with no error anywhere. This pins the contract in the direction
|
||||
// that broke.
|
||||
func TestFlexBandAntKeyIsUppercased(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
hz int64
|
||||
want string
|
||||
}{
|
||||
{435_400_000, "70CM"}, // an FM bird's downlink
|
||||
{145_950_000, "2M"}, // its uplink
|
||||
{1_269_000_000, "23CM"}, // AO-92's L band
|
||||
{29_450_000, "10M"}, // AO-7 mode A
|
||||
{9_000_000_000, ""}, // nothing in the plan: no key, and no antenna
|
||||
} {
|
||||
if got := flexBandAntKey(c.hz); got != c.want {
|
||||
t.Errorf("flexBandAntKey(%d) = %q, want %q", c.hz, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A band list sorted as strings puts 10m between 1.25m and 12m, which is why
|
||||
// the plan's own index is the order.
|
||||
func TestBandOrderIsByFrequency(t *testing.T) {
|
||||
got := []string{"70cm", "10m", "160m", "2m", "20m", "banana"}
|
||||
sort.Slice(got, func(i, j int) bool { return bandOrder(got[i]) < bandOrder(got[j]) })
|
||||
want := []string{"160m", "20m", "10m", "2m", "70cm", "banana"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("sorted %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ var sensitiveSettingKeys = map[string]bool{
|
||||
keyExtLoTWKeyPassword: true,
|
||||
keyExtLoTWWebPassword: true,
|
||||
keyExtHRDLogCode: true,
|
||||
keyExtHamqthPassword: true,
|
||||
keyExtEQSLPassword: true,
|
||||
keyExtCloudlogAPIKey: true,
|
||||
// The web-publish config is one JSON blob and the FTP password lives inside
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
// The voice keyer, through the radio's own link.
|
||||
//
|
||||
// Selecting the radio as the "To radio" output makes the voice keyer hand its
|
||||
// messages to the CAT backend instead of a sound card. Everything around it is
|
||||
// unchanged — the same PTT before and after, the same gain, the same files —
|
||||
// which is the point: the audio takes a different road, not a different route.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/audio"
|
||||
"hamlog/internal/cat"
|
||||
)
|
||||
|
||||
// tciTXPlayer hands one message to the radio.
|
||||
//
|
||||
// The controller is fetched on the CAT goroutine and the message is then played
|
||||
// OFF it. Playing on it would hold that goroutine for the length of the
|
||||
// message, and everything else about the rig — frequency, mode, PTT state —
|
||||
// goes through the same place: a ten-second call would freeze the display and
|
||||
// the antenna following for ten seconds. The TCI backend serialises its own
|
||||
// writes, so this is safe to call from here.
|
||||
func (a *App) tciTXPlayer(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("CAT not initialized")
|
||||
}
|
||||
type txPlayer interface {
|
||||
PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error
|
||||
}
|
||||
var player txPlayer
|
||||
err := a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
|
||||
p, ok := t.(txPlayer)
|
||||
if !ok {
|
||||
return fmt.Errorf("this radio cannot take transmit audio over its CAT link")
|
||||
}
|
||||
player = p
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return player.PlayTXAudio(pcm, rate, ch, bits, stop)
|
||||
}
|
||||
|
||||
// applyTCITXSource tells the radio where to take its transmit audio from,
|
||||
// following the "To radio" device.
|
||||
//
|
||||
// This is the setting an operator would otherwise have to find in ExpertSDR3
|
||||
// and set again for every mode, because it is remembered per mode there. It is
|
||||
// the third argument of TRX, so OpsLog can simply say it each time it keys —
|
||||
// and a radio told nothing keeps the operator's microphone, which is what
|
||||
// every PTT that is not a voice message should do.
|
||||
func (a *App) applyTCITXSource() {
|
||||
if a.cat == nil {
|
||||
return
|
||||
}
|
||||
cfg, _ := a.GetAudioSettings()
|
||||
src := ""
|
||||
if cfg.ToRadio == audio.NetworkDeviceID {
|
||||
src = "tci"
|
||||
}
|
||||
_ = a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
|
||||
if s, ok := t.(interface{ SetTXAudioSource(string) }); ok {
|
||||
s.SetTXAudioSource(src)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// installTCITXPlayer offers the radio as an audio output, or withdraws it.
|
||||
//
|
||||
// Withdrawing matters as much as offering: a radio that has gone away must stop
|
||||
// being a device the voice keyer will happily "play" to, or a message goes
|
||||
// nowhere and the operator hears their own PTT click and assumes it worked.
|
||||
func (a *App) installTCITXPlayer(on bool) {
|
||||
if !on {
|
||||
audio.SetNetworkPlayer(nil)
|
||||
return
|
||||
}
|
||||
audio.SetNetworkPlayer(a.tciTXPlayer)
|
||||
a.applyTCITXSource()
|
||||
applog.Printf("tci: the radio is available as an audio output — no virtual cable needed for the voice keyer")
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
// The TCI control console — bindings.
|
||||
//
|
||||
// Thin on purpose: the state is a snapshot the radio pushed and the setters are
|
||||
// one command each. Everything interesting is in internal/cat/tci_panel.go.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"hamlog/internal/cat"
|
||||
)
|
||||
|
||||
// GetTCIPanel returns the console state. Connected=false when the active CAT
|
||||
// backend is not a TCI radio, so the frontend has one thing to look at rather
|
||||
// than an error to distinguish from a disconnected radio.
|
||||
func (a *App) GetTCIPanel() cat.TCIPanelState {
|
||||
if a.cat == nil {
|
||||
return cat.TCIPanelState{}
|
||||
}
|
||||
st, _ := a.cat.TCIPanelState()
|
||||
return st
|
||||
}
|
||||
|
||||
// tciDo is the shape every setter below takes.
|
||||
func (a *App) tciDo(fn func(cat.TCIPanelController) error) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("CAT not initialized")
|
||||
}
|
||||
return a.cat.TCIPanelDo(fn)
|
||||
}
|
||||
|
||||
// SetTCIDrive sets the transmit drive (0-100).
|
||||
func (a *App) SetTCIDrive(v int) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetDrive(v) })
|
||||
}
|
||||
|
||||
// SetTCITuneDrive sets the drive TUNE uses (0-100).
|
||||
func (a *App) SetTCITuneDrive(v int) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetTuneDrive(v) })
|
||||
}
|
||||
|
||||
// SetTCIMicLevel sets the microphone gain (0-100).
|
||||
func (a *App) SetTCIMicLevel(v int) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetMicLevel(v) })
|
||||
}
|
||||
|
||||
// SetTCIVolume sets the receive volume in dB (0 down to -60).
|
||||
func (a *App) SetTCIVolume(db int) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetVolume(db) })
|
||||
}
|
||||
|
||||
// SetTCIMute mutes or unmutes the receiver.
|
||||
func (a *App) SetTCIMute(on bool) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetMute(on) })
|
||||
}
|
||||
|
||||
// SetTCIAGC picks the AGC speed: off, long, slow, med, fast.
|
||||
func (a *App) SetTCIAGC(mode string) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetAGC(mode) })
|
||||
}
|
||||
|
||||
// SetTCISquelch turns the squelch on or off.
|
||||
func (a *App) SetTCISquelch(on bool) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetSquelch(on) })
|
||||
}
|
||||
|
||||
// SetTCISquelchLevel sets the squelch threshold in dBm.
|
||||
func (a *App) SetTCISquelchLevel(v int) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetSquelchLevel(v) })
|
||||
}
|
||||
|
||||
// SetTCINB, SetTCINR, SetTCIANF and SetTCIAPF switch the receive processing.
|
||||
func (a *App) SetTCINB(on bool) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetNB(on) })
|
||||
}
|
||||
func (a *App) SetTCINR(on bool) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetNR(on) })
|
||||
}
|
||||
func (a *App) SetTCIANF(on bool) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetANF(on) })
|
||||
}
|
||||
func (a *App) SetTCIAPF(on bool) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetAPF(on) })
|
||||
}
|
||||
|
||||
// SetTCIFilter sets the passband edges in Hz.
|
||||
func (a *App) SetTCIFilter(lo, hi int) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetFilter(lo, hi) })
|
||||
}
|
||||
|
||||
// SetTCIRIT / SetTCIXIT switch the offsets on; the Offset calls move them.
|
||||
func (a *App) SetTCIRIT(on bool) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetRIT(on) })
|
||||
}
|
||||
func (a *App) SetTCIXIT(on bool) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetXIT(on) })
|
||||
}
|
||||
func (a *App) SetTCIRITOffset(hz int) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetRITOffset(hz) })
|
||||
}
|
||||
func (a *App) SetTCIXITOffset(hz int) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetXITOffset(hz) })
|
||||
}
|
||||
|
||||
// SetTCILock locks the radio's VFO knob.
|
||||
func (a *App) SetTCILock(on bool) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetLock(on) })
|
||||
}
|
||||
|
||||
// SetTCITune starts or stops the tune carrier.
|
||||
//
|
||||
// IT TRANSMITS, and at tune_drive rather than at drive — which is why the panel
|
||||
// shows those two numbers next to the button rather than hiding one of them in
|
||||
// a menu.
|
||||
func (a *App) SetTCITune(on bool) error {
|
||||
return a.tciDo(func(t cat.TCIPanelController) error { return t.SetTune(on) })
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
// Feeding the QSO recorder from the TCI stream.
|
||||
//
|
||||
// The recorder works in 16 kHz mono, which is what its files and its mixing are
|
||||
// built around; TCI delivers 48 kHz stereo float32. The conversion is the whole
|
||||
// of this file, and it happens here rather than in internal/cat because the
|
||||
// radio's job is to hand over what it sent, not to know what the recorder wants.
|
||||
//
|
||||
// Confirmed on a SunSDR (ExpertSDR3 1.5): 2048 samples a frame, 8192 bytes,
|
||||
// four bytes per sample — and a test recording that plays back clean.
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/audio"
|
||||
"hamlog/internal/cat"
|
||||
)
|
||||
|
||||
// tciRecordSink pushes the receive stream into the QSO recorder.
|
||||
//
|
||||
// Installed whenever the TCI backend starts, and harmless when nothing is
|
||||
// recording: PushRX drops what arrives unless a QSO is being captured, so the
|
||||
// cost while idle is a decimation and a function call.
|
||||
func (a *App) tciRecordSink(rate int, samples []float32) {
|
||||
if a.qsoRec == nil || len(samples) == 0 {
|
||||
return
|
||||
}
|
||||
a.qsoRec.PushRX(tciToRecorderPCM(rate, samples))
|
||||
}
|
||||
|
||||
// tciToRecorderPCM converts the stream's mono float samples to the recorder's
|
||||
// 16-bit PCM at its own rate.
|
||||
//
|
||||
// Averaging rather than picking every third sample: dropping samples aliases
|
||||
// everything above 8 kHz back down into the voice band, and on a receiver that
|
||||
// is hiss — the one thing a QSO recording has plenty of. A three-tap mean is a
|
||||
// crude low-pass, but it is a low-pass, and it costs two additions.
|
||||
func tciToRecorderPCM(rate int, samples []float32) []byte {
|
||||
if rate <= 0 {
|
||||
rate = 48000
|
||||
}
|
||||
step := rate / audio.RecorderSampleRate
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
out := make([]byte, 0, (len(samples)/step)*2)
|
||||
for i := 0; i+step <= len(samples); i += step {
|
||||
var sum float32
|
||||
for j := 0; j < step; j++ {
|
||||
sum += samples[i+j]
|
||||
}
|
||||
v := sum / float32(step)
|
||||
if v > 1 {
|
||||
v = 1
|
||||
}
|
||||
if v < -1 {
|
||||
v = -1
|
||||
}
|
||||
var b [2]byte
|
||||
binary.LittleEndian.PutUint16(b[:], uint16(int16(v*32767)))
|
||||
out = append(out, b[0], b[1])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// startTCIRecording opens the receive stream and routes it to the recorder.
|
||||
//
|
||||
// Called when the TCI backend comes up, and only when the operator has asked
|
||||
// for it by choosing the radio as their receive device: opening a 384 kB/s
|
||||
// stream on a station that records nothing is work the radio does for nobody.
|
||||
func (a *App) startTCIRecording() {
|
||||
if a.cat == nil {
|
||||
return
|
||||
}
|
||||
// One switch, and it is the one an operator is already looking at: the
|
||||
// "From radio" device. There used to be a tick box here as well, from when
|
||||
// this was an experiment with no device to choose — two controls for one
|
||||
// question, and the second was where nobody would look.
|
||||
cfg, _ := a.GetAudioSettings()
|
||||
if cfg.FromRadio != audio.NetworkDeviceID {
|
||||
return
|
||||
}
|
||||
err := a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
|
||||
if s, ok := t.(interface {
|
||||
SetTCIAudioSink(func(int, []float32))
|
||||
}); ok {
|
||||
s.SetTCIAudioSink(a.tciRecordSink)
|
||||
}
|
||||
return t.StartTCIAudio(0, 48000)
|
||||
})
|
||||
if err != nil {
|
||||
applog.Printf("tci: could not open the receive stream for recording: %v", err)
|
||||
return
|
||||
}
|
||||
applog.Printf("tci: recording the receive audio over TCI — no virtual cable needed")
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/audio"
|
||||
)
|
||||
|
||||
// The stream is 48 kHz and the recorder works at 16 — three to one. A
|
||||
// recording that keeps every sample plays back three times too fast, which is
|
||||
// the fault that gets blamed on the decoding rather than on the rate.
|
||||
func TestTheStreamIsResampledToTheRecorderRate(t *testing.T) {
|
||||
const in = 48000
|
||||
samples := make([]float32, in/10) // a tenth of a second
|
||||
pcm := tciToRecorderPCM(in, samples)
|
||||
want := (audio.RecorderSampleRate / 10) * 2 // 16-bit
|
||||
if len(pcm) != want {
|
||||
t.Fatalf("a tenth of a second produced %d bytes, want %d", len(pcm), want)
|
||||
}
|
||||
}
|
||||
|
||||
// Full scale must arrive as full scale: a conversion that quietly halves the
|
||||
// level turns a recording into evidence of a fault that is not there.
|
||||
func TestFullScaleSurvivesTheConversion(t *testing.T) {
|
||||
samples := make([]float32, 12)
|
||||
for i := range samples {
|
||||
samples[i] = 1
|
||||
}
|
||||
pcm := tciToRecorderPCM(48000, samples)
|
||||
if len(pcm) < 2 {
|
||||
t.Fatal("no samples came out")
|
||||
}
|
||||
v := int16(binary.LittleEndian.Uint16(pcm[:2]))
|
||||
if v < 32000 {
|
||||
t.Fatalf("full scale came out at %d", v)
|
||||
}
|
||||
}
|
||||
|
||||
// A rate the recorder already works in is passed through rather than mangled by
|
||||
// a division that would round to nothing.
|
||||
func TestAStreamAtTheRecorderRateIsNotDecimated(t *testing.T) {
|
||||
samples := make([]float32, 160)
|
||||
if got, want := len(tciToRecorderPCM(audio.RecorderSampleRate, samples)), 160*2; got != want {
|
||||
t.Fatalf("%d bytes, want %d", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
// Which decoder the entry field belongs to, when several are running.
|
||||
//
|
||||
// A station running WSJT-X, JTDX and MSHV at once has three programs sending
|
||||
// Status once a second each. Click a call in one of them and only that one has
|
||||
// a DX Call; the other two are idle and say so. Both statements are true, and
|
||||
// both arrive — so the entry field is filled by the program the operator is
|
||||
// working and emptied by the two that are not, once a second, and the map
|
||||
// zooms in and out with it.
|
||||
//
|
||||
// So the first program to announce a station is FOCUSED, and until it lets go
|
||||
// the others cannot touch the entry field. That is the operator's own answer:
|
||||
// "if I call on one program, keep that one's UDP for the duration of the QSO".
|
||||
//
|
||||
// Focus is released when the focused program clears its own DX Call, when it
|
||||
// stops sending altogether (it was closed), or when a QSO is logged — never on
|
||||
// a timer that could hand the field to another program mid-over.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
// udpFocusIdle is how long a focused program may go silent before the focus is
|
||||
// given up.
|
||||
//
|
||||
// Generous on purpose: a decoder sends Status every second, so anything above a
|
||||
// few seconds means it has been closed or has lost its network. Thirty is long
|
||||
// enough to survive a machine that stutters and short enough that a program
|
||||
// closed mid-QSO does not lock the entry field for the rest of the evening.
|
||||
const udpFocusIdle = 30 * time.Second
|
||||
|
||||
type udpFocus struct {
|
||||
mu sync.Mutex
|
||||
inst string
|
||||
at time.Time
|
||||
// told marks that the log already carries the line explaining why another
|
||||
// program's callsign is being ignored. Once per focus, not once a second.
|
||||
told map[string]bool
|
||||
}
|
||||
|
||||
// claim records that inst is announcing a station, and reports whether inst is
|
||||
// the program the entry field currently belongs to.
|
||||
func (f *udpFocus) claim(inst string) bool {
|
||||
if inst == "" {
|
||||
return true // a sender with no id: nothing to arbitrate between
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.inst == "" || f.inst == inst || time.Since(f.at) > udpFocusIdle {
|
||||
if f.inst != inst {
|
||||
applog.Printf("udp: the entry field follows %s while it is calling", inst)
|
||||
f.told = nil
|
||||
}
|
||||
f.inst, f.at = inst, time.Now()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// holds reports whether inst may act on the entry field, without claiming it.
|
||||
// Used for the clear: a program that is not focused clearing its own DX Call
|
||||
// says nothing about the QSO in progress somewhere else.
|
||||
func (f *udpFocus) holds(inst string) bool {
|
||||
if inst == "" {
|
||||
return true
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.inst == "" || time.Since(f.at) > udpFocusIdle {
|
||||
return true
|
||||
}
|
||||
return f.inst == inst
|
||||
}
|
||||
|
||||
// release gives the field up — the focused program cleared its call, or a QSO
|
||||
// was logged and the next station may come from anywhere.
|
||||
func (f *udpFocus) release(why string) {
|
||||
f.mu.Lock()
|
||||
had := f.inst
|
||||
f.inst, f.at, f.told = "", time.Time{}, nil
|
||||
f.mu.Unlock()
|
||||
if had != "" {
|
||||
applog.Printf("udp: the entry field is free again (%s let go: %s)", had, why)
|
||||
}
|
||||
}
|
||||
|
||||
// noteIgnored logs, once per focused program, that another one's callsign was
|
||||
// not applied. Without it the behaviour is invisible: an operator whose second
|
||||
// decoder "stopped filling the call" has nothing to read.
|
||||
func (f *udpFocus) noteIgnored(inst, call string) {
|
||||
f.mu.Lock()
|
||||
if f.told == nil {
|
||||
f.told = map[string]bool{}
|
||||
}
|
||||
first := !f.told[inst]
|
||||
f.told[inst] = true
|
||||
holder := f.inst
|
||||
f.mu.Unlock()
|
||||
if first {
|
||||
applog.Printf("udp: [%s] %q not applied — %s has the entry field while it is calling",
|
||||
inst, strings.ToUpper(call), holder)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The reported failure, in order: MSHV is called on, WSJT-X and JTDX sit idle
|
||||
// beside it, and every one of their Status packets used to empty the entry
|
||||
// field that MSHV had just filled — once a second, with the map zooming in and
|
||||
// out to match.
|
||||
func TestUdpFocusKeepsTheFieldWithTheCallingProgram(t *testing.T) {
|
||||
var f udpFocus
|
||||
|
||||
if !f.claim("MSHV") {
|
||||
t.Fatal("the first program to announce a station must take the field")
|
||||
}
|
||||
// The other two, announcing stations of their own, are refused.
|
||||
if f.claim("WSJT-X") {
|
||||
t.Error("WSJT-X took the field while MSHV was calling")
|
||||
}
|
||||
if f.claim("JTDX") {
|
||||
t.Error("JTDX took the field while MSHV was calling")
|
||||
}
|
||||
// And their clears do not empty it — this is the half that caused the flicker.
|
||||
if f.holds("WSJT-X") {
|
||||
t.Error("an idle WSJT-X was allowed to clear MSHV's callsign")
|
||||
}
|
||||
if !f.holds("MSHV") {
|
||||
t.Error("MSHV lost the right to clear its own callsign")
|
||||
}
|
||||
// MSHV moving to the next station keeps the field.
|
||||
if !f.claim("MSHV") {
|
||||
t.Error("the focused program must keep the field across stations")
|
||||
}
|
||||
}
|
||||
|
||||
// Letting go, three ways.
|
||||
func TestUdpFocusRelease(t *testing.T) {
|
||||
var f udpFocus
|
||||
|
||||
// The focused program clears its own call.
|
||||
f.claim("MSHV")
|
||||
f.release("DX Call cleared")
|
||||
if !f.claim("WSJT-X") {
|
||||
t.Error("after a release the next program should be able to take the field")
|
||||
}
|
||||
|
||||
// A QSO is logged.
|
||||
f.release("QSO logged")
|
||||
if !f.claim("JTDX") {
|
||||
t.Error("logging a QSO must free the field for whichever program hears the next station")
|
||||
}
|
||||
|
||||
// The focused program is closed and stops sending. Its hold lapses rather
|
||||
// than locking the entry field for the rest of the evening.
|
||||
f.mu.Lock()
|
||||
f.at = time.Now().Add(-udpFocusIdle - time.Second)
|
||||
f.mu.Unlock()
|
||||
if !f.claim("MSHV") {
|
||||
t.Error("a silent program must not hold the field for ever")
|
||||
}
|
||||
}
|
||||
|
||||
// A sender with no program id — an ADIF relay, a remote "set call" — is not
|
||||
// something to arbitrate between, and must never be locked out.
|
||||
func TestUdpFocusIgnoresUnnamedSenders(t *testing.T) {
|
||||
var f udpFocus
|
||||
f.claim("MSHV")
|
||||
if !f.claim("") {
|
||||
t.Error("an unnamed sender was refused the entry field")
|
||||
}
|
||||
if !f.holds("") {
|
||||
t.Error("an unnamed sender was refused a clear")
|
||||
}
|
||||
}
|
||||
@@ -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,307 @@
|
||||
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 (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"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"
|
||||
// One key per verdict, holding "#RRGGBB". Only the BACKGROUND is stored: the
|
||||
// text colour is computed from it, so a chosen colour can never come out
|
||||
// unreadable in somebody else's window.
|
||||
keyWsjtColWatchlist = "udp.wsjt.colour.watchlist"
|
||||
keyWsjtColNewDXCC = "udp.wsjt.colour.new_dxcc"
|
||||
keyWsjtColNewBand = "udp.wsjt.colour.new_band"
|
||||
keyWsjtColWorked = "udp.wsjt.colour.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 DEFAULT palette. Fixed colours, not theme tokens — they are painted into
|
||||
// another application's window, which has no idea what theme OpsLog wears, and
|
||||
// the operator can change each of them (see WsjtHighlightColours).
|
||||
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}
|
||||
)
|
||||
|
||||
// WsjtHighlightColours is the operator's palette, one background per verdict.
|
||||
type WsjtHighlightColours struct {
|
||||
Watchlist string `json:"watchlist"`
|
||||
NewDXCC string `json:"new_dxcc"`
|
||||
NewBand string `json:"new_band"`
|
||||
Worked string `json:"worked"`
|
||||
}
|
||||
|
||||
// GetWsjtHighlightColours returns the palette in "#RRGGBB", defaults included.
|
||||
func (a *App) GetWsjtHighlightColours() WsjtHighlightColours {
|
||||
return WsjtHighlightColours{
|
||||
Watchlist: a.settingOr(keyWsjtColWatchlist, hexOfRGB(hlWatchlist)),
|
||||
NewDXCC: a.settingOr(keyWsjtColNewDXCC, hexOfRGB(hlNewDXCC)),
|
||||
NewBand: a.settingOr(keyWsjtColNewBand, hexOfRGB(hlNewBand)),
|
||||
Worked: a.settingOr(keyWsjtColWorked, hexOfRGB(hlWorked)),
|
||||
}
|
||||
}
|
||||
|
||||
// SetWsjtHighlightColours stores the palette and repaints.
|
||||
//
|
||||
// The repaint is the whole point of clearing: the de-duplication remembers what
|
||||
// it has already told each decoder, so without this a callsign keeps yesterday's
|
||||
// colour until it changes verdict — and the operator, having just picked a new
|
||||
// one, sees nothing happen.
|
||||
func (a *App) SetWsjtHighlightColours(c WsjtHighlightColours) {
|
||||
set := func(key, v, def string) {
|
||||
if _, ok := parseHexRGB(v); !ok {
|
||||
v = def
|
||||
}
|
||||
a.setSetting(key, strings.ToUpper(strings.TrimSpace(v)))
|
||||
}
|
||||
set(keyWsjtColWatchlist, c.Watchlist, hexOfRGB(hlWatchlist))
|
||||
set(keyWsjtColNewDXCC, c.NewDXCC, hexOfRGB(hlNewDXCC))
|
||||
set(keyWsjtColNewBand, c.NewBand, hexOfRGB(hlNewBand))
|
||||
set(keyWsjtColWorked, c.Worked, hexOfRGB(hlWorked))
|
||||
a.clearWsjtHighlights()
|
||||
applog.Printf("wsjt highlight: palette changed — repainting")
|
||||
}
|
||||
|
||||
// colourFor reads one verdict's background and picks a legible foreground.
|
||||
//
|
||||
// The text colour is DERIVED, never stored: an operator choosing a dark blue
|
||||
// would otherwise get black text on it in somebody else's window and conclude
|
||||
// the feature is broken. Rec. 601 luma, the same rule a browser's contrast
|
||||
// checker uses, with the threshold where black stops being readable.
|
||||
func (a *App) colourFor(key, def string) (udp.RGB, udp.RGB) {
|
||||
bg, ok := parseHexRGB(a.settingOr(key, def))
|
||||
if !ok {
|
||||
bg, _ = parseHexRGB(def)
|
||||
}
|
||||
luma := (299*int(bg.R) + 587*int(bg.G) + 114*int(bg.B)) / 1000
|
||||
if luma < 140 {
|
||||
return bg, hlWhite
|
||||
}
|
||||
return bg, hlBlack
|
||||
}
|
||||
|
||||
func hexOfRGB(c udp.RGB) string { return fmt.Sprintf("#%02X%02X%02X", c.R, c.G, c.B) }
|
||||
|
||||
// parseHexRGB reads "#RRGGBB" (or "RRGGBB"). Anything else is refused rather
|
||||
// than half-read: a colour that silently becomes black is worse than a default.
|
||||
func parseHexRGB(s string) (udp.RGB, bool) {
|
||||
s = strings.TrimPrefix(strings.TrimSpace(s), "#")
|
||||
if len(s) != 6 {
|
||||
return udp.RGB{}, false
|
||||
}
|
||||
v, err := strconv.ParseUint(s, 16, 32)
|
||||
if err != nil {
|
||||
return udp.RGB{}, false
|
||||
}
|
||||
return udp.RGB{R: byte(v >> 16), G: byte(v >> 8), B: byte(v)}, true
|
||||
}
|
||||
|
||||
// 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) {
|
||||
c := a.clusterStatusMaps()
|
||||
// ALREADY WORKED HERE, whatever else the station is.
|
||||
//
|
||||
// Settled first because it is the one fact that cancels the others. A watch
|
||||
// list entry worked on this band and mode stayed pink for the rest of the
|
||||
// session — the list is a statement of intent, not of what is left to do, and
|
||||
// the colour that means "call this one" was being shown for a station already
|
||||
// in the log. From the operator's side there was no way to tell the two
|
||||
// apart, which is the only thing the colours are for.
|
||||
workedHere := false
|
||||
if band != "" && mode != "" && c.workedCallSlots != nil {
|
||||
m := strings.ToUpper(strings.TrimSpace(mode))
|
||||
if c.normMode != nil {
|
||||
m = c.normMode(m)
|
||||
}
|
||||
_, workedHere = c.workedCallSlots[strings.ToUpper(call)+"|"+strings.ToLower(band)+"|"+m]
|
||||
}
|
||||
if a.watchlist != nil && !workedHere {
|
||||
if _, ok := a.watchlist.Match(call); ok {
|
||||
bgc, fgc := a.colourFor(keyWsjtColWatchlist, hexOfRGB(hlWatchlist))
|
||||
return &bgc, &fgc, "watchlist"
|
||||
}
|
||||
}
|
||||
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 := a.colourFor(keyWsjtColNewDXCC, hexOfRGB(hlNewDXCC))
|
||||
return &bgc, &fgc, "new-dxcc"
|
||||
}
|
||||
if band != "" {
|
||||
if _, workedBand := ent.Bands[strings.ToLower(band)]; !workedBand {
|
||||
bgc, fgc := a.colourFor(keyWsjtColNewBand, hexOfRGB(hlNewBand))
|
||||
return &bgc, &fgc, "new-band"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
// Its own option: on a well-filled log this matches most of a period, and a
|
||||
// screen where nearly every line is coloured has stopped saying anything.
|
||||
if workedHere && a.wsjtHLWorkedOn.Load() {
|
||||
bgc, fgc := a.colourFor(keyWsjtColWorked, hexOfRGB(hlWorked))
|
||||
return &bgc, &fgc, "worked"
|
||||
}
|
||||
return nil, nil, ""
|
||||
}
|
||||
+9
-2
@@ -19,7 +19,7 @@ type RowColorRule struct {
|
||||
ID string `json:"id"`
|
||||
Color string `json:"color"`
|
||||
Enabled bool `json:"enabled"`
|
||||
// Channels this rule looks at: qsl (paper), lotw, eqsl, qrz.
|
||||
// Channels this rule looks at: qsl (paper), lotw, eqsl, qrz, hamlog.
|
||||
//
|
||||
// Empty means ALL of them — what an operator expects from a rule they have
|
||||
// not narrowed, and what keeps a config written before this field meaningful.
|
||||
@@ -27,7 +27,7 @@ type RowColorRule struct {
|
||||
}
|
||||
|
||||
// The QSL channels a rule can be scoped to.
|
||||
var rowColorChannels = []string{"qsl", "lotw", "eqsl", "qrz"}
|
||||
var rowColorChannels = []string{"qsl", "lotw", "eqsl", "qrz", "hamlog"}
|
||||
|
||||
// RowColorSettings is the whole appearance block.
|
||||
type RowColorSettings struct {
|
||||
@@ -174,6 +174,11 @@ type MatrixColors struct {
|
||||
EntityWorked string `json:"entity_worked"`
|
||||
NotWorked string `json:"not_worked"`
|
||||
CurrentEntry string `json:"current_entry"`
|
||||
// The dot marking a slot already worked with the callsign in hand. It is a
|
||||
// mark rather than a background, so it needs its own two colours: it is
|
||||
// drawn on top of all five of the above and must stay legible over each.
|
||||
MarkWorked string `json:"mark_worked"`
|
||||
MarkConfirmed string `json:"mark_confirmed"`
|
||||
}
|
||||
|
||||
// normMatrixColors keeps only plain hex values. Anything else becomes "" — i.e.
|
||||
@@ -196,6 +201,8 @@ func normMatrixColors(c MatrixColors) MatrixColors {
|
||||
EntityWorked: clean(c.EntityWorked),
|
||||
NotWorked: clean(c.NotWorked),
|
||||
CurrentEntry: clean(c.CurrentEntry),
|
||||
MarkWorked: clean(c.MarkWorked),
|
||||
MarkConfirmed: clean(c.MarkConfirmed),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+736
@@ -0,0 +1,736 @@
|
||||
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"
|
||||
// Periods, not minutes — see autocall.Settings.RestPeriods. A new key rather
|
||||
// than a reinterpreted one: the old value was in minutes, and reading "2" as
|
||||
// two periods or as two minutes are eight periods apart.
|
||||
keyAutoCallRest = "autocall.rest_periods"
|
||||
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
|
||||
// RestPeriods the pause between two of them, counted in the station's own
|
||||
// transmit periods.
|
||||
MaxRounds int `json:"max_rounds"`
|
||||
RestPeriods int `json:"rest_periods"`
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// intOr reads a stored integer, keeping a stored ZERO — which num() cannot,
|
||||
// since it treats zero as "nothing set".
|
||||
func intOr(v string, def int) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil || n < 0 {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
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),
|
||||
// Zero is meaningful (call it again next period), so it is read directly
|
||||
// rather than through num(), which treats zero as "unset".
|
||||
RestPeriods: intOr(a.settingOr(keyAutoCallRest, ""), d.RestPeriods),
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
} {
|
||||
if v > 0 {
|
||||
a.setSetting(key, strconv.Itoa(v))
|
||||
}
|
||||
}
|
||||
// Stored even at zero, unlike the counters above: no rest at all is a
|
||||
// setting, not an empty field.
|
||||
if s.RestPeriods >= 0 {
|
||||
a.setSetting(keyAutoCallRest, strconv.Itoa(s.RestPeriods))
|
||||
}
|
||||
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,
|
||||
RestPeriods: s.RestPeriods,
|
||||
}
|
||||
}
|
||||
|
||||
// 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.acJudged, a.acDirty = 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{}
|
||||
a.acJudged, a.acDirty = map[string]string{}, map[string]bool{}
|
||||
}
|
||||
if prev := a.acPeriod[inst]; prev != "" && prev != key {
|
||||
prevAt, prevTR, buf := a.acAt[inst], a.acTR[inst], a.acBuf[inst]
|
||||
// Only if something in it has NOT been judged. The buffer now outlives
|
||||
// the judgement (see below), so without this the arrival of the next
|
||||
// period would judge the previous one a second time on the very same
|
||||
// decodes — and act on them, a slot late.
|
||||
pending := a.acDirty[inst] || a.acJudged[inst] != prev
|
||||
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
|
||||
a.acBuf[inst] = []acDecode{{d: d}}
|
||||
a.acFed[inst], a.acDirty[inst] = time.Now(), true
|
||||
a.acMu.Unlock()
|
||||
// The previous period ends here whatever the sweeper was going to do: a
|
||||
// decode stamped with the next slot is proof the old one is over.
|
||||
if pending {
|
||||
a.autoCallJudge(inst, prev, prevAt, prevTR, buf)
|
||||
}
|
||||
return
|
||||
}
|
||||
// APPENDED, never restarted. The buffer survives the period being judged,
|
||||
// so a decode that arrives after the others belongs to the same period and
|
||||
// is weighed against all of them.
|
||||
//
|
||||
// It used to be dropped and then judged on its own: the sweeper cleared the
|
||||
// buffer, a straggler opened a "new" one under the same key, and the ladder
|
||||
// was applied to whatever handful had come late — with the other thirty
|
||||
// stations of that period nowhere in sight. A deep decode arriving a second
|
||||
// after the burst is exactly the station worth calling.
|
||||
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
|
||||
a.acFed[inst], a.acDirty[inst] = time.Now(), true
|
||||
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
|
||||
}
|
||||
// Judged once per period, and again only when something new has come in
|
||||
// for it. The period itself is kept open until a decode from the NEXT one
|
||||
// arrives, so a straggler is judged with the whole period behind it.
|
||||
if a.acJudged[inst] == key && !a.acDirty[inst] {
|
||||
continue
|
||||
}
|
||||
ready = append(ready, due{inst, key, a.acAt[inst], tr, a.acBuf[inst]})
|
||||
a.acJudged[inst], a.acDirty[inst] = key, false
|
||||
}
|
||||
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()
|
||||
// Nothing pending for this receiver, and nothing judged for a while: the
|
||||
// band has gone quiet under it.
|
||||
quiet := !a.acDirty[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)
|
||||
}
|
||||
}
|
||||
+3
-40
@@ -6,10 +6,8 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
|
||||
@@ -75,11 +73,8 @@ func (a *App) SaveAutostartPrograms(progs []AutostartProgram) error {
|
||||
// BrowseExecutable opens a native file picker for choosing a program to launch.
|
||||
func (a *App) BrowseExecutable() (string, error) {
|
||||
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||
Title: "Choose a program to launch on startup",
|
||||
Filters: []wruntime.FileFilter{
|
||||
{DisplayName: "Programs (*.exe;*.bat;*.cmd)", Pattern: "*.exe;*.bat;*.cmd"},
|
||||
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
||||
},
|
||||
Title: "Choose a program to launch on startup",
|
||||
Filters: executableFilters(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -150,9 +145,7 @@ func (a *App) CloseAutostartPrograms() {
|
||||
if name == "" {
|
||||
name = filepath.Base(p.Path)
|
||||
}
|
||||
cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid))
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000}
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
if out, err := closeProcess(pid); err != nil {
|
||||
applog.Printf("autostart: could not close %s (pid %d): %v — %s", name, pid, err, strings.TrimSpace(string(out)))
|
||||
continue
|
||||
}
|
||||
@@ -227,33 +220,3 @@ func splitArgs(s string) []string {
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// runningProcessNames returns the set of lowercase executable names currently
|
||||
// running, via the Windows `tasklist`. Best effort — on failure the set is
|
||||
// empty (we then just attempt to launch, which is acceptable).
|
||||
func runningProcessNames() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
cmd := exec.Command("tasklist", "/FO", "CSV", "/NH")
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
|
||||
data, err := cmd.Output()
|
||||
if err != nil {
|
||||
applog.Printf("autostart: tasklist failed: %v", err)
|
||||
return out
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// CSV row: "image.exe","PID",... — take the first quoted field.
|
||||
field := line
|
||||
if i := strings.Index(line[1:], "\""); i >= 0 && strings.HasPrefix(line, "\"") {
|
||||
field = line[1 : i+1]
|
||||
}
|
||||
field = strings.Trim(field, "\"")
|
||||
if field != "" {
|
||||
out[strings.ToLower(field)] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
+27
-4
@@ -93,14 +93,27 @@ func keepKnownBands(want []string) []string {
|
||||
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
|
||||
// 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,
|
||||
// and past 1000 km the reports stop being about the operator's own path — which
|
||||
// is the entire premise of measuring from a receiver rather than a transmitter.
|
||||
// and past 3000 km a "nearby" receiver is on the far side of a continent, which
|
||||
// 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 {
|
||||
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 n
|
||||
@@ -194,7 +207,17 @@ func (a *App) startBandOpenFeed() {
|
||||
// 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,
|
||||
// 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)
|
||||
if chaseGrids {
|
||||
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
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 no writable folder can be found at all.
|
||||
func bootLogPath() string {
|
||||
dir := bootLogDir()
|
||||
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 {
|
||||
return filepath.Join(bootLogDir(), "OpsLog", ".launching")
|
||||
}
|
||||
|
||||
// bootLogDir is where the breadcrumbs live: %LOCALAPPDATA% on Windows, and on
|
||||
// Linux the XDG cache directory (~/.cache) that os.UserCacheDir resolves to.
|
||||
//
|
||||
// The temp directory is the last resort and not the first, because it is the
|
||||
// one place the evidence does not survive: a station that reboots after a
|
||||
// failed launch loses exactly the log that would have explained it.
|
||||
func bootLogDir() string {
|
||||
if dir := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); dir != "" {
|
||||
return dir
|
||||
}
|
||||
if dir, err := os.UserCacheDir(); err == nil && strings.TrimSpace(dir) != "" {
|
||||
return dir
|
||||
}
|
||||
return os.TempDir()
|
||||
}
|
||||
|
||||
// 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]
|
||||
// Handled before the column map: freq takes a numeric path (freq_hz +
|
||||
// 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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+1048
File diff suppressed because it is too large
Load Diff
@@ -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,182 @@
|
||||
// Command satdiag answers "is this pass real, and is that Doppler right?" from
|
||||
// a station's own cached elements, without launching OpsLog.
|
||||
//
|
||||
// go run ./cmd/satdiag <data dir> <locator> <satellite>
|
||||
//
|
||||
// It prints which element set the satellite resolved to and how old it is, the
|
||||
// look angle now, the range rate BOTH as the propagator reports it and as the
|
||||
// range actually changes, the Doppler each transponder would be given, and the
|
||||
// next passes. It exists because a wrong Doppler and a wrong satellite look the
|
||||
// same from the front — an operator saying "the frequency moves enormously" —
|
||||
// and the two are told apart by these numbers in a second.
|
||||
//
|
||||
// It found the range rate the SGP4 library reports being wrong by a factor of
|
||||
// 250 and of the wrong sign. Not part of the build.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/sat"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dir := os.Args[1]
|
||||
grid := os.Args[2]
|
||||
name := os.Args[3]
|
||||
|
||||
f := sat.NewFetcher(dir)
|
||||
els, at, err := f.LoadCache()
|
||||
if err != nil {
|
||||
fmt.Println("cache:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
store := sat.NewStore()
|
||||
store.Replace(els, at)
|
||||
fmt.Printf("elements: %d, fetched %s (%s ago)\n\n", len(els), at.Format(time.RFC3339), time.Since(at).Round(time.Minute))
|
||||
|
||||
birds, err := sat.LoadBirds(dir)
|
||||
if err != nil {
|
||||
fmt.Println("birds:", err)
|
||||
}
|
||||
b, ok := birds.Find(name)
|
||||
if !ok {
|
||||
fmt.Println("no frequency plan for", name)
|
||||
os.Exit(1)
|
||||
}
|
||||
// The same resolution the app does.
|
||||
var el sat.Element
|
||||
found := false
|
||||
if e, ok := store.GetNORAD(b.NORAD); ok {
|
||||
el, found = e, true
|
||||
fmt.Printf("elements found BY NORAD %d → %q\n", b.NORAD, e.Name)
|
||||
} else if e, ok := store.Get(b.Name); ok {
|
||||
el, found = e, true
|
||||
fmt.Printf("elements found by name → %q (NORAD %d)\n", e.Name, e.NORAD)
|
||||
} else {
|
||||
for _, a := range b.Aliases {
|
||||
if e, ok := store.Get(a); ok {
|
||||
el, found = e, true
|
||||
fmt.Printf("elements found by alias %q → %q (NORAD %d)\n", a, e.Name, e.NORAD)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
// Last resort, exactly as satElement does: scan every element name and
|
||||
// compare on letters and digits alone. This is how "JAS-2 (FO-29)" and
|
||||
// "FO-29" meet, and leaving it out of the diagnostic made a satellite
|
||||
// that resolves perfectly well in the app look unresolvable here.
|
||||
for _, n := range store.Names() {
|
||||
if b.Matches(n) {
|
||||
if e, ok := store.Get(n); ok {
|
||||
el, found = e, true
|
||||
fmt.Printf("elements found by SCAN → %q (NORAD %d)\n", e.Name, e.NORAD)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
fmt.Println("NO ELEMENTS")
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("epoch: %s (%s old)\n", el.Epoch.Format(time.RFC3339), time.Since(el.Epoch).Round(time.Hour))
|
||||
fmt.Println("line1:", el.Line1)
|
||||
|
||||
lat, lon, okGrid := gridToLatLon(grid)
|
||||
if !okGrid {
|
||||
fmt.Println("bad locator:", grid)
|
||||
os.Exit(1)
|
||||
}
|
||||
obs := sat.Observer{Lat: lat, Lon: lon}
|
||||
fmt.Printf("observer: %s → %.4f, %.4f\n\n", grid, obs.Lat, obs.Lon)
|
||||
|
||||
now := time.Now().UTC()
|
||||
p, err := el.Track(obs, now)
|
||||
if err != nil {
|
||||
fmt.Println("track:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("NOW %s : az %.1f el %.1f range %.0f km\n", now.Format("15:04:05"), p.Az, p.El, p.RangeKm)
|
||||
fmt.Printf(" range rate REPORTED by the library : %+10.3f km/s\n", p.RangeRate)
|
||||
fmt.Printf(" range rate MEASURED (d range / dt) : %+10.3f km/s\n", numericRate(el, obs, now))
|
||||
|
||||
for _, tp := range b.Transponders {
|
||||
sh := sat.Doppler(p, tp.DownLo, tp.UpLo)
|
||||
fmt.Printf(" %-28s down %d → %d (%+d Hz) up %d → %d (%+d Hz)\n",
|
||||
tp.Label, tp.DownLo, sh.DownHz, sh.DownHz-tp.DownLo, tp.UpLo, sh.UpHz, sh.UpHz-tp.UpLo)
|
||||
}
|
||||
|
||||
fmt.Println("\nnext passes (min el 0):")
|
||||
passes, err := store.Passes(el.Name, obs, now, now.Add(12*time.Hour), 0)
|
||||
if err != nil {
|
||||
fmt.Println("passes:", err)
|
||||
}
|
||||
for i, ps := range passes {
|
||||
if i >= 8 {
|
||||
break
|
||||
}
|
||||
fmt.Printf(" %s → %s max %.1f° az %.0f→%.0f\n",
|
||||
ps.AOS.Format("15:04:05"), ps.LOS.Format("15:04:05"), ps.MaxEl, ps.AOSAz, ps.LOSAz)
|
||||
}
|
||||
|
||||
// The extremes of the Doppler across the next pass, which is the honest
|
||||
// answer to "does it move that much".
|
||||
if len(passes) > 0 {
|
||||
ps := passes[0]
|
||||
var lo, hi int64
|
||||
for tt := ps.AOS; tt.Before(ps.LOS); tt = tt.Add(10 * time.Second) {
|
||||
q, err := el.Track(obs, tt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
d := sat.Doppler(q, b.Transponders[0].DownLo, 0).DownHz - b.Transponders[0].DownLo
|
||||
if d < lo {
|
||||
lo = d
|
||||
}
|
||||
if d > hi {
|
||||
hi = d
|
||||
}
|
||||
}
|
||||
fmt.Printf("\ndownlink Doppler across that pass: %+d Hz … %+d Hz (span %d Hz)\n", lo, hi, hi-lo)
|
||||
}
|
||||
}
|
||||
|
||||
// gridToLatLon is the six-character Maidenhead centre.
|
||||
func gridToLatLon(g string) (float64, float64, bool) {
|
||||
g = strings.ToUpper(strings.TrimSpace(g))
|
||||
if len(g) < 4 {
|
||||
return 0, 0, false
|
||||
}
|
||||
lon := float64(g[0]-'A')*20 - 180
|
||||
lat := float64(g[1]-'A')*10 - 90
|
||||
lon += float64(g[2]-'0') * 2
|
||||
lat += float64(g[3]-'0') * 1
|
||||
if len(g) >= 6 {
|
||||
lon += float64(g[4]-'A') * (2.0 / 24)
|
||||
lat += float64(g[5]-'A') * (1.0 / 24)
|
||||
lon += (2.0 / 24) / 2
|
||||
lat += (1.0 / 24) / 2
|
||||
} else {
|
||||
lon += 1
|
||||
lat += 0.5
|
||||
}
|
||||
return lat, lon, true
|
||||
}
|
||||
|
||||
// numericRate is the range rate measured rather than reported: the distance a
|
||||
// second later minus the distance a second earlier, over two seconds. It cannot
|
||||
// disagree with physics, so it is the reference the library's own figure is
|
||||
// checked against.
|
||||
func numericRate(el sat.Element, obs sat.Observer, at time.Time) float64 {
|
||||
a, e1 := el.Track(obs, at.Add(-time.Second))
|
||||
b, e2 := el.Track(obs, at.Add(time.Second))
|
||||
if e1 != nil || e2 != nil {
|
||||
return 0
|
||||
}
|
||||
return (b.RangeKm - a.RangeKm) / 2
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
// Command satgen refreshes internal/sat/birds.json from the public databases.
|
||||
//
|
||||
// A one-shot generator, run by hand, NOT part of the build — the same
|
||||
// arrangement as cmd/cntygen. Satellites are switched between modes and new
|
||||
// ones fly, and the shipped frequency plan should be re-cut every few releases
|
||||
// rather than typed from memory.
|
||||
//
|
||||
// go run ./cmd/satgen
|
||||
//
|
||||
// It reads three sources and joins them on the NORAD catalog number:
|
||||
//
|
||||
// - Celestrak's amateur group and PE0SAT's mirror, for WHICH satellites
|
||||
// OpsLog can get elements for. There is no point shipping a frequency plan
|
||||
// for a bird whose TLE never arrives.
|
||||
// - SatNOGS DB, for the transmitters. It is the maintained, machine-readable
|
||||
// transponder database; AMSAT's chart is authoritative but is a web page.
|
||||
//
|
||||
// It NEVER destroys a curated entry. The hand-written plans carry things
|
||||
// SatNOGS does not reliably hold — a CTCSS tone, a readable label, the QO-100
|
||||
// passband as operators actually describe it — so an existing bird is kept
|
||||
// verbatim and only has its NORAD number filled in. New satellites are appended.
|
||||
// Read the diff before committing it: this is a starting point for an operator,
|
||||
// and a wrong uplink is worse than a missing one.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/sat"
|
||||
)
|
||||
|
||||
const (
|
||||
birdsPath = "internal/sat/birds.json"
|
||||
satnogsTX = "https://db.satnogs.org/api/transmitters/?format=json"
|
||||
satnogsSats = "https://db.satnogs.org/api/satellites/?format=json"
|
||||
)
|
||||
|
||||
// satellite is the subset of a SatNOGS satellite record we use. Its whole
|
||||
// purpose is the decay date: a frequency plan for a spacecraft that burned up
|
||||
// two years ago is a row in the operator's list that will never do anything.
|
||||
type satellite struct {
|
||||
NORAD int `json:"norad_cat_id"`
|
||||
Name string `json:"name"`
|
||||
Names string `json:"names"` // other designations, comma or newline separated
|
||||
Status string `json:"status"`
|
||||
Decayed string `json:"decayed"`
|
||||
}
|
||||
|
||||
// tleFeeds are the element sources OpsLog itself reads (see internal/sat/tle.go).
|
||||
var tleFeeds = []string{
|
||||
"https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=tle",
|
||||
"http://tle.pe0sat.nl/kepler/amateur.txt",
|
||||
}
|
||||
|
||||
// transmitter is the subset of a SatNOGS DB record we use.
|
||||
type transmitter struct {
|
||||
Description string `json:"description"`
|
||||
Alive bool `json:"alive"`
|
||||
Type string `json:"type"` // Transmitter | Transponder | Transceiver
|
||||
UplinkLow int64 `json:"uplink_low"`
|
||||
UplinkHigh int64 `json:"uplink_high"`
|
||||
DownlinkLow int64 `json:"downlink_low"`
|
||||
DownlinkHigh int64 `json:"downlink_high"`
|
||||
Mode string `json:"mode"`
|
||||
Invert bool `json:"invert"`
|
||||
NORAD int `json:"norad_cat_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
feed, err := loadFeeds()
|
||||
if err != nil {
|
||||
die(err)
|
||||
}
|
||||
fmt.Printf("elements: %d satellites across %d feeds\n", len(feed), len(tleFeeds))
|
||||
|
||||
txs, err := loadTransmitters()
|
||||
if err != nil {
|
||||
die(err)
|
||||
}
|
||||
fmt.Printf("satnogs: %d transmitters\n", len(txs))
|
||||
|
||||
cat, err := loadSatellites()
|
||||
if err != nil {
|
||||
die(err)
|
||||
}
|
||||
fmt.Printf("satnogs: %d catalogued satellites\n", len(cat))
|
||||
|
||||
birds, err := loadBirds()
|
||||
if err != nil {
|
||||
die(err)
|
||||
}
|
||||
fmt.Printf("existing plan: %d satellites\n", len(birds))
|
||||
|
||||
// 0. Drop what has come down. SatNOGS carries the re-entry date, so this is
|
||||
// a documented fact rather than a judgement about which of the missing
|
||||
// satellites are missing for good — the first-generation Tevel
|
||||
// constellation alone had left eight rows that could never do anything.
|
||||
kept := birds[:0]
|
||||
for _, b := range birds {
|
||||
if s, ok := decayed(b, cat); ok {
|
||||
fmt.Printf(" - %s re-entered %s — removed\n", b.Name, strings.TrimSuffix(s.Decayed, "T00:00:00Z"))
|
||||
continue
|
||||
}
|
||||
kept = append(kept, b)
|
||||
}
|
||||
birds = kept
|
||||
|
||||
// 1. Give every curated entry its catalog number, so the join stops
|
||||
// depending on how three different parties spell the same satellite.
|
||||
covered := map[int]bool{}
|
||||
for i := range birds {
|
||||
if birds[i].NORAD == 0 {
|
||||
if n, ok := noradFor(birds[i], feed); ok {
|
||||
birds[i].NORAD = n
|
||||
fmt.Printf(" + NORAD %5d for %s\n", n, birds[i].Name)
|
||||
} else {
|
||||
fmt.Printf(" ! no elements found for %s — left without a catalog number\n", birds[i].Name)
|
||||
}
|
||||
}
|
||||
if birds[i].NORAD != 0 {
|
||||
covered[birds[i].NORAD] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Append the satellites we can track and have a usable uplink for.
|
||||
byNORAD := map[int][]transmitter{}
|
||||
for _, t := range txs {
|
||||
if !usable(t) || feed[t.NORAD] == "" || covered[t.NORAD] {
|
||||
continue
|
||||
}
|
||||
byNORAD[t.NORAD] = append(byNORAD[t.NORAD], t)
|
||||
}
|
||||
// A satellite whose only WORKABLE path is one SatNOGS calls inactive.
|
||||
//
|
||||
// LilacSat-2 is the case that taught this: its FM transponder (144.350 up,
|
||||
// 437.200 down) is marked inactive because it runs on an announced schedule
|
||||
// rather than continuously, so the generator dropped it and shipped the
|
||||
// satellite with nothing but an APRS digipeater. An operator comparing
|
||||
// against any other tracker then finds a frequency plan missing the only
|
||||
// thing anybody works that bird on.
|
||||
//
|
||||
// Not fixed automatically: "inactive" is right far more often than it is
|
||||
// wrong, and reviving every dead transponder would fill the list with
|
||||
// satellites that answer nothing. It is REPORTED, so the next regeneration
|
||||
// is read with this in front of it and the handful worth curating are
|
||||
// curated — a scheduled transponder belongs in the plan with "(scheduled)"
|
||||
// in its label, the way PO-101 and now LO-90 carry it.
|
||||
reportRefused(txs, feed, covered, byNORAD)
|
||||
|
||||
added := 0
|
||||
for n, list := range byNORAD {
|
||||
b := sat.Bird{Name: displayName(feed[n]), NORAD: n}
|
||||
if alias := strings.TrimSpace(feed[n]); alias != "" && alias != b.Name {
|
||||
b.Aliases = []string{alias}
|
||||
}
|
||||
for _, t := range list {
|
||||
b.Transponders = append(b.Transponders, toTransponder(t))
|
||||
}
|
||||
sort.Slice(b.Transponders, func(i, j int) bool {
|
||||
return b.Transponders[i].DownLo < b.Transponders[j].DownLo
|
||||
})
|
||||
birds = append(birds, b)
|
||||
added++
|
||||
fmt.Printf(" NEW %5d %-24s %d transponder(s)\n", n, b.Name, len(b.Transponders))
|
||||
}
|
||||
|
||||
sort.SliceStable(birds, func(i, j int) bool { return birds[i].Name < birds[j].Name })
|
||||
out, err := json.MarshalIndent(birds, "", " ")
|
||||
if err != nil {
|
||||
die(err)
|
||||
}
|
||||
if err := os.WriteFile(birdsPath, append(out, '\n'), 0o644); err != nil {
|
||||
die(err)
|
||||
}
|
||||
fmt.Printf("\nwrote %s — %d satellites (%d new)\n", birdsPath, len(birds), added)
|
||||
}
|
||||
|
||||
// usable decides whether a SatNOGS transmitter is something an operator can
|
||||
// work through.
|
||||
//
|
||||
// The database holds every emission a satellite makes, and most of them are not
|
||||
// a contact: a telemetry beacon with a command uplink is listed exactly like an
|
||||
// FM repeater, and shipping the command channel as a transponder would invite
|
||||
// somebody to transmit on it. So both ends must exist, and anything that
|
||||
// describes itself as telemetry or control is refused unless it also calls
|
||||
// itself a repeater, a transponder or a digipeater.
|
||||
func usable(t transmitter) bool {
|
||||
if !t.Alive || t.Status != "active" {
|
||||
return false
|
||||
}
|
||||
if t.UplinkLow <= 0 || t.DownlinkLow <= 0 {
|
||||
return false
|
||||
}
|
||||
d := strings.ToLower(t.Description)
|
||||
isWorkable := strings.Contains(d, "repeater") || strings.Contains(d, "transponder") ||
|
||||
strings.Contains(d, "digipeater") || strings.Contains(d, "aprs") ||
|
||||
strings.Contains(d, "voice") || strings.Contains(d, "sstv") || strings.Contains(d, "dstar")
|
||||
if isWorkable {
|
||||
return true
|
||||
}
|
||||
for _, bad := range []string{"telemetry", "command", "control", "dtmf", "beacon", "tlm"} {
|
||||
if strings.Contains(d, bad) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// An ANALOG emission with both ends is a contact by construction: nobody
|
||||
// puts an FM or SSB uplink on a satellite for housekeeping. This is what
|
||||
// catches the plainly-described repeaters — AO-27 says only "Mode V/U FM",
|
||||
// and rejecting it for not using the word "repeater" would have dropped one
|
||||
// of the best-known FM birds there is.
|
||||
if m := adifMode(t.Mode); m == "FM" || m == "SSB" || m == "CW" {
|
||||
return true
|
||||
}
|
||||
// A digital emission has to say what it is. A GMSK uplink is a command
|
||||
// channel far more often than it is a digipeater, and shipping the wrong one
|
||||
// invites an operator to transmit on a control frequency.
|
||||
return t.Type == "Transponder" || (t.UplinkHigh > t.UplinkLow && t.DownlinkHigh > t.DownlinkLow)
|
||||
}
|
||||
|
||||
// ctcssRe pulls a tone out of prose. SatNOGS has no field for it, and it is not
|
||||
// optional: an FM uplink without the right tone opens nothing at all.
|
||||
var ctcssRe = regexp.MustCompile(`(?i)(?:ctcss|pl)[^0-9]{0,4}(\d{2,3}(?:\.\d)?)|(\d{2,3}(?:\.\d)?)\s*(?:hz)?\s*(?:ctcss|pl)\b`)
|
||||
|
||||
func toTransponder(t transmitter) sat.Transponder {
|
||||
tp := sat.Transponder{
|
||||
Label: cleanLabel(t.Description),
|
||||
Mode: adifMode(t.Mode),
|
||||
DownLo: t.DownlinkLow,
|
||||
DownHi: t.DownlinkHigh,
|
||||
UpLo: t.UplinkLow,
|
||||
UpHi: t.UplinkHigh,
|
||||
Inverting: t.Invert,
|
||||
}
|
||||
// A "high" equal to the "low" is SatNOGS saying "a channel", not a one-hertz
|
||||
// passband; Transponder.Linear() must not be fooled into interpolating.
|
||||
if tp.DownHi <= tp.DownLo {
|
||||
tp.DownHi = 0
|
||||
}
|
||||
if tp.UpHi <= tp.UpLo {
|
||||
tp.UpHi = 0
|
||||
}
|
||||
// Inversion is a property of a PASSBAND. SatNOGS sets the flag on some FM
|
||||
// channels too, where it means nothing — the code ignores it there, but a
|
||||
// data file that says an FM repeater inverts is a data file that will
|
||||
// mislead the next person to read it.
|
||||
if tp.DownHi == 0 || tp.UpHi == 0 {
|
||||
tp.Inverting = false
|
||||
}
|
||||
if m := ctcssRe.FindStringSubmatch(t.Description); m != nil {
|
||||
v := m[1]
|
||||
if v == "" {
|
||||
v = m[2]
|
||||
}
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil && f >= 60 && f <= 260 {
|
||||
tp.CTCSS = f
|
||||
}
|
||||
}
|
||||
return tp
|
||||
}
|
||||
|
||||
// adifMode maps SatNOGS' modulation names onto the four modes a log knows.
|
||||
func adifMode(m string) string {
|
||||
switch u := strings.ToUpper(strings.TrimSpace(m)); {
|
||||
case strings.HasPrefix(u, "FM"), u == "SSTV", u == "DSTAR", u == "NFM":
|
||||
return "FM"
|
||||
case u == "USB", u == "LSB", u == "SSB":
|
||||
return "SSB"
|
||||
case u == "CW":
|
||||
return "CW"
|
||||
default:
|
||||
return "DATA"
|
||||
}
|
||||
}
|
||||
|
||||
func cleanLabel(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "Transponder"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// displayName prefers the OSCAR designation an operator says out loud.
|
||||
// "SAUDISAT 1C (SO-50)" is SO-50 to everybody except a catalog.
|
||||
func displayName(feedName string) string {
|
||||
s := strings.TrimSpace(feedName)
|
||||
if i := strings.IndexByte(s, '('); i > 0 && strings.HasSuffix(s, ")") {
|
||||
inner := strings.TrimSpace(s[i+1 : len(s)-1])
|
||||
if oscarRe.MatchString(inner) {
|
||||
return inner
|
||||
}
|
||||
}
|
||||
// "RS-44 & BREEZE-KM R/B" — the rocket body it flies with is not its name.
|
||||
if i := strings.Index(s, " & "); i > 0 {
|
||||
return strings.TrimSpace(s[:i])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
var oscarRe = regexp.MustCompile(`^[A-Z]{1,3}-\d{1,3}$`)
|
||||
|
||||
// noradFor finds a curated entry's catalog number by the name matching the
|
||||
// package already does.
|
||||
//
|
||||
// Deterministic on purpose. One satellite can hold TWO catalog entries — a
|
||||
// deployment catalogued before the objects were told apart, GreenCube being
|
||||
// 53106 and 53109 in the two feeds — and iterating the map picked a different
|
||||
// one each run, so the generated file changed for no reason and the diff was
|
||||
// unreadable. Candidates are therefore scored and tied on the lower number:
|
||||
// a feed name whose designation IS the bird's name ("GREENCUBE (IO-117)" for
|
||||
// IO-117) beats one that only matches through an alias.
|
||||
func noradFor(b sat.Bird, feed map[int]string) (int, bool) {
|
||||
nums := make([]int, 0, len(feed))
|
||||
for n := range feed {
|
||||
nums = append(nums, n)
|
||||
}
|
||||
sort.Ints(nums)
|
||||
|
||||
best, bestScore := 0, -1
|
||||
for _, n := range nums {
|
||||
name := feed[n]
|
||||
if !b.Matches(name) {
|
||||
continue
|
||||
}
|
||||
score := 0
|
||||
if strings.EqualFold(displayName(name), b.Name) {
|
||||
score = 2
|
||||
} else if strings.EqualFold(strings.TrimSpace(name), b.Name) {
|
||||
score = 1
|
||||
}
|
||||
if score > bestScore {
|
||||
best, bestScore = n, score
|
||||
}
|
||||
}
|
||||
return best, best != 0
|
||||
}
|
||||
|
||||
func loadFeeds() (map[int]string, error) {
|
||||
out := map[int]string{}
|
||||
for _, url := range tleFeeds {
|
||||
body, err := get(url)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: %v\n", url, err)
|
||||
continue
|
||||
}
|
||||
lines := []string{}
|
||||
for _, l := range strings.Split(string(body), "\n") {
|
||||
if s := strings.TrimSpace(l); s != "" {
|
||||
lines = append(lines, s)
|
||||
}
|
||||
}
|
||||
for i := 0; i+2 < len(lines); i += 3 {
|
||||
if !strings.HasPrefix(lines[i+1], "1 ") || len(lines[i+1]) < 7 {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(lines[i+1][2:7]))
|
||||
if err != nil || n <= 0 {
|
||||
continue
|
||||
}
|
||||
// First feed wins: Celestrak's spelling is the one the operator sees.
|
||||
if _, had := out[n]; !had {
|
||||
out[n] = lines[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("no elements from any feed")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// decayed reports whether this bird's spacecraft has re-entered, matching on
|
||||
// the catalog number when we have one and on the designations SatNOGS lists
|
||||
// otherwise — "NAYIF-1" carries "EO-88" only in its alternative names.
|
||||
func decayed(b sat.Bird, cat []satellite) (satellite, bool) {
|
||||
for _, s := range cat {
|
||||
if s.Status != "re-entered" && s.Decayed == "" {
|
||||
continue
|
||||
}
|
||||
if b.NORAD != 0 {
|
||||
if s.NORAD == b.NORAD {
|
||||
return s, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
names := append(strings.FieldsFunc(s.Names, func(r rune) bool { return r == ',' || r == '\n' }), s.Name)
|
||||
for _, n := range names {
|
||||
if strings.TrimSpace(n) == "" {
|
||||
continue
|
||||
}
|
||||
if b.Matches(strings.TrimSpace(n)) {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return satellite{}, false
|
||||
}
|
||||
|
||||
func loadSatellites() ([]satellite, error) {
|
||||
body, err := get(satnogsSats)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []satellite
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return nil, fmt.Errorf("satnogs satellites: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func loadTransmitters() ([]transmitter, error) {
|
||||
body, err := get(satnogsTX)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []transmitter
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return nil, fmt.Errorf("satnogs: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func loadBirds() ([]sat.Bird, error) {
|
||||
b, err := os.ReadFile(birdsPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []sat.Bird
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", birdsPath, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func get(url string) ([]byte, error) {
|
||||
c := &http.Client{Timeout: 90 * time.Second}
|
||||
resp, err := c.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s: %s", url, resp.Status)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||
}
|
||||
|
||||
func die(err error) {
|
||||
fmt.Fprintln(os.Stderr, "satgen:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// reportRefused names the satellites whose only two-way path was refused for
|
||||
// being inactive, so the operator running this can decide about each one.
|
||||
//
|
||||
// The output is deliberately a question and not a change: SatNOGS calling a
|
||||
// transponder inactive is usually correct, and the exceptions are the birds
|
||||
// whose transponder is switched on to a schedule rather than left running.
|
||||
func reportRefused(txs []transmitter, feed map[int]string, covered map[int]bool, kept map[int][]transmitter) {
|
||||
var lines []string
|
||||
for _, t := range txs {
|
||||
if t.Status == "active" || t.UplinkLow <= 0 || t.DownlinkLow <= 0 {
|
||||
continue
|
||||
}
|
||||
if feed[t.NORAD] == "" || covered[t.NORAD] || len(kept[t.NORAD]) > 0 {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" ? %5d %-22s %-4s %.3f up / %.3f down %q",
|
||||
t.NORAD, displayName(feed[t.NORAD]), adifMode(t.Mode),
|
||||
float64(t.UplinkLow)/1e6, float64(t.DownlinkLow)/1e6, t.Description))
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
sort.Strings(lines)
|
||||
fmt.Println("\nRefused as inactive, and nothing else was kept for these satellites.")
|
||||
fmt.Println("A transponder that runs to a schedule looks exactly like a dead one here:")
|
||||
for _, l := range lines {
|
||||
fmt.Println(l)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// systemInstallDataDir keeps the portable "data beside the binary" layout where
|
||||
// it works, and falls back to the XDG data directory where it cannot.
|
||||
//
|
||||
// Both halves are needed on Linux, and only on Linux. A tarball or AppImage
|
||||
// unpacked into the home directory behaves exactly like the Windows build —
|
||||
// the folder travels with the program, which is the whole point of the design.
|
||||
// But the ordinary way software arrives here is a package that installs into
|
||||
// /usr/bin or /opt, where no user may write, and telling an operator to "move
|
||||
// the program somewhere writable" is telling them their distribution installed
|
||||
// it wrong. So when the folder beside the binary is read-only, OpsLog keeps its
|
||||
// data in ~/.local/share/OpsLog instead and says so in the log.
|
||||
//
|
||||
// Detection is by TRYING, not by matching path prefixes: /opt, /usr/local and a
|
||||
// NFS-mounted home are all writable on some stations and not on others, and the
|
||||
// only honest test is whether the write succeeds.
|
||||
func systemInstallDataDir(besideExe string) (string, bool) {
|
||||
xdgOnce.Do(func() { xdgDir, xdgUsed = resolveDataDir(besideExe) })
|
||||
return xdgDir, xdgUsed
|
||||
}
|
||||
|
||||
var (
|
||||
xdgOnce sync.Once
|
||||
xdgDir string
|
||||
xdgUsed bool
|
||||
)
|
||||
|
||||
func resolveDataDir(besideExe string) (string, bool) {
|
||||
if writable(besideExe) {
|
||||
return "", false
|
||||
}
|
||||
base := os.Getenv("XDG_DATA_HOME")
|
||||
if base == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return "", false // nowhere better to go; let the caller report the failure
|
||||
}
|
||||
base = filepath.Join(home, ".local", "share")
|
||||
}
|
||||
alt := filepath.Join(base, "OpsLog", "data")
|
||||
if !writable(alt) {
|
||||
return "", false
|
||||
}
|
||||
bootLog("data dir: %s is not writable — keeping the data in %s instead", besideExe, alt)
|
||||
return alt, true
|
||||
}
|
||||
|
||||
// writable reports whether dir can be created and written to. The probe file is
|
||||
// removed again; a leftover in the data folder would be one more thing to
|
||||
// explain.
|
||||
func writable(dir string) bool {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return false
|
||||
}
|
||||
probe := filepath.Join(dir, ".writetest")
|
||||
if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil {
|
||||
return false
|
||||
}
|
||||
_ = os.Remove(probe)
|
||||
return true
|
||||
}
|
||||
|
||||
// dataDirAdvice is what the operator is told when neither location works — a
|
||||
// full disk, or a home directory that is not writable either.
|
||||
const dataDirAdvice = "\n\nOpsLog keeps its data next to the program, or in ~/.local/share/OpsLog when that folder belongs to the system. Neither could be written to: check the disk is not full and that your home directory is writable."
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
// systemInstallDataDir never diverts on Windows: the data folder is beside the
|
||||
// executable, full stop. That is what makes an OpsLog on a USB stick carry its
|
||||
// logbook with it, and an operator who copies the folder to a new PC find
|
||||
// everything already there.
|
||||
//
|
||||
// A copy dropped into Program Files is refused the write and told so (see
|
||||
// checkDataDirWritable) rather than quietly logging somewhere else, because
|
||||
// "where are my QSOs?" is a far worse afternoon than "move this folder".
|
||||
func systemInstallDataDir(besideExe string) (string, bool) { return "", false }
|
||||
|
||||
// dataDirAdvice is what the operator is told when that folder cannot be written.
|
||||
const dataDirAdvice = "\n\nMove OpsLog.exe somewhere your account can write — a folder in Documents, or the desktop — and start it again. Program Files is refused to anything not running as administrator."
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// config.json is the only record of WHERE the database is. Losing it moves an
|
||||
// operator's whole station back to an empty default, and it has happened twice.
|
||||
// These are the two ways it was lost.
|
||||
|
||||
// A half-written file must never be publishable. os.WriteFile truncates and
|
||||
// then fills, so a process that stops in between leaves an empty pointer; the
|
||||
// rename cannot.
|
||||
func TestWriteBootstrapIsAtomicAndKeepsABackup(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Absolute and OUTSIDE the application folder, so portablePath stores them
|
||||
// verbatim — the round trip is what is under test, not the re-rooting.
|
||||
first := filepath.Join(t.TempDir(), "first", "one.db")
|
||||
second := filepath.Join(t.TempDir(), "second", "two.db")
|
||||
if err := writeBootstrap(dir, dbPointer{DBPath: first}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeBootstrap(dir, dbPointer{DBPath: second}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// No temporary left lying around to be mistaken for the real thing.
|
||||
if _, err := os.Stat(dbPointerPath(dir) + ".tmp"); err == nil {
|
||||
t.Error("the temporary file was left behind")
|
||||
}
|
||||
// The previous contents are still there.
|
||||
var prev dbPointer
|
||||
b, err := os.ReadFile(dbPointerPath(dir) + ".bak")
|
||||
if err != nil {
|
||||
t.Fatalf("no backup was kept: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(b, &prev); err != nil {
|
||||
t.Fatalf("the backup does not parse: %v", err)
|
||||
}
|
||||
if prev.DBPath != first {
|
||||
t.Errorf("the backup holds %q, want the previous pointer", prev.DBPath)
|
||||
}
|
||||
if got := readBootstrap(dir); got.DBPath != second {
|
||||
t.Errorf("read back %q, want the current pointer", got.DBPath)
|
||||
}
|
||||
}
|
||||
|
||||
// A pointer that EXISTS and cannot be read is not the same thing as no pointer.
|
||||
// Treating it as one is what opened an empty database and presented an operator
|
||||
// with a program that had forgotten them.
|
||||
func TestReadBootstrapRecoversFromABrokenPointer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mine := filepath.Join(t.TempDir(), "mine", "station.db")
|
||||
if err := writeBootstrap(dir, dbPointer{DBPath: mine}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Two writes, so there is a backup of the good one to fall back to.
|
||||
if err := writeBootstrap(dir, dbPointer{DBPath: mine}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Now truncate it, exactly as an interrupted write would.
|
||||
if err := os.WriteFile(dbPointerPath(dir), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := readBootstrap(dir)
|
||||
if got.DBPath != mine {
|
||||
t.Fatalf("read %q — the database the operator chose was lost", got.DBPath)
|
||||
}
|
||||
// And it is put back, so the next launch does not have to recover again.
|
||||
if again := readBootstrap(dir); again.DBPath != mine {
|
||||
t.Errorf("the restored pointer did not stick: %q", again.DBPath)
|
||||
}
|
||||
}
|
||||
|
||||
// With nothing to restore from, the broken file is KEPT. It is evidence, and it
|
||||
// may still be readable by hand.
|
||||
func TestReadBootstrapKeepsAnUnrecoverablePointer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(dbPointerPath(dir), []byte("{oops"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readBootstrap(dir); got.DBPath != "" {
|
||||
t.Errorf("invented a path out of a broken file: %q", got.DBPath)
|
||||
}
|
||||
if _, err := os.Stat(dbPointerPath(dir) + ".broken"); err != nil {
|
||||
t.Errorf("the broken pointer was not kept: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The warning that turns a silent loss into a sentence: a new empty database
|
||||
// about to be created in a folder that already holds a full one.
|
||||
func TestOtherDatabasesInSpotsTheFullOneNextDoor(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
full := filepath.Join(dir, "opslog.db")
|
||||
if err := os.WriteFile(full, []byte("not empty"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chosen := filepath.Join(dir, "settings.db")
|
||||
if got := otherDatabasesIn(dir, chosen); len(got) != 1 || got[0] != "opslog.db" {
|
||||
t.Errorf("got %v, want the full database next door", got)
|
||||
}
|
||||
// A zero-byte file is not a lost configuration.
|
||||
if err := os.WriteFile(full, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := otherDatabasesIn(dir, chosen); len(got) != 0 {
|
||||
t.Errorf("an empty file was reported as a database: %v", got)
|
||||
}
|
||||
// And the one being opened is never reported against itself.
|
||||
if err := os.WriteFile(chosen, []byte("in use"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := otherDatabasesIn(dir, chosen); len(got) != 0 {
|
||||
t.Errorf("the chosen database was reported as another: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
package main
|
||||
|
||||
// Chasing a split pile-up by the report the DX just sent.
|
||||
//
|
||||
// Working a DXpedition in split means guessing where it is listening. The DX
|
||||
// answers one station, sends "5NN", and moves on; the useful information is
|
||||
// therefore not the callsign it answered but the FREQUENCY that callsign was
|
||||
// transmitting on, because the DX's receiver was there a second ago.
|
||||
//
|
||||
// A CW skimmer already knows this. SDC (Software Defined Connector) decodes the
|
||||
// whole pile-up and marks each report on the panadapter as a spot — the marker
|
||||
// TEXT is configured in SDC by the operator ("599" for a fresh report, "X" for
|
||||
// an older one, by default). Those spots reach OpsLog already: it subscribes to
|
||||
// the radio's spot feed, and every spot another program posts arrives on
|
||||
// Flex.OnForeignSpot.
|
||||
//
|
||||
// So the whole feature is: recognise the marker, move the TRANSMIT slice there,
|
||||
// leave the receive slice on the DX. Nothing here decodes anything.
|
||||
//
|
||||
// Three deliberate choices:
|
||||
//
|
||||
// - the marker text is a SETTING, not a constant. It is chosen in SDC, and
|
||||
// any guess made here would be wrong for the operator who chose otherwise.
|
||||
// - only the transmit slice moves, never the receive slice. Losing the DX is
|
||||
// a worse outcome than a missed call.
|
||||
// - the offset is signed and in Hz, because working "up a bit" from where the
|
||||
// last station was answered is exactly how a pile-up is chased.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/cat"
|
||||
)
|
||||
|
||||
const keyFlexRSTChase = "flex.rst_chase"
|
||||
|
||||
// FlexRSTChase is the whole configuration.
|
||||
type FlexRSTChase struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
// Markers are the skimmer's marker texts, comma-separated ("599,5NN").
|
||||
// Matched against the spot's callsign field, case-insensitively and whole:
|
||||
// a spot IS the marker or it is an ordinary callsign, and a substring rule
|
||||
// would drag in any station whose call happens to contain the digits.
|
||||
Markers string `json:"markers"`
|
||||
// OffsetHz is added to the marker's frequency. Signed: chasing upward from
|
||||
// the last station worked is the usual tactic, downward happens too.
|
||||
OffsetHz int `json:"offset_hz"`
|
||||
// SplitOnly refuses to act when the radio is not in split. On by default:
|
||||
// out of split the transmit slice IS the receive slice, so "move the TX
|
||||
// slice" would take the operator off the DX they are listening to.
|
||||
SplitOnly bool `json:"split_only"`
|
||||
}
|
||||
|
||||
var defaultFlexRSTChase = FlexRSTChase{Enabled: false, Markers: "599", OffsetHz: 0, SplitOnly: true}
|
||||
|
||||
// rstChaseMinGap throttles the moves. A skimmer marks every report it decodes,
|
||||
// and a busy pile-up produces several a second; without a floor the transmit
|
||||
// slice would twitch continuously and never be anywhere long enough to call.
|
||||
const rstChaseMinGap = 700 * time.Millisecond
|
||||
|
||||
// rstChaseMinStep ignores a marker that lands where the slice already is.
|
||||
// Re-sending the same frequency is not free: it is a command to the radio and a
|
||||
// slice status back, several times a second, for no change at all.
|
||||
const rstChaseMinStep = 20 // Hz
|
||||
|
||||
var (
|
||||
rstChaseMu sync.Mutex
|
||||
rstChaseLast time.Time
|
||||
rstChaseFreq int64
|
||||
)
|
||||
|
||||
// GetFlexRSTChase returns the stored configuration (defaults when unset).
|
||||
func (a *App) GetFlexRSTChase() FlexRSTChase {
|
||||
s := defaultFlexRSTChase
|
||||
if a.settings == nil {
|
||||
return s
|
||||
}
|
||||
// settingOr, NOT settings.Get with profileScope(): the store already applies
|
||||
// the active profile's prefix, so scoping the key here wrote p3.flex.rst_chase
|
||||
// and read p3.p3.flex.rst_chase — the switch could never be read back on, and
|
||||
// the feature did nothing at all with no sign of why.
|
||||
if v := a.settingOr(keyFlexRSTChase, ""); strings.TrimSpace(v) != "" {
|
||||
_ = json.Unmarshal([]byte(v), &s)
|
||||
}
|
||||
return normRSTChase(s)
|
||||
}
|
||||
|
||||
// SaveFlexRSTChase stores the configuration.
|
||||
func (a *App) SaveFlexRSTChase(s FlexRSTChase) error {
|
||||
b, err := json.Marshal(normRSTChase(s))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.setSetting(keyFlexRSTChase, string(b))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetFlexRSTChaseEnabled flips the switch alone.
|
||||
//
|
||||
// Its own binding because this belongs on a button in the panel, not in a
|
||||
// settings page: it is turned on when a DXpedition appears and off when it is
|
||||
// worked, which is a thing done mid-QSO with one hand.
|
||||
func (a *App) SetFlexRSTChaseEnabled(on bool) error {
|
||||
s := a.GetFlexRSTChase()
|
||||
s.Enabled = on
|
||||
return a.SaveFlexRSTChase(s)
|
||||
}
|
||||
|
||||
func normRSTChase(s FlexRSTChase) FlexRSTChase {
|
||||
if strings.TrimSpace(s.Markers) == "" {
|
||||
s.Markers = defaultFlexRSTChase.Markers
|
||||
}
|
||||
// A pile-up is a few kHz wide. Anything past that is a typo (Hz entered as
|
||||
// if it were kHz), and honouring it would transmit far outside the segment.
|
||||
if s.OffsetHz > 10000 {
|
||||
s.OffsetHz = 10000
|
||||
}
|
||||
if s.OffsetHz < -10000 {
|
||||
s.OffsetHz = -10000
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// rstChaseMarkerSet splits the configured markers into a comparison set.
|
||||
func rstChaseMarkerSet(markers string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, m := range strings.FieldsFunc(markers, func(r rune) bool { return r == ',' || r == ';' || r == ' ' }) {
|
||||
if m = strings.ToUpper(strings.TrimSpace(m)); m != "" {
|
||||
out[m] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleForeignSpot is what a skimmer's spot arrives at.
|
||||
func (a *App) handleForeignSpot(callsign string, freqHz int64) {
|
||||
cfg := a.GetFlexRSTChase()
|
||||
if !cfg.Enabled || a.cat == nil {
|
||||
return
|
||||
}
|
||||
if !rstChaseMarkerSet(cfg.Markers)[strings.ToUpper(strings.TrimSpace(callsign))] {
|
||||
return // an ordinary spot: another station's callsign, not a report
|
||||
}
|
||||
// From here on every refusal is logged. A marker WAS recognised, so the
|
||||
// operator is entitled to know why the slice stayed where it was — silence
|
||||
// at this point is indistinguishable from a feature that does not work.
|
||||
if cfg.SplitOnly && !a.cat.State().Split {
|
||||
applog.Printf("rst chase: %s marked at %s, but the radio is not in split — ignored",
|
||||
strings.ToUpper(callsign), hzText(freqHz))
|
||||
return
|
||||
}
|
||||
target := freqHz + int64(cfg.OffsetHz)
|
||||
if target <= 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
rstChaseMu.Lock()
|
||||
if now.Sub(rstChaseLast) < rstChaseMinGap {
|
||||
rstChaseMu.Unlock()
|
||||
return
|
||||
}
|
||||
if rstChaseFreq != 0 && absInt64(target-rstChaseFreq) < rstChaseMinStep {
|
||||
rstChaseMu.Unlock()
|
||||
return
|
||||
}
|
||||
rstChaseLast, rstChaseFreq = now, target
|
||||
rstChaseMu.Unlock()
|
||||
|
||||
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
|
||||
return fc.SetTXSliceFrequency(target)
|
||||
}); err != nil {
|
||||
applog.Printf("rst chase: moving the TX slice to %s failed: %v", hzText(target), err)
|
||||
return
|
||||
}
|
||||
applog.Printf("rst chase: %s marked at %s → TX slice to %s (offset %+d Hz)",
|
||||
strings.ToUpper(callsign), hzText(freqHz), hzText(target), cfg.OffsetHz)
|
||||
}
|
||||
|
||||
// hzText renders a frequency the way an operator reads one on a dial.
|
||||
func hzText(hz int64) string {
|
||||
return strconv.FormatFloat(float64(hz)/1000, 'f', 3, 64) + " kHz"
|
||||
}
|
||||
+1484
-446
File diff suppressed because it is too large
Load Diff
@@ -141,7 +141,7 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogContent overlayBlur={false} className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2"><Bell className="size-4 text-primary" /> {t('altm.title')}</DialogTitle>
|
||||
<DialogDescription>{t('altm.desc')}</DialogDescription>
|
||||
|
||||
@@ -47,7 +47,7 @@ function powerLevelLabel(pl?: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
|
||||
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; kpa?: any; pgxl?: any };
|
||||
|
||||
export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) {
|
||||
// Peak-hold so the jittery VITA-49 meters read steadily (own ref per card).
|
||||
@@ -64,6 +64,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
||||
|
||||
const isSPE = !!amp.spe;
|
||||
const isACOM = !!amp.acom;
|
||||
const isKPA = !!amp.kpa;
|
||||
|
||||
if (isSPE) {
|
||||
const spe = amp.spe;
|
||||
@@ -127,7 +128,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
||||
if (isACOM) {
|
||||
const acom = amp.acom;
|
||||
return (
|
||||
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${amp.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
|
||||
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${amp.name || `Acom${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button type="button" disabled={!acom.connected}
|
||||
onClick={() => AmpOperate(amp.id, !acom.operate).catch(() => {})}
|
||||
@@ -168,6 +169,53 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
||||
);
|
||||
}
|
||||
|
||||
if (isKPA) {
|
||||
const kpa = amp.kpa;
|
||||
return (
|
||||
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${amp.name || kpa.model || 'Elecraft'}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button type="button" disabled={!kpa.connected}
|
||||
onClick={() => AmpOperate(amp.id, !kpa.operate).catch(() => {})}
|
||||
title={kpa.fault_text ? t('flxp.kpaClearsFault') : undefined}
|
||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
kpa.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{kpa.operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
{/* The amplifier answers while its main supplies are off — a sleeping
|
||||
microcontroller stays awake for exactly that — so ON is offered
|
||||
over the network as well, unlike the SPE and Acom. */}
|
||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||
<button type="button" disabled={!kpa.connected}
|
||||
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||
<button type="button" disabled={!kpa.connected}
|
||||
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
||||
</div>
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-sm', kpa.connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||
<span className={cn('size-2 rounded-full', kpa.connected ? 'bg-success' : 'bg-danger')} />
|
||||
{kpa.connected ? (kpa.tuning ? t('flxp.kpaTuning') : (kpa.power_on ? 'ON' : 'OFF')) : t('flxp.kpaOffline')}
|
||||
</span>
|
||||
{kpa.connected && (
|
||||
<span className="text-sm font-mono text-muted-foreground tabular-nums">
|
||||
{kpa.band ? `${kpa.band} · ` : ''}{kpa.fwd_w}W · SWR {Number(kpa.swr ?? 0).toFixed(1)} · {kpa.temp_c}°C · {kpa.volt_v}V {kpa.cur_a}A
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{kpa.fault_text && (
|
||||
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">⚠ {kpa.fault_text}</span>
|
||||
)}
|
||||
</div>
|
||||
{kpa.connected && (
|
||||
<MeterBar label={t('flxp.outputPower')} value={Number(kpa.fwd_w) || 0} unit="W"
|
||||
lo={0} hi={String(kpa.model).includes('500') ? 500 : 1500}
|
||||
display={`${Number(kpa.fwd_w) || 0} W`}
|
||||
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// PowerGenius XL — OPERATE + meters ride on the Flex; fan mode on the GSCP link.
|
||||
const pg = amp.pgxl || {};
|
||||
const viaFlex = !!flex?.amp_available;
|
||||
|
||||
@@ -15,7 +15,7 @@ import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from
|
||||
// With several amplifiers configured the caller passes a selection ("all" or an
|
||||
// amp id) chosen from the toolbar icon's dropdown.
|
||||
|
||||
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
|
||||
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; kpa?: any; pgxl?: any };
|
||||
|
||||
// Full-scale watts for the output bar, from the model string.
|
||||
function maxW(model?: string, fallback = 1300): number {
|
||||
@@ -92,27 +92,39 @@ function OperateButton({ operate, disabled, onClick, t }: {
|
||||
function AmpBlock({ amp, flex, showName, t }: {
|
||||
amp: Amp; flex: any; showName: boolean; t: (k: string, v?: any) => string;
|
||||
}) {
|
||||
const spe = amp.spe, acom = amp.acom;
|
||||
const spe = amp.spe, acom = amp.acom, kpaAmp = amp.kpa;
|
||||
const hold = usePeakHold();
|
||||
|
||||
if (spe || acom) {
|
||||
const s = spe || acom;
|
||||
// One block for the three amplifiers OpsLog drives over its own link. They
|
||||
// report the same handful of things under different names, so the differences
|
||||
// are named here rather than spread through the markup.
|
||||
if (spe || acom || kpaAmp) {
|
||||
const s = spe || acom || kpaAmp;
|
||||
// The amp reports zero watts on receive, so its TX flag clears the meter as
|
||||
// soon as the operator lets go — and the radio's own flag, when we have one,
|
||||
// gets there first (the amp is polled on its own slower cycle).
|
||||
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : s.tx !== false;
|
||||
const w = hold('w', Number(spe ? s.output_w : s.fwd_w) || 0, txing);
|
||||
const swr = Number(spe ? s.swr_ant : s.swr) || 0;
|
||||
const hi = spe ? maxW(s.model) : (Number(s.max_w) || 800);
|
||||
// The full-scale mark. A KPA500 reading against a 1500 W scale would look
|
||||
// idle at full output, so the model decides it.
|
||||
const hi = spe ? maxW(s.model)
|
||||
: kpaAmp ? (String(s.model).includes('500') ? 500 : 1500)
|
||||
: (Number(s.max_w) || 800);
|
||||
// Power ON drives the remote-on control lines, so it stays available while
|
||||
// the amplifier is off and reporting nothing — but only over a serial link.
|
||||
const canPowerOn = spe ? (s.connected || s.transport === 'serial') : (s.port_open && s.transport === 'serial');
|
||||
// A KPA answers ^ON while its main supplies are off — the sleeping
|
||||
// microcontroller stays awake for exactly that — so power-on is available
|
||||
// whenever the link itself is up, over serial and over the network alike.
|
||||
const canPowerOn = spe ? (s.connected || s.transport === 'serial')
|
||||
: kpaAmp ? !!s.connected
|
||||
: (s.port_open && s.transport === 'serial');
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-1.5">
|
||||
{showName && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', s.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || (spe ? 'SPE' : 'ACOM')}</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || (spe ? 'SPE' : kpaAmp ? (s.model || 'KPA') : 'Acom')}</span>
|
||||
{s.connected && s.band && <span className="ml-auto text-[9px] text-muted-foreground shrink-0">{s.band}</span>}
|
||||
</div>
|
||||
)}
|
||||
@@ -155,11 +167,17 @@ function AmpBlock({ amp, flex, showName, t }: {
|
||||
) : (
|
||||
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
|
||||
)}
|
||||
{(s.warnings || s.alarms || s.err_text) && (
|
||||
{(s.warnings || s.alarms || s.err_text || s.fault_text) && (
|
||||
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
|
||||
{s.err_text || `${s.warnings || ''} ${s.alarms || ''}`.trim()}
|
||||
{s.fault_text || s.err_text || `${s.warnings || ''} ${s.alarms || ''}`.trim()}
|
||||
</div>
|
||||
)}
|
||||
{/* Said where the fault is read, because it is the way out of it: on a
|
||||
KPA, going to OPERATE clears the current fault — everything except
|
||||
temperature, which clears by cooling. */}
|
||||
{kpaAmp && s.fault_text && (
|
||||
<div className="text-[9px] text-center text-muted-foreground">{t('ampw.kpaClear')}</div>
|
||||
)}
|
||||
<PowerMeter label={t('flxp.outputPower')} watts={s.connected ? w : 0} maxWatts={hi} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -29,9 +31,9 @@ const LABELS: Record<string, string> = {
|
||||
|
||||
// The channels a rule can be scoped to. "worked" is the catch-all — it means
|
||||
// nothing on ANY channel — so narrowing it would say nothing.
|
||||
const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz'] as const;
|
||||
const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz', 'hamlog'] as const;
|
||||
const CHANNEL_LABELS: Record<string, string> = {
|
||||
qsl: 'appr.chQsl', lotw: 'LoTW', eqsl: 'eQSL', qrz: 'QRZ.com',
|
||||
qsl: 'appr.chQsl', lotw: 'LoTW', eqsl: 'eQSL', qrz: 'QRZ.com', hamlog: 'HAMLOG.online',
|
||||
};
|
||||
|
||||
// MatrixColorsSection recolours the band/mode matrix — the PH/CW/DIG grid in the
|
||||
@@ -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-none" />
|
||||
<span className="inline-block w-7 h-5 rounded bg-mx-none ring-2 ring-mx-cur ring-inset" />
|
||||
<span className="relative inline-block w-7 h-5 rounded bg-mx-dx-conf">
|
||||
<span className="absolute top-[4px] right-[4px] size-[5px] rounded-full bg-mx-mark-work ring-1 ring-background" />
|
||||
</span>
|
||||
<span className="relative inline-block w-7 h-5 rounded bg-mx-dx-conf">
|
||||
<span className="absolute top-[4px] right-[4px] size-[5px] rounded-full bg-mx-mark-conf ring-1 ring-background" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={reset}
|
||||
@@ -284,6 +292,116 @@ export function AppearancePanel() {
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,7 +68,8 @@ const AWARD_TYPES = ['REFERENCE', 'QSOFIELDS', 'CALLSIGN'];
|
||||
const NO_FIELD = '__none__';
|
||||
const CONFIRM_SRC = [
|
||||
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
|
||||
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
|
||||
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'hamlog', label: 'HAMLOG.online' },
|
||||
{ id: 'custom', label: 'Custom' },
|
||||
];
|
||||
const 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'];
|
||||
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
|
||||
@@ -402,7 +403,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-6xl w-[95vw] max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogContent overlayBlur={false} className="max-w-6xl w-[95vw] max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogHeader className="px-5 py-3 border-b">
|
||||
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { X, Plus, Loader2 } from 'lucide-react';
|
||||
import { SearchAwardReferences, GetAwardDefs, GetAwardReferenceMeta, AwardRefsNew } from '../../wailsjs/go/main/App';
|
||||
import { SearchAwardReferences, GetAwardDefs, GetAwardReferenceMeta, AwardRefsNew, GetTrackedAwards } from '../../wailsjs/go/main/App';
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type AwardRef = { code: string; name: string; dxcc: number; group: string; subgrp: string };
|
||||
type AwardDef = { code: string; name: string; field?: string; dxcc_filter?: number[] | null; dynamic?: boolean };
|
||||
type AwardDef = { code: string; name: string; field?: string; dxcc_filter?: number[] | null; dynamic?: boolean; valid?: boolean };
|
||||
type Meta = { code: string; count: number; can_update: boolean };
|
||||
|
||||
// Fields auto-derived from structured QSO data — their awards (DXCC/WAZ/WAS/…)
|
||||
@@ -94,11 +94,16 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// The awards the operator FOLLOWS, exactly as the Awards tab reads them.
|
||||
// This panel used to offer every award that existed, so a station chasing
|
||||
// three of them was picking references out of a list of twenty.
|
||||
const [tracked, setTracked] = useState<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
Promise.all([GetAwardDefs(), GetAwardReferenceMeta()])
|
||||
.then(([d, m]) => {
|
||||
Promise.all([GetAwardDefs(), GetAwardReferenceMeta(), GetTrackedAwards()])
|
||||
.then(([d, m, tr]) => {
|
||||
setDefs((d ?? []) as any);
|
||||
setMetas(Object.fromEntries(((m ?? []) as Meta[]).map((x) => [String(x.code).toUpperCase(), x])));
|
||||
setTracked(new Set(((tr ?? []) as string[]).map((c) => String(c).toUpperCase())));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
@@ -111,6 +116,12 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
return defs.filter((d) => {
|
||||
// Computed awards (field = dxcc/cqz/…) are derived automatically.
|
||||
if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) return false;
|
||||
// Switched off in the award editor: not a candidate for anything.
|
||||
if (d.valid === false) return false;
|
||||
// Followed awards only, when a selection has been made. An empty
|
||||
// selection means "all of them" — the same rule the Awards tab uses, so
|
||||
// the two lists cannot disagree about what this station chases.
|
||||
if (tracked.size > 0 && !tracked.has(String(d.code).toUpperCase())) return false;
|
||||
const scope = d.dxcc_filter ?? [];
|
||||
if (scope.length > 0 && (!dxcc || !scope.includes(dxcc))) return false;
|
||||
// Offer the award even when its reference list isn't loaded yet: a custom
|
||||
@@ -122,7 +133,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
||||
return true;
|
||||
}).map((d) => ({ code: d.code, name: d.name, field: String(d.field ?? '').toLowerCase() }))
|
||||
.sort((a, b) => a.code.localeCompare(b.code));
|
||||
}, [defs, metas, dxcc]);
|
||||
}, [defs, metas, dxcc, tracked]);
|
||||
|
||||
// Keep the selected award valid as the offered list changes with the call.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -29,7 +29,32 @@ type AwardResult = {
|
||||
type AwardStatRow = { label: string; cells: number[]; total: number; grand_total: number };
|
||||
type AwardStats = { code: string; bands: string[]; rows: AwardStatRow[] };
|
||||
|
||||
// Fixed band columns for the matrix view (Log4OM-style).
|
||||
// Every band the matrix can show, in the order they belong in.
|
||||
//
|
||||
// The columns are the intersection of this with what the award covers, so a
|
||||
// band missing HERE cannot appear at all whatever the award says — which is how
|
||||
// DDFM, a French award worked well into the microwaves, ended at 70cm with no
|
||||
// 23cm column and no way to ask for one.
|
||||
//
|
||||
// Mirrors bandOrder in internal/award — the same list in frequency order. The
|
||||
// two are kept apart because they answer different questions (that one sorts an
|
||||
// award's own band counts, this one lays out columns), but a band added to one
|
||||
// belongs in the other.
|
||||
// The DXCC Challenge counts one point per CONFIRMED entity per band, over ten
|
||||
// bands — and 60 m is NOT one of them. That is the whole of the gap an operator
|
||||
// notices between OpsLog and LoTW: 3229 here against 3004 there, and 224 of the
|
||||
// difference is the 60 m column.
|
||||
//
|
||||
// Worked out from the confirmed row rather than asked of the backend, because
|
||||
// that row IS the answer: the Challenge is its sum over these ten bands.
|
||||
const CHALLENGE_BANDS = ['160m', '80m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'];
|
||||
|
||||
const ALL_BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m',
|
||||
'6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
|
||||
|
||||
// What to show when there is nothing to narrow it down: an award with no band
|
||||
// list and no contacts yet. The classic set — the whole of ALL_BANDS would be
|
||||
// twenty-nine columns of mostly nothing.
|
||||
const GRID_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm'];
|
||||
|
||||
// Per-band status for a reference, highest first.
|
||||
@@ -40,6 +65,19 @@ function cellStatus(r: AwardRef, band: string): CellStatus {
|
||||
if (r.bands?.includes(band)) return 'worked';
|
||||
return 'none';
|
||||
}
|
||||
// slotsToConfirm counts the band-slots worked with this reference and not yet
|
||||
// confirmed on any of them — the QSLs still outstanding, one per cell showing W.
|
||||
//
|
||||
// It is the difference the Challenge line makes visible in the aggregate (1832
|
||||
// worked against 1554 confirmed) without saying WHERE it is. Counted over the
|
||||
// bands actually on screen, so it always adds up to the columns in front of the
|
||||
// operator rather than to a band set they filtered out.
|
||||
function slotsToConfirm(r: AwardRef, bands: string[]): number {
|
||||
let n = 0;
|
||||
for (const b of bands) if (cellStatus(r, b) === 'worked') n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
const CELL_STYLE: Record<CellStatus, string> = {
|
||||
validated: 'bg-success text-success-foreground',
|
||||
confirmed: 'bg-warning text-warning-foreground',
|
||||
@@ -87,7 +125,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
const [refSearch, setRefSearch] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
|
||||
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
|
||||
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf' | 'slots_notconf'>('all');
|
||||
// Mode filter, stacked ON TOP of the status one. "Worked on CW but not
|
||||
// confirmed" is two questions at once, and answering only one of them is what
|
||||
// sends an operator to a spreadsheet.
|
||||
@@ -180,26 +218,46 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
useEffect(() => { if (selected) compute(selected); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [modeFilter]);
|
||||
|
||||
// Bands relevant to the selected award, used by BOTH the grid and the stats
|
||||
// matrix so neither is padded with bands the award doesn't use. Rule: the
|
||||
// award's explicit valid_bands if it has any (e.g. WAPC = 40/20/15/10m); else
|
||||
// the bands the operator actually has contacts on (so DXCC, which has no band
|
||||
// restriction, shows 160m–6m instead of empty VHF/UHF columns). Empty set =
|
||||
// no basis yet → callers fall back to all bands.
|
||||
// matrix so neither is padded with bands nothing was worked on. Rule: the
|
||||
// bands this operator HAS contacts on for this award; failing that, the
|
||||
// award's own list of permitted bands. Empty set = no basis at all → callers
|
||||
// fall back to the classic columns.
|
||||
const awardBands = useMemo(() => {
|
||||
const vb = (awardList.find((a) => a.code === selected)?.bands ?? []).map((b) => b.toLowerCase());
|
||||
if (vb.length > 0) return new Set(vb);
|
||||
const worked = (current?.bands ?? [])
|
||||
// The bands with CONTACTS on them come first, whatever the award declares.
|
||||
//
|
||||
// It used to take the declared list when there was one, which put up a
|
||||
// column for every band the award permits — DDFM allows 6m and 70cm, so
|
||||
// both stood there empty on a station that has never worked a French
|
||||
// department on either. An always-blank column reads as a gap in the log
|
||||
// rather than as a band the operator never tried.
|
||||
const worked = new Set((current?.bands ?? [])
|
||||
.filter((b) => (b.worked ?? 0) > 0)
|
||||
.map((b) => String(b.band).toLowerCase());
|
||||
return new Set(worked);
|
||||
.map((b) => String(b.band).toLowerCase()));
|
||||
if (worked.size > 0) return worked;
|
||||
// Nothing worked yet: the award's own list is the honest answer — it says
|
||||
// what this award is played on, which is the only thing there is to show.
|
||||
return new Set((awardList.find((a) => a.code === selected)?.bands ?? []).map((b) => b.toLowerCase()));
|
||||
}, [awardList, selected, current]);
|
||||
|
||||
const gridBands = useMemo(() => {
|
||||
if (awardBands.size === 0) return GRID_BANDS;
|
||||
const filtered = GRID_BANDS.filter((b) => awardBands.has(b));
|
||||
// Filtered from ALL_BANDS, so a band the award covers gets its column even
|
||||
// when it is one the classic set never listed.
|
||||
const filtered = ALL_BANDS.filter((b) => awardBands.has(b));
|
||||
return filtered.length ? filtered : GRID_BANDS;
|
||||
}, [awardBands]);
|
||||
|
||||
const challenge = useMemo(() => {
|
||||
if (!stats || selected.toUpperCase() !== 'DXCC') return null;
|
||||
const row = stats.rows.find((r) => r.label === 'CONFIRMED');
|
||||
if (!row) return null;
|
||||
let total = 0;
|
||||
stats.bands.forEach((b, i) => {
|
||||
if (CHALLENGE_BANDS.includes(String(b).toLowerCase())) total += Number(row.cells[i]) || 0;
|
||||
});
|
||||
return total;
|
||||
}, [stats, selected]);
|
||||
|
||||
// Stats rows to show: drop the mode-category rows (CW / DIGITAL / PHONE) the
|
||||
// award doesn't cover. The "ALL" rows (no suffix) always show; a category row
|
||||
// shows only when the award has no emission restriction or lists that emission.
|
||||
@@ -259,6 +317,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
if (refFilter === 'worked' && !r.worked) return false;
|
||||
if (refFilter === 'notworked' && r.worked) return false;
|
||||
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
|
||||
// Worked-not-confirmed by SLOT, not by reference: an entity confirmed on
|
||||
// 20 m still has a 15 m contact waiting for its card, and every filter
|
||||
// above answers "no" for it because the entity itself is confirmed.
|
||||
if (refFilter === 'slots_notconf' && slotsToConfirm(r, gridBands) === 0) return false;
|
||||
if (modeFilter !== 'all' && refFilter !== 'notworked') {
|
||||
// A reference never worked has no mode, so "not worked" plus a mode is
|
||||
// a contradiction: the mode filter stands aside rather than emptying
|
||||
@@ -294,7 +356,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
}
|
||||
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
|
||||
});
|
||||
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]);
|
||||
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir, gridBands]);
|
||||
|
||||
// The gap itself, over whatever the other filters left on screen: the number
|
||||
// of cells an operator would have to turn green to close it.
|
||||
const slotGap = useMemo(
|
||||
() => filteredRefs.reduce((n, r) => n + slotsToConfirm(r, gridBands), 0),
|
||||
[filteredRefs, gridBands],
|
||||
);
|
||||
|
||||
// The group column earns its width only when the list actually carries one
|
||||
// (DXCC prefixes, POTA locations); most custom lists have none. For DXCC the
|
||||
@@ -319,7 +388,13 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
{t('awp.rescan')}
|
||||
</Button>
|
||||
</div>
|
||||
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); onAwardsChanged?.(); }} />
|
||||
{/* setRescanTick as well as setByCode. The grid was refreshed on save
|
||||
and the statistics matrix was not, so changing which confirmations
|
||||
count — LoTW only, LoTW plus cards — left the whole table showing
|
||||
the previous rule's numbers with nothing to say they were stale.
|
||||
That is how a setting comes to look as though it does nothing. */}
|
||||
<AwardEditor open={editing} onClose={() => setEditing(false)}
|
||||
onSaved={() => { setByCode({}); setRescanTick((t) => t + 1); loadList(); onAwardsChanged?.(); }} />
|
||||
{/* Quick selector — scales when there are many awards. */}
|
||||
{awardList.length > 0 && (
|
||||
<div className="px-2 py-2 border-b border-border/40">
|
||||
@@ -417,7 +492,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
<Input className="h-8 w-56 pl-7 text-sm" placeholder={t('awp.filterReferences')} value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
|
||||
{([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')]] as const).map(([k, label]) => (
|
||||
{([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')], ['slots_notconf', t('awp.filterSlotsNotCfmd')]] as const).map(([k, label]) => (
|
||||
<button key={k} onClick={() => setRefFilter(k)}
|
||||
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
|
||||
{label}
|
||||
@@ -433,6 +508,11 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
|
||||
{slotGap > 0 && (
|
||||
<span className="text-xs text-muted-foreground" title={t('awp.slotGapTip')}>
|
||||
· <span className="font-semibold text-foreground">{slotGap}</span> {t('awp.slotGap')}
|
||||
</span>
|
||||
)}
|
||||
{/* Only for an award scoped to a DXCC entity. "In this award's
|
||||
scope but with no reference" needs a scope to be in: on a
|
||||
worldwide reference award — POTA, SOTA, IOTA, WWFF — every
|
||||
@@ -473,8 +553,8 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
<tr className="bg-card">
|
||||
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.statistic')}</th>
|
||||
{statsBandIdx.map((i) => <th key={stats.bands[i]} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{stats.bands[i]}</th>)}
|
||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center">{t('awp.total')}</th>
|
||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center">{t('awp.grand')}</th>
|
||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center" title={t('awp.totalHint')}>{t('awp.total')}</th>
|
||||
<th className="py-1.5 px-2 font-medium border-b border-border text-center" title={t('awp.grandHint')}>{t('awp.grand')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -493,6 +573,19 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
{challenge !== null && (
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td className="sticky left-0 bg-card py-1.5 pr-3 font-semibold whitespace-nowrap" title={t('awp.challengeHint')}>
|
||||
{t('awp.challenge')}
|
||||
</td>
|
||||
<td colSpan={statsBandIdx.length} className="py-1.5 text-[11px] text-muted-foreground">
|
||||
{t('awp.challengeBands')}
|
||||
</td>
|
||||
<td className="text-center py-1.5 px-2 font-mono font-bold text-success" colSpan={2}>{challenge}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
@@ -540,7 +633,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
|
||||
<td key={b} className="border-b border-l border-border/30 p-0 text-center">
|
||||
{s === 'none' ? <span className="block w-11 h-7" /> : (
|
||||
<button
|
||||
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
|
||||
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110',
|
||||
// Chasing the slots still to confirm, the
|
||||
// confirmed ones are context, not the answer:
|
||||
// a row is kept for its W cells and its V
|
||||
// cells would otherwise be the loudest thing
|
||||
// on it. Faded rather than hidden — which
|
||||
// band is already done is worth seeing.
|
||||
refFilter === 'slots_notconf' && s !== 'worked' && 'opacity-25')}
|
||||
title={t('awp.cellTitle', { ref: r.ref, band: b })}
|
||||
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
|
||||
>{CELL_LABEL[s]}</button>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 { useI18n } from '@/lib/i18n';
|
||||
import { bandRange, bandSegments, subscribeIaruRegion, type SegMode } from '@/lib/bandplan';
|
||||
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
||||
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
|
||||
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
||||
@@ -76,6 +77,13 @@ const BMP_MARKER_LABEL: Record<string, string> = {
|
||||
new_pota: 'bmp.legendNewPota',
|
||||
new_county: 'bmp.legendNewCounty',
|
||||
worked_call: 'bmp.legendWorkedCall',
|
||||
// Not on the strip — the pill is 22 px and a fourth segment turns it into a
|
||||
// colour code nobody reads — but the TOOLTIP has room, and leaving them out of
|
||||
// it made the two panels contradict each other: the cluster said NEW PFX about
|
||||
// a spot the map called "Worked". Both were true (the entity is worked on this
|
||||
// slot, the WPX prefix never has been) and neither view said so.
|
||||
new_pfx: 'clg2.newPfx',
|
||||
new_grid: 'clg2.newGrid',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
@@ -107,59 +115,26 @@ interface Props {
|
||||
keyNav?: boolean;
|
||||
}
|
||||
|
||||
const BAND_RANGES: Record<string, [number, number]> = {
|
||||
'160m': [1800, 2000],
|
||||
'80m': [3500, 3800],
|
||||
'60m': [5350, 5450],
|
||||
'40m': [7000, 7200],
|
||||
'30m': [10100, 10150],
|
||||
'20m': [14000, 14350],
|
||||
'17m': [18068, 18168],
|
||||
'15m': [21000, 21450],
|
||||
'12m': [24890, 24990],
|
||||
'10m': [28000, 29700],
|
||||
'6m': [50000, 50500],
|
||||
'4m': [70000, 70500],
|
||||
'2m': [144000, 146000],
|
||||
'70cm': [430000, 440000],
|
||||
// Band edges and mode segments come from lib/bandplan, which knows the
|
||||
// operator's IARU region (Settings → General): a Region 2 operator's 40 m runs
|
||||
// to 7300 with phone from 7125, and drawing Region 1's plan under their spots
|
||||
// cut the top 100 kHz of the band off the map.
|
||||
//
|
||||
// Sub-band shading colours: these are IDENTITIES (which mode the segment is
|
||||
// for), not states, so they take categorical hues — never the status tokens.
|
||||
// All three are drawn from the cool end of the categorical order and laid down
|
||||
// at low opacity, so the band plan stays background context while the warm
|
||||
// spot pills keep the foreground. Checked with the palette validator in both
|
||||
// themes (violet failed the dark protan step; magenta clears every check).
|
||||
const SEG_COLOR: Record<SegMode, string> = {
|
||||
cw: 'var(--chart-7)', // magenta
|
||||
digi: 'var(--chart-1)', // blue
|
||||
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
|
||||
// are read on top of it.
|
||||
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.
|
||||
function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
|
||||
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.
|
||||
const dispOpts = readSpotDisplayOptions();
|
||||
const spotStatus = useMemo(() => {
|
||||
if (!dispOpts.muteWorked && !dispOpts.slotHighlight) return spotStatusRaw;
|
||||
if (!dispOpts.muteWorked && !dispOpts.slotHighlight && dispOpts.chasePota) return spotStatusRaw;
|
||||
const out: Record<string, SpotStatusEntry> = {};
|
||||
for (const k of Object.keys(spotStatusRaw)) out[k] = applySpotDisplay(spotStatusRaw[k], dispOpts) as SpotStatusEntry;
|
||||
return out;
|
||||
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight]);
|
||||
const range = BAND_RANGES[band];
|
||||
const segments = SEGMENT_COLORS[band] ?? [];
|
||||
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight, dispOpts.chasePota]);
|
||||
// Re-render when the operator changes their IARU region in Settings.
|
||||
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));
|
||||
// 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
|
||||
// remembered zoom.
|
||||
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
|
||||
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
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]);
|
||||
// No ctrl+wheel zoom here any more: ctrl+wheel is the WINDOW zoom everywhere
|
||||
// else in OpsLog (View ▸ Zoom in/out), and one gesture that resizes the whole
|
||||
// app over one panel and one band map over another is a gesture nobody can
|
||||
// trust. The + / − buttons keep the zoom, deliberately and visibly.
|
||||
|
||||
// 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.
|
||||
@@ -737,7 +717,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
||||
'hover:translate-x-0.5 hover:shadow',
|
||||
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
|
||||
colour, exactly as before; otherwise it splits into one
|
||||
@@ -775,6 +755,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
||||
</div>
|
||||
</div>
|
||||
{/* 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">
|
||||
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
||||
<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. */}
|
||||
<span className="mx-0.5 opacity-40">|</span>
|
||||
<LegendDot colour={SEG_CW} label={t("bmp.legendCW")} />
|
||||
<LegendDot colour={SEG_DIGI} label={t("bmp.legendData")} />
|
||||
<LegendDot colour={SEG_PHONE} label={t("bmp.legendPhone")} />
|
||||
<LegendDot colour={SEG_COLOR.cw} label={t("bmp.legendCW")} />
|
||||
<LegendDot colour={SEG_COLOR.digi} label={t("bmp.legendData")} />
|
||||
<LegendDot colour={SEG_COLOR.phone} label={t("bmp.legendPhone")} />
|
||||
</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')}
|
||||
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
|
||||
)}
|
||||
<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">
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,10 @@ interface Props {
|
||||
busy: boolean;
|
||||
currentBand: 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
|
||||
// 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.
|
||||
@@ -57,7 +60,11 @@ const DEFAULT_BANDS: { tag: string; label: string }[] = [
|
||||
];
|
||||
const CLASSES = ['PH', 'CW', 'DIG'] as const;
|
||||
|
||||
const PHONE_MODES = new Set(['SSB','USB','LSB','AM','FM','DIGITALVOICE','PHONE']);
|
||||
export const PHONE_MODES = new Set(['SSB','USB','LSB','AM','FM','DIGITALVOICE','PHONE']);
|
||||
|
||||
// Which digital row the matrix opens on. Empty = DIG, the group of them all.
|
||||
// Set in Settings ▸ General; see the rotation below.
|
||||
export const MATRIX_DIGI_KEY = 'opslog.matrixDigiMode';
|
||||
function classMatchesMode(cls: string, mode: string): boolean {
|
||||
const u = (mode || '').toUpperCase();
|
||||
if (cls === 'PH') return PHONE_MODES.has(u);
|
||||
@@ -81,29 +88,81 @@ const STATUS_CLASSES: Record<string, string> = {
|
||||
// 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
|
||||
// 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-work', label: 'mx.callWork' },
|
||||
{ swatch: 'bg-mx-dx-conf', label: 'mx.dxConf' },
|
||||
{ swatch: 'bg-mx-dx-work', label: 'mx.dxWork' },
|
||||
{ swatch: 'bg-mx-none', label: 'mx.none' },
|
||||
{ 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 =
|
||||
status === 'call_c' ? t('mx.tipCallConf') :
|
||||
status === 'call_w' ? t('mx.tipCallWork') :
|
||||
status === 'dxcc_c' ? t('mx.tipDxConf') :
|
||||
status === 'dxcc_w' ? t('mx.tipDxWork') :
|
||||
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();
|
||||
// Cell drill-down: which band+class the operator clicked, or 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],
|
||||
);
|
||||
// Where the rotation STARTS. An operator who only ever works FT8 was shown
|
||||
// "DIG" every time and had to click to the mode they actually use, on every
|
||||
// callsign — so the row they want is the one it opens on. Empty (the default)
|
||||
// keeps DIG, which is right for anyone working several digital modes.
|
||||
const [digIdx, setDigIdx] = useState(0); // 0 = the DIG group itself
|
||||
useEffect(() => {
|
||||
const want = (localStorage.getItem(MATRIX_DIGI_KEY) || '').toUpperCase().trim();
|
||||
if (!want) { setDigIdx(0); return; }
|
||||
const i = digModes.indexOf(want);
|
||||
setDigIdx(i >= 0 ? i + 1 : 0);
|
||||
}, [digModes]);
|
||||
// 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
|
||||
// bands they actually use), falling back to the built-in default set.
|
||||
const cols = useMemo(
|
||||
@@ -126,6 +185,16 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
return m;
|
||||
}, [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.
|
||||
// Derived straight from the entity's real band_status (all bands it was
|
||||
// worked on — not just the operator's configured column list).
|
||||
@@ -211,11 +280,11 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
return (
|
||||
<section
|
||||
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',
|
||||
)}
|
||||
>
|
||||
<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 ? (
|
||||
<>
|
||||
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
|
||||
@@ -238,7 +307,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
{forCall}
|
||||
</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}`}
|
||||
</Badge>
|
||||
{mwBadge}
|
||||
@@ -279,7 +348,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
<table className="border-separate" style={{ borderSpacing: 3 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-[26px]" />
|
||||
<th className="w-[38px] min-w-[38px] max-w-[38px]" />
|
||||
{cols.map((b) => (
|
||||
<th
|
||||
key={b.tag}
|
||||
@@ -294,13 +363,29 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{CLASSES.map((cls) => {
|
||||
const classCurrent = classMatchesMode(cls, currentMode);
|
||||
{CLASSES.map((clsBase) => {
|
||||
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 (
|
||||
<tr key={cls}>
|
||||
<th
|
||||
onClick={clsBase === 'DIG' && digModes.length ? cycleDig : undefined}
|
||||
title={clsBase === 'DIG' && digModes.length ? t('bsg.digCycle') : undefined}
|
||||
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',
|
||||
)}
|
||||
>
|
||||
@@ -308,21 +393,29 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
</th>
|
||||
{cols.map((b) => {
|
||||
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;
|
||||
return (
|
||||
<td
|
||||
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}
|
||||
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',
|
||||
// Only a filled cell has anything to show — an empty one
|
||||
// stays inert rather than opening a "no QSOs" dialog.
|
||||
st && 'cursor-pointer hover:brightness-110',
|
||||
isCurrent && 'ring-2 ring-mx-cur ring-inset',
|
||||
)}
|
||||
/>
|
||||
>
|
||||
{mine ? <CallMark state={mine} /> : null}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
@@ -337,11 +430,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
|
||||
className={cn(
|
||||
'inline-block size-3 rounded shrink-0',
|
||||
'relative inline-block size-3 rounded shrink-0',
|
||||
l.swatch,
|
||||
l.ring && 'ring-2 ring-mx-cur ring-inset',
|
||||
)}
|
||||
/>
|
||||
>
|
||||
{l.mark ? <CallMark state={l.mark} /> : null}
|
||||
</span>
|
||||
{t(l.label)}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -46,14 +46,27 @@ const FIELDS: FieldDef[] = [
|
||||
{ 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_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_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||
// HAMLOG.online: no promoted column, written into extras_json (see
|
||||
// qso.bulkEditableExtras). Status and date, like every other service here.
|
||||
{ id: 'hamlog_sent', label: 'bulk.fHamlogSent', group: 'QSL / upload', kind: 'status' },
|
||||
{ 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_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
|
||||
{ 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 },
|
||||
// No promoted column: written into extras_json (see qso.bulkEditableExtras).
|
||||
{ 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_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_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
|
||||
{ id: 'my_street', label: 'bulk.fMyStreet', group: 'My station', kind: 'text' },
|
||||
@@ -180,7 +193,7 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogContent overlayBlur={false} className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('bulk.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
// new is being decoded on FT8/FT4/JS8 near here — not that the band is dead.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Radar, Loader2, X } from 'lucide-react';
|
||||
import { formatDistance } from '@/lib/units';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { chaseAllows } from '@/lib/spotDisplay';
|
||||
import { markerColour } from '@/lib/spotMarkers';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { GetChaseNewSpots } from '../../wailsjs/go/main/App';
|
||||
import { GetChaseNewSpots, GetPSKReporterStatus } from '../../wailsjs/go/main/App';
|
||||
|
||||
export interface ChaseNewSpot {
|
||||
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.
|
||||
//
|
||||
// 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 {
|
||||
switch (s.status) {
|
||||
case 'new': return 'dxcc';
|
||||
@@ -67,22 +73,32 @@ function categoryOf(s: ChaseNewSpot): Category | null {
|
||||
case 'new-mode': return 'mode';
|
||||
case 'new-slot': return 'slot';
|
||||
}
|
||||
if (s.new_pfx) return 'pfx';
|
||||
if (s.new_grid) return 'grid';
|
||||
if (s.new_pfx && chaseAllows('pfx')) return 'pfx';
|
||||
if (s.new_grid && chaseAllows('grid')) return 'grid';
|
||||
return null;
|
||||
}
|
||||
|
||||
const FILTER_KEY = 'opslog.chaseNewFilters';
|
||||
|
||||
function allowedCategories(): Category[] {
|
||||
return CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => c.key);
|
||||
}
|
||||
|
||||
function loadFilters(): Set<Category> {
|
||||
const allowed = allowedCategories();
|
||||
try {
|
||||
const raw = localStorage.getItem(FILTER_KEY);
|
||||
if (raw) {
|
||||
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 */ }
|
||||
return new Set(CATEGORIES.map((c) => c.key));
|
||||
return new Set(allowed);
|
||||
}
|
||||
|
||||
export function ChaseNewPanel({ onPick, onClose }: Props) {
|
||||
@@ -90,6 +106,10 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
|
||||
const [spots, setSpots] = useState<ChaseNewSpot[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
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
|
||||
// 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 {
|
||||
const r = ((await GetChaseNewSpots()) ?? []) as ChaseNewSpot[];
|
||||
if (alive) { setSpots(r); setLoaded(true); }
|
||||
const st = await GetPSKReporterStatus();
|
||||
if (alive) setFeed(st);
|
||||
} catch { /* the feed may not be up yet */ }
|
||||
};
|
||||
tick();
|
||||
@@ -131,7 +153,7 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
|
||||
|
||||
{/* Filters, in the same order and colours as the badges they hide. */}
|
||||
<div className="flex flex-1 flex-wrap items-center gap-1">
|
||||
{CATEGORIES.map((c) => (
|
||||
{CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
@@ -148,8 +170,13 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
|
||||
))}
|
||||
</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">
|
||||
{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>
|
||||
{onClose && (
|
||||
<button
|
||||
@@ -186,7 +213,7 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
|
||||
title={[
|
||||
s.country,
|
||||
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` : '',
|
||||
].filter(Boolean).join(' · ')}
|
||||
>
|
||||
@@ -212,7 +239,16 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AllCommunityModule, ModuleRegistry,
|
||||
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
|
||||
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent, type RowDoubleClickedEvent,
|
||||
} from 'ag-grid-community';
|
||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
@@ -13,8 +13,9 @@ import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||
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 { distanceUnit, distanceValue, subscribeDistanceUnit } from '@/lib/units';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||
@@ -45,6 +46,7 @@ export type ClusterSpot = {
|
||||
raw: string;
|
||||
repeats?: number;
|
||||
pota_ref?: string;
|
||||
sota_ref?: string;
|
||||
pota_name?: string;
|
||||
};
|
||||
|
||||
@@ -63,6 +65,9 @@ export type SpotStatusEntry = {
|
||||
state?: string;
|
||||
new_pota?: boolean;
|
||||
new_pfx?: boolean;
|
||||
unconf_status?: boolean;
|
||||
unconf_pfx?: boolean;
|
||||
unconf_cty?: boolean;
|
||||
pfx?: string;
|
||||
// lotw: the DX uploads to LoTW, per ARRL user list. Inert until downloaded.
|
||||
lotw?: boolean;
|
||||
@@ -75,7 +80,18 @@ export type SpotStatusEntry = {
|
||||
type Props = {
|
||||
rows: ClusterSpot[];
|
||||
spotStatus: Record<string, SpotStatusEntry>;
|
||||
// A DOUBLE click works the spot: QSY, mode, callsign, the lot.
|
||||
onSpotClick?: (s: ClusterSpot) => void;
|
||||
// A SINGLE click only fills the callsign. Reading the cluster means passing
|
||||
// over dozens of lines, and every pass used to drag the radio with it — one
|
||||
// stray click while looking took the operator off the station they were
|
||||
// working. Looking and going are now two different gestures.
|
||||
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';
|
||||
@@ -135,6 +151,12 @@ function statusFor(p: any): SpotStatusEntry | undefined {
|
||||
// filled pfx → new prefix filled POTA → new park filled county → new county
|
||||
// blue call → already worked (not a novelty, so text only)
|
||||
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
|
||||
|
||||
// FILLING the cell that carries the fact, rather than only tinting its text.
|
||||
@@ -177,9 +199,10 @@ function statusColor(s: SpotStatusEntry | undefined): string | null {
|
||||
case 'new-band-mode':
|
||||
case 'new-band':
|
||||
case 'new-mode':
|
||||
case 'new-slot':
|
||||
case 'new-call':
|
||||
return NEW;
|
||||
case 'new-slot':
|
||||
return NEWSLOT;
|
||||
default:
|
||||
return s?.worked_call ? WKD : null;
|
||||
}
|
||||
@@ -284,13 +307,15 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
: s?.status === 'new-slot' ? t('clg2.newSlot')
|
||||
: s?.status === 'new-call' ? t('clg2.newCall')
|
||||
: 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
|
||||
// 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_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
|
||||
// held back, so it reads as "less" of the same thing rather than a
|
||||
// different fact — and the label says which.
|
||||
@@ -323,13 +348,26 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
},
|
||||
{
|
||||
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,
|
||||
cellStyle: (p: any) => (statusFor(p)?.new_pota
|
||||
? fillStyle(markerColour('new_pota'))
|
||||
: { color: 'var(--success)' }) as any,
|
||||
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',
|
||||
headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
|
||||
@@ -426,8 +464,13 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
||||
},
|
||||
{
|
||||
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',
|
||||
valueFormatter: (p) => p.value ? String(p.value) : '',
|
||||
// The header carries the unit, so the cells stay bare numbers and the
|
||||
// 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),
|
||||
},
|
||||
{
|
||||
@@ -500,13 +543,15 @@ const GROUP_ORDER = ['Spot', 'Geo'];
|
||||
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);
|
||||
|
||||
export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect, headerLeft }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
// 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
|
||||
// matching column events. Without this guard those events were persisted, so a
|
||||
@@ -582,7 +627,60 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
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>) {
|
||||
if (e.data && onSpotSelect) onSpotSelect(e.data);
|
||||
}
|
||||
|
||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<ClusterSpot>) {
|
||||
if (e.data && onSpotClick) onSpotClick(e.data);
|
||||
}
|
||||
|
||||
@@ -622,7 +720,9 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
|
||||
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)}
|
||||
title={t('clg2.clearFiltersTitle')}>
|
||||
<FilterX className="size-3.5" /> {t('clg2.clearFilters')}
|
||||
@@ -636,7 +736,7 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
<AgGridReact<ClusterSpot>
|
||||
ref={gridRef}
|
||||
theme={hamlogTheme}
|
||||
rowData={rows}
|
||||
rowData={shown}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColDef}
|
||||
context={context}
|
||||
@@ -647,11 +747,21 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
onColumnVisible={saveColumnState}
|
||||
onSortChanged={saveColumnState}
|
||||
onRowClicked={handleRowClicked}
|
||||
onRowDoubleClicked={handleRowDoubleClicked}
|
||||
onBodyScroll={onBodyScroll}
|
||||
animateRows={false}
|
||||
suppressCellFocus
|
||||
getRowId={(p) => `${(p.data as any).received_at}-${(p.data as any).dx_call}-${(p.data as any).source_id}`}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -12,12 +12,17 @@
|
||||
// Status flags (new entity / band / mode / slot / grid / prefix / POTA / county)
|
||||
// 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.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
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 { writeUiPref } from '@/lib/uiPref';
|
||||
import { SetAutoCallVisible } from '../../wailsjs/go/main/App';
|
||||
import { decoderName } from '@/lib/decoderName';
|
||||
|
||||
export type Decode = {
|
||||
call: string;
|
||||
@@ -72,8 +77,14 @@ type StatusEntry = {
|
||||
new_pota?: boolean;
|
||||
new_pfx?: 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.
|
||||
grid_state?: string;
|
||||
state?: string;
|
||||
grid?: string;
|
||||
lotw?: boolean;
|
||||
};
|
||||
@@ -88,8 +99,17 @@ interface Props {
|
||||
// receiver reported last, which is a coin toss — each pane needs its own.
|
||||
txStates?: Record<string, TxMsg>;
|
||||
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;
|
||||
// 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;
|
||||
// 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
|
||||
// live view, not data — clearing it costs nothing but the seconds until the
|
||||
// next period lands.
|
||||
@@ -105,6 +125,15 @@ interface Props {
|
||||
// machine off is not something to go hunting through a settings tree for.
|
||||
autoCallOn?: boolean;
|
||||
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
|
||||
@@ -112,7 +141,10 @@ interface Props {
|
||||
//
|
||||
// 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.
|
||||
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 }[] = [
|
||||
{ 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: 'pfx', label: 'dec.bgPfx', colour: markerColour('new_pfx') },
|
||||
{ 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
|
||||
@@ -143,9 +176,25 @@ function catsOf(e: StatusEntry | undefined): Set<NewCat> {
|
||||
if (e.new_grid) out.add('grid');
|
||||
if (e.new_pfx) out.add('pfx');
|
||||
if (e.new_county) out.add('cty');
|
||||
if (e.new_state) out.add('state');
|
||||
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 SPLIT_KEY = 'opslog.decodeSplit';
|
||||
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
|
||||
// resize handles all read from this, so a column cannot be resized in the header
|
||||
// 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 }[] = [
|
||||
{ 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: 'dt', tkey: 'dec.colDt', def: 44, min: 32 },
|
||||
{ key: 'freq', tkey: 'dec.colFreq', def: 56, min: 40 },
|
||||
@@ -241,9 +295,64 @@ const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
|
||||
// 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.
|
||||
{ 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: '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',
|
||||
};
|
||||
|
||||
// The columns worth sorting on. Not every column: time is what the periods
|
||||
// already are, and sorting a slot by callsign or message answers no question an
|
||||
// operator has.
|
||||
type SortKey = 'snr' | 'freq' | 'dist' | 'country' | 'status';
|
||||
const SORTABLE: SortKey[] = ['snr', 'freq', 'dist', 'country', 'status'];
|
||||
|
||||
// Which way each column is worth reading FIRST — strongest signal, lowest
|
||||
// frequency, furthest DX, A to Z, most wanted. Clicking again reverses it.
|
||||
const SORT_FIRST: Record<SortKey, 'asc' | 'desc'> = {
|
||||
snr: 'desc', freq: 'asc', dist: 'desc', country: 'asc', status: 'desc',
|
||||
};
|
||||
|
||||
// How wanted a station is, as a number to sort by. The cluster's own order,
|
||||
// most wanted first — a new entity above a new band above a new slot — so the
|
||||
// two views rank the same things the same way.
|
||||
const STATUS_RANK: Record<string, number> = {
|
||||
'new': 100, 'new-band-mode': 90, 'new-band': 80, 'new-mode': 70, 'new-slot': 60,
|
||||
'new-call': 30, 'worked': 10,
|
||||
};
|
||||
function statusRank(e?: StatusEntry): number {
|
||||
if (!e) return 0;
|
||||
let r = STATUS_RANK[e.status ?? ''] ?? 0;
|
||||
// The markers that are orthogonal to the entity: a new county on a worked
|
||||
// country is still something to chase, and should not sort with the plain
|
||||
// duplicates.
|
||||
if (e.new_pota) r = Math.max(r, 50);
|
||||
if (e.new_county) r = Math.max(r, 45);
|
||||
if (e.new_grid) r = Math.max(r, 44);
|
||||
if (e.new_state) r = Math.max(r, 43);
|
||||
if (e.new_pfx) r = Math.max(r, 42);
|
||||
return r;
|
||||
}
|
||||
|
||||
const COL_MAX = 600;
|
||||
const COLW_KEY = 'opslog.decodeColWidths';
|
||||
|
||||
@@ -308,14 +417,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
|
||||
// amber in one panel and green in the next.
|
||||
const ENTITY_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
'new': { label: 'dec.stNew', cls: 'bg-success text-success-foreground' },
|
||||
'new-band': { label: 'dec.stBand', cls: 'bg-warning text-warning-foreground' },
|
||||
'new-mode': { label: 'dec.stMode', cls: 'bg-info text-info-foreground' },
|
||||
'new-slot': { label: 'dec.stSlot', cls: 'bg-caution text-caution-foreground' },
|
||||
'new-call': { label: 'dec.stCall', cls: 'bg-muted text-muted-foreground' },
|
||||
const ENTITY_BADGE: Record<string, { label: string; cls: string; colour: string }> = {
|
||||
// colour is the category's own hue, for the UNCONFIRMED rendering: the
|
||||
// filled cls puts light text on a filled chip, and dimming that to a
|
||||
// transparent background left light-on-nothing — an invisible badge.
|
||||
'new': { label: 'dec.stNew', cls: 'bg-success text-success-foreground', colour: 'var(--success)' },
|
||||
'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
|
||||
// new grid, a new prefix or a park never logged.
|
||||
//
|
||||
@@ -327,6 +455,7 @@ const ENTITY_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
const EXTRA_BADGES: { key: keyof StatusEntry; marker: SpotMarkerKey; label: string }[] = [
|
||||
{ key: 'new_pota', marker: 'new_pota', label: 'dec.bgPota' },
|
||||
{ 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_county', marker: 'new_county', label: 'dec.bgCounty' },
|
||||
];
|
||||
@@ -360,16 +489,31 @@ function periodLabel(ms: number, trSec: number): string {
|
||||
// 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
|
||||
// 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;
|
||||
// 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
|
||||
// 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.
|
||||
const parts = msg.split(/(s+)/);
|
||||
const parts = msg.split(/(\s+)/);
|
||||
return (
|
||||
<>
|
||||
{parts.map((tok, i) => {
|
||||
if (/^s+$/.test(tok)) return tok;
|
||||
if (/^\s+$/.test(tok)) return tok;
|
||||
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 (me && bare === me) return <span key={i} className="font-bold text-success">{tok}</span>;
|
||||
@@ -388,7 +532,7 @@ function renderMsg(msg: string, me: string, calling: string) {
|
||||
//
|
||||
// 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.
|
||||
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());
|
||||
useEffect(() => {
|
||||
// 100 ms: smooth enough for a bar that fills in three and three quarter
|
||||
@@ -404,19 +548,26 @@ function PeriodClock({ trSec, mode }: { trSec: number; mode?: string }) {
|
||||
// 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.
|
||||
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 (
|
||||
<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={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}%` }}
|
||||
/>
|
||||
</span>
|
||||
<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)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -482,21 +633,29 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
|
||||
// the same way it was grouped.
|
||||
tr: trSeconds(g.decodes[0]?.mode ?? g.tx[0]?.mode, g.decodes[0]?.tr_period),
|
||||
tx: g.tx,
|
||||
// Strongest first inside a period: the eye should land on what is
|
||||
// workable, and time within a slot means nothing — they were all
|
||||
// transmitting simultaneously.
|
||||
decodes: g.decodes.sort((x, y) => y.snr - x.snr),
|
||||
// ARRIVAL order inside a period, per the operator: it mirrors the
|
||||
// decoder's own window line for line, which makes the two screens
|
||||
// comparable at a glance — the strongest-first sort scrambled that
|
||||
// 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();
|
||||
// Column widths, dragged in the header and shared by every row. Persisted
|
||||
// through writeUiPref (not raw localStorage) so the layout travels with data/
|
||||
// like every other portable preference.
|
||||
const [colw, setColw] = useState<ColWidths>(loadWidths);
|
||||
const template = useMemo(() => COLS.map((c) => `${colw[c.key]}px`).join(' '), [colw]);
|
||||
const tableW = useMemo(() => COLS.reduce((s, c) => s + colw[c.key], 0), [colw]);
|
||||
// The chase list as TYPED. Re-seeded whenever the stored value changes —
|
||||
// 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 col = COLS.find((c) => c.key === key)!;
|
||||
const w = Math.min(COL_MAX, Math.max(col.min, Math.round(px)));
|
||||
@@ -545,8 +704,85 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
const statusOf = (d: Decode): StatusEntry | undefined =>
|
||||
spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`];
|
||||
|
||||
// ── Sorting, inside a period ──────────────────────────────────────────
|
||||
//
|
||||
// WITHIN each slot and never across them. The periods are the point of this
|
||||
// panel — what was on the air in one fifteen-second window — and a list
|
||||
// sorted end to end by signal would mix three minutes of decodes into one
|
||||
// column of numbers with no way to tell which slot any of them came from.
|
||||
//
|
||||
// Arrival order stays the default and stays one click away, because it
|
||||
// mirrors the decoder's own window line for line, which is what makes the
|
||||
// two screens comparable at a glance.
|
||||
const [sortSpec, setSortSpec] = usePersisted('sort', '');
|
||||
const [sortKey, sortDir] = useMemo(() => {
|
||||
const [k, d] = String(sortSpec || '').split(':');
|
||||
return [SORTABLE.includes(k as SortKey) ? (k as SortKey) : '', d === 'asc' ? 'asc' : 'desc'] as const;
|
||||
}, [sortSpec]);
|
||||
|
||||
// One click sorts the way that column is worth reading — strongest signal,
|
||||
// furthest DX, lowest frequency, A to Z, most wanted. The second reverses it,
|
||||
// the third gives arrival order back.
|
||||
const toggleSort = (k: SortKey) => {
|
||||
if (sortKey !== k) { setSortSpec(`${k}:${SORT_FIRST[k]}`); return; }
|
||||
if (sortDir === SORT_FIRST[k]) { setSortSpec(`${k}:${SORT_FIRST[k] === 'asc' ? 'desc' : 'asc'}`); return; }
|
||||
setSortSpec('');
|
||||
};
|
||||
|
||||
const sortValue = useCallback((d: Decode, k: SortKey): number | string => {
|
||||
const e = statusOf(d);
|
||||
switch (k) {
|
||||
case 'snr': return d.snr;
|
||||
case 'freq': return d.freq_hz ?? 0;
|
||||
case 'dist': {
|
||||
const g = d.grid || e?.grid || '';
|
||||
const path = myGrid && g ? pathBetween(myGrid, g) : null;
|
||||
// A station that never sent a grid cannot be placed. Sorted to the end
|
||||
// whichever way round the column goes, rather than pretending to a
|
||||
// distance of zero and sitting at the top of "nearest first".
|
||||
return path ? path.distanceShort : Number.NaN;
|
||||
}
|
||||
case 'country': return (e?.country ?? '').toUpperCase();
|
||||
case 'status': return statusRank(e);
|
||||
}
|
||||
}, [spotStatus, myGrid]);
|
||||
|
||||
const sortDecodes = useCallback((list: Decode[]): Decode[] => {
|
||||
if (!sortKey) return list;
|
||||
const sign = sortDir === 'asc' ? 1 : -1;
|
||||
return [...list].sort((a, b) => {
|
||||
const va = sortValue(a, sortKey), vb = sortValue(b, sortKey);
|
||||
const na = typeof va === 'number' && Number.isNaN(va);
|
||||
const nb = typeof vb === 'number' && Number.isNaN(vb);
|
||||
if (na !== nb) return na ? 1 : -1; // unknowns last, both ways
|
||||
if (na && nb) return 0;
|
||||
if (typeof va === 'string' || typeof vb === 'string') {
|
||||
const sa = String(va), sb = String(vb);
|
||||
// An empty country is an unknown too, not a name that sorts first.
|
||||
if (!sa !== !sb) return sa ? -1 : 1;
|
||||
return sign * sa.localeCompare(sb);
|
||||
}
|
||||
return sign * ((va as number) - (vb as number));
|
||||
});
|
||||
}, [sortKey, sortDir, sortValue]);
|
||||
|
||||
// The mode currently on the air, for the slot clock. The newest decode knows
|
||||
// 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 liveTr = trSeconds(liveMode, decodes.length ? decodes[decodes.length - 1].tr_period : undefined);
|
||||
|
||||
@@ -558,6 +794,8 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
// opens with our callsign, sometimes bracketed when the sender compressed a
|
||||
// non-standard call.
|
||||
const me = (myCall ?? '').toUpperCase();
|
||||
const [, bumpUnit] = useState(0);
|
||||
useEffect(() => subscribeDistanceUnit(() => bumpUnit((n) => n + 1)), []);
|
||||
const calling = (txState?.dx_call ?? '').toUpperCase();
|
||||
const answersMe = (msg?: string): boolean => {
|
||||
if (!me || !msg) return false;
|
||||
@@ -583,27 +821,31 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
// 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
|
||||
// mean anything.
|
||||
const { bands, modes, conts, instances } = useMemo(() => {
|
||||
const b = new Set<string>(), m = new Set<string>(), c = new Set<string>(), i = new Set<string>();
|
||||
const { bands, modes, instances } = useMemo(() => {
|
||||
const b = new Set<string>(), m = new Set<string>(), i = new Set<string>();
|
||||
for (const d of decodes) {
|
||||
if (d.band) b.add(d.band);
|
||||
if (d.mode) m.add(d.mode);
|
||||
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
|
||||
}, [decodes, spotStatus]);
|
||||
|
||||
// The chips are the continents on the feed — a selector with nothing to choose
|
||||
// is furniture — PLUS anything currently selected. Without that second half a
|
||||
// filter can strand itself: pick AF, the last African station stops decoding,
|
||||
// and the list empties with no chip left to switch it back off.
|
||||
const contChips = useMemo(
|
||||
() => [...new Set([...conts, ...contList])].sort(),
|
||||
[conts, contList],
|
||||
);
|
||||
// The receiver column is dead weight with one decoder — which is nearly
|
||||
// everybody — so it is not there at all until a second one starts feeding.
|
||||
const cols = useMemo(() => COLS.filter((c) => c.key !== 'rx' || (instances.length > 1 && !splitByInstance)),
|
||||
[instances.length, splitByInstance]);
|
||||
const template = useMemo(() => cols.map((c) => `${colw[c.key]}px`).join(' '), [cols, colw]);
|
||||
const tableW = useMemo(() => cols.reduce((s, c) => s + colw[c.key], 0), [cols, colw]);
|
||||
// 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 q = search.trim().toUpperCase();
|
||||
@@ -629,6 +871,22 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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
|
||||
// the slot they went out in.
|
||||
@@ -639,19 +897,24 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
// Each pane cuts its own periods: the bands differ, so the slot boundaries and
|
||||
// the transmit messages belong to one receiver and not the other.
|
||||
const panes = useMemo(() => {
|
||||
// The sort is applied to each period's decodes, never to the periods
|
||||
// themselves: the slots stay newest-first, which is what the panel is.
|
||||
const sorted = (ps: ReturnType<typeof buildPeriods>) =>
|
||||
sortKey ? ps.map((p) => ({ ...p, decodes: sortDecodes(p.decodes) })) : ps;
|
||||
if (!splitByInstance || instances.length < 2) {
|
||||
return [{ key: '', label: '', tx: txState ?? undefined, periods: buildPeriods(filtered, txMsgs) }];
|
||||
return [{ key: '', label: '', tx: txState ?? undefined, periods: sorted(buildPeriods(filtered, txMsgs)) }];
|
||||
}
|
||||
return instances.map((inst) => ({
|
||||
key: inst,
|
||||
label: inst,
|
||||
// What the program is called, not the id it announces — see decoderName.
|
||||
label: decoderName(inst),
|
||||
tx: txStates?.[inst],
|
||||
periods: buildPeriods(
|
||||
periods: sorted(buildPeriods(
|
||||
filtered.filter((d) => (d.instance ?? '') === inst),
|
||||
txMsgs.filter((m) => (m.instance ?? '') === inst),
|
||||
),
|
||||
)),
|
||||
}));
|
||||
}, [filtered, txMsgs, splitByInstance, instances, txState, txStates]);
|
||||
}, [filtered, txMsgs, splitByInstance, instances, txState, txStates, sortKey, sortDecodes]);
|
||||
|
||||
|
||||
const resetFilters = () => {
|
||||
@@ -684,7 +947,23 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
{/* 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
|
||||
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" />
|
||||
|
||||
<button type="button" className={chip(cqOnly, 'success')} onClick={() => setCqOnly(!cqOnly)}>
|
||||
@@ -697,7 +976,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
{/* Per-category badges, in the colours of the flags they select — the
|
||||
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')}>
|
||||
{NEW_CATS.map((c) => {
|
||||
{NEW_CATS.filter((c) => chaseAllows(c.key)).map((c) => {
|
||||
const on = cats.has(c.key);
|
||||
return (
|
||||
<button
|
||||
@@ -811,6 +1090,88 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
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
|
||||
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')}
|
||||
</button>
|
||||
)}
|
||||
{/* WHAT IT IS DOING, BESIDE THE SWITCH RATHER THAN ON IT.
|
||||
A thing that keys the transmitter must never be a switch with no
|
||||
readout — but the readout was crammed inside the button as
|
||||
"Auto D2ACE 1/7 ·1/3", where the two counters read as one number and
|
||||
the whole control changed width every period. Out here each figure
|
||||
gets a label, and the station being called is the biggest thing on
|
||||
the row. */}
|
||||
{autoCallOn && autoCall?.target && (
|
||||
<span className="h-8 inline-flex items-center gap-2 rounded-lg border border-success/50 bg-success/10 px-2"
|
||||
title={t('dec.autoTargetTip', { call: autoCall.target })}>
|
||||
<span className="font-mono text-sm font-bold text-success">{autoCall.target}</span>
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<span className="uppercase tracking-wide">{t('dec.autoCalls')}</span>
|
||||
<span className="font-mono tabular-nums text-foreground">{autoCall.calls}/{autoCall.max}</span>
|
||||
</span>
|
||||
{/* Only once there is one to report: a zero that is always there is
|
||||
a figure nobody reads, and it was the half of the pair that got
|
||||
mistaken for the call count. */}
|
||||
{autoCall.misses > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<span className="uppercase tracking-wide">{t('dec.autoMisses')}</span>
|
||||
<span className={cn('font-mono tabular-nums',
|
||||
autoCall.misses + 1 >= autoCall.max_miss ? 'text-warning' : 'text-foreground')}>
|
||||
{autoCall.misses}/{autoCall.max_miss}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</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="h-8 inline-flex items-center gap-1.5 rounded-lg border border-warning/50 bg-warning/10 px-2 animate-pulse"
|
||||
title={t('dec.autoWaitTip', { call: autoCall.waiting })}>
|
||||
<span className="text-sm">⏳</span>
|
||||
<span className="font-mono text-sm font-bold text-warning">{autoCall.waiting}</span>
|
||||
<span className="text-[11px] uppercase tracking-wide text-muted-foreground">{t('dec.autoWaiting')}</span>
|
||||
</span>
|
||||
)}
|
||||
{/* 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 && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -877,7 +1238,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
<span className="flex-1" />
|
||||
{txState.band && <span className="text-xs text-muted-foreground shrink-0">{txState.band}</span>}
|
||||
{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>
|
||||
)}
|
||||
@@ -936,23 +1297,33 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
<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')}
|
||||
style={{ gridTemplateColumns: template, width: tableW }}>
|
||||
{COLS.map((c, i) => (
|
||||
<span key={c.key}
|
||||
// Not CELL_LAST for the final column: its overflow-hidden would
|
||||
// clip that column's own resize handle.
|
||||
className={cn('relative flex items-center min-w-0 px-2',
|
||||
i < COLS.length - 1 && 'border-r border-border/30',
|
||||
// The three numeric columns label their own right edge, where the
|
||||
// figures are.
|
||||
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq') && 'justify-end')}
|
||||
title={c.key === 'dt' ? t('dec.colDtTitle') : c.key === 'freq' ? t('dec.colFreqTitle') : undefined}>
|
||||
<span className="truncate">{t(c.tkey)}</span>
|
||||
<ColResizer
|
||||
onResize={(dx) => setColWidth(c.key, colw[c.key] + dx)}
|
||||
onReset={() => setColWidth(c.key, c.def)}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
{cols.map((c, i) => {
|
||||
const sortable = SORTABLE.includes(c.key as SortKey);
|
||||
const active = sortable && sortKey === c.key;
|
||||
return (
|
||||
<span key={c.key}
|
||||
// Not CELL_LAST for the final column: its overflow-hidden would
|
||||
// clip that column's own resize handle.
|
||||
className={cn('relative flex items-center min-w-0 px-2',
|
||||
i < cols.length - 1 && 'border-r border-border/30',
|
||||
// The three numeric columns label their own right edge, where the
|
||||
// figures are.
|
||||
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq' || c.key === 'dist') && 'justify-end',
|
||||
sortable && 'cursor-pointer select-none hover:text-foreground',
|
||||
active && 'text-primary')}
|
||||
onClick={sortable ? () => toggleSort(c.key as SortKey) : undefined}
|
||||
title={sortable ? t('dec.sortTip')
|
||||
: c.key === 'dt' ? t('dec.colDtTitle')
|
||||
: c.key === 'freq' ? t('dec.colFreqTitle') : undefined}>
|
||||
<span className="truncate">{c.key === 'dist' ? `${t(c.tkey)} (${distanceUnit()})` : t(c.tkey)}</span>
|
||||
{active && <span className="ml-0.5 shrink-0">{sortDir === 'asc' ? '▲' : '▼'}</span>}
|
||||
<ColResizer
|
||||
onResize={(dx) => setColWidth(c.key, colw[c.key] + dx)}
|
||||
onReset={() => setColWidth(c.key, c.def)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1004,10 +1375,10 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
{g.decodes.map((d, i) => {
|
||||
const e = statusOf(d);
|
||||
const st = e?.status && e.status !== 'worked' ? e.status : '';
|
||||
const entity = st ? ENTITY_BADGE[st] : undefined;
|
||||
const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key]);
|
||||
const entities = st ? entityBadgesFor(st) : [];
|
||||
const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key] && chaseAllows(b.key as string));
|
||||
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.
|
||||
const replying = answersMe(d.msg);
|
||||
// The station we are calling, so it can be picked out of a slot
|
||||
@@ -1017,11 +1388,19 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
<button
|
||||
key={`${d.call}-${d.freq_hz}-${i}`}
|
||||
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 })}
|
||||
style={{ gridTemplateColumns: template, width: tableW }}
|
||||
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'
|
||||
: mine ? 'bg-info/10'
|
||||
: 'hover:bg-muted/50')}
|
||||
@@ -1034,6 +1413,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
{hhmmssCompact(d.at)}
|
||||
</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))}>
|
||||
{d.snr > 0 ? `+${d.snr}` : d.snr}
|
||||
</span>
|
||||
@@ -1067,7 +1455,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
{/* The message carries the callsign already — which is why
|
||||
there is no column repeating it. */}
|
||||
<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>
|
||||
|
||||
{/* The decode's own grid first, the remembered one as the
|
||||
@@ -1080,6 +1468,23 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
<span className="truncate">{d.grid || e?.grid || ''}</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="truncate">{e?.country ?? ''}</span>
|
||||
</span>
|
||||
@@ -1096,27 +1501,43 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
{t('dec.wkd')}
|
||||
</span>
|
||||
)}
|
||||
{entity && (
|
||||
<span className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0', entity.cls)}>
|
||||
{t(entity.label)}
|
||||
{/* Plainest first: the LoTW letter, the worked note, then
|
||||
the coloured badges — the watch list leading them, being
|
||||
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>
|
||||
)}
|
||||
{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) => {
|
||||
// A square WORKED but not yet confirmed is a different job
|
||||
// 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 thing, and its label says which. One badge for both
|
||||
// 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);
|
||||
return (
|
||||
<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"
|
||||
style={unconf
|
||||
? { borderColor: c, color: c, opacity: 0.45, borderStyle: 'dashed' }
|
||||
: { borderColor: c, color: c }}>
|
||||
{unconf ? t('dec.bgGridUnconf') : t(b.label)}
|
||||
{t(b.label)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useOperatingLists } from '@/lib/operatingLists';
|
||||
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
|
||||
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||
@@ -70,6 +72,11 @@ interface Props {
|
||||
band: string;
|
||||
mode: string;
|
||||
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
|
||||
// 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.
|
||||
@@ -150,8 +157,9 @@ 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 oper = useOperatingLists(tab);
|
||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||
|
||||
@@ -289,6 +297,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
||||
currentBand={slotCall ? (slotBand ?? '') : band}
|
||||
currentMode={slotCall ? (slotMode ?? '') : mode}
|
||||
bands={bands}
|
||||
modes={modes}
|
||||
hasCall={slotCall ? true : callsign.trim() !== ''}
|
||||
forCall={slotCall}
|
||||
onEditQso={onEditQso}
|
||||
@@ -469,16 +478,44 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{/* The rigs and antennas already declared in Settings ▸ Operating
|
||||
conditions. Typing them again on every contact is both work and a
|
||||
source of spellings that do not match — "IC-7610", "IC 7610" and
|
||||
"ic7610" are three different rigs to an award and to a filter.
|
||||
Free text stays allowed: a QSO made from somebody else's station
|
||||
carries a rig that was never in this tree. */}
|
||||
<Field label={t('detp.rig')} span={3}>
|
||||
<Input value={details.my_rig} onChange={(e) => onChange({ my_rig: e.target.value })} />
|
||||
<Combobox value={details.my_rig} options={oper.rigs} showToggle allowFreeText
|
||||
onChange={(v) => onChange({ my_rig: v })} />
|
||||
</Field>
|
||||
<Field label={t('detp.antenna')} span={3}>
|
||||
<Input value={details.my_antenna} onChange={(e) => onChange({ my_antenna: e.target.value })} />
|
||||
{/* The antennas of the chosen rig, since that is what they hang off
|
||||
— and all of them when the rig is one this tree does not know. */}
|
||||
<Combobox value={details.my_antenna} options={oper.antennasFor(details.my_rig)} showToggle allowFreeText
|
||||
onChange={(v) => onChange({ my_antenna: v })} />
|
||||
</Field>
|
||||
{satelliteMode && (
|
||||
<>
|
||||
<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 label={t('detp.satelliteMode')} span={3}>
|
||||
<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
|
||||
};
|
||||
|
||||
// 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
|
||||
// the reserved area. Recording/labeling lives in Settings → Audio.
|
||||
export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) {
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Radio, Power, Activity, AudioLines, SlidersHorizontal } from 'lucide-react';
|
||||
import {
|
||||
GetKenwoodState, RefreshKenwood, SetKenwoodPower, SetKenwoodAFGain, SetKenwoodTX, TuneKenwoodATU,
|
||||
SetKenwoodRFGain, SetKenwoodMicGain, SetKenwoodSquelch, SetKenwoodPreamp, SetKenwoodAtt,
|
||||
SetKenwoodNB, SetKenwoodNR, SetKenwoodAGC, SetKenwoodFilter, SetKenwoodAntenna,
|
||||
SetKenwoodRIT, SetKenwoodXIT, ClearKenwoodRIT, SetKenwoodKeySpeed, ToggleKenwoodATU, SetKenwoodRITOffset, SetKenwoodPanelMode,
|
||||
GetCATState,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { sMeterRST } from '@/lib/rst';
|
||||
import { ShiftRow } from '@/components/ShiftRow';
|
||||
import { MeterBar } from '@/components/MeterBar';
|
||||
import { WheelRange } from '@/components/WheelRange';
|
||||
|
||||
type KenwoodState = {
|
||||
available: boolean; model?: string; elecraft: boolean; mode?: string; data_sub?: string;
|
||||
transmitting: boolean; split: boolean; split_tx_hz?: number;
|
||||
s_meter: number; s_meter_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;
|
||||
preamp: boolean; att: boolean; nb: boolean; nr: boolean; agc?: string;
|
||||
filter_hz: number; antenna: number; rit: boolean; xit: boolean; rit_offset: number; key_speed: number;
|
||||
meters_provisional: boolean;
|
||||
};
|
||||
|
||||
const ZERO: KenwoodState = {
|
||||
available: false, elecraft: false, transmitting: false, split: false,
|
||||
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,
|
||||
preamp: false, att: false, nb: false, nr: false,
|
||||
filter_hz: 0, antenna: 0, rit: false, xit: false, rit_offset: 0, key_speed: 0,
|
||||
meters_provisional: true,
|
||||
};
|
||||
|
||||
// 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
|
||||
// panel offering thirty widths is slower to use than the knob it replaces.
|
||||
// 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, CALIBRATED against a real K3 beside its own display
|
||||
// (2026-08): raw 5 reads S7 on the radio, raw 9 reads S9+20. So S9 sits near
|
||||
// raw 6.5 — not 9, which showed everything two S-units low — and each raw step
|
||||
// above it is worth ~8 dB, shown in the 10 dB steps the K3's own meter uses.
|
||||
// A Kenwood answers 0-30 on the same command and keeps the simple 1-per-raw
|
||||
// scale until someone calibrates one against a real radio too.
|
||||
const K3_S9_RAW = 6.5;
|
||||
const K3_DB_PER_RAW = 8;
|
||||
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' };
|
||||
}
|
||||
const s = Math.max(0, Math.min(9, Math.round(rawV * (elecraft ? 9 / K3_S9_RAW : 1))));
|
||||
return { s, over: 0, label: `S${s}` };
|
||||
}
|
||||
|
||||
// Segment colour, printed the way a radio's own meter is: green up to S9, amber
|
||||
// through the S9+ range, red once the signal would be reported as 59+20 or more.
|
||||
function sSegColor(frac: number) {
|
||||
if (frac > 0.78) return '#dc2626';
|
||||
if (frac > 0.55) return '#f59e0b';
|
||||
return '#16a34a';
|
||||
}
|
||||
|
||||
// The card shell the Yaesu and Icom consoles use, so the three read alike.
|
||||
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, so the panel reads as one
|
||||
// instrument instead of a collection of differently-styled switches.
|
||||
function Toggle({ label, on, off, onClick }: { label: string; on: boolean; off: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" disabled={off} onClick={onClick}
|
||||
className={cn('px-2 py-1 rounded-md text-[11px] font-bold tracking-wide border transition-all disabled:opacity-30',
|
||||
on ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:border-primary/60')}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 }) {
|
||||
const { t } = useI18n();
|
||||
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 [err, setErr] = useState('');
|
||||
// Optimistic overlay: a slider must follow the finger, not the poll. Dropped
|
||||
// once the radio has had time to answer with the value it actually took.
|
||||
const [local, setLocal] = useState<{ rf_power?: number; af_gain?: number; rf_gain?: number; mic_gain?: number; squelch?: number; key_speed?: number }>({});
|
||||
const localAtRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const s = (await GetKenwoodState()) as KenwoodState;
|
||||
const c = (await GetCATState()) as any;
|
||||
if (!alive) return;
|
||||
setSt(s);
|
||||
setFreqHz(c?.split && c?.freq_rx_hz > 0 ? c.freq_rx_hz : (c?.freq_hz ?? 0));
|
||||
if (Date.now() - localAtRef.current > 1200) setLocal({});
|
||||
setErr('');
|
||||
} catch (e: any) {
|
||||
if (alive) setErr(String(e?.message ?? e));
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 500);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
const view = { ...st, ...local };
|
||||
const off = !st.available;
|
||||
|
||||
const setErrMsg = (e: any) => setErr(String(e?.message ?? e));
|
||||
|
||||
const put = (patch: typeof local, run: () => Promise<any>) => {
|
||||
setLocal((p) => ({ ...p, ...patch }));
|
||||
localAtRef.current = Date.now();
|
||||
run().catch((e) => setErr(String(e?.message ?? e)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||
{/* Same wrapper as the Yaesu, Icom and Flex consoles: capped width and
|
||||
CENTRED. A console stretched across a 2000 px window puts a slider a
|
||||
hand's width from its own label, and the panel stops reading as one
|
||||
instrument. */}
|
||||
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
||||
{/* VFO + status */}
|
||||
<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.elecraft ? 'Elecraft' : 'Kenwood'} {st.model}
|
||||
<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>
|
||||
{/* 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"
|
||||
onClick={() => RefreshKenwood().catch(() => {})} title={t('k3.refreshHint')}>
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{off && <div className="text-xs text-muted-foreground px-1">{t('k3.waiting')}</div>}
|
||||
{!!err && <div className="text-[11px] text-danger px-1">{err}</div>}
|
||||
|
||||
{/* Meters */}
|
||||
<Card icon={Activity} title={t('k3.meters')}>
|
||||
<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}
|
||||
accent="#16a34a" segColor={sSegColor}
|
||||
display={view.transmitting ? '—' : sParts(view.s_meter_raw, view.elecraft).label}
|
||||
onClick={() => {
|
||||
if (view.transmitting || !onReportRST) return;
|
||||
const sp = sParts(view.s_meter_raw, view.elecraft);
|
||||
onReportRST(sMeterRST(sp.s, sp.over, view.mode));
|
||||
}}
|
||||
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"
|
||||
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:
|
||||
a match that looks ideal on an antenna nobody has measured is the one
|
||||
reading that can cost a radio. */}
|
||||
<MeterBar label="SWR" value={view.transmitting && view.swr > 0 ? view.swr : 1} lo={1} hi={4}
|
||||
accent="#f59e0b"
|
||||
display={view.transmitting && view.swr > 0 ? view.swr.toFixed(1) : '—'} />
|
||||
</div>
|
||||
{view.meters_provisional && (
|
||||
<p className="text-[10px] text-muted-foreground">{t('k3.provisional')}</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* MOX + TUNE */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" disabled={off}
|
||||
onClick={() => SetKenwoodTX(!view.transmitting).catch((e) => setErr(String(e?.message ?? e)))}
|
||||
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
view.transmitting
|
||||
? 'bg-danger text-danger-foreground border-danger shadow-[0_0_14px] shadow-danger/50'
|
||||
: 'bg-card text-danger border-danger hover:bg-danger-muted')}>
|
||||
<Power className="size-4 inline mr-1 -mt-0.5" /> MOX
|
||||
</button>
|
||||
<button type="button" disabled={off}
|
||||
onClick={() => TuneKenwoodATU().catch((e) => setErr(String(e?.message ?? e)))}
|
||||
title={t('k3.tuneHint')}
|
||||
className="flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 border-warning text-warning bg-card hover:bg-warning-muted transition-all disabled:opacity-30">
|
||||
<Activity className="size-4 inline mr-1 -mt-0.5" /> TUNE
|
||||
</button>
|
||||
{/* The HOLD of the same switch: tuner in line or bypassed. Two buttons
|
||||
because they are two things on the radio — tuning is a cycle you
|
||||
start, bypassing is a state you leave it in. */}
|
||||
<button type="button" disabled={off}
|
||||
onClick={() => ToggleKenwoodATU().catch(setErrMsg)}
|
||||
title={t('k3.atuHint')}
|
||||
className="px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 border-border text-muted-foreground bg-card hover:bg-muted transition-all disabled:opacity-30">
|
||||
ATU
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Levels — two columns, so a slider stays beside the label it belongs to
|
||||
instead of running the width of the window. */}
|
||||
<Card icon={SlidersHorizontal} title={t('k3.levels')}>
|
||||
<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">
|
||||
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.power')}</span>
|
||||
<WheelRange min={0} max={110} step={1} disabled={off}
|
||||
value={view.rf_power ?? 0}
|
||||
onChange={(n) => put({ rf_power: n }, () => SetKenwoodPower(n))} />
|
||||
<span className="w-12 text-right font-mono tabular-nums">{view.rf_power ?? 0} W</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<span className="w-16 shrink-0 text-muted-foreground flex items-center gap-1">
|
||||
<AudioLines className="size-3.5" /> {t('k3.volume')}
|
||||
</span>
|
||||
<WheelRange min={0} max={100} disabled={off}
|
||||
value={view.af_gain ?? 0}
|
||||
onChange={(n) => put({ af_gain: n }, () => SetKenwoodAFGain(n))} />
|
||||
<span className="w-12 text-right font-mono tabular-nums">{view.af_gain ?? 0}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.rfGain')}</span>
|
||||
<WheelRange min={0} max={100} disabled={off}
|
||||
value={view.rf_gain ?? 0}
|
||||
onChange={(n) => put({ rf_gain: n }, () => SetKenwoodRFGain(n))} />
|
||||
<span className="w-12 text-right font-mono tabular-nums">{view.rf_gain ?? 0}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.micGain')}</span>
|
||||
<WheelRange min={0} max={100} disabled={off}
|
||||
value={view.mic_gain ?? 0}
|
||||
onChange={(n) => put({ mic_gain: n }, () => SetKenwoodMicGain(n))} />
|
||||
<span className="w-12 text-right font-mono tabular-nums">{view.mic_gain ?? 0}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.squelch')}</span>
|
||||
<WheelRange min={0} max={100} disabled={off}
|
||||
value={view.squelch ?? 0}
|
||||
onChange={(n) => put({ squelch: n }, () => SetKenwoodSquelch(n))} />
|
||||
<span className="w-12 text-right font-mono tabular-nums">{view.squelch ?? 0}</span>
|
||||
</label>
|
||||
{/* CW keyer speed — the radio's own keyer, the one the K3 sends with. */}
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.keySpeed')}</span>
|
||||
<WheelRange min={8} max={50} disabled={off}
|
||||
value={view.key_speed || 20}
|
||||
onChange={(n) => put({ key_speed: n }, () => SetKenwoodKeySpeed(n))} />
|
||||
<span className="w-12 text-right font-mono tabular-nums">{view.key_speed || 0} wpm</span>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Receive chain. Toggles, because that is what they are on the radio:
|
||||
one press each, and the state comes back from the rig rather than from
|
||||
what the button was asked to do. */}
|
||||
<Card icon={AudioLines} title={t('k3.receive')}>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Toggle label="PRE" on={view.preamp} off={off} onClick={() => SetKenwoodPreamp(!view.preamp).catch(setErrMsg)} />
|
||||
<Toggle label="ATT" on={view.att} off={off} onClick={() => SetKenwoodAtt(!view.att).catch(setErrMsg)} />
|
||||
<Toggle label="NB" on={view.nb} off={off} onClick={() => SetKenwoodNB(!view.nb).catch(setErrMsg)} />
|
||||
<Toggle label="NR" on={view.nr} off={off} onClick={() => SetKenwoodNR(!view.nr).catch(setErrMsg)} />
|
||||
<span className="w-2" />
|
||||
{['OFF', 'SLOW', 'FAST'].map((a) => (
|
||||
<Toggle key={a} label={a} on={(view.agc || '').toUpperCase() === a} off={off}
|
||||
onClick={() => SetKenwoodAGC(a).catch(setErrMsg)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* RIT / XIT — the shared ShiftRow, exactly as the Icom and TCI consoles
|
||||
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">
|
||||
{/* Antenna only when the radio answered AN — a K3 without the internal
|
||||
ATU has one socket and no switch to offer. */}
|
||||
{view.antenna > 0 && (<>
|
||||
<span className="w-2" />
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('k3.antenna')}</span>
|
||||
{[1, 2].map((n) => (
|
||||
<Toggle key={n} label={'ANT' + n} on={view.antenna === n} off={off}
|
||||
onClick={() => SetKenwoodAntenna(n).catch(setErrMsg)} />
|
||||
))}
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
{/* Filter width */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('k3.filter')}</span>
|
||||
{FILTERS.map((hz) => (
|
||||
<Toggle key={hz} label={hz >= 1000 ? (hz / 1000).toFixed(1) + 'k' : String(hz)}
|
||||
on={view.filter_hz === hz} off={off}
|
||||
onClick={() => SetKenwoodFilter(hz).catch(setErrMsg)} />
|
||||
))}
|
||||
{view.filter_hz > 0 && (
|
||||
<span className="text-[10px] font-mono text-muted-foreground">{view.filter_hz} Hz</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import { useCallback, 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';
|
||||
import { loadMapView, saveMapView, MAP_VIEW_FT } from '@/lib/mapView';
|
||||
import { loadMapBase, saveMapBase, MAP_BASE_FT } from '@/lib/mapBase';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { GetHearMe, SetHearMe, GetWhoHearsMe } from '../../wailsjs/go/main/App';
|
||||
import { Ear } from 'lucide-react';
|
||||
|
||||
// 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.
|
||||
|
||||
// The map is handed the SAME decode objects the list works from — it only reads
|
||||
// a few of the fields. The rest travel with them so a click here can answer the
|
||||
// station exactly as a double-click in the list does.
|
||||
export type FTMapDecode = {
|
||||
call: string;
|
||||
grid?: string;
|
||||
band?: string;
|
||||
snr: number;
|
||||
at: string;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
|
||||
// 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';
|
||||
|
||||
// One colour for every arc, overriding the palette. Empty means per band,
|
||||
// which stays the default.
|
||||
//
|
||||
// The palette is only useful to somebody watching several bands at once, and
|
||||
// it is calibrated against a plain map: 60m navy and 70cm olive all but
|
||||
// disappear over the satellite imagery, and 20m yellow over the deserts. An
|
||||
// operator on one band has nothing to lose by painting the whole map in a
|
||||
// colour that shows up against the ground he chose.
|
||||
const COL_KEY = 'opslog.ftMapColour';
|
||||
|
||||
// The reverse layer's own colour. Empty means the default below.
|
||||
//
|
||||
// Separate from COL_KEY on purpose: the decode colour exists because the
|
||||
// band palette vanishes over some basemaps, and this one has exactly the
|
||||
// same problem for exactly the same reason — cyan over a pale sea reads no
|
||||
// better than 60m navy does. One control for both would have forced the
|
||||
// two layers into one colour, which is the distinction it took a shape to
|
||||
// make in the first place.
|
||||
const HEARD_COL_KEY = 'opslog.ftMapHeardColour';
|
||||
|
||||
// A colour input only accepts #rrggbb, so anything else stored here is
|
||||
// treated as no choice at all rather than driving the swatch to black.
|
||||
const asHex = (v: string) => (/^#[0-9a-f]{6}$/i.test(v.trim()) ? v.trim() : '');
|
||||
|
||||
// One station reporting our own transmissions, from PSK Reporter.
|
||||
type Heard = { call: string; grid: string; band: string; mode: string; snr: number; at: string };
|
||||
|
||||
// The reverse layer is marks only, in one colour whatever the band, and the
|
||||
// mark is a DIAMOND.
|
||||
//
|
||||
// It started with an arc per station, like the decodes, and that was wrong:
|
||||
// with a few dozen receivers reporting, the map became a fan of lines out of
|
||||
// one square that buried the very arcs it sat beside. Nothing was gained by
|
||||
// them either — an arc's job on the decode layer is to say WHICH of many
|
||||
// stations a path belongs to, and here every path starts at the same place.
|
||||
//
|
||||
// Shape, then, rather than colour, carries the distinction: the decode dots
|
||||
// are small filled circles, and the arcs already use fourteen colours, so a
|
||||
// fifteenth would read as another band. A diamond is unmistakably not one of
|
||||
// them at a glance.
|
||||
//
|
||||
// Filled, with a hairline white edge — the same trick the home marker uses.
|
||||
// It was a ring, and a ring is an outline drawn over whatever is beneath it:
|
||||
// eight pixels of it over the satellite imagery was barely there.
|
||||
const HEARD_COLOUR = '#22d3ee'; // the default, when nothing is chosen
|
||||
|
||||
const MAX_ARCS = 300;
|
||||
const MAX_AGE_MS = 30 * 60_000;
|
||||
|
||||
export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
||||
decodes: FTMapDecode[];
|
||||
myGrid: string;
|
||||
// One click takes the station, two answer it — the list's own gestures.
|
||||
onSelect?: (d: FTMapDecode) => void;
|
||||
onCall?: (d: FTMapDecode) => void;
|
||||
}) {
|
||||
// Held in refs so the redraw below does not have to list them as dependencies
|
||||
// and rebuild every arc whenever the parent re-renders.
|
||||
const [colour, setColour] = useState(() => asHex(localStorage.getItem(COL_KEY) ?? ''));
|
||||
const [heardColour, setHeardColour] = useState(() => asHex(localStorage.getItem(HEARD_COL_KEY) ?? ''));
|
||||
const heardInk = heardColour || HEARD_COLOUR;
|
||||
// Whether the reverse feed is wanted lives in the DB, not here: it is what
|
||||
// starts an MQTT subscription, so the backend has to be the one that knows.
|
||||
const [hearMe, setHearMe] = useState(false);
|
||||
const [heard, setHeard] = useState<Heard[]>([]);
|
||||
const [heardBusy, setHeardBusy] = useState(false);
|
||||
const selectRef = useRef(onSelect);
|
||||
const callRef = useRef(onCall);
|
||||
useEffect(() => { selectRef.current = onSelect; callRef.current = onCall; }, [onSelect, onCall]);
|
||||
// Distinguishing the two gestures is ours to do: Leaflet fires click before
|
||||
// dblclick and leaves the telling apart to the handler.
|
||||
const clickTimer = useRef<number | undefined>(undefined);
|
||||
useEffect(() => () => window.clearTimeout(clickTimer.current), []);
|
||||
// Where this map was left. Panning and zooming it is the operator saying which
|
||||
// part of the world they are working; throwing that away on every tab switch
|
||||
// made it something to set up again rather than something to glance at.
|
||||
const saved = useRef(loadMapView(MAP_VIEW_FT));
|
||||
const { t } = useI18n();
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const layerRef = useRef<L.LayerGroup | null>(null);
|
||||
// Its own group: the reverse layer refreshes on its own clock, and clearing
|
||||
// the decode arcs to redraw it would throw away three hundred polylines
|
||||
// every twenty seconds for nothing.
|
||||
const heardLayerRef = 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: saved.current ? [saved.current.lat, saved.current.lon] : [25, 0],
|
||||
zoom: saved.current ? saved.current.zoom : 2,
|
||||
minZoom: 2,
|
||||
});
|
||||
// Every move, not just the deliberate ones: a zoom is as much a choice as a
|
||||
// pan, and there is no moment afterwards at which to ask.
|
||||
m.on('moveend', () => {
|
||||
const c = m.getCenter();
|
||||
saveMapView(MAP_VIEW_FT, c.lat, c.lng, m.getZoom());
|
||||
});
|
||||
mapRef.current = m;
|
||||
layerRef.current = L.layerGroup().addTo(m);
|
||||
heardLayerRef.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;
|
||||
heardLayerRef.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);
|
||||
saveMapBase(MAP_BASE_FT, basemap);
|
||||
}, [basemap]);
|
||||
|
||||
useEffect(() => { GetHearMe().then((v) => setHearMe(!!v)).catch(() => {}); }, []);
|
||||
|
||||
// Polled rather than pushed: the reports arrive from the broker in batches
|
||||
// whenever an uploader gets round to it, and a fifteen-minute window redrawn
|
||||
// every twenty seconds is as live as the data underneath it actually is.
|
||||
const loadHeard = useCallback(() => {
|
||||
GetWhoHearsMe().then((r: any) => setHeard((Array.isArray(r) ? r : []) as Heard[])).catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!hearMe) { setHeard([]); return; }
|
||||
loadHeard();
|
||||
const id = window.setInterval(loadHeard, 20_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [hearMe, loadHeard]);
|
||||
|
||||
const toggleHearMe = async () => {
|
||||
setHeardBusy(true);
|
||||
const next = !hearMe;
|
||||
try {
|
||||
await SetHearMe(next);
|
||||
setHearMe(next);
|
||||
} catch {
|
||||
// The usual cause is no station callsign, which is what it subscribes
|
||||
// to. Read the state back rather than assuming either way.
|
||||
try { setHearMe(!!(await GetHearMe())); } catch { /* leave it */ }
|
||||
} finally { setHeardBusy(false); }
|
||||
};
|
||||
|
||||
// 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 stroke = 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: stroke, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
|
||||
}).addTo(layer);
|
||||
const label = `${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`;
|
||||
const mk = L.circleMarker([to.lat, to.lon], {
|
||||
// A three-pixel dot is a fine mark and a poor target, so the visible
|
||||
// radius stays and an invisible one three times the size takes the
|
||||
// clicks.
|
||||
radius: 3, color: stroke, weight: 1, fillColor: stroke, fillOpacity: 0.9 * fade,
|
||||
}).bindTooltip(label, { direction: 'top' }).addTo(layer);
|
||||
// The tooltip goes on the HIT circle too, and it is the one that matters:
|
||||
// being on top, it takes the hover as well as the click, and binding it
|
||||
// only to the dot underneath left the map silent from the moment the dots
|
||||
// became clickable — the callsign and report an operator reads by pointing
|
||||
// at a station had simply gone.
|
||||
const hit = L.circleMarker([to.lat, to.lon], {
|
||||
radius: 9, opacity: 0, fillOpacity: 0, interactive: true,
|
||||
}).bindTooltip(label, { direction: 'top' }).addTo(layer);
|
||||
for (const target of [mk, hit]) {
|
||||
target.on('click', (e) => {
|
||||
// Not to the map: a click on a station is not a click on the water.
|
||||
L.DomEvent.stopPropagation(e as unknown as Event);
|
||||
window.clearTimeout(clickTimer.current);
|
||||
clickTimer.current = window.setTimeout(() => selectRef.current?.(d), 250);
|
||||
});
|
||||
target.on('dblclick', (e) => {
|
||||
// stop(), not stopPropagation(): the map zooms on a double click, and
|
||||
// answering a station is not a request to zoom in on it.
|
||||
L.DomEvent.stop(e as unknown as Event);
|
||||
window.clearTimeout(clickTimer.current);
|
||||
callRef.current?.(d);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [decodes, myGrid, colour]);
|
||||
|
||||
// The reverse layer: one mark where each station that reported us sits, and
|
||||
// no line to it.
|
||||
//
|
||||
// A divIcon rather than a canvas circle, because canvas draws circles and
|
||||
// nothing else, and the whole point is a shape that is not a circle. The
|
||||
// cost is DOM nodes, which is affordable HERE and would not be on the decode
|
||||
// layer: this is a few dozen receivers against three hundred arcs.
|
||||
useEffect(() => {
|
||||
const layer = heardLayerRef.current;
|
||||
if (!layer) return;
|
||||
layer.clearLayers();
|
||||
if (!hearMe) return;
|
||||
const now = Date.now();
|
||||
for (const h of heard) {
|
||||
const to = gridToLatLon(h.grid);
|
||||
if (!to) continue;
|
||||
const ageMs = Math.max(0, now - Date.parse(h.at));
|
||||
const ageMin = Math.round(ageMs / 60_000);
|
||||
// Faded with age over the window, as the decode arcs are: the freshest
|
||||
// report is the one that says a path is open NOW.
|
||||
const fade = Math.max(0.3, 1 - ageMs / (15 * 60_000));
|
||||
const label = `${h.call} · ${h.grid} · ${h.snr > 0 ? '+' : ''}${h.snr} dB · ${h.band}${ageMin > 0 ? ` · ${ageMin}'` : ''}`;
|
||||
// The box is bigger than the diamond so there is something to point at:
|
||||
// a nine-pixel mark is a fine sight and a poor target.
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8],
|
||||
html: `<div style="width:16px;height:16px;display:flex;align-items:center;justify-content:center">`
|
||||
+ `<div style="width:9px;height:9px;transform:rotate(45deg);background:${heardInk};`
|
||||
+ `box-shadow:0 0 0 1px rgba(255,255,255,.75);opacity:${fade.toFixed(2)}"></div></div>`,
|
||||
});
|
||||
L.marker([to.lat, to.lon], { icon, interactive: true, keyboard: false })
|
||||
.bindTooltip(label, { direction: 'top' })
|
||||
.addTo(layer);
|
||||
}
|
||||
}, [heard, hearMe, heardInk]);
|
||||
|
||||
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.
|
||||
left-16, not left-12: Leaflet's zoom control is 30 px of buttons plus
|
||||
its 10 px margin and a border, and at 48 px this row started on top
|
||||
of it — the − button took the click that was meant for Street. */}
|
||||
<div className="absolute top-2 left-16 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>
|
||||
{/* The two things that are not the basemap, on the OTHER side.
|
||||
|
||||
They sat in the same row, which grew until it reached the middle of
|
||||
the map — and a control bar spanning half the width of a world map is
|
||||
covering the Atlantic to save a corner that was empty the whole time.
|
||||
Leaflet puts nothing top-right but the attribution, which is at the
|
||||
bottom. */}
|
||||
<div className="absolute top-2 right-2 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
|
||||
{/* Opens on the crimson the home marker already uses — chosen to hold
|
||||
up on every basemap, which is a better first suggestion than
|
||||
whichever band colour happens to be first in the palette. */}
|
||||
<input type="color" title={t('ftmap.colour')}
|
||||
className="size-5 self-center rounded border border-border bg-transparent p-0 cursor-pointer"
|
||||
value={colour || '#e11d48'}
|
||||
onChange={(e) => { const v = asHex(e.target.value); setColour(v); writeUiPref(COL_KEY, v); }} />
|
||||
{!!colour && (
|
||||
<button type="button" title={t('ftmap.colourPerBand')}
|
||||
onClick={() => { setColour(''); writeUiPref(COL_KEY, ''); }}
|
||||
className="px-1 text-[11px] text-muted-foreground hover:text-foreground">↺</button>
|
||||
)}
|
||||
<span className="mx-0.5 w-px self-stretch bg-border" />
|
||||
{/* The reverse layer. A switch, not a filter: it starts a subscription
|
||||
at the broker, so it is off until asked for. */}
|
||||
<button type="button" onClick={toggleHearMe} disabled={heardBusy}
|
||||
title={t('ftmap.hearMeTip')}
|
||||
className={cn('flex items-center gap-1 px-1.5 h-6 rounded text-[11px] disabled:opacity-50',
|
||||
hearMe ? 'font-semibold' : 'text-muted-foreground hover:bg-muted')}
|
||||
style={hearMe ? { color: heardInk } : undefined}>
|
||||
<Ear className="size-3" />
|
||||
{t('ftmap.hearMe')}
|
||||
{hearMe && <span className="tabular-nums opacity-80">{heard.length}</span>}
|
||||
</button>
|
||||
{hearMe && (
|
||||
<input type="color" title={t('ftmap.heardColour')}
|
||||
className="size-5 self-center rounded border border-border bg-transparent p-0 cursor-pointer"
|
||||
value={heardInk}
|
||||
onChange={(e) => { const v = asHex(e.target.value); setHeardColour(v); writeUiPref(HEARD_COL_KEY, v); }} />
|
||||
)}
|
||||
{hearMe && !!heardColour && (
|
||||
<button type="button" title={t('ftmap.heardColourReset')}
|
||||
onClick={() => { setHeardColour(''); writeUiPref(HEARD_COL_KEY, ''); }}
|
||||
className="px-1 text-[11px] text-muted-foreground hover:text-foreground">↺</button>
|
||||
)}
|
||||
</div>
|
||||
{/* What the diamonds are. In the legend rather than a tooltip because
|
||||
they are the only thing on the map that is not a decode of ours, and
|
||||
an unexplained second mark is worse than none.
|
||||
|
||||
Bottom-right, where the band legend is not: the two would otherwise
|
||||
stack into one block and read as one key. */}
|
||||
{hearMe && heard.length > 0 && (
|
||||
<div className="absolute bottom-2 right-2 z-[1000] flex items-center gap-1.5 rounded-md bg-background/85 backdrop-blur px-2 py-1.5 border border-border text-[11px]">
|
||||
<span className="inline-block size-2 rotate-45" style={{ background: heardInk }} />
|
||||
{t('ftmap.hearMeLegend', { n: heard.length })}
|
||||
</div>
|
||||
)}
|
||||
{/* Band legend — only the bands actually on screen. With one colour
|
||||
forced it keeps the band NAMES and drops the swatches: which bands
|
||||
are up is still worth knowing, a colour key that no longer maps to
|
||||
anything is not. */}
|
||||
{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">
|
||||
{!colour && <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>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,8 @@ type FieldType = 'text' | 'number' | 'date' | 'adifdate';
|
||||
const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
||||
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
|
||||
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' },
|
||||
// When the record entered the logbook — the way to isolate what an import added.
|
||||
{ value: 'created_at', label: 'fltb.fCreated', type: 'date' },
|
||||
{ value: 'qso_date_off', label: 'fltb.fEndDate', type: 'date' },
|
||||
{ value: 'band', label: 'fltb.fBand', type: 'text' },
|
||||
{ value: 'band_rx', label: 'fltb.fRxBand', type: 'text' },
|
||||
@@ -84,8 +86,18 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
||||
{ value: 'qrzcom_qso_download_date', label: 'fltb.fQrzRcvdDate', type: 'adifdate' },
|
||||
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' },
|
||||
{ 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_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
|
||||
// HAMLOG.online: no promoted column, filtered through extras_json (see
|
||||
// qso.filterableExtras).
|
||||
{ value: 'hamlog_sent', label: 'fltb.fHamlogSent', type: 'text' },
|
||||
{ value: 'hamlog_sent_date', label: 'fltb.fHamlogSentDate', type: 'adifdate' },
|
||||
{ value: 'hamlog_rcvd', label: 'fltb.fHamlogRcvd', type: 'text' },
|
||||
{ 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: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
||||
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
||||
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel,
|
||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
||||
GetFlexRSTChase, SaveFlexRSTChase, SetFlexRSTChaseEnabled,
|
||||
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
|
||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||
FlexSetLMSNR, FlexSetLMSNRLevel, FlexSetLMSANF, FlexSetLMSANFLevel,
|
||||
@@ -304,6 +305,25 @@ function powerLevelLabel(pl?: string): string {
|
||||
// host can keep the WinKeyer (which actually sends the macros) in sync.
|
||||
export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
// Split-pile-up chaser (see flexrstchase.go). Held here so the button can
|
||||
// paint its state without polling the backend for it.
|
||||
const [rstChase, setRstChase] = useState<{ enabled: boolean; markers: string; offset_hz: number; split_only: boolean }>(
|
||||
{ enabled: false, markers: '599', offset_hz: 0, split_only: true });
|
||||
const [rstChaseOffsetText, setRstChaseOffsetText] = useState('0');
|
||||
// The marker text is edited as raw text and only parsed on blur: it is a
|
||||
// comma-separated list, and normalising it on every keystroke would eat the
|
||||
// comma the moment it is typed.
|
||||
const [rstChaseMarkersText, setRstChaseMarkersText] = useState('599');
|
||||
// The marker field is summoned by right-clicking the button, not parked on
|
||||
// the row: it is set once to agree with SDC and then never touched, while the
|
||||
// row it was sitting in is the busiest in the panel.
|
||||
const [rstChaseEditing, setRstChaseEditing] = useState(false);
|
||||
useEffect(() => {
|
||||
GetFlexRSTChase().then((c: any) => {
|
||||
if (!c) return;
|
||||
setRstChase(c); setRstChaseOffsetText(String(c.offset_hz ?? 0)); setRstChaseMarkersText(String(c.markers ?? '599'));
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
const [st, setSt] = useState<FlexState>(ZERO);
|
||||
// Extra/"advanced" DSP rows (WNB + the SmartSDR v4 NRL/NRS/NRF/ANFL/AI-FFT
|
||||
// block) collapse behind a button so the RECEIVE card doesn't grow tall — only
|
||||
@@ -652,6 +672,64 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
st.split ? 'bg-info text-info-foreground border-info shadow-[0_0_12px] shadow-info/50' : 'bg-card text-info border-info hover:bg-info-muted')}>
|
||||
SPLIT
|
||||
</button>
|
||||
{/* The split chaser. On the panel and not in a settings page:
|
||||
it is switched on when a DXpedition appears and off when it is
|
||||
in the log, which is done mid-QSO with one hand. The offset
|
||||
sits beside it because it is the one number retuned while
|
||||
chasing — everything else about the feature is configured once. */}
|
||||
{/* CW ONLY. The marker comes from a skimmer decoding a CW report,
|
||||
so on SSB or FT8 there is nothing to chase — and a switch that
|
||||
cannot fire is an invitation to wonder why it does nothing. */}
|
||||
{isCW && (<>
|
||||
<button type="button" disabled={off}
|
||||
title={t('flxp.rstChaseHint', { m: rstChase.markers })}
|
||||
onContextMenu={(e) => { e.preventDefault(); setRstChaseEditing(true); }}
|
||||
onClick={() => { const on = !rstChase.enabled; setRstChase({ ...rstChase, enabled: on }); SetFlexRSTChaseEnabled(on).catch(() => {}); }}
|
||||
className={cn('px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
rstChase.enabled ? 'bg-success text-success-foreground border-success shadow-[0_0_12px] shadow-success/50' : 'bg-card text-success border-success hover:bg-success-muted')}>
|
||||
{rstChase.markers.split(',')[0].trim() || '599'}
|
||||
</button>
|
||||
{/* The marker is whatever SDC was told to write — "599", "5NN", or
|
||||
several. It has to agree with the skimmer or nothing ever fires,
|
||||
so it is edited here rather than in a settings page — but only
|
||||
when asked for, since it is set once and then left alone. */}
|
||||
{rstChaseEditing && (
|
||||
<input type="text" autoFocus value={rstChaseMarkersText}
|
||||
onChange={(e) => setRstChaseMarkersText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
||||
if (e.key === 'Escape') { setRstChaseMarkersText(rstChase.markers); setRstChaseEditing(false); }
|
||||
}}
|
||||
onBlur={() => {
|
||||
const v = rstChaseMarkersText.trim() || '599';
|
||||
setRstChaseMarkersText(v);
|
||||
setRstChaseEditing(false);
|
||||
if (v === rstChase.markers) return;
|
||||
const next = { ...rstChase, markers: v };
|
||||
setRstChase(next); SaveFlexRSTChase(next as any).catch(() => {});
|
||||
}}
|
||||
title={t('flxp.rstChaseMarkerHint')}
|
||||
className="w-16 h-6 rounded border border-input bg-background px-1 text-[11px] font-mono uppercase" />
|
||||
)}
|
||||
{rstChase.enabled && (
|
||||
<label className="flex items-center gap-1 text-[11px] text-muted-foreground whitespace-nowrap ml-2">
|
||||
{t('flxp.rstChaseOffset')}
|
||||
<input type="number" step={10} value={rstChaseOffsetText}
|
||||
onChange={(e) => setRstChaseOffsetText(e.target.value)}
|
||||
onBlur={() => {
|
||||
// Kept as text while typing: normalising every keystroke
|
||||
// makes a minus sign impossible to enter.
|
||||
const n = Math.round(Number(rstChaseOffsetText));
|
||||
const v = Number.isFinite(n) ? n : 0;
|
||||
setRstChaseOffsetText(String(v));
|
||||
const next = { ...rstChase, offset_hz: v };
|
||||
setRstChase(next); SaveFlexRSTChase(next as any).catch(() => {});
|
||||
}}
|
||||
className="w-16 h-6 rounded border border-input bg-background px-1.5 text-[11px] font-mono" />
|
||||
Hz
|
||||
</label>
|
||||
)}
|
||||
</>)}
|
||||
{st.split && !!st.tx_freq_hz && (
|
||||
<span className="text-[11px] font-mono text-muted-foreground whitespace-nowrap">
|
||||
TX {(st.tx_freq_hz / 1e6).toFixed(3)}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { Loader2, RefreshCw } from 'lucide-react';
|
||||
import { GridSquares } from '../../wailsjs/go/main/App';
|
||||
import { GridSquares, GridSquareChoices, GetListsSettings } from '../../wailsjs/go/main/App';
|
||||
import { gridSquareBounds, gridToLatLon } from '@/lib/maidenhead';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BASEMAPS, addBasemap, loadBasemap, type BasemapKey } from '@/components/MainMap';
|
||||
import { BASEMAPS, addBasemap, type BasemapKey } from '@/components/MainMap';
|
||||
import { loadMapBase, saveMapBase, MAP_BASE_GRIDS, MAP_BASE_WORLD } from '@/lib/mapBase';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { loadMapView, saveMapView, MAP_VIEW_GRIDS } from '@/lib/mapView';
|
||||
|
||||
// GridSquareMap — every Maidenhead square in the log, drawn on a world map.
|
||||
//
|
||||
@@ -45,20 +47,27 @@ function cssColour(token: string, fallback: string): string {
|
||||
} catch { return fallback; }
|
||||
}
|
||||
|
||||
// Mode scope. The names come straight from the backend's own classes ("ALL",
|
||||
// "PHONE", "CW", "DIGI") plus FTX, which is narrower than digital and usually
|
||||
// the honest one beside an FTx panel — a square worked on RTTY in a contest is
|
||||
// not a square worked on FT8.
|
||||
// Mode scope. The four broad classes the rest of the app uses, as buttons —
|
||||
// and then any single mode the log actually holds, from the dropdown beside
|
||||
// them.
|
||||
//
|
||||
// There used to be an FTx button here, lumping FT8, FT4 and FT2 together. It
|
||||
// was the wrong grain in both directions: "digital" already put a contest RTTY
|
||||
// square beside an FT8 one, and FTx then put FT8 beside FT4, when the question
|
||||
// this map answers is where ONE mode has been heard. The specific modes are
|
||||
// read from the log rather than listed here, so FT2 is offered to an operator
|
||||
// already using it and needs no change here the day it becomes registered.
|
||||
const SCOPES = [
|
||||
{ key: 'ALL', label: 'gsm.all' },
|
||||
{ key: 'PHONE', label: 'gsm.phone' },
|
||||
{ key: 'CW', label: 'gsm.cw' },
|
||||
{ key: 'DIGI', label: 'gsm.digital' },
|
||||
{ key: 'FTX', label: 'gsm.ftx' },
|
||||
] as const;
|
||||
type ScopeKey = typeof SCOPES[number]['key'];
|
||||
type ScopeKey = string;
|
||||
|
||||
const SCOPE_KEY = 'opslog.gridMapScope';
|
||||
const BAND_KEY = 'opslog.gridMapBand';
|
||||
const SAT_KEY = 'opslog.gridMapSat';
|
||||
// Chosen fill colours. Empty means "follow the theme", which is the default and
|
||||
// stays the default: the tokens already track the four themes, and freezing a
|
||||
// hex at first run would leave a dark-theme map painted in the light palette.
|
||||
@@ -81,11 +90,26 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
||||
const [squares, setSquares] = useState<Square[] | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [scope, setScope] = useState<ScopeKey>(
|
||||
() => (SCOPES.some((s) => s.key === localStorage.getItem(SCOPE_KEY))
|
||||
? (localStorage.getItem(SCOPE_KEY) as ScopeKey) : 'DIGI'));
|
||||
// The stored scope is taken as given rather than checked against SCOPES: it
|
||||
// may legitimately be a mode name now, and the backend answers a mode nothing
|
||||
// was worked on with no squares rather than an error.
|
||||
const [scope, setScope] = useState<ScopeKey>(() => {
|
||||
const v = localStorage.getItem(SCOPE_KEY) || 'DIGI';
|
||||
// FTX was a button until the named modes replaced it. Left as it was, no
|
||||
// control would show it selected while the map stayed filtered by it.
|
||||
return v === 'FTX' ? 'DIGI' : v;
|
||||
});
|
||||
const [band, setBand] = useState(() => localStorage.getItem(BAND_KEY) ?? '');
|
||||
const [sat, setSat] = useState(() => localStorage.getItem(SAT_KEY) ?? '');
|
||||
// What the three filters can offer. The modes and satellites are the ones the
|
||||
// squares were actually worked on; the bands are the station's own list too,
|
||||
// so a band configured but not yet worked is still there to ask about.
|
||||
const [choices, setChoices] = useState<{ modes: string[]; bands: string[]; satellites: string[] }>(
|
||||
{ modes: [], bands: [], satellites: [] });
|
||||
|
||||
const [basemap, setBasemap] = useState<BasemapKey>(loadBasemap);
|
||||
// This map's own imagery. It shared the world map's key until they were
|
||||
// separated, so a choice made back then is inherited rather than reset.
|
||||
const [basemap, setBasemap] = useState<BasemapKey>(() => loadMapBase(MAP_BASE_GRIDS, 'light', MAP_BASE_WORLD));
|
||||
const [confColour, setConfColour] = useState(() => localStorage.getItem(COL_CONFIRMED_KEY) ?? '');
|
||||
const [workedColour, setWorkedColour] = useState(() => localStorage.getItem(COL_WORKED_KEY) ?? '');
|
||||
// Repaint the squares when the THEME changes, not the basemap: the fills come
|
||||
@@ -98,17 +122,53 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
const load = async (sc: ScopeKey = scope) => {
|
||||
const load = async (sc: ScopeKey = scope, bd: string = band, st: string = sat) => {
|
||||
setBusy(true); setErr('');
|
||||
try {
|
||||
const r = (await GridSquares(sc)) as any;
|
||||
const r = (await GridSquares(sc, bd, st)) as any;
|
||||
setSquares((Array.isArray(r) ? r : []) as Square[]);
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
setSquares([]);
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
useEffect(() => { void load(scope); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [scope]);
|
||||
useEffect(() => { void load(scope, band, sat); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [scope, band, sat]);
|
||||
|
||||
// Loaded once: what the filters can offer changes only when the log does, and
|
||||
// the refresh button reloads it alongside the squares.
|
||||
const loadChoices = async () => {
|
||||
try {
|
||||
const c: any = await GridSquareChoices();
|
||||
let bands: string[] = (c?.bands ?? []) as string[];
|
||||
try {
|
||||
const ls: any = await GetListsSettings();
|
||||
const have = new Set(bands.map((b) => b.toLowerCase()));
|
||||
// Union, the station's own list first: a configured band with nothing
|
||||
// worked on it is still a fair question, and a band worked but never
|
||||
// configured must not become unreachable.
|
||||
const extra = ((ls?.bands ?? []) as string[])
|
||||
.map((b) => String(b).toLowerCase())
|
||||
.filter((b) => b && !have.has(b));
|
||||
bands = [...extra, ...bands];
|
||||
} catch { /* the log's own bands are enough */ }
|
||||
setChoices({
|
||||
modes: (c?.modes ?? []) as string[],
|
||||
bands,
|
||||
satellites: (c?.satellites ?? []) as string[],
|
||||
});
|
||||
} catch { /* the class buttons still work without it */ }
|
||||
};
|
||||
useEffect(() => { void loadChoices(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, []);
|
||||
|
||||
// Only the modes NOT already a button: listing SSB and CW again would be two
|
||||
// controls giving one answer.
|
||||
const namedModes = useMemo(
|
||||
() => choices.modes.filter((m) => !SCOPES.some((c) => c.key === m.toUpperCase())),
|
||||
[choices.modes]);
|
||||
const pick = (key: string, v: string, set: (v: string) => void) => {
|
||||
set(v);
|
||||
try { localStorage.setItem(key, v); } catch { /* quota */ }
|
||||
};
|
||||
|
||||
// One-time map creation. preferCanvas: a busy digital log is a few thousand
|
||||
// rectangles, and as SVG that is a few thousand DOM nodes to lay out on every
|
||||
@@ -128,15 +188,19 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
||||
// impossible on any window wider than it is tall: one step out was already
|
||||
// too far in.
|
||||
minZoom: 0,
|
||||
maxBoundsViscosity: 1, // don't let a drag slide the world off to one side
|
||||
|
||||
}).setView([20, 0], 2);
|
||||
// The whole world, once, whatever the window is shaped like — computed by
|
||||
// Leaflet from the container rather than guessed with a zoom number.
|
||||
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)));
|
||||
// Leaflet from the container rather than guessed with a zoom number. Unless
|
||||
// the operator has already chosen a view: theirs is the answer, and fitting
|
||||
// the world over it would undo the choice on every tab switch.
|
||||
const saved = loadMapView(MAP_VIEW_GRIDS);
|
||||
if (saved) m.setView([saved.lat, saved.lon], saved.zoom);
|
||||
else m.fitWorld({ animate: false });
|
||||
m.on('moveend', () => {
|
||||
const c = m.getCenter();
|
||||
saveMapView(MAP_VIEW_GRIDS, c.lat, c.lng, m.getZoom());
|
||||
});
|
||||
mapRef.current = m;
|
||||
layerRef.current = L.layerGroup().addTo(m);
|
||||
// Leaflet measures its container ONCE, when the map is created, and never
|
||||
@@ -148,7 +212,7 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
||||
// fitWorld above ran against a container that may still have had no size —
|
||||
// the tab is mounted hidden. Fit ONCE more the first time it really has one,
|
||||
// and never again: after that the view belongs to the operator.
|
||||
let fitted = false;
|
||||
let fitted = !!saved; // a remembered view is already the right size
|
||||
const ro = new ResizeObserver(() => {
|
||||
m.invalidateSize({ animate: false });
|
||||
const el = hostRef.current;
|
||||
@@ -236,19 +300,51 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
{SCOPES.map((s, i) => (
|
||||
<button key={s.key} type="button"
|
||||
onClick={() => { setScope(s.key); try { localStorage.setItem(SCOPE_KEY, s.key); } catch { /* quota */ } }}
|
||||
onClick={() => pick(SCOPE_KEY, s.key, setScope)}
|
||||
className={cn('px-1.5 h-6 text-[11px] whitespace-nowrap', i > 0 && 'border-l border-border',
|
||||
scope === s.key ? 'bg-primary text-primary-foreground' : 'hover:bg-muted text-muted-foreground')}>
|
||||
{t(s.label)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* One named mode, where the FTx button used to be. It shares the scope
|
||||
with the buttons rather than filtering on top of them: mode is one
|
||||
question, and two controls that both answer it is how a map ends up
|
||||
showing PHONE ∩ FT8, which is empty. Picking a mode here therefore
|
||||
un-picks the buttons, and vice versa. */}
|
||||
{namedModes.length > 0 && (
|
||||
<select
|
||||
value={namedModes.includes(scope) ? scope : ''}
|
||||
onChange={(e) => pick(SCOPE_KEY, e.target.value || 'ALL', setScope)}
|
||||
title={t('gsm.oneMode')}
|
||||
className="h-6 rounded border border-border bg-background px-1 text-[11px]"
|
||||
>
|
||||
<option value="">{t('gsm.oneMode')}</option>
|
||||
{namedModes.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{choices.bands.length > 0 && (
|
||||
<select value={band} onChange={(e) => pick(BAND_KEY, e.target.value, setBand)}
|
||||
title={t('gsm.band')} className="h-6 rounded border border-border bg-background px-1 text-[11px]">
|
||||
<option value="">{t('gsm.allBands')}</option>
|
||||
{choices.bands.map((b) => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{/* Only for a station that has worked one. A satellite dropdown on a
|
||||
purely terrestrial log is a control that can only ever be empty. */}
|
||||
{choices.satellites.length > 0 && (
|
||||
<select value={sat} onChange={(e) => pick(SAT_KEY, e.target.value, setSat)}
|
||||
title={t('gsm.satellite')} className="h-6 rounded border border-border bg-background px-1 text-[11px]">
|
||||
<option value="">{t('gsm.allSats')}</option>
|
||||
{choices.satellites.map((n) => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{t('gsm.count', { n: stats.total, c: stats.confirmed })}
|
||||
</span>
|
||||
<select
|
||||
value={basemap}
|
||||
onChange={(e) => { const v = e.target.value as BasemapKey; setBasemap(v); writeUiPref('opslog.mapBasemap', v); }}
|
||||
onChange={(e) => { const v = e.target.value as BasemapKey; setBasemap(v); saveMapBase(MAP_BASE_GRIDS, v); }}
|
||||
title={t('gsm.basemap')}
|
||||
className="h-6 rounded border border-border bg-background px-1 text-[11px]"
|
||||
>
|
||||
@@ -279,7 +375,7 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground px-1">↺</button>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
<button type="button" onClick={() => void load()} disabled={busy} title={t('gsm.refresh')}
|
||||
<button type="button" onClick={() => { void load(); void loadChoices(); }} disabled={busy} title={t('gsm.refresh')}
|
||||
className="inline-flex items-center justify-center size-6 rounded border border-border hover:bg-muted disabled:opacity-50">
|
||||
{busy ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||
</button>
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
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 {
|
||||
GetIcomState, IcomRefresh,
|
||||
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
|
||||
IcomSetANF, IcomSetAPF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
|
||||
IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetPTT,
|
||||
IcomSetScope, IcomScopeData, IcomSetScopeMode, IcomSetScopeEdges, GetCATState, SetCATFrequency, SetCATMode,
|
||||
AudioMonitorActive, AudioStartMonitor, AudioStopMonitor,
|
||||
IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetATU, IcomConsolePTT,
|
||||
GetCATState, SetCATFrequency, SetCATMode,
|
||||
IcomSetRIT, IcomSetRITOn, IcomSetXITOn,
|
||||
IcomSetAntenna, IcomSetPBTInner, IcomSetPBTOuter, IcomSetManualNotch, IcomSetNotchPos,
|
||||
IcomSetSquelch, IcomSetComp, IcomSetCompLevel, IcomSetMonitor, IcomSetMonLevel,
|
||||
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower,
|
||||
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower, IcomRecallBand,
|
||||
} 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 { ShiftRow } from '@/components/ShiftRow';
|
||||
|
||||
type IcomState = {
|
||||
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;
|
||||
rf_power: number; mic_gain: number;
|
||||
af_gain: number; rf_gain: number;
|
||||
@@ -50,7 +53,13 @@ const ZERO: IcomState = {
|
||||
type Band = { l: string; hz: number };
|
||||
|
||||
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: '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 B23 = { l: '23cm', hz: 1_296_200_000 }; // SSB calling
|
||||
|
||||
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
|
||||
// the plain SetFrequency command — no band-stacking codes needed.
|
||||
// These frequencies are the FALLBACK: with the band stacking registers switched
|
||||
// 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
|
||||
// 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];
|
||||
}
|
||||
|
||||
// Mode buttons for the console (like RS-BA1's row). SetCATMode picks USB/LSB for
|
||||
// SSB by frequency and the rig's data variant for digital modes.
|
||||
const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM'];
|
||||
// Mode buttons for the console (like RS-BA1's row).
|
||||
//
|
||||
// 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
|
||||
// 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
|
||||
// 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 }[] {
|
||||
const m = (model ?? '').toUpperCase();
|
||||
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' }];
|
||||
}
|
||||
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.
|
||||
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 (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;
|
||||
}
|
||||
|
||||
@@ -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="h-full rounded-full transition-[width] duration-150" style={{ width: `${v}%`, background: accent }} />
|
||||
</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) {
|
||||
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>;
|
||||
}
|
||||
|
||||
// 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
|
||||
// CI-V 0-255 scale, +60 dB near full scale). Used for both the display label and
|
||||
// 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 } {
|
||||
if (v >= 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];
|
||||
}
|
||||
|
||||
// ScopePanadapter — enables the rig's spectrum-scope stream and draws the
|
||||
// reassembled sweep as a modern SDR panadapter: a glowing filled spectrum trace
|
||||
// on top and a scrolling colour waterfall below. Amplitudes are raw rig scale
|
||||
// (~0-160), normalised to the tallest recent peak so the trace fills the height.
|
||||
function ScopePanadapter() {
|
||||
const { t } = useI18n();
|
||||
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 ◀/▶)
|
||||
// The spectrum scope is GONE, deliberately. Every Icom streams its waveform
|
||||
// differently — the IC-7851 controls a scope it never streams, and a real
|
||||
// IC-7760 stops answering CI-V altogether a few frames in, taking CAT and
|
||||
// audio down with it — and chasing a per-model frame layout for a decoration
|
||||
// is not worth a console that drops the link. The radio has a better scope
|
||||
// on its own front panel.
|
||||
|
||||
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 } = {}) {
|
||||
// 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 [st, setSt] = useState<IcomState>(ZERO);
|
||||
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
|
||||
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 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(() => {});
|
||||
};
|
||||
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
|
||||
// re-reads the cache; the backend also loads DSP on the first responsive read).
|
||||
const refresh = async () => {
|
||||
@@ -627,7 +449,9 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
||||
const toggleMox = () => {
|
||||
const next = !txRef.current;
|
||||
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 () => {
|
||||
@@ -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).
|
||||
const split = !!cat?.split;
|
||||
const mainHz: number = split ? (cat?.freq_rx_hz || 0) : (cat?.freq_hz || 0);
|
||||
const subHz: number = split ? (cat?.freq_hz || 0) : 0;
|
||||
const curMode: string = cat?.mode || st.mode || '';
|
||||
// The sub receiver's dial is worth seeing whether or not split is on —
|
||||
// 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
|
||||
// CW and data); APF (audio peak filter) is CW-only. Fold USB/LSB into phone.
|
||||
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. */}
|
||||
{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')}
|
||||
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
|
||||
@@ -741,9 +578,10 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
||||
</div>
|
||||
</div>
|
||||
{/* 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">
|
||||
{MODES.map((m) => {
|
||||
const on = modeMatches(m, curMode);
|
||||
<div className="grid border-t border-border/60 divide-x divide-border/60"
|
||||
style={{ gridTemplateColumns: `repeat(${modesFor(st.model).length}, minmax(0, 1fr))` }}>
|
||||
{modesFor(st.model).map((m) => {
|
||||
const on = modeMatches(m, curMode, mainHz);
|
||||
return (
|
||||
<button key={m} type="button" onClick={() => setMode(m)}
|
||||
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>
|
||||
|
||||
{/* Live meters — always visible: S (RX, click → RST), Po in watts, SWR. */}
|
||||
<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">
|
||||
{/* Live meters — the SAME LED MeterBar every other console uses (Flex,
|
||||
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 (
|
||||
<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}
|
||||
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>
|
||||
|
||||
{/* Spectrum panadapter (full width). */}
|
||||
<ScopePanadapter />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{/* Band buttons + antenna selection. */}
|
||||
<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">
|
||||
{bandsFor(st.model).map((b) => {
|
||||
const here = bandOfHz(mainHz) === b.l;
|
||||
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}
|
||||
className={cn('px-1 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
||||
here
|
||||
@@ -787,20 +638,22 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Row label={t('icmp.antenna')}>
|
||||
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
|
||||
onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} />
|
||||
</Row>
|
||||
{hasAntennaSelector(st.model) && (
|
||||
<Row label={t('icmp.antenna')}>
|
||||
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
|
||||
onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} />
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */}
|
||||
<Card icon={SlidersHorizontal} title={t('icmp.clarifiers')} accent="#8b5cf6">
|
||||
<ShiftRow label="RIT" accent="#8b5cf6" on={st.rit_on} hz={st.rit_hz}
|
||||
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}
|
||||
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>
|
||||
</Card>
|
||||
|
||||
@@ -808,9 +661,16 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
||||
<Card icon={Mic} title={t('icmp.transmit')} accent="#ef4444">
|
||||
<Row label={t('icmp.power')}>
|
||||
<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>
|
||||
{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')}>
|
||||
<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>
|
||||
@@ -823,6 +683,9 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
||||
{tx ? 'TX ON' : 'MOX'}
|
||||
</button>
|
||||
<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}
|
||||
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')}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,16 +3,16 @@ import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { nightPolygon } from '../lib/greyline';
|
||||
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
||||
import { loadMapBase, saveMapBase, MAP_BASE_WORLD } from '@/lib/mapBase';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { formatDistance } from '@/lib/units';
|
||||
import { loadMapView, saveMapView, MAP_VIEW_WORLD } from '@/lib/mapView';
|
||||
|
||||
// Persisted free-pan view of the world map (when auto-zoom is off).
|
||||
function loadMapView(): { lat: number; lon: number; zoom: number } | null {
|
||||
try { const v = JSON.parse(localStorage.getItem('opslog.mapView') || 'null'); return v && typeof v.zoom === 'number' ? v : null; }
|
||||
catch { return null; }
|
||||
}
|
||||
function saveMapView(m: L.Map) {
|
||||
const loadWorldView = () => loadMapView(MAP_VIEW_WORLD);
|
||||
function saveWorldView(m: L.Map) {
|
||||
const c = m.getCenter();
|
||||
writeUiPref('opslog.mapView', JSON.stringify({ lat: c.lat, lon: c.lng, zoom: m.getZoom() }));
|
||||
saveMapView(MAP_VIEW_WORLD, c.lat, c.lng, m.getZoom());
|
||||
}
|
||||
|
||||
// The Main tab is built from two independent map panes that the operator can
|
||||
@@ -27,6 +27,26 @@ function saveMapView(m: L.Map) {
|
||||
// unwrapLon makes a lat/lon ring continuous in longitude (each point within
|
||||
// 180° of the previous) so a polygon crossing the antimeridian doesn't snap
|
||||
// across the whole map. Coords may exceed ±180; Leaflet (worldCopyJump) is fine.
|
||||
// homeView is where a world map should open for THIS station.
|
||||
//
|
||||
// Centred on 0° a world map puts Europe in the middle and leaves an Australian
|
||||
// looking at their own country in the bottom-right corner, with the paths to
|
||||
// everywhere they work running off both edges. Centred on their own longitude
|
||||
// the same map reads the way their antenna does: the Americas to the east,
|
||||
// Europe and Africa to the west. Leaflet repeats the world horizontally
|
||||
// (worldCopyJump), so this is a choice of viewpoint and costs nothing — the
|
||||
// tiles either side are the same world, and the great-circle code already draws
|
||||
// across the seam (see unwrapLon).
|
||||
//
|
||||
// The latitude is damped, not followed: a station at 69° north centred on itself
|
||||
// would spend half the map on the Arctic. It leans towards the operator's
|
||||
// hemisphere and stops well short of the pole.
|
||||
function homeView(grid: string): [number, number] {
|
||||
const home = gridToLatLon(grid);
|
||||
if (!home) return [20, 0];
|
||||
return [Math.max(-35, Math.min(45, home.lat * 0.6 + 8)), home.lon];
|
||||
}
|
||||
|
||||
function unwrapLon(ring: [number, number][]): [number, number][] {
|
||||
const out: [number, number][] = [];
|
||||
let prev = NaN;
|
||||
@@ -42,8 +62,18 @@ function unwrapLon(ring: [number, number][]): [number, number][] {
|
||||
return out;
|
||||
}
|
||||
|
||||
const CARTO_LIGHT = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
||||
const CARTO_ATTR = '© OpenStreetMap © CARTO';
|
||||
// CARTO IS GONE, and it went the same way OpenStreetMap did.
|
||||
//
|
||||
// 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,
|
||||
// and their usage policy does not cover a desktop application handed to an
|
||||
@@ -55,25 +85,42 @@ const CARTO_ATTR = '© OpenStreetMap © CARTO';
|
||||
// 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
|
||||
// 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';
|
||||
|
||||
// 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
|
||||
// LABELLED (country/continent names). `labelsUrl` adds a transparent place-name
|
||||
// overlay on top of an imagery basemap (so satellite keeps its names too).
|
||||
export type BasemapKey = 'light' | 'voyager' | 'street' | 'satellite';
|
||||
export const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: string; subdomains?: string; labelsUrl?: string }> = {
|
||||
light: { label: 'Light', url: CARTO_LIGHT, attr: CARTO_ATTR, subdomains: 'abcd' },
|
||||
voyager: { label: 'Voyager', url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
||||
attr: CARTO_ATTR, subdomains: 'abcd' },
|
||||
// maxNativeZoom is where a layer RUNS OUT of tiles. Leaflet then upscales the
|
||||
// last real one instead of asking for a level that does not exist, which is the
|
||||
// difference between a slightly soft map and a blank grey square: the light
|
||||
// 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 },
|
||||
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',
|
||||
labelsUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}' },
|
||||
};
|
||||
// loadBasemap is the WORLD map's imagery. Each map keeps its own — see
|
||||
// lib/mapBase, which is where the keys live.
|
||||
export function loadBasemap(): BasemapKey {
|
||||
const v = localStorage.getItem('opslog.mapBasemap');
|
||||
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
|
||||
return loadMapBase(MAP_BASE_WORLD, 'light');
|
||||
}
|
||||
|
||||
// addBasemap (re)installs the imagery layer and, for satellite, its transparent
|
||||
@@ -100,7 +147,8 @@ export function addBasemap(
|
||||
if (labels.current) { m.removeLayer(labels.current); labels.current = null; }
|
||||
const bm = BASEMAPS[key];
|
||||
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,
|
||||
...(opts?.bounds ? { bounds: opts.bounds } : {}),
|
||||
};
|
||||
@@ -150,31 +198,50 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
const autoZoomRef = useRef(autoZoom);
|
||||
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
|
||||
|
||||
// WAIT FOR THE SQUARE BEFORE PAINTING.
|
||||
//
|
||||
// The station's own locator arrives from the profile a moment after this
|
||||
// component mounts. Building the map immediately meant painting the world at
|
||||
// 0°, then moving it to the operator's longitude — a screenful of tiles
|
||||
// fetched from Esri and thrown away on every first run. The map is built once,
|
||||
// when it knows where it is looking.
|
||||
//
|
||||
// Not for ever, though: an operator with no locator set (or a profile still
|
||||
// loading after two seconds) gets the default view rather than a blank panel.
|
||||
const [mapReady, setMapReady] = useState(() => !!gridToLatLon(fromGrid) || !!loadWorldView());
|
||||
useEffect(() => {
|
||||
if (mapReady) return;
|
||||
if (gridToLatLon(fromGrid)) { setMapReady(true); return; }
|
||||
const t = window.setTimeout(() => setMapReady(true), 2000);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [fromGrid, mapReady]);
|
||||
|
||||
// One-time map creation.
|
||||
useEffect(() => {
|
||||
if (worldRef.current && !worldMap.current) {
|
||||
if (worldRef.current && !worldMap.current && mapReady) {
|
||||
// preferCanvas: the beam lobe is a dense FAN of translucent radials — up to
|
||||
// ~120 thick strokes with a bidirectional Ultrabeam. As SVG that is ~120
|
||||
// composited paths re-rasterised on every pan, zoom and redraw, which is
|
||||
// enough to blow WebView2's raster budget and leave the window painting in
|
||||
// patches. On canvas it is a single layer.
|
||||
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true, preferCanvas: true })
|
||||
.setView([20, 0], 1);
|
||||
.setView(homeView(fromGrid), 1);
|
||||
addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||
worldOverlay.current = L.layerGroup().addTo(m);
|
||||
worldMap.current = m;
|
||||
const sv = loadMapView();
|
||||
const sv = loadWorldView();
|
||||
if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom);
|
||||
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
|
||||
m.on('moveend', () => { if (!autoZoomRef.current) saveWorldView(m); });
|
||||
}
|
||||
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
|
||||
|
||||
// Resizing the pane is the ONLY thing that needs invalidateSize. Calling it on
|
||||
// every overlay redraw (as before) forced a full repaint of the map each time
|
||||
// the rotor moved a degree.
|
||||
const ro = new ResizeObserver(() => worldMap.current?.invalidateSize());
|
||||
if (worldRef.current) ro.observe(worldRef.current);
|
||||
return () => { window.clearTimeout(t); ro.disconnect(); };
|
||||
}, []);
|
||||
}, [mapReady]);
|
||||
|
||||
// Swap the basemap (and its optional place-name overlay) when the operator
|
||||
// picks a different one. Vector overlays (path/beam) live in Leaflet's
|
||||
@@ -342,8 +409,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
|
||||
if (autoZoom) {
|
||||
if (from && to && arcPts) {
|
||||
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
||||
arcPts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
||||
// Latitudes clamped to Mercator's edge (±85°): the arc to a polar
|
||||
// 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 });
|
||||
} else if (to) {
|
||||
wm.setView([to.lat, to.lon], 3);
|
||||
@@ -366,12 +439,15 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
|
||||
<div ref={worldRef} className="absolute inset-0" />
|
||||
{/* Basemap picker — Light / Street / Satellite (key-free tiles). */}
|
||||
<div className="absolute top-1 left-12 z-[500] flex rounded-md overflow-hidden shadow border border-border backdrop-blur">
|
||||
{/* Clear of the zoom buttons, not merely next to them: at left-12 the
|
||||
first basemap sat a few pixels from the − button and operators kept
|
||||
hitting Light when they meant to zoom out. */}
|
||||
<div className="absolute top-1 left-[4.5rem] z-[500] flex rounded-md overflow-hidden shadow border border-border backdrop-blur">
|
||||
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => { setBasemap(k); writeUiPref('opslog.mapBasemap', k); }}
|
||||
onClick={() => { setBasemap(k); saveMapBase(MAP_BASE_WORLD, k); }}
|
||||
title={`Basemap: ${BASEMAPS[k].label}`}
|
||||
className={`px-2 py-1 text-[11px] font-medium transition-colors ${
|
||||
basemap === k ? 'bg-primary text-primary-foreground' : 'bg-card/90 text-muted-foreground hover:bg-card'
|
||||
@@ -390,7 +466,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
setAutoZoom(v);
|
||||
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
|
||||
const m = worldMap.current;
|
||||
if (!v && m) saveMapView(m);
|
||||
if (!v && m) saveWorldView(m);
|
||||
}}
|
||||
title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'}
|
||||
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
|
||||
@@ -416,8 +492,8 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
</button>
|
||||
{path && (
|
||||
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
||||
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
|
||||
<span className="text-muted-foreground"> · LP</span> {Math.round(path.distanceLong).toLocaleString()} km</div>
|
||||
<div><span className="text-muted-foreground">Dist</span> {formatDistance(path.distanceShort)}
|
||||
<span className="text-muted-foreground"> · LP</span> {formatDistance(path.distanceLong)}</div>
|
||||
<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>
|
||||
</div>
|
||||
@@ -445,8 +521,12 @@ export function LocatorMap({ toGrid, toLabel }: LocatorProps) {
|
||||
// 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
|
||||
// more obligation to tolerate it than the last one was.
|
||||
L.tileLayer(CARTO_LIGHT, {
|
||||
attribution: CARTO_ATTR, subdomains: 'abcd', maxZoom: 19,
|
||||
L.tileLayer(ESRI_LIGHT, {
|
||||
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,
|
||||
}).addTo(m);
|
||||
locatorOverlay.current = L.layerGroup().addTo(m);
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
// how one of them quietly stops matching the other — a pattern button that sets
|
||||
// a different direction, a band list that tunes somewhere else.
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ArrowDownToLine, ChevronDown, ChevronUp, Loader2, Minus, Plus, RefreshCw, Antenna as AntennaIcon, X } from 'lucide-react';
|
||||
import { ArrowDownToLine, ChevronDown, ChevronUp, Loader2, Minus, Plus, RefreshCw, Ruler, Antenna as AntennaIcon, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||
SetUltrabeamDirection, UltrabeamRetract, MotorCalibrate, MotorSetElement, MotorReadElements,
|
||||
MotorTuneKHz, MotorNudgeKHz, SetMotorFollow,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
@@ -235,11 +235,29 @@ export function MotorAntennaWidget({ ant, refetch, t, onClose, essentialsOnly }:
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="button" disabled={!ant.connected}
|
||||
onClick={() => run(UltrabeamRetract())}
|
||||
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
||||
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
|
||||
</button>
|
||||
{/* Home, and — on a SteppIR — calibrate beside it. Two buttons on one
|
||||
row rather than a second full-width one: the widget is narrow and
|
||||
already tall, and the pair reads as "the two things that move every
|
||||
element at once", which is what they are. */}
|
||||
<div className={cn('grid gap-1.5', isUB ? 'grid-cols-1' : 'grid-cols-2')}>
|
||||
<button type="button" disabled={!ant.connected}
|
||||
onClick={() => run(UltrabeamRetract())}
|
||||
title={t('station.retractTip')}
|
||||
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
||||
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
|
||||
</button>
|
||||
{/* Asked first, always. Calibration runs every element to its end
|
||||
stop and takes minutes — it is the right answer to an antenna that
|
||||
tunes wrong, and the wrong answer to a stray click mid-contest. */}
|
||||
{!isUB && (
|
||||
<button type="button" disabled={!ant.connected}
|
||||
onClick={() => { if (window.confirm(t('station.calibrateConfirm'))) run(MotorCalibrate()); }}
|
||||
title={t('station.calibrateTip')}
|
||||
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-border bg-muted/40 py-1 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
||||
<Ruler className="size-3.5" /> {t('station.calibrate')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isUB && !essentialsOnly && (
|
||||
<div>
|
||||
|
||||
@@ -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,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, 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 { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
@@ -31,7 +31,7 @@ const UPLOAD_COLS: ColDef<UploadRow>[] = [
|
||||
];
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -41,6 +41,8 @@ const SERVICES = [
|
||||
{ v: 'hrdlog', label: 'HRDLog.net' },
|
||||
{ v: 'eqsl', label: 'eQSL.cc' },
|
||||
{ v: 'lotw', label: 'LoTW' },
|
||||
{ v: 'hamlog', label: 'HAMLOG.online' },
|
||||
{ v: 'hamqth', label: 'HamQTH' },
|
||||
{ v: 'pota', label: 'POTA hunter log' },
|
||||
{ v: 'paper', label: 'Paper QSL' },
|
||||
];
|
||||
@@ -254,6 +256,15 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
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
|
||||
// chosen date, 'all' = everything.
|
||||
const [sinceMode, setSinceMode] = useState<'last' | 'date' | 'all'>('last');
|
||||
@@ -349,6 +360,34 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
||||
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
|
||||
}
|
||||
|
||||
// HAMLOG.online has no download verb — their agent protocol only uploads —
|
||||
// so confirmations come back as an ADIF exported from their site. It is read
|
||||
// here rather than through the ordinary ADIF import because that one matches
|
||||
// on the UTC minute and INSERTS whatever fails to match: a few hundred copies
|
||||
// of contacts already in the log. This path only ever stamps QSOs it finds.
|
||||
async function importHamlogCfm() {
|
||||
const path = await OpenADIFFile();
|
||||
if (!path) return;
|
||||
setLogLines([]); setBusy(true); setLogAction('download'); setShowLog(true);
|
||||
try { await ImportHamlogConfirmations(path); }
|
||||
catch (e: any) { setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setBusy(false); }
|
||||
}
|
||||
|
||||
// The unmatched half of an import. Offered as a file because a few hundred
|
||||
// discrepancies are a list to work through, not something to read in a log
|
||||
// window.
|
||||
async function exportHamlogUnmatched() {
|
||||
try {
|
||||
const path = await SaveADIFFile();
|
||||
if (!path) return;
|
||||
const n = await ExportHamlogUnmatched(path);
|
||||
setLogLines((p) => [...p, `Exported ${n} unmatched confirmation(s) → ${path}`]);
|
||||
setShowLog(true);
|
||||
} catch (e: any) {
|
||||
setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); setShowLog(true);
|
||||
}
|
||||
}
|
||||
|
||||
function viewResults() {
|
||||
setShowLog(false);
|
||||
if (logAction === 'upload') selectRequired();
|
||||
@@ -405,6 +444,18 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
||||
{paperBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
||||
{t('qslm.search')}
|
||||
</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>
|
||||
</>
|
||||
) : (
|
||||
@@ -569,6 +620,10 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
||||
<thead className="sticky top-0 bg-card">
|
||||
<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>
|
||||
{/* 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.thCountry')}</th><th className="py-1.5 px-2">{t('qslm.thNew')}</th>
|
||||
</tr>
|
||||
@@ -578,6 +633,9 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
||||
<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 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.mode}</td>
|
||||
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
|
||||
@@ -665,11 +723,43 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
||||
{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 gap-2 flex-wrap">
|
||||
{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}
|
||||
title={t('qslm.hamlogImportTitle')}>
|
||||
<DownloadCloud className="size-3.5" /> {t('qslm.hamlogImportCfm')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={exportHamlogUnmatched} disabled={busy}
|
||||
title={t('qslm.hamlogUnmatchedTitle')}>
|
||||
<UploadCloud className="size-3.5 rotate-180" /> {t('qslm.hamlogUnmatched')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={download} disabled={busy}
|
||||
title={t('qslm.downloadTitle')}>
|
||||
<DownloadCloud className="size-3.5" /> {t('qslm.downloadConf')}
|
||||
</Button>
|
||||
)}
|
||||
{/* Date window */}
|
||||
{service !== 'hamlog' && (<>
|
||||
<Select value={sinceMode} onValueChange={(v) => setSinceMode(v as any)}>
|
||||
<SelectTrigger className="h-8 w-[150px] text-xs" title={t('qslm.downloadRangeTitle')}>
|
||||
<SelectValue />
|
||||
@@ -693,6 +783,20 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
||||
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
|
||||
{t('qslm.addNotFound')}
|
||||
</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>
|
||||
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
|
||||
<UploadCloud className="size-3.5" /> {t('qslm.uploadTo', { n: selectedCount, service: serviceLabel })}
|
||||
|
||||
@@ -18,8 +18,11 @@ type Props = {
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
onBulkEdit?: (ids: number[]) => void;
|
||||
onExportSelected?: (ids: number[]) => void;
|
||||
// Same rows, but keeping the APP_OPSLOG_* tags — award references above all.
|
||||
onExportSelectedFull?: (ids: number[]) => void;
|
||||
onExportSelectedFields?: (ids: number[]) => void;
|
||||
onExportFiltered?: () => void;
|
||||
onExportFilteredFull?: () => void;
|
||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||
onExportCabrilloFiltered?: () => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
@@ -31,6 +34,8 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
||||
{ service: 'hrdlog', name: 'HRDLog.net' },
|
||||
{ service: 'eqsl', name: 'eQSL.cc' },
|
||||
{ service: 'lotw', name: 'LoTW' },
|
||||
{ service: 'hamqth', name: 'HamQTH' },
|
||||
{ service: 'cloudlog', name: 'Cloudlog / Wavelog' },
|
||||
];
|
||||
|
||||
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
||||
@@ -39,7 +44,7 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
||||
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
||||
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
||||
// which used to dismiss the menu the instant it appeared.)
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFull, onExportSelectedFields, onExportFiltered, onExportFilteredFull, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||
const { t } = useI18n();
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
// Starts at the cursor; the layout effect corrects it once the real size is
|
||||
@@ -189,6 +194,20 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
<span>{t('qctx.exportSelectedAdif', { n })}</span>
|
||||
</button>
|
||||
)}
|
||||
{/* The same rows WITH the OpsLog fields.
|
||||
The export above is standard ADIF — what another logger should be
|
||||
given — and it drops every APP_ tag, award references included. A
|
||||
backup, or a move to another OpsLog, needs them: RDA@KR-04 lives in
|
||||
APP_OPSLOG_AWARDREFS and nowhere else. */}
|
||||
{onExportSelectedFull && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onExportSelectedFull(menu.ids); onClose(); }}
|
||||
>
|
||||
<FileDown className="size-4 text-info" />
|
||||
<span>{t('qctx.exportSelectedAdifFull', { n })}</span>
|
||||
</button>
|
||||
)}
|
||||
{onExportSelectedFields && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
@@ -207,6 +226,15 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
<span>{t('qctx.exportFilteredAdif')}</span>
|
||||
</button>
|
||||
)}
|
||||
{onExportFilteredFull && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
onClick={() => { onExportFilteredFull(); onClose(); }}
|
||||
>
|
||||
<FileDown className="size-4 text-info" />
|
||||
<span>{t('qctx.exportFilteredAdifFull')}</span>
|
||||
</button>
|
||||
)}
|
||||
{onExportCabrilloSelected && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
|
||||
import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings, OpenExternalURL, SetOpsLogQSLReceived } from '../../wailsjs/go/main/App';
|
||||
import { rstOptions, type RSTLists } from '@/lib/rst';
|
||||
import { rstOptions, stepRST, type RSTLists } from '@/lib/rst';
|
||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useOperatingLists } from '@/lib/operatingLists';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { flagURL } from '@/lib/flags';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { bandForMHz } from '@/lib/bandplan';
|
||||
import { titleCase, sentenceCase } from '@/lib/textCase';
|
||||
import type { QSOForm } from '@/types';
|
||||
|
||||
@@ -71,7 +73,7 @@ const CONFIRMATIONS: ConfDef[] = [
|
||||
{ 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: '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 },
|
||||
];
|
||||
// i18n label keys for confirmation channels whose label has translatable words
|
||||
@@ -80,11 +82,46 @@ const CONF_LABEL_KEYS: Record<string, string> = {
|
||||
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
|
||||
// columns and this channel is backed by ADIF extras — but it still belongs in
|
||||
// the channel picker and the status table alongside the rest.
|
||||
const OPSLOG_CONF = 'OPSLOG';
|
||||
|
||||
// HAMLOG.online. Out of CONFIRMATIONS for the same reason as the OpsLog card —
|
||||
// it is stored in the ADIF extras, not in QSO columns — but it is an upload
|
||||
// service like QRZ.com or Club Log, so it gets the ordinary sent/received/date
|
||||
// editor rather than a special one. The received key is THEIR field name, the
|
||||
// one their ADIF export writes; see award.HamlogQSLKey.
|
||||
const HAMLOG_CONF = 'HAMLOG';
|
||||
const HAMLOG_KEYS = {
|
||||
sent: 'APP_OPSLOG_HAMLOG_SENT',
|
||||
sentDate: 'APP_OPSLOG_HAMLOG_SENT_DATE',
|
||||
rcvd: 'APP_HAMLOG_QSO_CFM',
|
||||
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.
|
||||
function StatusCell({ value }: { value?: string }) {
|
||||
const { t } = useI18n();
|
||||
@@ -252,6 +289,9 @@ function QslViaSelect({ value, onChange }: { value?: string; onChange: (v: strin
|
||||
|
||||
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) {
|
||||
const { t } = useI18n();
|
||||
// Read once per opening of the editor: rigs and antennas do not change while
|
||||
// a contact is being corrected.
|
||||
const oper = useOperatingLists();
|
||||
// Use the operator's configured band/mode lists (incl. custom ones like 13cm);
|
||||
// fall back to the built-in sets. Always include the QSO's own band/mode so an
|
||||
// imported/legacy value is never silently dropped from the dropdown.
|
||||
@@ -277,6 +317,19 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
const splitHz = (hz?: number) => hz
|
||||
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
||||
: { 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 fr0 = splitHz(draft.freq_rx_hz);
|
||||
const [freqKHz, setFreqKHz] = useState(f0.khz);
|
||||
@@ -493,7 +546,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogContent overlayBlur={false} className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogHeader className="flex-row items-baseline gap-2">
|
||||
<DialogTitle>{t('qedit.title')}</DialogTitle>
|
||||
<span className="font-mono text-xs text-muted-foreground">#{draft.id} — {draft.callsign}</span>
|
||||
@@ -549,9 +602,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col w-20"><Label>S</Label>
|
||||
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_sent', v)} /></div>
|
||||
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType
|
||||
onChange={(v) => set('rst_sent', v)}
|
||||
onWheelStep={(d) => set('rst_sent', stepRST(draft.rst_sent ?? '', d, draft.mode ?? ''))} /></div>
|
||||
<div className="flex flex-col w-20"><Label>R</Label>
|
||||
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_rcvd', v)} /></div>
|
||||
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType
|
||||
onChange={(v) => set('rst_rcvd', v)}
|
||||
onWheelStep={(d) => set('rst_rcvd', stepRST(draft.rst_rcvd ?? '', d, draft.mode ?? ''))} /></div>
|
||||
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
||||
title={t('qedit.fetchTitle')}>
|
||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
||||
@@ -599,13 +656,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||
<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) => { 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 className="flex items-center gap-2">
|
||||
<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={freqRxHz} onChange={(e) => setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||
<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) => { 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>
|
||||
|
||||
@@ -715,6 +772,16 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
const def = CONFIRMATIONS.find((c) => c.key === confSel) ?? CONFIRMATIONS[0];
|
||||
const val = (k?: keyof QSOForm) => (k ? ((draft as any)[k] ?? '') : '');
|
||||
const put = (k: keyof QSOForm | undefined, v: any) => { if (k) (set as any)(k, v); };
|
||||
// The extras-backed channel reads and writes the same way, one
|
||||
// level down. Save merges the extras rather than replacing them,
|
||||
// so an emptied field keeps its stored value — which is why the
|
||||
// statuses are written as explicit Y/N and never removed.
|
||||
const exVal = (k: string) => String(draft.extras?.[k] ?? '');
|
||||
const exPut = (k: string, v: any) => {
|
||||
const next = { ...(draft.extras ?? {}) };
|
||||
next[k] = String(v ?? '');
|
||||
set('extras', next as any);
|
||||
};
|
||||
return (
|
||||
<div className="flex gap-6">
|
||||
{/* Left: edit one confirmation channel at a time */}
|
||||
@@ -724,18 +791,46 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<Select value={confSel} onValueChange={setConfSel}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<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>)}
|
||||
{/* Listed here but NOT in CONFIRMATIONS: that table maps
|
||||
{confOrder([
|
||||
...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
|
||||
extras. It gets its own editor below rather than the
|
||||
generic sent/received/date grid, which has no field
|
||||
to bind to. */}
|
||||
<SelectItem value={OPSLOG_CONF}>{t('qedit.confOpsLog')}</SelectItem>
|
||||
OpsLog card, HAMLOG.online and HamQTH — are backed by
|
||||
ADIF extras rather than QSO columns, and each gets its
|
||||
own editor below instead of the generic
|
||||
sent/received/date grid, which has no field to bind
|
||||
to. */}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{confSel === OPSLOG_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><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMLOG_KEYS.sent)} onChange={(v) => exPut(HAMLOG_KEYS.sent, v)} /></div>
|
||||
<div><Label>{t('qedit.received')}</Label><QslSelect value={exVal(HAMLOG_KEYS.rcvd)} onChange={(v) => exPut(HAMLOG_KEYS.rcvd, v)} /></div>
|
||||
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={exVal(HAMLOG_KEYS.sentDate)} onChange={(v) => exPut(HAMLOG_KEYS.sentDate, v)} /></div>
|
||||
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={exVal(HAMLOG_KEYS.rcvdDate)} onChange={(v) => exPut(HAMLOG_KEYS.rcvdDate, v)} /></div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
||||
</p>
|
||||
</>
|
||||
) : confSel === OPSLOG_CONF ? (
|
||||
/* OpsLog's own card. "Sent" is stamped when the card
|
||||
actually goes out, so it is shown, not offered: ticking
|
||||
it by hand would record something that never happened.
|
||||
@@ -809,24 +904,32 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
</tr>
|
||||
</thead>
|
||||
<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">
|
||||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
|
||||
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
|
||||
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground">—</span>}</td>
|
||||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{c.label}</td>
|
||||
<td className="w-24"><StatusCell value={c.sent} /></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>
|
||||
))}
|
||||
{/* 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>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -883,8 +986,18 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<F label={t('qedit.street')} span={2}><Input value={draft.my_street ?? ''} onChange={(e) => set('my_street', e.target.value)} /></F>
|
||||
<F label={t('qedit.city')} span={2}><Input value={draft.my_city ?? ''} onChange={(e) => set('my_city', e.target.value)} /></F>
|
||||
<F label={t('qedit.postal')} span={2}><Input value={draft.my_postal_code ?? ''} onChange={(e) => set('my_postal_code', e.target.value)} /></F>
|
||||
<F label={t('qedit.rig')} span={3}><Input value={draft.my_rig ?? ''} onChange={(e) => set('my_rig', e.target.value)} /></F>
|
||||
<F label={t('qedit.antenna')} span={3}><Input value={draft.my_antenna ?? ''} onChange={(e) => set('my_antenna', e.target.value)} /></F>
|
||||
{/* The station's own rigs and antennas (Settings ▸ Operating
|
||||
conditions), so a correction here spells them the same way
|
||||
the log already does. Free text stays: an imported contact
|
||||
carries whatever the other logger wrote. */}
|
||||
<F label={t('qedit.rig')} span={3}>
|
||||
<Combobox value={draft.my_rig ?? ''} options={oper.rigs} showToggle allowFreeText
|
||||
onChange={(v) => set('my_rig', v)} />
|
||||
</F>
|
||||
<F label={t('qedit.antenna')} span={3}>
|
||||
<Combobox value={draft.my_antenna ?? ''} options={oper.antennasFor(draft.my_rig ?? '')} showToggle allowFreeText
|
||||
onChange={(v) => set('my_antenna', v)} />
|
||||
</F>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { formatDateTimeUTC, formatDateOnly, getDateFormat, subscribeDateFormat }
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Columns3, FilterX, ListChecks } from 'lucide-react';
|
||||
import type { QSOForm } from '@/types';
|
||||
import { distanceUnit, distanceValue, subscribeDistanceUnit } from '@/lib/units';
|
||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
@@ -70,8 +71,10 @@ type Props = {
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
onBulkEdit?: (ids: number[]) => void;
|
||||
onExportSelected?: (ids: number[]) => void;
|
||||
onExportSelectedFull?: (ids: number[]) => void;
|
||||
onExportSelectedFields?: (ids: number[]) => void;
|
||||
onExportFiltered?: () => void;
|
||||
onExportFilteredFull?: () => void;
|
||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||
onExportCabrilloFiltered?: () => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
@@ -175,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' },
|
||||
// Derived, not stored: computed from the two locations at display time, like
|
||||
// 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',
|
||||
valueGetter: (p) => qsoDistanceKm(p.data, myGrid),
|
||||
{ group: 'Contacted', label: t('rqg.c.distance_km'), colId: 'distance_km',
|
||||
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 },
|
||||
{ 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 },
|
||||
@@ -207,6 +211,17 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
|
||||
// App-specific: operator marked a QSL as RECEIVED for this QSO (drives PSE/TNX).
|
||||
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_rcvd'), colId: 'opslog_qsl_card_rcvd', headerName: t('rqg.c.opslog_qsl_card_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_QSL_RCVD'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||
// HAMLOG.online. No promoted column — the ADIF standard names a field for
|
||||
// hamlog.EU and none for hamlog.ONLINE — so these read the extras, exactly
|
||||
// like the OpsLog card columns above. Hidden by default: a column per
|
||||
// service, shown to everyone, is how a grid becomes unreadable.
|
||||
{ group: 'Uploads', label: t('rqg.c.hamlog_sent'), colId: 'hamlog_sent', headerName: t('rqg.h.hamlog_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_HAMLOG_SENT'] || 'N'; }, 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_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.
|
||||
{ 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 },
|
||||
|
||||
@@ -223,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_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.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 ──
|
||||
{ group: 'Contest', label: t('rqg.c.contest_id'), colId: 'contest_id', headerName: t('rqg.h.contest_id'), field: 'contest_id' as any, width: 110 },
|
||||
@@ -309,7 +326,7 @@ const sanitizeAwardCols = (st: any[] | null | undefined): any[] =>
|
||||
return rest;
|
||||
});
|
||||
|
||||
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols, rowColors }: Props) {
|
||||
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFull, onExportSelectedFields, onExportFiltered, onExportFilteredFull, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols, rowColors }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
@@ -325,7 +342,9 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
||||
// inside the column definitions, so nothing else would notice.
|
||||
const [dateFmt, setDateFmt] = useState(getDateFormat);
|
||||
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
|
||||
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,
|
||||
// select just it; then open the bulk-action menu on the whole selection.
|
||||
@@ -741,8 +760,10 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
||||
onSendEQSL={onSendEQSL}
|
||||
onBulkEdit={onBulkEdit}
|
||||
onExportSelected={onExportSelected}
|
||||
onExportSelectedFull={onExportSelectedFull}
|
||||
onExportSelectedFields={onExportSelectedFields}
|
||||
onExportFiltered={onExportFiltered}
|
||||
onExportFilteredFull={onExportFilteredFull}
|
||||
onExportCabrilloSelected={onExportCabrilloSelected}
|
||||
onExportCabrilloFiltered={onExportCabrilloFiltered}
|
||||
onDelete={onDelete}
|
||||
@@ -768,7 +789,11 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||||
{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;
|
||||
return (
|
||||
<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
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
// The sky, seen from underneath it.
|
||||
//
|
||||
// The map answers "where is the satellite over the earth". This answers "where
|
||||
// do I look", which during a pass is the question that matters: whether the
|
||||
// bird comes over the top or clips the horizon behind the house is something no
|
||||
// amount of azimuth and elevation digits conveys, and one glance at a polar
|
||||
// plot settles it.
|
||||
//
|
||||
// The projection is the one every satellite tracker uses and every operator
|
||||
// already reads: the centre is the zenith, the outer circle is the horizon, and
|
||||
// north is up. So the radius is (90 − elevation), NOT the elevation — a
|
||||
// satellite overhead is a dot in the middle, and a pass that stays near the rim
|
||||
// is one that never rises.
|
||||
|
||||
export type SkyPoint = { at: string; az: number; el: number };
|
||||
|
||||
export function SkyPlot({ track, az, el, name, visible, size = 300 }: {
|
||||
// The pass, sampled from rise to set. Empty draws the dial alone, which is
|
||||
// still worth showing: it says where the antenna is pointing now.
|
||||
track: SkyPoint[];
|
||||
// Where the satellite is at this instant, or null when it is not up.
|
||||
az?: number | null;
|
||||
el?: number | null;
|
||||
name?: string;
|
||||
visible?: boolean;
|
||||
size?: number;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const R = size / 2 - 14; // the horizon circle
|
||||
const cx = size / 2, cy = size / 2;
|
||||
|
||||
// Where a bearing and an elevation land on the dial.
|
||||
const pt = (azDeg: number, elDeg: number): [number, number] => {
|
||||
const r = R * (90 - Math.max(0, Math.min(90, elDeg))) / 90;
|
||||
const a = (azDeg * Math.PI) / 180;
|
||||
return [cx + r * Math.sin(a), cy - r * Math.cos(a)];
|
||||
};
|
||||
|
||||
const rings = [15, 30, 45, 60, 75];
|
||||
const path = track.length > 1
|
||||
? track.map((p, i) => `${i === 0 ? 'M' : 'L'}${pt(p.az, p.el).map((v) => v.toFixed(1)).join(' ')}`).join(' ')
|
||||
: '';
|
||||
|
||||
// Ticks every 10°, longer every 30°, so the rim reads as a compass rather
|
||||
// than a plain circle.
|
||||
const ticks = [];
|
||||
for (let a = 0; a < 360; a += 10) {
|
||||
const long = a % 30 === 0;
|
||||
const rad = (a * Math.PI) / 180;
|
||||
const r1 = R, r2 = R - (long ? 7 : 4);
|
||||
ticks.push(
|
||||
<line key={a}
|
||||
x1={cx + r1 * Math.sin(rad)} y1={cy - r1 * Math.cos(rad)}
|
||||
x2={cx + r2 * Math.sin(rad)} y2={cy - r2 * Math.cos(rad)}
|
||||
stroke="var(--border)" strokeWidth={long ? 1.4 : 0.8} />,
|
||||
);
|
||||
}
|
||||
|
||||
const here = az != null && el != null ? pt(az, el) : null;
|
||||
const start = track.length > 1 ? pt(track[0].az, track[0].el) : null;
|
||||
const end = track.length > 1 ? pt(track[track.length - 1].az, track[track.length - 1].el) : null;
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${size} ${size}`} className="w-full h-auto select-none" role="img"
|
||||
aria-label={t('sat.skyPlot')}>
|
||||
<defs>
|
||||
{/* An arrowhead on the track: a pass has a direction, and which way the
|
||||
satellite is travelling decides where to point the antenna next. */}
|
||||
<marker id="skyArrow" viewBox="0 0 10 10" refX="6" refY="5"
|
||||
markerWidth="5" markerHeight="5" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--success)" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<circle cx={cx} cy={cy} r={R} fill="var(--muted)" fillOpacity={0.25}
|
||||
stroke="var(--border)" strokeWidth={1.5} />
|
||||
{rings.map((e) => (
|
||||
<circle key={e} cx={cx} cy={cy} r={R * (90 - e) / 90}
|
||||
fill="none" stroke="var(--border)" strokeWidth={0.6} strokeDasharray="3 4" />
|
||||
))}
|
||||
{ticks}
|
||||
|
||||
{/* The cardinal cross. */}
|
||||
<line x1={cx} y1={cy - R} x2={cx} y2={cy + R} stroke="var(--border)" strokeWidth={0.6} />
|
||||
<line x1={cx - R} y1={cy} x2={cx + R} y2={cy} stroke="var(--border)" strokeWidth={0.6} />
|
||||
|
||||
{([
|
||||
{ lbl: 'N', x: cx, y: cy - R - 3, anchor: 'middle' },
|
||||
{ lbl: 'S', x: cx, y: cy + R + 11, anchor: 'middle' },
|
||||
{ lbl: 'E', x: cx + R + 4, y: cy + 4, anchor: 'start' },
|
||||
{ lbl: 'W', x: cx - R - 4, y: cy + 4, anchor: 'end' },
|
||||
] as const).map((c) => (
|
||||
<text key={c.lbl} x={c.x} y={c.y} textAnchor={c.anchor}
|
||||
fontSize={11} fontWeight={600} fill="var(--muted-foreground)">{c.lbl}</text>
|
||||
))}
|
||||
|
||||
{/* Elevation labels along the west arm, the way a tracker draws them. */}
|
||||
{[0, 30, 60].map((e) => (
|
||||
<text key={e} x={cx - R * (90 - e) / 90 + 2} y={cy + 10} fontSize={8}
|
||||
fill="var(--muted-foreground)" opacity={0.8}>{e}°</text>
|
||||
))}
|
||||
|
||||
{/* The pass. */}
|
||||
{path && (
|
||||
<path d={path} fill="none" stroke="var(--success)" strokeWidth={1.6}
|
||||
strokeDasharray="5 3" markerMid="url(#skyArrow)" markerEnd="url(#skyArrow)"
|
||||
opacity={0.85} />
|
||||
)}
|
||||
{start && <circle cx={start[0]} cy={start[1]} r={3} fill="none" stroke="var(--success)" strokeWidth={1.4} />}
|
||||
{end && <circle cx={end[0]} cy={end[1]} r={3} fill="var(--success)" opacity={0.6} />}
|
||||
|
||||
{/* Where it is now. Hollow and grey below the horizon: the numbers are
|
||||
still right, but nothing can be worked through the earth. */}
|
||||
{here && (
|
||||
<g>
|
||||
<line x1={here[0] - 6} y1={here[1]} x2={here[0] + 6} y2={here[1]}
|
||||
stroke={visible ? 'var(--success)' : 'var(--muted-foreground)'} strokeWidth={1.4} />
|
||||
<line x1={here[0]} y1={here[1] - 6} x2={here[0]} y2={here[1] + 6}
|
||||
stroke={visible ? 'var(--success)' : 'var(--muted-foreground)'} strokeWidth={1.4} />
|
||||
<circle cx={here[0]} cy={here[1]} r={4}
|
||||
fill={visible ? 'var(--success)' : 'none'}
|
||||
stroke={visible ? 'var(--background)' : 'var(--muted-foreground)'} strokeWidth={1.2} />
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* The name and the look angles, in the middle, where a tracker puts them
|
||||
— big enough to read from the other side of the shack. */}
|
||||
{!!name && (
|
||||
<text x={cx} y={cy - R * 0.42} textAnchor="middle" fontSize={18} fontWeight={600}
|
||||
fill="var(--foreground)" opacity={0.85}>{name}</text>
|
||||
)}
|
||||
{az != null && el != null && (
|
||||
<text x={cx} y={cy - R * 0.22} textAnchor="middle" fontSize={12}
|
||||
fill="var(--muted-foreground)" className="tabular-nums">
|
||||
AZ {az.toFixed(1)}° EL {el.toFixed(1)}°
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical, ChevronUp, ChevronDown, Radio, Zap, Mic } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -11,6 +11,8 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
|
||||
import { RotorCompass } from '@/components/RotorCompass';
|
||||
import { RotorCompassClassic } from '@/components/RotorCompassClassic';
|
||||
import { rotorStyle, subscribeRotorStyle } from '@/lib/rotorStyle';
|
||||
import { AmpCard } from '@/components/AmpCard';
|
||||
import { TunerCard } from '@/components/TunerCard';
|
||||
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
||||
@@ -23,6 +25,9 @@ import {
|
||||
GetAmpStatuses, GetFlexState,
|
||||
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
||||
GetPSUStatus, GetPSUSettings, SetPSUOutput,
|
||||
GetCATState,
|
||||
GetWinkeyerStatus, WinkeyerSetSpeed, WinkeyerStop, WinkeyerConnect,
|
||||
GetDVKStatus, GetDVKMessages, DVKPlay, DVKStop,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
||||
@@ -80,6 +85,181 @@ function PSUCard({ st, busy, onToggle, t }: {
|
||||
);
|
||||
}
|
||||
|
||||
// ── What commands the station, and not only what it switches ───────────────
|
||||
//
|
||||
// This tab began as the relay and rotator dashboard, and stopped there: the
|
||||
// three things an operator touches most — the radio, the CW keyer and the voice
|
||||
// keyer — were the ones missing from the page that claims to show the station.
|
||||
//
|
||||
// Each card polls its own binding and holds its own state, like PSUCard above.
|
||||
// That is deliberate: they can then be dropped into the grid, reordered and
|
||||
// hidden with everything else, and adding one costs nothing to the panel around
|
||||
// it. None of them tries to be the full console — a card says what the thing is
|
||||
// doing and offers the one or two controls worth reaching for from here.
|
||||
|
||||
const fmtMHz = (hz: number) => (hz > 0 ? (hz / 1e6).toFixed(6) : '—');
|
||||
|
||||
// The radio. The frequency and the mode large, because that is what an operator
|
||||
// glances at, and the split pair underneath only when there IS a split — a
|
||||
// second frequency shown at all times is one more number to read past.
|
||||
function RigCard({ t }: { t: (k: string, v?: any) => string }) {
|
||||
const [st, setSt] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = () => GetCATState().then((s: any) => { if (alive) setSt(s); }).catch(() => {});
|
||||
tick();
|
||||
const h = window.setInterval(tick, 1000);
|
||||
return () => { alive = false; window.clearInterval(h); };
|
||||
}, []);
|
||||
const on = !!st?.connected;
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<Radio className="size-4 text-primary" />
|
||||
<div className="text-sm font-semibold truncate">{st?.rig || t('station.rig')}</div>
|
||||
<span className={cn('ml-auto size-2 rounded-full shrink-0', on ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||
title={on ? t('station.online') : (st?.error || t('station.offline'))} />
|
||||
</div>
|
||||
<div className="p-3 space-y-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xl font-semibold tabular-nums leading-none">{fmtMHz(st?.freq_hz ?? 0)}</span>
|
||||
<span className="text-xs text-muted-foreground">MHz</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap text-[11px]">
|
||||
{!!st?.mode && <span className="rounded px-1.5 py-px font-semibold bg-primary/15 text-primary border border-primary/30">{st.mode}</span>}
|
||||
{!!st?.band && <span className="text-muted-foreground">{st.band}</span>}
|
||||
{!!st?.vfo && <span className="text-muted-foreground">VFO {st.vfo}</span>}
|
||||
{!!st?.backend && <span className="ml-auto text-muted-foreground/70 truncate">{st.backend}</span>}
|
||||
</div>
|
||||
{st?.split && (
|
||||
<div className="flex items-center gap-2 text-[11px] tabular-nums">
|
||||
<span className="rounded px-1.5 py-px font-semibold bg-warning-muted text-warning-muted-foreground border border-warning-border">SPLIT</span>
|
||||
<span className="text-muted-foreground">RX {fmtMHz(st?.freq_rx_hz ?? 0)}</span>
|
||||
</div>
|
||||
)}
|
||||
{!on && (
|
||||
<div className="text-[11px] text-muted-foreground truncate" title={st?.error || ''}>
|
||||
{st?.enabled ? (st?.error || t('station.rigDown')) : t('station.rigOff')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The CW keyer. Speed is the control an operator reaches for mid-QSO — a
|
||||
// station answers faster or slower than expected and the reply has to match —
|
||||
// so it is here rather than only in the docked panel, and Stop is beside it
|
||||
// because a message sent to the wrong callsign has to end NOW.
|
||||
function KeyerCard({ t }: { t: (k: string, v?: any) => string }) {
|
||||
const [st, setSt] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = () => GetWinkeyerStatus().then((s: any) => { if (alive) setSt(s); }).catch(() => {});
|
||||
tick();
|
||||
const h = window.setInterval(tick, 1000);
|
||||
return () => { alive = false; window.clearInterval(h); };
|
||||
}, []);
|
||||
const on = !!st?.connected;
|
||||
const wpm = st?.wpm || 0;
|
||||
const step = (d: number) => {
|
||||
const w = Math.max(5, Math.min(50, wpm + d));
|
||||
setSt((cur: any) => ({ ...(cur ?? {}), wpm: w })); // shows at once; the poll confirms
|
||||
WinkeyerSetSpeed(w).catch(() => {});
|
||||
};
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<Zap className="size-4 text-primary" />
|
||||
<div className="text-sm font-semibold truncate">{t('station.keyer')}</div>
|
||||
{st?.busy && <span className="text-[10px] font-bold text-danger animate-pulse">TX</span>}
|
||||
<span className={cn('ml-auto size-2 rounded-full shrink-0', on ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||
title={on ? t('station.online') : (st?.error || t('station.offline'))} />
|
||||
</div>
|
||||
<div className="p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon" className="size-7" disabled={!on} onClick={() => step(-1)}>
|
||||
<Minus className="size-3.5" />
|
||||
</Button>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-xl font-semibold tabular-nums leading-none">{wpm || '—'}</span>
|
||||
<span className="text-xs text-muted-foreground">WPM</span>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" className="size-7" disabled={!on} onClick={() => step(1)}>
|
||||
<Plus className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="ml-auto h-7 px-2" disabled={!on || !st?.busy}
|
||||
onClick={() => WinkeyerStop().catch(() => {})}>
|
||||
<Square className="size-3 mr-1" />{t('station.stop')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="truncate">{st?.port || t('station.noPort')}</span>
|
||||
{!!st?.version && <span className="ml-auto shrink-0">v{st.version}</span>}
|
||||
</div>
|
||||
{!on && (
|
||||
<Button variant="outline" size="sm" className="w-full h-7"
|
||||
onClick={() => WinkeyerConnect().catch(() => {})}>
|
||||
{t('station.connect')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The voice keyer. The messages themselves, because a card that only said
|
||||
// "idle" would be a light and not a control — from here a CQ goes out without
|
||||
// leaving the tab.
|
||||
function VoiceKeyerCard({ t }: { t: (k: string, v?: any) => string }) {
|
||||
const [st, setSt] = useState<any>({ playing: false, recording: false });
|
||||
const [msgs, setMsgs] = useState<any[]>([]);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = () => GetDVKStatus().then((s: any) => { if (alive) setSt(s ?? {}); }).catch(() => {});
|
||||
tick();
|
||||
const h = window.setInterval(tick, 1000);
|
||||
// The recordings change when the operator records one, which is rare and
|
||||
// never from this tab — read once, and again only on a status change worth
|
||||
// it would be more machinery than it saves.
|
||||
GetDVKMessages().then((m: any[]) => { if (alive) setMsgs(m ?? []); }).catch(() => {});
|
||||
return () => { alive = false; window.clearInterval(h); };
|
||||
}, []);
|
||||
const recorded = msgs.filter((m) => m.has_audio);
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<Mic className="size-4 text-primary" />
|
||||
<div className="text-sm font-semibold truncate">{t('station.voiceKeyer')}</div>
|
||||
{st?.playing && <span className="text-[10px] font-bold text-danger animate-pulse">TX</span>}
|
||||
{st?.recording && <span className="text-[10px] font-bold text-warning animate-pulse">REC</span>}
|
||||
<Button variant="ghost" size="sm" className="ml-auto h-6 px-2 text-[11px]"
|
||||
disabled={!st?.playing} onClick={() => DVKStop().catch(() => {})}>
|
||||
<Square className="size-3 mr-1" />{t('station.stop')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{recorded.length === 0 ? (
|
||||
<div className="text-[11px] text-muted-foreground">{t('station.noVoiceMsg')}</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{recorded.map((m) => (
|
||||
<button key={m.slot} type="button"
|
||||
onClick={() => DVKPlay(m.slot).catch(() => {})}
|
||||
disabled={st?.playing}
|
||||
title={`${m.duration_sec?.toFixed?.(1) ?? ''}s`}
|
||||
className="rounded-md border border-border bg-muted/30 px-2 py-1 text-[11px] font-medium hover:bg-muted disabled:opacity-40">
|
||||
<span className="text-muted-foreground mr-1">F{m.slot}</span>
|
||||
{m.label || `#${m.slot}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Device = {
|
||||
id: string; type: string; name: string; host: string;
|
||||
user?: string; pass?: string; channels?: number; labels: string[];
|
||||
@@ -128,6 +308,11 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
||||
}) {
|
||||
const [goto, setGoto] = 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 a = ((Math.round(az) % 360) + 360) % 360;
|
||||
@@ -145,9 +330,12 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
||||
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
|
||||
</div>
|
||||
<div className="p-3 flex gap-4 items-start">
|
||||
{/* The SP/LP readout lives INSIDE RotorCompass, so every compass in the
|
||||
app carries it rather than each caller drawing its own. */}
|
||||
<RotorCompass
|
||||
{/* The compact compass is the dial alone — short and long path are the
|
||||
two coloured dots on it. The figures are in the status bar. */}
|
||||
{/* 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}
|
||||
headings={hd.ok ? [hd.azimuth] : []}
|
||||
centerLat={centerLat ?? null}
|
||||
@@ -158,6 +346,7 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
||||
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
|
||||
onGoto={(az) => turn(az)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="font-mono">
|
||||
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
|
||||
@@ -306,6 +495,25 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
}, [poll, pollAnt, devices.length]);
|
||||
|
||||
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
|
||||
|
||||
// Whether the two keyers exist at this station. Asked ONCE, on opening the
|
||||
// tab: a keyer is bought, wired and configured, not something that appears
|
||||
// mid-session, and polling for the answer would be a round trip a second for
|
||||
// a fact that does not change. A keyer counts as present when it is connected
|
||||
// or a port is configured for it, the voice keyer when at least one message
|
||||
// has actually been recorded — an empty set of slots is not a keyer.
|
||||
const [keyerShown, setKeyerShown] = useState(false);
|
||||
const [dvkShown, setDvkShown] = useState(false);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
GetWinkeyerStatus().then((s: any) => {
|
||||
if (alive) setKeyerShown(!!s && (!!s.connected || !!String(s.port ?? '').trim()));
|
||||
}).catch(() => {});
|
||||
GetDVKMessages().then((m: any[]) => {
|
||||
if (alive) setDvkShown((m ?? []).some((x) => x?.has_audio));
|
||||
}).catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
// Reorder so `dragged` lands just before `target`.
|
||||
const onDrop = (targetId: string) => {
|
||||
const src = dragId.current; dragId.current = null;
|
||||
@@ -408,6 +616,13 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
// single ~430px column — they are the same cards the FlexRadio panel shows
|
||||
// full-width, and they need that room here too.
|
||||
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
|
||||
// The radio first: it is the station, and everything else on this page is
|
||||
// something attached to it. Then the two keyers, each only when there is
|
||||
// something behind it — an operator who works neither CW nor voice keyer
|
||||
// should not be given two dead cards to read past.
|
||||
widgets.push({ id: 'rig', node: <RigCard t={t} /> });
|
||||
if (keyerShown) widgets.push({ id: 'keyer', node: <KeyerCard t={t} /> });
|
||||
if (dvkShown) widgets.push({ id: 'dvk', node: <VoiceKeyerCard t={t} />, wide: true });
|
||||
if (rot.enabled) {
|
||||
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pokeRotorHeading} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||||
}
|
||||
|
||||
@@ -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,8 @@ import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
|
||||
import {
|
||||
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
|
||||
GetWsjtHighlight, SetWsjtHighlight, GetWsjtHighlightWorked, SetWsjtHighlightWorked, GetWsjtFollowMode, SetWsjtFollowMode,
|
||||
GetWsjtHighlightColours, SetWsjtHighlightColours,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -158,6 +160,19 @@ const TRIGGERS = [
|
||||
type Props = { onError: (msg: string) => void };
|
||||
|
||||
export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
const [highlightOn, setHighlightOn] = useState(false);
|
||||
const [hlWorked, setHlWorked] = useState(false);
|
||||
// The palette, as chosen. Background per verdict; the text colour is the
|
||||
// backend’s business (see colourFor).
|
||||
const [colours, setColours] = useState({ watchlist: '#F472B6', new_dxcc: '#16823C', new_band: '#E27A18', worked: '#4B5563' });
|
||||
const [followMode, setFollowMode] = useState(true);
|
||||
useEffect(() => {
|
||||
GetWsjtHighlight().then((v) => setHighlightOn(!!v)).catch(() => {});
|
||||
GetWsjtHighlightColours().then((c: any) => { if (c) setColours(c); }).catch(() => {});
|
||||
GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {});
|
||||
GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {});
|
||||
GetWsjtFollowMode().then((v) => setFollowMode(!!v)).catch(() => {});
|
||||
}, []);
|
||||
const { t } = useI18n();
|
||||
const [items, setItems] = useState<UDPConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -229,10 +244,72 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
||||
{t('udpp.intro')}
|
||||
</div>
|
||||
|
||||
{/* Log-aware colours in WSJT-X / JTDX's own window — lives HERE because
|
||||
this panel is where the WSJT-X link is configured. */}
|
||||
<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>
|
||||
{/* The palette, nested under the switch for the same reason as the box
|
||||
below it. One colour per verdict, and the background only: the text
|
||||
colour is worked out from it, so a chosen colour cannot come back
|
||||
unreadable in the decoder's window. */}
|
||||
{highlightOn && (
|
||||
<div className="pl-6 max-w-2xl space-y-1.5">
|
||||
<div className="text-[11px] text-muted-foreground">{t('udpp.hlColours')}</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{([
|
||||
['watchlist', t('udpp.hlWatchlist')],
|
||||
['new_dxcc', t('udpp.hlNewDxcc')],
|
||||
['new_band', t('udpp.hlNewBand')],
|
||||
['worked', t('udpp.hlWorkedC')],
|
||||
] as const).map(([k, label]) => (
|
||||
<label key={k} className="inline-flex items-center gap-1.5 text-xs">
|
||||
<input
|
||||
type="color"
|
||||
value={(colours as any)[k] || '#000000'}
|
||||
onChange={(e) => {
|
||||
const next = { ...colours, [k]: e.target.value.toUpperCase() };
|
||||
setColours(next);
|
||||
void SetWsjtHighlightColours(next as any);
|
||||
}}
|
||||
className="h-6 w-8 rounded border border-border bg-background p-0.5 cursor-pointer"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
<button type="button" className="text-xs text-muted-foreground underline hover:text-foreground"
|
||||
onClick={() => {
|
||||
const def = { watchlist: '#F472B6', new_dxcc: '#16823C', new_band: '#E27A18', worked: '#4B5563' };
|
||||
setColours(def as any);
|
||||
void SetWsjtHighlightColours(def as any);
|
||||
}}>
|
||||
{t('udpp.hlReset')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 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>
|
||||
</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
|
||||
title={t('udpp.inboundTitle')}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// A range slider the mouse wheel can move.
|
||||
//
|
||||
// Dragging a 4-pixel-tall slider to change the power by five watts is a fussy
|
||||
// gesture; rolling the wheel over it is not, and it is what an operator reaches
|
||||
// for after using the Flex and Yaesu consoles, which have had it for a while.
|
||||
//
|
||||
// React's own onWheel is registered PASSIVE, so preventDefault() inside it is
|
||||
// ignored and the panel scrolls under the pointer while the value changes. The
|
||||
// listener therefore has to be attached natively with { passive: false }, and
|
||||
// the live values read through refs — the listener is installed once and would
|
||||
// otherwise capture the first render's value for ever.
|
||||
//
|
||||
// The Flex and Yaesu panels each grew their own copy of this before it was worth
|
||||
// sharing. They are left alone deliberately: they work, they are in daily use,
|
||||
// and their styling differs in small ways that a merge would have to guess at.
|
||||
export function WheelRange({ value, onChange, min = 0, max = 100, step = 1, disabled, accent = 'var(--primary)', className }: {
|
||||
value: number; onChange: (v: number) => void;
|
||||
min?: number; max?: number; step?: number; disabled?: boolean; accent?: string; className?: string;
|
||||
}) {
|
||||
const v = Math.max(min, Math.min(max, value));
|
||||
const pct = max > min ? ((v - min) / (max - min)) * 100 : 0;
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const valRef = useRef(v); valRef.current = v;
|
||||
const cbRef = useRef(onChange); cbRef.current = onChange;
|
||||
const disRef = useRef(disabled); disRef.current = disabled;
|
||||
const stepRef = useRef(step); stepRef.current = step;
|
||||
const minRef = useRef(min); minRef.current = min;
|
||||
const maxRef = useRef(max); maxRef.current = max;
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (disRef.current) return;
|
||||
e.preventDefault();
|
||||
const d = e.deltaY < 0 ? stepRef.current : -stepRef.current;
|
||||
const nv = Math.max(minRef.current, Math.min(maxRef.current, valRef.current + d));
|
||||
if (nv !== valRef.current) cbRef.current(nv);
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="range" min={min} max={max} step={step} value={v} disabled={disabled}
|
||||
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
|
||||
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
|
||||
'[&::-webkit-slider-thumb]:bg-card [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:cursor-pointer',
|
||||
className)}
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${accent} ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) ${pct}%)`,
|
||||
borderColor: accent,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ interface Props {
|
||||
wpm: number;
|
||||
macros: WKMacro[];
|
||||
sent: string; // text echoed back by the keyer as it transmits
|
||||
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | '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
|
||||
onSetBreakIn?: (mode: number) => void;
|
||||
onSelectPort: (p: string) => void;
|
||||
@@ -73,9 +73,17 @@ export function WinkeyerPanel({
|
||||
|
||||
const connected = status.connected;
|
||||
|
||||
// Send-on-type only exists where the engine can key one character at a time:
|
||||
// the WinKeyer and Flex CWX. The stored preference survives an engine change,
|
||||
// though, and an Icom or Yaesu operator inherited a hidden switch that was on
|
||||
// — the checkbox is not shown for them, so nothing keyed as they typed AND
|
||||
// Enter sent nothing either, since it believes the text has already gone.
|
||||
// Macros kept working, which is exactly how it looked from the outside.
|
||||
const liveType = sendOnType && (source === 'winkeyer' || source === 'flex');
|
||||
|
||||
function sendText() {
|
||||
const t = cwText.trim();
|
||||
if (t && !sendOnType) onSend(t); // in send-on-type the text already went out
|
||||
if (t && !liveType) onSend(t); // in send-on-type the text already went out
|
||||
setCwText('');
|
||||
}
|
||||
|
||||
@@ -83,7 +91,7 @@ export function WinkeyerPanel({
|
||||
// WinKeyer backspace for each deleted char (removes it from the buffer if it
|
||||
// hasn't been keyed yet). Only end-of-string edits are mirrored live.
|
||||
function onCwChange(v: string) {
|
||||
if (sendOnType && connected) {
|
||||
if (liveType && connected) {
|
||||
const old = cwText;
|
||||
if (v.length > old.length && v.startsWith(old)) {
|
||||
onSendRaw(v.slice(old.length));
|
||||
@@ -101,16 +109,16 @@ export function WinkeyerPanel({
|
||||
<Radio className="size-4 text-primary shrink-0" />
|
||||
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : 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 className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
||||
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
||||
<div className="flex-1" />
|
||||
{source === 'icom' || source === 'flex' || source === 'yaesu' || source === 'kenwood' ? (
|
||||
{source === 'icom' || source === 'flex' || source === 'yaesu' || source === 'kenwood' || source === 'tci' ? (
|
||||
<span className="text-[11px] font-medium text-muted-foreground">
|
||||
{source === 'flex'
|
||||
? (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.civReady') : t('wkp.civOffline'))}
|
||||
</span>
|
||||
@@ -200,13 +208,13 @@ export function WinkeyerPanel({
|
||||
value={cwText}
|
||||
onChange={(e) => onCwChange(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendText(); } }}
|
||||
placeholder={sendOnType ? t('wkp.phLive') : t('wkp.phEnter')}
|
||||
placeholder={liveType ? t('wkp.phLive') : t('wkp.phEnter')}
|
||||
disabled={!connected}
|
||||
className="font-mono uppercase"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={sendText} disabled={!connected}>
|
||||
<Send className="size-3.5" /> {sendOnType ? t('wkp.clear') : t('wkp.send')}
|
||||
<Send className="size-3.5" /> {liveType ? t('wkp.clear') : t('wkp.send')}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" className="h-8" onClick={onStop} disabled={!connected} title={t('wkp.abort')}>
|
||||
<Square className="size-3.5" /> {t('wkp.stop')}
|
||||
|
||||
@@ -41,6 +41,7 @@ type Props = {
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
onBulkEdit?: (ids: number[]) => void;
|
||||
onExportSelected?: (ids: number[]) => void;
|
||||
onExportSelectedFull?: (ids: number[]) => void;
|
||||
onExportSelectedFields?: (ids: number[]) => void;
|
||||
onExportCabrilloSelected?: (ids: number[]) => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
@@ -58,7 +59,7 @@ function fmtDate(s: any): string {
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
|
||||
export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFull, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
@@ -279,6 +280,7 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
|
||||
onSendEQSL={onSendEQSL}
|
||||
onBulkEdit={onBulkEdit}
|
||||
onExportSelected={onExportSelected}
|
||||
onExportSelectedFull={onExportSelectedFull}
|
||||
onExportSelectedFields={onExportSelectedFields}
|
||||
onExportCabrilloSelected={onExportCabrilloSelected}
|
||||
onDelete={onDelete}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna } from 'lu
|
||||
import {
|
||||
GetYaesuState, RefreshYaesuPanel,
|
||||
SetYaesuPower, SetYaesuMicGain, SetYaesuAFGain, SetYaesuRFGain, SetYaesuSquelch,
|
||||
SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel,
|
||||
SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuAntenna, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel,
|
||||
SetYaesuNarrow, SetYaesuVOX, SetYaesuSplit, SetYaesuBand, TuneYaesuATU,
|
||||
SetYaesuModeRaw, SetYaesuSplitOffset, SetYaesuKeySpeed, SetYaesuBreakIn, YaesuZeroIn, GetCATState,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
@@ -17,8 +17,9 @@ type YaesuState = {
|
||||
transmitting: boolean; split: boolean;
|
||||
s_meter: number; power_meter: number; swr_meter: number;
|
||||
rf_power: number; mic_gain: number; af_gain: number; rf_gain: number; squelch: number;
|
||||
agc?: string; preamp: number; att: number;
|
||||
nb: boolean; nr: boolean; nr_level: number; narrow: boolean; vox: boolean;
|
||||
agc?: string; preamp: number; att: number; antenna: number;
|
||||
nb: boolean; nr: boolean; nr_level: number; narrow: boolean; narrow_supported?: boolean; vox: boolean;
|
||||
max_power?: number;
|
||||
split_tx_hz?: number; key_speed?: number; break_in?: boolean; swr?: number; power_w?: number;
|
||||
};
|
||||
|
||||
@@ -26,7 +27,7 @@ const ZERO: YaesuState = {
|
||||
available: false, transmitting: false, split: false,
|
||||
s_meter: 0, power_meter: 0, swr_meter: 0,
|
||||
rf_power: 0, mic_gain: 0, af_gain: 0, rf_gain: 0, squelch: 0,
|
||||
preamp: 0, att: 0, nb: false, nr: false, nr_level: 0, narrow: false, vox: false,
|
||||
preamp: 0, att: 0, antenna: 0, nb: false, nr: false, nr_level: 0, narrow: false, vox: false,
|
||||
};
|
||||
|
||||
// Band buttons use the rig's OWN band memory (CAT "BS"), not a frequency we
|
||||
@@ -74,6 +75,10 @@ function activeMode(raw?: string): { id: string; side: 'U' | 'L' } | null {
|
||||
// IPO bypasses the preamp entirely (best on a quiet, high-signal band), AMP1 and
|
||||
// AMP2 add gain. Presenting it as a toggle would hide the middle position.
|
||||
const PREAMPS = [{ v: '0', l: 'IPO' }, { v: '1', l: 'AMP1' }, { v: '2', l: 'AMP2' }];
|
||||
// Three jacks is the most any of these rigs has (FTDX101); an FTDX10 answers
|
||||
// with 1 or 2 and the third button simply never gets used. A rig with a single
|
||||
// socket answers nothing at all and reports 0, and the row is not drawn.
|
||||
const ANTENNAS = [{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }, { v: '3', l: 'ANT3' }];
|
||||
const AGCS = [{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }, { v: 'AUTO', l: 'AUTO' }];
|
||||
// The attenuator is a three-step pad on these rigs (6/12/18 dB), not a toggle.
|
||||
const ATTS = [{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
|
||||
@@ -424,6 +429,16 @@ export function YaesuPanel({ onReportRST, onKeySpeed }: {
|
||||
<Row label="ATT">
|
||||
<Segmented value={String(view.att)} options={ATTS} onChange={(v) => push('att', parseInt(v, 10), () => SetYaesuAtt(parseInt(v, 10)))} />
|
||||
</Row>
|
||||
{/* Only on a rig that HAS a choice: 0 means the AN command went
|
||||
unanswered, which is what a single-socket FT-891 does. And the
|
||||
choice is remembered for the band it was made on — no table to
|
||||
fill in anywhere, the backend learns it here. */}
|
||||
{view.antenna > 0 && (
|
||||
<Row label="ANT">
|
||||
<Segmented value={String(view.antenna)} options={ANTENNAS}
|
||||
onChange={(v) => push('antenna', parseInt(v, 10), () => SetYaesuAntenna(parseInt(v, 10)))} />
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Noise + filter */}
|
||||
@@ -431,7 +446,13 @@ export function YaesuPanel({ onReportRST, onKeySpeed }: {
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Chip on={view.nb} onClick={() => push('nb', !view.nb, () => SetYaesuNB(!view.nb))} label="NB" />
|
||||
<Chip on={view.nr} onClick={() => push('nr', !view.nr, () => SetYaesuNR(!view.nr))} label="DNR" />
|
||||
<Chip on={view.narrow} onClick={() => push('narrow', !view.narrow, () => SetYaesuNarrow(!view.narrow))} label="NAR" />
|
||||
{/* NAR is the narrow IF filter. Shown only when the rig ANSWERED NA:
|
||||
a button that does nothing when pressed reads as a fault in the
|
||||
radio, which is how it was reported from an FTDX101. */}
|
||||
{view.narrow_supported && (
|
||||
<Chip on={view.narrow} onClick={() => push('narrow', !view.narrow, () => SetYaesuNarrow(!view.narrow))}
|
||||
label="NAR" title={t('yaesu.narrowHint')} />
|
||||
)}
|
||||
</div>
|
||||
<Row label="DNR">
|
||||
{/* 1-15 on the rig, shown as-is rather than rescaled to a percentage:
|
||||
@@ -448,7 +469,7 @@ export function YaesuPanel({ onReportRST, onKeySpeed }: {
|
||||
<Row label="PWR">
|
||||
{/* Watts, not a percentage: the rig reports and takes watts, and a
|
||||
percentage would be a second unit to reconcile every time. */}
|
||||
<Slider value={view.rf_power || 5} min={5} max={100} accent="var(--destructive)"
|
||||
<Slider value={view.rf_power || 5} min={5} max={Math.max(100, view.max_power || 100)} accent="var(--destructive)"
|
||||
onChange={(v) => push('rf_power', v, () => SetYaesuPower(v))} />
|
||||
<span className="w-10 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_power}W</span>
|
||||
</Row>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import * as React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
// A text field that types into itself first.
|
||||
//
|
||||
// Preferences is one component holding two hundred pieces of state, and its
|
||||
// biggest panels are eight hundred lines of form. A plain controlled input
|
||||
// sends every keystroke into that state, so every character re-renders the
|
||||
// whole dialog — the external-services panel, the CAT panel — and the letter
|
||||
// appears after the finger has left the key.
|
||||
//
|
||||
// This keeps the text where it is being typed and hands it up shortly after.
|
||||
// The value shown is the operator's, immediately; the parent's copy catches up
|
||||
// a moment later, which is soon enough for anything that reads it — nothing in
|
||||
// a settings form acts on a half-typed word.
|
||||
//
|
||||
// It is a drop-in for Input, on purpose: the fix is a changed import, not a
|
||||
// hundred edited call sites. Which means it has to behave correctly in every
|
||||
// shape those call sites take:
|
||||
//
|
||||
// • Blur flushes at once, so clicking Save cannot lose the last word typed,
|
||||
// and so does unmounting — a panel changed mid-word still hands up what
|
||||
// was there.
|
||||
// • A value that comes back DIFFERENT from what was sent up is adopted, even
|
||||
// while the field has focus. That is how the fields which normalise as you
|
||||
// type keep working: a callsign box that upper-cases, a port box that
|
||||
// drops everything but digits. They echo a corrected value, and the
|
||||
// correction wins.
|
||||
// • A value changed from outside while the field is idle wins too — that is
|
||||
// how loading the settings, or switching profile, refills the form.
|
||||
// • Types that are not text — checkbox, colour, file — pass straight
|
||||
// through. There is no typing to buffer and their events are not text.
|
||||
const PASSTHROUGH = new Set(['checkbox', 'radio', 'file', 'color', 'range', 'submit', 'button', 'image', 'reset']);
|
||||
|
||||
// Short enough that a normalising field corrects itself while the operator is
|
||||
// still on the same word, long enough that a burst of typing is one render.
|
||||
const DEBOUNCE_MS = 120;
|
||||
|
||||
export const BufferedInput = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ value, onChange, onBlur, onFocus, type, ...props }, ref) => {
|
||||
const buffered = value !== undefined && !!onChange && !PASSTHROUGH.has(type ?? 'text');
|
||||
const incoming = String(value ?? '');
|
||||
const [local, setLocal] = React.useState(incoming);
|
||||
const focused = React.useRef(false);
|
||||
const timer = React.useRef<number | undefined>(undefined);
|
||||
// What we last handed up. Anything else arriving from the parent is the
|
||||
// parent's own doing — a normalisation, a reload — and it wins.
|
||||
const emitted = React.useRef(incoming);
|
||||
const pending = React.useRef<React.ChangeEvent<HTMLInputElement> | null>(null);
|
||||
const onChangeRef = React.useRef(onChange);
|
||||
React.useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!focused.current || incoming !== emitted.current) {
|
||||
setLocal(incoming);
|
||||
emitted.current = incoming;
|
||||
}
|
||||
}, [incoming]);
|
||||
|
||||
const flush = React.useCallback(() => {
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = undefined;
|
||||
const e = pending.current;
|
||||
pending.current = null;
|
||||
if (e) {
|
||||
emitted.current = e.target.value;
|
||||
onChangeRef.current?.(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Unmounted mid-word — the panel changed, the dialog closed — still hands
|
||||
// up what was typed.
|
||||
React.useEffect(() => () => {
|
||||
window.clearTimeout(timer.current);
|
||||
if (pending.current) onChangeRef.current?.(pending.current);
|
||||
}, []);
|
||||
|
||||
if (!buffered) {
|
||||
return <Input ref={ref} type={type} value={value} onChange={onChange} onBlur={onBlur} onFocus={onFocus} {...props} />;
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
type={type}
|
||||
value={local}
|
||||
onFocus={(e) => { focused.current = true; onFocus?.(e); }}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setLocal(v);
|
||||
// The element's value changes again before the timer fires, so what
|
||||
// matters is copied out of it now.
|
||||
pending.current = { ...e, target: { ...e.target, value: v } } as React.ChangeEvent<HTMLInputElement>;
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(flush, DEBOUNCE_MS);
|
||||
}}
|
||||
onBlur={(e) => { focused.current = false; flush(); onBlur?.(e); }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
BufferedInput.displayName = 'BufferedInput';
|
||||
@@ -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 { Input } from './input';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -8,7 +9,7 @@ import { cn } from '@/lib/utils';
|
||||
// can't hold a typo'd value that isn't in the list.
|
||||
export function Combobox({
|
||||
value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false,
|
||||
showToggle = false,
|
||||
showToggle = false, onWheelStep,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
@@ -20,6 +21,10 @@ export function Combobox({
|
||||
// fields read live by other actions — e.g. RST, so a CW macro sent without
|
||||
// leaving the field uses the value just typed.
|
||||
commitOnType?: boolean;
|
||||
// Wheel over the field steps the value. The control cannot know what a step
|
||||
// means — a decibel here, an S-unit there — so it reports the direction and
|
||||
// the owner decides.
|
||||
onWheelStep?: (dir: 1 | -1) => void;
|
||||
// Draw a chevron that opens the full list on click.
|
||||
//
|
||||
// Without it this control is indistinguishable from a plain text box: it opens
|
||||
@@ -36,6 +41,25 @@ export function Combobox({
|
||||
// otherwise "open" onto COM7 alone.
|
||||
const [browse, setBrowse] = useState(false);
|
||||
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 });
|
||||
|
||||
// A NATIVE, non-passive listener: React attaches onWheel passively, where
|
||||
// preventDefault does nothing at all and the panel scrolls away under the
|
||||
// field being adjusted.
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || !onWheelStep) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (e.deltaY === 0) return;
|
||||
e.preventDefault();
|
||||
onWheelStep(e.deltaY < 0 ? 1 : -1); // up is a better report
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, [onWheelStep]);
|
||||
|
||||
useEffect(() => {
|
||||
function onDoc(e: MouseEvent) {
|
||||
@@ -45,6 +69,28 @@ export function Combobox({
|
||||
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 ? []
|
||||
: browse ? options.slice(0, 60)
|
||||
: options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60);
|
||||
@@ -121,8 +167,16 @@ export function Combobox({
|
||||
<ChevronDown className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{open && filtered.length > 0 && (
|
||||
<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">
|
||||
{/* THE MENU IS PORTALLED to the body, and positioned from the field's own
|
||||
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) => (
|
||||
<button
|
||||
key={o}
|
||||
@@ -134,7 +188,8 @@ export function Combobox({
|
||||
{o}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,14 +8,23 @@ const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
// blur=false drops the backdrop filter and dims harder instead.
|
||||
//
|
||||
// A backdrop-filter over the whole window is recomputed every time anything
|
||||
// above it repaints — and underneath this one sits an application that never
|
||||
// stops moving: CAT polls four times a second, spots arrive, meters sweep, maps
|
||||
// redraw. On a long-lived dialog with text fields in it, that shows as a delay
|
||||
// between the key and the letter. Ornament is not worth a keyboard that feels
|
||||
// slow, so the dialogs an operator TYPES in for minutes at a time turn it off.
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & { blur?: boolean }
|
||||
>(({ className, blur = true, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-stone-900/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'fixed inset-0 z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
blur ? 'bg-stone-900/40 backdrop-blur-sm' : 'bg-stone-900/60',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -25,10 +34,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean; hideOverlay?: boolean }
|
||||
>(({ className, children, hideClose, hideOverlay, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean; hideOverlay?: boolean; overlayBlur?: boolean }
|
||||
>(({ className, children, hideClose, hideOverlay, overlayBlur, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
{!hideOverlay && <DialogOverlay />}
|
||||
{!hideOverlay && <DialogOverlay blur={overlayBlur} />}
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
|
||||
@@ -1,208 +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);
|
||||
return {
|
||||
...defaultAutoCall,
|
||||
...v,
|
||||
criteria: { ...emptyCriteria, ...(v?.criteria ?? {}) },
|
||||
watchCriteria: { ...emptyCriteria, ...(v?.watchCriteria ?? {}) },
|
||||
watch: Array.isArray(v?.watch) ? v.watch : [],
|
||||
};
|
||||
} 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 '';
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
// Stored through writeUiPref like every other portable preference, so the
|
||||
// buttons travel with data/ rather than living in one browser profile.
|
||||
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { writeUiPrefDebounced } from '@/lib/uiPref';
|
||||
|
||||
export type ClusterMacro = {
|
||||
label: string; // what the button says
|
||||
@@ -43,8 +43,12 @@ export function loadClusterMacros(): ClusterMacro[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Debounced, because this is called on every keystroke in twenty-four text
|
||||
// boxes. The local cache is written at once — it is what everything reads back
|
||||
// — and only the database write waits for the typing to stop. A round trip into
|
||||
// Go per character is what "the letters appear after I have moved on" was.
|
||||
export function saveClusterMacros(macros: ClusterMacro[]): void {
|
||||
writeUiPref(clusterMacrosKey, JSON.stringify(macros));
|
||||
writeUiPrefDebounced(clusterMacrosKey, JSON.stringify(macros));
|
||||
}
|
||||
|
||||
// visibleClusterMacros drops the slots that would send nothing. The COMMAND is
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Cluster nodes worth starting from.
|
||||
//
|
||||
// Setting up a telnet cluster is the step operators get stuck on: the host and
|
||||
// the port are two pieces of information nobody has to hand, a typo produces a
|
||||
// silent failure to connect, and the ports are not guessable — a Reverse Beacon
|
||||
// feed on 7000 carries CW and RTTY while 7001 carries FT8 and FT4, which no
|
||||
// amount of trying will tell you.
|
||||
//
|
||||
// So the editor offers a list. It fills the fields and then gets out of the
|
||||
// way: everything stays editable, because a node moves or an operator wants a
|
||||
// different name for it, and a preset that could not be corrected would be
|
||||
// worse than none.
|
||||
//
|
||||
// The list is meant to grow. One entry per node, and nothing here is special —
|
||||
// a node added by hand behaves exactly the same.
|
||||
|
||||
export type ClusterPreset = {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
// What it carries, in a few words: the dropdown is chosen from, not read, and
|
||||
// "SOTA" means nothing to somebody who has never chased a summit.
|
||||
about: string;
|
||||
// Sent one per line after login. Empty for the nodes that need nothing.
|
||||
init?: string;
|
||||
};
|
||||
|
||||
export const CLUSTER_PRESETS: ClusterPreset[] = [
|
||||
{ name: 'F4BPO', host: 'cluster.f4bpo.com', port: 7300,
|
||||
about: 'General DX cluster (OpsLog author’s node)' },
|
||||
{ name: 'DXFun', host: 'dxfun.com', port: 8000,
|
||||
about: 'General DX cluster, worldwide' },
|
||||
{ name: 'F5LEN', host: 'dxcluster.f5len.org', port: 7373,
|
||||
about: 'General DX cluster' },
|
||||
{ name: 'F5MZN', host: 'f5mzn.org', port: 9000,
|
||||
about: 'General DX cluster' },
|
||||
{ name: 'KM3T', host: 'dxcc.km3t.net', port: 7373,
|
||||
about: 'General DX cluster' },
|
||||
{ name: 'SOTA', host: 'cluster.sota.org.uk', port: 7300,
|
||||
about: 'Summits On The Air spots' },
|
||||
{ name: 'POTA', host: 'pota-cluster.iz2lsc.eu', port: 7373,
|
||||
about: 'Parks On The Air spots' },
|
||||
// The two Reverse Beacon feeds are one network on two ports, and which port
|
||||
// decides which modes arrive. Getting that wrong looks exactly like a dead
|
||||
// node, so they are listed separately and named for what they carry.
|
||||
{ name: 'RBN CW', host: 'telnet.reversebeacon.net', port: 7000,
|
||||
about: 'Reverse Beacon Network — CW and RTTY skimmers' },
|
||||
{ name: 'RBN FTx', host: 'telnet.reversebeacon.net', port: 7001,
|
||||
about: 'Reverse Beacon Network — FT8 and FT4 skimmers' },
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
+410
-102
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user