Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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. |
|
||||||
@@ -20,6 +20,7 @@ var deniedCallHashes = map[string]struct{}{
|
|||||||
"0741c9e394b42f43191899105553b47155ddc3026da12b5360701f9c181ff123": {},
|
"0741c9e394b42f43191899105553b47155ddc3026da12b5360701f9c181ff123": {},
|
||||||
"ab4926a3a0ab76d41b5b99cd3ad0683584970c341c29427c1dfa4b3c329ce415": {},
|
"ab4926a3a0ab76d41b5b99cd3ad0683584970c341c29427c1dfa4b3c329ce415": {},
|
||||||
"9d17c9c213a6cc89c12d7520bcf21c86a0cf43d17e82e74f33f3a77cb865d28a": {},
|
"9d17c9c213a6cc89c12d7520bcf21c86a0cf43d17e82e74f33f3a77cb865d28a": {},
|
||||||
|
"94ee059335e587e501cc4bf90613e0814f00a7b08bc7c648fd865a2af6a22cc2": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
// callDenied reports whether a callsign is on deniedCallHashes. The call is
|
// callDenied reports whether a callsign is on deniedCallHashes. The call is
|
||||||
|
|||||||
+1039
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,636 @@
|
|||||||
|
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{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
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{}),
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 <-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()
|
||||||
|
t.status = SatTrackStatus{
|
||||||
|
On: true, Name: b.Name, Transponder: tp.Label, Mode: tp.Mode,
|
||||||
|
NominalDown: nominal, NominalUp: nomUp,
|
||||||
|
DownHz: down, UpHz: up,
|
||||||
|
Az: pos.Az, El: pos.El, Visible: visible,
|
||||||
|
Radio: t.status.Radio, Error: t.status.Error,
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
t.pointRotator(pos, b.Geostationary)
|
||||||
|
t.readRotator()
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
st := t.status
|
||||||
|
t.mu.Unlock()
|
||||||
|
a.emitSatTrack(st)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := tp.Mode
|
||||||
|
if lastDown != 0 {
|
||||||
|
mode = "" // set once, at the start of the pass — see satMode/satSetMode
|
||||||
|
}
|
||||||
|
err := a.satTune(down, up, mode, mode)
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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() {}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+115
-23
@@ -7,6 +7,8 @@ package main
|
|||||||
// the spot grid, so the two windows can never disagree.
|
// the spot grid, so the two windows can never disagree.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
"hamlog/internal/applog"
|
||||||
@@ -18,6 +20,13 @@ const (
|
|||||||
keyWsjtHighlight = "udp.wsjt.highlight"
|
keyWsjtHighlight = "udp.wsjt.highlight"
|
||||||
keyWsjtFollowMode = "udp.wsjt.followmode" // spot clicks switch the decoder's mode
|
keyWsjtFollowMode = "udp.wsjt.followmode" // spot clicks switch the decoder's mode
|
||||||
keyWsjtHLWorked = "udp.wsjt.highlight_worked"
|
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
|
// wsjtModes are the modes a Configure message can meaningfully ask for — the
|
||||||
@@ -53,8 +62,9 @@ func (a *App) ConfigureDecoderMode(mode string) {
|
|||||||
a.udp.SendConfigureMode(mode)
|
a.udp.SendConfigureMode(mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The palette. Fixed colours, not theme tokens — they are painted into another
|
// The DEFAULT palette. Fixed colours, not theme tokens — they are painted into
|
||||||
// application's window, which has no idea what theme OpsLog wears.
|
// another application's window, which has no idea what theme OpsLog wears, and
|
||||||
|
// the operator can change each of them (see WsjtHighlightColours).
|
||||||
var (
|
var (
|
||||||
hlWatchlist = udp.RGB{R: 244, G: 114, B: 182} // the watchlist pink
|
hlWatchlist = udp.RGB{R: 244, G: 114, B: 182} // the watchlist pink
|
||||||
hlNewDXCC = udp.RGB{R: 22, G: 130, B: 60} // green
|
hlNewDXCC = udp.RGB{R: 22, G: 130, B: 60} // green
|
||||||
@@ -65,9 +75,81 @@ var (
|
|||||||
// only DIM colour of the four: the others say "look at this", and this one
|
// 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.
|
// says the opposite — it has to recede, not compete with them.
|
||||||
hlWorked = udp.RGB{R: 75, G: 85, B: 99}
|
hlWorked = udp.RGB{R: 75, G: 85, B: 99}
|
||||||
hlWorkedFg = udp.RGB{R: 203, G: 213, B: 225}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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
|
// GetWsjtHighlightWorked reports whether stations already worked on this band
|
||||||
// and mode are greyed out as well.
|
// and mode are greyed out as well.
|
||||||
//
|
//
|
||||||
@@ -174,42 +256,52 @@ func (a *App) maybeHighlightDecode(instance, call, band, mode string) {
|
|||||||
// Anything else is "no colour", and the empty verdict doubles as the clear
|
// Anything else is "no colour", and the empty verdict doubles as the clear
|
||||||
// signal in maybeHighlightDecode.
|
// signal in maybeHighlightDecode.
|
||||||
func (a *App) decodeHighlightVerdict(call, band, mode string) (bg, fg *udp.RGB, verdict string) {
|
func (a *App) decodeHighlightVerdict(call, band, mode string) (bg, fg *udp.RGB, verdict string) {
|
||||||
if a.watchlist != nil {
|
|
||||||
if _, ok := a.watchlist.Match(call); ok {
|
|
||||||
c := hlWatchlist
|
|
||||||
f := hlBlack
|
|
||||||
return &c, &f, "watchlist"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c := a.clusterStatusMaps()
|
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 a.dxcc != nil {
|
||||||
if m, ok := a.dxcc.Lookup(call); ok && m.Entity != nil {
|
if m, ok := a.dxcc.Lookup(call); ok && m.Entity != nil {
|
||||||
num := dxcc.EntityDXCC(m.Entity.Name)
|
num := dxcc.EntityDXCC(m.Entity.Name)
|
||||||
ent := c.entities[num]
|
ent := c.entities[num]
|
||||||
if ent == nil {
|
if ent == nil {
|
||||||
bgc, fgc := hlNewDXCC, hlWhite
|
bgc, fgc := a.colourFor(keyWsjtColNewDXCC, hexOfRGB(hlNewDXCC))
|
||||||
return &bgc, &fgc, "new-dxcc"
|
return &bgc, &fgc, "new-dxcc"
|
||||||
}
|
}
|
||||||
if band != "" {
|
if band != "" {
|
||||||
if _, workedBand := ent.Bands[strings.ToLower(band)]; !workedBand {
|
if _, workedBand := ent.Bands[strings.ToLower(band)]; !workedBand {
|
||||||
bgc, fgc := hlNewBand, hlBlack
|
bgc, fgc := a.colourFor(keyWsjtColNewBand, hexOfRGB(hlNewBand))
|
||||||
return &bgc, &fgc, "new-band"
|
return &bgc, &fgc, "new-band"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Worked already, this exact callsign on this band in this mode — a dupe,
|
// A dupe, judged by the same ledger and the same digital-mode grouping the
|
||||||
// judged by the same ledger and the same digital-mode grouping the cluster
|
// cluster uses, so the two windows cannot disagree about what "worked" means.
|
||||||
// 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
|
||||||
if a.wsjtHLWorkedOn.Load() && band != "" && mode != "" {
|
// screen where nearly every line is coloured has stopped saying anything.
|
||||||
m := strings.ToUpper(strings.TrimSpace(mode))
|
if workedHere && a.wsjtHLWorkedOn.Load() {
|
||||||
if c.normMode != nil {
|
bgc, fgc := a.colourFor(keyWsjtColWorked, hexOfRGB(hlWorked))
|
||||||
m = c.normMode(m)
|
|
||||||
}
|
|
||||||
if _, ok := c.workedCallSlots[strings.ToUpper(call)+"|"+strings.ToLower(band)+"|"+m]; ok {
|
|
||||||
bgc, fgc := hlWorked, hlWorkedFg
|
|
||||||
return &bgc, &fgc, "worked"
|
return &bgc, &fgc, "worked"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return nil, nil, ""
|
return nil, nil, ""
|
||||||
}
|
}
|
||||||
|
|||||||
+274
-32
@@ -30,7 +30,12 @@ const (
|
|||||||
keyAutoCallWatched = "autocall.watched_attempts"
|
keyAutoCallWatched = "autocall.watched_attempts"
|
||||||
keyAutoCallMisses = "autocall.misses"
|
keyAutoCallMisses = "autocall.misses"
|
||||||
keyAutoCallRounds = "autocall.max_rounds"
|
keyAutoCallRounds = "autocall.max_rounds"
|
||||||
keyAutoCallRestMin = "autocall.rest_min"
|
// 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
|
// AutoCallSettings is the panel's shape. Durations are in minutes because that
|
||||||
@@ -46,9 +51,28 @@ type AutoCallSettings struct {
|
|||||||
// it, before it is given up on.
|
// it, before it is given up on.
|
||||||
Misses int `json:"misses"`
|
Misses int `json:"misses"`
|
||||||
// MaxRounds: how many series of calls one station gets in a session, and
|
// MaxRounds: how many series of calls one station gets in a session, and
|
||||||
// RestMin the pause between two of them.
|
// RestPeriods the pause between two of them, counted in the station's own
|
||||||
|
// transmit periods.
|
||||||
MaxRounds int `json:"max_rounds"`
|
MaxRounds int `json:"max_rounds"`
|
||||||
RestMin int `json:"rest_min"`
|
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 {
|
func (a *App) GetAutoCallSettings() AutoCallSettings {
|
||||||
@@ -66,24 +90,36 @@ func (a *App) GetAutoCallSettings() AutoCallSettings {
|
|||||||
Only: strings.ToUpper(strings.TrimSpace(a.settingOr(keyAutoCallOnly, ""))),
|
Only: strings.ToUpper(strings.TrimSpace(a.settingOr(keyAutoCallOnly, ""))),
|
||||||
Attempts: num(keyAutoCallAttempts, d.Attempts),
|
Attempts: num(keyAutoCallAttempts, d.Attempts),
|
||||||
WatchedAttempts: num(keyAutoCallWatched, d.WatchedAttempts),
|
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),
|
Misses: num(keyAutoCallMisses, d.Misses),
|
||||||
MaxRounds: num(keyAutoCallRounds, d.MaxRounds),
|
MaxRounds: num(keyAutoCallRounds, d.MaxRounds),
|
||||||
RestMin: num(keyAutoCallRestMin, int(d.Rest/time.Minute)),
|
// 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 {
|
func (a *App) SaveAutoCallSettings(s AutoCallSettings) error {
|
||||||
a.setSetting(keyAutoCallOn, map[bool]string{true: "1", false: "0"}[s.Enabled])
|
a.setSetting(keyAutoCallOn, map[bool]string{true: "1", false: "0"}[s.Enabled])
|
||||||
a.setSetting(keyAutoCallOnly, strings.ToUpper(strings.TrimSpace(s.Only)))
|
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{
|
for key, v := range map[string]int{
|
||||||
keyAutoCallAttempts: s.Attempts, keyAutoCallWatched: s.WatchedAttempts,
|
keyAutoCallAttempts: s.Attempts, keyAutoCallWatched: s.WatchedAttempts,
|
||||||
keyAutoCallMisses: s.Misses, keyAutoCallRounds: s.MaxRounds,
|
keyAutoCallMisses: s.Misses, keyAutoCallRounds: s.MaxRounds,
|
||||||
keyAutoCallRestMin: s.RestMin,
|
|
||||||
} {
|
} {
|
||||||
if v > 0 {
|
if v > 0 {
|
||||||
a.setSetting(key, strconv.Itoa(v))
|
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()
|
a.applyAutoCall()
|
||||||
applog.Printf("autocall: %v (only=%q, %d/%d calls, %d misses, %d rounds)",
|
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)
|
s.Enabled, s.Only, s.Attempts, s.WatchedAttempts, s.Misses, s.MaxRounds)
|
||||||
@@ -121,10 +157,10 @@ func (a *App) autoCallEngine() *autocall.Engine {
|
|||||||
func (a *App) autoCallSettings() autocall.Settings {
|
func (a *App) autoCallSettings() autocall.Settings {
|
||||||
s := a.GetAutoCallSettings()
|
s := a.GetAutoCallSettings()
|
||||||
return autocall.Settings{
|
return autocall.Settings{
|
||||||
Enabled: s.Enabled, Only: s.Only,
|
Enabled: s.Enabled, Only: s.Only, OnScreenOnly: s.OnScreenOnly,
|
||||||
Attempts: s.Attempts, WatchedAttempts: s.WatchedAttempts,
|
Attempts: s.Attempts, WatchedAttempts: s.WatchedAttempts,
|
||||||
Misses: s.Misses, MaxRounds: s.MaxRounds,
|
Misses: s.Misses, MaxRounds: s.MaxRounds,
|
||||||
Rest: time.Duration(s.RestMin) * time.Minute,
|
RestPeriods: s.RestPeriods,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,22 +171,45 @@ func (a *App) applyAutoCall() {
|
|||||||
e := a.autoCallEngine()
|
e := a.autoCallEngine()
|
||||||
s := a.autoCallSettings()
|
s := a.autoCallSettings()
|
||||||
e.SetSettings(s)
|
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 {
|
if !s.Enabled {
|
||||||
e.Reset()
|
e.Reset()
|
||||||
a.acMu.Lock()
|
a.acMu.Lock()
|
||||||
a.acPeriod, a.acBuf = nil, nil
|
a.acPeriod, a.acBuf = nil, nil
|
||||||
|
a.acJudged, a.acDirty = nil, nil
|
||||||
a.acMu.Unlock()
|
a.acMu.Unlock()
|
||||||
}
|
}
|
||||||
a.emitAutoCall()
|
a.emitAutoCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetAutoCall is the operator's restart after it gave up — and what Halt
|
// ResetAutoCall is the operator's restart after the engine gave up on an
|
||||||
// does, since halting means "not this station".
|
// explicit target: it clears every verdict, including the grey list.
|
||||||
func (a *App) ResetAutoCall() {
|
func (a *App) ResetAutoCall() {
|
||||||
a.autoCallEngine().Reset()
|
a.autoCallEngine().Reset()
|
||||||
a.emitAutoCall()
|
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.
|
// AutoCallStatus is what the toolbar shows.
|
||||||
type AutoCallStatus struct {
|
type AutoCallStatus struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
@@ -159,11 +218,16 @@ type AutoCallStatus struct {
|
|||||||
// whichever is typed into, both show it.
|
// whichever is typed into, both show it.
|
||||||
Only string `json:"only"`
|
Only string `json:"only"`
|
||||||
Target string `json:"target"`
|
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"`
|
Calls int `json:"calls"`
|
||||||
Max int `json:"max"`
|
Max int `json:"max"`
|
||||||
Misses int `json:"misses"`
|
Misses int `json:"misses"`
|
||||||
MaxMiss int `json:"max_miss"`
|
MaxMiss int `json:"max_miss"`
|
||||||
Stopped bool `json:"stopped"`
|
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
|
// Reason is the last decision in plain words. An auto-call that is doing
|
||||||
// nothing on purpose looks exactly like one that is broken.
|
// nothing on purpose looks exactly like one that is broken.
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
@@ -177,8 +241,9 @@ func (a *App) GetAutoCallStatus() AutoCallStatus {
|
|||||||
set := a.GetAutoCallSettings()
|
set := a.GetAutoCallSettings()
|
||||||
return AutoCallStatus{
|
return AutoCallStatus{
|
||||||
Enabled: set.Enabled, Only: set.Only,
|
Enabled: set.Enabled, Only: set.Only,
|
||||||
Target: st.Target, Calls: st.Attempts, Max: st.Max,
|
Target: st.Target, Waiting: st.Waiting, Calls: st.Attempts, Max: st.Max,
|
||||||
Misses: st.Misses, MaxMiss: st.MaxMiss, Stopped: st.Stopped,
|
Misses: st.Misses, MaxMiss: st.MaxMiss, Stopped: st.Stopped,
|
||||||
|
Greylisted: a.autoCallEngine().Greylisted(),
|
||||||
Reason: reason,
|
Reason: reason,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,20 +299,60 @@ func (a *App) autoCallFeed(d autocall.Decode) {
|
|||||||
a.acMu.Lock()
|
a.acMu.Lock()
|
||||||
if a.acPeriod == nil {
|
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.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 {
|
if prev := a.acPeriod[inst]; prev != "" && prev != key {
|
||||||
prevAt, prevTR, buf := a.acAt[inst], a.acTR[inst], a.acBuf[inst]
|
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.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
|
||||||
a.acBuf[inst] = []acDecode{{d: d}}
|
a.acBuf[inst] = []acDecode{{d: d}}
|
||||||
|
a.acFed[inst], a.acDirty[inst] = time.Now(), true
|
||||||
a.acMu.Unlock()
|
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)
|
a.autoCallJudge(inst, prev, prevAt, prevTR, buf)
|
||||||
|
}
|
||||||
return
|
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.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.acBuf[inst] = append(a.acBuf[inst], acDecode{d: d})
|
||||||
a.acMu.Unlock()
|
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.
|
// 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
|
// Per receiver, because with two decoders one may fall silent while the other
|
||||||
@@ -273,15 +378,21 @@ func (a *App) autoCallSweep() {
|
|||||||
if tr <= 0 {
|
if tr <= 0 {
|
||||||
tr = 15
|
tr = 15
|
||||||
}
|
}
|
||||||
// One slot plus a margin: decodes for a period keep arriving for a
|
// Measured from when the last decode ARRIVED, not from the period it
|
||||||
// second or two after it ends, and judging early would count a station
|
// belongs to: a decode is stamped with the start of its own slot, so
|
||||||
// as missing that is about to be listed.
|
// waiting "a slot plus four seconds" from that stamp is waiting until
|
||||||
if time.Since(a.acAt[inst]) < time.Duration(tr)*time.Second+4*time.Second {
|
// 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
|
continue
|
||||||
}
|
}
|
||||||
ready = append(ready, due{inst, key, a.acAt[inst], tr, a.acBuf[inst]})
|
ready = append(ready, due{inst, key, a.acAt[inst], tr, a.acBuf[inst]})
|
||||||
delete(a.acPeriod, inst)
|
a.acJudged[inst], a.acDirty[inst] = key, false
|
||||||
delete(a.acBuf, inst)
|
|
||||||
}
|
}
|
||||||
a.acMu.Unlock()
|
a.acMu.Unlock()
|
||||||
for _, d := range ready {
|
for _, d := range ready {
|
||||||
@@ -304,7 +415,9 @@ func (a *App) autoCallSilence() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.acMu.Lock()
|
a.acMu.Lock()
|
||||||
quiet := a.acPeriod[inst] == "" && time.Since(a.acLastJudge) > 20*time.Second
|
// 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]
|
tr := a.acTR[inst]
|
||||||
a.acMu.Unlock()
|
a.acMu.Unlock()
|
||||||
if !quiet {
|
if !quiet {
|
||||||
@@ -351,18 +464,18 @@ func (a *App) autoCallJudge(inst, key string, at time.Time, tr int, buf []acDeco
|
|||||||
status[st.Call+"|"+st.Band+"|"+st.Mode] = st
|
status[st.Call+"|"+st.Band+"|"+st.Mode] = st
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chase := a.autoCallChase()
|
||||||
cands := make([]autocall.Candidate, 0, len(uniq))
|
cands := make([]autocall.Candidate, 0, len(uniq))
|
||||||
for _, dd := range uniq {
|
for _, dd := range uniq {
|
||||||
st := status[dd.d.Call+"|"+dd.d.Band+"|"+dd.d.Mode]
|
st := status[dd.d.Call+"|"+dd.d.Band+"|"+dd.d.Mode]
|
||||||
cands = append(cands, autocall.Candidate{
|
c := candidateOf(dd.d, st, chase)
|
||||||
Decode: dd.d,
|
c.Watched = a.autoCallWatched(dd.d.Call)
|
||||||
Need: autoCallNeedOf(st.Status),
|
c.Hidden = a.autoCallHidden(dd.d.Call)
|
||||||
Watched: a.autoCallWatched(dd.d.Call),
|
cands = append(cands, c)
|
||||||
Worked: st.Status == "worked",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tx := a.autoCallTX()
|
tx := a.autoCallTX()
|
||||||
|
|
||||||
act := a.autoCallEngine().OnPeriod(autocall.Period{
|
act := a.autoCallEngine().OnPeriod(autocall.Period{
|
||||||
Instance: inst, Key: key, At: at, TRPeriod: tr, Decodes: cands, TX: tx,
|
Instance: inst, Key: key, At: at, TRPeriod: tr, Decodes: cands, TX: tx,
|
||||||
MyCall: a.opCall,
|
MyCall: a.opCall,
|
||||||
@@ -370,6 +483,81 @@ func (a *App) autoCallJudge(inst, key string, at time.Time, tr int, buf []acDeco
|
|||||||
a.autoCallDo(act)
|
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.
|
// autoCallDo carries out a decision and records it.
|
||||||
func (a *App) autoCallDo(act autocall.Action) {
|
func (a *App) autoCallDo(act autocall.Action) {
|
||||||
if act.Reason != "" {
|
if act.Reason != "" {
|
||||||
@@ -385,9 +573,9 @@ func (a *App) autoCallDo(act autocall.Action) {
|
|||||||
applog.Printf("autocall: the call to %s could not be sent: %v", d.Call, err)
|
applog.Printf("autocall: the call to %s could not be sent: %v", d.Call, err)
|
||||||
}
|
}
|
||||||
case autocall.DoHalt:
|
case autocall.DoHalt:
|
||||||
// autoTxOnly=false: stop now. The whole point of a brake is that it does
|
// Soft: let the over finish, then stop transmitting. Hard: stop now.
|
||||||
// not wait for the over in progress to finish.
|
// The engine decides — see Action.Soft.
|
||||||
if err := a.HaltDecodeTx("", false); err != nil {
|
if err := a.HaltDecodeTx("", act.Soft); err != nil {
|
||||||
applog.Printf("autocall: halt failed: %v", err)
|
applog.Printf("autocall: halt failed: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -433,6 +621,38 @@ func (a *App) autoCallSetTX(tx autocall.TXState) {
|
|||||||
a.acMu.Unlock()
|
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
|
// 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
|
// 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 same NEW BAND the auto-call ranks — two answers to one question is how
|
||||||
@@ -471,18 +691,40 @@ func (a *App) autoCallWatched(call string) bool {
|
|||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// startAutoCall arms the engine at launch.
|
// startAutoCall starts the engine's loop, with auto-call OFF.
|
||||||
//
|
//
|
||||||
// The stored "on" is honoured, and the engine starts with NO target: a program
|
// It is never on from a stored value. This is the one setting in the program
|
||||||
// that came up already calling a station chosen before the last shutdown is not
|
// that puts the station on the air by itself, and the operator who left it on
|
||||||
// something an operator can be expected to anticipate.
|
// 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() {
|
func (a *App) startAutoCall() {
|
||||||
a.applyAutoCall()
|
a.disarmAutoCall("launch")
|
||||||
go a.autoCallLoop()
|
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() {
|
func (a *App) autoCallLoop() {
|
||||||
t := time.NewTicker(2 * time.Second)
|
// 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()
|
defer t.Stop()
|
||||||
for range t.C {
|
for range t.C {
|
||||||
if a.ctx == nil {
|
if a.ctx == nil {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-39
@@ -6,10 +6,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
"hamlog/internal/applog"
|
||||||
|
|
||||||
@@ -76,10 +74,7 @@ func (a *App) SaveAutostartPrograms(progs []AutostartProgram) error {
|
|||||||
func (a *App) BrowseExecutable() (string, error) {
|
func (a *App) BrowseExecutable() (string, error) {
|
||||||
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
|
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||||
Title: "Choose a program to launch on startup",
|
Title: "Choose a program to launch on startup",
|
||||||
Filters: []wruntime.FileFilter{
|
Filters: executableFilters(),
|
||||||
{DisplayName: "Programs (*.exe;*.bat;*.cmd)", Pattern: "*.exe;*.bat;*.cmd"},
|
|
||||||
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,9 +145,7 @@ func (a *App) CloseAutostartPrograms() {
|
|||||||
if name == "" {
|
if name == "" {
|
||||||
name = filepath.Base(p.Path)
|
name = filepath.Base(p.Path)
|
||||||
}
|
}
|
||||||
cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid))
|
if out, err := closeProcess(pid); err != nil {
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000}
|
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
|
||||||
applog.Printf("autostart: could not close %s (pid %d): %v — %s", name, pid, err, strings.TrimSpace(string(out)))
|
applog.Printf("autostart: could not close %s (pid %d): %v — %s", name, pid, err, strings.TrimSpace(string(out)))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -227,33 +220,3 @@ func splitArgs(s string) []string {
|
|||||||
}
|
}
|
||||||
return args
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
+18
-9
@@ -23,12 +23,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// bootLogPath is the file, or "" when even LOCALAPPDATA is unavailable.
|
// bootLogPath is the file, or "" when no writable folder can be found at all.
|
||||||
func bootLogPath() string {
|
func bootLogPath() string {
|
||||||
dir := os.Getenv("LOCALAPPDATA")
|
dir := bootLogDir()
|
||||||
if strings.TrimSpace(dir) == "" {
|
|
||||||
dir = os.TempDir()
|
|
||||||
}
|
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -136,11 +133,23 @@ func webviewDataPath() string {
|
|||||||
// stuckMarkerPath is written before the window is attempted and removed once it
|
// 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.
|
// opens, so the NEXT launch can tell that the last one never got there.
|
||||||
func stuckMarkerPath() string {
|
func stuckMarkerPath() string {
|
||||||
dir := os.Getenv("LOCALAPPDATA")
|
return filepath.Join(bootLogDir(), "OpsLog", ".launching")
|
||||||
if strings.TrimSpace(dir) == "" {
|
}
|
||||||
dir = os.TempDir()
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
return filepath.Join(dir, "OpsLog", ".launching")
|
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.
|
// lastLaunchHung is set at startup from the marker left by the previous run.
|
||||||
|
|||||||
+238
@@ -1,4 +1,242 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.27.20",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Every rotator interface now lives in Settings ▸ Rotator, and the satellite page only picks one of them. EasyComm and PstRotator used to be described inside the satellite settings while the other backends were described in the rotator list, so one mast was configured twice. What you already set up is moved into the list for you and selected.",
|
||||||
|
"Each rotator interface says whether it drives azimuth alone or azimuth and elevation, beside the interface itself. The satellite rotator list shows the azimuth-only ones greyed out rather than hiding them, so a rotor that cannot follow a pass says why.",
|
||||||
|
"New rotator interface: ERC-M by DF9GR, the azimuth/elevation controller for a Yaesu G-5500. Over its USB COM port or the network, with its emulation set to GS-232. Untested on hardware — reports welcome.",
|
||||||
|
"EasyComm II is now an ordinary rotator interface, so it can turn the antenna from the compass and from a spot click, not only during a satellite pass.",
|
||||||
|
"On the satellite map, an unselected satellite is readable: a bigger dot with a dark halo under a white ring, which shows up on a street map and on a dark ocean alike, and the ones above the horizon carry their name.",
|
||||||
|
"Hovering a satellite on the map now says what a pass is worth — elevation and azimuth, distance and whether it is closing or going away, rise and set with the countdown, and how high it will get. It no longer closes itself every five seconds while you read it.",
|
||||||
|
"The satellite footprint is drawn for the selected bird only. A footprint is thousands of kilometres across, and a dozen of them overlapped into a wash of circles that hid the coastline, the ground track and the satellites themselves.",
|
||||||
|
"The frequency plan goes from 25 satellites to 44, cut from Celestrak, PE0SAT and the SatNOGS transponder database instead of typed by hand — the nine Tevel-2 satellites, the Chinese space station, AO-27, AO-123, RS-44 and twenty more. Twelve that had re-entered are gone, first-generation Tevel among them. Your own file is merged rather than replaced: satellites you have never seen are added, and any frequency you corrected stands.",
|
||||||
|
"A satellite is now found by its catalog number rather than by its name. \"RADFXSAT (FOX-1B)\" and \"AO-91\" are the same bird, and so are \"TIANYAN 01\" and \"TO-108\" — the second pair never met before, so TO-108 tracked nothing.",
|
||||||
|
"On the satellite tab, the mode is a coloured badge instead of a grey footnote, and an FM bird shows its CTCSS tone with the same weight as a frequency — a repeater called without its tone does not answer, and the operator hears an empty channel and concludes the satellite is not up. When there is no tone it says so, rather than leaving a blank that could mean either. The mode also appears in the transponder list and in the header, so it survives hiding the readout column.",
|
||||||
|
"New option: follow the azimuth only. A station with an ordinary rotator and no elevation motor can now track a satellite — a pass at the edge of the footprint stays between the horizon and about 15° for its whole length, and a beam covers that with its beamwidth. With it on, any rotator in the list can be chosen. What you give up is the high passes, where a satellite overhead has a bearing that means nothing, which is why it is a switch and not something OpsLog decides for you.",
|
||||||
|
"The pass table now lists every satellite you follow, not only the ones with a pass coming. QO-100 never has one because it never sets, a bird whose elements have not arrived cannot be predicted, and one whose next pass falls beyond the window is simply past the horizon of the table — all three used to look like satellites OpsLog had lost. They sit at the end, each saying which of the three it is, and clicking one selects it like any other row."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Toutes les interfaces de rotor sont désormais dans Réglages ▸ Rotator, et la page satellite ne fait qu’en choisir une. EasyComm et PstRotator se configuraient dans les réglages satellite pendant que les autres se configuraient dans la liste des rotors : un même pylône était décrit deux fois. Ce que vous aviez réglé est déplacé dans la liste et sélectionné automatiquement.",
|
||||||
|
"Chaque interface de rotor indique si elle pilote l’azimut seul ou l’azimut et l’élévation, juste à côté de l’interface. La liste des rotors de la page satellite affiche les azimut-seul en grisé plutôt que de les cacher : un rotor qui ne peut pas suivre un passage dit pourquoi.",
|
||||||
|
"Nouvelle interface de rotor : ERC-M de DF9GR, le contrôleur azimut/élévation pour un Yaesu G-5500. Via son port COM USB ou le réseau, avec son émulation réglée sur GS-232. Non testé sur matériel — vos retours sont les bienvenus.",
|
||||||
|
"EasyComm II devient une interface de rotor comme les autres : elle peut tourner l’antenne depuis le compas et depuis un clic sur un spot, plus seulement pendant un passage satellite.",
|
||||||
|
"Sur la carte satellite, un satellite non sélectionné est lisible : un point plus gros avec un halo sombre sous un anneau blanc, visible aussi bien sur une carte routière que sur un océan noir, et ceux au-dessus de l’horizon portent leur nom.",
|
||||||
|
"Le survol d’un satellite sur la carte indique désormais ce que vaut le passage — élévation et azimut, distance et si elle diminue ou augmente, lever et coucher avec le décompte, et la hauteur qu’il atteindra. L’infobulle ne se referme plus toutes les cinq secondes pendant qu’on la lit.",
|
||||||
|
"L’empreinte au sol n’est tracée que pour le satellite sélectionné. Une empreinte fait des milliers de kilomètres, et une douzaine se superposaient en un lavis de cercles qui masquait le trait de côte, la trace au sol et les satellites eux-mêmes.",
|
||||||
|
"Le plan de fréquences passe de 25 à 44 satellites, généré depuis Celestrak, PE0SAT et la base de transpondeurs SatNOGS au lieu d’être saisi à la main — les neuf Tevel-2, la station spatiale chinoise, AO-27, AO-123, RS-44 et vingt autres. Douze rentrés dans l’atmosphère ont été retirés, dont les Tevel de première génération. Votre fichier est fusionné et non remplacé : les satellites inconnus sont ajoutés, et vos corrections de fréquence restent.",
|
||||||
|
"Un satellite est désormais trouvé par son numéro de catalogue plutôt que par son nom. « RADFXSAT (FOX-1B) » et « AO-91 » sont le même oiseau, tout comme « TIANYAN 01 » et « TO-108 » — ces deux-là ne se rencontraient jamais, donc TO-108 ne suivait rien.",
|
||||||
|
"Sur l’onglet satellite, le mode est une pastille colorée au lieu d’une note grise, et un satellite FM affiche sa tonalité CTCSS avec le même poids qu’une fréquence — un relais appelé sans sa tonalité ne répond pas, et l’OM entend un canal vide et en conclut que le satellite n’est pas passé. Quand il n’y a pas de tonalité, c’est écrit, plutôt qu’un blanc qui pourrait vouloir dire l’un ou l’autre. Le mode apparaît aussi dans la liste des transpondeurs et dans l’en-tête, donc il survit au masquage de la colonne de droite.",
|
||||||
|
"Nouvelle option : suivre l’azimut seulement. Une station avec un rotor ordinaire et sans moteur d’élévation peut désormais suivre un satellite — un passage en bord d’empreinte reste entre l’horizon et 15° environ sur toute sa durée, et une beam couvre ça avec son ouverture. Avec l’option activée, n’importe quel rotor de la liste peut être choisi. Ce qu’on perd, ce sont les passages hauts, où un satellite au zénith a un cap qui ne veut plus rien dire — d’où un réglage plutôt qu’un choix fait à votre place.",
|
||||||
|
"Le tableau des passages liste désormais tous les satellites suivis, et plus seulement ceux qui ont un passage à venir. QO-100 n’en a jamais puisqu’il ne se couche pas, un satellite dont les éléments ne sont pas arrivés ne peut pas être prédit, et celui dont le prochain passage tombe au-delà de la fenêtre est simplement hors de portée du tableau — les trois avaient l’air de satellites qu’OpsLog avait perdus. Ils sont en fin de liste, chacun disant lequel des trois cas il est, et un clic les sélectionne comme n’importe quelle ligne."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.19",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"After an update, OpsLog starts again. The fix that stopped Defender calling the updater a trojan removed the helper that waited for the old process to die, and nothing took over the job: the new instance was patient with the single-instance lock for twenty seconds while the old one is allowed thirty to shut down — closing a remote logbook, a CAT session, sometimes a backup. Where that ran long the new process gave up in silence, leaving no window and a leftover OpsLog in the task manager. It now waits for the previous process itself, ending the instant it does; and if it really has not gone, it says so instead of claiming OpsLog is already running.",
|
||||||
|
"A UDP row set to multicast on an address that is not one now listens anyway. 127.0.0.1 in the group box is the common mistake — it is the address every other field in every other program wants — but a multicast group runs 224.0.0.0 to 239.255.255.255, and joining anything else failed on every interface with a Windows error naming nothing the operator had typed. The row simply did not run. It now listens on unicast, which is what such an address means, and says so in the log.",
|
||||||
|
"The confirmation defaults added for the newest services were blank. HAMLOG.online arrived after most profiles were set up, so it had no default at all — and blank is not a status anybody chose. Every service now starts the same way: the sent side at R (waiting to go out), the received side at N. A blank left by a service that did not exist when you last saved is filled in; a status you chose yourself is untouched.",
|
||||||
|
"OmniRig: a setting for rig files whose CW is the reverse one. OmniRig has two CW modes and nothing says which one a rig file calls plain CW — some Icom files map PM_CW_U to CW, others to CW-R — so clicking a CW spot on an IC-7610 landed the radio in CW-R, and the only way out was to edit the rig file. Settings → CAT → OmniRig now has a tick box for it, applied at once without dropping the link. (If your VFOs read the wrong way round on the same rig, the VFO override beside it is the answer: rig files disagree there too.)",
|
||||||
|
"Choosing a radio now switches CAT on. The master switch sits above the radio dropdown, and leaving it off while you pick your brand, type the address and run the detector — which finds your radio and prints its name — is a trap: a Flex 6700 owner did exactly that, saved six times, and got no link and no error. Picking a radio, or clicking one the detector found, ticks it. The panel also says so plainly while it is off, and the log line that used to announce \"link unchanged, staying connected\" when nothing was connected now says CAT is switched off.",
|
||||||
|
"My rig and my antenna are dropdowns now, in the entry form and in the QSO editor, offering what you declared in Settings → Operating conditions — and the antennas of the rig you picked, since that is what they hang off. Typing them again on every contact was 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 still works, for a QSO made from somebody else's station.",
|
||||||
|
"The FT decodes list is capped at 2000 rows. The rolling half hour is not a limit on a crowded evening — three decoders fill it with several thousand — and the panel slowed down long before anything aged out, since every row is a layout, a status and a distance. Past two thousand the oldest go, which is what has already been scrolled past.",
|
||||||
|
"The rotor dial sits on the panel instead of punching a hole in it. It was drawn on a full black square, which read as a tile dropped into the widget rather than an instrument on it; it is a disc now, and the corners are whatever it is sitting on. The continents are brighter too — at the old shade the land was about eight per cent lighter than the sea, technically a map and practically a dark square with a suggestion in it."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Après une mise à jour, OpsLog redémarre. Le correctif qui a fait cesser la détection en cheval de Troie a supprimé l'assistant qui attendait la mort de l'ancien processus, et rien n'a repris ce travail : la nouvelle instance patientait vingt secondes sur le verrou d'instance unique alors que l'ancienne dispose de trente pour se fermer — elle referme un journal distant, une session CAT, parfois une sauvegarde. Quand cela durait, le nouveau processus abandonnait en silence : pas de fenêtre, et un OpsLog restant dans le gestionnaire des tâches. Il attend désormais l'ancien processus lui-même, et repart à l'instant où celui-ci s'arrête ; et s'il n'est vraiment pas parti, il le dit au lieu d'annoncer qu'OpsLog tourne déjà.",
|
||||||
|
"Une ligne UDP réglée en multicast sur une adresse qui n'en est pas une écoute désormais quand même. 127.0.0.1 dans le champ groupe est l'erreur classique — c'est l'adresse que réclame tout autre champ de tout autre programme — mais un groupe multicast va de 224.0.0.0 à 239.255.255.255, et rejoindre autre chose échouait sur toutes les interfaces avec une erreur Windows ne nommant rien de ce que l'opérateur avait saisi. La ligne ne tournait tout simplement pas. Elle écoute maintenant en unicast, ce que veut dire une telle adresse, et le dit dans le journal.",
|
||||||
|
"Les statuts par défaut des services les plus récents étaient vides. HAMLOG.online est arrivé après la configuration de la plupart des profils : il n'avait donc aucun défaut — et vide n'est pas un statut que quelqu'un a choisi. Chaque service démarre désormais pareil : côté envoi R (en attente de départ), côté réception N. Un vide laissé par un service qui n'existait pas lors de votre dernier enregistrement est comblé ; un statut que vous avez choisi n'est pas touché.",
|
||||||
|
"OmniRig : un réglage pour les fichiers de rig dont la CW est l'inverse. OmniRig a deux modes CW et rien ne dit lequel un fichier appelle CW tout court — certains fichiers Icom associent PM_CW_U à CW, d'autres à CW-R — si bien qu'un clic sur un spot CW mettait un IC-7610 en CW-R, sans autre issue que de modifier le fichier de rig. Réglages → CAT → OmniRig a désormais une case pour cela, appliquée aussitôt sans couper la liaison. (Si vos VFO sont inversés sur la même radio, le sélecteur de VFO juste à côté est la réponse : les fichiers de rig divergent là aussi.)",
|
||||||
|
"Choisir une radio active désormais le CAT. L'interrupteur principal est au-dessus de la liste des radios, et le laisser éteint pendant qu'on choisit sa marque, saisit l'adresse et lance la détection — qui trouve la radio et affiche son nom — est un piège : un possesseur de Flex 6700 a fait exactement cela, enregistré six fois, sans liaison ni erreur. Choisir une radio, ou cliquer sur celle que la détection a trouvée, coche la case. Le panneau le dit aussi clairement tant qu'elle est décochée, et la ligne de journal qui annonçait « liaison inchangée, toujours connecté » alors que rien n'était connecté dit maintenant que le CAT est désactivé.",
|
||||||
|
"Mon équipement et mon antenne sont désormais des listes déroulantes, dans la saisie comme dans l'éditeur de QSO, proposant ce que vous avez déclaré dans Réglages → Conditions de trafic — et les antennes du poste choisi, puisque c'est à lui qu'elles sont rattachées. Les retaper à chaque contact était à la fois du travail et une source d'orthographes divergentes : « IC-7610 », « IC 7610 » et « ic7610 » sont trois équipements différents pour un diplôme et pour un filtre. La saisie libre reste possible, pour un QSO fait depuis la station de quelqu'un d'autre.",
|
||||||
|
"La liste des décodages FT est plafonnée à 2000 lignes. La demi-heure glissante n'est pas une limite un soir chargé — trois décodeurs la remplissent de plusieurs milliers — et le panneau ralentissait bien avant que quoi que ce soit n'expire, chaque ligne étant une mise en page, un statut et une distance. Au-delà de deux mille, les plus anciennes partent : celles qu'on a déjà dépassées en défilant.",
|
||||||
|
"Le cadran du rotor se pose sur le panneau au lieu d'y percer un trou. Il était dessiné sur un carré noir plein, qui se lisait comme une tuile posée dans le widget plutôt que comme un instrument dessus ; c'est un disque désormais, et les coins sont ce sur quoi il repose. Les continents sont aussi plus clairs — à l'ancienne teinte, la terre était environ huit pour cent plus claire que la mer : techniquement une carte, en pratique un carré sombre avec une suggestion dedans."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.18",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"The dialogs you type in no longer sit on a blurred backdrop. A backdrop filter covers the whole window and is recomputed every time anything above it repaints — and behind these dialogs is an application that never stops moving: CAT polling four times a second, spots arriving, meters sweeping, maps redrawing. Worse in one place: the cluster editor opens from Preferences, so its overlay was a second full-window filter stacked over the first. Preferences, the cluster editor, the QSO editor, bulk edit, alert rules and award definitions now dim the background instead of blurring it; everything else keeps the blur.",
|
||||||
|
"One padlock on the entry form instead of five. Logging a contact from paper — a contest sheet, a friend's report, a QSO worked on another radio — means the frequency, the band, the mode, the date and both times all have to stop following the rig and the clock at once. That was five clicks in five different places, each of which had to be found first. The padlock beside Start UTC now holds all of them, and releases all of them.",
|
||||||
|
"Preferences no longer says the section name twice — the small line above each panel repeated the heading right under it, and the sidebar beside it already shows which section is open.",
|
||||||
|
"The band matrix can open on the digital mode you actually work. An operator who only ever does FT8 was shown DIGI every time and had to click through to their own mode on every callsign; Settings → General now chooses which digital row the matrix starts on. The row still rotates when you click it, and DIGI — all of them together — stays the default.",
|
||||||
|
"The MQTT chip is gone from the status bar. That is the name of a message protocol, not of anything an operator has. The state it carried — the openings feed up or down, and how many reports have arrived — is in the Chase New panel, which is the place that uses it.",
|
||||||
|
"The callsign box no longer narrows when you close the padlock. Its row gains a date field for a manual entry, and a flex row makes room by shrinking its children — so the widest box, the one the eye is on while typing, was the one that visibly moved. The callsign and both report boxes are now a notch narrower and fixed there, whether the date is showing or not."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Les dialogues dans lesquels on tape ne reposent plus sur un fond flouté. Un filtre de fond couvre toute la fenêtre et est recalculé chaque fois que quoi que ce soit au-dessus se repeint — et derrière ces dialogues il y a une application qui ne s'arrête jamais de bouger : le CAT qui interroge quatre fois par seconde, les spots qui arrivent, les vumètres qui balaient, les cartes qui se redessinent. Pire à un endroit : l'éditeur de cluster s'ouvre depuis les Préférences, donc son fond était un deuxième filtre plein écran empilé sur le premier. Les Préférences, l'éditeur de cluster, l'éditeur de QSO, l'édition groupée, les règles d'alerte et les définitions de diplômes assombrissent désormais le fond au lieu de le flouter ; tout le reste garde le flou.",
|
||||||
|
"Un seul cadenas dans la saisie au lieu de cinq. Enregistrer un contact depuis une feuille — un carnet de concours, le report d'un ami, un QSO fait sur une autre radio — suppose que la fréquence, la bande, le mode, la date et les deux heures cessent tous en même temps de suivre le poste et l'horloge. C'étaient cinq clics à cinq endroits différents, qu'il fallait d'abord trouver. Le cadenas à côté de Début UTC les fige maintenant tous, et les libère tous.",
|
||||||
|
"Les Préférences ne disent plus deux fois le nom de la section — la petite ligne au-dessus de chaque panneau répétait le titre juste en dessous, et la barre latérale montre déjà laquelle est ouverte.",
|
||||||
|
"La matrice peut s'ouvrir sur le mode numérique que vous travaillez vraiment. Celui qui ne fait que du FT8 voyait DIGI à chaque fois et devait cliquer jusqu'à son mode pour chaque indicatif ; Réglages → Général choisit désormais la ligne numérique sur laquelle la matrice démarre. La ligne continue de tourner au clic, et DIGI — tous ensemble — reste le défaut.",
|
||||||
|
"La pastille MQTT disparaît de la barre d'état. C'est le nom d'un protocole de messages, pas de quelque chose que possède un opérateur. Ce qu'elle indiquait — le flux d'ouvertures actif ou non, et le nombre de reports arrivés — est dans le panneau Chasse au nouveau, à l'endroit qui s'en sert.",
|
||||||
|
"Le champ indicatif ne rétrécit plus quand on ferme le cadenas. Sa ligne gagne un champ date pour une saisie manuelle, et une ligne flex fait de la place en rétrécissant ses enfants — donc le plus large, celui que l'œil suit pendant la frappe, était celui qui bougeait visiblement. L'indicatif et les deux champs de report sont désormais un cran plus étroits et fixes, que la date soit affichée ou non."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.17",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"[NEW] Satellites. A new tab (Tools → Satellites) works the amateur birds from end to end. A map with each satellite's footprint and the selected one's path over the ground; a sky plot the way every tracker draws one, centre straight up and rim at the horizon, with the whole pass and where the bird is on it; a countdown to AOS — or to LOS once it is up — with rise, peak and set, their compass directions, distance, altitude and footprint; and a pass table for everything you follow.\n\nTrack puts the radio on the satellite and keeps it there, once a second: an IC-9700 or IC-9100 in its own satellite mode, a FlexRadio on two slices (A the downlink, B the uplink, created if missing, full duplex on) — and any other rig on the downlink, which it says plainly rather than half-doing the job. Tune the receiver where you like: the tracker reads the dial, takes it as the station you have chosen, and moves the transmitter to match. An az/el rotator follows along, either driven directly over EasyComm II or handed to PstRotator if you already run it; a 450° rotator is used as one, so a pass crossing north continues instead of unwinding.\n\nQSOs made while tracking are logged with the NOMINAL frequencies, SAT_NAME, SAT_MODE and PROP_MODE=SAT — the transponder's own numbers, which both stations can agree on, rather than where one radio happened to be.\n\nOrbital elements come from Celestrak with a mirror behind it and are kept on disk, so the tab is full the moment it opens even with no internet; elements for a bird no feed carries yet can be pasted in and survive every refresh. Twenty-five satellites ship with a frequency plan — the FM and linear birds, GreenCube, QO-100 narrow and wide — in a file you can correct yourself when a transponder is switched. Everything about setting it up lives in Settings → Satellites, including which satellites you follow, chosen the way you choose awards.",
|
||||||
|
"Each map keeps its own imagery. The world map and the grid-square map shared one setting, so choosing satellite imagery to look at grids repainted the main map as well, and there was no way to have terrain on one and plain streets on the other. All four — world, grid squares, FT map, satellites — now remember their own choice, and it travels with the data folder like the remembered views. A choice already made for the grid map is carried over, not reset.",
|
||||||
|
"Two or three FT8 programs at once no longer fight over the callsign field. Click a station in MSHV and only MSHV has a DX Call; WSJT-X and JTDX beside it are idle and say so once a second each — and OpsLog was reading those as MSHV abandoning the station, so the entry emptied and refilled at 1 Hz and the map zoomed in and out with it. A cleared DX Call is now read per program, never across the listener; and the program that announces a station keeps the entry field until it clears its own call, is closed, or the QSO is logged.",
|
||||||
|
"The FT decodes table sorts on SNR, frequency, distance, country and status — click the heading. Within each slot and never across them: the periods are what the panel is, and a list sorted end to end would mix three minutes of decodes into one column with no way to tell which window any of them came from. One click sorts the way that column is worth reading (strongest signal, furthest DX, lowest frequency, A to Z, most wanted first), the second reverses it, the third gives back the order the decoder heard them in. Stations with no grid, or no country resolved yet, sort to the end either way rather than pretending to a distance of zero.",
|
||||||
|
"The cluster editor offers a list of known nodes. Setting up a telnet cluster is the step operators get stuck on: the address and the port are two pieces of information nobody has to hand, and a typo in either looks exactly like a node that is down. Pick one and the fields fill in — F4BPO, DXFun, F5LEN, F5MZN, KM3T, SOTA, POTA, and the two Reverse Beacon feeds, which are one network on two ports where 7000 carries CW and RTTY and 7001 carries FT8 and FT4. Everything stays editable, and a node typed in by hand works exactly the same. More will be added.",
|
||||||
|
"Preferences no longer lag behind the keyboard. Typing a cluster macro re-rendered the whole dialog on every keystroke and wrote a row into the database per character; the twenty-four boxes now stand on their own and the database write waits for the typing to stop.",
|
||||||
|
"Station Control shows what commands the station, not only what it switches. The radio is there now — frequency, mode, band, and the split pair when there is one — with the CW keyer beside it (speed up and down, and Stop, because a message going to the wrong callsign has to end now) and the voice keyer with its recorded messages as buttons, so a CQ goes out without leaving the tab. The two keyers appear only when there is something behind them: a port configured, or a message actually recorded. All three move and reorder with the other cards."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"[NOUVEAU] Satellites. Un nouvel onglet (Outils → Satellites) permet de travailler les satellites amateurs de bout en bout. Une carte avec l'empreinte de chacun et la trace au sol du satellite sélectionné ; une vue du ciel comme la dessine n'importe quel tracker, centre à la verticale et bord à l'horizon, avec le passage entier et la position du satellite dessus ; un compte à rebours jusqu'à l'AOS — ou jusqu'au LOS une fois levé — avec lever, culmination et coucher, leurs directions à la boussole, distance, altitude et empreinte ; et un tableau des passages de tout ce que vous suivez.\n\n« Suivre » met la radio sur le satellite et l'y maintient, chaque seconde : un IC-9700 ou IC-9100 dans son propre mode satellite, un FlexRadio sur deux slices (A la descente, B la montée, créées si elles manquent, full duplex activé) — et n'importe quel autre poste sur la descente seule, ce qu'il annonce clairement plutôt que de faire le travail à moitié. Accordez le récepteur où vous voulez : le suivi lit le VFO, y voit la station que vous avez choisie, et déplace l'émetteur en conséquence. Un rotor az/él suit aussi, piloté directement en EasyComm II ou confié à PstRotator si vous le faites déjà tourner ; un rotor 450° est utilisé comme tel, et un passage qui traverse le nord continue au lieu de se dérouler.\n\nLes QSO faits pendant le suivi sont enregistrés avec les fréquences NOMINALES, SAT_NAME, SAT_MODE et PROP_MODE=SAT — les chiffres du transpondeur, sur lesquels les deux stations peuvent s'accorder, plutôt que l'endroit où une radio se trouvait.\n\nLes éléments orbitaux viennent de Celestrak, avec un miroir derrière, et sont conservés sur disque : l'onglet est rempli dès son ouverture, même sans internet. Les éléments d'un satellite qu'aucun flux ne diffuse encore peuvent être collés à la main et survivent à chaque mise à jour. Vingt-cinq satellites sont livrés avec un plan de fréquences — les FM et les linéaires, GreenCube, QO-100 bande étroite et large — dans un fichier que vous pouvez corriger vous-même quand un transpondeur change de mode. Toute la configuration est dans Réglages → Satellites, y compris le choix des satellites suivis, sélectionnés comme on choisit ses diplômes.",
|
||||||
|
"Chaque carte garde son propre fond. La carte principale et celle des carrés partageaient un seul réglage : choisir la vue satellite pour regarder les carrés repeignait aussi la carte principale, et il n'y avait aucun moyen d'avoir le relief sur l'une et les rues sur l'autre. Les quatre — principale, carrés, FT map, satellites — retiennent désormais leur propre choix, qui suit le dossier de données comme les positions mémorisées. Un choix déjà fait pour la carte des carrés est repris, pas réinitialisé.",
|
||||||
|
"Deux ou trois logiciels FT8 en même temps ne se disputent plus le champ indicatif. Cliquez une station dans MSHV et lui seul a un DX Call ; WSJT-X et JTDX à côté sont au repos et le disent une fois par seconde chacun — et OpsLog y lisait MSHV abandonnant la station : le champ se vidait et se remplissait à 1 Hz, la carte zoomant au même rythme. Un DX Call effacé est désormais lu par programme, jamais à l'échelle du port ; et le logiciel qui annonce une station garde le champ jusqu'à ce qu'il efface son propre indicatif, soit fermé, ou que le QSO soit enregistré.",
|
||||||
|
"Le tableau des décodages FT se trie sur SNR, fréquence, distance, pays et statut — cliquez l'en-tête. À l'intérieur de chaque créneau et jamais au travers : les périodes sont la raison d'être du panneau, et un tri de bout en bout mélangerait trois minutes de décodages en une colonne sans plus savoir de quelle fenêtre chacun vient. Un clic trie dans le sens où la colonne se lit (signal le plus fort, DX le plus lointain, fréquence la plus basse, de A à Z, le plus recherché d'abord), un second inverse, un troisième rend l'ordre dans lequel le décodeur les a entendus. Les stations sans locator, ou dont le pays n'est pas encore résolu, se rangent à la fin dans les deux sens plutôt que de se faire passer pour une distance nulle.",
|
||||||
|
"L'éditeur de cluster propose une liste de nœuds connus. La configuration d'un cluster telnet est l'étape où l'on se bloque : l'adresse et le port sont deux informations que personne n'a sous la main, et une faute de frappe dans l'une ou l'autre ressemble exactement à un nœud en panne. On en choisit un et les champs se remplissent — F4BPO, DXFun, F5LEN, F5MZN, KM3T, SOTA, POTA, et les deux flux Reverse Beacon, qui sont un même réseau sur deux ports où 7000 porte la CW et le RTTY et 7001 le FT8 et le FT4. Tout reste modifiable, et un nœud saisi à la main fonctionne exactement pareil. D'autres seront ajoutés.",
|
||||||
|
"Les Préférences ne traînent plus derrière le clavier. Saisir une macro de cluster redessinait tout le dialogue à chaque frappe et écrivait une ligne en base par caractère ; les vingt-quatre champs sont désormais indépendants et l'écriture en base attend la fin de la saisie.",
|
||||||
|
"Contrôle station montre ce qui commande la station, et plus seulement ce qui la commute. La radio y figure désormais — fréquence, mode, bande, et le couple split quand il y en a un — avec à côté le manipulateur CW (vitesse en plus ou en moins, et Stop, parce qu'un message parti vers le mauvais indicatif doit s'arrêter tout de suite) et le manipulateur vocal avec ses messages enregistrés en boutons, pour lancer un CQ sans quitter l'onglet. Les deux manipulateurs n'apparaissent que s'il y a quelque chose derrière : un port configuré, ou un message réellement enregistré. Les trois se déplacent et se réordonnent avec les autres cartes."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.16",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"[NEW] Typing a digital watering hole sets the mode with it. A spot click has always carried one; a frequency typed by hand carried none, so the rig stayed in SSB on 28.074 while the operator waited for decodes. Same table and same tolerance as a spot (±3 kHz of a known FT8/FT4/JS8 frequency), only towards the digital modes: tuning away from one leaves the mode alone, because there the frequency says nothing about what you mean to do.",
|
||||||
|
"Yaesu CAT now drives the older radios. The FTDX10, FT-991A, FT-891 and FT-710 write a frequency in nine digits; everything before them — FTDX3000, FTDX5000, FTDX1200, FT-2000, FT-950, FT-450 — writes eight and answers a nine-digit command with a rejection, which is what an FTDX3000 owner saw: every FA refused and a radio that would not follow. The width is taken from the rig’s own reply rather than from a table of models, so a set is in the format that radio speaks — including models this backend has never heard of.",
|
||||||
|
"Yaesu: RTTY can be set on USB (Settings → CAT). ADIF records only “RTTY” and the rig has both sidebands, so the log cannot answer for it — the older RTTY-L stays the default, and a station whose FSK controller wants the upper one says so once. The choice reaches the radio already connected: it is not part of what defines the link, so the link is not rebuilt for it — and until now that meant it waited for the next launch while the rig went on choosing LSB.",
|
||||||
|
"The update no longer relaunches OpsLog through a hidden PowerShell. An unsigned program that replaces itself on disk, clears the mark-of-the-web and then spawns a windowless PowerShell to start another executable is — byte for byte — the shape of a dropper, and Windows Defender’s machine-learning model reads the shape, not the intention: 0.27.14 was removed from a station under Trojan:Script/Wacatac.H!ml. The new version simply starts itself and waits its turn on the single-instance lock, which it already knew how to do. Only the rare fallback path, when the running file cannot even be renamed, still needs a helper that outlives the process.",
|
||||||
|
"Rotor widget: with more than one rotor the panel no longer runs off the bottom. The selector row appears above the dial, and the widget’s height is not its own to take — it sits in a strip sized by the entry form beside it — so the SP/LP pair and half the Stop button were cut off. The dial, the button rows and the padding now give that row back between them, in proportion, and nothing is dropped."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"[NEW] Taper une fréquence d’appel numérique règle le mode avec elle. Un clic sur un spot en portait un depuis toujours ; une fréquence tapée à la main n’en portait aucun, si bien que le poste restait en SSB sur 28.074 pendant qu’on attendait les décodages. Même table et même tolérance qu’un spot (±3 kHz d’une fréquence FT8/FT4/JS8 connue), et seulement vers les modes numériques : en s’en éloignant le mode n’est pas touché, car là la fréquence ne dit rien de ce qu’on veut faire.",
|
||||||
|
"Le CAT Yaesu pilote désormais les postes plus anciens. FTDX10, FT-991A, FT-891 et FT-710 écrivent une fréquence sur neuf chiffres ; tout ce qui précède — FTDX3000, FTDX5000, FTDX1200, FT-2000, FT-950, FT-450 — l’écrit sur huit et rejette une commande à neuf chiffres. C’est ce que voyait un possesseur de FTDX3000 : chaque FA refusée et une radio qui ne suivait pas. Le format est pris dans la réponse du poste plutôt que dans une table de modèles : l’envoi part donc dans la langue de cette radio-là, y compris pour des modèles que ce backend ne connaît pas.",
|
||||||
|
"Yaesu : le RTTY peut être placé en USB (Réglages → CAT). L’ADIF n’enregistre que « RTTY » et le poste a les deux bandes latérales : le log ne peut pas répondre à sa place. Le RTTY-L ancien reste par défaut, et une station dont l’interface FSK veut la supérieure le dit une fois. Le choix atteint le poste déjà connecté : il ne fait pas partie de ce qui définit la liaison, donc celle-ci n’est pas reconstruite pour lui — et jusqu’ici cela voulait dire qu’il attendait le prochain lancement pendant que le poste continuait de choisir la LSB.",
|
||||||
|
"La mise à jour ne relance plus OpsLog par un PowerShell caché. Un programme non signé qui se remplace sur le disque, efface la marque « téléchargé depuis Internet » puis lance un PowerShell sans fenêtre pour démarrer un autre exécutable a — à l’octet près — la forme d’un dropper, et le modèle d’apprentissage de Windows Defender lit la forme, pas l’intention : la 0.27.14 a été supprimée chez un OM sous Trojan:Script/Wacatac.H!ml. La nouvelle version se lance elle-même et attend son tour sur le verrou d’instance unique, ce qu’elle savait déjà faire. Seul le repli rare, quand le fichier en cours d’exécution ne peut même pas être renommé, garde un assistant qui survit au processus.",
|
||||||
|
"Widget rotor : avec plusieurs rotors, le panneau ne déborde plus par le bas. La rangée de sélection apparaît au-dessus du cadran, et la hauteur du widget ne lui appartient pas — il occupe une bande dont la hauteur est fixée par la saisie à côté — si bien que la paire SP/LP et la moitié du bouton Stop se retrouvaient coupées. Le cadran, les rangées de boutons et les marges rendent désormais cette hauteur entre eux, chacun pour sa part, sans rien supprimer."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.15",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"[NEW] The WSJT-X / JTDX highlight colours are yours to choose (Settings → UDP), one per verdict — watch list, new DXCC, new band, worked. Only the background is set: the text colour is worked out from it, so a chosen colour cannot come back unreadable in the decoder’s window. The “grey out stations already worked” switch keeps its purpose — it decides WHETHER dupes are marked, not what colour they are — and is now called “Mark stations already worked”.",
|
||||||
|
"[NEW] Rotor dial: a circular scale and a beam instead of an arrow. The square ring made a marker at 45° sit further from the centre than one at north — a dial is read by angle, so the ring it is read against is now the same distance away all the way round. The antenna is drawn as a sector that fades outwards, which is the shape of the thing it stands for; where the mouse would send it appears in the same shape in orange, and its azimuth in place of the current heading while you aim. Green for where the antenna is, orange for where it would go, yellow for what was ordered — the second lobe of a bidirectional Ultrabeam and the dashed boom are unchanged. Design from EC1KD again.",
|
||||||
|
"WSJT-X highlighting: a watch-list station already worked on this band and mode is no longer painted as one to call. The list is a statement of intent, not of what is left to do, and its pink outranked everything — including the log — so a station already worked stayed pink for the session with no way to tell it from one still needed.",
|
||||||
|
"Motorised antenna: the transmitter is released as soon as the elements stop. Three delays were stacked between the antenna finishing and the operator being allowed to call — the antenna polled every two seconds, the transmit gag held for three after the command whatever the antenna said, and the screen refreshed every three. Both the Ultrabeam and the SteppIR are now polled four times a second WHILE IT MOVES (and left at two seconds when it is still, where nothing changes), the gag only bridges the command itself, and the widget follows at half a second. The SteppIR also reports a commanded move at once, as the Ultrabeam already did: it says nothing until its own poll comes round, so the shortened gag would otherwise have released the transmitter in the middle of a move.",
|
||||||
|
"Motorised antenna: the indicator turns amber the instant the move is ORDERED, from a button or from an automatic band change with tracking on, instead of waiting for the antenna to say it is moving. The transmit gag already started there; the screen did not, so the two disagreed by a second or more — and on a follow there was no warning at all until a poll landed.",
|
||||||
|
"Icom network audio starts at once instead of half a minute later. The message that authorises the stream is sent during the login, before the audio socket exists — so the rig was told to send audio to a port nothing was bound to, got a port-unreachable back, and only resumed when its own retry timer came round. It is sent once more as soon as the port is listening.",
|
||||||
|
"Icom over the network: when CI-V goes quiet while the experimental RX audio stream is still delivering, the log now says so and names the switch to try. The two share the rig’s session, and the shape in the field is exactly that — hundreds of audio packets arriving, not one CI-V reply, the watchdog tearing the session down, and the whole thing starting again. The silence report also lists the last eight CI-V commands sent: a rig that answers at connect and then never again has usually been sent something it does not like, and a count of unanswered commands never said which one.",
|
||||||
|
"Icom over the network: a rig left in standby no longer sits in a dial-and-drop loop. The clock that bounds “the control link answers but no CI-V comes back” belongs to a session and was never cleared when a new one opened, so every fresh session started already past its grace — torn down at once, redialled twenty seconds later, and torn down again for as long as the radio was asleep. Silent since connect is now read as what it is: a rig in standby, with the session kept so it can be woken.",
|
||||||
|
"The Icom console appears whenever the configured radio is an Icom, not only once the rig is talking — the console is where the power-ON button lives, so it used to be missing at the one moment it was needed. The console’s configured backend also follows a radio switched from the status bar, instead of waiting for a trip through Settings and a Save that changed nothing.",
|
||||||
|
"Audio: the Listening device now says when it cannot be opened. A device unplugged, renamed by Windows or unable to run at 16 kHz failed silently while everything upstream reported success — the stream up, the packets arriving, the monitor started — which is the whole of “I turned the sound on and nothing comes out”. The log also says, once, whether the network RX audio is reaching the speakers or arriving with nobody listening.",
|
||||||
|
"ADIF export: a record is written on one line again. ADDRESS is a multi-line field by the standard and callbooks and other loggers fill it that way — “Kabul”, four blank lines, “Afghanistan” — and OpsLog wrote it out as it was, so a record ran down a dozen lines with the next apparently starting in the middle of the page. Line breaks inside a value are now joined with a comma, which is how an address reads on one line anyway. The files were always valid (ADIF counts bytes); they were unreadable.",
|
||||||
|
"Club Log uploads are no longer refused as “not configured”. The check added for services with no credentials demanded a Club Log API key, which nobody has ever set — OpsLog carries its own application key — so an operator whose live upload had worked for months was turned away when sending QSOs by hand. Each service’s requirements now live beside the uploader that enforces them, and the message names the fields that are actually missing.",
|
||||||
|
"FT map: the callsign, square and report show on hover again. The invisible circle that catches the clicks sits on top of the dot, so it takes the hover too — and the label was bound only to the dot underneath, which left the map silent from the moment the stations became clickable."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"[NEW] Les couleurs de mise en évidence WSJT-X / JTDX sont au choix (Réglages → UDP), une par verdict — watchlist, nouveau DXCC, nouvelle bande, contactée. Seul le fond se règle : la couleur du texte en est déduite, pour qu’une couleur choisie ne revienne jamais illisible dans la fenêtre du décodeur. L’option « griser les stations déjà contactées » garde sa raison d’être — elle décide SI les doublons sont marqués, pas de quelle couleur — et s’appelle désormais « Marquer les stations déjà contactées ».",
|
||||||
|
"[NEW] Cadran rotor : échelle circulaire et faisceau au lieu d’une flèche. L’anneau carré plaçait un repère à 45° plus loin du centre qu’un repère au nord — un cadran se lit par l’angle, donc l’anneau qui sert de référence est désormais à la même distance tout autour. L’antenne est dessinée comme un secteur qui s’estompe vers l’extérieur, ce qui est la forme de ce qu’il représente ; là où la souris l’enverrait apparaît dans la même forme en orange, et son azimut à la place du cap courant pendant qu’on vise. Vert pour où l’antenne est, orange pour où elle irait, jaune pour ce qui a été demandé — le deuxième lobe d’un Ultrabeam bidirectionnel et le boom en pointillés sont inchangés. Design d’EC1KD, encore.",
|
||||||
|
"Mise en évidence WSJT-X : une station de la watchlist déjà contactée sur cette bande et ce mode n’est plus peinte comme une station à appeler. La liste dit une intention, pas ce qu’il reste à faire, et son rose passait devant tout — y compris le carnet — si bien qu’une station déjà faite restait rose toute la session, sans moyen de la distinguer d’une station encore à faire.",
|
||||||
|
"Antenne motorisée : l’émission est rendue dès que les éléments s’arrêtent. Trois délais s’ajoutaient entre la fin du mouvement et le droit d’appeler — l’antenne interrogée toutes les deux secondes, le blocage d’émission maintenu trois secondes après la commande quoi qu’en dise l’antenne, et l’écran rafraîchi toutes les trois. L’Ultrabeam comme la SteppIR sont désormais interrogées quatre fois par seconde PENDANT qu’elle bouge (et laissée à deux secondes à l’arrêt, où rien ne change), le blocage ne couvre plus que la commande elle-même, et le widget suit à la demi-seconde. La SteppIR signale aussi un mouvement dès qu’il est commandé, comme le faisait déjà l’Ultrabeam : elle ne dit rien avant sa propre lecture, et le blocage raccourci aurait sinon rendu l’émission en plein mouvement.",
|
||||||
|
"Antenne motorisée : l’indicateur passe à l’ambre à l’instant où le mouvement est COMMANDÉ, par un bouton comme par un changement de bande automatique quand le suivi est actif, au lieu d’attendre que l’antenne dise qu’elle bouge. Le blocage d’émission démarrait déjà là ; l’écran non, et les deux se contredisaient d’une seconde ou plus — sur un suivi, il n’y avait aucun signe avant l’arrivée d’une lecture.",
|
||||||
|
"L’audio réseau Icom démarre tout de suite au lieu d’une demi-minute plus tard. Le message qui autorise le flux part pendant la connexion, avant que la prise audio n’existe : le poste se voyait donc demander d’émettre vers un port où personne n’écoutait, recevait un « port injoignable » en retour, et ne reprenait qu’au tour suivant de son propre minuteur. Il est renvoyé dès que le port écoute.",
|
||||||
|
"Icom en réseau : quand le CI-V devient muet alors que le flux audio expérimental continue d’arriver, le journal le dit et nomme l’option à essayer. Les deux partagent la session du poste, et c’est exactement la forme observée en vrai — des centaines de paquets audio, pas une réponse CI-V, le chien de garde qui coupe la session, et tout qui recommence. Le rapport de silence liste aussi les huit dernières commandes CI-V envoyées : un poste qui répond à la connexion puis plus jamais s’est en général vu envoyer quelque chose qu’il n’aime pas, et un compteur de commandes sans réponse n’a jamais dit laquelle.",
|
||||||
|
"Icom en réseau : un poste laissé en veille ne tourne plus en boucle connexion/déconnexion. L’horloge qui borne « la liaison de contrôle répond mais aucun CI-V ne revient » appartient à une session et n’était jamais remise à zéro à l’ouverture de la suivante : chaque nouvelle session démarrait déjà au-delà de son délai de grâce — coupée aussitôt, rappelée vingt secondes plus tard, recoupée, aussi longtemps que la radio dormait. « Silencieux depuis la connexion » se lit désormais pour ce que c’est : un poste en veille, dont on garde la session pour pouvoir le réveiller.",
|
||||||
|
"La console Icom s’affiche dès que la radio configurée est un Icom, et pas seulement quand le poste parle — c’est là que se trouve le bouton d’allumage, il manquait donc au seul moment où il servait. Le backend configuré suit aussi un changement de radio fait depuis la barre d’état, au lieu d’attendre un passage dans les réglages et un « Enregistrer » qui ne changeait rien.",
|
||||||
|
"Audio : le périphérique d’écoute signale désormais quand il ne peut pas s’ouvrir. Un périphérique débranché, renommé par Windows ou incapable de fonctionner en 16 kHz échouait en silence pendant que tout en amont annonçait le succès — flux ouvert, paquets reçus, moniteur démarré — ce qui est exactement le « j’ai remis le son et rien ne sort ». Le journal dit aussi, une fois, si l’audio réseau atteint les haut-parleurs ou arrive sans que personne n’écoute.",
|
||||||
|
"Export ADIF : un enregistrement tient de nouveau sur une ligne. ADDRESS est un champ multiligne selon la norme, et les callbooks comme les autres logiciels le remplissent ainsi — « Kabul », quatre lignes vides, « Afghanistan » — qu’OpsLog recopiait tel quel : un enregistrement s’étalait sur une douzaine de lignes, le suivant semblant commencer au milieu de la page. Les retours à la ligne dans une valeur sont désormais réunis par une virgule, ce qui est de toute façon la façon de lire une adresse sur une ligne. Les fichiers étaient valides (l’ADIF compte les octets) ; ils étaient illisibles.",
|
||||||
|
"Les envois vers Club Log ne sont plus refusés comme « non configuré ». Le contrôle ajouté pour les services sans identifiants réclamait une clé API Club Log que personne n’a jamais saisie — OpsLog embarque la sienne — et un opérateur dont l’envoi automatique fonctionnait depuis des mois se voyait éconduit au moment d’envoyer des QSO à la main. Les exigences de chaque service vivent désormais à côté du code qui les applique, et le message nomme les champs réellement manquants.",
|
||||||
|
"Carte FTx : l’indicatif, le locator et le report réapparaissent au survol. Le cercle invisible qui capte les clics est au-dessus du point, donc il capte aussi le survol — et l’étiquette n’était liée qu’au point du dessous, ce qui rendait la carte muette dès que les stations sont devenues cliquables."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.14",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"[NEW] The mouse wheel steps the RST fields, in the entry strip and in the QSO editor. It counts the way an operator does — 57, 58, 59, 59+5, 59+10, 59+15, 59+20 — one S-unit up to nine and then five decibels at a time, and one decibel on a digital report. R and T do not move. The dropdown beside them lists the reports worth having to hand, not every legal one, so the wheel works on the value rather than walking the list.",
|
||||||
|
"[NEW] The world map opens centred on YOUR square, not on Greenwich. Centred on 0° it left an Australian looking at their own country in the bottom-right corner with every path 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. The latitude leans towards your hemisphere without following you to the pole, and a view you have panned to yourself still wins.",
|
||||||
|
"The world map now waits for the station’s square before painting. It used to draw the world at 0° and then move to your longitude, fetching a screenful of tiles and discarding it on every first run; it is built once, knowing where it is looking. A profile with no locator still gets the default view after a moment rather than a blank panel.",
|
||||||
|
"[NEW] The FT decodes map and the grid-square map remember where you left them — centre and zoom, portable with the data folder like the world map’s own view. Panning a map is the operator saying which part of the world they are working, and it was being thrown away on every tab switch.",
|
||||||
|
"[NEW] FT map: the station dots answer the same two gestures as the decodes list — one click takes the callsign into the entry, two answer it. The hit area is wider than the dot, and a double click no longer zooms the map on its way through.",
|
||||||
|
"The auto-call readout moved out of the Auto button and beside it: the station being called is the biggest thing on the row, the calls and the missed periods each carry a label instead of reading as one number, and a station being waited for shows with an hourglass. The button had been changing width every period.",
|
||||||
|
"Auto-call: the rest between two series is counted in the station’s own overs, not in minutes, and is ONE by default. Two minutes is four overs on FT8 — by then the DX has worked four other callers and half the time it has gone. Seven calls, one over listened through, and it goes again if the station is still there (Settings → DXHunter, “Rest (overs)”).",
|
||||||
|
"Auto-call sees a decode that arrives after the others. A decoder sends a period in a burst and stragglers follow — a deep decode a second behind the rest — and the straggler was judged on its own, with the thirty stations of its own period nowhere in sight. The period now stays open until the next one starts, and a late arrival is weighed against all of it.",
|
||||||
|
"Auto-call: a period the station was decoded in is never counted as a miss. A period is judged more than once — the decodes arrive in a burst and stragglers follow — and a later judgement holds a partial view of it, not evidence of absence: a station answering in that very period showed “1/3 missed” against it.",
|
||||||
|
"Auto-call never parks a watched callsign. After a few series of unanswered calls a station is set aside for the session — the right answer for one the LOG picked out, the wrong one for a station YOU named: a DXpedition running a pileup takes more than two series to get through to, which is exactly why it is on the list. The rest between series still applies.",
|
||||||
|
"PSK Reporter panel: with the whole-band scope, clicking a decode no longer resets the report count to zero. The window there belongs to the BAND — every FTx report on it, filtered by target only when the analysis is drawn — and it was being emptied on every target change, throwing away an hour of evidence at the exact moment it was worth something. The narrow scope still clears it, because there the window is one station’s.",
|
||||||
|
"Changing mode with a callsign in the field now fixes the report. The “the operator chose this report” flag was holding across a change of mode, where it means nothing — “+00” is not a weak SSB report, it is not a report at all — and anything that fills the field from the rig (the S-meter readouts in the rig consoles) sets that flag too, so it could stay in the wrong notation for the whole QSO. A judgement that can be carried across is carried (57 → 579, 599 → 59); otherwise the mode’s preset answers.",
|
||||||
|
"Icom CI-V: address 00 can be set, and “Other (custom address)” stays chosen. Zero was treated as “not configured” and every save put the rig back to the IC-7610’s 98 — the model list following it, since it is derived from the address rather than stored.",
|
||||||
|
"Cloudlog / Wavelog upload: a simplex contact is no longer uploaded as split. Every QSO carried a receive band and frequency equal to the transmit side, and Wavelog draws both — an ordinary FT8 contact read “17m/17m”. In ADIF an absent BAND_RX means “same as transmit”, so they are now written only when they differ. The record forwarded to another logger on the UDP link still carries them in full (Log4OM reads BAND_RX).",
|
||||||
|
"Right-click → Send to: an upload to a service with no credentials is refused, and says which ones are missing and where. It used to run on its own and report into the QSL Manager’s console, which is not open when the command came from the QSO list — so it looked exactly like an upload that worked. Cloudlog / Wavelog and HamQTH also name themselves properly in the toast.",
|
||||||
|
"Cluster: “S/F” in a spot comment is read as FT8, alongside “superfox”, “sfox” and “F/H”. They are all the same DXpedition transmit mode, and the comment was falling through to the band plan and coming out DATA."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"[NEW] La molette fait défiler les champs RST, dans la barre de saisie comme dans l’éditeur de QSO. Elle compte comme un opérateur — 57, 58, 59, 59+5, 59+10, 59+15, 59+20 — un point S jusqu’à neuf puis cinq décibels à la fois, et un décibel sur un report numérique. R et T ne bougent pas. La liste déroulante à côté contient les reports qu’on veut sous la main, pas tous les reports légaux : la molette agit donc sur la valeur plutôt que de parcourir la liste.",
|
||||||
|
"[NEW] La carte du monde s’ouvre centrée sur VOTRE locator, plus sur Greenwich. Centrée sur 0°, elle laissait un Australien avec son pays dans le coin en bas à droite et tous les trajets qui sortaient des deux bords ; centrée sur sa longitude, la même carte se lit comme son antenne travaille — les Amériques à l’est, l’Europe et l’Afrique à l’ouest. La latitude penche vers votre hémisphère sans vous suivre jusqu’au pôle, et une vue que vous avez déplacée vous-même reste prioritaire.",
|
||||||
|
"La carte du monde attend désormais le locator de la station avant de peindre. Elle dessinait le monde à 0° puis se déplaçait sur votre longitude, chargeant un écran de tuiles jeté aussitôt à chaque premier lancement ; elle est construite une fois, en sachant où elle regarde. Un profil sans locator obtient toujours la vue par défaut après un instant, pas un panneau vide.",
|
||||||
|
"[NEW] La carte des décodages FTx et la carte des locators retiennent où vous les avez laissées — centre et zoom, portables avec le dossier de données comme la vue de la carte du monde. Déplacer une carte, c’est dire quelle partie du monde on travaille, et c’était jeté à chaque changement d’onglet.",
|
||||||
|
"[NEW] Carte FTx : les points des stations répondent aux mêmes deux gestes que la liste des décodages — un clic met l’indicatif dans la saisie, deux l’appellent. La zone cliquable est plus large que le point, et un double clic ne zoome plus la carte au passage.",
|
||||||
|
"L’affichage de l’auto-call sort du bouton Auto pour se placer à côté : la station appelée est l’élément le plus lisible de la ligne, les appels et les périodes ratées portent chacun leur étiquette au lieu de se lire comme un seul nombre, et une station attendue s’affiche avec un sablier. Le bouton changeait de largeur à chaque période.",
|
||||||
|
"Auto-call : le repos entre deux séries se compte en tours de la station, plus en minutes, et vaut UN par défaut. Deux minutes, c’est quatre tours en FT8 — le DX a travaillé quatre autres appelants entre-temps, et la moitié du temps il est parti. Sept appels, un tour écouté, et ça repart si la station est toujours là (Réglages → DXHunter, « Repos (tours) »).",
|
||||||
|
"L’auto-call voit un décodage qui arrive après les autres. Un décodeur envoie une période en rafale, puis les retardataires — un décodage « deep » une seconde plus tard — et le retardataire était jugé tout seul, sans les trente stations de sa propre période. La période reste maintenant ouverte jusqu’au début de la suivante, et un arrivant tardif est pesé face à l’ensemble.",
|
||||||
|
"Auto-call : une période où la station a été décodée n’est plus comptée comme un raté. Une période est jugée plusieurs fois — les décodages arrivent en rafale puis les retardataires — et un jugement tardif n’en donne qu’une vue partielle, pas la preuve d’une absence : une station qui répondait dans cette période exacte se voyait compter « 1/3 raté ».",
|
||||||
|
"L’auto-call ne met jamais de côté un indicatif de la watchlist. Après quelques séries d’appels sans réponse, une station est écartée pour la session — la bonne réponse pour une station choisie par le CARNET, la mauvaise pour une station que VOUS avez nommée : un DX en pile-up demande plus de deux séries pour passer, et c’est précisément pour ça qu’il est sur la liste. Le repos entre séries s’applique toujours.",
|
||||||
|
"Panneau PSK Reporter : en portée « toute la bande », cliquer sur un décodage ne remet plus le nombre de reports à zéro. La fenêtre appartient là à la BANDE — tous les reports FTx qui y circulent, filtrés par cible seulement à l’affichage — et elle était vidée à chaque changement de cible, jetant une heure d’observations au moment précis où elles servent. La portée étroite continue de la vider : là, la fenêtre est celle d’une seule station.",
|
||||||
|
"Changer de mode avec un indicatif dans le champ corrige désormais le report. Le drapeau « l’opérateur a choisi ce report » tenait au travers d’un changement de mode, où il ne veut rien dire — « +00 » n’est pas un report SSB faible, ce n’est pas un report du tout — et tout ce qui remplit le champ depuis le poste (les lectures S-mètre des consoles) lève ce drapeau aussi : la notation pouvait rester fausse pour tout le QSO. Un jugement transposable l’est (57 → 579, 599 → 59) ; sinon le préréglage du mode répond.",
|
||||||
|
"Icom CI-V : l’adresse 00 peut être saisie, et « Other (custom address) » reste sélectionné. Le zéro était pris pour « non configuré » et chaque enregistrement remettait le poste sur le 98 de l’IC-7610 — la liste des modèles suivant, puisqu’elle est déduite de l’adresse et non enregistrée.",
|
||||||
|
"Upload Cloudlog / Wavelog : un contact simplex n’est plus envoyé comme un split. Chaque QSO portait une bande et une fréquence de réception égales à l’émission, et Wavelog affiche les deux — un FT8 ordinaire se lisait « 17m/17m ». En ADIF, un BAND_RX absent signifie « identique à l’émission » : ils ne sont donc écrits que s’ils diffèrent. L’enregistrement transmis à un autre logiciel par UDP les porte toujours en entier (Log4OM lit BAND_RX).",
|
||||||
|
"Clic droit → Envoyer vers : un envoi vers un service non configuré est refusé, en disant ce qui manque et où. Il partait tout seul et rendait compte dans la console du gestionnaire QSL, qui n’est pas ouverte quand la commande vient de la liste des QSO — ça ressemblait donc exactement à un envoi réussi. Cloudlog / Wavelog et HamQTH s’annoncent aussi sous leur nom dans le message.",
|
||||||
|
"Cluster : « S/F » dans un commentaire de spot est lu comme du FT8, au même titre que « superfox », « sfox » et « F/H ». C’est le même mode d’émission DXpédition, et le commentaire retombait sur le plan de bande pour ressortir en DATA."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.13",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"[NEW] Rotor widget redrawn: a night world map behind a square azimuth scale, an orange pointer following the mouse so a click lands where it is aimed, a yellow marker on the azimuth ordered until the antenna gets there, and quick turns in columns of six. The Ultrabeam boom and its second lobe are still drawn, and Station Control keeps the plain dial — dial only, since anything under it is pushed off the bottom of the row. Design contributed by EC1KD — thank you.",
|
||||||
|
"[NEW] Settings → Rotator now chooses the dial: the new world-map compass or the classic one. Both are kept — one reads across the shack, the other is the compact dial that was there before — and the choice applies to the docked widget and to Station Control at once.",
|
||||||
|
"Rotor widget: the Stop button no longer flickers on a rotor standing still. Movement was inferred from a one-degree change, which is less than the jitter a controller reports at rest.",
|
||||||
|
"[NEW] A docked watch-list panel, showing only what is ON THE AIR and still needed — one line per band and mode — callsign, band, mode, what it is worth, how long ago — freshest first, click to tune, with the cluster’s own NEW DXCC / NEW BAND / NEW SLOT badge on each row. The Watchlist tab is a tab, and an operator working FT8 lives on the decodes one: a station you asked to be told about was appearing on a screen you were not looking at. Turn it on with the bell in the toolbar.",
|
||||||
|
"[NEW] FT decodes: a distance column, next to the square it is computed from and in your own unit (km or miles). Rounded to whole units — a four-character grid is a square tens of kilometres wide and nothing finer is honest.",
|
||||||
|
"[NEW] The PSK Reporter panel switches to the station auto-call is WAITING for (the hourglass), while nothing is being called. Its analysis takes a history query and a period or two of live reports to fill, so starting it when the DX finally comes free is starting it too late — the wait is exactly what it should be spent on.",
|
||||||
|
"[NEW] Chase new: a band selection of its own, under the option (160 m to 70 cm). The station’s band list still applies underneath — this one says what is worth WATCHING tonight.",
|
||||||
|
"Chase new: the panel says what the feed is doing — up or down, how many reports it has taken, how many receiver squares it is subscribed to. An empty list said nothing about whether anything was arriving at all.",
|
||||||
|
"FT decodes: a receiver column appears when more than one decoder feeds the merged list, and the list empties for a receiver that changes band — half a screen of stations no longer reachable is worse than an empty one.",
|
||||||
|
"FT decodes: a pink WL badge marks a station on your watch list, and the period clock turns red while transmitting. It is the one thing on the screen that moves, so “am I on the air” is readable from across the room.",
|
||||||
|
"FT decodes: a message addressed to YOU is set in green, whole and bold, with a green edge on the row — read from the far side of the shack. The station you are calling keeps a light tint and its callsign picked out in red: most of what it sends goes to other people, and colouring those lines the same way said you were in a QSO you were not in.",
|
||||||
|
"Cluster: “superfox”, “super fox”, “fox/hound” and “F/H” in a spot comment are read as FT8. They are WSJT-X’s DXpedition transmit modes, not modes of their own, and the comment fell through to the band’s default.",
|
||||||
|
"Auto-call: what it calls, and in what order. It answers only what the decodes list is SHOWING — the filters above it (CQ only, LoTW only, the category chips, continents, minimum report, search) now steer the transmitter as well as the eye, and the separate “LoTW users only” option is gone. A new prefix, county, state, square or park is worth a call too, at the foot of the ladder and only for the kinds you chase (Settings → DX Cluster). A watched callsign outranks everything not on the list, a real need beats an unconfirmed one at the same level, and a better station may take over from one that has not answered yet — never from a QSO in progress.",
|
||||||
|
"Auto-call: it calls THROUGH a pileup. It used to give up the moment the station it was calling answered somebody else — which is precisely how a DX with a queue behaves, and the only way to be the next one is to keep calling while it works the others. Still bounded by the call and miss counters, and a station in mid-exchange is still never CHOSEN as a new target.",
|
||||||
|
"Auto-call: safety and control. It is always OFF at launch and after a profile switch, never armed from a stored setting — it is the one feature that puts the station on the air by itself, and OpsLog starts with Windows. Halt is now a verdict: the station is set aside for the session and never called again until auto-call is switched off and on. It says what it is waiting for, showing a wanted station that is working somebody else next to the Auto button. And it can log every decision (Settings → DXHunter): one line per period saying what was on the air and why each station was refused.",
|
||||||
|
"Auto-call: a round of fixes from on-air use. It no longer cuts your own 73 short, calls four to six seconds late, starts a call and drops it a second later, ignores a station calling you, misreads MSHV’s two-answers-in-one-line, refuses nearly everything on the air because it read the entity’s “worked” as the station’s, or opens with two misses already counted against a station it has only just picked.",
|
||||||
|
"Switching profile no longer leaves the previous logbook’s verdicts on the screen. Coming back from a profile with an empty log, every decode and spot stayed badged NEW until a restart: the worked-index, the chase-new list and the cached verdicts are now all dropped when the logbook changes.",
|
||||||
|
"Chase new fixes: the header counts both what the filters show and what the panel holds (“3 of 12 heard”), a stored filter set that hides everything is ignored, and switching between “new” and “new + unconfirmed” re-reads the list instead of leaving old verdicts on screen.",
|
||||||
|
"PSK Reporter panel fixes: the history is asked for in both feed scopes and refreshed every five minutes, so a target picked shortly after start-up no longer shows an empty page; the suggested transmit offset always has an answer when there is data; and the texts say ten minutes, which is the window actually used.",
|
||||||
|
"Callbook lookup: a compound callsign with a page of its OWN keeps that page’s location. HP/WE9G is filed on QRZ under exactly that form, with the Panama address and square the station is operating from, and OpsLog was throwing it away — the rule that drops a home address from a portable call was applying to pages that describe the operation itself. The record’s own country tells the two apart. Cached lookups heal on the next read; QSOs already logged without a grid keep it empty."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"[NEW] Widget rotor redessiné : carte du monde nocturne derrière une échelle d’azimut carrée, aiguille orange qui suit la souris pour qu’un clic parte où on vise, repère jaune sur l’azimut demandé jusqu’à l’arrivée de l’antenne, et directions rapides en colonnes de six. Le boom Ultrabeam et son deuxième lobe sont toujours tracés, et Station Control garde le cadran seul — rien en dessous, ce qui y était se retrouvait coupé en bas de la rangée. Design proposé par EC1KD — merci à lui.",
|
||||||
|
"[NEW] Réglages → Rotator permet de choisir le cadran : la nouvelle boussole carte du monde ou le cadran classique. Les deux sont conservés — l’un se lit de loin, l’autre est le cadran compact d’avant — et le choix s’applique aussitôt au widget docké comme à Station Control.",
|
||||||
|
"Widget rotor : le bouton Stop ne clignote plus sur un rotor à l’arrêt. Le mouvement était déduit d’un changement d’un degré, soit moins que le tremblement de lecture d’un contrôleur au repos.",
|
||||||
|
"[NEW] Un panneau watchlist docké, qui ne montre que ce qui est EN L’AIR et manque encore — une ligne par bande et mode — indicatif, bande, mode, ce que ça vaut, il y a combien de temps — le plus frais en haut, clic pour s’y caler, avec le badge NEW DXCC / NEW BAND / NEW SLOT du cluster sur chaque ligne. L’onglet Watchlist est un onglet, et en FT8 on vit sur celui des décodes : une station qu’on avait demandé à surveiller apparaissait sur un écran qu’on ne regardait pas. À activer avec la cloche dans la barre d’outils.",
|
||||||
|
"[NEW] FT decodes : une colonne distance, à côté du locator dont elle est calculée et dans votre unité (km ou miles). Arrondie à l’unité — un locator à quatre caractères est un carré de plusieurs dizaines de kilomètres, rien de plus fin ne serait honnête.",
|
||||||
|
"[NEW] Le panneau PSK Reporter bascule sur la station que l’auto-call ATTEND (le sablier), tant que rien n’est appelé. Son analyse met une requête d’historique et une période ou deux à se remplir : la lancer quand le DX se libère enfin, c’est la lancer trop tard — l’attente sert précisément à ça.",
|
||||||
|
"[NEW] Chase new : sélection de bandes propre au panneau, sous l’option (160 m à 70 cm). La liste de bandes de la station s’applique toujours en dessous — celle-ci dit ce qu’on veut SURVEILLER ce soir.",
|
||||||
|
"Chase new : le panneau indique l’état du flux — connecté ou non, nombre de rapports reçus, nombre de carrés de réception souscrits. Une liste vide ne disait rien sur ce qui arrivait vraiment.",
|
||||||
|
"FT decodes : une colonne récepteur apparaît quand plusieurs décodeurs alimentent la liste fusionnée, et la liste se vide pour un récepteur qui change de bande — un demi-écran de stations devenues inaccessibles est pire qu’un écran vide.",
|
||||||
|
"FT decodes : un badge WL rose marque une station de votre watchlist, et l’horloge de période passe au rouge en émission. C’est le seul élément qui bouge à l’écran, donc « suis-je en émission » se lit de loin.",
|
||||||
|
"FT decodes : un message qui VOUS est adressé s’affiche en vert, en entier et en gras, avec un liseré vert sur la ligne — lisible de l’autre bout du shack. La station que vous appelez garde une teinte légère et son indicatif en rouge : l’essentiel de ce qu’elle émet s’adresse à d’autres, et colorer ces lignes pareil laissait croire à un QSO en cours.",
|
||||||
|
"Cluster : « superfox », « super fox », « fox/hound » et « F/H » dans un commentaire de spot sont lus comme du FT8. Ce sont les modes d’émission DXpédition de WSJT-X, pas des modes en soi, et le commentaire retombait sur le mode par défaut de la bande.",
|
||||||
|
"Auto-call : ce qu’il appelle, et dans quel ordre. Il ne répond qu’à ce que la liste des décodes AFFICHE — les filtres du dessus (CQ seul, LoTW seul, les pastilles de catégorie, les continents, le rapport minimum, la recherche) pilotent désormais l’émission autant que l’œil, et l’option distincte « utilisateurs LoTW uniquement » disparaît. Un nouveau préfixe, comté, état, locator ou parc vaut aussi un appel, au dernier barreau de l’échelle et seulement pour ce que vous chassez (Réglages → DX Cluster). Un indicatif en watchlist passe devant tout ce qui n’y est pas, un vrai besoin devant un besoin non confirmé de même niveau, et une meilleure station peut prendre la place d’une autre qui n’a pas encore répondu — jamais celle d’un QSO en cours.",
|
||||||
|
"Auto-call : il appelle À TRAVERS un pile-up. Il abandonnait dès que la station appelée répondait à quelqu’un d’autre — or c’est exactement ce que fait un DX avec une file, et le seul moyen d’être le suivant est de continuer à appeler pendant qu’il travaille les autres. Toujours borné par les compteurs d’appels et de ratés, et une station en plein échange n’est toujours jamais CHOISIE comme nouvelle cible.",
|
||||||
|
"Auto-call : sécurité et contrôle. Il est toujours ARRÊTÉ au lancement et après un changement de profil, jamais armé depuis un réglage enregistré — c’est la seule fonction qui met la station en émission toute seule, et OpsLog démarre avec Windows. Halt devient un verdict : la station est mise de côté pour la session et n’est plus appelée jusqu’à ce que l’auto-call soit désactivé puis réactivé. Il dit ce qu’il attend, en affichant à côté du bouton Auto la station voulue qui travaille quelqu’un d’autre. Et il peut journaliser chaque décision (Réglages → DXHunter) : une ligne par période disant ce qui était sur l’air et pourquoi chaque station a été refusée.",
|
||||||
|
"Auto-call : série de corrections issues du trafic. Il ne coupe plus votre 73, n’appelle plus avec quatre à six secondes de retard, ne lance plus un appel pour le couper une seconde après, ne laisse plus sans réponse une station qui vous appelle, lit correctement les doubles réponses MSHV sur une même ligne, ne refuse plus presque tout ce qui passe parce qu’il prenait le « worked » de l’entité pour celui de la station, et ne démarre plus avec deux ratés au compteur sur une station tout juste choisie.",
|
||||||
|
"Changer de profil ne laisse plus les verdicts du carnet précédent à l’écran. En revenant d’un profil au log vide, tous les décodes et spots restaient marqués NEW jusqu’au redémarrage : l’index des contacts, la liste chase new et les verdicts en cache sont désormais vidés au changement de carnet.",
|
||||||
|
"Chase new, corrections : l’en-tête compte à la fois ce que les filtres montrent et ce que le panneau contient (« 3 sur 12 entendues »), un jeu de filtres enregistré qui cache tout est ignoré, et le passage entre « new » et « new + non confirmés » relit la liste au lieu de laisser d’anciens verdicts à l’écran.",
|
||||||
|
"Panneau PSK Reporter, corrections : l’historique est demandé dans les deux portées du flux et rafraîchi toutes les cinq minutes, donc une cible choisie peu après le démarrage n’affiche plus une page vide ; l’offset d’émission suggéré donne toujours une réponse quand il y a des données ; et les textes annoncent dix minutes, la fenêtre réellement utilisée.",
|
||||||
|
"Recherche callbook : un indicatif composé qui a sa PROPRE fiche garde la position de cette fiche. HP/WE9G est déposé sur QRZ sous cette forme exacte, avec l’adresse et le locator du Panama d’où la station émet, et OpsLog les jetait — la règle qui écarte l’adresse personnelle d’un indicatif portable s’appliquait aussi aux fiches qui décrivent l’opération elle-même. C’est le pays de la fiche qui les distingue. Les fiches en cache se corrigent à la lecture suivante ; les QSO déjà enregistrés sans locator le restent."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.27.12",
|
"version": "0.27.12",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
+453
-130
@@ -12,7 +12,7 @@ import {
|
|||||||
ContestDupe,
|
ContestDupe,
|
||||||
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
||||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UpdateQSOsCountyFromULS, ULSStatus, UploadQSOsManual, SendQSORecordingEmail,
|
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UpdateQSOsCountyFromULS, ULSStatus, UploadQSOsManual, SendQSORecordingEmail,
|
||||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
LookupCallsign, GetStationSettings, GetListsSettings, GetSatelliteNames,
|
||||||
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations, GetWhatsNew, GetChangelog,
|
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations, GetWhatsNew, GetChangelog,
|
||||||
SMTPConfigured, SendLogToDeveloper,
|
SMTPConfigured, SendLogToDeveloper,
|
||||||
WorkedBefore,
|
WorkedBefore,
|
||||||
@@ -52,9 +52,9 @@ import {
|
|||||||
ReportLiveActivity, LiveLastQSOAgeSec,
|
ReportLiveActivity, LiveLastQSOAgeSec,
|
||||||
GetAmpStatuses, AmpOperate,
|
GetAmpStatuses, AmpOperate,
|
||||||
GetFlexState, FlexAmpOperate,
|
GetFlexState, FlexAmpOperate,
|
||||||
GetPSKReporterStatus, GetLiveOpenings, GetChaseNew,
|
GetLiveOpenings, GetChaseNew,
|
||||||
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
|
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
|
||||||
GetAutoCallStatus, SetAutoCall, SetAutoCallOnly, TakeAutoCallTarget, ResetAutoCall,
|
GetAutoCallStatus, SetAutoCall, SetAutoCallOnly, TakeAutoCallTarget, HaltAutoCall, WatchlistEntries,
|
||||||
} from '../wailsjs/go/main/App';
|
} from '../wailsjs/go/main/App';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
|
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
|
||||||
@@ -80,6 +80,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog';
|
|||||||
import { SettingsModal } from '@/components/SettingsModal';
|
import { SettingsModal } from '@/components/SettingsModal';
|
||||||
import { FTMapPanel } from '@/components/FTMapPanel';
|
import { FTMapPanel } from '@/components/FTMapPanel';
|
||||||
import { DXpeditionsPanel } from '@/components/DXpeditionsPanel';
|
import { DXpeditionsPanel } from '@/components/DXpeditionsPanel';
|
||||||
|
import { SatellitePanel } from '@/components/SatellitePanel';
|
||||||
import { FirstRunModal } from '@/components/FirstRunModal';
|
import { FirstRunModal } from '@/components/FirstRunModal';
|
||||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||||
import { BandMap } from '@/components/BandMap';
|
import { BandMap } from '@/components/BandMap';
|
||||||
@@ -119,10 +120,14 @@ import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
|||||||
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
||||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||||
import { RotorCompass } from '@/components/RotorCompass';
|
import { RotorCompass } from '@/components/RotorCompass';
|
||||||
|
import { RotorCompassClassic } from '@/components/RotorCompassClassic';
|
||||||
|
import { WatchlistWidget } from '@/components/WatchlistWidget';
|
||||||
|
import { rotorStyle, subscribeRotorStyle } from '@/lib/rotorStyle';
|
||||||
import { GridSquareMap } from '@/components/GridSquareMap';
|
import { GridSquareMap } from '@/components/GridSquareMap';
|
||||||
import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros';
|
import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros';
|
||||||
import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel';
|
import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel';
|
||||||
import { PSKReporterPanel } from '@/components/PSKReporterPanel';
|
import { PSKReporterPanel } from '@/components/PSKReporterPanel';
|
||||||
|
import { decoderName } from '@/lib/decoderName';
|
||||||
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
|
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
import { formatDateTimeUTC } from '@/lib/dateFormat';
|
import { formatDateTimeUTC } from '@/lib/dateFormat';
|
||||||
@@ -146,6 +151,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { pathBetween, pathBetweenLatLon, gridToLatLon, latLonToGrid } from '@/lib/maidenhead';
|
import { pathBetween, pathBetweenLatLon, gridToLatLon, latLonToGrid } from '@/lib/maidenhead';
|
||||||
import { flagURL } from '@/lib/flags';
|
import { flagURL } from '@/lib/flags';
|
||||||
import { LogViewer } from '@/components/LogViewer';
|
import { LogViewer } from '@/components/LogViewer';
|
||||||
|
import { convertRST, rstFitsMode, stepRST } from '@/lib/rst';
|
||||||
|
|
||||||
type QSO = QSOForm;
|
type QSO = QSOForm;
|
||||||
type ImportResult = adifModels.ImportResult;
|
type ImportResult = adifModels.ImportResult;
|
||||||
@@ -300,8 +306,12 @@ function FreqWheelDisplay({ mhz, onNudge, className, placeholder = '—.——
|
|||||||
// pill. The full message stays in the tooltip. Recognises the common cases
|
// pill. The full message stays in the tooltip. Recognises the common cases
|
||||||
// (OmniRig not installed, not registered) and otherwise truncates.
|
// (OmniRig not installed, not registered) and otherwise truncates.
|
||||||
// RadioChip — the CAT status chip, and the radio picker behind it.
|
// RadioChip — the CAT status chip, and the radio picker behind it.
|
||||||
function RadioChip({ catUp, catState, onOpenSettings }: {
|
function RadioChip({ catUp, catState, onOpenSettings, onRadioSwitched }: {
|
||||||
catUp: boolean; catState: any; onOpenSettings: () => void;
|
catUp: boolean; catState: any; onOpenSettings: () => void;
|
||||||
|
// Switching radio here IS a settings change — the chosen entry becomes the CAT
|
||||||
|
// settings — so whatever reads those has to be told. Two Icoms swapped for one
|
||||||
|
// another never change the live backend name, and nothing else would notice.
|
||||||
|
onRadioSwitched?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [radios, setRadios] = useState<any[]>([]);
|
const [radios, setRadios] = useState<any[]>([]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -366,7 +376,7 @@ function RadioChip({ catUp, catState, onOpenSettings }: {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
if (r.active) return;
|
if (r.active) return;
|
||||||
SetActiveRadio(r.id).then(load).catch(() => {});
|
SetActiveRadio(r.id).then(() => { load(); onRadioSwitched?.(); }).catch(() => {});
|
||||||
}}
|
}}
|
||||||
className={cn('flex w-full items-center gap-2 px-2.5 py-1 text-left text-xs hover:bg-muted',
|
className={cn('flex w-full items-center gap-2 px-2.5 py-1 text-left text-xs hover:bg-muted',
|
||||||
r.active && 'font-semibold text-primary')}
|
r.active && 'font-semibold text-primary')}
|
||||||
@@ -556,7 +566,9 @@ function LockPad({ on, title, onToggle }: { on: boolean; title: string; onToggle
|
|||||||
type="button"
|
type="button"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
title={`${on ? 'Unlock' : 'Lock'} ${title}`}
|
// The whole tooltip, not a verb glued to a noun: the caller knows what
|
||||||
|
// this padlock does and can say it in the operator's own language.
|
||||||
|
title={title}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center justify-center size-3.5 rounded transition-colors',
|
'inline-flex items-center justify-center size-3.5 rounded transition-colors',
|
||||||
on ? 'text-warning hover:text-warning' : 'text-muted-foreground/40 hover:text-muted-foreground',
|
on ? 'text-warning hover:text-warning' : 'text-muted-foreground/40 hover:text-muted-foreground',
|
||||||
@@ -608,31 +620,34 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
const locksRef = useRef(locks);
|
const locksRef = useRef(locks);
|
||||||
useEffect(() => { locksRef.current = locks; }, [locks]);
|
useEffect(() => { locksRef.current = locks; }, [locks]);
|
||||||
const toggleLock = (k: LockKey) => {
|
// ONE padlock, not five.
|
||||||
setLocks((s) => {
|
//
|
||||||
const wasLocked = s[k];
|
// Logging a contact from a piece of paper — a contest sheet, a friend's
|
||||||
const next = { ...s, [k]: !wasLocked };
|
// report, a QSO worked on another radio — means the frequency, the band, the
|
||||||
if (wasLocked) {
|
// mode, the date and both times all have to stop following the rig and the
|
||||||
// Unlocking → restore automatic behavior. Without this the locked
|
// clock at once. That is a single decision, and it used to be five clicks in
|
||||||
// value would linger forever: a stale Start time would never refresh
|
// five different places, each of which had to be found first.
|
||||||
// even after a new callsign is entered.
|
//
|
||||||
if (k === 'start') {
|
// The five per-field locks stay underneath, because everything downstream
|
||||||
// If a QSO is currently in progress (callsign typed), snap start
|
// reads them and they say the right thing individually ("this value is
|
||||||
// to now since we missed the auto-start moment. Otherwise clear.
|
// decoupled from the rig"). Only the control is one.
|
||||||
|
const manualEntry = locks.start && locks.end && locks.band && locks.mode && locks.freq;
|
||||||
|
const setManualEntry = (on: boolean) => {
|
||||||
|
setLocks({ band: on, mode: on, freq: on, start: on, end: on });
|
||||||
|
if (on) {
|
||||||
|
// Pre-filled with today's date and the current UTC time so the fields are
|
||||||
|
// not empty; the operator only has to correct them.
|
||||||
|
const now = new Date();
|
||||||
|
setQsoStartedAt((d) => d ?? now);
|
||||||
|
setQsoEndedAt((d) => d ?? now);
|
||||||
|
} else {
|
||||||
|
// Back to automatic. Without this the frozen values would linger for
|
||||||
|
// ever: a start time held from a backdated entry would never refresh,
|
||||||
|
// even after a new callsign is typed. A QSO already in progress snaps its
|
||||||
|
// start to now, since the moment it would have been taken has passed.
|
||||||
setQsoStartedAt(callsign.trim() ? new Date() : null);
|
setQsoStartedAt(callsign.trim() ? new Date() : null);
|
||||||
} else if (k === 'end') {
|
|
||||||
// Drop the frozen end so the field tracks the live UTC clock.
|
|
||||||
setQsoEndedAt(null);
|
setQsoEndedAt(null);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Locking (manual / deferred entry) → pre-fill with today's date + the
|
|
||||||
// current UTC time so the fields aren't empty; the operator just adjusts.
|
|
||||||
const now = new Date();
|
|
||||||
if (k === 'start') setQsoStartedAt((d) => d ?? now);
|
|
||||||
else if (k === 'end') setQsoEndedAt((d) => d ?? now);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
const [band, setBand] = useState('20m');
|
const [band, setBand] = useState('20m');
|
||||||
const [mode, setMode] = useState('SSB');
|
const [mode, setMode] = useState('SSB');
|
||||||
@@ -653,6 +668,11 @@ export default function App() {
|
|||||||
useEffect(() => { rstListsRef.current = rstLists; }, [rstLists]);
|
useEffect(() => { rstListsRef.current = rstLists; }, [rstLists]);
|
||||||
const [rstSent, setRstSent] = useState('59');
|
const [rstSent, setRstSent] = useState('59');
|
||||||
const [rstRcvd, setRstRcvd] = useState('59');
|
const [rstRcvd, setRstRcvd] = useState('59');
|
||||||
|
// Read by applyModePreset, which runs from those same long-lived closures.
|
||||||
|
const rstSentRef = useRef(rstSent);
|
||||||
|
const rstRcvdRef = useRef(rstRcvd);
|
||||||
|
useEffect(() => { rstSentRef.current = rstSent; }, [rstSent]);
|
||||||
|
useEffect(() => { rstRcvdRef.current = rstRcvd; }, [rstRcvd]);
|
||||||
const [grid, setGrid] = useState('');
|
const [grid, setGrid] = useState('');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [qth, setQth] = useState('');
|
const [qth, setQth] = useState('');
|
||||||
@@ -707,6 +727,15 @@ export default function App() {
|
|||||||
// hide the rig ON/OFF buttons on USB, where the interface is unpowered when the
|
// hide the rig ON/OFF buttons on USB, where the interface is unpowered when the
|
||||||
// rig is off so power-ON can't work).
|
// rig is off so power-ON can't work).
|
||||||
const [catBackend, setCatBackend] = useState('');
|
const [catBackend, setCatBackend] = useState('');
|
||||||
|
// icomConfigured is "this station's radio IS an Icom", from the settings
|
||||||
|
// rather than from the link.
|
||||||
|
//
|
||||||
|
// The console used to appear only once the rig was talking. Switching to an
|
||||||
|
// Icom that was switched OFF therefore showed no console at all — and the
|
||||||
|
// console is where the ON button lives, so the one moment the button exists
|
||||||
|
// for was the one moment it could not be reached.
|
||||||
|
const icomConfigured = catBackend === 'icom' || catBackend === 'icom-net';
|
||||||
|
const icomShown = catState.backend === 'icom' || icomConfigured;
|
||||||
// Live space-weather (solar flux / sunspots / A / K) for the header strip.
|
// Live space-weather (solar flux / sunspots / A / K) for the header strip.
|
||||||
// Loaded on mount, refreshed on the backend 'solar:update' event, plus a slow
|
// Loaded on mount, refreshed on the backend 'solar:update' event, plus a slow
|
||||||
// fallback poll. These same numbers are stamped onto each logged QSO.
|
// fallback poll. These same numbers are stamped onto each logged QSO.
|
||||||
@@ -1331,6 +1360,17 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1');
|
const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1');
|
||||||
const [dxpedTabOpen, setDxpedTabOpen] = useState(() => localStorage.getItem('opslog.dxpedTab') === '1');
|
const [dxpedTabOpen, setDxpedTabOpen] = useState(() => localStorage.getItem('opslog.dxpedTab') === '1');
|
||||||
|
const [satTabOpen, setSatTabOpen] = useState(() => localStorage.getItem('opslog.satTab') === '1');
|
||||||
|
function openSatTab() {
|
||||||
|
setSatTabOpen(true);
|
||||||
|
writeUiPref('opslog.satTab', '1');
|
||||||
|
setActiveTab('sat');
|
||||||
|
}
|
||||||
|
function closeSatTab() {
|
||||||
|
setSatTabOpen(false);
|
||||||
|
writeUiPref('opslog.satTab', '0');
|
||||||
|
setActiveTab((t) => (t === 'sat' ? 'recent' : t));
|
||||||
|
}
|
||||||
function openDxpedTab() {
|
function openDxpedTab() {
|
||||||
setDxpedTabOpen(true);
|
setDxpedTabOpen(true);
|
||||||
writeUiPref('opslog.dxpedTab', '1');
|
writeUiPref('opslog.dxpedTab', '1');
|
||||||
@@ -2319,16 +2359,6 @@ export default function App() {
|
|||||||
return () => window.clearInterval(t);
|
return () => window.clearInterval(t);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// PSK Reporter feed, for the status-bar chip. Polled slowly: the chip only
|
|
||||||
// says up or down, and the count behind it is a tooltip.
|
|
||||||
const [pskr, setPskr] = useState<any>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const load = () => { GetPSKReporterStatus().then(setPskr).catch(() => {}); };
|
|
||||||
load();
|
|
||||||
const t = window.setInterval(load, 10000);
|
|
||||||
return () => window.clearInterval(t);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// "ON AIR" status-bar badge: mirrors the multi-op live status this operator
|
// "ON AIR" status-bar badge: mirrors the multi-op live status this operator
|
||||||
// publishes — online (blinking) when a QSO was logged in the last 5 min, else
|
// publishes — online (blinking) when a QSO was logged in the last 5 min, else
|
||||||
// offline. Publishing is always on for a shared MySQL logbook (no user toggle:
|
// offline. Publishing is always on for a shared MySQL logbook (no user toggle:
|
||||||
@@ -2406,6 +2436,17 @@ export default function App() {
|
|||||||
// the watchlist panel: a call can be added from the cluster or the
|
// the watchlist panel: a call can be added from the cluster or the
|
||||||
// DXpeditions tab, and the old inline message lived on a page nobody was
|
// DXpeditions tab, and the old inline message lived on a page nobody was
|
||||||
// looking at.
|
// looking at.
|
||||||
|
// The watch list as patterns, for the decodes badge. Loaded once and kept in
|
||||||
|
// step with the same event the notice below listens to — a call added from
|
||||||
|
// the cluster has to light up in the decodes list too.
|
||||||
|
const [watchPatterns, setWatchPatterns] = useState<string[]>([]);
|
||||||
|
const loadWatchPatterns = useCallback(() => {
|
||||||
|
WatchlistEntries()
|
||||||
|
.then((es: any[]) => setWatchPatterns((es ?? []).map((e: any) => String(e?.callsign ?? '')).filter(Boolean)))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { loadWatchPatterns(); }, [loadWatchPatterns]);
|
||||||
|
useEffect(() => EventsOn('watchlist:changed', () => loadWatchPatterns()), [loadWatchPatterns]);
|
||||||
const [wlNotice, setWlNotice] = useState<{ call: string; added: boolean } | null>(null);
|
const [wlNotice, setWlNotice] = useState<{ call: string; added: boolean } | null>(null);
|
||||||
const wlNoticeTimer = useRef<number | undefined>(undefined);
|
const wlNoticeTimer = useRef<number | undefined>(undefined);
|
||||||
useEffect(() => EventsOn('watchlist:changed', (e: any) => {
|
useEffect(() => EventsOn('watchlist:changed', (e: any) => {
|
||||||
@@ -2553,6 +2594,15 @@ export default function App() {
|
|||||||
// half hour — long enough to hold a whole opening, short enough that a night
|
// half hour — long enough to hold a whole opening, short enough that a night
|
||||||
// of FT8 on 20 m does not turn the list into something no filter can rescue.
|
// of FT8 on 20 m does not turn the list into something no filter can rescue.
|
||||||
const DECODE_KEEP_MS = 30 * 60 * 1000;
|
const DECODE_KEEP_MS = 30 * 60 * 1000;
|
||||||
|
// And a hard ceiling on the count, because the half hour is not one on a
|
||||||
|
// crowded band.
|
||||||
|
//
|
||||||
|
// Three decoders on an open evening put several thousand rows in that window,
|
||||||
|
// and the panel slows down long before the age limit removes any of them:
|
||||||
|
// every one is a row to lay out, a status to resolve and a distance to work
|
||||||
|
// out. Two thousand is more than a screen can hold many times over, and past
|
||||||
|
// it the oldest go — the newest period is what an operator is reading.
|
||||||
|
const DECODE_MAX = 2000;
|
||||||
const [decodes, setDecodes] = useState<DecodeRow[]>([]);
|
const [decodes, setDecodes] = useState<DecodeRow[]>([]);
|
||||||
const [txMsgs, setTxMsgs] = useState<TxMsgRow[]>([]);
|
const [txMsgs, setTxMsgs] = useState<TxMsgRow[]>([]);
|
||||||
// The LIVE transmit state, replaced on every Status — what is going out now
|
// The LIVE transmit state, replaced on every Status — what is going out now
|
||||||
@@ -2597,6 +2647,33 @@ export default function App() {
|
|||||||
// Staged like the cluster's, so a period arriving as one burst of fifty
|
// Staged like the cluster's, so a period arriving as one burst of fifty
|
||||||
// packets costs one status lookup and one render, not fifty of each.
|
// packets costs one status lookup and one render, not fifty of each.
|
||||||
const pendingDecodesRef = useRef<DecodeRow[]>([]);
|
const pendingDecodesRef = useRef<DecodeRow[]>([]);
|
||||||
|
// The band each receiver was last decoding on. A band change empties that
|
||||||
|
// receiver's list — see the flush below.
|
||||||
|
const decoderBandRef = useRef<Map<string, string>>(new Map());
|
||||||
|
|
||||||
|
// The RIG changing band empties the list too, without waiting for the
|
||||||
|
// decoder to catch up.
|
||||||
|
//
|
||||||
|
// Clicking a watchlist row or a spot tunes the radio; the decoder follows on
|
||||||
|
// its own clock and its first decode on the new band is up to a period away.
|
||||||
|
// Until then the operator reads a screen of stations from a band they have
|
||||||
|
// left — which is the moment the list is most misleading, because it looks
|
||||||
|
// current. Only the receivers that were on the band we came FROM are
|
||||||
|
// cleared: a second decoder parked on another band has not moved.
|
||||||
|
const rigBandRef = useRef<string>('');
|
||||||
|
useEffect(() => {
|
||||||
|
const band = (catState.band ?? '').toLowerCase();
|
||||||
|
const was = rigBandRef.current;
|
||||||
|
rigBandRef.current = band;
|
||||||
|
if (!band || !was || was === band) return;
|
||||||
|
const stale = new Set<string>();
|
||||||
|
decoderBandRef.current.forEach((b2, inst) => { if (b2 === was) stale.add(inst); });
|
||||||
|
if (stale.size === 0) return;
|
||||||
|
stale.forEach((inst) => decoderBandRef.current.delete(inst));
|
||||||
|
pendingDecodesRef.current = pendingDecodesRef.current.filter((d) => !stale.has(d.instance ?? ''));
|
||||||
|
setDecodes((arr) => arr.filter((d) => !stale.has(d.instance ?? '')));
|
||||||
|
setTxMsgs((arr) => arr.filter((m) => !stale.has(m.instance ?? '')));
|
||||||
|
}, [catState.band]);
|
||||||
const pendingDecodeTimer = useRef<number | undefined>(undefined);
|
const pendingDecodeTimer = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
// Rotor quick-turn buttons (Settings → Rotator). Same reload trigger as the
|
// Rotor quick-turn buttons (Settings → Rotator). Same reload trigger as the
|
||||||
@@ -2733,6 +2810,9 @@ export default function App() {
|
|||||||
|
|
||||||
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
|
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
|
||||||
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
|
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
|
||||||
|
// Off by default: it is an alerting panel, and one that appears uninvited on
|
||||||
|
// an operator who does not keep a watch list is just a box saying "empty".
|
||||||
|
const [showWatchWidget, setShowWatchWidget] = useState(() => localStorage.getItem('opslog.showWatchWidget') === '1');
|
||||||
const [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0');
|
const [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0');
|
||||||
const [showTuner, setShowTuner] = useState(() => localStorage.getItem('opslog.showTuner') !== '0');
|
const [showTuner, setShowTuner] = useState(() => localStorage.getItem('opslog.showTuner') !== '0');
|
||||||
const [showScp, setShowScp] = useState(() => localStorage.getItem('opslog.showScp') !== '0');
|
const [showScp, setShowScp] = useState(() => localStorage.getItem('opslog.showScp') !== '0');
|
||||||
@@ -2759,6 +2839,11 @@ export default function App() {
|
|||||||
// withholding them IS the compact mode, so there is no second layout to keep
|
// withholding them IS the compact mode, so there is no second layout to keep
|
||||||
// in step with the first.
|
// in step with the first.
|
||||||
const [rotorCompact, setRotorCompact] = useState(() => localStorage.getItem('opslog.rotorCompact') === '1');
|
const [rotorCompact, setRotorCompact] = useState(() => localStorage.getItem('opslog.rotorCompact') === '1');
|
||||||
|
// Which dial (Settings → Rotator). Subscribed rather than re-read on close, so
|
||||||
|
// the choice shows the moment it is made — it is picked by looking at it.
|
||||||
|
const [rotorDial, setRotorDial] = useState(() => rotorStyle());
|
||||||
|
useEffect(() => subscribeRotorStyle(() => setRotorDial(rotorStyle())), []);
|
||||||
|
const Compass2 = rotorDial === 'classic' ? RotorCompassClassic : RotorCompass;
|
||||||
|
|
||||||
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
|
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
|
||||||
// picked award references to the QSO field/extras each award actually reads.
|
// picked award references to the QSO field/extras each award actually reads.
|
||||||
@@ -3031,15 +3116,43 @@ export default function App() {
|
|||||||
const pokeUbStatus = useCallback(async () => {
|
const pokeUbStatus = useCallback(async () => {
|
||||||
try { const s: any = await GetUltrabeamStatus(); if (s) setUbStatus(s); } catch { /* transient */ }
|
try { const s: any = await GetUltrabeamStatus(); if (s) setUbStatus(s); } catch { /* transient */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
// A move was just ORDERED — from a button here or by the follow loop on a
|
||||||
|
// band change. Held for as long as it takes the antenna to answer, and no
|
||||||
|
// longer: the real status takes over the moment it arrives.
|
||||||
|
const [motorCmdAt, setMotorCmdAt] = useState(0);
|
||||||
|
const motorMoving = !!ubStatus.moving || (motorCmdAt > 0 && Date.now() - motorCmdAt < 2500);
|
||||||
|
|
||||||
|
// Three seconds while it sits still, half a second while it moves.
|
||||||
|
//
|
||||||
|
// The moving flag gags the transmitter, and this poll is the last of three
|
||||||
|
// delays between the elements stopping and the operator being allowed to call
|
||||||
|
// again — the antenna's own poll and the inhibit loop are the other two. At
|
||||||
|
// three seconds it was the largest of them: the antenna had finished, the
|
||||||
|
// radio was free, and the screen still said orange.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
try { const s: any = await GetUltrabeamStatus(); if (alive) setUbStatus(s); } catch {}
|
try { const s: any = await GetUltrabeamStatus(); if (alive) setUbStatus(s); } catch {}
|
||||||
};
|
};
|
||||||
tick();
|
tick();
|
||||||
const id = window.setInterval(tick, 3000);
|
const id = window.setInterval(tick, motorMoving ? 400 : 3000);
|
||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, []);
|
}, [motorMoving]);
|
||||||
|
|
||||||
|
// The backend says it at the instant the order goes out — see
|
||||||
|
// noteMotorMoveCommanded. Read at once as well, so the antenna's own answer
|
||||||
|
// replaces the assumption as soon as there is one.
|
||||||
|
useEffect(() => {
|
||||||
|
const off = EventsOn('motorant:move', () => {
|
||||||
|
setMotorCmdAt(Date.now());
|
||||||
|
pokeUbStatus();
|
||||||
|
window.setTimeout(pokeUbStatus, 350);
|
||||||
|
// And once the window is over, so "assumed moving" stops being assumed
|
||||||
|
// even if nothing else re-renders.
|
||||||
|
window.setTimeout(() => setMotorCmdAt((v) => (Date.now() - v >= 2500 ? 0 : v)), 2600);
|
||||||
|
});
|
||||||
|
return () => { off?.(); };
|
||||||
|
}, [pokeUbStatus]);
|
||||||
|
|
||||||
// Poll the Antenna Genius switch for active antenna per port + the list.
|
// Poll the Antenna Genius switch for active antenna per port + the list.
|
||||||
// Re-read the enabled flag each tick so toggling it in Settings makes the
|
// Re-read the enabled flag each tick so toggling it in Settings makes the
|
||||||
@@ -3209,12 +3322,25 @@ export default function App() {
|
|||||||
setCatBackend(c.backend ?? '');
|
setCatBackend(c.backend ?? '');
|
||||||
} catch {}
|
} catch {}
|
||||||
}, []);
|
}, []);
|
||||||
|
// The configured backend follows every way the radio can change: the CAT
|
||||||
|
// panel's Save, a switch from the status bar's radio list, and the link
|
||||||
|
// itself reporting a different backend. It was read once at launch and after
|
||||||
|
// a Settings save only — so switching radio from the status bar left the
|
||||||
|
// console configured for the previous rig, and the Icom power buttons stayed
|
||||||
|
// hidden until the operator went into Settings and pressed Save for no
|
||||||
|
// reason.
|
||||||
|
useEffect(() => { loadCATCfg(); }, [catState.backend, loadCATCfg]);
|
||||||
|
|
||||||
const loadLists = useCallback(async () => {
|
const loadLists = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const l: ListsSettings = await GetListsSettings();
|
const l: ListsSettings = await GetListsSettings();
|
||||||
setRstLists({ phone: (l as any).rst_phone ?? [], cw: (l as any).rst_cw ?? [], digital: (l as any).rst_digital ?? [] });
|
setRstLists({ phone: (l as any).rst_phone ?? [], cw: (l as any).rst_cw ?? [], digital: (l as any).rst_digital ?? [] });
|
||||||
if (l.bands && l.bands.length) setBands(l.bands);
|
if (l.bands && l.bands.length) setBands(l.bands);
|
||||||
setSatellites([...(((l as any).satellites ?? []) as string[])].filter(Boolean).sort());
|
// The satellites come from the satellite side now, not from a list typed
|
||||||
|
// by hand in Settings: one station kept two lists of the same birds and
|
||||||
|
// they drifted apart. Go merges the followed set with whatever the old
|
||||||
|
// hand-kept list still holds, so nobody's typing is lost.
|
||||||
|
GetSatelliteNames().then((s) => setSatellites((s ?? []) as string[])).catch(() => {});
|
||||||
if (l.modes && l.modes.length) {
|
if (l.modes && l.modes.length) {
|
||||||
setModePresets(l.modes);
|
setModePresets(l.modes);
|
||||||
const names = l.modes.map((m) => m.name);
|
const names = l.modes.map((m) => m.name);
|
||||||
@@ -3290,15 +3416,31 @@ export default function App() {
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
function applyModePreset(m: string) {
|
function applyModePreset(m: string) {
|
||||||
if (rstUserEditedRef.current) return;
|
// AN EDIT IS ABOUT A SIGNAL, NOT ABOUT A NOTATION.
|
||||||
|
//
|
||||||
|
// The edited flag protects a report the operator chose — 57 rather than 59 —
|
||||||
|
// and it used to protect it across a change of mode as well, where it means
|
||||||
|
// nothing: "+00" is not a weak SSB report, it is not a report at all. An
|
||||||
|
// operator with a callsign in the field, switching from FT8 to SSB, was left
|
||||||
|
// with a decibel figure to log, and anything that fills the report from the
|
||||||
|
// rig (the S-meter readouts in the rig consoles) sets that flag too — so the
|
||||||
|
// field could be stuck in the wrong notation for the rest of the QSO.
|
||||||
|
//
|
||||||
|
// So the flag holds only while the report still belongs to the mode. When it
|
||||||
|
// does not, the judgement is carried across where it can be (57 → 579,
|
||||||
|
// 599 → 59) and the preset answers where it cannot.
|
||||||
|
const fits = rstFitsMode(rstSentRef.current, m) && rstFitsMode(rstRcvdRef.current, m);
|
||||||
|
if (rstUserEditedRef.current && fits) return;
|
||||||
// Prefer the user's configured preset RST; otherwise fall back to the mode
|
// Prefer the user's configured preset RST; otherwise fall back to the mode
|
||||||
// category default (CW/RTTY/PSK → 599, phone → 59, digital → first option)
|
// category default (CW/RTTY/PSK → 599, phone → 59, digital → first option)
|
||||||
// so switching SSB→CW flips 59→599 even without a configured preset.
|
// so switching SSB→CW flips 59→599 even without a configured preset.
|
||||||
// Read through the refs, never the state: see their declaration.
|
// Read through the refs, never the state: see their declaration.
|
||||||
const p = modePresetsRef.current.find((x) => x.name === m);
|
const p = modePresetsRef.current.find((x) => x.name === m);
|
||||||
const fallback = rstOptions(m, rstListsRef.current)[0] || '';
|
const fallback = rstOptions(m, rstListsRef.current)[0] || '';
|
||||||
setRstSent(p?.default_rst_sent || fallback);
|
const keep = (cur: string, preset: string) =>
|
||||||
setRstRcvd(p?.default_rst_rcvd || fallback);
|
(rstUserEditedRef.current && convertRST(cur, m)) || preset || fallback;
|
||||||
|
setRstSent(keep(rstSentRef.current, p?.default_rst_sent || ''));
|
||||||
|
setRstRcvd(keep(rstRcvdRef.current, p?.default_rst_rcvd || ''));
|
||||||
}
|
}
|
||||||
// Clicking a spot (cluster grid or any band map): tune the rig, set the mode,
|
// Clicking a spot (cluster grid or any band map): tune the rig, set the mode,
|
||||||
// fill the call, pre-fill POTA, (re)start the recording. Shared so every spot
|
// fill the call, pre-fill POTA, (re)start the recording. Shared so every spot
|
||||||
@@ -3718,10 +3860,45 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch { /* status unresolved — the decode still shows, just unflagged */ }
|
} catch { /* status unresolved — the decode still shows, just unflagged */ }
|
||||||
|
// A BAND CHANGE empties that receiver's list.
|
||||||
|
//
|
||||||
|
// What is on 20 m says nothing about 40 m, and half a screen of stations
|
||||||
|
// that are no longer reachable is worse than an empty one: the period
|
||||||
|
// headings still march on, the rows still carry status badges, and the
|
||||||
|
// operator reads a band they have left. Per receiver — in a split view
|
||||||
|
// the other one has not moved — and only when the new band is known.
|
||||||
|
const moved = new Map<string, string>();
|
||||||
|
for (const d of batch) {
|
||||||
|
const inst = d.instance ?? '';
|
||||||
|
const band = (d.band ?? '').toLowerCase();
|
||||||
|
if (!band) continue;
|
||||||
|
const was = decoderBandRef.current.get(inst);
|
||||||
|
decoderBandRef.current.set(inst, band);
|
||||||
|
if (was && was !== band) moved.set(inst, band);
|
||||||
|
}
|
||||||
|
if (moved.size > 0) {
|
||||||
|
for (const [inst, band] of moved) {
|
||||||
|
LogUIError('decodes', `${decoderName(inst) || 'decoder'} moved to ${band.toUpperCase()} — its earlier decodes cleared`, '');
|
||||||
|
}
|
||||||
|
setTxMsgs((arr) => arr.filter((m) => !moved.has(m.instance ?? '')));
|
||||||
|
}
|
||||||
setDecodes((arr) => {
|
setDecodes((arr) => {
|
||||||
const cutoff = Date.now() - DECODE_KEEP_MS;
|
const cutoff = Date.now() - DECODE_KEEP_MS;
|
||||||
const next = [...arr, ...batch].filter((d) => Date.parse(d.at) >= cutoff);
|
// Rows from a receiver that has just changed band go with it — the
|
||||||
return next;
|
// batch itself is already on the new band.
|
||||||
|
const kept = moved.size === 0 ? arr : arr.filter((d) => !moved.has(d.instance ?? ''));
|
||||||
|
// The batch can straddle the change — a 300 ms flush can hold the last
|
||||||
|
// decodes of the old band and the first of the new. Only the new band
|
||||||
|
// survives for a receiver that moved.
|
||||||
|
const fresh = moved.size === 0 ? batch
|
||||||
|
: batch.filter((d) => {
|
||||||
|
const b2 = moved.get(d.instance ?? '');
|
||||||
|
return !b2 || (d.band ?? '').toLowerCase() === b2;
|
||||||
|
});
|
||||||
|
const next = [...kept, ...fresh].filter((d) => Date.parse(d.at) >= cutoff);
|
||||||
|
// Oldest first in this list, so the ceiling is applied from the front:
|
||||||
|
// what goes is what was already scrolled past.
|
||||||
|
return next.length > DECODE_MAX ? next.slice(next.length - DECODE_MAX) : next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
flushDecodesRef.current = () => { void flushDecodes(); };
|
flushDecodesRef.current = () => { void flushDecodes(); };
|
||||||
@@ -4476,11 +4653,16 @@ export default function App() {
|
|||||||
const LABELS: Record<string, string> = {
|
const LABELS: Record<string, string> = {
|
||||||
qrz: 'QRZ.com', clublog: 'Club Log', lotw: 'LoTW',
|
qrz: 'QRZ.com', clublog: 'Club Log', lotw: 'LoTW',
|
||||||
hrdlog: 'HRDLog.net', eqsl: 'eQSL.cc', hamlog: 'HAMLOG.online',
|
hrdlog: 'HRDLog.net', eqsl: 'eQSL.cc', hamlog: 'HAMLOG.online',
|
||||||
|
hamqth: 'HamQTH', cloudlog: 'Cloudlog / Wavelog',
|
||||||
};
|
};
|
||||||
const label = LABELS[service] ?? service;
|
const label = LABELS[service] ?? service;
|
||||||
|
try {
|
||||||
|
// Awaited BEFORE the toast: the backend refuses a service with no
|
||||||
|
// credentials, and announcing an upload that was never started is how an
|
||||||
|
// operator concludes their QSOs are on a site they never signed up to.
|
||||||
|
await UploadQSOsManual(service, ids as any);
|
||||||
showToast(`Uploading ${ids.length} QSO${ids.length > 1 ? 's' : ''} to ${label}…`);
|
showToast(`Uploading ${ids.length} QSO${ids.length > 1 ? 's' : ''} to ${label}…`);
|
||||||
try { await UploadQSOsManual(service, ids as any); }
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
|
||||||
}
|
}
|
||||||
// Right-click "Export filtered to ADIF (no limit)": exports every QSO that
|
// Right-click "Export filtered to ADIF (no limit)": exports every QSO that
|
||||||
// matches the current filter, bypassing the on-screen row threshold.
|
// matches the current filter, bypassing the on-screen row threshold.
|
||||||
@@ -5050,6 +5232,7 @@ export default function App() {
|
|||||||
{ name: 'tools', label: t('menu.tools'), items: [
|
{ name: 'tools', label: t('menu.tools'), items: [
|
||||||
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
||||||
{ type: 'item', label: t('dxp.tab'), action: 'tools.dxped' },
|
{ type: 'item', label: t('dxp.tab'), action: 'tools.dxped' },
|
||||||
|
{ type: 'item', label: t('sat.tab'), action: 'tools.sat' },
|
||||||
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
||||||
{ type: 'item', label: t('station.title'), action: 'tools.station' },
|
{ type: 'item', label: t('station.title'), action: 'tools.station' },
|
||||||
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
||||||
@@ -5107,6 +5290,7 @@ export default function App() {
|
|||||||
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
|
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
|
||||||
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
|
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
|
||||||
case 'tools.dxped': openDxpedTab(); break;
|
case 'tools.dxped': openDxpedTab(); break;
|
||||||
|
case 'tools.sat': openSatTab(); break;
|
||||||
case 'tools.decodes': openDecodesTab(); break;
|
case 'tools.decodes': openDecodesTab(); break;
|
||||||
case 'tools.ftmap': openFtmapTab(); break;
|
case 'tools.ftmap': openFtmapTab(); break;
|
||||||
case 'tools.grids': openGridsTab(); break;
|
case 'tools.grids': openGridsTab(); break;
|
||||||
@@ -5243,8 +5427,15 @@ export default function App() {
|
|||||||
// "59+30" turned up, which is five characters plus its padding and no longer
|
// "59+30" turned up, which is five characters plus its padding and no longer
|
||||||
// fitted. The RST fields are back to their original width; the callsign keeps
|
// fitted. The RST fields are back to their original width; the callsign keeps
|
||||||
// the rest of the row.
|
// the rest of the row.
|
||||||
|
// shrink-0, and a notch narrower than it used to be.
|
||||||
|
//
|
||||||
|
// The row it sits in gains a date field when the padlock is closed, and a
|
||||||
|
// flex row makes room by shrinking its children — so the callsign box, the
|
||||||
|
// widest of them, visibly narrowed the moment the operator started a manual
|
||||||
|
// entry. The field the eye is on while typing must not move. It is now the
|
||||||
|
// size it will always be, and the slack comes from the boxes beside it.
|
||||||
const callsignBlock = (
|
const callsignBlock = (
|
||||||
<div className="flex flex-col w-56" data-esm="call">
|
<div className="flex flex-col w-52 shrink-0" data-esm="call">
|
||||||
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
|
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
|
||||||
<span className="text-primary font-semibold">{t('field.callsign')}</span>
|
<span className="text-primary font-semibold">{t('field.callsign')}</span>
|
||||||
{lookupBusy && (
|
{lookupBusy && (
|
||||||
@@ -5390,14 +5581,25 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
// Both report boxes: a notch narrower and pinned, for the same reason as the
|
||||||
|
// callsign. They were the next widest things in the row, so once the callsign
|
||||||
|
// stopped giving, they were the ones that moved when the date appeared.
|
||||||
|
// "59+20" is the longest report either ever holds and still fits.
|
||||||
const rstTxBlock = (
|
const rstTxBlock = (
|
||||||
<div className="flex flex-col w-20" data-esm="rsttx"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
|
<div className="flex flex-col w-[4.5rem] shrink-0" data-esm="rsttx"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
|
||||||
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
|
{/* The wheel steps the report — an S-unit on RST, a decibel on a digital
|
||||||
|
one. Wheeling is the same gesture as saying "he is a bit stronger than
|
||||||
|
that", and it beats retyping three characters between overs. */}
|
||||||
|
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} commitOnType
|
||||||
|
onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }}
|
||||||
|
onWheelStep={(d) => { setRstSent((v) => stepRST(v, d, mode)); rstUserEditedRef.current = true; }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const rstRxBlock = (
|
const rstRxBlock = (
|
||||||
<div className="flex flex-col w-20" data-esm="rstrx"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
|
<div className="flex flex-col w-[4.5rem] shrink-0" data-esm="rstrx"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
|
||||||
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
|
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType
|
||||||
|
onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }}
|
||||||
|
onWheelStep={(d) => { setRstRcvd((v) => stepRST(v, d, mode)); rstUserEditedRef.current = true; }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
// DX country flag, shown large next to RST (moved here from the Country field).
|
// DX country flag, shown large next to RST (moved here from the Country field).
|
||||||
@@ -5430,7 +5632,7 @@ export default function App() {
|
|||||||
) : null;
|
) : null;
|
||||||
const startBlock = (
|
const startBlock = (
|
||||||
<div className="flex flex-col w-28">
|
<div className="flex flex-col w-28">
|
||||||
<Label className="mb-1 h-3.5 flex items-center gap-1 text-success">{t('field.startUtc')} <LockPad on={locks.start} title="start time" onToggle={() => toggleLock('start')} /></Label>
|
<Label className="mb-1 h-3.5 flex items-center gap-1 text-success">{t('field.startUtc')} <LockPad on={manualEntry} title={manualEntry ? t('field.manualEntryOff') : t('field.manualEntryOn')} onToggle={() => setManualEntry(!manualEntry)} /></Label>
|
||||||
<Input
|
<Input
|
||||||
readOnly={!locks.start}
|
readOnly={!locks.start}
|
||||||
tabIndex={locks.start ? 0 : -1}
|
tabIndex={locks.start ? 0 : -1}
|
||||||
@@ -5449,7 +5651,7 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
const endBlock = (
|
const endBlock = (
|
||||||
<div className="flex flex-col w-28">
|
<div className="flex flex-col w-28">
|
||||||
<Label className="mb-1 h-3.5 flex items-center gap-1 text-danger">{t('field.endUtc')} <LockPad on={locks.end} title="end time" onToggle={() => toggleLock('end')} /></Label>
|
<Label className="mb-1 h-3.5 flex items-center gap-1 text-danger">{t('field.endUtc')}</Label>
|
||||||
<Input
|
<Input
|
||||||
readOnly={!locks.end}
|
readOnly={!locks.end}
|
||||||
tabIndex={locks.end ? 0 : -1}
|
tabIndex={locks.end ? 0 : -1}
|
||||||
@@ -5720,7 +5922,7 @@ export default function App() {
|
|||||||
// used in the full layout to save vertical height.
|
// used in the full layout to save vertical height.
|
||||||
const bandRow = (
|
const bandRow = (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.band')} <LockPad on={locks.band} title="band" onToggle={() => toggleLock('band')} /></Label>
|
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.band')}</Label>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<Select value={band} onValueChange={onBandUserChange}>
|
<Select value={band} onValueChange={onBandUserChange}>
|
||||||
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
|
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
|
||||||
@@ -5731,7 +5933,7 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
const modeRow = (
|
const modeRow = (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.mode')} <LockPad on={locks.mode} title="mode" onToggle={() => toggleLock('mode')} /></Label>
|
<Label className="w-20 shrink-0 flex items-center gap-1">{t('field.mode')}</Label>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<Select value={mode} onValueChange={onModeUserChange}>
|
<Select value={mode} onValueChange={onModeUserChange}>
|
||||||
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
|
<SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
|
||||||
@@ -5759,7 +5961,29 @@ export default function App() {
|
|||||||
const mhz = parseFloat(mhzStr);
|
const mhz = parseFloat(mhzStr);
|
||||||
if (!Number.isFinite(mhz) || mhz < 0.1 || mhz > 3000) return;
|
if (!Number.isFinite(mhz) || mhz < 0.1 || mhz > 3000) return;
|
||||||
noteManualEdit();
|
noteManualEdit();
|
||||||
SetCATFrequency(Math.round(mhz * 1_000_000)).catch(() => {});
|
const hz = Math.round(mhz * 1_000_000);
|
||||||
|
SetCATFrequency(hz).catch(() => {});
|
||||||
|
tuneModeForWateringHole(hz);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 28.074 IS FT8, and typing it says so.
|
||||||
|
//
|
||||||
|
// A spot click has always carried a mode; a frequency typed by hand carried
|
||||||
|
// none, so the rig stayed in whatever it was — an FTDX3000 landing on 28.074
|
||||||
|
// in USB while the operator waited for decodes. Nobody tunes a watering hole
|
||||||
|
// to listen to it in SSB, and this is the same table and the same tolerance a
|
||||||
|
// spot click is judged with (±3 kHz of a known digital frequency).
|
||||||
|
//
|
||||||
|
// Only when it actually changes something, and only towards the digital
|
||||||
|
// modes: tuning away from 28.074 leaves the mode alone, because there the
|
||||||
|
// frequency says nothing about what the operator means to do.
|
||||||
|
const tuneModeForWateringHole = (hz: number) => {
|
||||||
|
const m = inferDigitalMode(hz);
|
||||||
|
if (!m || m === mode) return;
|
||||||
|
setMode(m);
|
||||||
|
applyModePreset(m);
|
||||||
|
if (catState.enabled && catState.connected && !locks.mode) SetCATMode(m).catch(() => {});
|
||||||
|
ConfigureDecoderMode(m).catch(() => {});
|
||||||
};
|
};
|
||||||
// Carry out what was typed in the call field. Bands go through the ordinary
|
// Carry out what was typed in the call field. Bands go through the ordinary
|
||||||
// band change, so the antennas, the power table and the outbound integrations
|
// band change, so the antennas, the power table and the outbound integrations
|
||||||
@@ -5777,6 +6001,7 @@ export default function App() {
|
|||||||
const b = bandForMHz(hz / 1_000_000);
|
const b = bandForMHz(hz / 1_000_000);
|
||||||
if (b) setBand(b);
|
if (b) setBand(b);
|
||||||
if (catState.enabled && catState.connected) SetCATFrequency(hz).catch(() => {});
|
if (catState.enabled && catState.connected) SetCATFrequency(hz).catch(() => {});
|
||||||
|
tuneModeForWateringHole(hz);
|
||||||
showToast((hz / 1_000_000).toFixed(3) + ' MHz');
|
showToast((hz / 1_000_000).toFixed(3) + ' MHz');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -5806,7 +6031,7 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
const freqBlock = (
|
const freqBlock = (
|
||||||
<div className="flex flex-col w-32">
|
<div className="flex flex-col w-32">
|
||||||
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockPad on={locks.freq} title="frequency" onToggle={() => toggleLock('freq')} /></Label>
|
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')}</Label>
|
||||||
<Input
|
<Input
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
@@ -6278,13 +6503,41 @@ export default function App() {
|
|||||||
// Follow the station the digital application says it is calling. A decode
|
// Follow the station the digital application says it is calling. A decode
|
||||||
// clicked here sets the target directly (see onCall); this covers the QSO
|
// clicked here sets the target directly (see onCall); this covers the QSO
|
||||||
// started from the other side — WSJT-X's own double-click, or auto-call.
|
// started from the other side — WSJT-X's own double-click, or auto-call.
|
||||||
|
//
|
||||||
|
// On the EDGE — when the decoder's DX call actually changes — and not by
|
||||||
|
// comparing it with the panel's current target: the auto-call effect below
|
||||||
|
// sets that target too, and two effects each restoring "their" value from the
|
||||||
|
// other's write is a loop, not a preference.
|
||||||
|
const pskDxRef = useRef('');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dx = (txState?.dx_call ?? '').toUpperCase().trim();
|
const dx = (txState?.dx_call ?? '').toUpperCase().trim();
|
||||||
if (dx && dx !== pskTarget) {
|
if (!dx || dx === pskDxRef.current) return;
|
||||||
|
pskDxRef.current = dx;
|
||||||
setPskTarget(dx);
|
setPskTarget(dx);
|
||||||
setPskTargetMode(txState?.mode ?? '');
|
setPskTargetMode(txState?.mode ?? '');
|
||||||
}
|
}, [txState?.dx_call, txState?.mode]);
|
||||||
}, [txState?.dx_call, txState?.mode, pskTarget]);
|
|
||||||
|
// WAITING FOR A STATION IS ALREADY A REASON TO ANALYSE IT.
|
||||||
|
//
|
||||||
|
// Auto-call shows an hourglass for a station it wants and cannot call yet
|
||||||
|
// because that station is working somebody else. The analysis takes a moment
|
||||||
|
// to fill — the history query, then a period or two of live reports — so
|
||||||
|
// starting it at the instant the DX becomes free is starting it too late.
|
||||||
|
// The wait is dead time, and this is exactly what it is worth spending on.
|
||||||
|
//
|
||||||
|
// Only while nothing is actually being called: a real target is the panel's
|
||||||
|
// subject, and it arrives here through the decoder's DX call above.
|
||||||
|
const pskWaitRef = useRef('');
|
||||||
|
useEffect(() => {
|
||||||
|
const w = String(autoCallStatus?.waiting ?? '').toUpperCase().trim();
|
||||||
|
if (!w) { pskWaitRef.current = ''; return; }
|
||||||
|
if (autoCallStatus?.target) return;
|
||||||
|
if (w === pskWaitRef.current) return;
|
||||||
|
pskWaitRef.current = w;
|
||||||
|
setPskTarget(w);
|
||||||
|
setPskTargetMode(txState?.mode ?? mode ?? '');
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [autoCallStatus?.waiting, autoCallStatus?.target]);
|
||||||
|
|
||||||
const renderDecodesPanel = () => (
|
const renderDecodesPanel = () => (
|
||||||
<div className="flex h-full min-h-0">
|
<div className="flex h-full min-h-0">
|
||||||
@@ -6314,6 +6567,37 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ONE CLICK TAKES THE STATION, TWO ANSWER IT — and the FT map means the same
|
||||||
|
// by them as the list does. They were written inline on the list, so the map
|
||||||
|
// had no way to offer the same gestures without a second copy of them.
|
||||||
|
function selectDecode(d: any) {
|
||||||
|
setPskTarget((d.call ?? '').toUpperCase());
|
||||||
|
setPskTargetMode(d.mode ?? '');
|
||||||
|
onCallsignInput(d.call, { force: true });
|
||||||
|
}
|
||||||
|
function callDecode(d: any) {
|
||||||
|
// The station being answered is also the one worth analysing: the PSK
|
||||||
|
// Reporter panel follows the click rather than asking for a second one.
|
||||||
|
setPskTarget((d.call ?? '').toUpperCase());
|
||||||
|
setPskTargetMode(d.mode ?? '');
|
||||||
|
// Auto-call adopts the station: the click chooses WHO, the watchdogs still
|
||||||
|
// decide how long it is called for.
|
||||||
|
TakeAutoCallTarget(d.call ?? '', d.band ?? '', d.mode ?? '').catch(() => {});
|
||||||
|
onCallsignInput(d.call, { force: true });
|
||||||
|
// With two slices on two bands, the Reply reaches the right INSTANCE but the
|
||||||
|
// radio still transmits on whichever slice holds the TX flag. Move it to the
|
||||||
|
// decode's band first, or an 80 m answer goes out on 20 m. A no-op on any
|
||||||
|
// backend without slices.
|
||||||
|
FlexTXOnBand(d.band ?? '').catch(() => { /* not a Flex, or no such slice */ });
|
||||||
|
AnswerDecode(
|
||||||
|
d.instance ?? '', d.ms ?? 0, d.snr, d.dt ?? 0,
|
||||||
|
// The RAW mode marker, not the resolved name: the receiving application
|
||||||
|
// matches the Reply against its decode list field for field, and JTDX
|
||||||
|
// drops one that says "FT8" where it decoded "~".
|
||||||
|
d.audio_hz ?? 0, d.mode_raw || d.mode || '', d.msg_raw ?? d.msg ?? '', !!d.low_conf,
|
||||||
|
).catch((e: any) => setError(String(e?.message ?? e)));
|
||||||
|
}
|
||||||
|
|
||||||
const renderDecodesList = () => (
|
const renderDecodesList = () => (
|
||||||
<DecodesPanel
|
<DecodesPanel
|
||||||
decodes={decodes}
|
decodes={decodes}
|
||||||
@@ -6325,6 +6609,7 @@ export default function App() {
|
|||||||
// compare with", never "the rig is on no band".
|
// compare with", never "the rig is on no band".
|
||||||
rigBand={catState.connected ? (catState.band || '') : ''}
|
rigBand={catState.connected ? (catState.band || '') : ''}
|
||||||
myCall={station.callsign}
|
myCall={station.callsign}
|
||||||
|
myGrid={station.my_grid}
|
||||||
// A DOUBLE click answers the station: it hands the decode back to
|
// A DOUBLE click answers the station: it hands the decode back to
|
||||||
// WSJT-X/MSHV as a Reply, the same thing as double-clicking the line in
|
// WSJT-X/MSHV as a Reply, the same thing as double-clicking the line in
|
||||||
// their own window. A single click only selects it — see onSelect below,
|
// their own window. A single click only selects it — see onSelect below,
|
||||||
@@ -6337,33 +6622,8 @@ export default function App() {
|
|||||||
// One click: take the station, transmit nothing. The entry is filled and
|
// One click: take the station, transmit nothing. The entry is filled and
|
||||||
// the panels follow it, exactly as clicking a cluster spot does — so a
|
// the panels follow it, exactly as clicking a cluster spot does — so a
|
||||||
// row can be inspected, looked up and read about without keying up.
|
// row can be inspected, looked up and read about without keying up.
|
||||||
onSelect={(d) => {
|
onSelect={selectDecode}
|
||||||
setPskTarget((d.call ?? '').toUpperCase());
|
onCall={callDecode}
|
||||||
setPskTargetMode(d.mode ?? '');
|
|
||||||
onCallsignInput(d.call, { force: true });
|
|
||||||
}}
|
|
||||||
onCall={(d) => {
|
|
||||||
// The station being answered is also the one worth analysing: the PSK
|
|
||||||
// Reporter panel follows the click rather than asking for a second one.
|
|
||||||
setPskTarget((d.call ?? '').toUpperCase());
|
|
||||||
setPskTargetMode(d.mode ?? '');
|
|
||||||
// Auto-call adopts the station: the click chooses WHO, the watchdogs
|
|
||||||
// still decide how long it is called for.
|
|
||||||
TakeAutoCallTarget(d.call ?? '', d.band ?? '', d.mode ?? '').catch(() => {});
|
|
||||||
onCallsignInput(d.call, { force: true });
|
|
||||||
// With two slices on two bands, the Reply reaches the right INSTANCE but
|
|
||||||
// the radio still transmits on whichever slice holds the TX flag. Move
|
|
||||||
// it to the decode's band first, or an 80 m answer goes out on 20 m.
|
|
||||||
// A no-op on any backend without slices.
|
|
||||||
FlexTXOnBand(d.band ?? '').catch(() => { /* not a Flex, or no such slice */ });
|
|
||||||
AnswerDecode(
|
|
||||||
d.instance ?? '', d.ms ?? 0, d.snr, d.dt ?? 0,
|
|
||||||
// The RAW mode marker, not the resolved name: the receiving
|
|
||||||
// application matches the Reply against its decode list field for
|
|
||||||
// field, and JTDX drops one that says "FT8" where it decoded "~".
|
|
||||||
d.audio_hz ?? 0, d.mode_raw || d.mode || '', d.msg_raw ?? d.msg ?? '', !!d.low_conf,
|
|
||||||
).catch((e: any) => setError(String(e?.message ?? e)));
|
|
||||||
}}
|
|
||||||
// Wipes the live view only — nothing here is stored, and the staging
|
// Wipes the live view only — nothing here is stored, and the staging
|
||||||
// buffer goes too or the next flush would put back what was just cleared.
|
// buffer goes too or the next flush would put back what was just cleared.
|
||||||
onClear={(instance) => {
|
onClear={(instance) => {
|
||||||
@@ -6385,15 +6645,16 @@ export default function App() {
|
|||||||
// An empty instance lets the backend fall back to whichever application
|
// An empty instance lets the backend fall back to whichever application
|
||||||
// last reported its status — the normal single-receiver case.
|
// last reported its status — the normal single-receiver case.
|
||||||
onHalt={(instance) => {
|
onHalt={(instance) => {
|
||||||
// Halt means stop, including whatever auto-call had started — and it
|
// Halt is a verdict on the station being called: it is set aside for
|
||||||
// clears the engine's state, so a target it had given up on is not
|
// the session rather than released, or the next period would call it
|
||||||
// still sitting there when the operator switches it back on.
|
// straight back — which is exactly what the operator just stopped.
|
||||||
ResetAutoCall().catch(() => {});
|
HaltAutoCall().catch(() => {});
|
||||||
HaltDecodeTx(instance, false).catch((e: any) => setError(String(e?.message ?? e)));
|
HaltDecodeTx(instance, false).catch((e: any) => setError(String(e?.message ?? e)));
|
||||||
}}
|
}}
|
||||||
autoCallOn={!!autoCallStatus?.enabled}
|
autoCallOn={!!autoCallStatus?.enabled}
|
||||||
onToggleAutoCall={toggleAutoCall}
|
onToggleAutoCall={toggleAutoCall}
|
||||||
autoCall={autoCallStatus}
|
autoCall={autoCallStatus}
|
||||||
|
watchlist={watchPatterns}
|
||||||
autoCallOnly={autoCallStatus?.only ?? ''}
|
autoCallOnly={autoCallStatus?.only ?? ''}
|
||||||
onSetAutoCallOnly={(list) => {
|
onSetAutoCallOnly={(list) => {
|
||||||
setAutoCallStatus((st: any) => ({ ...st, only: list.toUpperCase() }));
|
setAutoCallStatus((st: any) => ({ ...st, only: list.toUpperCase() }));
|
||||||
@@ -6677,9 +6938,9 @@ export default function App() {
|
|||||||
{/* Motorized-antenna pattern (Normal / 180° reverse / Bidirectional), next to the azimuth. */}
|
{/* Motorized-antenna pattern (Normal / 180° reverse / Bidirectional), next to the azimuth. */}
|
||||||
{ubStatus.enabled && (
|
{ubStatus.enabled && (
|
||||||
<div className="inline-flex items-center rounded-full border border-success-border bg-success-muted overflow-hidden text-[10px] font-semibold ml-1"
|
<div className="inline-flex items-center rounded-full border border-success-border bg-success-muted overflow-hidden text-[10px] font-semibold ml-1"
|
||||||
title={ubStatus.connected ? (ubStatus.moving ? 'Antenna: moving…' : 'Antenna pattern') : 'Antenna: connecting…'}>
|
title={ubStatus.connected ? (motorMoving ? 'Antenna: moving…' : 'Antenna pattern') : 'Antenna: connecting…'}>
|
||||||
<button type="button" className="pl-1.5 pr-0.5 flex items-center" onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }} title="Antenna settings">
|
<button type="button" className="pl-1.5 pr-0.5 flex items-center" onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }} title="Antenna settings">
|
||||||
<span className={cn('size-2 rounded-full', ubStatus.connected ? (ubStatus.moving ? 'bg-warning' : 'bg-success') : 'bg-muted-foreground/40')} />
|
<span className={cn('size-2 rounded-full', ubStatus.connected ? (motorMoving ? 'bg-warning' : 'bg-success') : 'bg-muted-foreground/40')} />
|
||||||
</button>
|
</button>
|
||||||
{([{ d: 0, l: 'N', t: 'Normal' }, { d: 1, l: '180°', t: 'Reverse (180°)' }, { d: 2, l: 'Bi', t: 'Bidirectional' }]).map((o) => (
|
{([{ d: 0, l: 'N', t: 'Normal' }, { d: 1, l: '180°', t: 'Reverse (180°)' }, { d: 2, l: 'Bi', t: 'Bidirectional' }]).map((o) => (
|
||||||
<button key={o.d} type="button" disabled={!ubStatus.connected} title={o.t}
|
<button key={o.d} type="button" disabled={!ubStatus.connected} title={o.t}
|
||||||
@@ -6737,6 +6998,18 @@ export default function App() {
|
|||||||
<Ear className="size-4" />
|
<Ear className="size-4" />
|
||||||
{cwOn && cwStatus.active && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success animate-pulse" />}
|
{cwOn && cwStatus.active && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success animate-pulse" />}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { const v = !showWatchWidget; setShowWatchWidget(v); writeUiPref('opslog.showWatchWidget', v ? '1' : '0'); }}
|
||||||
|
title={showWatchWidget ? t('wlw.hide') : t('wlw.show')}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||||
|
showWatchWidget ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
|
||||||
|
: 'border-border text-muted-foreground hover:bg-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Bell className="size-4" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
|
onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
|
||||||
@@ -6764,8 +7037,8 @@ export default function App() {
|
|||||||
{/* Amber while the elements travel: on a SteppIR that is also
|
{/* Amber while the elements travel: on a SteppIR that is also
|
||||||
when transmitting is a bad idea, so it is worth seeing from
|
when transmitting is a bad idea, so it is worth seeing from
|
||||||
the icon without opening the widget. */}
|
the icon without opening the widget. */}
|
||||||
{ubStatus.moving && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-warning animate-pulse" />}
|
{motorMoving && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-warning animate-pulse" />}
|
||||||
{!ubStatus.moving && showMotorAnt && ubStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
|
{!motorMoving && showMotorAnt && ubStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{agEnabled && (
|
{agEnabled && (
|
||||||
@@ -7219,12 +7492,30 @@ export default function App() {
|
|||||||
{e.date && <span className="text-[11px] text-muted-foreground">{e.date}</span>}
|
{e.date && <span className="text-[11px] text-muted-foreground">{e.date}</span>}
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-1.5 text-sm">
|
<ul className="space-y-1.5 text-sm">
|
||||||
{(clLang === 'fr' ? e.fr : e.en).map((line, i) => (
|
{(clLang === 'fr' ? e.fr : e.en).map((line, i) => {
|
||||||
|
// "[NEW] " at the head of an entry marks a new FEATURE, as
|
||||||
|
// opposed to a fix to one. The marker is written in the
|
||||||
|
// changelog file itself and is the same in both languages,
|
||||||
|
// so one convention covers EN and FR; it is stripped here
|
||||||
|
// and drawn as a pill. A release is mostly fixes, and the
|
||||||
|
// two or three things that are actually new should not have
|
||||||
|
// to be found by reading all of it.
|
||||||
|
const isNew = line.startsWith('[NEW] ');
|
||||||
|
const text = isNew ? line.slice(6) : line;
|
||||||
|
return (
|
||||||
<li key={i} className="flex gap-2">
|
<li key={i} className="flex gap-2">
|
||||||
<span className="text-primary mt-1.5 size-1.5 rounded-full bg-primary shrink-0" />
|
<span className={cn('mt-1.5 size-1.5 rounded-full shrink-0', isNew ? 'bg-success' : 'bg-primary')} />
|
||||||
<span>{line}</span>
|
<span>
|
||||||
|
{isNew && (
|
||||||
|
<span className="mr-1.5 align-[1px] rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide bg-success text-success-foreground">
|
||||||
|
{t('whatsnew.newTag')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -7446,7 +7737,7 @@ export default function App() {
|
|||||||
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
||||||
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
||||||
otherwise it shows the QRZ profile photo. */}
|
otherwise it shows the QRZ profile photo. */}
|
||||||
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showMotorAnt && ubStatus.enabled) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showAmpWidget && ampSts.length > 0) || (showScp && scpEnabled) || (chaseNewOn && showChaseNew) || (showLiveStations && dbConn?.backend === 'mysql')) && (
|
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showMotorAnt && ubStatus.enabled) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showAmpWidget && ampSts.length > 0) || (showScp && scpEnabled) || (chaseNewOn && showChaseNew) || showWatchWidget || (showLiveStations && dbConn?.backend === 'mysql')) && (
|
||||||
// relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
|
// relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
|
||||||
// the DVK with Auto CQ) can't grow the row — the row height stays set by
|
// the DVK with Auto CQ) can't grow the row — the row height stays set by
|
||||||
// the entry strip and each widget fills that height, scrolling inside.
|
// the entry strip and each widget fills that height, scrolling inside.
|
||||||
@@ -7509,10 +7800,27 @@ export default function App() {
|
|||||||
{/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
|
{/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
|
||||||
rotator is configured or a DX bearing exists. Compact mode drops the
|
rotator is configured or a DX bearing exists. Compact mode drops the
|
||||||
controls column, so the widget is just the dial and needs only its
|
controls column, so the widget is just the dial and needs only its
|
||||||
width. */}
|
width. The classic dial sizes itself from the inside and expects a fixed
|
||||||
|
column; the current one asks for the width it needs. */}
|
||||||
|
{showWatchWidget && (
|
||||||
|
<div className="w-[290px] shrink-0 min-h-0" style={{ order: wOrder('watchlist') }}>
|
||||||
|
{/* Same handler as a cluster row, for the same reason as Chase
|
||||||
|
new: a row here IS a spot, and half the reflex — the call
|
||||||
|
without the frequency, or the frequency without the mode — is
|
||||||
|
what makes a shortcut not worth using. */}
|
||||||
|
<WatchlistWidget
|
||||||
|
spots={spots}
|
||||||
|
spotStatus={spotStatus as any}
|
||||||
|
onPick={(s) => handleSpotClick(s as any)}
|
||||||
|
onClose={() => { setShowWatchWidget(false); writeUiPref('opslog.showWatchWidget', '0'); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{showRotor && (rotatorHeading.enabled || dxPath) && (
|
{showRotor && (rotatorHeading.enabled || dxPath) && (
|
||||||
<div className={cn('shrink-0 min-h-0', rotorCompact ? 'w-[196px]' : 'w-[320px]')} style={{ order: wOrder('rotor') }}>
|
<div className={cn('shrink-0 min-h-0',
|
||||||
<RotorCompass
|
rotorCompact ? 'w-[196px]' : rotorDial === 'classic' ? 'w-[320px]' : 'w-auto')}
|
||||||
|
style={{ order: wOrder('rotor') }}>
|
||||||
|
<Compass2
|
||||||
presets={rotorCompact ? undefined : rotorPresets}
|
presets={rotorCompact ? undefined : rotorPresets}
|
||||||
onStop={rotorCompact ? undefined : () => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
|
onStop={rotorCompact ? undefined : () => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
|
||||||
bearing={dxPath?.bearingShort ?? null}
|
bearing={dxPath?.bearingShort ?? null}
|
||||||
@@ -7533,7 +7841,7 @@ export default function App() {
|
|||||||
{showMotorAnt && ubStatus.enabled && (
|
{showMotorAnt && ubStatus.enabled && (
|
||||||
<div className="w-[230px] shrink-0 min-h-0" style={{ order: wOrder('motorant') }}>
|
<div className="w-[230px] shrink-0 min-h-0" style={{ order: wOrder('motorant') }}>
|
||||||
<MotorAntennaWidget
|
<MotorAntennaWidget
|
||||||
ant={ubStatus}
|
ant={{ ...ubStatus, moving: motorMoving }}
|
||||||
refetch={pokeUbStatus}
|
refetch={pokeUbStatus}
|
||||||
t={t}
|
t={t}
|
||||||
essentialsOnly
|
essentialsOnly
|
||||||
@@ -7820,7 +8128,7 @@ export default function App() {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
{catState.backend === 'flex' && <TabsTrigger value="flex">Flex Console</TabsTrigger>}
|
{catState.backend === 'flex' && <TabsTrigger value="flex">Flex Console</TabsTrigger>}
|
||||||
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom Console</TabsTrigger>}
|
{icomShown && <TabsTrigger value="icom">Icom Console</TabsTrigger>}
|
||||||
{catState.backend === 'yaesu' && <TabsTrigger value="yaesu">Yaesu Console</TabsTrigger>}
|
{catState.backend === 'yaesu' && <TabsTrigger value="yaesu">Yaesu Console</TabsTrigger>}
|
||||||
{(catState.backend === 'elecraft' || catState.backend === 'kenwood') && <TabsTrigger value="elecraft">{t('k3.console')}</TabsTrigger>}
|
{(catState.backend === 'elecraft' || catState.backend === 'kenwood') && <TabsTrigger value="elecraft">{t('k3.console')}</TabsTrigger>}
|
||||||
{catState.backend === 'tci' && <TabsTrigger value="tci">{t('tcip.console')}</TabsTrigger>}
|
{catState.backend === 'tci' && <TabsTrigger value="tci">{t('tcip.console')}</TabsTrigger>}
|
||||||
@@ -7887,6 +8195,21 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
|
{satTabOpen && (
|
||||||
|
<TabsTrigger value="sat" className="gap-1.5">
|
||||||
|
{t('sat.tab')}
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
aria-label="Close Satellites"
|
||||||
|
title="Close"
|
||||||
|
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
|
||||||
|
onPointerDown={(e) => { e.stopPropagation(); }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); closeSatTab(); }}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
{ftmapTabOpen && (
|
{ftmapTabOpen && (
|
||||||
<TabsTrigger value="ftmap" className="gap-1.5">
|
<TabsTrigger value="ftmap" className="gap-1.5">
|
||||||
{t('ftmap.tab')}
|
{t('ftmap.tab')}
|
||||||
@@ -8535,11 +8858,24 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
{satTabOpen && (
|
||||||
|
<TabsContent value="sat" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||||
|
{/* Mounted only while it is the visible tab: the panel polls the
|
||||||
|
tuning once a second, and there is no reason to compute an
|
||||||
|
orbit for a tab nobody is looking at. */}
|
||||||
|
{activeTab === 'sat' && (
|
||||||
|
<div className="h-full w-full min-h-0">
|
||||||
|
<SatellitePanel myGrid={station.my_grid} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
{ftmapTabOpen && (
|
{ftmapTabOpen && (
|
||||||
<TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
<TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||||
{activeTab === 'ftmap' && (
|
{activeTab === 'ftmap' && (
|
||||||
<div className="h-full w-full min-h-0 bg-card border border-border rounded-lg overflow-hidden">
|
<div className="h-full w-full min-h-0 bg-card border border-border rounded-lg overflow-hidden">
|
||||||
<FTMapPanel decodes={decodes as any} myGrid={station.my_grid} />
|
<FTMapPanel decodes={decodes as any} myGrid={station.my_grid}
|
||||||
|
onSelect={selectDecode} onCall={callDecode} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -8595,7 +8931,7 @@ export default function App() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{catState.backend === 'icom' && (
|
{icomShown && (
|
||||||
<TabsContent value="icom" className="flex-1 min-h-0 p-0">
|
<TabsContent value="icom" className="flex-1 min-h-0 p-0">
|
||||||
<IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
<IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -8766,6 +9102,7 @@ export default function App() {
|
|||||||
catUp={catUp}
|
catUp={catUp}
|
||||||
catState={catState}
|
catState={catState}
|
||||||
onOpenSettings={() => { setSettingsSection('cat'); setShowSettings(true); }}
|
onOpenSettings={() => { setSettingsSection('cat'); setShowSettings(true); }}
|
||||||
|
onRadioSwitched={loadCATCfg}
|
||||||
/>
|
/>
|
||||||
<Chip
|
<Chip
|
||||||
on={rotatorHeading.enabled && rotatorHeading.ok}
|
on={rotatorHeading.enabled && rotatorHeading.ok}
|
||||||
@@ -8811,26 +9148,12 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{/* PSK Reporter, next to the hardware chips because it is the same
|
{/* The PSK Reporter chip used to sit here, labelled MQTT. That is
|
||||||
kind of fact: a link that is either up or it is not. Shown ONLY
|
the name of a message protocol, not of anything an operator has:
|
||||||
when the opening watch is on — a permanently grey chip for a
|
a chip in the status bar has to say what it is about, and this
|
||||||
feature nobody enabled is clutter, and the bar is 28 px.
|
one told nobody anything. The state it carried — the openings
|
||||||
|
feed up or down, and how many reports have arrived — is shown in
|
||||||
The decode count is in the tooltip rather than the chip: it moves
|
the Chase New panel, which is the place that uses it. */}
|
||||||
several times a second on an open band, and a number flickering in
|
|
||||||
the corner of the eye is not information, it is a distraction. */}
|
|
||||||
{pskr?.running && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={t('pskr.tip', { n: pskr.received ?? 0, bands: (pskr.bands ?? []).join(' ') })}
|
|
||||||
onClick={() => { setSettingsSection('cluster'); setShowSettings(true); }}
|
|
||||||
className="inline-flex items-center gap-1.5 px-2 h-5 rounded border text-[11px] transition-colors border-border hover:bg-muted cursor-pointer shrink-0"
|
|
||||||
>
|
|
||||||
<span className={cn('size-2 rounded-full',
|
|
||||||
(pskr.received ?? 0) > 0 ? 'bg-success' : 'bg-warning')} />
|
|
||||||
MQTT
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* ON AIR badge: "did I log a QSO in the last 5 min" — meaningful on ANY
|
{/* ON AIR badge: "did I log a QSO in the last 5 min" — meaningful on ANY
|
||||||
logbook backend (only the live_status PUBLISHING is MySQL-specific),
|
logbook backend (only the live_status PUBLISHING is MySQL-specific),
|
||||||
so it is always shown. Gating it on MySQL made it vanish for
|
so it is always shown. Gating it on MySQL made it vanish for
|
||||||
@@ -9011,7 +9334,7 @@ export default function App() {
|
|||||||
onSaved={onSettingsSaved}
|
onSaved={onSettingsSaved}
|
||||||
onMainPaneChanged={onSettingsPaneChanged}
|
onMainPaneChanged={onSettingsPaneChanged}
|
||||||
flexAvailable={catState.backend === 'flex'}
|
flexAvailable={catState.backend === 'flex'}
|
||||||
icomAvailable={catState.backend === 'icom'}
|
icomAvailable={icomShown}
|
||||||
yaesuAvailable={catState.backend === 'yaesu'}
|
yaesuAvailable={catState.backend === 'yaesu'}
|
||||||
elecraftAvailable={catState.backend === 'elecraft' || catState.backend === 'kenwood'}
|
elecraftAvailable={catState.backend === 'elecraft' || catState.backend === 'kenwood'}
|
||||||
tciAvailable={catState.backend === 'tci'}
|
tciAvailable={catState.backend === 'tci'}
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
<DialogContent className="max-w-4xl">
|
<DialogContent overlayBlur={false} className="max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2"><Bell className="size-4 text-primary" /> {t('altm.title')}</DialogTitle>
|
<DialogTitle className="flex items-center gap-2"><Bell className="size-4 text-primary" /> {t('altm.title')}</DialogTitle>
|
||||||
<DialogDescription>{t('altm.desc')}</DialogDescription>
|
<DialogDescription>{t('altm.desc')}</DialogDescription>
|
||||||
|
|||||||
@@ -304,13 +304,13 @@ export function AppearancePanel() {
|
|||||||
// place here: it comes back where the operator left it rather than at the end.
|
// place here: it comes back where the operator left it rather than at the end.
|
||||||
export const WIDGET_KEYS = [
|
export const WIDGET_KEYS = [
|
||||||
'livestations', 'chat', 'rotor', 'motorant', 'antgenius',
|
'livestations', 'chat', 'rotor', 'motorant', 'antgenius',
|
||||||
'amp', 'tuner', 'scp', 'chasenew', 'dvk', 'winkeyer', 'photo',
|
'amp', 'tuner', 'scp', 'chasenew', 'watchlist', 'dvk', 'winkeyer', 'photo',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const WIDGET_LABELS: Record<string, string> = {
|
const WIDGET_LABELS: Record<string, string> = {
|
||||||
livestations: 'wo.livestations', chat: 'wo.chat', rotor: 'wo.rotor',
|
livestations: 'wo.livestations', chat: 'wo.chat', rotor: 'wo.rotor',
|
||||||
motorant: 'wo.motorant', antgenius: 'wo.antgenius', amp: 'wo.amp',
|
motorant: 'wo.motorant', antgenius: 'wo.antgenius', amp: 'wo.amp',
|
||||||
tuner: 'wo.tuner', scp: 'wo.scp', chasenew: 'wo.chasenew',
|
tuner: 'wo.tuner', scp: 'wo.scp', chasenew: 'wo.chasenew', watchlist: 'wo.watchlist',
|
||||||
dvk: 'wo.dvk', winkeyer: 'wo.winkeyer', photo: 'wo.photo',
|
dvk: 'wo.dvk', winkeyer: 'wo.winkeyer', photo: 'wo.photo',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -403,7 +403,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
<DialogContent className="max-w-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">
|
<DialogHeader className="px-5 py-3 border-b">
|
||||||
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
|
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@@ -60,7 +60,11 @@ const DEFAULT_BANDS: { tag: string; label: string }[] = [
|
|||||||
];
|
];
|
||||||
const CLASSES = ['PH', 'CW', 'DIG'] as const;
|
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 {
|
function classMatchesMode(cls: string, mode: string): boolean {
|
||||||
const u = (mode || '').toUpperCase();
|
const u = (mode || '').toUpperCase();
|
||||||
if (cls === 'PH') return PHONE_MODES.has(u);
|
if (cls === 'PH') return PHONE_MODES.has(u);
|
||||||
@@ -143,7 +147,17 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, modes,
|
|||||||
.filter((m) => m !== '' && m !== 'CW' && !PHONE_MODES.has(m)),
|
.filter((m) => m !== '' && m !== 'CW' && !PHONE_MODES.has(m)),
|
||||||
[modes],
|
[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
|
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
|
// A shorter mode list (the operator edited it) must not strand the rotation
|
||||||
// on a row that no longer exists.
|
// on a row that no longer exists.
|
||||||
const digPos = digModes.length ? digIdx % (digModes.length + 1) : 0;
|
const digPos = digModes.length ? digIdx % (digModes.length + 1) : 0;
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent overlayBlur={false} className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('bulk.title')}</DialogTitle>
|
<DialogTitle>{t('bulk.title')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
import { chaseAllows } from '@/lib/spotDisplay';
|
import { chaseAllows } from '@/lib/spotDisplay';
|
||||||
import { markerColour } from '@/lib/spotMarkers';
|
import { markerColour } from '@/lib/spotMarkers';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { GetChaseNewSpots } from '../../wailsjs/go/main/App';
|
import { GetChaseNewSpots, GetPSKReporterStatus } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
export interface ChaseNewSpot {
|
export interface ChaseNewSpot {
|
||||||
call: string;
|
call: string;
|
||||||
@@ -80,15 +80,25 @@ function categoryOf(s: ChaseNewSpot): Category | null {
|
|||||||
|
|
||||||
const FILTER_KEY = 'opslog.chaseNewFilters';
|
const FILTER_KEY = 'opslog.chaseNewFilters';
|
||||||
|
|
||||||
|
function allowedCategories(): Category[] {
|
||||||
|
return CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => c.key);
|
||||||
|
}
|
||||||
|
|
||||||
function loadFilters(): Set<Category> {
|
function loadFilters(): Set<Category> {
|
||||||
|
const allowed = allowedCategories();
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(FILTER_KEY);
|
const raw = localStorage.getItem(FILTER_KEY);
|
||||||
if (raw) {
|
if (raw) {
|
||||||
const list = JSON.parse(raw) as Category[];
|
const list = JSON.parse(raw) as Category[];
|
||||||
if (Array.isArray(list)) return new Set(list);
|
// A stored set holding NONE of the categories on offer hides the whole
|
||||||
|
// panel, for ever, with nothing to say why — and that is exactly what a
|
||||||
|
// preference written by an older build does once a category is renamed.
|
||||||
|
// Treated as "no preference": a panel that shows nothing at every launch
|
||||||
|
// is never what was meant, and the chips are one click away.
|
||||||
|
if (Array.isArray(list) && list.some((k) => allowed.includes(k))) return new Set(list);
|
||||||
}
|
}
|
||||||
} catch { /* a corrupt preference is not worth a broken panel */ }
|
} catch { /* a corrupt preference is not worth a broken panel */ }
|
||||||
return new Set(CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => c.key));
|
return new Set(allowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChaseNewPanel({ onPick, onClose }: Props) {
|
export function ChaseNewPanel({ onPick, onClose }: Props) {
|
||||||
@@ -96,6 +106,10 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
|
|||||||
const [spots, setSpots] = useState<ChaseNewSpot[]>([]);
|
const [spots, setSpots] = useState<ChaseNewSpot[]>([]);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [on, setOn] = useState<Set<Category>>(loadFilters);
|
const [on, setOn] = useState<Set<Category>>(loadFilters);
|
||||||
|
// The FEED, not the list: connected or not, how many reports it has taken,
|
||||||
|
// and what it is filtered on. Without it an empty panel says nothing about
|
||||||
|
// whether anything is arriving at all — which is the first question.
|
||||||
|
const [feed, setFeed] = useState<any>(null);
|
||||||
|
|
||||||
// Polled rather than pushed: the feed can deliver several a second under an
|
// Polled rather than pushed: the feed can deliver several a second under an
|
||||||
// opening, and an event per row would be a redraw per row for a list nobody
|
// opening, and an event per row would be a redraw per row for a list nobody
|
||||||
@@ -106,6 +120,8 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
|
|||||||
try {
|
try {
|
||||||
const r = ((await GetChaseNewSpots()) ?? []) as ChaseNewSpot[];
|
const r = ((await GetChaseNewSpots()) ?? []) as ChaseNewSpot[];
|
||||||
if (alive) { setSpots(r); setLoaded(true); }
|
if (alive) { setSpots(r); setLoaded(true); }
|
||||||
|
const st = await GetPSKReporterStatus();
|
||||||
|
if (alive) setFeed(st);
|
||||||
} catch { /* the feed may not be up yet */ }
|
} catch { /* the feed may not be up yet */ }
|
||||||
};
|
};
|
||||||
tick();
|
tick();
|
||||||
@@ -154,8 +170,13 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Both numbers: what is on screen, and what was heard. They differ
|
||||||
|
exactly when a category is switched off, which is the one case an
|
||||||
|
operator reads this panel as broken. */}
|
||||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||||
{loaded ? t('chn.count', { n: shown.length }) : ''}
|
{loaded ? (shown.length === spots.length
|
||||||
|
? t('chn.count', { n: spots.length })
|
||||||
|
: t('chn.countOf', { n: shown.length, total: spots.length })) : ''}
|
||||||
</span>
|
</span>
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button
|
<button
|
||||||
@@ -218,7 +239,16 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="border-t border-border px-2 py-1 text-[10px] text-muted-foreground">{t('chn.digitalOnly')}</p>
|
{/* The radius comes from the FEED, not from a sentence: it was written
|
||||||
|
into this line as "~300 km" and stayed 300 while the setting said
|
||||||
|
1000, which is the panel telling the operator their change did not
|
||||||
|
take when it had. */}
|
||||||
|
<p className="border-t border-border px-2 py-1 text-[10px] text-muted-foreground">
|
||||||
|
{t('chn.heardWithin', { km: feed?.near_km || 300 })}
|
||||||
|
{feed && (feed.running
|
||||||
|
? <span className="text-success"> · {t('chn.feedOn', { n: feed.received ?? 0, sq: feed.squares ?? 0 })}</span>
|
||||||
|
: <span className="text-warning"> · {feed.last_err ? t('chn.feedErr', { e: feed.last_err }) : t('chn.feedOff')}</span>)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,16 @@
|
|||||||
// Status flags (new entity / band / mode / slot / grid / prefix / POTA / county)
|
// 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
|
// come from the same resolver the cluster uses, so a call means the same thing in
|
||||||
// both panels rather than being judged twice by two rules.
|
// both panels rather than being judged twice by two rules.
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { AlertTriangle, Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
|
import { AlertTriangle, Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { chaseAllows } from '@/lib/spotDisplay';
|
import { chaseAllows } from '@/lib/spotDisplay';
|
||||||
|
import { pathBetween } from '@/lib/maidenhead';
|
||||||
|
import { distanceValue, distanceUnit, subscribeDistanceUnit } from '@/lib/units';
|
||||||
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
|
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { SetAutoCallVisible } from '../../wailsjs/go/main/App';
|
||||||
import { decoderName } from '@/lib/decoderName';
|
import { decoderName } from '@/lib/decoderName';
|
||||||
|
|
||||||
export type Decode = {
|
export type Decode = {
|
||||||
@@ -104,6 +107,9 @@ interface Props {
|
|||||||
// point the panels at it. Absent, a click falls back to onCall.
|
// point the panels at it. Absent, a click falls back to onCall.
|
||||||
onSelect?: (d: Decode) => void;
|
onSelect?: (d: Decode) => void;
|
||||||
myCall?: string;
|
myCall?: string;
|
||||||
|
// The station's own square: distance is measured from it, and without one the
|
||||||
|
// column stays empty rather than guessing.
|
||||||
|
myGrid?: string;
|
||||||
// Drop every decode and transmit message held for this panel. The list is a
|
// Drop every decode and transmit message held for this panel. The list is a
|
||||||
// live view, not data — clearing it costs nothing but the seconds until the
|
// live view, not data — clearing it costs nothing but the seconds until the
|
||||||
// next period lands.
|
// next period lands.
|
||||||
@@ -120,11 +126,14 @@ interface Props {
|
|||||||
autoCallOn?: boolean;
|
autoCallOn?: boolean;
|
||||||
onToggleAutoCall?: () => void;
|
onToggleAutoCall?: () => void;
|
||||||
// The engine's own account of what it is doing, straight from the backend.
|
// The engine's own account of what it is doing, straight from the backend.
|
||||||
autoCall?: { target: string; calls: number; max: number; misses: number; max_miss: number; stopped: boolean; reason: string };
|
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
|
// 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.
|
// waiting for is done WHILE watching the band, not in a settings tree.
|
||||||
autoCallOnly?: string;
|
autoCallOnly?: string;
|
||||||
onSetAutoCallOnly?: (list: string) => void;
|
onSetAutoCallOnly?: (list: string) => void;
|
||||||
|
// The watch list, as PATTERNS (VK9*, 3Y0J). A decode of one is worth saying
|
||||||
|
// so where the operator is reading the band, not only in the watchlist tab.
|
||||||
|
watchlist?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// The "new" categories, as toggle badges — the same idea and the same colours as
|
// The "new" categories, as toggle badges — the same idea and the same colours as
|
||||||
@@ -171,6 +180,21 @@ function catsOf(e: StatusEntry | undefined): Set<NewCat> {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isWatched applies the watch list's own rule — a trailing "*" is a prefix,
|
||||||
|
// anything else is the whole callsign — so a decode is judged here exactly as
|
||||||
|
// the backend judges a spot. Two rules for one list is how a badge and an alert
|
||||||
|
// start disagreeing about the same station.
|
||||||
|
function isWatched(call: string, patterns: string[] | undefined): boolean {
|
||||||
|
if (!patterns || patterns.length === 0 || !call) return false;
|
||||||
|
const c = call.toUpperCase();
|
||||||
|
for (const raw of patterns) {
|
||||||
|
const p = (raw ?? '').toUpperCase().trim();
|
||||||
|
if (!p) continue;
|
||||||
|
if (p.endsWith('*') ? c.startsWith(p.slice(0, -1)) : c === p) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const CAT_KEY = 'opslog.decodeCats';
|
const CAT_KEY = 'opslog.decodeCats';
|
||||||
const SPLIT_KEY = 'opslog.decodeSplit';
|
const SPLIT_KEY = 'opslog.decodeSplit';
|
||||||
const FILTER_KEY = 'opslog.decodeFilters';
|
const FILTER_KEY = 'opslog.decodeFilters';
|
||||||
@@ -252,9 +276,14 @@ const CELL_LAST = 'flex items-center min-w-0 px-2 gap-1 overflow-hidden';
|
|||||||
// One declaration per column, in display order: the header, the widths and the
|
// One declaration per column, in display order: the header, the widths and the
|
||||||
// resize handles all read from this, so a column cannot be resized in the header
|
// resize handles all read from this, so a column cannot be resized in the header
|
||||||
// and stay the old width in the body.
|
// and stay the old width in the body.
|
||||||
type ColKey = 'time' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'state' | 'country' | 'status';
|
type ColKey = 'time' | 'rx' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'dist' | 'state' | 'country' | 'status';
|
||||||
const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
|
const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
|
||||||
{ key: 'time', tkey: 'dec.colTime', def: 64, min: 44 },
|
{ key: 'time', tkey: 'dec.colTime', def: 64, min: 44 },
|
||||||
|
// WHICH RECEIVER heard it. Shown only while more than one is feeding, and
|
||||||
|
// that is the case it exists for: two decoders on one band send the same
|
||||||
|
// stations twice, each with its own SNR and DT, and a merged list gave no way
|
||||||
|
// at all to tell a second receiver from a duplicate.
|
||||||
|
{ key: 'rx', tkey: 'dec.colRx', def: 74, min: 44 },
|
||||||
{ key: 'snr', tkey: 'dec.colSnr', def: 50, min: 36 },
|
{ key: 'snr', tkey: 'dec.colSnr', def: 50, min: 36 },
|
||||||
{ key: 'dt', tkey: 'dec.colDt', def: 44, min: 32 },
|
{ key: 'dt', tkey: 'dec.colDt', def: 44, min: 32 },
|
||||||
{ key: 'freq', tkey: 'dec.colFreq', def: 56, min: 40 },
|
{ key: 'freq', tkey: 'dec.colFreq', def: 56, min: 40 },
|
||||||
@@ -268,6 +297,11 @@ const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
|
|||||||
{ key: 'grid', tkey: 'dec.colGrid', def: 62, min: 46 },
|
{ key: 'grid', tkey: 'dec.colGrid', def: 62, min: 46 },
|
||||||
// For the WAS chasers: the badge carries the two letters, the name spells
|
// For the WAS chasers: the badge carries the two letters, the name spells
|
||||||
// them out — "SD" alone is a quiz for a European.
|
// 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: 'state', tkey: 'dec.colState', def: 120, min: 56 },
|
||||||
{ key: 'country', tkey: 'dec.colCountry', def: 140, min: 70 },
|
{ key: 'country', tkey: 'dec.colCountry', def: 140, min: 70 },
|
||||||
{ key: 'status', tkey: 'dec.colStatus', def: 186, min: 80 },
|
{ key: 'status', tkey: 'dec.colStatus', def: 186, min: 80 },
|
||||||
@@ -286,6 +320,39 @@ const US_STATES: Record<string, string> = {
|
|||||||
WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming', DC: 'District of Columbia',
|
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 COL_MAX = 600;
|
||||||
const COLW_KEY = 'opslog.decodeColWidths';
|
const COLW_KEY = 'opslog.decodeColWidths';
|
||||||
|
|
||||||
@@ -422,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
|
// read "CQ CQ PE1NAO JO32" — the badge and the message's own first word saying
|
||||||
// the same thing twice. Highlighting the word already in the line keeps the
|
// the same thing twice. Highlighting the word already in the line keeps the
|
||||||
// scannability and drops the stutter.
|
// scannability and drops the stutter.
|
||||||
function renderMsg(msg: string, me: string, calling: string) {
|
function renderMsg(msg: string, me: string, calling: string, toMe: boolean) {
|
||||||
if (!msg) return null;
|
if (!msg) return null;
|
||||||
|
// THE WHOLE LINE, when it is addressed to YOU.
|
||||||
|
//
|
||||||
|
// Token colouring is for reading the band — it picks your call out of a wall
|
||||||
|
// of other people's traffic. A message sent TO you is a different question:
|
||||||
|
// not "is my call in there somewhere" but "what did he just send me", read
|
||||||
|
// from the far side of the shack, so the whole line carries the colour.
|
||||||
|
//
|
||||||
|
// Only that case. The station you are calling also transmits to everybody
|
||||||
|
// else — "LA8WRA LA1RAU/P +00" is your target reporting to another caller —
|
||||||
|
// and setting those lines in the same strong colour said you were in a QSO
|
||||||
|
// you were not in. Its callsign is picked out by the token pass below, which
|
||||||
|
// is what "he is on the air, working somebody else" should look like.
|
||||||
|
if (toMe) {
|
||||||
|
return <span className="font-bold text-success">{msg}</span>;
|
||||||
|
}
|
||||||
// Split on whitespace and colour the tokens that matter, rather than the
|
// Split on whitespace and colour the tokens that matter, rather than the
|
||||||
// whole line: an operator scanning a slot is looking for their own call in
|
// whole line: an operator scanning a slot is looking for their own call in
|
||||||
// the first position (someone answering) and for the station being called.
|
// the first position (someone answering) and for the station being called.
|
||||||
const parts = msg.split(/(s+)/);
|
const parts = msg.split(/(\s+)/);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{parts.map((tok, i) => {
|
{parts.map((tok, i) => {
|
||||||
if (/^s+$/.test(tok)) return tok;
|
if (/^\s+$/.test(tok)) return tok;
|
||||||
const bare = tok.replace(/[<>]/g, '').toUpperCase();
|
const bare = tok.replace(/[<>]/g, '').toUpperCase();
|
||||||
if (i === 0 && /^CQ$/i.test(tok)) return <span key={i} className="font-bold text-success">{tok.toUpperCase()}</span>;
|
if (i === 0 && /^CQ$/i.test(tok)) return <span key={i} className="font-bold text-success">{tok.toUpperCase()}</span>;
|
||||||
if (me && bare === me) return <span key={i} className="font-bold text-success">{tok}</span>;
|
if (me && bare === me) return <span key={i} className="font-bold text-success">{tok}</span>;
|
||||||
@@ -450,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
|
// It is the one moving thing on the panel, and it answers the question an
|
||||||
// operator actually has between overs: how long until the next batch.
|
// operator actually has between overs: how long until the next batch.
|
||||||
function PeriodClock({ trSec, mode }: { trSec: number; mode?: string }) {
|
function PeriodClock({ trSec, mode, tx }: { trSec: number; mode?: string; tx?: boolean }) {
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 100 ms: smooth enough for a bar that fills in three and three quarter
|
// 100 ms: smooth enough for a bar that fills in three and three quarter
|
||||||
@@ -466,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
|
// The last fifth of a slot is when a decode is imminent and an operator
|
||||||
// deciding whether to answer has run out of time to think.
|
// deciding whether to answer has run out of time to think.
|
||||||
const closing = left <= trSec / 5;
|
const closing = left <= trSec / 5;
|
||||||
|
// TRANSMITTING outranks both. The bar is the one thing on this screen that
|
||||||
|
// moves continuously, so it is what the eye is already on — and "am I on the
|
||||||
|
// air" is the state worth reading from across the room. Red, and it stays red
|
||||||
|
// for the whole over rather than turning amber near the end of it.
|
||||||
|
const tone = tx ? 'danger' : closing ? 'warning' : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2 shrink-0" title={mode ? `${mode} · ${trSec}s` : `${trSec}s`}>
|
<span className="flex items-center gap-2 shrink-0" title={mode ? `${mode} · ${trSec}s` : `${trSec}s`}>
|
||||||
<Timer className={cn('size-4', closing ? 'text-warning' : 'text-muted-foreground')} />
|
<Timer className={cn('size-4',
|
||||||
|
tone === 'danger' ? 'text-danger' : tone === 'warning' ? 'text-warning' : 'text-muted-foreground')} />
|
||||||
<span className="relative h-1.5 w-24 rounded-full bg-muted overflow-hidden">
|
<span className="relative h-1.5 w-24 rounded-full bg-muted overflow-hidden">
|
||||||
<span
|
<span
|
||||||
className={cn('absolute inset-y-0 left-0 rounded-full transition-[width] duration-100 ease-linear',
|
className={cn('absolute inset-y-0 left-0 rounded-full transition-[width] duration-100 ease-linear',
|
||||||
closing ? 'bg-warning' : 'bg-primary')}
|
tone === 'danger' ? 'bg-danger' : tone === 'warning' ? 'bg-warning' : 'bg-primary')}
|
||||||
style={{ width: `${pct}%` }}
|
style={{ width: `${pct}%` }}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span className={cn('font-mono text-sm tabular-nums w-10 text-right',
|
<span className={cn('font-mono text-sm tabular-nums w-10 text-right',
|
||||||
closing ? 'text-warning font-semibold' : 'text-muted-foreground')}>
|
tone === 'danger' ? 'text-danger font-semibold'
|
||||||
|
: tone === 'warning' ? 'text-warning font-semibold' : 'text-muted-foreground')}>
|
||||||
{left.toFixed(1)}
|
{left.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
@@ -552,7 +641,7 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly }: Props) {
|
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, myGrid, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
// Column widths, dragged in the header and shared by every row. Persisted
|
// Column widths, dragged in the header and shared by every row. Persisted
|
||||||
// through writeUiPref (not raw localStorage) so the layout travels with data/
|
// through writeUiPref (not raw localStorage) so the layout travels with data/
|
||||||
@@ -567,8 +656,6 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
|||||||
if (el && el.tagName === 'INPUT' && el.getAttribute('placeholder') === t('dec.chasePh')) return;
|
if (el && el.tagName === 'INPUT' && el.getAttribute('placeholder') === t('dec.chasePh')) return;
|
||||||
setOnlyText(autoCallOnly ?? '');
|
setOnlyText(autoCallOnly ?? '');
|
||||||
}, [autoCallOnly, t]);
|
}, [autoCallOnly, t]);
|
||||||
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]);
|
|
||||||
const setColWidth = (key: ColKey, px: number) => {
|
const setColWidth = (key: ColKey, px: number) => {
|
||||||
const col = COLS.find((c) => c.key === key)!;
|
const col = COLS.find((c) => c.key === key)!;
|
||||||
const w = Math.min(COL_MAX, Math.max(col.min, Math.round(px)));
|
const w = Math.min(COL_MAX, Math.max(col.min, Math.round(px)));
|
||||||
@@ -617,6 +704,68 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
|||||||
const statusOf = (d: Decode): StatusEntry | undefined =>
|
const statusOf = (d: Decode): StatusEntry | undefined =>
|
||||||
spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`];
|
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
|
// The mode currently on the air, for the slot clock. The newest decode knows
|
||||||
// best; between overs the transmit state still does.
|
// best; between overs the transmit state still does.
|
||||||
// A decoder that has lost its CAT link keeps announcing the last dial
|
// A decoder that has lost its CAT link keeps announcing the last dial
|
||||||
@@ -645,6 +794,8 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
|||||||
// opens with our callsign, sometimes bracketed when the sender compressed a
|
// opens with our callsign, sometimes bracketed when the sender compressed a
|
||||||
// non-standard call.
|
// non-standard call.
|
||||||
const me = (myCall ?? '').toUpperCase();
|
const me = (myCall ?? '').toUpperCase();
|
||||||
|
const [, bumpUnit] = useState(0);
|
||||||
|
useEffect(() => subscribeDistanceUnit(() => bumpUnit((n) => n + 1)), []);
|
||||||
const calling = (txState?.dx_call ?? '').toUpperCase();
|
const calling = (txState?.dx_call ?? '').toUpperCase();
|
||||||
const answersMe = (msg?: string): boolean => {
|
const answersMe = (msg?: string): boolean => {
|
||||||
if (!me || !msg) return false;
|
if (!me || !msg) return false;
|
||||||
@@ -681,6 +832,12 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [decodes, spotStatus]);
|
}, [decodes, spotStatus]);
|
||||||
|
|
||||||
|
// 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.
|
// All seven, always, in their usual order.
|
||||||
//
|
//
|
||||||
// The chips used to be built from the continents ON the feed, which read as a
|
// The chips used to be built from the continents ON the feed, which read as a
|
||||||
@@ -714,6 +871,22 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
|||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [decodes, spotStatus, cqOnly, lotwOnly, cats, bandSel, modeSel, contSel, minSnr, search]);
|
}, [decodes, spotStatus, cqOnly, lotwOnly, cats, bandSel, modeSel, contSel, minSnr, search]);
|
||||||
|
// What this panel is SHOWING, published to the auto-call engine.
|
||||||
|
//
|
||||||
|
// The filters are the operator's control over the transmitter as well as
|
||||||
|
// over the list: a station filtered off the screen is not called. The panel
|
||||||
|
// sends the callsigns rather than the filter settings, so there is one
|
||||||
|
// definition of "shown" and not two — the engine cannot disagree with what
|
||||||
|
// is in front of the operator.
|
||||||
|
useEffect(() => {
|
||||||
|
const calls = [...new Set(filtered.map((d) => (d.call ?? '').toUpperCase()).filter(Boolean))];
|
||||||
|
SetAutoCallVisible(calls, true).catch(() => {});
|
||||||
|
}, [filtered]);
|
||||||
|
// Closed, it publishes nothing: filters that are not on the screen cannot
|
||||||
|
// silence the engine behind the operator's back.
|
||||||
|
useEffect(() => () => { SetAutoCallVisible([], false).catch(() => {}); }, []);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Group into periods, newest first, and drop the operator's transmissions into
|
// Group into periods, newest first, and drop the operator's transmissions into
|
||||||
// the slot they went out in.
|
// the slot they went out in.
|
||||||
@@ -724,20 +897,24 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
|||||||
// Each pane cuts its own periods: the bands differ, so the slot boundaries and
|
// 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.
|
// the transmit messages belong to one receiver and not the other.
|
||||||
const panes = useMemo(() => {
|
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) {
|
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) => ({
|
return instances.map((inst) => ({
|
||||||
key: inst,
|
key: inst,
|
||||||
// What the program is called, not the id it announces — see decoderName.
|
// What the program is called, not the id it announces — see decoderName.
|
||||||
label: decoderName(inst),
|
label: decoderName(inst),
|
||||||
tx: txStates?.[inst],
|
tx: txStates?.[inst],
|
||||||
periods: buildPeriods(
|
periods: sorted(buildPeriods(
|
||||||
filtered.filter((d) => (d.instance ?? '') === inst),
|
filtered.filter((d) => (d.instance ?? '') === inst),
|
||||||
txMsgs.filter((m) => (m.instance ?? '') === inst),
|
txMsgs.filter((m) => (m.instance ?? '') === inst),
|
||||||
),
|
)),
|
||||||
}));
|
}));
|
||||||
}, [filtered, txMsgs, splitByInstance, instances, txState, txStates]);
|
}, [filtered, txMsgs, splitByInstance, instances, txState, txStates, sortKey, sortDecodes]);
|
||||||
|
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
@@ -770,7 +947,11 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
|||||||
{/* The slot clock. Taken from the newest decode's mode, falling back to
|
{/* The slot clock. Taken from the newest decode's mode, falling back to
|
||||||
what the transmit state reports, so it is right the moment anything
|
what the transmit state reports, so it is right the moment anything
|
||||||
is heard and keeps running when the band goes quiet. */}
|
is heard and keeps running when the band goes quiet. */}
|
||||||
<PeriodClock trSec={liveTr} mode={liveMode} />
|
{/* Any receiver on the air colours it: with two decoders the shared
|
||||||
|
txState is whichever reported last, and "somebody here is
|
||||||
|
transmitting" is what the bar has to say. */}
|
||||||
|
<PeriodClock trSec={liveTr} mode={liveMode}
|
||||||
|
tx={!!txState?.transmitting || Object.values(txStates ?? {}).some((s) => s?.transmitting)} />
|
||||||
{bandDrift && (
|
{bandDrift && (
|
||||||
<span
|
<span
|
||||||
title={t('dec.bandDriftTip')}
|
title={t('dec.bandDriftTip')}
|
||||||
@@ -927,13 +1108,47 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
|||||||
>
|
>
|
||||||
<Bot className="size-3.5" />
|
<Bot className="size-3.5" />
|
||||||
{t('dec.autoCall')}
|
{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 && (
|
{autoCallOn && autoCall?.target && (
|
||||||
<span className="font-mono text-xs">
|
<span className="h-8 inline-flex items-center gap-2 rounded-lg border border-success/50 bg-success/10 px-2"
|
||||||
{autoCall.target} {autoCall.calls}/{autoCall.max}
|
title={t('dec.autoTargetTip', { call: autoCall.target })}>
|
||||||
{autoCall.misses > 0 ? ` ·${autoCall.misses}/${autoCall.max_miss}` : ''}
|
<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>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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 chase list. Raw text while typing, committed on blur or Enter:
|
||||||
the stored value is upper-cased and trimmed, and binding the box to
|
the stored value is upper-cased and trimmed, and binding the box to
|
||||||
@@ -1082,23 +1297,33 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
|||||||
<div className="shrink-0 border-b border-border bg-background overflow-hidden">
|
<div className="shrink-0 border-b border-border bg-background overflow-hidden">
|
||||||
<div className={cn(ROW, 'h-7 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground')}
|
<div className={cn(ROW, 'h-7 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground')}
|
||||||
style={{ gridTemplateColumns: template, width: tableW }}>
|
style={{ gridTemplateColumns: template, width: tableW }}>
|
||||||
{COLS.map((c, i) => (
|
{cols.map((c, i) => {
|
||||||
|
const sortable = SORTABLE.includes(c.key as SortKey);
|
||||||
|
const active = sortable && sortKey === c.key;
|
||||||
|
return (
|
||||||
<span key={c.key}
|
<span key={c.key}
|
||||||
// Not CELL_LAST for the final column: its overflow-hidden would
|
// Not CELL_LAST for the final column: its overflow-hidden would
|
||||||
// clip that column's own resize handle.
|
// clip that column's own resize handle.
|
||||||
className={cn('relative flex items-center min-w-0 px-2',
|
className={cn('relative flex items-center min-w-0 px-2',
|
||||||
i < COLS.length - 1 && 'border-r border-border/30',
|
i < cols.length - 1 && 'border-r border-border/30',
|
||||||
// The three numeric columns label their own right edge, where the
|
// The three numeric columns label their own right edge, where the
|
||||||
// figures are.
|
// figures are.
|
||||||
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq') && 'justify-end')}
|
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq' || c.key === 'dist') && 'justify-end',
|
||||||
title={c.key === 'dt' ? t('dec.colDtTitle') : c.key === 'freq' ? t('dec.colFreqTitle') : undefined}>
|
sortable && 'cursor-pointer select-none hover:text-foreground',
|
||||||
<span className="truncate">{t(c.tkey)}</span>
|
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
|
<ColResizer
|
||||||
onResize={(dx) => setColWidth(c.key, colw[c.key] + dx)}
|
onResize={(dx) => setColWidth(c.key, colw[c.key] + dx)}
|
||||||
onReset={() => setColWidth(c.key, c.def)}
|
onReset={() => setColWidth(c.key, c.def)}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1172,7 +1397,10 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
|||||||
title={t('dec.callTitle', { call: d.call })}
|
title={t('dec.callTitle', { call: d.call })}
|
||||||
style={{ gridTemplateColumns: template, width: tableW }}
|
style={{ gridTemplateColumns: template, width: tableW }}
|
||||||
className={cn(ROW, 'text-left border-b border-border/20 transition-colors',
|
className={cn(ROW, 'text-left border-b border-border/20 transition-colors',
|
||||||
replying ? 'bg-success/20 hover:bg-success/25'
|
replying ? 'bg-success/25 hover:bg-success/30 border-l-2 border-l-success'
|
||||||
|
// The station being called: a tint saying "he is on the
|
||||||
|
// air", not the QSO treatment above — most of what he
|
||||||
|
// sends is to other people.
|
||||||
: worked ? 'bg-danger/15 hover:bg-danger/20'
|
: worked ? 'bg-danger/15 hover:bg-danger/20'
|
||||||
: mine ? 'bg-info/10'
|
: mine ? 'bg-info/10'
|
||||||
: 'hover:bg-muted/50')}
|
: 'hover:bg-muted/50')}
|
||||||
@@ -1185,6 +1413,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
|||||||
{hhmmssCompact(d.at)}
|
{hhmmssCompact(d.at)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* Which receiver heard it — present only while more than one
|
||||||
|
is feeding a merged list. */}
|
||||||
|
{cols.some((c) => c.key === 'rx') && (
|
||||||
|
<span className={cn(CELL, 'text-[11px] text-muted-foreground truncate')}
|
||||||
|
title={d.instance ?? ''}>
|
||||||
|
{decoderName(d.instance)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<span className={cn(CELL, 'justify-end font-mono text-[13px] font-semibold tabular-nums', snrTone(d.snr))}>
|
<span className={cn(CELL, 'justify-end font-mono text-[13px] font-semibold tabular-nums', snrTone(d.snr))}>
|
||||||
{d.snr > 0 ? `+${d.snr}` : d.snr}
|
{d.snr > 0 ? `+${d.snr}` : d.snr}
|
||||||
</span>
|
</span>
|
||||||
@@ -1218,7 +1455,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
|||||||
{/* The message carries the callsign already — which is why
|
{/* The message carries the callsign already — which is why
|
||||||
there is no column repeating it. */}
|
there is no column repeating it. */}
|
||||||
<span className={cn(CELL, 'font-mono text-[13px]')}>
|
<span className={cn(CELL, 'font-mono text-[13px]')}>
|
||||||
<span className="truncate">{renderMsg(d.msg ?? '', me, calling)}</span>
|
<span className="truncate">{renderMsg(d.msg ?? '', me, calling, replying)}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* The decode's own grid first, the remembered one as the
|
{/* The decode's own grid first, the remembered one as the
|
||||||
@@ -1231,6 +1468,14 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
|||||||
<span className="truncate">{d.grid || e?.grid || ''}</span>
|
<span className="truncate">{d.grid || e?.grid || ''}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<span className={cn(CELL, 'justify-end font-mono text-[11px] tabular-nums text-muted-foreground/80')}>
|
||||||
|
{(() => {
|
||||||
|
const g = d.grid || e?.grid || '';
|
||||||
|
const path = myGrid && g ? pathBetween(myGrid, g) : null;
|
||||||
|
return path ? distanceValue(path.distanceShort).toLocaleString() : '';
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
|
||||||
<span className={cn(CELL, 'gap-1.5')}>
|
<span className={cn(CELL, 'gap-1.5')}>
|
||||||
{e?.state && (
|
{e?.state && (
|
||||||
<>
|
<>
|
||||||
@@ -1256,6 +1501,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
|||||||
{t('dec.wkd')}
|
{t('dec.wkd')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{/* 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) => (
|
{entities.map((b) => (
|
||||||
<span key={b.label}
|
<span key={b.label}
|
||||||
title={e?.unconf_status ? t('dec.unconfTip') : undefined}
|
title={e?.unconf_status ? t('dec.unconfTip') : undefined}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
|
import { useOperatingLists } from '@/lib/operatingLists';
|
||||||
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
|
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
|
||||||
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
import { BandSlotGrid } from '@/components/BandSlotGrid';
|
||||||
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
import { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||||
@@ -158,6 +159,7 @@ function Field({ label, span = 1, className, children }: { label: string; span?:
|
|||||||
|
|
||||||
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, modes, satellites = [], slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) {
|
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, modes, satellites = [], slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const oper = useOperatingLists(tab);
|
||||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||||
|
|
||||||
@@ -476,11 +478,21 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</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}>
|
<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>
|
||||||
<Field label={t('detp.antenna')} span={3}>
|
<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>
|
</Field>
|
||||||
{satelliteMode && (
|
{satelliteMode && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { gridToLatLon, greatCirclePoints, splitAtAntimeridian } from '@/lib/maid
|
|||||||
import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
|
import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { loadMapView, saveMapView, MAP_VIEW_FT } from '@/lib/mapView';
|
||||||
|
import { loadMapBase, saveMapBase, MAP_BASE_FT } from '@/lib/mapBase';
|
||||||
|
|
||||||
// FT Map — the live decode feed as geography: every station decoded in the
|
// 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
|
// last half hour, an arc from the operator's own square to theirs, coloured by
|
||||||
@@ -18,12 +20,16 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
// capped, and redraws happen when the DECODE LIST changes — every 15 s in FT8,
|
// capped, and redraws happen when the DECODE LIST changes — every 15 s in FT8,
|
||||||
// not per frame.
|
// 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 = {
|
export type FTMapDecode = {
|
||||||
call: string;
|
call: string;
|
||||||
grid?: string;
|
grid?: string;
|
||||||
band?: string;
|
band?: string;
|
||||||
snr: number;
|
snr: number;
|
||||||
at: string;
|
at: string;
|
||||||
|
[k: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
// The band palette every PSK Reporter user already knows, near enough.
|
// The band palette every PSK Reporter user already knows, near enough.
|
||||||
@@ -38,7 +44,26 @@ const bandColour = (b?: string) => BAND_COLOURS[(b ?? '').toLowerCase()] || '#9c
|
|||||||
const MAX_ARCS = 300;
|
const MAX_ARCS = 300;
|
||||||
const MAX_AGE_MS = 30 * 60_000;
|
const MAX_AGE_MS = 30 * 60_000;
|
||||||
|
|
||||||
export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid: string }) {
|
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 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 { t } = useI18n();
|
||||||
const divRef = useRef<HTMLDivElement>(null);
|
const divRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<L.Map | null>(null);
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
@@ -58,7 +83,15 @@ export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid
|
|||||||
// No maxBounds: with the world smaller than the window the clamp
|
// No maxBounds: with the world smaller than the window the clamp
|
||||||
// dragged every zoom into a corner. noWrap tiles alone keep one world.
|
// dragged every zoom into a corner. noWrap tiles alone keep one world.
|
||||||
worldCopyJump: false, preferCanvas: true,
|
worldCopyJump: false, preferCanvas: true,
|
||||||
center: [25, 0], zoom: 2, minZoom: 2,
|
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;
|
mapRef.current = m;
|
||||||
layerRef.current = L.layerGroup().addTo(m);
|
layerRef.current = L.layerGroup().addTo(m);
|
||||||
@@ -96,7 +129,7 @@ export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid
|
|||||||
};
|
};
|
||||||
baseRef.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
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);
|
if (bm.labelsUrl) labelsRef.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
|
||||||
localStorage.setItem('opslog.ftmapBase', basemap);
|
saveMapBase(MAP_BASE_FT, basemap);
|
||||||
}, [basemap]);
|
}, [basemap]);
|
||||||
|
|
||||||
// The arcs, redrawn when the decode list changes. Newest last so they paint
|
// The arcs, redrawn when the decode list changes. Newest last so they paint
|
||||||
@@ -132,10 +165,36 @@ export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid
|
|||||||
L.polyline(pts as L.LatLngExpression[][], {
|
L.polyline(pts as L.LatLngExpression[][], {
|
||||||
color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
|
color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
|
||||||
}).addTo(layer);
|
}).addTo(layer);
|
||||||
L.circleMarker([to.lat, to.lon], {
|
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: colour, weight: 1, fillColor: colour, fillOpacity: 0.9 * fade,
|
radius: 3, color: colour, weight: 1, fillColor: colour, fillOpacity: 0.9 * fade,
|
||||||
}).bindTooltip(`${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`, { direction: 'top' })
|
}).bindTooltip(label, { direction: 'top' }).addTo(layer);
|
||||||
.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]);
|
}, [decodes, myGrid]);
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { GridSquares } from '../../wailsjs/go/main/App';
|
|||||||
import { gridSquareBounds, gridToLatLon } from '@/lib/maidenhead';
|
import { gridSquareBounds, gridToLatLon } from '@/lib/maidenhead';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { cn } from '@/lib/utils';
|
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 { 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.
|
// GridSquareMap — every Maidenhead square in the log, drawn on a world map.
|
||||||
//
|
//
|
||||||
@@ -85,7 +87,9 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
|||||||
() => (SCOPES.some((s) => s.key === localStorage.getItem(SCOPE_KEY))
|
() => (SCOPES.some((s) => s.key === localStorage.getItem(SCOPE_KEY))
|
||||||
? (localStorage.getItem(SCOPE_KEY) as ScopeKey) : 'DIGI'));
|
? (localStorage.getItem(SCOPE_KEY) as ScopeKey) : 'DIGI'));
|
||||||
|
|
||||||
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 [confColour, setConfColour] = useState(() => localStorage.getItem(COL_CONFIRMED_KEY) ?? '');
|
||||||
const [workedColour, setWorkedColour] = useState(() => localStorage.getItem(COL_WORKED_KEY) ?? '');
|
const [workedColour, setWorkedColour] = useState(() => localStorage.getItem(COL_WORKED_KEY) ?? '');
|
||||||
// Repaint the squares when the THEME changes, not the basemap: the fills come
|
// Repaint the squares when the THEME changes, not the basemap: the fills come
|
||||||
@@ -131,8 +135,16 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
|||||||
|
|
||||||
}).setView([20, 0], 2);
|
}).setView([20, 0], 2);
|
||||||
// The whole world, once, whatever the window is shaped like — computed by
|
// The whole world, once, whatever the window is shaped like — computed by
|
||||||
// Leaflet from the container rather than guessed with a zoom number.
|
// Leaflet from the container rather than guessed with a zoom number. Unless
|
||||||
m.fitWorld({ animate: false });
|
// 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;
|
mapRef.current = m;
|
||||||
layerRef.current = L.layerGroup().addTo(m);
|
layerRef.current = L.layerGroup().addTo(m);
|
||||||
// Leaflet measures its container ONCE, when the map is created, and never
|
// Leaflet measures its container ONCE, when the map is created, and never
|
||||||
@@ -144,7 +156,7 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
|||||||
// fitWorld above ran against a container that may still have had no size —
|
// 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,
|
// 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.
|
// 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(() => {
|
const ro = new ResizeObserver(() => {
|
||||||
m.invalidateSize({ animate: false });
|
m.invalidateSize({ animate: false });
|
||||||
const el = hostRef.current;
|
const el = hostRef.current;
|
||||||
@@ -244,7 +256,7 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
|||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
value={basemap}
|
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')}
|
title={t('gsm.basemap')}
|
||||||
className="h-6 rounded border border-border bg-background px-1 text-[11px]"
|
className="h-6 rounded border border-border bg-background px-1 text-[11px]"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,17 +3,16 @@ import L from 'leaflet';
|
|||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { nightPolygon } from '../lib/greyline';
|
import { nightPolygon } from '../lib/greyline';
|
||||||
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
||||||
|
import { loadMapBase, saveMapBase, MAP_BASE_WORLD } from '@/lib/mapBase';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
import { formatDistance } from '@/lib/units';
|
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).
|
// Persisted free-pan view of the world map (when auto-zoom is off).
|
||||||
function loadMapView(): { lat: number; lon: number; zoom: number } | null {
|
const loadWorldView = () => loadMapView(MAP_VIEW_WORLD);
|
||||||
try { const v = JSON.parse(localStorage.getItem('opslog.mapView') || 'null'); return v && typeof v.zoom === 'number' ? v : null; }
|
function saveWorldView(m: L.Map) {
|
||||||
catch { return null; }
|
|
||||||
}
|
|
||||||
function saveMapView(m: L.Map) {
|
|
||||||
const c = m.getCenter();
|
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
|
// The Main tab is built from two independent map panes that the operator can
|
||||||
@@ -28,6 +27,26 @@ function saveMapView(m: L.Map) {
|
|||||||
// unwrapLon makes a lat/lon ring continuous in longitude (each point within
|
// 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
|
// 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.
|
// 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][] {
|
function unwrapLon(ring: [number, number][]): [number, number][] {
|
||||||
const out: [number, number][] = [];
|
const out: [number, number][] = [];
|
||||||
let prev = NaN;
|
let prev = NaN;
|
||||||
@@ -98,9 +117,10 @@ export const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: st
|
|||||||
attr: 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics',
|
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}' },
|
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 {
|
export function loadBasemap(): BasemapKey {
|
||||||
const v = localStorage.getItem('opslog.mapBasemap');
|
return loadMapBase(MAP_BASE_WORLD, 'light');
|
||||||
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// addBasemap (re)installs the imagery layer and, for satellite, its transparent
|
// addBasemap (re)installs the imagery layer and, for satellite, its transparent
|
||||||
@@ -178,31 +198,50 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
const autoZoomRef = useRef(autoZoom);
|
const autoZoomRef = useRef(autoZoom);
|
||||||
useEffect(() => { autoZoomRef.current = autoZoom; }, [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.
|
// One-time map creation.
|
||||||
useEffect(() => {
|
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
|
// 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
|
// ~120 thick strokes with a bidirectional Ultrabeam. As SVG that is ~120
|
||||||
// composited paths re-rasterised on every pan, zoom and redraw, which is
|
// 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
|
// enough to blow WebView2's raster budget and leave the window painting in
|
||||||
// patches. On canvas it is a single layer.
|
// patches. On canvas it is a single layer.
|
||||||
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true, preferCanvas: true })
|
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);
|
addBasemap(m, basemap, baseLayer, labelsLayer);
|
||||||
worldOverlay.current = L.layerGroup().addTo(m);
|
worldOverlay.current = L.layerGroup().addTo(m);
|
||||||
worldMap.current = m;
|
worldMap.current = m;
|
||||||
const sv = loadMapView();
|
const sv = loadWorldView();
|
||||||
if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom);
|
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);
|
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
|
||||||
|
|
||||||
// Resizing the pane is the ONLY thing that needs invalidateSize. Calling it on
|
// 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
|
// every overlay redraw (as before) forced a full repaint of the map each time
|
||||||
// the rotor moved a degree.
|
// the rotor moved a degree.
|
||||||
const ro = new ResizeObserver(() => worldMap.current?.invalidateSize());
|
const ro = new ResizeObserver(() => worldMap.current?.invalidateSize());
|
||||||
if (worldRef.current) ro.observe(worldRef.current);
|
if (worldRef.current) ro.observe(worldRef.current);
|
||||||
return () => { window.clearTimeout(t); ro.disconnect(); };
|
return () => { window.clearTimeout(t); ro.disconnect(); };
|
||||||
}, []);
|
}, [mapReady]);
|
||||||
|
|
||||||
// Swap the basemap (and its optional place-name overlay) when the operator
|
// Swap the basemap (and its optional place-name overlay) when the operator
|
||||||
// picks a different one. Vector overlays (path/beam) live in Leaflet's
|
// picks a different one. Vector overlays (path/beam) live in Leaflet's
|
||||||
@@ -408,7 +447,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
<button
|
<button
|
||||||
key={k}
|
key={k}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setBasemap(k); writeUiPref('opslog.mapBasemap', k); }}
|
onClick={() => { setBasemap(k); saveMapBase(MAP_BASE_WORLD, k); }}
|
||||||
title={`Basemap: ${BASEMAPS[k].label}`}
|
title={`Basemap: ${BASEMAPS[k].label}`}
|
||||||
className={`px-2 py-1 text-[11px] font-medium transition-colors ${
|
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'
|
basemap === k ? 'bg-primary text-primary-foreground' : 'bg-card/90 text-muted-foreground hover:bg-card'
|
||||||
@@ -427,7 +466,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
setAutoZoom(v);
|
setAutoZoom(v);
|
||||||
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
|
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
|
||||||
const m = worldMap.current;
|
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'}
|
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 ${
|
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
|
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
|
||||||
import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings, OpenExternalURL, SetOpsLogQSLReceived } from '../../wailsjs/go/main/App';
|
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 { AwardRefSelector } from '@/components/AwardRefSelector';
|
||||||
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
|
||||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
|
import { useOperatingLists } from '@/lib/operatingLists';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { flagURL } from '@/lib/flags';
|
import { flagURL } from '@/lib/flags';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
@@ -288,6 +289,9 @@ function QslViaSelect({ value, onChange }: { value?: string; onChange: (v: strin
|
|||||||
|
|
||||||
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) {
|
export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], bands, modes }: Props) {
|
||||||
const { t } = useI18n();
|
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);
|
// 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
|
// 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.
|
// imported/legacy value is never silently dropped from the dropdown.
|
||||||
@@ -542,7 +546,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
<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">
|
<DialogHeader className="flex-row items-baseline gap-2">
|
||||||
<DialogTitle>{t('qedit.title')}</DialogTitle>
|
<DialogTitle>{t('qedit.title')}</DialogTitle>
|
||||||
<span className="font-mono text-xs text-muted-foreground">#{draft.id} — {draft.callsign}</span>
|
<span className="font-mono text-xs text-muted-foreground">#{draft.id} — {draft.callsign}</span>
|
||||||
@@ -598,9 +602,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col w-20"><Label>S</Label>
|
<div className="flex flex-col w-20"><Label>S</Label>
|
||||||
<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>
|
<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}
|
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
|
||||||
title={t('qedit.fetchTitle')}>
|
title={t('qedit.fetchTitle')}>
|
||||||
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
|
||||||
@@ -978,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.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.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.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>
|
{/* The station's own rigs and antennas (Settings ▸ Operating
|
||||||
<F label={t('qedit.antenna')} span={3}><Input value={draft.my_antenna ?? ''} onChange={(e) => set('my_antenna', e.target.value)} /></F>
|
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>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
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,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 { 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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -11,6 +11,8 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
|
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
|
||||||
import { RotorCompass } from '@/components/RotorCompass';
|
import { RotorCompass } from '@/components/RotorCompass';
|
||||||
|
import { RotorCompassClassic } from '@/components/RotorCompassClassic';
|
||||||
|
import { rotorStyle, subscribeRotorStyle } from '@/lib/rotorStyle';
|
||||||
import { AmpCard } from '@/components/AmpCard';
|
import { AmpCard } from '@/components/AmpCard';
|
||||||
import { TunerCard } from '@/components/TunerCard';
|
import { TunerCard } from '@/components/TunerCard';
|
||||||
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
||||||
@@ -23,6 +25,9 @@ import {
|
|||||||
GetAmpStatuses, GetFlexState,
|
GetAmpStatuses, GetFlexState,
|
||||||
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
||||||
GetPSUStatus, GetPSUSettings, SetPSUOutput,
|
GetPSUStatus, GetPSUSettings, SetPSUOutput,
|
||||||
|
GetCATState,
|
||||||
|
GetWinkeyerStatus, WinkeyerSetSpeed, WinkeyerStop, WinkeyerConnect,
|
||||||
|
GetDVKStatus, GetDVKMessages, DVKPlay, DVKStop,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
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 = {
|
type Device = {
|
||||||
id: string; type: string; name: string; host: string;
|
id: string; type: string; name: string; host: string;
|
||||||
user?: string; pass?: string; channels?: number; labels: 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 [goto, setGoto] = useState('');
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
|
// Both compasses in the app follow the same preference (Settings → Rotator):
|
||||||
|
// one operator's choice of dial, not one per panel.
|
||||||
|
const [dial, setDial] = useState(() => rotorStyle());
|
||||||
|
useEffect(() => subscribeRotorStyle(() => setDial(rotorStyle())), []);
|
||||||
|
const Dial = dial === 'classic' ? RotorCompassClassic : RotorCompass;
|
||||||
|
|
||||||
const turn = (az: number) => {
|
const turn = (az: number) => {
|
||||||
const a = ((Math.round(az) % 360) + 360) % 360;
|
const a = ((Math.round(az) % 360) + 360) % 360;
|
||||||
@@ -145,9 +330,12 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
|||||||
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
|
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 flex gap-4 items-start">
|
<div className="p-3 flex gap-4 items-start">
|
||||||
{/* The SP/LP readout lives INSIDE RotorCompass, so every compass in the
|
{/* The compact compass is the dial alone — short and long path are the
|
||||||
app carries it rather than each caller drawing its own. */}
|
two coloured dots on it. The figures are in the status bar. */}
|
||||||
<RotorCompass
|
{/* The dial-only form is square and fills the box it is given, so its
|
||||||
|
size is set here rather than baked into the component. */}
|
||||||
|
<div className={dial === 'classic' ? 'shrink-0' : 'w-[210px] shrink-0'}>
|
||||||
|
<Dial
|
||||||
bearing={bearing ?? null}
|
bearing={bearing ?? null}
|
||||||
headings={hd.ok ? [hd.azimuth] : []}
|
headings={hd.ok ? [hd.azimuth] : []}
|
||||||
centerLat={centerLat ?? null}
|
centerLat={centerLat ?? null}
|
||||||
@@ -158,6 +346,7 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
|||||||
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
|
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
|
||||||
onGoto={(az) => turn(az)}
|
onGoto={(az) => turn(az)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex-1 min-w-0 space-y-2">
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
<div className="font-mono">
|
<div className="font-mono">
|
||||||
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
|
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
|
||||||
@@ -306,6 +495,25 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
}, [poll, pollAnt, devices.length]);
|
}, [poll, pollAnt, devices.length]);
|
||||||
|
|
||||||
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
|
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`.
|
// Reorder so `dragged` lands just before `target`.
|
||||||
const onDrop = (targetId: string) => {
|
const onDrop = (targetId: string) => {
|
||||||
const src = dragId.current; dragId.current = null;
|
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
|
// single ~430px column — they are the same cards the FlexRadio panel shows
|
||||||
// full-width, and they need that room here too.
|
// full-width, and they need that room here too.
|
||||||
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
|
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) {
|
if (rot.enabled) {
|
||||||
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pokeRotorHeading} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pokeRotorHeading} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } fro
|
|||||||
import {
|
import {
|
||||||
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
|
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
|
||||||
GetWsjtHighlight, SetWsjtHighlight, GetWsjtHighlightWorked, SetWsjtHighlightWorked, GetWsjtFollowMode, SetWsjtFollowMode,
|
GetWsjtHighlight, SetWsjtHighlight, GetWsjtHighlightWorked, SetWsjtHighlightWorked, GetWsjtFollowMode, SetWsjtFollowMode,
|
||||||
|
GetWsjtHighlightColours, SetWsjtHighlightColours,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -161,9 +162,13 @@ type Props = { onError: (msg: string) => void };
|
|||||||
export function UDPIntegrationsPanel({ onError }: Props) {
|
export function UDPIntegrationsPanel({ onError }: Props) {
|
||||||
const [highlightOn, setHighlightOn] = useState(false);
|
const [highlightOn, setHighlightOn] = useState(false);
|
||||||
const [hlWorked, setHlWorked] = 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);
|
const [followMode, setFollowMode] = useState(true);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
GetWsjtHighlight().then((v) => setHighlightOn(!!v)).catch(() => {});
|
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(() => {});
|
||||||
GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {});
|
GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {});
|
||||||
GetWsjtFollowMode().then((v) => setFollowMode(!!v)).catch(() => {});
|
GetWsjtFollowMode().then((v) => setFollowMode(!!v)).catch(() => {});
|
||||||
@@ -249,16 +254,52 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
|||||||
<span className="block text-[11px] text-muted-foreground">{t('udpp.highlightHint')}</span>
|
<span className="block text-[11px] text-muted-foreground">{t('udpp.highlightHint')}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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
|
{/* Nested under the switch above: the same feature, and meaningless
|
||||||
while that one is off. */}
|
while that one is off. */}
|
||||||
{highlightOn && (
|
{highlightOn && (
|
||||||
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl pl-6">
|
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl pl-6">
|
||||||
<Checkbox checked={hlWorked}
|
<Checkbox checked={hlWorked}
|
||||||
onCheckedChange={(c) => { setHlWorked(!!c); void SetWsjtHighlightWorked(!!c); }} />
|
onCheckedChange={(c) => { setHlWorked(!!c); void SetWsjtHighlightWorked(!!c); }} />
|
||||||
<span>
|
<span>{t('udpp.hlWorked')}</span>
|
||||||
{t('udpp.hlWorked')}
|
|
||||||
<span className="block text-[11px] text-muted-foreground">{t('udpp.hlWorkedHint')}</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl">
|
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl">
|
||||||
|
|||||||
@@ -25,13 +25,7 @@ import {
|
|||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
import type { ClusterSpot, SpotStatusEntry } from '@/components/ClusterGrid';
|
import type { ClusterSpot, SpotStatusEntry } from '@/components/ClusterGrid';
|
||||||
import { inferSpotMode, spotStatusKey } from '@/lib/spot';
|
import { inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||||
|
import { useWatchlistSpots, matchesEntry, newBadge, type WLEntry } from '@/lib/watchlistSpots';
|
||||||
interface WLEntry {
|
|
||||||
callsign: string; lastSeenStr: string; addedAt: string; spotCount: number;
|
|
||||||
isContest: boolean; notify: boolean;
|
|
||||||
isExpedition: boolean; clubLogQSOs24h: number; clubLogTotalQSOs: number;
|
|
||||||
clubLogHasOQRS: boolean; clubLogLiveStream: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
spots: ClusterSpot[];
|
spots: ClusterSpot[];
|
||||||
@@ -40,21 +34,6 @@ interface Props {
|
|||||||
onSpotClick?: (s: ClusterSpot) => void;
|
onSpotClick?: (s: ClusterSpot) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A spot is ON AIR for the badge while its last sighting is this fresh.
|
|
||||||
const ON_AIR_MS = 10 * 60 * 1000;
|
|
||||||
|
|
||||||
// Exact unless the entry carries a trailing * — the same rule the backend's
|
|
||||||
// Match applies to the live stream, mirrored so the tab and the alerts can
|
|
||||||
// never disagree about what an entry covers.
|
|
||||||
function matchesEntry(call: string, pattern: string): boolean {
|
|
||||||
const c = call.toUpperCase();
|
|
||||||
if (pattern.endsWith('*')) {
|
|
||||||
const p = pattern.slice(0, -1);
|
|
||||||
return p !== '' && c.startsWith(p);
|
|
||||||
}
|
|
||||||
return c === pattern;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: Props) {
|
export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [entries, setEntries] = useState<WLEntry[]>([]);
|
const [entries, setEntries] = useState<WLEntry[]>([]);
|
||||||
@@ -95,8 +74,6 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
|||||||
noticeTimer.current = window.setTimeout(() => { setError(''); setNotice(''); }, 4000) as unknown as number;
|
noticeTimer.current = window.setTimeout(() => { setError(''); setNotice(''); }, 4000) as unknown as number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// worked answer per "call|band|modeclass|contest" key.
|
|
||||||
const [worked, setWorked] = useState<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try { setEntries(((await WatchlistEntries()) ?? []) as any as WLEntry[]); }
|
try { setEntries(((await WatchlistEntries()) ?? []) as any as WLEntry[]); }
|
||||||
@@ -117,63 +94,11 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
|||||||
|
|
||||||
// Live spots per entry — prefix-matched, newest first, deduped per band+mode
|
// Live spots per entry — prefix-matched, newest first, deduped per band+mode
|
||||||
// (one line per slot; the freshest spot represents it).
|
// (one line per slot; the freshest spot represents it).
|
||||||
const spotsFor = useMemo(() => {
|
// Which entries are on the air, and which of their slots are still needed.
|
||||||
const map = new Map<string, ClusterSpot[]>();
|
// One definition, shared with the docked watch-list widget — the answer
|
||||||
for (const e of entries) map.set(e.callsign, []);
|
// involves a debounced query per visible slot, and two copies of it would be
|
||||||
for (const s of spots) {
|
// two bursts of the same question and two ideas of what "needed" means.
|
||||||
for (const e of entries) {
|
const { spotsFor, workedFor, settled, onAir, worked } = useWatchlistSpots(entries, spots);
|
||||||
if (matchesEntry(s.dx_call ?? '', e.callsign)) { map.get(e.callsign)!.push(s); break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const [k, list] of map) {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
map.set(k, list.filter((s) => {
|
|
||||||
const key = `${(s.band ?? '')}|${inferSpotMode(s.comment ?? '', s.freq_hz)}|${s.dx_call}`;
|
|
||||||
if (seen.has(key)) return false;
|
|
||||||
seen.add(key);
|
|
||||||
return true;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [spots, entries]);
|
|
||||||
|
|
||||||
// The worked answers, refreshed when the visible slots change. Debounced: a
|
|
||||||
// spot burst must cost one round trip, not one per spot.
|
|
||||||
const queryTimer = useRef<number | undefined>(undefined);
|
|
||||||
useEffect(() => {
|
|
||||||
if (queryTimer.current) window.clearTimeout(queryTimer.current);
|
|
||||||
queryTimer.current = window.setTimeout(async () => {
|
|
||||||
const queries: { call: string; band: string; mode: string; contest: boolean }[] = [];
|
|
||||||
const keys: string[] = [];
|
|
||||||
for (const e of entries) {
|
|
||||||
for (const s of spotsFor.get(e.callsign) ?? []) {
|
|
||||||
const mode = inferSpotMode(s.comment ?? '', s.freq_hz) || '';
|
|
||||||
queries.push({ call: s.dx_call, band: s.band ?? '', mode, contest: e.isContest });
|
|
||||||
keys.push(`${s.dx_call}|${s.band ?? ''}|${mode}|${e.isContest ? 1 : 0}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (queries.length === 0) { setWorked({}); return; }
|
|
||||||
try {
|
|
||||||
const res: boolean[] = (await WatchlistWorkedSlots(queries as any)) ?? [];
|
|
||||||
// MERGED, not replaced: replacing made every already-answered key
|
|
||||||
// momentarily unknown on each refresh, which re-hid settled lines.
|
|
||||||
setWorked((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
keys.forEach((k, i) => { next[k] = !!res[i]; });
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} catch { /* the badges just stay conservative */ }
|
|
||||||
}, 150) as unknown as number;
|
|
||||||
return () => { if (queryTimer.current) window.clearTimeout(queryTimer.current); };
|
|
||||||
}, [spotsFor, entries]);
|
|
||||||
|
|
||||||
const wkey = (e: WLEntry, s: ClusterSpot) =>
|
|
||||||
`${s.dx_call}|${s.band ?? ''}|${inferSpotMode(s.comment ?? '', s.freq_hz) || ''}|${e.isContest ? 1 : 0}`;
|
|
||||||
const workedFor = (e: WLEntry, s: ClusterSpot): boolean => worked[wkey(e, s)] ?? false;
|
|
||||||
// A spot whose verdict has not come back yet is NOT drawn. Showing it as
|
|
||||||
// Needed and withdrawing it half a second later made the list twitch on
|
|
||||||
// every burst — and nobody needs a spot 400 ms early, they need it settled.
|
|
||||||
const settled = (e: WLEntry, s: ClusterSpot): boolean => wkey(e, s) in worked;
|
|
||||||
|
|
||||||
const add = async () => {
|
const add = async () => {
|
||||||
const c = addCall.trim().toUpperCase();
|
const c = addCall.trim().toUpperCase();
|
||||||
@@ -195,11 +120,7 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
|||||||
catch (e: any) { flash(String(e?.message ?? e), true); }
|
catch (e: any) { flash(String(e?.message ?? e), true); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const isOnAir = (e: WLEntry): boolean =>
|
const isOnAir = onAir;
|
||||||
(spotsFor.get(e.callsign) ?? []).some((s) => {
|
|
||||||
const ts = Date.parse(String((s as any).received_at ?? ''));
|
|
||||||
return ts > 0 && Date.now() - ts < ON_AIR_MS;
|
|
||||||
});
|
|
||||||
|
|
||||||
const shown = entries.filter((e) => {
|
const shown = entries.filter((e) => {
|
||||||
if (family === 'normal' && e.isContest) return false;
|
if (family === 'normal' && e.isContest) return false;
|
||||||
@@ -225,14 +146,8 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
|||||||
// The cluster's own badge for a spot, read from the shared status index.
|
// The cluster's own badge for a spot, read from the shared status index.
|
||||||
const dxccBadge = (s: ClusterSpot): { label: string; color: string } | null => {
|
const dxccBadge = (s: ClusterSpot): { label: string; color: string } | null => {
|
||||||
const st = spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)];
|
const st = spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)];
|
||||||
switch (st?.status) {
|
const b = newBadge(st?.status);
|
||||||
case 'new': return { label: t('wl.newDxcc'), color: 'var(--danger)' };
|
return b ? { label: t(b.key), color: b.colour } : null;
|
||||||
case 'new-band-mode': return { label: t('clg2.newBandMode'), color: 'var(--danger)' };
|
|
||||||
case 'new-band': return { label: t('clg2.newBand'), color: 'var(--warning)' };
|
|
||||||
case 'new-mode': return { label: t('clg2.newMode'), color: 'var(--caution)' };
|
|
||||||
case 'new-slot': return { label: t('clg2.newSlot'), color: '#5AC8FA' };
|
|
||||||
default: return null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Three decimals, and no trailing zeros beyond them: 7.056 rather than
|
// Three decimals, and no trailing zeros beyond them: 7.056 rather than
|
||||||
|
|||||||
@@ -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,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';
|
||||||
@@ -9,7 +9,7 @@ import { cn } from '@/lib/utils';
|
|||||||
// can't hold a typo'd value that isn't in the list.
|
// can't hold a typo'd value that isn't in the list.
|
||||||
export function Combobox({
|
export function Combobox({
|
||||||
value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false,
|
value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false,
|
||||||
showToggle = false,
|
showToggle = false, onWheelStep,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
||||||
@@ -21,6 +21,10 @@ export function Combobox({
|
|||||||
// fields read live by other actions — e.g. RST, so a CW macro sent without
|
// fields read live by other actions — e.g. RST, so a CW macro sent without
|
||||||
// leaving the field uses the value just typed.
|
// leaving the field uses the value just typed.
|
||||||
commitOnType?: boolean;
|
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.
|
// Draw a chevron that opens the full list on click.
|
||||||
//
|
//
|
||||||
// Without it this control is indistinguishable from a plain text box: it opens
|
// Without it this control is indistinguishable from a plain text box: it opens
|
||||||
@@ -42,6 +46,21 @@ export function Combobox({
|
|||||||
// leave the list floating over the wrong row.
|
// leave the list floating over the wrong row.
|
||||||
const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 });
|
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(() => {
|
useEffect(() => {
|
||||||
function onDoc(e: MouseEvent) {
|
function onDoc(e: MouseEvent) {
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
|||||||
@@ -8,14 +8,23 @@ const DialogTrigger = DialogPrimitive.Trigger;
|
|||||||
const DialogPortal = DialogPrimitive.Portal;
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
const DialogClose = DialogPrimitive.Close;
|
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<
|
const DialogOverlay = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & { blur?: boolean }
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, blur = true, ...props }, ref) => (
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -25,10 +34,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
|||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean; hideOverlay?: boolean }
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean; hideOverlay?: boolean; overlayBlur?: boolean }
|
||||||
>(({ className, children, hideClose, hideOverlay, ...props }, ref) => (
|
>(({ className, children, hideClose, hideOverlay, overlayBlur, ...props }, ref) => (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
{!hideOverlay && <DialogOverlay />}
|
{!hideOverlay && <DialogOverlay blur={overlayBlur} />}
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
// Stored through writeUiPref like every other portable preference, so the
|
// Stored through writeUiPref like every other portable preference, so the
|
||||||
// buttons travel with data/ rather than living in one browser profile.
|
// buttons travel with data/ rather than living in one browser profile.
|
||||||
|
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPrefDebounced } from '@/lib/uiPref';
|
||||||
|
|
||||||
export type ClusterMacro = {
|
export type ClusterMacro = {
|
||||||
label: string; // what the button says
|
label: string; // what the button says
|
||||||
@@ -43,8 +43,12 @@ export function loadClusterMacros(): ClusterMacro[] {
|
|||||||
return out;
|
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 {
|
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
|
// 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' },
|
||||||
|
];
|
||||||
+200
-40
File diff suppressed because one or more lines are too long
@@ -0,0 +1,38 @@
|
|||||||
|
// Which imagery each map draws on — one choice per map.
|
||||||
|
//
|
||||||
|
// The world map and the grid-square map used to share a single key, so picking
|
||||||
|
// satellite imagery to look at grids also repainted the main map, and there was
|
||||||
|
// no way to have terrain on one and plain streets on the other. They are
|
||||||
|
// different maps answering different questions, and the imagery that suits one
|
||||||
|
// is not the imagery that suits the next.
|
||||||
|
//
|
||||||
|
// Portable (see lib/uiPref) like the remembered views in lib/mapView: a copied
|
||||||
|
// data folder brings the choices with it.
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import type { BasemapKey } from '@/components/MainMap';
|
||||||
|
|
||||||
|
// The keys in use. Named here rather than typed at each call site so a rename
|
||||||
|
// cannot silently orphan somebody's choice.
|
||||||
|
export const MAP_BASE_WORLD = 'opslog.mapBasemap';
|
||||||
|
export const MAP_BASE_GRIDS = 'opslog.gridMapBase';
|
||||||
|
export const MAP_BASE_FT = 'opslog.ftmapBase';
|
||||||
|
export const MAP_BASE_SAT = 'opslog.satMapBase';
|
||||||
|
|
||||||
|
const VALID = ['light', 'voyager', 'street', 'satellite'];
|
||||||
|
|
||||||
|
// loadMapBase reads one map's choice.
|
||||||
|
//
|
||||||
|
// inheritFrom exists for the split: the grid map's choice lived under the world
|
||||||
|
// map's key until they were separated, so an operator who had chosen imagery
|
||||||
|
// there keeps it instead of being silently reset to the default.
|
||||||
|
export function loadMapBase(key: string, fallback: BasemapKey, inheritFrom?: string): BasemapKey {
|
||||||
|
const read = (k: string) => {
|
||||||
|
const v = localStorage.getItem(k);
|
||||||
|
return v && VALID.includes(v) ? (v as BasemapKey) : null;
|
||||||
|
};
|
||||||
|
return read(key) ?? (inheritFrom ? read(inheritFrom) : null) ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveMapBase(key: string, v: BasemapKey): void {
|
||||||
|
writeUiPref(key, v);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Remembered map views — where each map was left, per map.
|
||||||
|
//
|
||||||
|
// Panning and zooming a map is an operator saying "this is the part of the world
|
||||||
|
// I work". Throwing that away on every tab switch made the maps something to
|
||||||
|
// set up again each time rather than something to glance at, and the world map
|
||||||
|
// was the only one that remembered anything.
|
||||||
|
//
|
||||||
|
// One key per map, and portable (see lib/uiPref) like the world map's own: a
|
||||||
|
// copied data folder brings the views with it.
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
|
export type MapView = { lat: number; lon: number; zoom: number };
|
||||||
|
|
||||||
|
// The keys in use. Named here rather than typed at each call site so a rename
|
||||||
|
// cannot silently orphan somebody's saved view.
|
||||||
|
export const MAP_VIEW_WORLD = 'opslog.mapView';
|
||||||
|
export const MAP_VIEW_FT = 'opslog.ftMapView';
|
||||||
|
export const MAP_VIEW_GRIDS = 'opslog.gridMapView';
|
||||||
|
|
||||||
|
export function loadMapView(key: string): MapView | null {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(localStorage.getItem(key) || 'null');
|
||||||
|
return v && typeof v.zoom === 'number' && typeof v.lat === 'number' && typeof v.lon === 'number' ? v : null;
|
||||||
|
} catch {
|
||||||
|
return null; // corrupt or unreadable → open where the map would by default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveMapView(key: string, lat: number, lon: number, zoom: number): void {
|
||||||
|
writeUiPref(key, JSON.stringify({ lat, lon, zoom }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// The station's own rigs and antennas, for the MY_RIG and MY_ANTENNA fields.
|
||||||
|
//
|
||||||
|
// They are already defined once, in Settings ▸ Operating conditions — a station
|
||||||
|
// per rig, with the antennas hanging off it. Typing them again into 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, a filter and to
|
||||||
|
// anyone reading the log later.
|
||||||
|
//
|
||||||
|
// So the two fields offer what the operator has already declared. FREE TEXT
|
||||||
|
// stays allowed: a QSO made from somebody else's station, or imported from
|
||||||
|
// another logger, carries a rig that was never in this tree and must still be
|
||||||
|
// loggable — the same rule the satellite-name field follows.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ListOperatingTree } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
export type OperatingLists = {
|
||||||
|
rigs: string[];
|
||||||
|
// Every antenna in the profile, whichever rig it belongs to.
|
||||||
|
antennas: string[];
|
||||||
|
// The antennas of ONE rig. Falls back to all of them for a rig that is not in
|
||||||
|
// the tree — an operator typing a borrowed rig's name should still be offered
|
||||||
|
// their own antennas rather than nothing.
|
||||||
|
antennasFor: (rig: string) => string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY: OperatingLists = { rigs: [], antennas: [], antennasFor: () => [] };
|
||||||
|
|
||||||
|
function build(stations: any[]): OperatingLists {
|
||||||
|
const rigs: string[] = [];
|
||||||
|
const byRig = new Map<string, string[]>();
|
||||||
|
const all = new Set<string>();
|
||||||
|
for (const st of stations ?? []) {
|
||||||
|
const name = String(st?.name ?? '').trim();
|
||||||
|
const ants = ((st?.antennas ?? []) as any[])
|
||||||
|
.map((a) => String(a?.name ?? '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (name) {
|
||||||
|
rigs.push(name);
|
||||||
|
byRig.set(name.toUpperCase(), ants);
|
||||||
|
}
|
||||||
|
for (const a of ants) all.add(a);
|
||||||
|
}
|
||||||
|
const antennas = [...all];
|
||||||
|
return {
|
||||||
|
rigs,
|
||||||
|
antennas,
|
||||||
|
antennasFor: (rig: string) => byRig.get(String(rig ?? '').trim().toUpperCase()) ?? antennas,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// useOperatingLists reads the tree when the component mounts, and again whenever
|
||||||
|
// `reloadKey` changes — pass something that moves when Preferences close, so a
|
||||||
|
// rig added there is offered without a restart.
|
||||||
|
export function useOperatingLists(reloadKey?: unknown): OperatingLists {
|
||||||
|
const [lists, setLists] = useState<OperatingLists>(EMPTY);
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
ListOperatingTree()
|
||||||
|
.then((st: any) => { if (live) setLists(build(st ?? [])); })
|
||||||
|
// An empty list simply leaves both fields as free text, which is what they
|
||||||
|
// were before they had a list at all.
|
||||||
|
.catch(() => {});
|
||||||
|
return () => { live = false; };
|
||||||
|
}, [reloadKey]);
|
||||||
|
return lists;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Which rotor dial to draw.
|
||||||
|
//
|
||||||
|
// Two compasses ship: the current one (night map, square scale, mouse pointer,
|
||||||
|
// target marker) and the original small light-map dial. Neither is more correct
|
||||||
|
// than the other — one operator reads the big map at a glance, another wants the
|
||||||
|
// compact dial that was there before — so it is a preference, not a migration.
|
||||||
|
//
|
||||||
|
// A UI preference rather than a settings-database key: it decides what a widget
|
||||||
|
// looks like, nothing is transmitted from it, and it belongs to the screen it is
|
||||||
|
// read on. It is portable all the same (see lib/uiPref), so a copied folder
|
||||||
|
// keeps the dial its owner chose.
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
|
export const KEY_ROTOR_STYLE = 'opslog.rotorStyle';
|
||||||
|
|
||||||
|
export type RotorStyle = 'modern' | 'classic';
|
||||||
|
|
||||||
|
export function rotorStyle(): RotorStyle {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(KEY_ROTOR_STYLE) === 'classic' ? 'classic' : 'modern';
|
||||||
|
} catch {
|
||||||
|
return 'modern';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same shape as the distance unit: the compasses are rendered from two call
|
||||||
|
// sites, and a preference changed in Settings has to reach both without either
|
||||||
|
// of them polling for it.
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export function setRotorStyle(style: RotorStyle): void {
|
||||||
|
writeUiPref(KEY_ROTOR_STYLE, style);
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeRotorStyle(fn: () => void): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => { listeners.delete(fn); };
|
||||||
|
}
|
||||||
@@ -36,3 +36,85 @@ export function rstOptions(mode: string, lists: RSTLists): string[] {
|
|||||||
if (l && l.length) return l;
|
if (l && l.length) return l;
|
||||||
return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20'];
|
return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stepRST moves a report one step up or down, the way an operator would say it.
|
||||||
|
//
|
||||||
|
// The dropdown beside these fields is a list of the values worth having to hand,
|
||||||
|
// not of every legal report: nobody keeps 41 dB figures in it. So the wheel
|
||||||
|
// works on the VALUE, not on the list — a digital report moves by a decibel and
|
||||||
|
// an RST by one S-unit, which is what the hand on the wheel is asking for.
|
||||||
|
//
|
||||||
|
// dir is +1 for wheel up (a better report) and -1 for down.
|
||||||
|
export function stepRST(value: string, dir: number, mode: string): string {
|
||||||
|
const v = (value || '').trim();
|
||||||
|
if (v === '') return v;
|
||||||
|
if (rstCategory(mode) === 'digital') {
|
||||||
|
// A dB report: "-12", "+05", "0". Kept in its own shape — signed and two
|
||||||
|
// digits — because that is how every decoder writes it and how the operator
|
||||||
|
// reads it back off the screen.
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
if (!Number.isFinite(n)) return v;
|
||||||
|
// The range WSJT-X itself reports in, with room either side. Beyond it the
|
||||||
|
// number stops meaning anything.
|
||||||
|
const next = Math.max(-30, Math.min(35, n + dir));
|
||||||
|
return (next < 0 ? '-' : '+') + String(Math.abs(next)).padStart(2, '0');
|
||||||
|
}
|
||||||
|
// RST/RS, and the S9+ ladder above it. What an operator counts through is
|
||||||
|
//
|
||||||
|
// … 57 58 59 59+5 59+10 59+15 59+20 …
|
||||||
|
//
|
||||||
|
// — one S-unit up to nine, then five decibels at a time, because that is how
|
||||||
|
// a strong signal is reported and how the S-meter is read (see sMeterRST).
|
||||||
|
// Stepping the S digit and leaving the "+20" where it was would have gone
|
||||||
|
// from 59+20 to 58+20, which nobody has ever said on the air.
|
||||||
|
//
|
||||||
|
// R and T do not move: they are judgements about readability and tone, and a
|
||||||
|
// wheel has no business changing them.
|
||||||
|
const m = /^(\d)(\d)(\d?)(?:\+(\d+))?$/.exec(v);
|
||||||
|
if (!m) return v;
|
||||||
|
const head = m[1];
|
||||||
|
const tone = m[3];
|
||||||
|
let sUnit = parseInt(m[2], 10);
|
||||||
|
let over = m[4] === undefined ? 0 : parseInt(m[4], 10);
|
||||||
|
if (over > 0) {
|
||||||
|
// Above S9: five at a time, and back down through +5 to a plain 59.
|
||||||
|
over = Math.max(0, Math.min(60, over + dir * 5));
|
||||||
|
} else if (sUnit >= 9 && dir > 0) {
|
||||||
|
over = 5; // 59 → 59+5
|
||||||
|
} else {
|
||||||
|
sUnit = Math.max(1, Math.min(9, sUnit + dir));
|
||||||
|
}
|
||||||
|
return head + String(sUnit) + tone + (over > 0 ? '+' + over : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// rstFitsMode says whether a report belongs to the family a mode reports in.
|
||||||
|
//
|
||||||
|
// The three families are written differently and are not interchangeable: a
|
||||||
|
// signed decibel figure ("+00"), a three-figure RST ("599"), a two-figure RS
|
||||||
|
// ("59", with an optional "+20" above S9). "+00" on SSB is not a weak report,
|
||||||
|
// it is not a report at all.
|
||||||
|
export function rstFitsMode(value: string, mode: string): boolean {
|
||||||
|
const v = (value || '').trim();
|
||||||
|
if (v === '') return false;
|
||||||
|
switch (rstCategory(mode)) {
|
||||||
|
case 'digital': return /^[+-]\d{1,2}$/.test(v);
|
||||||
|
case 'cw': return /^\d{3}(\+\d+)?$/.test(v);
|
||||||
|
default: return /^\d{2}(\+\d+)?$/.test(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertRST carries a report across the CW/phone divide, keeping the operator's
|
||||||
|
// own judgement of the signal: 57 becomes 579, 599 becomes 59.
|
||||||
|
//
|
||||||
|
// Only between those two — a decibel figure says nothing about readability and
|
||||||
|
// an RST says nothing in decibels, so there the preset is the honest answer.
|
||||||
|
// Returns '' when it cannot be done.
|
||||||
|
export function convertRST(value: string, toMode: string): string {
|
||||||
|
const v = (value || '').trim();
|
||||||
|
const to = rstCategory(toMode);
|
||||||
|
if (to === 'digital') return '';
|
||||||
|
const m = /^(\d)(\d)(\d?)((?:\+\d+)?)$/.exec(v);
|
||||||
|
if (!m) return '';
|
||||||
|
if (to === 'cw') return m[1] + m[2] + '9' + m[4]; // RS → RST, tone 9
|
||||||
|
return m[1] + m[2] + m[4]; // RST → RS, tone dropped
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ export function cleanSpotter(s: string): string {
|
|||||||
// alone instead of guessing wrong.
|
// alone instead of guessing wrong.
|
||||||
export function inferSpotMode(comment: string, freqHz: number): string {
|
export function inferSpotMode(comment: string, freqHz: number): string {
|
||||||
const c = (comment || '').toUpperCase();
|
const c = (comment || '').toUpperCase();
|
||||||
|
// SuperFox and Fox/Hound are FT8 — they are WSJT-X's DXpedition transmit modes,
|
||||||
|
// not modes of their own, and they turn up in a comment written every way an
|
||||||
|
// operator can shorten them: "super fox", SFOX, S/F, F/H.
|
||||||
|
//
|
||||||
|
// A spot commented "super fox" fell through to the band plan and came out
|
||||||
|
// DATA, and that verdict is not cosmetic: the
|
||||||
|
// band+mode status is computed from this answer, so a ZD8 on 21.071 read as a
|
||||||
|
// new DATA slot rather than the new FT8 one it is.
|
||||||
|
if (/\bSUPER\s*FOX\b|\bSFOX\b|\bS\/F\b|\bFOX\s*\/?\s*HOUND\b|\bF\/H\b/.test(c)) return 'FT8';
|
||||||
if (/\bFT8\b/.test(c)) return 'FT8';
|
if (/\bFT8\b/.test(c)) return 'FT8';
|
||||||
if (/\bFT4\b/.test(c)) return 'FT4';
|
if (/\bFT4\b/.test(c)) return 'FT4';
|
||||||
if (/\bJS8\b/.test(c)) return 'JS8';
|
if (/\bJS8\b/.test(c)) return 'JS8';
|
||||||
|
|||||||
@@ -29,10 +29,15 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
|
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
|
||||||
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
||||||
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
||||||
|
// The same, for the FT decodes map and the grid-square map: a view an
|
||||||
|
// operator set up is theirs, and it should follow the folder like the rest.
|
||||||
|
'opslog.ftMapView', 'opslog.gridMapView', 'opslog.satMapView',
|
||||||
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
||||||
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
|
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
|
||||||
|
'opslog.matrixDigiMode', // band matrix: which digital row it opens on ('' = DIGI, the group)
|
||||||
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||||
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
// One imagery choice per map — world, grid squares, FT map, satellites.
|
||||||
|
'opslog.mapBasemap', 'opslog.gridMapBase', 'opslog.ftmapBase', 'opslog.satMapBase',
|
||||||
'opslog.dateFormat', // how dates are DISPLAYED (iso / fr / us); storage stays ISO
|
'opslog.dateFormat', // how dates are DISPLAYED (iso / fr / us); storage stays ISO
|
||||||
'opslog.mapGreyline', // world map: grey line (day/night terminator) shown
|
'opslog.mapGreyline', // world map: grey line (day/night terminator) shown
|
||||||
'opslog.awardRefSort', 'opslog.awardRefSortDir', // award reference table: sort column and direction
|
'opslog.awardRefSort', 'opslog.awardRefSortDir', // award reference table: sort column and direction
|
||||||
@@ -44,6 +49,10 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.iaruRegion', // IARU region (1/2/3) — band edges and segments on the band maps
|
'opslog.iaruRegion', // IARU region (1/2/3) — band edges and segments on the band maps
|
||||||
'opslog.chasePota', // show POTA references and the NEW POTA marker on spots
|
'opslog.chasePota', // show POTA references and the NEW POTA marker on spots
|
||||||
'opslog.chaseSota', // show SOTA references on spots
|
'opslog.chaseSota', // show SOTA references on spots
|
||||||
|
// The other chase switches. In the DB as well as locally because AUTO-CALL
|
||||||
|
// reads them: what the operator does not hunt is not something to transmit
|
||||||
|
// for, and the backend cannot see localStorage.
|
||||||
|
'opslog.chasePfx', 'opslog.chaseCounty', 'opslog.chaseState', 'opslog.chaseGrids',
|
||||||
'opslog.activeTab', // last selected tab
|
'opslog.activeTab', // last selected tab
|
||||||
'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares
|
'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares
|
||||||
'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}
|
'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}
|
||||||
@@ -51,6 +60,8 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.clusterMuteWorked', // cluster/band map: no colour or badge on worked spots
|
'opslog.clusterMuteWorked', // cluster/band map: no colour or badge on worked spots
|
||||||
'opslog.clusterSlotHighlight', // cluster/band map: colour calls not worked on this band+mode
|
'opslog.clusterSlotHighlight', // cluster/band map: colour calls not worked on this band+mode
|
||||||
'opslog.bandMapWidth', // docked band map: column width (px)
|
'opslog.bandMapWidth', // docked band map: column width (px)
|
||||||
|
'opslog.satSideWidth', 'opslog.satSideShown', // Satellites tab: readout column width, and whether it is shown
|
||||||
|
'opslog.satSkyShown', // Satellites tab: the polar sky plot
|
||||||
'opslog.bandMapTabWidth', // Band map tab: shared card width (px)
|
'opslog.bandMapTabWidth', // Band map tab: shared card width (px)
|
||||||
'opslog.bandMapZoom', // band map zoom (px/kHz step) remembered per band, as one {band: index} map
|
'opslog.bandMapZoom', // band map zoom (px/kHz step) remembered per band, as one {band: index} map
|
||||||
'opslog.decodeColWidths', // FT decodes table: per-column widths (px), as one {col: px} map
|
'opslog.decodeColWidths', // FT decodes table: per-column widths (px), as one {col: px} map
|
||||||
@@ -83,6 +94,50 @@ export async function syncPortablePrefs(): Promise<void> {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeUiPrefDebounced is writeUiPref for a value that changes AS SOMEBODY
|
||||||
|
// TYPES.
|
||||||
|
//
|
||||||
|
// The local cache is written at once, because that is what the interface reads
|
||||||
|
// back and it costs nothing. The DATABASE write is held until the typing stops:
|
||||||
|
// writeUiPref crosses into Go and writes a row, and doing that per character in
|
||||||
|
// a text box is a round trip per keystroke — twenty-four boxes of cluster
|
||||||
|
// macros was exactly that, and it showed as characters appearing after the
|
||||||
|
// finger had left the key.
|
||||||
|
//
|
||||||
|
// Pending writes are flushed when the page goes away, so a value typed and
|
||||||
|
// immediately followed by a close is not lost.
|
||||||
|
const pendingPrefs = new Map<string, { value: string; timer: number }>();
|
||||||
|
|
||||||
|
export function writeUiPrefDebounced(key: string, value: string, ms = 400): void {
|
||||||
|
try { localStorage.setItem(key, value); } catch { /* quota / private mode */ }
|
||||||
|
const prev = pendingPrefs.get(key);
|
||||||
|
if (prev) window.clearTimeout(prev.timer);
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
pendingPrefs.delete(key);
|
||||||
|
SetUIPref(key, value).catch((e: any) => {
|
||||||
|
try { LogUIError('ui pref', 'could not store ' + key + ': ' + String(e?.message ?? e), ''); } catch { /* nothing left to try */ }
|
||||||
|
});
|
||||||
|
}, ms);
|
||||||
|
pendingPrefs.set(key, { value, timer });
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushUiPrefs writes every pending value immediately.
|
||||||
|
export function flushUiPrefs(): void {
|
||||||
|
for (const [key, p] of pendingPrefs) {
|
||||||
|
window.clearTimeout(p.timer);
|
||||||
|
SetUIPref(key, p.value).catch(() => { /* the local cache still holds it */ });
|
||||||
|
}
|
||||||
|
pendingPrefs.clear();
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('beforeunload', flushUiPrefs);
|
||||||
|
// Closing the app does not always fire beforeunload in a WebView; a hidden
|
||||||
|
// page is the earlier and more reliable signal.
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'hidden') flushUiPrefs();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// writeUiPref write-throughs a value to the local cache AND the portable DB.
|
// writeUiPref write-throughs a value to the local cache AND the portable DB.
|
||||||
// Use it everywhere these keys are written instead of localStorage.setItem.
|
// Use it everywhere these keys are written instead of localStorage.setItem.
|
||||||
export function writeUiPref(key: string, value: string): void {
|
export function writeUiPref(key: string, value: string): void {
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// What the watch list is HEARING, and what of it is still needed.
|
||||||
|
//
|
||||||
|
// Two places ask the same question — the Watchlist tab and the docked widget —
|
||||||
|
// and the answer involves a debounced round trip to the logbook per visible
|
||||||
|
// slot. Written twice it would be two definitions of "needed" drifting apart,
|
||||||
|
// and two bursts of the same query on every spot; written here it is one.
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { ClusterSpot } from '@/components/ClusterGrid';
|
||||||
|
import { inferSpotMode } from '@/lib/spot';
|
||||||
|
import { WatchlistWorkedSlots } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
export interface WLEntry {
|
||||||
|
callsign: string; lastSeenStr: string; addedAt: string; spotCount: number;
|
||||||
|
isContest: boolean; notify: boolean;
|
||||||
|
isExpedition: boolean; clubLogQSOs24h: number; clubLogTotalQSOs: number;
|
||||||
|
clubLogHasOQRS: boolean; clubLogLiveStream: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A spot is ON AIR while its last sighting is this fresh.
|
||||||
|
export const ON_AIR_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
// Exact unless the entry carries a trailing * — the same rule the backend's
|
||||||
|
// Match applies to the live stream, mirrored so the list and the alerts can
|
||||||
|
// never disagree about what an entry covers.
|
||||||
|
export function matchesEntry(call: string, pattern: string): boolean {
|
||||||
|
const c = call.toUpperCase();
|
||||||
|
if (pattern.endsWith('*')) {
|
||||||
|
const p = pattern.slice(0, -1);
|
||||||
|
return p !== '' && c.startsWith(p);
|
||||||
|
}
|
||||||
|
return c === pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the cluster's verdict is worth saying, and in what colour.
|
||||||
|
//
|
||||||
|
// The order of severity is the one the band map and Chase new use: a new entity
|
||||||
|
// first, then the band and mode inside it. Shared because the tab and the docked
|
||||||
|
// widget must not label the same spot differently — an operator reads one of
|
||||||
|
// them to decide whether to leave what they are doing.
|
||||||
|
export const NEW_BADGES: Record<string, { key: string; colour: string }> = {
|
||||||
|
'new': { key: 'wl.newDxcc', colour: 'var(--danger)' },
|
||||||
|
'new-band-mode': { key: 'clg2.newBandMode', colour: 'var(--danger)' },
|
||||||
|
'new-band': { key: 'clg2.newBand', colour: 'var(--warning)' },
|
||||||
|
'new-mode': { key: 'clg2.newMode', colour: 'var(--caution)' },
|
||||||
|
'new-slot': { key: 'clg2.newSlot', colour: '#5AC8FA' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function newBadge(status?: string): { key: string; colour: string } | null {
|
||||||
|
return (status && NEW_BADGES[status]) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WatchlistSpots {
|
||||||
|
// The raw verdicts, exposed for dependency arrays: it changes only when an
|
||||||
|
// answer arrives, where the closures below are new on every render.
|
||||||
|
worked: Record<string, boolean>;
|
||||||
|
// The spots each entry covers, deduplicated by band+mode.
|
||||||
|
spotsFor: Map<string, ClusterSpot[]>;
|
||||||
|
// Worked on this exact slot (and, for a contest entry, today).
|
||||||
|
workedFor: (e: WLEntry, s: ClusterSpot) => boolean;
|
||||||
|
// Whether the logbook has actually answered for this pair yet. A spot whose
|
||||||
|
// verdict has not come back is NOT drawn: showing it as needed and
|
||||||
|
// withdrawing it half a second later made the list twitch on every burst,
|
||||||
|
// and nobody needs a spot 400 ms early — they need it settled.
|
||||||
|
settled: (e: WLEntry, s: ClusterSpot) => boolean;
|
||||||
|
onAir: (e: WLEntry) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWatchlistSpots(entries: WLEntry[], spots: ClusterSpot[]): WatchlistSpots {
|
||||||
|
const [worked, setWorked] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const spotsFor = useMemo(() => {
|
||||||
|
const map = new Map<string, ClusterSpot[]>();
|
||||||
|
for (const e of entries) map.set(e.callsign, []);
|
||||||
|
for (const s of spots) {
|
||||||
|
for (const e of entries) {
|
||||||
|
if (matchesEntry(s.dx_call ?? '', e.callsign)) { map.get(e.callsign)!.push(s); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [k, list] of map) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
map.set(k, list.filter((s) => {
|
||||||
|
const key = `${(s.band ?? '')}|${inferSpotMode(s.comment ?? '', s.freq_hz)}|${s.dx_call}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [spots, entries]);
|
||||||
|
|
||||||
|
// Debounced: a spot burst must cost one round trip, not one per spot.
|
||||||
|
const queryTimer = useRef<number | undefined>(undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
if (queryTimer.current) window.clearTimeout(queryTimer.current);
|
||||||
|
queryTimer.current = window.setTimeout(async () => {
|
||||||
|
const queries: { call: string; band: string; mode: string; contest: boolean }[] = [];
|
||||||
|
const keys: string[] = [];
|
||||||
|
for (const e of entries) {
|
||||||
|
for (const s of spotsFor.get(e.callsign) ?? []) {
|
||||||
|
const mode = inferSpotMode(s.comment ?? '', s.freq_hz) || '';
|
||||||
|
queries.push({ call: s.dx_call, band: s.band ?? '', mode, contest: e.isContest });
|
||||||
|
keys.push(`${s.dx_call}|${s.band ?? ''}|${mode}|${e.isContest ? 1 : 0}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (queries.length === 0) { setWorked({}); return; }
|
||||||
|
try {
|
||||||
|
const res: boolean[] = (await WatchlistWorkedSlots(queries as any)) ?? [];
|
||||||
|
// MERGED, not replaced: replacing made every already-answered key
|
||||||
|
// momentarily unknown on each refresh, which re-hid settled lines.
|
||||||
|
setWorked((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
keys.forEach((k, i) => { next[k] = !!res[i]; });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch { /* the badges just stay conservative */ }
|
||||||
|
}, 150) as unknown as number;
|
||||||
|
return () => { if (queryTimer.current) window.clearTimeout(queryTimer.current); };
|
||||||
|
}, [spotsFor, entries]);
|
||||||
|
|
||||||
|
const wkey = (e: WLEntry, s: ClusterSpot) =>
|
||||||
|
`${s.dx_call}|${s.band ?? ''}|${inferSpotMode(s.comment ?? '', s.freq_hz) || ''}|${e.isContest ? 1 : 0}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
worked,
|
||||||
|
spotsFor,
|
||||||
|
workedFor: (e, s) => worked[wkey(e, s)] ?? false,
|
||||||
|
settled: (e, s) => wkey(e, s) in worked,
|
||||||
|
onAir: (e) => (spotsFor.get(e.callsign) ?? []).some((s) => {
|
||||||
|
const ts = Date.parse(String((s as any).received_at ?? ''));
|
||||||
|
return ts > 0 && Date.now() - ts < ON_AIR_MS;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1245,3 +1245,40 @@
|
|||||||
.leaflet-container {
|
.leaflet-container {
|
||||||
background: var(--card) !important;
|
background: var(--card) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Satellite map tooltips. Leaflet's own are a white box with a grey border —
|
||||||
|
fine on a street map, a bright rectangle on a dark one, and always the wrong
|
||||||
|
colours for whichever theme the operator chose. These follow the theme, and
|
||||||
|
are wide enough for a pass: AOS, LOS, elevation and range each on their own
|
||||||
|
line. */
|
||||||
|
.leaflet-tooltip.sat-tip {
|
||||||
|
background: var(--popover);
|
||||||
|
color: var(--popover-foreground);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 4px 16px rgb(0 0 0 / 0.35);
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip.sat-tip::before { border-top-color: var(--border); }
|
||||||
|
.sat-tip-name { font-weight: 600; font-size: 12px; margin-bottom: 0.2rem; }
|
||||||
|
.sat-tip-row { display: flex; justify-content: space-between; gap: 1.25rem; }
|
||||||
|
.sat-tip-row > span:first-child { color: var(--muted-foreground); }
|
||||||
|
.sat-tip-note { color: var(--muted-foreground); font-style: italic; }
|
||||||
|
|
||||||
|
/* The name beside a satellite that is up right now. A plain div marker and
|
||||||
|
not a Leaflet tooltip, because Leaflet keeps one tooltip per layer and the
|
||||||
|
hover detail is the one worth keeping. */
|
||||||
|
.sat-name-label {
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
/* Painted twice — a dark halo under a light glyph — because the label sits on
|
||||||
|
satellite imagery, on a street map and on a dark ocean in the same session,
|
||||||
|
and no single colour is readable on all three. */
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 0 3px #000, 0 0 3px #000, 0 1px 2px #000;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.27.12';
|
export const APP_VERSION = '0.27.20';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+53
@@ -19,6 +19,7 @@ import {pskrtgt} from '../models';
|
|||||||
import {pskr} from '../models';
|
import {pskr} from '../models';
|
||||||
import {psu} from '../models';
|
import {psu} from '../models';
|
||||||
import {spe} from '../models';
|
import {spe} from '../models';
|
||||||
|
import {sat} from '../models';
|
||||||
import {solar} from '../models';
|
import {solar} from '../models';
|
||||||
import {tunergenius} from '../models';
|
import {tunergenius} from '../models';
|
||||||
import {webpub} from '../models';
|
import {webpub} from '../models';
|
||||||
@@ -52,6 +53,8 @@ export function ActiveRadioMyRig():Promise<string>;
|
|||||||
|
|
||||||
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
||||||
|
|
||||||
|
export function AddSatelliteElements(arg1:string):Promise<number>;
|
||||||
|
|
||||||
export function AmpFanMode(arg1:string,arg2:string):Promise<void>;
|
export function AmpFanMode(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function AmpOperate(arg1:string,arg2:boolean):Promise<void>;
|
export function AmpOperate(arg1:string,arg2:boolean):Promise<void>;
|
||||||
@@ -454,6 +457,8 @@ export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
|
|||||||
|
|
||||||
export function GetChaseNew():Promise<boolean>;
|
export function GetChaseNew():Promise<boolean>;
|
||||||
|
|
||||||
|
export function GetChaseNewBands():Promise<Array<string>>;
|
||||||
|
|
||||||
export function GetChaseNewGrids():Promise<boolean>;
|
export function GetChaseNewGrids():Promise<boolean>;
|
||||||
|
|
||||||
export function GetChaseNewSpots():Promise<Array<main.ChaseNewSpot>>;
|
export function GetChaseNewSpots():Promise<Array<main.ChaseNewSpot>>;
|
||||||
@@ -578,6 +583,8 @@ export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
|||||||
|
|
||||||
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
||||||
|
|
||||||
|
export function GetRotatorTypes():Promise<Array<main.RotatorTypeInfo>>;
|
||||||
|
|
||||||
export function GetRotators():Promise<Array<main.RotatorDevice>>;
|
export function GetRotators():Promise<Array<main.RotatorDevice>>;
|
||||||
|
|
||||||
export function GetRotorPresets():Promise<Array<main.RotorPreset>>;
|
export function GetRotorPresets():Promise<Array<main.RotorPreset>>;
|
||||||
@@ -586,6 +593,30 @@ export function GetRowColors():Promise<main.RowColorSettings>;
|
|||||||
|
|
||||||
export function GetSPEStatus():Promise<spe.Status>;
|
export function GetSPEStatus():Promise<spe.Status>;
|
||||||
|
|
||||||
|
export function GetSatSettings():Promise<main.SatSettings>;
|
||||||
|
|
||||||
|
export function GetSatelliteBirds():Promise<Array<main.SatBird>>;
|
||||||
|
|
||||||
|
export function GetSatelliteGroundTrack(arg1:string,arg2:number):Promise<Array<sat.Position>>;
|
||||||
|
|
||||||
|
export function GetSatelliteNames():Promise<Array<string>>;
|
||||||
|
|
||||||
|
export function GetSatelliteNextPass(arg1:string):Promise<main.SatPassInfo>;
|
||||||
|
|
||||||
|
export function GetSatelliteObserver():Promise<Record<string, any>>;
|
||||||
|
|
||||||
|
export function GetSatellitePasses(arg1:Array<string>,arg2:number):Promise<Array<sat.Pass>>;
|
||||||
|
|
||||||
|
export function GetSatellitePositions(arg1:Array<string>):Promise<Array<sat.Position>>;
|
||||||
|
|
||||||
|
export function GetSatelliteSkyTrack(arg1:string,arg2:number):Promise<Array<main.SatSkyPoint>>;
|
||||||
|
|
||||||
|
export function GetSatelliteTLEInfo():Promise<main.SatTLEInfo>;
|
||||||
|
|
||||||
|
export function GetSatelliteTracking():Promise<main.SatTrackStatus>;
|
||||||
|
|
||||||
|
export function GetSatelliteTuning(arg1:string,arg2:number,arg3:number):Promise<main.SatTuning>;
|
||||||
|
|
||||||
export function GetScpStatus():Promise<main.ScpStatus>;
|
export function GetScpStatus():Promise<main.ScpStatus>;
|
||||||
|
|
||||||
export function GetSecretStatus():Promise<main.SecretStatus>;
|
export function GetSecretStatus():Promise<main.SecretStatus>;
|
||||||
@@ -646,6 +677,8 @@ export function GetWsjtFollowMode():Promise<boolean>;
|
|||||||
|
|
||||||
export function GetWsjtHighlight():Promise<boolean>;
|
export function GetWsjtHighlight():Promise<boolean>;
|
||||||
|
|
||||||
|
export function GetWsjtHighlightColours():Promise<main.WsjtHighlightColours>;
|
||||||
|
|
||||||
export function GetWsjtHighlightWorked():Promise<boolean>;
|
export function GetWsjtHighlightWorked():Promise<boolean>;
|
||||||
|
|
||||||
export function GetYaesuBandAntennas():Promise<Record<string, number>>;
|
export function GetYaesuBandAntennas():Promise<Record<string, number>>;
|
||||||
@@ -654,6 +687,8 @@ export function GetYaesuState():Promise<cat.YaesuTXState>;
|
|||||||
|
|
||||||
export function GridSquares(arg1:string):Promise<Array<qso.GridSquare>>;
|
export function GridSquares(arg1:string):Promise<Array<qso.GridSquare>>;
|
||||||
|
|
||||||
|
export function HaltAutoCall():Promise<void>;
|
||||||
|
|
||||||
export function HaltDecodeTx(arg1:string,arg2:boolean):Promise<void>;
|
export function HaltDecodeTx(arg1:string,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
||||||
@@ -798,6 +833,8 @@ export function ListQSOFiltered(arg1:qso.QueryFilter):Promise<Array<qso.QSO>>;
|
|||||||
|
|
||||||
export function ListRadios():Promise<Array<main.RadioListEntry>>;
|
export function ListRadios():Promise<Array<main.RadioListEntry>>;
|
||||||
|
|
||||||
|
export function ListSatelliteRotors():Promise<Array<main.SatelliteRotorChoice>>;
|
||||||
|
|
||||||
export function ListSerialPorts():Promise<Array<string>>;
|
export function ListSerialPorts():Promise<Array<string>>;
|
||||||
|
|
||||||
export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>>;
|
export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>>;
|
||||||
@@ -968,6 +1005,8 @@ export function RefreshDXpeditions():Promise<void>;
|
|||||||
|
|
||||||
export function RefreshKenwood():Promise<void>;
|
export function RefreshKenwood():Promise<void>;
|
||||||
|
|
||||||
|
export function RefreshSatelliteTLE():Promise<main.SatTLEInfo>;
|
||||||
|
|
||||||
export function RefreshSolar():Promise<void>;
|
export function RefreshSolar():Promise<void>;
|
||||||
|
|
||||||
export function RefreshYaesuPanel():Promise<void>;
|
export function RefreshYaesuPanel():Promise<void>;
|
||||||
@@ -1110,6 +1149,8 @@ export function SaveRotorPresets(arg1:Array<main.RotorPreset>):Promise<void>;
|
|||||||
|
|
||||||
export function SaveRowColors(arg1:main.RowColorSettings):Promise<void>;
|
export function SaveRowColors(arg1:main.RowColorSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveSatSettings(arg1:main.SatSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
|
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveSpotColors(arg1:main.SpotColors):Promise<void>;
|
export function SaveSpotColors(arg1:main.SpotColors):Promise<void>;
|
||||||
@@ -1158,6 +1199,8 @@ export function SetAutoCall(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function SetAutoCallOnly(arg1:string):Promise<void>;
|
export function SetAutoCallOnly(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetAutoCallVisible(arg1:Array<string>,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetCATFrequency(arg1:number):Promise<void>;
|
export function SetCATFrequency(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetCATMode(arg1:string):Promise<void>;
|
export function SetCATMode(arg1:string):Promise<void>;
|
||||||
@@ -1168,6 +1211,8 @@ export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function SetChaseNew(arg1:boolean):Promise<void>;
|
export function SetChaseNew(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetChaseNewBands(arg1:Array<string>):Promise<void>;
|
||||||
|
|
||||||
export function SetChaseNewGrids(arg1:boolean):Promise<void>;
|
export function SetChaseNewGrids(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||||
@@ -1300,6 +1345,8 @@ export function SetWsjtFollowMode(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function SetWsjtHighlight(arg1:boolean):Promise<void>;
|
export function SetWsjtHighlight(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetWsjtHighlightColours(arg1:main.WsjtHighlightColours):Promise<void>;
|
||||||
|
|
||||||
export function SetWsjtHighlightWorked(arg1:boolean):Promise<void>;
|
export function SetWsjtHighlightWorked(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetYaesuAFGain(arg1:number):Promise<void>;
|
export function SetYaesuAFGain(arg1:number):Promise<void>;
|
||||||
@@ -1344,10 +1391,14 @@ export function SetYaesuVOX(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function StartCWDecoder():Promise<void>;
|
export function StartCWDecoder():Promise<void>;
|
||||||
|
|
||||||
|
export function StartSatelliteTracking(arg1:string,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||||
|
|
||||||
export function StopCWDecoder():Promise<void>;
|
export function StopCWDecoder():Promise<void>;
|
||||||
|
|
||||||
|
export function StopSatelliteTracking():Promise<void>;
|
||||||
|
|
||||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SyncFolderNow():Promise<number>;
|
export function SyncFolderNow():Promise<number>;
|
||||||
@@ -1388,6 +1439,8 @@ export function TestQRZUpload():Promise<string>;
|
|||||||
|
|
||||||
export function TestRotatorDevice(arg1:main.RotatorDevice,arg2:number):Promise<void>;
|
export function TestRotatorDevice(arg1:main.RotatorDevice,arg2:number):Promise<void>;
|
||||||
|
|
||||||
|
export function TestSatelliteRotator():Promise<string>;
|
||||||
|
|
||||||
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
|
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
|
||||||
|
|
||||||
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ export function AddQSO(arg1) {
|
|||||||
return window['go']['main']['App']['AddQSO'](arg1);
|
return window['go']['main']['App']['AddQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AddSatelliteElements(arg1) {
|
||||||
|
return window['go']['main']['App']['AddSatelliteElements'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function AmpFanMode(arg1, arg2) {
|
export function AmpFanMode(arg1, arg2) {
|
||||||
return window['go']['main']['App']['AmpFanMode'](arg1, arg2);
|
return window['go']['main']['App']['AmpFanMode'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -842,6 +846,10 @@ export function GetChaseNew() {
|
|||||||
return window['go']['main']['App']['GetChaseNew']();
|
return window['go']['main']['App']['GetChaseNew']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetChaseNewBands() {
|
||||||
|
return window['go']['main']['App']['GetChaseNewBands']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetChaseNewGrids() {
|
export function GetChaseNewGrids() {
|
||||||
return window['go']['main']['App']['GetChaseNewGrids']();
|
return window['go']['main']['App']['GetChaseNewGrids']();
|
||||||
}
|
}
|
||||||
@@ -1090,6 +1098,10 @@ export function GetRotatorHeading() {
|
|||||||
return window['go']['main']['App']['GetRotatorHeading']();
|
return window['go']['main']['App']['GetRotatorHeading']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetRotatorTypes() {
|
||||||
|
return window['go']['main']['App']['GetRotatorTypes']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetRotators() {
|
export function GetRotators() {
|
||||||
return window['go']['main']['App']['GetRotators']();
|
return window['go']['main']['App']['GetRotators']();
|
||||||
}
|
}
|
||||||
@@ -1106,6 +1118,54 @@ export function GetSPEStatus() {
|
|||||||
return window['go']['main']['App']['GetSPEStatus']();
|
return window['go']['main']['App']['GetSPEStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetSatSettings() {
|
||||||
|
return window['go']['main']['App']['GetSatSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSatelliteBirds() {
|
||||||
|
return window['go']['main']['App']['GetSatelliteBirds']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSatelliteGroundTrack(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['GetSatelliteGroundTrack'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSatelliteNames() {
|
||||||
|
return window['go']['main']['App']['GetSatelliteNames']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSatelliteNextPass(arg1) {
|
||||||
|
return window['go']['main']['App']['GetSatelliteNextPass'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSatelliteObserver() {
|
||||||
|
return window['go']['main']['App']['GetSatelliteObserver']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSatellitePasses(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['GetSatellitePasses'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSatellitePositions(arg1) {
|
||||||
|
return window['go']['main']['App']['GetSatellitePositions'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSatelliteSkyTrack(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['GetSatelliteSkyTrack'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSatelliteTLEInfo() {
|
||||||
|
return window['go']['main']['App']['GetSatelliteTLEInfo']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSatelliteTracking() {
|
||||||
|
return window['go']['main']['App']['GetSatelliteTracking']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSatelliteTuning(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['GetSatelliteTuning'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetScpStatus() {
|
export function GetScpStatus() {
|
||||||
return window['go']['main']['App']['GetScpStatus']();
|
return window['go']['main']['App']['GetScpStatus']();
|
||||||
}
|
}
|
||||||
@@ -1226,6 +1286,10 @@ export function GetWsjtHighlight() {
|
|||||||
return window['go']['main']['App']['GetWsjtHighlight']();
|
return window['go']['main']['App']['GetWsjtHighlight']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetWsjtHighlightColours() {
|
||||||
|
return window['go']['main']['App']['GetWsjtHighlightColours']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetWsjtHighlightWorked() {
|
export function GetWsjtHighlightWorked() {
|
||||||
return window['go']['main']['App']['GetWsjtHighlightWorked']();
|
return window['go']['main']['App']['GetWsjtHighlightWorked']();
|
||||||
}
|
}
|
||||||
@@ -1242,6 +1306,10 @@ export function GridSquares(arg1) {
|
|||||||
return window['go']['main']['App']['GridSquares'](arg1);
|
return window['go']['main']['App']['GridSquares'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function HaltAutoCall() {
|
||||||
|
return window['go']['main']['App']['HaltAutoCall']();
|
||||||
|
}
|
||||||
|
|
||||||
export function HaltDecodeTx(arg1, arg2) {
|
export function HaltDecodeTx(arg1, arg2) {
|
||||||
return window['go']['main']['App']['HaltDecodeTx'](arg1, arg2);
|
return window['go']['main']['App']['HaltDecodeTx'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -1530,6 +1598,10 @@ export function ListRadios() {
|
|||||||
return window['go']['main']['App']['ListRadios']();
|
return window['go']['main']['App']['ListRadios']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListSatelliteRotors() {
|
||||||
|
return window['go']['main']['App']['ListSatelliteRotors']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ListSerialPorts() {
|
export function ListSerialPorts() {
|
||||||
return window['go']['main']['App']['ListSerialPorts']();
|
return window['go']['main']['App']['ListSerialPorts']();
|
||||||
}
|
}
|
||||||
@@ -1870,6 +1942,10 @@ export function RefreshKenwood() {
|
|||||||
return window['go']['main']['App']['RefreshKenwood']();
|
return window['go']['main']['App']['RefreshKenwood']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RefreshSatelliteTLE() {
|
||||||
|
return window['go']['main']['App']['RefreshSatelliteTLE']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RefreshSolar() {
|
export function RefreshSolar() {
|
||||||
return window['go']['main']['App']['RefreshSolar']();
|
return window['go']['main']['App']['RefreshSolar']();
|
||||||
}
|
}
|
||||||
@@ -2154,6 +2230,10 @@ export function SaveRowColors(arg1) {
|
|||||||
return window['go']['main']['App']['SaveRowColors'](arg1);
|
return window['go']['main']['App']['SaveRowColors'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveSatSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveSatSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveSelfSpotSettings(arg1) {
|
export function SaveSelfSpotSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveSelfSpotSettings'](arg1);
|
return window['go']['main']['App']['SaveSelfSpotSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2250,6 +2330,10 @@ export function SetAutoCallOnly(arg1) {
|
|||||||
return window['go']['main']['App']['SetAutoCallOnly'](arg1);
|
return window['go']['main']['App']['SetAutoCallOnly'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetAutoCallVisible(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['SetAutoCallVisible'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetCATFrequency(arg1) {
|
export function SetCATFrequency(arg1) {
|
||||||
return window['go']['main']['App']['SetCATFrequency'](arg1);
|
return window['go']['main']['App']['SetCATFrequency'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2270,6 +2354,10 @@ export function SetChaseNew(arg1) {
|
|||||||
return window['go']['main']['App']['SetChaseNew'](arg1);
|
return window['go']['main']['App']['SetChaseNew'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetChaseNewBands(arg1) {
|
||||||
|
return window['go']['main']['App']['SetChaseNewBands'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetChaseNewGrids(arg1) {
|
export function SetChaseNewGrids(arg1) {
|
||||||
return window['go']['main']['App']['SetChaseNewGrids'](arg1);
|
return window['go']['main']['App']['SetChaseNewGrids'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2534,6 +2622,10 @@ export function SetWsjtHighlight(arg1) {
|
|||||||
return window['go']['main']['App']['SetWsjtHighlight'](arg1);
|
return window['go']['main']['App']['SetWsjtHighlight'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetWsjtHighlightColours(arg1) {
|
||||||
|
return window['go']['main']['App']['SetWsjtHighlightColours'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetWsjtHighlightWorked(arg1) {
|
export function SetWsjtHighlightWorked(arg1) {
|
||||||
return window['go']['main']['App']['SetWsjtHighlightWorked'](arg1);
|
return window['go']['main']['App']['SetWsjtHighlightWorked'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2622,6 +2714,10 @@ export function StartCWDecoder() {
|
|||||||
return window['go']['main']['App']['StartCWDecoder']();
|
return window['go']['main']['App']['StartCWDecoder']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function StartSatelliteTracking(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['StartSatelliteTracking'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function StationSetRelay(arg1, arg2, arg3) {
|
export function StationSetRelay(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
|
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
@@ -2630,6 +2726,10 @@ export function StopCWDecoder() {
|
|||||||
return window['go']['main']['App']['StopCWDecoder']();
|
return window['go']['main']['App']['StopCWDecoder']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function StopSatelliteTracking() {
|
||||||
|
return window['go']['main']['App']['StopSatelliteTracking']();
|
||||||
|
}
|
||||||
|
|
||||||
export function SwitchCATRig(arg1) {
|
export function SwitchCATRig(arg1) {
|
||||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2710,6 +2810,10 @@ export function TestRotatorDevice(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['TestRotatorDevice'](arg1, arg2);
|
return window['go']['main']['App']['TestRotatorDevice'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestSatelliteRotator() {
|
||||||
|
return window['go']['main']['App']['TestSatelliteRotator']();
|
||||||
|
}
|
||||||
|
|
||||||
export function TestStationDevice(arg1) {
|
export function TestStationDevice(arg1) {
|
||||||
return window['go']['main']['App']['TestStationDevice'](arg1);
|
return window['go']['main']['App']['TestStationDevice'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1961,7 +1961,9 @@ export namespace main {
|
|||||||
watched_attempts: number;
|
watched_attempts: number;
|
||||||
misses: number;
|
misses: number;
|
||||||
max_rounds: number;
|
max_rounds: number;
|
||||||
rest_min: number;
|
rest_periods: number;
|
||||||
|
on_screen_only: boolean;
|
||||||
|
trace: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new AutoCallSettings(source);
|
return new AutoCallSettings(source);
|
||||||
@@ -1975,18 +1977,22 @@ export namespace main {
|
|||||||
this.watched_attempts = source["watched_attempts"];
|
this.watched_attempts = source["watched_attempts"];
|
||||||
this.misses = source["misses"];
|
this.misses = source["misses"];
|
||||||
this.max_rounds = source["max_rounds"];
|
this.max_rounds = source["max_rounds"];
|
||||||
this.rest_min = source["rest_min"];
|
this.rest_periods = source["rest_periods"];
|
||||||
|
this.on_screen_only = source["on_screen_only"];
|
||||||
|
this.trace = source["trace"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class AutoCallStatus {
|
export class AutoCallStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
only: string;
|
only: string;
|
||||||
target: string;
|
target: string;
|
||||||
|
waiting: string;
|
||||||
calls: number;
|
calls: number;
|
||||||
max: number;
|
max: number;
|
||||||
misses: number;
|
misses: number;
|
||||||
max_miss: number;
|
max_miss: number;
|
||||||
stopped: boolean;
|
stopped: boolean;
|
||||||
|
greylisted: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
@@ -1998,11 +2004,13 @@ export namespace main {
|
|||||||
this.enabled = source["enabled"];
|
this.enabled = source["enabled"];
|
||||||
this.only = source["only"];
|
this.only = source["only"];
|
||||||
this.target = source["target"];
|
this.target = source["target"];
|
||||||
|
this.waiting = source["waiting"];
|
||||||
this.calls = source["calls"];
|
this.calls = source["calls"];
|
||||||
this.max = source["max"];
|
this.max = source["max"];
|
||||||
this.misses = source["misses"];
|
this.misses = source["misses"];
|
||||||
this.max_miss = source["max_miss"];
|
this.max_miss = source["max_miss"];
|
||||||
this.stopped = source["stopped"];
|
this.stopped = source["stopped"];
|
||||||
|
this.greylisted = source["greylisted"];
|
||||||
this.reason = source["reason"];
|
this.reason = source["reason"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2352,6 +2360,7 @@ export namespace main {
|
|||||||
backend: string;
|
backend: string;
|
||||||
omnirig_rig: number;
|
omnirig_rig: number;
|
||||||
omnirig_vfo: string;
|
omnirig_vfo: string;
|
||||||
|
omnirig_cw_lower: boolean;
|
||||||
digi_as_usb: boolean;
|
digi_as_usb: boolean;
|
||||||
flex_host: string;
|
flex_host: string;
|
||||||
flex_port: number;
|
flex_port: number;
|
||||||
@@ -2371,6 +2380,7 @@ export namespace main {
|
|||||||
kenwood_baud: number;
|
kenwood_baud: number;
|
||||||
kenwood_data_mode: string;
|
kenwood_data_mode: string;
|
||||||
yaesu_low_lines: boolean;
|
yaesu_low_lines: boolean;
|
||||||
|
yaesu_rtty_usb: boolean;
|
||||||
kenwood_low_lines: boolean;
|
kenwood_low_lines: boolean;
|
||||||
icom_port: string;
|
icom_port: string;
|
||||||
icom_baud: number;
|
icom_baud: number;
|
||||||
@@ -2405,6 +2415,7 @@ export namespace main {
|
|||||||
this.backend = source["backend"];
|
this.backend = source["backend"];
|
||||||
this.omnirig_rig = source["omnirig_rig"];
|
this.omnirig_rig = source["omnirig_rig"];
|
||||||
this.omnirig_vfo = source["omnirig_vfo"];
|
this.omnirig_vfo = source["omnirig_vfo"];
|
||||||
|
this.omnirig_cw_lower = source["omnirig_cw_lower"];
|
||||||
this.digi_as_usb = source["digi_as_usb"];
|
this.digi_as_usb = source["digi_as_usb"];
|
||||||
this.flex_host = source["flex_host"];
|
this.flex_host = source["flex_host"];
|
||||||
this.flex_port = source["flex_port"];
|
this.flex_port = source["flex_port"];
|
||||||
@@ -2424,6 +2435,7 @@ export namespace main {
|
|||||||
this.kenwood_baud = source["kenwood_baud"];
|
this.kenwood_baud = source["kenwood_baud"];
|
||||||
this.kenwood_data_mode = source["kenwood_data_mode"];
|
this.kenwood_data_mode = source["kenwood_data_mode"];
|
||||||
this.yaesu_low_lines = source["yaesu_low_lines"];
|
this.yaesu_low_lines = source["yaesu_low_lines"];
|
||||||
|
this.yaesu_rtty_usb = source["yaesu_rtty_usb"];
|
||||||
this.kenwood_low_lines = source["kenwood_low_lines"];
|
this.kenwood_low_lines = source["kenwood_low_lines"];
|
||||||
this.icom_port = source["icom_port"];
|
this.icom_port = source["icom_port"];
|
||||||
this.icom_baud = source["icom_baud"];
|
this.icom_baud = source["icom_baud"];
|
||||||
@@ -3869,6 +3881,7 @@ export namespace main {
|
|||||||
com_port: string;
|
com_port: string;
|
||||||
baud: number;
|
baud: number;
|
||||||
spid_model?: string;
|
spid_model?: string;
|
||||||
|
max_az?: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new RotatorDevice(source);
|
return new RotatorDevice(source);
|
||||||
@@ -3891,6 +3904,7 @@ export namespace main {
|
|||||||
this.com_port = source["com_port"];
|
this.com_port = source["com_port"];
|
||||||
this.baud = source["baud"];
|
this.baud = source["baud"];
|
||||||
this.spid_model = source["spid_model"];
|
this.spid_model = source["spid_model"];
|
||||||
|
this.max_az = source["max_az"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class RotatorHeading {
|
export class RotatorHeading {
|
||||||
@@ -3898,6 +3912,8 @@ export namespace main {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
azimuth: number;
|
azimuth: number;
|
||||||
raw: string;
|
raw: string;
|
||||||
|
elevation: number;
|
||||||
|
has_elevation: boolean;
|
||||||
rotors: string[];
|
rotors: string[];
|
||||||
active: number;
|
active: number;
|
||||||
motorized: boolean;
|
motorized: boolean;
|
||||||
@@ -3912,11 +3928,39 @@ export namespace main {
|
|||||||
this.ok = source["ok"];
|
this.ok = source["ok"];
|
||||||
this.azimuth = source["azimuth"];
|
this.azimuth = source["azimuth"];
|
||||||
this.raw = source["raw"];
|
this.raw = source["raw"];
|
||||||
|
this.elevation = source["elevation"];
|
||||||
|
this.has_elevation = source["has_elevation"];
|
||||||
this.rotors = source["rotors"];
|
this.rotors = source["rotors"];
|
||||||
this.active = source["active"];
|
this.active = source["active"];
|
||||||
this.motorized = source["motorized"];
|
this.motorized = source["motorized"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class RotatorTypeInfo {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
elevation: boolean;
|
||||||
|
elevation_optional: boolean;
|
||||||
|
serial: boolean;
|
||||||
|
network: boolean;
|
||||||
|
default_port: number;
|
||||||
|
default_baud: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new RotatorTypeInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.label = source["label"];
|
||||||
|
this.elevation = source["elevation"];
|
||||||
|
this.elevation_optional = source["elevation_optional"];
|
||||||
|
this.serial = source["serial"];
|
||||||
|
this.network = source["network"];
|
||||||
|
this.default_port = source["default_port"];
|
||||||
|
this.default_baud = source["default_baud"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class RotorPreset {
|
export class RotorPreset {
|
||||||
label: string;
|
label: string;
|
||||||
azimuth: number;
|
azimuth: number;
|
||||||
@@ -3993,6 +4037,371 @@ export namespace main {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class SatTransponder {
|
||||||
|
label: string;
|
||||||
|
mode: string;
|
||||||
|
down_lo: number;
|
||||||
|
down_hi: number;
|
||||||
|
up_lo: number;
|
||||||
|
up_hi: number;
|
||||||
|
inverting: boolean;
|
||||||
|
ctcss: number;
|
||||||
|
linear: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SatTransponder(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.label = source["label"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.down_lo = source["down_lo"];
|
||||||
|
this.down_hi = source["down_hi"];
|
||||||
|
this.up_lo = source["up_lo"];
|
||||||
|
this.up_hi = source["up_hi"];
|
||||||
|
this.inverting = source["inverting"];
|
||||||
|
this.ctcss = source["ctcss"];
|
||||||
|
this.linear = source["linear"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class SatBird {
|
||||||
|
name: string;
|
||||||
|
norad: number;
|
||||||
|
geostationary: boolean;
|
||||||
|
favorite: boolean;
|
||||||
|
has_elements: boolean;
|
||||||
|
element_name: string;
|
||||||
|
epoch_age_h: number;
|
||||||
|
transponders: SatTransponder[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SatBird(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.name = source["name"];
|
||||||
|
this.norad = source["norad"];
|
||||||
|
this.geostationary = source["geostationary"];
|
||||||
|
this.favorite = source["favorite"];
|
||||||
|
this.has_elements = source["has_elements"];
|
||||||
|
this.element_name = source["element_name"];
|
||||||
|
this.epoch_age_h = source["epoch_age_h"];
|
||||||
|
this.transponders = this.convertValues(source["transponders"], SatTransponder);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class SatPassInfo {
|
||||||
|
name: string;
|
||||||
|
has_pass: boolean;
|
||||||
|
in_pass: boolean;
|
||||||
|
// Go type: time
|
||||||
|
aos: any;
|
||||||
|
// Go type: time
|
||||||
|
los: any;
|
||||||
|
aos_az: number;
|
||||||
|
los_az: number;
|
||||||
|
max_el: number;
|
||||||
|
max_el_az: number;
|
||||||
|
// Go type: time
|
||||||
|
max_el_at: any;
|
||||||
|
duration_s: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SatPassInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.name = source["name"];
|
||||||
|
this.has_pass = source["has_pass"];
|
||||||
|
this.in_pass = source["in_pass"];
|
||||||
|
this.aos = this.convertValues(source["aos"], null);
|
||||||
|
this.los = this.convertValues(source["los"], null);
|
||||||
|
this.aos_az = source["aos_az"];
|
||||||
|
this.los_az = source["los_az"];
|
||||||
|
this.max_el = source["max_el"];
|
||||||
|
this.max_el_az = source["max_el_az"];
|
||||||
|
this.max_el_at = this.convertValues(source["max_el_at"], null);
|
||||||
|
this.duration_s = source["duration_s"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class SatSettings {
|
||||||
|
favorites: string[];
|
||||||
|
min_el: number;
|
||||||
|
window_h: number;
|
||||||
|
auto_tle: boolean;
|
||||||
|
grid: string;
|
||||||
|
alt_m: number;
|
||||||
|
rot_on: boolean;
|
||||||
|
rot_id: string;
|
||||||
|
rot_az_only: boolean;
|
||||||
|
rot_min_el: number;
|
||||||
|
rot_step: number;
|
||||||
|
rot_park: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SatSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.favorites = source["favorites"];
|
||||||
|
this.min_el = source["min_el"];
|
||||||
|
this.window_h = source["window_h"];
|
||||||
|
this.auto_tle = source["auto_tle"];
|
||||||
|
this.grid = source["grid"];
|
||||||
|
this.alt_m = source["alt_m"];
|
||||||
|
this.rot_on = source["rot_on"];
|
||||||
|
this.rot_id = source["rot_id"];
|
||||||
|
this.rot_az_only = source["rot_az_only"];
|
||||||
|
this.rot_min_el = source["rot_min_el"];
|
||||||
|
this.rot_step = source["rot_step"];
|
||||||
|
this.rot_park = source["rot_park"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class SatSkyPoint {
|
||||||
|
// Go type: time
|
||||||
|
at: any;
|
||||||
|
az: number;
|
||||||
|
el: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SatSkyPoint(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.at = this.convertValues(source["at"], null);
|
||||||
|
this.az = source["az"];
|
||||||
|
this.el = source["el"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class SatTLEInfo {
|
||||||
|
count: number;
|
||||||
|
// Go type: time
|
||||||
|
fetched_at: any;
|
||||||
|
age_h: number;
|
||||||
|
stale: boolean;
|
||||||
|
custom: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SatTLEInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.count = source["count"];
|
||||||
|
this.fetched_at = this.convertValues(source["fetched_at"], null);
|
||||||
|
this.age_h = source["age_h"];
|
||||||
|
this.stale = source["stale"];
|
||||||
|
this.custom = source["custom"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class SatTrackStatus {
|
||||||
|
on: boolean;
|
||||||
|
name: string;
|
||||||
|
transponder: string;
|
||||||
|
mode: string;
|
||||||
|
nominal_down: number;
|
||||||
|
nominal_up: number;
|
||||||
|
down_hz: number;
|
||||||
|
up_hz: number;
|
||||||
|
az: number;
|
||||||
|
el: number;
|
||||||
|
visible: boolean;
|
||||||
|
radio: string;
|
||||||
|
error: string;
|
||||||
|
rot_on: boolean;
|
||||||
|
rot_az: number;
|
||||||
|
rot_el: number;
|
||||||
|
rot_live: boolean;
|
||||||
|
rot_az_only: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SatTrackStatus(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.on = source["on"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.transponder = source["transponder"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.nominal_down = source["nominal_down"];
|
||||||
|
this.nominal_up = source["nominal_up"];
|
||||||
|
this.down_hz = source["down_hz"];
|
||||||
|
this.up_hz = source["up_hz"];
|
||||||
|
this.az = source["az"];
|
||||||
|
this.el = source["el"];
|
||||||
|
this.visible = source["visible"];
|
||||||
|
this.radio = source["radio"];
|
||||||
|
this.error = source["error"];
|
||||||
|
this.rot_on = source["rot_on"];
|
||||||
|
this.rot_az = source["rot_az"];
|
||||||
|
this.rot_el = source["rot_el"];
|
||||||
|
this.rot_live = source["rot_live"];
|
||||||
|
this.rot_az_only = source["rot_az_only"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SatTuning {
|
||||||
|
name: string;
|
||||||
|
transponder: string;
|
||||||
|
mode: string;
|
||||||
|
nominal_down: number;
|
||||||
|
nominal_up: number;
|
||||||
|
down_hz: number;
|
||||||
|
up_hz: number;
|
||||||
|
ctcss: number;
|
||||||
|
inverting: boolean;
|
||||||
|
az: number;
|
||||||
|
el: number;
|
||||||
|
range_km: number;
|
||||||
|
range_rate: number;
|
||||||
|
visible: boolean;
|
||||||
|
// Go type: time
|
||||||
|
at: any;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
alt_km: number;
|
||||||
|
footprint_km: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SatTuning(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.name = source["name"];
|
||||||
|
this.transponder = source["transponder"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.nominal_down = source["nominal_down"];
|
||||||
|
this.nominal_up = source["nominal_up"];
|
||||||
|
this.down_hz = source["down_hz"];
|
||||||
|
this.up_hz = source["up_hz"];
|
||||||
|
this.ctcss = source["ctcss"];
|
||||||
|
this.inverting = source["inverting"];
|
||||||
|
this.az = source["az"];
|
||||||
|
this.el = source["el"];
|
||||||
|
this.range_km = source["range_km"];
|
||||||
|
this.range_rate = source["range_rate"];
|
||||||
|
this.visible = source["visible"];
|
||||||
|
this.at = this.convertValues(source["at"], null);
|
||||||
|
this.lat = source["lat"];
|
||||||
|
this.lon = source["lon"];
|
||||||
|
this.alt_km = source["alt_km"];
|
||||||
|
this.footprint_km = source["footprint_km"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class SatelliteRotorChoice {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
has_el: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SatelliteRotorChoice(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.key = source["key"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.type = source["type"];
|
||||||
|
this.has_el = source["has_el"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ScpStatus {
|
export class ScpStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
count: number;
|
count: number;
|
||||||
@@ -4584,6 +4993,24 @@ export namespace main {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class WsjtHighlightColours {
|
||||||
|
watchlist: string;
|
||||||
|
new_dxcc: string;
|
||||||
|
new_band: string;
|
||||||
|
worked: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new WsjtHighlightColours(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.watchlist = source["watchlist"];
|
||||||
|
this.new_dxcc = source["new_dxcc"];
|
||||||
|
this.new_band = source["new_band"];
|
||||||
|
this.worked = source["worked"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4962,6 +5389,8 @@ export namespace pskr {
|
|||||||
last_err?: string;
|
last_err?: string;
|
||||||
broker: string;
|
broker: string;
|
||||||
bands: string[];
|
bands: string[];
|
||||||
|
near_km: number;
|
||||||
|
squares: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Status(source);
|
return new Status(source);
|
||||||
@@ -4975,6 +5404,8 @@ export namespace pskr {
|
|||||||
this.last_err = source["last_err"];
|
this.last_err = source["last_err"];
|
||||||
this.broker = source["broker"];
|
this.broker = source["broker"];
|
||||||
this.bands = source["bands"];
|
this.bands = source["bands"];
|
||||||
|
this.near_km = source["near_km"];
|
||||||
|
this.squares = source["squares"];
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
@@ -6041,6 +6472,109 @@ export namespace qso {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace sat {
|
||||||
|
|
||||||
|
export class Pass {
|
||||||
|
name: string;
|
||||||
|
// Go type: time
|
||||||
|
aos: any;
|
||||||
|
// Go type: time
|
||||||
|
los: any;
|
||||||
|
aos_az: number;
|
||||||
|
los_az: number;
|
||||||
|
max_el: number;
|
||||||
|
max_el_az: number;
|
||||||
|
// Go type: time
|
||||||
|
max_el_at: any;
|
||||||
|
duration_s: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Pass(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.name = source["name"];
|
||||||
|
this.aos = this.convertValues(source["aos"], null);
|
||||||
|
this.los = this.convertValues(source["los"], null);
|
||||||
|
this.aos_az = source["aos_az"];
|
||||||
|
this.los_az = source["los_az"];
|
||||||
|
this.max_el = source["max_el"];
|
||||||
|
this.max_el_az = source["max_el_az"];
|
||||||
|
this.max_el_at = this.convertValues(source["max_el_at"], null);
|
||||||
|
this.duration_s = source["duration_s"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Position {
|
||||||
|
name: string;
|
||||||
|
// Go type: time
|
||||||
|
at: any;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
alt_km: number;
|
||||||
|
footprint_km: number;
|
||||||
|
az: number;
|
||||||
|
el: number;
|
||||||
|
range_km: number;
|
||||||
|
range_rate: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Position(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.name = source["name"];
|
||||||
|
this.at = this.convertValues(source["at"], null);
|
||||||
|
this.lat = source["lat"];
|
||||||
|
this.lon = source["lon"];
|
||||||
|
this.alt_km = source["alt_km"];
|
||||||
|
this.footprint_km = source["footprint_km"];
|
||||||
|
this.az = source["az"];
|
||||||
|
this.el = source["el"];
|
||||||
|
this.range_km = source["range_km"];
|
||||||
|
this.range_rate = source["range_rate"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace scp {
|
export namespace scp {
|
||||||
|
|
||||||
export class Result {
|
export class Result {
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ module hamlog
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/akhenakh/sgp4 v0.0.0-20260314155803-8ee03fc877eb
|
||||||
github.com/braheezy/shine-mp3 v0.1.0
|
github.com/braheezy/shine-mp3 v0.1.0
|
||||||
github.com/eclipse/paho.mqtt.golang v1.5.1
|
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||||
github.com/go-ole/go-ole v1.3.0
|
github.com/go-ole/go-ole v1.3.0
|
||||||
github.com/go-sql-driver/mysql v1.10.0
|
github.com/go-sql-driver/mysql v1.10.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/jfreymuth/pulse v0.1.3
|
||||||
github.com/jlaffaye/ftp v0.2.2
|
github.com/jlaffaye/ftp v0.2.2
|
||||||
github.com/moutend/go-wca v0.3.0
|
github.com/moutend/go-wca v0.3.0
|
||||||
github.com/wailsapp/wails/v2 v2.11.0
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
|
github.com/akhenakh/sgp4 v0.0.0-20260314155803-8ee03fc877eb h1:d9tZ7tJrssgs7Va9j8iu9vl7BlK2rmIs5RiOU7WQJrs=
|
||||||
|
github.com/akhenakh/sgp4 v0.0.0-20260314155803-8ee03fc877eb/go.mod h1:JfAepWD223Cel6uRpzYdip/xijWZ2FT457YFLWy8Md4=
|
||||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
github.com/braheezy/shine-mp3 v0.1.0 h1:N2wZhv6ipCFduTSftaPNdDgZ5xFmQAPvB7JcqA4sSi8=
|
github.com/braheezy/shine-mp3 v0.1.0 h1:N2wZhv6ipCFduTSftaPNdDgZ5xFmQAPvB7JcqA4sSi8=
|
||||||
@@ -27,6 +29,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
|||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||||
|
github.com/jfreymuth/pulse v0.1.3 h1:bc5TdxiB8E+2INnFjFWWgyfgXtz2IyNNNCX+Wt/ZD14=
|
||||||
|
github.com/jfreymuth/pulse v0.1.3/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no=
|
||||||
github.com/jlaffaye/ftp v0.2.2 h1:JwjrXCAIjN9ZYrF1/8qlmHFXDteh9MHYaiEIh/Oqtd8=
|
github.com/jlaffaye/ftp v0.2.2 h1:JwjrXCAIjN9ZYrF1/8qlmHFXDteh9MHYaiEIh/Oqtd8=
|
||||||
github.com/jlaffaye/ftp v0.2.2/go.mod h1:zuLAKdqFqFvNgkCrH0SC7K1XyUiydS7BFCmmoHUWWg0=
|
github.com/jlaffaye/ftp v0.2.2/go.mod h1:zuLAKdqFqFvNgkCrH0SC7K1XyUiydS7BFCmmoHUWWg0=
|
||||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||||
|
|||||||
+69
-5
@@ -120,12 +120,36 @@ func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (in
|
|||||||
func SingleRecordADIF(q qso.QSO) string {
|
func SingleRecordADIF(q qso.QSO) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
bw := bufio.NewWriter(&b)
|
bw := bufio.NewWriter(&b)
|
||||||
// Uploads target other services — keep it standard (no app-specific tags).
|
// Uploads target other services — keep it standard (no app-specific tags),
|
||||||
|
// and say nothing about the receive side when there is nothing to say: in
|
||||||
|
// ADIF an absent BAND_RX/FREQ_RX means "same as transmit", and a logger
|
||||||
|
// given both draws both. Wavelog reads a simplex FT8 contact uploaded with
|
||||||
|
// BAND_RX filled in as split and shows it as "17m/17m".
|
||||||
writeRecord(bw, q, false, nil)
|
writeRecord(bw, q, false, nil)
|
||||||
bw.Flush()
|
bw.Flush()
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForwardRecordADIF is the record sent to ANOTHER LOGGER on the UDP link.
|
||||||
|
//
|
||||||
|
// The receive side is written even when it repeats the transmit side, which is
|
||||||
|
// the opposite of the upload rule above and is deliberate: Log4OM reads BAND_RX
|
||||||
|
// and found nothing there for contacts logged by a path that left it blank. A
|
||||||
|
// logger on the same desk is being handed a copy of our record, not published
|
||||||
|
// to a service that will draw conclusions from every tag present.
|
||||||
|
func ForwardRecordADIF(q qso.QSO) string {
|
||||||
|
var b strings.Builder
|
||||||
|
bw := bufio.NewWriter(&b)
|
||||||
|
writeRecord(bw, q, false, nil, keepRX)
|
||||||
|
bw.Flush()
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepRX marks a record whose receive side must be written out in full.
|
||||||
|
type rxMode int
|
||||||
|
|
||||||
|
const keepRX rxMode = 1
|
||||||
|
|
||||||
// FullRecordADIF serialises one QSO LOSSLESSLY — including the APP_* extras —
|
// FullRecordADIF serialises one QSO LOSSLESSLY — including the APP_* extras —
|
||||||
// so it can be written out and read back with nothing dropped. Used by the
|
// so it can be written out and read back with nothing dropped. Used by the
|
||||||
// offline queue: a QSO parked in the safety file must come back identical
|
// offline queue: a QSO parked in the safety file must come back identical
|
||||||
@@ -161,7 +185,18 @@ func BatchRecordsADIF(records []string) string {
|
|||||||
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
||||||
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
||||||
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
|
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
|
||||||
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]bool) {
|
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]bool, rx ...rxMode) {
|
||||||
|
// The receive side, unless it merely repeats the transmit side. See
|
||||||
|
// SingleRecordADIF and ForwardRecordADIF.
|
||||||
|
bandRX, freqRX := q.BandRX, q.FreqRXHz
|
||||||
|
if len(rx) == 0 || rx[0] != keepRX {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(bandRX), strings.TrimSpace(q.Band)) {
|
||||||
|
bandRX = ""
|
||||||
|
}
|
||||||
|
if freqRX != nil && q.FreqHz != nil && *freqRX == *q.FreqHz {
|
||||||
|
freqRX = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
// allow == nil → write every promoted field (standard/full behaviour).
|
// allow == nil → write every promoted field (standard/full behaviour).
|
||||||
// Otherwise a promoted tag is written only when it's in the chosen set.
|
// Otherwise a promoted tag is written only when it's in the chosen set.
|
||||||
// w/wi/wf wrap the raw writers with that gate so the ~150 field lines below
|
// w/wi/wf wrap the raw writers with that gate so the ~150 field lines below
|
||||||
@@ -194,7 +229,7 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
|
|||||||
w("TIME_OFF", q.QSODateOff.UTC().Format("150405"))
|
w("TIME_OFF", q.QSODateOff.UTC().Format("150405"))
|
||||||
}
|
}
|
||||||
w("BAND", q.Band)
|
w("BAND", q.Band)
|
||||||
w("BAND_RX", q.BandRX)
|
w("BAND_RX", bandRX)
|
||||||
|
|
||||||
mode, submode := modeForExport(q.Mode, q.Submode)
|
mode, submode := modeForExport(q.Mode, q.Submode)
|
||||||
w("MODE", mode)
|
w("MODE", mode)
|
||||||
@@ -203,8 +238,8 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
|
|||||||
if q.FreqHz != nil && *q.FreqHz > 0 {
|
if q.FreqHz != nil && *q.FreqHz > 0 {
|
||||||
w("FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
|
w("FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
|
||||||
}
|
}
|
||||||
if q.FreqRXHz != nil && *q.FreqRXHz > 0 {
|
if freqRX != nil && *freqRX > 0 {
|
||||||
w("FREQ_RX", strconv.FormatFloat(float64(*q.FreqRXHz)/1_000_000, 'f', 6, 64))
|
w("FREQ_RX", strconv.FormatFloat(float64(*freqRX)/1_000_000, 'f', 6, 64))
|
||||||
}
|
}
|
||||||
|
|
||||||
w("RST_SENT", q.RSTSent)
|
w("RST_SENT", q.RSTSent)
|
||||||
@@ -372,12 +407,41 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
|
|||||||
// length is the byte count (ADIF spec), which matches len(v) in Go since v is
|
// length is the byte count (ADIF spec), which matches len(v) in Go since v is
|
||||||
// already a UTF-8 byte string.
|
// already a UTF-8 byte string.
|
||||||
func writeField(bw *bufio.Writer, tag, v string) {
|
func writeField(bw *bufio.Writer, tag, v string) {
|
||||||
|
v = oneLine(v)
|
||||||
if v == "" {
|
if v == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Fprintf(bw, "<%s:%d>%s ", tag, len(v), v)
|
fmt.Fprintf(bw, "<%s:%d>%s ", tag, len(v), v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// oneLine flattens a value onto a single line.
|
||||||
|
//
|
||||||
|
// ADIF counts bytes, so a value carrying line breaks is still read correctly —
|
||||||
|
// and it turns the file into something nobody can read. ADDRESS is a multi-line
|
||||||
|
// field by the standard, and callbooks and other loggers fill it that way: a
|
||||||
|
// value of "Kabul" followed by four blank lines and "Afghanistan" came out of
|
||||||
|
// OpsLog as one record spread down a dozen lines, with the next record
|
||||||
|
// apparently starting in the middle of the page.
|
||||||
|
//
|
||||||
|
// The breaks are dropped rather than escaped: the parts are trimmed and joined
|
||||||
|
// with a comma, which is how an address reads on one line anyway, and empty
|
||||||
|
// fragments go. The length prefix is computed after this, so the record stays
|
||||||
|
// exact.
|
||||||
|
func oneLine(v string) string {
|
||||||
|
if !strings.ContainsAny(v, "\r\n\t") {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
parts := strings.FieldsFunc(v, func(r rune) bool { return r == '\r' || r == '\n' })
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(strings.ReplaceAll(part, "\t", " "))
|
||||||
|
if part != "" {
|
||||||
|
out = append(out, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(out, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
func writeIntPtr(bw *bufio.Writer, tag string, p *int) {
|
func writeIntPtr(bw *bufio.Writer, tag string, p *int) {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package adif
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// An exported record has to fit on its own line. ADDRESS is a multi-line field
|
||||||
|
// by the standard and callbooks fill it that way, so an OpsLog export was one
|
||||||
|
// record spread down a dozen lines with the next apparently starting in the
|
||||||
|
// middle of the page.
|
||||||
|
func TestAMultiLineValueIsWrittenOnOneLine(t *testing.T) {
|
||||||
|
var b strings.Builder
|
||||||
|
bw := bufio.NewWriter(&b)
|
||||||
|
writeField(bw, "ADDRESS", "Kabul\r\n\r\n\r\n\r\nAfghanistan\r\n")
|
||||||
|
bw.Flush()
|
||||||
|
|
||||||
|
got := b.String()
|
||||||
|
if strings.ContainsAny(got, "\r\n") {
|
||||||
|
t.Fatalf("the record still breaks across lines: %q", got)
|
||||||
|
}
|
||||||
|
if want := "<ADDRESS:18>Kabul, Afghanistan "; got != want {
|
||||||
|
t.Errorf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the whole record, the way an operator reads the file.
|
||||||
|
func TestARecordIsOneLine(t *testing.T) {
|
||||||
|
hz := int64(28555000)
|
||||||
|
rec := SingleRecordADIF(qso.QSO{
|
||||||
|
Callsign: "T6T", Band: "10m", Mode: "SSB", FreqHz: &hz,
|
||||||
|
Address: "Kabul\n\n\nAfghanistan", Name: "Shuravi\t(Vyacheslav)",
|
||||||
|
})
|
||||||
|
if n := strings.Count(strings.TrimRight(rec, "\r\n"), "\n"); n != 0 {
|
||||||
|
t.Errorf("the record spans %d extra lines:\n%s", n, rec)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec, "Kabul, Afghanistan") {
|
||||||
|
t.Errorf("the address lost its parts:\n%s", rec)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec, "Shuravi (Vyacheslav)") {
|
||||||
|
t.Errorf("a tab was left in the value:\n%s", rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
// Device is one audio endpoint (a capture input or a render output).
|
||||||
|
//
|
||||||
|
// ID is whatever the platform calls the endpoint and is PERSISTED in settings:
|
||||||
|
// a WASAPI endpoint id on Windows, a PulseAudio source/sink name on Linux.
|
||||||
|
// It is opaque to everything above this package, which only ever hands it back.
|
||||||
|
type Device struct {
|
||||||
|
ID string `json:"id"` // opaque platform endpoint id (persisted)
|
||||||
|
Name string `json:"name"` // friendly name shown in dropdowns
|
||||||
|
Default bool `json:"default"` // is this the system default endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceName resolves an endpoint id to its friendly name.
|
||||||
|
//
|
||||||
|
// Diagnostics quote the id that was CONFIGURED, which is a GUID — an operator
|
||||||
|
// told "no audio at all from {0.0.1.00000000}.{6a27abfd…}" learns nothing they
|
||||||
|
// can act on, while "no audio at all from DAX RX 1 (FlexRadio DAX)" points
|
||||||
|
// straight at the DAX panel.
|
||||||
|
//
|
||||||
|
// Falls back to the id when the endpoint cannot be found, which is itself worth
|
||||||
|
// seeing: a device that has disappeared explains an empty recording too.
|
||||||
|
func DeviceName(id string) string {
|
||||||
|
if id == "" {
|
||||||
|
return "(none)"
|
||||||
|
}
|
||||||
|
for _, list := range []func() ([]Device, error){ListInputDevices, ListOutputDevices} {
|
||||||
|
devs, err := list()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, d := range devs {
|
||||||
|
if d.ID == id {
|
||||||
|
return d.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
@@ -15,13 +15,6 @@ import (
|
|||||||
"github.com/moutend/go-wca/pkg/wca"
|
"github.com/moutend/go-wca/pkg/wca"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Device is one audio endpoint (a capture input or a render output).
|
|
||||||
type Device struct {
|
|
||||||
ID string `json:"id"` // stable WASAPI endpoint id (persisted)
|
|
||||||
Name string `json:"name"` // friendly name shown in dropdowns
|
|
||||||
Default bool `json:"default"` // is this the system default endpoint
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListInputDevices returns the active capture endpoints — microphones,
|
// ListInputDevices returns the active capture endpoints — microphones,
|
||||||
// line-in, and the soundcard input wired to the rig's audio out ("From Radio").
|
// line-in, and the soundcard input wired to the rig's audio out ("From Radio").
|
||||||
func ListInputDevices() ([]Device, error) { return listEndpoints(wca.ECapture) }
|
func ListInputDevices() ([]Device, error) { return listEndpoints(wca.ECapture) }
|
||||||
@@ -101,30 +94,3 @@ func endpointName(dev *wca.IMMDevice, fallback string) string {
|
|||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeviceName resolves an endpoint id to its friendly name.
|
|
||||||
//
|
|
||||||
// Diagnostics quote the id that was CONFIGURED, which is a GUID — an operator
|
|
||||||
// told "no audio at all from {0.0.1.00000000}.{6a27abfd…}" learns nothing they
|
|
||||||
// can act on, while "no audio at all from DAX RX 1 (FlexRadio DAX)" points
|
|
||||||
// straight at the DAX panel.
|
|
||||||
//
|
|
||||||
// Falls back to the id when the endpoint cannot be found, which is itself worth
|
|
||||||
// seeing: a device that has disappeared explains an empty recording too.
|
|
||||||
func DeviceName(id string) string {
|
|
||||||
if id == "" {
|
|
||||||
return "(none)"
|
|
||||||
}
|
|
||||||
for _, list := range []func() ([]Device, error){ListInputDevices, ListOutputDevices} {
|
|
||||||
devs, err := list()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, d := range devs {
|
|
||||||
if d.ID == id {
|
|
||||||
return d.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package audio
|
||||||
|
|
||||||
|
// devices_linux.go — audio endpoints on Linux, through PulseAudio.
|
||||||
|
//
|
||||||
|
// PulseAudio and not ALSA, for two reasons that both matter here. ALSA's C
|
||||||
|
// library needs cgo, and OpsLog is a pure-Go build; and PulseAudio is the API
|
||||||
|
// that is actually present on a ham's desktop — PipeWire, which most current
|
||||||
|
// distributions ship, answers the PulseAudio protocol through pipewire-pulse,
|
||||||
|
// so one client speaks to both. github.com/jfreymuth/pulse implements that
|
||||||
|
// protocol in Go over the server's Unix socket, so nothing is linked in.
|
||||||
|
//
|
||||||
|
// The endpoint id we persist is the sink/source NAME
|
||||||
|
// ("alsa_input.usb-Icom_Inc._IC-7610-00.analog-stereo"), never the numeric
|
||||||
|
// index: the index is assigned at boot in device-arrival order and moves the
|
||||||
|
// moment a rig is plugged in before a headset.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jfreymuth/pulse"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pulseClient opens a short-lived connection to the local sound server. Each
|
||||||
|
// call gets its own: the connection is a Unix socket to a server that may be
|
||||||
|
// restarted underneath us (a PipeWire update, a user logging the session out
|
||||||
|
// and in), and holding one open for the lifetime of the app means every later
|
||||||
|
// call fails until OpsLog itself restarts.
|
||||||
|
func pulseClient() (*pulse.Client, error) {
|
||||||
|
c, err := pulse.NewClient(pulse.ClientApplicationName("OpsLog"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot reach the sound server (is PulseAudio or PipeWire running?): %w", err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInputDevices returns the capture sources.
|
||||||
|
//
|
||||||
|
// Monitor sources (".monitor", what a given output is playing) are kept rather
|
||||||
|
// than filtered out. They look like clutter until you meet the operator whose
|
||||||
|
// rig audio reaches OpsLog through a virtual cable — on Linux that is a
|
||||||
|
// null-sink and its monitor, and hiding it would hide the only device that
|
||||||
|
// works for them.
|
||||||
|
func ListInputDevices() ([]Device, error) {
|
||||||
|
c, err := pulseClient()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
srcs, err := c.ListSources()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defID := ""
|
||||||
|
if d, err := c.DefaultSource(); err == nil && d != nil {
|
||||||
|
defID = d.ID()
|
||||||
|
}
|
||||||
|
out := make([]Device, 0, len(srcs))
|
||||||
|
for _, s := range srcs {
|
||||||
|
out = append(out, Device{ID: s.ID(), Name: endpointLabel(s.Name(), s.ID()), Default: s.ID() == defID})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOutputDevices returns the render sinks.
|
||||||
|
func ListOutputDevices() ([]Device, error) {
|
||||||
|
c, err := pulseClient()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
sinks, err := c.ListSinks()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defID := ""
|
||||||
|
if d, err := c.DefaultSink(); err == nil && d != nil {
|
||||||
|
defID = d.ID()
|
||||||
|
}
|
||||||
|
out := make([]Device, 0, len(sinks))
|
||||||
|
for _, s := range sinks {
|
||||||
|
out = append(out, Device{ID: s.ID(), Name: endpointLabel(s.Name(), s.ID()), Default: s.ID() == defID})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// endpointLabel prefers the server's human description ("USB Audio CODEC
|
||||||
|
// Analog Stereo") and falls back to the raw name, which is ugly but still
|
||||||
|
// identifies the device — an empty entry in the dropdown identifies nothing.
|
||||||
|
func endpointLabel(desc, id string) string {
|
||||||
|
if d := strings.TrimSpace(desc); d != "" {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ package audio
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
@@ -281,63 +280,6 @@ func playPCM(deviceID string, pcm []byte, rate, ch, bits int, stop <-chan struct
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// pcmRing is a thread-safe, latency-bounded FIFO of PCM bytes feeding a live
|
|
||||||
// render stream. Producers (a USB-codec capture, or a decoded network audio
|
|
||||||
// stream) Push freshly-arrived samples; the render loop Pulls. It is the shared
|
|
||||||
// hand-off point between "where the audio comes from" (USB device / UDP 50003)
|
|
||||||
// and "where it's heard" (any WASAPI output) — so the transport can be swapped
|
|
||||||
// without touching the render side, mirroring the civTransport split on the CAT
|
|
||||||
// side. On overflow the oldest audio is dropped to keep latency bounded; on
|
|
||||||
// underrun Pull simply returns short and the render loop pads with silence.
|
|
||||||
type pcmRing struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
buf []byte
|
|
||||||
max int // hard cap in bytes (drops oldest beyond this → bounded latency)
|
|
||||||
}
|
|
||||||
|
|
||||||
// newPCMRing makes a ring whose backlog is capped at maxBytes. Size it from the
|
|
||||||
// acceptable latency: bytesPerSec (=32000) worth ≈ 1 s.
|
|
||||||
func newPCMRing(maxBytes int) *pcmRing {
|
|
||||||
if maxBytes <= 0 {
|
|
||||||
maxBytes = bytesPerSec // 1 s default
|
|
||||||
}
|
|
||||||
return &pcmRing{max: maxBytes}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push appends samples, dropping the oldest audio if the backlog would exceed
|
|
||||||
// the cap (a slow/absent consumer never makes the producer block or grow without
|
|
||||||
// bound). A short glitch beats runaway latency for live monitoring.
|
|
||||||
func (r *pcmRing) Push(p []byte) {
|
|
||||||
if len(p) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r.mu.Lock()
|
|
||||||
r.buf = append(r.buf, p...)
|
|
||||||
if len(r.buf) > r.max {
|
|
||||||
drop := len(r.buf) - r.max
|
|
||||||
r.buf = append(r.buf[:0], r.buf[drop:]...)
|
|
||||||
}
|
|
||||||
r.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// pull removes and returns up to maxBytes of queued PCM (a private copy), or nil
|
|
||||||
// when empty. The render loop pads any shortfall with silence.
|
|
||||||
func (r *pcmRing) pull(maxBytes int) []byte {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
if len(r.buf) == 0 || maxBytes <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
n := maxBytes
|
|
||||||
if n > len(r.buf) {
|
|
||||||
n = len(r.buf)
|
|
||||||
}
|
|
||||||
out := make([]byte, n)
|
|
||||||
copy(out, r.buf[:n])
|
|
||||||
r.buf = append(r.buf[:0], r.buf[n:]...)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderStream continuously renders PCM pulled from src to a device until stop
|
// renderStream continuously renders PCM pulled from src to a device until stop
|
||||||
// closes — the streaming counterpart to playPCM's fixed buffer. On underrun it
|
// closes — the streaming counterpart to playPCM's fixed buffer. On underrun it
|
||||||
// writes silence rather than glitching, keeping the WASAPI clock steady so live
|
// writes silence rather than glitching, keeping the WASAPI clock steady so live
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package audio
|
||||||
|
|
||||||
|
// engine_linux.go — the four calls the rest of the package makes into the sound
|
||||||
|
// card, implemented on PulseAudio. The Windows half of this pair is engine.go
|
||||||
|
// (WASAPI); nothing above these functions knows which one it is talking to.
|
||||||
|
//
|
||||||
|
// Capture is fixed at 16 kHz mono 16-bit, the format the DVK, the recorder and
|
||||||
|
// the CW tap all share (see wav.go). We ask the server for it and let the
|
||||||
|
// server resample from whatever the device really runs at — the same division
|
||||||
|
// of labour as WASAPI's AUTOCONVERTPCM, and for the same reason: a rig codec
|
||||||
|
// that only does 48 kHz must still feed a 16 kHz pipeline, and the sound
|
||||||
|
// server's converter filters before it decimates, where a naive one folds the
|
||||||
|
// receiver hiss above 8 kHz straight back on top of the voice.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jfreymuth/pulse"
|
||||||
|
"github.com/jfreymuth/pulse/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// chunkFrames is how much audio a playback reader hands over at once (20 ms).
|
||||||
|
// It bounds how much silence a padded underrun can queue ahead of real audio,
|
||||||
|
// which is what keeps live monitoring from drifting seconds behind the rig.
|
||||||
|
const chunkFrames = sampleRate / 50
|
||||||
|
|
||||||
|
// channelMap describes n channels to the server. Only mono and stereo occur
|
||||||
|
// here — capture is always mono, and playback follows the WAV being played.
|
||||||
|
func channelMap(n int) proto.ChannelMap {
|
||||||
|
if n >= 2 {
|
||||||
|
return proto.ChannelMap{proto.ChannelLeft, proto.ChannelRight}
|
||||||
|
}
|
||||||
|
return proto.ChannelMap{proto.ChannelMono}
|
||||||
|
}
|
||||||
|
|
||||||
|
// chunkWriter turns the record stream's byte deliveries into onChunk calls.
|
||||||
|
// The server reuses its buffer between deliveries, so every chunk is copied
|
||||||
|
// before it leaves: the recorder keeps the slices it is given.
|
||||||
|
type chunkWriter struct{ onChunk func([]byte) }
|
||||||
|
|
||||||
|
func (w chunkWriter) Write(p []byte) (int, error) {
|
||||||
|
if len(p) > 0 && w.onChunk != nil {
|
||||||
|
cp := make([]byte, len(p))
|
||||||
|
copy(cp, p)
|
||||||
|
w.onChunk(cp)
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordPCM captures from a device into 16 kHz mono 16-bit PCM bytes until the
|
||||||
|
// stop channel is closed.
|
||||||
|
func recordPCM(deviceID string, stop <-chan struct{}) ([]byte, error) {
|
||||||
|
out := make([]byte, 0, bytesPerSec*4)
|
||||||
|
err := captureStream(deviceID, stop, func(chunk []byte) { out = append(out, chunk...) })
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureStream opens a device and calls onChunk with freshly-captured 16 kHz
|
||||||
|
// mono 16-bit PCM as it arrives, until stop closes. onChunk receives a private
|
||||||
|
// copy it may retain.
|
||||||
|
func captureStream(deviceID string, stop <-chan struct{}, onChunk func([]byte)) error {
|
||||||
|
c, err := pulseClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
// Rate and channels first, then latency — the latency option sizes its
|
||||||
|
// buffer from both, so setting it earlier would size it from the defaults.
|
||||||
|
//
|
||||||
|
// 50 ms of fragment: the CW decoder is downstream of this and works on the
|
||||||
|
// chunks as they arrive, so a server-chosen fragment of a quarter of a
|
||||||
|
// second would make it decide about a dit long after the dit was over.
|
||||||
|
opts := []pulse.RecordOption{
|
||||||
|
pulse.RecordSampleRate(sampleRate),
|
||||||
|
pulse.RecordChannels(channelMap(channels)),
|
||||||
|
pulse.RecordLatency(0.05),
|
||||||
|
pulse.RecordMediaName("OpsLog capture"),
|
||||||
|
}
|
||||||
|
// An empty id means "whatever the desktop calls the default", which is also
|
||||||
|
// what an operator who has never opened the audio settings expects.
|
||||||
|
if deviceID != "" {
|
||||||
|
src, err := c.SourceByID(deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("no audio input %q: %w", deviceID, err)
|
||||||
|
}
|
||||||
|
opts = append(opts, pulse.RecordSource(src))
|
||||||
|
}
|
||||||
|
st, err := c.NewRecord(pulse.NewWriter(chunkWriter{onChunk}, proto.FormatInt16LE), opts...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open capture: %w", err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
st.Start()
|
||||||
|
<-stop
|
||||||
|
st.Stop()
|
||||||
|
return st.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// playPCM plays a fixed buffer to a device and returns when it has been heard
|
||||||
|
// (or when stop closes, which cuts it short).
|
||||||
|
func playPCM(deviceID string, pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
|
||||||
|
if len(pcm) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
format, err := pulseFormat(bits)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
frameBytes := ch * bits / 8
|
||||||
|
if frameBytes <= 0 || rate <= 0 {
|
||||||
|
return fmt.Errorf("bad audio format")
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := pulseClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
// The reader hands out the buffer a slice at a time and ends the stream
|
||||||
|
// with EndOfData — the library's own sentinel. io.EOF would work as an end
|
||||||
|
// too, but it is recorded as the stream's error, and a message finishing
|
||||||
|
// normally must not look like a fault in the log.
|
||||||
|
pos := 0
|
||||||
|
read := func(buf []byte) (int, error) {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return 0, pulse.EndOfData
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if pos >= len(pcm) {
|
||||||
|
return 0, pulse.EndOfData
|
||||||
|
}
|
||||||
|
n := copy(buf, pcm[pos:])
|
||||||
|
n -= n % frameBytes // never hand the server a partial frame
|
||||||
|
if n == 0 {
|
||||||
|
return 0, pulse.EndOfData
|
||||||
|
}
|
||||||
|
pos += n
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := []pulse.PlaybackOption{
|
||||||
|
pulse.PlaybackSampleRate(rate),
|
||||||
|
pulse.PlaybackChannels(channelMap(ch)),
|
||||||
|
pulse.PlaybackLatency(0.1),
|
||||||
|
pulse.PlaybackMediaName("OpsLog playback"),
|
||||||
|
}
|
||||||
|
if deviceID != "" {
|
||||||
|
sink, err := c.SinkByID(deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("no audio output %q: %w", deviceID, err)
|
||||||
|
}
|
||||||
|
opts = append(opts, pulse.PlaybackSink(sink))
|
||||||
|
}
|
||||||
|
st, err := c.NewPlayback(pulse.NewReader(readerFunc(read), format), opts...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open playback: %w", err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
st.Start()
|
||||||
|
|
||||||
|
// Drain blocks until the server has played everything queued. Waiting on it
|
||||||
|
// in a goroutine keeps stop responsive: a voice message must cut off the
|
||||||
|
// instant the operator unkeys, not at the end of the buffer.
|
||||||
|
drained := make(chan struct{})
|
||||||
|
go func() { st.Drain(); close(drained) }()
|
||||||
|
select {
|
||||||
|
case <-drained:
|
||||||
|
case <-stop:
|
||||||
|
st.Stop()
|
||||||
|
}
|
||||||
|
return st.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderStream continuously renders PCM pulled from src to a device until stop
|
||||||
|
// closes — the streaming counterpart to playPCM's fixed buffer. On underrun it
|
||||||
|
// writes silence rather than glitching, keeping the server's clock steady so
|
||||||
|
// live monitor audio flows smoothly even when the source stalls briefly.
|
||||||
|
func renderStream(deviceID string, rate, ch, bits int, stop <-chan struct{}, src *pcmRing) error {
|
||||||
|
format, err := pulseFormat(bits)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
frameBytes := ch * bits / 8
|
||||||
|
if frameBytes <= 0 || rate <= 0 || src == nil {
|
||||||
|
return fmt.Errorf("bad audio format")
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := pulseClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
// Never return 0 bytes without an error: the library's playback loop would
|
||||||
|
// spin on it. A stalled source therefore yields silence, which is also the
|
||||||
|
// behaviour that keeps the clock running.
|
||||||
|
chunk := chunkFrames * frameBytes
|
||||||
|
read := func(buf []byte) (int, error) {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return 0, pulse.EndOfData
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
n := len(buf)
|
||||||
|
if n > chunk {
|
||||||
|
n = chunk
|
||||||
|
}
|
||||||
|
n -= n % frameBytes
|
||||||
|
if n == 0 {
|
||||||
|
n = frameBytes
|
||||||
|
}
|
||||||
|
got := copy(buf[:n], src.pull(n))
|
||||||
|
for i := got; i < n; i++ {
|
||||||
|
buf[i] = 0
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := []pulse.PlaybackOption{
|
||||||
|
pulse.PlaybackSampleRate(rate),
|
||||||
|
pulse.PlaybackChannels(channelMap(ch)),
|
||||||
|
pulse.PlaybackLatency(0.1),
|
||||||
|
pulse.PlaybackMediaName("OpsLog monitor"),
|
||||||
|
}
|
||||||
|
if deviceID != "" {
|
||||||
|
sink, err := c.SinkByID(deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("no audio output %q: %w", deviceID, err)
|
||||||
|
}
|
||||||
|
opts = append(opts, pulse.PlaybackSink(sink))
|
||||||
|
}
|
||||||
|
st, err := c.NewPlayback(pulse.NewReader(readerFunc(read), format), opts...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open monitor: %w", err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
st.Start()
|
||||||
|
<-stop
|
||||||
|
st.Stop()
|
||||||
|
// Give the server a moment to notice the stream stopped before the client
|
||||||
|
// socket goes away, so the last fragment is heard instead of clipped.
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
return st.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// pulseFormat maps a WAV bit depth onto the server's sample formats. 8 and 16
|
||||||
|
// bit cover everything OpsLog produces or reads; anything else is refused by
|
||||||
|
// name rather than played as noise.
|
||||||
|
func pulseFormat(bits int) (byte, error) {
|
||||||
|
switch bits {
|
||||||
|
case 8:
|
||||||
|
return proto.FormatUint8, nil
|
||||||
|
case 16:
|
||||||
|
return proto.FormatInt16LE, nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("unsupported sample size %d-bit (8 or 16 expected)", bits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readerFunc adapts a read closure to io.Reader.
|
||||||
|
type readerFunc func([]byte) (int, error)
|
||||||
|
|
||||||
|
func (f readerFunc) Read(p []byte) (int, error) { return f(p) }
|
||||||
|
|
||||||
|
var _ io.Reader = readerFunc(nil)
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package audio
|
package audio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -15,6 +13,12 @@ type Manager struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
recStop chan struct{}
|
recStop chan struct{}
|
||||||
recDone chan recResult
|
recDone chan recResult
|
||||||
|
// Said once each: "the audio is reaching the speakers" and "it is arriving
|
||||||
|
// with nobody listening". Both are answers to the same evening — sound
|
||||||
|
// switched on, nothing out of the speakers — and neither is worth a line per
|
||||||
|
// packet at fifty packets a second.
|
||||||
|
gotAudioOnce sync.Once
|
||||||
|
noSinkOnce sync.Once
|
||||||
// monGainPct scales what the RX monitor plays, 100 = as captured.
|
// monGainPct scales what the RX monitor plays, 100 = as captured.
|
||||||
//
|
//
|
||||||
// The "From radio" slider used to reach only the QSO recorder, so an
|
// The "From radio" slider used to reach only the QSO recorder, so an
|
||||||
@@ -224,6 +228,10 @@ func (m *Manager) StartMonitor(inputDev, outputDev string) error {
|
|||||||
return m.startMonitor(inputDev, outputDev, true)
|
return m.startMonitor(inputDev, outputDev, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Logf receives this package's diagnostic lines. Set to applog.Printf by the
|
||||||
|
// app; a no-op in tests and for anything that vendors the package alone.
|
||||||
|
var Logf = func(string, ...any) {}
|
||||||
|
|
||||||
// StartMonitorSink starts ONLY the render side (no USB capture) so an external
|
// StartMonitorSink starts ONLY the render side (no USB capture) so an external
|
||||||
// producer — the network 50003 stream — can feed decoded RX PCM via
|
// producer — the network 50003 stream — can feed decoded RX PCM via
|
||||||
// PushMonitorAudio. Same output path as StartMonitor, minus the capture goroutine.
|
// PushMonitorAudio. Same output path as StartMonitor, minus the capture goroutine.
|
||||||
@@ -255,8 +263,17 @@ func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
// Consumer: render the ring to the output device at the internal 16 kHz mono.
|
// Consumer: render the ring to the output device at the internal 16 kHz mono.
|
||||||
|
//
|
||||||
|
// The error was thrown away, and that is the whole of "I turned the sound on
|
||||||
|
// and nothing comes out": a Listening device that has been unplugged, renamed
|
||||||
|
// by Windows or cannot open at 16 kHz fails here, silently, while everything
|
||||||
|
// upstream reports success — the stream is up, the packets arrive, the
|
||||||
|
// monitor says it started. Said out loud, the operator knows to look at the
|
||||||
|
// device rather than at the radio.
|
||||||
go func() {
|
go func() {
|
||||||
_ = renderStream(outputDev, sampleRate, channels, bitsPerSample, stop, ring)
|
if err := renderStream(outputDev, sampleRate, channels, bitsPerSample, stop, ring); err != nil {
|
||||||
|
Logf("audio: the Listening device could not be opened (%q): %v", outputDev, err)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
m.notify()
|
m.notify()
|
||||||
return nil
|
return nil
|
||||||
@@ -321,9 +338,19 @@ func (m *Manager) PushMonitorAudio(pcm []byte) {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
ring := m.monRing
|
ring := m.monRing
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
if ring != nil {
|
if ring == nil {
|
||||||
ring.Push(pcm)
|
// Nothing is listening: the stream is feeding the recorder and the voice
|
||||||
|
// keyer only. Said ONCE, because the alternative — silence in the log for
|
||||||
|
// silence in the speakers — is what makes this take an evening to find.
|
||||||
|
m.noSinkOnce.Do(func() {
|
||||||
|
Logf("audio: network RX audio is arriving but no monitor is running — the speakers are off (Listening)")
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
m.gotAudioOnce.Do(func() {
|
||||||
|
Logf("audio: network RX audio reaching the Listening device (%d-byte chunks)", len(pcm))
|
||||||
|
})
|
||||||
|
ring.Push(pcm)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- TX audio passthrough (Phase 3: live mic → rig over USB) --------------
|
// ---- TX audio passthrough (Phase 3: live mic → rig over USB) --------------
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package audio
|
package audio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package audio
|
package audio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// pcmRing is a thread-safe, latency-bounded FIFO of PCM bytes feeding a live
|
||||||
|
// render stream. Producers (a USB-codec capture, or a decoded network audio
|
||||||
|
// stream) Push freshly-arrived samples; the render loop Pulls. It is the shared
|
||||||
|
// hand-off point between "where the audio comes from" (USB device / UDP 50003)
|
||||||
|
// and "where it's heard" (any WASAPI output) — so the transport can be swapped
|
||||||
|
// without touching the render side, mirroring the civTransport split on the CAT
|
||||||
|
// side. On overflow the oldest audio is dropped to keep latency bounded; on
|
||||||
|
// underrun Pull simply returns short and the render loop pads with silence.
|
||||||
|
type pcmRing struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buf []byte
|
||||||
|
max int // hard cap in bytes (drops oldest beyond this → bounded latency)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPCMRing makes a ring whose backlog is capped at maxBytes. Size it from the
|
||||||
|
// acceptable latency: bytesPerSec (=32000) worth ≈ 1 s.
|
||||||
|
func newPCMRing(maxBytes int) *pcmRing {
|
||||||
|
if maxBytes <= 0 {
|
||||||
|
maxBytes = bytesPerSec // 1 s default
|
||||||
|
}
|
||||||
|
return &pcmRing{max: maxBytes}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push appends samples, dropping the oldest audio if the backlog would exceed
|
||||||
|
// the cap (a slow/absent consumer never makes the producer block or grow without
|
||||||
|
// bound). A short glitch beats runaway latency for live monitoring.
|
||||||
|
func (r *pcmRing) Push(p []byte) {
|
||||||
|
if len(p) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
r.buf = append(r.buf, p...)
|
||||||
|
if len(r.buf) > r.max {
|
||||||
|
drop := len(r.buf) - r.max
|
||||||
|
r.buf = append(r.buf[:0], r.buf[drop:]...)
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// pull removes and returns up to maxBytes of queued PCM (a private copy), or nil
|
||||||
|
// when empty. The render loop pads any shortfall with silence.
|
||||||
|
func (r *pcmRing) pull(maxBytes int) []byte {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if len(r.buf) == 0 || maxBytes <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n := maxBytes
|
||||||
|
if n > len(r.buf) {
|
||||||
|
n = len(r.buf)
|
||||||
|
}
|
||||||
|
out := make([]byte, n)
|
||||||
|
copy(out, r.buf[:n])
|
||||||
|
r.buf = append(r.buf[:0], r.buf[n:]...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package audio
|
package audio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
+599
-76
@@ -47,6 +47,7 @@ package autocall
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
@@ -57,6 +58,16 @@ type Need int
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
NeedNone Need = iota
|
NeedNone Need = iota
|
||||||
|
// NeedExtra: the ENTITY has nothing left to give on this band and mode, but
|
||||||
|
// the station carries something else that has never been worked — a WPX
|
||||||
|
// prefix, a square, a US county or state, a park. Those are what the cluster
|
||||||
|
// calls the orthogonal markers, and an operator who ticked them in the chase
|
||||||
|
// settings is hunting them: leaving them at nothing-needed meant auto-call
|
||||||
|
// watched a never-worked prefix call CQ and did nothing.
|
||||||
|
//
|
||||||
|
// The lowest rung, deliberately. It is worth calling when nothing better is
|
||||||
|
// on the air, and never worth leaving a new band for.
|
||||||
|
NeedExtra
|
||||||
NeedSlot // entity worked on this band and in this mode, never together
|
NeedSlot // entity worked on this band and in this mode, never together
|
||||||
NeedMode // entity never worked in this mode
|
NeedMode // entity never worked in this mode
|
||||||
NeedBand // entity never worked on this band
|
NeedBand // entity never worked on this band
|
||||||
@@ -73,6 +84,8 @@ func (n Need) String() string {
|
|||||||
return "mode"
|
return "mode"
|
||||||
case NeedSlot:
|
case NeedSlot:
|
||||||
return "slot"
|
return "slot"
|
||||||
|
case NeedExtra:
|
||||||
|
return "extra"
|
||||||
}
|
}
|
||||||
return "-"
|
return "-"
|
||||||
}
|
}
|
||||||
@@ -109,6 +122,17 @@ type Candidate struct {
|
|||||||
Decode
|
Decode
|
||||||
Need Need
|
Need Need
|
||||||
Watched bool
|
Watched bool
|
||||||
|
// Extra names what NeedExtra is about — "prefix", "county", "square" — so the
|
||||||
|
// decision line says why the station was worth a call. Empty otherwise.
|
||||||
|
Extra string
|
||||||
|
// Unconfirmed says the need above exists ONLY because a contact was never
|
||||||
|
// confirmed — the operator hunts "new + unconfirmed", the band is in the log
|
||||||
|
// and the QSL is not. A real need outranks it; see rank.
|
||||||
|
Unconfirmed bool
|
||||||
|
// Hidden is true when the decodes panel's own filters are keeping this
|
||||||
|
// station off the screen. The operator's filters are the control: what is
|
||||||
|
// not shown is not called — see Settings.OnScreenOnly.
|
||||||
|
Hidden bool
|
||||||
// Worked is "already in the log on this band AND mode". Nothing is gained
|
// Worked is "already in the log on this band AND mode". Nothing is gained
|
||||||
// by calling it again — and while chasing confirmations it is the trap that
|
// by calling it again — and while chasing confirmations it is the trap that
|
||||||
// makes the same station be called all evening, because an unconfirmed QSO
|
// makes the same station be called all evening, because an unconfirmed QSO
|
||||||
@@ -145,20 +169,39 @@ type Settings struct {
|
|||||||
// Misses is how many of the station's OWN transmit periods may pass with no
|
// Misses is how many of the station's OWN transmit periods may pass with no
|
||||||
// decode of it before it is given up on.
|
// decode of it before it is given up on.
|
||||||
Misses int
|
Misses int
|
||||||
// Rest is how long a released callsign waits before it can be picked again,
|
// RestPeriods is how many of the station's OWN OVERS a released callsign sits
|
||||||
// and MaxRounds how many such series it gets before being parked for the
|
// out before it can be picked again, and MaxRounds how many such series it
|
||||||
// session.
|
// gets before being parked for the session.
|
||||||
Rest time.Duration
|
//
|
||||||
|
// Counted in overs, not in minutes, because that is the unit the thing is
|
||||||
|
// happening in: an operator who has called a DX seven times without an answer
|
||||||
|
// listens through one of its transmissions and calls again if it is still
|
||||||
|
// there. Two minutes is four overs on FT8 — the DX has worked four other
|
||||||
|
// callers by then, and half the time it has gone.
|
||||||
|
RestPeriods int
|
||||||
MaxRounds int
|
MaxRounds int
|
||||||
// MaxHold is the wall-clock backstop on one target.
|
// MaxHold is the wall-clock backstop on one target.
|
||||||
MaxHold time.Duration
|
MaxHold time.Duration
|
||||||
|
// OnScreenOnly restricts the calling to what the decodes panel is actually
|
||||||
|
// showing.
|
||||||
|
//
|
||||||
|
// The filters an operator sets while reading the band — CQ only, LoTW only,
|
||||||
|
// the new-category chips, continents, a minimum report, the search box — now
|
||||||
|
// bind the transmitter too: a station filtered off the screen is not called.
|
||||||
|
// It replaces a separate "LoTW users only" setting, which said one thing
|
||||||
|
// while the chip above the list said another.
|
||||||
|
//
|
||||||
|
// The panel publishes what it is showing; when it is publishing nothing —
|
||||||
|
// the tab was never opened this session — there is nothing to restrict and
|
||||||
|
// the ladder decides alone.
|
||||||
|
OnScreenOnly bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defaults are what the operator gets before touching anything.
|
// Defaults are what the operator gets before touching anything.
|
||||||
func Defaults() Settings {
|
func Defaults() Settings {
|
||||||
return Settings{
|
return Settings{
|
||||||
Attempts: 7, WatchedAttempts: 15, Misses: 3,
|
Attempts: 7, WatchedAttempts: 15, Misses: 3,
|
||||||
Rest: 2 * time.Minute, MaxRounds: 3, MaxHold: 4 * time.Minute,
|
RestPeriods: 1, MaxRounds: 3, MaxHold: 4 * time.Minute,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,8 +216,8 @@ func (s Settings) withDefaults() Settings {
|
|||||||
if s.Misses <= 0 {
|
if s.Misses <= 0 {
|
||||||
s.Misses = d.Misses
|
s.Misses = d.Misses
|
||||||
}
|
}
|
||||||
if s.Rest <= 0 {
|
if s.RestPeriods <= 0 {
|
||||||
s.Rest = d.Rest
|
s.RestPeriods = d.RestPeriods
|
||||||
}
|
}
|
||||||
if s.MaxRounds <= 0 {
|
if s.MaxRounds <= 0 {
|
||||||
s.MaxRounds = d.MaxRounds
|
s.MaxRounds = d.MaxRounds
|
||||||
@@ -201,6 +244,19 @@ type Action struct {
|
|||||||
Kind ActionKind
|
Kind ActionKind
|
||||||
Decode Decode
|
Decode Decode
|
||||||
Reason string
|
Reason string
|
||||||
|
// Soft asks the decoder to finish the over it is sending and only then stop
|
||||||
|
// transmitting, rather than cutting the carrier where it stands.
|
||||||
|
//
|
||||||
|
// It matters for exactly one brake. The attempts cap trips WHILE the seventh
|
||||||
|
// call is going out — that is what counts it — so a hard halt cuts that
|
||||||
|
// transmission a second in, which on the air is a half-sent call and on the
|
||||||
|
// screen is an auto-call that "starts and stops". Nothing is saved by
|
||||||
|
// stopping it: the frame is already half gone.
|
||||||
|
//
|
||||||
|
// Every other brake fires on a station that is not being transmitted to (it
|
||||||
|
// has vanished, or the clock ran out between overs), and there a hard halt
|
||||||
|
// is right: it is the one that also clears the decoder's own auto-sequence.
|
||||||
|
Soft bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Engine holds the state between periods. Not safe for concurrent use; the
|
// Engine holds the state between periods. Not safe for concurrent use; the
|
||||||
@@ -214,6 +270,19 @@ type Engine struct {
|
|||||||
// targetInst is the receiver the target was picked on, so the brakes are
|
// targetInst is the receiver the target was picked on, so the brakes are
|
||||||
// counted against ITS periods and its transmissions.
|
// counted against ITS periods and its transmissions.
|
||||||
targetInst string
|
targetInst string
|
||||||
|
// now is the time of the last period seen. Every deadline in here is measured
|
||||||
|
// against it rather than against the wall clock: the periods ARE the clock —
|
||||||
|
// they arrive stamped — and mixing the two gave a rest that expired against
|
||||||
|
// one reading and a hold that ran against another.
|
||||||
|
now time.Time
|
||||||
|
// waiting is the station the engine WOULD call and cannot, because it is in
|
||||||
|
// a QSO with somebody else. Kept so the toolbar can say so: an auto-call
|
||||||
|
// deliberately holding its fire looks exactly like one with nothing to do,
|
||||||
|
// and from the outside there was no way to tell them apart.
|
||||||
|
waiting string
|
||||||
|
// answered says the target has replied to us at least once — the exchange is
|
||||||
|
// under way and nothing may take its place.
|
||||||
|
answered bool
|
||||||
// heldSince is when this station BECAME the target, and is never refreshed
|
// heldSince is when this station BECAME the target, and is never refreshed
|
||||||
// while it stays one. It was, and that quietly disabled the clock backstop:
|
// while it stays one. It was, and that quietly disabled the clock backstop:
|
||||||
// a station decoded every period for ever kept pushing its own deadline
|
// a station decoded every period for ever kept pushing its own deadline
|
||||||
@@ -221,6 +290,12 @@ type Engine struct {
|
|||||||
heldSince time.Time
|
heldSince time.Time
|
||||||
attempts int
|
attempts int
|
||||||
misses int
|
misses int
|
||||||
|
// seenKey is the period in which the target was last DECODED. A period is
|
||||||
|
// judged more than once — the decodes arrive in bursts and stragglers follow —
|
||||||
|
// and a later judgement holds a partial view of it, not evidence of absence:
|
||||||
|
// the station answered in that very period and the counter still read one
|
||||||
|
// miss out of three.
|
||||||
|
seenKey string
|
||||||
lastMiss string // period already counted, so one period counts once
|
lastMiss string // period already counted, so one period counts once
|
||||||
txSlot int // which of the two slots the target transmits in; -1 unknown
|
txSlot int // which of the two slots the target transmits in; -1 unknown
|
||||||
stopped bool // an explicit "Only" target gave up: needs a restart
|
stopped bool // an explicit "Only" target gave up: needs a restart
|
||||||
@@ -230,6 +305,20 @@ type Engine struct {
|
|||||||
// how many series it has already had this session.
|
// how many series it has already had this session.
|
||||||
rested map[string]time.Time
|
rested map[string]time.Time
|
||||||
rounds map[string]int
|
rounds map[string]int
|
||||||
|
// trace, when set, receives one line per period: what was on the air, why
|
||||||
|
// each station was refused, and what was decided. Off unless the operator
|
||||||
|
// asks for it — a line per period is a line every fifteen seconds, all
|
||||||
|
// night.
|
||||||
|
trace func(string, ...any)
|
||||||
|
// greyed is every station the OPERATOR stopped a call to.
|
||||||
|
//
|
||||||
|
// Halt is a verdict, not a pause: the operator watched the engine call this
|
||||||
|
// station and said no. Answering that by releasing the target and picking
|
||||||
|
// the same station again the next period — which is what clearing the state
|
||||||
|
// did — is the machine overruling them, and it is what Halt exists to
|
||||||
|
// prevent. It holds until auto-call is switched off and on again, which is
|
||||||
|
// the one gesture that plainly means "start over".
|
||||||
|
greyed map[string]bool
|
||||||
// done is every station the exchange was completed with this session.
|
// done is every station the exchange was completed with this session.
|
||||||
//
|
//
|
||||||
// The log is the authority on what is worked, and it is SLOW: the QSO is
|
// The log is the authority on what is worked, and it is SLOW: the QSO is
|
||||||
@@ -242,7 +331,8 @@ type Engine struct {
|
|||||||
|
|
||||||
func New(s Settings) *Engine {
|
func New(s Settings) *Engine {
|
||||||
return &Engine{set: s.withDefaults(), txSlot: -1,
|
return &Engine{set: s.withDefaults(), txSlot: -1,
|
||||||
rested: map[string]time.Time{}, rounds: map[string]int{}, done: map[string]bool{}}
|
rested: map[string]time.Time{}, rounds: map[string]int{}, done: map[string]bool{},
|
||||||
|
greyed: map[string]bool{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSettings swaps the settings in place. A target already being called is
|
// SetSettings swaps the settings in place. A target already being called is
|
||||||
@@ -262,6 +352,8 @@ func (e *Engine) Target() string {
|
|||||||
// the engine has given up and is waiting for the operator.
|
// the engine has given up and is waiting for the operator.
|
||||||
type Status struct {
|
type Status struct {
|
||||||
Target string `json:"target"`
|
Target string `json:"target"`
|
||||||
|
// Waiting: wanted, decoded, and in a QSO with somebody else.
|
||||||
|
Waiting string `json:"waiting"`
|
||||||
Attempts int `json:"attempts"`
|
Attempts int `json:"attempts"`
|
||||||
Max int `json:"max"`
|
Max int `json:"max"`
|
||||||
Misses int `json:"misses"`
|
Misses int `json:"misses"`
|
||||||
@@ -271,7 +363,8 @@ type Status struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) Status() Status {
|
func (e *Engine) Status() Status {
|
||||||
st := Status{Misses: e.misses, MaxMiss: e.set.Misses, Stopped: e.stopped, StoppedOn: e.stoppedOn}
|
st := Status{Misses: e.misses, MaxMiss: e.set.Misses, Stopped: e.stopped, StoppedOn: e.stoppedOn,
|
||||||
|
Waiting: e.waiting}
|
||||||
if e.target != nil {
|
if e.target != nil {
|
||||||
st.Target = e.target.Call
|
st.Target = e.target.Call
|
||||||
st.Attempts = e.attempts
|
st.Attempts = e.attempts
|
||||||
@@ -287,9 +380,38 @@ func (e *Engine) Reset() {
|
|||||||
e.targetInst = ""
|
e.targetInst = ""
|
||||||
e.lastMiss, e.stopped, e.stoppedOn = "", false, ""
|
e.lastMiss, e.stopped, e.stoppedOn = "", false, ""
|
||||||
e.rested, e.rounds = map[string]time.Time{}, map[string]int{}
|
e.rested, e.rounds = map[string]time.Time{}, map[string]int{}
|
||||||
e.done = map[string]bool{}
|
e.done, e.greyed = map[string]bool{}, map[string]bool{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Halt is the operator stopping the call in progress.
|
||||||
|
//
|
||||||
|
// The station is set aside for the session — see greyed — and the target is
|
||||||
|
// released. Returns the callsign so the caller can say what happened; empty
|
||||||
|
// when nothing was being called, where Halt is just a halt.
|
||||||
|
func (e *Engine) Halt() string {
|
||||||
|
if e.target == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
call := strings.ToUpper(e.target.Call)
|
||||||
|
e.greyed[call] = true
|
||||||
|
e.release(call, true)
|
||||||
|
// Not counted as a series: the brakes are about a station that will not
|
||||||
|
// answer, and this one was never given the chance. It will not be called
|
||||||
|
// again anyway.
|
||||||
|
e.rounds[call]--
|
||||||
|
if e.rounds[call] < 0 {
|
||||||
|
e.rounds[call] = 0
|
||||||
|
}
|
||||||
|
delete(e.rested, call)
|
||||||
|
return call
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTrace turns the per-period trace on (a non-nil function) or off (nil).
|
||||||
|
func (e *Engine) SetTrace(fn func(string, ...any)) { e.trace = fn }
|
||||||
|
|
||||||
|
// Greylisted reports how many stations the operator has stopped this session.
|
||||||
|
func (e *Engine) Greylisted() int { return len(e.greyed) }
|
||||||
|
|
||||||
// Take hands the operator's own click to the engine: the station they picked
|
// Take hands the operator's own click to the engine: the station they picked
|
||||||
// becomes the target, with the counters restarted. Everything after it — the
|
// becomes the target, with the counters restarted. Everything after it — the
|
||||||
// brakes, the hand-off at the end of the QSO — then applies as usual.
|
// brakes, the hand-off at the end of the QSO — then applies as usual.
|
||||||
@@ -309,23 +431,47 @@ func (e *Engine) maxAttempts(c Candidate) int {
|
|||||||
return e.set.Attempts
|
return e.set.Attempts
|
||||||
}
|
}
|
||||||
|
|
||||||
// rank is the ladder, as one number. Watched lifts a station by one rung, and
|
// rank is the ladder, as one number.
|
||||||
// a watched station with nothing needed sits at the bottom rather than being
|
|
||||||
// ineligible: the operator asked for that callsign.
|
|
||||||
//
|
//
|
||||||
// 9 WL DXCC 8 DXCC 7 WL band 6 band
|
// A WATCHED callsign outranks everything that is not watched. That is what a
|
||||||
// 5 WL mode 4 mode 3 WL slot 2 slot 1 watched, nothing needed
|
// watch list means: the operator has named this station, and the machine's
|
||||||
|
// opinion of what the log needs does not get to overrule it.
|
||||||
|
//
|
||||||
|
// It did before, one rung at a time — watched new band above plain new band,
|
||||||
|
// but a watched station with nothing needed at the very bottom. On a band full
|
||||||
|
// of stations that ARE needed, that station was never reached: a watched
|
||||||
|
// DXpedition sat there all evening while the engine worked Brazilians. The
|
||||||
|
// list is not a tie-breaker, it is the answer.
|
||||||
|
//
|
||||||
|
// Below that line the old order stands, and for the same reasons: what is
|
||||||
|
// needed first, and a real need above one that exists only because a QSL never
|
||||||
|
// came — an unconfirmed new BAND still outranks a real new SLOT, because the
|
||||||
|
// band is the bigger prize whatever state it is in.
|
||||||
|
//
|
||||||
|
// 100+ every watched callsign, ordered among themselves by the same rule
|
||||||
|
// 18 DXCC 16 DXCC unconf
|
||||||
|
// 14 band 12 band unconf
|
||||||
|
// 10 mode 8 mode unconf
|
||||||
|
// 6 slot 4 slot unconf
|
||||||
// 0 nothing to call for
|
// 0 nothing to call for
|
||||||
|
//
|
||||||
|
// needLabel is what the candidate is worth, in words: the rung, or the name of
|
||||||
|
// the orthogonal marker when that is the whole reason for the call.
|
||||||
|
func needLabel(c Candidate) string {
|
||||||
|
if c.Need == NeedExtra && c.Extra != "" {
|
||||||
|
return "new " + c.Extra
|
||||||
|
}
|
||||||
|
return c.Need.String()
|
||||||
|
}
|
||||||
|
|
||||||
func rank(c Candidate) int {
|
func rank(c Candidate) int {
|
||||||
if c.Need == NeedNone {
|
r := int(c.Need) * 4 // slot 4, mode 8, band 12, DXCC 16
|
||||||
if c.Watched {
|
if c.Need != NeedNone && !c.Unconfirmed {
|
||||||
return 1
|
r += 2
|
||||||
}
|
}
|
||||||
return 0
|
|
||||||
}
|
|
||||||
r := int(c.Need) * 2 // slot 2, mode 4, band 6, DXCC 8
|
|
||||||
if c.Watched {
|
if c.Watched {
|
||||||
r++
|
// Above every unwatched station, whatever the log makes of either.
|
||||||
|
return 100 + r
|
||||||
}
|
}
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@@ -371,6 +517,40 @@ func isGrid(tok string) bool {
|
|||||||
return gridRe.MatchString(tok)
|
return gridRe.MatchString(tok)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// messages splits a decoded line into the messages it carries.
|
||||||
|
//
|
||||||
|
// One transmission can hold two. MSHV answers several callers at once — its
|
||||||
|
// own multi-answer transmission, not SuperFox — and the decoder prints them as
|
||||||
|
// one line:
|
||||||
|
//
|
||||||
|
// YV5ALI RR73; F4BPO <HK0/PY8WW> -08
|
||||||
|
//
|
||||||
|
// The second half is a report to F4BPO. Reading only the start of the line, the
|
||||||
|
// engine saw a station working YV5ALI, decided it could not answer us, and
|
||||||
|
// dropped the target — at the exact moment the DX was answering. Each segment
|
||||||
|
// is its own message and names its own recipient first.
|
||||||
|
func messages(msg string) []string {
|
||||||
|
return strings.Split(strings.ToUpper(strings.TrimSpace(msg)), ";")
|
||||||
|
}
|
||||||
|
|
||||||
|
// bare strips the angle brackets a decoder puts round a compressed callsign,
|
||||||
|
// and upper-cases what is left. "<HP/WE9G>" and "HP/WE9G" are one station, and
|
||||||
|
// every comparison in here has to agree about that.
|
||||||
|
func bare(call string) string {
|
||||||
|
return strings.Trim(strings.ToUpper(strings.TrimSpace(call)), "<>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// addressedTo reports whether one message segment is addressed to a callsign.
|
||||||
|
// The recipient is the first token, sometimes bracketed when the sender has
|
||||||
|
// compressed a non-standard call.
|
||||||
|
func addressedTo(part, call string) bool {
|
||||||
|
toks := strings.Fields(part)
|
||||||
|
if len(toks) == 0 || call == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return bare(toks[0]) == bare(call)
|
||||||
|
}
|
||||||
|
|
||||||
// callable reports whether a station can be answered RIGHT NOW.
|
// callable reports whether a station can be answered RIGHT NOW.
|
||||||
//
|
//
|
||||||
// A station in mid-exchange is committed to somebody else: it will not answer,
|
// A station in mid-exchange is committed to somebody else: it will not answer,
|
||||||
@@ -383,17 +563,23 @@ func callable(c Candidate, myCall string) bool {
|
|||||||
if c.CQ {
|
if c.CQ {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
toks := strings.Fields(strings.ToUpper(strings.TrimSpace(c.Msg)))
|
parts := messages(c.Msg)
|
||||||
if len(toks) == 0 {
|
if len(parts) == 0 {
|
||||||
return false // nothing to read: assume it is busy rather than call blind
|
return false // nothing to read: assume it is busy rather than call blind
|
||||||
}
|
}
|
||||||
if myCall != "" && toks[0] == strings.ToUpper(myCall) {
|
for _, part := range parts {
|
||||||
|
toks := strings.Fields(part)
|
||||||
|
if len(toks) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if addressedTo(part, myCall) {
|
||||||
return true // it is calling us
|
return true // it is calling us
|
||||||
}
|
}
|
||||||
switch toks[len(toks)-1] {
|
switch toks[len(toks)-1] {
|
||||||
case "RR73", "RRR", "73":
|
case "RR73", "RRR", "73":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,8 +589,12 @@ func callingUs(c Candidate, myCall string) bool {
|
|||||||
if myCall == "" || c.CQ {
|
if myCall == "" || c.CQ {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
toks := strings.Fields(strings.ToUpper(strings.TrimSpace(c.Msg)))
|
for _, part := range messages(c.Msg) {
|
||||||
return len(toks) > 0 && toks[0] == strings.ToUpper(myCall)
|
if addressedTo(part, myCall) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// finished reports that the exchange with this station is over: it sent us the
|
// finished reports that the exchange with this station is over: it sent us the
|
||||||
@@ -420,8 +610,11 @@ func finished(decodes []Candidate, call, myCall string) bool {
|
|||||||
if strings.ToUpper(c.Call) != call {
|
if strings.ToUpper(c.Call) != call {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
toks := strings.Fields(strings.ToUpper(strings.TrimSpace(c.Msg)))
|
// Every segment: a fox says goodbye to one caller and hello to the next
|
||||||
if len(toks) < 2 || toks[0] != me {
|
// in the same transmission.
|
||||||
|
for _, part := range messages(c.Msg) {
|
||||||
|
toks := strings.Fields(part)
|
||||||
|
if len(toks) < 2 || !addressedTo(part, me) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch toks[len(toks)-1] {
|
switch toks[len(toks)-1] {
|
||||||
@@ -429,6 +622,7 @@ func finished(decodes []Candidate, call, myCall string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,6 +660,9 @@ func (e *Engine) OnPeriod(p Period) Action {
|
|||||||
if !e.set.Enabled {
|
if !e.set.Enabled {
|
||||||
return Action{}
|
return Action{}
|
||||||
}
|
}
|
||||||
|
if !p.At.IsZero() {
|
||||||
|
e.now = p.At
|
||||||
|
}
|
||||||
// ONE station at a time, on the receiver it is being called on.
|
// ONE station at a time, on the receiver it is being called on.
|
||||||
//
|
//
|
||||||
// This is the guarantee with two decoders running: while a target is held,
|
// This is the guarantee with two decoders running: while a target is held,
|
||||||
@@ -482,16 +679,38 @@ func (e *Engine) OnPeriod(p Period) Action {
|
|||||||
t := *e.target
|
t := *e.target
|
||||||
tc := strings.ToUpper(t.Call)
|
tc := strings.ToUpper(t.Call)
|
||||||
|
|
||||||
|
// HAS IT ANSWERED US? Settled first, before any of the checks below can
|
||||||
|
// decide to leave it.
|
||||||
|
//
|
||||||
|
// The reply lands in the same period the ladder is re-read, and that
|
||||||
|
// period used to be judged with answered still false — so a station
|
||||||
|
// answering our CQ-call was dropped for a better-ranked one in the exact
|
||||||
|
// period it came back to us. Watched from the shack: mid-exchange with
|
||||||
|
// V31MA, report sent, and OpsLog switched to a station calling on the
|
||||||
|
// other side of the screen. A QSO in progress outranks the ladder.
|
||||||
|
if seen := bestOf(p.Decodes, tc); seen != nil && callingUs(*seen, p.MyCall) {
|
||||||
|
e.answered = true
|
||||||
|
}
|
||||||
|
|
||||||
// The exchange is over — either the station sent us its last frame, or
|
// The exchange is over — either the station sent us its last frame, or
|
||||||
// the log now holds it. Hand straight on to the next station in the same
|
// the log now holds it. Hand straight on to the next station in the same
|
||||||
// period rather than waiting for the next one: the slot is the resource.
|
// period rather than waiting for the next one: the slot is the resource.
|
||||||
if finished(p.Decodes, tc, p.MyCall) || workedNow(p.Decodes, tc) {
|
if finished(p.Decodes, tc, p.MyCall) || workedNow(p.Decodes, tc) {
|
||||||
e.release(tc, false)
|
e.release(tc, false)
|
||||||
// Straight on to the next station, list or no list: pick() is what
|
// AND NOTHING ELSE THIS PERIOD. The next station is picked on the next
|
||||||
// knows the chase list, and with one on it the answer is simply the
|
// one, fifteen seconds later, and it is still there.
|
||||||
// next callsign there — a list of two DXpeditions must not stop
|
//
|
||||||
// after the first.
|
// His RR73 is decoded while we are between overs, so the decoder is
|
||||||
return e.pick(p, fmt.Sprintf("QSO with %s finished", tc))
|
// about to send our own 73 in the slot that is opening. Answering
|
||||||
|
// somebody else now takes that slot: the reply switches the DX call
|
||||||
|
// mid-sequence and the 73 never goes out whole — an operator watching
|
||||||
|
// saw both transmissions in one period, and the station at the other
|
||||||
|
// end never got the frame that closes the contact.
|
||||||
|
//
|
||||||
|
// This used to hand straight on "because the slot is the resource".
|
||||||
|
// The slot is worth less than the QSO it would spoil.
|
||||||
|
return Action{Kind: DoNothing,
|
||||||
|
Reason: fmt.Sprintf("QSO with %s finished — leaving the next slot for our 73", tc)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The clock backstop. Every counter below depends on decodes arriving in
|
// The clock backstop. Every counter below depends on decodes arriving in
|
||||||
@@ -501,22 +720,82 @@ func (e *Engine) OnPeriod(p Period) Action {
|
|||||||
return Action{Kind: DoHalt, Reason: fmt.Sprintf("%s held for %s with nothing to show for it", tc, e.set.MaxHold)}
|
return Action{Kind: DoHalt, Reason: fmt.Sprintf("%s held for %s with nothing to show for it", tc, e.set.MaxHold)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if e.trace != nil {
|
||||||
|
e.trace("period %s holding %s calls=%d/%d misses=%d/%d%s",
|
||||||
|
p.Key, tc, e.attempts, e.maxAttempts(t), e.misses, e.set.Misses,
|
||||||
|
map[bool]string{true: " (transmitting)", false: ""}[p.TX.Transmitting])
|
||||||
|
}
|
||||||
|
// SOMETHING BETTER HAS COME ON THE AIR.
|
||||||
|
//
|
||||||
|
// A target is held until it answers or a brake releases it, and that is
|
||||||
|
// right once an exchange has started — but before it has, holding means
|
||||||
|
// spending the next two minutes on a new slot while the watched
|
||||||
|
// DXpedition calls CQ three rows above it. Which is what an operator
|
||||||
|
// sees: the first CQ ignored, then the second one answered because the
|
||||||
|
// other call had run its course.
|
||||||
|
//
|
||||||
|
// So while the target has NOT answered us, a strictly better station
|
||||||
|
// takes its place. "Better" is the ladder, which does not flicker: a
|
||||||
|
// station's need does not change from period to period, so this cannot
|
||||||
|
// oscillate between two of them — only a genuinely higher rung wins.
|
||||||
|
if !e.answered {
|
||||||
|
if a, ok := e.preempt(p); ok {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
}
|
||||||
if seen := bestOf(p.Decodes, tc); seen != nil {
|
if seen := bestOf(p.Decodes, tc); seen != nil {
|
||||||
// The decode is refreshed so a reply carries a current timestamp; the
|
// The decode is refreshed so a reply carries a current timestamp; the
|
||||||
// hold clock is NOT — see heldSince.
|
// hold clock is NOT — see heldSince.
|
||||||
e.target = seen
|
e.target = seen
|
||||||
e.misses, e.lastMiss = 0, ""
|
e.misses, e.lastMiss, e.seenKey = 0, "", p.Key
|
||||||
e.txSlot = slotOf(p.At, periodSecs(p, *seen))
|
e.txSlot = slotOf(p.At, periodSecs(p, *seen))
|
||||||
// In QSO: the decoder is sequencing the exchange on its own and the
|
// In QSO: the decoder is sequencing the exchange on its own and the
|
||||||
// attempt counter has done its job. Interrupting it with another
|
// attempt counter has done its job. Interrupting it with another
|
||||||
// reply is how two transmissions land in one slot.
|
// reply is how two transmissions land in one slot.
|
||||||
if callingUs(*seen, p.MyCall) {
|
if callingUs(*seen, p.MyCall) {
|
||||||
e.attempts = 0
|
e.attempts = 0
|
||||||
|
e.answered = true
|
||||||
|
// The exchange is under way, so the hold clock starts again from
|
||||||
|
// here: it exists to end a call nobody answers, and this one has
|
||||||
|
// been answered. Without this a QSO that began at the end of a
|
||||||
|
// long wait could be halted mid-exchange by a backstop that was
|
||||||
|
// counting the wait.
|
||||||
|
e.heldSince = p.At
|
||||||
|
return Action{}
|
||||||
|
}
|
||||||
|
// IT IS WORKING SOMEBODY ELSE, AND WE GO ON CALLING.
|
||||||
|
//
|
||||||
|
// This used to stop, on the reasoning that a station in a QSO cannot
|
||||||
|
// hear the call. That is how a pileup is NOT worked: a DX with a
|
||||||
|
// queue answers one caller per period, and the only way to be the
|
||||||
|
// next one is to keep calling while it works the others. An operator
|
||||||
|
// watched exactly that — D44TWO finishing a contact, the call
|
||||||
|
// started, the DX coming back to somebody else, and the engine
|
||||||
|
// giving up on a station that was about to be free.
|
||||||
|
//
|
||||||
|
// The effort is already bounded: seven calls, fifteen for a watched
|
||||||
|
// station, and the miss counter for one that goes off the air. And
|
||||||
|
// nothing is wasted while it is busy — a better station may still
|
||||||
|
// take the slot, because it has not answered us (see preempt).
|
||||||
|
//
|
||||||
|
// The callable test still governs the CHOICE of a target, where it
|
||||||
|
// belongs: a reply to a decode in mid-exchange is one WSJT-X and
|
||||||
|
// JTDX may refuse outright.
|
||||||
|
if e.trace != nil && !callable(*seen, p.MyCall) {
|
||||||
|
e.trace("period %s %s is working %s — carrying on calling", p.Key, tc, addressee(seen.Msg))
|
||||||
}
|
}
|
||||||
return Action{}
|
return Action{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Absent. Only its OWN transmit periods count against it.
|
// Absent. Only its OWN transmit periods count against it — and never one
|
||||||
|
// of ours: our transmission is the reason it is not there.
|
||||||
|
if p.TX.Transmitting {
|
||||||
|
return Action{}
|
||||||
|
}
|
||||||
|
// Nor a period it was already decoded in: see seenKey.
|
||||||
|
if e.seenKey == p.Key {
|
||||||
|
return Action{}
|
||||||
|
}
|
||||||
if e.txSlot >= 0 && slotOf(p.At, periodSecs(p, t)) != e.txSlot {
|
if e.txSlot >= 0 && slotOf(p.At, periodSecs(p, t)) != e.txSlot {
|
||||||
return Action{}
|
return Action{}
|
||||||
}
|
}
|
||||||
@@ -536,65 +815,132 @@ func (e *Engine) OnPeriod(p Period) Action {
|
|||||||
if e.stopped {
|
if e.stopped {
|
||||||
return Action{} // an explicit target gave up; the operator restarts it
|
return Action{} // an explicit target gave up; the operator restarts it
|
||||||
}
|
}
|
||||||
// Never start a call over a transmission in progress: the reply would land
|
|
||||||
// in a slot the decoder is already using.
|
|
||||||
if p.TX.Transmitting {
|
|
||||||
return Action{}
|
|
||||||
}
|
|
||||||
return e.pick(p, "")
|
return e.pick(p, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// pick chooses the best station on the air and answers it.
|
// pick chooses the best station on the air and answers it.
|
||||||
|
//
|
||||||
|
// NEVER over a transmission in progress, and that guard belongs HERE because
|
||||||
|
// there are two ways in. A reply is not a request the decoder queues: WSJT-X
|
||||||
|
// acts on it at once, drops the exchange it is in and starts calling the new
|
||||||
|
// station — so a reply sent while our own 73 was going out cut that 73 about a
|
||||||
|
// second in, and the station we had just worked never got it. Reported from the
|
||||||
|
// air, and the sequence is exactly this one: his RRR is decoded, the QSO reads
|
||||||
|
// as finished, and the next station is picked in the same instant.
|
||||||
|
//
|
||||||
|
// The slot is worth having, but not at the price of the QSO in hand. The next
|
||||||
|
// period is a fifteen-second wait and everything worth calling is still there.
|
||||||
func (e *Engine) pick(p Period, why string) Action {
|
func (e *Engine) pick(p Period, why string) Action {
|
||||||
|
if e.trace != nil {
|
||||||
|
e.tracePick(p)
|
||||||
|
}
|
||||||
|
if p.TX.Transmitting {
|
||||||
|
if why != "" {
|
||||||
|
return Action{Kind: DoNothing, Reason: why + " — holding until the transmission ends"}
|
||||||
|
}
|
||||||
|
return Action{}
|
||||||
|
}
|
||||||
only := onlyList(e.set.Only)
|
only := onlyList(e.set.Only)
|
||||||
var best *Candidate
|
var best *Candidate
|
||||||
var bestRank int
|
var bestRank int
|
||||||
// bestRank of everything eligible, rested or not: a station that is resting
|
// The best station that is wanted, decoded, and working somebody else. It
|
||||||
// may only be re-picked while nothing better is on the air, and that is the
|
// is not a candidate — it cannot answer — but it is the reason the engine
|
||||||
// comparison.
|
// is silent, and the operator is entitled to know that.
|
||||||
topRank := 0
|
waiting, waitingRank := "", 0
|
||||||
for i := range p.Decodes {
|
for i := range p.Decodes {
|
||||||
c := p.Decodes[i]
|
c := p.Decodes[i]
|
||||||
if !e.eligible(c, p, only) {
|
if ok, why := e.judge(c, p, only); !ok {
|
||||||
continue
|
if why == "busy" {
|
||||||
}
|
if r := rank(c); r > waitingRank {
|
||||||
if r := rank(c); r > topRank {
|
waiting, waitingRank = strings.ToUpper(c.Call), r
|
||||||
topRank = r
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for i := range p.Decodes {
|
|
||||||
c := p.Decodes[i]
|
|
||||||
if !e.eligible(c, p, only) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
r := rank(c)
|
r := rank(c)
|
||||||
if resting, ok := e.rested[strings.ToUpper(c.Call)]; ok && p.At.Before(resting) {
|
|
||||||
// Rested: allowed back only when it is the best thing there is.
|
|
||||||
// Anything of higher rank takes the slot instead.
|
|
||||||
if r < topRank {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if best == nil || better(c, r, *best, bestRank, p.MyCall) {
|
if best == nil || better(c, r, *best, bestRank, p.MyCall) {
|
||||||
cc := c
|
cc := c
|
||||||
best, bestRank = &cc, r
|
best, bestRank = &cc, r
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
e.waiting = waiting
|
||||||
if best == nil {
|
if best == nil {
|
||||||
|
if waiting != "" {
|
||||||
|
return Action{Kind: DoNothing,
|
||||||
|
Reason: fmt.Sprintf("waiting for %s — it is working somebody else", waiting)}
|
||||||
|
}
|
||||||
if why != "" {
|
if why != "" {
|
||||||
return Action{Kind: DoNothing, Reason: why + " — nothing else worth calling"}
|
return Action{Kind: DoNothing, Reason: why + " — nothing else worth calling"}
|
||||||
}
|
}
|
||||||
return Action{}
|
return Action{}
|
||||||
}
|
}
|
||||||
|
e.waiting = ""
|
||||||
e.target, e.heldSince, e.targetInst = best, p.At, best.Instance
|
e.target, e.heldSince, e.targetInst = best, p.At, best.Instance
|
||||||
e.attempts, e.misses, e.txSlot, e.lastMiss = 0, 0, -1, ""
|
// ITS SLOT IS KNOWN FROM THE DECODE THAT MADE IT A CANDIDATE.
|
||||||
reason := fmt.Sprintf("calling %s (%s%s)", best.Call, watchedTag(*best), best.Need)
|
//
|
||||||
|
// This used to start at -1, meaning "parity unknown", and with parity
|
||||||
|
// unknown the miss counter has nothing to filter on: every period without
|
||||||
|
// the station counted, INCLUDING the one we spend transmitting to it, where
|
||||||
|
// it cannot be decoded by definition. An operator answering a CQ saw the
|
||||||
|
// counter at two misses out of three before the station had had a single
|
||||||
|
// chance to come back — one bad period from being given up on.
|
||||||
|
e.attempts, e.misses, e.lastMiss = 0, 0, ""
|
||||||
|
e.txSlot = slotOf(p.At, periodSecs(p, *best))
|
||||||
|
e.answered = false
|
||||||
|
e.waiting = ""
|
||||||
|
reason := fmt.Sprintf("calling %s (%s%s)", best.Call, watchedTag(*best), needLabel(*best))
|
||||||
if why != "" {
|
if why != "" {
|
||||||
reason = why + " — " + reason
|
reason = why + " — " + reason
|
||||||
}
|
}
|
||||||
return Action{Kind: DoReply, Decode: best.Decode, Reason: reason}
|
return Action{Kind: DoReply, Decode: best.Decode, Reason: reason}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tracePick writes the one line that says what this period held and what it
|
||||||
|
// was worth. Counts by refusal, then the best few candidates in rank order —
|
||||||
|
// enough to see WHY a station on the screen was not the one called.
|
||||||
|
func (e *Engine) tracePick(p Period) {
|
||||||
|
only := onlyList(e.set.Only)
|
||||||
|
refused := map[string]int{}
|
||||||
|
// The callsigns too, not only the counts. "worked=2" answers "how many" and
|
||||||
|
// leaves "which" — and which is the whole question when an operator is
|
||||||
|
// looking at one station on the screen and asking why it was passed over.
|
||||||
|
who := map[string][]string{}
|
||||||
|
type scored struct {
|
||||||
|
c Candidate
|
||||||
|
r int
|
||||||
|
}
|
||||||
|
var ok []scored
|
||||||
|
for i := range p.Decodes {
|
||||||
|
if good, why := e.judge(p.Decodes[i], p, only); good {
|
||||||
|
ok = append(ok, scored{p.Decodes[i], rank(p.Decodes[i])})
|
||||||
|
} else {
|
||||||
|
refused[why]++
|
||||||
|
if len(who[why]) < 4 {
|
||||||
|
who[why] = append(who[why], p.Decodes[i].Call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(ok, func(i, j int) bool { return ok[i].r > ok[j].r })
|
||||||
|
best := make([]string, 0, 3)
|
||||||
|
for i := 0; i < len(ok) && i < 3; i++ {
|
||||||
|
best = append(best, fmt.Sprintf("%s(%s%s,%d dB,r%d)",
|
||||||
|
ok[i].c.Call, watchedTag(ok[i].c), needLabel(ok[i].c), ok[i].c.SNR, ok[i].r))
|
||||||
|
}
|
||||||
|
why := make([]string, 0, len(refused))
|
||||||
|
for _, k := range []string{"worked", "nothing-needed", "busy", "resting", "halted", "parked", "hidden", "not-chased", "replay", "self"} {
|
||||||
|
if n := refused[k]; n > 0 {
|
||||||
|
names := strings.Join(who[k], ",")
|
||||||
|
if n > len(who[k]) {
|
||||||
|
names += ",…"
|
||||||
|
}
|
||||||
|
why = append(why, fmt.Sprintf("%s=%d(%s)", k, n, names))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.trace("period %s rx=%q decodes=%d callable=%d [%s] refused[%s]%s",
|
||||||
|
p.Key, p.Instance, len(p.Decodes), len(ok), strings.Join(best, " "),
|
||||||
|
strings.Join(why, " "),
|
||||||
|
map[bool]string{true: " (transmitting)", false: ""}[p.TX.Transmitting])
|
||||||
|
}
|
||||||
func watchedTag(c Candidate) string {
|
func watchedTag(c Candidate) string {
|
||||||
if c.Watched {
|
if c.Watched {
|
||||||
return "watched "
|
return "watched "
|
||||||
@@ -604,30 +950,100 @@ func watchedTag(c Candidate) string {
|
|||||||
|
|
||||||
// eligible is every refusal that applies before a station is even ranked.
|
// eligible is every refusal that applies before a station is even ranked.
|
||||||
func (e *Engine) eligible(c Candidate, p Period, only []string) bool {
|
func (e *Engine) eligible(c Candidate, p Period, only []string) bool {
|
||||||
|
ok, _ := e.judge(c, p, only)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// judge is eligible() with the reason attached, in one word.
|
||||||
|
//
|
||||||
|
// The reason exists for the log. An auto-call that calls nobody is the hardest
|
||||||
|
// thing to argue with — the band is full, the panel shows a dozen stations
|
||||||
|
// wanted, and nothing goes out — and "twenty-three decodes, nineteen already
|
||||||
|
// worked, three greyed, one resting" answers it in a line where a boolean
|
||||||
|
// cannot.
|
||||||
|
func (e *Engine) judge(c Candidate, p Period, only []string) (bool, string) {
|
||||||
call := strings.ToUpper(strings.TrimSpace(c.Call))
|
call := strings.ToUpper(strings.TrimSpace(c.Call))
|
||||||
if call == "" || call == strings.ToUpper(p.MyCall) {
|
if call == "" || call == strings.ToUpper(p.MyCall) {
|
||||||
return false
|
return false, "self"
|
||||||
}
|
}
|
||||||
if !c.IsNew {
|
if !c.IsNew {
|
||||||
return false // replayed history: the station may be hours gone
|
return false, "replay" // the station may be hours gone
|
||||||
}
|
}
|
||||||
if len(only) > 0 {
|
if len(only) > 0 {
|
||||||
// The named stations, and each only until the QSO with it is made: an
|
// The named stations, and each only until the QSO with it is made: an
|
||||||
// explicit request is not a standing order to work the same station all
|
// explicit request is not a standing order to work the same station all
|
||||||
// evening. The others on the list stay callable.
|
// evening. The others on the list stay callable.
|
||||||
return inList(only, call) && !e.done[call] && !c.Worked
|
switch {
|
||||||
|
case !inList(only, call):
|
||||||
|
return false, "not-chased"
|
||||||
|
case e.greyed[call]:
|
||||||
|
return false, "halted"
|
||||||
|
case e.done[call] || c.Worked:
|
||||||
|
return false, "worked"
|
||||||
|
}
|
||||||
|
return true, ""
|
||||||
}
|
}
|
||||||
if c.Worked || e.done[call] {
|
if c.Worked || e.done[call] {
|
||||||
return false
|
return false, "worked"
|
||||||
|
}
|
||||||
|
if e.greyed[call] {
|
||||||
|
return false, "halted"
|
||||||
|
}
|
||||||
|
// A STATION CALLING US is answered, needed or not.
|
||||||
|
//
|
||||||
|
// It has heard us, it is waiting, and the QSO is one over away — refusing it
|
||||||
|
// because the log has nothing to gain is how an operator finishes a contact,
|
||||||
|
// sees two stations calling them on the next sequence, and watches the
|
||||||
|
// machine ignore both. Everything below this point is about choosing whom to
|
||||||
|
// CALL; a caller is not chosen, it is already there.
|
||||||
|
//
|
||||||
|
// The refusals above still apply — worked on this band and mode, the QSO
|
||||||
|
// already made, a station the operator stopped. Those are answers about this
|
||||||
|
// station, not about the log's appetite.
|
||||||
|
if callingUs(c, p.MyCall) {
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
// "LoTW users only" is a rule about STRANGERS. It cannot overrule the watch
|
||||||
|
// list: naming a callsign is the operator saying they want that station, and
|
||||||
|
// a DXpedition that uploads nowhere is exactly the kind of station one puts
|
||||||
|
// on the list. Reported from the air — a watched J38DX, plainly calling CQ,
|
||||||
|
// never called all evening because it is not in the LoTW user file.
|
||||||
|
// FILTERED OFF THE SCREEN. The operator's own filters, applied to the
|
||||||
|
// transmitter: what is not shown is not called.
|
||||||
|
if e.set.OnScreenOnly && c.Hidden {
|
||||||
|
return false, "hidden"
|
||||||
}
|
}
|
||||||
if rank(c) == 0 {
|
if rank(c) == 0 {
|
||||||
return false
|
return false, "nothing-needed"
|
||||||
}
|
}
|
||||||
if e.rounds[call] >= e.set.MaxRounds {
|
// A WATCHED callsign is never parked.
|
||||||
return false // parked for the session
|
//
|
||||||
|
// Parking is the answer to "it will not answer, stop wasting the evening on
|
||||||
|
// it" — a fair verdict about a station the LOG picked out, and the wrong one
|
||||||
|
// about a station the OPERATOR did. A DXpedition running a pileup takes more
|
||||||
|
// than two series of calls to get through to, which is precisely why it was
|
||||||
|
// put on the list; watched ZD8GB was refused for the rest of the session
|
||||||
|
// after fourteen unanswered calls, while it went on transmitting six streams
|
||||||
|
// a period.
|
||||||
|
//
|
||||||
|
// The rest between series still applies, so it does not monopolise the
|
||||||
|
// transmitter — it simply never becomes ineligible.
|
||||||
|
if e.rounds[call] >= e.set.MaxRounds && !c.Watched {
|
||||||
|
return false, "parked" // its series are spent for the session
|
||||||
|
}
|
||||||
|
// RESTING. A series that ended in a brake is followed by a real pause,
|
||||||
|
// whatever else is on the air — the exception used to be "unless nothing
|
||||||
|
// better is decoding", and for the station everybody is chasing that is
|
||||||
|
// every period: seven calls, a halt, and the same station called again four
|
||||||
|
// seconds later. From the outside that is not a rest, it is a stutter.
|
||||||
|
if until, ok := e.rested[call]; ok && p.At.Before(until) {
|
||||||
|
return false, "resting"
|
||||||
}
|
}
|
||||||
// Mid-exchange with somebody else: it cannot answer us — see callable.
|
// Mid-exchange with somebody else: it cannot answer us — see callable.
|
||||||
return callable(c, p.MyCall)
|
if !callable(c, p.MyCall) {
|
||||||
|
return false, "busy"
|
||||||
|
}
|
||||||
|
return true, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// better orders two eligible stations: the need first, then the one already
|
// better orders two eligible stations: the need first, then the one already
|
||||||
@@ -645,11 +1061,78 @@ func better(a Candidate, ra int, b Candidate, rb int, myCall string) bool {
|
|||||||
return a.SNR > b.SNR
|
return a.SNR > b.SNR
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// preempt hands the slot to a better station, if one is on the air.
|
||||||
|
func (e *Engine) preempt(p Period) (Action, bool) {
|
||||||
|
if e.target == nil || p.TX.Transmitting {
|
||||||
|
// Mid-over: the reply would switch the decoder's call in the middle of
|
||||||
|
// a transmission. It waits for the gap, like every other call.
|
||||||
|
return Action{}, false
|
||||||
|
}
|
||||||
|
held := rank(*e.target)
|
||||||
|
heldSeen := bestOf(p.Decodes, strings.ToUpper(e.target.Call)) != nil
|
||||||
|
only := onlyList(e.set.Only)
|
||||||
|
best, bestRank := (*Candidate)(nil), held
|
||||||
|
for i := range p.Decodes {
|
||||||
|
c := p.Decodes[i]
|
||||||
|
if strings.EqualFold(c.Call, e.target.Call) || !e.eligible(c, p, only) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Strictly better takes over. So does an EQUAL rung when the station we
|
||||||
|
// are calling is not even on the air this period: the seven calls are
|
||||||
|
// going into the void while a station of the same value is calling CQ
|
||||||
|
// three rows above it, which is what an operator sees and cannot explain.
|
||||||
|
r := rank(c)
|
||||||
|
switch {
|
||||||
|
case r > bestRank:
|
||||||
|
case r == bestRank && !heldSeen && best == nil:
|
||||||
|
case best != nil && r == bestRank && better(c, r, *best, bestRank, p.MyCall):
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
{
|
||||||
|
cc := c
|
||||||
|
best, bestRank = &cc, r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best == nil {
|
||||||
|
return Action{}, false
|
||||||
|
}
|
||||||
|
was := strings.ToUpper(e.target.Call)
|
||||||
|
e.drop()
|
||||||
|
e.target, e.heldSince, e.targetInst = best, p.At, best.Instance
|
||||||
|
return Action{Kind: DoReply, Decode: best.Decode,
|
||||||
|
Reason: fmt.Sprintf("%s (%s%s) takes over from %s — nothing had been answered yet",
|
||||||
|
best.Call, watchedTag(*best), needLabel(*best), was)}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// drop lets the target go with no verdict attached: not worked, not given up
|
||||||
|
// on, not rested. It is simply not the station to be calling right now, and it
|
||||||
|
// is picked again like any other the moment it is.
|
||||||
|
func (e *Engine) drop() {
|
||||||
|
e.target, e.attempts, e.misses, e.txSlot, e.lastMiss = nil, 0, 0, -1, ""
|
||||||
|
e.targetInst, e.answered, e.seenKey = "", false, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// addressee is who a message is being sent to — the first token, which is the
|
||||||
|
// callsign being answered — the first segment of a multi-answer line, which is
|
||||||
|
// the QSO it is finishing.
|
||||||
|
func addressee(msg string) string {
|
||||||
|
parts := messages(msg)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return "someone else"
|
||||||
|
}
|
||||||
|
toks := strings.Fields(parts[0])
|
||||||
|
if len(toks) == 0 {
|
||||||
|
return "someone else"
|
||||||
|
}
|
||||||
|
return bare(toks[0])
|
||||||
|
}
|
||||||
|
|
||||||
// release clears the target. gaveUp marks it as a series that ended in a brake
|
// release clears the target. gaveUp marks it as a series that ended in a brake
|
||||||
// rather than in a QSO.
|
// rather than in a QSO.
|
||||||
func (e *Engine) release(call string, gaveUp bool) {
|
func (e *Engine) release(call string, gaveUp bool) {
|
||||||
e.target, e.attempts, e.misses, e.txSlot, e.lastMiss = nil, 0, 0, -1, ""
|
e.target, e.attempts, e.misses, e.txSlot, e.lastMiss = nil, 0, 0, -1, ""
|
||||||
e.targetInst = ""
|
e.targetInst, e.answered = "", false
|
||||||
if !gaveUp {
|
if !gaveUp {
|
||||||
// A finished QSO is not a failed series — the callsign keeps its rounds —
|
// A finished QSO is not a failed series — the callsign keeps its rounds —
|
||||||
// but it IS finished: nothing more is wanted from this station today,
|
// but it IS finished: nothing more is wanted from this station today,
|
||||||
@@ -662,7 +1145,11 @@ func (e *Engine) release(call string, gaveUp bool) {
|
|||||||
func (e *Engine) giveUp(call string, t Candidate) {
|
func (e *Engine) giveUp(call string, t Candidate) {
|
||||||
e.release(call, true)
|
e.release(call, true)
|
||||||
e.rounds[call]++
|
e.rounds[call]++
|
||||||
e.rested[call] = time.Now().Add(e.set.Rest)
|
at := e.now
|
||||||
|
if at.IsZero() {
|
||||||
|
at = time.Now()
|
||||||
|
}
|
||||||
|
e.rested[call] = at.Add(restFor(e.set.RestPeriods, candidateSecs(t)))
|
||||||
// An explicit "call this station" that runs out of attempts stops the
|
// An explicit "call this station" that runs out of attempts stops the
|
||||||
// feature instead of moving on. There is nothing else it was asked to do.
|
// feature instead of moving on. There is nothing else it was asked to do.
|
||||||
if strings.TrimSpace(e.set.Only) != "" {
|
if strings.TrimSpace(e.set.Only) != "" {
|
||||||
@@ -693,19 +1180,32 @@ func (e *Engine) NoteTX(tx TXState) Action {
|
|||||||
return Action{}
|
return Action{}
|
||||||
}
|
}
|
||||||
t := *e.target
|
t := *e.target
|
||||||
tc := strings.ToUpper(t.Call)
|
tc := bare(t.Call)
|
||||||
msg := strings.ToUpper(strings.TrimSpace(tx.Msg))
|
msg := strings.ToUpper(strings.TrimSpace(tx.Msg))
|
||||||
switch {
|
switch {
|
||||||
case msg != "":
|
case msg != "":
|
||||||
toks := strings.Fields(msg)
|
toks := strings.Fields(msg)
|
||||||
if len(toks) < 3 || toks[0] != tc || !isGrid(toks[2]) {
|
// bare(): a decoder compresses a non-standard callsign into angle
|
||||||
|
// brackets — "<HP/WE9G> F4BPO JN36" is a call to HP/WE9G — and comparing
|
||||||
|
// the raw token left the counter at 0/15 while the operator watched call
|
||||||
|
// after call go out. Every brake downstream of it was dead too.
|
||||||
|
if len(toks) < 3 || bare(toks[0]) != tc {
|
||||||
|
return Action{}
|
||||||
|
}
|
||||||
|
if !isGrid(toks[2]) {
|
||||||
|
// A report, an R-report, RR73: we are INSIDE the exchange, not
|
||||||
|
// opening it. It does not count as a call — and it settles that
|
||||||
|
// nothing may take this station's place, which matters when the
|
||||||
|
// decoder is answering somebody the engine never picked (the
|
||||||
|
// operator double-clicked a row) and no reply has been decoded yet.
|
||||||
|
e.answered = true
|
||||||
return Action{}
|
return Action{}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
// JTDX reports no transmit message. The DX call is all there is, so
|
// JTDX reports no transmit message. The DX call is all there is, so
|
||||||
// every transmit period aimed at the target counts — more generous, and
|
// every transmit period aimed at the target counts — more generous, and
|
||||||
// far better than a counter frozen at zero for ever.
|
// far better than a counter frozen at zero for ever.
|
||||||
if strings.ToUpper(strings.TrimSpace(tx.DXCall)) != tc {
|
if bare(tx.DXCall) != tc {
|
||||||
return Action{}
|
return Action{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -715,7 +1215,8 @@ func (e *Engine) NoteTX(tx TXState) Action {
|
|||||||
}
|
}
|
||||||
max := e.maxAttempts(t)
|
max := e.maxAttempts(t)
|
||||||
e.giveUp(tc, t)
|
e.giveUp(tc, t)
|
||||||
return Action{Kind: DoHalt, Reason: fmt.Sprintf("%s called %d times without an answer", tc, max)}
|
return Action{Kind: DoHalt, Soft: true,
|
||||||
|
Reason: fmt.Sprintf("%s called %d times without an answer — stopping after this over", tc, max)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// bestOf returns the most callable decode of one station in a period.
|
// bestOf returns the most callable decode of one station in a period.
|
||||||
@@ -750,6 +1251,28 @@ func workedNow(decodes []Candidate, call string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// restFor turns a number of the station's own overs into a deadline.
|
||||||
|
//
|
||||||
|
// A station transmits every OTHER slot, so one of its overs is two T/R periods —
|
||||||
|
// and the extra half-cycle is what makes the answer stable: without it the
|
||||||
|
// deadline lands exactly on the station's next transmission, where a
|
||||||
|
// millisecond of clock skew decides whether it is called or passed over. Landing
|
||||||
|
// it mid-cycle makes "sit out one over" mean one over, every time.
|
||||||
|
func restFor(overs, trSec int) time.Duration {
|
||||||
|
if overs <= 0 {
|
||||||
|
return time.Duration(trSec) * time.Second // no rest: its next over will do
|
||||||
|
}
|
||||||
|
return time.Duration(overs*2*trSec+trSec) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidateSecs is the station's own T/R period in seconds, defaulting to FT8's.
|
||||||
|
func candidateSecs(c Candidate) int {
|
||||||
|
if c.TRPeriod > 0 {
|
||||||
|
return c.TRPeriod
|
||||||
|
}
|
||||||
|
return 15
|
||||||
|
}
|
||||||
|
|
||||||
func periodSecs(p Period, c Candidate) int {
|
func periodSecs(p Period, c Candidate) int {
|
||||||
if c.TRPeriod > 0 {
|
if c.TRPeriod > 0 {
|
||||||
return c.TRPeriod
|
return c.TRPeriod
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package autocall
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -56,13 +57,17 @@ func on() *Engine { return New(Settings{Enabled: true}) }
|
|||||||
// ── The ladder ────────────────────────────────────────────────────────────
|
// ── The ladder ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestLadderOrder(t *testing.T) {
|
func TestLadderOrder(t *testing.T) {
|
||||||
// Every rung, in the order the operator asked for.
|
// Every rung. The watched ones come FIRST, ordered among themselves by what
|
||||||
|
// is needed — the list is the operator's answer, not a tie-breaker.
|
||||||
order := []Candidate{
|
order := []Candidate{
|
||||||
cq("A", NeedDXCC, 0, watched), cq("B", NeedDXCC, 0),
|
cq("A", NeedDXCC, 0, watched), cq("C", NeedBand, 0, watched),
|
||||||
cq("C", NeedBand, 0, watched), cq("D", NeedBand, 0),
|
cq("E", NeedMode, 0, watched), cq("G", NeedSlot, 0, watched),
|
||||||
cq("E", NeedMode, 0, watched), cq("F", NeedMode, 0),
|
|
||||||
cq("G", NeedSlot, 0, watched), cq("H", NeedSlot, 0),
|
|
||||||
cq("I", NeedNone, 0, watched),
|
cq("I", NeedNone, 0, watched),
|
||||||
|
cq("B", NeedDXCC, 0), cq("D", NeedBand, 0),
|
||||||
|
cq("F", NeedMode, 0), cq("H", NeedSlot, 0),
|
||||||
|
// The orthogonal markers sit at the foot of the ladder: worth a call
|
||||||
|
// when nothing better is on the air, never worth leaving a band for.
|
||||||
|
cq("J", NeedExtra, 0),
|
||||||
}
|
}
|
||||||
for i := 1; i < len(order); i++ {
|
for i := 1; i < len(order); i++ {
|
||||||
if rank(order[i-1]) <= rank(order[i]) {
|
if rank(order[i-1]) <= rank(order[i]) {
|
||||||
@@ -76,10 +81,17 @@ func TestLadderOrder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// And the pick agrees with the ladder, whatever order the period lists them.
|
// And the pick agrees with the ladder, whatever order the period lists them.
|
||||||
e := on()
|
e := on()
|
||||||
a := e.OnPeriod(period(0, order[7], order[3], order[0], order[5]))
|
a := e.OnPeriod(period(0, order[8], order[5], order[0], order[6]))
|
||||||
if a.Kind != DoReply || a.Decode.Call != "A" {
|
if a.Kind != DoReply || a.Decode.Call != "A" {
|
||||||
t.Fatalf("picked %+v, want the watched new entity", a)
|
t.Fatalf("picked %+v, want the watched new entity", a)
|
||||||
}
|
}
|
||||||
|
// A watched station with NOTHING needed still beats a new entity that is not
|
||||||
|
// watched — the case that sent an operator hunting: a watched DXpedition sat
|
||||||
|
// on the band all evening while the engine worked what the log wanted.
|
||||||
|
e = on()
|
||||||
|
if a := e.OnPeriod(period(2, cq("RARE", NeedDXCC, 0), cq("WATCHED", NeedNone, -20, watched))); a.Decode.Call != "WATCHED" {
|
||||||
|
t.Errorf("picked %q, want the watched callsign", a.Decode.Call)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStrongestWinsBetweenEquals(t *testing.T) {
|
func TestStrongestWinsBetweenEquals(t *testing.T) {
|
||||||
@@ -232,24 +244,56 @@ func TestAReleasedStationYieldsToAnythingBetter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAReleasedStationIsCalledAgainWhenNothingBetterIsOnTheAir(t *testing.T) {
|
// A series that ended in a brake is followed by ONE of the station's own overs
|
||||||
|
// passed over — long enough to be a pause, short enough that the DX everybody
|
||||||
|
// is chasing is still there when the calling resumes. It used to be two
|
||||||
|
// minutes, which on FT8 is four overs: by then the DX has worked four other
|
||||||
|
// callers, and half the time it has gone.
|
||||||
|
func TestAReleasedStationSitsOutOneOfItsOvers(t *testing.T) {
|
||||||
e := on()
|
e := on()
|
||||||
e.OnPeriod(period(0, cq("DX", NeedBand, -5)))
|
e.OnPeriod(period(0, cq("DX", NeedBand, -5)))
|
||||||
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
|
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
|
||||||
for i := 0; i < 7; i++ {
|
for i := 0; i < 7; i++ {
|
||||||
e.NoteTX(tx)
|
e.NoteTX(tx)
|
||||||
}
|
}
|
||||||
a := e.OnPeriod(period(2, cq("DX", NeedBand, -5)))
|
// Its very next over is passed over, whatever else is on the air: seven
|
||||||
if a.Kind != DoReply || a.Decode.Call != "DX" {
|
// calls, a halt, and the same station called again four seconds later is not
|
||||||
t.Errorf("nothing better on the air and the station was not called again: %+v", a)
|
// a rest, it is a stutter.
|
||||||
|
if a := e.OnPeriod(period(2, cq("DX", NeedBand, -5))); a.Kind != DoNothing {
|
||||||
|
t.Fatalf("called again during the rest: %+v", a)
|
||||||
|
}
|
||||||
|
// And the one after that goes: still there, still wanted, and the operator
|
||||||
|
// has listened through an over.
|
||||||
|
if a := e.OnPeriod(period(4, cq("DX", NeedBand, -5))); a.Kind != DoReply {
|
||||||
|
t.Errorf("after one over: %+v, want the station called again", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheAttemptsCapLetsTheOverFinish(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
||||||
|
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
|
||||||
|
var a Action
|
||||||
|
for i := 0; i < 7; i++ {
|
||||||
|
a = e.NoteTX(tx)
|
||||||
|
}
|
||||||
|
if a.Kind != DoHalt {
|
||||||
|
t.Fatalf("no halt after seven calls: %+v", a)
|
||||||
|
}
|
||||||
|
// The cap trips WHILE the seventh call is going out — cutting the carrier
|
||||||
|
// there sends half a call. It asks the decoder to finish the over first.
|
||||||
|
if !a.Soft {
|
||||||
|
t.Error("the attempts cap cut the transmission that counted it")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAStationIsParkedAfterItsRounds(t *testing.T) {
|
func TestAStationIsParkedAfterItsRounds(t *testing.T) {
|
||||||
e := on()
|
e := on()
|
||||||
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
|
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
|
||||||
|
// Rounds are spaced past the rest (2 min = 8 periods) or the station would
|
||||||
|
// simply be resting rather than parked.
|
||||||
for round := 1; round <= 3; round++ {
|
for round := 1; round <= 3; round++ {
|
||||||
a := e.OnPeriod(period(round*2, cq("DX", NeedBand, -5)))
|
a := e.OnPeriod(period(round*10, cq("DX", NeedBand, -5)))
|
||||||
if a.Kind != DoReply {
|
if a.Kind != DoReply {
|
||||||
t.Fatalf("round %d: not called (%+v)", round, a)
|
t.Fatalf("round %d: not called (%+v)", round, a)
|
||||||
}
|
}
|
||||||
@@ -259,7 +303,7 @@ func TestAStationIsParkedAfterItsRounds(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Three series of seven is twenty-one calls. That is the end of it for this
|
// Three series of seven is twenty-one calls. That is the end of it for this
|
||||||
// session — the whole point of the exercise is that it cannot reach fifty.
|
// session — the whole point of the exercise is that it cannot reach fifty.
|
||||||
if a := e.OnPeriod(period(20, cq("DX", NeedBand, -5))); a.Kind != DoNothing {
|
if a := e.OnPeriod(period(60, cq("DX", NeedBand, -5))); a.Kind != DoNothing {
|
||||||
t.Errorf("a fourth series was started: %+v", a)
|
t.Errorf("a fourth series was started: %+v", a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,9 +315,13 @@ func TestFinishedQSOMovesToTheNextPriority(t *testing.T) {
|
|||||||
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
||||||
done := callsMe("DX", NeedDXCC, -5)
|
done := callsMe("DX", NeedDXCC, -5)
|
||||||
done.Msg = me + " DX RR73"
|
done.Msg = me + " DX RR73"
|
||||||
a := e.OnPeriod(period(2, done, cq("NEXT", NeedBand, -10)))
|
// The period the QSO ends in belongs to our own 73 — nothing else is called.
|
||||||
|
if a := e.OnPeriod(period(2, done, cq("NEXT", NeedBand, -10))); a.Kind != DoNothing {
|
||||||
|
t.Fatalf("took the slot our 73 goes out in: %+v", a)
|
||||||
|
}
|
||||||
|
a := e.OnPeriod(period(4, cq("NEXT", NeedBand, -10)))
|
||||||
if a.Kind != DoReply || a.Decode.Call != "NEXT" {
|
if a.Kind != DoReply || a.Decode.Call != "NEXT" {
|
||||||
t.Errorf("after the QSO ended: %+v, want the next priority in the same period", a)
|
t.Errorf("next period: %+v, want the next priority", a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,12 +330,14 @@ func TestFinishedQSOWithNoPriorityAnswersWhoeverIsCallingUs(t *testing.T) {
|
|||||||
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
||||||
done := callsMe("DX", NeedDXCC, -5)
|
done := callsMe("DX", NeedDXCC, -5)
|
||||||
done.Msg = me + " DX RR73"
|
done.Msg = me + " DX RR73"
|
||||||
// Two stations calling us, nothing needed from either: the strongest wins.
|
// Two stations calling us, nothing needed from either: the strongest wins —
|
||||||
|
// on the period after the one our 73 goes out in.
|
||||||
weak := callsMe("WEAK", NeedNone, -18)
|
weak := callsMe("WEAK", NeedNone, -18)
|
||||||
weak.Watched = true
|
weak.Watched = true
|
||||||
loud := callsMe("LOUD", NeedNone, -4)
|
loud := callsMe("LOUD", NeedNone, -4)
|
||||||
loud.Watched = true
|
loud.Watched = true
|
||||||
a := e.OnPeriod(period(2, done, weak, loud))
|
e.OnPeriod(period(2, done))
|
||||||
|
a := e.OnPeriod(period(4, weak, loud))
|
||||||
if a.Kind != DoReply || a.Decode.Call != "LOUD" {
|
if a.Kind != DoReply || a.Decode.Call != "LOUD" {
|
||||||
t.Errorf("answered %+v, want the strongest of the stations calling us", a)
|
t.Errorf("answered %+v, want the strongest of the stations calling us", a)
|
||||||
}
|
}
|
||||||
@@ -403,7 +453,8 @@ func TestChaseListTakesSeveralCallsigns(t *testing.T) {
|
|||||||
// single request.
|
// single request.
|
||||||
done := callsMe("VP6D", NeedDXCC, -22)
|
done := callsMe("VP6D", NeedDXCC, -22)
|
||||||
done.Msg = me + " VP6D RR73"
|
done.Msg = me + " VP6D RR73"
|
||||||
a = e.OnPeriod(period(4, done, cq("3Y0J", NeedSlot, -1)))
|
e.OnPeriod(period(4, done, cq("3Y0J", NeedSlot, -1)))
|
||||||
|
a = e.OnPeriod(period(6, cq("3Y0J", NeedSlot, -1)))
|
||||||
if a.Kind != DoReply || a.Decode.Call != "3Y0J" {
|
if a.Kind != DoReply || a.Decode.Call != "3Y0J" {
|
||||||
t.Errorf("after working VP6D: %+v, want the other station on the list", a)
|
t.Errorf("after working VP6D: %+v, want the other station on the list", a)
|
||||||
}
|
}
|
||||||
@@ -460,3 +511,468 @@ func TestTheOtherReceiverCannotBreakTheQSOInProgress(t *testing.T) {
|
|||||||
t.Errorf("after the QSO ended, the other receiver was still locked out: %+v", a)
|
t.Errorf("after the QSO ended, the other receiver was still locked out: %+v", a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The one reported from the air: his RRR arrives, we are sending our 73, and
|
||||||
|
// the engine picked the next station in the same instant. WSJT-X acts on a
|
||||||
|
// reply at once — it dropped the exchange and started calling the new station,
|
||||||
|
// cutting the 73 a second in.
|
||||||
|
func TestNothingIsCalledOverOurOwnTransmission(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
||||||
|
done := callsMe("DX", NeedDXCC, -5)
|
||||||
|
done.Msg = me + " DX RRR"
|
||||||
|
p := period(2, done, cq("NEXT", NeedBand, -10))
|
||||||
|
p.TX = TXState{Transmitting: true, Msg: "DX " + me + " 73"}
|
||||||
|
if a := e.OnPeriod(p); a.Kind != DoNothing {
|
||||||
|
t.Fatalf("called %+v while our own over was still going out", a)
|
||||||
|
}
|
||||||
|
// The QSO is still released — only the next call waits.
|
||||||
|
if e.Target() != "" {
|
||||||
|
t.Errorf("target = %q after the QSO finished, want none", e.Target())
|
||||||
|
}
|
||||||
|
// Next period, carrier down: now it may take the next station.
|
||||||
|
if a := e.OnPeriod(period(4, cq("NEXT", NeedBand, -10))); a.Kind != DoReply || a.Decode.Call != "NEXT" {
|
||||||
|
t.Errorf("once the transmission ended: %+v, want the next station called", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheHoldClockRestartsWhenTheStationAnswers(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
||||||
|
// Called for a long time — nearly the backstop — and then he answers.
|
||||||
|
for p := 2; p <= 14; p += 2 {
|
||||||
|
e.OnPeriod(period(p, cq("DX", NeedDXCC, -5)))
|
||||||
|
}
|
||||||
|
e.OnPeriod(period(16, callsMe("DX", NeedDXCC, -5)))
|
||||||
|
// The exchange must not be halted by a backstop that was counting the wait.
|
||||||
|
for _, p := range []int{18, 20} {
|
||||||
|
if a := e.OnPeriod(period(p, callsMe("DX", NeedDXCC, -5))); a.Kind == DoHalt {
|
||||||
|
t.Fatalf("period %d: halted an exchange in progress (%s)", p, a.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARealNeedOutranksAnUnconfirmedOne(t *testing.T) {
|
||||||
|
real := cq("REAL", NeedBand, -20)
|
||||||
|
unconf := cq("UNCONF", NeedBand, -2)
|
||||||
|
unconf.Unconfirmed = true
|
||||||
|
// Same need, and the unconfirmed one is 18 dB louder. The band never worked
|
||||||
|
// is still the catch: the other is a QSL to chase, not a QSO to make.
|
||||||
|
e := on()
|
||||||
|
if a := e.OnPeriod(period(0, unconf, real)); a.Decode.Call != "REAL" {
|
||||||
|
t.Errorf("picked %q, want the band never worked", a.Decode.Call)
|
||||||
|
}
|
||||||
|
// Watched changes that, and is meant to: the list is the operator's answer.
|
||||||
|
unconf.Watched = true
|
||||||
|
e = on()
|
||||||
|
if a := e.OnPeriod(period(0, unconf, real)); a.Decode.Call != "UNCONF" {
|
||||||
|
t.Errorf("picked %q, want the watched station", a.Decode.Call)
|
||||||
|
}
|
||||||
|
unconf.Watched = false
|
||||||
|
// Between two unwatched stations, an unconfirmed BAND still beats a real
|
||||||
|
// SLOT: the band is the bigger prize whatever state it is in.
|
||||||
|
slot := cq("SLOT", NeedSlot, 0)
|
||||||
|
e = on()
|
||||||
|
if a := e.OnPeriod(period(0, slot, unconf)); a.Decode.Call != "UNCONF" {
|
||||||
|
t.Errorf("picked %q, want the unconfirmed band over a real slot", a.Decode.Call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The operator's Halt ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHaltSetsTheStationAsideForTheSession(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
if a := e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))); a.Decode.Call != "DX" {
|
||||||
|
t.Fatalf("not called: %+v", a)
|
||||||
|
}
|
||||||
|
// The operator stops it mid-call.
|
||||||
|
if got := e.Halt(); got != "DX" {
|
||||||
|
t.Fatalf("Halt returned %q, want the station being called", got)
|
||||||
|
}
|
||||||
|
if e.Target() != "" {
|
||||||
|
t.Error("the target survived a halt")
|
||||||
|
}
|
||||||
|
// Still the loudest new entity on the air, period after period. It is the
|
||||||
|
// operator's verdict, so it is not called again — not next period, not in
|
||||||
|
// ten minutes.
|
||||||
|
for _, n := range []int{2, 4, 40} {
|
||||||
|
if a := e.OnPeriod(period(n, cq("DX", NeedDXCC, -5))); a.Kind != DoNothing {
|
||||||
|
t.Errorf("period %d: called a station the operator had stopped: %+v", n, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Naming it explicitly does not override the operator either.
|
||||||
|
e.SetSettings(Settings{Enabled: true, Only: "DX"})
|
||||||
|
if a := e.OnPeriod(period(42, cq("DX", NeedDXCC, -5))); a.Kind != DoNothing {
|
||||||
|
t.Errorf("the chase list overrode a halt: %+v", a)
|
||||||
|
}
|
||||||
|
// Switching auto-call off and on is what starts over.
|
||||||
|
e.Reset()
|
||||||
|
e.SetSettings(Settings{Enabled: true})
|
||||||
|
if a := e.OnPeriod(period(44, cq("DX", NeedDXCC, -5))); a.Kind != DoReply {
|
||||||
|
t.Errorf("after a restart the station is fair game again: %+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHaltWithNothingBeingCalledIsJustAHalt(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
if got := e.Halt(); got != "" {
|
||||||
|
t.Errorf("Halt returned %q with no target", got)
|
||||||
|
}
|
||||||
|
if e.Greylisted() != 0 {
|
||||||
|
t.Errorf("greylisted %d stations from an idle halt", e.Greylisted())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTraceSaysWhyNobodyWasCalled(t *testing.T) {
|
||||||
|
var lines []string
|
||||||
|
e := on()
|
||||||
|
e.SetTrace(func(f string, args ...any) { lines = append(lines, fmt.Sprintf(f, args...)) })
|
||||||
|
|
||||||
|
worked := cq("A", NeedDXCC, -5, worked)
|
||||||
|
nothing := cq("B", NeedNone, -5)
|
||||||
|
busyOne := busy("C", NeedBand, -5)
|
||||||
|
e.Halt() // nothing held: no-op, keeps the set empty
|
||||||
|
e.OnPeriod(period(0, worked, nothing, busyOne))
|
||||||
|
|
||||||
|
if len(lines) == 0 {
|
||||||
|
t.Fatal("tracing on and nothing was written")
|
||||||
|
}
|
||||||
|
got := lines[len(lines)-1]
|
||||||
|
for _, want := range []string{"decodes=3", "callable=0", "worked=1", "nothing-needed=1", "busy=1"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("trace %q does not say %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And when it DOES call, the line names the candidates it ranked.
|
||||||
|
lines = nil
|
||||||
|
e.OnPeriod(period(2, cq("DX", NeedBand, -9, watched)))
|
||||||
|
if len(lines) == 0 || !strings.Contains(lines[0], "DX(watched band,-9 dB,r118)") {
|
||||||
|
t.Errorf("trace %v does not describe the station it called", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAStationCallingUsIsAnsweredEvenWithNothingToGain(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
// A QSO has just ended and two stations are calling us. Neither is worth
|
||||||
|
// anything to the log — and both are worth answering: they have heard us,
|
||||||
|
// they are waiting, and the contact is one over away.
|
||||||
|
weak := callsMe("WEAK", NeedNone, -18)
|
||||||
|
loud := callsMe("LOUD", NeedNone, -3)
|
||||||
|
a := e.OnPeriod(period(0, weak, loud))
|
||||||
|
if a.Kind != DoReply || a.Decode.Call != "LOUD" {
|
||||||
|
t.Fatalf("answered %+v, want the strongest of the stations calling us", a)
|
||||||
|
}
|
||||||
|
// A caller already worked on this band and mode is a duplicate, not a QSO.
|
||||||
|
e = on()
|
||||||
|
done := callsMe("DUPE", NeedNone, -1)
|
||||||
|
done.Worked = true
|
||||||
|
if a := e.OnPeriod(period(2, done)); a.Kind != DoNothing {
|
||||||
|
t.Errorf("answered a station already in the log on this band and mode: %+v", a)
|
||||||
|
}
|
||||||
|
// And one the operator halted stays halted, however politely it calls.
|
||||||
|
e = on()
|
||||||
|
e.OnPeriod(period(4, cq("STOP", NeedDXCC, -5)))
|
||||||
|
e.Halt()
|
||||||
|
if a := e.OnPeriod(period(6, callsMe("STOP", NeedDXCC, -5))); a.Kind != DoNothing {
|
||||||
|
t.Errorf("answered a station the operator had stopped: %+v", a)
|
||||||
|
}
|
||||||
|
// A needed station still comes first: a caller with nothing to gain is
|
||||||
|
// answered when nothing better is on the air, which is what was asked for.
|
||||||
|
e = on()
|
||||||
|
if a := e.OnPeriod(period(8, callsMe("CALLER", NeedNone, 0), cq("RARE", NeedDXCC, -20))); a.Decode.Call != "RARE" {
|
||||||
|
t.Errorf("picked %q, want the new entity ahead of a caller with nothing needed", a.Decode.Call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAStationWorkingSomebodyElseIsNotCHOSEN — the test above is about a target
|
||||||
|
// already being called; this is about picking one. A reply to a decode in
|
||||||
|
// mid-exchange is a reply WSJT-X and JTDX may refuse outright, so it never
|
||||||
|
// starts a call there.
|
||||||
|
func TestAStationWorkingSomebodyElseIsNotCHOSEN(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
busyNow := busy("ON7GB", NeedSlot, +5)
|
||||||
|
busyNow.Msg = "PY2SAD ON7GB JO21"
|
||||||
|
if a := e.OnPeriod(period(0, busyNow)); a.Kind == DoReply {
|
||||||
|
t.Fatalf("called %+v — it is in a QSO with PY2SAD", a)
|
||||||
|
}
|
||||||
|
if e.Target() != "" {
|
||||||
|
t.Errorf("target is %q", e.Target())
|
||||||
|
}
|
||||||
|
// Its final frame is different: one period from free is the best moment
|
||||||
|
// there is to be calling it.
|
||||||
|
last := busy("ON7GB", NeedSlot, +5)
|
||||||
|
last.Msg = "PY2SAD ON7GB RR73"
|
||||||
|
if a := e.OnPeriod(period(2, last)); a.Kind != DoReply {
|
||||||
|
t.Errorf("%+v — a station on its last frame was not called", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFinalFrameToSomebodyElseIsNotABusyTarget(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
e.OnPeriod(period(0, cq("DX", NeedSlot, 0)))
|
||||||
|
// Sending RR73 to another station: one period from being free, and the best
|
||||||
|
// moment there is to be calling it.
|
||||||
|
wrap := busy("DX", NeedSlot, 0)
|
||||||
|
wrap.Msg = "PY2SAD DX RR73"
|
||||||
|
if a := e.OnPeriod(period(2, wrap)); a.Kind != DoNothing || e.Target() != "DX" {
|
||||||
|
t.Errorf("dropped a station that was finishing: %+v (target %q)", a, e.Target())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNothingHiddenByThePanelIsCalled(t *testing.T) {
|
||||||
|
e := New(Settings{Enabled: true, OnScreenOnly: true})
|
||||||
|
// The operator has filtered the list down to CQs: what is not on the screen
|
||||||
|
// is not called, whatever the log makes of it.
|
||||||
|
hidden := cq("RARE", NeedDXCC, 0)
|
||||||
|
hidden.Hidden = true
|
||||||
|
shown := cq("PLAIN", NeedSlot, -20)
|
||||||
|
if a := e.OnPeriod(period(0, hidden, shown)); a.Decode.Call != "PLAIN" {
|
||||||
|
t.Errorf("picked %q, want the station the panel is showing", a.Decode.Call)
|
||||||
|
}
|
||||||
|
// With the panel publishing nothing, nothing is hidden and the ladder
|
||||||
|
// decides alone.
|
||||||
|
e = New(Settings{Enabled: true, OnScreenOnly: true})
|
||||||
|
free := cq("RARE", NeedDXCC, 0)
|
||||||
|
if a := e.OnPeriod(period(2, free, shown)); a.Decode.Call != "RARE" {
|
||||||
|
t.Errorf("picked %q with no filtering in force, want the new entity", a.Decode.Call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MSHV answers two stations in one transmission and the decoder prints them as
|
||||||
|
// one line. Reported from the air: the DX was answering us in the second half
|
||||||
|
// and the engine, reading only the first, dropped it as busy.
|
||||||
|
func TestAMultiAnswerLineIsReadWhole(t *testing.T) {
|
||||||
|
fox := cq("HK0/PY8WW", NeedDXCC, -17, watched)
|
||||||
|
fox.CQ = false
|
||||||
|
fox.Msg = "YV5ALI RR73; " + me + " <HK0/PY8WW> -08"
|
||||||
|
|
||||||
|
if !callingUs(fox, me) {
|
||||||
|
t.Error("the second half is a report to us and was not read as one")
|
||||||
|
}
|
||||||
|
if !callable(fox, me) {
|
||||||
|
t.Error("a station answering us was judged uncallable")
|
||||||
|
}
|
||||||
|
// Held as the target, that line must not release it — it is the answer we
|
||||||
|
// were waiting for, not a station working somebody else.
|
||||||
|
e := on()
|
||||||
|
e.OnPeriod(period(0, cq("HK0/PY8WW", NeedDXCC, -17, watched)))
|
||||||
|
if a := e.OnPeriod(period(2, fox)); a.Kind != DoNothing || e.Target() != "HK0/PY8WW" {
|
||||||
|
t.Errorf("dropped the DX as it answered us: %+v (target %q)", a, e.Target())
|
||||||
|
}
|
||||||
|
// The exchange is under way, so the attempt counter has done its job.
|
||||||
|
if e.Status().Attempts != 0 {
|
||||||
|
t.Errorf("attempts = %d while in QSO, want 0", e.Status().Attempts)
|
||||||
|
}
|
||||||
|
// And its final frame to us, in the same shape, ends the QSO.
|
||||||
|
done := fox
|
||||||
|
done.Msg = "IK2ABC RR73; " + me + " <HK0/PY8WW> RR73"
|
||||||
|
if !finished([]Candidate{done}, "HK0/PY8WW", me) {
|
||||||
|
t.Error("a 73 to us inside a multi-answer line did not end the QSO")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reported from the air twice: the DX sends RR73, our own 73 is about to go
|
||||||
|
// out, and the engine answered somebody else in that very slot — two
|
||||||
|
// transmissions in one period, and the station at the other end never got the
|
||||||
|
// frame that closes the contact.
|
||||||
|
func TestTheSlotAfterAQSOBelongsToOur73(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
e.OnPeriod(period(0, cq("HP/WE9G", NeedDXCC, -6)))
|
||||||
|
bye := callsMe("HP/WE9G", NeedDXCC, -6)
|
||||||
|
bye.Msg = "<" + me + "> HP/WE9G RR73"
|
||||||
|
// A new band is decoding in the same period and is not called.
|
||||||
|
a := e.OnPeriod(period(2, bye, cq("GW8DX", NeedBand, +2)))
|
||||||
|
if a.Kind != DoNothing {
|
||||||
|
t.Fatalf("called %+v in the slot our 73 goes out in", a)
|
||||||
|
}
|
||||||
|
if !strings.Contains(a.Reason, "73") {
|
||||||
|
t.Errorf("reason %q does not say why the slot was left alone", a.Reason)
|
||||||
|
}
|
||||||
|
// It is still there fifteen seconds later, which is the whole cost.
|
||||||
|
if a := e.OnPeriod(period(4, cq("GW8DX", NeedBand, +2))); a.Kind != DoReply || a.Decode.Call != "GW8DX" {
|
||||||
|
t.Errorf("next period: %+v, want the new band called", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABetterStationTakesOverBeforeAnybodyHasAnswered(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
e.OnPeriod(period(0, cq("PLAIN", NeedSlot, +10)))
|
||||||
|
// A watched station comes on the air: nothing has answered us yet, so the
|
||||||
|
// call in progress is worth less than the one now possible.
|
||||||
|
a := e.OnPeriod(period(2, cq("PLAIN", NeedSlot, +10), cq("WATCHED", NeedNone, -15, watched)))
|
||||||
|
if a.Kind != DoReply || a.Decode.Call != "WATCHED" {
|
||||||
|
t.Fatalf("%+v — a better station did not take over", a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equal rungs do NOT take over while the station being called is on the air.
|
||||||
|
e = on()
|
||||||
|
e.OnPeriod(period(4, cq("A", NeedBand, 0)))
|
||||||
|
if a := e.OnPeriod(period(6, cq("A", NeedBand, 0), cq("B", NeedBand, +20))); a.Kind != DoNothing {
|
||||||
|
t.Errorf("%+v — swapped between two stations of equal value", a)
|
||||||
|
}
|
||||||
|
// But they do when it is absent: seven calls into the void while a station
|
||||||
|
// of the same value is calling CQ is what the operator was watching.
|
||||||
|
if a := e.OnPeriod(period(8, cq("B", NeedBand, +20))); a.Kind != DoReply || a.Decode.Call != "B" {
|
||||||
|
t.Errorf("%+v — kept calling a station that was not on the air", a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once the station has answered, nothing takes its place.
|
||||||
|
e = on()
|
||||||
|
e.OnPeriod(period(10, cq("DX", NeedSlot, 0)))
|
||||||
|
e.OnPeriod(period(12, callsMe("DX", NeedSlot, 0)))
|
||||||
|
if a := e.OnPeriod(period(14, callsMe("DX", NeedSlot, 0), cq("RARE", NeedDXCC, 0, watched))); a.Kind != DoNothing {
|
||||||
|
t.Errorf("%+v — abandoned an exchange in progress", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnExchangeInProgressIsNeverAbandoned is the shack report: mid-QSO with
|
||||||
|
// V31MA — its report decoded, our RR73 going out — and a station of a higher
|
||||||
|
// rung called us from the other side of the screen. The engine switched.
|
||||||
|
func TestAnExchangeInProgressIsNeverAbandoned(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
// V31MA is worth nothing on the ladder: worked before, nothing needed.
|
||||||
|
if a := e.OnPeriod(period(0, callsMe("V31MA", NeedNone, -12))); a.Kind != DoReply {
|
||||||
|
t.Fatalf("%+v — a station calling us was not answered", a)
|
||||||
|
}
|
||||||
|
// Its report arrives in the same period a better station calls us. That
|
||||||
|
// period used to be judged before the reply was taken into account.
|
||||||
|
a := e.OnPeriod(period(2,
|
||||||
|
callsMe("V31MA", NeedNone, -12),
|
||||||
|
callsMe("F5NNN", NeedSlot, -10)))
|
||||||
|
if a.Kind != DoNothing {
|
||||||
|
t.Fatalf("%+v — left V31MA mid-exchange", a)
|
||||||
|
}
|
||||||
|
if e.target == nil || e.target.Call != "V31MA" {
|
||||||
|
t.Fatalf("target is %v — the QSO in progress lost its place", e.target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Our own transmission settles it too: a report is not an opening call, so
|
||||||
|
// even a QSO the operator started by hand is protected.
|
||||||
|
e = on()
|
||||||
|
e.OnPeriod(period(4, cq("V31MA", NeedNone, -12, watched)))
|
||||||
|
e.NoteTX(TXState{Transmitting: true, Msg: "V31MA F4BPO RR73"})
|
||||||
|
if !e.answered {
|
||||||
|
t.Error("sending a report did not count as being inside the exchange")
|
||||||
|
}
|
||||||
|
if a := e.OnPeriod(period(6, cq("V31MA", NeedNone, -12, watched), callsMe("F5NNN", NeedDXCC, -10))); a.Kind != DoNothing {
|
||||||
|
t.Errorf("%+v — abandoned a QSO we were in the middle of", a)
|
||||||
|
}
|
||||||
|
if e.attempts != 0 {
|
||||||
|
t.Errorf("attempts=%d — an exchange frame was counted as a call", e.attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAPileupIsWorkedByCallingThroughIt is the shack report: D44TWO finishing a
|
||||||
|
// contact, the call started, the DX coming back to somebody else — and the
|
||||||
|
// engine giving up on a station that was one period from being free.
|
||||||
|
func TestAPileupIsWorkedByCallingThroughIt(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
if a := e.OnPeriod(period(0, cq("D44TWO", NeedDXCC, -7))); a.Kind != DoReply {
|
||||||
|
t.Fatalf("%+v", a)
|
||||||
|
}
|
||||||
|
// It answers three other callers in a row — which is what a DX with a queue
|
||||||
|
// does, and calling through it is how the queue is joined.
|
||||||
|
does := func(n int) Action { return e.OnPeriod(period(n, busy("D44TWO", NeedDXCC, -7))) }
|
||||||
|
for _, n := range []int{2, 4, 6} {
|
||||||
|
if a := does(n); a.Kind != DoNothing {
|
||||||
|
t.Fatalf("%+v — stopped calling a station working the pileup", a)
|
||||||
|
}
|
||||||
|
if e.Target() != "D44TWO" {
|
||||||
|
t.Fatalf("target is %q — the station was released while it worked others", e.Target())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// And when it comes to us, the exchange starts as usual.
|
||||||
|
if a := e.OnPeriod(period(8, callsMe("D44TWO", NeedDXCC, -7))); a.Kind != DoNothing {
|
||||||
|
t.Errorf("%+v", a)
|
||||||
|
}
|
||||||
|
if !e.answered {
|
||||||
|
t.Error("the reply was not taken as the start of the exchange")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A BETTER station may still take the slot while it is busy: nothing has
|
||||||
|
// been answered, so nothing is lost by moving.
|
||||||
|
e = on()
|
||||||
|
e.OnPeriod(period(10, cq("D44TWO", NeedSlot, -7)))
|
||||||
|
if a := e.OnPeriod(period(12, busy("D44TWO", NeedSlot, -7), cq("RARE", NeedDXCC, -20, watched))); a.Kind != DoReply || a.Decode.Call != "RARE" {
|
||||||
|
t.Errorf("%+v — a watched new one did not take the slot from a busy station", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTheFirstCallStartsWithNoMisses is the shack report: a CQ answered for the
|
||||||
|
// first time, and the toolbar already reading two misses out of three.
|
||||||
|
func TestTheFirstCallStartsWithNoMisses(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
// It calls CQ on an odd slot; we answer.
|
||||||
|
if a := e.OnPeriod(period(1, cq("ER1CW", NeedBand, -2))); a.Kind != DoReply {
|
||||||
|
t.Fatalf("%+v", a)
|
||||||
|
}
|
||||||
|
// The next period is OURS: we are transmitting to it, and of course it is
|
||||||
|
// not decoded. That is not a miss.
|
||||||
|
pp := period(2)
|
||||||
|
pp.TX = TXState{Transmitting: true}
|
||||||
|
e.OnPeriod(pp)
|
||||||
|
if e.misses != 0 {
|
||||||
|
t.Fatalf("misses=%d after our own transmit period", e.misses)
|
||||||
|
}
|
||||||
|
// Nor is a period of the wrong parity with nothing in it.
|
||||||
|
e.OnPeriod(period(4))
|
||||||
|
if e.misses != 0 {
|
||||||
|
t.Fatalf("misses=%d — counted a period the station never transmits in", e.misses)
|
||||||
|
}
|
||||||
|
// ITS period, and it is not there: that is a miss.
|
||||||
|
e.OnPeriod(period(3))
|
||||||
|
if e.misses != 1 {
|
||||||
|
t.Fatalf("misses=%d — the station's own silent period was not counted", e.misses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parking answers "it will not answer, stop wasting the evening on it" — which
|
||||||
|
// is a verdict about a station the LOG picked out, not about one the OPERATOR
|
||||||
|
// did. From the air: a watched ZD8GB, six streams a period, refused for the
|
||||||
|
// rest of the session after two series of unanswered calls.
|
||||||
|
func TestAWatchedCallsignIsNeverParked(t *testing.T) {
|
||||||
|
spent := func(c Candidate) *Engine {
|
||||||
|
e := New(Settings{Enabled: true, MaxRounds: 2})
|
||||||
|
e.OnPeriod(period(0)) // the engine's clock, so the rest below is period time
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
e.giveUp(strings.ToUpper(c.Call), c) // a series ended by a brake
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// Period 8, well past the rest that follows a series: what is left is the
|
||||||
|
// parking, and only the parking.
|
||||||
|
plain := cq("PLAIN", NeedDXCC, 0)
|
||||||
|
if a := spent(plain).OnPeriod(period(8, plain)); a.Kind == DoReply {
|
||||||
|
t.Errorf("%+v — a station whose series are spent was called again", a)
|
||||||
|
}
|
||||||
|
|
||||||
|
dx := cq("ZD8GB", NeedDXCC, 0, watched)
|
||||||
|
if a := spent(dx).OnPeriod(period(8, dx)); a.Kind != DoReply {
|
||||||
|
t.Errorf("%+v — a watched DXpedition was parked for the session", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A period is judged more than once — the decodes arrive in a burst and
|
||||||
|
// stragglers follow — and a later judgement of it holds a partial view, not
|
||||||
|
// evidence that the station has gone. From the air: D2ACE answered in the very
|
||||||
|
// period the counter then read as a miss.
|
||||||
|
func TestAPeriodTheTargetWasSeenInIsNeverAMiss(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
if a := e.OnPeriod(period(1, cq("D2ACE", NeedBand, 13))); a.Kind != DoReply {
|
||||||
|
t.Fatalf("%+v", a)
|
||||||
|
}
|
||||||
|
// Its own next period: decoded, so the exchange is under way.
|
||||||
|
p := period(3, callsMe("D2ACE", NeedBand, 13))
|
||||||
|
e.OnPeriod(p)
|
||||||
|
if e.Status().Misses != 0 {
|
||||||
|
t.Fatalf("misses=%d after being decoded", e.Status().Misses)
|
||||||
|
}
|
||||||
|
// The same period again, this time from a flush that carries only the
|
||||||
|
// stragglers — the target is not among them.
|
||||||
|
e.OnPeriod(period(3))
|
||||||
|
if got := e.Status().Misses; got != 0 {
|
||||||
|
t.Errorf("misses=%d — a second flush of a period it was seen in counted against it", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -832,6 +832,50 @@ func (m *Manager) IcomDo(fn func(IcomController) error) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SatTuner is a backend that can be put on a satellite: a receiver on one band
|
||||||
|
// and a transmitter on another, both moving under Doppler, at the same time.
|
||||||
|
//
|
||||||
|
// It is a separate interface from the per-manufacturer ones because what a
|
||||||
|
// satellite needs is not a manufacturer's feature — it is a shape of operating
|
||||||
|
// that a FlexRadio and an IC-9700 both provide and reach in completely
|
||||||
|
// different ways. A backend that cannot do it simply does not implement this,
|
||||||
|
// and the caller falls back to tuning the downlink alone rather than pretending.
|
||||||
|
type SatTuner interface {
|
||||||
|
// SetSatellite arms or disarms satellite operation: the rig's own satellite
|
||||||
|
// mode where it has one, two slices where it has those. Disarming must leave
|
||||||
|
// the radio somewhere an operator can work from, not half-configured.
|
||||||
|
SetSatellite(on bool) error
|
||||||
|
// TuneSatellite points the receiver at downHz and the transmitter at upHz,
|
||||||
|
// both already Doppler-corrected. Modes are ADIF names ("SSB", "FM", "CW");
|
||||||
|
// an empty one leaves that side's mode alone.
|
||||||
|
TuneSatellite(downHz, upHz int64, downMode, upMode string) error
|
||||||
|
// SatReceiveHz is where the receiver actually is. The operator tunes it to
|
||||||
|
// follow a station across a linear transponder, and that dial movement is
|
||||||
|
// the input the whole tracker works from — without reading it back, a
|
||||||
|
// tracker fights the operator instead of helping them.
|
||||||
|
SatReceiveHz() (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatCapable reports whether the active backend can hold a satellite pair.
|
||||||
|
func (m *Manager) SatCapable() bool {
|
||||||
|
m.mu.RLock()
|
||||||
|
b := m.backend
|
||||||
|
m.mu.RUnlock()
|
||||||
|
_, ok := b.(SatTuner)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatDo dispatches a satellite control onto the CAT goroutine.
|
||||||
|
func (m *Manager) SatDo(fn func(SatTuner) error) error {
|
||||||
|
return m.exec(func(b Backend) error {
|
||||||
|
st, ok := b.(SatTuner)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("this radio cannot hold a satellite pair from OpsLog")
|
||||||
|
}
|
||||||
|
return fn(st)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// exec marshals a backend operation onto the CAT goroutine. Returns the
|
// exec marshals a backend operation onto the CAT goroutine. Returns the
|
||||||
// operation's error or a "busy"/"not running" error if dispatch failed.
|
// operation's error or a "busy"/"not running" error if dispatch failed.
|
||||||
func (m *Manager) exec(fn func(Backend) error) error {
|
func (m *Manager) exec(fn func(Backend) error) error {
|
||||||
@@ -1101,6 +1145,23 @@ func (m *Manager) YaesuDo(fn func(YaesuController) error) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OmniRigController is the handful of OmniRig preferences that can be changed
|
||||||
|
// without dropping the rig link.
|
||||||
|
type OmniRigController interface {
|
||||||
|
SetCWLower(bool) // which of OmniRig's two CW bits means plain CW
|
||||||
|
}
|
||||||
|
|
||||||
|
// OmniRigDo dispatches an OmniRig preference onto the CAT goroutine.
|
||||||
|
func (m *Manager) OmniRigDo(fn func(OmniRigController) error) error {
|
||||||
|
return m.exec(func(b Backend) error {
|
||||||
|
oc, ok := b.(OmniRigController)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("active CAT backend is not OmniRig")
|
||||||
|
}
|
||||||
|
return fn(oc)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// KenwoodController is the Kenwood/Elecraft CW-over-CAT capability (the KY keyer),
|
// KenwoodController is the Kenwood/Elecraft CW-over-CAT capability (the KY keyer),
|
||||||
// so a K3 can key CW through its single CAT link instead of a second COM port.
|
// so a K3 can key CW through its single CAT link instead of a second COM port.
|
||||||
type KenwoodController interface {
|
type KenwoodController interface {
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ const (
|
|||||||
CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off)
|
CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off)
|
||||||
CmdRIT = 0x21 // RIT/ΔTX: sub 0x00 offset freq, 0x01 RIT on/off, 0x02 ΔTX(XIT) on/off
|
CmdRIT = 0x21 // RIT/ΔTX: sub 0x00 offset freq, 0x01 RIT on/off, 0x02 ΔTX(XIT) on/off
|
||||||
CmdSendCW = 0x17 // send a CW message (ASCII, ≤30 chars) via the rig's keyer; data 0xFF = stop
|
CmdSendCW = 0x17 // send a CW message (ASCII, ≤30 chars) via the rig's keyer; data 0xFF = stop
|
||||||
|
// CmdVFO selects which receiver subsequent commands address. On the two-band
|
||||||
|
// satellite rigs (IC-9700, IC-9100) the MAIN band is the downlink and the SUB
|
||||||
|
// band the uplink, so every satellite frequency set is "point at a band, then
|
||||||
|
// tune it".
|
||||||
|
CmdVFO = 0x07
|
||||||
|
|
||||||
SubLevelKeySpeed = 0x0C // CmdLevel: CW keying speed (0-255 → KeyMinWPM..KeyMaxWPM)
|
SubLevelKeySpeed = 0x0C // CmdLevel: CW keying speed (0-255 → KeyMinWPM..KeyMaxWPM)
|
||||||
|
|
||||||
@@ -112,6 +117,17 @@ const (
|
|||||||
SubSwBreakIn = 0x47 // CW break-in: 0=OFF, 1=SEMI, 2=FULL (needed so 0x17 CW keys TX)
|
SubSwBreakIn = 0x47 // CW break-in: 0=OFF, 1=SEMI, 2=FULL (needed so 0x17 CW keys TX)
|
||||||
SubSwMN = 0x48 // manual notch on/off
|
SubSwMN = 0x48 // manual notch on/off
|
||||||
SubSwAPF = 0x32 // audio peak filter on/off (CW only)
|
SubSwAPF = 0x32 // audio peak filter on/off (CW only)
|
||||||
|
// Satellite mode (IC-9700 / IC-9100). The rig's OWN satellite mode, not an
|
||||||
|
// imitation of one: it pairs main and sub, gives full duplex, and keeps the
|
||||||
|
// two dials linked the way the radio's designers meant. Asking it to do that
|
||||||
|
// is always better than building the same thing out of split.
|
||||||
|
SubSwSatellite = 0x5A
|
||||||
|
|
||||||
|
// CmdVFO sub-commands: which of a two-receiver rig's bands the next command
|
||||||
|
// addresses.
|
||||||
|
SubVFOMain = 0xD0 // MAIN band — the downlink in satellite mode
|
||||||
|
SubVFOSub = 0xD1 // SUB band — the uplink
|
||||||
|
SubVFOExchange = 0xB0 // swap main and sub
|
||||||
)
|
)
|
||||||
|
|
||||||
// CW break-in modes (CmdSwitch 0x47).
|
// CW break-in modes (CmdSwitch 0x47).
|
||||||
|
|||||||
+20
-3
@@ -1,5 +1,3 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package cat
|
package cat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -67,6 +65,14 @@ type Flex struct {
|
|||||||
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||||
pendingSpotMode map[int]string // seq → ADIF mode, paired with pendingSpot
|
pendingSpotMode map[int]string // seq → ADIF mode, paired with pendingSpot
|
||||||
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
||||||
|
pendingSat map[int]string // seq → "rx"/"tx", awaiting a satellite slice's index
|
||||||
|
// Satellite pair: slice A is the downlink, slice B the uplink. -1 when not
|
||||||
|
// armed. satCreatedTX marks an uplink slice OpsLog opened, and is the only
|
||||||
|
// one it will close again.
|
||||||
|
satOn bool
|
||||||
|
satRX int
|
||||||
|
satTX int
|
||||||
|
satCreatedTX bool
|
||||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||||
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
|
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
|
||||||
spotFreq map[int]int64 // spot index → Hz, so a click can report where it was (the trigger message carries only the index)
|
spotFreq map[int]int64 // spot index → Hz, so a click can report where it was (the trigger message carries only the index)
|
||||||
@@ -227,7 +233,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
|||||||
return &Flex{
|
return &Flex{
|
||||||
host: strings.TrimSpace(host), port: port,
|
host: strings.TrimSpace(host), port: port,
|
||||||
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
||||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotFreq: map[int]int64{}, pendingSpotFreq: map[int]int64{}, panWindow: map[string]panView{}, spotSig: map[string]string{}, spotSent: map[string]time.Time{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotFreq: map[int]int64{}, pendingSpotFreq: map[int]int64{}, panWindow: map[string]panView{}, spotSig: map[string]string{}, spotSent: map[string]time.Time{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{}, pendingSat: map[int]string{}, satRX: -1, satTX: -1,
|
||||||
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||||
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||||
pinnedSlice: -1,
|
pinnedSlice: -1,
|
||||||
@@ -458,12 +464,23 @@ func (f *Flex) reader(conn net.Conn) {
|
|||||||
if splitSeq {
|
if splitSeq {
|
||||||
delete(f.pendingSplit, seq)
|
delete(f.pendingSplit, seq)
|
||||||
}
|
}
|
||||||
|
// The same reply carries the index of a slice created for a satellite
|
||||||
|
// pair; which of the two it is was recorded when it was asked for.
|
||||||
|
satRole := f.pendingSat[seq]
|
||||||
|
if satRole != "" {
|
||||||
|
delete(f.pendingSat, seq)
|
||||||
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
if splitSeq && ok && len(parts) >= 3 {
|
if splitSeq && ok && len(parts) >= 3 {
|
||||||
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||||
f.send(fmt.Sprintf("slice s %d tx=1", idx))
|
f.send(fmt.Sprintf("slice s %d tx=1", idx))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if satRole != "" && ok && len(parts) >= 3 {
|
||||||
|
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||||
|
f.adoptSatSlice(satRole, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Connection ended.
|
// Connection ended.
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package cat
|
package cat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,8 +7,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/sys/windows"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// FlexRadio is one radio found by discovery.
|
// FlexRadio is one radio found by discovery.
|
||||||
@@ -38,7 +34,7 @@ func DiscoverFlex(timeout time.Duration) ([]FlexRadio, error) {
|
|||||||
Control: func(_, _ string, c syscall.RawConn) error {
|
Control: func(_, _ string, c syscall.RawConn) error {
|
||||||
var serr error
|
var serr error
|
||||||
_ = c.Control(func(fd uintptr) {
|
_ = c.Control(func(fd uintptr) {
|
||||||
serr = windows.SetsockoptInt(windows.Handle(fd), windows.SOL_SOCKET, windows.SO_REUSEADDR, 1)
|
serr = setSocketReuse(fd)
|
||||||
})
|
})
|
||||||
return serr
|
return serr
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Satellite operation on a FlexRadio.
|
||||||
|
//
|
||||||
|
// A Flex has no satellite mode, and does not need one: it has slices. Slice A
|
||||||
|
// is the downlink and slice B the uplink — the arrangement every Flex satellite
|
||||||
|
// operator already uses by hand — with the transmitter on B and full duplex on,
|
||||||
|
// so the operator hears their own signal come back through the transponder.
|
||||||
|
// The transverters that put 145 and 435 MHz within the radio's reach are
|
||||||
|
// configured in SmartSDR, and their offsets are the radio's business: OpsLog
|
||||||
|
// sends the real satellite frequency and SmartSDR does the arithmetic.
|
||||||
|
//
|
||||||
|
// The two slices are CREATED when they are missing, because "slice B does not
|
||||||
|
// exist" is not a thing to make the operator fix at the start of a ten-minute
|
||||||
|
// pass. Only what OpsLog created is taken away again on disarming: a slice the
|
||||||
|
// operator opened is theirs.
|
||||||
|
|
||||||
|
// SetSatellite arranges (or unwinds) the two-slice satellite pair.
|
||||||
|
func (f *Flex) SetSatellite(on bool) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
connected := f.conn != nil
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
if !on {
|
||||||
|
return f.satDisarm()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The downlink slice is the one the operator is already on: taking the
|
||||||
|
// active slice rather than insisting on index 0 means arming the satellite
|
||||||
|
// does not move them off the receiver they were listening to.
|
||||||
|
f.mu.Lock()
|
||||||
|
rxIdx, _ := f.mainSliceLocked()
|
||||||
|
var txIdx = -1
|
||||||
|
for _, idx := range f.sortedSliceIdxLocked() {
|
||||||
|
if s := f.slices[idx]; s != nil && s.inUse && idx != rxIdx {
|
||||||
|
txIdx = idx
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.satRX, f.satTX = rxIdx, txIdx
|
||||||
|
f.satOn = true
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
// Full duplex before anything else: without it the radio mutes the receiver
|
||||||
|
// on transmit, and an operator who cannot hear their own downlink has no way
|
||||||
|
// to know they are in the passband at all.
|
||||||
|
f.send("radio set full_duplex_enabled=1")
|
||||||
|
|
||||||
|
if rxIdx < 0 {
|
||||||
|
// A radio with no slice at all. One is created; the status that comes
|
||||||
|
// back adopts it as the downlink.
|
||||||
|
f.satCreate("rx", 145.900, "USB")
|
||||||
|
}
|
||||||
|
if txIdx < 0 {
|
||||||
|
f.satCreate("tx", 435.100, "USB")
|
||||||
|
} else {
|
||||||
|
f.send(fmt.Sprintf("slice s %d tx=1", txIdx))
|
||||||
|
}
|
||||||
|
applog.Printf("flex: satellite armed (rx slice %d, tx slice %d)", rxIdx, txIdx)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flex) satDisarm() error {
|
||||||
|
f.mu.Lock()
|
||||||
|
rx, tx, created := f.satRX, f.satTX, f.satCreatedTX
|
||||||
|
f.satOn, f.satRX, f.satTX, f.satCreatedTX = false, -1, -1, false
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
f.send("radio set full_duplex_enabled=0")
|
||||||
|
if created && tx >= 0 {
|
||||||
|
f.send(fmt.Sprintf("slice remove %d", tx))
|
||||||
|
}
|
||||||
|
// Transmit goes back where the operator is listening. A radio left
|
||||||
|
// transmitting on a slice that no longer exists — or on the uplink band with
|
||||||
|
// the satellite gone — is not somewhere anyone should be handed back.
|
||||||
|
if rx >= 0 {
|
||||||
|
f.send(fmt.Sprintf("slice s %d tx=1", rx))
|
||||||
|
}
|
||||||
|
applog.Printf("flex: satellite disarmed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// satCreate asks for a slice and remembers what it is for; the index arrives in
|
||||||
|
// the reply (see the R-line handler), which is where the role is applied.
|
||||||
|
func (f *Flex) satCreate(role string, freqMHz float64, mode string) {
|
||||||
|
seq := f.send(fmt.Sprintf("slice create freq=%.6f mode=%s", freqMHz, mode))
|
||||||
|
if seq <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
if f.pendingSat == nil {
|
||||||
|
f.pendingSat = map[int]string{}
|
||||||
|
}
|
||||||
|
f.pendingSat[seq] = role
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// adoptSatSlice records a freshly created slice in its role. Called from the
|
||||||
|
// reply handler with the index the radio assigned.
|
||||||
|
func (f *Flex) adoptSatSlice(role string, idx int) {
|
||||||
|
f.mu.Lock()
|
||||||
|
switch role {
|
||||||
|
case "rx":
|
||||||
|
f.satRX = idx
|
||||||
|
case "tx":
|
||||||
|
f.satTX = idx
|
||||||
|
f.satCreatedTX = true
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
if role == "tx" {
|
||||||
|
f.send(fmt.Sprintf("slice s %d tx=1", idx))
|
||||||
|
}
|
||||||
|
applog.Printf("flex: satellite %s slice is %d", role, idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TuneSatellite moves the two slices.
|
||||||
|
func (f *Flex) TuneSatellite(downHz, upHz int64, downMode, upMode string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
rx, tx := f.satRX, f.satTX
|
||||||
|
connected := f.conn != nil
|
||||||
|
if rx >= 0 && f.slices[rx] != nil && downHz > 0 {
|
||||||
|
f.slices[rx].freqHz = downHz // optimistic, as SetFrequency is
|
||||||
|
}
|
||||||
|
if tx >= 0 && f.slices[tx] != nil && upHz > 0 {
|
||||||
|
f.slices[tx].freqHz = upHz
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
if rx < 0 {
|
||||||
|
// The slice was asked for and its index has not come back yet. Nothing is
|
||||||
|
// wrong — the next Doppler step, a second later, will find it.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if downHz > 0 {
|
||||||
|
f.send(fmt.Sprintf("slice t %d %.6f", rx, float64(downHz)/1e6))
|
||||||
|
f.satMode(rx, downMode, downHz)
|
||||||
|
}
|
||||||
|
if tx >= 0 && upHz > 0 {
|
||||||
|
f.send(fmt.Sprintf("slice t %d %.6f", tx, float64(upHz)/1e6))
|
||||||
|
f.satMode(tx, upMode, upHz)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// satMode sets a slice's mode only when it is not already there. A mode command
|
||||||
|
// on every Doppler step is a command a second per slice for a whole pass, and
|
||||||
|
// SmartSDR redraws the filter each time.
|
||||||
|
func (f *Flex) satMode(idx int, mode string, freqHz int64) {
|
||||||
|
mode = strings.TrimSpace(mode)
|
||||||
|
if mode == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// USB on both sides above 30 MHz, which is every satellite worth the name —
|
||||||
|
// including the parts of a passband that would be an LSB band down on HF.
|
||||||
|
if strings.EqualFold(mode, "SSB") && freqHz > 30_000_000 {
|
||||||
|
mode = "USB"
|
||||||
|
}
|
||||||
|
fm := adifModeToFlex(mode, freqHz)
|
||||||
|
if fm == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
s := f.slices[idx]
|
||||||
|
same := s != nil && strings.EqualFold(s.mode, fm)
|
||||||
|
if s != nil {
|
||||||
|
s.mode = fm
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
if same {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatReceiveHz is where the downlink slice sits.
|
||||||
|
//
|
||||||
|
// From the cache, not from a read: SmartSDR pushes every slice change as it
|
||||||
|
// happens, so the cached value is what the radio said, and there is no round
|
||||||
|
// trip to pay for once a second.
|
||||||
|
func (f *Flex) SatReceiveHz() (int64, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.satRX < 0 {
|
||||||
|
return 0, fmt.Errorf("flex: no downlink slice")
|
||||||
|
}
|
||||||
|
s := f.slices[f.satRX]
|
||||||
|
if s == nil || !s.inUse {
|
||||||
|
return 0, fmt.Errorf("flex: the downlink slice has gone")
|
||||||
|
}
|
||||||
|
return s.freqHz, nil
|
||||||
|
}
|
||||||
@@ -60,3 +60,33 @@ func TestIcomSilenceBackoff(t *testing.T) {
|
|||||||
t.Errorf("backoff overshot the ceiling: %s", g)
|
t.Errorf("backoff overshot the ceiling: %s", g)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A new session must not be judged on the previous one's silence.
|
||||||
|
//
|
||||||
|
// From an operator's log: a rig switched to standby, then a dial-and-drop loop
|
||||||
|
// every forty seconds for as long as it was left there. lastGoodAt bounds "the
|
||||||
|
// link answers but no CI-V comes back"; it belongs to a session, and it was
|
||||||
|
// never cleared when a new one opened, so every fresh session started already
|
||||||
|
// past the grace — and the Icom console, where the power-ON button lives,
|
||||||
|
// blinked away on every pass.
|
||||||
|
func TestAFreshSessionStartsWithACleanSilenceClock(t *testing.T) {
|
||||||
|
b := &IcomSerial{
|
||||||
|
lastGoodAt: time.Now().Add(-30 * time.Minute), // a session from before standby
|
||||||
|
readFails: 9,
|
||||||
|
silentGrace: icomSilentGraceMax,
|
||||||
|
}
|
||||||
|
// What Connect does once the transport is open, before anything is sent.
|
||||||
|
b.lastGoodAt = time.Time{}
|
||||||
|
b.readFails = 0
|
||||||
|
b.silentGrace = icomSilentGrace
|
||||||
|
|
||||||
|
if !b.lastGoodAt.IsZero() {
|
||||||
|
t.Fatal("the previous session's last good read survived into this one")
|
||||||
|
}
|
||||||
|
tolerate := func(lastGood time.Time, silentFor, grace time.Duration) bool {
|
||||||
|
return lastGood.IsZero() || silentFor < grace
|
||||||
|
}
|
||||||
|
if !tolerate(b.lastGoodAt, time.Hour, b.silentGrace) {
|
||||||
|
t.Error("a session silent since connect was torn down — that is a rig in standby, and where the ON button has to work")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,12 +67,26 @@ type icomAudio struct {
|
|||||||
txOuter uint16
|
txOuter uint16
|
||||||
txSend uint16
|
txSend uint16
|
||||||
lastRx atomic.Int64 // UnixNano of last packet (liveness)
|
lastRx atomic.Int64 // UnixNano of last packet (liveness)
|
||||||
|
rxCount atomic.Int64 // packets delivered on this stream
|
||||||
|
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
closeOnce sync.Once
|
closeOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *icomAudio) markRx() { a.lastRx.Store(time.Now().UnixNano()) }
|
func (a *icomAudio) markRx() {
|
||||||
|
a.lastRx.Store(time.Now().UnixNano())
|
||||||
|
a.rxCount.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// packets reports whether the stream is actually delivering — the difference
|
||||||
|
// between "audio is on" and "audio is arriving", which is what makes it worth
|
||||||
|
// naming as a suspect when CI-V has gone quiet on the same session.
|
||||||
|
func (a *icomAudio) packets() int64 {
|
||||||
|
if a == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return a.rxCount.Load()
|
||||||
|
}
|
||||||
|
|
||||||
// Close tears the audio stream down (disconnect a few times; UDP is lossy).
|
// Close tears the audio stream down (disconnect a few times; UDP is lossy).
|
||||||
func (a *icomAudio) Close() {
|
func (a *icomAudio) Close() {
|
||||||
|
|||||||
+79
-4
@@ -43,7 +43,7 @@ var icnBE = binary.BigEndian
|
|||||||
// = CI-V only (the proven default). The audio stream is fully separate from CAT,
|
// = CI-V only (the proven default). The audio stream is fully separate from CAT,
|
||||||
// so enabling it can't affect freq/mode/DSP control.
|
// so enabling it can't affect freq/mode/DSP control.
|
||||||
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string, audioSink func([]byte)) *IcomSerial {
|
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string, audioSink func([]byte)) *IcomSerial {
|
||||||
if civAddr <= 0 || civAddr > 0xFF {
|
if civAddr < 0 || civAddr > 0xFF {
|
||||||
civAddr = 0x98 // IC-7610
|
civAddr = 0x98 // IC-7610
|
||||||
}
|
}
|
||||||
if digitalDefault == "" {
|
if digitalDefault == "" {
|
||||||
@@ -132,6 +132,13 @@ type icomNet struct {
|
|||||||
// loop, not the rig — and RS-BA1 showing no such dropouts points that way.
|
// loop, not the rig — and RS-BA1 showing no such dropouts points that way.
|
||||||
txCiv atomic.Uint32
|
txCiv atomic.Uint32
|
||||||
txAtData atomic.Uint32
|
txAtData atomic.Uint32
|
||||||
|
// WHAT was asked, not just how much. A rig that answers at connect and then
|
||||||
|
// never again has usually been sent something it does not like, and counting
|
||||||
|
// the unanswered commands says nothing about which one that was. The last few
|
||||||
|
// command headers are kept so the silence report can name them — the only way
|
||||||
|
// to find a poison command on a radio nobody here has.
|
||||||
|
cmdMu sync.Mutex
|
||||||
|
lastCmd []string
|
||||||
|
|
||||||
rx chan []byte // CI-V byte chunks from civPump → Read (control replies)
|
rx chan []byte // CI-V byte chunks from civPump → Read (control replies)
|
||||||
scopeRx chan []byte // scope (0x27) frames, kept off rx so the panadapter
|
scopeRx chan []byte // scope (0x27) frames, kept off rx so the panadapter
|
||||||
@@ -268,6 +275,7 @@ func (n *icomNet) Write(p []byte) (int, error) {
|
|||||||
n.vCivSeq++
|
n.vCivSeq++
|
||||||
n.seqMu.Unlock()
|
n.seqMu.Unlock()
|
||||||
n.txCiv.Add(1)
|
n.txCiv.Add(1)
|
||||||
|
n.noteCmd(p)
|
||||||
pkt := icnCivData(seq, n.vID, n.vRemote, civSeq, p)
|
pkt := icnCivData(seq, n.vID, n.vRemote, civSeq, p)
|
||||||
n.sentMu.Lock()
|
n.sentMu.Lock()
|
||||||
n.sentBuf[seq] = pkt
|
n.sentBuf[seq] = pkt
|
||||||
@@ -280,6 +288,35 @@ func (n *icomNet) Write(p []byte) (int, error) {
|
|||||||
return len(p), nil
|
return len(p), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// noteCmd remembers the command bytes of a CI-V frame — everything after the
|
||||||
|
// preamble and the two addresses, up to four bytes, which is command,
|
||||||
|
// sub-command and the first of the data.
|
||||||
|
func (n *icomNet) noteCmd(p []byte) {
|
||||||
|
if len(p) < 5 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body := p[4:]
|
||||||
|
if len(body) > 4 {
|
||||||
|
body = body[:4]
|
||||||
|
}
|
||||||
|
n.cmdMu.Lock()
|
||||||
|
n.lastCmd = append(n.lastCmd, fmt.Sprintf("% X", body))
|
||||||
|
if len(n.lastCmd) > 8 {
|
||||||
|
n.lastCmd = n.lastCmd[len(n.lastCmd)-8:]
|
||||||
|
}
|
||||||
|
n.cmdMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// recentCmds is what noteCmd collected, oldest first.
|
||||||
|
func (n *icomNet) recentCmds() string {
|
||||||
|
n.cmdMu.Lock()
|
||||||
|
defer n.cmdMu.Unlock()
|
||||||
|
if len(n.lastCmd) == 0 {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
return strings.Join(n.lastCmd, " | ")
|
||||||
|
}
|
||||||
|
|
||||||
// icnTrace toggles verbose per-frame CI-V request/reply logging for diagnosing
|
// icnTrace toggles verbose per-frame CI-V request/reply logging for diagnosing
|
||||||
// the network transport. Off by default (the connect-step logs stay); flip to
|
// the network transport. Off by default (the connect-step logs stay); flip to
|
||||||
// true to trace every TX/RX again.
|
// true to trace every TX/RX again.
|
||||||
@@ -496,6 +533,18 @@ func (n *icomNet) civPump() {
|
|||||||
}
|
}
|
||||||
debugLog.Printf("icom net: no CI-V DATA for 10 s (transport last heard %s ago; last scope frame %s ago; last socket error: %v; missing-seq backlog: %d; CI-V commands SENT since the last answer: %d)",
|
debugLog.Printf("icom net: no CI-V DATA for 10 s (transport last heard %s ago; last scope frame %s ago; last socket error: %v; missing-seq backlog: %d; CI-V commands SENT since the last answer: %d)",
|
||||||
time.Since(lastPkt).Round(time.Second), scopeAge, lastErr, len(n.rxMissing), n.txCiv.Load()-n.txAtData.Load())
|
time.Since(lastPkt).Round(time.Second), scopeAge, lastErr, len(n.rxMissing), n.txCiv.Load()-n.txAtData.Load())
|
||||||
|
debugLog.Printf("icom net: the last CI-V commands sent, oldest first: %s", n.recentCmds())
|
||||||
|
// THE AUDIO STREAM IS THE FIRST SUSPECT, AND ONLY THE LOG CAN SAY SO.
|
||||||
|
//
|
||||||
|
// The RX audio stream is experimental and shares the rig's session with
|
||||||
|
// CI-V. The shape seen in the field is exactly this one: audio packets
|
||||||
|
// arriving by the hundred while not one CI-V reply comes back, the
|
||||||
|
// watchdog tearing the session down, and the whole thing beginning
|
||||||
|
// again — a loop an operator reads as "the Icom keeps disconnecting",
|
||||||
|
// with nothing pointing at the switch that would end it.
|
||||||
|
if n.audio != nil && n.audio.packets() > 0 {
|
||||||
|
debugLog.Printf("icom net: the RX audio stream is running and still delivering while CI-V has gone quiet — that option is experimental and shares this session. If the drops continue, turn OFF \"Stream RX audio over the network\" in Settings → CAT and see whether control steadies.")
|
||||||
|
}
|
||||||
// And try the gentle repair before the 30 s watchdog tears the whole
|
// And try the gentle repair before the 30 s watchdog tears the whole
|
||||||
// session down: if the rig quietly closed the CI-V data flow (the
|
// session down: if the rig quietly closed the CI-V data flow (the
|
||||||
// transport is still chatting, so the session itself stands), saying
|
// transport is still chatting, so the session itself stands), saying
|
||||||
@@ -808,20 +857,46 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan
|
|||||||
n.vTracked++
|
n.vTracked++
|
||||||
n.vCivSeq++
|
n.vCivSeq++
|
||||||
|
|
||||||
go n.ctrlPump()
|
|
||||||
go n.civPump()
|
|
||||||
|
|
||||||
// Optional RX audio stream (50003). The rig was told (conninfo rxEnable=1) to
|
// Optional RX audio stream (50003). The rig was told (conninfo rxEnable=1) to
|
||||||
// stream audio; open the socket + handshake now. A failure here is NON-fatal:
|
// stream audio; open the socket + handshake now. A failure here is NON-fatal:
|
||||||
// CAT works without audio, so we log and continue rather than tear down a
|
// CAT works without audio, so we log and continue rather than tear down a
|
||||||
// perfectly good control/CI-V session.
|
// perfectly good control/CI-V session.
|
||||||
|
//
|
||||||
|
// BEFORE the pumps start, because the conninfo below touches the control-
|
||||||
|
// stream auth state, and after ctrlPump is running that state belongs to it.
|
||||||
if wantAudio {
|
if wantAudio {
|
||||||
if a, err := dialIcomAudio(host, audioSink, cancel); err != nil {
|
if a, err := dialIcomAudio(host, audioSink, cancel); err != nil {
|
||||||
debugLog.Printf("icom net: audio stream FAILED (CAT unaffected): %v", err)
|
debugLog.Printf("icom net: audio stream FAILED (CAT unaffected): %v", err)
|
||||||
} else {
|
} else {
|
||||||
n.audio = a
|
n.audio = a
|
||||||
|
// AND THE CONNINFO AGAIN, NOW THAT SOMEBODY IS LISTENING ON 50003.
|
||||||
|
//
|
||||||
|
// The first one goes out during the login, before this socket exists —
|
||||||
|
// it has to, since it is what authorises the stream. So the rig is told
|
||||||
|
// to send audio to a port nothing is bound to yet, and what comes back
|
||||||
|
// is an ICMP port-unreachable; it then backs off, and the audio appears
|
||||||
|
// only when its own retry timer comes round. An operator timed that at
|
||||||
|
// twenty to thirty seconds of silence after switching the speakers on,
|
||||||
|
// and one log here shows a minute and a half.
|
||||||
|
//
|
||||||
|
// Re-sent once the port is open, so the rig starts streaming into a
|
||||||
|
// socket that is ready for it. Idempotent — the same message the session
|
||||||
|
// already carries, which is why RS-BA1 repeats it too.
|
||||||
|
pkt := icnConnInfo(n.cTracked, n.cAuthSeq, n.cTokReq, n.cID, n.cRemote, n.cToken, user, rigMAC, 50002, 50003, 0x01)
|
||||||
|
n.cSentBuf[n.cTracked] = pkt
|
||||||
|
n.cTracked++
|
||||||
|
n.cAuthSeq++
|
||||||
|
if _, err := ctrl.Write(pkt); err != nil {
|
||||||
|
debugLog.Printf("icom net: could not re-send the conninfo after opening the audio port: %v", err)
|
||||||
|
} else {
|
||||||
|
debugLog.Printf("icom net: conninfo re-sent now that :50003 is listening — the rig can start the audio at once")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go n.ctrlPump()
|
||||||
|
go n.civPump()
|
||||||
|
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/cat/civ"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Satellite operation on an Icom.
|
||||||
|
//
|
||||||
|
// Two rigs in the range have a satellite mode of their own — the IC-9700 and
|
||||||
|
// the IC-9100 — and on those the right thing to do is ask the radio for it
|
||||||
|
// rather than build an imitation out of split. Their satellite mode pairs the
|
||||||
|
// MAIN band (the downlink) with the SUB band (the uplink), gives full duplex,
|
||||||
|
// and keeps the two dials linked the way the designers meant. Every other Icom
|
||||||
|
// has one receiver on one band: it can be tuned to the downlink, and that is
|
||||||
|
// the whole truth about what it can do on a cross-band satellite.
|
||||||
|
//
|
||||||
|
// UNTESTED ON HARDWARE. Built from the IC-9700 CI-V reference: 0x16 0x5A arms
|
||||||
|
// satellite mode, 0x07 0xD0 / 0xD1 select MAIN and SUB, and once a band is
|
||||||
|
// selected the ordinary 0x05 / 0x06 tune it. If an IC-9700 owner reports it
|
||||||
|
// misbehaving, the log lines below name every frame sent.
|
||||||
|
|
||||||
|
// ErrSatUplinkUnreachable says the downlink was tuned and the uplink was not,
|
||||||
|
// because the radio has no second receiver and the two are on different bands.
|
||||||
|
//
|
||||||
|
// A distinct error rather than a silent half-success: a tracker that quietly
|
||||||
|
// stops transmitting where the operator expects it to is worse than one that
|
||||||
|
// says it cannot. The caller reports it once, not once per Doppler step.
|
||||||
|
var ErrSatUplinkUnreachable = errors.New("cat: this radio has one receiver — the uplink is on another band and cannot be set")
|
||||||
|
|
||||||
|
// SetSatellite arms the rig's own satellite mode.
|
||||||
|
func (b *IcomSerial) SetSatellite(on bool) error {
|
||||||
|
if !b.satNative {
|
||||||
|
// Nothing to arm and nothing to break: the tuning path below does what
|
||||||
|
// this radio can do without any mode change. Refusing here would deny an
|
||||||
|
// operator the downlink, which is most of the value on a receive-heavy
|
||||||
|
// pass.
|
||||||
|
b.satOn = on
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := b.exec(civ.CmdSwitch, civ.SubSwSatellite, boolByte(on)); err != nil {
|
||||||
|
return fmt.Errorf("icom: satellite mode %v refused: %w", on, err)
|
||||||
|
}
|
||||||
|
b.satOn = on
|
||||||
|
applog.Printf("icom: satellite mode %v (%s)", on, b.model)
|
||||||
|
if on {
|
||||||
|
// Leave the radio pointing at MAIN. Everything else in OpsLog — the poll
|
||||||
|
// loop, the logged frequency, the operator's dial — reads the selected
|
||||||
|
// band, and on a satellite the band worth reading is the one carrying the
|
||||||
|
// downlink.
|
||||||
|
_ = b.exec(civ.CmdVFO, civ.SubVFOMain)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TuneSatellite puts the receiver on downHz and the transmitter on upHz.
|
||||||
|
func (b *IcomSerial) TuneSatellite(downHz, upHz int64, downMode, upMode string) error {
|
||||||
|
if downHz <= 0 {
|
||||||
|
return fmt.Errorf("icom: no downlink frequency")
|
||||||
|
}
|
||||||
|
if !b.satNative {
|
||||||
|
return b.tuneSatSingleBand(downHz, upHz, downMode, upMode)
|
||||||
|
}
|
||||||
|
// MAIN — the downlink.
|
||||||
|
if err := b.exec(civ.CmdVFO, civ.SubVFOMain); err != nil {
|
||||||
|
return fmt.Errorf("icom: could not select the main band: %w", err)
|
||||||
|
}
|
||||||
|
if err := b.SetFrequency(downHz); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := b.satSetMode(downMode, downHz); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// SUB — the uplink.
|
||||||
|
if upHz > 0 {
|
||||||
|
if err := b.exec(civ.CmdVFO, civ.SubVFOSub); err != nil {
|
||||||
|
return fmt.Errorf("icom: could not select the sub band: %w", err)
|
||||||
|
}
|
||||||
|
uerr := b.execIdempotent(fmt.Sprintf("set uplink %d Hz", upHz),
|
||||||
|
append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(upHz)...)...)
|
||||||
|
merr := b.satSetMode(upMode, upHz)
|
||||||
|
// Back to MAIN whatever happened. A rig left pointing at SUB reports the
|
||||||
|
// uplink as its frequency, and every band-dependent thing in OpsLog —
|
||||||
|
// the log, the antenna, the amplifier — would follow the transmitter
|
||||||
|
// onto the wrong band.
|
||||||
|
if err := b.exec(civ.CmdVFO, civ.SubVFOMain); err != nil {
|
||||||
|
applog.Printf("icom: could not return to the main band: %v", err)
|
||||||
|
}
|
||||||
|
if uerr != nil {
|
||||||
|
return uerr
|
||||||
|
}
|
||||||
|
if merr != nil {
|
||||||
|
return merr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// satSetMode sets the mode of whichever band is currently selected. An empty
|
||||||
|
// mode leaves it alone — a linear transponder is worked in one mode for a whole
|
||||||
|
// pass, and re-sending it every second is traffic for nothing.
|
||||||
|
func (b *IcomSerial) satSetMode(mode string, freqHz int64) error {
|
||||||
|
mode = strings.TrimSpace(mode)
|
||||||
|
if mode == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// modeCode resolves "SSB" against the CURRENT dial to pick a sideband, which
|
||||||
|
// is wrong here twice over: the dial may still be on the other band, and on
|
||||||
|
// satellites USB is the convention on both sides whatever the frequency.
|
||||||
|
code, data, err := b.modeCode(satSideband(mode))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return b.setModeBytes(mode, code, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// satSideband is the sideband convention above 30 MHz: USB, on both the uplink
|
||||||
|
// and the downlink, including the parts of a linear transponder that fall in
|
||||||
|
// what would be an LSB band on HF. The exceptions — AO-7's mode A downlink on
|
||||||
|
// 29 MHz among them — are still USB by convention, so there is no exception to
|
||||||
|
// make.
|
||||||
|
func satSideband(mode string) string {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(mode), "SSB") {
|
||||||
|
return "USB"
|
||||||
|
}
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatReceiveHz is where the receiver is now.
|
||||||
|
func (b *IcomSerial) SatReceiveHz() (int64, error) {
|
||||||
|
if b.satNative {
|
||||||
|
// The selected band is MAIN — see TuneSatellite, which always returns to
|
||||||
|
// it — so the ordinary frequency read is the downlink.
|
||||||
|
if err := b.exec(civ.CmdVFO, civ.SubVFOMain); err != nil {
|
||||||
|
applog.Printf("icom: sat readback could not select main: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.readFreq()
|
||||||
|
}
|
||||||
|
|
||||||
|
// tuneSatSingleBand is every other Icom: one receiver, one band.
|
||||||
|
//
|
||||||
|
// The downlink is set, because that is what the operator is listening to. The
|
||||||
|
// uplink is set through split only when it is close enough to be on the same
|
||||||
|
// band — QO-100 behind transverters, AO-7's mode A — and otherwise reported as
|
||||||
|
// out of reach rather than quietly skipped.
|
||||||
|
func (b *IcomSerial) tuneSatSingleBand(downHz, upHz int64, downMode, _ string) error {
|
||||||
|
if err := b.SetFrequency(downHz); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := b.satSetMode(downMode, downHz); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if upHz <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// One megahertz apart is the working definition of "the same band" here: it
|
||||||
|
// covers a transponder's own passband and any sensible transverter pairing,
|
||||||
|
// and excludes every real cross-band satellite (145 / 435 MHz).
|
||||||
|
if abs64(upHz-downHz) > 1_000_000 {
|
||||||
|
return ErrSatUplinkUnreachable
|
||||||
|
}
|
||||||
|
if err := b.exec(append([]byte{civ.CmdVfoFreq, civ.SubVfoUnselected}, civ.FreqToBCD(upHz)...)...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !b.satOn {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return b.exec(civ.CmdSplit, boolByte(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
func abs64(v int64) int64 {
|
||||||
|
if v < 0 {
|
||||||
|
return -v
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -94,6 +94,13 @@ type IcomSerial struct {
|
|||||||
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read
|
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read
|
||||||
// via ScopeData from the binding goroutine).
|
// via ScopeData from the binding goroutine).
|
||||||
dualScope bool
|
dualScope bool
|
||||||
|
// satNative marks the two-band satellite rigs — the IC-9700 and the IC-9100 —
|
||||||
|
// which have a real satellite mode of their own. Everything else gets the
|
||||||
|
// downlink and, where the uplink is reachable, split.
|
||||||
|
satNative bool
|
||||||
|
// satOn tracks what we last told the rig, so TuneSatellite can arm the mode
|
||||||
|
// once rather than on every Doppler step.
|
||||||
|
satOn bool
|
||||||
// Set when the rig rejects the waveform-output command in both shapes: it has
|
// Set when the rig rejects the waveform-output command in both shapes: it has
|
||||||
// no stream to give, and asking again on every enable is noise.
|
// no stream to give, and asking again on every enable is noise.
|
||||||
scopeUnsupported bool
|
scopeUnsupported bool
|
||||||
@@ -172,12 +179,13 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// NewIcomSerial builds an (unconnected) Icom serial backend. baud defaults to
|
// NewIcomSerial builds an (unconnected) Icom serial backend. baud defaults to
|
||||||
// 115200, rig address to the IC-7610's 0x98 when out of range.
|
// 115200, rig address to the IC-7610's 0x98 when out of range — and 0x00 is in
|
||||||
|
// range: it is a valid CI-V address that some rigs and interfaces are set to.
|
||||||
func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *IcomSerial {
|
func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *IcomSerial {
|
||||||
if baud <= 0 {
|
if baud <= 0 {
|
||||||
baud = 115200
|
baud = 115200
|
||||||
}
|
}
|
||||||
if civAddr <= 0 || civAddr > 0xFF {
|
if civAddr < 0 || civAddr > 0xFF {
|
||||||
civAddr = 0x98 // IC-7610
|
civAddr = 0x98 // IC-7610
|
||||||
}
|
}
|
||||||
if digitalDefault == "" {
|
if digitalDefault == "" {
|
||||||
@@ -231,6 +239,21 @@ func (b *IcomSerial) Connect() error {
|
|||||||
_ = port.SetRTS(false)
|
_ = port.SetRTS(false)
|
||||||
b.port = port
|
b.port = port
|
||||||
b.model = civ.ModelName(b.rigAddr)
|
b.model = civ.ModelName(b.rigAddr)
|
||||||
|
// A NEW SESSION IS NOT JUDGED ON THE OLD ONE'S SILENCE.
|
||||||
|
//
|
||||||
|
// lastGoodAt bounds "the control link answers but no CI-V comes back". It
|
||||||
|
// belongs to a session, and it was never cleared when a new one opened —
|
||||||
|
// so a rig that went to standby half an hour ago handed every fresh session
|
||||||
|
// a half-hour-old "last good read", which is past the grace before the first
|
||||||
|
// command is even sent. The session was torn down at once, redialled twenty
|
||||||
|
// seconds later, and torn down again: a loop with no way out, and the Icom
|
||||||
|
// console (with its power-ON button) blinking away on every pass.
|
||||||
|
//
|
||||||
|
// Cleared, the rule reads as it was written: silent since connect is a rig in
|
||||||
|
// standby, and the session is kept so the operator can wake it.
|
||||||
|
b.lastGoodAt = time.Time{}
|
||||||
|
b.readFails = 0
|
||||||
|
b.silentGrace = icomSilentGrace
|
||||||
|
|
||||||
// Start the reader before any request: recv() now waits on respCh, which only
|
// Start the reader before any request: recv() now waits on respCh, which only
|
||||||
// the reader feeds. respCh is buffered so a burst (or the scope stream) never
|
// the reader feeds. respCh is buffered so a burst (or the scope stream) never
|
||||||
@@ -268,6 +291,10 @@ func (b *IcomSerial) Connect() error {
|
|||||||
// non-default address still RENDERS; this flag only drives the SET/read commands
|
// non-default address still RENDERS; this flag only drives the SET/read commands
|
||||||
// (mode, span, edges), which need the 0x00 selector to be accepted on the 7300.
|
// (mode, span, edges), which need the 0x00 selector to be accepted on the 7300.
|
||||||
b.dualScope = idAddr == 0x98 || idAddr == 0xA2 || idAddr == 0x94
|
b.dualScope = idAddr == 0x98 || idAddr == 0xA2 || idAddr == 0x94
|
||||||
|
// The satellite rigs: IC-9700 and IC-9100. Both carry two receivers on two
|
||||||
|
// bands and a satellite mode that pairs them; no other Icom in this table
|
||||||
|
// does, and asking one that does not is a rejected frame per Doppler step.
|
||||||
|
b.satNative = idAddr == 0xA2 || idAddr == 0x7C
|
||||||
// Silence any LEFTOVER waveform stream, BLIND, before anything else. The
|
// Silence any LEFTOVER waveform stream, BLIND, before anything else. The
|
||||||
// 0x27 output flag lives in the RADIO and survives sessions; its flood is
|
// 0x27 output flag lives in the RADIO and survives sessions; its flood is
|
||||||
// what makes the IC-7760 stop answering CI-V — so waiting for CI-V to
|
// what makes the IC-7760 stop answering CI-V — so waiting for CI-V to
|
||||||
|
|||||||
+25
-2
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
package cat
|
package cat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -36,6 +38,14 @@ type OmniRig struct {
|
|||||||
// on the main VFO) and read B — the frequency "never followed the knob",
|
// on the main VFO) and read B — the frequency "never followed the knob",
|
||||||
// while it was following the other one all along.
|
// while it was following the other one all along.
|
||||||
ForceVFO string
|
ForceVFO string
|
||||||
|
// CWLower sends PM_CW_L rather than PM_CW_U when asked for CW.
|
||||||
|
//
|
||||||
|
// OmniRig has two CW modes and nothing says which one an .ini file calls
|
||||||
|
// plain CW. Icom rig files disagree: on some PM_CW_U is CI-V mode 0x03 (CW),
|
||||||
|
// on others it is 0x07 (CW-R). An operator clicking a CW spot on an IC-7610
|
||||||
|
// landed in CW-R every time and had to edit the rig file to get out of it.
|
||||||
|
// This is the setting that means he does not have to.
|
||||||
|
CWLower bool
|
||||||
|
|
||||||
omnirig *ole.IDispatch
|
omnirig *ole.IDispatch
|
||||||
rig *ole.IDispatch
|
rig *ole.IDispatch
|
||||||
@@ -76,7 +86,7 @@ type OmniRig struct {
|
|||||||
// NewOmniRig creates a non-connected backend. Call Connect before use.
|
// NewOmniRig creates a non-connected backend. Call Connect before use.
|
||||||
// NewOmniRig builds the backend. forceVFO is "" to follow whatever the rig file
|
// NewOmniRig builds the backend. forceVFO is "" to follow whatever the rig file
|
||||||
// reports, or "A"/"B" to override it — see the ForceVFO field.
|
// reports, or "A"/"B" to override it — see the ForceVFO field.
|
||||||
func NewOmniRig(rigNum int, forceVFO string) *OmniRig {
|
func NewOmniRig(rigNum int, forceVFO string, cwLower bool) *OmniRig {
|
||||||
if rigNum < 1 || rigNum > 2 {
|
if rigNum < 1 || rigNum > 2 {
|
||||||
rigNum = 1
|
rigNum = 1
|
||||||
}
|
}
|
||||||
@@ -84,7 +94,7 @@ func NewOmniRig(rigNum int, forceVFO string) *OmniRig {
|
|||||||
if v != "A" && v != "B" {
|
if v != "A" && v != "B" {
|
||||||
v = ""
|
v = ""
|
||||||
}
|
}
|
||||||
return &OmniRig{RigNum: rigNum, ForceVFO: v}
|
return &OmniRig{RigNum: rigNum, ForceVFO: v, CWLower: cwLower}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *OmniRig) Name() string { return "omnirig" }
|
func (o *OmniRig) Name() string { return "omnirig" }
|
||||||
@@ -602,6 +612,13 @@ func (o *OmniRig) SetFrequency(hz int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetCWLower chooses which of OmniRig's two CW bits means plain CW.
|
||||||
|
//
|
||||||
|
// Applied to the RUNNING backend, because the CAT link does not depend on it:
|
||||||
|
// dropping the rig — and with it WSJT-X's rigctl session — to change which bit
|
||||||
|
// a mode maps to would cost far more than it fixes.
|
||||||
|
func (o *OmniRig) SetCWLower(on bool) { o.CWLower = on }
|
||||||
|
|
||||||
// SetMode maps an ADIF mode to the OmniRig PM_* bit and pushes it to the rig.
|
// SetMode maps an ADIF mode to the OmniRig PM_* bit and pushes it to the rig.
|
||||||
// For SSB, the USB/LSB side is chosen from the rig's current frequency
|
// For SSB, the USB/LSB side is chosen from the rig's current frequency
|
||||||
// following worldwide convention (LSB below 14 MHz, USB above).
|
// following worldwide convention (LSB below 14 MHz, USB above).
|
||||||
@@ -625,7 +642,13 @@ func (o *OmniRig) SetMode(mode string) error {
|
|||||||
)
|
)
|
||||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
case "CW":
|
case "CW":
|
||||||
|
// Which bit means plain CW is a property of the RIG FILE, not of CW —
|
||||||
|
// see the CWLower field.
|
||||||
|
if o.CWLower {
|
||||||
|
bit, bitName = pmCWL, "PM_CW_L"
|
||||||
|
} else {
|
||||||
bit, bitName = pmCWU, "PM_CW_U"
|
bit, bitName = pmCWU, "PM_CW_U"
|
||||||
|
}
|
||||||
case "SSB":
|
case "SSB":
|
||||||
// Decide USB vs LSB from the frequency. Prefer the freq we just COMMANDED
|
// Decide USB vs LSB from the frequency. Prefer the freq we just COMMANDED
|
||||||
// (a clicked spot sets freq then mode ~150ms later): OmniRig's Freq
|
// (a clicked spot sets freq then mode ~150ms later): OmniRig's Freq
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
package cat
|
package cat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
package cat
|
package cat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package cat
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// OmniRig is COM automation against a Windows-only application, so off Windows
|
||||||
|
// there is nothing to talk to. The backend still EXISTS here rather than being
|
||||||
|
// compiled out of app.go, because a settings database is portable: an operator
|
||||||
|
// who moves a profile from Windows to Linux keeps "omnirig" as their saved CAT
|
||||||
|
// backend, and OpsLog must start and say why the rig is silent instead of
|
||||||
|
// failing to build or panicking on a nil backend.
|
||||||
|
//
|
||||||
|
// The fix for those operators is a native backend (Icom, Yaesu, Kenwood/
|
||||||
|
// Elecraft, Flex, TCI, Xiegu all speak to the radio directly) or Hamlib.
|
||||||
|
type OmniRig struct{ CWLower bool }
|
||||||
|
|
||||||
|
var errOmniRigWindowsOnly = errors.New("OmniRig runs only on Windows — pick a native CAT backend (Icom, Yaesu, Kenwood/Elecraft, FlexRadio, TCI, Xiegu) in Settings ▸ CAT")
|
||||||
|
|
||||||
|
func NewOmniRig(rigNum int, forceVFO string, cwLower bool) *OmniRig {
|
||||||
|
return &OmniRig{CWLower: cwLower}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OmniRig) Name() string { return "omnirig" }
|
||||||
|
func (o *OmniRig) Connect() error { return errOmniRigWindowsOnly }
|
||||||
|
func (o *OmniRig) Disconnect() {}
|
||||||
|
func (o *OmniRig) ReadState() (RigState, error) { return RigState{}, errOmniRigWindowsOnly }
|
||||||
|
func (o *OmniRig) SetFrequency(hz int64) error { return errOmniRigWindowsOnly }
|
||||||
|
func (o *OmniRig) SetMode(mode string) error { return errOmniRigWindowsOnly }
|
||||||
|
func (o *OmniRig) SetPTT(on bool) error { return errOmniRigWindowsOnly }
|
||||||
|
|
||||||
|
// SetCWLower satisfies OmniRigController so the preference push at startup is a
|
||||||
|
// no-op here rather than a "backend does not support this" error in the log.
|
||||||
|
func (o *OmniRig) SetCWLower(on bool) { o.CWLower = on }
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
package cat
|
package cat
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package cat
|
||||||
|
|
||||||
|
import "golang.org/x/sys/unix"
|
||||||
|
|
||||||
|
// setSocketReuse is the Unix half of the Windows SO_REUSEADDR above. Linux
|
||||||
|
// wants SO_REUSEPORT as well before two processes may share a bound UDP port;
|
||||||
|
// it is not defined on every Unix, so a refusal there is ignored.
|
||||||
|
func setSocketReuse(fd uintptr) error {
|
||||||
|
if err := unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package cat
|
||||||
|
|
||||||
|
import "golang.org/x/sys/windows"
|
||||||
|
|
||||||
|
// setSocketReuse enables SO_REUSEADDR before bind, so discovery can listen on
|
||||||
|
// :4992 while SmartSDR is already listening for the same radio broadcast.
|
||||||
|
// Without it the second bind fails with WSAEADDRINUSE and the operator has to
|
||||||
|
// type the radio's IP by hand.
|
||||||
|
func setSocketReuse(fd uintptr) error {
|
||||||
|
return windows.SetsockoptInt(windows.Handle(fd),
|
||||||
|
windows.SOL_SOCKET, windows.SO_REUSEADDR, 1)
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package cat
|
package cat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package cat
|
package cat
|
||||||
|
|
||||||
// TCI audio — receiving the radio's audio over the same WebSocket that carries
|
// TCI audio — receiving the radio's audio over the same WebSocket that carries
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package cat
|
package cat
|
||||||
|
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package cat
|
package cat
|
||||||
|
|
||||||
// The TCI control panel: what the radio already tells us, gathered up.
|
// The TCI control panel: what the radio already tells us, gathered up.
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package cat
|
package cat
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package cat
|
package cat
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|||||||
+79
-5
@@ -59,6 +59,13 @@ var yaesuModels = map[string]string{
|
|||||||
"0650": "FT-891",
|
"0650": "FT-891",
|
||||||
"0670": "FT-DX3000",
|
"0670": "FT-DX3000",
|
||||||
"0460": "FT-450D",
|
"0460": "FT-450D",
|
||||||
|
// The eight-digit family. Named for the console; the frequency format is
|
||||||
|
// learned from the rig either way (see learnFreqWidth).
|
||||||
|
"0251": "FT-2000",
|
||||||
|
"0310": "FT-950",
|
||||||
|
"0583": "FTDX1200",
|
||||||
|
"0462": "FTDX3000",
|
||||||
|
"0101": "FTDX5000",
|
||||||
}
|
}
|
||||||
|
|
||||||
// yaesuModeToADIF maps the MD digit to an ADIF mode. The DATA and RTTY variants
|
// yaesuModeToADIF maps the MD digit to an ADIF mode. The DATA and RTTY variants
|
||||||
@@ -100,6 +107,11 @@ type Yaesu struct {
|
|||||||
// rxVFOCmd is "FR" when the rig reports its receive VFO that way, else empty
|
// rxVFOCmd is "FR" when the rig reports its receive VFO that way, else empty
|
||||||
// and VS is used — see ReadState.
|
// and VS is used — see ReadState.
|
||||||
rxVFOCmd string
|
rxVFOCmd string
|
||||||
|
// freqDigits is how many digits this rig writes a frequency in, LEARNED from
|
||||||
|
// its own replies. See learnFreqWidth.
|
||||||
|
freqDigits int
|
||||||
|
// rttyUpper picks RTTY-U over RTTY-L — see SetRTTYUpper.
|
||||||
|
rttyUpper bool
|
||||||
|
|
||||||
curFreq int64
|
curFreq int64
|
||||||
curRXFreq int64
|
curRXFreq int64
|
||||||
@@ -130,6 +142,18 @@ func NewYaesu(portName string, baud int, digital string) *Yaesu {
|
|||||||
return &Yaesu{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
|
return &Yaesu{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRTTYUpper chooses which sideband RTTY is set on.
|
||||||
|
//
|
||||||
|
// Yaesu has both — MD06 is RTTY-L, MD09 is RTTY-U — and ADIF has neither: it
|
||||||
|
// says "RTTY" and stops there, so the rig cannot be driven from the logged mode
|
||||||
|
// alone. LSB is the older convention and stays the default; an operator whose
|
||||||
|
// FSK controller or decoder wants the other one says so once here.
|
||||||
|
func (y *Yaesu) SetRTTYUpper(v bool) {
|
||||||
|
y.mu.Lock()
|
||||||
|
defer y.mu.Unlock()
|
||||||
|
y.rttyUpper = v
|
||||||
|
}
|
||||||
|
|
||||||
// SetLowerLines chooses whether DTR and RTS are deasserted on connect. Set
|
// SetLowerLines chooses whether DTR and RTS are deasserted on connect. Set
|
||||||
// before Connect.
|
// before Connect.
|
||||||
func (y *Yaesu) SetLowerLines(v bool) {
|
func (y *Yaesu) SetLowerLines(v bool) {
|
||||||
@@ -275,6 +299,7 @@ func (y *Yaesu) ReadState() (RigState, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return RigState{}, err // the rig stopped answering — let the Manager reconnect
|
return RigState{}, err // the rig stopped answering — let the Manager reconnect
|
||||||
}
|
}
|
||||||
|
y.learnFreqWidth(faRaw, "FA")
|
||||||
freqA, ok := parseYaesuFreq(faRaw, "FA")
|
freqA, ok := parseYaesuFreq(faRaw, "FA")
|
||||||
if !ok {
|
if !ok {
|
||||||
return RigState{}, fmt.Errorf("yaesu: unparsable FA reply %q", faRaw)
|
return RigState{}, fmt.Errorf("yaesu: unparsable FA reply %q", faRaw)
|
||||||
@@ -355,7 +380,7 @@ func (y *Yaesu) SetFrequency(hz int64) error {
|
|||||||
if y.curVFO == "B" {
|
if y.curVFO == "B" {
|
||||||
cmd = "FB"
|
cmd = "FB"
|
||||||
}
|
}
|
||||||
return y.write(fmt.Sprintf("%s%09d;", cmd, hz))
|
return y.write(fmt.Sprintf("%s%0*d;", cmd, y.freqWidth(), hz))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (y *Yaesu) SetMode(mode string) error {
|
func (y *Yaesu) SetMode(mode string) error {
|
||||||
@@ -364,7 +389,7 @@ func (y *Yaesu) SetMode(mode string) error {
|
|||||||
if y.port == nil {
|
if y.port == nil {
|
||||||
return fmt.Errorf("yaesu: not connected")
|
return fmt.Errorf("yaesu: not connected")
|
||||||
}
|
}
|
||||||
d := yaesuModeDigit(mode, y.curFreq)
|
d := yaesuModeDigit(mode, y.curFreq, y.rttyUpper)
|
||||||
if d == 0 {
|
if d == 0 {
|
||||||
return fmt.Errorf("yaesu: no CAT mode for %q", mode)
|
return fmt.Errorf("yaesu: no CAT mode for %q", mode)
|
||||||
}
|
}
|
||||||
@@ -481,7 +506,53 @@ func cmdPrefix(cmd string) string {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseYaesuFreq reads "FA014074000;" into Hz.
|
// EIGHT DIGITS OR NINE — the rig says which, and it is not a matter of taste.
|
||||||
|
//
|
||||||
|
// The FTDX10, FT-991A, FT-891 and FT-710 write a frequency in nine digits;
|
||||||
|
// everything before them — FTDX3000, FTDX5000, FTDX1200, FT-2000, FT-950,
|
||||||
|
// FT-450 — writes eight, and answers a nine-digit SET with "?;". An operator
|
||||||
|
// with an FTDX3000 saw exactly that: every FA command rejected, a radio that
|
||||||
|
// would not follow, and nothing to say why.
|
||||||
|
//
|
||||||
|
// The width is LEARNED rather than tabulated: the rig announces it in every
|
||||||
|
// reply to "FA;", so the answer comes from the radio in front of the operator
|
||||||
|
// instead of from a list of models that will always be one release behind. Nine
|
||||||
|
// until the first reply lands, which is what the modern rigs use and what this
|
||||||
|
// backend was written against.
|
||||||
|
const yaesuFreqDigitsDefault = 9
|
||||||
|
|
||||||
|
func (y *Yaesu) freqWidth() int {
|
||||||
|
if y.freqDigits >= 8 && y.freqDigits <= 11 {
|
||||||
|
return y.freqDigits
|
||||||
|
}
|
||||||
|
return yaesuFreqDigitsDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
// learnFreqWidth takes the width from a frequency reply. Only a reply that
|
||||||
|
// parses as a frequency teaches anything — a "?;" or a stray frame says nothing
|
||||||
|
// about the format, and a width learned from one would be worse than the
|
||||||
|
// default.
|
||||||
|
func (y *Yaesu) learnFreqWidth(reply, prefix string) {
|
||||||
|
r := strings.TrimSpace(reply)
|
||||||
|
if !strings.HasPrefix(r, prefix) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
digits := strings.TrimSuffix(strings.TrimPrefix(r, prefix), ";")
|
||||||
|
if len(digits) < 8 || len(digits) > 11 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, c := range digits {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if y.freqDigits != len(digits) {
|
||||||
|
debugLog.Printf("yaesu: this rig writes frequencies in %d digits — commands will match", len(digits))
|
||||||
|
y.freqDigits = len(digits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseYaesuFreq reads "FA014074000;" (or "FA14074000;") into Hz.
|
||||||
func parseYaesuFreq(reply, prefix string) (int64, bool) {
|
func parseYaesuFreq(reply, prefix string) (int64, bool) {
|
||||||
r := strings.TrimSpace(reply)
|
r := strings.TrimSpace(reply)
|
||||||
if !strings.HasPrefix(r, prefix) {
|
if !strings.HasPrefix(r, prefix) {
|
||||||
@@ -551,7 +622,7 @@ func resolveYaesuVFOs(freqA, freqB int64, vfo string, split bool) (tx, rx int64,
|
|||||||
// yaesuModeDigit maps an ADIF mode to the MD digit. SSB has no single digit —
|
// yaesuModeDigit maps an ADIF mode to the MD digit. SSB has no single digit —
|
||||||
// the sideband follows the worldwide convention (LSB below 10 MHz, USB above),
|
// the sideband follows the worldwide convention (LSB below 10 MHz, USB above),
|
||||||
// which is why the current frequency is part of the decision.
|
// which is why the current frequency is part of the decision.
|
||||||
func yaesuModeDigit(mode string, freqHz int64) byte {
|
func yaesuModeDigit(mode string, freqHz int64, rttyUpper bool) byte {
|
||||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
case "SSB":
|
case "SSB":
|
||||||
if freqHz > 0 && freqHz < 10_000_000 {
|
if freqHz > 0 && freqHz < 10_000_000 {
|
||||||
@@ -569,7 +640,10 @@ func yaesuModeDigit(mode string, freqHz int64) byte {
|
|||||||
case "AM":
|
case "AM":
|
||||||
return '5'
|
return '5'
|
||||||
case "RTTY":
|
case "RTTY":
|
||||||
return '6'
|
if rttyUpper {
|
||||||
|
return '9' // RTTY-U
|
||||||
|
}
|
||||||
|
return '6' // RTTY-L, the older convention
|
||||||
case "":
|
case "":
|
||||||
return 0
|
return 0
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The FTDX10 family writes a frequency in nine digits; everything before it —
|
||||||
|
// FTDX3000, FTDX5000, FTDX1200, FT-2000, FT-950, FT-450 — writes eight and
|
||||||
|
// answers a nine-digit SET with "?;". Reported from an FTDX3000: every FA
|
||||||
|
// command rejected, a radio that would not follow.
|
||||||
|
//
|
||||||
|
// The width is taken from the rig's own reply, so a model this backend has
|
||||||
|
// never heard of is right on the first read.
|
||||||
|
func TestYaesuFrequencyWidthIsLearnedFromTheRig(t *testing.T) {
|
||||||
|
y := &Yaesu{}
|
||||||
|
if got := y.freqWidth(); got != 9 {
|
||||||
|
t.Errorf("before any reply the width is %d, want the modern 9", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
y.learnFreqWidth("FA14074000;", "FA") // an FTDX3000
|
||||||
|
if got := y.freqWidth(); got != 8 {
|
||||||
|
t.Errorf("width %d after an eight-digit reply, want 8", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
y.learnFreqWidth("FA014074000;", "FA") // and an FTDX10 on the next session
|
||||||
|
if got := y.freqWidth(); got != 9 {
|
||||||
|
t.Errorf("width %d after a nine-digit reply, want 9", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing that is not a frequency teaches anything: a rejection, a stray
|
||||||
|
// frame or a reply from another command would otherwise set the format for
|
||||||
|
// every command that follows.
|
||||||
|
for _, junk := range []string{"?;", "FA;", "FB014074000;", "FA1407400X;", "FA1234567;", "FA123456789012;"} {
|
||||||
|
before := y.freqWidth()
|
||||||
|
y.learnFreqWidth(junk, "FA")
|
||||||
|
if after := y.freqWidth(); after != before {
|
||||||
|
t.Errorf("%q changed the width from %d to %d", junk, before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user