Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
707f0bc848 | ||
|
|
b2e93e9164 | ||
|
|
eb06cc29b0 | ||
|
|
d25edd114c | ||
|
|
f090e845ff | ||
|
|
d2e62debe8 | ||
|
|
8359caab16 | ||
|
|
f998023a28 | ||
|
|
3b6269978c | ||
|
|
052fc4cb80 | ||
|
|
2615365684 | ||
|
|
580e5782f8 | ||
|
|
3ce74ef8d7 | ||
|
|
3af6299f32 | ||
|
|
136c5d5b6b | ||
|
|
d8c9f05d10 | ||
|
|
a88e871640 | ||
|
|
ce7b3686f5 | ||
|
|
b0a973d390 | ||
|
|
e2fe406445 | ||
|
|
3c93684b2b | ||
|
|
3dad00f8ad | ||
|
|
b8491f3038 | ||
|
|
5ddc5e38a9 | ||
|
|
8f643b5b67 | ||
|
|
37b798e9f5 | ||
|
|
3ac6f7e49c | ||
|
|
0add56fb2d | ||
|
|
20b4431502 | ||
|
|
660b1bfa1f | ||
|
|
b15055ba4a | ||
|
|
8f17416eca | ||
|
|
86fd03fd6b | ||
|
|
fcf00e04f4 | ||
|
|
543550c716 | ||
|
|
ca81d4fc68 | ||
|
|
8b1dff581b | ||
|
|
f05e6290df | ||
|
|
a89be4b86e | ||
|
|
dbeabc1bae | ||
|
|
6f1c998a26 | ||
|
|
d185b10559 | ||
|
|
41a2a38025 | ||
|
|
f8f8de4a30 | ||
|
|
82cd5c5d0b | ||
|
|
612e265837 | ||
|
|
3415e12363 | ||
|
|
e0b110392a | ||
|
|
38da904f4b | ||
|
|
b7d1eddfa0 | ||
|
|
7208efbba6 | ||
|
|
2414b2077f | ||
|
|
1cbf3ecc4b | ||
|
|
ccae8599eb | ||
|
|
cdd91ab7e6 | ||
|
|
5b7469ae44 | ||
|
|
3c7ea2d894 | ||
|
|
55fbfa4499 | ||
|
|
b655cd9631 |
@@ -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. |
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// ── Who hears me (PSK Reporter) ────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The FT map draws what this station decodes. This is the other direction:
|
||||||
|
// which stations are reporting our own transmissions, which is the half an
|
||||||
|
// operator cannot see from their own receiver and the half that decides whether
|
||||||
|
// calling is worth the cycle.
|
||||||
|
//
|
||||||
|
// See internal/pskrme for why it costs almost nothing — the operator's callsign
|
||||||
|
// goes in the topic's TRANSMIT level, so the broker sends nothing else.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/pskrme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// keyHearMeOn is per-profile, not global: a second profile is usually a second
|
||||||
|
// callsign, and the answer to "who hears me" is not the same one.
|
||||||
|
const keyHearMeOn = "hearme.on"
|
||||||
|
|
||||||
|
// GetHearMe reports whether the feed is wanted.
|
||||||
|
func (a *App) GetHearMe() bool {
|
||||||
|
if a.settings == nil || !a.settingsScoped.Load() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
v, _ := a.settings.Get(a.ctx, keyHearMeOn)
|
||||||
|
return v == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHearMe turns the feed on or off and brings the subscription with it.
|
||||||
|
func (a *App) SetHearMe(on bool) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
if err := a.settings.Set(a.ctx, keyHearMeOn, boolStr(on)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.startHearMe()
|
||||||
|
if on {
|
||||||
|
// Not an error: the feed is wanted and will come up as soon as there is
|
||||||
|
// a callsign to watch for, and saying so beats a silent switch.
|
||||||
|
if strings.TrimSpace(a.opCall) == "" {
|
||||||
|
return fmt.Errorf("set the station callsign first — the feed watches for it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startHearMe rebuilds the watcher from the setting. Called at startup, when
|
||||||
|
// the setting changes, and on a profile switch — the callsign is what it
|
||||||
|
// subscribes to, so a new profile needs a new subscription.
|
||||||
|
func (a *App) startHearMe() {
|
||||||
|
if a.hearMe != nil {
|
||||||
|
a.hearMe.Stop()
|
||||||
|
a.hearMe = nil
|
||||||
|
}
|
||||||
|
if !a.GetHearMe() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
call := strings.ToUpper(strings.TrimSpace(a.opCall))
|
||||||
|
if call == "" {
|
||||||
|
applog.Printf("pskrme: no station callsign — nothing to watch for")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w := pskrme.New(pskrme.Config{MyCall: call, Logf: applog.Printf})
|
||||||
|
if err := w.Start(); err != nil {
|
||||||
|
applog.Printf("pskrme: feed did not start: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.hearMe = w
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWhoHearsMe is the map layer: one entry per station that has reported us
|
||||||
|
// inside the window, carrying its own square so the arc is arithmetic.
|
||||||
|
func (a *App) GetWhoHearsMe() []pskrme.Report {
|
||||||
|
if a.hearMe == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return a.hearMe.Reports()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHearMeStatus tells a working feed from a silent one: a connection that is
|
||||||
|
// up and reporting nothing looks exactly like a broken one until you can see a
|
||||||
|
// number moving.
|
||||||
|
func (a *App) GetHearMeStatus() pskrme.Status {
|
||||||
|
if a.hearMe == nil {
|
||||||
|
return pskrme.Status{Watching: strings.ToUpper(strings.TrimSpace(a.opCall))}
|
||||||
|
}
|
||||||
|
return a.hearMe.Status()
|
||||||
|
}
|
||||||
+185
-80
@@ -34,15 +34,22 @@ const (
|
|||||||
keySatGrid = "sat.grid" // locator override ("" = the station's own)
|
keySatGrid = "sat.grid" // locator override ("" = the station's own)
|
||||||
keySatAltM = "sat.alt_m" // antenna height above sea level, metres
|
keySatAltM = "sat.alt_m" // antenna height above sea level, metres
|
||||||
|
|
||||||
// The az/el rotator. Its own settings rather than the HF rotator's: a
|
// The az/el rotator.
|
||||||
// satellite station's elevation rotator is a different machine on a
|
|
||||||
// different port, and an operator who has both must not have to choose.
|
|
||||||
keySatRotOn = "sat.rot_enabled"
|
keySatRotOn = "sat.rot_enabled"
|
||||||
// Which program drives the mast: OpsLog itself over EasyComm, or PstRotator,
|
// WHICH rotor, out of the ones configured in Settings ▸ Rotator — the key
|
||||||
// which many stations already run in front of their controller. Its own port
|
// flattenRotors gives it. How to reach it is that list's business, not
|
||||||
// key because it is a different program on a different port from an EasyComm
|
// this page's: describing one mast in two places is how a station ends up
|
||||||
// controller, and an operator who tries both must not lose the first setting
|
// working on HF and not on a pass.
|
||||||
// to the second.
|
keySatRotID = "sat.rot_id"
|
||||||
|
// Follow the azimuth and leave the elevation alone. See SatSettings.RotAzOnly.
|
||||||
|
keySatRotAzOnly = "sat.rot_az_only"
|
||||||
|
keySatRotMinEl = "sat.rot_min_el" // don't drive the rotator below this elevation
|
||||||
|
keySatRotStep = "sat.rot_step" // degrees of change worth a command
|
||||||
|
keySatRotPark = "sat.rot_park" // park at az 0 / el 0 when tracking stops
|
||||||
|
|
||||||
|
// The satellite page used to configure its own EasyComm or PstRotator link.
|
||||||
|
// These keys are read once by migrateSatRotator, which turns what they hold
|
||||||
|
// into a real entry in the rotator list, and are never written again.
|
||||||
keySatRotType = "sat.rot_type" // "easycomm" | "pstrotator"
|
keySatRotType = "sat.rot_type" // "easycomm" | "pstrotator"
|
||||||
keySatRotPstPort = "sat.rot_pst_port" // PstRotator's UDP command port
|
keySatRotPstPort = "sat.rot_pst_port" // PstRotator's UDP command port
|
||||||
keySatRotTransport = "sat.rot_transport" // "serial" | "tcp"
|
keySatRotTransport = "sat.rot_transport" // "serial" | "tcp"
|
||||||
@@ -51,9 +58,6 @@ const (
|
|||||||
keySatRotCOM = "sat.rot_com"
|
keySatRotCOM = "sat.rot_com"
|
||||||
keySatRotBaud = "sat.rot_baud"
|
keySatRotBaud = "sat.rot_baud"
|
||||||
keySatRotMaxAz = "sat.rot_max_az" // 360 or 450
|
keySatRotMaxAz = "sat.rot_max_az" // 360 or 450
|
||||||
keySatRotMinEl = "sat.rot_min_el" // don't drive the rotator below this elevation
|
|
||||||
keySatRotStep = "sat.rot_step" // degrees of change worth a command
|
|
||||||
keySatRotPark = "sat.rot_park" // park at az 0 / el 0 when tracking stops
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// customTLEName holds elements the operator pasted in by hand.
|
// customTLEName holds elements the operator pasted in by hand.
|
||||||
@@ -74,15 +78,30 @@ type SatSettings struct {
|
|||||||
AltM int `json:"alt_m"`
|
AltM int `json:"alt_m"`
|
||||||
|
|
||||||
// The az/el rotator.
|
// The az/el rotator.
|
||||||
|
//
|
||||||
|
// RotID names one of the rotors configured in Settings ▸ Rotator — the
|
||||||
|
// key flattenRotors gives it. Everything about HOW to reach that rotator
|
||||||
|
// (backend, host, COM port, baud, 360/450) belongs to the rotator list and
|
||||||
|
// is deliberately not repeated here.
|
||||||
|
//
|
||||||
|
// What IS here is the tracking policy, which is the satellite page's own
|
||||||
|
// business and means nothing to a rotor turned by hand: below which
|
||||||
|
// elevation not to bother, how far the antenna must be off before a command
|
||||||
|
// is worth sending, and whether to park at the end.
|
||||||
RotOn bool `json:"rot_on"`
|
RotOn bool `json:"rot_on"`
|
||||||
RotType string `json:"rot_type"`
|
RotID string `json:"rot_id"`
|
||||||
RotPstPort int `json:"rot_pst_port"`
|
// RotAzOnly follows the satellite in azimuth and leaves the elevation
|
||||||
RotTransport string `json:"rot_transport"`
|
// alone — which is how most stations that work satellites actually do it.
|
||||||
RotHost string `json:"rot_host"`
|
//
|
||||||
RotPort int `json:"rot_port"`
|
// A pass at the far edge of the footprint never climbs above ten or fifteen
|
||||||
RotCOM string `json:"rot_com"`
|
// degrees, and a beam on a plain azimuth rotator points straight through it:
|
||||||
RotBaud int `json:"rot_baud"`
|
// the beamwidth covers the whole thing. Refusing to track for want of an
|
||||||
RotMaxAz int `json:"rot_max_az"`
|
// elevation motor turned the feature off for every operator who has a tower
|
||||||
|
// and no az/el mast, which is nearly all of them.
|
||||||
|
//
|
||||||
|
// It also rescues an az/el station whose elevation motor has failed, and it
|
||||||
|
// is why the rotor list stops being filtered when this is set.
|
||||||
|
RotAzOnly bool `json:"rot_az_only"`
|
||||||
RotMinEl int `json:"rot_min_el"`
|
RotMinEl int `json:"rot_min_el"`
|
||||||
RotStep int `json:"rot_step"`
|
RotStep int `json:"rot_step"`
|
||||||
RotPark bool `json:"rot_park"`
|
RotPark bool `json:"rot_park"`
|
||||||
@@ -189,6 +208,11 @@ type SatPassInfo struct {
|
|||||||
// PC with no internet as much as on one with. The fetch is the slow, optional
|
// PC with no internet as much as on one with. The fetch is the slow, optional
|
||||||
// half and never blocks a launch.
|
// half and never blocks a launch.
|
||||||
func (a *App) startSatellites() {
|
func (a *App) startSatellites() {
|
||||||
|
// Before anything else reads the rotator choice: an operator upgrading from
|
||||||
|
// the version where the satellite page held its own rotator link must find
|
||||||
|
// that mast already in the list and already selected.
|
||||||
|
a.migrateSatRotator()
|
||||||
|
|
||||||
dir := a.dataDir
|
dir := a.dataDir
|
||||||
birds, err := sat.LoadBirds(dir)
|
birds, err := sat.LoadBirds(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -239,47 +263,24 @@ func (a *App) satParts() (*sat.Store, *sat.Birds, *sat.Fetcher) {
|
|||||||
// ── Settings ────────────────────────────────────────────────────────────────
|
// ── Settings ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (a *App) satSettings() SatSettings {
|
func (a *App) satSettings() SatSettings {
|
||||||
// The rotator defaults are the common case, not a blank form: EasyComm over
|
// A five-degree step, which on a beam with any gain at all is well inside
|
||||||
// a serial port at 9600, a 360° machine, and a five-degree step — which on a
|
// the beamwidth and keeps a pass from being a command a second.
|
||||||
// beam with any gain at all is well inside the beamwidth and keeps a pass
|
|
||||||
// from being a command a second.
|
|
||||||
out := SatSettings{
|
out := SatSettings{
|
||||||
MinEl: 10, WindowH: 24, AutoTLE: true,
|
MinEl: 10, WindowH: 24, AutoTLE: true,
|
||||||
RotType: satRotEasycomm, RotPstPort: 12000,
|
RotMinEl: 0, RotStep: 5,
|
||||||
RotTransport: "serial", RotPort: 4533, RotBaud: 9600,
|
|
||||||
RotMaxAz: 360, RotMinEl: 0, RotStep: 5,
|
|
||||||
}
|
}
|
||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
m, err := a.settings.GetMany(a.ctx,
|
m, err := a.settings.GetMany(a.ctx,
|
||||||
keySatFavorites, keySatMinEl, keySatWindowH, keySatAutoTLE, keySatGrid, keySatAltM,
|
keySatFavorites, keySatMinEl, keySatWindowH, keySatAutoTLE, keySatGrid, keySatAltM,
|
||||||
keySatRotOn, keySatRotType, keySatRotPstPort, keySatRotTransport, keySatRotHost, keySatRotPort, keySatRotCOM,
|
keySatRotOn, keySatRotID, keySatRotAzOnly, keySatRotMinEl, keySatRotStep, keySatRotPark)
|
||||||
keySatRotBaud, keySatRotMaxAz, keySatRotMinEl, keySatRotStep, keySatRotPark)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
out.RotOn = m[keySatRotOn] == "1"
|
out.RotOn = m[keySatRotOn] == "1"
|
||||||
if ty := m[keySatRotType]; ty == satRotPst || ty == satRotEasycomm {
|
out.RotID = strings.TrimSpace(m[keySatRotID])
|
||||||
out.RotType = ty
|
out.RotAzOnly = m[keySatRotAzOnly] == "1"
|
||||||
}
|
|
||||||
if v, err := strconv.Atoi(m[keySatRotPstPort]); err == nil && v > 0 && v <= 65535 {
|
|
||||||
out.RotPstPort = v
|
|
||||||
}
|
|
||||||
if tr := m[keySatRotTransport]; tr == "tcp" || tr == "serial" {
|
|
||||||
out.RotTransport = tr
|
|
||||||
}
|
|
||||||
out.RotHost = strings.TrimSpace(m[keySatRotHost])
|
|
||||||
if v, err := strconv.Atoi(m[keySatRotPort]); err == nil && v > 0 && v <= 65535 {
|
|
||||||
out.RotPort = v
|
|
||||||
}
|
|
||||||
out.RotCOM = strings.TrimSpace(m[keySatRotCOM])
|
|
||||||
if v, err := strconv.Atoi(m[keySatRotBaud]); err == nil && v >= 1200 && v <= 115200 {
|
|
||||||
out.RotBaud = v
|
|
||||||
}
|
|
||||||
if v, err := strconv.Atoi(m[keySatRotMaxAz]); err == nil && v == 450 {
|
|
||||||
out.RotMaxAz = 450
|
|
||||||
}
|
|
||||||
if v, err := strconv.Atoi(m[keySatRotMinEl]); err == nil && v >= -10 && v <= 30 {
|
if v, err := strconv.Atoi(m[keySatRotMinEl]); err == nil && v >= -10 && v <= 30 {
|
||||||
out.RotMinEl = v
|
out.RotMinEl = v
|
||||||
}
|
}
|
||||||
@@ -313,7 +314,12 @@ func (a *App) GetSatSettings() (SatSettings, error) {
|
|||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return SatSettings{}, fmt.Errorf("db not initialized")
|
return SatSettings{}, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
return a.satSettings(), nil
|
out := a.satSettings()
|
||||||
|
// Resolved HERE and not in satSettings, which is read during startup before
|
||||||
|
// the frequency plan is loaded. Saving the panel writes the resolved list
|
||||||
|
// back, so the rename settles itself the first time anything is changed.
|
||||||
|
out.Favorites = a.satFavorites()
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveSatSettings stores them.
|
// SaveSatSettings stores them.
|
||||||
@@ -337,27 +343,9 @@ func (a *App) SaveSatSettings(s SatSettings) error {
|
|||||||
seen[strings.ToUpper(n)] = true
|
seen[strings.ToUpper(n)] = true
|
||||||
favs = append(favs, n)
|
favs = append(favs, n)
|
||||||
}
|
}
|
||||||
if s.RotType != satRotPst {
|
|
||||||
s.RotType = satRotEasycomm
|
|
||||||
}
|
|
||||||
if s.RotPstPort <= 0 || s.RotPstPort > 65535 {
|
|
||||||
s.RotPstPort = 12000
|
|
||||||
}
|
|
||||||
if s.RotTransport != "tcp" {
|
|
||||||
s.RotTransport = "serial"
|
|
||||||
}
|
|
||||||
if s.RotMaxAz != 450 {
|
|
||||||
s.RotMaxAz = 360
|
|
||||||
}
|
|
||||||
if s.RotStep < 1 || s.RotStep > 30 {
|
if s.RotStep < 1 || s.RotStep > 30 {
|
||||||
s.RotStep = 5
|
s.RotStep = 5
|
||||||
}
|
}
|
||||||
if s.RotPort <= 0 || s.RotPort > 65535 {
|
|
||||||
s.RotPort = 4533
|
|
||||||
}
|
|
||||||
if s.RotBaud < 1200 || s.RotBaud > 115200 {
|
|
||||||
s.RotBaud = 9600
|
|
||||||
}
|
|
||||||
for k, v := range map[string]string{
|
for k, v := range map[string]string{
|
||||||
keySatFavorites: strings.Join(favs, ","),
|
keySatFavorites: strings.Join(favs, ","),
|
||||||
keySatMinEl: strconv.Itoa(s.MinEl),
|
keySatMinEl: strconv.Itoa(s.MinEl),
|
||||||
@@ -366,14 +354,8 @@ func (a *App) SaveSatSettings(s SatSettings) error {
|
|||||||
keySatGrid: strings.ToUpper(strings.TrimSpace(s.Grid)),
|
keySatGrid: strings.ToUpper(strings.TrimSpace(s.Grid)),
|
||||||
keySatAltM: strconv.Itoa(s.AltM),
|
keySatAltM: strconv.Itoa(s.AltM),
|
||||||
keySatRotOn: boolStr(s.RotOn),
|
keySatRotOn: boolStr(s.RotOn),
|
||||||
keySatRotType: s.RotType,
|
keySatRotID: strings.TrimSpace(s.RotID),
|
||||||
keySatRotPstPort: strconv.Itoa(s.RotPstPort),
|
keySatRotAzOnly: boolStr(s.RotAzOnly),
|
||||||
keySatRotTransport: s.RotTransport,
|
|
||||||
keySatRotHost: strings.TrimSpace(s.RotHost),
|
|
||||||
keySatRotPort: strconv.Itoa(s.RotPort),
|
|
||||||
keySatRotCOM: strings.TrimSpace(s.RotCOM),
|
|
||||||
keySatRotBaud: strconv.Itoa(s.RotBaud),
|
|
||||||
keySatRotMaxAz: strconv.Itoa(s.RotMaxAz),
|
|
||||||
keySatRotMinEl: strconv.Itoa(s.RotMinEl),
|
keySatRotMinEl: strconv.Itoa(s.RotMinEl),
|
||||||
keySatRotStep: strconv.Itoa(s.RotStep),
|
keySatRotStep: strconv.Itoa(s.RotStep),
|
||||||
keySatRotPark: boolStr(s.RotPark),
|
keySatRotPark: boolStr(s.RotPark),
|
||||||
@@ -382,6 +364,15 @@ func (a *App) SaveSatSettings(s SatSettings) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The Satellites tab is usually open BEHIND the settings window, and it
|
||||||
|
// read the followed list once when it was mounted: dropping QO-100 left it
|
||||||
|
// in the pass table and in the dropdown until the tab was reopened, which
|
||||||
|
// looks exactly like a setting that did not save. Every key written here
|
||||||
|
// changes what the tab should show — the followed list, the horizon, the
|
||||||
|
// window, the locator — so one event covers them all.
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "sat:settings")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,9 +549,8 @@ func (a *App) AddSatelliteElements(text string) (int, error) {
|
|||||||
// configuration problem into a satellite that "does not exist".
|
// configuration problem into a satellite that "does not exist".
|
||||||
func (a *App) GetSatelliteBirds() []SatBird {
|
func (a *App) GetSatelliteBirds() []SatBird {
|
||||||
store, birds, _ := a.satParts()
|
store, birds, _ := a.satParts()
|
||||||
set := a.satSettings()
|
|
||||||
fav := map[string]bool{}
|
fav := map[string]bool{}
|
||||||
for _, n := range set.Favorites {
|
for _, n := range a.satFavorites() {
|
||||||
fav[strings.ToUpper(n)] = true
|
fav[strings.ToUpper(n)] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,6 +651,11 @@ func (a *App) GetSatelliteNames() []string {
|
|||||||
// The feed's name and the operator's name for the same satellite are routinely
|
// The feed's name and the operator's name for the same satellite are routinely
|
||||||
// different, and the element set is keyed by the feed's.
|
// different, and the element set is keyed by the feed's.
|
||||||
func satElement(store *sat.Store, b sat.Bird) (sat.Element, bool) {
|
func satElement(store *sat.Store, b sat.Bird) (sat.Element, bool) {
|
||||||
|
// The catalog number first: it is exact, and it is what the generated plan
|
||||||
|
// carries. Everything below is for the hand-written entries that have none.
|
||||||
|
if e, ok := store.GetNORAD(b.NORAD); ok {
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
if e, ok := store.Get(b.Name); ok {
|
if e, ok := store.Get(b.Name); ok {
|
||||||
return e, true
|
return e, true
|
||||||
}
|
}
|
||||||
@@ -683,15 +678,48 @@ func satElement(store *sat.Store, b sat.Bird) (sat.Element, bool) {
|
|||||||
|
|
||||||
// ── Tracking ────────────────────────────────────────────────────────────────
|
// ── Tracking ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// satFavorites is the followed list, with every name resolved to the one the
|
||||||
|
// frequency plan uses now.
|
||||||
|
//
|
||||||
|
// A satellite is renamed when it is granted an OSCAR number — LILACSAT-2
|
||||||
|
// became LO-90 — and the plan carries the old name as an alias. The followed
|
||||||
|
// list, though, is stored as the plain text the operator picked: after such a
|
||||||
|
// rename his own choice was listed as having no elements while the same bird
|
||||||
|
// sat under its new name in the available column, so the satellite he had
|
||||||
|
// chosen had quietly become a stranger.
|
||||||
|
//
|
||||||
|
// Deduplicated on the way out, because an operator who followed both spellings
|
||||||
|
// must not now see the same bird twice.
|
||||||
|
func (a *App) satFavorites() []string {
|
||||||
|
names := a.satSettings().Favorites
|
||||||
|
_, birds, _ := a.satParts()
|
||||||
|
if birds == nil {
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(names))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, n := range names {
|
||||||
|
if b, ok := birds.Find(n); ok {
|
||||||
|
n = b.Name
|
||||||
|
}
|
||||||
|
k := strings.ToUpper(n)
|
||||||
|
if seen[k] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[k] = true
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// satNames resolves the names the UI asked for, falling back to the favourites
|
// satNames resolves the names the UI asked for, falling back to the favourites
|
||||||
// and then to every planned bird we hold elements for.
|
// and then to every planned bird we hold elements for.
|
||||||
func (a *App) satNames(names []string) []string {
|
func (a *App) satNames(names []string) []string {
|
||||||
if len(names) > 0 {
|
if len(names) > 0 {
|
||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
set := a.satSettings()
|
if favs := a.satFavorites(); len(favs) > 0 {
|
||||||
if len(set.Favorites) > 0 {
|
return favs
|
||||||
return set.Favorites
|
|
||||||
}
|
}
|
||||||
var out []string
|
var out []string
|
||||||
for _, b := range a.GetSatelliteBirds() {
|
for _, b := range a.GetSatelliteBirds() {
|
||||||
@@ -978,3 +1006,80 @@ func (a *App) GetSatelliteTuning(name string, transponder int, downHz int64) (Sa
|
|||||||
out.Visible = p.Visible()
|
out.Visible = p.Visible()
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// migrateSatRotator moves a pre-list satellite rotator into Settings ▸ Rotator.
|
||||||
|
//
|
||||||
|
// Until now the satellite page configured its own EasyComm or PstRotator link,
|
||||||
|
// separately from the rotator list every other backend lived in. An operator who
|
||||||
|
// had set one up must not open OpsLog to an empty dropdown and a mast that no
|
||||||
|
// longer turns — so the old keys are read once, turned into a real rotor in the
|
||||||
|
// list, and the satellite page is pointed at it.
|
||||||
|
//
|
||||||
|
// Runs once. The legacy keys are cleared afterwards so a second run cannot add
|
||||||
|
// the same mast a second time, and so the next reader of this file is not left
|
||||||
|
// wondering which of the two copies is live.
|
||||||
|
func (a *App) migrateSatRotator() {
|
||||||
|
if a.settings == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m, err := a.settings.GetMany(a.ctx,
|
||||||
|
keySatRotID, keySatRotType, keySatRotTransport, keySatRotHost, keySatRotPort,
|
||||||
|
keySatRotCOM, keySatRotBaud, keySatRotPstPort, keySatRotMaxAz)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(m[keySatRotID]) != "" {
|
||||||
|
return // already migrated, or configured since
|
||||||
|
}
|
||||||
|
legacy := strings.TrimSpace(m[keySatRotType])
|
||||||
|
if legacy == "" {
|
||||||
|
return // the satellite rotator was never configured
|
||||||
|
}
|
||||||
|
|
||||||
|
atoi := func(s string) int { n, _ := strconv.Atoi(s); return n }
|
||||||
|
dev := RotatorDevice{
|
||||||
|
ID: fmt.Sprintf("rotor-sat-%d", time.Now().Unix()),
|
||||||
|
Name: "Satellite",
|
||||||
|
MaxAz: atoi(m[keySatRotMaxAz]),
|
||||||
|
// A satellite rotor carries a fixed antenna, not a motorized Ultrabeam
|
||||||
|
// or SteppIR: showing it pattern paths would be showing it something it
|
||||||
|
// cannot do.
|
||||||
|
Motorized: false,
|
||||||
|
}
|
||||||
|
switch legacy {
|
||||||
|
case satRotPst:
|
||||||
|
dev.Type = "pst"
|
||||||
|
dev.Host = strings.TrimSpace(m[keySatRotHost])
|
||||||
|
dev.Port = atoi(m[keySatRotPstPort])
|
||||||
|
// It was in the satellite settings, so it has elevation by construction.
|
||||||
|
dev.HasElevation = true
|
||||||
|
default:
|
||||||
|
dev.Type = "easycomm"
|
||||||
|
dev.Transport = strings.TrimSpace(m[keySatRotTransport])
|
||||||
|
dev.Host = strings.TrimSpace(m[keySatRotHost])
|
||||||
|
dev.Port = atoi(m[keySatRotPort])
|
||||||
|
dev.ComPort = strings.TrimSpace(m[keySatRotCOM])
|
||||||
|
dev.Baud = atoi(m[keySatRotBaud])
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := a.GetRotators()
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("satellite: cannot read the rotator list to migrate the satellite rotator: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list = append(list, dev)
|
||||||
|
if err := a.SaveRotators(list); err != nil {
|
||||||
|
applog.Printf("satellite: cannot save the migrated satellite rotator: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.settings.Set(a.ctx, keySatRotID, dev.ID); err != nil {
|
||||||
|
applog.Printf("satellite: migrated the rotator but could not select it: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Clear the old keys so this cannot run twice.
|
||||||
|
for _, k := range []string{keySatRotType, keySatRotTransport, keySatRotHost, keySatRotPort,
|
||||||
|
keySatRotCOM, keySatRotBaud, keySatRotPstPort, keySatRotMaxAz} {
|
||||||
|
_ = a.settings.Set(a.ctx, k, "")
|
||||||
|
}
|
||||||
|
applog.Printf("satellite: the %s rotator configured on the satellite page is now %q in Settings ▸ Rotator", legacy, dev.Name)
|
||||||
|
}
|
||||||
|
|||||||
+207
-34
@@ -1,16 +1,18 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
// The two ways a satellite station points its antenna.
|
// How a satellite station points its antenna.
|
||||||
//
|
//
|
||||||
// Some operators drive their az/el rotator directly — EasyComm II, what
|
// It does NOT configure a rotator. Every rotator interface OpsLog knows lives in
|
||||||
// SatPC32 and Gpredict speak. Others already run PstRotator, which sits between
|
// Settings ▸ Rotator, once, and the satellite page only CHOOSES one of them.
|
||||||
// them and a dozen different controllers and handles az AND el; for those,
|
// The two used to be separate: EasyComm and PstRotator were described inside the
|
||||||
// OpsLog talking to the controller itself would be a second program fighting
|
// satellite settings while five other backends were described in the rotator
|
||||||
// PstRotator over the same cable.
|
// 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.
|
||||||
//
|
//
|
||||||
// So both, behind one small interface, chosen in Settings. Neither is more
|
// What remains here is the adapter: turning whichever backend the operator
|
||||||
// "correct" than the other: the right one is whichever the station already has
|
// picked into the three things a pass needs — point it, ask where it is, let go
|
||||||
// working.
|
// of it at the end.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -18,8 +20,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"hamlog/internal/rotator/easycomm"
|
"hamlog/internal/rotator/gs232"
|
||||||
"hamlog/internal/rotator/pst"
|
"hamlog/internal/rotator/pst"
|
||||||
|
"hamlog/internal/rotator/spid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// satRotator is what the tracker needs of an antenna: point it, ask where it
|
// satRotator is what the tracker needs of an antenna: point it, ask where it
|
||||||
@@ -33,41 +36,172 @@ type satRotator interface {
|
|||||||
Close()
|
Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// The rotator kinds, as stored.
|
// 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 (
|
const (
|
||||||
satRotEasycomm = "easycomm"
|
satRotEasycomm = "easycomm"
|
||||||
satRotPst = "pstrotator"
|
satRotPst = "pstrotator"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newSatRotator builds the configured controller.
|
// newSatRotator builds a controller for the rotor the satellite page selected.
|
||||||
func newSatRotator(s SatSettings) (satRotator, error) {
|
func (a *App) newSatRotator(s SatSettings) (satRotator, error) {
|
||||||
switch s.RotType {
|
if strings.TrimSpace(s.RotID) == "" {
|
||||||
case satRotPst:
|
return nil, fmt.Errorf("no rotator chosen for satellite tracking — pick one in Settings ▸ Satellite")
|
||||||
if strings.TrimSpace(s.RotHost) == "" && s.RotPort <= 0 {
|
|
||||||
return nil, fmt.Errorf("no address for PstRotator")
|
|
||||||
}
|
}
|
||||||
return &pstSatRotator{c: pst.New(s.RotHost, s.RotPstPort), maxAz: s.RotMaxAz}, nil
|
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:
|
default:
|
||||||
if s.RotTransport == "tcp" {
|
return nil, fmt.Errorf("the %s backend cannot be pointed in elevation", l.Type)
|
||||||
if strings.TrimSpace(s.RotHost) == "" {
|
|
||||||
return nil, fmt.Errorf("no address for the rotator")
|
|
||||||
}
|
}
|
||||||
return easycomm.New(s.RotHost, s.RotPort, s.RotMaxAz), nil
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(s.RotCOM) == "" {
|
out := []SatelliteRotorChoice{}
|
||||||
return nil, fmt.Errorf("no COM port for the rotator")
|
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 easycomm.NewSerial(s.RotCOM, s.RotBaud, s.RotMaxAz), nil
|
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.
|
// pstSatRotator points the antenna through PstRotator.
|
||||||
//
|
//
|
||||||
// PstRotator takes whole degrees and does its own overlap handling for a 450°
|
// 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.
|
// 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 EasyComm needs is
|
// So the azimuth is sent plainly, and the 450° logic that the direct backends
|
||||||
// deliberately NOT applied here: two programs each deciding to go the long way
|
// need is deliberately NOT applied here: two programs each deciding to go the
|
||||||
// round is how an antenna ends up unwinding in the middle of a pass.
|
// long way round is how an antenna ends up unwinding in the middle of a pass.
|
||||||
type pstSatRotator struct {
|
type pstSatRotator struct {
|
||||||
c *pst.Client
|
c *pst.Client
|
||||||
maxAz int
|
maxAz int
|
||||||
@@ -87,12 +221,7 @@ func (p *pstSatRotator) Point(az, el float64) error {
|
|||||||
if a < 0 {
|
if a < 0 {
|
||||||
a += 360
|
a += 360
|
||||||
}
|
}
|
||||||
if el < 0 {
|
el = clampEl(el)
|
||||||
el = 0
|
|
||||||
}
|
|
||||||
if el > 180 {
|
|
||||||
el = 180
|
|
||||||
}
|
|
||||||
if err := p.c.GoTo(int(math.Round(a)), true, int(math.Round(el))); err != nil {
|
if err := p.c.GoTo(int(math.Round(a)), true, int(math.Round(el))); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -138,3 +267,47 @@ func (p *pstSatRotator) Heading() (float64, float64, bool, error) {
|
|||||||
// Close: nothing to release. Every PstRotator command is one datagram, and the
|
// Close: nothing to release. Every PstRotator command is one datagram, and the
|
||||||
// socket lives for the length of a single write.
|
// socket lives for the length of a single write.
|
||||||
func (p *pstSatRotator) Close() {}
|
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() {}
|
||||||
|
|||||||
+193
-15
@@ -69,6 +69,7 @@ type satTracker struct {
|
|||||||
rotStep float64
|
rotStep float64
|
||||||
rotMinE float64
|
rotMinE float64
|
||||||
rotPark bool
|
rotPark bool
|
||||||
|
rotAzOnly bool
|
||||||
rotAz float64 // last commanded, so a step smaller than the beamwidth costs nothing
|
rotAz float64 // last commanded, so a step smaller than the beamwidth costs nothing
|
||||||
rotEl float64
|
rotEl float64
|
||||||
rotSent bool
|
rotSent bool
|
||||||
@@ -76,6 +77,10 @@ type satTracker struct {
|
|||||||
|
|
||||||
stop chan struct{}
|
stop chan struct{}
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
|
// wake makes the loop take a step NOW instead of at the next tick. Changing
|
||||||
|
// satellite has to move the radio at once: a second of the old bird's
|
||||||
|
// frequencies is a second of the wrong pass.
|
||||||
|
wake chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SatTrackStatus is what the tracker is doing, for the panel.
|
// SatTrackStatus is what the tracker is doing, for the panel.
|
||||||
@@ -91,6 +96,11 @@ type SatTrackStatus struct {
|
|||||||
Az float64 `json:"az"`
|
Az float64 `json:"az"`
|
||||||
El float64 `json:"el"`
|
El float64 `json:"el"`
|
||||||
Visible bool `json:"visible"`
|
Visible bool `json:"visible"`
|
||||||
|
// Where the satellite is, as opposed to where to point: an operator reads
|
||||||
|
// the distance to know whether a pass is worth calling on, and the altitude
|
||||||
|
// to know how long it will last.
|
||||||
|
RangeKm float64 `json:"range_km"`
|
||||||
|
AltKm float64 `json:"alt_km"`
|
||||||
Radio string `json:"radio"` // what the rig is doing: "sat", "downlink-only", ""
|
Radio string `json:"radio"` // what the rig is doing: "sat", "downlink-only", ""
|
||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
|
|
||||||
@@ -101,6 +111,10 @@ type SatTrackStatus struct {
|
|||||||
RotAz float64 `json:"rot_az"`
|
RotAz float64 `json:"rot_az"`
|
||||||
RotEl float64 `json:"rot_el"`
|
RotEl float64 `json:"rot_el"`
|
||||||
RotLive bool `json:"rot_live"`
|
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.
|
// StartSatelliteTracking arms the radio and starts following the satellite.
|
||||||
@@ -124,6 +138,7 @@ func (a *App) StartSatelliteTracking(name string, transponder int) error {
|
|||||||
nominalDown: b.Transponders[transponder].Centre(),
|
nominalDown: b.Transponders[transponder].Centre(),
|
||||||
stop: make(chan struct{}),
|
stop: make(chan struct{}),
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
|
wake: make(chan struct{}, 1),
|
||||||
}
|
}
|
||||||
t.status = SatTrackStatus{On: true, Name: b.Name, Transponder: b.Transponders[transponder].Label, Mode: b.Transponders[transponder].Mode}
|
t.status = SatTrackStatus{On: true, Name: b.Name, Transponder: b.Transponders[transponder].Label, Mode: b.Transponders[transponder].Mode}
|
||||||
|
|
||||||
@@ -131,13 +146,15 @@ func (a *App) StartSatelliteTracking(name string, transponder int) error {
|
|||||||
// left alone, so it gets one command rather than a loop.
|
// left alone, so it gets one command rather than a loop.
|
||||||
set := a.satSettings()
|
set := a.satSettings()
|
||||||
if set.RotOn {
|
if set.RotOn {
|
||||||
r, rerr := newSatRotator(set)
|
r, rerr := a.newSatRotator(set)
|
||||||
if rerr != nil {
|
if rerr != nil {
|
||||||
applog.Printf("sat: no rotator: %v", rerr)
|
applog.Printf("sat: no rotator: %v", rerr)
|
||||||
t.status.Error = rerr.Error()
|
t.status.Error = rerr.Error()
|
||||||
} else {
|
} else {
|
||||||
t.rot = r
|
t.rot = r
|
||||||
t.rotStep, t.rotMinE, t.rotPark = float64(set.RotStep), float64(set.RotMinEl), set.RotPark
|
t.rotStep, t.rotMinE, t.rotPark = float64(set.RotStep), float64(set.RotMinEl), set.RotPark
|
||||||
|
t.rotAzOnly = set.RotAzOnly
|
||||||
|
t.status.RotAzOnly = set.RotAzOnly
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +168,7 @@ func (a *App) StartSatelliteTracking(name string, transponder int) error {
|
|||||||
t.status.Error = err.Error()
|
t.status.Error = err.Error()
|
||||||
} else {
|
} else {
|
||||||
radio = "sat"
|
radio = "sat"
|
||||||
|
a.applySatRadio(b.Transponders[transponder])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.status.Radio = radio
|
t.status.Radio = radio
|
||||||
@@ -193,7 +211,7 @@ func (a *App) TestSatelliteRotator() (string, error) {
|
|||||||
if !set.RotOn {
|
if !set.RotOn {
|
||||||
return "", fmt.Errorf("the satellite rotator is switched off")
|
return "", fmt.Errorf("the satellite rotator is switched off")
|
||||||
}
|
}
|
||||||
c, err := newSatRotator(set)
|
c, err := a.newSatRotator(set)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -251,6 +269,7 @@ func (a *App) satTrackLoop(t *satTracker) {
|
|||||||
select {
|
select {
|
||||||
case <-t.stop:
|
case <-t.stop:
|
||||||
return
|
return
|
||||||
|
case <-t.wake:
|
||||||
case <-tick.C:
|
case <-tick.C:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,22 +343,29 @@ func (a *App) satTrackStep(t *satTracker) {
|
|||||||
down, up := sh.DownHz, sh.UpHz
|
down, up := sh.DownHz, sh.UpHz
|
||||||
|
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
t.status = SatTrackStatus{
|
// Rebuilt from the old one, not from nothing.
|
||||||
On: true, Name: b.Name, Transponder: tp.Label, Mode: tp.Mode,
|
//
|
||||||
NominalDown: nominal, NominalUp: nomUp,
|
// The rotator fields are written by readRotator, which runs at most every
|
||||||
DownHz: down, UpHz: up,
|
// three seconds — a controller query binds a socket and waits. Building a
|
||||||
Az: pos.Az, El: pos.El, Visible: visible,
|
// fresh status here dropped them on every OTHER tick, so the antenna
|
||||||
Radio: t.status.Radio, Error: t.status.Error,
|
// readout appeared for one second in three and vanished again, which reads
|
||||||
}
|
// as a rotator that keeps disconnecting.
|
||||||
|
st := t.status
|
||||||
|
st.On, st.Name, st.Transponder, st.Mode = true, b.Name, tp.Label, tp.Mode
|
||||||
|
st.NominalDown, st.NominalUp = nominal, nomUp
|
||||||
|
st.DownHz, st.UpHz = down, up
|
||||||
|
st.Az, st.El, st.Visible = pos.Az, pos.El, visible
|
||||||
|
st.RangeKm, st.AltKm = pos.RangeKm, pos.AltKm
|
||||||
|
t.status = st
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
|
|
||||||
t.pointRotator(pos, b.Geostationary)
|
t.pointRotator(pos, b.Geostationary)
|
||||||
t.readRotator()
|
t.readRotator()
|
||||||
|
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
st := t.status
|
out := t.status
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
a.emitSatTrack(st)
|
a.emitSatTrack(out)
|
||||||
|
|
||||||
// Only send what has actually moved. The step is the smallest change worth a
|
// 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
|
// command: on SSB a listener hears twenty hertz, on an FM channel nothing
|
||||||
@@ -352,11 +378,12 @@ func (a *App) satTrackStep(t *satTracker) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mode := tp.Mode
|
downMode, upMode := satSidebands(tp)
|
||||||
if lastDown != 0 {
|
if lastDown != 0 {
|
||||||
mode = "" // set once, at the start of the pass — see satMode/satSetMode
|
// Set once, at the start of the pass — see satMode/satSetMode.
|
||||||
|
downMode, upMode = "", ""
|
||||||
}
|
}
|
||||||
err := a.satTune(down, up, mode, mode)
|
err := a.satTune(down, up, downMode, upMode)
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.lastDown, t.lastUp, t.fails = down, up, 0
|
t.lastDown, t.lastUp, t.fails = down, up, 0
|
||||||
@@ -415,7 +442,14 @@ func (t *satTracker) pointRotator(pos sat.Position, geostationary bool) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if t.rotSent && math.Abs(az-t.rotAz) < t.rotStep && math.Abs(el-t.rotEl) < t.rotStep {
|
// 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
|
return
|
||||||
}
|
}
|
||||||
if err := t.rot.Point(az, el); err != nil {
|
if err := t.rot.Point(az, el); err != nil {
|
||||||
@@ -620,3 +654,147 @@ func satBandLetter(hz int64) string {
|
|||||||
}
|
}
|
||||||
return "K" // 24 GHz and above
|
return "K" // 24 GHz and above
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// flexBandAntKey is the key a frequency has in the per-band antenna and power
|
||||||
|
// maps.
|
||||||
|
//
|
||||||
|
// Those maps are keyed by the band name UPPERCASED ("70CM"), because that is
|
||||||
|
// how the settings panel writes them; bandForHz returns the band plan's own
|
||||||
|
// spelling ("70cm"). Every other caller happened to uppercase on the way in,
|
||||||
|
// the satellite tracker did not, and so it read an empty antenna out of a map
|
||||||
|
// the operator had filled in — which is not a mistake worth making twice.
|
||||||
|
func flexBandAntKey(hz int64) string {
|
||||||
|
return strings.ToUpper(bandForHz(hz))
|
||||||
|
}
|
||||||
|
|
||||||
|
// applySatRadio puts each satellite slice on the antenna configured for ITS
|
||||||
|
// band, and sets the uplink tone.
|
||||||
|
//
|
||||||
|
// Settings ▸ FlexRadio already holds a per-band RX/TX antenna map, and it was
|
||||||
|
// only ever applied by the entry form on a band change — to the active slice.
|
||||||
|
// A pass never goes through that path: the tracker arms two slices itself, on
|
||||||
|
// two different bands, and both were left on whatever the radio last used. A
|
||||||
|
// station with transverters (XVTA on 2 m, XVTB on 70 cm) therefore heard
|
||||||
|
// nothing at all, having configured exactly the thing that was being ignored.
|
||||||
|
//
|
||||||
|
// The bands come from the NOMINAL frequencies, not the Doppler-corrected ones:
|
||||||
|
// a correction of ten kilohertz cannot change the band, and the nominal pair is
|
||||||
|
// what the operator's configuration is written against.
|
||||||
|
func (a *App) applySatRadio(tp sat.Transponder) {
|
||||||
|
if a.cat == nil || !a.cat.SatCapable() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The CTCSS tone first: an FM bird will not answer without it, and it is the
|
||||||
|
// one setting an operator cannot make from the front panel once a pass has
|
||||||
|
// started. Zero turns it off, which is what a linear bird needs.
|
||||||
|
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
|
||||||
|
return fc.SatTone(tp.CTCSS)
|
||||||
|
}); err != nil {
|
||||||
|
applog.Printf("sat: could not set the uplink tone: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := a.GetFlexBandAntennas()
|
||||||
|
if err != nil || len(m) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The downlink is received, so it takes that band's RX antenna; the uplink
|
||||||
|
// is transmitted, so it takes that band's TX antenna.
|
||||||
|
downBand, upBand := flexBandAntKey(tp.DownLo), flexBandAntKey(tp.UpLo)
|
||||||
|
rxAnt := m[downBand].RX
|
||||||
|
txAnt := m[upBand].TX
|
||||||
|
if strings.TrimSpace(rxAnt) == "" && strings.TrimSpace(txAnt) == "" {
|
||||||
|
// Worth a line: an operator who HAS configured the pair and still sees
|
||||||
|
// the wrong antenna has no other way to tell a setting he never made
|
||||||
|
// from a lookup that missed.
|
||||||
|
applog.Printf("sat: no antenna configured for this pass (down %s, up %s)", downBand, upBand)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applog.Printf("sat: antennas rx=%q (%s) tx=%q (%s)", rxAnt, downBand, txAnt, upBand)
|
||||||
|
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
|
||||||
|
return fc.SatAntennas(rxAnt, txAnt)
|
||||||
|
}); err != nil {
|
||||||
|
// Not fatal: a rig that is not a Flex has no such thing, and a pass with
|
||||||
|
// the wrong antenna is still a pass.
|
||||||
|
applog.Printf("sat: could not set the satellite antennas: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// satSidebands is which sideband to set on each side of a linear transponder.
|
||||||
|
//
|
||||||
|
// The two are NOT the same when the transponder inverts, and FO-29, RS-44 and
|
||||||
|
// AO-73 all do: the passband is turned over, so a signal transmitted on lower
|
||||||
|
// sideband comes back on upper. Setting USB at both ends — which is what
|
||||||
|
// happened until now — put the operator's own audio through the transponder
|
||||||
|
// upside down, which is unreadable at the far end and sounds like nothing much
|
||||||
|
// at ours.
|
||||||
|
//
|
||||||
|
// Anything that is not SSB is the same on both sides: an FM repeater is FM up
|
||||||
|
// and FM down, and CW is CW whichever way round the passband runs.
|
||||||
|
func satSidebands(tp sat.Transponder) (downMode, upMode string) {
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(tp.Mode), "SSB") {
|
||||||
|
return tp.Mode, tp.Mode
|
||||||
|
}
|
||||||
|
// Every satellite is above 30 MHz, so the downlink is upper sideband — even
|
||||||
|
// on the AO-7 10 m downlink, which would be lower sideband on HF.
|
||||||
|
if tp.Inverting {
|
||||||
|
return "USB", "LSB"
|
||||||
|
}
|
||||||
|
return "USB", "USB"
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetargetSatelliteTracking points the tracker at a different satellite without
|
||||||
|
// letting go of the radio.
|
||||||
|
//
|
||||||
|
// Two birds are often up at once, and an operator switching between them found
|
||||||
|
// the frequencies stayed on the first: the panel's selection is the DISPLAY's,
|
||||||
|
// while the tracker held its own name and went on following what it was started
|
||||||
|
// with. Stopping and starting worked, which is how it was discovered, and is
|
||||||
|
// also how a Flex loses and rebuilds both its slices for no reason.
|
||||||
|
//
|
||||||
|
// So the radio stays armed and the rotator stays open, and only what is being
|
||||||
|
// followed changes. Everything derived from the old satellite is cleared so the
|
||||||
|
// next step sets it afresh: the frequencies, the mode on both slices (set once
|
||||||
|
// per satellite, not per tick), the antennas and the tone — the new bird may be
|
||||||
|
// U/V where the old one was V/U, which swaps which slice is on which band.
|
||||||
|
func (a *App) RetargetSatelliteTracking(name string, transponder int) error {
|
||||||
|
a.satTrackMu.Lock()
|
||||||
|
t := a.satTrack
|
||||||
|
a.satTrackMu.Unlock()
|
||||||
|
if t == nil {
|
||||||
|
// Not tracking: this is simply a start.
|
||||||
|
return a.StartSatelliteTracking(name, transponder)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, birds, _ := a.satParts()
|
||||||
|
b, ok := birds.Find(name)
|
||||||
|
if !ok || len(b.Transponders) == 0 {
|
||||||
|
return fmt.Errorf("%s has no frequency plan to tune to", name)
|
||||||
|
}
|
||||||
|
if transponder < 0 || transponder >= len(b.Transponders) {
|
||||||
|
transponder = 0
|
||||||
|
}
|
||||||
|
tp := b.Transponders[transponder]
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
t.name, t.tp = b.Name, transponder
|
||||||
|
t.nominalDown = tp.Centre()
|
||||||
|
// Zeroed so the next step tunes and sets the mode again rather than deciding
|
||||||
|
// nothing has changed.
|
||||||
|
t.lastDown, t.lastUp, t.fails = 0, 0, 0
|
||||||
|
// And so the antenna is commanded at once instead of waiting for the new
|
||||||
|
// satellite to drift a step away from where the old one happened to be.
|
||||||
|
t.rotSent = false
|
||||||
|
t.status.Name, t.status.Transponder, t.status.Mode = b.Name, tp.Label, tp.Mode
|
||||||
|
t.status.Error = ""
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
if a.cat != nil && a.cat.SatCapable() {
|
||||||
|
a.applySatRadio(tp)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case t.wake <- struct{}{}:
|
||||||
|
default: // a step is already pending; it will pick this up
|
||||||
|
}
|
||||||
|
applog.Printf("sat: now tracking %s (%s)", b.Name, tp.Label)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hamlog/internal/sat"
|
"hamlog/internal/sat"
|
||||||
@@ -45,3 +46,129 @@ func TestSatModeLetters(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Azimuth-only tracking must not command the rotor once a second.
|
||||||
|
//
|
||||||
|
// The step check used to compare BOTH axes, so with the elevation never
|
||||||
|
// commanded its difference stayed above the step for the whole pass and every
|
||||||
|
// tick sent the antenna to the bearing it was already on. A rotator is a
|
||||||
|
// mechanical thing with a finite number of turns in it.
|
||||||
|
func TestPointRotatorAzOnlyIgnoresElevation(t *testing.T) {
|
||||||
|
rec := &countingRotator{}
|
||||||
|
tr := &satTracker{rot: rec, rotStep: 5, rotAzOnly: true}
|
||||||
|
|
||||||
|
// The satellite climbs while the bearing barely moves — a pass going
|
||||||
|
// overhead from the side, which is the shape that provoked this.
|
||||||
|
for _, p := range []sat.Position{
|
||||||
|
{Az: 100, El: 5},
|
||||||
|
{Az: 101, El: 20},
|
||||||
|
{Az: 102, El: 45},
|
||||||
|
{Az: 103, El: 70},
|
||||||
|
} {
|
||||||
|
tr.pointRotator(p, false)
|
||||||
|
}
|
||||||
|
if rec.n != 1 {
|
||||||
|
t.Errorf("azimuth-only sent %d commands for 3° of bearing, want 1", rec.n)
|
||||||
|
}
|
||||||
|
if rec.lastAz != 100 {
|
||||||
|
t.Errorf("commanded azimuth %v, want the first one", rec.lastAz)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it still follows the azimuth when the azimuth actually moves.
|
||||||
|
tr.pointRotator(sat.Position{Az: 130, El: 70}, false)
|
||||||
|
if rec.n != 2 {
|
||||||
|
t.Errorf("a 30° swing was not followed: %d commands", rec.n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With an elevation axis, a climb is still followed.
|
||||||
|
func TestPointRotatorFollowsElevationWhenItCan(t *testing.T) {
|
||||||
|
rec := &countingRotator{}
|
||||||
|
tr := &satTracker{rot: rec, rotStep: 5}
|
||||||
|
tr.pointRotator(sat.Position{Az: 100, El: 5}, false)
|
||||||
|
tr.pointRotator(sat.Position{Az: 101, El: 40}, false)
|
||||||
|
if rec.n != 2 {
|
||||||
|
t.Errorf("a 35° climb was not followed: %d commands", rec.n)
|
||||||
|
}
|
||||||
|
if rec.lastEl != 40 {
|
||||||
|
t.Errorf("commanded elevation %v, want 40", rec.lastEl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type countingRotator struct {
|
||||||
|
n int
|
||||||
|
lastAz, lastEl float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *countingRotator) Point(az, el float64) error {
|
||||||
|
c.n++
|
||||||
|
c.lastAz, c.lastEl = az, el
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (c *countingRotator) Heading() (float64, float64, bool, error) { return 0, 0, false, nil }
|
||||||
|
func (c *countingRotator) Close() {}
|
||||||
|
|
||||||
|
// Which sideband goes on each slice.
|
||||||
|
//
|
||||||
|
// An inverting transponder turns the passband over, so a signal transmitted on
|
||||||
|
// lower sideband comes back on upper. Setting USB at both ends put the
|
||||||
|
// operator's own audio through upside down — unreadable at the far end, and on
|
||||||
|
// FO-29, RS-44 and AO-73 that is every contact attempted.
|
||||||
|
func TestSatSidebands(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
tp sat.Transponder
|
||||||
|
wantDown, want string
|
||||||
|
}{
|
||||||
|
{"inverting linear: LSB up, USB down",
|
||||||
|
sat.Transponder{Mode: "SSB", Inverting: true}, "USB", "LSB"},
|
||||||
|
{"non-inverting linear: USB both ways",
|
||||||
|
sat.Transponder{Mode: "SSB"}, "USB", "USB"},
|
||||||
|
// A tone is transmitted and received in FM whichever way the passband
|
||||||
|
// runs, and CW is CW.
|
||||||
|
{"FM is FM both ways", sat.Transponder{Mode: "FM"}, "FM", "FM"},
|
||||||
|
{"CW ignores inversion", sat.Transponder{Mode: "CW", Inverting: true}, "CW", "CW"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
down, up := satSidebands(c.tp)
|
||||||
|
if down != c.wantDown || up != c.want {
|
||||||
|
t.Errorf("%s: got %s/%s, want %s/%s", c.name, down, up, c.wantDown, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The per-band antenna map is keyed by the UPPERCASED band name, because that
|
||||||
|
// is what the settings panel writes. The satellite tracker looked its two bands
|
||||||
|
// up with the band plan's own spelling, matched nothing, and ran the pass on
|
||||||
|
// whichever antenna the radio was last left on — a 70 cm downlink through a 2 m
|
||||||
|
// transverter, with no error anywhere. This pins the contract in the direction
|
||||||
|
// that broke.
|
||||||
|
func TestFlexBandAntKeyIsUppercased(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
hz int64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{435_400_000, "70CM"}, // an FM bird's downlink
|
||||||
|
{145_950_000, "2M"}, // its uplink
|
||||||
|
{1_269_000_000, "23CM"}, // AO-92's L band
|
||||||
|
{29_450_000, "10M"}, // AO-7 mode A
|
||||||
|
{9_000_000_000, ""}, // nothing in the plan: no key, and no antenna
|
||||||
|
} {
|
||||||
|
if got := flexBandAntKey(c.hz); got != c.want {
|
||||||
|
t.Errorf("flexBandAntKey(%d) = %q, want %q", c.hz, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A band list sorted as strings puts 10m between 1.25m and 12m, which is why
|
||||||
|
// the plan's own index is the order.
|
||||||
|
func TestBandOrderIsByFrequency(t *testing.T) {
|
||||||
|
got := []string{"70cm", "10m", "160m", "2m", "20m", "banana"}
|
||||||
|
sort.Slice(got, func(i, j int) bool { return bandOrder(got[i]) < bandOrder(got[j]) })
|
||||||
|
want := []string{"160m", "20m", "10m", "2m", "70cm", "banana"}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("sorted %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+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.
|
||||||
|
|||||||
+166
-2
@@ -1,4 +1,166 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.27.23",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"SteppIR: a Calibrate button, beside Retract. It drives every element to its end stop so the controller re-learns where zero is — the cure for an antenna that tunes to the wrong length after a power cut mid-move, after the elements were pushed by hand, or after a motor slipped. It takes minutes and the antenna is unusable until it finishes, so it asks first. Ultrabeam controllers have no such command and say so rather than pretending. Retracting the elements was already there and now explains itself: it is the storage position, and the next tune brings them back out on its own.",
|
||||||
|
"On the rotor dial, the beam no longer whips a full turn round the compass when the antenna crosses north. A rotator at 020° turned anticlockwise reports 020, 010, 000, 359, 358 … and the animation travelled from 20° to 359° the long way — a complete revolution on screen while the mast moved forty degrees the other way. The beam now follows an accumulated angle, so what is drawn is the way the antenna is actually turning. Both lobes of a bidirectional antenna are handled separately, since they cross north at different moments.",
|
||||||
|
"The rotor widget stops claiming the antenna is turning every time the wind moves it. A threshold alone could not tell the difference — a gust pushes a beam well past four degrees and back, and each excursion counted, so Stop lit and went out all evening on an antenna that had not turned. What separates a rotation from the weather is not how far the reading moves but which way: a rotor under power advances, gust after gust reverses. A step now counts only when the previous one went the same way, so movement is announced one poll later on a mast that takes half a minute to cross a pass, and never at all on a windy afternoon.",
|
||||||
|
"LilacSat-2 gets its FM transponder — 144.350 up, 437.200 down — and its proper name, LO-90. It was shipped with nothing but an APRS digipeater, because the transponder database marks that transponder inactive: it runs to an announced schedule rather than continuously, which from a database looks exactly like a dead one. The label says \"(scheduled)\" so nobody wonders why it is quiet. The generator now also lists the satellites it refused on that ground and kept nothing else for, so the next regeneration is not silent about them.",
|
||||||
|
"A frequency OpsLog got wrong can now be corrected on a station that already has the satellite file. Until now the file was copied out on the first run and was the operator's from then on, so a mistake we shipped — LilacSat-2 with no FM transponder — had become their data and could never be mended. The plan now adds what is missing, brings up to date what was never edited, and leaves alone what was: an entry you corrected by hand outranks anything shipped, and the log names the ones it stood down on. The first run after this cannot tell the two apart, so it takes the shipped plan and copies your file to satellites.json.bak first — after that your edits are recognised precisely and survive every release.",
|
||||||
|
"The per-band antennas are applied to the satellite slices at last. The map is keyed by the band name in capitals, the tracker looked its two bands up in lower case, and so it read no antenna at all out of a table the operator had filled in — a 70 cm downlink stayed on the 2 m transverter. The log now names the band and the antenna it resolved, so a setting never made can be told apart from a lookup that missed.",
|
||||||
|
"Tracking now shows where the satellite is beside where the antenna points: azimuth, elevation, distance and altitude, in the same strip as the two frequencies. The elevation goes dim below the horizon, so a bird still being followed on its way up cannot be read as workable.",
|
||||||
|
"The two satellite lists in the settings are sorted by name, numerically — AO-27, AO-91, AO-123. The left one came in the order the frequency file happens to be written and the right one in the order they were clicked, so finding one bird among sixteen meant reading all sixteen. A satellite renamed on getting its OSCAR number, as LILACSAT-2 became LO-90, is also recognised in a followed list that still holds the old name instead of being listed as having no elements.",
|
||||||
|
"The grid-square map filters by one mode, by band and by satellite. The FTx button is gone: it grouped FT8, FT4 and FT2 together, while the question the map answers is where a single mode has been heard. The named modes are read from the log itself, so each one is offered only if there is something behind it — and FT2 appears for whoever is already using it, with nothing to change here the day it becomes a registered mode. The band list is the station's own plus anything worked outside it, and the satellite list only appears once a square has been worked through a bird.",
|
||||||
|
"Saving the satellite settings now refreshes the Satellites tab. It read the followed list once when it was opened, so un-following a satellite left it in the pass table and in the dropdown until the tab was reopened — which looks exactly like a setting that failed to save. The lowest pass, the window and the locator are picked up the same way, and a satellite that is no longer followed hands the selection to the first one that is.",
|
||||||
|
"The FT map takes one colour for every decode, next to the basemap buttons. The band palette is calibrated against a plain map — 60m navy and 70cm olive all but vanish over the satellite imagery — and it is only useful to somebody watching several bands at once, which most operators are not. Leave it empty for the colour per band, as before. The legend then keeps the band names and drops the swatches, since which bands are up is still worth knowing.",
|
||||||
|
"\"Later\" on an update notice now asks for how long: 1, 4, 12 or 24 hours. It only hid the card before, and the check behind it runs every five minutes, so the same notice came back four times an hour all evening for a version already declined. The delay is recorded against that version, so a newer release is different news and appears at once — a deferral can never bury an update. The cross hides it for an hour, and opening About still answers whatever was deferred, because asking is asking.",
|
||||||
|
"Stop stays lit for the whole rotation instead of blinking. Keeping the wind out took a four-degree step, but four degrees is four seconds of travel on a real rotor, so between two of them nothing confirmed the mast was still turning and the button went dark and came back the whole way round. Getting in stays strict — two steps the same way, which weather never gives — while staying in now needs only continued progress in that direction: one degree the same way is not a gust when the rotor is already under power.",
|
||||||
|
"The frequency plan is checked against AMSAT's live FM and image lists. AO-123 gets the 67.0 Hz tone its uplink needs, SO-50 says in its label that 74.4 Hz arms the timer before 67.0 can key it, and eight SSTV satellites are added — HC1PX, RS18S, RS27S, RS38S, RS40S, RS57S, RS58S and RS83S — as receive-only entries, with SSTV downlinks added to SONATE-2 and QMR-KWT-2. Every FM bird's uplink, downlink and tone now agrees with the published table.",
|
||||||
|
"The FT map can now show who hears YOU. \"Who hears me\" draws, in dashed cyan, every station reporting your own transmissions to PSK Reporter, alongside the solid arcs of what you decode — the direction you cannot see from your own receiver, and on FT8 the one that says whether calling is worth the cycle. It costs almost nothing: your callsign goes in the topic's transmit level, so the broker sends nothing else, and it works with the band-opening watch switched off. Off until asked for, and each station appears once with its freshest report inside a fifteen-minute window.",
|
||||||
|
"\"Who hears me\" is marks alone, no arcs. With a few dozen receivers reporting, a line to each made a fan out of one square that buried the decode arcs beside it — and the lines said nothing, since every one of these paths starts in the same place. The mark is a hollow diamond, fading with age: shape rather than colour carries the difference, the decode dots being filled circles and the arcs having used fourteen colours already.",
|
||||||
|
"The FT map's controls stop covering it. The basemap buttons had crept over Leaflet's zoom control, so the minus button took clicks meant for Street, and the row had grown with the colour picker and the reverse layer until it reached the middle of a world map — covering the Atlantic to save a top-right corner that was empty the whole time. Basemaps stay on the left, clear of the zoom; the colour and \"Who hears me\" move to the right.",
|
||||||
|
"The who-hears-me diamonds are filled and take a colour of their own, chosen beside the switch that turns them on. Filled with a hairline white edge, the trick the home marker already uses: a ring is an outline drawn over whatever is beneath it, and eight pixels of it over the satellite imagery was barely there. The colour is separate from the decode one because a single control would have forced both layers into one colour — the very distinction it took a shape to make."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"SteppIR : un bouton Calibrer, à côté de Rétracter. Il amène chaque élément en butée pour que le contrôleur retrouve son zéro — le remède à une antenne qui s’accorde à la mauvaise longueur après une coupure en pleine course, après avoir poussé les éléments à la main, ou après un moteur qui a glissé. Cela prend plusieurs minutes et l’antenne est inutilisable jusqu’à la fin : une confirmation est donc demandée. Les contrôleurs Ultrabeam n’ont pas cette commande et le disent, au lieu de faire semblant. Rétracter les éléments existait déjà et s’explique maintenant : c’est la position de rangement, et le prochain accord les fait ressortir tout seuls.",
|
||||||
|
"Sur le cadran du rotor, le faisceau ne fait plus un tour complet de la boussole quand l’antenne franchit le nord. Un rotor à 020° tourné dans le sens antihoraire annonce 020, 010, 000, 359, 358… et l’animation allait de 20° à 359° par le chemin long — une révolution complète à l’écran pendant que le pylône bougeait de quarante degrés dans l’autre sens. Le faisceau suit désormais un angle cumulé : ce qui est dessiné est le mouvement réel de l’antenne. Les deux lobes d’une antenne bidirectionnelle sont traités séparément, puisqu’ils ne franchissent pas le nord au même moment.",
|
||||||
|
"Le widget rotor n’annonce plus une antenne en rotation chaque fois que le vent la bouge. Un simple seuil ne pouvait pas faire la différence — une rafale pousse une beam bien au-delà de quatre degrés puis la ramène, et chaque écart comptait : Stop s’allumait et s’éteignait toute la soirée sur une antenne qui n’avait pas tourné. Ce qui distingue une rotation de la météo n’est pas l’amplitude mais le sens : un rotor sous tension avance, une rafale revient. Un écart ne compte donc que si le précédent allait dans le même sens — la rotation est annoncée un relevé plus tard sur un pylône qui met trente secondes à traverser, et plus du tout par vent fort.",
|
||||||
|
"LilacSat-2 récupère son transpondeur FM — 144,350 en montée, 437,200 en descente — et son vrai nom, LO-90. Il était livré avec un simple digipeater APRS, parce que la base de transpondeurs marque ce transpondeur comme inactif : il fonctionne selon un calendrier annoncé plutôt qu’en continu, ce qui, vu d’une base de données, ressemble exactement à un transpondeur mort. Le libellé indique « (scheduled) » pour que personne ne se demande pourquoi il est muet. Le générateur liste désormais aussi les satellites qu’il a écartés pour cette raison sans rien garder d’autre, afin que la prochaine régénération ne les passe plus sous silence.",
|
||||||
|
"Une fréquence qu’OpsLog avait fausse peut désormais être corrigée sur une station qui possède déjà le fichier satellites. Jusqu’ici ce fichier était copié au premier lancement puis appartenait à l’opérateur, si bien qu’une erreur de notre part — LilacSat-2 sans transpondeur FM — devenait sa donnée et ne pouvait plus être réparée. Le plan ajoute maintenant ce qui manque, met à jour ce qui n’a jamais été modifié, et laisse intact ce qui l’a été : une entrée corrigée à la main prime sur tout ce qui est livré, et le journal nomme celles devant lesquelles il s’est effacé. Le premier lancement après ce changement ne peut pas distinguer les deux : il prend donc le plan livré et copie d’abord votre fichier en satellites.json.bak — ensuite vos modifications sont reconnues précisément et survivent à chaque version.",
|
||||||
|
"Les antennes par bande sont enfin appliquées aux slices satellite. La table est indexée par le nom de bande en majuscules, le suivi cherchait ses deux bandes en minuscules, et il ne lisait donc aucune antenne dans un tableau que l’opérateur avait rempli — une descente 70 cm restait sur le transverter 2 m. Le journal indique désormais la bande et l’antenne retenues, pour distinguer un réglage jamais fait d’une recherche qui a échoué.",
|
||||||
|
"Le suivi affiche désormais où est le satellite à côté de là où pointe l’antenne : azimut, élévation, distance et altitude, dans le même bandeau que les deux fréquences. L’élévation s’estompe sous l’horizon, pour qu’un satellite encore suivi avant son lever ne soit pas pris pour un satellite travaillable.",
|
||||||
|
"Les deux listes de satellites des réglages sont triées par nom, en tenant compte des nombres — AO-27, AO-91, AO-123. Celle de gauche suivait l’ordre du fichier de fréquences et celle de droite l’ordre des clics : retrouver un satellite parmi seize obligeait à lire les seize. Un satellite renommé lors de l’attribution de son numéro OSCAR, comme LILACSAT-2 devenu LO-90, est aussi reconnu dans une liste de suivis qui porte encore l’ancien nom, au lieu d’y apparaître sans éléments.",
|
||||||
|
"La carte des carrés locator se filtre par mode précis, par bande et par satellite. Le bouton FTx disparaît : il regroupait FT8, FT4 et FT2, alors que la question à laquelle répond cette carte est de savoir où un seul mode a été entendu. Les modes proposés sont lus dans le journal, donc chacun n’apparaît que s’il y a quelque chose derrière — et FT2 est proposé à qui l’utilise déjà, sans rien à changer ici le jour où il deviendra un mode officiel. La liste des bandes est celle de la station, complétée par ce qui a été travaillé en dehors, et la liste des satellites n’apparaît qu’une fois un carré travaillé par satellite.",
|
||||||
|
"Enregistrer les réglages satellite rafraîchit désormais l’onglet Satellites. Il lisait la liste des suivis une seule fois à son ouverture : retirer un satellite le laissait dans le tableau des passes et dans la liste déroulante jusqu’à la réouverture de l’onglet — ce qui ressemble exactement à un réglage qui ne s’est pas enregistré. L’élévation minimale, la fenêtre de prévision et le locator sont pris en compte de la même façon, et un satellite qui n’est plus suivi passe la sélection au premier qui l’est.",
|
||||||
|
"La carte FT accepte une couleur unique pour tous les décodages, à côté des boutons de fond de carte. La palette par bande est calibrée sur une carte claire — le bleu marine du 60 m et l’olive du 70 cm disparaissent presque sur l’imagerie satellite — et elle ne sert qu’à qui surveille plusieurs bandes à la fois, ce qui n’est pas le cas de la plupart. Laissez-la vide pour retrouver la couleur par bande. La légende conserve alors les noms de bandes et abandonne les pastilles, car savoir quelles bandes sont ouvertes reste utile.",
|
||||||
|
"« Plus tard » sur une notification de mise à jour demande désormais combien de temps : 1, 4, 12 ou 24 heures. Le bouton ne faisait que masquer la fenêtre, et la vérification derrière tourne toutes les cinq minutes : la même notification revenait quatre fois par heure toute la soirée pour une version déjà refusée. Le report est enregistré pour cette version précise, donc une version plus récente est une autre nouvelle et s’affiche immédiatement — un report ne peut jamais enterrer une mise à jour. La croix masque pendant une heure, et ouvrir « À propos » répond toujours, même sur une version reportée : demander, c’est demander.",
|
||||||
|
"Le bouton Stop reste allumé pendant toute la rotation au lieu de clignoter. Écarter le vent demandait un écart de quatre degrés, mais quatre degrés représentent quatre secondes de course sur un vrai rotor : entre deux d’entre eux, plus rien ne confirmait que le pylône tournait encore et le bouton s’éteignait puis se rallumait tout du long. L’entrée reste stricte — deux écarts dans le même sens, ce que la météo ne produit jamais — tandis que le maintien ne demande plus qu’une progression continue dans ce sens : un degré du même côté n’est pas une rafale quand le rotor est déjà sous tension.",
|
||||||
|
"Le plan de fréquences est vérifié contre les listes FM et image d’AMSAT. AO-123 reçoit le ton de 67,0 Hz qu’exige sa montée, SO-50 indique dans son libellé que 74,4 Hz arme le minuteur avant que 67,0 puisse l’ouvrir, et huit satellites SSTV sont ajoutés — HC1PX, RS18S, RS27S, RS38S, RS40S, RS57S, RS58S et RS83S — en réception seule, avec une descente SSTV ajoutée à SONATE-2 et QMR-KWT-2. La montée, la descente et le ton de chaque satellite FM correspondent désormais à la table publiée.",
|
||||||
|
"La carte FT peut désormais montrer qui VOUS entend. « Qui m’entend » trace en pointillés cyan chaque station qui rapporte vos propres émissions à PSK Reporter, à côté des arcs pleins de ce que vous décodez — la direction qu’on ne peut pas voir depuis son propre récepteur, et en FT8 celle qui dit si appeler vaut le cycle. Le coût est quasi nul : votre indicatif est placé dans le niveau émission du topic, donc le broker n’envoie rien d’autre, et cela fonctionne même avec la veille d’ouverture de bande éteinte. Éteint par défaut, et chaque station apparaît une fois avec son rapport le plus récent dans une fenêtre de quinze minutes.",
|
||||||
|
"« Qui m’entend » n’affiche plus que des marques, sans arcs. Avec quelques dizaines de récepteurs, un trait vers chacun formait un éventail depuis un seul carré qui enterrait les arcs de décodage voisins — et ces traits ne disaient rien, puisque tous ces chemins partent du même endroit. La marque est un losange creux qui s’estompe avec l’âge : c’est la forme, et non la couleur, qui porte la différence, les points de décodage étant des cercles pleins et les arcs utilisant déjà quatorze couleurs.",
|
||||||
|
"Les contrôles de la carte FT cessent de la recouvrir. Les boutons de fond de carte avaient débordé sur le zoom de Leaflet — le bouton moins prenait les clics destinés à Street — et la rangée s’était allongée avec le sélecteur de couleur et la couche inverse jusqu’à atteindre le milieu d’une carte du monde, masquant l’Atlantique pour épargner un coin haut-droit resté vide tout ce temps. Les fonds de carte restent à gauche, dégagés du zoom ; la couleur et « Qui m’entend » passent à droite.",
|
||||||
|
"Les losanges de « qui m’entend » sont pleins et prennent leur propre couleur, choisie à côté de l’interrupteur qui les allume. Pleins avec un liseré blanc d’un pixel, le procédé qu’utilise déjà le marqueur de la station : un cercle creux n’est qu’un contour tracé sur ce qui se trouve dessous, et huit pixels de contour sur l’imagerie satellite se voyaient à peine. La couleur est distincte de celle des décodages, car un seul réglage aurait forcé les deux couches dans une même couleur — précisément la distinction qu’il a fallu une forme pour établir."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.22",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"If OpsLog is about to create a new, empty settings database in a folder that already holds a full one, it says so — in the startup log and on screen — instead of opening quietly as though nothing were configured. Nothing is deleted and nothing is guessed: the message names the other file, which is where your settings still are.",
|
||||||
|
"config.json — the one file that records WHERE your database is — is now written atomically, and a copy of the previous one is kept beside it. It was written by truncating the file and then filling it, so a process that stopped in between (a crash, a power cut, an update closing the old instance) left it empty; OpsLog then read \"no database chosen\", opened a new empty one, and started having forgotten everything. An unreadable config.json is now restored from its backup, and one that cannot be restored is KEPT as config.json.broken rather than silently replaced.",
|
||||||
|
"The antenna readout no longer flickers in and out during a pass. The rotator is asked where it is every three seconds, but the tracking status was rebuilt from scratch every second and dropped the answer in between — so the antenna appeared for one second in three, which reads as a rotator that keeps disconnecting.",
|
||||||
|
"While tracking, the two frequencies and the antenna bearing sit beside the Tracking button. During a pass an operator watches the radio and the antenna, not a column on the far side of the window — and that column is the first thing hidden to get the map full width. The compass spins while the antenna is still slewing: a mast takes tens of seconds to cross a pass, and the difference between \"on its way\" and \"stuck\" is the whole reason to look at it.",
|
||||||
|
"Satellite frequencies are shown to a hundred hertz instead of one. The Doppler moves about sixty hertz a second on 70 cm, so the last two digits changed every tick and the display was a blur of numbers nobody could read and nobody needed. The radio still gets the whole figure — this is only how much of it is worth putting in front of you. The shift beside it now reads \"+9.7 kHz\" rather than \"+9741 Hz\".",
|
||||||
|
"Rotator Genius: OpsLog no longer clamps a target to 360°, and reads the limits the Genius reports so it can drive an overlap when the controller offers one. In practice a Rotator Genius is a 360° controller — its Limits fields say where the mechanical stop sits within one turn, not how far the mast travels — so an operator with a 450° rotator still gets 360° of it, and that limit is the controller’s, not OpsLog’s. The rotator range is therefore not offered for it: a setting that can only ever be refused by the box is worse than none.",
|
||||||
|
"FlexRadio, satellite: the uplink slice is properly armed. Creating a slice is asynchronous — the radio reports its number afterwards — and OpsLog carried on without waiting, so everything meant for the uplink went nowhere: it was never tuned (it sat at the 435.100 it was created with), never got its sideband, its antenna or its CTCSS tone, and never became the transmitter, leaving the radio transmitting on the DOWNLINK slice. Arming now waits for both slices, adopts one that the radio announces without a reply of its own, and gives a late-arriving uplink everything it was owed."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Si OpsLog s’apprête à créer une base de réglages neuve et vide dans un dossier qui en contient déjà une pleine, il le dit — dans le journal de démarrage et à l’écran — au lieu de s’ouvrir sans bruit comme si rien n’était configuré. Rien n’est supprimé et rien n’est deviné : le message nomme l’autre fichier, là où vos réglages sont toujours.",
|
||||||
|
"config.json — le seul fichier qui note OÙ se trouve votre base — est désormais écrit de façon atomique, avec une copie de la version précédente conservée à côté. Il était écrit en tronquant le fichier puis en le remplissant : un processus interrompu entre les deux (plantage, coupure de courant, mise à jour fermant l’ancienne instance) le laissait vide. OpsLog lisait alors « aucune base choisie », en ouvrait une neuve et vide, et démarrait en ayant tout oublié. Un config.json illisible est maintenant restauré depuis sa sauvegarde, et celui qu’on ne peut pas restaurer est CONSERVÉ sous le nom config.json.broken au lieu d’être remplacé en silence.",
|
||||||
|
"L’affichage de l’antenne ne clignote plus pendant un passage. Le rotor est interrogé toutes les trois secondes, mais l’état du suivi était reconstruit de zéro chaque seconde et perdait la réponse entre-temps — l’antenne apparaissait donc une seconde sur trois, ce qui se lit comme un rotor qui se déconnecte sans arrêt.",
|
||||||
|
"Pendant le suivi, les deux fréquences et le cap de l’antenne sont affichés à côté du bouton Tracking. Pendant un passage, on regarde la radio et l’antenne, pas une colonne à l’autre bout de la fenêtre — et c’est la première chose qu’on masque pour avoir la carte en pleine largeur. La boussole tourne tant que l’antenne est en mouvement : un pylône met des dizaines de secondes à traverser un passage, et distinguer « en route » de « bloqué » est toute la raison de la regarder.",
|
||||||
|
"Les fréquences satellite sont affichées à la centaine de hertz au lieu du hertz. Le Doppler se déplace d’environ soixante hertz par seconde en 70 cm : les deux derniers chiffres changeaient à chaque tick et l’affichage était une bouillie de chiffres illisible et inutile. La radio reçoit toujours la valeur complète — il ne s’agit que de ce qui vaut la peine d’être mis sous vos yeux. Le décalage à côté indique désormais « +9,7 kHz » plutôt que « +9741 Hz ».",
|
||||||
|
"Rotator Genius : OpsLog n’écrête plus une consigne à 360° et lit les limites que le Genius rapporte, de façon à exploiter un recouvrement quand le contrôleur en offre un. Dans les faits, le Rotator Genius est un contrôleur 360° — ses champs Limits indiquent où se trouve la butée mécanique dans un tour, pas la course du pylône — donc un rotor 450° n’en donne que 360, et cette limite est celle du contrôleur, pas d’OpsLog. L’amplitude du rotor n’est donc pas proposée pour lui : un réglage que le boîtier ne pourra que refuser est pire que pas de réglage du tout.",
|
||||||
|
"FlexRadio, satellite : la tranche de montée est correctement armée. Créer une tranche est asynchrone — la radio annonce son numéro ensuite — et OpsLog continuait sans attendre : tout ce qui était destiné à la montée partait dans le vide. Elle n’était jamais accordée (elle restait sur le 435,100 de sa création), ne recevait ni sa bande latérale, ni son antenne, ni sa tonalité CTCSS, et ne devenait jamais l’émettrice — la radio émettait donc sur la tranche de DESCENTE. L’armement attend maintenant les deux tranches, adopte celle que la radio annonce sans réponse propre, et donne à une montée arrivée en retard tout ce qui lui était dû."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.21",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"The Doppler correction was wrong — by a factor of about 250, and in the wrong direction. The SGP4 library reports a range rate that is not one: the ISS closing at 5.5 km/s came back as +2036 km/s, which moved a 2 m downlink two megahertz instead of three kilohertz, and moved it the wrong way. OpsLog now measures the range rate from the range itself, which cannot disagree with physics. A 2 m downlink shifts about ±3.5 kHz across a pass and a 70 cm one about ±10 kHz, as they should.",
|
||||||
|
"FlexRadio, satellite: the per-band antennas you configured are now applied to the satellite slices. They were not — the entry form applied them on a band change, to the active slice, and a pass never goes through that path. The two slices are on two different bands, so each gets its own: the downlink takes the receive antenna for its band, the uplink the transmit antenna for its. On a station with transverters (XVTA on 2 m, XVTB on 70 cm) the downlink was left on whatever the radio last used, and heard nothing.",
|
||||||
|
"FlexRadio, satellite: on an inverting transponder the uplink is set to LSB and the downlink to USB, instead of USB at both ends. The passband is turned over, so audio transmitted on the wrong sideband comes back through it upside down — which is every attempted contact on FO-29, RS-44 and AO-73.",
|
||||||
|
"FlexRadio, satellite: the CTCSS tone is set on the uplink slice from the satellite's frequency plan. An FM bird does not answer without it, and it is the one setting an operator cannot reach from the front panel once a pass has started.",
|
||||||
|
"Changing satellite while tracking now moves the radio to the new one at once, and the antenna with it. The selection on the page was the display's; the tracker held its own and went on following whatever it was started with, so with two birds up at the same time the frequencies stayed on the first — the only way through was to stop tracking and start it again. The radio stays armed through the change, so a Flex no longer throws away and rebuilds both its slices for nothing.",
|
||||||
|
"WinKeyer: a keyer that will not connect is now woken up instead of given up on. An operator with a WinKey2 USB had to run K1EL's WKdemo and close it again before OpsLog could open the keyer at all — so the second attempt now does what closing WKdemo does: Host Close in case a session that ended badly left the keyer waiting for a host that went away, Admin Reset for a parser stuck part-way through a command, and a DTR pulse, which on a WKUSB or an Arduino clone is a power-on reset in all but name. A keyer that echoes but refuses to open is also closed and asked again, which is the same leftover-session case seen from the other side. A port known to need this gets it straight away next time."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"La correction Doppler était fausse — d’un facteur d’environ 250, et dans le mauvais sens. La bibliothèque SGP4 renvoie une vitesse radiale qui n’en est pas une : l’ISS se rapprochant à 5,5 km/s était rapportée à +2036 km/s, ce qui déplaçait une descente 2 m de deux mégahertz au lieu de trois kilohertz, et dans la mauvaise direction. OpsLog mesure désormais cette vitesse à partir de la distance elle-même, ce qui ne peut pas contredire la physique. Une descente 2 m se décale d’environ ±3,5 kHz sur un passage et une 70 cm d’environ ±10 kHz, comme il se doit.",
|
||||||
|
"FlexRadio, satellite : les antennes par bande que vous avez configurées sont désormais appliquées aux tranches satellite. Elles ne l’étaient pas — la fenêtre de saisie les appliquait au changement de bande, sur la tranche active, et un passage ne passe jamais par là. Les deux tranches sont sur deux bandes différentes, donc chacune reçoit la sienne : la descente prend l’antenne de réception de sa bande, la montée l’antenne d’émission de la sienne. Sur une station à transverters (XVTA en 2 m, XVTB en 70 cm), la descente restait sur ce que la radio utilisait en dernier, et n’entendait rien.",
|
||||||
|
"FlexRadio, satellite : sur un transpondeur inverseur, la montée est mise en LSB et la descente en USB, au lieu d’USB des deux côtés. La bande passante est retournée : une audio émise sur la mauvaise bande latérale revient à l’envers — soit tous les QSO tentés sur FO-29, RS-44 et AO-73.",
|
||||||
|
"FlexRadio, satellite : la tonalité CTCSS est réglée sur la tranche de montée depuis le plan de fréquences du satellite. Un satellite FM ne répond pas sans elle, et c’est le seul réglage qu’un OM ne peut pas atteindre en façade une fois le passage commencé.",
|
||||||
|
"Changer de satellite pendant le suivi déplace désormais la radio sur le nouveau immédiatement, et l’antenne avec. La sélection de la page était celle de l’affichage ; le tracker gardait la sienne et continuait de suivre celui avec lequel il avait démarré, donc avec deux satellites en passage simultané les fréquences restaient sur le premier — il fallait arrêter puis relancer le suivi. La radio reste armée pendant le changement : un Flex ne jette plus ses deux tranches pour les reconstruire inutilement.",
|
||||||
|
"WinKeyer : un keyer qui refuse de se connecter est désormais réveillé au lieu d’être abandonné. Un OM avec un WinKey2 USB devait lancer le WKdemo de K1EL puis le refermer avant qu’OpsLog puisse ouvrir le keyer — la seconde tentative fait donc maintenant ce que fait la fermeture de WKdemo : Host Close au cas où une session mal terminée aurait laissé le keyer à attendre un hôte disparu, Admin Reset pour un analyseur bloqué au milieu d’une commande, et une impulsion sur DTR, qui sur un WKUSB ou un clone Arduino est une remise sous tension ou presque. Un keyer qui répond à l’écho mais refuse de s’ouvrir est également refermé puis redemandé — le même cas de session résiduelle, vu de l’autre côté. Un port connu pour en avoir besoin y a droit d’emblée la fois suivante."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.27.17",
|
||||||
"date": "",
|
"date": "",
|
||||||
@@ -8,7 +170,8 @@
|
|||||||
"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.",
|
"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 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.",
|
"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."
|
"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": [
|
"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.",
|
"[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.",
|
||||||
@@ -16,7 +179,8 @@
|
|||||||
"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é.",
|
"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.",
|
"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.",
|
"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."
|
"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."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
// Command satdiag answers "is this pass real, and is that Doppler right?" from
|
||||||
|
// a station's own cached elements, without launching OpsLog.
|
||||||
|
//
|
||||||
|
// go run ./cmd/satdiag <data dir> <locator> <satellite>
|
||||||
|
//
|
||||||
|
// It prints which element set the satellite resolved to and how old it is, the
|
||||||
|
// look angle now, the range rate BOTH as the propagator reports it and as the
|
||||||
|
// range actually changes, the Doppler each transponder would be given, and the
|
||||||
|
// next passes. It exists because a wrong Doppler and a wrong satellite look the
|
||||||
|
// same from the front — an operator saying "the frequency moves enormously" —
|
||||||
|
// and the two are told apart by these numbers in a second.
|
||||||
|
//
|
||||||
|
// It found the range rate the SGP4 library reports being wrong by a factor of
|
||||||
|
// 250 and of the wrong sign. Not part of the build.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/sat"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dir := os.Args[1]
|
||||||
|
grid := os.Args[2]
|
||||||
|
name := os.Args[3]
|
||||||
|
|
||||||
|
f := sat.NewFetcher(dir)
|
||||||
|
els, at, err := f.LoadCache()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("cache:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
store := sat.NewStore()
|
||||||
|
store.Replace(els, at)
|
||||||
|
fmt.Printf("elements: %d, fetched %s (%s ago)\n\n", len(els), at.Format(time.RFC3339), time.Since(at).Round(time.Minute))
|
||||||
|
|
||||||
|
birds, err := sat.LoadBirds(dir)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("birds:", err)
|
||||||
|
}
|
||||||
|
b, ok := birds.Find(name)
|
||||||
|
if !ok {
|
||||||
|
fmt.Println("no frequency plan for", name)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
// The same resolution the app does.
|
||||||
|
var el sat.Element
|
||||||
|
found := false
|
||||||
|
if e, ok := store.GetNORAD(b.NORAD); ok {
|
||||||
|
el, found = e, true
|
||||||
|
fmt.Printf("elements found BY NORAD %d → %q\n", b.NORAD, e.Name)
|
||||||
|
} else if e, ok := store.Get(b.Name); ok {
|
||||||
|
el, found = e, true
|
||||||
|
fmt.Printf("elements found by name → %q (NORAD %d)\n", e.Name, e.NORAD)
|
||||||
|
} else {
|
||||||
|
for _, a := range b.Aliases {
|
||||||
|
if e, ok := store.Get(a); ok {
|
||||||
|
el, found = e, true
|
||||||
|
fmt.Printf("elements found by alias %q → %q (NORAD %d)\n", a, e.Name, e.NORAD)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
// Last resort, exactly as satElement does: scan every element name and
|
||||||
|
// compare on letters and digits alone. This is how "JAS-2 (FO-29)" and
|
||||||
|
// "FO-29" meet, and leaving it out of the diagnostic made a satellite
|
||||||
|
// that resolves perfectly well in the app look unresolvable here.
|
||||||
|
for _, n := range store.Names() {
|
||||||
|
if b.Matches(n) {
|
||||||
|
if e, ok := store.Get(n); ok {
|
||||||
|
el, found = e, true
|
||||||
|
fmt.Printf("elements found by SCAN → %q (NORAD %d)\n", e.Name, e.NORAD)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
fmt.Println("NO ELEMENTS")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("epoch: %s (%s old)\n", el.Epoch.Format(time.RFC3339), time.Since(el.Epoch).Round(time.Hour))
|
||||||
|
fmt.Println("line1:", el.Line1)
|
||||||
|
|
||||||
|
lat, lon, okGrid := gridToLatLon(grid)
|
||||||
|
if !okGrid {
|
||||||
|
fmt.Println("bad locator:", grid)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
obs := sat.Observer{Lat: lat, Lon: lon}
|
||||||
|
fmt.Printf("observer: %s → %.4f, %.4f\n\n", grid, obs.Lat, obs.Lon)
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
p, err := el.Track(obs, now)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("track:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("NOW %s : az %.1f el %.1f range %.0f km\n", now.Format("15:04:05"), p.Az, p.El, p.RangeKm)
|
||||||
|
fmt.Printf(" range rate REPORTED by the library : %+10.3f km/s\n", p.RangeRate)
|
||||||
|
fmt.Printf(" range rate MEASURED (d range / dt) : %+10.3f km/s\n", numericRate(el, obs, now))
|
||||||
|
|
||||||
|
for _, tp := range b.Transponders {
|
||||||
|
sh := sat.Doppler(p, tp.DownLo, tp.UpLo)
|
||||||
|
fmt.Printf(" %-28s down %d → %d (%+d Hz) up %d → %d (%+d Hz)\n",
|
||||||
|
tp.Label, tp.DownLo, sh.DownHz, sh.DownHz-tp.DownLo, tp.UpLo, sh.UpHz, sh.UpHz-tp.UpLo)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\nnext passes (min el 0):")
|
||||||
|
passes, err := store.Passes(el.Name, obs, now, now.Add(12*time.Hour), 0)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("passes:", err)
|
||||||
|
}
|
||||||
|
for i, ps := range passes {
|
||||||
|
if i >= 8 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s → %s max %.1f° az %.0f→%.0f\n",
|
||||||
|
ps.AOS.Format("15:04:05"), ps.LOS.Format("15:04:05"), ps.MaxEl, ps.AOSAz, ps.LOSAz)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The extremes of the Doppler across the next pass, which is the honest
|
||||||
|
// answer to "does it move that much".
|
||||||
|
if len(passes) > 0 {
|
||||||
|
ps := passes[0]
|
||||||
|
var lo, hi int64
|
||||||
|
for tt := ps.AOS; tt.Before(ps.LOS); tt = tt.Add(10 * time.Second) {
|
||||||
|
q, err := el.Track(obs, tt)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d := sat.Doppler(q, b.Transponders[0].DownLo, 0).DownHz - b.Transponders[0].DownLo
|
||||||
|
if d < lo {
|
||||||
|
lo = d
|
||||||
|
}
|
||||||
|
if d > hi {
|
||||||
|
hi = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("\ndownlink Doppler across that pass: %+d Hz … %+d Hz (span %d Hz)\n", lo, hi, hi-lo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gridToLatLon is the six-character Maidenhead centre.
|
||||||
|
func gridToLatLon(g string) (float64, float64, bool) {
|
||||||
|
g = strings.ToUpper(strings.TrimSpace(g))
|
||||||
|
if len(g) < 4 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
lon := float64(g[0]-'A')*20 - 180
|
||||||
|
lat := float64(g[1]-'A')*10 - 90
|
||||||
|
lon += float64(g[2]-'0') * 2
|
||||||
|
lat += float64(g[3]-'0') * 1
|
||||||
|
if len(g) >= 6 {
|
||||||
|
lon += float64(g[4]-'A') * (2.0 / 24)
|
||||||
|
lat += float64(g[5]-'A') * (1.0 / 24)
|
||||||
|
lon += (2.0 / 24) / 2
|
||||||
|
lat += (1.0 / 24) / 2
|
||||||
|
} else {
|
||||||
|
lon += 1
|
||||||
|
lat += 0.5
|
||||||
|
}
|
||||||
|
return lat, lon, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// numericRate is the range rate measured rather than reported: the distance a
|
||||||
|
// second later minus the distance a second earlier, over two seconds. It cannot
|
||||||
|
// disagree with physics, so it is the reference the library's own figure is
|
||||||
|
// checked against.
|
||||||
|
func numericRate(el sat.Element, obs sat.Observer, at time.Time) float64 {
|
||||||
|
a, e1 := el.Track(obs, at.Add(-time.Second))
|
||||||
|
b, e2 := el.Track(obs, at.Add(time.Second))
|
||||||
|
if e1 != nil || e2 != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return (b.RangeKm - a.RangeKm) / 2
|
||||||
|
}
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
// Command satgen refreshes internal/sat/birds.json from the public databases.
|
||||||
|
//
|
||||||
|
// A one-shot generator, run by hand, NOT part of the build — the same
|
||||||
|
// arrangement as cmd/cntygen. Satellites are switched between modes and new
|
||||||
|
// ones fly, and the shipped frequency plan should be re-cut every few releases
|
||||||
|
// rather than typed from memory.
|
||||||
|
//
|
||||||
|
// go run ./cmd/satgen
|
||||||
|
//
|
||||||
|
// It reads three sources and joins them on the NORAD catalog number:
|
||||||
|
//
|
||||||
|
// - Celestrak's amateur group and PE0SAT's mirror, for WHICH satellites
|
||||||
|
// OpsLog can get elements for. There is no point shipping a frequency plan
|
||||||
|
// for a bird whose TLE never arrives.
|
||||||
|
// - SatNOGS DB, for the transmitters. It is the maintained, machine-readable
|
||||||
|
// transponder database; AMSAT's chart is authoritative but is a web page.
|
||||||
|
//
|
||||||
|
// It NEVER destroys a curated entry. The hand-written plans carry things
|
||||||
|
// SatNOGS does not reliably hold — a CTCSS tone, a readable label, the QO-100
|
||||||
|
// passband as operators actually describe it — so an existing bird is kept
|
||||||
|
// verbatim and only has its NORAD number filled in. New satellites are appended.
|
||||||
|
// Read the diff before committing it: this is a starting point for an operator,
|
||||||
|
// and a wrong uplink is worse than a missing one.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/sat"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
birdsPath = "internal/sat/birds.json"
|
||||||
|
satnogsTX = "https://db.satnogs.org/api/transmitters/?format=json"
|
||||||
|
satnogsSats = "https://db.satnogs.org/api/satellites/?format=json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// satellite is the subset of a SatNOGS satellite record we use. Its whole
|
||||||
|
// purpose is the decay date: a frequency plan for a spacecraft that burned up
|
||||||
|
// two years ago is a row in the operator's list that will never do anything.
|
||||||
|
type satellite struct {
|
||||||
|
NORAD int `json:"norad_cat_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Names string `json:"names"` // other designations, comma or newline separated
|
||||||
|
Status string `json:"status"`
|
||||||
|
Decayed string `json:"decayed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tleFeeds are the element sources OpsLog itself reads (see internal/sat/tle.go).
|
||||||
|
var tleFeeds = []string{
|
||||||
|
"https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=tle",
|
||||||
|
"http://tle.pe0sat.nl/kepler/amateur.txt",
|
||||||
|
}
|
||||||
|
|
||||||
|
// transmitter is the subset of a SatNOGS DB record we use.
|
||||||
|
type transmitter struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
Alive bool `json:"alive"`
|
||||||
|
Type string `json:"type"` // Transmitter | Transponder | Transceiver
|
||||||
|
UplinkLow int64 `json:"uplink_low"`
|
||||||
|
UplinkHigh int64 `json:"uplink_high"`
|
||||||
|
DownlinkLow int64 `json:"downlink_low"`
|
||||||
|
DownlinkHigh int64 `json:"downlink_high"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Invert bool `json:"invert"`
|
||||||
|
NORAD int `json:"norad_cat_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
feed, err := loadFeeds()
|
||||||
|
if err != nil {
|
||||||
|
die(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("elements: %d satellites across %d feeds\n", len(feed), len(tleFeeds))
|
||||||
|
|
||||||
|
txs, err := loadTransmitters()
|
||||||
|
if err != nil {
|
||||||
|
die(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("satnogs: %d transmitters\n", len(txs))
|
||||||
|
|
||||||
|
cat, err := loadSatellites()
|
||||||
|
if err != nil {
|
||||||
|
die(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("satnogs: %d catalogued satellites\n", len(cat))
|
||||||
|
|
||||||
|
birds, err := loadBirds()
|
||||||
|
if err != nil {
|
||||||
|
die(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("existing plan: %d satellites\n", len(birds))
|
||||||
|
|
||||||
|
// 0. Drop what has come down. SatNOGS carries the re-entry date, so this is
|
||||||
|
// a documented fact rather than a judgement about which of the missing
|
||||||
|
// satellites are missing for good — the first-generation Tevel
|
||||||
|
// constellation alone had left eight rows that could never do anything.
|
||||||
|
kept := birds[:0]
|
||||||
|
for _, b := range birds {
|
||||||
|
if s, ok := decayed(b, cat); ok {
|
||||||
|
fmt.Printf(" - %s re-entered %s — removed\n", b.Name, strings.TrimSuffix(s.Decayed, "T00:00:00Z"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, b)
|
||||||
|
}
|
||||||
|
birds = kept
|
||||||
|
|
||||||
|
// 1. Give every curated entry its catalog number, so the join stops
|
||||||
|
// depending on how three different parties spell the same satellite.
|
||||||
|
covered := map[int]bool{}
|
||||||
|
for i := range birds {
|
||||||
|
if birds[i].NORAD == 0 {
|
||||||
|
if n, ok := noradFor(birds[i], feed); ok {
|
||||||
|
birds[i].NORAD = n
|
||||||
|
fmt.Printf(" + NORAD %5d for %s\n", n, birds[i].Name)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ! no elements found for %s — left without a catalog number\n", birds[i].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if birds[i].NORAD != 0 {
|
||||||
|
covered[birds[i].NORAD] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Append the satellites we can track and have a usable uplink for.
|
||||||
|
byNORAD := map[int][]transmitter{}
|
||||||
|
for _, t := range txs {
|
||||||
|
if !usable(t) || feed[t.NORAD] == "" || covered[t.NORAD] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byNORAD[t.NORAD] = append(byNORAD[t.NORAD], t)
|
||||||
|
}
|
||||||
|
// A satellite whose only WORKABLE path is one SatNOGS calls inactive.
|
||||||
|
//
|
||||||
|
// LilacSat-2 is the case that taught this: its FM transponder (144.350 up,
|
||||||
|
// 437.200 down) is marked inactive because it runs on an announced schedule
|
||||||
|
// rather than continuously, so the generator dropped it and shipped the
|
||||||
|
// satellite with nothing but an APRS digipeater. An operator comparing
|
||||||
|
// against any other tracker then finds a frequency plan missing the only
|
||||||
|
// thing anybody works that bird on.
|
||||||
|
//
|
||||||
|
// Not fixed automatically: "inactive" is right far more often than it is
|
||||||
|
// wrong, and reviving every dead transponder would fill the list with
|
||||||
|
// satellites that answer nothing. It is REPORTED, so the next regeneration
|
||||||
|
// is read with this in front of it and the handful worth curating are
|
||||||
|
// curated — a scheduled transponder belongs in the plan with "(scheduled)"
|
||||||
|
// in its label, the way PO-101 and now LO-90 carry it.
|
||||||
|
reportRefused(txs, feed, covered, byNORAD)
|
||||||
|
|
||||||
|
added := 0
|
||||||
|
for n, list := range byNORAD {
|
||||||
|
b := sat.Bird{Name: displayName(feed[n]), NORAD: n}
|
||||||
|
if alias := strings.TrimSpace(feed[n]); alias != "" && alias != b.Name {
|
||||||
|
b.Aliases = []string{alias}
|
||||||
|
}
|
||||||
|
for _, t := range list {
|
||||||
|
b.Transponders = append(b.Transponders, toTransponder(t))
|
||||||
|
}
|
||||||
|
sort.Slice(b.Transponders, func(i, j int) bool {
|
||||||
|
return b.Transponders[i].DownLo < b.Transponders[j].DownLo
|
||||||
|
})
|
||||||
|
birds = append(birds, b)
|
||||||
|
added++
|
||||||
|
fmt.Printf(" NEW %5d %-24s %d transponder(s)\n", n, b.Name, len(b.Transponders))
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(birds, func(i, j int) bool { return birds[i].Name < birds[j].Name })
|
||||||
|
out, err := json.MarshalIndent(birds, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
die(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(birdsPath, append(out, '\n'), 0o644); err != nil {
|
||||||
|
die(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("\nwrote %s — %d satellites (%d new)\n", birdsPath, len(birds), added)
|
||||||
|
}
|
||||||
|
|
||||||
|
// usable decides whether a SatNOGS transmitter is something an operator can
|
||||||
|
// work through.
|
||||||
|
//
|
||||||
|
// The database holds every emission a satellite makes, and most of them are not
|
||||||
|
// a contact: a telemetry beacon with a command uplink is listed exactly like an
|
||||||
|
// FM repeater, and shipping the command channel as a transponder would invite
|
||||||
|
// somebody to transmit on it. So both ends must exist, and anything that
|
||||||
|
// describes itself as telemetry or control is refused unless it also calls
|
||||||
|
// itself a repeater, a transponder or a digipeater.
|
||||||
|
func usable(t transmitter) bool {
|
||||||
|
if !t.Alive || t.Status != "active" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if t.UplinkLow <= 0 || t.DownlinkLow <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
d := strings.ToLower(t.Description)
|
||||||
|
isWorkable := strings.Contains(d, "repeater") || strings.Contains(d, "transponder") ||
|
||||||
|
strings.Contains(d, "digipeater") || strings.Contains(d, "aprs") ||
|
||||||
|
strings.Contains(d, "voice") || strings.Contains(d, "sstv") || strings.Contains(d, "dstar")
|
||||||
|
if isWorkable {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"telemetry", "command", "control", "dtmf", "beacon", "tlm"} {
|
||||||
|
if strings.Contains(d, bad) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An ANALOG emission with both ends is a contact by construction: nobody
|
||||||
|
// puts an FM or SSB uplink on a satellite for housekeeping. This is what
|
||||||
|
// catches the plainly-described repeaters — AO-27 says only "Mode V/U FM",
|
||||||
|
// and rejecting it for not using the word "repeater" would have dropped one
|
||||||
|
// of the best-known FM birds there is.
|
||||||
|
if m := adifMode(t.Mode); m == "FM" || m == "SSB" || m == "CW" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// A digital emission has to say what it is. A GMSK uplink is a command
|
||||||
|
// channel far more often than it is a digipeater, and shipping the wrong one
|
||||||
|
// invites an operator to transmit on a control frequency.
|
||||||
|
return t.Type == "Transponder" || (t.UplinkHigh > t.UplinkLow && t.DownlinkHigh > t.DownlinkLow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ctcssRe pulls a tone out of prose. SatNOGS has no field for it, and it is not
|
||||||
|
// optional: an FM uplink without the right tone opens nothing at all.
|
||||||
|
var ctcssRe = regexp.MustCompile(`(?i)(?:ctcss|pl)[^0-9]{0,4}(\d{2,3}(?:\.\d)?)|(\d{2,3}(?:\.\d)?)\s*(?:hz)?\s*(?:ctcss|pl)\b`)
|
||||||
|
|
||||||
|
func toTransponder(t transmitter) sat.Transponder {
|
||||||
|
tp := sat.Transponder{
|
||||||
|
Label: cleanLabel(t.Description),
|
||||||
|
Mode: adifMode(t.Mode),
|
||||||
|
DownLo: t.DownlinkLow,
|
||||||
|
DownHi: t.DownlinkHigh,
|
||||||
|
UpLo: t.UplinkLow,
|
||||||
|
UpHi: t.UplinkHigh,
|
||||||
|
Inverting: t.Invert,
|
||||||
|
}
|
||||||
|
// A "high" equal to the "low" is SatNOGS saying "a channel", not a one-hertz
|
||||||
|
// passband; Transponder.Linear() must not be fooled into interpolating.
|
||||||
|
if tp.DownHi <= tp.DownLo {
|
||||||
|
tp.DownHi = 0
|
||||||
|
}
|
||||||
|
if tp.UpHi <= tp.UpLo {
|
||||||
|
tp.UpHi = 0
|
||||||
|
}
|
||||||
|
// Inversion is a property of a PASSBAND. SatNOGS sets the flag on some FM
|
||||||
|
// channels too, where it means nothing — the code ignores it there, but a
|
||||||
|
// data file that says an FM repeater inverts is a data file that will
|
||||||
|
// mislead the next person to read it.
|
||||||
|
if tp.DownHi == 0 || tp.UpHi == 0 {
|
||||||
|
tp.Inverting = false
|
||||||
|
}
|
||||||
|
if m := ctcssRe.FindStringSubmatch(t.Description); m != nil {
|
||||||
|
v := m[1]
|
||||||
|
if v == "" {
|
||||||
|
v = m[2]
|
||||||
|
}
|
||||||
|
if f, err := strconv.ParseFloat(v, 64); err == nil && f >= 60 && f <= 260 {
|
||||||
|
tp.CTCSS = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tp
|
||||||
|
}
|
||||||
|
|
||||||
|
// adifMode maps SatNOGS' modulation names onto the four modes a log knows.
|
||||||
|
func adifMode(m string) string {
|
||||||
|
switch u := strings.ToUpper(strings.TrimSpace(m)); {
|
||||||
|
case strings.HasPrefix(u, "FM"), u == "SSTV", u == "DSTAR", u == "NFM":
|
||||||
|
return "FM"
|
||||||
|
case u == "USB", u == "LSB", u == "SSB":
|
||||||
|
return "SSB"
|
||||||
|
case u == "CW":
|
||||||
|
return "CW"
|
||||||
|
default:
|
||||||
|
return "DATA"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanLabel(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return "Transponder"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayName prefers the OSCAR designation an operator says out loud.
|
||||||
|
// "SAUDISAT 1C (SO-50)" is SO-50 to everybody except a catalog.
|
||||||
|
func displayName(feedName string) string {
|
||||||
|
s := strings.TrimSpace(feedName)
|
||||||
|
if i := strings.IndexByte(s, '('); i > 0 && strings.HasSuffix(s, ")") {
|
||||||
|
inner := strings.TrimSpace(s[i+1 : len(s)-1])
|
||||||
|
if oscarRe.MatchString(inner) {
|
||||||
|
return inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "RS-44 & BREEZE-KM R/B" — the rocket body it flies with is not its name.
|
||||||
|
if i := strings.Index(s, " & "); i > 0 {
|
||||||
|
return strings.TrimSpace(s[:i])
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
var oscarRe = regexp.MustCompile(`^[A-Z]{1,3}-\d{1,3}$`)
|
||||||
|
|
||||||
|
// noradFor finds a curated entry's catalog number by the name matching the
|
||||||
|
// package already does.
|
||||||
|
//
|
||||||
|
// Deterministic on purpose. One satellite can hold TWO catalog entries — a
|
||||||
|
// deployment catalogued before the objects were told apart, GreenCube being
|
||||||
|
// 53106 and 53109 in the two feeds — and iterating the map picked a different
|
||||||
|
// one each run, so the generated file changed for no reason and the diff was
|
||||||
|
// unreadable. Candidates are therefore scored and tied on the lower number:
|
||||||
|
// a feed name whose designation IS the bird's name ("GREENCUBE (IO-117)" for
|
||||||
|
// IO-117) beats one that only matches through an alias.
|
||||||
|
func noradFor(b sat.Bird, feed map[int]string) (int, bool) {
|
||||||
|
nums := make([]int, 0, len(feed))
|
||||||
|
for n := range feed {
|
||||||
|
nums = append(nums, n)
|
||||||
|
}
|
||||||
|
sort.Ints(nums)
|
||||||
|
|
||||||
|
best, bestScore := 0, -1
|
||||||
|
for _, n := range nums {
|
||||||
|
name := feed[n]
|
||||||
|
if !b.Matches(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
score := 0
|
||||||
|
if strings.EqualFold(displayName(name), b.Name) {
|
||||||
|
score = 2
|
||||||
|
} else if strings.EqualFold(strings.TrimSpace(name), b.Name) {
|
||||||
|
score = 1
|
||||||
|
}
|
||||||
|
if score > bestScore {
|
||||||
|
best, bestScore = n, score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best, best != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadFeeds() (map[int]string, error) {
|
||||||
|
out := map[int]string{}
|
||||||
|
for _, url := range tleFeeds {
|
||||||
|
body, err := get(url)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: %s: %v\n", url, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines := []string{}
|
||||||
|
for _, l := range strings.Split(string(body), "\n") {
|
||||||
|
if s := strings.TrimSpace(l); s != "" {
|
||||||
|
lines = append(lines, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i+2 < len(lines); i += 3 {
|
||||||
|
if !strings.HasPrefix(lines[i+1], "1 ") || len(lines[i+1]) < 7 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(lines[i+1][2:7]))
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// First feed wins: Celestrak's spelling is the one the operator sees.
|
||||||
|
if _, had := out[n]; !had {
|
||||||
|
out[n] = lines[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, fmt.Errorf("no elements from any feed")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decayed reports whether this bird's spacecraft has re-entered, matching on
|
||||||
|
// the catalog number when we have one and on the designations SatNOGS lists
|
||||||
|
// otherwise — "NAYIF-1" carries "EO-88" only in its alternative names.
|
||||||
|
func decayed(b sat.Bird, cat []satellite) (satellite, bool) {
|
||||||
|
for _, s := range cat {
|
||||||
|
if s.Status != "re-entered" && s.Decayed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.NORAD != 0 {
|
||||||
|
if s.NORAD == b.NORAD {
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names := append(strings.FieldsFunc(s.Names, func(r rune) bool { return r == ',' || r == '\n' }), s.Name)
|
||||||
|
for _, n := range names {
|
||||||
|
if strings.TrimSpace(n) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.Matches(strings.TrimSpace(n)) {
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return satellite{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSatellites() ([]satellite, error) {
|
||||||
|
body, err := get(satnogsSats)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []satellite
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
return nil, fmt.Errorf("satnogs satellites: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTransmitters() ([]transmitter, error) {
|
||||||
|
body, err := get(satnogsTX)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []transmitter
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
return nil, fmt.Errorf("satnogs: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadBirds() ([]sat.Bird, error) {
|
||||||
|
b, err := os.ReadFile(birdsPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []sat.Bird
|
||||||
|
if err := json.Unmarshal(b, &out); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", birdsPath, err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func get(url string) ([]byte, error) {
|
||||||
|
c := &http.Client{Timeout: 90 * time.Second}
|
||||||
|
resp, err := c.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("%s: %s", url, resp.Status)
|
||||||
|
}
|
||||||
|
return io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||||
|
}
|
||||||
|
|
||||||
|
func die(err error) {
|
||||||
|
fmt.Fprintln(os.Stderr, "satgen:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportRefused names the satellites whose only two-way path was refused for
|
||||||
|
// being inactive, so the operator running this can decide about each one.
|
||||||
|
//
|
||||||
|
// The output is deliberately a question and not a change: SatNOGS calling a
|
||||||
|
// transponder inactive is usually correct, and the exceptions are the birds
|
||||||
|
// whose transponder is switched on to a schedule rather than left running.
|
||||||
|
func reportRefused(txs []transmitter, feed map[int]string, covered map[int]bool, kept map[int][]transmitter) {
|
||||||
|
var lines []string
|
||||||
|
for _, t := range txs {
|
||||||
|
if t.Status == "active" || t.UplinkLow <= 0 || t.DownlinkLow <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if feed[t.NORAD] == "" || covered[t.NORAD] || len(kept[t.NORAD]) > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf(" ? %5d %-22s %-4s %.3f up / %.3f down %q",
|
||||||
|
t.NORAD, displayName(feed[t.NORAD]), adifMode(t.Mode),
|
||||||
|
float64(t.UplinkLow)/1e6, float64(t.DownlinkLow)/1e6, t.Description))
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sort.Strings(lines)
|
||||||
|
fmt.Println("\nRefused as inactive, and nothing else was kept for these satellites.")
|
||||||
|
fmt.Println("A transponder that runs to a schedule looks exactly like a dead one here:")
|
||||||
|
for _, l := range lines {
|
||||||
|
fmt.Println(l)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// systemInstallDataDir keeps the portable "data beside the binary" layout where
|
||||||
|
// it works, and falls back to the XDG data directory where it cannot.
|
||||||
|
//
|
||||||
|
// Both halves are needed on Linux, and only on Linux. A tarball or AppImage
|
||||||
|
// unpacked into the home directory behaves exactly like the Windows build —
|
||||||
|
// the folder travels with the program, which is the whole point of the design.
|
||||||
|
// But the ordinary way software arrives here is a package that installs into
|
||||||
|
// /usr/bin or /opt, where no user may write, and telling an operator to "move
|
||||||
|
// the program somewhere writable" is telling them their distribution installed
|
||||||
|
// it wrong. So when the folder beside the binary is read-only, OpsLog keeps its
|
||||||
|
// data in ~/.local/share/OpsLog instead and says so in the log.
|
||||||
|
//
|
||||||
|
// Detection is by TRYING, not by matching path prefixes: /opt, /usr/local and a
|
||||||
|
// NFS-mounted home are all writable on some stations and not on others, and the
|
||||||
|
// only honest test is whether the write succeeds.
|
||||||
|
func systemInstallDataDir(besideExe string) (string, bool) {
|
||||||
|
xdgOnce.Do(func() { xdgDir, xdgUsed = resolveDataDir(besideExe) })
|
||||||
|
return xdgDir, xdgUsed
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
xdgOnce sync.Once
|
||||||
|
xdgDir string
|
||||||
|
xdgUsed bool
|
||||||
|
)
|
||||||
|
|
||||||
|
func resolveDataDir(besideExe string) (string, bool) {
|
||||||
|
if writable(besideExe) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
base := os.Getenv("XDG_DATA_HOME")
|
||||||
|
if base == "" {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil || home == "" {
|
||||||
|
return "", false // nowhere better to go; let the caller report the failure
|
||||||
|
}
|
||||||
|
base = filepath.Join(home, ".local", "share")
|
||||||
|
}
|
||||||
|
alt := filepath.Join(base, "OpsLog", "data")
|
||||||
|
if !writable(alt) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
bootLog("data dir: %s is not writable — keeping the data in %s instead", besideExe, alt)
|
||||||
|
return alt, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// writable reports whether dir can be created and written to. The probe file is
|
||||||
|
// removed again; a leftover in the data folder would be one more thing to
|
||||||
|
// explain.
|
||||||
|
func writable(dir string) bool {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
probe := filepath.Join(dir, ".writetest")
|
||||||
|
if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_ = os.Remove(probe)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// dataDirAdvice is what the operator is told when neither location works — a
|
||||||
|
// full disk, or a home directory that is not writable either.
|
||||||
|
const dataDirAdvice = "\n\nOpsLog keeps its data next to the program, or in ~/.local/share/OpsLog when that folder belongs to the system. Neither could be written to: check the disk is not full and that your home directory is writable."
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// systemInstallDataDir never diverts on Windows: the data folder is beside the
|
||||||
|
// executable, full stop. That is what makes an OpsLog on a USB stick carry its
|
||||||
|
// logbook with it, and an operator who copies the folder to a new PC find
|
||||||
|
// everything already there.
|
||||||
|
//
|
||||||
|
// A copy dropped into Program Files is refused the write and told so (see
|
||||||
|
// checkDataDirWritable) rather than quietly logging somewhere else, because
|
||||||
|
// "where are my QSOs?" is a far worse afternoon than "move this folder".
|
||||||
|
func systemInstallDataDir(besideExe string) (string, bool) { return "", false }
|
||||||
|
|
||||||
|
// dataDirAdvice is what the operator is told when that folder cannot be written.
|
||||||
|
const dataDirAdvice = "\n\nMove OpsLog.exe somewhere your account can write — a folder in Documents, or the desktop — and start it again. Program Files is refused to anything not running as administrator."
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// config.json is the only record of WHERE the database is. Losing it moves an
|
||||||
|
// operator's whole station back to an empty default, and it has happened twice.
|
||||||
|
// These are the two ways it was lost.
|
||||||
|
|
||||||
|
// A half-written file must never be publishable. os.WriteFile truncates and
|
||||||
|
// then fills, so a process that stops in between leaves an empty pointer; the
|
||||||
|
// rename cannot.
|
||||||
|
func TestWriteBootstrapIsAtomicAndKeepsABackup(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// Absolute and OUTSIDE the application folder, so portablePath stores them
|
||||||
|
// verbatim — the round trip is what is under test, not the re-rooting.
|
||||||
|
first := filepath.Join(t.TempDir(), "first", "one.db")
|
||||||
|
second := filepath.Join(t.TempDir(), "second", "two.db")
|
||||||
|
if err := writeBootstrap(dir, dbPointer{DBPath: first}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := writeBootstrap(dir, dbPointer{DBPath: second}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// No temporary left lying around to be mistaken for the real thing.
|
||||||
|
if _, err := os.Stat(dbPointerPath(dir) + ".tmp"); err == nil {
|
||||||
|
t.Error("the temporary file was left behind")
|
||||||
|
}
|
||||||
|
// The previous contents are still there.
|
||||||
|
var prev dbPointer
|
||||||
|
b, err := os.ReadFile(dbPointerPath(dir) + ".bak")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("no backup was kept: %v", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &prev); err != nil {
|
||||||
|
t.Fatalf("the backup does not parse: %v", err)
|
||||||
|
}
|
||||||
|
if prev.DBPath != first {
|
||||||
|
t.Errorf("the backup holds %q, want the previous pointer", prev.DBPath)
|
||||||
|
}
|
||||||
|
if got := readBootstrap(dir); got.DBPath != second {
|
||||||
|
t.Errorf("read back %q, want the current pointer", got.DBPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pointer that EXISTS and cannot be read is not the same thing as no pointer.
|
||||||
|
// Treating it as one is what opened an empty database and presented an operator
|
||||||
|
// with a program that had forgotten them.
|
||||||
|
func TestReadBootstrapRecoversFromABrokenPointer(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
mine := filepath.Join(t.TempDir(), "mine", "station.db")
|
||||||
|
if err := writeBootstrap(dir, dbPointer{DBPath: mine}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Two writes, so there is a backup of the good one to fall back to.
|
||||||
|
if err := writeBootstrap(dir, dbPointer{DBPath: mine}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Now truncate it, exactly as an interrupted write would.
|
||||||
|
if err := os.WriteFile(dbPointerPath(dir), nil, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := readBootstrap(dir)
|
||||||
|
if got.DBPath != mine {
|
||||||
|
t.Fatalf("read %q — the database the operator chose was lost", got.DBPath)
|
||||||
|
}
|
||||||
|
// And it is put back, so the next launch does not have to recover again.
|
||||||
|
if again := readBootstrap(dir); again.DBPath != mine {
|
||||||
|
t.Errorf("the restored pointer did not stick: %q", again.DBPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With nothing to restore from, the broken file is KEPT. It is evidence, and it
|
||||||
|
// may still be readable by hand.
|
||||||
|
func TestReadBootstrapKeepsAnUnrecoverablePointer(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(dbPointerPath(dir), []byte("{oops"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := readBootstrap(dir); got.DBPath != "" {
|
||||||
|
t.Errorf("invented a path out of a broken file: %q", got.DBPath)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dbPointerPath(dir) + ".broken"); err != nil {
|
||||||
|
t.Errorf("the broken pointer was not kept: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The warning that turns a silent loss into a sentence: a new empty database
|
||||||
|
// about to be created in a folder that already holds a full one.
|
||||||
|
func TestOtherDatabasesInSpotsTheFullOneNextDoor(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
full := filepath.Join(dir, "opslog.db")
|
||||||
|
if err := os.WriteFile(full, []byte("not empty"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
chosen := filepath.Join(dir, "settings.db")
|
||||||
|
if got := otherDatabasesIn(dir, chosen); len(got) != 1 || got[0] != "opslog.db" {
|
||||||
|
t.Errorf("got %v, want the full database next door", got)
|
||||||
|
}
|
||||||
|
// A zero-byte file is not a lost configuration.
|
||||||
|
if err := os.WriteFile(full, nil, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := otherDatabasesIn(dir, chosen); len(got) != 0 {
|
||||||
|
t.Errorf("an empty file was reported as a database: %v", got)
|
||||||
|
}
|
||||||
|
// And the one being opened is never reported against itself.
|
||||||
|
if err := os.WriteFile(chosen, []byte("in use"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := otherDatabasesIn(dir, chosen); len(got) != 0 {
|
||||||
|
t.Errorf("the chosen database was reported as another: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+118
-67
@@ -52,7 +52,7 @@ 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, HaltAutoCall, WatchlistEntries,
|
GetAutoCallStatus, SetAutoCall, SetAutoCallOnly, TakeAutoCallTarget, HaltAutoCall, WatchlistEntries,
|
||||||
} from '../wailsjs/go/main/App';
|
} from '../wailsjs/go/main/App';
|
||||||
@@ -566,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',
|
||||||
@@ -618,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');
|
||||||
@@ -2354,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:
|
||||||
@@ -2460,6 +2455,33 @@ export default function App() {
|
|||||||
if (wlNoticeTimer.current) window.clearTimeout(wlNoticeTimer.current);
|
if (wlNoticeTimer.current) window.clearTimeout(wlNoticeTimer.current);
|
||||||
wlNoticeTimer.current = window.setTimeout(() => setWlNotice(null), 4000);
|
wlNoticeTimer.current = window.setTimeout(() => setWlNotice(null), 4000);
|
||||||
}), []);
|
}), []);
|
||||||
|
// An update deferred stays deferred.
|
||||||
|
//
|
||||||
|
// "Later" only hid the card, and the check behind it runs every five
|
||||||
|
// minutes — so the same notice came back four times an hour, all evening,
|
||||||
|
// for a version already declined. It now records until WHEN, and for which
|
||||||
|
// version: a release newer than the one put off is a different piece of
|
||||||
|
// news and appears at once, so a snooze can never bury an update for good.
|
||||||
|
//
|
||||||
|
// Deliberately not a portable UI pref — a machine told to wait four hours
|
||||||
|
// has said nothing about the operator's other machines.
|
||||||
|
const SNOOZE_KEY = 'opslog.updateSnooze';
|
||||||
|
const updateSnoozed = (version: string) => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SNOOZE_KEY);
|
||||||
|
if (!raw) return false;
|
||||||
|
const s = JSON.parse(raw) as { v?: string; until?: number };
|
||||||
|
return s?.v === version && Number(s?.until) > Date.now();
|
||||||
|
} catch { return false; } // unreadable is not snoozed
|
||||||
|
};
|
||||||
|
const snoozeUpdate = (hours: number) => {
|
||||||
|
try {
|
||||||
|
if (updateInfo) localStorage.setItem(SNOOZE_KEY, JSON.stringify({ v: updateInfo.latest, until: Date.now() + hours * 3600_000 }));
|
||||||
|
} catch { /* quota: the card just comes back, which is the old behaviour */ }
|
||||||
|
setLaterOpen(false);
|
||||||
|
setUpdateInfo(null);
|
||||||
|
};
|
||||||
|
const [laterOpen, setLaterOpen] = useState(false);
|
||||||
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||||
// Fresh update check on demand (opening About), so it never shows a stale
|
// Fresh update check on demand (opening About), so it never shows a stale
|
||||||
// "you're up to date". Clears updateInfo when the latest check finds nothing.
|
// "you're up to date". Clears updateInfo when the latest check finds nothing.
|
||||||
@@ -2477,7 +2499,11 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (localStorage.getItem('opslog.checkUpdates') === '0') return;
|
if (localStorage.getItem('opslog.checkUpdates') === '0') return;
|
||||||
const check = () => CheckForUpdate().then((u: any) => {
|
const check = () => CheckForUpdate().then((u: any) => {
|
||||||
if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') });
|
// The snooze is checked HERE and not in checkUpdateNow: opening About
|
||||||
|
// is a question, and it deserves the answer whatever was deferred.
|
||||||
|
if (u?.available && u?.latest && !updateSnoozed(String(u.latest))) {
|
||||||
|
setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') });
|
||||||
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
check();
|
check();
|
||||||
const id = window.setInterval(check, 5 * 60 * 1000);
|
const id = window.setInterval(check, 5 * 60 * 1000);
|
||||||
@@ -2599,6 +2625,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
|
||||||
@@ -3526,6 +3561,11 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const st = await GetStartupStatus();
|
const st = await GetStartupStatus();
|
||||||
if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; }
|
if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; }
|
||||||
|
// Started, but somewhere that deserves saying out loud — a new, empty
|
||||||
|
// settings database opened beside a full one. An operator who is not
|
||||||
|
// told this concludes their configuration was thrown away, when the
|
||||||
|
// file holding it is sitting right there.
|
||||||
|
if (st.warn) setError(st.warn);
|
||||||
// First launch (or a never-configured profile): collect the mandatory
|
// First launch (or a never-configured profile): collect the mandatory
|
||||||
// station identity before anything else.
|
// station identity before anything else.
|
||||||
try {
|
try {
|
||||||
@@ -3892,7 +3932,9 @@ export default function App() {
|
|||||||
return !b2 || (d.band ?? '').toLowerCase() === b2;
|
return !b2 || (d.band ?? '').toLowerCase() === b2;
|
||||||
});
|
});
|
||||||
const next = [...kept, ...fresh].filter((d) => Date.parse(d.at) >= cutoff);
|
const next = [...kept, ...fresh].filter((d) => Date.parse(d.at) >= cutoff);
|
||||||
return next;
|
// 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(); };
|
||||||
@@ -5421,8 +5463,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 && (
|
||||||
@@ -5568,8 +5617,12 @@ 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>
|
||||||
{/* The wheel steps the report — an S-unit on RST, a decibel on a digital
|
{/* 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
|
one. Wheeling is the same gesture as saying "he is a bit stronger than
|
||||||
that", and it beats retyping three characters between overs. */}
|
that", and it beats retyping three characters between overs. */}
|
||||||
@@ -5579,7 +5632,7 @@ export default function App() {
|
|||||||
</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
|
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType
|
||||||
onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }}
|
onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }}
|
||||||
onWheelStep={(d) => { setRstRcvd((v) => stepRST(v, d, mode)); rstUserEditedRef.current = true; }} />
|
onWheelStep={(d) => { setRstRcvd((v) => stepRST(v, d, mode)); rstUserEditedRef.current = true; }} />
|
||||||
@@ -5615,7 +5668,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}
|
||||||
@@ -5634,7 +5687,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}
|
||||||
@@ -5905,7 +5958,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>
|
||||||
@@ -5916,7 +5969,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>
|
||||||
@@ -6014,7 +6067,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"
|
||||||
@@ -7400,16 +7453,28 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||||
<button onClick={startUpdate} className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">
|
<button onClick={startUpdate} className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">
|
||||||
{updateInfo.downloadUrl ? t('upd.install') : t('upd.download')}
|
{updateInfo.downloadUrl ? t('upd.install') : t('upd.download')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setUpdateInfo(null)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">{t('upd.later')}</button>
|
{laterOpen ? (
|
||||||
|
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||||
|
{t('upd.remindIn')}
|
||||||
|
{[1, 4, 12, 24].map((h) => (
|
||||||
|
<button key={h} onClick={() => snoozeUpdate(h)}
|
||||||
|
className="h-6 px-1.5 rounded border border-border text-[11px] tabular-nums hover:bg-muted text-foreground">
|
||||||
|
{h}h
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setLaterOpen(true)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">{t('upd.later')}</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!updating && (
|
{!updating && (
|
||||||
<button onClick={() => setUpdateInfo(null)} className="text-muted-foreground hover:text-foreground shrink-0" title="Dismiss">
|
<button onClick={() => snoozeUpdate(1)} className="text-muted-foreground hover:text-foreground shrink-0" title={t('upd.dismissHour')}>
|
||||||
<X className="size-4" />
|
<X className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -9131,26 +9196,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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { gridToLatLon, greatCirclePoints, splitAtAntimeridian } from '@/lib/maidenhead';
|
import { gridToLatLon, greatCirclePoints, splitAtAntimeridian } from '@/lib/maidenhead';
|
||||||
@@ -7,6 +7,9 @@ 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 { loadMapView, saveMapView, MAP_VIEW_FT } from '@/lib/mapView';
|
||||||
import { loadMapBase, saveMapBase, MAP_BASE_FT } from '@/lib/mapBase';
|
import { loadMapBase, saveMapBase, MAP_BASE_FT } from '@/lib/mapBase';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { GetHearMe, SetHearMe, GetWhoHearsMe } from '../../wailsjs/go/main/App';
|
||||||
|
import { Ear } from 'lucide-react';
|
||||||
|
|
||||||
// FT Map — the live decode feed as geography: every station decoded in the
|
// 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
|
||||||
@@ -41,6 +44,52 @@ const BAND_COLOURS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
const bandColour = (b?: string) => BAND_COLOURS[(b ?? '').toLowerCase()] || '#9ca3af';
|
const bandColour = (b?: string) => BAND_COLOURS[(b ?? '').toLowerCase()] || '#9ca3af';
|
||||||
|
|
||||||
|
// One colour for every arc, overriding the palette. Empty means per band,
|
||||||
|
// which stays the default.
|
||||||
|
//
|
||||||
|
// The palette is only useful to somebody watching several bands at once, and
|
||||||
|
// it is calibrated against a plain map: 60m navy and 70cm olive all but
|
||||||
|
// disappear over the satellite imagery, and 20m yellow over the deserts. An
|
||||||
|
// operator on one band has nothing to lose by painting the whole map in a
|
||||||
|
// colour that shows up against the ground he chose.
|
||||||
|
const COL_KEY = 'opslog.ftMapColour';
|
||||||
|
|
||||||
|
// The reverse layer's own colour. Empty means the default below.
|
||||||
|
//
|
||||||
|
// Separate from COL_KEY on purpose: the decode colour exists because the
|
||||||
|
// band palette vanishes over some basemaps, and this one has exactly the
|
||||||
|
// same problem for exactly the same reason — cyan over a pale sea reads no
|
||||||
|
// better than 60m navy does. One control for both would have forced the
|
||||||
|
// two layers into one colour, which is the distinction it took a shape to
|
||||||
|
// make in the first place.
|
||||||
|
const HEARD_COL_KEY = 'opslog.ftMapHeardColour';
|
||||||
|
|
||||||
|
// A colour input only accepts #rrggbb, so anything else stored here is
|
||||||
|
// treated as no choice at all rather than driving the swatch to black.
|
||||||
|
const asHex = (v: string) => (/^#[0-9a-f]{6}$/i.test(v.trim()) ? v.trim() : '');
|
||||||
|
|
||||||
|
// One station reporting our own transmissions, from PSK Reporter.
|
||||||
|
type Heard = { call: string; grid: string; band: string; mode: string; snr: number; at: string };
|
||||||
|
|
||||||
|
// The reverse layer is marks only, in one colour whatever the band, and the
|
||||||
|
// mark is a DIAMOND.
|
||||||
|
//
|
||||||
|
// It started with an arc per station, like the decodes, and that was wrong:
|
||||||
|
// with a few dozen receivers reporting, the map became a fan of lines out of
|
||||||
|
// one square that buried the very arcs it sat beside. Nothing was gained by
|
||||||
|
// them either — an arc's job on the decode layer is to say WHICH of many
|
||||||
|
// stations a path belongs to, and here every path starts at the same place.
|
||||||
|
//
|
||||||
|
// Shape, then, rather than colour, carries the distinction: the decode dots
|
||||||
|
// are small filled circles, and the arcs already use fourteen colours, so a
|
||||||
|
// fifteenth would read as another band. A diamond is unmistakably not one of
|
||||||
|
// them at a glance.
|
||||||
|
//
|
||||||
|
// Filled, with a hairline white edge — the same trick the home marker uses.
|
||||||
|
// It was a ring, and a ring is an outline drawn over whatever is beneath it:
|
||||||
|
// eight pixels of it over the satellite imagery was barely there.
|
||||||
|
const HEARD_COLOUR = '#22d3ee'; // the default, when nothing is chosen
|
||||||
|
|
||||||
const MAX_ARCS = 300;
|
const MAX_ARCS = 300;
|
||||||
const MAX_AGE_MS = 30 * 60_000;
|
const MAX_AGE_MS = 30 * 60_000;
|
||||||
|
|
||||||
@@ -53,6 +102,14 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
|||||||
}) {
|
}) {
|
||||||
// Held in refs so the redraw below does not have to list them as dependencies
|
// Held in refs so the redraw below does not have to list them as dependencies
|
||||||
// and rebuild every arc whenever the parent re-renders.
|
// and rebuild every arc whenever the parent re-renders.
|
||||||
|
const [colour, setColour] = useState(() => asHex(localStorage.getItem(COL_KEY) ?? ''));
|
||||||
|
const [heardColour, setHeardColour] = useState(() => asHex(localStorage.getItem(HEARD_COL_KEY) ?? ''));
|
||||||
|
const heardInk = heardColour || HEARD_COLOUR;
|
||||||
|
// Whether the reverse feed is wanted lives in the DB, not here: it is what
|
||||||
|
// starts an MQTT subscription, so the backend has to be the one that knows.
|
||||||
|
const [hearMe, setHearMe] = useState(false);
|
||||||
|
const [heard, setHeard] = useState<Heard[]>([]);
|
||||||
|
const [heardBusy, setHeardBusy] = useState(false);
|
||||||
const selectRef = useRef(onSelect);
|
const selectRef = useRef(onSelect);
|
||||||
const callRef = useRef(onCall);
|
const callRef = useRef(onCall);
|
||||||
useEffect(() => { selectRef.current = onSelect; callRef.current = onCall; }, [onSelect, onCall]);
|
useEffect(() => { selectRef.current = onSelect; callRef.current = onCall; }, [onSelect, onCall]);
|
||||||
@@ -68,6 +125,10 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
|||||||
const divRef = useRef<HTMLDivElement>(null);
|
const divRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<L.Map | null>(null);
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
const layerRef = useRef<L.LayerGroup | null>(null);
|
const layerRef = useRef<L.LayerGroup | null>(null);
|
||||||
|
// Its own group: the reverse layer refreshes on its own clock, and clearing
|
||||||
|
// the decode arcs to redraw it would throw away three hundred polylines
|
||||||
|
// every twenty seconds for nothing.
|
||||||
|
const heardLayerRef = useRef<L.LayerGroup | null>(null);
|
||||||
const baseRef = useRef<L.TileLayer | null>(null);
|
const baseRef = useRef<L.TileLayer | null>(null);
|
||||||
const labelsRef = useRef<L.TileLayer | null>(null);
|
const labelsRef = useRef<L.TileLayer | null>(null);
|
||||||
const [basemap, setBasemap] = useState<BasemapKey>(() =>
|
const [basemap, setBasemap] = useState<BasemapKey>(() =>
|
||||||
@@ -95,6 +156,7 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
|||||||
});
|
});
|
||||||
mapRef.current = m;
|
mapRef.current = m;
|
||||||
layerRef.current = L.layerGroup().addTo(m);
|
layerRef.current = L.layerGroup().addTo(m);
|
||||||
|
heardLayerRef.current = L.layerGroup().addTo(m);
|
||||||
// Leaflet measures its container ONCE, when the map is created, and then
|
// Leaflet measures its container ONCE, when the map is created, and then
|
||||||
// draws tiles for that size for ever. This panel is mounted the moment its
|
// draws tiles for that size for ever. This panel is mounted the moment its
|
||||||
// tab is selected — before the flex layout has settled — and the window can
|
// tab is selected — before the flex layout has settled — and the window can
|
||||||
@@ -113,6 +175,7 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
|||||||
m.remove();
|
m.remove();
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
layerRef.current = null;
|
layerRef.current = null;
|
||||||
|
heardLayerRef.current = null;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -132,6 +195,34 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
|||||||
saveMapBase(MAP_BASE_FT, basemap);
|
saveMapBase(MAP_BASE_FT, basemap);
|
||||||
}, [basemap]);
|
}, [basemap]);
|
||||||
|
|
||||||
|
useEffect(() => { GetHearMe().then((v) => setHearMe(!!v)).catch(() => {}); }, []);
|
||||||
|
|
||||||
|
// Polled rather than pushed: the reports arrive from the broker in batches
|
||||||
|
// whenever an uploader gets round to it, and a fifteen-minute window redrawn
|
||||||
|
// every twenty seconds is as live as the data underneath it actually is.
|
||||||
|
const loadHeard = useCallback(() => {
|
||||||
|
GetWhoHearsMe().then((r: any) => setHeard((Array.isArray(r) ? r : []) as Heard[])).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hearMe) { setHeard([]); return; }
|
||||||
|
loadHeard();
|
||||||
|
const id = window.setInterval(loadHeard, 20_000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [hearMe, loadHeard]);
|
||||||
|
|
||||||
|
const toggleHearMe = async () => {
|
||||||
|
setHeardBusy(true);
|
||||||
|
const next = !hearMe;
|
||||||
|
try {
|
||||||
|
await SetHearMe(next);
|
||||||
|
setHearMe(next);
|
||||||
|
} catch {
|
||||||
|
// The usual cause is no station callsign, which is what it subscribes
|
||||||
|
// to. Read the state back rather than assuming either way.
|
||||||
|
try { setHearMe(!!(await GetHearMe())); } catch { /* leave it */ }
|
||||||
|
} finally { setHeardBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
// The arcs, redrawn when the decode list changes. Newest last so they paint
|
// The arcs, redrawn when the decode list changes. Newest last so they paint
|
||||||
// on top; opacity falls with age so the map reads as "now" with a memory.
|
// on top; opacity falls with age so the map reads as "now" with a memory.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -156,21 +247,21 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
|||||||
if (!to) continue;
|
if (!to) continue;
|
||||||
const age = now - Date.parse(d.at);
|
const age = now - Date.parse(d.at);
|
||||||
const fade = Math.max(0.15, 1 - age / MAX_AGE_MS);
|
const fade = Math.max(0.15, 1 - age / MAX_AGE_MS);
|
||||||
const colour = bandColour(d.band);
|
const stroke = colour || bandColour(d.band);
|
||||||
// Cut at the antimeridian: this map shows ONE world, so a path running
|
// Cut at the antimeridian: this map shows ONE world, so a path running
|
||||||
// past ±180 has to leave one edge and come back at the other. Without it
|
// past ±180 has to leave one edge and come back at the other. Without it
|
||||||
// every arc out of VK or ZL was drawn into the blank space off the side
|
// every arc out of VK or ZL was drawn into the blank space off the side
|
||||||
// of the map, its far end sitting alone on the opposite coast.
|
// of the map, its far end sitting alone on the opposite coast.
|
||||||
const pts = splitAtAntimeridian(greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48));
|
const pts = splitAtAntimeridian(greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48));
|
||||||
L.polyline(pts as L.LatLngExpression[][], {
|
L.polyline(pts as L.LatLngExpression[][], {
|
||||||
color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
|
color: stroke, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
|
||||||
}).addTo(layer);
|
}).addTo(layer);
|
||||||
const label = `${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`;
|
const label = `${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`;
|
||||||
const mk = L.circleMarker([to.lat, to.lon], {
|
const mk = L.circleMarker([to.lat, to.lon], {
|
||||||
// A three-pixel dot is a fine mark and a poor target, so the visible
|
// 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
|
// radius stays and an invisible one three times the size takes the
|
||||||
// clicks.
|
// clicks.
|
||||||
radius: 3, color: colour, weight: 1, fillColor: colour, fillOpacity: 0.9 * fade,
|
radius: 3, color: stroke, weight: 1, fillColor: stroke, fillOpacity: 0.9 * fade,
|
||||||
}).bindTooltip(label, { direction: 'top' }).addTo(layer);
|
}).bindTooltip(label, { direction: 'top' }).addTo(layer);
|
||||||
// The tooltip goes on the HIT circle too, and it is the one that matters:
|
// 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
|
// being on top, it takes the hover as well as the click, and binding it
|
||||||
@@ -196,7 +287,45 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [decodes, myGrid]);
|
}, [decodes, myGrid, colour]);
|
||||||
|
|
||||||
|
// The reverse layer: one mark where each station that reported us sits, and
|
||||||
|
// no line to it.
|
||||||
|
//
|
||||||
|
// A divIcon rather than a canvas circle, because canvas draws circles and
|
||||||
|
// nothing else, and the whole point is a shape that is not a circle. The
|
||||||
|
// cost is DOM nodes, which is affordable HERE and would not be on the decode
|
||||||
|
// layer: this is a few dozen receivers against three hundred arcs.
|
||||||
|
useEffect(() => {
|
||||||
|
const layer = heardLayerRef.current;
|
||||||
|
if (!layer) return;
|
||||||
|
layer.clearLayers();
|
||||||
|
if (!hearMe) return;
|
||||||
|
const now = Date.now();
|
||||||
|
for (const h of heard) {
|
||||||
|
const to = gridToLatLon(h.grid);
|
||||||
|
if (!to) continue;
|
||||||
|
const ageMs = Math.max(0, now - Date.parse(h.at));
|
||||||
|
const ageMin = Math.round(ageMs / 60_000);
|
||||||
|
// Faded with age over the window, as the decode arcs are: the freshest
|
||||||
|
// report is the one that says a path is open NOW.
|
||||||
|
const fade = Math.max(0.3, 1 - ageMs / (15 * 60_000));
|
||||||
|
const label = `${h.call} · ${h.grid} · ${h.snr > 0 ? '+' : ''}${h.snr} dB · ${h.band}${ageMin > 0 ? ` · ${ageMin}'` : ''}`;
|
||||||
|
// The box is bigger than the diamond so there is something to point at:
|
||||||
|
// a nine-pixel mark is a fine sight and a poor target.
|
||||||
|
const icon = L.divIcon({
|
||||||
|
className: '',
|
||||||
|
iconSize: [16, 16],
|
||||||
|
iconAnchor: [8, 8],
|
||||||
|
html: `<div style="width:16px;height:16px;display:flex;align-items:center;justify-content:center">`
|
||||||
|
+ `<div style="width:9px;height:9px;transform:rotate(45deg);background:${heardInk};`
|
||||||
|
+ `box-shadow:0 0 0 1px rgba(255,255,255,.75);opacity:${fade.toFixed(2)}"></div></div>`,
|
||||||
|
});
|
||||||
|
L.marker([to.lat, to.lon], { icon, interactive: true, keyboard: false })
|
||||||
|
.bindTooltip(label, { direction: 'top' })
|
||||||
|
.addTo(layer);
|
||||||
|
}
|
||||||
|
}, [heard, hearMe, heardInk]);
|
||||||
|
|
||||||
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
|
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
|
||||||
return (
|
return (
|
||||||
@@ -205,8 +334,11 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
|||||||
// keeps all of it inside this panel.
|
// keeps all of it inside this panel.
|
||||||
<div className="relative isolate z-0 h-full w-full min-h-0">
|
<div className="relative isolate z-0 h-full w-full min-h-0">
|
||||||
<div ref={divRef} className="absolute inset-0 rounded-lg overflow-hidden" />
|
<div ref={divRef} className="absolute inset-0 rounded-lg overflow-hidden" />
|
||||||
{/* Basemap picker, MainMap's own vocabulary. */}
|
{/* Basemap picker, MainMap's own vocabulary.
|
||||||
<div className="absolute top-2 left-12 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
|
left-16, not left-12: Leaflet's zoom control is 30 px of buttons plus
|
||||||
|
its 10 px margin and a border, and at 48 px this row started on top
|
||||||
|
of it — the − button took the click that was meant for Street. */}
|
||||||
|
<div className="absolute top-2 left-16 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
|
||||||
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
||||||
<button key={k} type="button" onClick={() => setBasemap(k)}
|
<button key={k} type="button" onClick={() => setBasemap(k)}
|
||||||
className={cn('px-2 py-0.5 rounded text-[11px]',
|
className={cn('px-2 py-0.5 rounded text-[11px]',
|
||||||
@@ -215,12 +347,71 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* Band legend — only the bands actually on screen. */}
|
{/* The two things that are not the basemap, on the OTHER side.
|
||||||
|
|
||||||
|
They sat in the same row, which grew until it reached the middle of
|
||||||
|
the map — and a control bar spanning half the width of a world map is
|
||||||
|
covering the Atlantic to save a corner that was empty the whole time.
|
||||||
|
Leaflet puts nothing top-right but the attribution, which is at the
|
||||||
|
bottom. */}
|
||||||
|
<div className="absolute top-2 right-2 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
|
||||||
|
{/* Opens on the crimson the home marker already uses — chosen to hold
|
||||||
|
up on every basemap, which is a better first suggestion than
|
||||||
|
whichever band colour happens to be first in the palette. */}
|
||||||
|
<input type="color" title={t('ftmap.colour')}
|
||||||
|
className="size-5 self-center rounded border border-border bg-transparent p-0 cursor-pointer"
|
||||||
|
value={colour || '#e11d48'}
|
||||||
|
onChange={(e) => { const v = asHex(e.target.value); setColour(v); writeUiPref(COL_KEY, v); }} />
|
||||||
|
{!!colour && (
|
||||||
|
<button type="button" title={t('ftmap.colourPerBand')}
|
||||||
|
onClick={() => { setColour(''); writeUiPref(COL_KEY, ''); }}
|
||||||
|
className="px-1 text-[11px] text-muted-foreground hover:text-foreground">↺</button>
|
||||||
|
)}
|
||||||
|
<span className="mx-0.5 w-px self-stretch bg-border" />
|
||||||
|
{/* The reverse layer. A switch, not a filter: it starts a subscription
|
||||||
|
at the broker, so it is off until asked for. */}
|
||||||
|
<button type="button" onClick={toggleHearMe} disabled={heardBusy}
|
||||||
|
title={t('ftmap.hearMeTip')}
|
||||||
|
className={cn('flex items-center gap-1 px-1.5 h-6 rounded text-[11px] disabled:opacity-50',
|
||||||
|
hearMe ? 'font-semibold' : 'text-muted-foreground hover:bg-muted')}
|
||||||
|
style={hearMe ? { color: heardInk } : undefined}>
|
||||||
|
<Ear className="size-3" />
|
||||||
|
{t('ftmap.hearMe')}
|
||||||
|
{hearMe && <span className="tabular-nums opacity-80">{heard.length}</span>}
|
||||||
|
</button>
|
||||||
|
{hearMe && (
|
||||||
|
<input type="color" title={t('ftmap.heardColour')}
|
||||||
|
className="size-5 self-center rounded border border-border bg-transparent p-0 cursor-pointer"
|
||||||
|
value={heardInk}
|
||||||
|
onChange={(e) => { const v = asHex(e.target.value); setHeardColour(v); writeUiPref(HEARD_COL_KEY, v); }} />
|
||||||
|
)}
|
||||||
|
{hearMe && !!heardColour && (
|
||||||
|
<button type="button" title={t('ftmap.heardColourReset')}
|
||||||
|
onClick={() => { setHeardColour(''); writeUiPref(HEARD_COL_KEY, ''); }}
|
||||||
|
className="px-1 text-[11px] text-muted-foreground hover:text-foreground">↺</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* What the diamonds are. In the legend rather than a tooltip because
|
||||||
|
they are the only thing on the map that is not a decode of ours, and
|
||||||
|
an unexplained second mark is worse than none.
|
||||||
|
|
||||||
|
Bottom-right, where the band legend is not: the two would otherwise
|
||||||
|
stack into one block and read as one key. */}
|
||||||
|
{hearMe && heard.length > 0 && (
|
||||||
|
<div className="absolute bottom-2 right-2 z-[1000] flex items-center gap-1.5 rounded-md bg-background/85 backdrop-blur px-2 py-1.5 border border-border text-[11px]">
|
||||||
|
<span className="inline-block size-2 rotate-45" style={{ background: heardInk }} />
|
||||||
|
{t('ftmap.hearMeLegend', { n: heard.length })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Band legend — only the bands actually on screen. With one colour
|
||||||
|
forced it keeps the band NAMES and drops the swatches: which bands
|
||||||
|
are up is still worth knowing, a colour key that no longer maps to
|
||||||
|
anything is not. */}
|
||||||
{bands.length > 0 && (
|
{bands.length > 0 && (
|
||||||
<div className="absolute bottom-2 left-2 z-[1000] flex flex-wrap gap-x-2.5 gap-y-1 rounded-md bg-background/85 backdrop-blur px-2 py-1.5 border border-border">
|
<div className="absolute bottom-2 left-2 z-[1000] flex flex-wrap gap-x-2.5 gap-y-1 rounded-md bg-background/85 backdrop-blur px-2 py-1.5 border border-border">
|
||||||
{bands.map((b) => (
|
{bands.map((b) => (
|
||||||
<span key={b} className="flex items-center gap-1 text-[11px] text-foreground">
|
<span key={b} className="flex items-center gap-1 text-[11px] text-foreground">
|
||||||
<span className="inline-block w-3 h-[3px] rounded" style={{ background: bandColour(b) }} />
|
{!colour && <span className="inline-block w-3 h-[3px] rounded" style={{ background: bandColour(b) }} />}
|
||||||
{b.toUpperCase()}
|
{b.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { Loader2, RefreshCw } from 'lucide-react';
|
import { Loader2, RefreshCw } from 'lucide-react';
|
||||||
import { GridSquares } from '../../wailsjs/go/main/App';
|
import { GridSquares, GridSquareChoices, GetListsSettings } from '../../wailsjs/go/main/App';
|
||||||
import { gridSquareBounds, gridToLatLon } from '@/lib/maidenhead';
|
import { 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';
|
||||||
@@ -47,20 +47,27 @@ function cssColour(token: string, fallback: string): string {
|
|||||||
} catch { return fallback; }
|
} catch { return fallback; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode scope. The names come straight from the backend's own classes ("ALL",
|
// Mode scope. The four broad classes the rest of the app uses, as buttons —
|
||||||
// "PHONE", "CW", "DIGI") plus FTX, which is narrower than digital and usually
|
// and then any single mode the log actually holds, from the dropdown beside
|
||||||
// the honest one beside an FTx panel — a square worked on RTTY in a contest is
|
// them.
|
||||||
// not a square worked on FT8.
|
//
|
||||||
|
// There used to be an FTx button here, lumping FT8, FT4 and FT2 together. It
|
||||||
|
// was the wrong grain in both directions: "digital" already put a contest RTTY
|
||||||
|
// square beside an FT8 one, and FTx then put FT8 beside FT4, when the question
|
||||||
|
// this map answers is where ONE mode has been heard. The specific modes are
|
||||||
|
// read from the log rather than listed here, so FT2 is offered to an operator
|
||||||
|
// already using it and needs no change here the day it becomes registered.
|
||||||
const SCOPES = [
|
const SCOPES = [
|
||||||
{ key: 'ALL', label: 'gsm.all' },
|
{ key: 'ALL', label: 'gsm.all' },
|
||||||
{ key: 'PHONE', label: 'gsm.phone' },
|
{ key: 'PHONE', label: 'gsm.phone' },
|
||||||
{ key: 'CW', label: 'gsm.cw' },
|
{ key: 'CW', label: 'gsm.cw' },
|
||||||
{ key: 'DIGI', label: 'gsm.digital' },
|
{ key: 'DIGI', label: 'gsm.digital' },
|
||||||
{ key: 'FTX', label: 'gsm.ftx' },
|
|
||||||
] as const;
|
] as const;
|
||||||
type ScopeKey = typeof SCOPES[number]['key'];
|
type ScopeKey = string;
|
||||||
|
|
||||||
const SCOPE_KEY = 'opslog.gridMapScope';
|
const SCOPE_KEY = 'opslog.gridMapScope';
|
||||||
|
const BAND_KEY = 'opslog.gridMapBand';
|
||||||
|
const SAT_KEY = 'opslog.gridMapSat';
|
||||||
// Chosen fill colours. Empty means "follow the theme", which is the default and
|
// Chosen fill colours. Empty means "follow the theme", which is the default and
|
||||||
// stays the default: the tokens already track the four themes, and freezing a
|
// stays the default: the tokens already track the four themes, and freezing a
|
||||||
// hex at first run would leave a dark-theme map painted in the light palette.
|
// hex at first run would leave a dark-theme map painted in the light palette.
|
||||||
@@ -83,9 +90,22 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
|||||||
const [squares, setSquares] = useState<Square[] | null>(null);
|
const [squares, setSquares] = useState<Square[] | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
const [scope, setScope] = useState<ScopeKey>(
|
// The stored scope is taken as given rather than checked against SCOPES: it
|
||||||
() => (SCOPES.some((s) => s.key === localStorage.getItem(SCOPE_KEY))
|
// may legitimately be a mode name now, and the backend answers a mode nothing
|
||||||
? (localStorage.getItem(SCOPE_KEY) as ScopeKey) : 'DIGI'));
|
// was worked on with no squares rather than an error.
|
||||||
|
const [scope, setScope] = useState<ScopeKey>(() => {
|
||||||
|
const v = localStorage.getItem(SCOPE_KEY) || 'DIGI';
|
||||||
|
// FTX was a button until the named modes replaced it. Left as it was, no
|
||||||
|
// control would show it selected while the map stayed filtered by it.
|
||||||
|
return v === 'FTX' ? 'DIGI' : v;
|
||||||
|
});
|
||||||
|
const [band, setBand] = useState(() => localStorage.getItem(BAND_KEY) ?? '');
|
||||||
|
const [sat, setSat] = useState(() => localStorage.getItem(SAT_KEY) ?? '');
|
||||||
|
// What the three filters can offer. The modes and satellites are the ones the
|
||||||
|
// squares were actually worked on; the bands are the station's own list too,
|
||||||
|
// so a band configured but not yet worked is still there to ask about.
|
||||||
|
const [choices, setChoices] = useState<{ modes: string[]; bands: string[]; satellites: string[] }>(
|
||||||
|
{ modes: [], bands: [], satellites: [] });
|
||||||
|
|
||||||
// This map's own imagery. It shared the world map's key until they were
|
// 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.
|
// separated, so a choice made back then is inherited rather than reset.
|
||||||
@@ -102,17 +122,53 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
|||||||
return () => obs.disconnect();
|
return () => obs.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const load = async (sc: ScopeKey = scope) => {
|
const load = async (sc: ScopeKey = scope, bd: string = band, st: string = sat) => {
|
||||||
setBusy(true); setErr('');
|
setBusy(true); setErr('');
|
||||||
try {
|
try {
|
||||||
const r = (await GridSquares(sc)) as any;
|
const r = (await GridSquares(sc, bd, st)) as any;
|
||||||
setSquares((Array.isArray(r) ? r : []) as Square[]);
|
setSquares((Array.isArray(r) ? r : []) as Square[]);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setErr(String(e?.message ?? e));
|
setErr(String(e?.message ?? e));
|
||||||
setSquares([]);
|
setSquares([]);
|
||||||
} finally { setBusy(false); }
|
} finally { setBusy(false); }
|
||||||
};
|
};
|
||||||
useEffect(() => { void load(scope); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [scope]);
|
useEffect(() => { void load(scope, band, sat); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [scope, band, sat]);
|
||||||
|
|
||||||
|
// Loaded once: what the filters can offer changes only when the log does, and
|
||||||
|
// the refresh button reloads it alongside the squares.
|
||||||
|
const loadChoices = async () => {
|
||||||
|
try {
|
||||||
|
const c: any = await GridSquareChoices();
|
||||||
|
let bands: string[] = (c?.bands ?? []) as string[];
|
||||||
|
try {
|
||||||
|
const ls: any = await GetListsSettings();
|
||||||
|
const have = new Set(bands.map((b) => b.toLowerCase()));
|
||||||
|
// Union, the station's own list first: a configured band with nothing
|
||||||
|
// worked on it is still a fair question, and a band worked but never
|
||||||
|
// configured must not become unreachable.
|
||||||
|
const extra = ((ls?.bands ?? []) as string[])
|
||||||
|
.map((b) => String(b).toLowerCase())
|
||||||
|
.filter((b) => b && !have.has(b));
|
||||||
|
bands = [...extra, ...bands];
|
||||||
|
} catch { /* the log's own bands are enough */ }
|
||||||
|
setChoices({
|
||||||
|
modes: (c?.modes ?? []) as string[],
|
||||||
|
bands,
|
||||||
|
satellites: (c?.satellites ?? []) as string[],
|
||||||
|
});
|
||||||
|
} catch { /* the class buttons still work without it */ }
|
||||||
|
};
|
||||||
|
useEffect(() => { void loadChoices(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, []);
|
||||||
|
|
||||||
|
// Only the modes NOT already a button: listing SSB and CW again would be two
|
||||||
|
// controls giving one answer.
|
||||||
|
const namedModes = useMemo(
|
||||||
|
() => choices.modes.filter((m) => !SCOPES.some((c) => c.key === m.toUpperCase())),
|
||||||
|
[choices.modes]);
|
||||||
|
const pick = (key: string, v: string, set: (v: string) => void) => {
|
||||||
|
set(v);
|
||||||
|
try { localStorage.setItem(key, v); } catch { /* quota */ }
|
||||||
|
};
|
||||||
|
|
||||||
// One-time map creation. preferCanvas: a busy digital log is a few thousand
|
// One-time map creation. preferCanvas: a busy digital log is a few thousand
|
||||||
// rectangles, and as SVG that is a few thousand DOM nodes to lay out on every
|
// rectangles, and as SVG that is a few thousand DOM nodes to lay out on every
|
||||||
@@ -244,13 +300,45 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
|||||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
{SCOPES.map((s, i) => (
|
{SCOPES.map((s, i) => (
|
||||||
<button key={s.key} type="button"
|
<button key={s.key} type="button"
|
||||||
onClick={() => { setScope(s.key); try { localStorage.setItem(SCOPE_KEY, s.key); } catch { /* quota */ } }}
|
onClick={() => pick(SCOPE_KEY, s.key, setScope)}
|
||||||
className={cn('px-1.5 h-6 text-[11px] whitespace-nowrap', i > 0 && 'border-l border-border',
|
className={cn('px-1.5 h-6 text-[11px] whitespace-nowrap', i > 0 && 'border-l border-border',
|
||||||
scope === s.key ? 'bg-primary text-primary-foreground' : 'hover:bg-muted text-muted-foreground')}>
|
scope === s.key ? 'bg-primary text-primary-foreground' : 'hover:bg-muted text-muted-foreground')}>
|
||||||
{t(s.label)}
|
{t(s.label)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{/* One named mode, where the FTx button used to be. It shares the scope
|
||||||
|
with the buttons rather than filtering on top of them: mode is one
|
||||||
|
question, and two controls that both answer it is how a map ends up
|
||||||
|
showing PHONE ∩ FT8, which is empty. Picking a mode here therefore
|
||||||
|
un-picks the buttons, and vice versa. */}
|
||||||
|
{namedModes.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={namedModes.includes(scope) ? scope : ''}
|
||||||
|
onChange={(e) => pick(SCOPE_KEY, e.target.value || 'ALL', setScope)}
|
||||||
|
title={t('gsm.oneMode')}
|
||||||
|
className="h-6 rounded border border-border bg-background px-1 text-[11px]"
|
||||||
|
>
|
||||||
|
<option value="">{t('gsm.oneMode')}</option>
|
||||||
|
{namedModes.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{choices.bands.length > 0 && (
|
||||||
|
<select value={band} onChange={(e) => pick(BAND_KEY, e.target.value, setBand)}
|
||||||
|
title={t('gsm.band')} className="h-6 rounded border border-border bg-background px-1 text-[11px]">
|
||||||
|
<option value="">{t('gsm.allBands')}</option>
|
||||||
|
{choices.bands.map((b) => <option key={b} value={b}>{b}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{/* Only for a station that has worked one. A satellite dropdown on a
|
||||||
|
purely terrestrial log is a control that can only ever be empty. */}
|
||||||
|
{choices.satellites.length > 0 && (
|
||||||
|
<select value={sat} onChange={(e) => pick(SAT_KEY, e.target.value, setSat)}
|
||||||
|
title={t('gsm.satellite')} className="h-6 rounded border border-border bg-background px-1 text-[11px]">
|
||||||
|
<option value="">{t('gsm.allSats')}</option>
|
||||||
|
{choices.satellites.map((n) => <option key={n} value={n}>{n}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||||
{t('gsm.count', { n: stats.total, c: stats.confirmed })}
|
{t('gsm.count', { n: stats.total, c: stats.confirmed })}
|
||||||
</span>
|
</span>
|
||||||
@@ -287,7 +375,7 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
|||||||
className="text-[11px] text-muted-foreground hover:text-foreground px-1">↺</button>
|
className="text-[11px] text-muted-foreground hover:text-foreground px-1">↺</button>
|
||||||
)}
|
)}
|
||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
<button type="button" onClick={() => void load()} disabled={busy} title={t('gsm.refresh')}
|
<button type="button" onClick={() => { void load(); void loadChoices(); }} disabled={busy} title={t('gsm.refresh')}
|
||||||
className="inline-flex items-center justify-center size-6 rounded border border-border hover:bg-muted disabled:opacity-50">
|
className="inline-flex items-center justify-center size-6 rounded border border-border hover:bg-muted disabled:opacity-50">
|
||||||
{busy ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
{busy ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
// how one of them quietly stops matching the other — a pattern button that sets
|
// how one of them quietly stops matching the other — a pattern button that sets
|
||||||
// a different direction, a band list that tunes somewhere else.
|
// a different direction, a band list that tunes somewhere else.
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { ArrowDownToLine, ChevronDown, ChevronUp, Loader2, Minus, Plus, RefreshCw, Antenna as AntennaIcon, X } from 'lucide-react';
|
import { ArrowDownToLine, ChevronDown, ChevronUp, Loader2, Minus, Plus, RefreshCw, Ruler, Antenna as AntennaIcon, X } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
SetUltrabeamDirection, UltrabeamRetract, MotorCalibrate, MotorSetElement, MotorReadElements,
|
||||||
MotorTuneKHz, MotorNudgeKHz, SetMotorFollow,
|
MotorTuneKHz, MotorNudgeKHz, SetMotorFollow,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
@@ -235,11 +235,29 @@ export function MotorAntennaWidget({ ant, refetch, t, onClose, essentialsOnly }:
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Home, and — on a SteppIR — calibrate beside it. Two buttons on one
|
||||||
|
row rather than a second full-width one: the widget is narrow and
|
||||||
|
already tall, and the pair reads as "the two things that move every
|
||||||
|
element at once", which is what they are. */}
|
||||||
|
<div className={cn('grid gap-1.5', isUB ? 'grid-cols-1' : 'grid-cols-2')}>
|
||||||
<button type="button" disabled={!ant.connected}
|
<button type="button" disabled={!ant.connected}
|
||||||
onClick={() => run(UltrabeamRetract())}
|
onClick={() => run(UltrabeamRetract())}
|
||||||
|
title={t('station.retractTip')}
|
||||||
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
||||||
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
|
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
|
||||||
</button>
|
</button>
|
||||||
|
{/* Asked first, always. Calibration runs every element to its end
|
||||||
|
stop and takes minutes — it is the right answer to an antenna that
|
||||||
|
tunes wrong, and the wrong answer to a stray click mid-contest. */}
|
||||||
|
{!isUB && (
|
||||||
|
<button type="button" disabled={!ant.connected}
|
||||||
|
onClick={() => { if (window.confirm(t('station.calibrateConfirm'))) run(MotorCalibrate()); }}
|
||||||
|
title={t('station.calibrateTip')}
|
||||||
|
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-border bg-muted/40 py-1 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
||||||
|
<Ruler className="size-3.5" /> {t('station.calibrate')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{isUB && !essentialsOnly && (
|
{isUB && !essentialsOnly && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -982,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>
|
||||||
|
|
||||||
|
|||||||
@@ -44,16 +44,26 @@ type RotorPreset = { label: string; azimuth: number };
|
|||||||
// A rotor is slow and its readout is coarse, so "is it moving" is inferred
|
// A rotor is slow and its readout is coarse, so "is it moving" is inferred
|
||||||
// rather than reported: a heading that changes by more than a degree means it
|
// rather than reported: a heading that changes by more than a degree means it
|
||||||
// is, and it is considered stopped once the readout has been still for a while.
|
// is, and it is considered stopped once the readout has been still for a while.
|
||||||
const MOVEMENT_SETTLE_MS = 1600;
|
// Long enough to outlast the gap between two readings of a turning rotor.
|
||||||
// Four degrees, not one.
|
|
||||||
//
|
//
|
||||||
// A rotor at rest does not report a constant heading: the potentiometer and the
|
// It was 1600 ms, shorter than the time a rotor takes to move far enough to be
|
||||||
// controller's rounding walk the reading a degree or two either side, and at one
|
// noticed at all: at a degree a second, four-degree steps are four seconds
|
||||||
// degree that jitter WAS movement — Stop lit for a second and a half, went out,
|
// apart, so Stop went dark between every one of them and lit again on the
|
||||||
// and lit again, for an antenna that had not turned all evening. The reference
|
// next — a rotation crossing a pass blinked the whole way round. The window
|
||||||
// is only moved when the threshold is crossed, so a rotor genuinely turning
|
// now only has to outlast ONE degree of progress, which even a slow mast
|
||||||
// accumulates towards it however slowly it goes; noise around a value never
|
// delivers about every two seconds while the poller is running fast.
|
||||||
// gets there.
|
const MOVEMENT_SETTLE_MS = 3000;
|
||||||
|
// Four degrees: the band inside which a reading is not a step at all.
|
||||||
|
//
|
||||||
|
// A rotor at rest does not report a constant heading — the potentiometer and
|
||||||
|
// the controller's rounding walk the reading a degree or two either side. But
|
||||||
|
// the threshold is only half the answer, and on its own it was not enough: WIND
|
||||||
|
// moves a beam further than four degrees and back again, and every one of those
|
||||||
|
// excursions counted, so Stop lit and went out all evening on an antenna that
|
||||||
|
// had not turned. Raising the number only raises the wind speed it takes.
|
||||||
|
//
|
||||||
|
// What actually separates a rotation from the weather is the DIRECTION — see the
|
||||||
|
// movement effect below.
|
||||||
const MOVEMENT_TRIGGER_DEG = 4;
|
const MOVEMENT_TRIGGER_DEG = 4;
|
||||||
// How long an order is given to produce movement before the widget stops
|
// How long an order is given to produce movement before the widget stops
|
||||||
// claiming the antenna is turning — the rotor may already have been there.
|
// claiming the antenna is turning — the rotor may already have been there.
|
||||||
@@ -81,8 +91,12 @@ type BeamKind = 'antenna' | 'hover';
|
|||||||
const TARGET_YELLOW = '#FBBF24';
|
const TARGET_YELLOW = '#FBBF24';
|
||||||
const MAP_BG_TOP = '#0B1015';
|
const MAP_BG_TOP = '#0B1015';
|
||||||
const MAP_BG_BOTTOM = '#080C11';
|
const MAP_BG_BOTTOM = '#080C11';
|
||||||
const MAP_LAND = '#202832';
|
// The continents, and they have to be VISIBLE. At #202832 on a #0B1015 ground
|
||||||
const MAP_LAND_SECONDARY = '#25303A';
|
// the land was some eight per cent brighter than the sea — technically a map,
|
||||||
|
// practically a dark square with a suggestion in it. These read as coastlines
|
||||||
|
// while staying well under the beams, which are what the dial is for.
|
||||||
|
const MAP_LAND = '#33414F';
|
||||||
|
const MAP_LAND_SECONDARY = '#41525F';
|
||||||
|
|
||||||
// What each rotor was last seen at, and what it was last told to do, kept
|
// What each rotor was last seen at, and what it was last told to do, kept
|
||||||
// OUTSIDE the component and keyed by rotor index.
|
// OUTSIDE the component and keyed by rotor index.
|
||||||
@@ -120,6 +134,35 @@ function unwrapRotation(nextAngle: number, previousRotation: number | null): num
|
|||||||
return previousRotation + delta;
|
return previousRotation + delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// useUnwrappedRotation is unwrapRotation kept across renders, for a beam whose
|
||||||
|
// angle comes from the ANTENNA rather than from the mouse.
|
||||||
|
//
|
||||||
|
// This is what was missing, and on a rotator with an overlap it is unmissable:
|
||||||
|
// a G-2800 sitting at 020° turned anticlockwise reports 020, 010, 000, 359,
|
||||||
|
// 358 … 340, and the CSS transition from 20deg to 359deg travels the long way —
|
||||||
|
// the beam whips a full turn round the dial while the antenna moves forty
|
||||||
|
// degrees the other way. Reported from the air.
|
||||||
|
//
|
||||||
|
// The angle is therefore accumulated rather than reset: 020 → 000 → −001 →
|
||||||
|
// −020, which is the way the mast is actually moving. The ref is advanced only
|
||||||
|
// when the input changes, so a re-render for any other reason cannot make the
|
||||||
|
// beam creep.
|
||||||
|
function useUnwrappedRotation(angle: number | null): number | null {
|
||||||
|
const rotation = useRef<number | null>(null);
|
||||||
|
const lastInput = useRef<number | null>(null);
|
||||||
|
if (angle == null) {
|
||||||
|
rotation.current = null;
|
||||||
|
lastInput.current = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const a = normalizeAzimuth(angle);
|
||||||
|
if (lastInput.current !== a || rotation.current == null) {
|
||||||
|
rotation.current = unwrapRotation(a, rotation.current);
|
||||||
|
lastInput.current = a;
|
||||||
|
}
|
||||||
|
return rotation.current;
|
||||||
|
}
|
||||||
|
|
||||||
// ── The dial ───────────────────────────────────────────────────────────────
|
// ── The dial ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function RotorCompassDial({
|
function RotorCompassDial({
|
||||||
@@ -143,6 +186,12 @@ function RotorCompassDial({
|
|||||||
// is read by moving the eye, and this one is read while aiming.
|
// is read by moving the eye, and this one is read while aiming.
|
||||||
onHoverAzimuth?: (az: number | null) => void;
|
onHoverAzimuth?: (az: number | null) => void;
|
||||||
}) {
|
}) {
|
||||||
|
// The beams animate on an accumulated angle, so crossing north never sends
|
||||||
|
// them the long way round the dial. Both lobes of a bidirectional antenna get
|
||||||
|
// their own, because they cross north at different moments.
|
||||||
|
const antennaRotation = useUnwrappedRotation(azimuth ?? null);
|
||||||
|
const secondaryRotation = useUnwrappedRotation(secondary ?? null);
|
||||||
|
|
||||||
// Gradient and mask ids must be unique per instance: two compasses on one
|
// Gradient and mask ids must be unique per instance: two compasses on one
|
||||||
// screen (docked widget + Station Control) would otherwise share the first
|
// screen (docked widget + Station Control) would otherwise share the first
|
||||||
// one's definitions.
|
// one's definitions.
|
||||||
@@ -289,7 +338,11 @@ function RotorCompassDial({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full min-w-0 aspect-square rounded-md border border-border bg-background flex items-center justify-center overflow-hidden">
|
// No card of its own, and no square: the dial is drawn as a disc and the
|
||||||
|
// corners are left to whatever it is sitting on. A black tile inside the
|
||||||
|
// rotor panel read as a hole punched in it — the widget is already a card,
|
||||||
|
// and this is an instrument on that card, not a second one.
|
||||||
|
<div className="w-full h-full min-w-0 aspect-square flex items-center justify-center overflow-hidden">
|
||||||
<svg
|
<svg
|
||||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||||
className={cn('block w-full h-full select-none', onGoto ? 'cursor-crosshair' : 'cursor-default')}
|
className={cn('block w-full h-full select-none', onGoto ? 'cursor-crosshair' : 'cursor-default')}
|
||||||
@@ -338,10 +391,10 @@ function RotorCompassDial({
|
|||||||
))}
|
))}
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<rect x="0" y="0" width={SIZE} height={SIZE} fill={`url(#${bgGradientId})`} />
|
<circle cx={CENTER} cy={CENTER} r={MAP_RADIUS} fill={`url(#${bgGradientId})`} />
|
||||||
|
|
||||||
{landPath && (
|
{landPath && (
|
||||||
<g mask={`url(#${mapFadeMaskId})`} opacity="0.78" pointerEvents="none">
|
<g mask={`url(#${mapFadeMaskId})`} opacity="0.92" pointerEvents="none">
|
||||||
<path d={landPath} fill={MAP_LAND} />
|
<path d={landPath} fill={MAP_LAND} />
|
||||||
<path d={landPath} fill={MAP_LAND_SECONDARY} opacity="0.22" transform="translate(0.35 0.35)" />
|
<path d={landPath} fill={MAP_LAND_SECONDARY} opacity="0.22" transform="translate(0.35 0.35)" />
|
||||||
</g>
|
</g>
|
||||||
@@ -420,8 +473,8 @@ function RotorCompassDial({
|
|||||||
|
|
||||||
{/* The second lobe of a bidirectional antenna: the same beam, dimmed —
|
{/* The second lobe of a bidirectional antenna: the same beam, dimmed —
|
||||||
it radiates as much, and it is not where the operator aimed. */}
|
it radiates as much, and it is not where the operator aimed. */}
|
||||||
{secondary != null && renderBeam(normalizeAzimuth(secondary), 'antenna', 0.45, true)}
|
{secondaryRotation != null && renderBeam(secondaryRotation, 'antenna', 0.45, true)}
|
||||||
{azimuth != null && renderBeam(normalizeAzimuth(azimuth), 'antenna', 1, true)}
|
{antennaRotation != null && renderBeam(antennaRotation, 'antenna', 1, true)}
|
||||||
|
|
||||||
<circle cx={CENTER} cy={CENTER} r={CENTER_DOT_RADIUS} fill={COMPASS_ORANGE} pointerEvents="none" />
|
<circle cx={CENTER} cy={CENTER} r={CENTER_DOT_RADIUS} fill={COMPASS_ORANGE} pointerEvents="none" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -474,6 +527,14 @@ export function RotorCompass({
|
|||||||
const latestAzimuthRef = useRef<number | null>(displayAzimuth);
|
const latestAzimuthRef = useRef<number | null>(displayAzimuth);
|
||||||
const movementReferenceRef = useRef<number | null>(displayAzimuth);
|
const movementReferenceRef = useRef<number | null>(displayAzimuth);
|
||||||
const movementSeenRef = useRef(false);
|
const movementSeenRef = useRef(false);
|
||||||
|
// Which way the last accepted step went (+1 CW, −1 CCW, 0 none), and how many
|
||||||
|
// in a row have gone that way. Two make it a rotation; one is weather.
|
||||||
|
const movementDirRef = useRef(0);
|
||||||
|
const movementRunRef = useRef(0);
|
||||||
|
// The previous reading, whatever it was. movementReferenceRef deliberately
|
||||||
|
// holds still through sub-threshold steps so they can accumulate, which
|
||||||
|
// makes it useless for measuring progress poll to poll.
|
||||||
|
const lastRawRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const rememberTarget = (value: number | null) => {
|
const rememberTarget = (value: number | null) => {
|
||||||
if (value == null) rememberedTargets.delete(rotorKey);
|
if (value == null) rememberedTargets.delete(rotorKey);
|
||||||
@@ -504,6 +565,9 @@ export function RotorCompass({
|
|||||||
setTargetFading(false);
|
setTargetFading(false);
|
||||||
setIsMoving(rememberedTarget != null);
|
setIsMoving(rememberedTarget != null);
|
||||||
movementSeenRef.current = false;
|
movementSeenRef.current = false;
|
||||||
|
movementDirRef.current = 0;
|
||||||
|
movementRunRef.current = 0;
|
||||||
|
lastRawRef.current = nextAzimuth;
|
||||||
window.clearTimeout(movementTimerRef.current);
|
window.clearTimeout(movementTimerRef.current);
|
||||||
window.clearTimeout(commandTimerRef.current);
|
window.clearTimeout(commandTimerRef.current);
|
||||||
window.clearTimeout(targetArrivalTimerRef.current);
|
window.clearTimeout(targetArrivalTimerRef.current);
|
||||||
@@ -519,13 +583,11 @@ export function RotorCompass({
|
|||||||
window.clearTimeout(targetFadeTimerRef.current);
|
window.clearTimeout(targetFadeTimerRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Movement is inferred from the readout itself, so a rotor turned by its own
|
// Declare the antenna moving and start the clock on it stopping. Called from
|
||||||
// controller — or by another program — reads as moving here too.
|
// both halves of the test below, and re-arming the timer is the whole point:
|
||||||
useEffect(() => {
|
// the antenna counts as stopped only once nothing has said otherwise for
|
||||||
if (rawAzimuth == null) return;
|
// MOVEMENT_SETTLE_MS.
|
||||||
const reference = movementReferenceRef.current;
|
function armMovement() {
|
||||||
if (reference == null) { movementReferenceRef.current = rawAzimuth; return; }
|
|
||||||
if (angularDistance(rawAzimuth, reference) >= MOVEMENT_TRIGGER_DEG) {
|
|
||||||
movementSeenRef.current = true;
|
movementSeenRef.current = true;
|
||||||
setIsMoving(true);
|
setIsMoving(true);
|
||||||
window.clearTimeout(commandTimerRef.current);
|
window.clearTimeout(commandTimerRef.current);
|
||||||
@@ -533,9 +595,62 @@ export function RotorCompass({
|
|||||||
movementTimerRef.current = window.setTimeout(() => {
|
movementTimerRef.current = window.setTimeout(() => {
|
||||||
setIsMoving(false);
|
setIsMoving(false);
|
||||||
movementSeenRef.current = false;
|
movementSeenRef.current = false;
|
||||||
|
// Forget the direction too: the next real move starts its own run rather
|
||||||
|
// than inheriting one from a rotation that finished minutes ago.
|
||||||
|
movementDirRef.current = 0;
|
||||||
|
movementRunRef.current = 0;
|
||||||
}, MOVEMENT_SETTLE_MS);
|
}, MOVEMENT_SETTLE_MS);
|
||||||
movementReferenceRef.current = rawAzimuth;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Movement is inferred from the readout itself, so a rotor turned by its own
|
||||||
|
// controller — or by another program — reads as moving here too.
|
||||||
|
//
|
||||||
|
// The test is the DIRECTION, not the size of the step.
|
||||||
|
//
|
||||||
|
// A threshold alone does not work, whatever it is set to. Wind pushes a beam
|
||||||
|
// off its bearing and back — 100°, 105°, 100°, 106° — and every one of those
|
||||||
|
// excursions clears a four-degree threshold, so Stop lit and went out all
|
||||||
|
// evening on an antenna that had not turned. Raising the number only raises
|
||||||
|
// the wind speed it takes.
|
||||||
|
//
|
||||||
|
// What separates the two is not amplitude but sign: a rotor under power
|
||||||
|
// advances, gust after gust reverses. So a step is only movement when the
|
||||||
|
// PREVIOUS step went the same way. Wind gives +5, −5, +5 and never two in a
|
||||||
|
// row; a rotor gives +4, +4, +4 and is announced on the second — one poll,
|
||||||
|
// about a second, on a mast that takes half a minute to cross a pass.
|
||||||
|
//
|
||||||
|
// Getting IN is strict; staying in is not, and must not be. The same four
|
||||||
|
// degrees that keep the wind out are four seconds of travel on a real rotor,
|
||||||
|
// so waiting for the next four-degree step before believing it is still
|
||||||
|
// turning left Stop dark for most of the rotation. Once movement is
|
||||||
|
// established, ANY continued progress the way it was going keeps it alive —
|
||||||
|
// one degree the same way is not weather when the mast is already under
|
||||||
|
// power, and the strict test is what guarantees that it is.
|
||||||
|
useEffect(() => {
|
||||||
|
if (rawAzimuth == null) return;
|
||||||
|
// Signed and the short way round: crossing north is a small step, not 350°.
|
||||||
|
const short = (from: number, to: number) => ((to - from + 540) % 360) - 180;
|
||||||
|
const previous = lastRawRef.current;
|
||||||
|
lastRawRef.current = rawAzimuth;
|
||||||
|
if (movementSeenRef.current && previous != null && movementDirRef.current !== 0) {
|
||||||
|
const step = short(previous, rawAzimuth);
|
||||||
|
if (step !== 0 && Math.sign(step) === movementDirRef.current) armMovement();
|
||||||
|
}
|
||||||
|
const reference = movementReferenceRef.current;
|
||||||
|
if (reference == null) { movementReferenceRef.current = rawAzimuth; return; }
|
||||||
|
const delta = short(reference, rawAzimuth);
|
||||||
|
if (Math.abs(delta) < MOVEMENT_TRIGGER_DEG) return; // inside the noise band
|
||||||
|
const sign = delta > 0 ? 1 : -1;
|
||||||
|
if (movementDirRef.current === sign) {
|
||||||
|
movementRunRef.current += 1;
|
||||||
|
} else {
|
||||||
|
movementDirRef.current = sign;
|
||||||
|
movementRunRef.current = 1;
|
||||||
|
}
|
||||||
|
movementReferenceRef.current = rawAzimuth;
|
||||||
|
if (movementRunRef.current < 2) return; // one step either way is weather
|
||||||
|
|
||||||
|
armMovement();
|
||||||
}, [rawAzimuth, rotorKey]);
|
}, [rawAzimuth, rotorKey]);
|
||||||
|
|
||||||
// Arrival: confirmed over time, then faded. Every check re-reads the
|
// Arrival: confirmed over time, then faded. Every check re-reads the
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen, Radar } from 'lucide-react';
|
import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen, Radar, Compass } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
|
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
|
||||||
GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack,
|
GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack,
|
||||||
StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking,
|
StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking, RetargetSatelliteTracking,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -57,10 +57,10 @@ type Tuning = {
|
|||||||
type Track = {
|
type Track = {
|
||||||
on: boolean; name: string; transponder: string; mode: string;
|
on: boolean; name: string; transponder: string; mode: string;
|
||||||
nominal_down: number; nominal_up: number; down_hz: number; up_hz: number;
|
nominal_down: number; nominal_up: number; down_hz: number; up_hz: number;
|
||||||
az: number; el: number; visible: boolean;
|
az: number; el: number; visible: boolean; range_km: number; alt_km: number;
|
||||||
radio: string; // "sat" | "downlink-only" | ""
|
radio: string; // "sat" | "downlink-only" | ""
|
||||||
error: string;
|
error: string;
|
||||||
rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean;
|
rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean; rot_az_only: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAP_VIEW_SAT = 'opslog.satMapView';
|
const MAP_VIEW_SAT = 'opslog.satMapView';
|
||||||
@@ -74,11 +74,28 @@ const SIDE_SHOWN_KEY = 'opslog.satSideShown';
|
|||||||
const SIDE_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720;
|
const SIDE_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720;
|
||||||
const SKY_SHOWN_KEY = 'opslog.satSkyShown';
|
const SKY_SHOWN_KEY = 'opslog.satSkyShown';
|
||||||
|
|
||||||
|
// Four decimals — a hundred hertz, which is what a linear transponder is
|
||||||
|
// actually tuned to.
|
||||||
|
//
|
||||||
|
// It used to be six, and the last two digits changed every tick: the Doppler
|
||||||
|
// moves about sixty hertz a second on 70 cm, so the display was a blur of
|
||||||
|
// numbers nobody could read and nobody needed. The RADIO still gets the whole
|
||||||
|
// figure — the correction is computed and sent to the hertz — this is only how
|
||||||
|
// much of it is worth putting in front of an operator. The shift beside it, in
|
||||||
|
// kilohertz, is where the fine movement shows.
|
||||||
const fmtHz = (hz: number) => {
|
const fmtHz = (hz: number) => {
|
||||||
if (!hz) return '—';
|
if (!hz) return '—';
|
||||||
// Six decimals: a linear transponder is tuned to the hundred hertz, and the
|
return (hz / 1e6).toFixed(4).replace(/(\d)(?=(\d{3})+\.)/g, '$1 ');
|
||||||
// Doppler correction moves the last three digits every second.
|
};
|
||||||
return (hz / 1e6).toFixed(6).replace(/(\d)(?=(\d{3})+\.)/g, '$1 ');
|
|
||||||
|
// The Doppler shift, as an operator would say it: hertz while it is small
|
||||||
|
// enough to say in hertz, kilohertz once it is not. "+9741 Hz" is four digits
|
||||||
|
// of precision on a number that is only ever read as "about ten kilohertz".
|
||||||
|
const fmtShift = (hz: number) => {
|
||||||
|
const sign = hz > 0 ? '+' : '−';
|
||||||
|
const a = Math.abs(hz);
|
||||||
|
if (a < 1000) return `${sign}${Math.round(a)} Hz`;
|
||||||
|
return `${sign}${(a / 1000).toFixed(1)} kHz`;
|
||||||
};
|
};
|
||||||
const fmtDeg = (d: number) => `${d.toFixed(1)}°`;
|
const fmtDeg = (d: number) => `${d.toFixed(1)}°`;
|
||||||
const fmtKm = (km: number) => `${Math.round(km).toLocaleString()} km`;
|
const fmtKm = (km: number) => `${Math.round(km).toLocaleString()} km`;
|
||||||
@@ -128,6 +145,73 @@ const MODE_COLOUR: Record<string, string> = {
|
|||||||
DATA: 'var(--warning)',
|
DATA: 'var(--warning)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// escapeHtml, because a satellite name comes from data/satellites.json, which
|
||||||
|
// the operator edits by hand. A stray "<" there must not be able to break the
|
||||||
|
// tooltip it lands in.
|
||||||
|
const escapeHtml = (s: string) =>
|
||||||
|
s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string));
|
||||||
|
|
||||||
|
// satTip is what hovering a satellite on the map says.
|
||||||
|
//
|
||||||
|
// The dot alone answered "there it is" and nothing else — the name, an
|
||||||
|
// elevation and an altitude, none of which decides anything. What decides
|
||||||
|
// whether to reach for the radio is how long is left, how high it will get and
|
||||||
|
// where to point: so the pass is here, and reading it costs a hover instead of
|
||||||
|
// selecting the bird and looking somewhere else on the screen.
|
||||||
|
function satTip(p: Position, pass: Pass | undefined, t: (k: string) => string): string {
|
||||||
|
const row = (label: string, value: string) =>
|
||||||
|
`<div class="sat-tip-row"><span>${label}</span><span>${value}</span></div>`;
|
||||||
|
const out: string[] = [`<div class="sat-tip-name">${escapeHtml(p.name)}</div>`];
|
||||||
|
|
||||||
|
if (p.el > 0) {
|
||||||
|
out.push(row(t('sat.tipEl'), `${fmtDeg(p.el)}`));
|
||||||
|
out.push(row(t('sat.tipAz'), `${fmtDeg(p.az)} ${compass(p.az)}`));
|
||||||
|
} else {
|
||||||
|
out.push(`<div class="sat-tip-note">${t('sat.tipBelow')}</div>`);
|
||||||
|
}
|
||||||
|
// Closing or opening: the sign of the range rate is the difference between a
|
||||||
|
// pass about to start being useful and one already going away.
|
||||||
|
const trend = p.range_rate < -0.05 ? ' ↓' : p.range_rate > 0.05 ? ' ↑' : '';
|
||||||
|
out.push(row(t('sat.tipRange'), fmtKm(p.range_km) + trend));
|
||||||
|
out.push(row(t('sat.tipAlt'), fmtKm(p.alt_km)));
|
||||||
|
|
||||||
|
if (pass) {
|
||||||
|
const aos = Date.parse(pass.aos), los = Date.parse(pass.los), now = Date.now();
|
||||||
|
if (now >= aos && now < los) {
|
||||||
|
out.push(row(t('sat.tipLos'), `${hhmm(pass.los)} · ${fmtCountdown(los - now)}`));
|
||||||
|
} else {
|
||||||
|
out.push(row(t('sat.tipAos'), `${hhmm(pass.aos)} · ${fmtCountdown(aos - now)} · ${compass(pass.aos_az)}`));
|
||||||
|
out.push(row(t('sat.tipLos'), `${hhmm(pass.los)} · ${compass(pass.los_az)}`));
|
||||||
|
}
|
||||||
|
out.push(row(t('sat.tipMaxEl'), `${fmtDeg(pass.max_el)} ${compass(pass.max_el_az)}`));
|
||||||
|
} else {
|
||||||
|
// No pass inside the prediction window. Worth saying: an empty space here
|
||||||
|
// reads as a bug, and "nothing in the next 24 hours" is an answer.
|
||||||
|
out.push(`<div class="sat-tip-note">${t('sat.tipNoPass')}</div>`);
|
||||||
|
}
|
||||||
|
return out.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModeBadge says FM or SSB where it cannot be missed.
|
||||||
|
//
|
||||||
|
// The mode used to be one word in a grey 11-pixel footnote under the
|
||||||
|
// frequencies, and it is not a footnote: FM and SSB are two different evenings.
|
||||||
|
// One is a channel, a tone and a handheld; the other is a passband, a beam and a
|
||||||
|
// VFO that has to be walked as the Doppler moves. An operator who reads the
|
||||||
|
// wrong one calls into silence.
|
||||||
|
function ModeBadge({ mode, className }: { mode?: string; className?: string }) {
|
||||||
|
if (!mode) return null;
|
||||||
|
const colour = MODE_COLOUR[mode] ?? 'var(--muted-foreground)';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide border', className)}
|
||||||
|
style={{ color: colour, borderColor: `color-mix(in srgb, ${colour} 45%, transparent)`, background: `color-mix(in srgb, ${colour} 14%, transparent)` }}
|
||||||
|
>
|
||||||
|
{mode}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ModeDot({ mode }: { mode: string }) {
|
function ModeDot({ mode }: { mode: string }) {
|
||||||
const colour = MODE_COLOUR[mode];
|
const colour = MODE_COLOUR[mode];
|
||||||
if (!colour) return null;
|
if (!colour) return null;
|
||||||
@@ -147,6 +231,14 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
const [tpIdx, setTpIdx] = useState(0);
|
const [tpIdx, setTpIdx] = useState(0);
|
||||||
const [positions, setPositions] = useState<Position[]>([]);
|
const [positions, setPositions] = useState<Position[]>([]);
|
||||||
const [passes, setPasses] = useState<Pass[]>([]);
|
const [passes, setPasses] = useState<Pass[]>([]);
|
||||||
|
// The next pass per satellite, for the map tooltips. The list is already
|
||||||
|
// ordered by AOS across every bird, so the first entry for a name is its next
|
||||||
|
// one — no second prediction run for what is already on screen.
|
||||||
|
const nextPassOf = useMemo(() => {
|
||||||
|
const m = new Map<string, Pass>();
|
||||||
|
for (const p of passes) if (!m.has(p.name)) m.set(p.name, p);
|
||||||
|
return m;
|
||||||
|
}, [passes]);
|
||||||
const [tuning, setTuning] = useState<Tuning | null>(null);
|
const [tuning, setTuning] = useState<Tuning | null>(null);
|
||||||
const [pass, setPass] = useState<PassInfo | null>(null);
|
const [pass, setPass] = useState<PassInfo | null>(null);
|
||||||
const [tle, setTle] = useState<{ count: number; age_h: number; stale: boolean; custom: number } | null>(null);
|
const [tle, setTle] = useState<{ count: number; age_h: number; stale: boolean; custom: number } | null>(null);
|
||||||
@@ -171,6 +263,19 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
return birds.filter((b) => b.has_elements && (b.transponders?.length ?? 0) > 0);
|
return birds.filter((b) => b.has_elements && (b.transponders?.length ?? 0) > 0);
|
||||||
}, [birds]);
|
}, [birds]);
|
||||||
const bird = useMemo(() => birds.find((b) => b.name === sel) ?? null, [birds, sel]);
|
const bird = useMemo(() => birds.find((b) => b.name === sel) ?? null, [birds, sel]);
|
||||||
|
|
||||||
|
// The satellites you follow that have NO pass in the table.
|
||||||
|
//
|
||||||
|
// They are the reason the table was not the whole list: QO-100 never has a
|
||||||
|
// pass because it never sets, a bird whose elements have not arrived cannot
|
||||||
|
// be predicted at all, and one whose next pass falls outside the prediction
|
||||||
|
// window is simply beyond it. Left out, those three looked like satellites
|
||||||
|
// OpsLog had lost — so they are listed at the end, each saying which of the
|
||||||
|
// three it is, and clicking one selects it exactly like a pass row.
|
||||||
|
const idle = useMemo(() => {
|
||||||
|
const withPass = new Set(passes.map((p) => p.name));
|
||||||
|
return shown.filter((b) => !withPass.has(b.name));
|
||||||
|
}, [shown, passes]);
|
||||||
const tp = bird?.transponders?.[tpIdx] ?? null;
|
const tp = bird?.transponders?.[tpIdx] ?? null;
|
||||||
// The mode each satellite is worked in, for the pass table's dot. Its first
|
// The mode each satellite is worked in, for the pass table's dot. Its first
|
||||||
// transponder: on a bird that has two, the first is the one it is known for.
|
// transponder: on a bird that has two, the first is the one it is known for.
|
||||||
@@ -201,6 +306,12 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
|
|
||||||
useEffect(() => { loadBirds(); loadTle(); loadPasses(); }, [loadBirds, loadTle, loadPasses]);
|
useEffect(() => { loadBirds(); loadTle(); loadPasses(); }, [loadBirds, loadTle, loadPasses]);
|
||||||
useEffect(() => EventsOn('sat:tle', () => { loadTle(); loadBirds(); loadPasses(); }), [loadTle, loadBirds, loadPasses]);
|
useEffect(() => EventsOn('sat:tle', () => { loadTle(); loadBirds(); loadPasses(); }), [loadTle, loadBirds, loadPasses]);
|
||||||
|
// Saving the satellite settings. The followed list, the lowest pass and the
|
||||||
|
// locator all change what belongs here, and this tab is normally open behind
|
||||||
|
// the settings window while they are edited. The selection repairs itself:
|
||||||
|
// a satellite that is no longer followed drops out of the dropdown, and the
|
||||||
|
// effect below moves to the first one that is.
|
||||||
|
useEffect(() => EventsOn('sat:settings', () => { loadBirds(); loadPasses(); }), [loadBirds, loadPasses]);
|
||||||
useEffect(() => { if (sel) localStorage.setItem('opslog.satSelected', sel); }, [sel]);
|
useEffect(() => { if (sel) localStorage.setItem('opslog.satSelected', sel); }, [sel]);
|
||||||
useEffect(() => { setTpIdx(0); }, [sel]);
|
useEffect(() => { setTpIdx(0); }, [sel]);
|
||||||
|
|
||||||
@@ -309,12 +420,39 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Changing satellite WHILE tracking moves the radio to the new one at once.
|
||||||
|
//
|
||||||
|
// The selection here is the display's; the tracker held its own and went on
|
||||||
|
// following what it was started with, so two birds up at the same time meant
|
||||||
|
// switching between them and watching the frequencies stay on the first.
|
||||||
|
// Stopping and restarting worked, and is also how a Flex throws away and
|
||||||
|
// rebuilds both its slices for nothing.
|
||||||
|
//
|
||||||
|
// Guarded on tracking being on, so selecting a satellite with the radio idle
|
||||||
|
// stays what it has always been: a look, not a command.
|
||||||
|
const trackingOn = !!tracking?.on;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!trackingOn || !sel) return;
|
||||||
|
RetargetSatelliteTracking(sel, tpIdx)
|
||||||
|
.then(async () => setTracking((await GetSatelliteTracking()) as any))
|
||||||
|
.catch((e: any) => setErr(String(e?.message ?? e)));
|
||||||
|
}, [sel, tpIdx, trackingOn]);
|
||||||
|
|
||||||
// ── Map ──────────────────────────────────────────────────────────────────
|
// ── Map ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const divRef = useRef<HTMLDivElement>(null);
|
const divRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<L.Map | null>(null);
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
const layerRef = useRef<L.LayerGroup | null>(null);
|
const layerRef = useRef<L.LayerGroup | null>(null);
|
||||||
const baseRef = useRef<L.TileLayer | null>(null);
|
const baseRef = useRef<L.TileLayer | null>(null);
|
||||||
|
// Where the pointer is over the map, in container pixels.
|
||||||
|
//
|
||||||
|
// The satellite layer is rebuilt every five seconds as the birds move, and a
|
||||||
|
// rebuilt marker is a new marker: the tooltip the operator was reading closed
|
||||||
|
// itself, over and over, which made the hover detail useless exactly when it
|
||||||
|
// was being used. Knowing where the pointer is lets the redraw reopen the
|
||||||
|
// tooltip of the dot it is still on — and only that one, so nothing is left
|
||||||
|
// hanging open once the mouse has moved away.
|
||||||
|
const mouseRef = useRef<L.Point | null>(null);
|
||||||
const labelsRef = useRef<L.TileLayer | null>(null);
|
const labelsRef = useRef<L.TileLayer | null>(null);
|
||||||
const [basemap, setBasemap] = useState<BasemapKey>(() => loadMapBase(MAP_BASE_SAT, 'light'));
|
const [basemap, setBasemap] = useState<BasemapKey>(() => loadMapBase(MAP_BASE_SAT, 'light'));
|
||||||
const saved = useRef(loadMapView(MAP_VIEW_SAT));
|
const saved = useRef(loadMapView(MAP_VIEW_SAT));
|
||||||
@@ -366,6 +504,8 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
const c = m.getCenter();
|
const c = m.getCenter();
|
||||||
saveMapView(MAP_VIEW_SAT, c.lat, c.lng, m.getZoom());
|
saveMapView(MAP_VIEW_SAT, c.lat, c.lng, m.getZoom());
|
||||||
});
|
});
|
||||||
|
m.on('mousemove', (e: L.LeafletMouseEvent) => { mouseRef.current = e.containerPoint; });
|
||||||
|
m.on('mouseout', () => { mouseRef.current = null; });
|
||||||
mapRef.current = m;
|
mapRef.current = m;
|
||||||
layerRef.current = L.layerGroup().addTo(m);
|
layerRef.current = L.layerGroup().addTo(m);
|
||||||
const ro = new ResizeObserver(() => m.invalidateSize({ animate: false }));
|
const ro = new ResizeObserver(() => m.invalidateSize({ animate: false }));
|
||||||
@@ -430,28 +570,86 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
if (!wanted.has(p.name) && p.name !== sel) continue;
|
if (!wanted.has(p.name) && p.name !== sel) continue;
|
||||||
const chosen = p.name === sel;
|
const chosen = p.name === sel;
|
||||||
const up = p.el > 0;
|
const up = p.el > 0;
|
||||||
const colour = chosen ? '#22c55e' : up ? '#f59e0b' : '#9ca3af';
|
const colour = chosen ? '#22c55e' : up ? '#f59e0b' : '#94a3b8';
|
||||||
// The footprint is the honest answer to "can I hear it": everything inside
|
// The footprint is the honest answer to "can I hear it": everything inside
|
||||||
// the circle has the satellite above its horizon.
|
// the circle has the satellite above its horizon.
|
||||||
|
//
|
||||||
|
// Drawn for the SELECTED bird only. A footprint is thousands of kilometres
|
||||||
|
// across, so a dozen of them overlap into a wash of circles that hides the
|
||||||
|
// coastline, the ground track and the satellites themselves — and the
|
||||||
|
// question it answers is only ever asked about the one being worked.
|
||||||
|
if (chosen) {
|
||||||
L.circle([p.lat, p.lon], {
|
L.circle([p.lat, p.lon], {
|
||||||
radius: p.footprint_km * 1000,
|
radius: p.footprint_km * 1000,
|
||||||
color: colour, weight: chosen ? 1.2 : 0.8, opacity: chosen ? 0.7 : 0.35,
|
color: colour, weight: 1.2, opacity: 0.7,
|
||||||
fillColor: colour, fillOpacity: chosen ? 0.1 : 0.05,
|
fillColor: colour, fillOpacity: 0.1,
|
||||||
}).addTo(layer);
|
}).addTo(layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two rings and not one. The map is a street map on one station and a
|
||||||
|
// dark satellite image on the next, and a single-stroke dot disappears
|
||||||
|
// into one of them — a pale marker on pale terrain, a grey one on a black
|
||||||
|
// ocean. A dark halo under a white ring reads on both, which is what an
|
||||||
|
// unselected satellite needs: it is precisely the one nobody is looking
|
||||||
|
// straight at.
|
||||||
|
const r = chosen ? 7 : up ? 6 : 5;
|
||||||
L.circleMarker([p.lat, p.lon], {
|
L.circleMarker([p.lat, p.lon], {
|
||||||
radius: chosen ? 6 : 4, color: '#fff', weight: 1,
|
radius: r + 1.5, color: '#000', weight: 2, opacity: 0.45,
|
||||||
|
fill: false, interactive: false,
|
||||||
|
}).addTo(layer);
|
||||||
|
|
||||||
|
const dot = L.circleMarker([p.lat, p.lon], {
|
||||||
|
radius: r, color: '#fff', weight: 2,
|
||||||
fillColor: colour, fillOpacity: 1,
|
fillColor: colour, fillOpacity: 1,
|
||||||
})
|
})
|
||||||
.bindTooltip(`${p.name} · ${fmtDeg(p.el)} · ${Math.round(p.alt_km)} km`, { direction: 'top' })
|
.bindTooltip(satTip(p, nextPassOf.get(p.name), t), {
|
||||||
|
direction: 'top', className: 'sat-tip', offset: [0, -6],
|
||||||
|
})
|
||||||
.on('click', () => setSel(p.name))
|
.on('click', () => setSel(p.name))
|
||||||
.addTo(layer);
|
.addTo(layer);
|
||||||
|
|
||||||
|
// Was the pointer on this dot before the redraw replaced it? Then put the
|
||||||
|
// tooltip back, with the numbers it has just refreshed.
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (map && mouseRef.current) {
|
||||||
|
const at = map.latLngToContainerPoint([p.lat, p.lon]);
|
||||||
|
if (at.distanceTo(mouseRef.current) <= r + 3) dot.openTooltip();
|
||||||
}
|
}
|
||||||
}, [positions, track, home?.lat, home?.lon, myGrid, sel, shown]);
|
|
||||||
|
// A name beside the ones that are UP. The map can carry a dozen birds and
|
||||||
|
// labelling them all is a map nobody can read; the two or three above the
|
||||||
|
// horizon are the ones an operator is choosing between right now, and
|
||||||
|
// hovering each grey dot in turn to find them is the work this saves.
|
||||||
|
//
|
||||||
|
// Its own non-interactive marker rather than a permanent tooltip on the
|
||||||
|
// dot: Leaflet keeps ONE tooltip per layer, so a permanent label would
|
||||||
|
// take the place of the hover detail — and the detail is the point.
|
||||||
|
if (up || chosen) {
|
||||||
|
L.marker([p.lat, p.lon], {
|
||||||
|
icon: L.divIcon({
|
||||||
|
className: 'sat-name-label',
|
||||||
|
html: `<span>${escapeHtml(p.name)}</span>`,
|
||||||
|
iconSize: [0, 0],
|
||||||
|
iconAnchor: [-(r + 5), 6],
|
||||||
|
}),
|
||||||
|
interactive: false, keyboard: false,
|
||||||
|
}).addTo(layer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [positions, track, home?.lat, home?.lon, myGrid, sel, shown, nextPassOf, t]);
|
||||||
|
|
||||||
// ── Render ───────────────────────────────────────────────────────────────
|
// ── Render ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// The pass, as a countdown and a bar. Both derived here from two timestamps,
|
// The pass, as a countdown and a bar. Both derived here from two timestamps,
|
||||||
// so they move every second without asking Go anything.
|
// so they move every second without asking Go anything.
|
||||||
|
// Is the antenna still on its way? The rotator is asked where it is every
|
||||||
|
// three seconds and a mast takes tens of seconds to cross a pass, so a
|
||||||
|
// difference between where it is and where the satellite is means it is
|
||||||
|
// moving — which is exactly what a number alone cannot show, and the
|
||||||
|
// difference between "on its way" and "stuck" is the whole reason to look.
|
||||||
|
const antennaMoving = !!tracking?.rot_on && !!tracking.rot_live &&
|
||||||
|
Math.abs(((tracking.az - tracking.rot_az + 540) % 360) - 180) > 3;
|
||||||
|
|
||||||
const aosMs = pass?.has_pass ? Date.parse(pass.aos) : 0;
|
const aosMs = pass?.has_pass ? Date.parse(pass.aos) : 0;
|
||||||
const losMs = pass?.has_pass ? Date.parse(pass.los) : 0;
|
const losMs = pass?.has_pass ? Date.parse(pass.los) : 0;
|
||||||
const inPass = !!pass?.has_pass && now >= aosMs && now < losMs;
|
const inPass = !!pass?.has_pass && now >= aosMs && now < losMs;
|
||||||
@@ -483,11 +681,27 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
value={tpIdx}
|
value={tpIdx}
|
||||||
onChange={(e) => setTpIdx(Number(e.target.value))}
|
onChange={(e) => setTpIdx(Number(e.target.value))}
|
||||||
>
|
>
|
||||||
|
{/* The mode belongs in the choice itself. A satellite with an FM
|
||||||
|
repeater and a linear transponder offers two labels that both
|
||||||
|
read like a name, and picking the wrong one is a whole pass
|
||||||
|
spent on the wrong kind of radio. */}
|
||||||
{bird!.transponders!.map((x, i) => (
|
{bird!.transponders!.map((x, i) => (
|
||||||
<option key={i} value={i}>{x.label}</option>
|
<option key={i} value={i}>
|
||||||
|
{x.label} — {x.mode}{x.ctcss ? ` ${x.ctcss.toFixed(1)}` : ''}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
|
{/* Also in the header, so the mode and the tone survive hiding the
|
||||||
|
readout column — which is exactly what an operator does when they
|
||||||
|
want the map full width during a pass. */}
|
||||||
|
<ModeBadge mode={tp?.mode} />
|
||||||
|
{tp?.mode === 'FM' && !!tp.ctcss && (
|
||||||
|
<span className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold tabular-nums border border-caution/45 bg-caution/10 text-caution"
|
||||||
|
title={t('sat.toneHint')}>
|
||||||
|
{tp.ctcss.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={tracking?.on ? 'default' : 'outline'}
|
variant={tracking?.on ? 'default' : 'outline'}
|
||||||
@@ -504,6 +718,61 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
{tracking?.on && tracking.radio === 'downlink-only' && (
|
{tracking?.on && tracking.radio === 'downlink-only' && (
|
||||||
<span className="text-[11px] text-warning">{t('sat.downlinkOnly')}</span>
|
<span className="text-[11px] text-warning">{t('sat.downlinkOnly')}</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* What the station is actually doing, beside the button that started
|
||||||
|
it. During a pass an operator watches the radio and the antenna, not
|
||||||
|
a column on the far side of the window — and that column is the
|
||||||
|
first thing they hide to get the map full width. */}
|
||||||
|
{tracking?.on && (
|
||||||
|
<div className="flex items-center gap-2.5 rounded-md border border-border bg-card/60 px-2 py-0.5 text-xs tabular-nums">
|
||||||
|
{/* Where the bird IS, which is not where the antenna is pointing:
|
||||||
|
these three say whether the pass is worth calling on, and the
|
||||||
|
rotator group further along says whether the mast has caught up
|
||||||
|
with them. Elevation goes dim below the horizon, so a satellite
|
||||||
|
still being tracked on its way up cannot be read as workable. */}
|
||||||
|
<span className="flex items-center gap-1.5" title={`${t('sat.tipAz')} / ${t('sat.tipEl')}`}>
|
||||||
|
<Radar className="size-3 text-muted-foreground" />
|
||||||
|
<span className={cn('font-medium', !tracking.visible && 'text-muted-foreground')}>
|
||||||
|
{Math.round(tracking.az)}° / {tracking.el.toFixed(1)}°
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{tracking.range_km > 0 && (
|
||||||
|
<span className="text-muted-foreground" title={t('sat.range')}>
|
||||||
|
{Math.round(tracking.range_km).toLocaleString()} km
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tracking.alt_km > 0 && (
|
||||||
|
<span className="text-muted-foreground" title={t('sat.altitude')}>
|
||||||
|
↑{Math.round(tracking.alt_km).toLocaleString()} km
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-1 border-l border-border pl-2.5" title={t('sat.down')}>
|
||||||
|
<ArrowDown className="size-3 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{fmtHz(tracking.down_hz)}</span>
|
||||||
|
</span>
|
||||||
|
{!!tracking.up_hz && (
|
||||||
|
<span className="flex items-center gap-1" title={t('sat.up')}>
|
||||||
|
<ArrowUp className="size-3 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{fmtHz(tracking.up_hz)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tracking.rot_on && (
|
||||||
|
<span className={cn('flex items-center gap-1 border-l border-border pl-2.5',
|
||||||
|
antennaMoving && 'text-caution')} title={t('sat.antenna')}>
|
||||||
|
{/* The needle spins while the antenna is slewing. A rotator
|
||||||
|
takes tens of seconds to cross a pass, and the difference
|
||||||
|
between "on its way" and "stuck" is the whole reason to look
|
||||||
|
at it — a number alone cannot show movement. */}
|
||||||
|
<Compass className={cn('size-3', antennaMoving ? 'animate-spin' : 'text-muted-foreground')}
|
||||||
|
style={antennaMoving ? { animationDuration: '3s' } : undefined} />
|
||||||
|
<span className="font-medium">
|
||||||
|
{Math.round(tracking.rot_az)}°
|
||||||
|
{!tracking.rot_az_only && ` / ${Math.round(tracking.rot_el)}°`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{/* Elements are maintenance, so only their AGE is here — and only when
|
{/* Elements are maintenance, so only their AGE is here — and only when
|
||||||
it has become a reason the panel might be wrong. */}
|
it has become a reason the panel might be wrong. */}
|
||||||
@@ -568,7 +837,7 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
inPass ? 'border-success/60' : 'border-border')}>
|
inPass ? 'border-success/60' : 'border-border')}>
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<span className="font-medium text-sm truncate">{bird?.name ?? '—'}</span>
|
<span className="font-medium text-sm truncate">{bird?.name ?? '—'}</span>
|
||||||
<span className="text-[11px] text-muted-foreground truncate">{tp?.label ?? ''}</span>
|
<ModeBadge mode={tp?.mode} className="self-center" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{bird?.geostationary ? (
|
{bird?.geostationary ? (
|
||||||
@@ -659,7 +928,15 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
{tracking?.rot_on && (
|
{tracking?.rot_on && (
|
||||||
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-baseline gap-2 text-[11px] tabular-nums">
|
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-baseline gap-2 text-[11px] tabular-nums">
|
||||||
<span className="text-muted-foreground uppercase tracking-wide text-[10px]">{t('sat.antenna')}</span>
|
<span className="text-muted-foreground uppercase tracking-wide text-[10px]">{t('sat.antenna')}</span>
|
||||||
<span className="font-medium">{fmtDeg(tracking.rot_az)} / {fmtDeg(tracking.rot_el)}</span>
|
{/* No elevation when none is being driven: an undriven zero
|
||||||
|
draws an antenna lying on the horizon, which is a bearing
|
||||||
|
and not the absence of one. */}
|
||||||
|
<span className="font-medium">
|
||||||
|
{tracking.rot_az_only
|
||||||
|
? fmtDeg(tracking.rot_az)
|
||||||
|
: `${fmtDeg(tracking.rot_az)} / ${fmtDeg(tracking.rot_el)}`}
|
||||||
|
</span>
|
||||||
|
{tracking.rot_az_only && <span className="text-muted-foreground">{t('sat.rotAzOnly')}</span>}
|
||||||
{!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
|
{!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -667,17 +944,52 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
|
|
||||||
{/* What to tune. */}
|
{/* What to tune. */}
|
||||||
<div className="rounded-lg border border-border bg-card p-2">
|
<div className="rounded-lg border border-border bg-card p-2">
|
||||||
|
{/* The mode leads, because it decides everything below it. */}
|
||||||
|
<div className="flex items-center gap-1.5 mb-1.5">
|
||||||
|
<ModeBadge mode={tp?.mode} />
|
||||||
|
<span className="text-[11px] text-muted-foreground truncate">{tp?.label ?? '—'}</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{tp?.inverting && (
|
||||||
|
<span className="shrink-0 text-[10px] uppercase tracking-wide text-warning">{t('sat.inverting')}</span>
|
||||||
|
)}
|
||||||
|
{tp?.linear && (
|
||||||
|
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">
|
||||||
|
{Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{bird?.geostationary && (
|
||||||
|
<span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground">{t('sat.geo')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<FreqRow label={t('sat.down')} hz={tuning?.down_hz ?? 0} nominal={tuning?.nominal_down ?? 0} />
|
<FreqRow label={t('sat.down')} hz={tuning?.down_hz ?? 0} nominal={tuning?.nominal_down ?? 0} />
|
||||||
<FreqRow label={t('sat.up')} hz={tuning?.up_hz ?? 0} nominal={tuning?.nominal_up ?? 0} />
|
<FreqRow label={t('sat.up')} hz={tuning?.up_hz ?? 0} nominal={tuning?.nominal_up ?? 0} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-muted-foreground">
|
|
||||||
{!!tp?.mode && <span>{tp.mode}</span>}
|
{/* The tone, on the FM birds, with the same weight as a frequency.
|
||||||
{!!tp?.ctcss && <span>CTCSS {tp.ctcss.toFixed(1)}</span>}
|
It IS one, as far as the outcome goes: a repeater called without
|
||||||
{tp?.inverting && <span>{t('sat.inverting')}</span>}
|
its tone does not answer, and the operator hears an empty
|
||||||
{tp?.linear && <span>{Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz</span>}
|
channel and concludes the satellite is not up. Said explicitly
|
||||||
{bird?.geostationary && <span>{t('sat.geo')}</span>}
|
when there is none, too — a blank line cannot tell "no tone"
|
||||||
|
from "OpsLog does not know". */}
|
||||||
|
{tp?.mode === 'FM' && (
|
||||||
|
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-baseline gap-2">
|
||||||
|
<span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t('sat.tone')}
|
||||||
|
</span>
|
||||||
|
{tp.ctcss ? (
|
||||||
|
<>
|
||||||
|
<span className="text-base font-semibold tabular-nums text-caution">
|
||||||
|
{tp.ctcss.toFixed(1)} Hz
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{t('sat.toneHint')}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-muted-foreground">{t('sat.toneNone')}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* What is coming. */}
|
{/* What is coming. */}
|
||||||
@@ -686,10 +998,10 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
{t('sat.nextPasses')}
|
{t('sat.nextPasses')}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0 overflow-auto">
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
{passes.length === 0 && (
|
{passes.length === 0 && idle.length === 0 && (
|
||||||
<div className="p-2 text-[11px] text-muted-foreground">{t('sat.noPasses')}</div>
|
<div className="p-2 text-[11px] text-muted-foreground">{t('sat.noPasses')}</div>
|
||||||
)}
|
)}
|
||||||
{passes.length > 0 && (
|
{(passes.length > 0 || idle.length > 0) && (
|
||||||
// A real table, so the name column takes the width the longest
|
// A real table, so the name column takes the width the longest
|
||||||
// name needs — "ZHUHAI-1 OVS-1A" was cut to eight characters in
|
// name needs — "ZHUHAI-1 OVS-1A" was cut to eight characters in
|
||||||
// a fixed one — and the rest keeps its columns lined up under
|
// a fixed one — and the rest keeps its columns lined up under
|
||||||
@@ -738,6 +1050,29 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{/* The rest of what you follow, so the table IS the list:
|
||||||
|
nothing you can select is missing from it. */}
|
||||||
|
{idle.map((b) => {
|
||||||
|
const why = b.geostationary ? t('sat.alwaysUp')
|
||||||
|
: !b.has_elements ? t('sat.noElements')
|
||||||
|
: t('sat.noPassWindow');
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={`idle-${b.name}`}
|
||||||
|
onClick={() => setSel(b.name)}
|
||||||
|
className={cn('cursor-pointer hover:bg-accent/50 border-t border-border/40',
|
||||||
|
b.name === sel && 'bg-accent/40')}
|
||||||
|
>
|
||||||
|
<td className="px-2 py-1 whitespace-nowrap">
|
||||||
|
<span className={cn('font-medium', !b.has_elements && 'text-muted-foreground')}>{b.name}</span>
|
||||||
|
<ModeDot mode={modeOf(b.name)} />
|
||||||
|
</td>
|
||||||
|
<td colSpan={4} className="px-2 py-1 text-right text-muted-foreground whitespace-nowrap">
|
||||||
|
{why}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
@@ -782,9 +1117,7 @@ function FreqRow({ label, hz, nominal }: { label: string; hz: number; nominal: n
|
|||||||
<span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span>
|
<span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span>
|
||||||
<span className="text-base font-semibold tabular-nums">{fmtHz(hz)}</span>
|
<span className="text-base font-semibold tabular-nums">{fmtHz(hz)}</span>
|
||||||
{!!shift && (
|
{!!shift && (
|
||||||
<span className="text-[10px] tabular-nums text-muted-foreground">
|
<span className="text-[10px] tabular-nums text-muted-foreground">{fmtShift(shift)}</span>
|
||||||
{shift > 0 ? '+' : '−'}{Math.abs(Math.round(shift))} Hz
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ import {
|
|||||||
GetChaseSettings, SaveChaseSettings,
|
GetChaseSettings, SaveChaseSettings,
|
||||||
GetAudioMonitorPref,
|
GetAudioMonitorPref,
|
||||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||||
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop, GetRotatorTypes,
|
||||||
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
|
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
|
||||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, CompactDatabase, CheckHamlogKey, CompareRDASources, ApplyRDAChoices,
|
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, CompactDatabase, CheckHamlogKey, CompareRDASources, ApplyRDAChoices,
|
||||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||||
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||||||
GetPSUSettings, SavePSUSettings,
|
GetPSUSettings, SavePSUSettings,
|
||||||
GetSatSettings, SaveSatSettings, TestSatelliteRotator,
|
GetSatSettings, SaveSatSettings, TestSatelliteRotator, ListSatelliteRotors,
|
||||||
GetSatelliteTLEInfo, RefreshSatelliteTLE, AddSatelliteElements, GetSatelliteBirds,
|
GetSatelliteTLEInfo, RefreshSatelliteTLE, AddSatelliteElements, GetSatelliteBirds,
|
||||||
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
|
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
|
||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||||
@@ -74,7 +74,11 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { WebPublishPanel } from '@/components/WebPublishPanel';
|
import { WebPublishPanel } from '@/components/WebPublishPanel';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
// Every text field in Preferences types into itself and hands the value up a
|
||||||
|
// moment later. This dialog is one component with two hundred pieces of state
|
||||||
|
// and panels eight hundred lines long, so a plain controlled input re-rendered
|
||||||
|
// the whole thing per character — see BufferedInput.
|
||||||
|
import { BufferedInput as Input } from '@/components/ui/buffered-input';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
@@ -95,6 +99,7 @@ import { AppearancePanel } from '@/components/AppearancePanel';
|
|||||||
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
|
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
|
||||||
import { loadClusterMacros, saveClusterMacros, type ClusterMacro } from '@/lib/clusterMacros';
|
import { loadClusterMacros, saveClusterMacros, type ClusterMacro } from '@/lib/clusterMacros';
|
||||||
import { CLUSTER_PRESETS } from '@/lib/clusterPresets';
|
import { CLUSTER_PRESETS } from '@/lib/clusterPresets';
|
||||||
|
import { MATRIX_DIGI_KEY, PHONE_MODES as MATRIX_PHONE_MODES } from '@/components/BandSlotGrid';
|
||||||
|
|
||||||
type LookupSettings = LookupSettingsForm;
|
type LookupSettings = LookupSettingsForm;
|
||||||
type StationSettings = StationSettingsForm;
|
type StationSettings = StationSettingsForm;
|
||||||
@@ -1463,6 +1468,13 @@ function SatelliteElementsBlock({ autoTle, onAutoTle }: { autoTle: boolean; onAu
|
|||||||
// Following none means following every satellite that has both elements and a
|
// Following none means following every satellite that has both elements and a
|
||||||
// frequency plan, which is the sensible thing for somebody who has not chosen
|
// frequency plan, which is the sensible thing for somebody who has not chosen
|
||||||
// yet and the reason the list does not start out empty-handed.
|
// yet and the reason the list does not start out empty-handed.
|
||||||
|
// byCallsign orders satellite names the way an operator reads them: alphabetical,
|
||||||
|
// but with the number taken as a number. Plain string order files AO-123 between
|
||||||
|
// AO-1 and AO-27, which is not where anybody looks for it.
|
||||||
|
function byCallsign(a: { name?: string }, b: { name?: string }) {
|
||||||
|
return String(a?.name ?? '').localeCompare(String(b?.name ?? ''), undefined, { numeric: true, sensitivity: 'base' });
|
||||||
|
}
|
||||||
|
|
||||||
function SatelliteFollowList({ followed, onChange }: { followed: string[]; onChange: (next: string[]) => void }) {
|
function SatelliteFollowList({ followed, onChange }: { followed: string[]; onChange: (next: string[]) => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [all, setAll] = useState<any[]>([]);
|
const [all, setAll] = useState<any[]>([]);
|
||||||
@@ -1480,8 +1492,13 @@ function SatelliteFollowList({ followed, onChange }: { followed: string[]; onCha
|
|||||||
const needle = q.trim().toLowerCase();
|
const needle = q.trim().toLowerCase();
|
||||||
const available = all.filter((b) => !followedSet.has(String(b.name).toUpperCase())
|
const available = all.filter((b) => !followedSet.has(String(b.name).toUpperCase())
|
||||||
&& (!withPlanOnly || (b.transponders?.length ?? 0) > 0)
|
&& (!withPlanOnly || (b.transponders?.length ?? 0) > 0)
|
||||||
&& (needle === '' || String(b.name).toLowerCase().includes(needle)));
|
&& (needle === '' || String(b.name).toLowerCase().includes(needle))).sort(byCallsign);
|
||||||
const chosen = followed.map((n) => byName.get(n) ?? { name: n, has_elements: false, transponders: [] });
|
// Sorted, both columns: the left one came in the order the frequency file
|
||||||
|
// happens to be written and the right one in the order the operator clicked,
|
||||||
|
// so finding AO-91 among sixteen followed birds meant reading all sixteen.
|
||||||
|
const chosen = followed
|
||||||
|
.map((n) => byName.get(n) ?? { name: n, has_elements: false, transponders: [] })
|
||||||
|
.sort(byCallsign);
|
||||||
|
|
||||||
const label = (b: any) => {
|
const label = (b: any) => {
|
||||||
const bits: string[] = [];
|
const bits: string[] = [];
|
||||||
@@ -1854,7 +1871,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
// IC-7610 the moment they chose Other.
|
// IC-7610 the moment they chose Other.
|
||||||
const [icomCustom, setIcomCustom] = useState(false);
|
const [icomCustom, setIcomCustom] = useState(false);
|
||||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false,
|
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', omnirig_cw_lower: false, flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false,
|
||||||
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, yaesu_rtty_usb: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_link: 'usb', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
|
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, yaesu_rtty_usb: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_link: 'usb', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
|
||||||
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
||||||
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
|
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
|
||||||
@@ -1866,13 +1883,21 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
// else writes the backend (loading a profile, an older settings file).
|
// else writes the backend (loading a profile, an older settings file).
|
||||||
const catBrand = brandOfBackend(catCfg.backend, (catCfg as any).kenwood_link).brand;
|
const catBrand = brandOfBackend(catCfg.backend, (catCfg as any).kenwood_link).brand;
|
||||||
const catLink = brandOfBackend(catCfg.backend, (catCfg as any).kenwood_link).link;
|
const catLink = brandOfBackend(catCfg.backend, (catCfg as any).kenwood_link).link;
|
||||||
|
// Choosing a radio TURNS CAT ON.
|
||||||
|
//
|
||||||
|
// The master switch sits above this dropdown, and leaving it off while the
|
||||||
|
// operator picks their brand, types the IP and runs the detector — which found
|
||||||
|
// their radio and printed its name — is a trap: every one of those gestures
|
||||||
|
// means "connect to this". A Flex 6700 owner did exactly that, saved six
|
||||||
|
// times, and got no link and no error. They can still untick it; nothing here
|
||||||
|
// ever turns CAT off on its own.
|
||||||
const applyCatBrand = (id: string) => {
|
const applyCatBrand = (id: string) => {
|
||||||
const b = CAT_BRANDS.find((x) => x.id === id);
|
const b = CAT_BRANDS.find((x) => x.id === id);
|
||||||
if (!b) return;
|
if (!b) return;
|
||||||
// Keep the connection when the new brand offers it, otherwise take its
|
// Keep the connection when the new brand offers it, otherwise take its
|
||||||
// first — picking Flex from Kenwood-over-USB has to land on something.
|
// first — picking Flex from Kenwood-over-USB has to land on something.
|
||||||
const link = b.links.includes(catLink) ? catLink : b.links[0];
|
const link = b.links.includes(catLink) ? catLink : b.links[0];
|
||||||
setCatCfg((s) => ({ ...s, backend: b.backend(link), kenwood_link: link } as any));
|
setCatCfg((s) => ({ ...s, backend: b.backend(link), kenwood_link: link, enabled: true } as any));
|
||||||
};
|
};
|
||||||
const applyCatLink = (link: string) => {
|
const applyCatLink = (link: string) => {
|
||||||
const b = CAT_BRANDS.find((x) => x.id === catBrand);
|
const b = CAT_BRANDS.find((x) => x.id === catBrand);
|
||||||
@@ -1883,6 +1908,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
// While true, the next key press is captured as the PTT hotkey.
|
// While true, the next key press is captured as the PTT hotkey.
|
||||||
const [capturingPtt, setCapturingPtt] = useState(false);
|
const [capturingPtt, setCapturingPtt] = useState(false);
|
||||||
const [rotors, setRotors] = useState<RotatorDevice[]>([]);
|
const [rotors, setRotors] = useState<RotatorDevice[]>([]);
|
||||||
|
// What each rotator backend is and what it can do, straight from Go. The
|
||||||
|
// panel used to hold its own copy of the list — labels, default ports, which
|
||||||
|
// ones offer a COM port — and a second copy of that knowledge is a copy that
|
||||||
|
// drifts. It also carries the answer the satellite page needs: which
|
||||||
|
// interfaces drive an elevation axis.
|
||||||
|
const [rotTypes, setRotTypes] = useState<any[]>([]);
|
||||||
|
// The rotors the satellite page may choose between. Read from the same list,
|
||||||
|
// so a mast is described once.
|
||||||
|
const [satRotors, setSatRotors] = useState<any[]>([]);
|
||||||
const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]);
|
const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]);
|
||||||
// Whether the presets have actually been READ back yet.
|
// Whether the presets have actually been READ back yet.
|
||||||
//
|
//
|
||||||
@@ -1909,7 +1943,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
// Satellites: the observer and the az/el rotator. The rest of the satellite
|
// Satellites: the observer and the az/el rotator. The rest of the satellite
|
||||||
// settings (favourites, the pass window) are set in the tab itself, where
|
// settings (favourites, the pass window) are set in the tab itself, where
|
||||||
// they are used.
|
// they are used.
|
||||||
const [satCfg, setSatCfg] = useState<any>({ min_el: 10, window_h: 24, auto_tle: true, grid: '', alt_m: 0, rot_on: false, rot_type: 'easycomm', rot_pst_port: 12000, rot_transport: 'serial', rot_host: '', rot_port: 4533, rot_com: '', rot_baud: 9600, rot_max_az: 360, rot_min_el: 0, rot_step: 5, rot_park: false });
|
const [satCfg, setSatCfg] = useState<any>({ min_el: 10, window_h: 24, auto_tle: true, grid: '', alt_m: 0, rot_on: false, rot_id: '', rot_az_only: false, rot_min_el: 0, rot_step: 5, rot_park: false });
|
||||||
const [satTest, setSatTest] = useState('');
|
const [satTest, setSatTest] = useState('');
|
||||||
|
|
||||||
// Amplifier list — operators can run SEVERAL amps (even two SPEs combined),
|
// Amplifier list — operators can run SEVERAL amps (even two SPEs combined),
|
||||||
@@ -2007,6 +2041,17 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
|
||||||
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
||||||
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
|
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
|
||||||
|
// The digital row the band matrix opens on: '' = DIG, the group of them all.
|
||||||
|
const [matrixDigi, setMatrixDigi] = useState(() => localStorage.getItem(MATRIX_DIGI_KEY) || '');
|
||||||
|
// The operator's own digital modes, which is what the matrix rotates through
|
||||||
|
// — the same rule it uses: everything in their mode list that is neither CW
|
||||||
|
// nor a phone mode.
|
||||||
|
const digitalModeNames = useMemo(
|
||||||
|
() => (lists.modes ?? [])
|
||||||
|
.map((m: any) => String(m?.name ?? '').toUpperCase().trim())
|
||||||
|
.filter((m) => m && m !== 'CW' && !MATRIX_PHONE_MODES.has(m)),
|
||||||
|
[lists.modes],
|
||||||
|
);
|
||||||
const [milesUnit, setMilesUnit] = useState(() => localStorage.getItem('opslog.distanceMiles') === '1');
|
const [milesUnit, setMilesUnit] = useState(() => localStorage.getItem('opslog.distanceMiles') === '1');
|
||||||
const [region, setRegion] = useState<IaruRegion>(() => iaruRegion());
|
const [region, setRegion] = useState<IaruRegion>(() => iaruRegion());
|
||||||
const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
|
const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
|
||||||
@@ -2493,6 +2538,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
await reloadClusterServers();
|
await reloadClusterServers();
|
||||||
setCatCfg(c);
|
setCatCfg(c);
|
||||||
setRotors((r ?? []) as any);
|
setRotors((r ?? []) as any);
|
||||||
|
try { setRotTypes(((await GetRotatorTypes()) ?? []) as any[]); } catch {}
|
||||||
|
try { setSatRotors(((await ListSatelliteRotors()) ?? []) as any[]); } catch {}
|
||||||
// Loaded HERE, in the loader that runs on mount — not only in the
|
// Loaded HERE, in the loader that runs on mount — not only in the
|
||||||
// event-driven one below. Missing from this one, the state stayed empty
|
// event-driven one below. Missing from this one, the state stayed empty
|
||||||
// on a normal open and Save then wrote an empty list over the operator's
|
// on a normal open and Save then wrote an empty list over the operator's
|
||||||
@@ -2555,6 +2602,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
try { setLookup(await GetLookupSettings() as any); } catch {}
|
try { setLookup(await GetLookupSettings() as any); } catch {}
|
||||||
try { setCatCfg(await GetCATSettings() as any); } catch {}
|
try { setCatCfg(await GetCATSettings() as any); } catch {}
|
||||||
try { setRotors(((await GetRotators()) ?? []) as any); } catch {}
|
try { setRotors(((await GetRotators()) ?? []) as any); } catch {}
|
||||||
|
try { setRotTypes(((await GetRotatorTypes()) ?? []) as any[]); } catch {}
|
||||||
|
try { setSatRotors(((await ListSatelliteRotors()) ?? []) as any[]); } catch {}
|
||||||
try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
|
try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
|
||||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
@@ -2756,6 +2805,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
await SaveLookupSettings(lookup as any);
|
await SaveLookupSettings(lookup as any);
|
||||||
await SaveCATSettings(catCfg as any);
|
await SaveCATSettings(catCfg as any);
|
||||||
await SaveRotators(rotors as any);
|
await SaveRotators(rotors as any);
|
||||||
|
// The satellite page picks from this list. Re-read it after a save so a
|
||||||
|
// rotor added on the Rotator panel can be chosen straight away, without
|
||||||
|
// closing the window first.
|
||||||
|
try { setSatRotors(((await ListSatelliteRotors()) ?? []) as any[]); } catch {}
|
||||||
// Only once they have been read back — see rotorPresetsLoaded.
|
// Only once they have been read back — see rotorPresetsLoaded.
|
||||||
if (rotorPresetsLoaded) await SaveRotorPresets(rotorPresets as any);
|
if (rotorPresetsLoaded) await SaveRotorPresets(rotorPresets as any);
|
||||||
await SaveUltrabeamSettings(ultrabeam as any);
|
await SaveUltrabeamSettings(ultrabeam as any);
|
||||||
@@ -2812,7 +2865,6 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const breadcrumb = useMemo(() => { const k = SECTION_KEY[selected]; return k ? t(k) : (SECTION_LABELS[selected] ?? selected); }, [selected, t]);
|
|
||||||
|
|
||||||
// === Section content renderers ===
|
// === Section content renderers ===
|
||||||
|
|
||||||
@@ -3563,6 +3615,14 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
|
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
|
||||||
{t('cat.enable')}
|
{t('cat.enable')}
|
||||||
</label>
|
</label>
|
||||||
|
{/* Said where it is happening. Everything below this line can be filled
|
||||||
|
in perfectly — the right radio, the right address, the detector
|
||||||
|
finding it by name — and none of it connects while this is off. */}
|
||||||
|
{!catCfg.enabled && (
|
||||||
|
<div className="rounded-md border border-warning-border bg-warning-muted px-3 py-2 text-xs text-warning-muted-foreground">
|
||||||
|
{t('cat.disabledNotice')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* BRAND, then CONNECTION.
|
{/* BRAND, then CONNECTION.
|
||||||
The backend list mixed the two: "Icom (USB)" and "Icom (network)"
|
The backend list mixed the two: "Icom (USB)" and "Icom (network)"
|
||||||
@@ -3625,6 +3685,16 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
<p className="text-[10px] text-muted-foreground">{t('cat.omnirigVfoHint')}</p>
|
<p className="text-[10px] text-muted-foreground">{t('cat.omnirigVfoHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{catCfg.backend === 'omnirig' && (
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox className="mt-0.5" checked={!!catCfg.omnirig_cw_lower}
|
||||||
|
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, omnirig_cw_lower: !!c }))} />
|
||||||
|
<span>{t('cat.omnirigCwLower')}</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{t('cat.omnirigCwLowerHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{catCfg.backend === 'flex' && (
|
{catCfg.backend === 'flex' && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -3638,7 +3708,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
onChange={(n) => setCatCfg((s) => ({ ...s, flex_port: n }))} fallback={4992} />
|
onChange={(n) => setCatCfg((s) => ({ ...s, flex_port: n }))} fallback={4992} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} />
|
<FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port, enabled: true }))} />
|
||||||
</div>
|
</div>
|
||||||
{/* What OpsLog DOES with a Flex — panadapter spots, decode spots,
|
{/* What OpsLog DOES with a Flex — panadapter spots, decode spots,
|
||||||
the DAX switch for voice messages — moved to Settings →
|
the DAX switch for voice messages — moved to Settings →
|
||||||
@@ -4632,113 +4702,55 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
|
|
||||||
{!!satCfg.rot_on && (
|
{!!satCfg.rot_on && (
|
||||||
<>
|
<>
|
||||||
{/* Who drives the mast. Not a detail: a station already running
|
{/* WHICH rotor, not how to reach it. Every interface is
|
||||||
PstRotator must NOT have OpsLog on the same cable as well. */}
|
described once, in Settings ▸ Rotator; this page only picks
|
||||||
<div className="grid grid-cols-4 gap-3">
|
one of them. Describing one mast in two places is how a
|
||||||
{/* Two columns wide: "OpsLog (EasyComm II)" does not fit in a
|
station ends up working on HF and not on a pass. */}
|
||||||
third of the row, and a truncated choice is a choice an
|
<div className="space-y-1 max-w-md">
|
||||||
operator cannot read. */}
|
<Label>{t('satset.rotPick')}</Label>
|
||||||
<div className="space-y-1 col-span-2">
|
|
||||||
<Label>{t('satset.rotType')}</Label>
|
|
||||||
<Select value={satCfg.rot_type || 'easycomm'} onValueChange={(v) => set('rot_type', v)}>
|
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="easycomm">{t('satset.rotEasycomm')}</SelectItem>
|
|
||||||
<SelectItem value="pstrotator">{t('satset.rotPst')}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
{satCfg.rot_type === 'pstrotator' && (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>{t('satset.rotHost')}</Label>
|
|
||||||
<Input className="font-mono" placeholder="127.0.0.1"
|
|
||||||
value={satCfg.rot_host ?? ''} onChange={(e) => set('rot_host', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>{t('satset.rotPstPort')}</Label>
|
|
||||||
<Input className="font-mono" value={String(satCfg.rot_pst_port ?? 12000)}
|
|
||||||
onChange={(e) => set('rot_pst_port', num(e.target.value))} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{satCfg.rot_type === 'pstrotator' && (
|
|
||||||
<p className="text-xs text-muted-foreground">{t('satset.rotPstHint')}</p>
|
|
||||||
)}
|
|
||||||
<div className={cn('grid grid-cols-3 gap-3', satCfg.rot_type === 'pstrotator' && 'hidden')}>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>{t('satset.rotLink')}</Label>
|
|
||||||
<Select value={satCfg.rot_transport || 'serial'} onValueChange={(v) => set('rot_transport', v)}>
|
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="serial">{t('satset.rotSerial')}</SelectItem>
|
|
||||||
<SelectItem value="tcp">{t('satset.rotTcp')}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
{satCfg.rot_transport === 'tcp' ? (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>{t('satset.rotHost')}</Label>
|
|
||||||
<Input className="font-mono" placeholder="127.0.0.1"
|
|
||||||
value={satCfg.rot_host ?? ''} onChange={(e) => set('rot_host', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>{t('satset.rotPort')}</Label>
|
|
||||||
<Input className="font-mono" value={String(satCfg.rot_port ?? 4533)}
|
|
||||||
onChange={(e) => set('rot_port', num(e.target.value))} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>{t('satset.rotCom')}</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select value={satCfg.rot_com || '_'} onValueChange={(v) => set('rot_com', v === '_' ? '' : v)}>
|
<Select value={satCfg.rot_id || '_'} onValueChange={(v) => set('rot_id', v === '_' ? '' : v)}>
|
||||||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder={t('satset.rotPickNone')} /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{ports.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
|
{satRotors.length === 0 && <SelectItem value="_" disabled>{t('satset.rotNoneConfigured')}</SelectItem>}
|
||||||
{ports.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
{/* The azimuth-only rotors are always LISTED. Without
|
||||||
</SelectContent>
|
the switch below they are greyed and say why — an
|
||||||
</Select>
|
operator who owns one rotator and does not see it
|
||||||
<Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setPorts((p ?? []) as string[])).catch(() => {})}>
|
concludes OpsLog cannot find it, where "azimuth
|
||||||
↻
|
only" beside it teaches the real thing. With the
|
||||||
</Button>
|
switch on, every rotor is fair game. */}
|
||||||
</div>
|
{satRotors.map((r: any) => (
|
||||||
</div>
|
<SelectItem key={r.key} value={r.key} disabled={!r.has_el && !satCfg.rot_az_only}>
|
||||||
<div className="space-y-1">
|
{(r.name || r.type) + (r.has_el ? '' : ` — ${t('satset.rotAzOnlyTag')}`)}
|
||||||
<Label>{t('satset.rotBaud')}</Label>
|
</SelectItem>
|
||||||
<Select value={String(satCfg.rot_baud || 9600)} onValueChange={(v) => set('rot_baud', parseInt(v, 10) || 9600)}>
|
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{[1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200].map((b) => (
|
|
||||||
<SelectItem key={b} value={String(b)}>{b}</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<Button size="sm" variant="outline" className="h-9"
|
||||||
|
onClick={() => ListSatelliteRotors().then((r) => setSatRotors((r ?? []) as any[])).catch(() => {})}>
|
||||||
|
↻
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
<p className="text-xs text-muted-foreground">{t('satset.rotPickHint')}</p>
|
||||||
|
{!satCfg.rot_az_only && satRotors.length > 0 && !satRotors.some((r: any) => r.has_el) && (
|
||||||
|
<p className="text-xs text-[var(--warning)]">{t('satset.rotNoElAtAll')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Azimuth only. Not a fallback — it is how most stations that
|
||||||
|
work satellites are actually built, and refusing to track
|
||||||
|
without an elevation motor turned the feature off for every
|
||||||
|
operator with a tower and no az/el mast. */}
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox className="mt-0.5" checked={!!satCfg.rot_az_only}
|
||||||
|
onCheckedChange={(c) => set('rot_az_only', !!c)} />
|
||||||
|
<span>
|
||||||
|
{t('satset.rotAzOnly')}
|
||||||
|
<span className="block text-xs text-muted-foreground">{t('satset.rotAzOnlyHint')}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{/* The rotator's range is ours to know only when we drive the
|
|
||||||
controller. PstRotator knows which machine is on the other
|
|
||||||
end and does its own overlap; two programs each deciding to
|
|
||||||
go the long way round is how an antenna unwinds mid-pass. */}
|
|
||||||
{satCfg.rot_type !== 'pstrotator' && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>{t('satset.rotRange')}</Label>
|
|
||||||
<Select value={String(satCfg.rot_max_az ?? 360)} onValueChange={(v) => set('rot_max_az', parseInt(v, 10))}>
|
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="360">360°</SelectItem>
|
|
||||||
<SelectItem value="450">450°</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('satset.rotMinEl')}</Label>
|
<Label>{t('satset.rotMinEl')}</Label>
|
||||||
<Input className="font-mono" value={String(satCfg.rot_min_el ?? 0)}
|
<Input className="font-mono" value={String(satCfg.rot_min_el ?? 0)}
|
||||||
@@ -5097,7 +5109,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
const addRotor = () => setRotors((l) => [...l, {
|
const addRotor = () => setRotors((l) => [...l, {
|
||||||
id: '', name: '', type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false,
|
id: '', name: '', type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false,
|
||||||
rotator_num: 1, dual: false, motorized: true, name2: '', motorized2: false,
|
rotator_num: 1, dual: false, motorized: true, name2: '', motorized2: false,
|
||||||
transport: 'tcp', com_port: '', baud: 9600,
|
transport: 'tcp', com_port: '', baud: 9600, max_az: 360,
|
||||||
} as any]);
|
} as any]);
|
||||||
const removeRotor = (i: number) => setRotors((l) => l.filter((_, j) => j !== i));
|
const removeRotor = (i: number) => setRotors((l) => l.filter((_, j) => j !== i));
|
||||||
const anyPst = rotors.some((d) => ((d as any).type ?? 'pst') === 'pst');
|
const anyPst = rotors.some((d) => ((d as any).type ?? 'pst') === 'pst');
|
||||||
@@ -5112,18 +5124,54 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
const dev = d as any;
|
const dev = d as any;
|
||||||
const isRG = dev.type === 'rotgenius';
|
const isRG = dev.type === 'rotgenius';
|
||||||
const isARCO = dev.type === 'arco';
|
const isARCO = dev.type === 'arco';
|
||||||
|
const isERC = dev.type === 'erc';
|
||||||
const isDCU1 = dev.type === 'dcu1';
|
const isDCU1 = dev.type === 'dcu1';
|
||||||
// A SPID has a COM port and nothing else — no network transport to
|
// A SPID has a COM port and nothing else — no network transport to
|
||||||
// offer, which is the whole point of driving it without PstRotator.
|
// offer, which is the whole point of driving it without PstRotator.
|
||||||
const isSPID = dev.type === 'spid';
|
const isSPID = dev.type === 'spid';
|
||||||
const isSerialCap = isARCO || isDCU1 || isSPID; // COM-port or serial-over-IP controllers
|
const isEasycomm = dev.type === 'easycomm';
|
||||||
|
const isSerialCap = isARCO || isERC || isDCU1 || isSPID || isEasycomm; // COM-port or serial-over-IP controllers
|
||||||
const transport = dev.transport ?? 'tcp';
|
const transport = dev.transport ?? 'tcp';
|
||||||
|
// What this backend can do, from Go. The panel asks rather than
|
||||||
|
// deciding: the same table answers the satellite page's question
|
||||||
|
// about which rotors have an elevation axis, and two tables would
|
||||||
|
// eventually disagree about one rotor.
|
||||||
|
const info = rotTypes.find((k) => k.id === (dev.type ?? 'pst'));
|
||||||
|
// PstRotator is the only backend where the elevation belongs to the
|
||||||
|
// station rather than to the protocol — it forwards EL happily to a
|
||||||
|
// mast that has no elevation motor, so only the operator knows.
|
||||||
|
const elOptional = !!info?.elevation_optional;
|
||||||
|
const hasEl = elOptional ? !!dev.has_elevation
|
||||||
|
: (isSPID ? (dev.spid_model || 'rot2prog') !== 'rot1prog' : !!info?.elevation);
|
||||||
|
// The mast's range is ours to know only when OpsLog drives the
|
||||||
|
// controller. PstRotator knows which machine is on the other end and
|
||||||
|
// does its own overlap; two programs each deciding to go the long
|
||||||
|
// way round is how an antenna unwinds mid-pass.
|
||||||
|
//
|
||||||
|
// NOT offered for a Rotator Genius. Its manual is plain — "you will
|
||||||
|
// not be able to give it a target beyond the limits" — and its
|
||||||
|
// Limits fields say where the mechanical stop sits within ONE turn
|
||||||
|
// ("5 to 4" is a dead zone at four and a half degrees), not how far
|
||||||
|
// the mast travels. Offering 450° there would be offering a setting
|
||||||
|
// that can only ever be refused by the box. Whether the overlap is
|
||||||
|
// used is decided from what the Genius itself reports, in
|
||||||
|
// rotgeniusGoTo, and needs no setting at all.
|
||||||
|
const ownsOverlap = isERC || isEasycomm;
|
||||||
return (
|
return (
|
||||||
<div key={dev.id || i} className="rounded-xl border border-border bg-card/40 p-3 space-y-3">
|
<div key={dev.id || i} className="rounded-xl border border-border bg-card/40 p-3 space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Compass className="size-4 text-primary shrink-0" />
|
<Compass className="size-4 text-primary shrink-0" />
|
||||||
<Input className="h-8 flex-1" value={dev.name ?? ''} placeholder={`Rotor ${i + 1}`}
|
<Input className="h-8 flex-1" value={dev.name ?? ''} placeholder={`Rotor ${i + 1}`}
|
||||||
onChange={(e) => patch(i, { name: e.target.value })} />
|
onChange={(e) => patch(i, { name: e.target.value })} />
|
||||||
|
{/* Which axes this interface drives, beside the interface
|
||||||
|
itself. Without it the only way to find out is to pick a
|
||||||
|
rotor on the satellite page and be told no. */}
|
||||||
|
<span className={cn('shrink-0 rounded-md px-2 py-0.5 text-[11px] font-medium border',
|
||||||
|
hasEl ? 'border-[var(--success)]/40 text-[var(--success)] bg-[var(--success)]/10'
|
||||||
|
: 'border-border text-muted-foreground bg-muted/40')}
|
||||||
|
title={hasEl ? t('rot.capAzElHint') : t('rot.capAzHint')}>
|
||||||
|
{hasEl ? t('rot.capAzEl') : t('rot.capAz')}
|
||||||
|
</span>
|
||||||
<Button size="icon" variant="ghost" className="size-8 text-muted-foreground hover:text-destructive"
|
<Button size="icon" variant="ghost" className="size-8 text-muted-foreground hover:text-destructive"
|
||||||
onClick={() => removeRotor(i)} title={t('rot.remove')}>
|
onClick={() => removeRotor(i)} title={t('rot.remove')}>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
@@ -5132,17 +5180,28 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('rot.type')}</Label>
|
<Label>{t('rot.type')}</Label>
|
||||||
{/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
|
{/* The list, the labels, each backend's default port and its
|
||||||
(placeholder — must match the ARCO's LAN menu), PstRotator 12000. */}
|
default baud all come from Go — see rotatorTypes. A
|
||||||
|
SPID's 600 baud and an ERC-M's 19200 are not typos, and a
|
||||||
|
wrong rate reads exactly like a dead controller. */}
|
||||||
<Select value={dev.type ?? 'pst'}
|
<Select value={dev.type ?? 'pst'}
|
||||||
onValueChange={(v) => patch(i, { type: v as any, port: v === 'rotgenius' ? 9006 : (v === 'arco' || v === 'dcu1') ? 4001 : 12000, ...(v === 'dcu1' || v === 'spid' ? { transport: 'serial' } : {}), ...(v === 'spid' ? { baud: 600, spid_model: 'rot2prog' } : {}) })}>
|
onValueChange={(v) => {
|
||||||
|
const k = rotTypes.find((x) => x.id === v);
|
||||||
|
patch(i, {
|
||||||
|
type: v as any,
|
||||||
|
port: k?.default_port || 12000,
|
||||||
|
baud: k?.default_baud || 9600,
|
||||||
|
// A backend with no network transport must not be left
|
||||||
|
// pointing at a TCP host it cannot use.
|
||||||
|
...(k && !k.network ? { transport: 'serial' } : {}),
|
||||||
|
...(v === 'spid' ? { spid_model: 'rot2prog' } : {}),
|
||||||
|
} as any);
|
||||||
|
}}>
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
{rotTypes.map((k) => (
|
||||||
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
|
<SelectItem key={k.id} value={k.id}>{k.label}</SelectItem>
|
||||||
<SelectItem value="arco">GS-232A controller (microHAM ARCO, ERC…)</SelectItem>
|
))}
|
||||||
<SelectItem value="dcu1">Hy-Gain DCU-1 (RotorCard DXA, Rotor-EZ, Green Heron)</SelectItem>
|
|
||||||
<SelectItem value="spid">SPID / AlfaSpid (RAS, BIG-RAS, MD-01, MD-02)</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -5175,8 +5234,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* ARCO and DCU-1 controllers reach over the LAN (TCP) or a serial COM. */}
|
{/* Offered only when the backend really has both. A SPID has a
|
||||||
{isSerialCap && !isSPID && (
|
COM port and nothing else, which is the whole point of
|
||||||
|
driving it without PstRotator in front. */}
|
||||||
|
{!!info?.serial && !!info?.network && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Connection</Label>
|
<Label>Connection</Label>
|
||||||
<Select value={transport} onValueChange={(v) => patch(i, { transport: v as any })}>
|
<Select value={transport} onValueChange={(v) => patch(i, { transport: v as any })}>
|
||||||
@@ -5213,10 +5274,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
{/* A SPID runs at 600 or 1200 baud — not a typo, a pulse
|
{/* A SPID runs at 600 or 1200 baud — not a typo, a pulse
|
||||||
controller has nothing to say quickly. Offering only the
|
controller has nothing to say quickly. Offering only the
|
||||||
usual rates would have left it permanently mute. */}
|
usual rates would have left it permanently mute. */}
|
||||||
<Select value={String(dev.baud || (isSPID ? 600 : 9600))} onValueChange={(v) => patch(i, { baud: Number(v) })}>
|
<Select value={String(dev.baud || info?.default_baud || 9600)} onValueChange={(v) => patch(i, { baud: Number(v) })}>
|
||||||
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(isSPID ? [600, 1200, 2400, 4800, 9600] : [4800, 9600, 19200, 38400, 57600]).map((b) => (
|
{(isSPID ? [600, 1200, 2400, 4800, 9600] : [4800, 9600, 19200, 38400, 57600, 115200]).map((b) => (
|
||||||
<SelectItem key={b} value={String(b)}>{b} baud</SelectItem>
|
<SelectItem key={b} value={String(b)}>{b} baud</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -5233,20 +5294,35 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{isRG || isSerialCap ? 'TCP port' : 'UDP port'}</Label>
|
<Label>{isRG || isSerialCap ? 'TCP port' : 'UDP port'}</Label>
|
||||||
<PortInput value={dev.port} onChange={(n) => patch(i, { port: n })}
|
<PortInput value={dev.port} onChange={(n) => patch(i, { port: n })}
|
||||||
fallback={isRG ? 9006 : isSerialCap ? 4001 : 12000} className="font-mono" />
|
fallback={info?.default_port || 12000} className="font-mono" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isRG && !isSerialCap && (
|
{elOptional && (
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={!!dev.has_elevation} onCheckedChange={(c) => patch(i, { has_elevation: !!c })} />
|
<Checkbox checked={!!dev.has_elevation} onCheckedChange={(c) => patch(i, { has_elevation: !!c })} />
|
||||||
This rotator supports elevation (VHF / satellite)
|
{t('rot.hasElevation')}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
{/* 360 or 450, and only for the backends OpsLog drives itself. */}
|
||||||
|
{ownsOverlap && (
|
||||||
|
<div className="space-y-1 max-w-[10rem]">
|
||||||
|
<Label>{t('rot.range')}</Label>
|
||||||
|
<Select value={String(dev.max_az === 450 ? 450 : 360)} onValueChange={(v) => patch(i, { max_az: parseInt(v, 10) } as any)}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="360">360°</SelectItem>
|
||||||
|
<SelectItem value="450">450°</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
||||||
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
|
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
|
||||||
{isDCU1 && <p className="text-xs text-muted-foreground">{t('rot.dcu1Hint')}</p>}
|
{isDCU1 && <p className="text-xs text-muted-foreground">{t('rot.dcu1Hint')}</p>}
|
||||||
{isSPID && <p className="text-xs text-muted-foreground">{t('rot.spidHint')}</p>}
|
{isSPID && <p className="text-xs text-muted-foreground">{t('rot.spidHint')}</p>}
|
||||||
|
{isERC && <p className="text-xs text-muted-foreground">{t('rot.ercHint')}</p>}
|
||||||
|
{isEasycomm && <p className="text-xs text-muted-foreground">{t('rot.easycommHint')}</p>}
|
||||||
{/* Which antenna this rotor carries — only relevant with >1 rotor. */}
|
{/* Which antenna this rotor carries — only relevant with >1 rotor. */}
|
||||||
{multi && (
|
{multi && (
|
||||||
<div className="space-y-1 max-w-xs">
|
<div className="space-y-1 max-w-xs">
|
||||||
@@ -8106,6 +8182,24 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
<Checkbox checked={groupDigital} onCheckedChange={(c) => { const v = !!c; setGroupDigital(v); writeUiPref('opslog.groupDigitalSlots', v ? '1' : '0'); }} />
|
<Checkbox checked={groupDigital} onCheckedChange={(c) => { const v = !!c; setGroupDigital(v); writeUiPref('opslog.groupDigitalSlots', v ? '1' : '0'); }} />
|
||||||
<span title={t('gen.groupDigitalHint')}>{t('gen.groupDigital')}</span>
|
<span title={t('gen.groupDigitalHint')}>{t('gen.groupDigital')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
{/* Which digital row the band matrix opens on. An operator who works
|
||||||
|
only FT8 was shown DIG every time and had to click through to the
|
||||||
|
mode they actually use, on every callsign. The row still rotates
|
||||||
|
— this only says where it starts. */}
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span title={t('gen.matrixDigiHint')}>{t('gen.matrixDigi')}</span>
|
||||||
|
<Select value={matrixDigi || '_'} onValueChange={(v) => {
|
||||||
|
const next = v === '_' ? '' : v;
|
||||||
|
setMatrixDigi(next);
|
||||||
|
writeUiPref(MATRIX_DIGI_KEY, next);
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_">DIGI</SelectItem>
|
||||||
|
{digitalModeNames.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
{/* Distances are computed in km everywhere and converted at display
|
{/* Distances are computed in km everywhere and converted at display
|
||||||
time — see lib/units. Changing this repaints the columns that
|
time — see lib/units. Changing this repaints the columns that
|
||||||
@@ -8858,7 +8952,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
<DialogContent className="max-w-[1180px] w-full max-h-[90vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
{/* No backdrop blur. This dialog is open for minutes with the operator
|
||||||
|
typing in it, and the application behind it never stops repainting —
|
||||||
|
a full-window backdrop filter is then recomputed under every one of
|
||||||
|
those repaints, which is what the delay between key and letter was. */}
|
||||||
|
<DialogContent overlayBlur={false} className="max-w-[1180px] w-full max-h-[90vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('settings.title')}</DialogTitle>
|
<DialogTitle>{t('settings.title')}</DialogTitle>
|
||||||
<DialogDescription className="sr-only">Configure OpsLog modules — station, lookup, hardware…</DialogDescription>
|
<DialogDescription className="sr-only">Configure OpsLog modules — station, lookup, hardware…</DialogDescription>
|
||||||
@@ -8874,10 +8972,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Right content pane */}
|
{/* Right content pane */}
|
||||||
|
{/* No breadcrumb line. It said the same word as the heading right
|
||||||
|
underneath it — "GENERAL" over "General" — and the sidebar
|
||||||
|
beside it already shows which section is open, highlighted. Two
|
||||||
|
lines and a highlight for one fact. */}
|
||||||
<div className="overflow-y-auto p-6">
|
<div className="overflow-y-auto p-6">
|
||||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-3 font-semibold">
|
|
||||||
{breadcrumb}
|
|
||||||
</div>
|
|
||||||
<PanelHost key={selected} render={PANELS[selected]} />
|
<PanelHost key={selected} render={PANELS[selected]} />
|
||||||
|
|
||||||
{err && (
|
{err && (
|
||||||
@@ -8971,7 +9070,10 @@ function ClusterServerEditor({ value, onCancel, onSave }: ClusterEditorProps) {
|
|||||||
(p) => p.host.toLowerCase() === (s.host ?? '').trim().toLowerCase() && p.port === s.port);
|
(p) => p.host.toLowerCase() === (s.host ?? '').trim().toLowerCase() && p.port === s.port);
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(o) => { if (!o) onCancel(); }}>
|
<Dialog open onOpenChange={(o) => { if (!o) onCancel(); }}>
|
||||||
<DialogContent className="max-w-[640px] px-6">
|
{/* Opened from Preferences, so its overlay would be the SECOND
|
||||||
|
full-window backdrop filter stacked over a moving page — which is
|
||||||
|
where the typing delay was first noticed. */}
|
||||||
|
<DialogContent overlayBlur={false} className="max-w-[640px] px-6">
|
||||||
<DialogHeader className="px-2">
|
<DialogHeader className="px-2">
|
||||||
<DialogTitle>{s.id ? `Edit cluster · ${s.name || 'unnamed'}` : 'New cluster'}</DialogTitle>
|
<DialogTitle>{s.id ? `Edit cluster · ${s.name || 'unnamed'}` : 'New cluster'}</DialogTitle>
|
||||||
<DialogDescription className="text-xs">
|
<DialogDescription className="text-xs">
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -25,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 };
|
||||||
@@ -82,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[];
|
||||||
@@ -317,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;
|
||||||
@@ -419,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} /> });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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(
|
||||||
|
|||||||
+74
-20
File diff suppressed because one or more lines are too long
@@ -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;
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.ftMapView', 'opslog.gridMapView', 'opslog.satMapView',
|
'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)
|
||||||
// One imagery choice per map — world, grid squares, FT map, satellites.
|
// One imagery choice per map — world, grid squares, FT map, satellites.
|
||||||
'opslog.mapBasemap', 'opslog.gridMapBase', 'opslog.ftmapBase', 'opslog.satMapBase',
|
'opslog.mapBasemap', 'opslog.gridMapBase', 'opslog.ftmapBase', 'opslog.satMapBase',
|
||||||
@@ -68,6 +69,8 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.clusterHideSpots', // cluster console: hide the DX spot flood so replies are readable
|
'opslog.clusterHideSpots', // cluster console: hide the DX spot flood so replies are readable
|
||||||
'opslog.clusterConsoleFollow', // cluster console: keep the view pinned to the newest line
|
'opslog.clusterConsoleFollow', // cluster console: keep the view pinned to the newest line
|
||||||
'opslog.gridMapColorConfirmed', 'opslog.gridMapColorWorked', // grid map: chosen fills (empty = follow the theme)
|
'opslog.gridMapColorConfirmed', 'opslog.gridMapColorWorked', // grid map: chosen fills (empty = follow the theme)
|
||||||
|
'opslog.ftMapColour', // FT map: one colour for every arc (empty = the band palette)
|
||||||
|
'opslog.ftMapHeardColour', // FT map: the who-hears-me diamonds (empty = the default cyan)
|
||||||
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
||||||
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
||||||
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
||||||
|
|||||||
@@ -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.17';
|
export const APP_VERSION = '0.27.23';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+20
-1
@@ -14,6 +14,7 @@ import {bandopen} from '../models';
|
|||||||
import {cluster} from '../models';
|
import {cluster} from '../models';
|
||||||
import {dxped} from '../models';
|
import {dxped} from '../models';
|
||||||
import {extsvc} from '../models';
|
import {extsvc} from '../models';
|
||||||
|
import {pskrme} from '../models';
|
||||||
import {powergenius} from '../models';
|
import {powergenius} from '../models';
|
||||||
import {pskrtgt} from '../models';
|
import {pskrtgt} from '../models';
|
||||||
import {pskr} from '../models';
|
import {pskr} from '../models';
|
||||||
@@ -517,6 +518,10 @@ export function GetGridCacheStatus():Promise<main.GridCacheStatus>;
|
|||||||
|
|
||||||
export function GetGridScopeSettings():Promise<main.GridScopeSettings>;
|
export function GetGridScopeSettings():Promise<main.GridScopeSettings>;
|
||||||
|
|
||||||
|
export function GetHearMe():Promise<boolean>;
|
||||||
|
|
||||||
|
export function GetHearMeStatus():Promise<pskrme.Status>;
|
||||||
|
|
||||||
export function GetIcomState():Promise<cat.IcomTXState>;
|
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||||
|
|
||||||
export function GetKenwoodState():Promise<cat.KenwoodTXState>;
|
export function GetKenwoodState():Promise<cat.KenwoodTXState>;
|
||||||
@@ -583,6 +588,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>>;
|
||||||
@@ -665,6 +672,8 @@ export function GetWebPublishStatus():Promise<main.WebPublishStatus>;
|
|||||||
|
|
||||||
export function GetWhatsNew():Promise<Array<main.ChangelogEntry>>;
|
export function GetWhatsNew():Promise<Array<main.ChangelogEntry>>;
|
||||||
|
|
||||||
|
export function GetWhoHearsMe():Promise<Array<pskrme.Report>>;
|
||||||
|
|
||||||
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
||||||
|
|
||||||
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||||
@@ -683,7 +692,9 @@ export function GetYaesuBandAntennas():Promise<Record<string, number>>;
|
|||||||
|
|
||||||
export function GetYaesuState():Promise<cat.YaesuTXState>;
|
export function GetYaesuState():Promise<cat.YaesuTXState>;
|
||||||
|
|
||||||
export function GridSquares(arg1:string):Promise<Array<qso.GridSquare>>;
|
export function GridSquareChoices():Promise<main.GridSquareChoices>;
|
||||||
|
|
||||||
|
export function GridSquares(arg1:string,arg2:string,arg3:string):Promise<Array<qso.GridSquare>>;
|
||||||
|
|
||||||
export function HaltAutoCall():Promise<void>;
|
export function HaltAutoCall():Promise<void>;
|
||||||
|
|
||||||
@@ -831,6 +842,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>>;
|
||||||
@@ -849,6 +862,8 @@ export function LookupCallsign(arg1:string,arg2:string):Promise<lookup.Result>;
|
|||||||
|
|
||||||
export function LookupCallsignFresh(arg1:string,arg2:string):Promise<lookup.Result>;
|
export function LookupCallsignFresh(arg1:string,arg2:string):Promise<lookup.Result>;
|
||||||
|
|
||||||
|
export function MotorCalibrate():Promise<void>;
|
||||||
|
|
||||||
export function MotorNudgeKHz(arg1:number):Promise<void>;
|
export function MotorNudgeKHz(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function MotorReadElements():Promise<Array<number>>;
|
export function MotorReadElements():Promise<Array<number>>;
|
||||||
@@ -1041,6 +1056,8 @@ export function RestartApp():Promise<void>;
|
|||||||
|
|
||||||
export function RestartQSORecorder():Promise<void>;
|
export function RestartQSORecorder():Promise<void>;
|
||||||
|
|
||||||
|
export function RetargetSatelliteTracking(arg1:string,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function RetryOfflineSync():Promise<number>;
|
export function RetryOfflineSync():Promise<number>;
|
||||||
|
|
||||||
export function RevealDataFolder():Promise<void>;
|
export function RevealDataFolder():Promise<void>;
|
||||||
@@ -1225,6 +1242,8 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
|||||||
|
|
||||||
export function SetFlexRSTChaseEnabled(arg1:boolean):Promise<void>;
|
export function SetFlexRSTChaseEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetHearMe(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetKenwoodAFGain(arg1:number):Promise<void>;
|
export function SetKenwoodAFGain(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetKenwoodAGC(arg1:string):Promise<void>;
|
export function SetKenwoodAGC(arg1:string):Promise<void>;
|
||||||
|
|||||||
@@ -966,6 +966,14 @@ export function GetGridScopeSettings() {
|
|||||||
return window['go']['main']['App']['GetGridScopeSettings']();
|
return window['go']['main']['App']['GetGridScopeSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetHearMe() {
|
||||||
|
return window['go']['main']['App']['GetHearMe']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetHearMeStatus() {
|
||||||
|
return window['go']['main']['App']['GetHearMeStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetIcomState() {
|
export function GetIcomState() {
|
||||||
return window['go']['main']['App']['GetIcomState']();
|
return window['go']['main']['App']['GetIcomState']();
|
||||||
}
|
}
|
||||||
@@ -1098,6 +1106,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']();
|
||||||
}
|
}
|
||||||
@@ -1262,6 +1274,10 @@ export function GetWhatsNew() {
|
|||||||
return window['go']['main']['App']['GetWhatsNew']();
|
return window['go']['main']['App']['GetWhatsNew']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetWhoHearsMe() {
|
||||||
|
return window['go']['main']['App']['GetWhoHearsMe']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetWinkeyerSettings() {
|
export function GetWinkeyerSettings() {
|
||||||
return window['go']['main']['App']['GetWinkeyerSettings']();
|
return window['go']['main']['App']['GetWinkeyerSettings']();
|
||||||
}
|
}
|
||||||
@@ -1298,8 +1314,12 @@ export function GetYaesuState() {
|
|||||||
return window['go']['main']['App']['GetYaesuState']();
|
return window['go']['main']['App']['GetYaesuState']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GridSquares(arg1) {
|
export function GridSquareChoices() {
|
||||||
return window['go']['main']['App']['GridSquares'](arg1);
|
return window['go']['main']['App']['GridSquareChoices']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GridSquares(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['GridSquares'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HaltAutoCall() {
|
export function HaltAutoCall() {
|
||||||
@@ -1594,6 +1614,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']();
|
||||||
}
|
}
|
||||||
@@ -1630,6 +1654,10 @@ export function LookupCallsignFresh(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['LookupCallsignFresh'](arg1, arg2);
|
return window['go']['main']['App']['LookupCallsignFresh'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function MotorCalibrate() {
|
||||||
|
return window['go']['main']['App']['MotorCalibrate']();
|
||||||
|
}
|
||||||
|
|
||||||
export function MotorNudgeKHz(arg1) {
|
export function MotorNudgeKHz(arg1) {
|
||||||
return window['go']['main']['App']['MotorNudgeKHz'](arg1);
|
return window['go']['main']['App']['MotorNudgeKHz'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2014,6 +2042,10 @@ export function RestartQSORecorder() {
|
|||||||
return window['go']['main']['App']['RestartQSORecorder']();
|
return window['go']['main']['App']['RestartQSORecorder']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RetargetSatelliteTracking(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['RetargetSatelliteTracking'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function RetryOfflineSync() {
|
export function RetryOfflineSync() {
|
||||||
return window['go']['main']['App']['RetryOfflineSync']();
|
return window['go']['main']['App']['RetryOfflineSync']();
|
||||||
}
|
}
|
||||||
@@ -2382,6 +2414,10 @@ export function SetFlexRSTChaseEnabled(arg1) {
|
|||||||
return window['go']['main']['App']['SetFlexRSTChaseEnabled'](arg1);
|
return window['go']['main']['App']['SetFlexRSTChaseEnabled'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetHearMe(arg1) {
|
||||||
|
return window['go']['main']['App']['SetHearMe'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetKenwoodAFGain(arg1) {
|
export function SetKenwoodAFGain(arg1) {
|
||||||
return window['go']['main']['App']['SetKenwoodAFGain'](arg1);
|
return window['go']['main']['App']['SetKenwoodAFGain'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
+148
-16
@@ -2360,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;
|
||||||
@@ -2414,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"];
|
||||||
@@ -3073,6 +3075,22 @@ export namespace main {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class GridSquareChoices {
|
||||||
|
modes: string[];
|
||||||
|
bands: string[];
|
||||||
|
satellites: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new GridSquareChoices(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.modes = source["modes"];
|
||||||
|
this.bands = source["bands"];
|
||||||
|
this.satellites = source["satellites"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class HamlogCfmResult {
|
export class HamlogCfmResult {
|
||||||
total: number;
|
total: number;
|
||||||
confirmed: number;
|
confirmed: number;
|
||||||
@@ -3879,6 +3897,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);
|
||||||
@@ -3901,6 +3920,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 {
|
||||||
@@ -3908,6 +3928,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;
|
||||||
@@ -3922,11 +3944,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;
|
||||||
@@ -4136,14 +4186,8 @@ export namespace main {
|
|||||||
grid: string;
|
grid: string;
|
||||||
alt_m: number;
|
alt_m: number;
|
||||||
rot_on: boolean;
|
rot_on: boolean;
|
||||||
rot_type: string;
|
rot_id: string;
|
||||||
rot_pst_port: number;
|
rot_az_only: boolean;
|
||||||
rot_transport: string;
|
|
||||||
rot_host: string;
|
|
||||||
rot_port: number;
|
|
||||||
rot_com: string;
|
|
||||||
rot_baud: number;
|
|
||||||
rot_max_az: number;
|
|
||||||
rot_min_el: number;
|
rot_min_el: number;
|
||||||
rot_step: number;
|
rot_step: number;
|
||||||
rot_park: boolean;
|
rot_park: boolean;
|
||||||
@@ -4161,14 +4205,8 @@ export namespace main {
|
|||||||
this.grid = source["grid"];
|
this.grid = source["grid"];
|
||||||
this.alt_m = source["alt_m"];
|
this.alt_m = source["alt_m"];
|
||||||
this.rot_on = source["rot_on"];
|
this.rot_on = source["rot_on"];
|
||||||
this.rot_type = source["rot_type"];
|
this.rot_id = source["rot_id"];
|
||||||
this.rot_pst_port = source["rot_pst_port"];
|
this.rot_az_only = source["rot_az_only"];
|
||||||
this.rot_transport = source["rot_transport"];
|
|
||||||
this.rot_host = source["rot_host"];
|
|
||||||
this.rot_port = source["rot_port"];
|
|
||||||
this.rot_com = source["rot_com"];
|
|
||||||
this.rot_baud = source["rot_baud"];
|
|
||||||
this.rot_max_az = source["rot_max_az"];
|
|
||||||
this.rot_min_el = source["rot_min_el"];
|
this.rot_min_el = source["rot_min_el"];
|
||||||
this.rot_step = source["rot_step"];
|
this.rot_step = source["rot_step"];
|
||||||
this.rot_park = source["rot_park"];
|
this.rot_park = source["rot_park"];
|
||||||
@@ -4260,12 +4298,15 @@ export namespace main {
|
|||||||
az: number;
|
az: number;
|
||||||
el: number;
|
el: number;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
range_km: number;
|
||||||
|
alt_km: number;
|
||||||
radio: string;
|
radio: string;
|
||||||
error: string;
|
error: string;
|
||||||
rot_on: boolean;
|
rot_on: boolean;
|
||||||
rot_az: number;
|
rot_az: number;
|
||||||
rot_el: number;
|
rot_el: number;
|
||||||
rot_live: boolean;
|
rot_live: boolean;
|
||||||
|
rot_az_only: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new SatTrackStatus(source);
|
return new SatTrackStatus(source);
|
||||||
@@ -4284,12 +4325,15 @@ export namespace main {
|
|||||||
this.az = source["az"];
|
this.az = source["az"];
|
||||||
this.el = source["el"];
|
this.el = source["el"];
|
||||||
this.visible = source["visible"];
|
this.visible = source["visible"];
|
||||||
|
this.range_km = source["range_km"];
|
||||||
|
this.alt_km = source["alt_km"];
|
||||||
this.radio = source["radio"];
|
this.radio = source["radio"];
|
||||||
this.error = source["error"];
|
this.error = source["error"];
|
||||||
this.rot_on = source["rot_on"];
|
this.rot_on = source["rot_on"];
|
||||||
this.rot_az = source["rot_az"];
|
this.rot_az = source["rot_az"];
|
||||||
this.rot_el = source["rot_el"];
|
this.rot_el = source["rot_el"];
|
||||||
this.rot_live = source["rot_live"];
|
this.rot_live = source["rot_live"];
|
||||||
|
this.rot_az_only = source["rot_az_only"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4360,6 +4404,24 @@ export namespace main {
|
|||||||
return 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;
|
||||||
@@ -4536,6 +4598,7 @@ export namespace main {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
err: string;
|
err: string;
|
||||||
db_path: string;
|
db_path: string;
|
||||||
|
warn: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new StartupStatus(source);
|
return new StartupStatus(source);
|
||||||
@@ -4546,6 +4609,7 @@ export namespace main {
|
|||||||
this.ok = source["ok"];
|
this.ok = source["ok"];
|
||||||
this.err = source["err"];
|
this.err = source["err"];
|
||||||
this.db_path = source["db_path"];
|
this.db_path = source["db_path"];
|
||||||
|
this.warn = source["warn"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class StationDevice {
|
export class StationDevice {
|
||||||
@@ -5387,6 +5451,74 @@ export namespace pskr {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace pskrme {
|
||||||
|
|
||||||
|
export class Report {
|
||||||
|
call: string;
|
||||||
|
grid: string;
|
||||||
|
band: string;
|
||||||
|
mode: string;
|
||||||
|
snr: number;
|
||||||
|
freq_hz: number;
|
||||||
|
// Go type: time
|
||||||
|
at: any;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Report(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.call = source["call"];
|
||||||
|
this.grid = source["grid"];
|
||||||
|
this.band = source["band"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.snr = source["snr"];
|
||||||
|
this.freq_hz = source["freq_hz"];
|
||||||
|
this.at = this.convertValues(source["at"], null);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Status {
|
||||||
|
enabled: boolean;
|
||||||
|
online: boolean;
|
||||||
|
reports: number;
|
||||||
|
watching: string;
|
||||||
|
error: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Status(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.online = source["online"];
|
||||||
|
this.reports = source["reports"];
|
||||||
|
this.watching = source["watching"];
|
||||||
|
this.error = source["error"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace pskrtgt {
|
export namespace pskrtgt {
|
||||||
|
|
||||||
export class Bin {
|
export class Bin {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ require (
|
|||||||
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
|
||||||
|
|||||||
@@ -29,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=
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -584,6 +584,11 @@ type FlexController interface {
|
|||||||
SetMute(bool) error
|
SetMute(bool) error
|
||||||
SetRXAntenna(string) error
|
SetRXAntenna(string) error
|
||||||
SetTXAntenna(string) error
|
SetTXAntenna(string) error
|
||||||
|
// SatAntennas sets the antenna on each SATELLITE slice — they are on two
|
||||||
|
// different bands and, with transverters, two different ports.
|
||||||
|
SatAntennas(rxAnt, txAnt string) error
|
||||||
|
// SatTone sets the CTCSS tone the satellite uplink transmits (0 = off).
|
||||||
|
SatTone(hz float64) error
|
||||||
SetActiveSlice(int) error // focus slice idx so commands target it
|
SetActiveSlice(int) error // focus slice idx so commands target it
|
||||||
// ZoomPan sets the visible width (MHz) of the active slice's panadapter and
|
// ZoomPan sets the visible width (MHz) of the active slice's panadapter and
|
||||||
// keeps freqMHz inside it, re-centring when it must. See Flex.ZoomPan.
|
// keeps freqMHz inside it, re-centring when it must. See Flex.ZoomPan.
|
||||||
@@ -1145,6 +1150,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 {
|
||||||
|
|||||||
+32
-2
@@ -1,5 +1,3 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package cat
|
package cat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -15,6 +13,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Flex is a native FlexRadio (SmartSDR) CAT backend. It speaks the radio's TCP
|
// Flex is a native FlexRadio (SmartSDR) CAT backend. It speaks the radio's TCP
|
||||||
@@ -75,6 +75,14 @@ type Flex struct {
|
|||||||
satRX int
|
satRX int
|
||||||
satTX int
|
satTX int
|
||||||
satCreatedTX bool
|
satCreatedTX bool
|
||||||
|
// What the uplink slice is owed, kept so it can be given to a slice that
|
||||||
|
// turns up LATE. Arming, the antennas, the tone and the mode all happen
|
||||||
|
// before the radio has necessarily reported the slice it was asked to
|
||||||
|
// create; without this they were applied to an index of -1 and never again.
|
||||||
|
satUpMode string
|
||||||
|
satRXAnt string
|
||||||
|
satTXAnt string
|
||||||
|
satTone float64
|
||||||
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)
|
||||||
@@ -1036,7 +1044,29 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
s.filterHi = atoiDefault(val, s.filterHi)
|
s.filterHi = atoiDefault(val, s.filterHi)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A satellite uplink slice that arrived without our hearing about it.
|
||||||
|
//
|
||||||
|
// satCreate correlates the "slice create" reply by sequence number, and when
|
||||||
|
// that correlation misses, the slice exists on the radio and OpsLog does not
|
||||||
|
// know its index. Everything then silently does nothing: the uplink is never
|
||||||
|
// tuned, never gets its mode, never gets its antenna or its CTCSS tone, and
|
||||||
|
// — worst — never becomes the transmitter, so the radio goes on transmitting
|
||||||
|
// on the DOWNLINK slice. Seen on the air: slice B sitting at the 435.100000
|
||||||
|
// it was created with, both slices in USB, and the red TX badge on the 2 m
|
||||||
|
// downlink.
|
||||||
|
//
|
||||||
|
// The status message needs no correlation. If satellite mode is armed, the
|
||||||
|
// uplink is still unknown, and a slice is in use that is not the downlink,
|
||||||
|
// that is the slice — the radio is telling us plainly.
|
||||||
|
adopt := -1
|
||||||
|
if f.satOn && f.satTX < 0 && idx != f.satRX && s.inUse {
|
||||||
|
adopt = idx
|
||||||
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
|
if adopt >= 0 {
|
||||||
|
applog.Printf("flex: adopting slice %d as the satellite uplink from its status — the create reply never came back", adopt)
|
||||||
|
f.adoptSatSlice("tx", adopt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// defInt returns v, or def when v is zero (so sliders show sane defaults before
|
// defInt returns v, or def when v is zero (so sliders show sane defaults before
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
+136
-2
@@ -3,6 +3,7 @@ package cat
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
"hamlog/internal/applog"
|
||||||
)
|
)
|
||||||
@@ -65,10 +66,41 @@ func (f *Flex) SetSatellite(on bool) error {
|
|||||||
} else {
|
} else {
|
||||||
f.send(fmt.Sprintf("slice s %d tx=1", txIdx))
|
f.send(fmt.Sprintf("slice s %d tx=1", txIdx))
|
||||||
}
|
}
|
||||||
|
// WAIT for the slices before saying the pair is armed.
|
||||||
|
//
|
||||||
|
// Creating a slice is asynchronous: the index comes back in a later reply.
|
||||||
|
// Returning before it arrives meant everything downstream ran against an
|
||||||
|
// uplink of -1 — no antenna, no CTCSS tone, no mode, never tuned, and never
|
||||||
|
// made the transmitter, so the radio went on transmitting on the DOWNLINK.
|
||||||
|
// Seen on the air, and it is the one failure here that can put a signal
|
||||||
|
// somewhere it must not go.
|
||||||
|
rxIdx, txIdx = f.awaitSatSlices(3 * time.Second)
|
||||||
|
if rxIdx < 0 || txIdx < 0 {
|
||||||
|
applog.Printf("flex: satellite armed but the radio did not report both slices (rx %d, tx %d) — "+
|
||||||
|
"the uplink will be picked up when it does", rxIdx, txIdx)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
applog.Printf("flex: satellite armed (rx slice %d, tx slice %d)", rxIdx, txIdx)
|
applog.Printf("flex: satellite armed (rx slice %d, tx slice %d)", rxIdx, txIdx)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// awaitSatSlices waits for both slice indices to be known, and returns whatever
|
||||||
|
// it has when the time is up. Polled rather than signalled: the indices arrive
|
||||||
|
// on the reader goroutine by two different routes — the create reply and the
|
||||||
|
// slice status — and a poll is indifferent to which of them got there first.
|
||||||
|
func (f *Flex) awaitSatSlices(d time.Duration) (rx, tx int) {
|
||||||
|
deadline := time.Now().Add(d)
|
||||||
|
for {
|
||||||
|
f.mu.Lock()
|
||||||
|
rx, tx = f.satRX, f.satTX
|
||||||
|
f.mu.Unlock()
|
||||||
|
if (rx >= 0 && tx >= 0) || time.Now().After(deadline) {
|
||||||
|
return rx, tx
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Flex) satDisarm() error {
|
func (f *Flex) satDisarm() error {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
rx, tx, created := f.satRX, f.satTX, f.satCreatedTX
|
rx, tx, created := f.satRX, f.satTX, f.satCreatedTX
|
||||||
@@ -118,6 +150,24 @@ func (f *Flex) adoptSatSlice(role string, idx int) {
|
|||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
if role == "tx" {
|
if role == "tx" {
|
||||||
f.send(fmt.Sprintf("slice s %d tx=1", idx))
|
f.send(fmt.Sprintf("slice s %d tx=1", idx))
|
||||||
|
// Everything this slice was owed while nobody knew where it was. Set
|
||||||
|
// here rather than left to the next Doppler step, because the mode, the
|
||||||
|
// antenna and the tone are all sent ONCE — the step only re-sends
|
||||||
|
// frequencies.
|
||||||
|
f.mu.Lock()
|
||||||
|
mode, ant, tone := f.satUpMode, f.satTXAnt, f.satTone
|
||||||
|
f.mu.Unlock()
|
||||||
|
if strings.TrimSpace(ant) != "" {
|
||||||
|
f.send(fmt.Sprintf("slice s %d txant=%s", idx, ant))
|
||||||
|
f.send(fmt.Sprintf("slice s %d rxant=%s", idx, ant))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(mode) != "" {
|
||||||
|
f.satMode(idx, mode, 0)
|
||||||
|
}
|
||||||
|
if tone > 0 {
|
||||||
|
f.send(fmt.Sprintf("slice s %d fm_tone_value=%.1f", idx, tone))
|
||||||
|
f.send(fmt.Sprintf("slice s %d fm_tone_mode=CTCSS_TX", idx))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
applog.Printf("flex: satellite %s slice is %d", role, idx)
|
applog.Printf("flex: satellite %s slice is %d", role, idx)
|
||||||
}
|
}
|
||||||
@@ -146,6 +196,11 @@ func (f *Flex) TuneSatellite(downHz, upHz int64, downMode, upMode string) error
|
|||||||
f.send(fmt.Sprintf("slice t %d %.6f", rx, float64(downHz)/1e6))
|
f.send(fmt.Sprintf("slice t %d %.6f", rx, float64(downHz)/1e6))
|
||||||
f.satMode(rx, downMode, downHz)
|
f.satMode(rx, downMode, downHz)
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(upMode) != "" {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.satUpMode = upMode
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
if tx >= 0 && upHz > 0 {
|
if tx >= 0 && upHz > 0 {
|
||||||
f.send(fmt.Sprintf("slice t %d %.6f", tx, float64(upHz)/1e6))
|
f.send(fmt.Sprintf("slice t %d %.6f", tx, float64(upHz)/1e6))
|
||||||
f.satMode(tx, upMode, upHz)
|
f.satMode(tx, upMode, upHz)
|
||||||
@@ -161,8 +216,11 @@ func (f *Flex) satMode(idx int, mode string, freqHz int64) {
|
|||||||
if mode == "" {
|
if mode == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// USB on both sides above 30 MHz, which is every satellite worth the name —
|
// A bare "SSB" still means upper sideband above 30 MHz, which is every
|
||||||
// including the parts of a passband that would be an LSB band down on HF.
|
// satellite worth the name — including the parts of a passband that would be
|
||||||
|
// an LSB band down on HF. An explicit USB or LSB from the caller is left
|
||||||
|
// alone: on an INVERTING transponder the two sides are different sidebands,
|
||||||
|
// and only the caller knows which way round this bird runs.
|
||||||
if strings.EqualFold(mode, "SSB") && freqHz > 30_000_000 {
|
if strings.EqualFold(mode, "SSB") && freqHz > 30_000_000 {
|
||||||
mode = "USB"
|
mode = "USB"
|
||||||
}
|
}
|
||||||
@@ -200,3 +258,79 @@ func (f *Flex) SatReceiveHz() (int64, error) {
|
|||||||
}
|
}
|
||||||
return s.freqHz, nil
|
return s.freqHz, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SatAntennas selects the antenna each satellite slice uses.
|
||||||
|
//
|
||||||
|
// The two slices are on two different bands — a V/U bird receives on 70 cm and
|
||||||
|
// transmits on 2 m, a U/V one does the reverse — so they cannot share one
|
||||||
|
// antenna setting. On a station with transverters they are not even the same
|
||||||
|
// port: XVTA for 2 m, XVTB for 70 cm, and a downlink slice left on the HF
|
||||||
|
// antenna hears nothing at all.
|
||||||
|
//
|
||||||
|
// Per SLICE, not through sendSlice, which addresses whichever slice is active.
|
||||||
|
// During a pass the active slice is the downlink, so the uplink's antenna would
|
||||||
|
// never have been set.
|
||||||
|
//
|
||||||
|
// Empty strings are left alone: an operator who has configured 2 m and not
|
||||||
|
// 70 cm should keep whatever the radio already had on the other side rather
|
||||||
|
// than have it cleared.
|
||||||
|
func (f *Flex) SatAntennas(rxAnt, txAnt string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
rx, tx := f.satRX, f.satTX
|
||||||
|
connected := f.conn != nil
|
||||||
|
// Remembered so a slice that is reported late still gets its antenna.
|
||||||
|
f.satRXAnt, f.satTXAnt = rxAnt, txAnt
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
// The downlink slice is the one being listened to, so it takes the receive
|
||||||
|
// antenna; the uplink slice is the one keyed, so it takes the transmit one.
|
||||||
|
if rx >= 0 && strings.TrimSpace(rxAnt) != "" {
|
||||||
|
f.send(fmt.Sprintf("slice s %d rxant=%s", rx, rxAnt))
|
||||||
|
applog.Printf("flex: satellite downlink slice %d on antenna %s", rx, rxAnt)
|
||||||
|
}
|
||||||
|
if tx >= 0 && strings.TrimSpace(txAnt) != "" {
|
||||||
|
f.send(fmt.Sprintf("slice s %d txant=%s", tx, txAnt))
|
||||||
|
// A transmit slice also has to HEAR its own band on some radios, and a
|
||||||
|
// transverter port is the only thing connected to it. Setting the
|
||||||
|
// receive antenna to match costs nothing when it is already right.
|
||||||
|
f.send(fmt.Sprintf("slice s %d rxant=%s", tx, txAnt))
|
||||||
|
applog.Printf("flex: satellite uplink slice %d on antenna %s", tx, txAnt)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatTone sets the CTCSS tone the uplink slice transmits, in Hz. Zero turns it
|
||||||
|
// off.
|
||||||
|
//
|
||||||
|
// On the UPLINK slice, because that is the one that keys: a tone is something
|
||||||
|
// transmitted, and the repeater on the satellite will not open without it. This
|
||||||
|
// is the whole difference between an operator hearing a pass and hearing
|
||||||
|
// nothing on SO-50, AO-91, PO-101 and every other FM bird with a tone — and it
|
||||||
|
// is exactly the setting that cannot be made by hand mid-pass.
|
||||||
|
func (f *Flex) SatTone(hz float64) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
tx := f.satTX
|
||||||
|
connected := f.conn != nil
|
||||||
|
f.satTone = hz
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
if tx < 0 {
|
||||||
|
return nil // the slice has not come back yet; the next arming will set it
|
||||||
|
}
|
||||||
|
if hz <= 0 {
|
||||||
|
f.send(fmt.Sprintf("slice s %d fm_tone_mode=OFF", tx))
|
||||||
|
applog.Printf("flex: satellite uplink tone off")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Value before mode: a radio that is told CTCSS_TX while still holding the
|
||||||
|
// previous tone transmits the previous tone for as long as it takes the
|
||||||
|
// second command to arrive.
|
||||||
|
f.send(fmt.Sprintf("slice s %d fm_tone_value=%.1f", tx, hz))
|
||||||
|
f.send(fmt.Sprintf("slice s %d fm_tone_mode=CTCSS_TX", tx))
|
||||||
|
applog.Printf("flex: satellite uplink tone %.1f Hz on slice %d", hz, tx)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
+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"
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ func Configured(svc Service, cfg ExternalServices) error {
|
|||||||
return missing("Cloudlog / Wavelog", need...)
|
return missing("Cloudlog / Wavelog", need...)
|
||||||
}
|
}
|
||||||
case ServiceLoTW:
|
case ServiceLoTW:
|
||||||
add(set(cfg.LoTW.TQSLPath), "the path to tqsl.exe")
|
add(set(cfg.LoTW.TQSLPath), "the path to TQSL")
|
||||||
add(set(cfg.LoTW.StationLocation), "the TQSL station location")
|
add(set(cfg.LoTW.StationLocation), "the TQSL station location")
|
||||||
if len(need) > 0 {
|
if len(need) > 0 {
|
||||||
return missing("LoTW", need...)
|
return missing("LoTW", need...)
|
||||||
|
|||||||
+2
-25
@@ -297,29 +297,6 @@ func ListStationLocations(stationDataPath string) ([]StationLocation, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultTQSLPath returns the usual tqsl.exe install path on Windows, or ""
|
|
||||||
// if not found.
|
|
||||||
func DefaultTQSLPath() string {
|
|
||||||
for _, p := range []string{
|
|
||||||
`C:\Program Files (x86)\TrustedQSL\tqsl.exe`,
|
|
||||||
`C:\Program Files\TrustedQSL\tqsl.exe`,
|
|
||||||
} {
|
|
||||||
if fileExists(p) {
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultStationDataPath returns TQSL's station_data location (%APPDATA%\
|
|
||||||
// TrustedQSL\station_data on Windows), or "" if APPDATA isn't set.
|
|
||||||
func DefaultStationDataPath() string {
|
|
||||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
|
||||||
return filepath.Join(appData, "TrustedQSL", "station_data")
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func fileExists(p string) bool {
|
func fileExists(p string) bool {
|
||||||
info, err := os.Stat(p)
|
info, err := os.Stat(p)
|
||||||
return err == nil && !info.IsDir()
|
return err == nil && !info.IsDir()
|
||||||
@@ -375,7 +352,7 @@ func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord stri
|
|||||||
case tqsl == "":
|
case tqsl == "":
|
||||||
return UploadResult{}, fmt.Errorf("lotw: TQSL path not set")
|
return UploadResult{}, fmt.Errorf("lotw: TQSL path not set")
|
||||||
case !fileExists(tqsl):
|
case !fileExists(tqsl):
|
||||||
return UploadResult{}, fmt.Errorf("lotw: tqsl.exe not found at %q", tqsl)
|
return UploadResult{}, fmt.Errorf("lotw: TQSL not found at %q", tqsl)
|
||||||
case loc == "":
|
case loc == "":
|
||||||
return UploadResult{}, fmt.Errorf("lotw: station location not set")
|
return UploadResult{}, fmt.Errorf("lotw: station location not set")
|
||||||
case strings.TrimSpace(adifRecord) == "":
|
case strings.TrimSpace(adifRecord) == "":
|
||||||
@@ -515,7 +492,7 @@ func TestLoTW(cfg ServiceConfig, stationDataPath string) (string, error) {
|
|||||||
tqsl := strings.TrimSpace(cfg.TQSLPath)
|
tqsl := strings.TrimSpace(cfg.TQSLPath)
|
||||||
loc := strings.TrimSpace(cfg.StationLocation)
|
loc := strings.TrimSpace(cfg.StationLocation)
|
||||||
if tqsl == "" || !fileExists(tqsl) {
|
if tqsl == "" || !fileExists(tqsl) {
|
||||||
return "", fmt.Errorf("lotw: tqsl.exe not found (set the TQSL path)")
|
return "", fmt.Errorf("lotw: TQSL not found (set the TQSL path)")
|
||||||
}
|
}
|
||||||
if loc == "" {
|
if loc == "" {
|
||||||
return "", fmt.Errorf("lotw: pick a station location")
|
return "", fmt.Errorf("lotw: pick a station location")
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package extsvc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultTQSLPath finds TrustedQSL, or returns "" so the operator can point at
|
||||||
|
// it by hand.
|
||||||
|
//
|
||||||
|
// PATH is asked FIRST, unlike the Windows side where two fixed install folders
|
||||||
|
// are the whole story. On Linux tqsl comes from the distribution's package
|
||||||
|
// manager, a Flatpak or a self-built copy, and each puts it somewhere
|
||||||
|
// different; whichever one the operator installed is the one their shell finds.
|
||||||
|
// The fixed list below is only for a desktop session that started without a
|
||||||
|
// useful PATH.
|
||||||
|
func DefaultTQSLPath() string {
|
||||||
|
if p, err := exec.LookPath("tqsl"); err == nil && fileExists(p) {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
candidates := []string{
|
||||||
|
"/usr/bin/tqsl",
|
||||||
|
"/usr/local/bin/tqsl",
|
||||||
|
"/var/lib/flatpak/exports/bin/org.arrl.tqsl",
|
||||||
|
filepath.Join(os.Getenv("HOME"), ".local/share/flatpak/exports/bin/org.arrl.tqsl"),
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
candidates = append(candidates, "/Applications/TrustedQSL/tqsl.app/Contents/MacOS/tqsl")
|
||||||
|
}
|
||||||
|
for _, p := range candidates {
|
||||||
|
if fileExists(p) {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultStationDataPath returns TQSL's station_data location.
|
||||||
|
//
|
||||||
|
// ~/.tqsl is where the Unix build of TrustedQSL keeps its configuration. A
|
||||||
|
// Flatpak install redirects it into the sandbox
|
||||||
|
// (~/.var/app/org.arrl.tqsl/data/tqsl), so that is tried too — an operator on a
|
||||||
|
// Flatpak TQSL otherwise sees an empty station-location list with nothing to
|
||||||
|
// explain it.
|
||||||
|
func DefaultStationDataPath() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil || home == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, p := range []string{
|
||||||
|
filepath.Join(home, ".tqsl", "station_data"),
|
||||||
|
filepath.Join(home, ".var", "app", "org.arrl.tqsl", "data", "tqsl", "station_data"),
|
||||||
|
} {
|
||||||
|
if fileExists(p) {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nothing found: name the ordinary location anyway. The settings field then
|
||||||
|
// shows the path TQSL would create on its first run, which is a better
|
||||||
|
// starting point for the operator than an empty box.
|
||||||
|
return filepath.Join(home, ".tqsl", "station_data")
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package extsvc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultTQSLPath returns the usual tqsl.exe install path, or "" if not found.
|
||||||
|
func DefaultTQSLPath() string {
|
||||||
|
for _, p := range []string{
|
||||||
|
`C:\Program Files (x86)\TrustedQSL\tqsl.exe`,
|
||||||
|
`C:\Program Files\TrustedQSL\tqsl.exe`,
|
||||||
|
} {
|
||||||
|
if fileExists(p) {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultStationDataPath returns TQSL's station_data location
|
||||||
|
// (%APPDATA%\TrustedQSL\station_data), or "" if APPDATA isn't set.
|
||||||
|
func DefaultStationDataPath() string {
|
||||||
|
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||||
|
return filepath.Join(appData, "TrustedQSL", "station_data")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package udp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A "multicast" row whose group is not a multicast address.
|
||||||
|
//
|
||||||
|
// 127.0.0.1 in that box is the common mistake — it is the address every other
|
||||||
|
// field in every other program wants — and it used to fail the join on every
|
||||||
|
// interface with a Windows error about an address not being valid in its
|
||||||
|
// context. The row did not run and the message named nothing the operator had
|
||||||
|
// typed. Reported by an operator whose WSJT-X rows were dead for exactly this
|
||||||
|
// reason, while a third row on unicast worked perfectly beside them.
|
||||||
|
func TestOnlyRealMulticastGroupsAreJoined(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
addr string
|
||||||
|
multicast bool
|
||||||
|
}{
|
||||||
|
{"224.0.0.1", true}, // the all-hosts group WSJT-X offers
|
||||||
|
{"239.255.0.1", true}, // the administratively-scoped range
|
||||||
|
{"127.0.0.1", false}, // loopback: the mistake
|
||||||
|
{"192.168.1.10", false},
|
||||||
|
{"0.0.0.0", false},
|
||||||
|
} {
|
||||||
|
ip := net.ParseIP(tc.addr)
|
||||||
|
if ip == nil {
|
||||||
|
t.Fatalf("%s does not parse", tc.addr)
|
||||||
|
}
|
||||||
|
if got := ip.IsMulticast(); got != tc.multicast {
|
||||||
|
t.Errorf("%s: IsMulticast() = %v, wanted %v", tc.addr, got, tc.multicast)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -310,7 +310,28 @@ func newServer(cfg Config, out chan<- Event, mgr *Manager) *Server {
|
|||||||
|
|
||||||
func (s *Server) start() error {
|
func (s *Server) start() error {
|
||||||
var conn *net.UDPConn
|
var conn *net.UDPConn
|
||||||
if s.cfg.Multicast {
|
// "Multicast" ticked with an address that is not one.
|
||||||
|
//
|
||||||
|
// 127.0.0.1 in the group box is the common mistake, and it is an
|
||||||
|
// understandable one — it is the address every other field in every other
|
||||||
|
// program wants. But a multicast group is 224.0.0.0 to 239.255.255.255, and
|
||||||
|
// joining anything else fails on every interface with a Windows error about
|
||||||
|
// an address not being valid in its context. The row then simply does not
|
||||||
|
// run, and an operator reads a setsockopt message that names nothing they
|
||||||
|
// typed.
|
||||||
|
//
|
||||||
|
// So it listens anyway, as unicast, which is what an address like that means
|
||||||
|
// — and says what it did. The row works, and the reason it is not multicast
|
||||||
|
// is in the log rather than in a kernel error code.
|
||||||
|
multicast := s.cfg.Multicast
|
||||||
|
if multicast {
|
||||||
|
if ip := net.ParseIP(strings.TrimSpace(s.cfg.MulticastGroup)); ip != nil && !ip.IsMulticast() {
|
||||||
|
applog.Printf("udp: [%s] %s is not a multicast address (those run 224.0.0.0-239.255.255.255) — listening on unicast :%d instead\n",
|
||||||
|
s.cfg.Name, ip, s.cfg.Port)
|
||||||
|
multicast = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if multicast {
|
||||||
group := strings.TrimSpace(s.cfg.MulticastGroup)
|
group := strings.TrimSpace(s.cfg.MulticastGroup)
|
||||||
if group == "" {
|
if group == "" {
|
||||||
return fmt.Errorf("multicast enabled but group address is empty")
|
return fmt.Errorf("multicast enabled but group address is empty")
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
// Package pskrme answers the other half of the decodes map: who is hearing ME.
|
||||||
|
//
|
||||||
|
// The FT map draws what this station decodes, which is one direction of every
|
||||||
|
// path on it. The reverse — which stations are reporting our own transmissions
|
||||||
|
// — is the half an operator cannot see from their own receiver at all, and on
|
||||||
|
// FT8 it is the half that decides whether calling is worth the cycle.
|
||||||
|
//
|
||||||
|
// It is the narrowest possible slice of the PSK Reporter feed. The v2 topic is
|
||||||
|
//
|
||||||
|
// pskr/filter/v2/<band>/<mode>/<tx call>/<rx call>/<tx grid>/<rx grid>/…
|
||||||
|
//
|
||||||
|
// so putting the operator's callsign in the TX level makes the broker send
|
||||||
|
// nothing else. That is the whole reason this is cheap: internal/pskr measured
|
||||||
|
// 83 messages a second for four bands unfiltered, and 0.2 to 1.2 a second once
|
||||||
|
// filtered on the receiver's square — one callsign in the transmit level is a
|
||||||
|
// handful of messages per FT8 cycle, however open the band is.
|
||||||
|
//
|
||||||
|
// Its own connection, like internal/pskrtgt has its own: the three want
|
||||||
|
// different slices of the feed, and none of them can be filtered out of
|
||||||
|
// another's. It also means this works with the band-opening watch switched off,
|
||||||
|
// which matters — tying it to that feed's lifecycle would have made it fail
|
||||||
|
// silently for anyone not chasing openings.
|
||||||
|
//
|
||||||
|
// Nothing is persisted. A report older than the window is dropped on the next
|
||||||
|
// read, and an empty window means nobody has reported us recently, which is the
|
||||||
|
// honest answer rather than a stale map.
|
||||||
|
package pskrme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultBroker is PSK Reporter's public MQTT endpoint, TLS.
|
||||||
|
const DefaultBroker = "tls://mqtt.pskreporter.info:1884"
|
||||||
|
|
||||||
|
// Window is how long a report keeps counting.
|
||||||
|
//
|
||||||
|
// Fifteen minutes. PSK Reporter's uploaders batch, many of them every five, so
|
||||||
|
// a tighter window shows a fraction of the stations that actually heard the
|
||||||
|
// last few calls — internal/pskrtgt widened its own to ten for exactly that
|
||||||
|
// reason. This one is looser still because it feeds a MAP: a receiver that
|
||||||
|
// heard us twelve minutes ago is a path worth seeing on it, where the same
|
||||||
|
// report as a live "can he hear me" verdict would be stale.
|
||||||
|
const Window = 15 * time.Minute
|
||||||
|
|
||||||
|
// Report is one station's reception of us, reduced to what a map needs.
|
||||||
|
type Report struct {
|
||||||
|
Call string `json:"call"` // who reported us
|
||||||
|
Grid string `json:"grid"` // their square, from the message itself
|
||||||
|
Band string `json:"band"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
SNR int `json:"snr"` // how they heard us, their report
|
||||||
|
FreqHz int64 `json:"freq_hz"` // where we were when they did
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is what the panel needs to tell a working feed from a silent one.
|
||||||
|
type Status struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Online bool `json:"online"`
|
||||||
|
Reports uint64 `json:"reports"` // accepted since start
|
||||||
|
Watching string `json:"watching"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is what the watcher needs to run.
|
||||||
|
type Config struct {
|
||||||
|
Broker string
|
||||||
|
// MyCall is the callsign to watch for in the TRANSMIT level. Without one
|
||||||
|
// there is no subscription to make: a wildcard there would be the whole
|
||||||
|
// feed, which is the one thing this package exists not to do.
|
||||||
|
MyCall string
|
||||||
|
Logf func(string, ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watcher owns the connection, its one subscription, and the sliding window.
|
||||||
|
type Watcher struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
cfg Config
|
||||||
|
client mqtt.Client
|
||||||
|
running bool
|
||||||
|
topic string
|
||||||
|
|
||||||
|
reports []Report
|
||||||
|
received uint64
|
||||||
|
lastErr string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) *Watcher {
|
||||||
|
if cfg.Broker == "" {
|
||||||
|
cfg.Broker = DefaultBroker
|
||||||
|
}
|
||||||
|
if cfg.Logf == nil {
|
||||||
|
cfg.Logf = func(string, ...any) {}
|
||||||
|
}
|
||||||
|
cfg.MyCall = strings.ToUpper(strings.TrimSpace(cfg.MyCall))
|
||||||
|
return &Watcher{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// message is the payload, the same shape internal/pskr documents.
|
||||||
|
type message struct {
|
||||||
|
Freq int64 `json:"f"`
|
||||||
|
Mode string `json:"md"`
|
||||||
|
SNR int `json:"rp"`
|
||||||
|
TxCall string `json:"sc"`
|
||||||
|
TxGrid string `json:"sl"`
|
||||||
|
RxCall string `json:"rc"`
|
||||||
|
RxGrid string `json:"rl"`
|
||||||
|
Band string `json:"b"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start brings the subscription up. Safe to call on a running watcher.
|
||||||
|
func (w *Watcher) Start() error {
|
||||||
|
w.mu.Lock()
|
||||||
|
if w.running {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
call := w.cfg.MyCall
|
||||||
|
w.mu.Unlock()
|
||||||
|
if call == "" {
|
||||||
|
return fmt.Errorf("pskrme: no station callsign")
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := mqtt.NewClientOptions().
|
||||||
|
AddBroker(w.cfg.Broker).
|
||||||
|
SetClientID(fmt.Sprintf("opslog-hearme-%d", time.Now().UnixNano())).
|
||||||
|
SetCleanSession(true).
|
||||||
|
SetAutoReconnect(true).
|
||||||
|
SetConnectRetry(true).
|
||||||
|
SetConnectRetryInterval(30 * time.Second).
|
||||||
|
SetConnectTimeout(15 * time.Second).
|
||||||
|
SetOrderMatters(false)
|
||||||
|
// Subscribed on every connect, reconnects included: the session is clean, so
|
||||||
|
// the broker remembers nothing and a silent reconnect would leave a feed
|
||||||
|
// that looks up and delivers nothing for the rest of the evening.
|
||||||
|
opts.OnConnect = func(c mqtt.Client) {
|
||||||
|
// Both wildcards deliberate: every band and every mode. The filter that
|
||||||
|
// matters is the callsign, and an operator wants to know who hears them
|
||||||
|
// wherever they happen to be.
|
||||||
|
topic := "pskr/filter/v2/+/+/" + call + "/#"
|
||||||
|
if tok := c.Subscribe(topic, 0, w.handle); tok.Wait() && tok.Error() != nil {
|
||||||
|
w.setErr(tok.Error().Error())
|
||||||
|
w.cfg.Logf("pskrme: subscribing to %s failed: %v", topic, tok.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.mu.Lock()
|
||||||
|
w.topic = topic
|
||||||
|
w.lastErr = ""
|
||||||
|
w.mu.Unlock()
|
||||||
|
w.cfg.Logf("pskrme: watching who reports %s", call)
|
||||||
|
}
|
||||||
|
opts.OnConnectionLost = func(_ mqtt.Client, err error) {
|
||||||
|
w.setErr(err.Error())
|
||||||
|
w.cfg.Logf("pskrme: connection lost: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := mqtt.NewClient(opts)
|
||||||
|
if tok := client.Connect(); tok.Wait() && tok.Error() != nil {
|
||||||
|
return fmt.Errorf("pskrme: connect: %w", tok.Error())
|
||||||
|
}
|
||||||
|
w.mu.Lock()
|
||||||
|
w.client, w.running = client, true
|
||||||
|
w.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop drops the connection and everything collected. A feed turned off must
|
||||||
|
// not leave a map showing who heard us before it was.
|
||||||
|
func (w *Watcher) Stop() {
|
||||||
|
w.mu.Lock()
|
||||||
|
client, running := w.client, w.running
|
||||||
|
w.client, w.running = nil, false
|
||||||
|
w.reports = nil
|
||||||
|
w.topic = ""
|
||||||
|
w.mu.Unlock()
|
||||||
|
if running && client != nil {
|
||||||
|
client.Disconnect(250)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) setErr(msg string) {
|
||||||
|
w.mu.Lock()
|
||||||
|
w.lastErr = msg
|
||||||
|
w.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle runs on the MQTT goroutine, so it does the least possible.
|
||||||
|
func (w *Watcher) handle(_ mqtt.Client, m mqtt.Message) {
|
||||||
|
var msg message
|
||||||
|
if err := json.Unmarshal(m.Payload(), &msg); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The topic filter already guarantees the transmitter, but a receiver with
|
||||||
|
// no callsign or no square cannot be drawn and is not a report of anything.
|
||||||
|
if strings.TrimSpace(msg.RxCall) == "" || len(strings.TrimSpace(msg.RxGrid)) < 4 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r := Report{
|
||||||
|
Call: strings.ToUpper(strings.TrimSpace(msg.RxCall)),
|
||||||
|
Grid: strings.ToUpper(strings.TrimSpace(msg.RxGrid)),
|
||||||
|
Band: strings.ToLower(strings.TrimSpace(msg.Band)),
|
||||||
|
Mode: strings.ToUpper(strings.TrimSpace(msg.Mode)),
|
||||||
|
SNR: msg.SNR,
|
||||||
|
FreqHz: msg.Freq,
|
||||||
|
At: time.Now(),
|
||||||
|
}
|
||||||
|
w.mu.Lock()
|
||||||
|
w.reports = append(w.reports, r)
|
||||||
|
w.received++
|
||||||
|
// A cap as well as the window, so a pathological feed cannot grow this
|
||||||
|
// without bound between two reads.
|
||||||
|
if len(w.reports) > 4000 {
|
||||||
|
w.reports = w.reports[len(w.reports)-2000:]
|
||||||
|
}
|
||||||
|
w.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reports is who has heard us inside the window, freshest report per callsign.
|
||||||
|
//
|
||||||
|
// One entry per STATION, not per message: the same receiver uploading every
|
||||||
|
// five minutes is one pair of ears on the map, and its latest report is the one
|
||||||
|
// that says whether the path is still there.
|
||||||
|
func (w *Watcher) Reports() []Report {
|
||||||
|
cutoff := time.Now().Add(-Window)
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
// Pruned on read rather than on a timer: the only thing that cares about the
|
||||||
|
// window is whoever is looking.
|
||||||
|
kept := w.reports[:0]
|
||||||
|
for _, r := range w.reports {
|
||||||
|
if r.At.After(cutoff) {
|
||||||
|
kept = append(kept, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.reports = kept
|
||||||
|
|
||||||
|
byCall := map[string]int{}
|
||||||
|
out := make([]Report, 0, len(kept))
|
||||||
|
for _, r := range kept {
|
||||||
|
if i, seen := byCall[r.Call]; seen {
|
||||||
|
if r.At.After(out[i].At) {
|
||||||
|
out[i] = r
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byCall[r.Call] = len(out)
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status reports the connection, for the panel.
|
||||||
|
func (w *Watcher) Status() Status {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
online := w.running && w.client != nil && w.client.IsConnected()
|
||||||
|
return Status{
|
||||||
|
Enabled: w.running,
|
||||||
|
Online: online,
|
||||||
|
Reports: w.received,
|
||||||
|
Watching: w.cfg.MyCall,
|
||||||
|
Error: w.lastErr,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package pskrme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// One station uploading every five minutes must be ONE pair of ears on the map,
|
||||||
|
// showing its freshest report — not four arcs to the same square, and not the
|
||||||
|
// oldest of them deciding whether the path still looks open.
|
||||||
|
func TestReportsKeepsTheFreshestPerStation(t *testing.T) {
|
||||||
|
w := New(Config{MyCall: "F4BPO"})
|
||||||
|
now := time.Now()
|
||||||
|
w.reports = []Report{
|
||||||
|
{Call: "OH5CX", Grid: "KP30", SNR: -18, At: now.Add(-9 * time.Minute)},
|
||||||
|
{Call: "W1AW", Grid: "FN31", SNR: -5, At: now.Add(-2 * time.Minute)},
|
||||||
|
{Call: "OH5CX", Grid: "KP30", SNR: -11, At: now.Add(-1 * time.Minute)},
|
||||||
|
}
|
||||||
|
got := w.Reports()
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %d stations, want 2: %+v", len(got), got)
|
||||||
|
}
|
||||||
|
for _, r := range got {
|
||||||
|
if r.Call == "OH5CX" && r.SNR != -11 {
|
||||||
|
t.Errorf("OH5CX kept the %d dB report, want the freshest (-11)", r.SNR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A report older than the window is gone, and gone from the slice too: the map
|
||||||
|
// must not show a path that stopped existing a quarter of an hour ago, and the
|
||||||
|
// window is what keeps this from growing all evening.
|
||||||
|
func TestReportsDropsWhatIsPastTheWindow(t *testing.T) {
|
||||||
|
w := New(Config{MyCall: "F4BPO"})
|
||||||
|
now := time.Now()
|
||||||
|
w.reports = []Report{
|
||||||
|
{Call: "OLD", Grid: "JN36", At: now.Add(-Window - time.Minute)},
|
||||||
|
{Call: "NEW", Grid: "JN36", At: now.Add(-time.Minute)},
|
||||||
|
}
|
||||||
|
got := w.Reports()
|
||||||
|
if len(got) != 1 || got[0].Call != "NEW" {
|
||||||
|
t.Fatalf("got %+v, want only NEW", got)
|
||||||
|
}
|
||||||
|
if len(w.reports) != 1 {
|
||||||
|
t.Errorf("the stale report is still held: %d kept", len(w.reports))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turning the feed off clears what it collected. Left in place, switching it
|
||||||
|
// back on would redraw a map of who heard us before it was on.
|
||||||
|
func TestStopForgetsTheReports(t *testing.T) {
|
||||||
|
w := New(Config{MyCall: "F4BPO"})
|
||||||
|
w.reports = []Report{{Call: "OH5CX", Grid: "KP30", At: time.Now()}}
|
||||||
|
w.Stop()
|
||||||
|
if got := w.Reports(); len(got) != 0 {
|
||||||
|
t.Errorf("got %+v after Stop, want nothing", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No callsign, no subscription: the transmit level would be a wildcard, which
|
||||||
|
// is the entire feed — the one thing this package exists not to ask for.
|
||||||
|
func TestStartRefusesWithoutACallsign(t *testing.T) {
|
||||||
|
if err := New(Config{}).Start(); err == nil {
|
||||||
|
t.Fatal("started with no callsign")
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
-5
@@ -1860,9 +1860,19 @@ type GridSquare struct {
|
|||||||
// return false to drop a QSO. Aggregation to 4 characters happens HERE rather
|
// return false to drop a QSO. Aggregation to 4 characters happens HERE rather
|
||||||
// than in SQL — the column holds 4, 6 and 8-character grids, and lower(substr)
|
// than in SQL — the column holds 4, 6 and 8-character grids, and lower(substr)
|
||||||
// in SQL would differ between SQLite and MySQL for no gain.
|
// in SQL would differ between SQLite and MySQL for no gain.
|
||||||
func (r *Repo) GridSquares(ctx context.Context, keep func(mode string) bool) ([]GridSquare, error) {
|
// GridSquareRow is one contact as the grid map decides whether to keep it.
|
||||||
|
//
|
||||||
|
// Submode is here beside Mode because the mode an operator filters by is not
|
||||||
|
// always the one in MODE: ADIF puts PSK63 in SUBMODE with PSK above it, so a
|
||||||
|
// filter that read MODE alone offered "PSK" for a log full of PSK63.
|
||||||
|
type GridSquareRow struct {
|
||||||
|
Mode, Submode, Band, SatName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) GridSquares(ctx context.Context, keep func(GridSquareRow) bool) ([]GridSquare, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT COALESCE(grid,''), UPPER(COALESCE(mode,'')), LOWER(COALESCE(band,'')),
|
SELECT COALESCE(grid,''), UPPER(COALESCE(mode,'')), UPPER(COALESCE(submode,'')),
|
||||||
|
LOWER(COALESCE(band,'')), UPPER(COALESCE(sat_name,'')),
|
||||||
COALESCE(lotw_rcvd,''), COALESCE(qsl_rcvd,''), COALESCE(eqsl_rcvd,'')
|
COALESCE(lotw_rcvd,''), COALESCE(qsl_rcvd,''), COALESCE(eqsl_rcvd,'')
|
||||||
FROM qso
|
FROM qso
|
||||||
WHERE grid IS NOT NULL AND grid != ''
|
WHERE grid IS NOT NULL AND grid != ''
|
||||||
@@ -1873,11 +1883,11 @@ func (r *Repo) GridSquares(ctx context.Context, keep func(mode string) bool) ([]
|
|||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
out := map[string]*GridSquare{}
|
out := map[string]*GridSquare{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var grid, mode, band, lotw, card, eqsl string
|
var grid, mode, submode, band, satName, lotw, card, eqsl string
|
||||||
if err := rows.Scan(&grid, &mode, &band, &lotw, &card, &eqsl); err != nil {
|
if err := rows.Scan(&grid, &mode, &submode, &band, &satName, &lotw, &card, &eqsl); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if keep != nil && !keep(mode) {
|
if keep != nil && !keep(GridSquareRow{Mode: mode, Submode: submode, Band: band, SatName: satName}) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
g := strings.ToUpper(strings.TrimSpace(grid))
|
g := strings.ToUpper(strings.TrimSpace(grid))
|
||||||
@@ -1914,6 +1924,51 @@ func (r *Repo) GridSquares(ctx context.Context, keep func(mode string) bool) ([]
|
|||||||
return list, nil
|
return list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GridSquareChoices is every mode, band and satellite that the squares on the
|
||||||
|
// map were actually worked on.
|
||||||
|
//
|
||||||
|
// Taken from the log rather than from a list in the code, so a filter can only
|
||||||
|
// ever offer something there is something to see behind — and so a mode that
|
||||||
|
// does not exist yet needs no change here the day an operator starts using it.
|
||||||
|
// The mode is the SUBMODE when there is one: PSK63 is the answer, not PSK.
|
||||||
|
func (r *Repo) GridSquareChoices(ctx context.Context) (modes, bands, sats []string, err error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT DISTINCT UPPER(COALESCE(mode,'')), UPPER(COALESCE(submode,'')),
|
||||||
|
LOWER(COALESCE(band,'')), UPPER(COALESCE(sat_name,''))
|
||||||
|
FROM qso
|
||||||
|
WHERE grid IS NOT NULL AND grid != ''`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, fmt.Errorf("query grid choices: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
seenM, seenB, seenS := map[string]bool{}, map[string]bool{}, map[string]bool{}
|
||||||
|
for rows.Next() {
|
||||||
|
var mode, submode, band, sat string
|
||||||
|
if err := rows.Scan(&mode, &submode, &band, &sat); err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
if m := strings.TrimSpace(submode); m != "" {
|
||||||
|
mode = m
|
||||||
|
}
|
||||||
|
if mode = strings.TrimSpace(mode); mode != "" && !seenM[mode] {
|
||||||
|
seenM[mode] = true
|
||||||
|
modes = append(modes, mode)
|
||||||
|
}
|
||||||
|
if band = strings.TrimSpace(band); band != "" && !seenB[band] {
|
||||||
|
seenB[band] = true
|
||||||
|
bands = append(bands, band)
|
||||||
|
}
|
||||||
|
if sat = strings.TrimSpace(sat); sat != "" && !seenS[sat] {
|
||||||
|
seenS[sat] = true
|
||||||
|
sats = append(sats, sat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
return modes, bands, sats, nil
|
||||||
|
}
|
||||||
|
|
||||||
// BandSlotQSOs returns every contact on one band that belongs to a slot of the
|
// BandSlotQSOs returns every contact on one band that belongs to a slot of the
|
||||||
// entry matrix: the exact callsign, or any callsign in the same DXCC entity.
|
// entry matrix: the exact callsign, or any callsign in the same DXCC entity.
|
||||||
// Mode is NOT filtered here — the class (phone / CW / digital) is a derived
|
// Mode is NOT filtered here — the class (phone / CW / digital) is a derived
|
||||||
|
|||||||
@@ -16,9 +16,12 @@
|
|||||||
// GS-232A subset used:
|
// GS-232A subset used:
|
||||||
//
|
//
|
||||||
// Maaa<CR> move to azimuth aaa (000-450)
|
// Maaa<CR> move to azimuth aaa (000-450)
|
||||||
|
// Waaa eee<CR> move to azimuth aaa AND elevation eee (az/el controllers)
|
||||||
// S<CR> stop rotation
|
// S<CR> stop rotation
|
||||||
// C<CR> query azimuth — replies "+0aaa" (GS-232A) or "AZ=aaa" (GS-232B
|
// C<CR> query azimuth — replies "+0aaa" (GS-232A) or "AZ=aaa" (GS-232B
|
||||||
// flavour); both are parsed.
|
// flavour); both are parsed.
|
||||||
|
// C2<CR> query both axes — "+0aaa+0eee" / "AZ=aaa EL=eee"
|
||||||
|
// B<CR> query elevation alone, for the controllers that do not answer C2
|
||||||
package gs232
|
package gs232
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -242,3 +245,84 @@ func (c *Client) Heading() (az int, raw string, err error) {
|
|||||||
az, _ = strconv.Atoi(m[1])
|
az, _ = strconv.Atoi(m[1])
|
||||||
return az % 360, raw, nil
|
return az % 360, raw, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Elevation: the az/el controllers ---
|
||||||
|
//
|
||||||
|
// The ERC-M (Easy Rotor Control, DF9GR) is the reason this half exists. It
|
||||||
|
// drives a Yaesu G-5500 — the az/el pair most satellite stations own — and
|
||||||
|
// emulates GS-232 over its USB port, so the same three commands that already
|
||||||
|
// pointed an azimuth rotator point a satellite antenna once elevation is added.
|
||||||
|
//
|
||||||
|
// A plain ERC or a microHAM ARCO answers the azimuth commands and ignores
|
||||||
|
// these; that is why the elevation capability is a property of the configured
|
||||||
|
// TYPE and not something probed at runtime. Asking a controller with no
|
||||||
|
// elevation motor where its elevation is gets an answer, and the answer is
|
||||||
|
// zero, for ever.
|
||||||
|
|
||||||
|
// GoToAzEl points an az/el controller at both axes in one command. GS-232's W
|
||||||
|
// takes the two angles separated by a space, azimuth first.
|
||||||
|
//
|
||||||
|
// Elevation is clamped to 0-180 rather than 0-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 (c *Client) GoToAzEl(az, el int) error {
|
||||||
|
az = ((az % 360) + 360) % 360
|
||||||
|
if el < 0 {
|
||||||
|
el = 0
|
||||||
|
}
|
||||||
|
if el > 180 {
|
||||||
|
el = 180
|
||||||
|
}
|
||||||
|
_, err := c.roundTrip(fmt.Sprintf("W%03d %03d", az, el), false)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// elRe matches the elevation half of a reply, in either flavour. The GS-232A
|
||||||
|
// form of C2 is "+0aaa+0eee" — two identically-shaped groups — so the azimuth
|
||||||
|
// is taken from the first match and the elevation from the second, which is
|
||||||
|
// what bothRe below does; this one is for the reply to a bare B.
|
||||||
|
var elRe = regexp.MustCompile(`(?:\+0|EL=)(\d{3})`)
|
||||||
|
|
||||||
|
// bothRe pulls both angles out of a C2 reply.
|
||||||
|
var bothRe = regexp.MustCompile(`(?:\+0|AZ=)(\d{3})[^0-9+]*(?:\+0|EL=)(\d{3})`)
|
||||||
|
|
||||||
|
// Position queries both axes.
|
||||||
|
//
|
||||||
|
// C2 first, because one exchange is one chance for a serial line to go quiet.
|
||||||
|
// Controllers that answer C2 with the azimuth alone — some ERC firmware does —
|
||||||
|
// fall through to the two separate queries rather than reporting an elevation
|
||||||
|
// of zero, which would read as "the antenna is on the horizon" and is the one
|
||||||
|
// wrong answer that looks plausible.
|
||||||
|
func (c *Client) Position() (az, el int, raw string, err error) {
|
||||||
|
raw, err = c.roundTrip("C2", true)
|
||||||
|
if err == nil {
|
||||||
|
if m := bothRe.FindStringSubmatch(raw); m != nil {
|
||||||
|
a, _ := strconv.Atoi(m[1])
|
||||||
|
e, _ := strconv.Atoi(m[2])
|
||||||
|
return a % 360, e, raw, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a, azRaw, aerr := c.Heading()
|
||||||
|
if aerr != nil {
|
||||||
|
return 0, 0, azRaw, aerr
|
||||||
|
}
|
||||||
|
e, elRaw, eerr := c.Elevation()
|
||||||
|
if eerr != nil {
|
||||||
|
return a, 0, azRaw + " " + elRaw, eerr
|
||||||
|
}
|
||||||
|
return a, e, azRaw + " " + elRaw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Elevation queries the elevation axis alone.
|
||||||
|
func (c *Client) Elevation() (el int, raw string, err error) {
|
||||||
|
raw, err = c.roundTrip("B", true)
|
||||||
|
if err != nil {
|
||||||
|
return 0, raw, err
|
||||||
|
}
|
||||||
|
m := elRe.FindStringSubmatch(raw)
|
||||||
|
if m == nil {
|
||||||
|
return 0, raw, fmt.Errorf("unrecognised elevation reply %q", raw)
|
||||||
|
}
|
||||||
|
el, _ = strconv.Atoi(m[1])
|
||||||
|
return el, raw, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,3 +32,68 @@ func TestAzimuthReplies(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The az/el replies an ERC-M sends back to C2, in both flavours. The GS-232A
|
||||||
|
// form is two identical "+0nnn" groups running together with nothing between
|
||||||
|
// them, which is exactly the shape that makes a naive azimuth regex match the
|
||||||
|
// ELEVATION when the azimuth is read a second time.
|
||||||
|
func TestPositionReplies(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
raw string
|
||||||
|
wantAz, wantEl int
|
||||||
|
}{
|
||||||
|
{"+0140+0032\r\n", 140, 32}, // GS-232A, the ERC-M's own form
|
||||||
|
{"+0000+0000\r", 0, 0}, // parked
|
||||||
|
{"AZ=140 EL=032\r\n", 140, 32}, // GS-232B flavour
|
||||||
|
{"AZ=005 EL=090\r\n", 5, 90}, // straight up
|
||||||
|
{"+0270+0180\r\n", 270, 180}, // past the zenith, still counting
|
||||||
|
{"\r\n+0075+0005\r\n", 75, 5}, // a leftover terminator ahead of it
|
||||||
|
{"+0450+0045\r\n", 90, 45}, // 450° mast in its overlap
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
m := bothRe.FindStringSubmatch(strings.TrimSpace(c.raw))
|
||||||
|
if m == nil {
|
||||||
|
t.Errorf("no position found in %q", c.raw)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
az, _ := strconv.Atoi(m[1])
|
||||||
|
el, _ := strconv.Atoi(m[2])
|
||||||
|
if az%360 != c.wantAz || el != c.wantEl {
|
||||||
|
t.Errorf("%q → az %d el %d, want az %d el %d", c.raw, az%360, el, c.wantAz, c.wantEl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A controller that answers C2 with the azimuth alone must NOT be read as
|
||||||
|
// "elevation zero" — that is a plausible-looking wrong answer, the antenna
|
||||||
|
// sitting on the horizon, and it would send the tracker chasing it.
|
||||||
|
func TestPositionRejectsAzimuthOnlyReply(t *testing.T) {
|
||||||
|
for _, raw := range []string{"+0140\r\n", "AZ=140\r\n", "?>\r\n"} {
|
||||||
|
if m := bothRe.FindStringSubmatch(strings.TrimSpace(raw)); m != nil {
|
||||||
|
t.Errorf("%q parsed as a two-axis reply (%v) — it is not one", raw, m[1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reply to a bare B, for the controllers that do not answer C2.
|
||||||
|
func TestElevationReplies(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
raw string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"+0032\r\n", 32},
|
||||||
|
{"EL=032\r\n", 32},
|
||||||
|
{"+0000\r", 0},
|
||||||
|
{"+0090\r\n", 90},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
m := elRe.FindStringSubmatch(strings.TrimSpace(c.raw))
|
||||||
|
if m == nil {
|
||||||
|
t.Errorf("no elevation found in %q", c.raw)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got, _ := strconv.Atoi(m[1]); got != c.want {
|
||||||
|
t.Errorf("%q → %d, want %d", c.raw, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,10 +33,16 @@ const (
|
|||||||
|
|
||||||
// Status is one rotator's live state parsed from a |h reply.
|
// Status is one rotator's live state parsed from a |h reply.
|
||||||
type Status struct {
|
type Status struct {
|
||||||
Azimuth int // current heading in degrees (0..360)
|
Azimuth int // current heading in degrees (0..450 on an overlap rotator)
|
||||||
Connected bool // false when the sensor reports 999 (not connected)
|
Connected bool // false when the sensor reports 999 (not connected)
|
||||||
Moving int // 0 not moving, 1 CW, 2 CCW
|
Moving int // 0 not moving, 1 CW, 2 CCW
|
||||||
Target int // target azimuth when moving (else -1)
|
Target int // target azimuth when moving (else -1)
|
||||||
|
// The soft limits the Genius itself is configured with, as it reports them.
|
||||||
|
// Read rather than assumed: an operator with a 450° mast has told the
|
||||||
|
// Genius so, and that is the authority on how far it will go — OpsLog
|
||||||
|
// asking for 400° on a box configured for 360 is a command it will refuse.
|
||||||
|
LimitCW int
|
||||||
|
LimitCCW int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client is a stateless connector: each call opens a short-lived TCP connection,
|
// Client is a stateless connector: each call opens a short-lived TCP connection,
|
||||||
@@ -128,15 +134,30 @@ func (c *Client) Read(rotator int) (Status, error) {
|
|||||||
cur := atoiField(string(p[base : base+3]))
|
cur := atoiField(string(p[base : base+3]))
|
||||||
moving := atoiField(string(p[base+10 : base+11]))
|
moving := atoiField(string(p[base+10 : base+11]))
|
||||||
target := atoiField(string(p[base+15 : base+18]))
|
target := atoiField(string(p[base+15 : base+18]))
|
||||||
st := Status{Azimuth: cur, Moving: moving, Connected: cur != 999, Target: -1}
|
st := Status{
|
||||||
|
Azimuth: cur, Moving: moving, Connected: cur != 999, Target: -1,
|
||||||
|
LimitCW: atoiField(string(p[base+3 : base+6])),
|
||||||
|
LimitCCW: atoiField(string(p[base+6 : base+9])),
|
||||||
|
}
|
||||||
if target != 999 {
|
if target != 999 {
|
||||||
st.Target = target
|
st.Target = target
|
||||||
}
|
}
|
||||||
return st, nil
|
return st, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GoTo moves the rotator to az (0..360). The reply's status byte is 'K' on
|
// GoTo moves the rotator to az. The reply's status byte is 'K' on accept, 'F'
|
||||||
// accept, 'F' on reject.
|
// on reject.
|
||||||
|
//
|
||||||
|
// The ceiling is 450 and not 360, which is the whole point: a rotator with an
|
||||||
|
// overlap can be asked for 010° as either 10 or 370, and only the second reaches
|
||||||
|
// it without unwinding the cable back through north. The command carries three
|
||||||
|
// digits, so the range was never the protocol's — it was ours, and it left an
|
||||||
|
// operator with a 450° mast clicking "clockwise" by hand every time a bearing
|
||||||
|
// crossed north.
|
||||||
|
//
|
||||||
|
// A Genius configured for a 360° rotator refuses a target beyond its own limit,
|
||||||
|
// which is the correct place for that decision: it knows what is bolted to the
|
||||||
|
// tower, and OpsLog does not.
|
||||||
func (c *Client) GoTo(rotator, az int) error {
|
func (c *Client) GoTo(rotator, az int) error {
|
||||||
if rotator != 1 && rotator != 2 {
|
if rotator != 1 && rotator != 2 {
|
||||||
rotator = 1
|
rotator = 1
|
||||||
@@ -144,8 +165,8 @@ func (c *Client) GoTo(rotator, az int) error {
|
|||||||
if az < 0 {
|
if az < 0 {
|
||||||
az = 0
|
az = 0
|
||||||
}
|
}
|
||||||
if az > 360 {
|
if az > 450 {
|
||||||
az = 360
|
az = 450
|
||||||
}
|
}
|
||||||
reply, err := c.exchange(fmt.Sprintf("|A%d%03d", rotator, az), 8)
|
reply, err := c.exchange(fmt.Sprintf("|A%d%03d", rotator, az), 8)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package sat
|
package sat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -115,6 +118,11 @@ func (t Transponder) Centre() int64 {
|
|||||||
// Bird is one satellite's frequency plan.
|
// Bird is one satellite's frequency plan.
|
||||||
type Bird struct {
|
type Bird struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
// NORAD is the catalog number, and the only exact way to find this
|
||||||
|
// satellite's elements: the feed, AMSAT and the operator all spell the NAME
|
||||||
|
// differently, while the number is carried inside the TLE itself. Aliases
|
||||||
|
// remain for the entries that predate it and for a hand-written plan.
|
||||||
|
NORAD int `json:"norad,omitempty"`
|
||||||
Aliases []string `json:"aliases,omitempty"`
|
Aliases []string `json:"aliases,omitempty"`
|
||||||
// Geostationary: no pass, no Doppler worth correcting, a fixed look angle.
|
// Geostationary: no pass, no Doppler worth correcting, a fixed look angle.
|
||||||
// QO-100 is the reason the flag exists, and it changes what the whole
|
// QO-100 is the reason the flag exists, and it changes what the whole
|
||||||
@@ -190,6 +198,44 @@ func LoadBirds(dir string) (*Birds, error) {
|
|||||||
_ = b.parse(shippedBirds)
|
_ = b.parse(shippedBirds)
|
||||||
return b, fmt.Errorf("sat: %s could not be read (%w) — the shipped list is in use for this session, and your file has been left alone", BirdsName, perr)
|
return b, fmt.Errorf("sat: %s could not be read (%w) — the shipped list is in use for this session, and your file has been left alone", BirdsName, perr)
|
||||||
}
|
}
|
||||||
|
// New satellites AND corrections reach an existing station.
|
||||||
|
//
|
||||||
|
// The operator's copy is written on the first run and was then theirs
|
||||||
|
// for ever, which broke both ways: a release that added nine Tevel-2
|
||||||
|
// satellites reached nobody who had opened the tab, and a frequency we
|
||||||
|
// had shipped WRONG could never be mended — LilacSat-2 went out with an
|
||||||
|
// APRS digipeater and no FM transponder, and the wrong data had become
|
||||||
|
// the operator's own file.
|
||||||
|
//
|
||||||
|
// So the merge adds what is missing, replaces what they never touched,
|
||||||
|
// and leaves alone what they edited. See mergeShipped for how the three
|
||||||
|
// are told apart.
|
||||||
|
added, updated, kept, replaced := b.mergeShipped(dir)
|
||||||
|
if added > 0 || updated > 0 {
|
||||||
|
// A one-time copy before the first run that can overwrite an entry
|
||||||
|
// we have no baseline for. Cheap insurance on a file an operator may
|
||||||
|
// have spent an evening correcting.
|
||||||
|
if len(replaced) > 0 {
|
||||||
|
if err := os.WriteFile(path+".bak", data, 0o644); err == nil {
|
||||||
|
log.Printf("sat: %s copied to %s.bak before the plan was brought up to date", BirdsName, BirdsName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out, merr := json.MarshalIndent(b.list, "", " "); merr == nil {
|
||||||
|
_ = os.WriteFile(path, append(out, '\n'), 0o644)
|
||||||
|
}
|
||||||
|
log.Printf("sat: frequency plan — %d satellites added, %d brought up to date", added, updated)
|
||||||
|
}
|
||||||
|
if len(replaced) > 0 {
|
||||||
|
log.Printf("sat: %s taken from the shipped plan (no record of what this station was given). "+
|
||||||
|
"If one of those was your own correction, it is in %s.bak", strings.Join(replaced, ", "), BirdsName)
|
||||||
|
}
|
||||||
|
if len(kept) > 0 {
|
||||||
|
// Named, not silent: an operator who corrected a frequency should be
|
||||||
|
// able to see that OpsLog noticed and stood down.
|
||||||
|
log.Printf("sat: your own edits kept for %s — delete them from %s to take the shipped plan instead",
|
||||||
|
strings.Join(kept, ", "), BirdsName)
|
||||||
|
}
|
||||||
|
writeBaseline(dir)
|
||||||
return b, nil
|
return b, nil
|
||||||
case os.IsNotExist(err):
|
case os.IsNotExist(err):
|
||||||
if perr := b.parse(shippedBirds); perr != nil {
|
if perr := b.parse(shippedBirds); perr != nil {
|
||||||
@@ -197,6 +243,7 @@ func LoadBirds(dir string) (*Birds, error) {
|
|||||||
}
|
}
|
||||||
if werr := os.MkdirAll(dir, 0o755); werr == nil {
|
if werr := os.MkdirAll(dir, 0o755); werr == nil {
|
||||||
_ = os.WriteFile(path, shippedBirds, 0o644)
|
_ = os.WriteFile(path, shippedBirds, 0o644)
|
||||||
|
writeBaseline(dir)
|
||||||
}
|
}
|
||||||
return b, nil
|
return b, nil
|
||||||
default:
|
default:
|
||||||
@@ -283,3 +330,170 @@ func (b *Birds) Len() int {
|
|||||||
defer b.mu.RUnlock()
|
defer b.mu.RUnlock()
|
||||||
return len(b.list)
|
return len(b.list)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BaselineName records the shipped plan as it was last handed to this station.
|
||||||
|
//
|
||||||
|
// It exists so a CORRECTION can reach an operator who already has the file.
|
||||||
|
// Without it the merge could only add satellites, never mend one: LilacSat-2
|
||||||
|
// shipped with an APRS digipeater and no FM transponder, and every station that
|
||||||
|
// had already opened the satellite tab was stuck with it for ever — the wrong
|
||||||
|
// frequency was the operator's file now, and their file was sacred.
|
||||||
|
const BaselineName = "satellites.shipped.json"
|
||||||
|
|
||||||
|
// mergeShipped brings the shipped plan into the operator's list.
|
||||||
|
//
|
||||||
|
// Three cases, and the middle one is the point:
|
||||||
|
//
|
||||||
|
// - A satellite they do not have is ADDED. That is how new birds arrive.
|
||||||
|
// - A satellite they have, UNCHANGED from the plan they were given, is
|
||||||
|
// REPLACED by the current one. They never edited it, so it is not theirs to
|
||||||
|
// keep — it is our data, and ours was wrong.
|
||||||
|
// - A satellite they have EDITED is left exactly as it is, and said so in the
|
||||||
|
// log. A frequency somebody corrected by hand outranks anything shipped:
|
||||||
|
// they were on the air and we were not.
|
||||||
|
//
|
||||||
|
// "Unchanged" is decided against the baseline, so the comparison is with the
|
||||||
|
// plan THEY were given rather than with whatever ships today. Their edits
|
||||||
|
// therefore survive every future release, not just the next one.
|
||||||
|
func (b *Birds) mergeShipped(dir string) (added, updated int, kept, replaced []string) {
|
||||||
|
var list []Bird
|
||||||
|
if err := json.Unmarshal(shippedBirds, &list); err != nil {
|
||||||
|
return 0, 0, nil, nil
|
||||||
|
}
|
||||||
|
baseline := readBaseline(dir)
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
byNORAD := map[int]int{} // catalog number → index in b.list
|
||||||
|
byName := map[string]int{}
|
||||||
|
for i, x := range b.list {
|
||||||
|
if x.NORAD != 0 {
|
||||||
|
byNORAD[x.NORAD] = i
|
||||||
|
}
|
||||||
|
for _, n := range append([]string{x.Name}, x.Aliases...) {
|
||||||
|
if k := loose(n); k != "" {
|
||||||
|
if _, seen := byName[k]; !seen {
|
||||||
|
byName[k] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
find := func(c Bird) int {
|
||||||
|
if c.NORAD != 0 {
|
||||||
|
if i, ok := byNORAD[c.NORAD]; ok {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range append([]string{c.Name}, c.Aliases...) {
|
||||||
|
if i, ok := byName[loose(n)]; ok {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cand := range list {
|
||||||
|
i := find(cand)
|
||||||
|
if i < 0 {
|
||||||
|
b.list = append(b.list, cand)
|
||||||
|
added++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sameBird(b.list[i], cand) {
|
||||||
|
continue // already current
|
||||||
|
}
|
||||||
|
was, hadBaseline := baseline[birdKey(cand)]
|
||||||
|
switch {
|
||||||
|
case !hadBaseline:
|
||||||
|
// FIRST run after baselines existed, and there is no record of what
|
||||||
|
// this station was given — so an edit of theirs and a mistake of
|
||||||
|
// ours are indistinguishable here.
|
||||||
|
//
|
||||||
|
// The shipped plan wins, ONCE, and the whole file is backed up
|
||||||
|
// first. Standing down instead would have been the safe-looking
|
||||||
|
// choice and the wrong one: the baseline written at the end of this
|
||||||
|
// run would then record their entry as "edited" and freeze a
|
||||||
|
// frequency we know to be wrong for the life of the install. A
|
||||||
|
// backup and a log line are recoverable; that is not.
|
||||||
|
replaced = append(replaced, b.list[i].Name)
|
||||||
|
b.list[i] = cand
|
||||||
|
updated++
|
||||||
|
case sameBird(b.list[i], was):
|
||||||
|
b.list[i] = cand
|
||||||
|
updated++
|
||||||
|
default:
|
||||||
|
kept = append(kept, b.list[i].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.reindexLocked()
|
||||||
|
b.mu.Unlock()
|
||||||
|
return added, updated, kept, replaced
|
||||||
|
}
|
||||||
|
|
||||||
|
// birdKey identifies a satellite across versions: the catalog number when there
|
||||||
|
// is one, the loose name otherwise.
|
||||||
|
func birdKey(x Bird) string {
|
||||||
|
if x.NORAD != 0 {
|
||||||
|
return "n:" + strconv.Itoa(x.NORAD)
|
||||||
|
}
|
||||||
|
return "s:" + loose(x.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameBird compares two plans for one satellite by VALUE — the frequencies, the
|
||||||
|
// modes, the tone, the labels. Field by field through JSON rather than one
|
||||||
|
// comparison per field, so a transponder field added later cannot silently drop
|
||||||
|
// out of the test and start reporting equal plans as different.
|
||||||
|
func sameBird(a, c Bird) bool {
|
||||||
|
ja, ea := json.Marshal(a)
|
||||||
|
jc, ec := json.Marshal(c)
|
||||||
|
if ea != nil || ec != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return bytes.Equal(ja, jc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readBaseline loads the shipped plan this station was last given.
|
||||||
|
func readBaseline(dir string) map[string]Bird {
|
||||||
|
out := map[string]Bird{}
|
||||||
|
raw, err := os.ReadFile(filepath.Join(dir, BaselineName))
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
var list []Bird
|
||||||
|
if json.Unmarshal(raw, &list) != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, x := range list {
|
||||||
|
out[birdKey(x)] = x
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeBaseline records what was shipped, so the NEXT release can tell an
|
||||||
|
// operator's correction from one of ours.
|
||||||
|
func writeBaseline(dir string) {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(filepath.Join(dir, BaselineName), shippedBirds, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reindexLocked rebuilds the name lookup after the list has changed.
|
||||||
|
func (b *Birds) reindexLocked() {
|
||||||
|
byKey := make(map[string]int, len(b.list)*3)
|
||||||
|
put := func(name string, i int) {
|
||||||
|
if k := loose(name); k != "" {
|
||||||
|
if _, seen := byKey[k]; !seen {
|
||||||
|
byKey[k] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, bird := range b.list {
|
||||||
|
put(bird.Name, i)
|
||||||
|
}
|
||||||
|
for i, bird := range b.list {
|
||||||
|
for _, a := range bird.Aliases {
|
||||||
|
put(a, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.byKey = byKey
|
||||||
|
}
|
||||||
|
|||||||
+673
-276
File diff suppressed because it is too large
Load Diff
+229
-4
@@ -1,6 +1,7 @@
|
|||||||
package sat
|
package sat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -158,8 +159,19 @@ func TestLoadBirdsWritesTheEditableCopy(t *testing.T) {
|
|||||||
t.Fatalf("the editable copy was not written: %v", err)
|
t.Fatalf("the editable copy was not written: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// An operator's own list is what gets used from then on.
|
// An operator's own list is kept, AND the shipped satellites they have never
|
||||||
mine := `[{"name":"MY-SAT","transponders":[{"label":"FM","mode":"FM","down_lo":1,"up_lo":2}]}]`
|
// seen are added to it.
|
||||||
|
//
|
||||||
|
// The merge is what carries a new satellite to a station that has already
|
||||||
|
// run OpsLog once: without it, the copy written on the very first launch was
|
||||||
|
// the operator's list for ever, and a release adding nine Tevel-2 birds
|
||||||
|
// reached nobody. What it must never do is take something back — so the
|
||||||
|
// operator's own satellite, and their correction to a shipped one, both have
|
||||||
|
// to survive it.
|
||||||
|
mine := `[
|
||||||
|
{"name":"MY-SAT","transponders":[{"label":"FM","mode":"FM","down_lo":1,"up_lo":2}]},
|
||||||
|
{"name":"SO-50","norad":27607,"transponders":[{"label":"corrected","mode":"FM","down_lo":436796000,"up_lo":145850000,"ctcss":74.4}]}
|
||||||
|
]`
|
||||||
if err := os.WriteFile(path, []byte(mine), 0o644); err != nil {
|
if err := os.WriteFile(path, []byte(mine), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -167,8 +179,23 @@ func TestLoadBirdsWritesTheEditableCopy(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if b.Len() != 1 {
|
if b.Len() < shipped {
|
||||||
t.Fatalf("the operator's list was not used: %d satellites", b.Len())
|
t.Fatalf("the shipped satellites were not merged in: %d, expected at least %d", b.Len(), shipped)
|
||||||
|
}
|
||||||
|
if _, ok := b.Find("MY-SAT"); !ok {
|
||||||
|
t.Error("the operator's own satellite was dropped by the merge")
|
||||||
|
}
|
||||||
|
// Their correction stands: same tone, same frequency, not the shipped one.
|
||||||
|
got, ok := b.Find("SO-50")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("SO-50 vanished")
|
||||||
|
}
|
||||||
|
if len(got.Transponders) != 1 || got.Transponders[0].CTCSS != 74.4 || got.Transponders[0].DownLo != 436796000 {
|
||||||
|
t.Errorf("the operator's correction to SO-50 was overwritten: %+v", got.Transponders)
|
||||||
|
}
|
||||||
|
// And a shipped satellite they had never seen is now there.
|
||||||
|
if _, ok := b.Find("AO-7"); !ok {
|
||||||
|
t.Error("a shipped satellite was not added to the operator's list")
|
||||||
}
|
}
|
||||||
|
|
||||||
// And a broken one falls back without destroying what they wrote.
|
// And a broken one falls back without destroying what they wrote.
|
||||||
@@ -186,3 +213,201 @@ func TestLoadBirdsWritesTheEditableCopy(t *testing.T) {
|
|||||||
t.Error("the operator's broken file was overwritten")
|
t.Error("the operator's broken file was overwritten")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The shipped plan is generated (cmd/satgen) from public databases, so it is
|
||||||
|
// worth its own guard, on top of TestShippedBirds above: a bad regeneration
|
||||||
|
// should fail here rather than mistune an antenna on somebody first pass.
|
||||||
|
func TestGeneratedBirdsAreSane(t *testing.T) {
|
||||||
|
var list []Bird
|
||||||
|
if err := json.Unmarshal(shippedBirds, &list); err != nil {
|
||||||
|
t.Fatalf("birds.json does not parse: %v", err)
|
||||||
|
}
|
||||||
|
// The generator joins three feeds. If one of them answered with nothing, the
|
||||||
|
// output silently shrinks, and this is where that shows up.
|
||||||
|
if len(list) < 30 {
|
||||||
|
t.Errorf("only %d satellites shipped — the generator probably ran against an empty feed", len(list))
|
||||||
|
}
|
||||||
|
seenNORAD := map[int]string{}
|
||||||
|
seenName := map[string]bool{}
|
||||||
|
for _, b := range list {
|
||||||
|
if b.Name == "" {
|
||||||
|
t.Error("a satellite with no name")
|
||||||
|
}
|
||||||
|
if seenName[loose(b.Name)] {
|
||||||
|
t.Errorf("%s appears twice", b.Name)
|
||||||
|
}
|
||||||
|
seenName[loose(b.Name)] = true
|
||||||
|
// Two entries for one catalog number is one satellite the operator can
|
||||||
|
// pick twice, with two different sets of frequencies.
|
||||||
|
if b.NORAD != 0 {
|
||||||
|
if other, dup := seenNORAD[b.NORAD]; dup {
|
||||||
|
t.Errorf("NORAD %d is both %s and %s", b.NORAD, other, b.Name)
|
||||||
|
}
|
||||||
|
seenNORAD[b.NORAD] = b.Name
|
||||||
|
}
|
||||||
|
for _, tp := range b.Transponders {
|
||||||
|
// An amateur satellite works between 15 m and 24 GHz. Anything outside
|
||||||
|
// that is a units mistake, and a units mistake is how a rig ends up
|
||||||
|
// commanded somewhere it cannot go.
|
||||||
|
for _, hz := range []int64{tp.DownLo, tp.DownHi, tp.UpLo, tp.UpHi} {
|
||||||
|
if hz != 0 && (hz < 21_000_000 || hz > 24_000_000_000) {
|
||||||
|
t.Errorf("%s: %d Hz is not an amateur satellite frequency", b.Name, hz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Inversion only means something across a passband. The code ignores
|
||||||
|
// the flag on a channel, but a file that claims an FM repeater inverts
|
||||||
|
// will mislead whoever reads it next.
|
||||||
|
if tp.Inverting && !tp.Linear() {
|
||||||
|
t.Errorf("%s: %q is a channel and cannot invert", b.Name, tp.Label)
|
||||||
|
}
|
||||||
|
switch tp.Mode {
|
||||||
|
case "FM", "SSB", "CW", "DATA":
|
||||||
|
default:
|
||||||
|
t.Errorf("%s: %q is not an ADIF mode the log can store", b.Name, tp.Mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three cases the merge has to tell apart. Getting the middle one wrong is
|
||||||
|
// how LilacSat-2 shipped with no FM transponder and could never be mended.
|
||||||
|
func TestMergeShippedRespectsEditsAndFixesOurs(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// First run: the shipped plan and its baseline are written.
|
||||||
|
if _, err := LoadBirds(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, BaselineName)); err != nil {
|
||||||
|
t.Fatalf("no baseline was recorded: %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, BirdsName)
|
||||||
|
|
||||||
|
// The operator corrects SO-50's tone and adds a satellite of their own.
|
||||||
|
var list []Bird
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &list); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var untouched Bird
|
||||||
|
for i := range list {
|
||||||
|
if list[i].Name == "SO-50" {
|
||||||
|
list[i].Transponders[0].CTCSS = 74.4
|
||||||
|
}
|
||||||
|
if list[i].Name == "AO-7" {
|
||||||
|
untouched = list[i] // left exactly as shipped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list = append(list, Bird{Name: "MY-SAT", Transponders: []Transponder{{Label: "mine", Mode: "FM", DownLo: 145000000, UpLo: 435000000}}})
|
||||||
|
out, _ := json.MarshalIndent(list, "", " ")
|
||||||
|
if err := os.WriteFile(path, out, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load again. Nothing shipped has changed, so nothing should move.
|
||||||
|
b, err := LoadBirds(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, ok := b.Find("SO-50")
|
||||||
|
if !ok || got.Transponders[0].CTCSS != 74.4 {
|
||||||
|
t.Errorf("the operator's tone was lost: %+v", got.Transponders)
|
||||||
|
}
|
||||||
|
if _, ok := b.Find("MY-SAT"); !ok {
|
||||||
|
t.Error("the operator's own satellite was dropped")
|
||||||
|
}
|
||||||
|
if a, ok := b.Find("AO-7"); !ok || !sameBird(a, untouched) {
|
||||||
|
t.Error("an untouched satellite was altered for no reason")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An entry the operator never touched is REPLACED when the shipped plan
|
||||||
|
// changes. That is the whole point: our data, and ours was wrong.
|
||||||
|
func TestMergeShippedUpdatesWhatWasNeverEdited(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if _, err := LoadBirds(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, BirdsName)
|
||||||
|
|
||||||
|
// Pretend an older release shipped SO-50 with a wrong downlink, and that the
|
||||||
|
// operator simply took it: their file AND the baseline both hold the wrong
|
||||||
|
// value, which is exactly what "never edited" looks like.
|
||||||
|
rewrite := func(p string, mutate func(*Bird)) {
|
||||||
|
raw, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var l []Bird
|
||||||
|
if err := json.Unmarshal(raw, &l); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := range l {
|
||||||
|
if l[i].Name == "SO-50" {
|
||||||
|
mutate(&l[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out, _ := json.MarshalIndent(l, "", " ")
|
||||||
|
if err := os.WriteFile(p, out, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wrong := func(x *Bird) { x.Transponders[0].DownLo = 1 }
|
||||||
|
rewrite(path, wrong)
|
||||||
|
rewrite(filepath.Join(dir, BaselineName), wrong)
|
||||||
|
|
||||||
|
b, err := LoadBirds(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, ok := b.Find("SO-50")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("SO-50 vanished")
|
||||||
|
}
|
||||||
|
if got.Transponders[0].DownLo == 1 {
|
||||||
|
t.Error("a value the operator never edited was not brought up to date — a shipped mistake is unfixable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With NO baseline — the first run after baselines existed — the shipped plan
|
||||||
|
// wins and the file is backed up. Standing down would freeze a known-wrong
|
||||||
|
// frequency for the life of the install.
|
||||||
|
func TestMergeShippedWithNoBaselineTakesShippedAndBacksUp(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if _, err := LoadBirds(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, BirdsName)
|
||||||
|
if err := os.Remove(filepath.Join(dir, BaselineName)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A wrong value, with nothing to say whether it is ours or theirs.
|
||||||
|
raw, _ := os.ReadFile(path)
|
||||||
|
var l []Bird
|
||||||
|
if err := json.Unmarshal(raw, &l); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := range l {
|
||||||
|
if l[i].Name == "SO-50" {
|
||||||
|
l[i].Transponders[0].DownLo = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out, _ := json.MarshalIndent(l, "", " ")
|
||||||
|
if err := os.WriteFile(path, out, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := LoadBirds(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := b.Find("SO-50")
|
||||||
|
if got.Transponders[0].DownLo == 1 {
|
||||||
|
t.Error("the shipped plan did not take over, so the wrong value is now frozen for ever")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path + ".bak"); err != nil {
|
||||||
|
t.Errorf("no backup was written before overwriting: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+58
-4
@@ -186,6 +186,27 @@ func (s *Store) Get(name string) (Element, bool) {
|
|||||||
return e, ok
|
return e, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetNORAD returns one satellite's elements by catalog number.
|
||||||
|
//
|
||||||
|
// The exact join, and the only one that stays exact. A name is written
|
||||||
|
// differently by every party involved — the feed says "RADFXSAT (FOX-1B)", the
|
||||||
|
// operator says "AO-91", AMSAT's chart says both — and matching on letters and
|
||||||
|
// digits gets most of them and quietly misses the rest. The catalog number is
|
||||||
|
// in the TLE itself and is what a frequency plan should carry.
|
||||||
|
func (s *Store) GetNORAD(n int) (Element, bool) {
|
||||||
|
if n <= 0 {
|
||||||
|
return Element{}, false
|
||||||
|
}
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
for _, k := range s.order {
|
||||||
|
if e := s.byKey[k]; e.NORAD == n {
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Element{}, false
|
||||||
|
}
|
||||||
|
|
||||||
// Names lists what the store holds, in the order it arrived.
|
// Names lists what the store holds, in the order it arrived.
|
||||||
func (s *Store) Names() []string {
|
func (s *Store) Names() []string {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
@@ -250,9 +271,6 @@ func (e Element) Track(obs Observer, at time.Time) (Position, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Position{}, fmt.Errorf("sat: %q: %w", e.Name, err)
|
return Position{}, fmt.Errorf("sat: %q: %w", e.Name, err)
|
||||||
}
|
}
|
||||||
// The state vector carries the position AND the velocity, which is what the
|
|
||||||
// look angle needs for the range rate — and the range rate is the whole of
|
|
||||||
// the Doppler shift.
|
|
||||||
sv := &sgp4.StateVector{
|
sv := &sgp4.StateVector{
|
||||||
X: eci.Position.X, Y: eci.Position.Y, Z: eci.Position.Z,
|
X: eci.Position.X, Y: eci.Position.Y, Z: eci.Position.Z,
|
||||||
VX: eci.Velocity.X, VY: eci.Velocity.Y, VZ: eci.Velocity.Z,
|
VX: eci.Velocity.X, VY: eci.Velocity.Y, VZ: eci.Velocity.Z,
|
||||||
@@ -271,10 +289,46 @@ func (e Element) Track(obs Observer, at time.Time) (Position, error) {
|
|||||||
Az: o.LookAngles.Azimuth,
|
Az: o.LookAngles.Azimuth,
|
||||||
El: o.LookAngles.Elevation,
|
El: o.LookAngles.Elevation,
|
||||||
RangeKm: o.LookAngles.Range,
|
RangeKm: o.LookAngles.Range,
|
||||||
RangeRate: o.LookAngles.RangeRate,
|
RangeRate: e.rangeRate(loc, at.UTC()),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rangeRate is how fast the satellite is closing or opening, in km/s.
|
||||||
|
//
|
||||||
|
// MEASURED, not taken from the propagator. The library reports a range rate
|
||||||
|
// that is wrong by a factor of some 250 AND has the wrong sign — the ISS at
|
||||||
|
// −5.5 km/s (closing) came back as +2036 km/s — which put the Doppler
|
||||||
|
// correction hundreds of kilohertz out and moved it the wrong way. The
|
||||||
|
// difference between two ranges a second apart cannot be wrong in either
|
||||||
|
// respect: it differentiates the very number the panel displays.
|
||||||
|
//
|
||||||
|
// Two extra propagations per call. SGP4 costs microseconds and this runs at
|
||||||
|
// most a few hundred times a second across every satellite on screen, so the
|
||||||
|
// price of being right here is not worth optimising away.
|
||||||
|
func (e Element) rangeRate(loc *sgp4.Location, at time.Time) float64 {
|
||||||
|
const dt = time.Second // ±1 s: far below any curvature in the range, far above float noise
|
||||||
|
before, ok1 := e.rangeAt(loc, at.Add(-dt))
|
||||||
|
after, ok2 := e.rangeAt(loc, at.Add(dt))
|
||||||
|
if !ok1 || !ok2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return (after - before) / (2 * dt.Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
// rangeAt is the distance to the satellite at one instant, in km.
|
||||||
|
func (e Element) rangeAt(loc *sgp4.Location, at time.Time) (float64, bool) {
|
||||||
|
eci, err := e.tle.FindPositionAtTime(at.UTC())
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
sv := &sgp4.StateVector{X: eci.Position.X, Y: eci.Position.Y, Z: eci.Position.Z}
|
||||||
|
o, err := sv.GetLookAngle(loc, at.UTC())
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return o.LookAngles.Range, true
|
||||||
|
}
|
||||||
|
|
||||||
// earthRadiusKm is the mean radius — the footprint is a circle drawn on a
|
// earthRadiusKm is the mean radius — the footprint is a circle drawn on a
|
||||||
// sphere, and a metre of flattening does not show at that scale.
|
// sphere, and a metre of flattening does not show at that scale.
|
||||||
const earthRadiusKm = 6371.0
|
const earthRadiusKm = 6371.0
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/akhenakh/sgp4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A real ISS element set, and the answers a second tracker agrees with. The
|
// A real ISS element set, and the answers a second tracker agrees with. The
|
||||||
@@ -16,6 +18,9 @@ const (
|
|||||||
issLine2 = "2 25544 51.6392 121.4587 0007976 86.1587 27.9639 15.50126585478227"
|
issLine2 = "2 25544 51.6392 121.4587 0007976 86.1587 27.9639 15.50126585478227"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// testLoc is the same observer, in the form the internal range helper takes.
|
||||||
|
var testLoc = sgp4.Location{Latitude: 48.5, Longitude: 3.0}
|
||||||
|
|
||||||
func issElement(t *testing.T) Element {
|
func issElement(t *testing.T) Element {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
e, err := ParseElement(issName, issLine1, issLine2)
|
e, err := ParseElement(issName, issLine1, issLine2)
|
||||||
@@ -170,3 +175,71 @@ func TestStoreReplaceKeepsOrderAndStampsTheFetch(t *testing.T) {
|
|||||||
t.Error("an unknown satellite was tracked anyway")
|
t.Error("an unknown satellite was tracked anyway")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The range rate is the whole of the Doppler shift, and it was wrong in both
|
||||||
|
// magnitude and sign — the propagator library reported +2036 km/s for an ISS
|
||||||
|
// that was closing at 5.5, which moved the correction hundreds of kilohertz the
|
||||||
|
// wrong way. These are the two things about it that cannot be argued with.
|
||||||
|
func TestRangeRateIsPhysical(t *testing.T) {
|
||||||
|
e := issElement(t)
|
||||||
|
obs := Observer{Lat: 48.5, Lon: 3.0}
|
||||||
|
// A day's worth, sampled across every geometry a pass goes through.
|
||||||
|
base := e.Epoch.Add(2 * time.Hour)
|
||||||
|
for i := 0; i < 240; i++ {
|
||||||
|
at := base.Add(time.Duration(i) * 6 * time.Minute)
|
||||||
|
p, err := e.Track(obs, at)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("track: %v", err)
|
||||||
|
}
|
||||||
|
// Nothing in low earth orbit closes faster than it flies, and it flies
|
||||||
|
// at about 7.7 km/s. A figure outside this is a units mistake.
|
||||||
|
if math.Abs(p.RangeRate) > 8 {
|
||||||
|
t.Fatalf("%s: range rate %.1f km/s — faster than orbital velocity", at.Format(time.RFC3339), p.RangeRate)
|
||||||
|
}
|
||||||
|
// And it must be the derivative of the range we display, sign included.
|
||||||
|
before, _ := e.rangeAt(&testLoc, at.Add(-2*time.Second))
|
||||||
|
after, _ := e.rangeAt(&testLoc, at.Add(2*time.Second))
|
||||||
|
want := (after - before) / 4
|
||||||
|
if math.Abs(p.RangeRate-want) > 0.05 {
|
||||||
|
t.Errorf("%s: range rate %.3f but the range moves at %.3f km/s",
|
||||||
|
at.Format(time.RFC3339), p.RangeRate, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Doppler that comes out of it, on the two bands satellites are worked on.
|
||||||
|
// A LEO gives about ±3.5 kHz on 2 m and ±10 kHz on 70 cm; ten times either is
|
||||||
|
// the bug this pins.
|
||||||
|
func TestDopplerStaysWithinTheTextbookRange(t *testing.T) {
|
||||||
|
e := issElement(t)
|
||||||
|
obs := Observer{Lat: 48.5, Lon: 3.0}
|
||||||
|
base := e.Epoch.Add(2 * time.Hour)
|
||||||
|
var maxVHF, maxUHF int64
|
||||||
|
for i := 0; i < 480; i++ {
|
||||||
|
p, err := e.Track(obs, base.Add(time.Duration(i)*3*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vhf := Doppler(p, 145_800_000, 0).DownHz - 145_800_000
|
||||||
|
uhf := Doppler(p, 437_800_000, 0).DownHz - 437_800_000
|
||||||
|
if a := abs64(vhf); a > maxVHF {
|
||||||
|
maxVHF = a
|
||||||
|
}
|
||||||
|
if a := abs64(uhf); a > maxUHF {
|
||||||
|
maxUHF = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxVHF < 1_500 || maxVHF > 5_000 {
|
||||||
|
t.Errorf("2 m Doppler peaks at %d Hz, expected roughly 3.5 kHz", maxVHF)
|
||||||
|
}
|
||||||
|
if maxUHF < 5_000 || maxUHF > 14_000 {
|
||||||
|
t.Errorf("70 cm Doppler peaks at %d Hz, expected roughly 10 kHz", maxUHF)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func abs64(v int64) int64 {
|
||||||
|
if v < 0 {
|
||||||
|
return -v
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|||||||
@@ -741,3 +741,28 @@ func portBusyHint(mode, com string, err error) string {
|
|||||||
}
|
}
|
||||||
return " — another program already has " + com + " open (the SteppIR control window, PstRotator, a terminal). A COM port has one owner: close the other program, then OpsLog can connect."
|
return " — another program already has " + com + " open (the SteppIR control window, PstRotator, a terminal). A COM port has one owner: close the other program, then OpsLog can connect."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calibrate runs the controller's calibration: the elements are driven to their
|
||||||
|
// end stops so the controller re-learns where zero is.
|
||||||
|
//
|
||||||
|
// It is the cure for an antenna that tunes to the wrong length after a power cut
|
||||||
|
// mid-move, after the elements have been retracted by hand, or after a motor has
|
||||||
|
// slipped — the controller counts steps from a remembered position, and once
|
||||||
|
// that memory is wrong every frequency after it is wrong by the same amount.
|
||||||
|
//
|
||||||
|
// It takes MINUTES and moves every element the whole way, so it is not something
|
||||||
|
// to do during a contest. Like Retract it drops the controller out of AUTOTRACK,
|
||||||
|
// which is handled transparently: the next SetFrequency re-issues AUTOTRACK ON.
|
||||||
|
func (c *Client) Calibrate() error {
|
||||||
|
// A valid frequency accompanies every SET frame; the controller ignores it
|
||||||
|
// for this command, but a malformed frame is refused outright.
|
||||||
|
khz := c.LastSetKHz()
|
||||||
|
if khz <= 0 {
|
||||||
|
if st, _ := c.GetStatus(); st != nil && st.Frequency > 0 {
|
||||||
|
khz = st.Frequency
|
||||||
|
} else {
|
||||||
|
khz = 14000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.writeCmd(buildSet(khz*1000, DirNormal, 'V'))
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ import (
|
|||||||
const (
|
const (
|
||||||
cmdNull = 0x13
|
cmdNull = 0x13
|
||||||
cmdAdmin = 0x00
|
cmdAdmin = 0x00
|
||||||
|
adminReset = 0x01
|
||||||
adminOpen = 0x02
|
adminOpen = 0x02
|
||||||
|
adminClose = 0x03
|
||||||
adminEcho = 0x04
|
adminEcho = 0x04
|
||||||
echoProbe = 0x55 // K1EL's own choice; any byte works, this one is 0b01010101
|
echoProbe = 0x55 // K1EL's own choice; any byte works, this one is 0b01010101
|
||||||
bootDelay = 400 * time.Millisecond
|
bootDelay = 400 * time.Millisecond
|
||||||
@@ -75,7 +77,11 @@ func hostOpen(p serial.Port, slowBoot bool) (ver int, needsSlowBoot bool, err er
|
|||||||
if attempt > 1 || slowBoot {
|
if attempt > 1 || slowBoot {
|
||||||
wait = resetDelay
|
wait = resetDelay
|
||||||
}
|
}
|
||||||
ver, err := hostOpenOnce(p, wait)
|
// A port already known to need the slow path gets the wake-up on the
|
||||||
|
// FIRST attempt too: it needed it last time, and making the operator
|
||||||
|
// wait through a failure to earn it again is a connect that takes twice
|
||||||
|
// as long for no new information.
|
||||||
|
ver, err := hostOpenOnce(p, wait, attempt > 1 || slowBoot)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if attempt > 1 {
|
if attempt > 1 {
|
||||||
applog.Printf("winkeyer: answered on attempt %d — this keyer needs %s to boot (a K3NG or another Arduino keyer with auto-reset on); remembering that for this port", attempt, wait)
|
applog.Printf("winkeyer: answered on attempt %d — this keyer needs %s to boot (a K3NG or another Arduino keyer with auto-reset on); remembering that for this port", attempt, wait)
|
||||||
@@ -90,7 +96,10 @@ func hostOpen(p serial.Port, slowBoot bool) (ver int, needsSlowBoot bool, err er
|
|||||||
return 0, false, lastErr
|
return 0, false, lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func hostOpenOnce(p serial.Port, boot time.Duration) (int, error) {
|
func hostOpenOnce(p serial.Port, boot time.Duration, hard bool) (int, error) {
|
||||||
|
if hard {
|
||||||
|
recoverKeyer(p)
|
||||||
|
}
|
||||||
// The keyer may still be booting off the DTR line we just raised.
|
// The keyer may still be booting off the DTR line we just raised.
|
||||||
time.Sleep(boot)
|
time.Sleep(boot)
|
||||||
drain(p)
|
drain(p)
|
||||||
@@ -130,12 +139,60 @@ func hostOpenOnce(p serial.Port, boot time.Duration) (int, error) {
|
|||||||
}
|
}
|
||||||
ver, ok := readByte(p, openTimeout)
|
ver, ok := readByte(p, openTimeout)
|
||||||
traceHandshake("RX", nil, ver, ok)
|
traceHandshake("RX", nil, ver, ok)
|
||||||
|
if !ok {
|
||||||
|
// It answered the echo, so there IS a keyer on this port — it simply
|
||||||
|
// will not open. A keyer already IN host mode does exactly that: a
|
||||||
|
// previous session that ended badly never sent Host Close, and it has
|
||||||
|
// been waiting ever since for a host that went away. Close it and ask
|
||||||
|
// again.
|
||||||
|
applog.Printf("winkeyer: echoed but did not open — closing a host session left over from last time, and asking again")
|
||||||
|
if _, err := p.Write([]byte{cmdAdmin, adminClose}); err != nil {
|
||||||
|
return 0, fmt.Errorf("host close: %w", err)
|
||||||
|
}
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
drain(p)
|
||||||
|
traceHandshake("TX", open, 0, false)
|
||||||
|
if _, err := p.Write(open); err != nil {
|
||||||
|
return 0, fmt.Errorf("host open: %w", err)
|
||||||
|
}
|
||||||
|
ver, ok = readByte(p, openTimeout)
|
||||||
|
traceHandshake("RX", nil, ver, ok)
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, errors.New("host open: the keyer echoed but did not return its firmware version")
|
return 0, errors.New("host open: the keyer echoed but did not return its firmware version")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return int(ver), nil
|
return int(ver), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recoverKeyer does to the keyer what running K1EL's WKdemo and closing it
|
||||||
|
// again does — which is the workaround an operator found for a WKUSB that
|
||||||
|
// OpsLog could not open until they had.
|
||||||
|
//
|
||||||
|
// Three things, in an order that survives each of them failing:
|
||||||
|
//
|
||||||
|
// - Host Close, in case the keyer is still in host mode from a session that
|
||||||
|
// ended without one: a crash, a cable pulled, a machine switched off.
|
||||||
|
// - Admin Reset, which returns it to its power-up state. A parser stuck
|
||||||
|
// part-way through a command whose parameters will never arrive cannot be
|
||||||
|
// talked out of it any other way.
|
||||||
|
// - A DTR pulse. That is what closing another program actually does to the
|
||||||
|
// line, and on the boxes that wire DTR to the processor's reset — a WKUSB,
|
||||||
|
// and every Arduino-based clone — it is a power-on reset in all but name.
|
||||||
|
//
|
||||||
|
// RTS is left alone throughout: on a serial WinKeyer it is the negative rail
|
||||||
|
// the RS-232 swing comes from, and driving it starves the chip.
|
||||||
|
func recoverKeyer(p serial.Port) {
|
||||||
|
applog.Printf("winkeyer: waking the keyer — host close, reset, then a DTR pulse")
|
||||||
|
_, _ = p.Write([]byte{cmdNull, cmdNull, cmdNull, cmdAdmin, adminClose})
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
_, _ = p.Write([]byte{cmdAdmin, adminReset})
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
_ = p.SetDTR(false)
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
_ = p.SetDTR(true)
|
||||||
|
drain(p)
|
||||||
|
}
|
||||||
|
|
||||||
// traceHandshake puts the opening exchange in the log, ALWAYS — unlike the
|
// traceHandshake puts the opening exchange in the log, ALWAYS — unlike the
|
||||||
// running trace beside it, which is behind the diagnostic option.
|
// running trace beside it, which is behind the diagnostic option.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -58,7 +59,17 @@ func acquireInstance(wait bool) bool {
|
|||||||
if !wait {
|
if !wait {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
deadline := time.Now().Add(20 * time.Second)
|
// Forty-five seconds, not twenty.
|
||||||
|
//
|
||||||
|
// The instance we are waiting for is allowed THIRTY to shut down — see
|
||||||
|
// armExitWatchdog, which force-exits it at that point — because it closes a
|
||||||
|
// remote logbook, a CAT session and sometimes a backup on the way out. A
|
||||||
|
// twenty-second patience was therefore shorter than the wait it existed for,
|
||||||
|
// and on a station where shutdown ran long the new instance gave up while
|
||||||
|
// the old one was still finishing: no new window after an update, and a
|
||||||
|
// leftover OpsLog in the task manager. This is the backstop; --wait-pid
|
||||||
|
// below is the real answer.
|
||||||
|
deadline := time.Now().Add(45 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
time.Sleep(300 * time.Millisecond)
|
time.Sleep(300 * time.Millisecond)
|
||||||
if acquireSingleInstance() {
|
if acquireSingleInstance() {
|
||||||
@@ -68,6 +79,23 @@ func acquireInstance(wait bool) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// waitPidArg reads "--wait-pid N": the process this one must outlive.
|
||||||
|
func waitPidArg(args []string) int {
|
||||||
|
for i, a := range args {
|
||||||
|
if a == "--wait-pid" && i+1 < len(args) {
|
||||||
|
if n, err := strconv.Atoi(args[i+1]); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := strings.CutPrefix(a, "--wait-pid="); ok {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// processStart is stamped on the very first instruction of main, before the
|
// processStart is stamped on the very first instruction of main, before the
|
||||||
// single-instance guard and before anything else runs.
|
// single-instance guard and before anything else runs.
|
||||||
//
|
//
|
||||||
@@ -90,6 +118,20 @@ func main() {
|
|||||||
// to free instead of bailing out. Then clear the old exe it left behind.
|
// to free instead of bailing out. Then clear the old exe it left behind.
|
||||||
bootLogLaunch()
|
bootLogLaunch()
|
||||||
postUpdate := hasFlag(os.Args[1:], "--post-update")
|
postUpdate := hasFlag(os.Args[1:], "--post-update")
|
||||||
|
// The instance that started us is still shutting down. Wait for it to
|
||||||
|
// actually END — a kernel wait on its handle, which finishes the instant it
|
||||||
|
// does — rather than hoping the mutex frees inside a fixed window. This is
|
||||||
|
// what the old PowerShell helper did, and losing it is what left an operator
|
||||||
|
// with no window after an update and the previous OpsLog still in the task
|
||||||
|
// manager.
|
||||||
|
if pid := waitPidArg(os.Args[1:]); pid > 0 {
|
||||||
|
bootLog("waiting for the previous instance (pid %d) to exit", pid)
|
||||||
|
if waitForProcessExit(pid, 60*time.Second) {
|
||||||
|
bootLog("the previous instance is gone")
|
||||||
|
} else {
|
||||||
|
bootLog("the previous instance (pid %d) is STILL running after 60s — trying anyway", pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
// A self-relaunch (database switch) races its own parent: the new process
|
// A self-relaunch (database switch) races its own parent: the new process
|
||||||
// regularly wins the start against the old one's teardown, and the operator
|
// regularly wins the start against the old one's teardown, and the operator
|
||||||
// got "OpsLog is already running" for following instructions. Same patience
|
// got "OpsLog is already running" for following instructions. Same patience
|
||||||
@@ -100,7 +142,16 @@ func main() {
|
|||||||
// window, no data folder, no log, which is indistinguishable from a
|
// window, no data folder, no log, which is indistinguishable from a
|
||||||
// program that died on its first instruction.
|
// program that died on its first instruction.
|
||||||
bootLog("another instance already holds the single-instance mutex - exiting")
|
bootLog("another instance already holds the single-instance mutex - exiting")
|
||||||
|
if postUpdate {
|
||||||
|
// After an update the ordinary message is a lie by omission: the
|
||||||
|
// operator did not start a second copy, the update did, and what
|
||||||
|
// they need to know is that the PREVIOUS version never finished
|
||||||
|
// closing.
|
||||||
|
fatalBox("OpsLog", "The previous version of OpsLog has not finished closing, so the updated one cannot start.\n\n"+
|
||||||
|
"Close the leftover OpsLog.exe in the Task Manager, then start OpsLog again — the update is already installed.")
|
||||||
|
} else {
|
||||||
fatalBox("OpsLog", "OpsLog is already running.\n\nLook for its window, or for a leftover OpsLog.exe in the Task Manager, and close it before starting another.")
|
fatalBox("OpsLog", "OpsLog is already running.\n\nLook for its window, or for a leftover OpsLog.exe in the Task Manager, and close it before starting another.")
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
bootLog("single-instance mutex acquired")
|
bootLog("single-instance mutex acquired")
|
||||||
@@ -111,8 +162,7 @@ func main() {
|
|||||||
// OpsLog had was inside the folder it could not create.
|
// OpsLog had was inside the folder it could not create.
|
||||||
if err := checkDataDirWritable(); err != nil {
|
if err := checkDataDirWritable(); err != nil {
|
||||||
bootLog("FATAL %v", err)
|
bootLog("FATAL %v", err)
|
||||||
fatalBox("OpsLog", "OpsLog cannot write next to its own program file.\n\n"+err.Error()+
|
fatalBox("OpsLog", "OpsLog cannot write next to its own program file.\n\n"+err.Error()+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.")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if postUpdate {
|
if postUpdate {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hideConsole has nothing to hide on Linux: a child started from a GUI process
|
||||||
|
// inherits no console, so no window can flash.
|
||||||
|
func hideConsole(cmd *exec.Cmd) {}
|
||||||
|
|
||||||
|
// runningProcessNames returns the set of lowercase executable names currently
|
||||||
|
// running, read straight from /proc rather than by shelling out to `ps` — the
|
||||||
|
// output format of `ps` varies between distributions and busybox, /proc does
|
||||||
|
// not.
|
||||||
|
//
|
||||||
|
// Two names are recorded per process: the kernel's comm (truncated to 15
|
||||||
|
// characters, which is why it cannot be the only one — "gridtracker-bin" and
|
||||||
|
// "wsjtx-improved" both hit the limit) and the basename of the real executable
|
||||||
|
// behind /proc/<pid>/exe. Either may be what the operator configured.
|
||||||
|
func runningProcessNames() map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
entries, err := os.ReadDir("/proc")
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("autostart: cannot read /proc: %v", err)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pid := e.Name()
|
||||||
|
if _, err := strconv.Atoi(pid); err != nil {
|
||||||
|
continue // not a process directory
|
||||||
|
}
|
||||||
|
if comm, err := os.ReadFile("/proc/" + pid + "/comm"); err == nil {
|
||||||
|
if n := strings.ToLower(strings.TrimSpace(string(comm))); n != "" {
|
||||||
|
out[n] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The exe symlink is unreadable for processes owned by another user;
|
||||||
|
// that is expected and not worth a log line.
|
||||||
|
if exe, err := os.Readlink("/proc/" + pid + "/exe"); err == nil {
|
||||||
|
if n := strings.ToLower(filepath.Base(strings.TrimSuffix(exe, " (deleted)"))); n != "" {
|
||||||
|
out[n] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeProcess asks the process to close. SIGTERM is the Unix equivalent of the
|
||||||
|
// polite WM_CLOSE used on Windows: WSJT-X and friends get to save their state
|
||||||
|
// instead of being shot with SIGKILL.
|
||||||
|
func closeProcess(pid int) ([]byte, error) {
|
||||||
|
p, err := os.FindProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, p.Signal(syscall.SIGTERM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// executableFilters is what the "choose a program" dialog offers. Nothing to
|
||||||
|
// filter on here: a Linux program is a file with the executable bit, and its
|
||||||
|
// name carries no extension — wsjtx, gridtracker, flrig. An *.exe filter would
|
||||||
|
// show the operator an empty folder.
|
||||||
|
func executableFilters() []wruntime.FileFilter {
|
||||||
|
return []wruntime.FileFilter{{DisplayName: "All files", Pattern: "*"}}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hideConsole keeps a helper process from flashing a console window. OpsLog is
|
||||||
|
// a GUI application, and every `tasklist`/`taskkill`/`powershell` it runs would
|
||||||
|
// otherwise blink a black box in front of the operator mid-QSO.
|
||||||
|
func hideConsole(cmd *exec.Cmd) {
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
hideConsole(cmd)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeProcess asks the process to close. `taskkill` without /F sends WM_CLOSE,
|
||||||
|
// so WSJT-X and friends get to save their state instead of being shot.
|
||||||
|
func closeProcess(pid int) ([]byte, error) {
|
||||||
|
cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid))
|
||||||
|
hideConsole(cmd)
|
||||||
|
return cmd.CombinedOutput()
|
||||||
|
}
|
||||||
|
|
||||||
|
// executableFilters is what the "choose a program" dialog offers. Windows knows
|
||||||
|
// a program by its extension.
|
||||||
|
func executableFilters() []wruntime.FileFilter {
|
||||||
|
return []wruntime.FileFilter{
|
||||||
|
{DisplayName: "Programs (*.exe;*.bat;*.cmd)", Pattern: "*.exe;*.bat;*.cmd"},
|
||||||
|
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The sent side must never start at Y.
|
||||||
|
//
|
||||||
|
// Y means "already sent", so the uploader skips the contact for ever. An
|
||||||
|
// operator whose eQSL default was Y had a logbook that never reached eQSL and
|
||||||
|
// nothing on screen to explain it — the log said "not eligible, EQSLSent
|
||||||
|
// already Y" and that is the only place it was ever said.
|
||||||
|
func TestNoConfirmationDefaultsToAlreadySent(t *testing.T) {
|
||||||
|
d := defaultQSLDefaults()
|
||||||
|
v := reflect.ValueOf(d)
|
||||||
|
for i := 0; i < v.NumField(); i++ {
|
||||||
|
name := v.Type().Field(i).Name
|
||||||
|
val := strings.ToUpper(strings.TrimSpace(v.Field(i).String()))
|
||||||
|
if val == "Y" {
|
||||||
|
t.Errorf("%s defaults to Y — every new QSO would be skipped by its uploader for ever", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every field has a default. A service added without one comes back blank,
|
||||||
|
// which is not a status anybody chose — HAMLOG.online arrived that way and
|
||||||
|
// every existing profile got nothing for it.
|
||||||
|
func TestEveryConfirmationHasADefault(t *testing.T) {
|
||||||
|
d := defaultQSLDefaults()
|
||||||
|
v := reflect.ValueOf(d)
|
||||||
|
for i := 0; i < v.NumField(); i++ {
|
||||||
|
if strings.TrimSpace(v.Field(i).String()) == "" {
|
||||||
|
t.Errorf("%s has no default — add it to defaultQSLDefaults", v.Type().Field(i).Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A profile saved before a service existed has no value stored for it, and
|
||||||
|
// gets the shipped one rather than a blank.
|
||||||
|
func TestStoredBlanksAreFilledFromTheDefaults(t *testing.T) {
|
||||||
|
// What an old profile looks like: the services it knew about are set, the
|
||||||
|
// ones added later are empty.
|
||||||
|
stored := QSLDefaults{
|
||||||
|
QSLSent: "N", QSLRcvd: "N",
|
||||||
|
EQSLSent: "R", EQSLRcvd: "N",
|
||||||
|
LOTWSent: "R", LOTWRcvd: "N",
|
||||||
|
ClublogStatus: "R", ClublogCfm: "N", HRDLogStatus: "R",
|
||||||
|
QRZComStatus: "R", QRZComCfm: "N",
|
||||||
|
}
|
||||||
|
got := fillMissingQSLDefaults(stored)
|
||||||
|
if got.HamlogStatus != "R" || got.HamlogCfm != "N" || got.HamqthStatus != "R" {
|
||||||
|
t.Errorf("services added later were left blank: hamlog=%q/%q hamqth=%q",
|
||||||
|
got.HamlogStatus, got.HamlogCfm, got.HamqthStatus)
|
||||||
|
}
|
||||||
|
// And a value the operator DID choose is untouched.
|
||||||
|
chosen := QSLDefaults{QSLSent: "I", EQSLSent: "N"}
|
||||||
|
filled := fillMissingQSLDefaults(chosen)
|
||||||
|
if filled.QSLSent != "I" || filled.EQSLSent != "N" {
|
||||||
|
t.Errorf("a chosen status was overwritten: %q / %q", filled.QSLSent, filled.EQSLSent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWaitPidArg(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"after the flag", []string{"--post-update", "--wait-pid", "4321"}, 4321},
|
||||||
|
{"joined with =", []string{"--wait-pid=4321"}, 4321},
|
||||||
|
{"absent", []string{"--post-update"}, 0},
|
||||||
|
{"flag with nothing after it", []string{"--wait-pid"}, 0},
|
||||||
|
{"not a number", []string{"--wait-pid", "later"}, 0},
|
||||||
|
} {
|
||||||
|
if got := waitPidArg(tc.args); got != tc.want {
|
||||||
|
t.Errorf("%s: got %d, wanted %d", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every relaunch has to tell the new process which one to wait for.
|
||||||
|
//
|
||||||
|
// The auto-update relaunch lost that when its PowerShell helper was removed —
|
||||||
|
// the helper had waited for the pid, and nothing took over the job — and an
|
||||||
|
// operator was left with no window after an update and the previous OpsLog
|
||||||
|
// still running. This keeps the two spawn sites honest: if a relaunch is added
|
||||||
|
// without --wait-pid, it is the same bug again.
|
||||||
|
func TestEveryRelaunchPassesItsPid(t *testing.T) {
|
||||||
|
spawn := regexp.MustCompile(`exec\.Command\(exe, "--(post-update|relaunch)"[^)]*\)`)
|
||||||
|
for _, file := range []string{"update.go", "app.go"} {
|
||||||
|
src, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", file, err)
|
||||||
|
}
|
||||||
|
for _, call := range spawn.FindAllString(string(src), -1) {
|
||||||
|
if !strings.Contains(call, "--wait-pid") {
|
||||||
|
t.Errorf("%s: %s does not tell the new instance which process to wait for", file, call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Which rotors the satellite page may offer. Getting this wrong is not a
|
||||||
|
// cosmetic fault: a rotor listed as az/el that has no elevation motor is a
|
||||||
|
// tracker sending W commands into a controller that ignores them, and a pass
|
||||||
|
// spent wondering why the antenna never lifts.
|
||||||
|
func TestRotorHasElevation(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
dev RotatorDevice
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"ERC-M drives both axes of a G-5500", RotatorDevice{Type: "erc"}, true},
|
||||||
|
{"EasyComm is an az/el protocol", RotatorDevice{Type: "easycomm"}, true},
|
||||||
|
{"an ARCO is azimuth only", RotatorDevice{Type: "arco"}, false},
|
||||||
|
{"a Rotator Genius is azimuth only", RotatorDevice{Type: "rotgenius"}, false},
|
||||||
|
{"a DCU-1 is azimuth only", RotatorDevice{Type: "dcu1"}, false},
|
||||||
|
|
||||||
|
// SPID: the dialect decides. Rot1Prog has no elevation in its reply
|
||||||
|
// format at all, so offering it would be offering a rotor that cannot
|
||||||
|
// answer the question.
|
||||||
|
{"SPID Rot2Prog has elevation", RotatorDevice{Type: "spid", SpidModel: "rot2prog"}, true},
|
||||||
|
{"SPID defaults to Rot2Prog", RotatorDevice{Type: "spid"}, true},
|
||||||
|
{"SPID Rot1Prog does not", RotatorDevice{Type: "spid", SpidModel: "rot1prog"}, false},
|
||||||
|
|
||||||
|
// PstRotator: the elevation belongs to the station, not the protocol.
|
||||||
|
// PstRotator will happily forward EL to a controller that has no
|
||||||
|
// elevation motor, so only the operator can answer this one.
|
||||||
|
{"PstRotator with elevation declared", RotatorDevice{Type: "pst", HasElevation: true}, true},
|
||||||
|
{"PstRotator without", RotatorDevice{Type: "pst"}, false},
|
||||||
|
|
||||||
|
// An unknown type falls back to PstRotator, and must not fall back to
|
||||||
|
// "has elevation" with it.
|
||||||
|
{"an unknown backend", RotatorDevice{Type: "nonsense"}, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := rotorHasElevation(c.dev); got != c.want {
|
||||||
|
t.Errorf("%s: got %v, want %v", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The satellite page stores a rotor KEY, not a list index: deleting the first
|
||||||
|
// rotor must not silently point the tracker at a different mast.
|
||||||
|
func TestFlattenRotorsKeys(t *testing.T) {
|
||||||
|
devs := []RotatorDevice{
|
||||||
|
{ID: "a", Name: "HF", Type: "pst"},
|
||||||
|
{ID: "b", Name: "Sat", Type: "erc"},
|
||||||
|
{ID: "c", Name: "RG", Type: "rotgenius", Dual: true, Name2: "RG 2"},
|
||||||
|
}
|
||||||
|
got := flattenRotors(devs)
|
||||||
|
want := []struct {
|
||||||
|
key string
|
||||||
|
hasEl bool
|
||||||
|
}{
|
||||||
|
{"a", false},
|
||||||
|
{"b", true},
|
||||||
|
{"c", false},
|
||||||
|
{"c#2", false}, // the second port of a Dual Rotator Genius
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("got %d logical rotors, want %d", len(got), len(want))
|
||||||
|
}
|
||||||
|
for i, w := range want {
|
||||||
|
if got[i].Key != w.key || got[i].HasEl != w.hasEl {
|
||||||
|
t.Errorf("rotor %d: key %q el %v, want key %q el %v", i, got[i].Key, got[i].HasEl, w.key, w.hasEl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each backend's default baud, because a blanket 9600 is silence on two of
|
||||||
|
// them: a SPID runs at 600 and an ERC-M ships at 19200, and a wrong rate reads
|
||||||
|
// exactly like a dead controller.
|
||||||
|
func TestRotorDefaultBaud(t *testing.T) {
|
||||||
|
cases := map[string]int{"spid": 600, "erc": 19200, "dcu1": 4800, "arco": 9600, "easycomm": 9600}
|
||||||
|
for typ, want := range cases {
|
||||||
|
if got := rotorTypeInfo(typ).DefaultBaud; got != want {
|
||||||
|
t.Errorf("%s: default baud %d, want %d", typ, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,3 +12,16 @@ func onSomeMonitorImpl(x, y, w, h int) bool { return true }
|
|||||||
func clampToVisible(x, y, w, h int) (int, int, bool) { return x, y, false }
|
func clampToVisible(x, y, w, h int) (int, int, bool) { return x, y, false }
|
||||||
|
|
||||||
func logMonitorLayout() {}
|
func logMonitorLayout() {}
|
||||||
|
|
||||||
|
// monitorRects cannot be answered off Windows: Wails exposes no monitor
|
||||||
|
// geometry, and X11/Wayland disagree about whether a client may even ask. The
|
||||||
|
// empty list is the "cannot tell" the callers above already handle by trusting
|
||||||
|
// the operator's saved position.
|
||||||
|
func monitorRects() []screenRect { return nil }
|
||||||
|
|
||||||
|
// describeMonitors still has to say something in the log — a window nobody can
|
||||||
|
// see is reported as "OpsLog did not start", and the log line is the first
|
||||||
|
// thing looked at.
|
||||||
|
func describeMonitors(rects []screenRect) string {
|
||||||
|
return "monitor layout unavailable on this platform"
|
||||||
|
}
|
||||||
|
|||||||
Executable
+154
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# linux-setup.sh — check what a Linux build of OpsLog needs, then build it.
|
||||||
|
#
|
||||||
|
# Run it from the repository root: ./scripts/linux-setup.sh
|
||||||
|
#
|
||||||
|
# It never installs anything itself. It prints the one command your distribution
|
||||||
|
# needs and stops, because a script that runs sudo on somebody else's machine is
|
||||||
|
# a script nobody should run. Once the dependencies are in, run it again and it
|
||||||
|
# builds.
|
||||||
|
set -u
|
||||||
|
|
||||||
|
red() { printf '\033[31m%s\033[0m\n' "$*"; }
|
||||||
|
grn() { printf '\033[32m%s\033[0m\n' "$*"; }
|
||||||
|
ylw() { printf '\033[33m%s\033[0m\n' "$*"; }
|
||||||
|
head_() { printf '\n\033[1m== %s\033[0m\n' "$*"; }
|
||||||
|
|
||||||
|
missing=0
|
||||||
|
note() { red " MISSING: $*"; missing=1; }
|
||||||
|
ok() { grn " ok: $*"; }
|
||||||
|
|
||||||
|
head_ "Distribution"
|
||||||
|
if [ -r /etc/os-release ]; then
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. /etc/os-release
|
||||||
|
echo " ${PRETTY_NAME:-unknown}"
|
||||||
|
family="${ID_LIKE:-$ID}"
|
||||||
|
else
|
||||||
|
echo " unknown (no /etc/os-release)"
|
||||||
|
family=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
head_ "Build dependencies"
|
||||||
|
have() { command -v "$1" >/dev/null 2>&1; }
|
||||||
|
pkg() { pkg-config --exists "$1" 2>/dev/null; }
|
||||||
|
|
||||||
|
have gcc || have cc || note "a C compiler (Wails links against the system WebKit, so cgo is required here)"
|
||||||
|
have pkg-config || note "pkg-config"
|
||||||
|
pkg gtk+-3.0 || note "GTK 3 development headers"
|
||||||
|
|
||||||
|
# Wails 2.10+ builds against webkit2gtk-4.1; Debian 12 and older still ship 4.0,
|
||||||
|
# which works with an extra build tag. Detect which one is present rather than
|
||||||
|
# telling the operator to guess.
|
||||||
|
webkit_tags=""
|
||||||
|
if pkg webkit2gtk-4.1; then
|
||||||
|
ok "webkit2gtk-4.1"
|
||||||
|
elif pkg webkit2gtk-4.0; then
|
||||||
|
ok "webkit2gtk-4.0 (older — will build with -tags webkit2_40)"
|
||||||
|
webkit_tags="-tags webkit2_40"
|
||||||
|
else
|
||||||
|
note "webkit2gtk development headers (4.1 preferred, 4.0 accepted)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Node, and its VERSION — this is the trap on an LTS base. Ubuntu 22.04 (so
|
||||||
|
# Linux Mint 21) ships node 12, and Vite needs 18. The build fails deep inside
|
||||||
|
# the frontend with a syntax error that says nothing about the real cause, so
|
||||||
|
# catch it here where it can be named.
|
||||||
|
if have node; then
|
||||||
|
nodemaj=$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)
|
||||||
|
if [ "${nodemaj:-0}" -ge 18 ]; then
|
||||||
|
ok "node $(node -v)"
|
||||||
|
else
|
||||||
|
note "node 18 or newer (you have $(node -v) — the distribution package is too old for Vite)"
|
||||||
|
ylw " curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - && sudo apt install nodejs"
|
||||||
|
ylw " or use nvm: https://github.com/nvm-sh/nvm"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
note "node 18+"
|
||||||
|
fi
|
||||||
|
have npm || note "npm"
|
||||||
|
|
||||||
|
head_ "Go"
|
||||||
|
if have go; then
|
||||||
|
gov=$(go version | awk '{print $3}')
|
||||||
|
ok "$gov"
|
||||||
|
# 1.25 is what go.mod asks for. No distribution ships it yet — Ubuntu 22.04
|
||||||
|
# (Mint 21) has 1.18, Ubuntu 24.04 (Mint 22) has 1.22 — so `apt install
|
||||||
|
# golang-go` gets you a Go that cannot build this repository at all.
|
||||||
|
if ! go version | grep -Eq 'go1\.(2[5-9]|[3-9][0-9])'; then
|
||||||
|
note "go 1.25+ — $gov is too old for go.mod, and no apt package is new enough"
|
||||||
|
ylw " wget https://go.dev/dl/go1.25.1.linux-amd64.tar.gz"
|
||||||
|
ylw " sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.25.1.linux-amd64.tar.gz"
|
||||||
|
ylw " echo 'export PATH=/usr/local/go/bin:\$HOME/go/bin:\$PATH' >> ~/.profile # then log out and back in"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
note "go 1.25+ — from https://go.dev/dl/, NOT from apt (no distribution ships 1.25 yet)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
head_ "Wails CLI"
|
||||||
|
WAILS="$(command -v wails || true)"
|
||||||
|
[ -z "$WAILS" ] && [ -x "$HOME/go/bin/wails" ] && WAILS="$HOME/go/bin/wails"
|
||||||
|
if [ -n "$WAILS" ]; then
|
||||||
|
ok "$($WAILS version 2>/dev/null | head -1)"
|
||||||
|
else
|
||||||
|
note "the wails CLI: go install github.com/wailsapp/wails/v2/cmd/[email protected]"
|
||||||
|
ylw " (then make sure ~/go/bin is on your PATH)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$missing" -ne 0 ]; then
|
||||||
|
head_ "Install these first"
|
||||||
|
case "$family" in
|
||||||
|
*debian*|*ubuntu*)
|
||||||
|
echo " sudo apt install build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev"
|
||||||
|
echo " (on Debian 12 and older: libwebkit2gtk-4.0-dev)"
|
||||||
|
echo
|
||||||
|
echo " Go and node are NOT in that line on purpose — the apt versions are too"
|
||||||
|
echo " old on every current Ubuntu/Mint base. See the notes above for both." ;;
|
||||||
|
*fedora*|*rhel*)
|
||||||
|
echo " sudo dnf install gcc-c++ pkgconf-pkg-config gtk3-devel webkit2gtk4.1-devel nodejs npm" ;;
|
||||||
|
*arch*)
|
||||||
|
echo " sudo pacman -S base-devel pkgconf gtk3 webkit2gtk-4.1 nodejs npm" ;;
|
||||||
|
*suse*)
|
||||||
|
echo " sudo zypper install -t pattern devel_basis && sudo zypper install pkg-config gtk3-devel webkit2gtk3-soup2-devel nodejs npm" ;;
|
||||||
|
*)
|
||||||
|
echo " a C compiler, pkg-config, GTK 3 and webkit2gtk development packages, node and npm" ;;
|
||||||
|
esac
|
||||||
|
echo
|
||||||
|
echo "Then run this script again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
head_ "Serial ports"
|
||||||
|
# The single most common reason a rig that works in WSJT-X refuses to open here.
|
||||||
|
if id -nG | tr ' ' '\n' | grep -qx 'dialout\|uucp'; then
|
||||||
|
ok "you are in the serial group"
|
||||||
|
else
|
||||||
|
ylw " You are NOT in the 'dialout' group (or 'uucp' on Arch/Fedora)."
|
||||||
|
ylw " CAT, keyers, rotators and amplifiers will fail with \"permission denied\"."
|
||||||
|
ylw " sudo usermod -aG dialout \$USER # then log out and back in"
|
||||||
|
fi
|
||||||
|
|
||||||
|
head_ "Sound server"
|
||||||
|
if have pactl && pactl info >/dev/null 2>&1; then
|
||||||
|
ok "$(pactl info | sed -n 's/^Server Name: //p')"
|
||||||
|
else
|
||||||
|
ylw " No PulseAudio/PipeWire server answered. The voice keyer, the QSO"
|
||||||
|
ylw " recorder and the CW decoder will report they cannot reach it."
|
||||||
|
ylw " Everything else works without one."
|
||||||
|
fi
|
||||||
|
|
||||||
|
head_ "Building"
|
||||||
|
echo " wails build $webkit_tags"
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
"$WAILS" build $webkit_tags || { red "Build failed."; exit 1; }
|
||||||
|
|
||||||
|
head_ "Done"
|
||||||
|
grn " ./build/bin/OpsLog"
|
||||||
|
echo
|
||||||
|
echo " First run creates build/bin/data/ beside the binary — a fresh, empty"
|
||||||
|
echo " logbook. Do NOT copy config.json over from Windows: it holds a Windows"
|
||||||
|
echo " path. Point OpsLog at your real logbook from Settings ▸ Database."
|
||||||
|
echo
|
||||||
|
echo " If the window never opens, look at:"
|
||||||
|
echo " ~/.cache/OpsLog/startup.log (before the window)"
|
||||||
|
echo " build/bin/data/opslog.log (after it)"
|
||||||
@@ -44,3 +44,13 @@ func TestTidySerialPortsIgnoresCase(t *testing.T) {
|
|||||||
t.Errorf("got %v, want [COM3]", got)
|
t.Errorf("got %v, want [COM3]", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On Linux the ports are paths, and the same trap is waiting there:
|
||||||
|
// /dev/ttyUSB10 sorted lexically lands between USB1 and USB2.
|
||||||
|
func TestTidySerialPortsSortsUnixNamesNaturally(t *testing.T) {
|
||||||
|
got := tidySerialPorts([]string{"/dev/ttyUSB10", "/dev/ttyUSB2", "/dev/ttyACM0", "/dev/ttyUSB1"})
|
||||||
|
want := []string{"/dev/ttyACM0", "/dev/ttyUSB1", "/dev/ttyUSB2", "/dev/ttyUSB10"}
|
||||||
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||||
|
t.Errorf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
//go:build linux && !bindings
|
||||||
|
|
||||||
|
// NB the !bindings tag: Wails generates the TypeScript bindings by BUILDING AND
|
||||||
|
// RUNNING this binary. With the guard active, a normal OpsLog already running on
|
||||||
|
// the dev machine holds the lock, the generator's process exits instantly, and
|
||||||
|
// no bindings are produced. Excluding the guard from that build keeps generation
|
||||||
|
// working while shipping builds still get it.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Linux half of the single-instance guard. Windows uses a named mutex; here
|
||||||
|
// it is an advisory lock (flock) held on a file for as long as the process
|
||||||
|
// lives.
|
||||||
|
//
|
||||||
|
// A lock and not a pid file, because a pid file is wrong exactly when it
|
||||||
|
// matters: OpsLog killed by the OOM killer, or crashing on a bad rig response,
|
||||||
|
// leaves its pid behind and every later launch refuses to start. The kernel
|
||||||
|
// drops an flock when the process ends however it ends, so there is no stale
|
||||||
|
// state to clean up and no "delete this file to start again" for the operator
|
||||||
|
// to discover.
|
||||||
|
var instanceLock *os.File
|
||||||
|
|
||||||
|
// instanceLockPath prefers XDG_RUNTIME_DIR (/run/user/1000) — per user, and
|
||||||
|
// emptied when the session ends, which is what a runtime lock wants. The cache
|
||||||
|
// directory is the fallback for the sessions that do not set it (a bare TTY, an
|
||||||
|
// ssh -X login).
|
||||||
|
func instanceLockPath() string {
|
||||||
|
dir := os.Getenv("XDG_RUNTIME_DIR")
|
||||||
|
if dir == "" {
|
||||||
|
dir = bootLogDir()
|
||||||
|
}
|
||||||
|
dir = filepath.Join(dir, "OpsLog")
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "instance.lock")
|
||||||
|
}
|
||||||
|
|
||||||
|
// acquireSingleInstance reports whether this process now owns the instance
|
||||||
|
// lock. Safe to call repeatedly: the retry loop in acquireInstance does, and a
|
||||||
|
// second flock on a second descriptor of the same file would conflict with the
|
||||||
|
// one we already hold.
|
||||||
|
func acquireSingleInstance() bool {
|
||||||
|
if instanceLock != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
path := instanceLockPath()
|
||||||
|
if path == "" {
|
||||||
|
applog.Printf("single-instance: no writable folder for the lock — the guard is off for this run")
|
||||||
|
return true // fail open: refusing to start is worse than a possible duplicate
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("single-instance: cannot open %s (%v) — the guard is off for this run", path, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return false // another OpsLog holds it
|
||||||
|
}
|
||||||
|
// The pid is written for the operator's benefit, not ours — it is what a
|
||||||
|
// "which process is holding this?" question needs. The lock itself is the
|
||||||
|
// kernel's, and does not depend on the contents.
|
||||||
|
_ = f.Truncate(0)
|
||||||
|
_, _ = f.WriteString(strconv.Itoa(os.Getpid()) + "\n")
|
||||||
|
_ = f.Sync()
|
||||||
|
instanceLock = f // deliberately never closed: closing releases the lock
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForProcessExit waits for pid to disappear, up to timeout, and reports
|
||||||
|
// whether it did. Signal 0 asks the kernel "does this process exist?" without
|
||||||
|
// touching it.
|
||||||
|
//
|
||||||
|
// EPERM means it exists and belongs to somebody else — still running, as far as
|
||||||
|
// the caller is concerned. Only ESRCH is gone.
|
||||||
|
func waitForProcessExit(pid int, timeout time.Duration) bool {
|
||||||
|
if pid <= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if err := syscall.Kill(pid, 0); err == syscall.ESRCH {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return syscall.Kill(pid, 0) == syscall.ESRCH
|
||||||
|
}
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
//go:build !windows || bindings
|
//go:build (!windows && !linux) || bindings
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
// acquireSingleInstance is a no-op off Windows (the guard uses a Windows named
|
// acquireSingleInstance is a no-op off Windows (the guard uses a Windows named
|
||||||
// mutex), and during Wails' binding generation (the `bindings` tag) — that step
|
// mutex), and during Wails' binding generation (the `bindings` tag) — that step
|
||||||
// runs this binary, and a real OpsLog already running would otherwise make it
|
// runs this binary, and a real OpsLog already running would otherwise make it
|
||||||
// exit before Wails could reflect the bindings.
|
// exit before Wails could reflect the bindings.
|
||||||
func acquireSingleInstance() bool { return true }
|
func acquireSingleInstance() bool { return true }
|
||||||
|
|
||||||
|
// waitForProcessExit has nothing to wait on off Windows: there is no
|
||||||
|
// single-instance guard there either, so the relaunch never has to queue behind
|
||||||
|
// the old process.
|
||||||
|
func waitForProcessExit(pid int, timeout time.Duration) bool { return true }
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
"golang.org/x/sys/windows"
|
"golang.org/x/sys/windows"
|
||||||
@@ -68,3 +69,35 @@ func focusExistingWindow() {
|
|||||||
showWindow.Call(hwnd, swRestore)
|
showWindow.Call(hwnd, swRestore)
|
||||||
setForeground.Call(hwnd)
|
setForeground.Call(hwnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// waitForProcessExit blocks until the process with this pid is gone, or the
|
||||||
|
// timeout runs out. Reports whether it actually went.
|
||||||
|
//
|
||||||
|
// This is what the auto-update relaunch needs, and it is the piece that was
|
||||||
|
// lost when the PowerShell helper went. The old instance is allowed thirty
|
||||||
|
// seconds to shut down (see armExitWatchdog) — it closes a remote logbook, a
|
||||||
|
// CAT session, sometimes a backup — while the new one was only patient with the
|
||||||
|
// mutex for twenty. On a station where shutdown took longer than that, the new
|
||||||
|
// process gave up and exited, and the operator was left with the old one still
|
||||||
|
// running and no new window: exactly the report.
|
||||||
|
//
|
||||||
|
// Waiting on a handle rather than sleeping a fixed time is also the honest
|
||||||
|
// version: it ends the instant the old process ends, however long or short that
|
||||||
|
// is, and it is a plain kernel wait — nothing that looks like a script starting
|
||||||
|
// another program.
|
||||||
|
func waitForProcessExit(pid int, timeout time.Duration) bool {
|
||||||
|
if pid <= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
|
||||||
|
if err != nil {
|
||||||
|
// Already gone, or not ours to wait on. Either way there is nothing to
|
||||||
|
// wait for — and refusing to launch over an unexpected permission error
|
||||||
|
// would be worse than starting.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(h)
|
||||||
|
ms := uint32(timeout / time.Millisecond)
|
||||||
|
ev, err := windows.WaitForSingleObject(h, ms)
|
||||||
|
return err == nil && ev == uint32(windows.WAIT_OBJECT_0)
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.27.17"
|
appVersion = "0.27.23"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user