Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38da904f4b | ||
|
|
b7d1eddfa0 | ||
|
|
7208efbba6 | ||
|
|
2414b2077f | ||
|
|
1cbf3ecc4b | ||
|
|
ccae8599eb | ||
|
|
cdd91ab7e6 | ||
|
|
5b7469ae44 | ||
|
|
3c7ea2d894 | ||
|
|
55fbfa4499 | ||
|
|
b655cd9631 | ||
|
|
bc880ef6bd | ||
|
|
7ff0c2ac69 | ||
|
|
fa6e30545a | ||
|
|
3e206268b4 | ||
|
|
659e33676a | ||
|
|
cf44b37bf4 | ||
|
|
37dadeda84 | ||
|
|
b478cbfd2a | ||
|
|
76022ff91c | ||
|
|
b918a8395b | ||
|
|
2283734210 | ||
|
|
9dfa6f7d39 | ||
|
|
3ed48336af | ||
|
|
7f03b046ab | ||
|
|
90e363f49e | ||
|
|
465481f8f1 | ||
|
|
680bf410fe | ||
|
|
1009d06a4c | ||
|
|
7a84f00060 | ||
|
|
2d71351080 | ||
|
|
b0f76a8ba1 | ||
|
|
ed062a040c | ||
|
|
0430aab78e | ||
|
|
5d526d29db | ||
|
|
09c4358626 | ||
|
|
9ce7cf3b69 | ||
|
|
3745d23339 |
@@ -67,6 +67,7 @@ import (
|
|||||||
"hamlog/internal/rotator/pst"
|
"hamlog/internal/rotator/pst"
|
||||||
"hamlog/internal/rotator/spid"
|
"hamlog/internal/rotator/spid"
|
||||||
"hamlog/internal/rotgenius"
|
"hamlog/internal/rotgenius"
|
||||||
|
"hamlog/internal/sat"
|
||||||
"hamlog/internal/scp"
|
"hamlog/internal/scp"
|
||||||
"hamlog/internal/settings"
|
"hamlog/internal/settings"
|
||||||
"hamlog/internal/solar"
|
"hamlog/internal/solar"
|
||||||
@@ -157,6 +158,7 @@ const (
|
|||||||
// the rule since is that nothing a backend does may be steered by another
|
// the rule since is that nothing a backend does may be steered by another
|
||||||
// backend's setting.
|
// backend's setting.
|
||||||
keyCATYaesuLowLines = "cat.yaesu.low_dtr_rts" // deassert DTR/RTS on connect
|
keyCATYaesuLowLines = "cat.yaesu.low_dtr_rts" // deassert DTR/RTS on connect
|
||||||
|
keyCATYaesuRTTYUSB = "cat.yaesu.rtty_usb" // set RTTY on USB rather than the older LSB
|
||||||
keyCATKenwoodLowLines = "cat.kenwood.low_dtr_rts" // deassert DTR/RTS on connect
|
keyCATKenwoodLowLines = "cat.kenwood.low_dtr_rts" // deassert DTR/RTS on connect
|
||||||
keyCATKenwoodDataMode = "cat.kenwood.data_mode" // data modes → "usb" | "data" (MD6) | "keep"
|
keyCATKenwoodDataMode = "cat.kenwood.data_mode" // data modes → "usb" | "data" (MD6) | "keep"
|
||||||
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
|
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
|
||||||
@@ -511,6 +513,10 @@ type CATSettings struct {
|
|||||||
// interfaces that read either line as PTT. Off by default: lowering them
|
// interfaces that read either line as PTT. Off by default: lowering them
|
||||||
// stops some USB-serial interfaces transmitting at all.
|
// stops some USB-serial interfaces transmitting at all.
|
||||||
YaesuLowLines bool `json:"yaesu_low_lines"`
|
YaesuLowLines bool `json:"yaesu_low_lines"`
|
||||||
|
// YaesuRTTYUSB: ADIF says "RTTY" and Yaesu has both sidebands, so the rig
|
||||||
|
// cannot be driven from the logged mode alone. Off = RTTY-L, the older
|
||||||
|
// convention.
|
||||||
|
YaesuRTTYUSB bool `json:"yaesu_rtty_usb"`
|
||||||
KenwoodLowLines bool `json:"kenwood_low_lines"`
|
KenwoodLowLines bool `json:"kenwood_low_lines"`
|
||||||
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
|
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
|
||||||
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
|
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
|
||||||
@@ -879,6 +885,23 @@ type App struct {
|
|||||||
|
|
||||||
alertStore *alerts.Store // DX-cluster spot alert rules (global JSON)
|
alertStore *alerts.Store // DX-cluster spot alert rules (global JSON)
|
||||||
|
|
||||||
|
// udpFocus arbitrates the entry field between several decoders running at
|
||||||
|
// once — see app_udp_focus.go.
|
||||||
|
udpFocus udpFocus
|
||||||
|
|
||||||
|
// Satellites. The elements (where the birds are) and the frequency plan
|
||||||
|
// (what to do with the radio) are held apart because they come from
|
||||||
|
// different places and change for different reasons — a feed every few
|
||||||
|
// days, an operator's correction when a transponder is switched.
|
||||||
|
satMu sync.Mutex
|
||||||
|
satStore *sat.Store // orbital elements, by satellite name
|
||||||
|
satBirds *sat.Birds // uplink/downlink plan
|
||||||
|
satFetch *sat.Fetcher // element feeds + the on-disk cache
|
||||||
|
// satTrack is the live tracker: the goroutine that walks the radio through a
|
||||||
|
// pass. nil when nothing is being tracked.
|
||||||
|
satTrackMu sync.Mutex
|
||||||
|
satTrack *satTracker
|
||||||
|
|
||||||
cwMu sync.Mutex // guards the CW decoder lifecycle
|
cwMu sync.Mutex // guards the CW decoder lifecycle
|
||||||
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
|
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
|
||||||
cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
|
cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
|
||||||
@@ -1638,6 +1661,10 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
a.alertStore = as
|
a.alertStore = as
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Satellites: the cached elements and the frequency plan. Local files only —
|
||||||
|
// any element fetch it decides to make goes to the network on its own.
|
||||||
|
a.startSatellites()
|
||||||
|
|
||||||
// Ultrabeam antenna: connect in the background if enabled.
|
// Ultrabeam antenna: connect in the background if enabled.
|
||||||
a.startUltrabeam()
|
a.startUltrabeam()
|
||||||
// Antenna Genius switch: connect in the background if enabled.
|
// Antenna Genius switch: connect in the background if enabled.
|
||||||
@@ -1941,6 +1968,10 @@ func (a *App) shutdown(ctx context.Context) {
|
|||||||
applog.Printf("shutdown: closing autostart programs")
|
applog.Printf("shutdown: closing autostart programs")
|
||||||
a.CloseAutostartPrograms()
|
a.CloseAutostartPrograms()
|
||||||
a.stopPSKTarget() // one TLS socket to a public broker; nothing to flush
|
a.stopPSKTarget() // one TLS socket to a public broker; nothing to flush
|
||||||
|
// Before CAT goes down: disarming satellite mode takes the radio out of full
|
||||||
|
// duplex and puts the transmitter back where the operator is listening, and
|
||||||
|
// that has to happen while the link is still up.
|
||||||
|
a.StopSatelliteTracking()
|
||||||
applog.Printf("shutdown: stopping UDP")
|
applog.Printf("shutdown: stopping UDP")
|
||||||
if a.udp != nil {
|
if a.udp != nil {
|
||||||
a.udp.StopAll()
|
a.udp.StopAll()
|
||||||
@@ -3067,6 +3098,10 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
a.applyStationDefaults(&q, true)
|
a.applyStationDefaults(&q, true)
|
||||||
|
// Before fillRXDefaults, which copies the transmit frequency into the receive
|
||||||
|
// one: on a satellite the two are on different bands, and letting that copy
|
||||||
|
// happen first would bury the downlink under the uplink.
|
||||||
|
a.applySatellite(&q)
|
||||||
fillRXDefaults(&q)
|
fillRXDefaults(&q)
|
||||||
fillDistance(&q)
|
fillDistance(&q)
|
||||||
a.applyDXCCNumber(&q)
|
a.applyDXCCNumber(&q)
|
||||||
@@ -3101,6 +3136,9 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
q.ID = id
|
q.ID = id
|
||||||
|
// The contact is over, so no decoder holds the entry field any more: the
|
||||||
|
// next station may come from whichever one hears it first.
|
||||||
|
a.udpFocus.release("QSO logged")
|
||||||
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh (in-memory)
|
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh (in-memory)
|
||||||
a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async)
|
a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async)
|
||||||
// Snapshot the QSO recording SYNCHRONOUSLY, BEFORE announcing the log: the
|
// Snapshot the QSO recording SYNCHRONOUSLY, BEFORE announcing the log: the
|
||||||
@@ -5574,11 +5612,19 @@ func (a *App) recomputeAwardRefsForCodesAsync(codes []string) {
|
|||||||
// definition / reference-list change, or a bulk ADIF import.
|
// definition / reference-list change, or a bulk ADIF import.
|
||||||
func (a *App) recomputeAwardRefsAsync() {
|
func (a *App) recomputeAwardRefsAsync() {
|
||||||
go func() {
|
go func() {
|
||||||
|
t0 := time.Now()
|
||||||
n, err := a.RecomputeAllAwardRefs()
|
n, err := a.RecomputeAllAwardRefs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
applog.Printf("award_refs: bulk recompute failed: %v", err)
|
applog.Printf("award_refs: bulk recompute failed: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Logged on SUCCESS too, and this is why: "I changed an award and the
|
||||||
|
// column stayed empty" is unanswerable without knowing whether the
|
||||||
|
// recompute ran at all, how long it took, and whether it changed
|
||||||
|
// anything. Zero rows changed is a real answer — the definition matches
|
||||||
|
// nothing in the log — and it looks exactly like a recompute that never
|
||||||
|
// happened.
|
||||||
|
applog.Printf("award_refs: bulk recompute done — %d rows changed in %s", n, time.Since(t0).Round(time.Millisecond))
|
||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "awards:recomputed", n)
|
wruntime.EventsEmit(a.ctx, "awards:recomputed", n)
|
||||||
}
|
}
|
||||||
@@ -8317,7 +8363,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATKenwoodLink, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort, keyCATDigiUSB)
|
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATYaesuRTTYUSB, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATKenwoodLink, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort, keyCATDigiUSB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CATSettings{}, err
|
return CATSettings{}, err
|
||||||
}
|
}
|
||||||
@@ -8343,6 +8389,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
KenwoodLink: kenwoodLinkOr(m[keyCATKenwoodLink], m[keyCATKenwoodHost]),
|
KenwoodLink: kenwoodLinkOr(m[keyCATKenwoodLink], m[keyCATKenwoodHost]),
|
||||||
KenwoodBaud: 9600,
|
KenwoodBaud: 9600,
|
||||||
YaesuLowLines: m[keyCATYaesuLowLines] == "1",
|
YaesuLowLines: m[keyCATYaesuLowLines] == "1",
|
||||||
|
YaesuRTTYUSB: m[keyCATYaesuRTTYUSB] == "1",
|
||||||
KenwoodLowLines: m[keyCATKenwoodLowLines] == "1",
|
KenwoodLowLines: m[keyCATKenwoodLowLines] == "1",
|
||||||
KenwoodDataMode: m[keyCATKenwoodDataMode],
|
KenwoodDataMode: m[keyCATKenwoodDataMode],
|
||||||
IcomPort: m[keyCATIcomPort],
|
IcomPort: m[keyCATIcomPort],
|
||||||
@@ -8556,6 +8603,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
keyCATKenwoodLink: kenwoodLinkOr(s.KenwoodLink, s.KenwoodHost),
|
keyCATKenwoodLink: kenwoodLinkOr(s.KenwoodLink, s.KenwoodHost),
|
||||||
keyCATKenwoodBaud: strconv.Itoa(s.KenwoodBaud),
|
keyCATKenwoodBaud: strconv.Itoa(s.KenwoodBaud),
|
||||||
keyCATYaesuLowLines: b01(s.YaesuLowLines),
|
keyCATYaesuLowLines: b01(s.YaesuLowLines),
|
||||||
|
keyCATYaesuRTTYUSB: b01(s.YaesuRTTYUSB),
|
||||||
keyCATKenwoodLowLines: b01(s.KenwoodLowLines),
|
keyCATKenwoodLowLines: b01(s.KenwoodLowLines),
|
||||||
keyCATKenwoodDataMode: strings.ToLower(strings.TrimSpace(s.KenwoodDataMode)),
|
keyCATKenwoodDataMode: strings.ToLower(strings.TrimSpace(s.KenwoodDataMode)),
|
||||||
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
|
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
|
||||||
@@ -14583,6 +14631,13 @@ func (a *App) consumeUDPEvents() {
|
|||||||
"adif": ev.LoggedADIF,
|
"adif": ev.LoggedADIF,
|
||||||
})
|
})
|
||||||
case ev.ClearCall:
|
case ev.ClearCall:
|
||||||
|
// Only from the program the entry field belongs to. An idle decoder
|
||||||
|
// alongside the one being worked clears its own DX Call for reasons
|
||||||
|
// of its own, and that must not empty a field somebody else filled.
|
||||||
|
if !a.udpFocus.holds(ev.ProgramID) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
a.udpFocus.release("DX Call cleared")
|
||||||
applog.Printf("udp: emit udp:clear_call (DX Call cleared in the digital app)\n")
|
applog.Printf("udp: emit udp:clear_call (DX Call cleared in the digital app)\n")
|
||||||
wruntime.EventsEmit(a.ctx, "udp:clear_call", map[string]any{
|
wruntime.EventsEmit(a.ctx, "udp:clear_call", map[string]any{
|
||||||
"service": string(ev.Service),
|
"service": string(ev.Service),
|
||||||
@@ -14607,6 +14662,12 @@ func (a *App) consumeUDPEvents() {
|
|||||||
wruntime.EventsEmit(a.ctx, "udp:remote_call", ev.DXCall)
|
wruntime.EventsEmit(a.ctx, "udp:remote_call", ev.DXCall)
|
||||||
}
|
}
|
||||||
case ev.DXCall != "":
|
case ev.DXCall != "":
|
||||||
|
// With two or three decoders running, the one announcing a station
|
||||||
|
// takes the entry field and keeps it until it lets go. See udpFocus.
|
||||||
|
if !a.udpFocus.claim(ev.ProgramID) {
|
||||||
|
a.udpFocus.noteIgnored(ev.ProgramID, ev.DXCall)
|
||||||
|
break
|
||||||
|
}
|
||||||
applog.Printf("udp: emit udp:dx_call %q (mode=%s freq=%d)\n", ev.DXCall, ev.Mode, ev.FreqHz)
|
applog.Printf("udp: emit udp:dx_call %q (mode=%s freq=%d)\n", ev.DXCall, ev.Mode, ev.FreqHz)
|
||||||
wruntime.EventsEmit(a.ctx, "udp:dx_call", map[string]any{
|
wruntime.EventsEmit(a.ctx, "udp:dx_call", map[string]any{
|
||||||
"call": ev.DXCall,
|
"call": ev.DXCall,
|
||||||
@@ -16223,6 +16284,14 @@ func (a *App) reloadCAT() {
|
|||||||
go a.startQSORecorderIfEnabled()
|
go a.startQSORecorderIfEnabled()
|
||||||
}
|
}
|
||||||
a.reloadCATShare(s)
|
a.reloadCATShare(s)
|
||||||
|
// Preferences that the LINK does not depend on, pushed to the rig that is
|
||||||
|
// already connected. They are deliberately absent from catLinkSig — none of
|
||||||
|
// them is worth dropping the CAT link (and with it WSJT-X's rigctl session)
|
||||||
|
// to apply — so without this they waited for the next launch, and the
|
||||||
|
// operator ticking "RTTY on USB" watched the rig go on choosing LSB.
|
||||||
|
if s.Enabled && s.Backend == "yaesu" && a.cat != nil {
|
||||||
|
_ = a.cat.YaesuDo(func(y cat.YaesuController) error { y.SetRTTYUpper(s.YaesuRTTYUSB); return nil })
|
||||||
|
}
|
||||||
// Nothing about the link changed → leave it connected. See catLinkSig.
|
// Nothing about the link changed → leave it connected. See catLinkSig.
|
||||||
if sig := catLinkSig(s); sig == a.catSig {
|
if sig := catLinkSig(s); sig == a.catSig {
|
||||||
applog.Printf("cat: settings saved, link unchanged — staying connected")
|
applog.Printf("cat: settings saved, link unchanged — staying connected")
|
||||||
@@ -16283,6 +16352,7 @@ func (a *App) reloadCAT() {
|
|||||||
// (a rig file that hides the VFO, a Freq property meaning A on one model
|
// (a rig file that hides the VFO, a Freq property meaning A on one model
|
||||||
// and B on another); talking to the radio directly removes it.
|
// and B on another); talking to the radio directly removes it.
|
||||||
yz := cat.NewYaesu(s.YaesuPort, s.YaesuBaud, s.DigitalDefault)
|
yz := cat.NewYaesu(s.YaesuPort, s.YaesuBaud, s.DigitalDefault)
|
||||||
|
yz.SetRTTYUpper(s.YaesuRTTYUSB)
|
||||||
yz.SetLowerLines(s.YaesuLowLines)
|
yz.SetLowerLines(s.YaesuLowLines)
|
||||||
a.cat.Start(yz)
|
a.cat.Start(yz)
|
||||||
case "kenwood", "elecraft":
|
case "kenwood", "elecraft":
|
||||||
@@ -16759,6 +16829,9 @@ func (a *App) reloadAfterProfileSwitch() {
|
|||||||
// of the process.
|
// of the process.
|
||||||
a.disarmAutoCall("profile switch")
|
a.disarmAutoCall("profile switch")
|
||||||
a.autoCallEngine().Reset()
|
a.autoCallEngine().Reset()
|
||||||
|
// Same reasoning for the satellite tracker: it transmits, and the new profile
|
||||||
|
// may be a different station on a different antenna.
|
||||||
|
a.StopSatelliteTracking()
|
||||||
}
|
}
|
||||||
|
|
||||||
// DuplicateProfile clones an existing profile under newName. Useful when
|
// DuplicateProfile clones an existing profile under newName. Useful when
|
||||||
|
|||||||
+980
@@ -0,0 +1,980 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Satellites — the wiring around internal/sat.
|
||||||
|
//
|
||||||
|
// The package knows orbits and frequency plans; this file is what the station
|
||||||
|
// knows: where the antenna is, which birds the operator cares about, and where
|
||||||
|
// the elements are kept. Nothing here talks to a radio or a rotator yet — that
|
||||||
|
// is the next layer, and it is deliberately built on top of GetSatelliteTuning
|
||||||
|
// rather than beside it, so what the operator reads on screen and what gets
|
||||||
|
// sent to the rig can never disagree.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/sat"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
keySatFavorites = "sat.favorites" // comma-separated satellite names
|
||||||
|
keySatMinEl = "sat.min_el" // degrees; passes lower than this are not listed
|
||||||
|
keySatWindowH = "sat.window_h" // hours of pass predictions
|
||||||
|
keySatAutoTLE = "sat.auto_tle" // fetch elements at startup when the set is stale
|
||||||
|
keySatGrid = "sat.grid" // locator override ("" = the station's own)
|
||||||
|
keySatAltM = "sat.alt_m" // antenna height above sea level, metres
|
||||||
|
|
||||||
|
// The az/el rotator. Its own settings rather than the HF rotator's: a
|
||||||
|
// 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"
|
||||||
|
// Which program drives the mast: OpsLog itself over EasyComm, or PstRotator,
|
||||||
|
// which many stations already run in front of their controller. Its own port
|
||||||
|
// key because it is a different program on a different port from an EasyComm
|
||||||
|
// controller, and an operator who tries both must not lose the first setting
|
||||||
|
// to the second.
|
||||||
|
keySatRotType = "sat.rot_type" // "easycomm" | "pstrotator"
|
||||||
|
keySatRotPstPort = "sat.rot_pst_port" // PstRotator's UDP command port
|
||||||
|
keySatRotTransport = "sat.rot_transport" // "serial" | "tcp"
|
||||||
|
keySatRotHost = "sat.rot_host"
|
||||||
|
keySatRotPort = "sat.rot_port"
|
||||||
|
keySatRotCOM = "sat.rot_com"
|
||||||
|
keySatRotBaud = "sat.rot_baud"
|
||||||
|
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.
|
||||||
|
//
|
||||||
|
// Kept apart from the feed cache because the cache is REPLACED wholesale on
|
||||||
|
// every refresh: a freshly launched satellite, whose elements arrive on a
|
||||||
|
// mailing list days before any feed carries it, would be wiped by the first
|
||||||
|
// automatic update — which is precisely the week everybody wants to hear it.
|
||||||
|
const customTLEName = "satellites.custom.tle"
|
||||||
|
|
||||||
|
// SatSettings is the station's side of satellite work.
|
||||||
|
type SatSettings struct {
|
||||||
|
Favorites []string `json:"favorites"`
|
||||||
|
MinEl int `json:"min_el"`
|
||||||
|
WindowH int `json:"window_h"`
|
||||||
|
AutoTLE bool `json:"auto_tle"`
|
||||||
|
Grid string `json:"grid"`
|
||||||
|
AltM int `json:"alt_m"`
|
||||||
|
|
||||||
|
// The az/el rotator.
|
||||||
|
RotOn bool `json:"rot_on"`
|
||||||
|
RotType string `json:"rot_type"`
|
||||||
|
RotPstPort int `json:"rot_pst_port"`
|
||||||
|
RotTransport string `json:"rot_transport"`
|
||||||
|
RotHost string `json:"rot_host"`
|
||||||
|
RotPort int `json:"rot_port"`
|
||||||
|
RotCOM string `json:"rot_com"`
|
||||||
|
RotBaud int `json:"rot_baud"`
|
||||||
|
RotMaxAz int `json:"rot_max_az"`
|
||||||
|
RotMinEl int `json:"rot_min_el"`
|
||||||
|
RotStep int `json:"rot_step"`
|
||||||
|
RotPark bool `json:"rot_park"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatTransponder is one path through a satellite, as the UI needs it.
|
||||||
|
type SatTransponder struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
DownLo int64 `json:"down_lo"`
|
||||||
|
DownHi int64 `json:"down_hi"`
|
||||||
|
UpLo int64 `json:"up_lo"`
|
||||||
|
UpHi int64 `json:"up_hi"`
|
||||||
|
Inverting bool `json:"inverting"`
|
||||||
|
CTCSS float64 `json:"ctcss"`
|
||||||
|
Linear bool `json:"linear"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatBird is a satellite as the operator sees it: the frequency plan joined to
|
||||||
|
// whatever elements we hold for it.
|
||||||
|
type SatBird struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
NORAD int `json:"norad"`
|
||||||
|
Geostationary bool `json:"geostationary"`
|
||||||
|
Favorite bool `json:"favorite"`
|
||||||
|
HasElements bool `json:"has_elements"`
|
||||||
|
ElementName string `json:"element_name"` // the feed's spelling, when it differs
|
||||||
|
EpochAgeH float64 `json:"epoch_age_h"`
|
||||||
|
Transponders []SatTransponder `json:"transponders"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatTLEInfo describes the element set the station is working from.
|
||||||
|
type SatTLEInfo struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
FetchedAt time.Time `json:"fetched_at"`
|
||||||
|
AgeH float64 `json:"age_h"`
|
||||||
|
Stale bool `json:"stale"`
|
||||||
|
Custom int `json:"custom"` // hand-entered satellites among the count
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatTuning is where to listen and where to transmit, right now.
|
||||||
|
//
|
||||||
|
// Both the nominal and the corrected pair are returned on purpose: the nominal
|
||||||
|
// is what goes in the log (see the ADIF note on SAT_NAME) and the corrected is
|
||||||
|
// what goes to the radio. An operator staring at a display that shows only one
|
||||||
|
// of them cannot tell a Doppler correction from a mistuned transponder.
|
||||||
|
type SatTuning struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Transponder string `json:"transponder"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
NominalDown int64 `json:"nominal_down"`
|
||||||
|
NominalUp int64 `json:"nominal_up"`
|
||||||
|
DownHz int64 `json:"down_hz"`
|
||||||
|
UpHz int64 `json:"up_hz"`
|
||||||
|
CTCSS float64 `json:"ctcss"`
|
||||||
|
Inverting bool `json:"inverting"`
|
||||||
|
|
||||||
|
Az float64 `json:"az"`
|
||||||
|
El float64 `json:"el"`
|
||||||
|
RangeKm float64 `json:"range_km"`
|
||||||
|
RangeRate float64 `json:"range_rate"`
|
||||||
|
Visible bool `json:"visible"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
|
||||||
|
// Where the satellite is over the earth. Carried with the tuning because
|
||||||
|
// they are read together and change together — the panel would otherwise ask
|
||||||
|
// twice a second for two halves of one instant.
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
Lon float64 `json:"lon"`
|
||||||
|
AltKm float64 `json:"alt_km"`
|
||||||
|
Footprint float64 `json:"footprint_km"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatPassInfo is the pass in progress, or the next one.
|
||||||
|
//
|
||||||
|
// Separate from the tuning and polled far more slowly: predicting a pass steps
|
||||||
|
// the orbit thirty seconds at a time across hours, which is not something to do
|
||||||
|
// once a second for a countdown a browser can run itself from two timestamps.
|
||||||
|
type SatPassInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
HasPass bool `json:"has_pass"`
|
||||||
|
// InPass distinguishes "it is up now" from "it rises at". The pass in
|
||||||
|
// progress is reported whatever its maximum elevation: an operator watching
|
||||||
|
// a satellite go over does not want it hidden because it fell below the
|
||||||
|
// threshold that filters the TABLE of what is worth waiting for.
|
||||||
|
InPass bool `json:"in_pass"`
|
||||||
|
AOS time.Time `json:"aos"`
|
||||||
|
LOS time.Time `json:"los"`
|
||||||
|
AOSAz float64 `json:"aos_az"`
|
||||||
|
LOSAz float64 `json:"los_az"`
|
||||||
|
MaxEl float64 `json:"max_el"`
|
||||||
|
MaxElAz float64 `json:"max_el_az"`
|
||||||
|
MaxElAt time.Time `json:"max_el_at"`
|
||||||
|
Duration float64 `json:"duration_s"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lifecycle ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// startSatellites loads what is already on disk and, only if asked, goes to the
|
||||||
|
// network.
|
||||||
|
//
|
||||||
|
// Cache first and synchronously: it is one file and a few hundred parses, and
|
||||||
|
// it means the satellite tab is populated the instant it is opened, on a shack
|
||||||
|
// PC with no internet as much as on one with. The fetch is the slow, optional
|
||||||
|
// half and never blocks a launch.
|
||||||
|
func (a *App) startSatellites() {
|
||||||
|
dir := a.dataDir
|
||||||
|
birds, err := sat.LoadBirds(dir)
|
||||||
|
if err != nil {
|
||||||
|
// LoadBirds always returns a usable list; the error says the operator's
|
||||||
|
// own file was refused, which they need to be told about.
|
||||||
|
applog.Printf("sat: %v", err)
|
||||||
|
}
|
||||||
|
store := sat.NewStore()
|
||||||
|
fetch := sat.NewFetcher(dir)
|
||||||
|
fetch.Logf = applog.Printf
|
||||||
|
|
||||||
|
if els, at, err := fetch.LoadCache(); err == nil {
|
||||||
|
store.Replace(els, at)
|
||||||
|
applog.Printf("sat: %d satellites from the cached element set (%s old)", len(els), time.Since(at).Round(time.Minute))
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
applog.Printf("sat: the cached element set could not be read: %v", err)
|
||||||
|
}
|
||||||
|
a.satMu.Lock()
|
||||||
|
a.satStore, a.satBirds, a.satFetch = store, birds, fetch
|
||||||
|
a.satMu.Unlock()
|
||||||
|
a.loadCustomElements()
|
||||||
|
|
||||||
|
set := a.satSettings()
|
||||||
|
if set.AutoTLE && a.satTLEInfo().Stale {
|
||||||
|
go func() {
|
||||||
|
if _, err := a.RefreshSatelliteTLE(); err != nil {
|
||||||
|
applog.Printf("sat: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// satParts hands back the three pieces under the lock, building them if the
|
||||||
|
// startup path has not run — a binding called from a tab the operator opened
|
||||||
|
// before startup finished must not answer "no satellites".
|
||||||
|
func (a *App) satParts() (*sat.Store, *sat.Birds, *sat.Fetcher) {
|
||||||
|
a.satMu.Lock()
|
||||||
|
if a.satStore == nil {
|
||||||
|
a.satMu.Unlock()
|
||||||
|
a.startSatellites()
|
||||||
|
a.satMu.Lock()
|
||||||
|
}
|
||||||
|
s, b, f := a.satStore, a.satBirds, a.satFetch
|
||||||
|
a.satMu.Unlock()
|
||||||
|
return s, b, f
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (a *App) satSettings() SatSettings {
|
||||||
|
// The rotator defaults are the common case, not a blank form: EasyComm over
|
||||||
|
// a serial port at 9600, a 360° machine, and a five-degree step — which on a
|
||||||
|
// beam with any gain at all is well inside the beamwidth and keeps a pass
|
||||||
|
// from being a command a second.
|
||||||
|
out := SatSettings{
|
||||||
|
MinEl: 10, WindowH: 24, AutoTLE: true,
|
||||||
|
RotType: satRotEasycomm, RotPstPort: 12000,
|
||||||
|
RotTransport: "serial", RotPort: 4533, RotBaud: 9600,
|
||||||
|
RotMaxAz: 360, RotMinEl: 0, RotStep: 5,
|
||||||
|
}
|
||||||
|
if a.settings == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
m, err := a.settings.GetMany(a.ctx,
|
||||||
|
keySatFavorites, keySatMinEl, keySatWindowH, keySatAutoTLE, keySatGrid, keySatAltM,
|
||||||
|
keySatRotOn, keySatRotType, keySatRotPstPort, keySatRotTransport, keySatRotHost, keySatRotPort, keySatRotCOM,
|
||||||
|
keySatRotBaud, keySatRotMaxAz, keySatRotMinEl, keySatRotStep, keySatRotPark)
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out.RotOn = m[keySatRotOn] == "1"
|
||||||
|
if ty := m[keySatRotType]; ty == satRotPst || ty == satRotEasycomm {
|
||||||
|
out.RotType = ty
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
out.RotMinEl = v
|
||||||
|
}
|
||||||
|
if v, err := strconv.Atoi(m[keySatRotStep]); err == nil && v >= 1 && v <= 30 {
|
||||||
|
out.RotStep = v
|
||||||
|
}
|
||||||
|
out.RotPark = m[keySatRotPark] == "1"
|
||||||
|
for _, n := range strings.Split(m[keySatFavorites], ",") {
|
||||||
|
if n = strings.TrimSpace(n); n != "" {
|
||||||
|
out.Favorites = append(out.Favorites, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, err := strconv.Atoi(m[keySatMinEl]); err == nil && v >= 0 && v <= 60 {
|
||||||
|
out.MinEl = v
|
||||||
|
}
|
||||||
|
if v, err := strconv.Atoi(m[keySatWindowH]); err == nil && v >= 1 && v <= 168 {
|
||||||
|
out.WindowH = v
|
||||||
|
}
|
||||||
|
if v, ok := m[keySatAutoTLE]; ok && v != "" {
|
||||||
|
out.AutoTLE = v == "1"
|
||||||
|
}
|
||||||
|
out.Grid = strings.TrimSpace(m[keySatGrid])
|
||||||
|
if v, err := strconv.Atoi(m[keySatAltM]); err == nil && v > -500 && v < 9000 {
|
||||||
|
out.AltM = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSatSettings returns the satellite preferences.
|
||||||
|
func (a *App) GetSatSettings() (SatSettings, error) {
|
||||||
|
if a.settings == nil {
|
||||||
|
return SatSettings{}, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
return a.satSettings(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveSatSettings stores them.
|
||||||
|
func (a *App) SaveSatSettings(s SatSettings) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
if s.MinEl < 0 || s.MinEl > 60 {
|
||||||
|
s.MinEl = 10
|
||||||
|
}
|
||||||
|
if s.WindowH < 1 || s.WindowH > 168 {
|
||||||
|
s.WindowH = 24
|
||||||
|
}
|
||||||
|
var favs []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, n := range s.Favorites {
|
||||||
|
n = strings.TrimSpace(n)
|
||||||
|
if n == "" || seen[strings.ToUpper(n)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[strings.ToUpper(n)] = true
|
||||||
|
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 {
|
||||||
|
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{
|
||||||
|
keySatFavorites: strings.Join(favs, ","),
|
||||||
|
keySatMinEl: strconv.Itoa(s.MinEl),
|
||||||
|
keySatWindowH: strconv.Itoa(s.WindowH),
|
||||||
|
keySatAutoTLE: boolStr(s.AutoTLE),
|
||||||
|
keySatGrid: strings.ToUpper(strings.TrimSpace(s.Grid)),
|
||||||
|
keySatAltM: strconv.Itoa(s.AltM),
|
||||||
|
keySatRotOn: boolStr(s.RotOn),
|
||||||
|
keySatRotType: s.RotType,
|
||||||
|
keySatRotPstPort: strconv.Itoa(s.RotPstPort),
|
||||||
|
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),
|
||||||
|
keySatRotStep: strconv.Itoa(s.RotStep),
|
||||||
|
keySatRotPark: boolStr(s.RotPark),
|
||||||
|
} {
|
||||||
|
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// satObserver is the ground station: the satellite grid if the operator set one,
|
||||||
|
// otherwise the station's own.
|
||||||
|
//
|
||||||
|
// A locator, not a latitude and longitude: it is what every logbook already
|
||||||
|
// holds, and its six-character precision is a couple of kilometres — three
|
||||||
|
// hundredths of a degree of azimuth at the worst possible geometry, far below
|
||||||
|
// any rotator's backlash.
|
||||||
|
func (a *App) satObserver() (sat.Observer, error) {
|
||||||
|
set := a.satSettings()
|
||||||
|
grid := set.Grid
|
||||||
|
if grid == "" && a.profiles != nil {
|
||||||
|
// The station locator lives on the ACTIVE PROFILE, not in a settings key.
|
||||||
|
// keyStationMyGrid is a legacy key that EnsureDefault migrated into the
|
||||||
|
// profile years ago and nothing writes any more — reading it told an
|
||||||
|
// operator with a perfectly good locator on screen that he had not set
|
||||||
|
// one.
|
||||||
|
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||||||
|
grid = p.MyGrid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
grid = strings.TrimSpace(grid)
|
||||||
|
lat, lon, ok := gridToLatLon(grid)
|
||||||
|
if !ok {
|
||||||
|
return sat.Observer{}, fmt.Errorf("your locator is not set — Settings ▸ Station, or Settings ▸ Satellites for a different site")
|
||||||
|
}
|
||||||
|
return sat.Observer{Lat: lat, Lon: lon, AltM: float64(set.AltM)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSatelliteObserver reports the ground station the predictions are made for,
|
||||||
|
// so the UI can show it — and say plainly when there is none.
|
||||||
|
func (a *App) GetSatelliteObserver() (map[string]any, error) {
|
||||||
|
obs, err := a.satObserver()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"lat": obs.Lat, "lon": obs.Lon, "alt_m": obs.AltM}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Elements ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (a *App) customTLEPath() string { return filepath.Join(a.dataDir, customTLEName) }
|
||||||
|
|
||||||
|
// loadCustomElements merges the hand-entered file over the feed's set. Last
|
||||||
|
// writer wins in the store, so an operator's own elements for a satellite
|
||||||
|
// override the feed's — which is the whole point of having typed them.
|
||||||
|
func (a *App) loadCustomElements() int {
|
||||||
|
f, err := os.Open(a.customTLEPath())
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
els, skipped, err := sat.ParseTLESet(f)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("sat: %s could not be read: %v", customTLEName, err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if skipped > 0 {
|
||||||
|
applog.Printf("sat: %d entries in %s were unusable", skipped, customTLEName)
|
||||||
|
}
|
||||||
|
store, _, _ := a.satParts()
|
||||||
|
for _, e := range els {
|
||||||
|
store.Put(e)
|
||||||
|
}
|
||||||
|
return len(els)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) customElementCount() int {
|
||||||
|
f, err := os.Open(a.customTLEPath())
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
els, _, err := sat.ParseTLESet(f)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(els)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) satTLEInfo() SatTLEInfo {
|
||||||
|
store, _, _ := a.satParts()
|
||||||
|
at := store.FetchedAt()
|
||||||
|
info := SatTLEInfo{Count: store.Len(), FetchedAt: at, Custom: a.customElementCount()}
|
||||||
|
if !at.IsZero() {
|
||||||
|
info.AgeH = time.Since(at).Hours()
|
||||||
|
info.Stale = time.Since(at) > sat.StaleAfter
|
||||||
|
} else {
|
||||||
|
info.Stale = true // nothing on disk yet: the operator has to be told to fetch
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSatelliteTLEInfo describes the element set, including how old it is.
|
||||||
|
func (a *App) GetSatelliteTLEInfo() SatTLEInfo { return a.satTLEInfo() }
|
||||||
|
|
||||||
|
// RefreshSatelliteTLE downloads a fresh element set.
|
||||||
|
func (a *App) RefreshSatelliteTLE() (SatTLEInfo, error) {
|
||||||
|
store, _, fetch := a.satParts()
|
||||||
|
ctx := a.ctx
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
els, err := fetch.Fetch(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return a.satTLEInfo(), err
|
||||||
|
}
|
||||||
|
store.Replace(els, time.Now())
|
||||||
|
a.loadCustomElements() // the operator's own elements go back on top
|
||||||
|
info := a.satTLEInfo()
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "sat:tle", info)
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddSatelliteElements takes elements pasted in by hand — two or three lines
|
||||||
|
// per satellite — and keeps them across feed refreshes.
|
||||||
|
func (a *App) AddSatelliteElements(text string) (int, error) {
|
||||||
|
els, skipped, err := sat.ParseTLESet(strings.NewReader(text))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("those are not usable elements: %w", err)
|
||||||
|
}
|
||||||
|
existing := map[string]bool{}
|
||||||
|
var keep []sat.Element
|
||||||
|
if f, ferr := os.Open(a.customTLEPath()); ferr == nil {
|
||||||
|
old, _, _ := sat.ParseTLESet(f)
|
||||||
|
f.Close()
|
||||||
|
keep = old
|
||||||
|
}
|
||||||
|
// The new set wins for a satellite already in the file: pasting elements is
|
||||||
|
// how an operator UPDATES a bird the feeds do not carry.
|
||||||
|
for _, e := range els {
|
||||||
|
existing[strings.ToUpper(e.Name)] = true
|
||||||
|
}
|
||||||
|
var out []sat.Element
|
||||||
|
for _, e := range keep {
|
||||||
|
if !existing[strings.ToUpper(e.Name)] {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, els...)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for _, e := range out {
|
||||||
|
if e.Name != "" {
|
||||||
|
b.WriteString(e.Name + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString(e.Line1 + "\n" + e.Line2 + "\n")
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(a.customTLEPath(), []byte(b.String()), 0o644); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n := a.loadCustomElements()
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "sat:tle", a.satTLEInfo())
|
||||||
|
}
|
||||||
|
if skipped > 0 {
|
||||||
|
applog.Printf("sat: %d pasted entries were unusable and were skipped", skipped)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The list ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GetSatelliteBirds joins the frequency plan to the elements.
|
||||||
|
//
|
||||||
|
// Both halves are listed, not just their intersection: a bird with elements and
|
||||||
|
// no plan is one the operator can still track and add frequencies for, and a
|
||||||
|
// bird with a plan and no elements is the one visible symptom of an element set
|
||||||
|
// that is too old or too narrow — silently dropping either turns a fixable
|
||||||
|
// configuration problem into a satellite that "does not exist".
|
||||||
|
func (a *App) GetSatelliteBirds() []SatBird {
|
||||||
|
store, birds, _ := a.satParts()
|
||||||
|
set := a.satSettings()
|
||||||
|
fav := map[string]bool{}
|
||||||
|
for _, n := range set.Favorites {
|
||||||
|
fav[strings.ToUpper(n)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]SatBird, 0, birds.Len())
|
||||||
|
planned := map[string]bool{}
|
||||||
|
for _, b := range birds.All() {
|
||||||
|
item := SatBird{Name: b.Name, Geostationary: b.Geostationary, Favorite: fav[strings.ToUpper(b.Name)]}
|
||||||
|
for _, t := range b.Transponders {
|
||||||
|
item.Transponders = append(item.Transponders, SatTransponder{
|
||||||
|
Label: t.Label, Mode: t.Mode,
|
||||||
|
DownLo: t.DownLo, DownHi: t.DownHi, UpLo: t.UpLo, UpHi: t.UpHi,
|
||||||
|
Inverting: t.Inverting, CTCSS: t.CTCSS, Linear: t.Linear(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if e, ok := satElement(store, b); ok {
|
||||||
|
item.HasElements = true
|
||||||
|
item.NORAD = e.NORAD
|
||||||
|
item.EpochAgeH = e.Age().Hours()
|
||||||
|
planned[strings.ToUpper(e.Name)] = true
|
||||||
|
if !strings.EqualFold(e.Name, b.Name) {
|
||||||
|
item.ElementName = e.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
// The rest of the element set, so nothing the station holds is invisible.
|
||||||
|
for _, n := range store.Names() {
|
||||||
|
if planned[strings.ToUpper(n)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e, ok := store.Get(n)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, SatBird{
|
||||||
|
Name: e.Name, NORAD: e.NORAD, HasElements: true,
|
||||||
|
EpochAgeH: e.Age().Hours(), Favorite: fav[strings.ToUpper(e.Name)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
// Favourites first, then the birds we can actually use, then by name.
|
||||||
|
if out[i].Favorite != out[j].Favorite {
|
||||||
|
return out[i].Favorite
|
||||||
|
}
|
||||||
|
iu := len(out[i].Transponders) > 0 && out[i].HasElements
|
||||||
|
ju := len(out[j].Transponders) > 0 && out[j].HasElements
|
||||||
|
if iu != ju {
|
||||||
|
return iu
|
||||||
|
}
|
||||||
|
return out[i].Name < out[j].Name
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSatelliteNames is the list behind the entry form's SAT_NAME box.
|
||||||
|
//
|
||||||
|
// One list, not two. It used to be a text box in Settings ▸ Lists that an
|
||||||
|
// operator typed their birds into by hand, which then had nothing to do with
|
||||||
|
// the satellites the tracker knew — the same station kept two lists of the same
|
||||||
|
// satellites and they drifted apart. This is the followed set (or every
|
||||||
|
// satellite with a frequency plan, when none is followed), plus anything the
|
||||||
|
// old hand-kept list still holds so nobody's typing is thrown away.
|
||||||
|
//
|
||||||
|
// SAT_NAME is compared character for character by the awards and by LoTW, so
|
||||||
|
// offering the spelling already used beats inventing a new one every pass.
|
||||||
|
func (a *App) GetSatelliteNames() []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
add := func(n string) {
|
||||||
|
n = strings.ToUpper(strings.TrimSpace(n))
|
||||||
|
if n == "" || seen[n] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[n] = true
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
for _, n := range a.satNames(nil) {
|
||||||
|
add(n)
|
||||||
|
}
|
||||||
|
// The legacy list. Read, never written: the panel that edited it is gone,
|
||||||
|
// and what it holds is somebody's past work.
|
||||||
|
if a.settings != nil {
|
||||||
|
if raw, _ := a.settings.Get(a.ctx, keyListsSatellites); raw != "" {
|
||||||
|
var legacy []string
|
||||||
|
if json.Unmarshal([]byte(raw), &legacy) == nil {
|
||||||
|
for _, n := range legacy {
|
||||||
|
add(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// satElement finds the elements for a bird, trying its aliases.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
func satElement(store *sat.Store, b sat.Bird) (sat.Element, bool) {
|
||||||
|
if e, ok := store.Get(b.Name); ok {
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
|
for _, alias := range b.Aliases {
|
||||||
|
if e, ok := store.Get(alias); ok {
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Last resort: scan, matching on letters and digits alone — that is how
|
||||||
|
// "RADFXSAT (FOX-1B)" and "AO-91" meet.
|
||||||
|
for _, n := range store.Names() {
|
||||||
|
if b.Matches(n) {
|
||||||
|
if e, ok := store.Get(n); ok {
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sat.Element{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tracking ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// satNames resolves the names the UI asked for, falling back to the favourites
|
||||||
|
// and then to every planned bird we hold elements for.
|
||||||
|
func (a *App) satNames(names []string) []string {
|
||||||
|
if len(names) > 0 {
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
set := a.satSettings()
|
||||||
|
if len(set.Favorites) > 0 {
|
||||||
|
return set.Favorites
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, b := range a.GetSatelliteBirds() {
|
||||||
|
if b.HasElements && len(b.Transponders) > 0 {
|
||||||
|
out = append(out, b.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// satResolve maps an operator-facing name onto the element set's own spelling.
|
||||||
|
func (a *App) satResolve(name string) (string, bool) {
|
||||||
|
store, birds, _ := a.satParts()
|
||||||
|
if _, ok := store.Get(name); ok {
|
||||||
|
return name, true
|
||||||
|
}
|
||||||
|
if b, ok := birds.Find(name); ok {
|
||||||
|
if e, ok2 := satElement(store, b); ok2 {
|
||||||
|
return e.Name, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSatellitePositions is where the given satellites are right now — the map's
|
||||||
|
// question, and the rotator's.
|
||||||
|
func (a *App) GetSatellitePositions(names []string) ([]sat.Position, error) {
|
||||||
|
obs, err := a.satObserver()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
store, _, _ := a.satParts()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
var out []sat.Position
|
||||||
|
for _, n := range a.satNames(names) {
|
||||||
|
real, ok := a.satResolve(n)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p, err := store.Track(real, obs, now)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.Name = n // answer in the operator's vocabulary, not the feed's
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSatelliteGroundTrack is the path a satellite draws over the ground, for
|
||||||
|
// the map: one point a minute, forward from now.
|
||||||
|
func (a *App) GetSatelliteGroundTrack(name string, minutes int) ([]sat.Position, error) {
|
||||||
|
if minutes <= 0 || minutes > 360 {
|
||||||
|
minutes = 120
|
||||||
|
}
|
||||||
|
obs, err := a.satObserver()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
real, ok := a.satResolve(name)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s is not in the element set", name)
|
||||||
|
}
|
||||||
|
store, _, _ := a.satParts()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
out := make([]sat.Position, 0, minutes+1)
|
||||||
|
for i := 0; i <= minutes; i++ {
|
||||||
|
p, err := store.Track(real, obs, now.Add(time.Duration(i)*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.Name = name
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSatellitePasses lists what is coming, in time order.
|
||||||
|
func (a *App) GetSatellitePasses(names []string, hours int) ([]sat.Pass, error) {
|
||||||
|
obs, err := a.satObserver()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
set := a.satSettings()
|
||||||
|
if hours <= 0 {
|
||||||
|
hours = set.WindowH
|
||||||
|
}
|
||||||
|
if hours > 168 {
|
||||||
|
hours = 168
|
||||||
|
}
|
||||||
|
store, _, _ := a.satParts()
|
||||||
|
want := a.satNames(names)
|
||||||
|
// The store is keyed by the feed's names; remember which operator name each
|
||||||
|
// answer belongs to so the table reads the way the operator thinks.
|
||||||
|
real := make([]string, 0, len(want))
|
||||||
|
back := map[string]string{}
|
||||||
|
for _, n := range want {
|
||||||
|
r, ok := a.satResolve(n)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
real = append(real, r)
|
||||||
|
back[r] = n
|
||||||
|
}
|
||||||
|
passes := store.NextPasses(real, obs, time.Now().UTC(), time.Duration(hours)*time.Hour, set.MinEl)
|
||||||
|
for i := range passes {
|
||||||
|
if n, ok := back[passes[i].Name]; ok {
|
||||||
|
passes[i].Name = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return passes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatSkyPoint is one moment of a pass as the antenna sees it.
|
||||||
|
type SatSkyPoint struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
Az float64 `json:"az"`
|
||||||
|
El float64 `json:"el"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSatelliteSkyTrack is the pass drawn as a path across the sky.
|
||||||
|
//
|
||||||
|
// The map answers "where is it over the earth"; this answers "where do I look",
|
||||||
|
// which on a pass is the question that matters. An operator reading a polar
|
||||||
|
// plot knows in one glance whether the bird comes over the top or clips the
|
||||||
|
// horizon behind the house — something no amount of azimuth and elevation
|
||||||
|
// digits conveys.
|
||||||
|
func (a *App) GetSatelliteSkyTrack(name string, points int) ([]SatSkyPoint, error) {
|
||||||
|
if points < 8 || points > 400 {
|
||||||
|
points = 120
|
||||||
|
}
|
||||||
|
p, err := a.GetSatelliteNextPass(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !p.HasPass {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
obs, err := a.satObserver()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
real, ok := a.satResolve(name)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s is not in the element set", name)
|
||||||
|
}
|
||||||
|
store, _, _ := a.satParts()
|
||||||
|
span := p.LOS.Sub(p.AOS)
|
||||||
|
if span <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
out := make([]SatSkyPoint, 0, points+1)
|
||||||
|
for i := 0; i <= points; i++ {
|
||||||
|
at := p.AOS.Add(time.Duration(float64(span) * float64(i) / float64(points)))
|
||||||
|
pos, err := store.Track(real, obs, at)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Below the horizon at the very ends, by a fraction of a degree, because
|
||||||
|
// the pass boundaries come from a coarser search than this sampling. A
|
||||||
|
// negative elevation would draw the track outside the horizon circle.
|
||||||
|
if pos.El < 0 {
|
||||||
|
pos.El = 0
|
||||||
|
}
|
||||||
|
out = append(out, SatSkyPoint{At: at.UTC(), Az: pos.Az, El: pos.El})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSatelliteNextPass is the pass in progress, or the next one to come.
|
||||||
|
//
|
||||||
|
// The one question that decides whether an operator sits down at the radio, and
|
||||||
|
// the reason a satellite tab is worth having at all: how long have I got, and
|
||||||
|
// how high does it get.
|
||||||
|
func (a *App) GetSatelliteNextPass(name string) (SatPassInfo, error) {
|
||||||
|
out := SatPassInfo{Name: name}
|
||||||
|
obs, err := a.satObserver()
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
real, ok := a.satResolve(name)
|
||||||
|
if !ok {
|
||||||
|
return out, fmt.Errorf("%s is not in the element set", name)
|
||||||
|
}
|
||||||
|
store, _, _ := a.satParts()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
// From a little before now: a pass that started two minutes ago is the one
|
||||||
|
// the operator is in, and asking from this instant would skip it and report
|
||||||
|
// the next orbit instead — an hour and a half away, while the satellite is
|
||||||
|
// overhead.
|
||||||
|
from := now.Add(-30 * time.Minute)
|
||||||
|
// Elevation zero, not the operator's minimum. That threshold filters the
|
||||||
|
// table of passes worth waiting for; it must not hide the pass they are
|
||||||
|
// actually working.
|
||||||
|
passes, err := store.Passes(real, obs, from, now.Add(26*time.Hour), 0)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
for _, p := range passes {
|
||||||
|
if p.LOS.Before(now) {
|
||||||
|
continue // already over
|
||||||
|
}
|
||||||
|
out.HasPass = true
|
||||||
|
out.InPass = !p.AOS.After(now)
|
||||||
|
out.AOS, out.LOS = p.AOS, p.LOS
|
||||||
|
out.AOSAz, out.LOSAz = p.AOSAz, p.LOSAz
|
||||||
|
out.MaxEl, out.MaxElAz, out.MaxElAt = p.MaxEl, p.MaxElAz, p.MaxElAt
|
||||||
|
out.Duration = p.Duration
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSatelliteTuning is the working answer: where to listen, where to transmit,
|
||||||
|
// and where the bird is, for one satellite and one transponder.
|
||||||
|
//
|
||||||
|
// downHz is where the operator has tuned inside the passband, in NOMINAL terms
|
||||||
|
// — 0 means the middle of it. Keeping the operator's frequency nominal, and
|
||||||
|
// applying Doppler only on the way out to the radio, is what makes a linear
|
||||||
|
// pass workable: the station being answered stays put on the dial while both
|
||||||
|
// radios chase the shift.
|
||||||
|
func (a *App) GetSatelliteTuning(name string, transponder int, downHz int64) (SatTuning, error) {
|
||||||
|
_, birds, _ := a.satParts()
|
||||||
|
b, ok := birds.Find(name)
|
||||||
|
if !ok {
|
||||||
|
return SatTuning{}, fmt.Errorf("%s has no frequency plan — add one in %s", name, sat.BirdsName)
|
||||||
|
}
|
||||||
|
if transponder < 0 || transponder >= len(b.Transponders) {
|
||||||
|
transponder = 0
|
||||||
|
}
|
||||||
|
if len(b.Transponders) == 0 {
|
||||||
|
return SatTuning{}, fmt.Errorf("%s has no transponder listed", b.Name)
|
||||||
|
}
|
||||||
|
t := b.Transponders[transponder]
|
||||||
|
if downHz <= 0 {
|
||||||
|
// While the tracker is running it owns the nominal frequency — it moves
|
||||||
|
// as the operator tunes. Reading the centre of the passband instead would
|
||||||
|
// show a frequency nobody is on the moment they hunt for a station.
|
||||||
|
downHz = a.satTrackedNominal(b.Name, transponder)
|
||||||
|
}
|
||||||
|
if downHz <= 0 {
|
||||||
|
downHz = t.Centre()
|
||||||
|
}
|
||||||
|
out := SatTuning{
|
||||||
|
Name: b.Name,
|
||||||
|
Transponder: t.Label,
|
||||||
|
Mode: t.Mode,
|
||||||
|
NominalDown: downHz,
|
||||||
|
NominalUp: t.UplinkFor(downHz),
|
||||||
|
CTCSS: t.CTCSS,
|
||||||
|
Inverting: t.Inverting,
|
||||||
|
At: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
// Geostationary: it does not move, so there is nothing to correct and no
|
||||||
|
// look angle worth recomputing every second. QO-100 is simply pointed at
|
||||||
|
// once and left alone.
|
||||||
|
if b.Geostationary {
|
||||||
|
out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
|
||||||
|
out.Visible = true
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
obs, err := a.satObserver()
|
||||||
|
if err != nil {
|
||||||
|
// No locator: the frequencies are still worth having, uncorrected.
|
||||||
|
out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
real, ok := a.satResolve(name)
|
||||||
|
if !ok {
|
||||||
|
out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
|
||||||
|
return out, fmt.Errorf("%s is not in the element set — refresh the elements", b.Name)
|
||||||
|
}
|
||||||
|
store, _, _ := a.satParts()
|
||||||
|
p, err := store.Track(real, obs, out.At)
|
||||||
|
if err != nil {
|
||||||
|
out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
sh := sat.Doppler(p, out.NominalDown, out.NominalUp)
|
||||||
|
out.DownHz, out.UpHz = sh.DownHz, sh.UpHz
|
||||||
|
out.Az, out.El, out.RangeKm, out.RangeRate = p.Az, p.El, p.RangeKm, p.RangeRate
|
||||||
|
out.Lat, out.Lon, out.AltKm, out.Footprint = p.Lat, p.Lon, p.AltKm, p.Footprint
|
||||||
|
out.Visible = p.Visible()
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The two ways a satellite station points its antenna.
|
||||||
|
//
|
||||||
|
// Some operators drive their az/el rotator directly — EasyComm II, what
|
||||||
|
// SatPC32 and Gpredict speak. Others already run PstRotator, which sits between
|
||||||
|
// them and a dozen different controllers and handles az AND el; for those,
|
||||||
|
// OpsLog talking to the controller itself would be a second program fighting
|
||||||
|
// PstRotator over the same cable.
|
||||||
|
//
|
||||||
|
// So both, behind one small interface, chosen in Settings. Neither is more
|
||||||
|
// "correct" than the other: the right one is whichever the station already has
|
||||||
|
// working.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"hamlog/internal/rotator/easycomm"
|
||||||
|
"hamlog/internal/rotator/pst"
|
||||||
|
)
|
||||||
|
|
||||||
|
// satRotator is what the tracker needs of an antenna: point it, ask where it
|
||||||
|
// is, and let go of it at the end of the pass.
|
||||||
|
type satRotator interface {
|
||||||
|
Point(az, el float64) error
|
||||||
|
// Heading reports where the antenna is. live is false when the answer is
|
||||||
|
// the last commanded position rather than a reading — a stuck rotator must
|
||||||
|
// not be able to hide behind an order it never carried out.
|
||||||
|
Heading() (az, el float64, live bool, err error)
|
||||||
|
Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rotator kinds, as stored.
|
||||||
|
const (
|
||||||
|
satRotEasycomm = "easycomm"
|
||||||
|
satRotPst = "pstrotator"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newSatRotator builds the configured controller.
|
||||||
|
func newSatRotator(s SatSettings) (satRotator, error) {
|
||||||
|
switch s.RotType {
|
||||||
|
case satRotPst:
|
||||||
|
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
|
||||||
|
default:
|
||||||
|
if s.RotTransport == "tcp" {
|
||||||
|
if strings.TrimSpace(s.RotHost) == "" {
|
||||||
|
return nil, fmt.Errorf("no address for the rotator")
|
||||||
|
}
|
||||||
|
return easycomm.New(s.RotHost, s.RotPort, s.RotMaxAz), nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s.RotCOM) == "" {
|
||||||
|
return nil, fmt.Errorf("no COM port for the rotator")
|
||||||
|
}
|
||||||
|
return easycomm.NewSerial(s.RotCOM, s.RotBaud, s.RotMaxAz), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pstSatRotator points the antenna through PstRotator.
|
||||||
|
//
|
||||||
|
// PstRotator takes whole degrees and does its own overlap handling for a 450°
|
||||||
|
// rotator — it knows which controller is on the other end, and OpsLog does not.
|
||||||
|
// So the azimuth is sent plainly, and the 450° logic that EasyComm needs is
|
||||||
|
// deliberately NOT applied here: two programs each deciding to go the long way
|
||||||
|
// round is how an antenna ends up unwinding in the middle of a pass.
|
||||||
|
type pstSatRotator struct {
|
||||||
|
c *pst.Client
|
||||||
|
maxAz int
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
// lastAz/lastEl are what was commanded, for the display when PstRotator
|
||||||
|
// does not answer a position query — which is the usual case for the many
|
||||||
|
// setups whose controller reports nothing back to it either.
|
||||||
|
lastAz, lastEl float64
|
||||||
|
commanded bool
|
||||||
|
azSilent bool // the azimuth query went unanswered; stop asking
|
||||||
|
elSilent bool // likewise for elevation, and far more common
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pstSatRotator) Point(az, el float64) error {
|
||||||
|
a := math.Mod(az, 360)
|
||||||
|
if a < 0 {
|
||||||
|
a += 360
|
||||||
|
}
|
||||||
|
if el < 0 {
|
||||||
|
el = 0
|
||||||
|
}
|
||||||
|
if el > 180 {
|
||||||
|
el = 180
|
||||||
|
}
|
||||||
|
if err := p.c.GoTo(int(math.Round(a)), true, int(math.Round(el))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
p.lastAz, p.lastEl, p.commanded = a, el, true
|
||||||
|
p.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pstSatRotator) Heading() (float64, float64, bool, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
azSilent, elSilent, la, le, commanded := p.azSilent, p.elSilent, p.lastAz, p.lastEl, p.commanded
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
az, el, live := la, le, false
|
||||||
|
if !azSilent {
|
||||||
|
if v, _, err := p.c.Heading(); err == nil {
|
||||||
|
az, live = float64(v), true
|
||||||
|
} else {
|
||||||
|
// One silence is enough. Each query binds a socket and waits a second
|
||||||
|
// and a half; repeating that every few seconds for a setup that will
|
||||||
|
// never answer is a stall per poll for nothing.
|
||||||
|
p.mu.Lock()
|
||||||
|
p.azSilent = true
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !elSilent {
|
||||||
|
if v, _, err := p.c.Elevation(); err == nil {
|
||||||
|
el = float64(v)
|
||||||
|
} else {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.elSilent = true
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !live && !commanded {
|
||||||
|
return 0, 0, false, fmt.Errorf("PstRotator does not report the antenna position")
|
||||||
|
}
|
||||||
|
return az, el, live, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close: nothing to release. Every PstRotator command is one datagram, and the
|
||||||
|
// socket lives for the length of a single write.
|
||||||
|
func (p *pstSatRotator) Close() {}
|
||||||
@@ -0,0 +1,622 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Doppler tracking — walking the radio through a pass.
|
||||||
|
//
|
||||||
|
// The hard part of satellite tuning is not the arithmetic, it is deciding who
|
||||||
|
// owns the dial. A tracker that simply forces both frequencies fights the
|
||||||
|
// operator every time they turn the knob to follow a station across a linear
|
||||||
|
// transponder, and one that never touches the receiver leaves them chasing a
|
||||||
|
// signal that slides 9 kHz across a 70 cm pass.
|
||||||
|
//
|
||||||
|
// So: the operator owns the receiver, and the tracker follows them. Every tick
|
||||||
|
// it asks the radio where the receiver actually is. If that is where the tracker
|
||||||
|
// put it, nothing has changed and it keeps correcting from the same NOMINAL
|
||||||
|
// frequency. If it has moved further than a dial-turn's tolerance, the operator
|
||||||
|
// has chosen a new station: the tracker converts what they landed on back into a
|
||||||
|
// nominal frequency and carries on from there. The transmitter is derived from
|
||||||
|
// the nominal and never argued with — which is exactly the division of labour on
|
||||||
|
// a linear bird, where the operator listens and the radio does the sums.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/cat"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
"hamlog/internal/sat"
|
||||||
|
)
|
||||||
|
|
||||||
|
// satTickEvery is how often the radio is re-pointed. One second: at the middle
|
||||||
|
// of a 70 cm pass the downlink moves about 60 Hz a second, which is audible on
|
||||||
|
// SSB within two or three of them and inaudible within one.
|
||||||
|
const satTickEvery = time.Second
|
||||||
|
|
||||||
|
// satDialTolerance is how far the receiver may differ from where the tracker put
|
||||||
|
// it before that difference is read as the operator tuning.
|
||||||
|
//
|
||||||
|
// 200 Hz is comfortably more than the rounding and the round-trip lag between
|
||||||
|
// setting a frequency and reading it back, and comfortably less than the
|
||||||
|
// smallest deliberate move anybody makes hunting a station on a transponder.
|
||||||
|
const satDialTolerance = 200
|
||||||
|
|
||||||
|
// satLightKmS is the speed of light in km/s, for turning a heard frequency back
|
||||||
|
// into a nominal one. The same constant internal/sat corrects with.
|
||||||
|
const satLightKmS = 299792.458
|
||||||
|
|
||||||
|
type satTracker struct {
|
||||||
|
name string
|
||||||
|
tp int
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
// nominalDown is where the operator is, expressed as if the satellite were
|
||||||
|
// standing still. Everything else is derived from it, and it is the only
|
||||||
|
// thing a dial movement changes.
|
||||||
|
nominalDown int64
|
||||||
|
lastDown int64 // what was last sent to the radio
|
||||||
|
lastUp int64
|
||||||
|
status SatTrackStatus
|
||||||
|
fails int
|
||||||
|
|
||||||
|
// The az/el rotator, built once at the start of the pass so a serial port is
|
||||||
|
// opened once rather than on every command. nil when none is configured.
|
||||||
|
rot satRotator
|
||||||
|
rotStep float64
|
||||||
|
rotMinE float64
|
||||||
|
rotPark bool
|
||||||
|
rotAz float64 // last commanded, so a step smaller than the beamwidth costs nothing
|
||||||
|
rotEl float64
|
||||||
|
rotSent bool
|
||||||
|
rotReadAt time.Time // when the controller was last asked where it is
|
||||||
|
|
||||||
|
stop chan struct{}
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatTrackStatus is what the tracker is doing, for the panel.
|
||||||
|
type SatTrackStatus struct {
|
||||||
|
On bool `json:"on"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Transponder string `json:"transponder"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
NominalDown int64 `json:"nominal_down"`
|
||||||
|
NominalUp int64 `json:"nominal_up"`
|
||||||
|
DownHz int64 `json:"down_hz"`
|
||||||
|
UpHz int64 `json:"up_hz"`
|
||||||
|
Az float64 `json:"az"`
|
||||||
|
El float64 `json:"el"`
|
||||||
|
Visible bool `json:"visible"`
|
||||||
|
Radio string `json:"radio"` // what the rig is doing: "sat", "downlink-only", ""
|
||||||
|
Error string `json:"error"`
|
||||||
|
|
||||||
|
// Where the antenna is. RotLive distinguishes a reading from the controller
|
||||||
|
// from the last position it was TOLD to go to — a stuck rotator must not be
|
||||||
|
// able to hide behind a command it never carried out.
|
||||||
|
RotOn bool `json:"rot_on"`
|
||||||
|
RotAz float64 `json:"rot_az"`
|
||||||
|
RotEl float64 `json:"rot_el"`
|
||||||
|
RotLive bool `json:"rot_live"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartSatelliteTracking arms the radio and starts following the satellite.
|
||||||
|
func (a *App) StartSatelliteTracking(name string, transponder int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("CAT is not running")
|
||||||
|
}
|
||||||
|
_, birds, _ := a.satParts()
|
||||||
|
b, ok := birds.Find(name)
|
||||||
|
if !ok || len(b.Transponders) == 0 {
|
||||||
|
return fmt.Errorf("%s has no frequency plan to tune to", name)
|
||||||
|
}
|
||||||
|
if transponder < 0 || transponder >= len(b.Transponders) {
|
||||||
|
transponder = 0
|
||||||
|
}
|
||||||
|
a.StopSatelliteTracking()
|
||||||
|
|
||||||
|
t := &satTracker{
|
||||||
|
name: b.Name,
|
||||||
|
tp: transponder,
|
||||||
|
nominalDown: b.Transponders[transponder].Centre(),
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
t.status = SatTrackStatus{On: true, Name: b.Name, Transponder: b.Transponders[transponder].Label, Mode: b.Transponders[transponder].Mode}
|
||||||
|
|
||||||
|
// The rotator, if there is one. A geostationary bird is pointed at once and
|
||||||
|
// left alone, so it gets one command rather than a loop.
|
||||||
|
set := a.satSettings()
|
||||||
|
if set.RotOn {
|
||||||
|
r, rerr := newSatRotator(set)
|
||||||
|
if rerr != nil {
|
||||||
|
applog.Printf("sat: no rotator: %v", rerr)
|
||||||
|
t.status.Error = rerr.Error()
|
||||||
|
} else {
|
||||||
|
t.rot = r
|
||||||
|
t.rotStep, t.rotMinE, t.rotPark = float64(set.RotStep), float64(set.RotMinEl), set.RotPark
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arm the radio for the pair. A rig that cannot hold one is NOT a failure:
|
||||||
|
// it can still be tuned to the downlink, which is most of a receive-heavy
|
||||||
|
// pass, and saying so beats refusing to track at all.
|
||||||
|
radio := "downlink-only"
|
||||||
|
if a.cat.SatCapable() {
|
||||||
|
if err := a.cat.SatDo(func(st cat.SatTuner) error { return st.SetSatellite(true) }); err != nil {
|
||||||
|
applog.Printf("sat: could not arm satellite mode: %v", err)
|
||||||
|
t.status.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
radio = "sat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.status.Radio = radio
|
||||||
|
|
||||||
|
a.satTrackMu.Lock()
|
||||||
|
a.satTrack = t
|
||||||
|
a.satTrackMu.Unlock()
|
||||||
|
go a.satTrackLoop(t)
|
||||||
|
applog.Printf("sat: tracking %s (%s), radio %s", t.name, t.status.Transponder, radio)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopSatelliteTracking hands the radio back.
|
||||||
|
func (a *App) StopSatelliteTracking() {
|
||||||
|
a.satTrackMu.Lock()
|
||||||
|
t := a.satTrack
|
||||||
|
a.satTrack = nil
|
||||||
|
a.satTrackMu.Unlock()
|
||||||
|
if t == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
close(t.stop)
|
||||||
|
<-t.done
|
||||||
|
if a.cat != nil && a.cat.SatCapable() {
|
||||||
|
if err := a.cat.SatDo(func(st cat.SatTuner) error { return st.SetSatellite(false) }); err != nil {
|
||||||
|
applog.Printf("sat: could not disarm satellite mode: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applog.Printf("sat: tracking stopped (%s)", t.name)
|
||||||
|
a.emitSatTrack(SatTrackStatus{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSatelliteRotator opens the configured controller and asks it where it is.
|
||||||
|
//
|
||||||
|
// The one question worth asking before a pass: is this port the rotator, and
|
||||||
|
// does it talk back? A controller that accepts commands silently is a normal,
|
||||||
|
// working one — so that answer is a success with a caveat, not a failure.
|
||||||
|
func (a *App) TestSatelliteRotator() (string, error) {
|
||||||
|
set := a.satSettings()
|
||||||
|
if !set.RotOn {
|
||||||
|
return "", fmt.Errorf("the satellite rotator is switched off")
|
||||||
|
}
|
||||||
|
c, err := newSatRotator(set)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
az, el, live, err := c.Heading()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !live {
|
||||||
|
return "The controller accepted the command but does not report its position — normal for many controllers. It will still be driven.", nil
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("The rotator is at %.1f° azimuth, %.1f° elevation.", az, el), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSatelliteTracking reports what the tracker is doing.
|
||||||
|
func (a *App) GetSatelliteTracking() SatTrackStatus {
|
||||||
|
a.satTrackMu.Lock()
|
||||||
|
t := a.satTrack
|
||||||
|
a.satTrackMu.Unlock()
|
||||||
|
if t == nil {
|
||||||
|
return SatTrackStatus{}
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
return t.status
|
||||||
|
}
|
||||||
|
|
||||||
|
// satTrackedNominal is the nominal downlink the tracker is currently working
|
||||||
|
// from, or 0 when it is not tracking this satellite and transponder.
|
||||||
|
func (a *App) satTrackedNominal(name string, transponder int) int64 {
|
||||||
|
a.satTrackMu.Lock()
|
||||||
|
t := a.satTrack
|
||||||
|
a.satTrackMu.Unlock()
|
||||||
|
if t == nil || t.tp != transponder || !strings.EqualFold(t.name, name) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
return t.nominalDown
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) emitSatTrack(s SatTrackStatus) {
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "sat:track", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) satTrackLoop(t *satTracker) {
|
||||||
|
defer close(t.done)
|
||||||
|
defer t.releaseRotator()
|
||||||
|
tick := time.NewTicker(satTickEvery)
|
||||||
|
defer tick.Stop()
|
||||||
|
for {
|
||||||
|
a.satTrackStep(t)
|
||||||
|
select {
|
||||||
|
case <-t.stop:
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// satTrackStep is one pass of the loop: read the dial, work out the pair, send
|
||||||
|
// what changed.
|
||||||
|
func (a *App) satTrackStep(t *satTracker) {
|
||||||
|
_, birds, _ := a.satParts()
|
||||||
|
b, ok := birds.Find(t.name)
|
||||||
|
if !ok || t.tp >= len(b.Transponders) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tp := b.Transponders[t.tp]
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
nominal := t.nominalDown
|
||||||
|
lastDown, lastUp := t.lastDown, t.lastUp
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
// Where the satellite is, and how fast it is running away. A geostationary
|
||||||
|
// bird is neither: its range rate is zero, so the zero position below gives
|
||||||
|
// a zero shift without a special case, and asking for a look angle we do not
|
||||||
|
// need would only fail on a station with no locator.
|
||||||
|
var pos sat.Position
|
||||||
|
visible := true
|
||||||
|
if !b.Geostationary {
|
||||||
|
obs, err := a.satObserver()
|
||||||
|
if err != nil {
|
||||||
|
t.setError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
real, ok := a.satResolve(t.name)
|
||||||
|
if !ok {
|
||||||
|
t.setError(fmt.Sprintf("%s is not in the element set", t.name))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store, _, _ := a.satParts()
|
||||||
|
p, err := store.Track(real, obs, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.setError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pos = p
|
||||||
|
visible = p.Visible()
|
||||||
|
}
|
||||||
|
// The fractional shift, positive when the satellite is approaching. Only the
|
||||||
|
// dial arithmetic below needs it as a number; the pair itself comes from
|
||||||
|
// sat.Doppler, so there is exactly one place where the sign of a correction
|
||||||
|
// is decided.
|
||||||
|
factor := -pos.RangeRate / satLightKmS
|
||||||
|
|
||||||
|
// Where did the operator leave the receiver? If it is not where the tracker
|
||||||
|
// put it, they have moved to another station and that is the new nominal.
|
||||||
|
if lastDown > 0 && tp.Linear() {
|
||||||
|
if actual, err := a.satReceiveHz(); err == nil && actual > 0 {
|
||||||
|
if abs64i(actual-lastDown) > satDialTolerance {
|
||||||
|
moved := satNominalFromDial(actual, factor)
|
||||||
|
if moved >= tp.DownLo && moved <= tp.DownHi {
|
||||||
|
nominal = moved
|
||||||
|
t.mu.Lock()
|
||||||
|
t.nominalDown = moved
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nomUp := tp.UplinkFor(nominal)
|
||||||
|
sh := sat.Doppler(pos, nominal, nomUp)
|
||||||
|
down, up := sh.DownHz, sh.UpHz
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
t.status = SatTrackStatus{
|
||||||
|
On: true, Name: b.Name, Transponder: tp.Label, Mode: tp.Mode,
|
||||||
|
NominalDown: nominal, NominalUp: nomUp,
|
||||||
|
DownHz: down, UpHz: up,
|
||||||
|
Az: pos.Az, El: pos.El, Visible: visible,
|
||||||
|
Radio: t.status.Radio, Error: t.status.Error,
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
t.pointRotator(pos, b.Geostationary)
|
||||||
|
t.readRotator()
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
st := t.status
|
||||||
|
t.mu.Unlock()
|
||||||
|
a.emitSatTrack(st)
|
||||||
|
|
||||||
|
// Only send what has actually moved. The step is the smallest change worth a
|
||||||
|
// command: on SSB a listener hears twenty hertz, on an FM channel nothing
|
||||||
|
// under a couple of hundred matters at all.
|
||||||
|
step := int64(20)
|
||||||
|
if strings.EqualFold(tp.Mode, "FM") {
|
||||||
|
step = 200
|
||||||
|
}
|
||||||
|
if abs64i(down-lastDown) < step && abs64i(up-lastUp) < step {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := tp.Mode
|
||||||
|
if lastDown != 0 {
|
||||||
|
mode = "" // set once, at the start of the pass — see satMode/satSetMode
|
||||||
|
}
|
||||||
|
err := a.satTune(down, up, mode, mode)
|
||||||
|
t.mu.Lock()
|
||||||
|
if err == nil {
|
||||||
|
t.lastDown, t.lastUp, t.fails = down, up, 0
|
||||||
|
t.status.Error = ""
|
||||||
|
} else {
|
||||||
|
t.fails++
|
||||||
|
t.status.Error = err.Error()
|
||||||
|
}
|
||||||
|
fails := t.fails
|
||||||
|
t.mu.Unlock()
|
||||||
|
if err != nil && (fails == 1 || fails%30 == 0) {
|
||||||
|
// Once, then once every half minute: a radio that has gone away must be
|
||||||
|
// visible in the log without filling it.
|
||||||
|
applog.Printf("sat: tuning %s failed (%d in a row): %v", t.name, fails, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// satNominalFromDial turns a frequency the operator tuned to into the nominal
|
||||||
|
// one it corresponds to.
|
||||||
|
//
|
||||||
|
// The inverse of the downlink correction: what comes out of the transponder at
|
||||||
|
// nominal arrives at heard = nominal × (1 + f). Doing this is what lets the
|
||||||
|
// operator hunt across a linear passband without the tracker dragging them back
|
||||||
|
// — where they land becomes the new truth, and the uplink follows it.
|
||||||
|
func satNominalFromDial(heardHz int64, factor float64) int64 {
|
||||||
|
if heardHz <= 0 || factor <= -1 {
|
||||||
|
return heardHz
|
||||||
|
}
|
||||||
|
return int64(math.Round(float64(heardHz) / (1 + factor)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// pointRotator keeps the antenna on the satellite.
|
||||||
|
//
|
||||||
|
// Below the configured elevation the rotator is left alone. Not because the
|
||||||
|
// numbers stop being right — they are right all the way round the orbit — but
|
||||||
|
// because a rotator that chases a satellite through the far side of the earth
|
||||||
|
// spends the whole night turning, and a mast is a mechanical thing with a
|
||||||
|
// finite number of turns in it.
|
||||||
|
func (t *satTracker) pointRotator(pos sat.Position, geostationary bool) {
|
||||||
|
if t.rot == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !geostationary && pos.El < t.rotMinE {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A step below the beamwidth is a command for nothing. Compared against what
|
||||||
|
// was last COMMANDED rather than where the rotator says it is: a rotator in
|
||||||
|
// motion is always somewhere between the two, and comparing against that
|
||||||
|
// would order a fresh move on every tick of a slew.
|
||||||
|
az, el := pos.Az, pos.El
|
||||||
|
if geostationary {
|
||||||
|
// A satellite that does not move needs pointing once. Its own az/el were
|
||||||
|
// not computed (there is nothing to compute), so leave the rotator where
|
||||||
|
// the operator put it.
|
||||||
|
if t.rotSent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t.rotSent && math.Abs(az-t.rotAz) < t.rotStep && math.Abs(el-t.rotEl) < t.rotStep {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := t.rot.Point(az, el); err != nil {
|
||||||
|
t.setError(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.rotAz, t.rotEl, t.rotSent = az, el, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// readRotator asks the controller where it actually is, for the display.
|
||||||
|
//
|
||||||
|
// Separate from the pointing, and it runs on every tick rather than only when a
|
||||||
|
// command was sent: watching the antenna crawl towards the bearing is how an
|
||||||
|
// operator sees a rotator that is slow, stalled, or turning the wrong way. A
|
||||||
|
// controller that does not answer says so once and is not asked again.
|
||||||
|
func (t *satTracker) readRotator() {
|
||||||
|
if t.rot == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Not on every tick. A PstRotator query binds a socket and waits up to a
|
||||||
|
// second and a half for an answer, and a held serial port still costs a
|
||||||
|
// round trip; three seconds is often enough to watch an antenna slew and
|
||||||
|
// rare enough not to sit in the way of the tuning.
|
||||||
|
if time.Since(t.rotReadAt) < 3*time.Second {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.rotReadAt = time.Now()
|
||||||
|
az, el, live, err := t.rot.Heading()
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
t.status.RotOn = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.status.RotOn, t.status.RotAz, t.status.RotEl, t.status.RotLive = true, az, el, live
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseRotator hands the mast back at the end of a pass.
|
||||||
|
func (t *satTracker) releaseRotator() {
|
||||||
|
if t.rot == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if t.rotPark && t.rotSent {
|
||||||
|
// Elevation down first and azimuth to north: a dish or a pair of yagis
|
||||||
|
// left pointing at the sky is what a gale takes away.
|
||||||
|
if err := t.rot.Point(0, 0); err != nil {
|
||||||
|
applog.Printf("sat: could not park the rotator: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.rot.Close()
|
||||||
|
t.rot = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *satTracker) setError(msg string) {
|
||||||
|
t.mu.Lock()
|
||||||
|
t.status.Error = msg
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// satTune sends the pair to whichever radio is connected.
|
||||||
|
func (a *App) satTune(downHz, upHz int64, downMode, upMode string) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("CAT is not running")
|
||||||
|
}
|
||||||
|
if a.cat.SatCapable() {
|
||||||
|
return a.cat.SatDo(func(st cat.SatTuner) error {
|
||||||
|
return st.TuneSatellite(downHz, upHz, downMode, upMode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// No satellite pair on this backend: the downlink is what it can do, and the
|
||||||
|
// operator was told so when tracking started (Radio = "downlink-only").
|
||||||
|
if err := a.cat.SetFrequency(downHz); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if downMode != "" {
|
||||||
|
return a.cat.SetMode(downMode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// satReceiveHz is where the receiver is, asked of the backend that knows.
|
||||||
|
func (a *App) satReceiveHz() (int64, error) {
|
||||||
|
if a.cat == nil {
|
||||||
|
return 0, fmt.Errorf("CAT is not running")
|
||||||
|
}
|
||||||
|
if a.cat.SatCapable() {
|
||||||
|
var hz int64
|
||||||
|
err := a.cat.SatDo(func(st cat.SatTuner) error {
|
||||||
|
v, e := st.SatReceiveHz()
|
||||||
|
hz = v
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
return hz, err
|
||||||
|
}
|
||||||
|
st := a.cat.State()
|
||||||
|
if st.RxFreqHz > 0 {
|
||||||
|
return st.RxFreqHz, nil
|
||||||
|
}
|
||||||
|
return st.FreqHz, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func abs64i(v int64) int64 {
|
||||||
|
if v < 0 {
|
||||||
|
return -v
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── What goes in the log ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// applySatellite stamps a QSO made through a satellite.
|
||||||
|
//
|
||||||
|
// The NOMINAL frequencies are logged, never the Doppler-corrected ones. Two
|
||||||
|
// stations working each other through a transponder read different numbers off
|
||||||
|
// their dials at the same instant — that is what Doppler means — and the only
|
||||||
|
// figure they can both agree on, and the only one that means anything to
|
||||||
|
// somebody reading the log later, is the transponder's own. LoTW matches on the
|
||||||
|
// band, so nothing is lost; a log full of 435.847 231 would simply be a record
|
||||||
|
// of where one radio happened to be.
|
||||||
|
func (a *App) applySatellite(q *qso.QSO) {
|
||||||
|
a.satTrackMu.Lock()
|
||||||
|
t := a.satTrack
|
||||||
|
a.satTrackMu.Unlock()
|
||||||
|
if t == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
name, down, up := t.status.Name, t.status.NominalDown, t.status.NominalUp
|
||||||
|
az, el := t.status.Az, t.status.El
|
||||||
|
t.mu.Unlock()
|
||||||
|
if name == "" || down <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Nothing the operator filled in is overwritten. A QSO edited by hand, or
|
||||||
|
// imported, or logged from a second radio while the tracker happened to be
|
||||||
|
// running, keeps what it was given.
|
||||||
|
if strings.TrimSpace(q.PropMode) == "" {
|
||||||
|
q.PropMode = "SAT"
|
||||||
|
}
|
||||||
|
if q.PropMode != "SAT" {
|
||||||
|
return // they said it was something else — meteor scatter, EME
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(q.SatName) == "" {
|
||||||
|
q.SatName = name
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(q.SatMode) == "" {
|
||||||
|
q.SatMode = satModeLetters(up, down)
|
||||||
|
}
|
||||||
|
// The transmit frequency is the uplink and the receive frequency the
|
||||||
|
// downlink — which is the one place a satellite QSO differs from every other
|
||||||
|
// kind, and the reason FREQ alone cannot describe one.
|
||||||
|
if up > 0 {
|
||||||
|
q.FreqHz = &up
|
||||||
|
if b := bandForHz(up); b != "" {
|
||||||
|
q.Band = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d := down
|
||||||
|
q.FreqRXHz = &d
|
||||||
|
if b := bandForHz(down); b != "" {
|
||||||
|
q.BandRX = b
|
||||||
|
}
|
||||||
|
if q.AntAz == nil && (az != 0 || el != 0) {
|
||||||
|
v := az
|
||||||
|
q.AntAz = &v
|
||||||
|
}
|
||||||
|
if q.AntEl == nil && el != 0 {
|
||||||
|
v := el
|
||||||
|
q.AntEl = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// satModeLetters is the ADIF SAT_MODE: the uplink band's letter, then the
|
||||||
|
// downlink's — "U/V" for 435 up, 145 down. The letters are AMSAT's, and they
|
||||||
|
// are what every satellite operator writes on a QSL card.
|
||||||
|
func satModeLetters(upHz, downHz int64) string {
|
||||||
|
u, d := satBandLetter(upHz), satBandLetter(downHz)
|
||||||
|
if u == "" || d == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return u + "/" + d
|
||||||
|
}
|
||||||
|
|
||||||
|
func satBandLetter(hz int64) string {
|
||||||
|
switch {
|
||||||
|
case hz <= 0:
|
||||||
|
return ""
|
||||||
|
case hz < 30_000_000:
|
||||||
|
return "A" // 10 m — mode A's downlink
|
||||||
|
case hz < 148_000_000:
|
||||||
|
return "V" // 2 m
|
||||||
|
case hz < 450_000_000:
|
||||||
|
return "U" // 70 cm
|
||||||
|
case hz < 1_300_000_000:
|
||||||
|
return "L" // 23 cm
|
||||||
|
case hz < 2_500_000_000:
|
||||||
|
return "S" // 13 cm
|
||||||
|
case hz < 6_000_000_000:
|
||||||
|
return "C" // 6 cm
|
||||||
|
case hz < 11_000_000_000:
|
||||||
|
return "X" // 3 cm
|
||||||
|
}
|
||||||
|
return "K" // 24 GHz and above
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/sat"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The dial arithmetic has to be the exact inverse of the correction, or every
|
||||||
|
// touch of the knob would nudge the nominal frequency a little further off and
|
||||||
|
// the uplink would walk across the passband over a pass.
|
||||||
|
func TestSatNominalFromDialRoundTrip(t *testing.T) {
|
||||||
|
// A range of range rates: hard approach, drifting, hard recession. ±8 km/s
|
||||||
|
// covers a low orbit overhead.
|
||||||
|
for _, rate := range []float64{-8, -3.2, -0.4, 0, 0.4, 3.2, 8} {
|
||||||
|
p := sat.Position{RangeRate: rate}
|
||||||
|
for _, nominal := range []int64{29_450_000, 145_900_000, 435_850_000, 10_489_675_000} {
|
||||||
|
sh := sat.Doppler(p, nominal, 0)
|
||||||
|
factor := -rate / satLightKmS
|
||||||
|
got := satNominalFromDial(sh.DownHz, factor)
|
||||||
|
if diff := got - nominal; diff > 1 || diff < -1 {
|
||||||
|
t.Errorf("rate %.1f km/s, %d Hz: heard %d, came back as %d (%+d)",
|
||||||
|
rate, nominal, sh.DownHz, got, diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAT_MODE is what goes on a QSL card, and the letters are the uplink's then
|
||||||
|
// the downlink's — the order operators write and the order ADIF wants.
|
||||||
|
func TestSatModeLetters(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
up, down int64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"FO-29: 2 m up, 70 cm down", 145_950_000, 435_850_000, "V/U"},
|
||||||
|
{"AO-91: 70 cm up, 2 m down", 435_250_000, 145_960_000, "U/V"},
|
||||||
|
{"AO-7 mode A: 2 m up, 10 m down", 145_900_000, 29_450_000, "V/A"},
|
||||||
|
{"QO-100: 13 cm up, 3 cm down", 2_400_175_000, 10_489_675_000, "S/X"},
|
||||||
|
{"receive only", 0, 145_800_000, ""},
|
||||||
|
} {
|
||||||
|
if got := satModeLetters(tc.up, tc.down); got != tc.want {
|
||||||
|
t.Errorf("%s: got %q, wanted %q", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Which decoder the entry field belongs to, when several are running.
|
||||||
|
//
|
||||||
|
// A station running WSJT-X, JTDX and MSHV at once has three programs sending
|
||||||
|
// Status once a second each. Click a call in one of them and only that one has
|
||||||
|
// a DX Call; the other two are idle and say so. Both statements are true, and
|
||||||
|
// both arrive — so the entry field is filled by the program the operator is
|
||||||
|
// working and emptied by the two that are not, once a second, and the map
|
||||||
|
// zooms in and out with it.
|
||||||
|
//
|
||||||
|
// So the first program to announce a station is FOCUSED, and until it lets go
|
||||||
|
// the others cannot touch the entry field. That is the operator's own answer:
|
||||||
|
// "if I call on one program, keep that one's UDP for the duration of the QSO".
|
||||||
|
//
|
||||||
|
// Focus is released when the focused program clears its own DX Call, when it
|
||||||
|
// stops sending altogether (it was closed), or when a QSO is logged — never on
|
||||||
|
// a timer that could hand the field to another program mid-over.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// udpFocusIdle is how long a focused program may go silent before the focus is
|
||||||
|
// given up.
|
||||||
|
//
|
||||||
|
// Generous on purpose: a decoder sends Status every second, so anything above a
|
||||||
|
// few seconds means it has been closed or has lost its network. Thirty is long
|
||||||
|
// enough to survive a machine that stutters and short enough that a program
|
||||||
|
// closed mid-QSO does not lock the entry field for the rest of the evening.
|
||||||
|
const udpFocusIdle = 30 * time.Second
|
||||||
|
|
||||||
|
type udpFocus struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
inst string
|
||||||
|
at time.Time
|
||||||
|
// told marks that the log already carries the line explaining why another
|
||||||
|
// program's callsign is being ignored. Once per focus, not once a second.
|
||||||
|
told map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// claim records that inst is announcing a station, and reports whether inst is
|
||||||
|
// the program the entry field currently belongs to.
|
||||||
|
func (f *udpFocus) claim(inst string) bool {
|
||||||
|
if inst == "" {
|
||||||
|
return true // a sender with no id: nothing to arbitrate between
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.inst == "" || f.inst == inst || time.Since(f.at) > udpFocusIdle {
|
||||||
|
if f.inst != inst {
|
||||||
|
applog.Printf("udp: the entry field follows %s while it is calling", inst)
|
||||||
|
f.told = nil
|
||||||
|
}
|
||||||
|
f.inst, f.at = inst, time.Now()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// holds reports whether inst may act on the entry field, without claiming it.
|
||||||
|
// Used for the clear: a program that is not focused clearing its own DX Call
|
||||||
|
// says nothing about the QSO in progress somewhere else.
|
||||||
|
func (f *udpFocus) holds(inst string) bool {
|
||||||
|
if inst == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.inst == "" || time.Since(f.at) > udpFocusIdle {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return f.inst == inst
|
||||||
|
}
|
||||||
|
|
||||||
|
// release gives the field up — the focused program cleared its call, or a QSO
|
||||||
|
// was logged and the next station may come from anywhere.
|
||||||
|
func (f *udpFocus) release(why string) {
|
||||||
|
f.mu.Lock()
|
||||||
|
had := f.inst
|
||||||
|
f.inst, f.at, f.told = "", time.Time{}, nil
|
||||||
|
f.mu.Unlock()
|
||||||
|
if had != "" {
|
||||||
|
applog.Printf("udp: the entry field is free again (%s let go: %s)", had, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteIgnored logs, once per focused program, that another one's callsign was
|
||||||
|
// not applied. Without it the behaviour is invisible: an operator whose second
|
||||||
|
// decoder "stopped filling the call" has nothing to read.
|
||||||
|
func (f *udpFocus) noteIgnored(inst, call string) {
|
||||||
|
f.mu.Lock()
|
||||||
|
if f.told == nil {
|
||||||
|
f.told = map[string]bool{}
|
||||||
|
}
|
||||||
|
first := !f.told[inst]
|
||||||
|
f.told[inst] = true
|
||||||
|
holder := f.inst
|
||||||
|
f.mu.Unlock()
|
||||||
|
if first {
|
||||||
|
applog.Printf("udp: [%s] %q not applied — %s has the entry field while it is calling",
|
||||||
|
inst, strings.ToUpper(call), holder)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The reported failure, in order: MSHV is called on, WSJT-X and JTDX sit idle
|
||||||
|
// beside it, and every one of their Status packets used to empty the entry
|
||||||
|
// field that MSHV had just filled — once a second, with the map zooming in and
|
||||||
|
// out to match.
|
||||||
|
func TestUdpFocusKeepsTheFieldWithTheCallingProgram(t *testing.T) {
|
||||||
|
var f udpFocus
|
||||||
|
|
||||||
|
if !f.claim("MSHV") {
|
||||||
|
t.Fatal("the first program to announce a station must take the field")
|
||||||
|
}
|
||||||
|
// The other two, announcing stations of their own, are refused.
|
||||||
|
if f.claim("WSJT-X") {
|
||||||
|
t.Error("WSJT-X took the field while MSHV was calling")
|
||||||
|
}
|
||||||
|
if f.claim("JTDX") {
|
||||||
|
t.Error("JTDX took the field while MSHV was calling")
|
||||||
|
}
|
||||||
|
// And their clears do not empty it — this is the half that caused the flicker.
|
||||||
|
if f.holds("WSJT-X") {
|
||||||
|
t.Error("an idle WSJT-X was allowed to clear MSHV's callsign")
|
||||||
|
}
|
||||||
|
if !f.holds("MSHV") {
|
||||||
|
t.Error("MSHV lost the right to clear its own callsign")
|
||||||
|
}
|
||||||
|
// MSHV moving to the next station keeps the field.
|
||||||
|
if !f.claim("MSHV") {
|
||||||
|
t.Error("the focused program must keep the field across stations")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letting go, three ways.
|
||||||
|
func TestUdpFocusRelease(t *testing.T) {
|
||||||
|
var f udpFocus
|
||||||
|
|
||||||
|
// The focused program clears its own call.
|
||||||
|
f.claim("MSHV")
|
||||||
|
f.release("DX Call cleared")
|
||||||
|
if !f.claim("WSJT-X") {
|
||||||
|
t.Error("after a release the next program should be able to take the field")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A QSO is logged.
|
||||||
|
f.release("QSO logged")
|
||||||
|
if !f.claim("JTDX") {
|
||||||
|
t.Error("logging a QSO must free the field for whichever program hears the next station")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The focused program is closed and stops sending. Its hold lapses rather
|
||||||
|
// than locking the entry field for the rest of the evening.
|
||||||
|
f.mu.Lock()
|
||||||
|
f.at = time.Now().Add(-udpFocusIdle - time.Second)
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !f.claim("MSHV") {
|
||||||
|
t.Error("a silent program must not hold the field for ever")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sender with no program id — an ADIF relay, a remote "set call" — is not
|
||||||
|
// something to arbitrate between, and must never be locked out.
|
||||||
|
func TestUdpFocusIgnoresUnnamedSenders(t *testing.T) {
|
||||||
|
var f udpFocus
|
||||||
|
f.claim("MSHV")
|
||||||
|
if !f.claim("") {
|
||||||
|
t.Error("an unnamed sender was refused the entry field")
|
||||||
|
}
|
||||||
|
if !f.holds("") {
|
||||||
|
t.Error("an unnamed sender was refused a clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,64 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.27.18",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"The dialogs you type in no longer sit on a blurred backdrop. A backdrop filter covers the whole window and is recomputed every time anything above it repaints — and behind these dialogs is an application that never stops moving: CAT polling four times a second, spots arriving, meters sweeping, maps redrawing. Worse in one place: the cluster editor opens from Preferences, so its overlay was a second full-window filter stacked over the first. Preferences, the cluster editor, the QSO editor, bulk edit, alert rules and award definitions now dim the background instead of blurring it; everything else keeps the blur.",
|
||||||
|
"One padlock on the entry form instead of five. Logging a contact from paper — a contest sheet, a friend's report, a QSO worked on another radio — means the frequency, the band, the mode, the date and both times all have to stop following the rig and the clock at once. That was five clicks in five different places, each of which had to be found first. The padlock beside Start UTC now holds all of them, and releases all of them.",
|
||||||
|
"Preferences no longer says the section name twice — the small line above each panel repeated the heading right under it, and the sidebar beside it already shows which section is open.",
|
||||||
|
"The band matrix can open on the digital mode you actually work. An operator who only ever does FT8 was shown DIGI every time and had to click through to their own mode on every callsign; Settings → General now chooses which digital row the matrix starts on. The row still rotates when you click it, and DIGI — all of them together — stays the default.",
|
||||||
|
"The MQTT chip is gone from the status bar. That is the name of a message protocol, not of anything an operator has. The state it carried — the openings feed up or down, and how many reports have arrived — is in the Chase New panel, which is the place that uses it.",
|
||||||
|
"The callsign box no longer narrows when you close the padlock. Its row gains a date field for a manual entry, and a flex row makes room by shrinking its children — so the widest box, the one the eye is on while typing, was the one that visibly moved. The callsign and both report boxes are now a notch narrower and fixed there, whether the date is showing or not."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Les dialogues dans lesquels on tape ne reposent plus sur un fond flouté. Un filtre de fond couvre toute la fenêtre et est recalculé chaque fois que quoi que ce soit au-dessus se repeint — et derrière ces dialogues il y a une application qui ne s'arrête jamais de bouger : le CAT qui interroge quatre fois par seconde, les spots qui arrivent, les vumètres qui balaient, les cartes qui se redessinent. Pire à un endroit : l'éditeur de cluster s'ouvre depuis les Préférences, donc son fond était un deuxième filtre plein écran empilé sur le premier. Les Préférences, l'éditeur de cluster, l'éditeur de QSO, l'édition groupée, les règles d'alerte et les définitions de diplômes assombrissent désormais le fond au lieu de le flouter ; tout le reste garde le flou.",
|
||||||
|
"Un seul cadenas dans la saisie au lieu de cinq. Enregistrer un contact depuis une feuille — un carnet de concours, le report d'un ami, un QSO fait sur une autre radio — suppose que la fréquence, la bande, le mode, la date et les deux heures cessent tous en même temps de suivre le poste et l'horloge. C'étaient cinq clics à cinq endroits différents, qu'il fallait d'abord trouver. Le cadenas à côté de Début UTC les fige maintenant tous, et les libère tous.",
|
||||||
|
"Les Préférences ne disent plus deux fois le nom de la section — la petite ligne au-dessus de chaque panneau répétait le titre juste en dessous, et la barre latérale montre déjà laquelle est ouverte.",
|
||||||
|
"La matrice peut s'ouvrir sur le mode numérique que vous travaillez vraiment. Celui qui ne fait que du FT8 voyait DIGI à chaque fois et devait cliquer jusqu'à son mode pour chaque indicatif ; Réglages → Général choisit désormais la ligne numérique sur laquelle la matrice démarre. La ligne continue de tourner au clic, et DIGI — tous ensemble — reste le défaut.",
|
||||||
|
"La pastille MQTT disparaît de la barre d'état. C'est le nom d'un protocole de messages, pas de quelque chose que possède un opérateur. Ce qu'elle indiquait — le flux d'ouvertures actif ou non, et le nombre de reports arrivés — est dans le panneau Chasse au nouveau, à l'endroit qui s'en sert.",
|
||||||
|
"Le champ indicatif ne rétrécit plus quand on ferme le cadenas. Sa ligne gagne un champ date pour une saisie manuelle, et une ligne flex fait de la place en rétrécissant ses enfants — donc le plus large, celui que l'œil suit pendant la frappe, était celui qui bougeait visiblement. L'indicatif et les deux champs de report sont désormais un cran plus étroits et fixes, que la date soit affichée ou non."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.17",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"[NEW] Satellites. A new tab (Tools → Satellites) works the amateur birds from end to end. A map with each satellite's footprint and the selected one's path over the ground; a sky plot the way every tracker draws one, centre straight up and rim at the horizon, with the whole pass and where the bird is on it; a countdown to AOS — or to LOS once it is up — with rise, peak and set, their compass directions, distance, altitude and footprint; and a pass table for everything you follow.\n\nTrack puts the radio on the satellite and keeps it there, once a second: an IC-9700 or IC-9100 in its own satellite mode, a FlexRadio on two slices (A the downlink, B the uplink, created if missing, full duplex on) — and any other rig on the downlink, which it says plainly rather than half-doing the job. Tune the receiver where you like: the tracker reads the dial, takes it as the station you have chosen, and moves the transmitter to match. An az/el rotator follows along, either driven directly over EasyComm II or handed to PstRotator if you already run it; a 450° rotator is used as one, so a pass crossing north continues instead of unwinding.\n\nQSOs made while tracking are logged with the NOMINAL frequencies, SAT_NAME, SAT_MODE and PROP_MODE=SAT — the transponder's own numbers, which both stations can agree on, rather than where one radio happened to be.\n\nOrbital elements come from Celestrak with a mirror behind it and are kept on disk, so the tab is full the moment it opens even with no internet; elements for a bird no feed carries yet can be pasted in and survive every refresh. Twenty-five satellites ship with a frequency plan — the FM and linear birds, GreenCube, QO-100 narrow and wide — in a file you can correct yourself when a transponder is switched. Everything about setting it up lives in Settings → Satellites, including which satellites you follow, chosen the way you choose awards.",
|
||||||
|
"Each map keeps its own imagery. The world map and the grid-square map shared one setting, so choosing satellite imagery to look at grids repainted the main map as well, and there was no way to have terrain on one and plain streets on the other. All four — world, grid squares, FT map, satellites — now remember their own choice, and it travels with the data folder like the remembered views. A choice already made for the grid map is carried over, not reset.",
|
||||||
|
"Two or three FT8 programs at once no longer fight over the callsign field. Click a station in MSHV and only MSHV has a DX Call; WSJT-X and JTDX beside it are idle and say so once a second each — and OpsLog was reading those as MSHV abandoning the station, so the entry emptied and refilled at 1 Hz and the map zoomed in and out with it. A cleared DX Call is now read per program, never across the listener; and the program that announces a station keeps the entry field until it clears its own call, is closed, or the QSO is logged.",
|
||||||
|
"The FT decodes table sorts on SNR, frequency, distance, country and status — click the heading. Within each slot and never across them: the periods are what the panel is, and a list sorted end to end would mix three minutes of decodes into one column with no way to tell which window any of them came from. One click sorts the way that column is worth reading (strongest signal, furthest DX, lowest frequency, A to Z, most wanted first), the second reverses it, the third gives back the order the decoder heard them in. Stations with no grid, or no country resolved yet, sort to the end either way rather than pretending to a distance of zero.",
|
||||||
|
"The cluster editor offers a list of known nodes. Setting up a telnet cluster is the step operators get stuck on: the address and the port are two pieces of information nobody has to hand, and a typo in either looks exactly like a node that is down. Pick one and the fields fill in — F4BPO, DXFun, F5LEN, F5MZN, KM3T, SOTA, POTA, and the two Reverse Beacon feeds, which are one network on two ports where 7000 carries CW and RTTY and 7001 carries FT8 and FT4. Everything stays editable, and a node typed in by hand works exactly the same. More will be added.",
|
||||||
|
"Preferences no longer lag behind the keyboard. Typing a cluster macro re-rendered the whole dialog on every keystroke and wrote a row into the database per character; the twenty-four boxes now stand on their own and the database write waits for the typing to stop.",
|
||||||
|
"Station Control shows what commands the station, not only what it switches. The radio is there now — frequency, mode, band, and the split pair when there is one — with the CW keyer beside it (speed up and down, and Stop, because a message going to the wrong callsign has to end now) and the voice keyer with its recorded messages as buttons, so a CQ goes out without leaving the tab. The two keyers appear only when there is something behind them: a port configured, or a message actually recorded. All three move and reorder with the other cards."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"[NOUVEAU] Satellites. Un nouvel onglet (Outils → Satellites) permet de travailler les satellites amateurs de bout en bout. Une carte avec l'empreinte de chacun et la trace au sol du satellite sélectionné ; une vue du ciel comme la dessine n'importe quel tracker, centre à la verticale et bord à l'horizon, avec le passage entier et la position du satellite dessus ; un compte à rebours jusqu'à l'AOS — ou jusqu'au LOS une fois levé — avec lever, culmination et coucher, leurs directions à la boussole, distance, altitude et empreinte ; et un tableau des passages de tout ce que vous suivez.\n\n« Suivre » met la radio sur le satellite et l'y maintient, chaque seconde : un IC-9700 ou IC-9100 dans son propre mode satellite, un FlexRadio sur deux slices (A la descente, B la montée, créées si elles manquent, full duplex activé) — et n'importe quel autre poste sur la descente seule, ce qu'il annonce clairement plutôt que de faire le travail à moitié. Accordez le récepteur où vous voulez : le suivi lit le VFO, y voit la station que vous avez choisie, et déplace l'émetteur en conséquence. Un rotor az/él suit aussi, piloté directement en EasyComm II ou confié à PstRotator si vous le faites déjà tourner ; un rotor 450° est utilisé comme tel, et un passage qui traverse le nord continue au lieu de se dérouler.\n\nLes QSO faits pendant le suivi sont enregistrés avec les fréquences NOMINALES, SAT_NAME, SAT_MODE et PROP_MODE=SAT — les chiffres du transpondeur, sur lesquels les deux stations peuvent s'accorder, plutôt que l'endroit où une radio se trouvait.\n\nLes éléments orbitaux viennent de Celestrak, avec un miroir derrière, et sont conservés sur disque : l'onglet est rempli dès son ouverture, même sans internet. Les éléments d'un satellite qu'aucun flux ne diffuse encore peuvent être collés à la main et survivent à chaque mise à jour. Vingt-cinq satellites sont livrés avec un plan de fréquences — les FM et les linéaires, GreenCube, QO-100 bande étroite et large — dans un fichier que vous pouvez corriger vous-même quand un transpondeur change de mode. Toute la configuration est dans Réglages → Satellites, y compris le choix des satellites suivis, sélectionnés comme on choisit ses diplômes.",
|
||||||
|
"Chaque carte garde son propre fond. La carte principale et celle des carrés partageaient un seul réglage : choisir la vue satellite pour regarder les carrés repeignait aussi la carte principale, et il n'y avait aucun moyen d'avoir le relief sur l'une et les rues sur l'autre. Les quatre — principale, carrés, FT map, satellites — retiennent désormais leur propre choix, qui suit le dossier de données comme les positions mémorisées. Un choix déjà fait pour la carte des carrés est repris, pas réinitialisé.",
|
||||||
|
"Deux ou trois logiciels FT8 en même temps ne se disputent plus le champ indicatif. Cliquez une station dans MSHV et lui seul a un DX Call ; WSJT-X et JTDX à côté sont au repos et le disent une fois par seconde chacun — et OpsLog y lisait MSHV abandonnant la station : le champ se vidait et se remplissait à 1 Hz, la carte zoomant au même rythme. Un DX Call effacé est désormais lu par programme, jamais à l'échelle du port ; et le logiciel qui annonce une station garde le champ jusqu'à ce qu'il efface son propre indicatif, soit fermé, ou que le QSO soit enregistré.",
|
||||||
|
"Le tableau des décodages FT se trie sur SNR, fréquence, distance, pays et statut — cliquez l'en-tête. À l'intérieur de chaque créneau et jamais au travers : les périodes sont la raison d'être du panneau, et un tri de bout en bout mélangerait trois minutes de décodages en une colonne sans plus savoir de quelle fenêtre chacun vient. Un clic trie dans le sens où la colonne se lit (signal le plus fort, DX le plus lointain, fréquence la plus basse, de A à Z, le plus recherché d'abord), un second inverse, un troisième rend l'ordre dans lequel le décodeur les a entendus. Les stations sans locator, ou dont le pays n'est pas encore résolu, se rangent à la fin dans les deux sens plutôt que de se faire passer pour une distance nulle.",
|
||||||
|
"L'éditeur de cluster propose une liste de nœuds connus. La configuration d'un cluster telnet est l'étape où l'on se bloque : l'adresse et le port sont deux informations que personne n'a sous la main, et une faute de frappe dans l'une ou l'autre ressemble exactement à un nœud en panne. On en choisit un et les champs se remplissent — F4BPO, DXFun, F5LEN, F5MZN, KM3T, SOTA, POTA, et les deux flux Reverse Beacon, qui sont un même réseau sur deux ports où 7000 porte la CW et le RTTY et 7001 le FT8 et le FT4. Tout reste modifiable, et un nœud saisi à la main fonctionne exactement pareil. D'autres seront ajoutés.",
|
||||||
|
"Les Préférences ne traînent plus derrière le clavier. Saisir une macro de cluster redessinait tout le dialogue à chaque frappe et écrivait une ligne en base par caractère ; les vingt-quatre champs sont désormais indépendants et l'écriture en base attend la fin de la saisie.",
|
||||||
|
"Contrôle station montre ce qui commande la station, et plus seulement ce qui la commute. La radio y figure désormais — fréquence, mode, bande, et le couple split quand il y en a un — avec à côté le manipulateur CW (vitesse en plus ou en moins, et Stop, parce qu'un message parti vers le mauvais indicatif doit s'arrêter tout de suite) et le manipulateur vocal avec ses messages enregistrés en boutons, pour lancer un CQ sans quitter l'onglet. Les deux manipulateurs n'apparaissent que s'il y a quelque chose derrière : un port configuré, ou un message réellement enregistré. Les trois se déplacent et se réordonnent avec les autres cartes."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.16",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"[NEW] Typing a digital watering hole sets the mode with it. A spot click has always carried one; a frequency typed by hand carried none, so the rig stayed in SSB on 28.074 while the operator waited for decodes. Same table and same tolerance as a spot (±3 kHz of a known FT8/FT4/JS8 frequency), only towards the digital modes: tuning away from one leaves the mode alone, because there the frequency says nothing about what you mean to do.",
|
||||||
|
"Yaesu CAT now drives the older radios. The FTDX10, FT-991A, FT-891 and FT-710 write a frequency in nine digits; everything before them — FTDX3000, FTDX5000, FTDX1200, FT-2000, FT-950, FT-450 — writes eight and answers a nine-digit command with a rejection, which is what an FTDX3000 owner saw: every FA refused and a radio that would not follow. The width is taken from the rig’s own reply rather than from a table of models, so a set is in the format that radio speaks — including models this backend has never heard of.",
|
||||||
|
"Yaesu: RTTY can be set on USB (Settings → CAT). ADIF records only “RTTY” and the rig has both sidebands, so the log cannot answer for it — the older RTTY-L stays the default, and a station whose FSK controller wants the upper one says so once. The choice reaches the radio already connected: it is not part of what defines the link, so the link is not rebuilt for it — and until now that meant it waited for the next launch while the rig went on choosing LSB.",
|
||||||
|
"The update no longer relaunches OpsLog through a hidden PowerShell. An unsigned program that replaces itself on disk, clears the mark-of-the-web and then spawns a windowless PowerShell to start another executable is — byte for byte — the shape of a dropper, and Windows Defender’s machine-learning model reads the shape, not the intention: 0.27.14 was removed from a station under Trojan:Script/Wacatac.H!ml. The new version simply starts itself and waits its turn on the single-instance lock, which it already knew how to do. Only the rare fallback path, when the running file cannot even be renamed, still needs a helper that outlives the process.",
|
||||||
|
"Rotor widget: with more than one rotor the panel no longer runs off the bottom. The selector row appears above the dial, and the widget’s height is not its own to take — it sits in a strip sized by the entry form beside it — so the SP/LP pair and half the Stop button were cut off. The dial, the button rows and the padding now give that row back between them, in proportion, and nothing is dropped."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"[NEW] Taper une fréquence d’appel numérique règle le mode avec elle. Un clic sur un spot en portait un depuis toujours ; une fréquence tapée à la main n’en portait aucun, si bien que le poste restait en SSB sur 28.074 pendant qu’on attendait les décodages. Même table et même tolérance qu’un spot (±3 kHz d’une fréquence FT8/FT4/JS8 connue), et seulement vers les modes numériques : en s’en éloignant le mode n’est pas touché, car là la fréquence ne dit rien de ce qu’on veut faire.",
|
||||||
|
"Le CAT Yaesu pilote désormais les postes plus anciens. FTDX10, FT-991A, FT-891 et FT-710 écrivent une fréquence sur neuf chiffres ; tout ce qui précède — FTDX3000, FTDX5000, FTDX1200, FT-2000, FT-950, FT-450 — l’écrit sur huit et rejette une commande à neuf chiffres. C’est ce que voyait un possesseur de FTDX3000 : chaque FA refusée et une radio qui ne suivait pas. Le format est pris dans la réponse du poste plutôt que dans une table de modèles : l’envoi part donc dans la langue de cette radio-là, y compris pour des modèles que ce backend ne connaît pas.",
|
||||||
|
"Yaesu : le RTTY peut être placé en USB (Réglages → CAT). L’ADIF n’enregistre que « RTTY » et le poste a les deux bandes latérales : le log ne peut pas répondre à sa place. Le RTTY-L ancien reste par défaut, et une station dont l’interface FSK veut la supérieure le dit une fois. Le choix atteint le poste déjà connecté : il ne fait pas partie de ce qui définit la liaison, donc celle-ci n’est pas reconstruite pour lui — et jusqu’ici cela voulait dire qu’il attendait le prochain lancement pendant que le poste continuait de choisir la LSB.",
|
||||||
|
"La mise à jour ne relance plus OpsLog par un PowerShell caché. Un programme non signé qui se remplace sur le disque, efface la marque « téléchargé depuis Internet » puis lance un PowerShell sans fenêtre pour démarrer un autre exécutable a — à l’octet près — la forme d’un dropper, et le modèle d’apprentissage de Windows Defender lit la forme, pas l’intention : la 0.27.14 a été supprimée chez un OM sous Trojan:Script/Wacatac.H!ml. La nouvelle version se lance elle-même et attend son tour sur le verrou d’instance unique, ce qu’elle savait déjà faire. Seul le repli rare, quand le fichier en cours d’exécution ne peut même pas être renommé, garde un assistant qui survit au processus.",
|
||||||
|
"Widget rotor : avec plusieurs rotors, le panneau ne déborde plus par le bas. La rangée de sélection apparaît au-dessus du cadran, et la hauteur du widget ne lui appartient pas — il occupe une bande dont la hauteur est fixée par la saisie à côté — si bien que la paire SP/LP et la moitié du bouton Stop se retrouvaient coupées. Le cadran, les rangées de boutons et les marges rendent désormais cette hauteur entre eux, chacun pour sa part, sans rien supprimer."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.27.15",
|
"version": "0.27.15",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
+125
-65
@@ -12,7 +12,7 @@ import {
|
|||||||
ContestDupe,
|
ContestDupe,
|
||||||
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
||||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UpdateQSOsCountyFromULS, ULSStatus, UploadQSOsManual, SendQSORecordingEmail,
|
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UpdateQSOsCountyFromULS, ULSStatus, UploadQSOsManual, SendQSORecordingEmail,
|
||||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
LookupCallsign, GetStationSettings, GetListsSettings, GetSatelliteNames,
|
||||||
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations, GetWhatsNew, GetChangelog,
|
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations, GetWhatsNew, GetChangelog,
|
||||||
SMTPConfigured, SendLogToDeveloper,
|
SMTPConfigured, SendLogToDeveloper,
|
||||||
WorkedBefore,
|
WorkedBefore,
|
||||||
@@ -52,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';
|
||||||
@@ -80,6 +80,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog';
|
|||||||
import { SettingsModal } from '@/components/SettingsModal';
|
import { SettingsModal } from '@/components/SettingsModal';
|
||||||
import { FTMapPanel } from '@/components/FTMapPanel';
|
import { FTMapPanel } from '@/components/FTMapPanel';
|
||||||
import { DXpeditionsPanel } from '@/components/DXpeditionsPanel';
|
import { DXpeditionsPanel } from '@/components/DXpeditionsPanel';
|
||||||
|
import { SatellitePanel } from '@/components/SatellitePanel';
|
||||||
import { FirstRunModal } from '@/components/FirstRunModal';
|
import { FirstRunModal } from '@/components/FirstRunModal';
|
||||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||||
import { BandMap } from '@/components/BandMap';
|
import { BandMap } from '@/components/BandMap';
|
||||||
@@ -565,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',
|
||||||
@@ -617,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');
|
||||||
@@ -1354,6 +1360,17 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1');
|
const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1');
|
||||||
const [dxpedTabOpen, setDxpedTabOpen] = useState(() => localStorage.getItem('opslog.dxpedTab') === '1');
|
const [dxpedTabOpen, setDxpedTabOpen] = useState(() => localStorage.getItem('opslog.dxpedTab') === '1');
|
||||||
|
const [satTabOpen, setSatTabOpen] = useState(() => localStorage.getItem('opslog.satTab') === '1');
|
||||||
|
function openSatTab() {
|
||||||
|
setSatTabOpen(true);
|
||||||
|
writeUiPref('opslog.satTab', '1');
|
||||||
|
setActiveTab('sat');
|
||||||
|
}
|
||||||
|
function closeSatTab() {
|
||||||
|
setSatTabOpen(false);
|
||||||
|
writeUiPref('opslog.satTab', '0');
|
||||||
|
setActiveTab((t) => (t === 'sat' ? 'recent' : t));
|
||||||
|
}
|
||||||
function openDxpedTab() {
|
function openDxpedTab() {
|
||||||
setDxpedTabOpen(true);
|
setDxpedTabOpen(true);
|
||||||
writeUiPref('opslog.dxpedTab', '1');
|
writeUiPref('opslog.dxpedTab', '1');
|
||||||
@@ -2342,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:
|
||||||
@@ -3320,7 +3327,11 @@ export default function App() {
|
|||||||
const l: ListsSettings = await GetListsSettings();
|
const l: ListsSettings = await GetListsSettings();
|
||||||
setRstLists({ phone: (l as any).rst_phone ?? [], cw: (l as any).rst_cw ?? [], digital: (l as any).rst_digital ?? [] });
|
setRstLists({ phone: (l as any).rst_phone ?? [], cw: (l as any).rst_cw ?? [], digital: (l as any).rst_digital ?? [] });
|
||||||
if (l.bands && l.bands.length) setBands(l.bands);
|
if (l.bands && l.bands.length) setBands(l.bands);
|
||||||
setSatellites([...(((l as any).satellites ?? []) as string[])].filter(Boolean).sort());
|
// The satellites come from the satellite side now, not from a list typed
|
||||||
|
// by hand in Settings: one station kept two lists of the same birds and
|
||||||
|
// they drifted apart. Go merges the followed set with whatever the old
|
||||||
|
// hand-kept list still holds, so nobody's typing is lost.
|
||||||
|
GetSatelliteNames().then((s) => setSatellites((s ?? []) as string[])).catch(() => {});
|
||||||
if (l.modes && l.modes.length) {
|
if (l.modes && l.modes.length) {
|
||||||
setModePresets(l.modes);
|
setModePresets(l.modes);
|
||||||
const names = l.modes.map((m) => m.name);
|
const names = l.modes.map((m) => m.name);
|
||||||
@@ -5210,6 +5221,7 @@ export default function App() {
|
|||||||
{ name: 'tools', label: t('menu.tools'), items: [
|
{ name: 'tools', label: t('menu.tools'), items: [
|
||||||
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
||||||
{ type: 'item', label: t('dxp.tab'), action: 'tools.dxped' },
|
{ type: 'item', label: t('dxp.tab'), action: 'tools.dxped' },
|
||||||
|
{ type: 'item', label: t('sat.tab'), action: 'tools.sat' },
|
||||||
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
||||||
{ type: 'item', label: t('station.title'), action: 'tools.station' },
|
{ type: 'item', label: t('station.title'), action: 'tools.station' },
|
||||||
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
||||||
@@ -5267,6 +5279,7 @@ export default function App() {
|
|||||||
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
|
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
|
||||||
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
|
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
|
||||||
case 'tools.dxped': openDxpedTab(); break;
|
case 'tools.dxped': openDxpedTab(); break;
|
||||||
|
case 'tools.sat': openSatTab(); break;
|
||||||
case 'tools.decodes': openDecodesTab(); break;
|
case 'tools.decodes': openDecodesTab(); break;
|
||||||
case 'tools.ftmap': openFtmapTab(); break;
|
case 'tools.ftmap': openFtmapTab(); break;
|
||||||
case 'tools.grids': openGridsTab(); break;
|
case 'tools.grids': openGridsTab(); break;
|
||||||
@@ -5403,8 +5416,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 && (
|
||||||
@@ -5550,8 +5570,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. */}
|
||||||
@@ -5561,7 +5585,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; }} />
|
||||||
@@ -5597,7 +5621,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}
|
||||||
@@ -5616,7 +5640,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}
|
||||||
@@ -5887,7 +5911,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>
|
||||||
@@ -5898,7 +5922,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>
|
||||||
@@ -5926,7 +5950,29 @@ export default function App() {
|
|||||||
const mhz = parseFloat(mhzStr);
|
const mhz = parseFloat(mhzStr);
|
||||||
if (!Number.isFinite(mhz) || mhz < 0.1 || mhz > 3000) return;
|
if (!Number.isFinite(mhz) || mhz < 0.1 || mhz > 3000) return;
|
||||||
noteManualEdit();
|
noteManualEdit();
|
||||||
SetCATFrequency(Math.round(mhz * 1_000_000)).catch(() => {});
|
const hz = Math.round(mhz * 1_000_000);
|
||||||
|
SetCATFrequency(hz).catch(() => {});
|
||||||
|
tuneModeForWateringHole(hz);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 28.074 IS FT8, and typing it says so.
|
||||||
|
//
|
||||||
|
// A spot click has always carried a mode; a frequency typed by hand carried
|
||||||
|
// none, so the rig stayed in whatever it was — an FTDX3000 landing on 28.074
|
||||||
|
// in USB while the operator waited for decodes. Nobody tunes a watering hole
|
||||||
|
// to listen to it in SSB, and this is the same table and the same tolerance a
|
||||||
|
// spot click is judged with (±3 kHz of a known digital frequency).
|
||||||
|
//
|
||||||
|
// Only when it actually changes something, and only towards the digital
|
||||||
|
// modes: tuning away from 28.074 leaves the mode alone, because there the
|
||||||
|
// frequency says nothing about what the operator means to do.
|
||||||
|
const tuneModeForWateringHole = (hz: number) => {
|
||||||
|
const m = inferDigitalMode(hz);
|
||||||
|
if (!m || m === mode) return;
|
||||||
|
setMode(m);
|
||||||
|
applyModePreset(m);
|
||||||
|
if (catState.enabled && catState.connected && !locks.mode) SetCATMode(m).catch(() => {});
|
||||||
|
ConfigureDecoderMode(m).catch(() => {});
|
||||||
};
|
};
|
||||||
// Carry out what was typed in the call field. Bands go through the ordinary
|
// Carry out what was typed in the call field. Bands go through the ordinary
|
||||||
// band change, so the antennas, the power table and the outbound integrations
|
// band change, so the antennas, the power table and the outbound integrations
|
||||||
@@ -5944,6 +5990,7 @@ export default function App() {
|
|||||||
const b = bandForMHz(hz / 1_000_000);
|
const b = bandForMHz(hz / 1_000_000);
|
||||||
if (b) setBand(b);
|
if (b) setBand(b);
|
||||||
if (catState.enabled && catState.connected) SetCATFrequency(hz).catch(() => {});
|
if (catState.enabled && catState.connected) SetCATFrequency(hz).catch(() => {});
|
||||||
|
tuneModeForWateringHole(hz);
|
||||||
showToast((hz / 1_000_000).toFixed(3) + ' MHz');
|
showToast((hz / 1_000_000).toFixed(3) + ' MHz');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -5973,7 +6020,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"
|
||||||
@@ -8137,6 +8184,21 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
|
{satTabOpen && (
|
||||||
|
<TabsTrigger value="sat" className="gap-1.5">
|
||||||
|
{t('sat.tab')}
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
aria-label="Close Satellites"
|
||||||
|
title="Close"
|
||||||
|
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
|
||||||
|
onPointerDown={(e) => { e.stopPropagation(); }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); closeSatTab(); }}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
{ftmapTabOpen && (
|
{ftmapTabOpen && (
|
||||||
<TabsTrigger value="ftmap" className="gap-1.5">
|
<TabsTrigger value="ftmap" className="gap-1.5">
|
||||||
{t('ftmap.tab')}
|
{t('ftmap.tab')}
|
||||||
@@ -8785,6 +8847,18 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
{satTabOpen && (
|
||||||
|
<TabsContent value="sat" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||||
|
{/* Mounted only while it is the visible tab: the panel polls the
|
||||||
|
tuning once a second, and there is no reason to compute an
|
||||||
|
orbit for a tab nobody is looking at. */}
|
||||||
|
{activeTab === 'sat' && (
|
||||||
|
<div className="h-full w-full min-h-0">
|
||||||
|
<SatellitePanel myGrid={station.my_grid} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
{ftmapTabOpen && (
|
{ftmapTabOpen && (
|
||||||
<TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
<TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||||
{activeTab === 'ftmap' && (
|
{activeTab === 'ftmap' && (
|
||||||
@@ -9063,26 +9137,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>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
// Status flags (new entity / band / mode / slot / grid / prefix / POTA / county)
|
// Status flags (new entity / band / mode / slot / grid / prefix / POTA / county)
|
||||||
// come from the same resolver the cluster uses, so a call means the same thing in
|
// come from the same resolver the cluster uses, so a call means the same thing in
|
||||||
// both panels rather than being judged twice by two rules.
|
// both panels rather than being judged twice by two rules.
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { AlertTriangle, Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
|
import { AlertTriangle, Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
@@ -320,6 +320,39 @@ const US_STATES: Record<string, string> = {
|
|||||||
WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming', DC: 'District of Columbia',
|
WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming', DC: 'District of Columbia',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The columns worth sorting on. Not every column: time is what the periods
|
||||||
|
// already are, and sorting a slot by callsign or message answers no question an
|
||||||
|
// operator has.
|
||||||
|
type SortKey = 'snr' | 'freq' | 'dist' | 'country' | 'status';
|
||||||
|
const SORTABLE: SortKey[] = ['snr', 'freq', 'dist', 'country', 'status'];
|
||||||
|
|
||||||
|
// Which way each column is worth reading FIRST — strongest signal, lowest
|
||||||
|
// frequency, furthest DX, A to Z, most wanted. Clicking again reverses it.
|
||||||
|
const SORT_FIRST: Record<SortKey, 'asc' | 'desc'> = {
|
||||||
|
snr: 'desc', freq: 'asc', dist: 'desc', country: 'asc', status: 'desc',
|
||||||
|
};
|
||||||
|
|
||||||
|
// How wanted a station is, as a number to sort by. The cluster's own order,
|
||||||
|
// most wanted first — a new entity above a new band above a new slot — so the
|
||||||
|
// two views rank the same things the same way.
|
||||||
|
const STATUS_RANK: Record<string, number> = {
|
||||||
|
'new': 100, 'new-band-mode': 90, 'new-band': 80, 'new-mode': 70, 'new-slot': 60,
|
||||||
|
'new-call': 30, 'worked': 10,
|
||||||
|
};
|
||||||
|
function statusRank(e?: StatusEntry): number {
|
||||||
|
if (!e) return 0;
|
||||||
|
let r = STATUS_RANK[e.status ?? ''] ?? 0;
|
||||||
|
// The markers that are orthogonal to the entity: a new county on a worked
|
||||||
|
// country is still something to chase, and should not sort with the plain
|
||||||
|
// duplicates.
|
||||||
|
if (e.new_pota) r = Math.max(r, 50);
|
||||||
|
if (e.new_county) r = Math.max(r, 45);
|
||||||
|
if (e.new_grid) r = Math.max(r, 44);
|
||||||
|
if (e.new_state) r = Math.max(r, 43);
|
||||||
|
if (e.new_pfx) r = Math.max(r, 42);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
const COL_MAX = 600;
|
const COL_MAX = 600;
|
||||||
const COLW_KEY = 'opslog.decodeColWidths';
|
const COLW_KEY = 'opslog.decodeColWidths';
|
||||||
|
|
||||||
@@ -671,6 +704,68 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
|||||||
const statusOf = (d: Decode): StatusEntry | undefined =>
|
const statusOf = (d: Decode): StatusEntry | undefined =>
|
||||||
spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`];
|
spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`];
|
||||||
|
|
||||||
|
// ── Sorting, inside a period ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// WITHIN each slot and never across them. The periods are the point of this
|
||||||
|
// panel — what was on the air in one fifteen-second window — and a list
|
||||||
|
// sorted end to end by signal would mix three minutes of decodes into one
|
||||||
|
// column of numbers with no way to tell which slot any of them came from.
|
||||||
|
//
|
||||||
|
// Arrival order stays the default and stays one click away, because it
|
||||||
|
// mirrors the decoder's own window line for line, which is what makes the
|
||||||
|
// two screens comparable at a glance.
|
||||||
|
const [sortSpec, setSortSpec] = usePersisted('sort', '');
|
||||||
|
const [sortKey, sortDir] = useMemo(() => {
|
||||||
|
const [k, d] = String(sortSpec || '').split(':');
|
||||||
|
return [SORTABLE.includes(k as SortKey) ? (k as SortKey) : '', d === 'asc' ? 'asc' : 'desc'] as const;
|
||||||
|
}, [sortSpec]);
|
||||||
|
|
||||||
|
// One click sorts the way that column is worth reading — strongest signal,
|
||||||
|
// furthest DX, lowest frequency, A to Z, most wanted. The second reverses it,
|
||||||
|
// the third gives arrival order back.
|
||||||
|
const toggleSort = (k: SortKey) => {
|
||||||
|
if (sortKey !== k) { setSortSpec(`${k}:${SORT_FIRST[k]}`); return; }
|
||||||
|
if (sortDir === SORT_FIRST[k]) { setSortSpec(`${k}:${SORT_FIRST[k] === 'asc' ? 'desc' : 'asc'}`); return; }
|
||||||
|
setSortSpec('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortValue = useCallback((d: Decode, k: SortKey): number | string => {
|
||||||
|
const e = statusOf(d);
|
||||||
|
switch (k) {
|
||||||
|
case 'snr': return d.snr;
|
||||||
|
case 'freq': return d.freq_hz ?? 0;
|
||||||
|
case 'dist': {
|
||||||
|
const g = d.grid || e?.grid || '';
|
||||||
|
const path = myGrid && g ? pathBetween(myGrid, g) : null;
|
||||||
|
// A station that never sent a grid cannot be placed. Sorted to the end
|
||||||
|
// whichever way round the column goes, rather than pretending to a
|
||||||
|
// distance of zero and sitting at the top of "nearest first".
|
||||||
|
return path ? path.distanceShort : Number.NaN;
|
||||||
|
}
|
||||||
|
case 'country': return (e?.country ?? '').toUpperCase();
|
||||||
|
case 'status': return statusRank(e);
|
||||||
|
}
|
||||||
|
}, [spotStatus, myGrid]);
|
||||||
|
|
||||||
|
const sortDecodes = useCallback((list: Decode[]): Decode[] => {
|
||||||
|
if (!sortKey) return list;
|
||||||
|
const sign = sortDir === 'asc' ? 1 : -1;
|
||||||
|
return [...list].sort((a, b) => {
|
||||||
|
const va = sortValue(a, sortKey), vb = sortValue(b, sortKey);
|
||||||
|
const na = typeof va === 'number' && Number.isNaN(va);
|
||||||
|
const nb = typeof vb === 'number' && Number.isNaN(vb);
|
||||||
|
if (na !== nb) return na ? 1 : -1; // unknowns last, both ways
|
||||||
|
if (na && nb) return 0;
|
||||||
|
if (typeof va === 'string' || typeof vb === 'string') {
|
||||||
|
const sa = String(va), sb = String(vb);
|
||||||
|
// An empty country is an unknown too, not a name that sorts first.
|
||||||
|
if (!sa !== !sb) return sa ? -1 : 1;
|
||||||
|
return sign * sa.localeCompare(sb);
|
||||||
|
}
|
||||||
|
return sign * ((va as number) - (vb as number));
|
||||||
|
});
|
||||||
|
}, [sortKey, sortDir, sortValue]);
|
||||||
|
|
||||||
// The mode currently on the air, for the slot clock. The newest decode knows
|
// The mode currently on the air, for the slot clock. The newest decode knows
|
||||||
// best; between overs the transmit state still does.
|
// best; between overs the transmit state still does.
|
||||||
// A decoder that has lost its CAT link keeps announcing the last dial
|
// A decoder that has lost its CAT link keeps announcing the last dial
|
||||||
@@ -802,20 +897,24 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
|||||||
// Each pane cuts its own periods: the bands differ, so the slot boundaries and
|
// Each pane cuts its own periods: the bands differ, so the slot boundaries and
|
||||||
// the transmit messages belong to one receiver and not the other.
|
// the transmit messages belong to one receiver and not the other.
|
||||||
const panes = useMemo(() => {
|
const panes = useMemo(() => {
|
||||||
|
// The sort is applied to each period's decodes, never to the periods
|
||||||
|
// themselves: the slots stay newest-first, which is what the panel is.
|
||||||
|
const sorted = (ps: ReturnType<typeof buildPeriods>) =>
|
||||||
|
sortKey ? ps.map((p) => ({ ...p, decodes: sortDecodes(p.decodes) })) : ps;
|
||||||
if (!splitByInstance || instances.length < 2) {
|
if (!splitByInstance || instances.length < 2) {
|
||||||
return [{ key: '', label: '', tx: txState ?? undefined, periods: buildPeriods(filtered, txMsgs) }];
|
return [{ key: '', label: '', tx: txState ?? undefined, periods: sorted(buildPeriods(filtered, txMsgs)) }];
|
||||||
}
|
}
|
||||||
return instances.map((inst) => ({
|
return instances.map((inst) => ({
|
||||||
key: inst,
|
key: inst,
|
||||||
// What the program is called, not the id it announces — see decoderName.
|
// What the program is called, not the id it announces — see decoderName.
|
||||||
label: decoderName(inst),
|
label: decoderName(inst),
|
||||||
tx: txStates?.[inst],
|
tx: txStates?.[inst],
|
||||||
periods: buildPeriods(
|
periods: sorted(buildPeriods(
|
||||||
filtered.filter((d) => (d.instance ?? '') === inst),
|
filtered.filter((d) => (d.instance ?? '') === inst),
|
||||||
txMsgs.filter((m) => (m.instance ?? '') === inst),
|
txMsgs.filter((m) => (m.instance ?? '') === inst),
|
||||||
),
|
)),
|
||||||
}));
|
}));
|
||||||
}, [filtered, txMsgs, splitByInstance, instances, txState, txStates]);
|
}, [filtered, txMsgs, splitByInstance, instances, txState, txStates, sortKey, sortDecodes]);
|
||||||
|
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
@@ -1198,7 +1297,10 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
|||||||
<div className="shrink-0 border-b border-border bg-background overflow-hidden">
|
<div className="shrink-0 border-b border-border bg-background overflow-hidden">
|
||||||
<div className={cn(ROW, 'h-7 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground')}
|
<div className={cn(ROW, 'h-7 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground')}
|
||||||
style={{ gridTemplateColumns: template, width: tableW }}>
|
style={{ gridTemplateColumns: template, width: tableW }}>
|
||||||
{cols.map((c, i) => (
|
{cols.map((c, i) => {
|
||||||
|
const sortable = SORTABLE.includes(c.key as SortKey);
|
||||||
|
const active = sortable && sortKey === c.key;
|
||||||
|
return (
|
||||||
<span key={c.key}
|
<span key={c.key}
|
||||||
// Not CELL_LAST for the final column: its overflow-hidden would
|
// Not CELL_LAST for the final column: its overflow-hidden would
|
||||||
// clip that column's own resize handle.
|
// clip that column's own resize handle.
|
||||||
@@ -1206,15 +1308,22 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
|||||||
i < cols.length - 1 && 'border-r border-border/30',
|
i < cols.length - 1 && 'border-r border-border/30',
|
||||||
// The three numeric columns label their own right edge, where the
|
// The three numeric columns label their own right edge, where the
|
||||||
// figures are.
|
// figures are.
|
||||||
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq' || c.key === 'dist') && 'justify-end')}
|
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq' || c.key === 'dist') && 'justify-end',
|
||||||
title={c.key === 'dt' ? t('dec.colDtTitle') : c.key === 'freq' ? t('dec.colFreqTitle') : undefined}>
|
sortable && 'cursor-pointer select-none hover:text-foreground',
|
||||||
|
active && 'text-primary')}
|
||||||
|
onClick={sortable ? () => toggleSort(c.key as SortKey) : undefined}
|
||||||
|
title={sortable ? t('dec.sortTip')
|
||||||
|
: c.key === 'dt' ? t('dec.colDtTitle')
|
||||||
|
: c.key === 'freq' ? t('dec.colFreqTitle') : undefined}>
|
||||||
<span className="truncate">{c.key === 'dist' ? `${t(c.tkey)} (${distanceUnit()})` : t(c.tkey)}</span>
|
<span className="truncate">{c.key === 'dist' ? `${t(c.tkey)} (${distanceUnit()})` : t(c.tkey)}</span>
|
||||||
|
{active && <span className="ml-0.5 shrink-0">{sortDir === 'asc' ? '▲' : '▼'}</span>}
|
||||||
<ColResizer
|
<ColResizer
|
||||||
onResize={(dx) => setColWidth(c.key, colw[c.key] + dx)}
|
onResize={(dx) => setColWidth(c.key, colw[c.key] + dx)}
|
||||||
onReset={() => setColWidth(c.key, c.def)}
|
onReset={() => setColWidth(c.key, c.def)}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { loadMapView, saveMapView, MAP_VIEW_FT } from '@/lib/mapView';
|
import { loadMapView, saveMapView, MAP_VIEW_FT } from '@/lib/mapView';
|
||||||
|
import { loadMapBase, saveMapBase, MAP_BASE_FT } from '@/lib/mapBase';
|
||||||
|
|
||||||
// FT Map — the live decode feed as geography: every station decoded in the
|
// FT Map — the live decode feed as geography: every station decoded in the
|
||||||
// last half hour, an arc from the operator's own square to theirs, coloured by
|
// last half hour, an arc from the operator's own square to theirs, coloured by
|
||||||
@@ -128,7 +129,7 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
|||||||
};
|
};
|
||||||
baseRef.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
baseRef.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
||||||
if (bm.labelsUrl) labelsRef.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
|
if (bm.labelsUrl) labelsRef.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
|
||||||
localStorage.setItem('opslog.ftmapBase', basemap);
|
saveMapBase(MAP_BASE_FT, basemap);
|
||||||
}, [basemap]);
|
}, [basemap]);
|
||||||
|
|
||||||
// The arcs, redrawn when the decode list changes. Newest last so they paint
|
// The arcs, redrawn when the decode list changes. Newest last so they paint
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { GridSquares } from '../../wailsjs/go/main/App';
|
|||||||
import { gridSquareBounds, gridToLatLon } from '@/lib/maidenhead';
|
import { gridSquareBounds, gridToLatLon } from '@/lib/maidenhead';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { BASEMAPS, addBasemap, loadBasemap, type BasemapKey } from '@/components/MainMap';
|
import { BASEMAPS, addBasemap, type BasemapKey } from '@/components/MainMap';
|
||||||
|
import { loadMapBase, saveMapBase, MAP_BASE_GRIDS, MAP_BASE_WORLD } from '@/lib/mapBase';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
import { loadMapView, saveMapView, MAP_VIEW_GRIDS } from '@/lib/mapView';
|
import { loadMapView, saveMapView, MAP_VIEW_GRIDS } from '@/lib/mapView';
|
||||||
|
|
||||||
@@ -86,7 +87,9 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
|||||||
() => (SCOPES.some((s) => s.key === localStorage.getItem(SCOPE_KEY))
|
() => (SCOPES.some((s) => s.key === localStorage.getItem(SCOPE_KEY))
|
||||||
? (localStorage.getItem(SCOPE_KEY) as ScopeKey) : 'DIGI'));
|
? (localStorage.getItem(SCOPE_KEY) as ScopeKey) : 'DIGI'));
|
||||||
|
|
||||||
const [basemap, setBasemap] = useState<BasemapKey>(loadBasemap);
|
// This map's own imagery. It shared the world map's key until they were
|
||||||
|
// separated, so a choice made back then is inherited rather than reset.
|
||||||
|
const [basemap, setBasemap] = useState<BasemapKey>(() => loadMapBase(MAP_BASE_GRIDS, 'light', MAP_BASE_WORLD));
|
||||||
const [confColour, setConfColour] = useState(() => localStorage.getItem(COL_CONFIRMED_KEY) ?? '');
|
const [confColour, setConfColour] = useState(() => localStorage.getItem(COL_CONFIRMED_KEY) ?? '');
|
||||||
const [workedColour, setWorkedColour] = useState(() => localStorage.getItem(COL_WORKED_KEY) ?? '');
|
const [workedColour, setWorkedColour] = useState(() => localStorage.getItem(COL_WORKED_KEY) ?? '');
|
||||||
// Repaint the squares when the THEME changes, not the basemap: the fills come
|
// Repaint the squares when the THEME changes, not the basemap: the fills come
|
||||||
@@ -253,7 +256,7 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
|||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
value={basemap}
|
value={basemap}
|
||||||
onChange={(e) => { const v = e.target.value as BasemapKey; setBasemap(v); writeUiPref('opslog.mapBasemap', v); }}
|
onChange={(e) => { const v = e.target.value as BasemapKey; setBasemap(v); saveMapBase(MAP_BASE_GRIDS, v); }}
|
||||||
title={t('gsm.basemap')}
|
title={t('gsm.basemap')}
|
||||||
className="h-6 rounded border border-border bg-background px-1 text-[11px]"
|
className="h-6 rounded border border-border bg-background px-1 text-[11px]"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import L from 'leaflet';
|
|||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { nightPolygon } from '../lib/greyline';
|
import { nightPolygon } from '../lib/greyline';
|
||||||
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
|
||||||
|
import { loadMapBase, saveMapBase, MAP_BASE_WORLD } from '@/lib/mapBase';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
import { formatDistance } from '@/lib/units';
|
import { formatDistance } from '@/lib/units';
|
||||||
import { loadMapView, saveMapView, MAP_VIEW_WORLD } from '@/lib/mapView';
|
import { loadMapView, saveMapView, MAP_VIEW_WORLD } from '@/lib/mapView';
|
||||||
@@ -116,9 +117,10 @@ export const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: st
|
|||||||
attr: 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics',
|
attr: 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics',
|
||||||
labelsUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}' },
|
labelsUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}' },
|
||||||
};
|
};
|
||||||
|
// loadBasemap is the WORLD map's imagery. Each map keeps its own — see
|
||||||
|
// lib/mapBase, which is where the keys live.
|
||||||
export function loadBasemap(): BasemapKey {
|
export function loadBasemap(): BasemapKey {
|
||||||
const v = localStorage.getItem('opslog.mapBasemap');
|
return loadMapBase(MAP_BASE_WORLD, 'light');
|
||||||
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// addBasemap (re)installs the imagery layer and, for satellite, its transparent
|
// addBasemap (re)installs the imagery layer and, for satellite, its transparent
|
||||||
@@ -445,7 +447,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
<button
|
<button
|
||||||
key={k}
|
key={k}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setBasemap(k); writeUiPref('opslog.mapBasemap', k); }}
|
onClick={() => { setBasemap(k); saveMapBase(MAP_BASE_WORLD, k); }}
|
||||||
title={`Basemap: ${BASEMAPS[k].label}`}
|
title={`Basemap: ${BASEMAPS[k].label}`}
|
||||||
className={`px-2 py-1 text-[11px] font-medium transition-colors ${
|
className={`px-2 py-1 text-[11px] font-medium transition-colors ${
|
||||||
basemap === k ? 'bg-primary text-primary-foreground' : 'bg-card/90 text-muted-foreground hover:bg-card'
|
basemap === k ? 'bg-primary text-primary-foreground' : 'bg-card/90 text-muted-foreground hover:bg-card'
|
||||||
|
|||||||
@@ -542,7 +542,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>
|
||||||
|
|||||||
@@ -610,9 +610,22 @@ export function RotorCompass({
|
|||||||
return columns;
|
return columns;
|
||||||
}, [presets]);
|
}, [presets]);
|
||||||
|
|
||||||
// 192 dial + 6 gap + 154 controls + 16 padding, plus 60 per preset column.
|
// THE SELECTOR HAS TO COME OUT OF SOMEWHERE.
|
||||||
|
//
|
||||||
|
// With more than one rotor a row of buttons appears above the dial, and the
|
||||||
|
// widget's height is not its own to take: it sits in a strip whose height is
|
||||||
|
// set by the entry form beside it. The extra row simply pushed the bottom of
|
||||||
|
// the panel off the end — the SP/LP pair and half the Stop button gone.
|
||||||
|
//
|
||||||
|
// So the dial and the button rows give the row back, in proportion: 24 px off
|
||||||
|
// the dial and 8 off each of the three rows is the height of a selector, and
|
||||||
|
// nothing has to be dropped.
|
||||||
|
const tight = !!(rotors && rotors.length > 1);
|
||||||
|
const dialPx = tight ? 168 : 192;
|
||||||
|
const rowPx = tight ? 52 : 60;
|
||||||
|
// 6 gap + 154 controls + 16 padding, plus 60 per preset column.
|
||||||
const controlsWidth = 154 + presetColumns.length * 60;
|
const controlsWidth = 154 + presetColumns.length * 60;
|
||||||
const widgetWidth = 368 + presetColumns.length * 60;
|
const widgetWidth = dialPx + 176 + presetColumns.length * 60;
|
||||||
|
|
||||||
const markMovementCommanded = () => {
|
const markMovementCommanded = () => {
|
||||||
movementSeenRef.current = false;
|
movementSeenRef.current = false;
|
||||||
@@ -713,7 +726,8 @@ export function RotorCompass({
|
|||||||
|
|
||||||
const renderPresetColumn = (column: RotorPreset[], columnIndex: number) => (
|
const renderPresetColumn = (column: RotorPreset[], columnIndex: number) => (
|
||||||
<div key={`preset-column-${columnIndex}`}
|
<div key={`preset-column-${columnIndex}`}
|
||||||
className="w-[54px] min-w-[54px] shrink-0 grid grid-rows-[60px_60px_60px] gap-1.5 min-h-0">
|
className="w-[54px] min-w-[54px] shrink-0 grid gap-1.5 min-h-0"
|
||||||
|
style={{ gridTemplateRows: `repeat(3, ${rowPx}px)` }}>
|
||||||
{[0, 2, 4].map((row) => (
|
{[0, 2, 4].map((row) => (
|
||||||
<div key={row} className="h-full min-h-0 grid grid-rows-2 gap-1">
|
<div key={row} className="h-full min-h-0 grid grid-rows-2 gap-1">
|
||||||
{column[row] && renderPresetButton(column[row], columnIndex * 6 + row)}
|
{column[row] && renderPresetButton(column[row], columnIndex * 6 + row)}
|
||||||
@@ -749,7 +763,8 @@ export function RotorCompass({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const mainControls = (
|
const mainControls = (
|
||||||
<div className="w-[154px] min-w-[154px] shrink-0 grid grid-rows-[60px_60px_60px] gap-1.5 min-h-0">
|
<div className="w-[154px] min-w-[154px] shrink-0 grid gap-1.5 min-h-0"
|
||||||
|
style={{ gridTemplateRows: `repeat(3, ${rowPx}px)` }}>
|
||||||
{/* Where the antenna is, and under it — smaller, yellow, and only while it
|
{/* Where the antenna is, and under it — smaller, yellow, and only while it
|
||||||
matters — where it was told to go. */}
|
matters — where it was told to go. */}
|
||||||
<div className="h-full min-h-0 rounded-md border border-border bg-background/30 px-1 text-center relative overflow-hidden">
|
<div className="h-full min-h-0 rounded-md border border-border bg-background/30 px-1 text-center relative overflow-hidden">
|
||||||
@@ -760,7 +775,8 @@ export function RotorCompass({
|
|||||||
it. The green one does not move, so nothing jumps when the mouse
|
it. The green one does not move, so nothing jumps when the mouse
|
||||||
leaves the dial. */}
|
leaves the dial. */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'absolute left-1/2 top-1/2 -translate-x-1/2 font-mono text-[30px] leading-none font-bold tabular-nums whitespace-nowrap transition-all duration-300 ease-out',
|
'absolute left-1/2 top-1/2 -translate-x-1/2 font-mono leading-none font-bold tabular-nums whitespace-nowrap transition-all duration-300 ease-out',
|
||||||
|
tight ? 'text-[26px]' : 'text-[30px]',
|
||||||
targetAzimuth != null ? '-translate-y-[72%]' : '-translate-y-1/2',
|
targetAzimuth != null ? '-translate-y-[72%]' : '-translate-y-1/2',
|
||||||
hoverAzimuth != null ? 'opacity-0 scale-95' : 'opacity-100 scale-100',
|
hoverAzimuth != null ? 'opacity-0 scale-95' : 'opacity-100 scale-100',
|
||||||
displayAzimuth != null ? 'text-success' : 'text-muted-foreground',
|
displayAzimuth != null ? 'text-success' : 'text-muted-foreground',
|
||||||
@@ -769,7 +785,8 @@ export function RotorCompass({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'absolute left-1/2 top-1/2 -translate-x-1/2 font-mono text-[30px] leading-none font-bold tabular-nums whitespace-nowrap transition-all duration-300 ease-out',
|
'absolute left-1/2 top-1/2 -translate-x-1/2 font-mono leading-none font-bold tabular-nums whitespace-nowrap transition-all duration-300 ease-out',
|
||||||
|
tight ? 'text-[26px]' : 'text-[30px]',
|
||||||
targetAzimuth != null ? '-translate-y-[72%]' : '-translate-y-1/2',
|
targetAzimuth != null ? '-translate-y-[72%]' : '-translate-y-1/2',
|
||||||
hoverAzimuth != null ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none',
|
hoverAzimuth != null ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none',
|
||||||
)}
|
)}
|
||||||
@@ -904,7 +921,7 @@ export function RotorCompass({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rotors && rotors.length > 1 && (
|
{rotors && rotors.length > 1 && (
|
||||||
<div className="flex flex-wrap gap-1 px-2 pt-1.5">
|
<div className="flex flex-wrap gap-1 px-2 pt-1">
|
||||||
{rotors.map((name, index) => {
|
{rotors.map((name, index) => {
|
||||||
const active = (activeRotor ?? 0) === index;
|
const active = (activeRotor ?? 0) === index;
|
||||||
const label = name?.trim() || `Rotor ${index + 1}`;
|
const label = name?.trim() || `Rotor ${index + 1}`;
|
||||||
@@ -922,8 +939,10 @@ export function RotorCompass({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showControls ? (
|
{showControls ? (
|
||||||
<div className="flex items-stretch gap-1.5 p-2 min-h-0">
|
// The padding gives its share too: four pixels, which is what the
|
||||||
<div className="w-[192px] min-w-[192px] h-[192px] min-h-[192px] shrink-0">{dial}</div>
|
// selector row still owed after the dial and the buttons had paid.
|
||||||
|
<div className={cn('flex items-stretch gap-1.5 min-h-0', tight ? 'p-1.5' : 'p-2')}>
|
||||||
|
<div className="shrink-0" style={{ width: dialPx, minWidth: dialPx, height: dialPx, minHeight: dialPx }}>{dial}</div>
|
||||||
<div className="shrink-0 flex gap-1.5 min-h-0"
|
<div className="shrink-0 flex gap-1.5 min-h-0"
|
||||||
style={{ width: `${controlsWidth}px`, minWidth: `${controlsWidth}px` }}>
|
style={{ width: `${controlsWidth}px`, minWidth: `${controlsWidth}px` }}>
|
||||||
{mainControls}
|
{mainControls}
|
||||||
|
|||||||
@@ -0,0 +1,791 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen, Radar } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
|
||||||
|
GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack,
|
||||||
|
StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { gridToLatLon, splitAtAntimeridian } from '@/lib/maidenhead';
|
||||||
|
import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
|
||||||
|
import { loadMapView, saveMapView } from '@/lib/mapView';
|
||||||
|
import { loadMapBase, saveMapBase, MAP_BASE_SAT } from '@/lib/mapBase';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { SkyPlot, type SkyPoint } from '@/components/SkyPlot';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
// Satellites — where the birds are, and what to do with the radio.
|
||||||
|
//
|
||||||
|
// The tab answers the three questions a pass poses, and answers them where they
|
||||||
|
// are asked: how long have I got (the countdown), where is it (the map), what
|
||||||
|
// do I tune (the readout). Everything else — which satellites to follow, where
|
||||||
|
// the elements come from, the rotator — is maintenance and lives in Settings.
|
||||||
|
// During a pass there is no time to configure anything.
|
||||||
|
|
||||||
|
type Bird = {
|
||||||
|
name: string; norad: number; geostationary: boolean; favorite: boolean;
|
||||||
|
has_elements: boolean; element_name: string; epoch_age_h: number;
|
||||||
|
transponders?: {
|
||||||
|
label: string; mode: string; down_lo: number; down_hi: number;
|
||||||
|
up_lo: number; up_hi: number; inverting: boolean; ctcss: number; linear: boolean;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
type Position = {
|
||||||
|
name: string; lat: number; lon: number; alt_km: number; footprint_km: number;
|
||||||
|
az: number; el: number; range_km: number; range_rate: number;
|
||||||
|
};
|
||||||
|
type Pass = {
|
||||||
|
name: string; aos: string; los: string; aos_az: number; los_az: number;
|
||||||
|
max_el: number; max_el_az: number; max_el_at: string; duration_s: number;
|
||||||
|
};
|
||||||
|
type PassInfo = {
|
||||||
|
name: string; has_pass: boolean; in_pass: boolean;
|
||||||
|
aos: string; los: string; aos_az: number; los_az: number;
|
||||||
|
max_el: number; max_el_az: number; max_el_at: string; duration_s: number;
|
||||||
|
};
|
||||||
|
type Tuning = {
|
||||||
|
name: string; transponder: string; mode: string;
|
||||||
|
nominal_down: number; nominal_up: number; down_hz: number; up_hz: number;
|
||||||
|
ctcss: number; inverting: boolean;
|
||||||
|
az: number; el: number; range_km: number; range_rate: number; visible: boolean;
|
||||||
|
lat: number; lon: number; alt_km: number; footprint_km: number;
|
||||||
|
};
|
||||||
|
type Track = {
|
||||||
|
on: boolean; name: string; transponder: string; mode: string;
|
||||||
|
nominal_down: number; nominal_up: number; down_hz: number; up_hz: number;
|
||||||
|
az: number; el: number; visible: boolean;
|
||||||
|
radio: string; // "sat" | "downlink-only" | ""
|
||||||
|
error: string;
|
||||||
|
rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAP_VIEW_SAT = 'opslog.satMapView';
|
||||||
|
|
||||||
|
// The readout column. Wide enough by default to hold a frequency to the hertz
|
||||||
|
// without wrapping, and adjustable because how much map an operator wants
|
||||||
|
// against how much detail is theirs to decide — a station watching a footprint
|
||||||
|
// cross an ocean wants the map, one working a pass wants the numbers.
|
||||||
|
const SIDE_W_KEY = 'opslog.satSideWidth';
|
||||||
|
const SIDE_SHOWN_KEY = 'opslog.satSideShown';
|
||||||
|
const SIDE_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720;
|
||||||
|
const SKY_SHOWN_KEY = 'opslog.satSkyShown';
|
||||||
|
|
||||||
|
const fmtHz = (hz: number) => {
|
||||||
|
if (!hz) return '—';
|
||||||
|
// Six decimals: a linear transponder is tuned to the hundred hertz, and the
|
||||||
|
// Doppler correction moves the last three digits every second.
|
||||||
|
return (hz / 1e6).toFixed(6).replace(/(\d)(?=(\d{3})+\.)/g, '$1 ');
|
||||||
|
};
|
||||||
|
const fmtDeg = (d: number) => `${d.toFixed(1)}°`;
|
||||||
|
const fmtKm = (km: number) => `${Math.round(km).toLocaleString()} km`;
|
||||||
|
const hhmm = (iso: string) => {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? '—' : d.toISOString().slice(11, 16);
|
||||||
|
};
|
||||||
|
const hhmmss = (iso: string) => {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? '—' : d.toISOString().slice(11, 19);
|
||||||
|
};
|
||||||
|
const inMin = (iso: string) => Math.round((Date.parse(iso) - Date.now()) / 60000);
|
||||||
|
|
||||||
|
// A countdown an operator can act on. Seconds while they matter, then minutes,
|
||||||
|
// then hours — nobody needs "1h 04m 37s", and nobody wants "0m" for the last
|
||||||
|
// fifty seconds before a satellite rises.
|
||||||
|
function fmtCountdown(ms: number): string {
|
||||||
|
const s = Math.max(0, Math.round(ms / 1000));
|
||||||
|
if (s < 60) return `${s}s`;
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`;
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
return `${h}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The eight points of the compass, for an azimuth an operator reads rather than
|
||||||
|
// computes. "rises at 213°" is a number; "rises SW" is a direction to look in.
|
||||||
|
const COMPASS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
||||||
|
const compass = (deg: number) => COMPASS[Math.round(((deg % 360) + 360) % 360 / 45) % 8];
|
||||||
|
|
||||||
|
// How good a pass is, as a colour. A bird 70° overhead and one scraping 12°
|
||||||
|
// along the horizon are not the same evening, and the table should say so
|
||||||
|
// without the operator reading every number.
|
||||||
|
function elClass(el: number): string {
|
||||||
|
if (el >= 50) return 'text-success';
|
||||||
|
if (el >= 25) return 'text-foreground';
|
||||||
|
if (el >= 15) return 'text-caution';
|
||||||
|
return 'text-muted-foreground';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mode a satellite is worked in, as a dot: FM and SSB call for a completely
|
||||||
|
// different set-up, and which of the two the next pass is decides whether the
|
||||||
|
// operator reaches for a handheld or the whole station.
|
||||||
|
const MODE_COLOUR: Record<string, string> = {
|
||||||
|
FM: 'var(--info)',
|
||||||
|
SSB: 'var(--success)',
|
||||||
|
CW: 'var(--caution)',
|
||||||
|
DATA: 'var(--warning)',
|
||||||
|
};
|
||||||
|
|
||||||
|
function ModeDot({ mode }: { mode: string }) {
|
||||||
|
const colour = MODE_COLOUR[mode];
|
||||||
|
if (!colour) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title={mode}
|
||||||
|
className="ml-1.5 inline-block size-1.5 rounded-full align-middle"
|
||||||
|
style={{ backgroundColor: colour }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [birds, setBirds] = useState<Bird[]>([]);
|
||||||
|
const [sel, setSel] = useState<string>(() => localStorage.getItem('opslog.satSelected') || '');
|
||||||
|
const [tpIdx, setTpIdx] = useState(0);
|
||||||
|
const [positions, setPositions] = useState<Position[]>([]);
|
||||||
|
const [passes, setPasses] = useState<Pass[]>([]);
|
||||||
|
const [tuning, setTuning] = useState<Tuning | 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 [tracking, setTracking] = useState<Track | null>(null);
|
||||||
|
const [sky, setSky] = useState<SkyPoint[]>([]);
|
||||||
|
const [skyShown, setSkyShown] = useState(() => localStorage.getItem(SKY_SHOWN_KEY) !== '0');
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
// A clock of its own, so every countdown on the panel ticks from one instant
|
||||||
|
// and none of them needs a round trip to Go to lose a second.
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// What the operator follows. Chosen in Settings; following nothing means
|
||||||
|
// every satellite we can both find and tune, which is what somebody who has
|
||||||
|
// not chosen yet should see.
|
||||||
|
const shown = useMemo(() => {
|
||||||
|
const favs = birds.filter((b) => b.favorite);
|
||||||
|
if (favs.length > 0) return favs;
|
||||||
|
return birds.filter((b) => b.has_elements && (b.transponders?.length ?? 0) > 0);
|
||||||
|
}, [birds]);
|
||||||
|
const bird = useMemo(() => birds.find((b) => b.name === sel) ?? null, [birds, sel]);
|
||||||
|
const tp = bird?.transponders?.[tpIdx] ?? null;
|
||||||
|
// 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.
|
||||||
|
const modeOf = useMemo(() => {
|
||||||
|
const m = new Map(birds.map((b) => [b.name, b.transponders?.[0]?.mode ?? ''] as const));
|
||||||
|
return (name: string) => m.get(name) ?? '';
|
||||||
|
}, [birds]);
|
||||||
|
|
||||||
|
// ── Data ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const loadBirds = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list: Bird[] = (await GetSatelliteBirds()) as any;
|
||||||
|
setBirds(list ?? []);
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadTle = useCallback(async () => {
|
||||||
|
try { setTle((await GetSatelliteTLEInfo()) as any); } catch { /* shown as unknown */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadPasses = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setPasses(((await GetSatellitePasses([], 0)) as any) ?? []);
|
||||||
|
setErr('');
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadBirds(); loadTle(); loadPasses(); }, [loadBirds, loadTle, loadPasses]);
|
||||||
|
useEffect(() => EventsOn('sat:tle', () => { loadTle(); loadBirds(); loadPasses(); }), [loadTle, loadBirds, loadPasses]);
|
||||||
|
useEffect(() => { if (sel) localStorage.setItem('opslog.satSelected', sel); }, [sel]);
|
||||||
|
useEffect(() => { setTpIdx(0); }, [sel]);
|
||||||
|
|
||||||
|
// Keep the selection inside what is followed: an operator who narrows the list
|
||||||
|
// in Settings must not be left looking at a satellite that is no longer there.
|
||||||
|
useEffect(() => {
|
||||||
|
if (shown.length === 0) return;
|
||||||
|
if (!sel || !shown.some((b) => b.name === sel)) setSel(shown[0].name);
|
||||||
|
}, [shown, sel]);
|
||||||
|
|
||||||
|
// The map's satellites, every five seconds: a low orbit moves about a third of
|
||||||
|
// a degree of longitude in that time, which is a pixel or two at this zoom.
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const p: Position[] = ((await GetSatellitePositions([])) as any) ?? [];
|
||||||
|
if (live) setPositions(p);
|
||||||
|
} catch { /* a missing locator is already reported by the passes call */ }
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 5000);
|
||||||
|
return () => { live = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The readout, every second: this is the number an operator types into a
|
||||||
|
// radio, and a Doppler correction on 70 cm moves by a few tens of hertz a
|
||||||
|
// second at the middle of a pass.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sel) { setTuning(null); return; }
|
||||||
|
let live = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const tn: Tuning = (await GetSatelliteTuning(sel, tpIdx, 0)) as any;
|
||||||
|
if (live) setTuning(tn);
|
||||||
|
} catch { if (live) setTuning(null); }
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 1000);
|
||||||
|
return () => { live = false; window.clearInterval(id); };
|
||||||
|
}, [sel, tpIdx]);
|
||||||
|
|
||||||
|
// The pass, every twenty seconds. Predicting one steps the orbit across hours;
|
||||||
|
// the countdown itself is two timestamps and a clock, which the browser runs.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sel) { setPass(null); return; }
|
||||||
|
let live = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const p: PassInfo = (await GetSatelliteNextPass(sel)) as any;
|
||||||
|
if (live) setPass(p);
|
||||||
|
} catch { if (live) setPass(null); }
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 20_000);
|
||||||
|
return () => { live = false; window.clearInterval(id); };
|
||||||
|
}, [sel]);
|
||||||
|
|
||||||
|
// The pass drawn across the sky. Once a minute is plenty: the SHAPE of a
|
||||||
|
// pass does not change while it happens — only the marker on it moves, and
|
||||||
|
// that comes from the tuning poll a second at a time.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sel || !skyShown) { setSky([]); return; }
|
||||||
|
let live = true;
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const pts: SkyPoint[] = ((await GetSatelliteSkyTrack(sel, 120)) as any) ?? [];
|
||||||
|
if (live) setSky(pts);
|
||||||
|
} catch { if (live) setSky([]); }
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const id = window.setInterval(load, 60_000);
|
||||||
|
return () => { live = false; window.clearInterval(id); };
|
||||||
|
}, [sel, skyShown]);
|
||||||
|
useEffect(() => { writeUiPref(SKY_SHOWN_KEY, skyShown ? '1' : '0'); }, [skyShown]);
|
||||||
|
|
||||||
|
// Passes are cheap but not free, and they change slowly.
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setInterval(loadPasses, 5 * 60_000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [loadPasses]);
|
||||||
|
|
||||||
|
// The tracker's own state, pushed as it moves. Polled as well, at a lazy
|
||||||
|
// rate, so a panel opened while tracking is already running is not blank
|
||||||
|
// until the next tick.
|
||||||
|
useEffect(() => {
|
||||||
|
const read = async () => {
|
||||||
|
try { setTracking((await GetSatelliteTracking()) as any); } catch { /* not tracking */ }
|
||||||
|
};
|
||||||
|
read();
|
||||||
|
const off = EventsOn('sat:track', (s: any) => setTracking(s ?? null));
|
||||||
|
const id = window.setInterval(read, 10_000);
|
||||||
|
return () => { off(); window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleTracking = async () => {
|
||||||
|
setErr('');
|
||||||
|
try {
|
||||||
|
if (tracking?.on) {
|
||||||
|
await StopSatelliteTracking();
|
||||||
|
setTracking(null);
|
||||||
|
} else {
|
||||||
|
await StartSatelliteTracking(sel, tpIdx);
|
||||||
|
setTracking((await GetSatelliteTracking()) as any);
|
||||||
|
}
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Map ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const divRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
|
const layerRef = useRef<L.LayerGroup | null>(null);
|
||||||
|
const baseRef = useRef<L.TileLayer | null>(null);
|
||||||
|
const labelsRef = useRef<L.TileLayer | null>(null);
|
||||||
|
const [basemap, setBasemap] = useState<BasemapKey>(() => loadMapBase(MAP_BASE_SAT, 'light'));
|
||||||
|
const saved = useRef(loadMapView(MAP_VIEW_SAT));
|
||||||
|
const [track, setTrack] = useState<Position[]>([]);
|
||||||
|
|
||||||
|
const home = useMemo(() => gridToLatLon(myGrid), [myGrid]);
|
||||||
|
|
||||||
|
// ── The readout column ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const [sideW, setSideW] = useState<number>(() => {
|
||||||
|
const n = parseFloat(localStorage.getItem(SIDE_W_KEY) || '');
|
||||||
|
return Number.isFinite(n) && n >= SIDE_W_MIN && n <= SIDE_W_MAX ? n : SIDE_W_DEFAULT;
|
||||||
|
});
|
||||||
|
const [sideShown, setSideShown] = useState(() => localStorage.getItem(SIDE_SHOWN_KEY) !== '0');
|
||||||
|
useEffect(() => { writeUiPref(SIDE_W_KEY, String(Math.round(sideW))); }, [sideW]);
|
||||||
|
useEffect(() => { writeUiPref(SIDE_SHOWN_KEY, sideShown ? '1' : '0'); }, [sideShown]);
|
||||||
|
|
||||||
|
// Dragging the grip. Measured from where the pointer STARTED rather than from
|
||||||
|
// the container, and with the pointer captured — without the capture the map
|
||||||
|
// underneath swallows the moves the instant the cursor crosses it.
|
||||||
|
const startSideDrag = (e: React.PointerEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
const x0 = e.clientX;
|
||||||
|
const w0 = sideW;
|
||||||
|
const onMove = (ev: PointerEvent) => {
|
||||||
|
setSideW(Math.min(SIDE_W_MAX, Math.max(SIDE_W_MIN, Math.round(w0 + (x0 - ev.clientX)))));
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
window.removeEventListener('pointermove', onMove);
|
||||||
|
window.removeEventListener('pointerup', onUp);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', onMove);
|
||||||
|
window.addEventListener('pointerup', onUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!divRef.current || mapRef.current) return;
|
||||||
|
const m = L.map(divRef.current, {
|
||||||
|
zoomControl: true, attributionControl: true,
|
||||||
|
worldCopyJump: false, preferCanvas: true,
|
||||||
|
// Opened on the station, not on the Atlantic: the passes that matter are
|
||||||
|
// the ones over the operator's own head.
|
||||||
|
center: saved.current ? [saved.current.lat, saved.current.lon] : [home?.lat ?? 25, home?.lon ?? 0],
|
||||||
|
zoom: saved.current ? saved.current.zoom : 3,
|
||||||
|
minZoom: 2,
|
||||||
|
});
|
||||||
|
m.on('moveend', () => {
|
||||||
|
const c = m.getCenter();
|
||||||
|
saveMapView(MAP_VIEW_SAT, c.lat, c.lng, m.getZoom());
|
||||||
|
});
|
||||||
|
mapRef.current = m;
|
||||||
|
layerRef.current = L.layerGroup().addTo(m);
|
||||||
|
const ro = new ResizeObserver(() => m.invalidateSize({ animate: false }));
|
||||||
|
ro.observe(divRef.current);
|
||||||
|
const settle = window.setTimeout(() => m.invalidateSize({ animate: false }), 100);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(settle);
|
||||||
|
ro.disconnect();
|
||||||
|
m.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
layerRef.current = null;
|
||||||
|
};
|
||||||
|
}, [home?.lat, home?.lon]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const m = mapRef.current;
|
||||||
|
if (!m) return;
|
||||||
|
baseRef.current?.remove(); labelsRef.current?.remove();
|
||||||
|
const bm = BASEMAPS[basemap];
|
||||||
|
const opts: L.TileLayerOptions = {
|
||||||
|
maxNativeZoom: bm.maxNativeZoom, noWrap: true,
|
||||||
|
bounds: L.latLngBounds(L.latLng(-85.0511, -180), L.latLng(85.0511, 180)),
|
||||||
|
};
|
||||||
|
baseRef.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
||||||
|
if (bm.labelsUrl) labelsRef.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
|
||||||
|
saveMapBase(MAP_BASE_SAT, basemap);
|
||||||
|
}, [basemap]);
|
||||||
|
|
||||||
|
// The selected bird's path over the ground, redrawn when the selection
|
||||||
|
// changes and every couple of minutes as it walks off the front of it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sel) { setTrack([]); return; }
|
||||||
|
let live = true;
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const pts: Position[] = ((await GetSatelliteGroundTrack(sel, 100)) as any) ?? [];
|
||||||
|
if (live) setTrack(pts);
|
||||||
|
} catch { if (live) setTrack([]); }
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const id = window.setInterval(load, 120_000);
|
||||||
|
return () => { live = false; window.clearInterval(id); };
|
||||||
|
}, [sel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const layer = layerRef.current;
|
||||||
|
if (!layer) return;
|
||||||
|
layer.clearLayers();
|
||||||
|
if (home) {
|
||||||
|
L.circleMarker([home.lat, home.lon], {
|
||||||
|
radius: 5, color: '#fff', weight: 2, fillColor: '#e11d48', fillOpacity: 1,
|
||||||
|
}).bindTooltip(myGrid, { direction: 'top' }).addTo(layer);
|
||||||
|
}
|
||||||
|
if (track.length > 1) {
|
||||||
|
const pts = splitAtAntimeridian(track.map((p) => [p.lat, p.lon] as [number, number]));
|
||||||
|
L.polyline(pts as L.LatLngExpression[][], {
|
||||||
|
color: 'var(--info)', weight: 1.2, opacity: 0.7, dashArray: '4 4', smoothFactor: 0,
|
||||||
|
}).addTo(layer);
|
||||||
|
}
|
||||||
|
const wanted = new Set(shown.map((b) => b.name));
|
||||||
|
for (const p of positions) {
|
||||||
|
if (!wanted.has(p.name) && p.name !== sel) continue;
|
||||||
|
const chosen = p.name === sel;
|
||||||
|
const up = p.el > 0;
|
||||||
|
const colour = chosen ? '#22c55e' : up ? '#f59e0b' : '#9ca3af';
|
||||||
|
// The footprint is the honest answer to "can I hear it": everything inside
|
||||||
|
// the circle has the satellite above its horizon.
|
||||||
|
L.circle([p.lat, p.lon], {
|
||||||
|
radius: p.footprint_km * 1000,
|
||||||
|
color: colour, weight: chosen ? 1.2 : 0.8, opacity: chosen ? 0.7 : 0.35,
|
||||||
|
fillColor: colour, fillOpacity: chosen ? 0.1 : 0.05,
|
||||||
|
}).addTo(layer);
|
||||||
|
L.circleMarker([p.lat, p.lon], {
|
||||||
|
radius: chosen ? 6 : 4, color: '#fff', weight: 1,
|
||||||
|
fillColor: colour, fillOpacity: 1,
|
||||||
|
})
|
||||||
|
.bindTooltip(`${p.name} · ${fmtDeg(p.el)} · ${Math.round(p.alt_km)} km`, { direction: 'top' })
|
||||||
|
.on('click', () => setSel(p.name))
|
||||||
|
.addTo(layer);
|
||||||
|
}
|
||||||
|
}, [positions, track, home?.lat, home?.lon, myGrid, sel, shown]);
|
||||||
|
|
||||||
|
// ── Render ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// The pass, as a countdown and a bar. Both derived here from two timestamps,
|
||||||
|
// so they move every second without asking Go anything.
|
||||||
|
const aosMs = pass?.has_pass ? Date.parse(pass.aos) : 0;
|
||||||
|
const losMs = pass?.has_pass ? Date.parse(pass.los) : 0;
|
||||||
|
const inPass = !!pass?.has_pass && now >= aosMs && now < losMs;
|
||||||
|
const progress = inPass && losMs > aosMs ? (now - aosMs) / (losMs - aosMs) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0 gap-1 p-1">
|
||||||
|
{/* Header: what to work, and the one button that touches the radio.
|
||||||
|
Everything else about satellites — which ones, where the elements come
|
||||||
|
from, the rotator — is in Settings, because none of it is something to
|
||||||
|
do while a bird is going over. */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap px-1 shrink-0">
|
||||||
|
<SatIcon className="size-4 text-muted-foreground" />
|
||||||
|
<select
|
||||||
|
className="h-7 rounded-md border border-border bg-background px-2 text-xs min-w-[12rem]"
|
||||||
|
value={sel}
|
||||||
|
onChange={(e) => setSel(e.target.value)}
|
||||||
|
>
|
||||||
|
{shown.length === 0 && <option value="">{t('sat.noneFollowed')}</option>}
|
||||||
|
{shown.map((b) => (
|
||||||
|
<option key={b.name} value={b.name} disabled={!b.has_elements && !b.geostationary}>
|
||||||
|
{b.name}{b.has_elements || b.geostationary ? '' : ` — ${t('sat.noElements')}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{(bird?.transponders?.length ?? 0) > 1 && (
|
||||||
|
<select
|
||||||
|
className="h-7 rounded-md border border-border bg-background px-2 text-xs"
|
||||||
|
value={tpIdx}
|
||||||
|
onChange={(e) => setTpIdx(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{bird!.transponders!.map((x, i) => (
|
||||||
|
<option key={i} value={i}>{x.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={tracking?.on ? 'default' : 'outline'}
|
||||||
|
className={cn('h-7 px-2 gap-1.5', tracking?.on && 'bg-success text-background hover:bg-success/90')}
|
||||||
|
onClick={toggleTracking}
|
||||||
|
disabled={!bird?.transponders?.length}
|
||||||
|
title={tracking?.on
|
||||||
|
? (tracking.radio === 'sat' ? t('sat.trackingFull') : t('sat.trackingDown'))
|
||||||
|
: t('sat.trackTip')}
|
||||||
|
>
|
||||||
|
<Radio className="size-3.5" />
|
||||||
|
{tracking?.on ? t('sat.tracking') : t('sat.track')}
|
||||||
|
</Button>
|
||||||
|
{tracking?.on && tracking.radio === 'downlink-only' && (
|
||||||
|
<span className="text-[11px] text-warning">{t('sat.downlinkOnly')}</span>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{/* Elements are maintenance, so only their AGE is here — and only when
|
||||||
|
it has become a reason the panel might be wrong. */}
|
||||||
|
{tle?.stale && <span className="text-[11px] text-warning">{t('sat.tleStale')}</span>}
|
||||||
|
<select
|
||||||
|
className="h-7 rounded-md border border-border bg-background px-2 text-xs"
|
||||||
|
value={basemap}
|
||||||
|
onChange={(e) => setBasemap(e.target.value as BasemapKey)}
|
||||||
|
>
|
||||||
|
{Object.entries(BASEMAPS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
variant="ghost" size="sm"
|
||||||
|
className={cn('h-7 px-1.5', skyShown && 'text-success')}
|
||||||
|
onClick={() => setSkyShown((v) => !v)}
|
||||||
|
title={skyShown ? t('sat.hideSky') : t('sat.showSky')}
|
||||||
|
>
|
||||||
|
<Radar className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
{/* Put the whole window on the map. On a laptop the readout takes a
|
||||||
|
third of the screen, and there are moments — watching a footprint
|
||||||
|
cross an ocean — when the map IS the answer. */}
|
||||||
|
<Button
|
||||||
|
variant="ghost" size="sm" className="h-7 px-1.5"
|
||||||
|
onClick={() => setSideShown((v) => !v)}
|
||||||
|
title={sideShown ? t('sat.hideSide') : t('sat.showSide')}
|
||||||
|
>
|
||||||
|
{sideShown ? <PanelRightClose className="size-3.5" /> : <PanelRightOpen className="size-3.5" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <div className="px-2 text-[11px] text-danger shrink-0">{err}</div>}
|
||||||
|
|
||||||
|
<div className="flex gap-1 flex-1 min-h-0">
|
||||||
|
{/* The map. isolate is load-bearing, not tidiness: Leaflet stacks its
|
||||||
|
own panes and controls up to z-index 1000, which without a stacking
|
||||||
|
context of their own float over Preferences and every dialog in the
|
||||||
|
app — the map ends up on top of the very buttons that would close
|
||||||
|
it. */}
|
||||||
|
<div className="relative isolate z-0 flex-1 min-w-0 rounded-lg overflow-hidden border border-border">
|
||||||
|
<div ref={divRef} className="h-full w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sideShown && (
|
||||||
|
<div
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
title={t('sat.sideWidthTip')}
|
||||||
|
onPointerDown={startSideDrag}
|
||||||
|
onDoubleClick={() => setSideW(SIDE_W_DEFAULT)}
|
||||||
|
className="group relative shrink-0 w-2 cursor-col-resize flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<span className="h-10 w-[3px] rounded-full bg-border group-hover:bg-primary transition-colors" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={cn('shrink-0 flex flex-col gap-1 min-h-0', !sideShown && 'hidden')}
|
||||||
|
style={{ width: sideW }}>
|
||||||
|
{/* The pass. The first thing an operator looks at and the reason they
|
||||||
|
sit down: how long have I got, and how high does it get. */}
|
||||||
|
<div className={cn('rounded-lg border bg-card p-2',
|
||||||
|
inPass ? 'border-success/60' : 'border-border')}>
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="font-medium text-sm truncate">{bird?.name ?? '—'}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground truncate">{tp?.label ?? ''}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bird?.geostationary ? (
|
||||||
|
<div className="mt-1 text-[11px] text-muted-foreground">{t('sat.geoHint')}</div>
|
||||||
|
) : !pass?.has_pass ? (
|
||||||
|
<div className="mt-1 text-[11px] text-muted-foreground">{t('sat.noPassSoon')}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-1 flex items-baseline gap-2">
|
||||||
|
<span className={cn('text-[10px] uppercase tracking-wide',
|
||||||
|
inPass ? 'text-success' : 'text-muted-foreground')}>
|
||||||
|
{inPass ? t('sat.los') : t('sat.aos')}
|
||||||
|
</span>
|
||||||
|
<span className={cn('text-xl font-semibold tabular-nums leading-none',
|
||||||
|
inPass && 'text-success')}>
|
||||||
|
{fmtCountdown((inPass ? losMs : aosMs) - now)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground tabular-nums ml-auto">
|
||||||
|
{hhmmss(inPass ? pass.los : pass.aos)}Z
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Where in the pass we are. A bar because the useful question
|
||||||
|
mid-pass is not the clock but "am I past the peak". */}
|
||||||
|
<div className="mt-1.5 h-1 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div className="h-full bg-success transition-[width] duration-1000 ease-linear"
|
||||||
|
style={{ width: `${Math.round(progress * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1.5 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
|
||||||
|
<PassBit label={t('sat.rise')} value={`${hhmm(pass.aos)} ${compass(pass.aos_az)}`} />
|
||||||
|
<PassBit label={t('sat.peak')} value={`${Math.round(pass.max_el)}° ${compass(pass.max_el_az)}`}
|
||||||
|
strong={pass.max_el >= 30} />
|
||||||
|
<PassBit label={t('sat.set')} value={`${hhmm(pass.los)} ${compass(pass.los_az)}`} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The sky, seen from underneath it: the centre is straight up, the
|
||||||
|
rim is the horizon, north is at the top. One glance says whether
|
||||||
|
the pass comes over the roof or along the treeline. */}
|
||||||
|
{skyShown && (
|
||||||
|
<div className="rounded-lg border border-border bg-card p-2">
|
||||||
|
<SkyPlot
|
||||||
|
track={sky}
|
||||||
|
az={tuning?.az ?? null}
|
||||||
|
el={tuning?.el ?? null}
|
||||||
|
name={bird?.name}
|
||||||
|
visible={!!tuning?.visible}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Where it is, right now. */}
|
||||||
|
<div className="rounded-lg border border-border bg-card p-2">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Readout label={t('sat.az')} value={tuning ? fmtDeg(tuning.az) : '—'}
|
||||||
|
sub={tuning ? compass(tuning.az) : ''} />
|
||||||
|
<Readout label={t('sat.el')} value={tuning ? fmtDeg(tuning.el) : '—'}
|
||||||
|
colour={tuning?.visible ? 'var(--success)' : 'var(--muted-foreground)'}
|
||||||
|
sub={tuning ? (tuning.visible ? t('sat.up') : t('sat.below')) : ''} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
|
||||||
|
<PassBit label={t('sat.range')} value={tuning?.range_km ? fmtKm(tuning.range_km) : '—'} />
|
||||||
|
<PassBit label={t('sat.altitude')} value={tuning?.alt_km ? fmtKm(tuning.alt_km) : '—'} />
|
||||||
|
<PassBit label={t('sat.footprint')} value={tuning?.footprint_km ? fmtKm(tuning.footprint_km) : '—'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Approaching or receding, which is the sign of the whole Doppler
|
||||||
|
correction and the one number that explains why the frequencies
|
||||||
|
are moving the way they are. */}
|
||||||
|
{!!tuning && !bird?.geostationary && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground tabular-nums">
|
||||||
|
{tuning.range_rate < 0
|
||||||
|
? <ArrowUp className="size-3 text-success" />
|
||||||
|
: <ArrowDown className="size-3 text-warning" />}
|
||||||
|
<span>{tuning.range_rate < 0 ? t('sat.approaching') : t('sat.receding')}</span>
|
||||||
|
<span>{Math.abs(tuning.range_rate).toFixed(2)} km/s</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Where the antenna is, beside where the satellite is. The two
|
||||||
|
differing is a rotator still slewing; the two differing for a
|
||||||
|
long time is a rotator that is stuck, and that is worth being
|
||||||
|
able to see without walking outside. */}
|
||||||
|
{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">
|
||||||
|
<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>
|
||||||
|
{!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* What to tune. */}
|
||||||
|
<div className="rounded-lg border border-border bg-card p-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<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} />
|
||||||
|
</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>}
|
||||||
|
{!!tp?.ctcss && <span>CTCSS {tp.ctcss.toFixed(1)}</span>}
|
||||||
|
{tp?.inverting && <span>{t('sat.inverting')}</span>}
|
||||||
|
{tp?.linear && <span>{Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz</span>}
|
||||||
|
{bird?.geostationary && <span>{t('sat.geo')}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* What is coming. */}
|
||||||
|
<div className="rounded-lg border border-border bg-card flex-1 min-h-0 flex flex-col">
|
||||||
|
<div className="px-2 py-1 text-[11px] font-medium text-muted-foreground border-b border-border shrink-0">
|
||||||
|
{t('sat.nextPasses')}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
|
{passes.length === 0 && (
|
||||||
|
<div className="p-2 text-[11px] text-muted-foreground">{t('sat.noPasses')}</div>
|
||||||
|
)}
|
||||||
|
{passes.length > 0 && (
|
||||||
|
// A real table, so the name column takes the width the longest
|
||||||
|
// name needs — "ZHUHAI-1 OVS-1A" was cut to eight characters in
|
||||||
|
// a fixed one — and the rest keeps its columns lined up under
|
||||||
|
// headings that say what the numbers are.
|
||||||
|
<table className="w-full text-[11px] tabular-nums">
|
||||||
|
<thead className="sticky top-0 z-10 bg-card">
|
||||||
|
<tr className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
<th className="text-left font-medium px-2 py-1">{t('sat.thSat')}</th>
|
||||||
|
<th className="text-left font-medium py-1">{t('sat.thAos')}</th>
|
||||||
|
<th className="text-left font-medium py-1">{t('sat.thLos')}</th>
|
||||||
|
<th className="text-right font-medium py-1">{t('sat.thMaxEl')}</th>
|
||||||
|
<th className="text-right font-medium px-2 py-1">{t('sat.thIn')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{passes.map((p, i) => {
|
||||||
|
const aos = Date.parse(p.aos);
|
||||||
|
const running = aos <= now && Date.parse(p.los) > now;
|
||||||
|
const soon = !running && aos - now < 5 * 60_000;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={`${p.name}-${p.aos}-${i}`}
|
||||||
|
onClick={() => setSel(p.name)}
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer hover:bg-accent/50 border-t border-border/40',
|
||||||
|
p.name === sel && 'bg-accent/40',
|
||||||
|
running && 'bg-success/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="px-2 py-1 whitespace-nowrap">
|
||||||
|
<span className={cn('font-medium', running && 'text-success')}>{p.name}</span>
|
||||||
|
<ModeDot mode={modeOf(p.name)} />
|
||||||
|
</td>
|
||||||
|
<td className="py-1 whitespace-nowrap">{hhmm(p.aos)}</td>
|
||||||
|
<td className="py-1 whitespace-nowrap text-muted-foreground">{hhmm(p.los)}</td>
|
||||||
|
{/* The elevation is the quality of the pass, so it is
|
||||||
|
coloured like one: a 70° pass overhead and a 12°
|
||||||
|
scrape along the horizon are not the same evening. */}
|
||||||
|
<td className={cn('py-1 text-right font-medium', elClass(p.max_el))}>
|
||||||
|
{Math.round(p.max_el)}°
|
||||||
|
</td>
|
||||||
|
<td className={cn('px-2 py-1 text-right whitespace-nowrap',
|
||||||
|
running ? 'text-success font-medium' : soon ? 'text-warning' : 'text-muted-foreground')}>
|
||||||
|
{running ? t('sat.now') : fmtCountdown(aos - now)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Readout({ label, value, colour, sub }: { label: string; value: string; colour?: string; sub?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md bg-muted/40 px-2 py-1">
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<span className="text-lg font-semibold tabular-nums leading-tight" style={colour ? { color: colour } : undefined}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
{!!sub && <span className="text-[10px] text-muted-foreground">{sub}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PassBit({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground truncate">{label}</div>
|
||||||
|
<div className={cn('truncate', strong && 'font-semibold text-foreground')}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The corrected frequency large, the nominal one small beside it. Showing only
|
||||||
|
// one of them leaves an operator unable to tell a Doppler correction from a
|
||||||
|
// mistuned transponder.
|
||||||
|
function FreqRow({ label, hz, nominal }: { label: string; hz: number; nominal: number }) {
|
||||||
|
const shift = hz && nominal ? hz - nominal : 0;
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<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>
|
||||||
|
{!!shift && (
|
||||||
|
<span className="text-[10px] tabular-nums text-muted-foreground">
|
||||||
|
{shift > 0 ? '+' : '−'}{Math.abs(Math.round(shift))} Hz
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||||
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||||||
GetPSUSettings, SavePSUSettings,
|
GetPSUSettings, SavePSUSettings,
|
||||||
|
GetSatSettings, SaveSatSettings, TestSatelliteRotator,
|
||||||
|
GetSatelliteTLEInfo, RefreshSatelliteTLE, AddSatelliteElements, GetSatelliteBirds,
|
||||||
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
|
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
|
||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||||
GetAudioSettings, SaveAudioSettings, AudioApplyLevels, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
GetAudioSettings, SaveAudioSettings, AudioApplyLevels, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||||
@@ -72,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';
|
||||||
@@ -92,6 +98,8 @@ import { OperatingPanel } from '@/components/OperatingPanel';
|
|||||||
import { AppearancePanel } from '@/components/AppearancePanel';
|
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 { MATRIX_DIGI_KEY, PHONE_MODES as MATRIX_PHONE_MODES } from '@/components/BandSlotGrid';
|
||||||
|
|
||||||
type LookupSettings = LookupSettingsForm;
|
type LookupSettings = LookupSettingsForm;
|
||||||
type StationSettings = StationSettingsForm;
|
type StationSettings = StationSettingsForm;
|
||||||
@@ -212,7 +220,6 @@ type SectionId =
|
|||||||
| 'lookup'
|
| 'lookup'
|
||||||
| 'lists-bands'
|
| 'lists-bands'
|
||||||
| 'lists-modes'
|
| 'lists-modes'
|
||||||
| 'lists-satellites'
|
|
||||||
| 'cluster'
|
| 'cluster'
|
||||||
| 'dxhunter'
|
| 'dxhunter'
|
||||||
| 'backup'
|
| 'backup'
|
||||||
@@ -228,6 +235,7 @@ type SectionId =
|
|||||||
| 'antgenius'
|
| 'antgenius'
|
||||||
| 'tunergenius'
|
| 'tunergenius'
|
||||||
| 'psu'
|
| 'psu'
|
||||||
|
| 'satellite'
|
||||||
| 'pgxl'
|
| 'pgxl'
|
||||||
| 'flex'
|
| 'flex'
|
||||||
| 'relayauto'
|
| 'relayauto'
|
||||||
@@ -310,6 +318,10 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
|||||||
{ kind: 'item', label: t('sec.station'), id: 'station' },
|
{ kind: 'item', label: t('sec.station'), id: 'station' },
|
||||||
{ kind: 'item', label: t('sec.profiles'), id: 'profiles' },
|
{ kind: 'item', label: t('sec.profiles'), id: 'profiles' },
|
||||||
{ kind: 'item', label: t('sec.operating'), id: 'operating' },
|
{ kind: 'item', label: t('sec.operating'), id: 'operating' },
|
||||||
|
// Not hardware, whatever the rotator block inside it suggests: which
|
||||||
|
// satellites you chase, where your antenna stands and how old your
|
||||||
|
// elements are is operating, and it sits with the rest of it.
|
||||||
|
{ kind: 'item', label: t('sec.satellite'), id: 'satellite' },
|
||||||
{ kind: 'item', label: t('sec.confirmations'), id: 'confirmations' },
|
{ kind: 'item', label: t('sec.confirmations'), id: 'confirmations' },
|
||||||
{ kind: 'item', label: t('sec.awards'), id: 'awards' },
|
{ kind: 'item', label: t('sec.awards'), id: 'awards' },
|
||||||
{ kind: 'item', label: t('sec.external'), id: 'external-services' },
|
{ kind: 'item', label: t('sec.external'), id: 'external-services' },
|
||||||
@@ -324,7 +336,6 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
|||||||
{ kind: 'group', label: t('nav.lists'), icon: Database, defaultOpen: true, children: [
|
{ kind: 'group', label: t('nav.lists'), icon: Database, defaultOpen: true, children: [
|
||||||
{ kind: 'item', label: t('sec.bands'), id: 'lists-bands' },
|
{ kind: 'item', label: t('sec.bands'), id: 'lists-bands' },
|
||||||
{ kind: 'item', label: t('sec.modes'), id: 'lists-modes' },
|
{ kind: 'item', label: t('sec.modes'), id: 'lists-modes' },
|
||||||
{ kind: 'item', label: t('sec.satellites'), id: 'lists-satellites' },
|
|
||||||
]},
|
]},
|
||||||
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
|
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
|
||||||
{ kind: 'item', label: t('sec.dxhunter'), id: 'dxhunter' },
|
{ kind: 'item', label: t('sec.dxhunter'), id: 'dxhunter' },
|
||||||
@@ -355,7 +366,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
|||||||
// Map section id → i18n key (breadcrumb / placeholders).
|
// Map section id → i18n key (breadcrumb / placeholders).
|
||||||
const SECTION_KEY: Partial<Record<SectionId, string>> = {
|
const SECTION_KEY: Partial<Record<SectionId, string>> = {
|
||||||
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
|
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
|
||||||
'external-services': 'sec.external', appearance: 'sec.appearance', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes', 'lists-satellites': 'sec.satellites',
|
'external-services': 'sec.external', appearance: 'sec.appearance', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
|
||||||
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
|
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
|
||||||
adifmon: 'sec.adifmon',
|
adifmon: 'sec.adifmon',
|
||||||
foldersync: 'sec.foldersync',
|
foldersync: 'sec.foldersync',
|
||||||
@@ -377,7 +388,6 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
|||||||
lookup: 'Callsign Lookup',
|
lookup: 'Callsign Lookup',
|
||||||
'lists-bands': 'Bands',
|
'lists-bands': 'Bands',
|
||||||
'lists-modes': 'Modes & default RST',
|
'lists-modes': 'Modes & default RST',
|
||||||
'lists-satellites': 'Satellites',
|
|
||||||
cluster: 'DX Cluster',
|
cluster: 'DX Cluster',
|
||||||
backup: 'Database backup',
|
backup: 'Database backup',
|
||||||
database: 'Database',
|
database: 'Database',
|
||||||
@@ -911,6 +921,66 @@ const MOTOR_BAND_DEFAULT_KHZ: Record<string, number> = {
|
|||||||
const relayCountUI = (type: string) =>
|
const relayCountUI = (type: string) =>
|
||||||
type === 'kmtronic' || type === 'denkovi' ? 8 : type === 'httpgen' ? 4 : 5;
|
type === 'kmtronic' || type === 'denkovi' ? 8 : type === 'httpgen' ? 4 : 5;
|
||||||
|
|
||||||
|
// The twelve cluster command macros.
|
||||||
|
//
|
||||||
|
// Module-scoped, with its own state, and that is the point rather than tidiness:
|
||||||
|
// nested inside SettingsModal every keystroke in one of these twenty-four boxes
|
||||||
|
// re-rendered the WHOLE preferences dialog — every list, every form, every
|
||||||
|
// panel — and the letters arrived visibly after the finger had left the key.
|
||||||
|
// Here a keystroke re-renders twelve rows.
|
||||||
|
//
|
||||||
|
// Written on every keystroke as before. This panel has no Save button, and a
|
||||||
|
// text box whose contents only take effect on some other button is how work
|
||||||
|
// gets lost; the database write is what waits (see saveClusterMacros).
|
||||||
|
function ClusterMacroEditor() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [macros, setMacros] = useState<ClusterMacro[]>(loadClusterMacros);
|
||||||
|
const setMacro = (i: number, patch: Partial<ClusterMacro>) => {
|
||||||
|
setMacros((cur) => {
|
||||||
|
const next = cur.map((m, j) => (j === i ? { ...m, ...patch } : m));
|
||||||
|
saveClusterMacros(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">{t('clu.macros')}</span>
|
||||||
|
</div>
|
||||||
|
{/* Two columns of six: twelve rows stacked would push everything else
|
||||||
|
in this panel off the screen. */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1.5">
|
||||||
|
{macros.map((m, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground tabular-nums w-4 text-right shrink-0">{i + 1}</span>
|
||||||
|
<Input
|
||||||
|
className="h-8 w-28 shrink-0 text-xs"
|
||||||
|
placeholder={t('clu.macroLabel')}
|
||||||
|
value={m.label}
|
||||||
|
maxLength={24}
|
||||||
|
onChange={(e) => setMacro(i, { label: e.target.value })}
|
||||||
|
/>
|
||||||
|
{/* 500, not 120. A DXSpider filter is a list of prefixes and an
|
||||||
|
operator's own list of wanted countries runs past a hundred
|
||||||
|
characters easily — the field simply stopped accepting
|
||||||
|
keystrokes, with nothing to say why, and the command was saved
|
||||||
|
truncated. The title shows the whole thing, since the box
|
||||||
|
cannot. */}
|
||||||
|
<Input
|
||||||
|
className="h-8 flex-1 min-w-0 font-mono text-xs"
|
||||||
|
placeholder={t('clu.macroCmd')}
|
||||||
|
value={m.cmd}
|
||||||
|
title={m.cmd}
|
||||||
|
maxLength={500}
|
||||||
|
onChange={(e) => setMacro(i, { cmd: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Live status + OPERATE/STANDBY toggle for ONE configured amplifier (by config
|
// Live status + OPERATE/STANDBY toggle for ONE configured amplifier (by config
|
||||||
// id) — SPE / ACOM / PGXL alike. Module-scoped (not a nested component) so it
|
// id) — SPE / ACOM / PGXL alike. Module-scoped (not a nested component) so it
|
||||||
// isn't remounted on every parent render. Polls once a second while shown.
|
// isn't remounted on every parent render. Polls once a second while shown.
|
||||||
@@ -1320,6 +1390,168 @@ function AwardsSelectionPanel({ profile }: { profile?: { name?: string; callsign
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SatelliteElementsBlock is where the orbital elements are kept up to date.
|
||||||
|
//
|
||||||
|
// In Settings rather than in the tab because it is maintenance, not operating:
|
||||||
|
// during a pass an operator wants the frequencies and the countdown, not a
|
||||||
|
// download button. Module-scope so it may hold its own hooks (see PanelHost).
|
||||||
|
function SatelliteElementsBlock({ autoTle, onAutoTle }: { autoTle: boolean; onAutoTle: (v: boolean) => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [info, setInfo] = useState<{ count: number; age_h: number; stale: boolean; custom: number } | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
const [paste, setPaste] = useState('');
|
||||||
|
const [showPaste, setShowPaste] = useState(false);
|
||||||
|
|
||||||
|
const read = async () => { try { setInfo(await GetSatelliteTLEInfo() as any); } catch { /* shown as unknown */ } };
|
||||||
|
useEffect(() => { read(); }, []);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
setBusy(true); setMsg('');
|
||||||
|
try {
|
||||||
|
const i: any = await RefreshSatelliteTLE();
|
||||||
|
setInfo(i);
|
||||||
|
setMsg(t('satset.tleFetched', { n: i?.count ?? 0 }));
|
||||||
|
} catch (e: any) { setMsg(String(e?.message ?? e)); }
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPasted = async () => {
|
||||||
|
setMsg('');
|
||||||
|
try {
|
||||||
|
const n: number = (await AddSatelliteElements(paste)) as any;
|
||||||
|
setPaste(''); setShowPaste(false);
|
||||||
|
await read();
|
||||||
|
setMsg(t('satset.tleAdded', { n }));
|
||||||
|
} catch (e: any) { setMsg(String(e?.message ?? e)); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const age = !info ? '—'
|
||||||
|
: info.count === 0 ? t('satset.tleNone')
|
||||||
|
: t('satset.tleAge', { n: info.count, h: Math.round(info.age_h) });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-semibold text-foreground">{t('satset.elements')}</h4>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<span className={cn('text-sm tabular-nums', info?.stale ? 'text-warning' : 'text-muted-foreground')}>{age}</span>
|
||||||
|
{!!info?.custom && <span className="text-xs text-muted-foreground">{t('satset.tleCustom', { n: info.custom })}</span>}
|
||||||
|
<Button size="sm" variant="outline" onClick={refresh} disabled={busy}>
|
||||||
|
{busy ? t('satset.tleFetching') : t('satset.tleFetch')}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setShowPaste((v) => !v)}>{t('satset.tlePaste')}</Button>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={autoTle} onCheckedChange={(c) => onAutoTle(!!c)} />
|
||||||
|
{t('satset.autoTle')}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('satset.tleHint')}</p>
|
||||||
|
{showPaste && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Textarea rows={5} className="font-mono text-xs" placeholder={t('satset.tlePastePlaceholder')}
|
||||||
|
value={paste} onChange={(e) => setPaste(e.target.value)} />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" onClick={addPasted} disabled={!paste.trim()}>{t('satset.tlePasteAdd')}</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">{t('satset.tlePasteHint')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msg && <div className="text-xs text-muted-foreground">{msg}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatelliteFollowList chooses which satellites the tab shows and the tracker
|
||||||
|
// offers — the same two-column shape as the awards, for the same reason: a feed
|
||||||
|
// carries a couple of hundred birds and an operator works six.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// yet and the reason the list does not start out empty-handed.
|
||||||
|
function SatelliteFollowList({ followed, onChange }: { followed: string[]; onChange: (next: string[]) => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [all, setAll] = useState<any[]>([]);
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [withPlanOnly, setWithPlanOnly] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try { setAll(((await GetSatelliteBirds()) ?? []) as any[]); } catch { /* an empty list says it itself */ }
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const followedSet = new Set(followed.map((s) => s.toUpperCase()));
|
||||||
|
const byName = new Map(all.map((b) => [b.name as string, b] as const));
|
||||||
|
const needle = q.trim().toLowerCase();
|
||||||
|
const available = all.filter((b) => !followedSet.has(String(b.name).toUpperCase())
|
||||||
|
&& (!withPlanOnly || (b.transponders?.length ?? 0) > 0)
|
||||||
|
&& (needle === '' || String(b.name).toLowerCase().includes(needle)));
|
||||||
|
const chosen = followed.map((n) => byName.get(n) ?? { name: n, has_elements: false, transponders: [] });
|
||||||
|
|
||||||
|
const label = (b: any) => {
|
||||||
|
const bits: string[] = [];
|
||||||
|
if ((b.transponders?.length ?? 0) > 0) bits.push(b.transponders.map((x: any) => x.mode).filter((m: string, i: number, a: string[]) => a.indexOf(m) === i).join('/'));
|
||||||
|
if (b.geostationary) bits.push(t('sat.geo'));
|
||||||
|
if (!b.has_elements) bits.push(t('sat.noElements'));
|
||||||
|
return bits.join(' · ');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-semibold text-foreground">{t('satset.follow')}</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('satset.followHint')}</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-lg border border-border bg-card/40 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border/60">
|
||||||
|
<span className="text-sm font-medium">{t('satset.available')} <span className="text-muted-foreground">({available.length})</span></span>
|
||||||
|
<button type="button" className="text-xs text-primary hover:underline disabled:opacity-40"
|
||||||
|
disabled={available.length === 0}
|
||||||
|
onClick={() => onChange([...followed, ...available.map((b) => b.name as string)])}>
|
||||||
|
{t('awards.addAll')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 border-b border-border/60 space-y-2">
|
||||||
|
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder={t('satset.search')} className="h-8" />
|
||||||
|
<label className="flex items-center gap-2 text-xs cursor-pointer text-muted-foreground">
|
||||||
|
<Checkbox checked={withPlanOnly} onCheckedChange={(c) => setWithPlanOnly(!!c)} />
|
||||||
|
{t('satset.withPlanOnly')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[300px] overflow-y-auto p-1.5 space-y-0.5">
|
||||||
|
{available.map((b) => (
|
||||||
|
<button key={b.name} type="button" onClick={() => onChange([...followed, b.name])}
|
||||||
|
className="group w-full flex items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-primary/10">
|
||||||
|
<span className="font-mono text-xs shrink-0">{b.name}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground truncate flex-1">{label(b)}</span>
|
||||||
|
<span className="text-primary opacity-0 group-hover:opacity-100">→</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{available.length === 0 && <div className="p-2 text-xs text-muted-foreground">{t('satset.allFollowed')}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-primary/40 bg-primary/5 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border/60">
|
||||||
|
<span className="text-sm font-medium">{t('satset.followed')} <span className="text-muted-foreground">({followed.length})</span></span>
|
||||||
|
<button type="button" className="text-xs text-primary hover:underline disabled:opacity-40"
|
||||||
|
disabled={followed.length === 0} onClick={() => onChange([])}>{t('awards.clear')}</button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[352px] overflow-y-auto p-1.5 space-y-0.5">
|
||||||
|
{chosen.map((b: any) => (
|
||||||
|
<button key={b.name} type="button" onClick={() => onChange(followed.filter((n) => n !== b.name))}
|
||||||
|
className="group w-full flex items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-primary/10">
|
||||||
|
<span className="text-muted-foreground opacity-0 group-hover:opacity-100">←</span>
|
||||||
|
<span className="font-mono text-xs shrink-0">{b.name}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground truncate flex-1">{label(b)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{followed.length === 0 && <div className="p-2 text-xs text-muted-foreground">{t('satset.noneFollowed')}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
||||||
const label = SECTION_LABELS[id] ?? id;
|
const label = SECTION_LABELS[id] ?? id;
|
||||||
const IconCmp = Icon ?? Construction;
|
const IconCmp = Icon ?? Construction;
|
||||||
@@ -1628,7 +1860,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
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: '', 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, 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,
|
||||||
digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001,
|
digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001,
|
||||||
@@ -1679,6 +1911,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string; use_for_my_antenna?: boolean; ant1_port?: number }>({ enabled: false, host: '', password: '', use_for_my_antenna: false, ant1_port: 1 });
|
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string; use_for_my_antenna?: boolean; ant1_port?: number }>({ enabled: false, host: '', password: '', use_for_my_antenna: false, ant1_port: 1 });
|
||||||
const [tunergenius, setTunergenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
const [tunergenius, setTunergenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
||||||
const [psuCfg, setPsuCfg] = useState<{ enabled: boolean; com_port: string; baud: number; address: number }>({ enabled: false, com_port: '', baud: 9600, address: 1 });
|
const [psuCfg, setPsuCfg] = useState<{ enabled: boolean; com_port: string; baud: number; address: number }>({ enabled: false, com_port: '', baud: 9600, address: 1 });
|
||||||
|
// 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
|
||||||
|
// 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 [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),
|
||||||
// each with its own connection. Saved as a whole via SaveAmplifiers.
|
// each with its own connection. Saved as a whole via SaveAmplifiers.
|
||||||
@@ -1775,12 +2012,20 @@ 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');
|
||||||
// Declared HERE and not in ClusterPanel: that renderer is called as a plain
|
|
||||||
// function by the PANELS map, so it must stay hooks-free.
|
|
||||||
const [clusterMacros, setClusterMacros] = useState<ClusterMacro[]>(loadClusterMacros);
|
|
||||||
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
||||||
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
|
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
|
||||||
// Password-encryption (secret vault) state.
|
// Password-encryption (secret vault) state.
|
||||||
@@ -2286,6 +2531,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||||||
try { setPsuCfg(await GetPSUSettings() as any); } catch {}
|
try { setPsuCfg(await GetPSUSettings() as any); } catch {}
|
||||||
|
try { setSatCfg(await GetSatSettings() as any); } catch {}
|
||||||
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
||||||
setBackupCfg(b as any);
|
setBackupCfg(b as any);
|
||||||
setQslDefaults(qd as any);
|
setQslDefaults(qd as any);
|
||||||
@@ -2330,6 +2576,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||||||
try { setPsuCfg(await GetPSUSettings() as any); } catch {}
|
try { setPsuCfg(await GetPSUSettings() as any); } catch {}
|
||||||
|
try { setSatCfg(await GetSatSettings() as any); } catch {}
|
||||||
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
||||||
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
||||||
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
||||||
@@ -2531,6 +2778,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
await SaveAntGeniusSettings(antgenius as any);
|
await SaveAntGeniusSettings(antgenius as any);
|
||||||
await SaveTunerGeniusSettings(tunergenius as any);
|
await SaveTunerGeniusSettings(tunergenius as any);
|
||||||
await SavePSUSettings(psuCfg as any);
|
await SavePSUSettings(psuCfg as any);
|
||||||
|
await SaveSatSettings(satCfg as any);
|
||||||
await SaveAmplifiers(amps as any);
|
await SaveAmplifiers(amps as any);
|
||||||
await SaveWinkeyerSettings(wk as any);
|
await SaveWinkeyerSettings(wk as any);
|
||||||
await SaveAudioSettings(audioCfg as any);
|
await SaveAudioSettings(audioCfg as any);
|
||||||
@@ -2580,7 +2828,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 ===
|
||||||
|
|
||||||
@@ -3103,39 +3350,6 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SatellitesPanel() {
|
|
||||||
const sats = lists.satellites ?? [];
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SectionHeader title={t('sec.satellites')} hint={t('sat.hint')} />
|
|
||||||
<div className="space-y-3 max-w-xl">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>{t('sat.listLabel')}</Label>
|
|
||||||
{/* Raw text, one per line, parsed on change — not a row-per-entry
|
|
||||||
editor with add and delete buttons. The list is short, edited
|
|
||||||
twice a year, and usually arrives pasted from a satellite
|
|
||||||
tracker; a textarea takes that paste in one gesture. */}
|
|
||||||
<textarea
|
|
||||||
className="w-full h-56 rounded-md border border-input bg-background p-2 font-mono text-xs"
|
|
||||||
value={sats.join('\n')}
|
|
||||||
placeholder={'AO-7\nAO-91\nRS-44\nSO-50'}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = e.target.value.split('\n').map((v) => v.trim());
|
|
||||||
setLists((s) => ({ ...s, satellites: next }));
|
|
||||||
}}
|
|
||||||
onBlur={() => setLists((s) => ({
|
|
||||||
// Tidied when the field is LEFT, never while typing: dropping an
|
|
||||||
// empty line as it is typed makes the Enter key look broken.
|
|
||||||
...s,
|
|
||||||
satellites: Array.from(new Set((s.satellites ?? []).map((v) => v.trim().toUpperCase()).filter(Boolean))).sort(),
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">{t('sat.listHint')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModesPanel() {
|
function ModesPanel() {
|
||||||
const selected = lists.modes ?? [];
|
const selected = lists.modes ?? [];
|
||||||
@@ -3532,6 +3746,17 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
</label>
|
</label>
|
||||||
<span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
|
<span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* ADIF records "RTTY" and nothing more, and the rig has two
|
||||||
|
sidebands to put it on — so this is the operator's answer, not
|
||||||
|
something the log can supply. */}
|
||||||
|
<div className="col-span-2 space-y-1">
|
||||||
|
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
||||||
|
<Checkbox checked={!!(catCfg as any).yaesu_rtty_usb}
|
||||||
|
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, yaesu_rtty_usb: !!c } as any))} />
|
||||||
|
{t('cat.yaesuRttyUsb')}
|
||||||
|
</label>
|
||||||
|
<span className="text-xs text-muted-foreground">{t('cat.yaesuRttyUsbHint')}</span>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{['icom', 'xiegu', 'kenwood', 'elecraft'].includes(catCfg.backend) && (
|
{['icom', 'xiegu', 'kenwood', 'elecraft'].includes(catCfg.backend) && (
|
||||||
@@ -4354,6 +4579,223 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Satellites: where the antenna is, and the machine that points it.
|
||||||
|
//
|
||||||
|
// The observer belongs here rather than in the tab because it is a property
|
||||||
|
// of the station, and the rotator because it is a second machine on a second
|
||||||
|
// port — a station with an HF rotator and an az/el pair must be able to have
|
||||||
|
// both, and choosing between them in one panel would be the wrong question.
|
||||||
|
function SatellitePanelSettings() {
|
||||||
|
const ports = wkPorts;
|
||||||
|
const setPorts = setWkPorts;
|
||||||
|
const set = (k: string, v: any) => setSatCfg((s: any) => ({ ...s, [k]: v }));
|
||||||
|
const num = (v: string) => parseInt(v.replace(/[^0-9-]/g, ''), 10) || 0;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader title={t('sec.satellite')} hint={t('satset.hint')} />
|
||||||
|
<div className="space-y-5 max-w-xl mb-5">
|
||||||
|
<SatelliteElementsBlock autoTle={!!satCfg.auto_tle} onAutoTle={(v) => set('auto_tle', v)} />
|
||||||
|
</div>
|
||||||
|
<div className="max-w-3xl mb-5">
|
||||||
|
<SatelliteFollowList
|
||||||
|
followed={satCfg.favorites ?? []}
|
||||||
|
onChange={(next) => set('favorites', next)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-5 max-w-xl">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('satset.altM')}</Label>
|
||||||
|
<Input className="font-mono" value={String(satCfg.alt_m ?? 0)}
|
||||||
|
onChange={(e) => set('alt_m', num(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('satset.minEl')}</Label>
|
||||||
|
<Input className="font-mono" value={String(satCfg.min_el ?? 10)}
|
||||||
|
onChange={(e) => set('min_el', num(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('satset.windowH')}</Label>
|
||||||
|
<Input className="font-mono" value={String(satCfg.window_h ?? 24)}
|
||||||
|
onChange={(e) => set('window_h', num(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('satset.altHint')}</p>
|
||||||
|
|
||||||
|
{/* The locator is NOT repeated here: it is the station's, set once in
|
||||||
|
Station information, and the passes are predicted from it. This is
|
||||||
|
the exception — an antenna at another site — and it says so, so
|
||||||
|
nobody has to wonder which of two locators is in use. */}
|
||||||
|
<details className="text-sm">
|
||||||
|
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||||
|
{t('satset.otherSite')}
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 flex items-center gap-3">
|
||||||
|
<Input className="font-mono w-40" placeholder={t('satset.gridPlaceholder')}
|
||||||
|
value={satCfg.grid ?? ''} onChange={(e) => set('grid', e.target.value.toUpperCase())} />
|
||||||
|
<span className="text-xs text-muted-foreground">{t('satset.otherSiteHint')}</span>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 pt-4 space-y-3">
|
||||||
|
<h4 className="text-sm font-semibold text-foreground">{t('satset.rotor')}</h4>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={!!satCfg.rot_on} onCheckedChange={(c) => set('rot_on', !!c)} />
|
||||||
|
{t('satset.rotEnable')}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('satset.rotHint')}</p>
|
||||||
|
|
||||||
|
{!!satCfg.rot_on && (
|
||||||
|
<>
|
||||||
|
{/* Who drives the mast. Not a detail: a station already running
|
||||||
|
PstRotator must NOT have OpsLog on the same cable as well. */}
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{/* Two columns wide: "OpsLog (EasyComm II)" does not fit in a
|
||||||
|
third of the row, and a truncated choice is a choice an
|
||||||
|
operator cannot read. */}
|
||||||
|
<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">
|
||||||
|
<Select value={satCfg.rot_com || '_'} onValueChange={(v) => set('rot_com', v === '_' ? '' : v)}>
|
||||||
|
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ports.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
|
||||||
|
{ports.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
|
↻
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('satset.rotBaud')}</Label>
|
||||||
|
<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>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<Label>{t('satset.rotMinEl')}</Label>
|
||||||
|
<Input className="font-mono" value={String(satCfg.rot_min_el ?? 0)}
|
||||||
|
onChange={(e) => set('rot_min_el', num(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('satset.rotStep')}</Label>
|
||||||
|
<Input className="font-mono" value={String(satCfg.rot_step ?? 5)}
|
||||||
|
onChange={(e) => set('rot_step', num(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('satset.rotRangeHint')}</p>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={!!satCfg.rot_park} onCheckedChange={(c) => set('rot_park', !!c)} />
|
||||||
|
{t('satset.rotPark')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={async () => {
|
||||||
|
setSatTest(t('satset.rotTesting'));
|
||||||
|
try {
|
||||||
|
// Saved first: the test opens the port from the STORED
|
||||||
|
// settings, and testing what is on screen rather than what
|
||||||
|
// is stored is the classic way to prove a COM port that is
|
||||||
|
// not the one about to be used.
|
||||||
|
await SaveSatSettings(satCfg as any);
|
||||||
|
setSatTest(String(await TestSatelliteRotator()));
|
||||||
|
} catch (e: any) { setSatTest(String(e?.message ?? e)); }
|
||||||
|
}}>
|
||||||
|
{t('satset.rotTest')}
|
||||||
|
</Button>
|
||||||
|
{satTest && <span className="text-xs text-muted-foreground">{satTest}</span>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PGXLPanelSettings() {
|
function PGXLPanelSettings() {
|
||||||
// The stored `type` stays a flat value ("spe13", "acom700", "pgxl"); the UI
|
// The stored `type` stays a flat value ("spe13", "acom700", "pgxl"); the UI
|
||||||
// presents it as brand + model.
|
// presents it as brand + model.
|
||||||
@@ -5605,14 +6047,6 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
|
|
||||||
function ClusterPanel() {
|
function ClusterPanel() {
|
||||||
const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||||
// Written on every keystroke. This panel has no Save button, and a pair of
|
|
||||||
// text boxes whose contents only take effect on some other button is how
|
|
||||||
// work gets lost.
|
|
||||||
const setMacro = (i: number, patch: Partial<ClusterMacro>) => {
|
|
||||||
const next = clusterMacros.map((m, j) => (j === i ? { ...m, ...patch } : m));
|
|
||||||
setClusterMacros(next);
|
|
||||||
saveClusterMacros(next);
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
@@ -5705,41 +6139,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
{t('clu.autoConnect')}
|
{t('clu.autoConnect')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
<ClusterMacroEditor />
|
||||||
<div>
|
|
||||||
<span className="text-sm font-medium">{t('clu.macros')}</span>
|
|
||||||
</div>
|
|
||||||
{/* Two columns of six: twelve rows stacked would push everything else
|
|
||||||
in this panel off the screen. */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1.5">
|
|
||||||
{clusterMacros.map((m, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-1.5">
|
|
||||||
<span className="text-[10px] text-muted-foreground tabular-nums w-4 text-right shrink-0">{i + 1}</span>
|
|
||||||
<Input
|
|
||||||
className="h-8 w-28 shrink-0 text-xs"
|
|
||||||
placeholder={t('clu.macroLabel')}
|
|
||||||
value={m.label}
|
|
||||||
maxLength={24}
|
|
||||||
onChange={(e) => setMacro(i, { label: e.target.value })}
|
|
||||||
/>
|
|
||||||
{/* 500, not 120. A DXSpider filter is a list of prefixes and
|
|
||||||
an operator's own list of wanted countries runs past a
|
|
||||||
hundred characters easily — the field simply stopped
|
|
||||||
accepting keystrokes, with nothing to say why, and the
|
|
||||||
command was saved truncated. The title shows the whole
|
|
||||||
thing, since the box cannot. */}
|
|
||||||
<Input
|
|
||||||
className="h-8 flex-1 min-w-0 font-mono text-xs"
|
|
||||||
placeholder={t('clu.macroCmd')}
|
|
||||||
value={m.cmd}
|
|
||||||
title={m.cmd}
|
|
||||||
maxLength={500}
|
|
||||||
onChange={(e) => setMacro(i, { cmd: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label className="flex items-start gap-2 text-sm cursor-pointer border-t border-border/60 pt-3">
|
<label className="flex items-start gap-2 text-sm cursor-pointer border-t border-border/60 pt-3">
|
||||||
<Checkbox checked={clusterWorkedSameSlot} className="mt-0.5"
|
<Checkbox checked={clusterWorkedSameSlot} className="mt-0.5"
|
||||||
onCheckedChange={(c) => { const v = !!c; setClusterWorkedSameSlot(v); writeUiPref('opslog.clusterWorkedSameSlot', v ? '1' : '0'); }} />
|
onCheckedChange={(c) => { const v = !!c; setClusterWorkedSameSlot(v); writeUiPref('opslog.clusterWorkedSameSlot', v ? '1' : '0'); }} />
|
||||||
@@ -7721,6 +8121,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
|
||||||
@@ -8326,7 +8744,6 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
lookup: LookupPanel,
|
lookup: LookupPanel,
|
||||||
'lists-bands': BandsPanel,
|
'lists-bands': BandsPanel,
|
||||||
'lists-modes': ModesPanel,
|
'lists-modes': ModesPanel,
|
||||||
'lists-satellites': SatellitesPanel,
|
|
||||||
cluster: ClusterPanel,
|
cluster: ClusterPanel,
|
||||||
dxhunter: DXHunterPanel,
|
dxhunter: DXHunterPanel,
|
||||||
udp: UDPIntegrationsPanelWrapper,
|
udp: UDPIntegrationsPanelWrapper,
|
||||||
@@ -8359,6 +8776,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
antgenius: AntGeniusPanelSettings,
|
antgenius: AntGeniusPanelSettings,
|
||||||
tunergenius: TunerGeniusPanelSettings,
|
tunergenius: TunerGeniusPanelSettings,
|
||||||
psu: PSUPanelSettings,
|
psu: PSUPanelSettings,
|
||||||
|
satellite: SatellitePanelSettings,
|
||||||
pgxl: PGXLPanelSettings,
|
pgxl: PGXLPanelSettings,
|
||||||
flex: () => (
|
flex: () => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -8473,7 +8891,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>
|
||||||
@@ -8489,10 +8911,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 && (
|
||||||
@@ -8577,11 +9000,19 @@ interface ClusterEditorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ClusterServerEditor({ value, onCancel, onSave }: ClusterEditorProps) {
|
function ClusterServerEditor({ value, onCancel, onSave }: ClusterEditorProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [s, setS] = useState(value);
|
const [s, setS] = useState(value);
|
||||||
const update = (patch: Partial<typeof s>) => setS((cur) => ({ ...cur, ...patch }));
|
const update = (patch: Partial<typeof s>) => setS((cur) => ({ ...cur, ...patch }));
|
||||||
|
// Which preset the fields currently match, so reopening a node created from
|
||||||
|
// one shows it selected rather than blank.
|
||||||
|
const presetIdx = CLUSTER_PRESETS.findIndex(
|
||||||
|
(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">
|
||||||
@@ -8589,6 +9020,39 @@ function ClusterServerEditor({ value, onCancel, onSave }: ClusterEditorProps) {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid grid-cols-2 gap-3 py-2 px-2">
|
<div className="grid grid-cols-2 gap-3 py-2 px-2">
|
||||||
|
{/* Start from a node that is known to work. The host 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. Everything stays editable
|
||||||
|
afterwards. */}
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>{t('clu.preset')}</Label>
|
||||||
|
<Select
|
||||||
|
value={presetIdx >= 0 ? String(presetIdx) : '_'}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
const p = CLUSTER_PRESETS[Number(v)];
|
||||||
|
if (!p) return;
|
||||||
|
update({
|
||||||
|
host: p.host, port: p.port,
|
||||||
|
// The name is only filled when the operator has not chosen one
|
||||||
|
// of their own: renaming a node is the first thing anybody with
|
||||||
|
// two of them does.
|
||||||
|
name: s.name.trim() ? s.name : p.name,
|
||||||
|
init_commands: s.init_commands?.trim() ? s.init_commands : (p.init ?? ''),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder={t('clu.presetPick')} /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_" disabled>{t('clu.presetPick')}</SelectItem>
|
||||||
|
{CLUSTER_PRESETS.map((p, i) => (
|
||||||
|
<SelectItem key={`${p.host}:${p.port}`} value={String(i)}>
|
||||||
|
{p.name} — {p.about}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('clu.presetHint')}</p>
|
||||||
|
</div>
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="space-y-1 col-span-2">
|
||||||
<Label>Display name</Label>
|
<Label>Display name</Label>
|
||||||
<Input autoFocus value={s.name} onChange={(e) => update({ name: e.target.value })} placeholder="VE7CC, F4BPO home…" />
|
<Input autoFocus value={s.name} onChange={(e) => update({ name: e.target.value })} placeholder="VE7CC, F4BPO home…" />
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
// The sky, seen from underneath it.
|
||||||
|
//
|
||||||
|
// The map answers "where is the satellite over the earth". This answers "where
|
||||||
|
// do I look", which during a pass is the question that matters: whether the
|
||||||
|
// bird comes over the top or clips the horizon behind the house is something no
|
||||||
|
// amount of azimuth and elevation digits conveys, and one glance at a polar
|
||||||
|
// plot settles it.
|
||||||
|
//
|
||||||
|
// The projection is the one every satellite tracker uses and every operator
|
||||||
|
// already reads: the centre is the zenith, the outer circle is the horizon, and
|
||||||
|
// north is up. So the radius is (90 − elevation), NOT the elevation — a
|
||||||
|
// satellite overhead is a dot in the middle, and a pass that stays near the rim
|
||||||
|
// is one that never rises.
|
||||||
|
|
||||||
|
export type SkyPoint = { at: string; az: number; el: number };
|
||||||
|
|
||||||
|
export function SkyPlot({ track, az, el, name, visible, size = 300 }: {
|
||||||
|
// The pass, sampled from rise to set. Empty draws the dial alone, which is
|
||||||
|
// still worth showing: it says where the antenna is pointing now.
|
||||||
|
track: SkyPoint[];
|
||||||
|
// Where the satellite is at this instant, or null when it is not up.
|
||||||
|
az?: number | null;
|
||||||
|
el?: number | null;
|
||||||
|
name?: string;
|
||||||
|
visible?: boolean;
|
||||||
|
size?: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const R = size / 2 - 14; // the horizon circle
|
||||||
|
const cx = size / 2, cy = size / 2;
|
||||||
|
|
||||||
|
// Where a bearing and an elevation land on the dial.
|
||||||
|
const pt = (azDeg: number, elDeg: number): [number, number] => {
|
||||||
|
const r = R * (90 - Math.max(0, Math.min(90, elDeg))) / 90;
|
||||||
|
const a = (azDeg * Math.PI) / 180;
|
||||||
|
return [cx + r * Math.sin(a), cy - r * Math.cos(a)];
|
||||||
|
};
|
||||||
|
|
||||||
|
const rings = [15, 30, 45, 60, 75];
|
||||||
|
const path = track.length > 1
|
||||||
|
? track.map((p, i) => `${i === 0 ? 'M' : 'L'}${pt(p.az, p.el).map((v) => v.toFixed(1)).join(' ')}`).join(' ')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Ticks every 10°, longer every 30°, so the rim reads as a compass rather
|
||||||
|
// than a plain circle.
|
||||||
|
const ticks = [];
|
||||||
|
for (let a = 0; a < 360; a += 10) {
|
||||||
|
const long = a % 30 === 0;
|
||||||
|
const rad = (a * Math.PI) / 180;
|
||||||
|
const r1 = R, r2 = R - (long ? 7 : 4);
|
||||||
|
ticks.push(
|
||||||
|
<line key={a}
|
||||||
|
x1={cx + r1 * Math.sin(rad)} y1={cy - r1 * Math.cos(rad)}
|
||||||
|
x2={cx + r2 * Math.sin(rad)} y2={cy - r2 * Math.cos(rad)}
|
||||||
|
stroke="var(--border)" strokeWidth={long ? 1.4 : 0.8} />,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const here = az != null && el != null ? pt(az, el) : null;
|
||||||
|
const start = track.length > 1 ? pt(track[0].az, track[0].el) : null;
|
||||||
|
const end = track.length > 1 ? pt(track[track.length - 1].az, track[track.length - 1].el) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${size} ${size}`} className="w-full h-auto select-none" role="img"
|
||||||
|
aria-label={t('sat.skyPlot')}>
|
||||||
|
<defs>
|
||||||
|
{/* An arrowhead on the track: a pass has a direction, and which way the
|
||||||
|
satellite is travelling decides where to point the antenna next. */}
|
||||||
|
<marker id="skyArrow" viewBox="0 0 10 10" refX="6" refY="5"
|
||||||
|
markerWidth="5" markerHeight="5" orient="auto-start-reverse">
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--success)" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<circle cx={cx} cy={cy} r={R} fill="var(--muted)" fillOpacity={0.25}
|
||||||
|
stroke="var(--border)" strokeWidth={1.5} />
|
||||||
|
{rings.map((e) => (
|
||||||
|
<circle key={e} cx={cx} cy={cy} r={R * (90 - e) / 90}
|
||||||
|
fill="none" stroke="var(--border)" strokeWidth={0.6} strokeDasharray="3 4" />
|
||||||
|
))}
|
||||||
|
{ticks}
|
||||||
|
|
||||||
|
{/* The cardinal cross. */}
|
||||||
|
<line x1={cx} y1={cy - R} x2={cx} y2={cy + R} stroke="var(--border)" strokeWidth={0.6} />
|
||||||
|
<line x1={cx - R} y1={cy} x2={cx + R} y2={cy} stroke="var(--border)" strokeWidth={0.6} />
|
||||||
|
|
||||||
|
{([
|
||||||
|
{ lbl: 'N', x: cx, y: cy - R - 3, anchor: 'middle' },
|
||||||
|
{ lbl: 'S', x: cx, y: cy + R + 11, anchor: 'middle' },
|
||||||
|
{ lbl: 'E', x: cx + R + 4, y: cy + 4, anchor: 'start' },
|
||||||
|
{ lbl: 'W', x: cx - R - 4, y: cy + 4, anchor: 'end' },
|
||||||
|
] as const).map((c) => (
|
||||||
|
<text key={c.lbl} x={c.x} y={c.y} textAnchor={c.anchor}
|
||||||
|
fontSize={11} fontWeight={600} fill="var(--muted-foreground)">{c.lbl}</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Elevation labels along the west arm, the way a tracker draws them. */}
|
||||||
|
{[0, 30, 60].map((e) => (
|
||||||
|
<text key={e} x={cx - R * (90 - e) / 90 + 2} y={cy + 10} fontSize={8}
|
||||||
|
fill="var(--muted-foreground)" opacity={0.8}>{e}°</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* The pass. */}
|
||||||
|
{path && (
|
||||||
|
<path d={path} fill="none" stroke="var(--success)" strokeWidth={1.6}
|
||||||
|
strokeDasharray="5 3" markerMid="url(#skyArrow)" markerEnd="url(#skyArrow)"
|
||||||
|
opacity={0.85} />
|
||||||
|
)}
|
||||||
|
{start && <circle cx={start[0]} cy={start[1]} r={3} fill="none" stroke="var(--success)" strokeWidth={1.4} />}
|
||||||
|
{end && <circle cx={end[0]} cy={end[1]} r={3} fill="var(--success)" opacity={0.6} />}
|
||||||
|
|
||||||
|
{/* Where it is now. Hollow and grey below the horizon: the numbers are
|
||||||
|
still right, but nothing can be worked through the earth. */}
|
||||||
|
{here && (
|
||||||
|
<g>
|
||||||
|
<line x1={here[0] - 6} y1={here[1]} x2={here[0] + 6} y2={here[1]}
|
||||||
|
stroke={visible ? 'var(--success)' : 'var(--muted-foreground)'} strokeWidth={1.4} />
|
||||||
|
<line x1={here[0]} y1={here[1] - 6} x2={here[0]} y2={here[1] + 6}
|
||||||
|
stroke={visible ? 'var(--success)' : 'var(--muted-foreground)'} strokeWidth={1.4} />
|
||||||
|
<circle cx={here[0]} cy={here[1]} r={4}
|
||||||
|
fill={visible ? 'var(--success)' : 'none'}
|
||||||
|
stroke={visible ? 'var(--background)' : 'var(--muted-foreground)'} strokeWidth={1.2} />
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* The name and the look angles, in the middle, where a tracker puts them
|
||||||
|
— big enough to read from the other side of the shack. */}
|
||||||
|
{!!name && (
|
||||||
|
<text x={cx} y={cy - R * 0.42} textAnchor="middle" fontSize={18} fontWeight={600}
|
||||||
|
fill="var(--foreground)" opacity={0.85}>{name}</text>
|
||||||
|
)}
|
||||||
|
{az != null && el != null && (
|
||||||
|
<text x={cx} y={cy - R * 0.22} textAnchor="middle" fontSize={12}
|
||||||
|
fill="var(--muted-foreground)" className="tabular-nums">
|
||||||
|
AZ {az.toFixed(1)}° EL {el.toFixed(1)}°
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical, ChevronUp, ChevronDown } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical, ChevronUp, ChevronDown, Radio, Zap, Mic } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
// Stored through writeUiPref like every other portable preference, so the
|
// Stored through writeUiPref like every other portable preference, so the
|
||||||
// buttons travel with data/ rather than living in one browser profile.
|
// buttons travel with data/ rather than living in one browser profile.
|
||||||
|
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPrefDebounced } from '@/lib/uiPref';
|
||||||
|
|
||||||
export type ClusterMacro = {
|
export type ClusterMacro = {
|
||||||
label: string; // what the button says
|
label: string; // what the button says
|
||||||
@@ -43,8 +43,12 @@ export function loadClusterMacros(): ClusterMacro[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debounced, because this is called on every keystroke in twenty-four text
|
||||||
|
// boxes. The local cache is written at once — it is what everything reads back
|
||||||
|
// — and only the database write waits for the typing to stop. A round trip into
|
||||||
|
// Go per character is what "the letters appear after I have moved on" was.
|
||||||
export function saveClusterMacros(macros: ClusterMacro[]): void {
|
export function saveClusterMacros(macros: ClusterMacro[]): void {
|
||||||
writeUiPref(clusterMacrosKey, JSON.stringify(macros));
|
writeUiPrefDebounced(clusterMacrosKey, JSON.stringify(macros));
|
||||||
}
|
}
|
||||||
|
|
||||||
// visibleClusterMacros drops the slots that would send nothing. The COMMAND is
|
// visibleClusterMacros drops the slots that would send nothing. The COMMAND is
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Cluster nodes worth starting from.
|
||||||
|
//
|
||||||
|
// Setting up a telnet cluster is the step operators get stuck on: the host and
|
||||||
|
// the port are two pieces of information nobody has to hand, a typo produces a
|
||||||
|
// silent failure to connect, and the ports are not guessable — a Reverse Beacon
|
||||||
|
// feed on 7000 carries CW and RTTY while 7001 carries FT8 and FT4, which no
|
||||||
|
// amount of trying will tell you.
|
||||||
|
//
|
||||||
|
// So the editor offers a list. It fills the fields and then gets out of the
|
||||||
|
// way: everything stays editable, because a node moves or an operator wants a
|
||||||
|
// different name for it, and a preset that could not be corrected would be
|
||||||
|
// worse than none.
|
||||||
|
//
|
||||||
|
// The list is meant to grow. One entry per node, and nothing here is special —
|
||||||
|
// a node added by hand behaves exactly the same.
|
||||||
|
|
||||||
|
export type ClusterPreset = {
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
// What it carries, in a few words: the dropdown is chosen from, not read, and
|
||||||
|
// "SOTA" means nothing to somebody who has never chased a summit.
|
||||||
|
about: string;
|
||||||
|
// Sent one per line after login. Empty for the nodes that need nothing.
|
||||||
|
init?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CLUSTER_PRESETS: ClusterPreset[] = [
|
||||||
|
{ name: 'F4BPO', host: 'cluster.f4bpo.com', port: 7300,
|
||||||
|
about: 'General DX cluster (OpsLog author’s node)' },
|
||||||
|
{ name: 'DXFun', host: 'dxfun.com', port: 8000,
|
||||||
|
about: 'General DX cluster, worldwide' },
|
||||||
|
{ name: 'F5LEN', host: 'dxcluster.f5len.org', port: 7373,
|
||||||
|
about: 'General DX cluster' },
|
||||||
|
{ name: 'F5MZN', host: 'f5mzn.org', port: 9000,
|
||||||
|
about: 'General DX cluster' },
|
||||||
|
{ name: 'KM3T', host: 'dxcc.km3t.net', port: 7373,
|
||||||
|
about: 'General DX cluster' },
|
||||||
|
{ name: 'SOTA', host: 'cluster.sota.org.uk', port: 7300,
|
||||||
|
about: 'Summits On The Air spots' },
|
||||||
|
{ name: 'POTA', host: 'pota-cluster.iz2lsc.eu', port: 7373,
|
||||||
|
about: 'Parks On The Air spots' },
|
||||||
|
// The two Reverse Beacon feeds are one network on two ports, and which port
|
||||||
|
// decides which modes arrive. Getting that wrong looks exactly like a dead
|
||||||
|
// node, so they are listed separately and named for what they carry.
|
||||||
|
{ name: 'RBN CW', host: 'telnet.reversebeacon.net', port: 7000,
|
||||||
|
about: 'Reverse Beacon Network — CW and RTTY skimmers' },
|
||||||
|
{ name: 'RBN FTx', host: 'telnet.reversebeacon.net', port: 7001,
|
||||||
|
about: 'Reverse Beacon Network — FT8 and FT4 skimmers' },
|
||||||
|
];
|
||||||
+132
-10
File diff suppressed because one or more lines are too long
@@ -0,0 +1,38 @@
|
|||||||
|
// Which imagery each map draws on — one choice per map.
|
||||||
|
//
|
||||||
|
// The world map and the grid-square map used to share a single key, so picking
|
||||||
|
// satellite imagery to look at grids also repainted the main map, and there was
|
||||||
|
// no way to have terrain on one and plain streets on the other. They are
|
||||||
|
// different maps answering different questions, and the imagery that suits one
|
||||||
|
// is not the imagery that suits the next.
|
||||||
|
//
|
||||||
|
// Portable (see lib/uiPref) like the remembered views in lib/mapView: a copied
|
||||||
|
// data folder brings the choices with it.
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import type { BasemapKey } from '@/components/MainMap';
|
||||||
|
|
||||||
|
// The keys in use. Named here rather than typed at each call site so a rename
|
||||||
|
// cannot silently orphan somebody's choice.
|
||||||
|
export const MAP_BASE_WORLD = 'opslog.mapBasemap';
|
||||||
|
export const MAP_BASE_GRIDS = 'opslog.gridMapBase';
|
||||||
|
export const MAP_BASE_FT = 'opslog.ftmapBase';
|
||||||
|
export const MAP_BASE_SAT = 'opslog.satMapBase';
|
||||||
|
|
||||||
|
const VALID = ['light', 'voyager', 'street', 'satellite'];
|
||||||
|
|
||||||
|
// loadMapBase reads one map's choice.
|
||||||
|
//
|
||||||
|
// inheritFrom exists for the split: the grid map's choice lived under the world
|
||||||
|
// map's key until they were separated, so an operator who had chosen imagery
|
||||||
|
// there keeps it instead of being silently reset to the default.
|
||||||
|
export function loadMapBase(key: string, fallback: BasemapKey, inheritFrom?: string): BasemapKey {
|
||||||
|
const read = (k: string) => {
|
||||||
|
const v = localStorage.getItem(k);
|
||||||
|
return v && VALID.includes(v) ? (v as BasemapKey) : null;
|
||||||
|
};
|
||||||
|
return read(key) ?? (inheritFrom ? read(inheritFrom) : null) ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveMapBase(key: string, v: BasemapKey): void {
|
||||||
|
writeUiPref(key, v);
|
||||||
|
}
|
||||||
@@ -31,11 +31,13 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
||||||
// The same, for the FT decodes map and the grid-square map: a view an
|
// The same, for the FT decodes map and the grid-square map: a view an
|
||||||
// operator set up is theirs, and it should follow the folder like the rest.
|
// operator set up is theirs, and it should follow the folder like the rest.
|
||||||
'opslog.ftMapView', 'opslog.gridMapView',
|
'opslog.ftMapView', 'opslog.gridMapView', 'opslog.satMapView',
|
||||||
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
||||||
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
|
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
|
||||||
|
'opslog.matrixDigiMode', // band matrix: which digital row it opens on ('' = DIGI, the group)
|
||||||
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||||
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
// One imagery choice per map — world, grid squares, FT map, satellites.
|
||||||
|
'opslog.mapBasemap', 'opslog.gridMapBase', 'opslog.ftmapBase', 'opslog.satMapBase',
|
||||||
'opslog.dateFormat', // how dates are DISPLAYED (iso / fr / us); storage stays ISO
|
'opslog.dateFormat', // how dates are DISPLAYED (iso / fr / us); storage stays ISO
|
||||||
'opslog.mapGreyline', // world map: grey line (day/night terminator) shown
|
'opslog.mapGreyline', // world map: grey line (day/night terminator) shown
|
||||||
'opslog.awardRefSort', 'opslog.awardRefSortDir', // award reference table: sort column and direction
|
'opslog.awardRefSort', 'opslog.awardRefSortDir', // award reference table: sort column and direction
|
||||||
@@ -58,6 +60,8 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.clusterMuteWorked', // cluster/band map: no colour or badge on worked spots
|
'opslog.clusterMuteWorked', // cluster/band map: no colour or badge on worked spots
|
||||||
'opslog.clusterSlotHighlight', // cluster/band map: colour calls not worked on this band+mode
|
'opslog.clusterSlotHighlight', // cluster/band map: colour calls not worked on this band+mode
|
||||||
'opslog.bandMapWidth', // docked band map: column width (px)
|
'opslog.bandMapWidth', // docked band map: column width (px)
|
||||||
|
'opslog.satSideWidth', 'opslog.satSideShown', // Satellites tab: readout column width, and whether it is shown
|
||||||
|
'opslog.satSkyShown', // Satellites tab: the polar sky plot
|
||||||
'opslog.bandMapTabWidth', // Band map tab: shared card width (px)
|
'opslog.bandMapTabWidth', // Band map tab: shared card width (px)
|
||||||
'opslog.bandMapZoom', // band map zoom (px/kHz step) remembered per band, as one {band: index} map
|
'opslog.bandMapZoom', // band map zoom (px/kHz step) remembered per band, as one {band: index} map
|
||||||
'opslog.decodeColWidths', // FT decodes table: per-column widths (px), as one {col: px} map
|
'opslog.decodeColWidths', // FT decodes table: per-column widths (px), as one {col: px} map
|
||||||
@@ -90,6 +94,50 @@ export async function syncPortablePrefs(): Promise<void> {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeUiPrefDebounced is writeUiPref for a value that changes AS SOMEBODY
|
||||||
|
// TYPES.
|
||||||
|
//
|
||||||
|
// The local cache is written at once, because that is what the interface reads
|
||||||
|
// back and it costs nothing. The DATABASE write is held until the typing stops:
|
||||||
|
// writeUiPref crosses into Go and writes a row, and doing that per character in
|
||||||
|
// a text box is a round trip per keystroke — twenty-four boxes of cluster
|
||||||
|
// macros was exactly that, and it showed as characters appearing after the
|
||||||
|
// finger had left the key.
|
||||||
|
//
|
||||||
|
// Pending writes are flushed when the page goes away, so a value typed and
|
||||||
|
// immediately followed by a close is not lost.
|
||||||
|
const pendingPrefs = new Map<string, { value: string; timer: number }>();
|
||||||
|
|
||||||
|
export function writeUiPrefDebounced(key: string, value: string, ms = 400): void {
|
||||||
|
try { localStorage.setItem(key, value); } catch { /* quota / private mode */ }
|
||||||
|
const prev = pendingPrefs.get(key);
|
||||||
|
if (prev) window.clearTimeout(prev.timer);
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
pendingPrefs.delete(key);
|
||||||
|
SetUIPref(key, value).catch((e: any) => {
|
||||||
|
try { LogUIError('ui pref', 'could not store ' + key + ': ' + String(e?.message ?? e), ''); } catch { /* nothing left to try */ }
|
||||||
|
});
|
||||||
|
}, ms);
|
||||||
|
pendingPrefs.set(key, { value, timer });
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushUiPrefs writes every pending value immediately.
|
||||||
|
export function flushUiPrefs(): void {
|
||||||
|
for (const [key, p] of pendingPrefs) {
|
||||||
|
window.clearTimeout(p.timer);
|
||||||
|
SetUIPref(key, p.value).catch(() => { /* the local cache still holds it */ });
|
||||||
|
}
|
||||||
|
pendingPrefs.clear();
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('beforeunload', flushUiPrefs);
|
||||||
|
// Closing the app does not always fire beforeunload in a WebView; a hidden
|
||||||
|
// page is the earlier and more reliable signal.
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'hidden') flushUiPrefs();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// writeUiPref write-throughs a value to the local cache AND the portable DB.
|
// writeUiPref write-throughs a value to the local cache AND the portable DB.
|
||||||
// Use it everywhere these keys are written instead of localStorage.setItem.
|
// Use it everywhere these keys are written instead of localStorage.setItem.
|
||||||
export function writeUiPref(key: string, value: string): void {
|
export function writeUiPref(key: string, value: string): void {
|
||||||
|
|||||||
@@ -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.15';
|
export const APP_VERSION = '0.27.18';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+37
@@ -19,6 +19,7 @@ import {pskrtgt} from '../models';
|
|||||||
import {pskr} from '../models';
|
import {pskr} from '../models';
|
||||||
import {psu} from '../models';
|
import {psu} from '../models';
|
||||||
import {spe} from '../models';
|
import {spe} from '../models';
|
||||||
|
import {sat} from '../models';
|
||||||
import {solar} from '../models';
|
import {solar} from '../models';
|
||||||
import {tunergenius} from '../models';
|
import {tunergenius} from '../models';
|
||||||
import {webpub} from '../models';
|
import {webpub} from '../models';
|
||||||
@@ -52,6 +53,8 @@ export function ActiveRadioMyRig():Promise<string>;
|
|||||||
|
|
||||||
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
||||||
|
|
||||||
|
export function AddSatelliteElements(arg1:string):Promise<number>;
|
||||||
|
|
||||||
export function AmpFanMode(arg1:string,arg2:string):Promise<void>;
|
export function AmpFanMode(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function AmpOperate(arg1:string,arg2:boolean):Promise<void>;
|
export function AmpOperate(arg1:string,arg2:boolean):Promise<void>;
|
||||||
@@ -588,6 +591,30 @@ export function GetRowColors():Promise<main.RowColorSettings>;
|
|||||||
|
|
||||||
export function GetSPEStatus():Promise<spe.Status>;
|
export function GetSPEStatus():Promise<spe.Status>;
|
||||||
|
|
||||||
|
export function GetSatSettings():Promise<main.SatSettings>;
|
||||||
|
|
||||||
|
export function GetSatelliteBirds():Promise<Array<main.SatBird>>;
|
||||||
|
|
||||||
|
export function GetSatelliteGroundTrack(arg1:string,arg2:number):Promise<Array<sat.Position>>;
|
||||||
|
|
||||||
|
export function GetSatelliteNames():Promise<Array<string>>;
|
||||||
|
|
||||||
|
export function GetSatelliteNextPass(arg1:string):Promise<main.SatPassInfo>;
|
||||||
|
|
||||||
|
export function GetSatelliteObserver():Promise<Record<string, any>>;
|
||||||
|
|
||||||
|
export function GetSatellitePasses(arg1:Array<string>,arg2:number):Promise<Array<sat.Pass>>;
|
||||||
|
|
||||||
|
export function GetSatellitePositions(arg1:Array<string>):Promise<Array<sat.Position>>;
|
||||||
|
|
||||||
|
export function GetSatelliteSkyTrack(arg1:string,arg2:number):Promise<Array<main.SatSkyPoint>>;
|
||||||
|
|
||||||
|
export function GetSatelliteTLEInfo():Promise<main.SatTLEInfo>;
|
||||||
|
|
||||||
|
export function GetSatelliteTracking():Promise<main.SatTrackStatus>;
|
||||||
|
|
||||||
|
export function GetSatelliteTuning(arg1:string,arg2:number,arg3:number):Promise<main.SatTuning>;
|
||||||
|
|
||||||
export function GetScpStatus():Promise<main.ScpStatus>;
|
export function GetScpStatus():Promise<main.ScpStatus>;
|
||||||
|
|
||||||
export function GetSecretStatus():Promise<main.SecretStatus>;
|
export function GetSecretStatus():Promise<main.SecretStatus>;
|
||||||
@@ -974,6 +1001,8 @@ export function RefreshDXpeditions():Promise<void>;
|
|||||||
|
|
||||||
export function RefreshKenwood():Promise<void>;
|
export function RefreshKenwood():Promise<void>;
|
||||||
|
|
||||||
|
export function RefreshSatelliteTLE():Promise<main.SatTLEInfo>;
|
||||||
|
|
||||||
export function RefreshSolar():Promise<void>;
|
export function RefreshSolar():Promise<void>;
|
||||||
|
|
||||||
export function RefreshYaesuPanel():Promise<void>;
|
export function RefreshYaesuPanel():Promise<void>;
|
||||||
@@ -1116,6 +1145,8 @@ export function SaveRotorPresets(arg1:Array<main.RotorPreset>):Promise<void>;
|
|||||||
|
|
||||||
export function SaveRowColors(arg1:main.RowColorSettings):Promise<void>;
|
export function SaveRowColors(arg1:main.RowColorSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveSatSettings(arg1:main.SatSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
|
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveSpotColors(arg1:main.SpotColors):Promise<void>;
|
export function SaveSpotColors(arg1:main.SpotColors):Promise<void>;
|
||||||
@@ -1356,10 +1387,14 @@ export function SetYaesuVOX(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function StartCWDecoder():Promise<void>;
|
export function StartCWDecoder():Promise<void>;
|
||||||
|
|
||||||
|
export function StartSatelliteTracking(arg1:string,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||||
|
|
||||||
export function StopCWDecoder():Promise<void>;
|
export function StopCWDecoder():Promise<void>;
|
||||||
|
|
||||||
|
export function StopSatelliteTracking():Promise<void>;
|
||||||
|
|
||||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SyncFolderNow():Promise<number>;
|
export function SyncFolderNow():Promise<number>;
|
||||||
@@ -1400,6 +1435,8 @@ export function TestQRZUpload():Promise<string>;
|
|||||||
|
|
||||||
export function TestRotatorDevice(arg1:main.RotatorDevice,arg2:number):Promise<void>;
|
export function TestRotatorDevice(arg1:main.RotatorDevice,arg2:number):Promise<void>;
|
||||||
|
|
||||||
|
export function TestSatelliteRotator():Promise<string>;
|
||||||
|
|
||||||
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
|
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
|
||||||
|
|
||||||
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ export function AddQSO(arg1) {
|
|||||||
return window['go']['main']['App']['AddQSO'](arg1);
|
return window['go']['main']['App']['AddQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AddSatelliteElements(arg1) {
|
||||||
|
return window['go']['main']['App']['AddSatelliteElements'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function AmpFanMode(arg1, arg2) {
|
export function AmpFanMode(arg1, arg2) {
|
||||||
return window['go']['main']['App']['AmpFanMode'](arg1, arg2);
|
return window['go']['main']['App']['AmpFanMode'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -1110,6 +1114,54 @@ export function GetSPEStatus() {
|
|||||||
return window['go']['main']['App']['GetSPEStatus']();
|
return window['go']['main']['App']['GetSPEStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetSatSettings() {
|
||||||
|
return window['go']['main']['App']['GetSatSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSatelliteBirds() {
|
||||||
|
return window['go']['main']['App']['GetSatelliteBirds']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSatelliteGroundTrack(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['GetSatelliteGroundTrack'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSatelliteNames() {
|
||||||
|
return window['go']['main']['App']['GetSatelliteNames']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSatelliteNextPass(arg1) {
|
||||||
|
return window['go']['main']['App']['GetSatelliteNextPass'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSatelliteObserver() {
|
||||||
|
return window['go']['main']['App']['GetSatelliteObserver']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSatellitePasses(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['GetSatellitePasses'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSatellitePositions(arg1) {
|
||||||
|
return window['go']['main']['App']['GetSatellitePositions'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSatelliteSkyTrack(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['GetSatelliteSkyTrack'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSatelliteTLEInfo() {
|
||||||
|
return window['go']['main']['App']['GetSatelliteTLEInfo']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSatelliteTracking() {
|
||||||
|
return window['go']['main']['App']['GetSatelliteTracking']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetSatelliteTuning(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['GetSatelliteTuning'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetScpStatus() {
|
export function GetScpStatus() {
|
||||||
return window['go']['main']['App']['GetScpStatus']();
|
return window['go']['main']['App']['GetScpStatus']();
|
||||||
}
|
}
|
||||||
@@ -1882,6 +1934,10 @@ export function RefreshKenwood() {
|
|||||||
return window['go']['main']['App']['RefreshKenwood']();
|
return window['go']['main']['App']['RefreshKenwood']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RefreshSatelliteTLE() {
|
||||||
|
return window['go']['main']['App']['RefreshSatelliteTLE']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RefreshSolar() {
|
export function RefreshSolar() {
|
||||||
return window['go']['main']['App']['RefreshSolar']();
|
return window['go']['main']['App']['RefreshSolar']();
|
||||||
}
|
}
|
||||||
@@ -2166,6 +2222,10 @@ export function SaveRowColors(arg1) {
|
|||||||
return window['go']['main']['App']['SaveRowColors'](arg1);
|
return window['go']['main']['App']['SaveRowColors'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveSatSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveSatSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveSelfSpotSettings(arg1) {
|
export function SaveSelfSpotSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveSelfSpotSettings'](arg1);
|
return window['go']['main']['App']['SaveSelfSpotSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2646,6 +2706,10 @@ export function StartCWDecoder() {
|
|||||||
return window['go']['main']['App']['StartCWDecoder']();
|
return window['go']['main']['App']['StartCWDecoder']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function StartSatelliteTracking(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['StartSatelliteTracking'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function StationSetRelay(arg1, arg2, arg3) {
|
export function StationSetRelay(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
|
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
@@ -2654,6 +2718,10 @@ export function StopCWDecoder() {
|
|||||||
return window['go']['main']['App']['StopCWDecoder']();
|
return window['go']['main']['App']['StopCWDecoder']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function StopSatelliteTracking() {
|
||||||
|
return window['go']['main']['App']['StopSatelliteTracking']();
|
||||||
|
}
|
||||||
|
|
||||||
export function SwitchCATRig(arg1) {
|
export function SwitchCATRig(arg1) {
|
||||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2734,6 +2802,10 @@ export function TestRotatorDevice(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['TestRotatorDevice'](arg1, arg2);
|
return window['go']['main']['App']['TestRotatorDevice'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestSatelliteRotator() {
|
||||||
|
return window['go']['main']['App']['TestSatelliteRotator']();
|
||||||
|
}
|
||||||
|
|
||||||
export function TestStationDevice(arg1) {
|
export function TestStationDevice(arg1) {
|
||||||
return window['go']['main']['App']['TestStationDevice'](arg1);
|
return window['go']['main']['App']['TestStationDevice'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2379,6 +2379,7 @@ export namespace main {
|
|||||||
kenwood_baud: number;
|
kenwood_baud: number;
|
||||||
kenwood_data_mode: string;
|
kenwood_data_mode: string;
|
||||||
yaesu_low_lines: boolean;
|
yaesu_low_lines: boolean;
|
||||||
|
yaesu_rtty_usb: boolean;
|
||||||
kenwood_low_lines: boolean;
|
kenwood_low_lines: boolean;
|
||||||
icom_port: string;
|
icom_port: string;
|
||||||
icom_baud: number;
|
icom_baud: number;
|
||||||
@@ -2432,6 +2433,7 @@ export namespace main {
|
|||||||
this.kenwood_baud = source["kenwood_baud"];
|
this.kenwood_baud = source["kenwood_baud"];
|
||||||
this.kenwood_data_mode = source["kenwood_data_mode"];
|
this.kenwood_data_mode = source["kenwood_data_mode"];
|
||||||
this.yaesu_low_lines = source["yaesu_low_lines"];
|
this.yaesu_low_lines = source["yaesu_low_lines"];
|
||||||
|
this.yaesu_rtty_usb = source["yaesu_rtty_usb"];
|
||||||
this.kenwood_low_lines = source["kenwood_low_lines"];
|
this.kenwood_low_lines = source["kenwood_low_lines"];
|
||||||
this.icom_port = source["icom_port"];
|
this.icom_port = source["icom_port"];
|
||||||
this.icom_baud = source["icom_baud"];
|
this.icom_baud = source["icom_baud"];
|
||||||
@@ -4001,6 +4003,363 @@ export namespace main {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class SatTransponder {
|
||||||
|
label: string;
|
||||||
|
mode: string;
|
||||||
|
down_lo: number;
|
||||||
|
down_hi: number;
|
||||||
|
up_lo: number;
|
||||||
|
up_hi: number;
|
||||||
|
inverting: boolean;
|
||||||
|
ctcss: number;
|
||||||
|
linear: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SatTransponder(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.label = source["label"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.down_lo = source["down_lo"];
|
||||||
|
this.down_hi = source["down_hi"];
|
||||||
|
this.up_lo = source["up_lo"];
|
||||||
|
this.up_hi = source["up_hi"];
|
||||||
|
this.inverting = source["inverting"];
|
||||||
|
this.ctcss = source["ctcss"];
|
||||||
|
this.linear = source["linear"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class SatBird {
|
||||||
|
name: string;
|
||||||
|
norad: number;
|
||||||
|
geostationary: boolean;
|
||||||
|
favorite: boolean;
|
||||||
|
has_elements: boolean;
|
||||||
|
element_name: string;
|
||||||
|
epoch_age_h: number;
|
||||||
|
transponders: SatTransponder[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SatBird(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.name = source["name"];
|
||||||
|
this.norad = source["norad"];
|
||||||
|
this.geostationary = source["geostationary"];
|
||||||
|
this.favorite = source["favorite"];
|
||||||
|
this.has_elements = source["has_elements"];
|
||||||
|
this.element_name = source["element_name"];
|
||||||
|
this.epoch_age_h = source["epoch_age_h"];
|
||||||
|
this.transponders = this.convertValues(source["transponders"], SatTransponder);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class SatPassInfo {
|
||||||
|
name: string;
|
||||||
|
has_pass: boolean;
|
||||||
|
in_pass: boolean;
|
||||||
|
// Go type: time
|
||||||
|
aos: any;
|
||||||
|
// Go type: time
|
||||||
|
los: any;
|
||||||
|
aos_az: number;
|
||||||
|
los_az: number;
|
||||||
|
max_el: number;
|
||||||
|
max_el_az: number;
|
||||||
|
// Go type: time
|
||||||
|
max_el_at: any;
|
||||||
|
duration_s: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SatPassInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.name = source["name"];
|
||||||
|
this.has_pass = source["has_pass"];
|
||||||
|
this.in_pass = source["in_pass"];
|
||||||
|
this.aos = this.convertValues(source["aos"], null);
|
||||||
|
this.los = this.convertValues(source["los"], null);
|
||||||
|
this.aos_az = source["aos_az"];
|
||||||
|
this.los_az = source["los_az"];
|
||||||
|
this.max_el = source["max_el"];
|
||||||
|
this.max_el_az = source["max_el_az"];
|
||||||
|
this.max_el_at = this.convertValues(source["max_el_at"], null);
|
||||||
|
this.duration_s = source["duration_s"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class SatSettings {
|
||||||
|
favorites: string[];
|
||||||
|
min_el: number;
|
||||||
|
window_h: number;
|
||||||
|
auto_tle: boolean;
|
||||||
|
grid: string;
|
||||||
|
alt_m: number;
|
||||||
|
rot_on: boolean;
|
||||||
|
rot_type: string;
|
||||||
|
rot_pst_port: number;
|
||||||
|
rot_transport: string;
|
||||||
|
rot_host: string;
|
||||||
|
rot_port: number;
|
||||||
|
rot_com: string;
|
||||||
|
rot_baud: number;
|
||||||
|
rot_max_az: number;
|
||||||
|
rot_min_el: number;
|
||||||
|
rot_step: number;
|
||||||
|
rot_park: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SatSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.favorites = source["favorites"];
|
||||||
|
this.min_el = source["min_el"];
|
||||||
|
this.window_h = source["window_h"];
|
||||||
|
this.auto_tle = source["auto_tle"];
|
||||||
|
this.grid = source["grid"];
|
||||||
|
this.alt_m = source["alt_m"];
|
||||||
|
this.rot_on = source["rot_on"];
|
||||||
|
this.rot_type = source["rot_type"];
|
||||||
|
this.rot_pst_port = source["rot_pst_port"];
|
||||||
|
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_step = source["rot_step"];
|
||||||
|
this.rot_park = source["rot_park"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class SatSkyPoint {
|
||||||
|
// Go type: time
|
||||||
|
at: any;
|
||||||
|
az: number;
|
||||||
|
el: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SatSkyPoint(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.at = this.convertValues(source["at"], null);
|
||||||
|
this.az = source["az"];
|
||||||
|
this.el = source["el"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class SatTLEInfo {
|
||||||
|
count: number;
|
||||||
|
// Go type: time
|
||||||
|
fetched_at: any;
|
||||||
|
age_h: number;
|
||||||
|
stale: boolean;
|
||||||
|
custom: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SatTLEInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.count = source["count"];
|
||||||
|
this.fetched_at = this.convertValues(source["fetched_at"], null);
|
||||||
|
this.age_h = source["age_h"];
|
||||||
|
this.stale = source["stale"];
|
||||||
|
this.custom = source["custom"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class SatTrackStatus {
|
||||||
|
on: boolean;
|
||||||
|
name: string;
|
||||||
|
transponder: string;
|
||||||
|
mode: string;
|
||||||
|
nominal_down: number;
|
||||||
|
nominal_up: number;
|
||||||
|
down_hz: number;
|
||||||
|
up_hz: number;
|
||||||
|
az: number;
|
||||||
|
el: number;
|
||||||
|
visible: boolean;
|
||||||
|
radio: string;
|
||||||
|
error: string;
|
||||||
|
rot_on: boolean;
|
||||||
|
rot_az: number;
|
||||||
|
rot_el: number;
|
||||||
|
rot_live: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SatTrackStatus(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.on = source["on"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.transponder = source["transponder"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.nominal_down = source["nominal_down"];
|
||||||
|
this.nominal_up = source["nominal_up"];
|
||||||
|
this.down_hz = source["down_hz"];
|
||||||
|
this.up_hz = source["up_hz"];
|
||||||
|
this.az = source["az"];
|
||||||
|
this.el = source["el"];
|
||||||
|
this.visible = source["visible"];
|
||||||
|
this.radio = source["radio"];
|
||||||
|
this.error = source["error"];
|
||||||
|
this.rot_on = source["rot_on"];
|
||||||
|
this.rot_az = source["rot_az"];
|
||||||
|
this.rot_el = source["rot_el"];
|
||||||
|
this.rot_live = source["rot_live"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SatTuning {
|
||||||
|
name: string;
|
||||||
|
transponder: string;
|
||||||
|
mode: string;
|
||||||
|
nominal_down: number;
|
||||||
|
nominal_up: number;
|
||||||
|
down_hz: number;
|
||||||
|
up_hz: number;
|
||||||
|
ctcss: number;
|
||||||
|
inverting: boolean;
|
||||||
|
az: number;
|
||||||
|
el: number;
|
||||||
|
range_km: number;
|
||||||
|
range_rate: number;
|
||||||
|
visible: boolean;
|
||||||
|
// Go type: time
|
||||||
|
at: any;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
alt_km: number;
|
||||||
|
footprint_km: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SatTuning(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.name = source["name"];
|
||||||
|
this.transponder = source["transponder"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.nominal_down = source["nominal_down"];
|
||||||
|
this.nominal_up = source["nominal_up"];
|
||||||
|
this.down_hz = source["down_hz"];
|
||||||
|
this.up_hz = source["up_hz"];
|
||||||
|
this.ctcss = source["ctcss"];
|
||||||
|
this.inverting = source["inverting"];
|
||||||
|
this.az = source["az"];
|
||||||
|
this.el = source["el"];
|
||||||
|
this.range_km = source["range_km"];
|
||||||
|
this.range_rate = source["range_rate"];
|
||||||
|
this.visible = source["visible"];
|
||||||
|
this.at = this.convertValues(source["at"], null);
|
||||||
|
this.lat = source["lat"];
|
||||||
|
this.lon = source["lon"];
|
||||||
|
this.alt_km = source["alt_km"];
|
||||||
|
this.footprint_km = source["footprint_km"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ScpStatus {
|
export class ScpStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
count: number;
|
count: number;
|
||||||
@@ -6071,6 +6430,109 @@ export namespace qso {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace sat {
|
||||||
|
|
||||||
|
export class Pass {
|
||||||
|
name: string;
|
||||||
|
// Go type: time
|
||||||
|
aos: any;
|
||||||
|
// Go type: time
|
||||||
|
los: any;
|
||||||
|
aos_az: number;
|
||||||
|
los_az: number;
|
||||||
|
max_el: number;
|
||||||
|
max_el_az: number;
|
||||||
|
// Go type: time
|
||||||
|
max_el_at: any;
|
||||||
|
duration_s: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Pass(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.name = source["name"];
|
||||||
|
this.aos = this.convertValues(source["aos"], null);
|
||||||
|
this.los = this.convertValues(source["los"], null);
|
||||||
|
this.aos_az = source["aos_az"];
|
||||||
|
this.los_az = source["los_az"];
|
||||||
|
this.max_el = source["max_el"];
|
||||||
|
this.max_el_az = source["max_el_az"];
|
||||||
|
this.max_el_at = this.convertValues(source["max_el_at"], null);
|
||||||
|
this.duration_s = source["duration_s"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Position {
|
||||||
|
name: string;
|
||||||
|
// Go type: time
|
||||||
|
at: any;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
alt_km: number;
|
||||||
|
footprint_km: number;
|
||||||
|
az: number;
|
||||||
|
el: number;
|
||||||
|
range_km: number;
|
||||||
|
range_rate: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Position(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.name = source["name"];
|
||||||
|
this.at = this.convertValues(source["at"], null);
|
||||||
|
this.lat = source["lat"];
|
||||||
|
this.lon = source["lon"];
|
||||||
|
this.alt_km = source["alt_km"];
|
||||||
|
this.footprint_km = source["footprint_km"];
|
||||||
|
this.az = source["az"];
|
||||||
|
this.el = source["el"];
|
||||||
|
this.range_km = source["range_km"];
|
||||||
|
this.range_rate = source["range_rate"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace scp {
|
export namespace scp {
|
||||||
|
|
||||||
export class Result {
|
export class Result {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module hamlog
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/akhenakh/sgp4 v0.0.0-20260314155803-8ee03fc877eb
|
||||||
github.com/braheezy/shine-mp3 v0.1.0
|
github.com/braheezy/shine-mp3 v0.1.0
|
||||||
github.com/eclipse/paho.mqtt.golang v1.5.1
|
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||||
github.com/go-ole/go-ole v1.3.0
|
github.com/go-ole/go-ole v1.3.0
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
|
github.com/akhenakh/sgp4 v0.0.0-20260314155803-8ee03fc877eb h1:d9tZ7tJrssgs7Va9j8iu9vl7BlK2rmIs5RiOU7WQJrs=
|
||||||
|
github.com/akhenakh/sgp4 v0.0.0-20260314155803-8ee03fc877eb/go.mod h1:JfAepWD223Cel6uRpzYdip/xijWZ2FT457YFLWy8Md4=
|
||||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
github.com/braheezy/shine-mp3 v0.1.0 h1:N2wZhv6ipCFduTSftaPNdDgZ5xFmQAPvB7JcqA4sSi8=
|
github.com/braheezy/shine-mp3 v0.1.0 h1:N2wZhv6ipCFduTSftaPNdDgZ5xFmQAPvB7JcqA4sSi8=
|
||||||
|
|||||||
@@ -832,6 +832,50 @@ func (m *Manager) IcomDo(fn func(IcomController) error) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SatTuner is a backend that can be put on a satellite: a receiver on one band
|
||||||
|
// and a transmitter on another, both moving under Doppler, at the same time.
|
||||||
|
//
|
||||||
|
// It is a separate interface from the per-manufacturer ones because what a
|
||||||
|
// satellite needs is not a manufacturer's feature — it is a shape of operating
|
||||||
|
// that a FlexRadio and an IC-9700 both provide and reach in completely
|
||||||
|
// different ways. A backend that cannot do it simply does not implement this,
|
||||||
|
// and the caller falls back to tuning the downlink alone rather than pretending.
|
||||||
|
type SatTuner interface {
|
||||||
|
// SetSatellite arms or disarms satellite operation: the rig's own satellite
|
||||||
|
// mode where it has one, two slices where it has those. Disarming must leave
|
||||||
|
// the radio somewhere an operator can work from, not half-configured.
|
||||||
|
SetSatellite(on bool) error
|
||||||
|
// TuneSatellite points the receiver at downHz and the transmitter at upHz,
|
||||||
|
// both already Doppler-corrected. Modes are ADIF names ("SSB", "FM", "CW");
|
||||||
|
// an empty one leaves that side's mode alone.
|
||||||
|
TuneSatellite(downHz, upHz int64, downMode, upMode string) error
|
||||||
|
// SatReceiveHz is where the receiver actually is. The operator tunes it to
|
||||||
|
// follow a station across a linear transponder, and that dial movement is
|
||||||
|
// the input the whole tracker works from — without reading it back, a
|
||||||
|
// tracker fights the operator instead of helping them.
|
||||||
|
SatReceiveHz() (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatCapable reports whether the active backend can hold a satellite pair.
|
||||||
|
func (m *Manager) SatCapable() bool {
|
||||||
|
m.mu.RLock()
|
||||||
|
b := m.backend
|
||||||
|
m.mu.RUnlock()
|
||||||
|
_, ok := b.(SatTuner)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatDo dispatches a satellite control onto the CAT goroutine.
|
||||||
|
func (m *Manager) SatDo(fn func(SatTuner) error) error {
|
||||||
|
return m.exec(func(b Backend) error {
|
||||||
|
st, ok := b.(SatTuner)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("this radio cannot hold a satellite pair from OpsLog")
|
||||||
|
}
|
||||||
|
return fn(st)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// exec marshals a backend operation onto the CAT goroutine. Returns the
|
// exec marshals a backend operation onto the CAT goroutine. Returns the
|
||||||
// operation's error or a "busy"/"not running" error if dispatch failed.
|
// operation's error or a "busy"/"not running" error if dispatch failed.
|
||||||
func (m *Manager) exec(fn func(Backend) error) error {
|
func (m *Manager) exec(fn func(Backend) error) error {
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ const (
|
|||||||
CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off)
|
CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off)
|
||||||
CmdRIT = 0x21 // RIT/ΔTX: sub 0x00 offset freq, 0x01 RIT on/off, 0x02 ΔTX(XIT) on/off
|
CmdRIT = 0x21 // RIT/ΔTX: sub 0x00 offset freq, 0x01 RIT on/off, 0x02 ΔTX(XIT) on/off
|
||||||
CmdSendCW = 0x17 // send a CW message (ASCII, ≤30 chars) via the rig's keyer; data 0xFF = stop
|
CmdSendCW = 0x17 // send a CW message (ASCII, ≤30 chars) via the rig's keyer; data 0xFF = stop
|
||||||
|
// CmdVFO selects which receiver subsequent commands address. On the two-band
|
||||||
|
// satellite rigs (IC-9700, IC-9100) the MAIN band is the downlink and the SUB
|
||||||
|
// band the uplink, so every satellite frequency set is "point at a band, then
|
||||||
|
// tune it".
|
||||||
|
CmdVFO = 0x07
|
||||||
|
|
||||||
SubLevelKeySpeed = 0x0C // CmdLevel: CW keying speed (0-255 → KeyMinWPM..KeyMaxWPM)
|
SubLevelKeySpeed = 0x0C // CmdLevel: CW keying speed (0-255 → KeyMinWPM..KeyMaxWPM)
|
||||||
|
|
||||||
@@ -112,6 +117,17 @@ const (
|
|||||||
SubSwBreakIn = 0x47 // CW break-in: 0=OFF, 1=SEMI, 2=FULL (needed so 0x17 CW keys TX)
|
SubSwBreakIn = 0x47 // CW break-in: 0=OFF, 1=SEMI, 2=FULL (needed so 0x17 CW keys TX)
|
||||||
SubSwMN = 0x48 // manual notch on/off
|
SubSwMN = 0x48 // manual notch on/off
|
||||||
SubSwAPF = 0x32 // audio peak filter on/off (CW only)
|
SubSwAPF = 0x32 // audio peak filter on/off (CW only)
|
||||||
|
// Satellite mode (IC-9700 / IC-9100). The rig's OWN satellite mode, not an
|
||||||
|
// imitation of one: it pairs main and sub, gives full duplex, and keeps the
|
||||||
|
// two dials linked the way the radio's designers meant. Asking it to do that
|
||||||
|
// is always better than building the same thing out of split.
|
||||||
|
SubSwSatellite = 0x5A
|
||||||
|
|
||||||
|
// CmdVFO sub-commands: which of a two-receiver rig's bands the next command
|
||||||
|
// addresses.
|
||||||
|
SubVFOMain = 0xD0 // MAIN band — the downlink in satellite mode
|
||||||
|
SubVFOSub = 0xD1 // SUB band — the uplink
|
||||||
|
SubVFOExchange = 0xB0 // swap main and sub
|
||||||
)
|
)
|
||||||
|
|
||||||
// CW break-in modes (CmdSwitch 0x47).
|
// CW break-in modes (CmdSwitch 0x47).
|
||||||
|
|||||||
+20
-1
@@ -67,6 +67,14 @@ type Flex struct {
|
|||||||
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||||
pendingSpotMode map[int]string // seq → ADIF mode, paired with pendingSpot
|
pendingSpotMode map[int]string // seq → ADIF mode, paired with pendingSpot
|
||||||
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
||||||
|
pendingSat map[int]string // seq → "rx"/"tx", awaiting a satellite slice's index
|
||||||
|
// Satellite pair: slice A is the downlink, slice B the uplink. -1 when not
|
||||||
|
// armed. satCreatedTX marks an uplink slice OpsLog opened, and is the only
|
||||||
|
// one it will close again.
|
||||||
|
satOn bool
|
||||||
|
satRX int
|
||||||
|
satTX int
|
||||||
|
satCreatedTX bool
|
||||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||||
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
|
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
|
||||||
spotFreq map[int]int64 // spot index → Hz, so a click can report where it was (the trigger message carries only the index)
|
spotFreq map[int]int64 // spot index → Hz, so a click can report where it was (the trigger message carries only the index)
|
||||||
@@ -227,7 +235,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
|||||||
return &Flex{
|
return &Flex{
|
||||||
host: strings.TrimSpace(host), port: port,
|
host: strings.TrimSpace(host), port: port,
|
||||||
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
||||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotFreq: map[int]int64{}, pendingSpotFreq: map[int]int64{}, panWindow: map[string]panView{}, spotSig: map[string]string{}, spotSent: map[string]time.Time{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotFreq: map[int]int64{}, pendingSpotFreq: map[int]int64{}, panWindow: map[string]panView{}, spotSig: map[string]string{}, spotSent: map[string]time.Time{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{}, pendingSat: map[int]string{}, satRX: -1, satTX: -1,
|
||||||
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||||
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||||
pinnedSlice: -1,
|
pinnedSlice: -1,
|
||||||
@@ -458,12 +466,23 @@ func (f *Flex) reader(conn net.Conn) {
|
|||||||
if splitSeq {
|
if splitSeq {
|
||||||
delete(f.pendingSplit, seq)
|
delete(f.pendingSplit, seq)
|
||||||
}
|
}
|
||||||
|
// The same reply carries the index of a slice created for a satellite
|
||||||
|
// pair; which of the two it is was recorded when it was asked for.
|
||||||
|
satRole := f.pendingSat[seq]
|
||||||
|
if satRole != "" {
|
||||||
|
delete(f.pendingSat, seq)
|
||||||
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
if splitSeq && ok && len(parts) >= 3 {
|
if splitSeq && ok && len(parts) >= 3 {
|
||||||
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||||
f.send(fmt.Sprintf("slice s %d tx=1", idx))
|
f.send(fmt.Sprintf("slice s %d tx=1", idx))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if satRole != "" && ok && len(parts) >= 3 {
|
||||||
|
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||||
|
f.adoptSatSlice(satRole, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Connection ended.
|
// Connection ended.
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Satellite operation on a FlexRadio.
|
||||||
|
//
|
||||||
|
// A Flex has no satellite mode, and does not need one: it has slices. Slice A
|
||||||
|
// is the downlink and slice B the uplink — the arrangement every Flex satellite
|
||||||
|
// operator already uses by hand — with the transmitter on B and full duplex on,
|
||||||
|
// so the operator hears their own signal come back through the transponder.
|
||||||
|
// The transverters that put 145 and 435 MHz within the radio's reach are
|
||||||
|
// configured in SmartSDR, and their offsets are the radio's business: OpsLog
|
||||||
|
// sends the real satellite frequency and SmartSDR does the arithmetic.
|
||||||
|
//
|
||||||
|
// The two slices are CREATED when they are missing, because "slice B does not
|
||||||
|
// exist" is not a thing to make the operator fix at the start of a ten-minute
|
||||||
|
// pass. Only what OpsLog created is taken away again on disarming: a slice the
|
||||||
|
// operator opened is theirs.
|
||||||
|
|
||||||
|
// SetSatellite arranges (or unwinds) the two-slice satellite pair.
|
||||||
|
func (f *Flex) SetSatellite(on bool) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
connected := f.conn != nil
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
if !on {
|
||||||
|
return f.satDisarm()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The downlink slice is the one the operator is already on: taking the
|
||||||
|
// active slice rather than insisting on index 0 means arming the satellite
|
||||||
|
// does not move them off the receiver they were listening to.
|
||||||
|
f.mu.Lock()
|
||||||
|
rxIdx, _ := f.mainSliceLocked()
|
||||||
|
var txIdx = -1
|
||||||
|
for _, idx := range f.sortedSliceIdxLocked() {
|
||||||
|
if s := f.slices[idx]; s != nil && s.inUse && idx != rxIdx {
|
||||||
|
txIdx = idx
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.satRX, f.satTX = rxIdx, txIdx
|
||||||
|
f.satOn = true
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
// Full duplex before anything else: without it the radio mutes the receiver
|
||||||
|
// on transmit, and an operator who cannot hear their own downlink has no way
|
||||||
|
// to know they are in the passband at all.
|
||||||
|
f.send("radio set full_duplex_enabled=1")
|
||||||
|
|
||||||
|
if rxIdx < 0 {
|
||||||
|
// A radio with no slice at all. One is created; the status that comes
|
||||||
|
// back adopts it as the downlink.
|
||||||
|
f.satCreate("rx", 145.900, "USB")
|
||||||
|
}
|
||||||
|
if txIdx < 0 {
|
||||||
|
f.satCreate("tx", 435.100, "USB")
|
||||||
|
} else {
|
||||||
|
f.send(fmt.Sprintf("slice s %d tx=1", txIdx))
|
||||||
|
}
|
||||||
|
applog.Printf("flex: satellite armed (rx slice %d, tx slice %d)", rxIdx, txIdx)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Flex) satDisarm() error {
|
||||||
|
f.mu.Lock()
|
||||||
|
rx, tx, created := f.satRX, f.satTX, f.satCreatedTX
|
||||||
|
f.satOn, f.satRX, f.satTX, f.satCreatedTX = false, -1, -1, false
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
f.send("radio set full_duplex_enabled=0")
|
||||||
|
if created && tx >= 0 {
|
||||||
|
f.send(fmt.Sprintf("slice remove %d", tx))
|
||||||
|
}
|
||||||
|
// Transmit goes back where the operator is listening. A radio left
|
||||||
|
// transmitting on a slice that no longer exists — or on the uplink band with
|
||||||
|
// the satellite gone — is not somewhere anyone should be handed back.
|
||||||
|
if rx >= 0 {
|
||||||
|
f.send(fmt.Sprintf("slice s %d tx=1", rx))
|
||||||
|
}
|
||||||
|
applog.Printf("flex: satellite disarmed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// satCreate asks for a slice and remembers what it is for; the index arrives in
|
||||||
|
// the reply (see the R-line handler), which is where the role is applied.
|
||||||
|
func (f *Flex) satCreate(role string, freqMHz float64, mode string) {
|
||||||
|
seq := f.send(fmt.Sprintf("slice create freq=%.6f mode=%s", freqMHz, mode))
|
||||||
|
if seq <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
if f.pendingSat == nil {
|
||||||
|
f.pendingSat = map[int]string{}
|
||||||
|
}
|
||||||
|
f.pendingSat[seq] = role
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// adoptSatSlice records a freshly created slice in its role. Called from the
|
||||||
|
// reply handler with the index the radio assigned.
|
||||||
|
func (f *Flex) adoptSatSlice(role string, idx int) {
|
||||||
|
f.mu.Lock()
|
||||||
|
switch role {
|
||||||
|
case "rx":
|
||||||
|
f.satRX = idx
|
||||||
|
case "tx":
|
||||||
|
f.satTX = idx
|
||||||
|
f.satCreatedTX = true
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
if role == "tx" {
|
||||||
|
f.send(fmt.Sprintf("slice s %d tx=1", idx))
|
||||||
|
}
|
||||||
|
applog.Printf("flex: satellite %s slice is %d", role, idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TuneSatellite moves the two slices.
|
||||||
|
func (f *Flex) TuneSatellite(downHz, upHz int64, downMode, upMode string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
rx, tx := f.satRX, f.satTX
|
||||||
|
connected := f.conn != nil
|
||||||
|
if rx >= 0 && f.slices[rx] != nil && downHz > 0 {
|
||||||
|
f.slices[rx].freqHz = downHz // optimistic, as SetFrequency is
|
||||||
|
}
|
||||||
|
if tx >= 0 && f.slices[tx] != nil && upHz > 0 {
|
||||||
|
f.slices[tx].freqHz = upHz
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
if rx < 0 {
|
||||||
|
// The slice was asked for and its index has not come back yet. Nothing is
|
||||||
|
// wrong — the next Doppler step, a second later, will find it.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if downHz > 0 {
|
||||||
|
f.send(fmt.Sprintf("slice t %d %.6f", rx, float64(downHz)/1e6))
|
||||||
|
f.satMode(rx, downMode, downHz)
|
||||||
|
}
|
||||||
|
if tx >= 0 && upHz > 0 {
|
||||||
|
f.send(fmt.Sprintf("slice t %d %.6f", tx, float64(upHz)/1e6))
|
||||||
|
f.satMode(tx, upMode, upHz)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// satMode sets a slice's mode only when it is not already there. A mode command
|
||||||
|
// on every Doppler step is a command a second per slice for a whole pass, and
|
||||||
|
// SmartSDR redraws the filter each time.
|
||||||
|
func (f *Flex) satMode(idx int, mode string, freqHz int64) {
|
||||||
|
mode = strings.TrimSpace(mode)
|
||||||
|
if mode == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// USB on both sides above 30 MHz, which is every satellite worth the name —
|
||||||
|
// including the parts of a passband that would be an LSB band down on HF.
|
||||||
|
if strings.EqualFold(mode, "SSB") && freqHz > 30_000_000 {
|
||||||
|
mode = "USB"
|
||||||
|
}
|
||||||
|
fm := adifModeToFlex(mode, freqHz)
|
||||||
|
if fm == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
s := f.slices[idx]
|
||||||
|
same := s != nil && strings.EqualFold(s.mode, fm)
|
||||||
|
if s != nil {
|
||||||
|
s.mode = fm
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
if same {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatReceiveHz is where the downlink slice sits.
|
||||||
|
//
|
||||||
|
// From the cache, not from a read: SmartSDR pushes every slice change as it
|
||||||
|
// happens, so the cached value is what the radio said, and there is no round
|
||||||
|
// trip to pay for once a second.
|
||||||
|
func (f *Flex) SatReceiveHz() (int64, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.satRX < 0 {
|
||||||
|
return 0, fmt.Errorf("flex: no downlink slice")
|
||||||
|
}
|
||||||
|
s := f.slices[f.satRX]
|
||||||
|
if s == nil || !s.inUse {
|
||||||
|
return 0, fmt.Errorf("flex: the downlink slice has gone")
|
||||||
|
}
|
||||||
|
return s.freqHz, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/cat/civ"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Satellite operation on an Icom.
|
||||||
|
//
|
||||||
|
// Two rigs in the range have a satellite mode of their own — the IC-9700 and
|
||||||
|
// the IC-9100 — and on those the right thing to do is ask the radio for it
|
||||||
|
// rather than build an imitation out of split. Their satellite mode pairs the
|
||||||
|
// MAIN band (the downlink) with the SUB band (the uplink), gives full duplex,
|
||||||
|
// and keeps the two dials linked the way the designers meant. Every other Icom
|
||||||
|
// has one receiver on one band: it can be tuned to the downlink, and that is
|
||||||
|
// the whole truth about what it can do on a cross-band satellite.
|
||||||
|
//
|
||||||
|
// UNTESTED ON HARDWARE. Built from the IC-9700 CI-V reference: 0x16 0x5A arms
|
||||||
|
// satellite mode, 0x07 0xD0 / 0xD1 select MAIN and SUB, and once a band is
|
||||||
|
// selected the ordinary 0x05 / 0x06 tune it. If an IC-9700 owner reports it
|
||||||
|
// misbehaving, the log lines below name every frame sent.
|
||||||
|
|
||||||
|
// ErrSatUplinkUnreachable says the downlink was tuned and the uplink was not,
|
||||||
|
// because the radio has no second receiver and the two are on different bands.
|
||||||
|
//
|
||||||
|
// A distinct error rather than a silent half-success: a tracker that quietly
|
||||||
|
// stops transmitting where the operator expects it to is worse than one that
|
||||||
|
// says it cannot. The caller reports it once, not once per Doppler step.
|
||||||
|
var ErrSatUplinkUnreachable = errors.New("cat: this radio has one receiver — the uplink is on another band and cannot be set")
|
||||||
|
|
||||||
|
// SetSatellite arms the rig's own satellite mode.
|
||||||
|
func (b *IcomSerial) SetSatellite(on bool) error {
|
||||||
|
if !b.satNative {
|
||||||
|
// Nothing to arm and nothing to break: the tuning path below does what
|
||||||
|
// this radio can do without any mode change. Refusing here would deny an
|
||||||
|
// operator the downlink, which is most of the value on a receive-heavy
|
||||||
|
// pass.
|
||||||
|
b.satOn = on
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := b.exec(civ.CmdSwitch, civ.SubSwSatellite, boolByte(on)); err != nil {
|
||||||
|
return fmt.Errorf("icom: satellite mode %v refused: %w", on, err)
|
||||||
|
}
|
||||||
|
b.satOn = on
|
||||||
|
applog.Printf("icom: satellite mode %v (%s)", on, b.model)
|
||||||
|
if on {
|
||||||
|
// Leave the radio pointing at MAIN. Everything else in OpsLog — the poll
|
||||||
|
// loop, the logged frequency, the operator's dial — reads the selected
|
||||||
|
// band, and on a satellite the band worth reading is the one carrying the
|
||||||
|
// downlink.
|
||||||
|
_ = b.exec(civ.CmdVFO, civ.SubVFOMain)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TuneSatellite puts the receiver on downHz and the transmitter on upHz.
|
||||||
|
func (b *IcomSerial) TuneSatellite(downHz, upHz int64, downMode, upMode string) error {
|
||||||
|
if downHz <= 0 {
|
||||||
|
return fmt.Errorf("icom: no downlink frequency")
|
||||||
|
}
|
||||||
|
if !b.satNative {
|
||||||
|
return b.tuneSatSingleBand(downHz, upHz, downMode, upMode)
|
||||||
|
}
|
||||||
|
// MAIN — the downlink.
|
||||||
|
if err := b.exec(civ.CmdVFO, civ.SubVFOMain); err != nil {
|
||||||
|
return fmt.Errorf("icom: could not select the main band: %w", err)
|
||||||
|
}
|
||||||
|
if err := b.SetFrequency(downHz); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := b.satSetMode(downMode, downHz); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// SUB — the uplink.
|
||||||
|
if upHz > 0 {
|
||||||
|
if err := b.exec(civ.CmdVFO, civ.SubVFOSub); err != nil {
|
||||||
|
return fmt.Errorf("icom: could not select the sub band: %w", err)
|
||||||
|
}
|
||||||
|
uerr := b.execIdempotent(fmt.Sprintf("set uplink %d Hz", upHz),
|
||||||
|
append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(upHz)...)...)
|
||||||
|
merr := b.satSetMode(upMode, upHz)
|
||||||
|
// Back to MAIN whatever happened. A rig left pointing at SUB reports the
|
||||||
|
// uplink as its frequency, and every band-dependent thing in OpsLog —
|
||||||
|
// the log, the antenna, the amplifier — would follow the transmitter
|
||||||
|
// onto the wrong band.
|
||||||
|
if err := b.exec(civ.CmdVFO, civ.SubVFOMain); err != nil {
|
||||||
|
applog.Printf("icom: could not return to the main band: %v", err)
|
||||||
|
}
|
||||||
|
if uerr != nil {
|
||||||
|
return uerr
|
||||||
|
}
|
||||||
|
if merr != nil {
|
||||||
|
return merr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// satSetMode sets the mode of whichever band is currently selected. An empty
|
||||||
|
// mode leaves it alone — a linear transponder is worked in one mode for a whole
|
||||||
|
// pass, and re-sending it every second is traffic for nothing.
|
||||||
|
func (b *IcomSerial) satSetMode(mode string, freqHz int64) error {
|
||||||
|
mode = strings.TrimSpace(mode)
|
||||||
|
if mode == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// modeCode resolves "SSB" against the CURRENT dial to pick a sideband, which
|
||||||
|
// is wrong here twice over: the dial may still be on the other band, and on
|
||||||
|
// satellites USB is the convention on both sides whatever the frequency.
|
||||||
|
code, data, err := b.modeCode(satSideband(mode))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return b.setModeBytes(mode, code, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// satSideband is the sideband convention above 30 MHz: USB, on both the uplink
|
||||||
|
// and the downlink, including the parts of a linear transponder that fall in
|
||||||
|
// what would be an LSB band on HF. The exceptions — AO-7's mode A downlink on
|
||||||
|
// 29 MHz among them — are still USB by convention, so there is no exception to
|
||||||
|
// make.
|
||||||
|
func satSideband(mode string) string {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(mode), "SSB") {
|
||||||
|
return "USB"
|
||||||
|
}
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatReceiveHz is where the receiver is now.
|
||||||
|
func (b *IcomSerial) SatReceiveHz() (int64, error) {
|
||||||
|
if b.satNative {
|
||||||
|
// The selected band is MAIN — see TuneSatellite, which always returns to
|
||||||
|
// it — so the ordinary frequency read is the downlink.
|
||||||
|
if err := b.exec(civ.CmdVFO, civ.SubVFOMain); err != nil {
|
||||||
|
applog.Printf("icom: sat readback could not select main: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.readFreq()
|
||||||
|
}
|
||||||
|
|
||||||
|
// tuneSatSingleBand is every other Icom: one receiver, one band.
|
||||||
|
//
|
||||||
|
// The downlink is set, because that is what the operator is listening to. The
|
||||||
|
// uplink is set through split only when it is close enough to be on the same
|
||||||
|
// band — QO-100 behind transverters, AO-7's mode A — and otherwise reported as
|
||||||
|
// out of reach rather than quietly skipped.
|
||||||
|
func (b *IcomSerial) tuneSatSingleBand(downHz, upHz int64, downMode, _ string) error {
|
||||||
|
if err := b.SetFrequency(downHz); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := b.satSetMode(downMode, downHz); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if upHz <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// One megahertz apart is the working definition of "the same band" here: it
|
||||||
|
// covers a transponder's own passband and any sensible transverter pairing,
|
||||||
|
// and excludes every real cross-band satellite (145 / 435 MHz).
|
||||||
|
if abs64(upHz-downHz) > 1_000_000 {
|
||||||
|
return ErrSatUplinkUnreachable
|
||||||
|
}
|
||||||
|
if err := b.exec(append([]byte{civ.CmdVfoFreq, civ.SubVfoUnselected}, civ.FreqToBCD(upHz)...)...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !b.satOn {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return b.exec(civ.CmdSplit, boolByte(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
func abs64(v int64) int64 {
|
||||||
|
if v < 0 {
|
||||||
|
return -v
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -94,6 +94,13 @@ type IcomSerial struct {
|
|||||||
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read
|
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read
|
||||||
// via ScopeData from the binding goroutine).
|
// via ScopeData from the binding goroutine).
|
||||||
dualScope bool
|
dualScope bool
|
||||||
|
// satNative marks the two-band satellite rigs — the IC-9700 and the IC-9100 —
|
||||||
|
// which have a real satellite mode of their own. Everything else gets the
|
||||||
|
// downlink and, where the uplink is reachable, split.
|
||||||
|
satNative bool
|
||||||
|
// satOn tracks what we last told the rig, so TuneSatellite can arm the mode
|
||||||
|
// once rather than on every Doppler step.
|
||||||
|
satOn bool
|
||||||
// Set when the rig rejects the waveform-output command in both shapes: it has
|
// Set when the rig rejects the waveform-output command in both shapes: it has
|
||||||
// no stream to give, and asking again on every enable is noise.
|
// no stream to give, and asking again on every enable is noise.
|
||||||
scopeUnsupported bool
|
scopeUnsupported bool
|
||||||
@@ -284,6 +291,10 @@ func (b *IcomSerial) Connect() error {
|
|||||||
// non-default address still RENDERS; this flag only drives the SET/read commands
|
// non-default address still RENDERS; this flag only drives the SET/read commands
|
||||||
// (mode, span, edges), which need the 0x00 selector to be accepted on the 7300.
|
// (mode, span, edges), which need the 0x00 selector to be accepted on the 7300.
|
||||||
b.dualScope = idAddr == 0x98 || idAddr == 0xA2 || idAddr == 0x94
|
b.dualScope = idAddr == 0x98 || idAddr == 0xA2 || idAddr == 0x94
|
||||||
|
// The satellite rigs: IC-9700 and IC-9100. Both carry two receivers on two
|
||||||
|
// bands and a satellite mode that pairs them; no other Icom in this table
|
||||||
|
// does, and asking one that does not is a rejected frame per Doppler step.
|
||||||
|
b.satNative = idAddr == 0xA2 || idAddr == 0x7C
|
||||||
// Silence any LEFTOVER waveform stream, BLIND, before anything else. The
|
// Silence any LEFTOVER waveform stream, BLIND, before anything else. The
|
||||||
// 0x27 output flag lives in the RADIO and survives sessions; its flood is
|
// 0x27 output flag lives in the RADIO and survives sessions; its flood is
|
||||||
// what makes the IC-7760 stop answering CI-V — so waiting for CI-V to
|
// what makes the IC-7760 stop answering CI-V — so waiting for CI-V to
|
||||||
|
|||||||
+79
-5
@@ -59,6 +59,13 @@ var yaesuModels = map[string]string{
|
|||||||
"0650": "FT-891",
|
"0650": "FT-891",
|
||||||
"0670": "FT-DX3000",
|
"0670": "FT-DX3000",
|
||||||
"0460": "FT-450D",
|
"0460": "FT-450D",
|
||||||
|
// The eight-digit family. Named for the console; the frequency format is
|
||||||
|
// learned from the rig either way (see learnFreqWidth).
|
||||||
|
"0251": "FT-2000",
|
||||||
|
"0310": "FT-950",
|
||||||
|
"0583": "FTDX1200",
|
||||||
|
"0462": "FTDX3000",
|
||||||
|
"0101": "FTDX5000",
|
||||||
}
|
}
|
||||||
|
|
||||||
// yaesuModeToADIF maps the MD digit to an ADIF mode. The DATA and RTTY variants
|
// yaesuModeToADIF maps the MD digit to an ADIF mode. The DATA and RTTY variants
|
||||||
@@ -100,6 +107,11 @@ type Yaesu struct {
|
|||||||
// rxVFOCmd is "FR" when the rig reports its receive VFO that way, else empty
|
// rxVFOCmd is "FR" when the rig reports its receive VFO that way, else empty
|
||||||
// and VS is used — see ReadState.
|
// and VS is used — see ReadState.
|
||||||
rxVFOCmd string
|
rxVFOCmd string
|
||||||
|
// freqDigits is how many digits this rig writes a frequency in, LEARNED from
|
||||||
|
// its own replies. See learnFreqWidth.
|
||||||
|
freqDigits int
|
||||||
|
// rttyUpper picks RTTY-U over RTTY-L — see SetRTTYUpper.
|
||||||
|
rttyUpper bool
|
||||||
|
|
||||||
curFreq int64
|
curFreq int64
|
||||||
curRXFreq int64
|
curRXFreq int64
|
||||||
@@ -130,6 +142,18 @@ func NewYaesu(portName string, baud int, digital string) *Yaesu {
|
|||||||
return &Yaesu{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
|
return &Yaesu{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRTTYUpper chooses which sideband RTTY is set on.
|
||||||
|
//
|
||||||
|
// Yaesu has both — MD06 is RTTY-L, MD09 is RTTY-U — and ADIF has neither: it
|
||||||
|
// says "RTTY" and stops there, so the rig cannot be driven from the logged mode
|
||||||
|
// alone. LSB is the older convention and stays the default; an operator whose
|
||||||
|
// FSK controller or decoder wants the other one says so once here.
|
||||||
|
func (y *Yaesu) SetRTTYUpper(v bool) {
|
||||||
|
y.mu.Lock()
|
||||||
|
defer y.mu.Unlock()
|
||||||
|
y.rttyUpper = v
|
||||||
|
}
|
||||||
|
|
||||||
// SetLowerLines chooses whether DTR and RTS are deasserted on connect. Set
|
// SetLowerLines chooses whether DTR and RTS are deasserted on connect. Set
|
||||||
// before Connect.
|
// before Connect.
|
||||||
func (y *Yaesu) SetLowerLines(v bool) {
|
func (y *Yaesu) SetLowerLines(v bool) {
|
||||||
@@ -275,6 +299,7 @@ func (y *Yaesu) ReadState() (RigState, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return RigState{}, err // the rig stopped answering — let the Manager reconnect
|
return RigState{}, err // the rig stopped answering — let the Manager reconnect
|
||||||
}
|
}
|
||||||
|
y.learnFreqWidth(faRaw, "FA")
|
||||||
freqA, ok := parseYaesuFreq(faRaw, "FA")
|
freqA, ok := parseYaesuFreq(faRaw, "FA")
|
||||||
if !ok {
|
if !ok {
|
||||||
return RigState{}, fmt.Errorf("yaesu: unparsable FA reply %q", faRaw)
|
return RigState{}, fmt.Errorf("yaesu: unparsable FA reply %q", faRaw)
|
||||||
@@ -355,7 +380,7 @@ func (y *Yaesu) SetFrequency(hz int64) error {
|
|||||||
if y.curVFO == "B" {
|
if y.curVFO == "B" {
|
||||||
cmd = "FB"
|
cmd = "FB"
|
||||||
}
|
}
|
||||||
return y.write(fmt.Sprintf("%s%09d;", cmd, hz))
|
return y.write(fmt.Sprintf("%s%0*d;", cmd, y.freqWidth(), hz))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (y *Yaesu) SetMode(mode string) error {
|
func (y *Yaesu) SetMode(mode string) error {
|
||||||
@@ -364,7 +389,7 @@ func (y *Yaesu) SetMode(mode string) error {
|
|||||||
if y.port == nil {
|
if y.port == nil {
|
||||||
return fmt.Errorf("yaesu: not connected")
|
return fmt.Errorf("yaesu: not connected")
|
||||||
}
|
}
|
||||||
d := yaesuModeDigit(mode, y.curFreq)
|
d := yaesuModeDigit(mode, y.curFreq, y.rttyUpper)
|
||||||
if d == 0 {
|
if d == 0 {
|
||||||
return fmt.Errorf("yaesu: no CAT mode for %q", mode)
|
return fmt.Errorf("yaesu: no CAT mode for %q", mode)
|
||||||
}
|
}
|
||||||
@@ -481,7 +506,53 @@ func cmdPrefix(cmd string) string {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseYaesuFreq reads "FA014074000;" into Hz.
|
// EIGHT DIGITS OR NINE — the rig says which, and it is not a matter of taste.
|
||||||
|
//
|
||||||
|
// The FTDX10, FT-991A, FT-891 and FT-710 write a frequency in nine digits;
|
||||||
|
// everything before them — FTDX3000, FTDX5000, FTDX1200, FT-2000, FT-950,
|
||||||
|
// FT-450 — writes eight, and answers a nine-digit SET with "?;". An operator
|
||||||
|
// with an FTDX3000 saw exactly that: every FA command rejected, a radio that
|
||||||
|
// would not follow, and nothing to say why.
|
||||||
|
//
|
||||||
|
// The width is LEARNED rather than tabulated: the rig announces it in every
|
||||||
|
// reply to "FA;", so the answer comes from the radio in front of the operator
|
||||||
|
// instead of from a list of models that will always be one release behind. Nine
|
||||||
|
// until the first reply lands, which is what the modern rigs use and what this
|
||||||
|
// backend was written against.
|
||||||
|
const yaesuFreqDigitsDefault = 9
|
||||||
|
|
||||||
|
func (y *Yaesu) freqWidth() int {
|
||||||
|
if y.freqDigits >= 8 && y.freqDigits <= 11 {
|
||||||
|
return y.freqDigits
|
||||||
|
}
|
||||||
|
return yaesuFreqDigitsDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
// learnFreqWidth takes the width from a frequency reply. Only a reply that
|
||||||
|
// parses as a frequency teaches anything — a "?;" or a stray frame says nothing
|
||||||
|
// about the format, and a width learned from one would be worse than the
|
||||||
|
// default.
|
||||||
|
func (y *Yaesu) learnFreqWidth(reply, prefix string) {
|
||||||
|
r := strings.TrimSpace(reply)
|
||||||
|
if !strings.HasPrefix(r, prefix) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
digits := strings.TrimSuffix(strings.TrimPrefix(r, prefix), ";")
|
||||||
|
if len(digits) < 8 || len(digits) > 11 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, c := range digits {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if y.freqDigits != len(digits) {
|
||||||
|
debugLog.Printf("yaesu: this rig writes frequencies in %d digits — commands will match", len(digits))
|
||||||
|
y.freqDigits = len(digits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseYaesuFreq reads "FA014074000;" (or "FA14074000;") into Hz.
|
||||||
func parseYaesuFreq(reply, prefix string) (int64, bool) {
|
func parseYaesuFreq(reply, prefix string) (int64, bool) {
|
||||||
r := strings.TrimSpace(reply)
|
r := strings.TrimSpace(reply)
|
||||||
if !strings.HasPrefix(r, prefix) {
|
if !strings.HasPrefix(r, prefix) {
|
||||||
@@ -551,7 +622,7 @@ func resolveYaesuVFOs(freqA, freqB int64, vfo string, split bool) (tx, rx int64,
|
|||||||
// yaesuModeDigit maps an ADIF mode to the MD digit. SSB has no single digit —
|
// yaesuModeDigit maps an ADIF mode to the MD digit. SSB has no single digit —
|
||||||
// the sideband follows the worldwide convention (LSB below 10 MHz, USB above),
|
// the sideband follows the worldwide convention (LSB below 10 MHz, USB above),
|
||||||
// which is why the current frequency is part of the decision.
|
// which is why the current frequency is part of the decision.
|
||||||
func yaesuModeDigit(mode string, freqHz int64) byte {
|
func yaesuModeDigit(mode string, freqHz int64, rttyUpper bool) byte {
|
||||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
case "SSB":
|
case "SSB":
|
||||||
if freqHz > 0 && freqHz < 10_000_000 {
|
if freqHz > 0 && freqHz < 10_000_000 {
|
||||||
@@ -569,7 +640,10 @@ func yaesuModeDigit(mode string, freqHz int64) byte {
|
|||||||
case "AM":
|
case "AM":
|
||||||
return '5'
|
return '5'
|
||||||
case "RTTY":
|
case "RTTY":
|
||||||
return '6'
|
if rttyUpper {
|
||||||
|
return '9' // RTTY-U
|
||||||
|
}
|
||||||
|
return '6' // RTTY-L, the older convention
|
||||||
case "":
|
case "":
|
||||||
return 0
|
return 0
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The FTDX10 family writes a frequency in nine digits; everything before it —
|
||||||
|
// FTDX3000, FTDX5000, FTDX1200, FT-2000, FT-950, FT-450 — writes eight and
|
||||||
|
// answers a nine-digit SET with "?;". Reported from an FTDX3000: every FA
|
||||||
|
// command rejected, a radio that would not follow.
|
||||||
|
//
|
||||||
|
// The width is taken from the rig's own reply, so a model this backend has
|
||||||
|
// never heard of is right on the first read.
|
||||||
|
func TestYaesuFrequencyWidthIsLearnedFromTheRig(t *testing.T) {
|
||||||
|
y := &Yaesu{}
|
||||||
|
if got := y.freqWidth(); got != 9 {
|
||||||
|
t.Errorf("before any reply the width is %d, want the modern 9", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
y.learnFreqWidth("FA14074000;", "FA") // an FTDX3000
|
||||||
|
if got := y.freqWidth(); got != 8 {
|
||||||
|
t.Errorf("width %d after an eight-digit reply, want 8", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
y.learnFreqWidth("FA014074000;", "FA") // and an FTDX10 on the next session
|
||||||
|
if got := y.freqWidth(); got != 9 {
|
||||||
|
t.Errorf("width %d after a nine-digit reply, want 9", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing that is not a frequency teaches anything: a rejection, a stray
|
||||||
|
// frame or a reply from another command would otherwise set the format for
|
||||||
|
// every command that follows.
|
||||||
|
for _, junk := range []string{"?;", "FA;", "FB014074000;", "FA1407400X;", "FA1234567;", "FA123456789012;"} {
|
||||||
|
before := y.freqWidth()
|
||||||
|
y.learnFreqWidth(junk, "FA")
|
||||||
|
if after := y.freqWidth(); after != before {
|
||||||
|
t.Errorf("%q changed the width from %d to %d", junk, before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,6 +84,11 @@ type YaesuTXState struct {
|
|||||||
// same shape as FlexController and IcomController.
|
// same shape as FlexController and IcomController.
|
||||||
type YaesuController interface {
|
type YaesuController interface {
|
||||||
YaesuState() YaesuTXState
|
YaesuState() YaesuTXState
|
||||||
|
// SetRTTYUpper is a preference, not a command — see Yaesu.SetRTTYUpper. It
|
||||||
|
// belongs here so a change of mind reaches the RUNNING rig: the link is not
|
||||||
|
// rebuilt for it, and until it was reachable this way the setting only took
|
||||||
|
// effect on the next launch.
|
||||||
|
SetRTTYUpper(bool)
|
||||||
RefreshYaesu() error
|
RefreshYaesu() error
|
||||||
SetYaesuPower(int) error
|
SetYaesuPower(int) error
|
||||||
SetYaesuMicGain(int) error
|
SetYaesuMicGain(int) error
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ func TestYaesuModeDigit(t *testing.T) {
|
|||||||
{"", 14074000, 0}, // nothing to set
|
{"", 14074000, 0}, // nothing to set
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
if got := yaesuModeDigit(c.mode, c.hz); got != c.want {
|
if got := yaesuModeDigit(c.mode, c.hz, false); got != c.want {
|
||||||
t.Errorf("yaesuModeDigit(%q, %d) = %q, want %q", c.mode, c.hz, got, c.want)
|
t.Errorf("yaesuModeDigit(%q, %d) = %q, want %q", c.mode, c.hz, got, c.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -406,3 +406,38 @@ func TestYaesuAntennaCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ADIF says "RTTY" and stops there, but Yaesu has both sidebands and the rig
|
||||||
|
// has to be told one. LSB is the older convention and the default; the other is
|
||||||
|
// a station's own choice, made once.
|
||||||
|
func TestYaesuRTTYSideband(t *testing.T) {
|
||||||
|
if got := yaesuModeDigit("RTTY", 14_080_000, false); got != '6' {
|
||||||
|
t.Errorf("RTTY = %q, want RTTY-L", got)
|
||||||
|
}
|
||||||
|
if got := yaesuModeDigit("RTTY", 14_080_000, true); got != '9' {
|
||||||
|
t.Errorf("RTTY (upper) = %q, want RTTY-U", got)
|
||||||
|
}
|
||||||
|
// The switch is about RTTY and nothing else.
|
||||||
|
if got := yaesuModeDigit("FT8", 28_074_000, true); got != 'C' {
|
||||||
|
t.Errorf("FT8 = %q, want DATA-U", got)
|
||||||
|
}
|
||||||
|
if got := yaesuModeDigit("CW", 14_030_000, true); got != '3' {
|
||||||
|
t.Errorf("CW = %q, want CW-U", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The RTTY sideband is a preference the LINK does not depend on, so it is not
|
||||||
|
// in catLinkSig and the link is not rebuilt for it — which means the running
|
||||||
|
// client has to accept it. It did not, and the setting waited for the next
|
||||||
|
// launch while the rig went on choosing LSB.
|
||||||
|
func TestYaesuAcceptsTheRTTYSidebandWhileConnected(t *testing.T) {
|
||||||
|
var y YaesuController = &Yaesu{}
|
||||||
|
y.SetRTTYUpper(true)
|
||||||
|
if got := y.(*Yaesu).rttyUpper; !got {
|
||||||
|
t.Error("a running Yaesu ignored the RTTY sideband")
|
||||||
|
}
|
||||||
|
y.SetRTTYUpper(false)
|
||||||
|
if got := y.(*Yaesu).rttyUpper; got {
|
||||||
|
t.Error("it could not be turned back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package udp
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Three decoders on one multicast group, which is the ordinary setup. MSHV is
|
||||||
|
// working a station; WSJT-X and JTDX are idle and say so once a second each.
|
||||||
|
//
|
||||||
|
// Read across the listener rather than per program, every one of those idle
|
||||||
|
// Status packets was a "the operator cleared the DX Call" — so OpsLog emptied
|
||||||
|
// the entry field, MSHV's next Status refilled it, and the entry blinked and
|
||||||
|
// the map zoomed at 1 Hz for as long as all three were running.
|
||||||
|
func TestDXClearIsPerProgram(t *testing.T) {
|
||||||
|
s := &Server{}
|
||||||
|
|
||||||
|
if s.noteDXCall("MSHV", "F5NNN") {
|
||||||
|
t.Fatal("taking up a station is not a clear")
|
||||||
|
}
|
||||||
|
// The idle ones, interleaved, as they arrive on the wire.
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if s.noteDXCall("WSJT-X", "") {
|
||||||
|
t.Fatal("an idle WSJT-X was read as MSHV clearing its call")
|
||||||
|
}
|
||||||
|
if s.noteDXCall("JTDX", "") {
|
||||||
|
t.Fatal("an idle JTDX was read as MSHV clearing its call")
|
||||||
|
}
|
||||||
|
if s.noteDXCall("MSHV", "F5NNN") {
|
||||||
|
t.Fatal("MSHV repeating the same station is not a clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MSHV's own clear is still an edge, and only once: the Status that follows
|
||||||
|
// is just as empty and must not re-clear a field the operator may have
|
||||||
|
// typed into since.
|
||||||
|
if !s.noteDXCall("MSHV", "") {
|
||||||
|
t.Error("MSHV clearing its own DX Call was not reported")
|
||||||
|
}
|
||||||
|
if s.noteDXCall("MSHV", "") {
|
||||||
|
t.Error("the clear repeated on the next identical Status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each program's edge is its own: WSJT-X letting go says nothing about MSHV.
|
||||||
|
func TestDXClearOfOneProgramLeavesTheOthers(t *testing.T) {
|
||||||
|
s := &Server{}
|
||||||
|
s.noteDXCall("MSHV", "F5NNN")
|
||||||
|
s.noteDXCall("WSJT-X", "DL1ABC")
|
||||||
|
|
||||||
|
if !s.noteDXCall("WSJT-X", "") {
|
||||||
|
t.Error("WSJT-X clearing its own call should be reported")
|
||||||
|
}
|
||||||
|
if s.noteDXCall("MSHV", "F5NNN") {
|
||||||
|
t.Error("MSHV's unchanged call was disturbed by WSJT-X's clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -235,7 +235,16 @@ type Server struct {
|
|||||||
// lastMode is the mode NAME from each program's last Status, used to resolve
|
// lastMode is the mode NAME from each program's last Status, used to resolve
|
||||||
// a Decode's one-character mode marker.
|
// a Decode's one-character mode marker.
|
||||||
lastMode map[string]string
|
lastMode map[string]string
|
||||||
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
|
// lastDX is each program's last DX Call, to spot the moment it is cleared.
|
||||||
|
//
|
||||||
|
// PER PROGRAM, and that is the whole point of the map. Two or three decoders
|
||||||
|
// commonly share one listener — the multicast group on 2237 is the usual
|
||||||
|
// setup — and a single value meant WSJT-X's empty DX Call was read as MSHV
|
||||||
|
// clearing the station it was calling. One "cleared" per second, alternating
|
||||||
|
// with MSHV re-announcing the call: the entry field emptied and refilled at
|
||||||
|
// 1 Hz and the map zoomed in and out with it. "The operator cleared the DX
|
||||||
|
// call" is a statement about ONE program, never about a socket.
|
||||||
|
lastDX map[string]string
|
||||||
|
|
||||||
// badPkts counts datagrams this listener could not parse, so the diagnostic
|
// badPkts counts datagrams this listener could not parse, so the diagnostic
|
||||||
// dump below stays bounded. A misconfigured port is not a one-off: the
|
// dump below stays bounded. A misconfigured port is not a one-off: the
|
||||||
@@ -403,6 +412,25 @@ func (s *Server) run() {
|
|||||||
// radios on different bands. The port is included — a program keeps its socket
|
// radios on different bands. The port is included — a program keeps its socket
|
||||||
// for as long as it runs, which is exactly the lifetime this has to be stable
|
// for as long as it runs, which is exactly the lifetime this has to be stable
|
||||||
// over.
|
// over.
|
||||||
|
// noteDXCall records a program's current DX Call and reports whether THIS
|
||||||
|
// program has just cleared one.
|
||||||
|
//
|
||||||
|
// A decoder sends Status every second whether anything changed or not, so the
|
||||||
|
// clear is an edge — a call, then none — and it is an edge in ONE program's
|
||||||
|
// stream. Several decoders commonly share a listener, and reading the edge
|
||||||
|
// across all of them made an idle WSJT-X look like MSHV abandoning the station
|
||||||
|
// it was calling, once a second, for as long as both were running.
|
||||||
|
func (s *Server) noteDXCall(inst, dx string) (cleared bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.lastDX == nil {
|
||||||
|
s.lastDX = map[string]string{}
|
||||||
|
}
|
||||||
|
prev := s.lastDX[inst]
|
||||||
|
s.lastDX[inst] = dx
|
||||||
|
return dx == "" && prev != ""
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) instanceLabel(id string, remote *net.UDPAddr) string {
|
func (s *Server) instanceLabel(id string, remote *net.UDPAddr) string {
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return ""
|
return ""
|
||||||
@@ -623,12 +651,9 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
// operator cleared it in WSJT-X / JTDX / MSHV. Fire ONE clear (tracked per
|
// operator cleared it in WSJT-X / JTDX / MSHV. Fire ONE clear (tracked per
|
||||||
// server) — an idle app sends empty Status every second, and we must not
|
// server) — an idle app sends empty Status every second, and we must not
|
||||||
// re-clear (which would fight a manual entry) on each of those.
|
// re-clear (which would fight a manual entry) on each of those.
|
||||||
s.mu.Lock()
|
if s.noteDXCall(inst, w.DXCall) {
|
||||||
prev := s.lastDX
|
|
||||||
s.lastDX = w.DXCall
|
|
||||||
s.mu.Unlock()
|
|
||||||
if w.DXCall == "" && prev != "" {
|
|
||||||
ev.ClearCall = true
|
ev.ClearCall = true
|
||||||
|
ev.ProgramID = inst // whose clear it is — the app filters on it
|
||||||
}
|
}
|
||||||
case ServiceADIF:
|
case ServiceADIF:
|
||||||
// JTAlert / GridTracker forward a text ADIF record after a QSO is
|
// JTAlert / GridTracker forward a text ADIF record after a QSO is
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
// Package easycomm drives azimuth/elevation rotator controllers that speak
|
||||||
|
// EasyComm II, over a raw TCP socket or a serial port.
|
||||||
|
//
|
||||||
|
// EasyComm is what satellite rotator controllers agreed on: SatPC32, Gpredict
|
||||||
|
// and Hamlib all speak it, so a controller that works with any of those works
|
||||||
|
// here. The dialect matters less than it looks — every command is a two-letter
|
||||||
|
// name with a number stuck to it, on one line, and a controller that does not
|
||||||
|
// recognise one ignores it.
|
||||||
|
//
|
||||||
|
// The subset used:
|
||||||
|
//
|
||||||
|
// AZ123.4 EL45.0<LF> point there
|
||||||
|
// AZ EL<LF> ask where it is — the reply is the same shape
|
||||||
|
// SA SE<LF> stop both axes
|
||||||
|
//
|
||||||
|
// Not every controller ANSWERS. A great many EasyComm boxes — the Arduino
|
||||||
|
// trackers above all — accept commands and never say a word back, which is
|
||||||
|
// perfectly legal in EasyComm I and common in II. So a silent controller is not
|
||||||
|
// treated as a broken one: the last commanded position is reported instead, and
|
||||||
|
// the rotator keeps being driven. Refusing to work with a write-only controller
|
||||||
|
// would rule out half the satellite stations in the hobby.
|
||||||
|
package easycomm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dialTimeout = 3 * time.Second
|
||||||
|
ioTimeout = 1500 * time.Millisecond
|
||||||
|
// replyWait is how long a query waits before deciding the controller is one
|
||||||
|
// of the silent ones. Short: this runs once a second inside a pass, and a
|
||||||
|
// controller that is going to answer answers in milliseconds.
|
||||||
|
replyWait = 400 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is one rotator controller. Exactly one of (Host, Port) or ComPort is
|
||||||
|
// used.
|
||||||
|
type Client struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
ComPort string
|
||||||
|
Baud int
|
||||||
|
// MaxAz is how far the rotator turns: 360 or 450. A 450° rotator can follow
|
||||||
|
// a pass straight through north without unwinding, which is the difference
|
||||||
|
// between hearing the whole of an overhead pass and losing the middle of it.
|
||||||
|
MaxAz int
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
// lastAz/lastEl are what was last commanded — the answer for a controller
|
||||||
|
// that does not talk back.
|
||||||
|
lastAz, lastEl float64
|
||||||
|
commanded bool
|
||||||
|
// silent latches once a query has gone unanswered. Without it, a write-only
|
||||||
|
// controller costs a 400 ms wait on every single poll of a pass.
|
||||||
|
silent bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a TCP client. There is no standard port; 4533 is Hamlib's rotctld
|
||||||
|
// convention and the usual default in the controllers' own setup screens.
|
||||||
|
func New(host string, port int, maxAz int) *Client {
|
||||||
|
if strings.TrimSpace(host) == "" {
|
||||||
|
host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
if port <= 0 || port > 65535 {
|
||||||
|
port = 4533
|
||||||
|
}
|
||||||
|
return &Client{Host: host, Port: port, MaxAz: normMaxAz(maxAz)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSerial builds a serial client.
|
||||||
|
func NewSerial(comPort string, baud int, maxAz int) *Client {
|
||||||
|
if baud <= 0 {
|
||||||
|
baud = 9600
|
||||||
|
}
|
||||||
|
return &Client{ComPort: comPort, Baud: baud, MaxAz: normMaxAz(maxAz)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normMaxAz(v int) int {
|
||||||
|
if v == 450 {
|
||||||
|
return 450
|
||||||
|
}
|
||||||
|
return 360
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point commands the rotator to an azimuth and elevation.
|
||||||
|
//
|
||||||
|
// The azimuth is given in the rotator's own terms: on a 450° machine an
|
||||||
|
// azimuth past 360 is a real, reachable position, and asking for 010 when the
|
||||||
|
// rotator is sitting at 370 would send it the long way round through the whole
|
||||||
|
// scale — three quarters of a turn, in the middle of a pass, with the antenna
|
||||||
|
// pointing at the ground for most of it.
|
||||||
|
func (c *Client) Point(az, el float64) error {
|
||||||
|
az = c.wrapAz(az)
|
||||||
|
el = clamp(el, 0, 180)
|
||||||
|
if err := c.send(fmt.Sprintf("AZ%.1f EL%.1f", az, el), false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.lastAz, c.lastEl, c.commanded = az, el, true
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop halts both axes.
|
||||||
|
func (c *Client) Stop() error { return c.send("SA SE", false) }
|
||||||
|
|
||||||
|
// Heading is where the rotator says it is.
|
||||||
|
//
|
||||||
|
// live is false when the answer is the last commanded position rather than a
|
||||||
|
// reading — the caller shows that differently, because "where I told it to go"
|
||||||
|
// and "where it is" are not the same claim and a stuck rotator must not be able
|
||||||
|
// to hide behind the first.
|
||||||
|
func (c *Client) Heading() (az, el float64, live bool, err error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
silent, la, le, commanded := c.silent, c.lastAz, c.lastEl, c.commanded
|
||||||
|
c.mu.Unlock()
|
||||||
|
if silent {
|
||||||
|
if !commanded {
|
||||||
|
return 0, 0, false, fmt.Errorf("easycomm: the controller does not report its position")
|
||||||
|
}
|
||||||
|
return la, le, false, nil
|
||||||
|
}
|
||||||
|
line, err := c.query("AZ EL")
|
||||||
|
if err != nil {
|
||||||
|
// One silence is enough: a controller either answers or it does not, and
|
||||||
|
// this runs every second for the length of a pass.
|
||||||
|
c.mu.Lock()
|
||||||
|
c.silent = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
if commanded {
|
||||||
|
return la, le, false, nil
|
||||||
|
}
|
||||||
|
return 0, 0, false, err
|
||||||
|
}
|
||||||
|
a, e, ok := parseHeading(line)
|
||||||
|
if !ok {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.silent = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
if commanded {
|
||||||
|
return la, le, false, nil
|
||||||
|
}
|
||||||
|
return 0, 0, false, fmt.Errorf("easycomm: could not read %q", line)
|
||||||
|
}
|
||||||
|
return a, e, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapAz brings an azimuth into what this rotator can reach.
|
||||||
|
//
|
||||||
|
// On a 360° machine that is a plain modulo. On a 450° one the extra 90° is an
|
||||||
|
// OVERLAP — 370 and 10 are the same direction — and which of the two to use is
|
||||||
|
// decided by whichever is nearer where the rotator already is, so a pass
|
||||||
|
// crossing north continues instead of unwinding.
|
||||||
|
func (c *Client) wrapAz(az float64) float64 {
|
||||||
|
az = math.Mod(az, 360)
|
||||||
|
if az < 0 {
|
||||||
|
az += 360
|
||||||
|
}
|
||||||
|
if c.MaxAz != 450 {
|
||||||
|
return az
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
cur, known := c.lastAz, c.commanded
|
||||||
|
c.mu.Unlock()
|
||||||
|
if !known {
|
||||||
|
return az
|
||||||
|
}
|
||||||
|
alt := az + 360
|
||||||
|
if alt > 450 {
|
||||||
|
return az
|
||||||
|
}
|
||||||
|
if math.Abs(alt-cur) < math.Abs(az-cur) {
|
||||||
|
return alt
|
||||||
|
}
|
||||||
|
return az
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Transport ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type heldPort struct {
|
||||||
|
p serial.Port
|
||||||
|
openedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
portsMu sync.Mutex
|
||||||
|
openPorts = map[string]*heldPort{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// bootSettle: an Arduino-based controller resets when its serial port is
|
||||||
|
// opened, and its bootloader then holds the processor for a second or more. A
|
||||||
|
// command sent into that window is simply lost — which is how a controller that
|
||||||
|
// answers a terminal perfectly reports nothing here.
|
||||||
|
const bootSettle = 2 * time.Second
|
||||||
|
|
||||||
|
func acquire(com string, baud int) (*heldPort, error) {
|
||||||
|
portsMu.Lock()
|
||||||
|
defer portsMu.Unlock()
|
||||||
|
if h, ok := openPorts[com]; ok && h.p != nil {
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
if baud <= 0 {
|
||||||
|
baud = 9600
|
||||||
|
}
|
||||||
|
sp, err := serial.Open(com, &serial.Mode{BaudRate: baud})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open rotator %s @ %d baud: %w", com, baud, err)
|
||||||
|
}
|
||||||
|
_ = sp.SetReadTimeout(150 * time.Millisecond)
|
||||||
|
h := &heldPort{p: sp, openedAt: time.Now()}
|
||||||
|
openPorts[com] = h
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func drop(com string) {
|
||||||
|
portsMu.Lock()
|
||||||
|
defer portsMu.Unlock()
|
||||||
|
if h, ok := openPorts[com]; ok {
|
||||||
|
if h.p != nil {
|
||||||
|
_ = h.p.Close()
|
||||||
|
}
|
||||||
|
delete(openPorts, com)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the serial port. TCP dials per command and holds nothing.
|
||||||
|
func (c *Client) Close() {
|
||||||
|
if c.ComPort != "" {
|
||||||
|
drop(c.ComPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) send(cmd string, wantReply bool) error {
|
||||||
|
_, err := c.exchange(cmd, wantReply)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) query(cmd string) (string, error) { return c.exchange(cmd, true) }
|
||||||
|
|
||||||
|
func (c *Client) exchange(cmd string, wantReply bool) (string, error) {
|
||||||
|
var conn io.ReadWriteCloser
|
||||||
|
if c.ComPort != "" {
|
||||||
|
h, err := acquire(c.ComPort, c.Baud)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if wait := bootSettle - time.Since(h.openedAt); wait > 0 {
|
||||||
|
time.Sleep(wait)
|
||||||
|
}
|
||||||
|
conn = h.p
|
||||||
|
drain(h.p)
|
||||||
|
} else {
|
||||||
|
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("connect rotator %s:%d: %w", c.Host, c.Port, err)
|
||||||
|
}
|
||||||
|
_ = nc.SetDeadline(time.Now().Add(ioTimeout))
|
||||||
|
defer nc.Close()
|
||||||
|
conn = nc
|
||||||
|
}
|
||||||
|
// LF, not CR: EasyComm's own documents use a line feed, and the controllers
|
||||||
|
// that want CR accept either. The reverse is not true of every Arduino
|
||||||
|
// sketch out there.
|
||||||
|
if _, err := conn.Write([]byte(cmd + "\n")); err != nil {
|
||||||
|
if c.ComPort != "" {
|
||||||
|
drop(c.ComPort)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("send %q: %w", cmd, err)
|
||||||
|
}
|
||||||
|
if !wantReply {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
buf := make([]byte, 128)
|
||||||
|
var sb strings.Builder
|
||||||
|
deadline := time.Now().Add(replyWait)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
n, err := conn.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
sb.Write(buf[:n])
|
||||||
|
if strings.ContainsAny(sb.String(), "\r\n") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line := strings.TrimSpace(sb.String())
|
||||||
|
if line == "" {
|
||||||
|
return "", fmt.Errorf("no reply to %q", cmd)
|
||||||
|
}
|
||||||
|
return line, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func drain(sp serial.Port) {
|
||||||
|
buf := make([]byte, 256)
|
||||||
|
for {
|
||||||
|
n, err := sp.Read(buf)
|
||||||
|
if n == 0 || err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHeading reads a controller's answer.
|
||||||
|
//
|
||||||
|
// The shapes in the wild differ more than the specification suggests —
|
||||||
|
// "AZ123.4 EL45.0", "AZ=123.4 EL=45.0", "+123.4+045.0", lower case, tabs — so
|
||||||
|
// this looks for the two labels and takes the number attached to each rather
|
||||||
|
// than trying to match a whole line.
|
||||||
|
func parseHeading(line string) (az, el float64, ok bool) {
|
||||||
|
up := strings.ToUpper(line)
|
||||||
|
az, aok := numberAfter(up, "AZ")
|
||||||
|
el, eok := numberAfter(up, "EL")
|
||||||
|
if !aok {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
// Elevation missing is not a broken reply: an azimuth-only controller
|
||||||
|
// answering an AZ EL query says what it has.
|
||||||
|
if !eok {
|
||||||
|
el = 0
|
||||||
|
}
|
||||||
|
return az, el, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func numberAfter(s, label string) (float64, bool) {
|
||||||
|
i := strings.Index(s, label)
|
||||||
|
if i < 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
rest := strings.TrimLeft(s[i+len(label):], " \t=:")
|
||||||
|
end := 0
|
||||||
|
for end < len(rest) {
|
||||||
|
ch := rest[end]
|
||||||
|
if (ch >= '0' && ch <= '9') || ch == '.' || ((ch == '-' || ch == '+') && end == 0) {
|
||||||
|
end++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if end == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseFloat(strings.TrimSuffix(rest[:end], "."), 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp(v, lo, hi float64) float64 {
|
||||||
|
if v < lo {
|
||||||
|
return lo
|
||||||
|
}
|
||||||
|
if v > hi {
|
||||||
|
return hi
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package easycomm
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Every one of these is a shape a real controller has been seen to answer with.
|
||||||
|
// The point of the parser is that none of them is special-cased.
|
||||||
|
func TestParseHeading(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
line string
|
||||||
|
az, el float64
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"AZ123.4 EL45.0", 123.4, 45, true},
|
||||||
|
{"AZ=123.4 EL=45.0", 123.4, 45, true},
|
||||||
|
{"az 123.4 el 45.0", 123.4, 45, true},
|
||||||
|
{"AZ123.4\tEL45.0\r\n", 123.4, 45, true},
|
||||||
|
{"AZ012.0 EL000.0", 12, 0, true},
|
||||||
|
{"AZ370.5 EL05.5", 370.5, 5.5, true},
|
||||||
|
{"AZ123.4", 123.4, 0, true}, // azimuth-only controller
|
||||||
|
{"RPRT 0", 0, 0, false},
|
||||||
|
{"", 0, 0, false},
|
||||||
|
} {
|
||||||
|
az, el, ok := parseHeading(tc.line)
|
||||||
|
if ok != tc.ok {
|
||||||
|
t.Errorf("%q: ok=%v, wanted %v", tc.line, ok, tc.ok)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ok && (az != tc.az || el != tc.el) {
|
||||||
|
t.Errorf("%q: got %.1f/%.1f, wanted %.1f/%.1f", tc.line, az, el, tc.az, tc.el)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 450° overlap is the whole reason a satellite rotator is worth having: a
|
||||||
|
// pass crossing north must continue past 360 instead of unwinding through the
|
||||||
|
// entire scale with the antenna sweeping the ground.
|
||||||
|
func TestWrapAz450(t *testing.T) {
|
||||||
|
c := &Client{MaxAz: 450}
|
||||||
|
// Nothing commanded yet: no history to be near, so the plain bearing.
|
||||||
|
if got := c.wrapAz(10); got != 10 {
|
||||||
|
t.Errorf("first move: got %.1f, wanted 10", got)
|
||||||
|
}
|
||||||
|
c.lastAz, c.commanded = 350, true
|
||||||
|
// Crossing north: 370 is 20° away, 10 is 340° away.
|
||||||
|
if got := c.wrapAz(10); got != 370 {
|
||||||
|
t.Errorf("crossing north from 350: got %.1f, wanted 370", got)
|
||||||
|
}
|
||||||
|
// Coming back down the same way, the overlap stays the near answer.
|
||||||
|
c.lastAz = 370
|
||||||
|
if got := c.wrapAz(350); got != 350 {
|
||||||
|
t.Errorf("back from 370: got %.1f, wanted 350", got)
|
||||||
|
}
|
||||||
|
// Beyond the rotator's reach there is no overlap to use.
|
||||||
|
c.lastAz = 440
|
||||||
|
if got := c.wrapAz(100); got != 100 {
|
||||||
|
t.Errorf("past the end of the scale: got %.1f, wanted 100", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrapAz360(t *testing.T) {
|
||||||
|
c := &Client{MaxAz: 360}
|
||||||
|
c.lastAz, c.commanded = 350, true
|
||||||
|
if got := c.wrapAz(10); got != 10 {
|
||||||
|
t.Errorf("a 360 rotator has no overlap: got %.1f, wanted 10", got)
|
||||||
|
}
|
||||||
|
if got := c.wrapAz(-10); got != 350 {
|
||||||
|
t.Errorf("negative bearing: got %.1f, wanted 350", got)
|
||||||
|
}
|
||||||
|
if got := c.wrapAz(725); got != 5 {
|
||||||
|
t.Errorf("two turns and five degrees: got %.1f, wanted 5", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A controller that never answers must not be treated as a broken one: the last
|
||||||
|
// commanded position is reported, marked as not live.
|
||||||
|
func TestSilentControllerReportsCommanded(t *testing.T) {
|
||||||
|
c := &Client{MaxAz: 360, silent: true}
|
||||||
|
if _, _, _, err := c.Heading(); err == nil {
|
||||||
|
t.Error("a silent controller with nothing commanded should say it cannot report")
|
||||||
|
}
|
||||||
|
c.lastAz, c.lastEl, c.commanded = 120, 30, true
|
||||||
|
az, el, live, err := c.Heading()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("after a command: %v", err)
|
||||||
|
}
|
||||||
|
if live {
|
||||||
|
t.Error("a commanded position must not be reported as a live reading")
|
||||||
|
}
|
||||||
|
if az != 120 || el != 30 {
|
||||||
|
t.Errorf("got %.1f/%.1f, wanted 120/30", az, el)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,6 +86,76 @@ func (c *Client) Heading() (az int, raw string, err error) {
|
|||||||
return a, raw, nil
|
return a, raw, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Elevation queries PstRotator for the current elevation.
|
||||||
|
//
|
||||||
|
// Same shape as Heading, and the same port+1 listener — but a great many
|
||||||
|
// PstRotator setups drive an azimuth-only rotator and answer nothing at all,
|
||||||
|
// which is why the caller is expected to ask once and stop rather than wait a
|
||||||
|
// second and a half per poll for a reply that is never coming.
|
||||||
|
//
|
||||||
|
// The reply is matched on its LABEL and not on "the first number in it": AZ?
|
||||||
|
// and EL? both report on the same port, so taking the first integer of whatever
|
||||||
|
// arrives would happily read an azimuth as an elevation.
|
||||||
|
func (c *Client) Elevation() (el int, raw string, err error) {
|
||||||
|
pc, err := net.ListenPacket("udp4", fmt.Sprintf(":%d", c.Port+1))
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", fmt.Errorf("listen :%d for PstRotator reply: %w", c.Port+1, err)
|
||||||
|
}
|
||||||
|
defer pc.Close()
|
||||||
|
|
||||||
|
if err := c.send("<PST>EL?</PST>"); err != nil {
|
||||||
|
return 0, "", fmt.Errorf("query PstRotator: %w", err)
|
||||||
|
}
|
||||||
|
_ = pc.SetReadDeadline(time.Now().Add(1500 * time.Millisecond))
|
||||||
|
buf := make([]byte, 512)
|
||||||
|
n, _, rerr := pc.ReadFrom(buf)
|
||||||
|
if rerr != nil {
|
||||||
|
return 0, "", fmt.Errorf("no reply on :%d: %w", c.Port+1, rerr)
|
||||||
|
}
|
||||||
|
raw = string(buf[:n])
|
||||||
|
v, ok := parseLabelled(raw, "EL", "AZ")
|
||||||
|
if !ok {
|
||||||
|
return 0, raw, fmt.Errorf("no elevation in reply %q", raw)
|
||||||
|
}
|
||||||
|
return v, raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLabelled reads the number attached to a label — "EL:45", "EL 45",
|
||||||
|
// "<PST><ELEVATION>45</ELEVATION></PST>".
|
||||||
|
//
|
||||||
|
// The number is the first one AFTER the label, and false is returned when the
|
||||||
|
// label is absent — which is how an answer to the other question gets refused
|
||||||
|
// rather than read as this one.
|
||||||
|
func parseLabelled(s, label, other string) (int, bool) {
|
||||||
|
up := strings.ToUpper(s)
|
||||||
|
i := strings.Index(up, label)
|
||||||
|
if i < 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
// A reply carrying BOTH labels is answering the other question first; only
|
||||||
|
// what follows our own label counts.
|
||||||
|
rest := up[i+len(label):]
|
||||||
|
if j := strings.Index(rest, other); j >= 0 {
|
||||||
|
rest = rest[:j]
|
||||||
|
}
|
||||||
|
j := 0
|
||||||
|
for j < len(rest) && (rest[j] < '0' || rest[j] > '9') {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
k := j
|
||||||
|
for k < len(rest) && rest[k] >= '0' && rest[k] <= '9' {
|
||||||
|
k++
|
||||||
|
}
|
||||||
|
if k == j {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(rest[j:k])
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
|
||||||
// parseAzimuth extracts the first integer found in a PstRotator reply
|
// parseAzimuth extracts the first integer found in a PstRotator reply
|
||||||
// ("AZ:123", "123", "<PST><AZIMUTH>123</AZIMUTH></PST>", …) and normalises
|
// ("AZ:123", "123", "<PST><AZIMUTH>123</AZIMUTH></PST>", …) and normalises
|
||||||
// it to [0,360).
|
// it to [0,360).
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
package sat
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The frequency side of a satellite: what to listen on, what to transmit on,
|
||||||
|
// and how the two are tied together.
|
||||||
|
//
|
||||||
|
// The elements say where a bird is; this says what to do with the radio when it
|
||||||
|
// is there. They are separate on purpose — the elements change every few days
|
||||||
|
// and come from a feed, while a transponder plan changes when a satellite is
|
||||||
|
// commanded into another mode, which is a matter for the operator and AMSAT's
|
||||||
|
// published chart.
|
||||||
|
//
|
||||||
|
// The shipped list is a STARTING POINT, not an authority: satellites are
|
||||||
|
// switched between modes, transponders are turned off for a season, and new
|
||||||
|
// ones fly. It is copied to the data directory on first use and read from there
|
||||||
|
// afterwards, so an operator can correct a frequency without waiting for a
|
||||||
|
// release — and keep the correction across updates.
|
||||||
|
|
||||||
|
//go:embed birds.json
|
||||||
|
var shippedBirds []byte
|
||||||
|
|
||||||
|
// BirdsName is the editable copy in the data directory.
|
||||||
|
const BirdsName = "satellites.json"
|
||||||
|
|
||||||
|
// Transponder is one usable path through a satellite.
|
||||||
|
type Transponder struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Mode string `json:"mode"` // ADIF: FM, SSB, CW, DATA
|
||||||
|
|
||||||
|
// The downlink and uplink passbands, in Hz. A single frequency (an FM
|
||||||
|
// repeater, a beacon) sets only the "lo" of each side.
|
||||||
|
DownLo int64 `json:"down_lo"`
|
||||||
|
DownHi int64 `json:"down_hi,omitempty"`
|
||||||
|
UpLo int64 `json:"up_lo,omitempty"`
|
||||||
|
UpHi int64 `json:"up_hi,omitempty"`
|
||||||
|
|
||||||
|
// Inverting: the transponder turns the passband over, so tuning UP the
|
||||||
|
// downlink means going DOWN the uplink. Getting this backwards puts the
|
||||||
|
// operator's transmission at the far end of the passband from the station
|
||||||
|
// they can hear — which is the classic first evening on a linear bird.
|
||||||
|
Inverting bool `json:"inverting,omitempty"`
|
||||||
|
|
||||||
|
// CTCSS is the subaudible tone an FM uplink needs, in Hz. Zero = none.
|
||||||
|
CTCSS float64 `json:"ctcss,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linear reports a transponder with a passband rather than a single channel.
|
||||||
|
func (t Transponder) Linear() bool { return t.DownHi > t.DownLo && t.UpHi > t.UpLo }
|
||||||
|
|
||||||
|
// UplinkFor is where to transmit in order to be heard at downHz on the
|
||||||
|
// downlink.
|
||||||
|
//
|
||||||
|
// On a channel (FM) the answer is the uplink frequency, whatever the operator
|
||||||
|
// is tuned to. On a linear transponder it is a position in the passband — the
|
||||||
|
// same distance in from the edge, and from the OTHER edge when the transponder
|
||||||
|
// inverts.
|
||||||
|
func (t Transponder) UplinkFor(downHz int64) int64 {
|
||||||
|
if t.UpLo <= 0 {
|
||||||
|
return 0 // receive-only: a beacon, or a downlink we have no way to answer
|
||||||
|
}
|
||||||
|
if !t.Linear() {
|
||||||
|
return t.UpLo
|
||||||
|
}
|
||||||
|
if downHz < t.DownLo {
|
||||||
|
downHz = t.DownLo
|
||||||
|
}
|
||||||
|
if downHz > t.DownHi {
|
||||||
|
downHz = t.DownHi
|
||||||
|
}
|
||||||
|
offset := downHz - t.DownLo
|
||||||
|
if t.Inverting {
|
||||||
|
return t.UpHi - offset
|
||||||
|
}
|
||||||
|
return t.UpLo + offset
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownlinkFor is the inverse: where a station transmitting at upHz comes out.
|
||||||
|
// It exists for the operator who tunes the uplink first — rarer, but the split
|
||||||
|
// has to be consistent whichever end they take hold of.
|
||||||
|
func (t Transponder) DownlinkFor(upHz int64) int64 {
|
||||||
|
if !t.Linear() {
|
||||||
|
return t.DownLo
|
||||||
|
}
|
||||||
|
if upHz < t.UpLo {
|
||||||
|
upHz = t.UpLo
|
||||||
|
}
|
||||||
|
if upHz > t.UpHi {
|
||||||
|
upHz = t.UpHi
|
||||||
|
}
|
||||||
|
if t.Inverting {
|
||||||
|
return t.DownLo + (t.UpHi - upHz)
|
||||||
|
}
|
||||||
|
return t.DownLo + (upHz - t.UpLo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Centre is the middle of the downlink passband — where to park when the
|
||||||
|
// operator picks a satellite and has not yet chosen a frequency in it.
|
||||||
|
func (t Transponder) Centre() int64 {
|
||||||
|
if !t.Linear() {
|
||||||
|
return t.DownLo
|
||||||
|
}
|
||||||
|
return t.DownLo + (t.DownHi-t.DownLo)/2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bird is one satellite's frequency plan.
|
||||||
|
type Bird struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Aliases []string `json:"aliases,omitempty"`
|
||||||
|
// 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
|
||||||
|
// tracking side does — there is nothing to predict and nothing to follow.
|
||||||
|
Geostationary bool `json:"geostationary,omitempty"`
|
||||||
|
Transponders []Transponder `json:"transponders"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches reports whether a name from an element feed is this satellite.
|
||||||
|
//
|
||||||
|
// The same rules Find uses, exposed for the other direction: the caller holds a
|
||||||
|
// bird and is scanning an element set spelled by somebody else.
|
||||||
|
func (b Bird) Matches(feedName string) bool {
|
||||||
|
cands := []string{feedName}
|
||||||
|
if i := strings.IndexByte(feedName, '('); i > 0 {
|
||||||
|
cands = append(cands, feedName[:i], strings.Trim(feedName[i:], "()"))
|
||||||
|
}
|
||||||
|
names := append([]string{b.Name}, b.Aliases...)
|
||||||
|
if i := strings.IndexByte(b.Name, '('); i > 0 {
|
||||||
|
names = append(names, b.Name[:i], strings.Trim(b.Name[i:], "()"))
|
||||||
|
}
|
||||||
|
for _, n := range names {
|
||||||
|
ln := loose(n)
|
||||||
|
if ln == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, c := range cands {
|
||||||
|
if ln == loose(c) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Birds is the frequency plan for every satellite the station knows.
|
||||||
|
type Birds struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
list []Bird
|
||||||
|
byKey map[string]int // name and aliases, loosely normalised → index in list
|
||||||
|
}
|
||||||
|
|
||||||
|
// loose is the matching form of a satellite name: upper case, letters and
|
||||||
|
// digits only.
|
||||||
|
//
|
||||||
|
// Feeds, AMSAT and operators all spell the same bird differently — "ES'HAIL 2",
|
||||||
|
// "ESHAIL-2", "Es'hail 2" — and none of them is wrong. Comparing the letters and
|
||||||
|
// digits alone is what lets the frequency plan meet the element set without a
|
||||||
|
// dozen aliases per satellite.
|
||||||
|
func loose(name string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToUpper(name) {
|
||||||
|
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadBirds reads the plan from the data directory, writing the shipped copy
|
||||||
|
// there first if there is none.
|
||||||
|
//
|
||||||
|
// A file the operator has broken is NOT overwritten: it is reported and the
|
||||||
|
// shipped list is used for this session, so a stray comma costs a correction
|
||||||
|
// rather than the corrections of the last two years.
|
||||||
|
func LoadBirds(dir string) (*Birds, error) {
|
||||||
|
b := &Birds{}
|
||||||
|
path := filepath.Join(dir, BirdsName)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
if perr := b.parse(data); perr != nil {
|
||||||
|
_ = 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, nil
|
||||||
|
case os.IsNotExist(err):
|
||||||
|
if perr := b.parse(shippedBirds); perr != nil {
|
||||||
|
return nil, perr
|
||||||
|
}
|
||||||
|
if werr := os.MkdirAll(dir, 0o755); werr == nil {
|
||||||
|
_ = os.WriteFile(path, shippedBirds, 0o644)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
default:
|
||||||
|
_ = b.parse(shippedBirds)
|
||||||
|
return b, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Birds) parse(data []byte) error {
|
||||||
|
var list []Bird
|
||||||
|
if err := json.Unmarshal(data, &list); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
byKey := make(map[string]int, len(list)*3)
|
||||||
|
put := func(name string, i int) {
|
||||||
|
if k := loose(name); k != "" {
|
||||||
|
// First writer wins: a satellite's own name must never be displaced by
|
||||||
|
// another bird's alias.
|
||||||
|
if _, seen := byKey[k]; !seen {
|
||||||
|
byKey[k] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, bird := range list {
|
||||||
|
put(bird.Name, i)
|
||||||
|
}
|
||||||
|
for i, bird := range list {
|
||||||
|
for _, a := range bird.Aliases {
|
||||||
|
put(a, i)
|
||||||
|
}
|
||||||
|
// "RADFXSAT (FOX-1B)" is one string in the feed and two names to an
|
||||||
|
// operator; index both halves so either spelling finds the bird.
|
||||||
|
if j := strings.IndexByte(bird.Name, '('); j > 0 {
|
||||||
|
put(bird.Name[:j], i)
|
||||||
|
put(strings.Trim(bird.Name[j:], "()"), i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
b.list, b.byKey = list, byKey
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find looks a satellite up by name or alias.
|
||||||
|
//
|
||||||
|
// Celestrak says "RADFXSAT (FOX-1B)" where every operator says AO-91, so the
|
||||||
|
// bracketed halves are tried on their own before giving up — that is how most
|
||||||
|
// feed names differ from the name on the chart.
|
||||||
|
func (b *Birds) Find(name string) (Bird, bool) {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
try := func(s string) (Bird, bool) {
|
||||||
|
if i, ok := b.byKey[loose(s)]; ok {
|
||||||
|
return b.list[i], true
|
||||||
|
}
|
||||||
|
return Bird{}, false
|
||||||
|
}
|
||||||
|
if bird, ok := try(name); ok {
|
||||||
|
return bird, true
|
||||||
|
}
|
||||||
|
if i := strings.IndexByte(name, '('); i > 0 {
|
||||||
|
if bird, ok := try(name[:i]); ok {
|
||||||
|
return bird, true
|
||||||
|
}
|
||||||
|
if bird, ok := try(strings.Trim(name[i:], "()")); ok {
|
||||||
|
return bird, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Bird{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// All lists the plan, in name order.
|
||||||
|
func (b *Birds) All() []Bird {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
out := append([]Bird(nil), b.list...)
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len is how many satellites carry a frequency plan.
|
||||||
|
func (b *Birds) Len() int {
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
return len(b.list)
|
||||||
|
}
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "ISS (ZARYA)",
|
||||||
|
"aliases": [
|
||||||
|
"ISS",
|
||||||
|
"ZARYA",
|
||||||
|
"ARISS"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "FM voice repeater",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 437800000,
|
||||||
|
"up_lo": 145990000,
|
||||||
|
"ctcss": 67
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "APRS digipeater",
|
||||||
|
"mode": "DATA",
|
||||||
|
"down_lo": 145825000,
|
||||||
|
"up_lo": 145825000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "SSTV",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 145800000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SO-50",
|
||||||
|
"aliases": [
|
||||||
|
"SAUDISAT 1C",
|
||||||
|
"SAUDISAT 1C (SO-50)"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "FM voice repeater",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 436795000,
|
||||||
|
"up_lo": 145850000,
|
||||||
|
"ctcss": 67
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AO-91",
|
||||||
|
"aliases": [
|
||||||
|
"RADFXSAT",
|
||||||
|
"FOX-1B",
|
||||||
|
"RADFXSAT (FOX-1B)"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "FM voice repeater",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 145960000,
|
||||||
|
"up_lo": 435250000,
|
||||||
|
"ctcss": 67
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "IO-86",
|
||||||
|
"aliases": [
|
||||||
|
"LAPAN-A2",
|
||||||
|
"LAPAN-ORARI"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "FM voice repeater",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 435880000,
|
||||||
|
"up_lo": 145880000,
|
||||||
|
"ctcss": 88.5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PO-101",
|
||||||
|
"aliases": [
|
||||||
|
"DIWATA-2",
|
||||||
|
"DIWATA-2B"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "FM voice repeater (scheduled)",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 145900000,
|
||||||
|
"up_lo": 437500000,
|
||||||
|
"ctcss": 141.3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AO-7",
|
||||||
|
"aliases": [
|
||||||
|
"AMSAT-OSCAR 7",
|
||||||
|
"OSCAR 7"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Mode B linear (inverting)",
|
||||||
|
"mode": "SSB",
|
||||||
|
"down_lo": 145925000,
|
||||||
|
"down_hi": 145975000,
|
||||||
|
"up_lo": 432125000,
|
||||||
|
"up_hi": 432175000,
|
||||||
|
"inverting": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Mode A linear",
|
||||||
|
"mode": "SSB",
|
||||||
|
"down_lo": 29400000,
|
||||||
|
"down_hi": 29500000,
|
||||||
|
"up_lo": 145850000,
|
||||||
|
"up_hi": 145950000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "FO-29",
|
||||||
|
"aliases": [
|
||||||
|
"JAS-2",
|
||||||
|
"FUJI-OSCAR 29"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Linear (inverting)",
|
||||||
|
"mode": "SSB",
|
||||||
|
"down_lo": 435800000,
|
||||||
|
"down_hi": 435900000,
|
||||||
|
"up_lo": 145900000,
|
||||||
|
"up_hi": 146000000,
|
||||||
|
"inverting": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AO-73",
|
||||||
|
"aliases": [
|
||||||
|
"FUNCUBE-1",
|
||||||
|
"FUNCUBE 1"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Linear (inverting)",
|
||||||
|
"mode": "SSB",
|
||||||
|
"down_lo": 145950000,
|
||||||
|
"down_hi": 145970000,
|
||||||
|
"up_lo": 435130000,
|
||||||
|
"up_hi": 435150000,
|
||||||
|
"inverting": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "JO-97",
|
||||||
|
"aliases": [
|
||||||
|
"JY1SAT",
|
||||||
|
"JY1-SAT"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Linear (inverting)",
|
||||||
|
"mode": "SSB",
|
||||||
|
"down_lo": 145855000,
|
||||||
|
"down_hi": 145875000,
|
||||||
|
"up_lo": 435100000,
|
||||||
|
"up_hi": 435120000,
|
||||||
|
"inverting": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RS-44",
|
||||||
|
"aliases": [
|
||||||
|
"DOSAAF-85"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Linear (inverting)",
|
||||||
|
"mode": "SSB",
|
||||||
|
"down_lo": 435640000,
|
||||||
|
"down_hi": 435680000,
|
||||||
|
"up_lo": 145965000,
|
||||||
|
"up_hi": 146005000,
|
||||||
|
"inverting": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "QO-100",
|
||||||
|
"aliases": [
|
||||||
|
"ES'HAIL 2",
|
||||||
|
"ESHAIL 2",
|
||||||
|
"ES'HAIL-2"
|
||||||
|
],
|
||||||
|
"geostationary": true,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Narrowband linear",
|
||||||
|
"mode": "SSB",
|
||||||
|
"down_lo": 10489550000,
|
||||||
|
"down_hi": 10489800000,
|
||||||
|
"up_lo": 2400050000,
|
||||||
|
"up_hi": 2400300000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Wideband (DATV)",
|
||||||
|
"mode": "DATA",
|
||||||
|
"down_lo": 10491000000,
|
||||||
|
"down_hi": 10499000000,
|
||||||
|
"up_lo": 2401500000,
|
||||||
|
"up_hi": 2409500000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TEVEL-1",
|
||||||
|
"aliases": [
|
||||||
|
"TEVEL 1"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "FM voice repeater",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 436400000,
|
||||||
|
"up_lo": 145970000,
|
||||||
|
"ctcss": 67
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TEVEL-2",
|
||||||
|
"aliases": [
|
||||||
|
"TEVEL 2"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "FM voice repeater",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 436400000,
|
||||||
|
"up_lo": 145970000,
|
||||||
|
"ctcss": 67
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TEVEL-3",
|
||||||
|
"aliases": [
|
||||||
|
"TEVEL 3"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "FM voice repeater",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 436400000,
|
||||||
|
"up_lo": 145970000,
|
||||||
|
"ctcss": 67
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TEVEL-4",
|
||||||
|
"aliases": [
|
||||||
|
"TEVEL 4"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "FM voice repeater",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 436400000,
|
||||||
|
"up_lo": 145970000,
|
||||||
|
"ctcss": 67
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TEVEL-5",
|
||||||
|
"aliases": [
|
||||||
|
"TEVEL 5"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "FM voice repeater",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 436400000,
|
||||||
|
"up_lo": 145970000,
|
||||||
|
"ctcss": 67
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TEVEL-6",
|
||||||
|
"aliases": [
|
||||||
|
"TEVEL 6"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "FM voice repeater",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 436400000,
|
||||||
|
"up_lo": 145970000,
|
||||||
|
"ctcss": 67
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TEVEL-7",
|
||||||
|
"aliases": [
|
||||||
|
"TEVEL 7"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "FM voice repeater",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 436400000,
|
||||||
|
"up_lo": 145970000,
|
||||||
|
"ctcss": 67
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TEVEL-8",
|
||||||
|
"aliases": [
|
||||||
|
"TEVEL 8"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "FM voice repeater",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 436400000,
|
||||||
|
"up_lo": 145970000,
|
||||||
|
"ctcss": 67
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "EO-88",
|
||||||
|
"aliases": [
|
||||||
|
"NAYIF-1",
|
||||||
|
"FUNCUBE-5"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Linear (inverting)",
|
||||||
|
"mode": "SSB",
|
||||||
|
"down_lo": 145960000,
|
||||||
|
"down_hi": 145990000,
|
||||||
|
"up_lo": 435015000,
|
||||||
|
"up_hi": 435045000,
|
||||||
|
"inverting": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AO-109",
|
||||||
|
"aliases": [
|
||||||
|
"RADFXSAT-2",
|
||||||
|
"FOX-1E"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Linear (inverting)",
|
||||||
|
"mode": "SSB",
|
||||||
|
"down_lo": 145860000,
|
||||||
|
"down_hi": 145880000,
|
||||||
|
"up_lo": 435750000,
|
||||||
|
"up_hi": 435770000,
|
||||||
|
"inverting": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CAS-4A",
|
||||||
|
"aliases": [
|
||||||
|
"ZHUHAI-1 OVS-1A",
|
||||||
|
"OVS-1A"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Linear (inverting)",
|
||||||
|
"mode": "SSB",
|
||||||
|
"down_lo": 145860000,
|
||||||
|
"down_hi": 145880000,
|
||||||
|
"up_lo": 435210000,
|
||||||
|
"up_hi": 435230000,
|
||||||
|
"inverting": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CAS-4B",
|
||||||
|
"aliases": [
|
||||||
|
"ZHUHAI-1 OVS-1B",
|
||||||
|
"OVS-1B"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Linear (inverting)",
|
||||||
|
"mode": "SSB",
|
||||||
|
"down_lo": 145905000,
|
||||||
|
"down_hi": 145925000,
|
||||||
|
"up_lo": 435270000,
|
||||||
|
"up_hi": 435290000,
|
||||||
|
"inverting": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TO-108",
|
||||||
|
"aliases": [
|
||||||
|
"CAS-6",
|
||||||
|
"TIANQIN-1"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Linear (inverting)",
|
||||||
|
"mode": "SSB",
|
||||||
|
"down_lo": 145915000,
|
||||||
|
"down_hi": 145935000,
|
||||||
|
"up_lo": 435270000,
|
||||||
|
"up_hi": 435290000,
|
||||||
|
"inverting": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "IO-117",
|
||||||
|
"aliases": [
|
||||||
|
"GREENCUBE",
|
||||||
|
"MEZTLI"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Digipeater (1200 bd GMSK)",
|
||||||
|
"mode": "DATA",
|
||||||
|
"down_lo": 435310000,
|
||||||
|
"up_lo": 435310000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package sat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The shipped list has to be readable and consistent — it is embedded, so a
|
||||||
|
// mistake in it is a mistake in every build.
|
||||||
|
func TestShippedBirds(t *testing.T) {
|
||||||
|
b := &Birds{}
|
||||||
|
if err := b.parse(shippedBirds); err != nil {
|
||||||
|
t.Fatalf("birds.json does not parse: %v", err)
|
||||||
|
}
|
||||||
|
if b.Len() < 5 {
|
||||||
|
t.Fatalf("only %d satellites shipped", b.Len())
|
||||||
|
}
|
||||||
|
for _, bird := range b.All() {
|
||||||
|
if len(bird.Transponders) == 0 {
|
||||||
|
t.Errorf("%s has no transponder", bird.Name)
|
||||||
|
}
|
||||||
|
for _, tr := range bird.Transponders {
|
||||||
|
if tr.DownLo <= 0 {
|
||||||
|
t.Errorf("%s / %s: no downlink", bird.Name, tr.Label)
|
||||||
|
}
|
||||||
|
if tr.DownHi != 0 && tr.DownHi <= tr.DownLo {
|
||||||
|
t.Errorf("%s / %s: downlink passband runs backwards", bird.Name, tr.Label)
|
||||||
|
}
|
||||||
|
if tr.UpHi != 0 && tr.UpHi <= tr.UpLo {
|
||||||
|
t.Errorf("%s / %s: uplink passband runs backwards", bird.Name, tr.Label)
|
||||||
|
}
|
||||||
|
// A linear transponder whose two passbands are different widths cannot
|
||||||
|
// map one onto the other, and the split would drift across the pass.
|
||||||
|
if tr.Linear() && (tr.DownHi-tr.DownLo) != (tr.UpHi-tr.UpLo) {
|
||||||
|
t.Errorf("%s / %s: passbands are %d and %d Hz wide",
|
||||||
|
bird.Name, tr.Label, tr.DownHi-tr.DownLo, tr.UpHi-tr.UpLo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindByAlias(t *testing.T) {
|
||||||
|
b := &Birds{}
|
||||||
|
if err := b.parse(shippedBirds); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Every spelling on the left is one an operator or a feed actually uses.
|
||||||
|
for _, tc := range []struct{ query, want string }{
|
||||||
|
{"AO-91", "AO-91"},
|
||||||
|
{"RADFXSAT (FOX-1B)", "AO-91"},
|
||||||
|
{"radfxsat", "AO-91"},
|
||||||
|
{"ISS (ZARYA)", "ISS (ZARYA)"},
|
||||||
|
{"ISS", "ISS (ZARYA)"},
|
||||||
|
{"SAUDISAT 1C (SO-50)", "SO-50"},
|
||||||
|
{"so 50", "SO-50"},
|
||||||
|
{"QO-100", "QO-100"},
|
||||||
|
{"ESHAIL-2", "QO-100"},
|
||||||
|
{"Es'hail 2", "QO-100"},
|
||||||
|
} {
|
||||||
|
got, ok := b.Find(tc.query)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%q was not found", tc.query)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got.Name != tc.want {
|
||||||
|
t.Errorf("%q found %q, wanted %q", tc.query, got.Name, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := b.Find("NOAA 15"); ok {
|
||||||
|
t.Error("a weather satellite should not carry an amateur frequency plan")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches is the other direction: a bird in hand, scanning a feed's names.
|
||||||
|
func TestBirdMatches(t *testing.T) {
|
||||||
|
b := Bird{Name: "AO-91", Aliases: []string{"RADFXSAT", "FOX-1B"}}
|
||||||
|
for _, feed := range []string{"AO-91", "RADFXSAT (FOX-1B)", "radfxsat", "FOX 1B"} {
|
||||||
|
if !b.Matches(feed) {
|
||||||
|
t.Errorf("%q was not recognised as AO-91", feed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, feed := range []string{"AO-92", "NOAA 15", "FOX-1A"} {
|
||||||
|
if b.Matches(feed) {
|
||||||
|
t.Errorf("%q was wrongly taken for AO-91", feed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A bracketed catalogue name matched from the other side.
|
||||||
|
iss := Bird{Name: "ISS (ZARYA)"}
|
||||||
|
if !iss.Matches("ISS") || !iss.Matches("ZARYA") {
|
||||||
|
t.Error("the ISS was not recognised by either half of its catalogue name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The uplink maths is the part that matters on the air: a station worked at one
|
||||||
|
// end of an inverting transponder has to be answered at the other.
|
||||||
|
func TestUplinkFor(t *testing.T) {
|
||||||
|
inv := Transponder{
|
||||||
|
DownLo: 435800000, DownHi: 435900000,
|
||||||
|
UpLo: 145900000, UpHi: 146000000,
|
||||||
|
Inverting: true,
|
||||||
|
}
|
||||||
|
straight := Transponder{
|
||||||
|
DownLo: 29400000, DownHi: 29500000,
|
||||||
|
UpLo: 145850000, UpHi: 145950000,
|
||||||
|
}
|
||||||
|
fm := Transponder{DownLo: 436795000, UpLo: 145850000}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
tr Transponder
|
||||||
|
down int64
|
||||||
|
want int64
|
||||||
|
}{
|
||||||
|
{"inverting, bottom of the downlink", inv, 435800000, 146000000},
|
||||||
|
{"inverting, top of the downlink", inv, 435900000, 145900000},
|
||||||
|
{"inverting, 30 kHz up", inv, 435830000, 145970000},
|
||||||
|
{"straight, bottom", straight, 29400000, 145850000},
|
||||||
|
{"straight, 25 kHz up", straight, 29425000, 145875000},
|
||||||
|
{"FM channel ignores the tuned downlink", fm, 436798000, 145850000},
|
||||||
|
{"below the passband is clamped", inv, 435700000, 146000000},
|
||||||
|
{"above the passband is clamped", inv, 436000000, 145900000},
|
||||||
|
} {
|
||||||
|
if got := tc.tr.UplinkFor(tc.down); got != tc.want {
|
||||||
|
t.Errorf("%s: got %d, wanted %d", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive-only: a beacon has nothing to answer on.
|
||||||
|
if got := (Transponder{DownLo: 145800000}).UplinkFor(145800000); got != 0 {
|
||||||
|
t.Errorf("a receive-only transponder gave an uplink of %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whichever end the operator takes hold of, the pair has to agree.
|
||||||
|
func TestDownlinkForRoundTrip(t *testing.T) {
|
||||||
|
for _, tr := range []Transponder{
|
||||||
|
{DownLo: 435800000, DownHi: 435900000, UpLo: 145900000, UpHi: 146000000, Inverting: true},
|
||||||
|
{DownLo: 29400000, DownHi: 29500000, UpLo: 145850000, UpHi: 145950000},
|
||||||
|
} {
|
||||||
|
for _, down := range []int64{tr.DownLo, tr.Centre(), tr.DownHi} {
|
||||||
|
if got := tr.DownlinkFor(tr.UplinkFor(down)); got != down {
|
||||||
|
t.Errorf("inverting=%v: %d → uplink → %d", tr.Inverting, down, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadBirdsWritesTheEditableCopy(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
b, err := LoadBirds(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
shipped := b.Len()
|
||||||
|
path := filepath.Join(dir, BirdsName)
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Fatalf("the editable copy was not written: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An operator's own list is what gets used from then on.
|
||||||
|
mine := `[{"name":"MY-SAT","transponders":[{"label":"FM","mode":"FM","down_lo":1,"up_lo":2}]}]`
|
||||||
|
if err := os.WriteFile(path, []byte(mine), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err = LoadBirds(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if b.Len() != 1 {
|
||||||
|
t.Fatalf("the operator's list was not used: %d satellites", b.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a broken one falls back without destroying what they wrote.
|
||||||
|
if err := os.WriteFile(path, []byte("[{oops"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err = LoadBirds(dir)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("a broken list was accepted silently")
|
||||||
|
}
|
||||||
|
if b.Len() != shipped {
|
||||||
|
t.Errorf("the shipped list did not take over: %d satellites", b.Len())
|
||||||
|
}
|
||||||
|
if data, _ := os.ReadFile(path); string(data) != "[{oops" {
|
||||||
|
t.Error("the operator's broken file was overwritten")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
// Package sat is where a satellite is, where it will be, and what that does to
|
||||||
|
// a frequency.
|
||||||
|
//
|
||||||
|
// Three things live here and nothing else: the orbital elements a station keeps
|
||||||
|
// (Store), the sky as seen from that station (Track, Passes), and the Doppler
|
||||||
|
// shift the motion imposes (Shift). The radio, the rotator and the screen are
|
||||||
|
// all somebody else's business — they are handed numbers by the app layer.
|
||||||
|
//
|
||||||
|
// The propagation itself is SGP4 from github.com/akhenakh/sgp4 (Apache-2.0,
|
||||||
|
// pure Go): the model everyone in this hobby uses, and the one the TLEs are
|
||||||
|
// built for. Writing it again would be writing it worse.
|
||||||
|
package sat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/akhenakh/sgp4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// speedOfLightKmS is the constant every Doppler correction here is built on.
|
||||||
|
const speedOfLightKmS = 299792.458
|
||||||
|
|
||||||
|
// Observer is the ground station: where the antenna is, in degrees and metres.
|
||||||
|
type Observer struct {
|
||||||
|
Lat, Lon float64
|
||||||
|
AltM float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position is a satellite seen from the ground at one instant.
|
||||||
|
//
|
||||||
|
// The two halves answer different questions and both are wanted: where the
|
||||||
|
// thing IS (for the map) and where to POINT (for the rotator and the Doppler).
|
||||||
|
type Position struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
|
||||||
|
// Sub-satellite point and height — the map's half.
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
Lon float64 `json:"lon"`
|
||||||
|
AltKm float64 `json:"alt_km"`
|
||||||
|
Footprint float64 `json:"footprint_km"` // radius of the visibility circle
|
||||||
|
|
||||||
|
// Look angles — the station's half.
|
||||||
|
Az float64 `json:"az"`
|
||||||
|
El float64 `json:"el"`
|
||||||
|
RangeKm float64 `json:"range_km"`
|
||||||
|
RangeRate float64 `json:"range_rate"` // km/s, positive = receding
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visible reports whether the satellite is above the horizon.
|
||||||
|
//
|
||||||
|
// Zero degrees, not a courtesy margin: an operator with a clear take-off works
|
||||||
|
// a pass from the moment it rises, and a station in a valley knows its own
|
||||||
|
// horizon better than this package ever will.
|
||||||
|
func (p Position) Visible() bool { return p.El > 0 }
|
||||||
|
|
||||||
|
// Pass is one crossing of the sky, from rise to set.
|
||||||
|
type Pass struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
AOS time.Time `json:"aos"`
|
||||||
|
LOS time.Time `json:"los"`
|
||||||
|
AOSAz float64 `json:"aos_az"`
|
||||||
|
LOSAz float64 `json:"los_az"`
|
||||||
|
MaxEl float64 `json:"max_el"`
|
||||||
|
MaxElAz float64 `json:"max_el_az"`
|
||||||
|
MaxElAt time.Time `json:"max_el_at"`
|
||||||
|
Duration float64 `json:"duration_s"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element is one satellite's orbital elements, as they were published.
|
||||||
|
//
|
||||||
|
// The raw lines are kept beside the parsed form because they are what gets
|
||||||
|
// written to the cache and what an operator pastes in by hand for a bird that
|
||||||
|
// is not in any feed yet — a freshly launched one, above all, which is exactly
|
||||||
|
// when everybody wants to hear it.
|
||||||
|
type Element struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
NORAD int `json:"norad"`
|
||||||
|
Line1 string `json:"line1"`
|
||||||
|
Line2 string `json:"line2"`
|
||||||
|
// Epoch is when these elements were computed. Their accuracy falls away
|
||||||
|
// from it, which is why the store knows how old they are.
|
||||||
|
Epoch time.Time `json:"epoch"`
|
||||||
|
|
||||||
|
tle *sgp4.TLE
|
||||||
|
}
|
||||||
|
|
||||||
|
// Age is how long ago these elements were computed.
|
||||||
|
func (e Element) Age() time.Duration {
|
||||||
|
if e.Epoch.IsZero() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return time.Since(e.Epoch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseElement reads one satellite from its two or three TLE lines.
|
||||||
|
func ParseElement(name, line1, line2 string) (Element, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
line1 = strings.TrimSpace(line1)
|
||||||
|
line2 = strings.TrimSpace(line2)
|
||||||
|
if line1 == "" || line2 == "" {
|
||||||
|
return Element{}, fmt.Errorf("sat: %q has no orbital elements", name)
|
||||||
|
}
|
||||||
|
raw := line1 + "\n" + line2
|
||||||
|
if name != "" {
|
||||||
|
raw = name + "\n" + raw
|
||||||
|
}
|
||||||
|
t, err := sgp4.ParseTLE(raw)
|
||||||
|
if err != nil {
|
||||||
|
return Element{}, fmt.Errorf("sat: %q: %w", name, err)
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(t.Name)
|
||||||
|
}
|
||||||
|
return Element{
|
||||||
|
Name: name,
|
||||||
|
NORAD: t.SatelliteNumber,
|
||||||
|
Line1: line1,
|
||||||
|
Line2: line2,
|
||||||
|
Epoch: tleEpoch(t),
|
||||||
|
tle: t,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tleEpoch turns the two-digit year and fractional day of a TLE into a time.
|
||||||
|
//
|
||||||
|
// The pivot is the one the format itself defines: 57 and above is the twentieth
|
||||||
|
// century, below it the twenty-first. It matters for the AGE of the elements,
|
||||||
|
// which is how an operator knows whether to trust a prediction.
|
||||||
|
func tleEpoch(t *sgp4.TLE) time.Time {
|
||||||
|
if t == nil || t.EpochDay <= 0 {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
year := t.EpochYear
|
||||||
|
switch {
|
||||||
|
case year >= 57 && year <= 99:
|
||||||
|
year += 1900
|
||||||
|
case year < 57:
|
||||||
|
year += 2000
|
||||||
|
}
|
||||||
|
start := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
return start.Add(time.Duration((t.EpochDay - 1) * float64(24*time.Hour)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store holds the elements a station tracks. Safe for concurrent use: the app
|
||||||
|
// refreshes it from a feed while the tracking loop reads it several times a
|
||||||
|
// second.
|
||||||
|
type Store struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
byKey map[string]Element
|
||||||
|
order []string // insertion order, so a listing reads like the feed
|
||||||
|
fetch time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore() *Store { return &Store{byKey: map[string]Element{}} }
|
||||||
|
|
||||||
|
// key is how a satellite is addressed. Case and spacing vary between feeds and
|
||||||
|
// between the operator's typing; the NORAD number would be exact but is not
|
||||||
|
// what anybody says out loud.
|
||||||
|
func key(name string) string { return strings.ToUpper(strings.TrimSpace(name)) }
|
||||||
|
|
||||||
|
// Put adds or replaces one satellite's elements.
|
||||||
|
func (s *Store) Put(e Element) {
|
||||||
|
if e.tle == nil || e.Name == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
k := key(e.Name)
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if _, had := s.byKey[k]; !had {
|
||||||
|
s.order = append(s.order, k)
|
||||||
|
}
|
||||||
|
s.byKey[k] = e
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns one satellite's elements.
|
||||||
|
func (s *Store) Get(name string) (Element, bool) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
e, ok := s.byKey[key(name)]
|
||||||
|
return e, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Names lists what the store holds, in the order it arrived.
|
||||||
|
func (s *Store) Names() []string {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
out := make([]string, 0, len(s.order))
|
||||||
|
for _, k := range s.order {
|
||||||
|
out = append(out, s.byKey[k].Name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len is how many satellites are known.
|
||||||
|
func (s *Store) Len() int {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return len(s.byKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchedAt is when the elements were last loaded from a feed, zero if never.
|
||||||
|
func (s *Store) FetchedAt() time.Time {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace swaps the whole set — what a feed refresh does. The order of the new
|
||||||
|
// set is kept, and the fetch time is stamped.
|
||||||
|
func (s *Store) Replace(els []Element, at time.Time) {
|
||||||
|
byKey := make(map[string]Element, len(els))
|
||||||
|
order := make([]string, 0, len(els))
|
||||||
|
for _, e := range els {
|
||||||
|
if e.tle == nil || e.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k := key(e.Name)
|
||||||
|
if _, had := byKey[k]; !had {
|
||||||
|
order = append(order, k)
|
||||||
|
}
|
||||||
|
byKey[k] = e
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.byKey, s.order, s.fetch = byKey, order, at
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track is where one satellite is, seen from one station, at one instant.
|
||||||
|
func (s *Store) Track(name string, obs Observer, at time.Time) (Position, error) {
|
||||||
|
e, ok := s.Get(name)
|
||||||
|
if !ok {
|
||||||
|
return Position{}, fmt.Errorf("sat: %q is not in the element set", name)
|
||||||
|
}
|
||||||
|
return e.Track(obs, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track is the same for elements already in hand.
|
||||||
|
func (e Element) Track(obs Observer, at time.Time) (Position, error) {
|
||||||
|
if e.tle == nil {
|
||||||
|
return Position{}, fmt.Errorf("sat: %q has no usable elements", e.Name)
|
||||||
|
}
|
||||||
|
loc := &sgp4.Location{Latitude: obs.Lat, Longitude: obs.Lon, Altitude: obs.AltM}
|
||||||
|
eci, err := e.tle.FindPositionAtTime(at.UTC())
|
||||||
|
if err != nil {
|
||||||
|
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{
|
||||||
|
X: eci.Position.X, Y: eci.Position.Y, Z: eci.Position.Z,
|
||||||
|
VX: eci.Velocity.X, VY: eci.Velocity.Y, VZ: eci.Velocity.Z,
|
||||||
|
}
|
||||||
|
o, err := sv.GetLookAngle(loc, at.UTC())
|
||||||
|
if err != nil {
|
||||||
|
return Position{}, fmt.Errorf("sat: %q look angle: %w", e.Name, err)
|
||||||
|
}
|
||||||
|
return Position{
|
||||||
|
Name: e.Name,
|
||||||
|
At: at.UTC(),
|
||||||
|
Lat: o.SatellitePos.Latitude,
|
||||||
|
Lon: o.SatellitePos.Longitude,
|
||||||
|
AltKm: o.SatellitePos.Altitude,
|
||||||
|
Footprint: footprintKm(o.SatellitePos.Altitude),
|
||||||
|
Az: o.LookAngles.Azimuth,
|
||||||
|
El: o.LookAngles.Elevation,
|
||||||
|
RangeKm: o.LookAngles.Range,
|
||||||
|
RangeRate: o.LookAngles.RangeRate,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const earthRadiusKm = 6371.0
|
||||||
|
|
||||||
|
// footprintKm is the radius of the circle from which the satellite is above the
|
||||||
|
// horizon: the ground distance to where it sits exactly on it.
|
||||||
|
func footprintKm(altKm float64) float64 {
|
||||||
|
if altKm <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return earthRadiusKm * math.Acos(earthRadiusKm/(earthRadiusKm+altKm))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passes lists the crossings of the sky between two instants.
|
||||||
|
//
|
||||||
|
// minEl drops the passes not worth waiting for: a bird that scrapes three
|
||||||
|
// degrees over the horizon is a line in a table that will never be a QSO, and
|
||||||
|
// on a busy evening those are most of the list.
|
||||||
|
func (s *Store) Passes(name string, obs Observer, from, to time.Time, minEl float64) ([]Pass, error) {
|
||||||
|
e, ok := s.Get(name)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("sat: %q is not in the element set", name)
|
||||||
|
}
|
||||||
|
if !to.After(from) {
|
||||||
|
return nil, fmt.Errorf("sat: the window ends before it starts")
|
||||||
|
}
|
||||||
|
// Thirty seconds: fine enough that the rise and set times are right to a few
|
||||||
|
// seconds, coarse enough that a day of predictions for a dozen satellites
|
||||||
|
// stays instant.
|
||||||
|
details, err := e.tle.GeneratePasses(obs.Lat, obs.Lon, obs.AltM, from.UTC(), to.UTC(), 30)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sat: %q passes: %w", e.Name, err)
|
||||||
|
}
|
||||||
|
out := make([]Pass, 0, len(details))
|
||||||
|
for _, d := range details {
|
||||||
|
if d.MaxElevation < minEl {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, Pass{
|
||||||
|
Name: e.Name,
|
||||||
|
AOS: d.AOS.UTC(),
|
||||||
|
LOS: d.LOS.UTC(),
|
||||||
|
AOSAz: d.AOSAzimuth,
|
||||||
|
LOSAz: d.LOSAzimuth,
|
||||||
|
MaxEl: d.MaxElevation,
|
||||||
|
MaxElAz: d.MaxElevationAz,
|
||||||
|
MaxElAt: d.MaxElevationTime.UTC(),
|
||||||
|
Duration: d.Duration.Seconds(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextPasses is Passes over several satellites at once, in time order — the
|
||||||
|
// question an operator actually asks: what is coming, and when.
|
||||||
|
func (s *Store) NextPasses(names []string, obs Observer, from time.Time, window time.Duration, minEl int) []Pass {
|
||||||
|
var all []Pass
|
||||||
|
for _, n := range names {
|
||||||
|
ps, err := s.Passes(n, obs, from, from.Add(window), float64(minEl))
|
||||||
|
if err != nil {
|
||||||
|
continue // a satellite whose elements are missing is simply not listed
|
||||||
|
}
|
||||||
|
all = append(all, ps...)
|
||||||
|
}
|
||||||
|
sort.Slice(all, func(i, j int) bool { return all[i].AOS.Before(all[j].AOS) })
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shift is the Doppler-corrected pair for one moment.
|
||||||
|
type Shift struct {
|
||||||
|
DownHz int64 `json:"down_hz"` // where to LISTEN for a nominal downlink
|
||||||
|
UpHz int64 `json:"up_hz"` // where to TRANSMIT for a nominal uplink
|
||||||
|
}
|
||||||
|
|
||||||
|
// Doppler corrects a nominal uplink/downlink pair for the satellite's motion.
|
||||||
|
//
|
||||||
|
// Two corrections, opposite in sign, and that is the part worth being careful
|
||||||
|
// about: the DOWNLINK is what we receive, so it arrives shifted and we tune to
|
||||||
|
// meet it — approaching (negative range rate) means a higher frequency. The
|
||||||
|
// UPLINK is what the satellite receives, so we must transmit shifted the other
|
||||||
|
// way for it to land on the transponder's nominal input.
|
||||||
|
//
|
||||||
|
// Zero in, zero out: a satellite with no uplink (a beacon) is not given an
|
||||||
|
// invented one.
|
||||||
|
func Doppler(p Position, downHz, upHz int64) Shift {
|
||||||
|
f := -p.RangeRate / speedOfLightKmS // fraction, positive when approaching
|
||||||
|
var s Shift
|
||||||
|
if downHz > 0 {
|
||||||
|
s.DownHz = downHz + int64(math.Round(float64(downHz)*f))
|
||||||
|
}
|
||||||
|
if upHz > 0 {
|
||||||
|
s.UpHz = upHz - int64(math.Round(float64(upHz)*f))
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package sat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A real ISS element set, and the answers a second tracker agrees with. The
|
||||||
|
// point is not the third decimal — it is that the observer, the epoch and the
|
||||||
|
// look angle are wired the right way round, which is exactly what silently
|
||||||
|
// comes out mirrored or an hour late.
|
||||||
|
const (
|
||||||
|
issName = "ISS (ZARYA)"
|
||||||
|
issLine1 = "1 25544U 98067A 24298.54791435 .00016717 00000+0 30074-3 0 9991"
|
||||||
|
issLine2 = "2 25544 51.6392 121.4587 0007976 86.1587 27.9639 15.50126585478227"
|
||||||
|
)
|
||||||
|
|
||||||
|
func issElement(t *testing.T) Element {
|
||||||
|
t.Helper()
|
||||||
|
e, err := ParseElement(issName, issLine1, issLine2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestElementCarriesItsIdentityAndEpoch(t *testing.T) {
|
||||||
|
e := issElement(t)
|
||||||
|
if e.NORAD != 25544 {
|
||||||
|
t.Errorf("NORAD = %d, want 25544", e.NORAD)
|
||||||
|
}
|
||||||
|
// Day 298.548 of 2024 — the day the elements were computed.
|
||||||
|
want := time.Date(2024, 10, 24, 13, 9, 0, 0, time.UTC)
|
||||||
|
if d := e.Epoch.Sub(want); d > time.Minute || d < -time.Minute {
|
||||||
|
t.Errorf("epoch = %s, want about %s", e.Epoch.Format(time.RFC3339), want.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The satellite is somewhere, that somewhere is on Earth's scale, and the look
|
||||||
|
// angles are self-consistent: a bird below the horizon is further away than one
|
||||||
|
// overhead, and the footprint is a plausible circle.
|
||||||
|
func TestTrackIsSaneFromAKnownStation(t *testing.T) {
|
||||||
|
e := issElement(t)
|
||||||
|
obs := Observer{Lat: 48.85, Lon: 2.35, AltM: 35} // JN18, Paris
|
||||||
|
at := time.Date(2024, 10, 24, 14, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
p, err := e.Track(obs, at)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("track: %v", err)
|
||||||
|
}
|
||||||
|
if p.Lat < -90 || p.Lat > 90 || p.Lon < -180 || p.Lon > 180 {
|
||||||
|
t.Errorf("sub-satellite point off the planet: %.3f %.3f", p.Lat, p.Lon)
|
||||||
|
}
|
||||||
|
if p.AltKm < 300 || p.AltKm > 500 {
|
||||||
|
t.Errorf("altitude %.1f km — the ISS is not there", p.AltKm)
|
||||||
|
}
|
||||||
|
if p.Az < 0 || p.Az >= 360 || p.El < -90 || p.El > 90 {
|
||||||
|
t.Errorf("look angles out of range: az %.1f el %.1f", p.Az, p.El)
|
||||||
|
}
|
||||||
|
// A satellite on the FAR side of the planet is still at a distance — up to
|
||||||
|
// two Earth radii plus its height — so the useful invariant is the one that
|
||||||
|
// holds when it is actually up: above the horizon it cannot be further away
|
||||||
|
// than the slant range to its own footprint edge.
|
||||||
|
if p.RangeKm < 300 || p.RangeKm > 13200 {
|
||||||
|
t.Errorf("range %.0f km is not this orbit seen from the ground", p.RangeKm)
|
||||||
|
}
|
||||||
|
if p.El > 0 && p.RangeKm > 2600 {
|
||||||
|
t.Errorf("visible at %.1f° yet %.0f km away", p.El, p.RangeKm)
|
||||||
|
}
|
||||||
|
// ~2000 km of visibility circle at 420 km up.
|
||||||
|
if p.Footprint < 1500 || p.Footprint > 2600 {
|
||||||
|
t.Errorf("footprint %.0f km", p.Footprint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Twelve hours of ISS passes over a European station: there are always several,
|
||||||
|
// they rise before they set, and the filter keeps its promise.
|
||||||
|
func TestPassesRiseBeforeTheySetAndRespectTheFloor(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
s.Put(issElement(t))
|
||||||
|
obs := Observer{Lat: 48.85, Lon: 2.35, AltM: 35}
|
||||||
|
from := time.Date(2024, 10, 24, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
all, err := s.Passes(issName, obs, from, from.Add(12*time.Hour), 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("passes: %v", err)
|
||||||
|
}
|
||||||
|
if len(all) == 0 {
|
||||||
|
t.Fatal("no ISS pass in twelve hours over Paris")
|
||||||
|
}
|
||||||
|
for _, p := range all {
|
||||||
|
if !p.LOS.After(p.AOS) {
|
||||||
|
t.Errorf("%s: sets (%s) before it rises (%s)", p.Name, p.LOS, p.AOS)
|
||||||
|
}
|
||||||
|
if p.MaxEl <= 0 || p.MaxEl > 90 {
|
||||||
|
t.Errorf("max elevation %.1f", p.MaxEl)
|
||||||
|
}
|
||||||
|
if p.MaxElAt.Before(p.AOS) || p.MaxElAt.After(p.LOS) {
|
||||||
|
t.Errorf("the highest point falls outside the pass")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
high, err := s.Passes(issName, obs, from, from.Add(12*time.Hour), 30)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("passes: %v", err)
|
||||||
|
}
|
||||||
|
if len(high) > len(all) {
|
||||||
|
t.Error("the elevation floor let MORE passes through")
|
||||||
|
}
|
||||||
|
for _, p := range high {
|
||||||
|
if p.MaxEl < 30 {
|
||||||
|
t.Errorf("a %.1f° pass survived a 30° floor", p.MaxEl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two corrections go in OPPOSITE directions, and that is the whole of it:
|
||||||
|
// the downlink arrives shifted so we tune to meet it, while the uplink has to
|
||||||
|
// leave shifted the other way to land on the transponder's nominal input.
|
||||||
|
func TestDopplerCorrectsBothWaysRoundTheRightWay(t *testing.T) {
|
||||||
|
const down, up = 145_950_000, 435_250_000
|
||||||
|
|
||||||
|
approaching := Position{RangeRate: -7.0} // km/s, coming towards us
|
||||||
|
receding := Position{RangeRate: +7.0}
|
||||||
|
|
||||||
|
a := Doppler(approaching, down, up)
|
||||||
|
if a.DownHz <= down {
|
||||||
|
t.Errorf("approaching: listen at %d, expected above %d", a.DownHz, down)
|
||||||
|
}
|
||||||
|
if a.UpHz >= up {
|
||||||
|
t.Errorf("approaching: transmit at %d, expected below %d", a.UpHz, up)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := Doppler(receding, down, up)
|
||||||
|
if r.DownHz >= down {
|
||||||
|
t.Errorf("receding: listen at %d, expected below %d", r.DownHz, down)
|
||||||
|
}
|
||||||
|
if r.UpHz <= up {
|
||||||
|
t.Errorf("receding: transmit at %d, expected above %d", r.UpHz, up)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size, not just sign: 7 km/s on 145.950 MHz is about 3.4 kHz.
|
||||||
|
if d := math.Abs(float64(a.DownHz - down)); d < 3000 || d > 3800 {
|
||||||
|
t.Errorf("shift of %.0f Hz on 2 m at 7 km/s", d)
|
||||||
|
}
|
||||||
|
// Stationary is untouched, and an absent uplink is not invented.
|
||||||
|
if s := Doppler(Position{}, down, 0); s.DownHz != down || s.UpHz != 0 {
|
||||||
|
t.Errorf("a still satellite was corrected: %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreReplaceKeepsOrderAndStampsTheFetch(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
e := issElement(t)
|
||||||
|
at := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC)
|
||||||
|
s.Replace([]Element{e}, at)
|
||||||
|
|
||||||
|
if s.Len() != 1 || s.Names()[0] != issName {
|
||||||
|
t.Errorf("store holds %v", s.Names())
|
||||||
|
}
|
||||||
|
if !s.FetchedAt().Equal(at) {
|
||||||
|
t.Errorf("fetched at %s", s.FetchedAt())
|
||||||
|
}
|
||||||
|
// Case and spacing vary between feeds and typists; the name is not a
|
||||||
|
// password.
|
||||||
|
if _, ok := s.Get("iss (zarya)"); !ok {
|
||||||
|
t.Error("a satellite could not be found under its own name in another case")
|
||||||
|
}
|
||||||
|
if _, err := s.Track("NOTHING", Observer{}, at); err == nil {
|
||||||
|
t.Error("an unknown satellite was tracked anyway")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package sat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Where the elements come from, and where they are kept.
|
||||||
|
//
|
||||||
|
// Celestrak's amateur group is the list every tracker in this hobby uses; the
|
||||||
|
// PE0SAT mirror is there for the day Celestrak is down or rate-limiting, which
|
||||||
|
// it does when a hundred trackers all wake up at the top of the hour.
|
||||||
|
const (
|
||||||
|
FeedCelestrak = "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=tle"
|
||||||
|
FeedPE0SAT = "http://tle.pe0sat.nl/kepler/amateur.txt"
|
||||||
|
// CacheName is the file kept in the data directory. Plain TLE text, so an
|
||||||
|
// operator can open it, read it, and paste a line into a tracker that is not
|
||||||
|
// this one.
|
||||||
|
CacheName = "satellites.tle"
|
||||||
|
// StaleAfter is when elements stop being worth trusting silently. SGP4 drifts
|
||||||
|
// a few hundred metres a day for a low orbit, which is nothing for a pass
|
||||||
|
// prediction and everything for a rotator at high elevation — so the age is
|
||||||
|
// SHOWN rather than enforced, and this is only the point at which OpsLog
|
||||||
|
// offers to fetch again.
|
||||||
|
StaleAfter = 3 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseTLESet reads a whole feed or cache file: three lines per satellite —
|
||||||
|
// name, then the two element lines — or two where the name is absent.
|
||||||
|
//
|
||||||
|
// A malformed satellite is SKIPPED, not fatal. A feed of two hundred birds with
|
||||||
|
// one bad checksum must still give the operator the other hundred and
|
||||||
|
// ninety-nine, and the count of what was dropped is returned so the app can say
|
||||||
|
// so instead of quietly holding a shorter list.
|
||||||
|
func ParseTLESet(r io.Reader) (els []Element, skipped int, err error) {
|
||||||
|
sc := bufio.NewScanner(r)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
|
||||||
|
var pending []string
|
||||||
|
flush := func() {
|
||||||
|
defer func() { pending = nil }()
|
||||||
|
var name, l1, l2 string
|
||||||
|
switch len(pending) {
|
||||||
|
case 3:
|
||||||
|
name, l1, l2 = pending[0], pending[1], pending[2]
|
||||||
|
case 2:
|
||||||
|
l1, l2 = pending[0], pending[1]
|
||||||
|
default:
|
||||||
|
if len(pending) > 0 {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e, perr := ParseElement(name, l1, l2)
|
||||||
|
if perr != nil {
|
||||||
|
skipped++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
els = append(els, e)
|
||||||
|
}
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimRight(sc.Text(), " \t\r")
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
flush()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A "1 " or "2 " line is an element line; anything else starts a new
|
||||||
|
// satellite. That rule reads both the three-line and the two-line form
|
||||||
|
// without the file having to say which it is.
|
||||||
|
isElement := len(line) > 2 && (line[0] == '1' || line[0] == '2') && line[1] == ' '
|
||||||
|
if !isElement && len(pending) > 0 {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
pending = append(pending, line)
|
||||||
|
if len(pending) == 3 || (len(pending) == 2 && strings.HasPrefix(pending[0], "1 ")) {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
return els, skipped, fmt.Errorf("sat: reading the element set: %w", err)
|
||||||
|
}
|
||||||
|
if len(els) == 0 {
|
||||||
|
return nil, skipped, fmt.Errorf("sat: no usable elements in that set (%d entries refused)", skipped)
|
||||||
|
}
|
||||||
|
return els, skipped, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetcher loads element sets from the feeds and keeps a copy on disk.
|
||||||
|
type Fetcher struct {
|
||||||
|
Dir string // where the cache file lives — the app's data directory
|
||||||
|
Feeds []string // tried in order; the first that answers wins
|
||||||
|
Timeout time.Duration // per feed
|
||||||
|
Logf func(string, ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFetcher builds one with the usual feeds.
|
||||||
|
func NewFetcher(dir string) *Fetcher {
|
||||||
|
return &Fetcher{
|
||||||
|
Dir: dir,
|
||||||
|
Feeds: []string{FeedCelestrak, FeedPE0SAT},
|
||||||
|
Timeout: 20 * time.Second,
|
||||||
|
Logf: func(string, ...any) {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) cachePath() string { return filepath.Join(f.Dir, CacheName) }
|
||||||
|
|
||||||
|
// LoadCache reads the elements kept from last time, with the file's own
|
||||||
|
// modification time as the fetch time.
|
||||||
|
//
|
||||||
|
// This is what makes the first screen after a launch a full one: an operator who
|
||||||
|
// opens the satellite tab on a train, or on a shack PC with no internet, still
|
||||||
|
// gets last week's elements — which are perfectly good for knowing what passes
|
||||||
|
// tonight — instead of an empty list and a spinner.
|
||||||
|
func (f *Fetcher) LoadCache() ([]Element, time.Time, error) {
|
||||||
|
p := f.cachePath()
|
||||||
|
fh, err := os.Open(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, err
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
els, skipped, err := ParseTLESet(fh)
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, err
|
||||||
|
}
|
||||||
|
at := time.Time{}
|
||||||
|
if st, serr := os.Stat(p); serr == nil {
|
||||||
|
at = st.ModTime()
|
||||||
|
}
|
||||||
|
if skipped > 0 {
|
||||||
|
f.Logf("sat: %d cached entries were unusable and were skipped", skipped)
|
||||||
|
}
|
||||||
|
return els, at, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch downloads a fresh set and writes the cache.
|
||||||
|
//
|
||||||
|
// The cache is only replaced once a feed has produced usable elements: a feed
|
||||||
|
// that answers with an error page, a captive-portal login or an empty file must
|
||||||
|
// not take away the set the station already had.
|
||||||
|
func (f *Fetcher) Fetch(ctx context.Context) ([]Element, error) {
|
||||||
|
var lastErr error
|
||||||
|
for _, url := range f.Feeds {
|
||||||
|
els, body, err := f.fetchOne(ctx, url)
|
||||||
|
if err != nil {
|
||||||
|
f.Logf("sat: %s: %v", shortHost(url), err)
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := f.writeCache(body); err != nil {
|
||||||
|
// Not fatal: the elements are in hand and the station can track
|
||||||
|
// tonight. Only the next cold start loses by it, and it says so.
|
||||||
|
f.Logf("sat: could not write the element cache: %v", err)
|
||||||
|
}
|
||||||
|
f.Logf("sat: %d satellites from %s", len(els), shortHost(url))
|
||||||
|
return els, nil
|
||||||
|
}
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("no feed configured")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("sat: could not fetch the element set: %w", lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) fetchOne(ctx context.Context, url string) ([]Element, []byte, error) {
|
||||||
|
to := f.Timeout
|
||||||
|
if to <= 0 {
|
||||||
|
to = 20 * time.Second
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, to)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
// Named, because Celestrak asks that clients identify themselves and answers
|
||||||
|
// an anonymous flood with a rate limit.
|
||||||
|
req.Header.Set("User-Agent", "OpsLog satellite tracker")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, nil, fmt.Errorf("HTTP %s", resp.Status)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
els, skipped, err := ParseTLESet(strings.NewReader(string(body)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if skipped > 0 {
|
||||||
|
f.Logf("sat: %s: %d entries were unusable and were skipped", shortHost(url), skipped)
|
||||||
|
}
|
||||||
|
return els, body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) writeCache(body []byte) error {
|
||||||
|
if strings.TrimSpace(f.Dir) == "" {
|
||||||
|
return fmt.Errorf("no data directory")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(f.Dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Written beside and renamed: a power cut mid-write must not leave a
|
||||||
|
// half-file that parses as twenty satellites instead of two hundred.
|
||||||
|
tmp := f.cachePath() + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, body, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, f.cachePath())
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortHost is a feed's host, for a log line that fits.
|
||||||
|
func shortHost(url string) string {
|
||||||
|
s := strings.TrimPrefix(strings.TrimPrefix(url, "https://"), "http://")
|
||||||
|
if i := strings.IndexAny(s, "/?"); i > 0 {
|
||||||
|
s = s[:i]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package sat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Both shapes of the same file: three lines per satellite, and the two-line
|
||||||
|
// form some feeds still serve. A reader that only understood one of them would
|
||||||
|
// come back empty from a mirror and look like a network fault.
|
||||||
|
func TestParseTLESetReadsBothShapes(t *testing.T) {
|
||||||
|
three := issName + "\n" + issLine1 + "\n" + issLine2 + "\n"
|
||||||
|
els, skipped, err := ParseTLESet(strings.NewReader(three))
|
||||||
|
if err != nil || len(els) != 1 || skipped != 0 {
|
||||||
|
t.Fatalf("three-line: %d sats, %d skipped, err %v", len(els), skipped, err)
|
||||||
|
}
|
||||||
|
if els[0].Name != issName {
|
||||||
|
t.Errorf("name %q", els[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
two := issLine1 + "\n" + issLine2 + "\n"
|
||||||
|
els, _, err = ParseTLESet(strings.NewReader(two))
|
||||||
|
if err != nil || len(els) != 1 {
|
||||||
|
t.Fatalf("two-line: %d sats, err %v", len(els), err)
|
||||||
|
}
|
||||||
|
if els[0].NORAD != 25544 {
|
||||||
|
t.Errorf("a nameless entry lost its identity: %+v", els[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One bad satellite must not cost the operator the other hundred and
|
||||||
|
// ninety-nine — but the count of what was dropped has to come back, or a
|
||||||
|
// silently shorter list reads as a complete one.
|
||||||
|
func TestParseTLESetSkipsWhatItCannotRead(t *testing.T) {
|
||||||
|
feed := strings.Join([]string{
|
||||||
|
"JUNK SATELLITE",
|
||||||
|
"1 99999U 00000A 24298.00000000 .00000000 00000+0 00000+0 0 0000", // bad checksum
|
||||||
|
"2 99999 00.0000 000.0000 0000000 000.0000 000.0000 00.00000000000000",
|
||||||
|
"",
|
||||||
|
issName, issLine1, issLine2,
|
||||||
|
}, "\n")
|
||||||
|
els, skipped, err := ParseTLESet(strings.NewReader(feed))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the whole feed was refused for one bad entry: %v", err)
|
||||||
|
}
|
||||||
|
if len(els) != 1 || els[0].Name != issName {
|
||||||
|
t.Errorf("kept %d satellites: %+v", len(els), els)
|
||||||
|
}
|
||||||
|
if skipped != 1 {
|
||||||
|
t.Errorf("skipped = %d, want 1 — a silently shorter list reads as a complete one", skipped)
|
||||||
|
}
|
||||||
|
// Nothing usable at all IS an error: an error page or a captive-portal login
|
||||||
|
// parses as zero satellites, and that must never replace a good set.
|
||||||
|
if _, _, err := ParseTLESet(strings.NewReader("<html>login required</html>")); err == nil {
|
||||||
|
t.Error("an HTML error page was accepted as an element set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cache is what makes the first screen after a launch a full one — on a
|
||||||
|
// train, or on a shack PC with no internet.
|
||||||
|
func TestCacheRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
f := NewFetcher(dir)
|
||||||
|
f.Logf = func(string, ...any) {}
|
||||||
|
|
||||||
|
body := issName + "\n" + issLine1 + "\n" + issLine2 + "\n"
|
||||||
|
if err := f.writeCache([]byte(body)); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, CacheName)); err != nil {
|
||||||
|
t.Fatalf("the cache file is not where an operator would look for it: %v", err)
|
||||||
|
}
|
||||||
|
// And no leftovers: the temp file is renamed, not copied.
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, CacheName+".tmp")); err == nil {
|
||||||
|
t.Error("the half-written file was left behind")
|
||||||
|
}
|
||||||
|
|
||||||
|
els, at, err := f.LoadCache()
|
||||||
|
if err != nil || len(els) != 1 {
|
||||||
|
t.Fatalf("load: %d sats, err %v", len(els), err)
|
||||||
|
}
|
||||||
|
if at.IsZero() {
|
||||||
|
t.Error("the cache has no age, so nothing can say whether to trust it")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,12 @@ func TestProfileSwitchReappliesEveryStartupDevice(t *testing.T) {
|
|||||||
// sweepers. Its settings do follow the profile: reloadAfterProfileSwitch
|
// sweepers. Its settings do follow the profile: reloadAfterProfileSwitch
|
||||||
// calls applyAutoCall, which re-reads them and clears the target.
|
// calls applyAutoCall, which re-reads them and clears the target.
|
||||||
"startAutoCall": "a single sweeper goroutine; applyAutoCall in the reload carries the settings",
|
"startAutoCall": "a single sweeper goroutine; applyAutoCall in the reload carries the settings",
|
||||||
|
// The elements and the frequency plan are FILES, shared by every
|
||||||
|
// profile — there is one sky. What is per profile (the favourites, the
|
||||||
|
// minimum elevation, the locator) is read live on every call, so a
|
||||||
|
// switch is already reflected without rebuilding anything. The tracker,
|
||||||
|
// which does transmit, IS stopped by reloadAfterProfileSwitch.
|
||||||
|
"startSatellites": "one sky: the elements and the plan are shared files, and the per-profile settings are read live",
|
||||||
}
|
}
|
||||||
|
|
||||||
startup := body(t, string(src), "func (a *App) startup(ctx context.Context) {")
|
startup := body(t, string(src), "func (a *App) startup(ctx context.Context) {")
|
||||||
|
|||||||
+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.15"
|
appVersion = "0.27.18"
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
@@ -212,20 +212,28 @@ func (a *App) DownloadAndApplyUpdate(url string) error {
|
|||||||
_ = os.Remove(exe + ":Zone.Identifier")
|
_ = os.Remove(exe + ":Zone.Identifier")
|
||||||
applog.Printf("update: installed new exe, scheduling relaunch")
|
applog.Printf("update: installed new exe, scheduling relaunch")
|
||||||
|
|
||||||
// Relaunch via a detached, hidden PowerShell that WAITS for this process to exit
|
// THE NEW EXE STARTS ITSELF. No helper, no script.
|
||||||
// (so the single-instance mutex is free) and THEN starts the new exe. Launching
|
//
|
||||||
// the new exe directly while we're still alive raced the mutex and often left
|
// This used to go through a hidden PowerShell that waited for our process to
|
||||||
// nothing running; waiting for our own exit first makes the restart reliable,
|
// die and then launched the new image — which is, byte for byte, the shape of
|
||||||
// and the launcher outlives us.
|
// a dropper: an unsigned binary replaces itself on disk, clears the
|
||||||
quoted := strings.ReplaceAll(exe, "'", "''")
|
// mark-of-the-web, and spawns a windowless PowerShell that starts another
|
||||||
ps := fmt.Sprintf(
|
// executable. Windows Defender's machine-learning model reads that shape and
|
||||||
"Wait-Process -Id %d -ErrorAction SilentlyContinue; Start-Sleep -Milliseconds 400; Start-Process -FilePath '%s' -ArgumentList '--post-update'",
|
// not our intentions, and an operator updating to 0.27.14 had OpsLog removed
|
||||||
os.Getpid(), quoted)
|
// under Trojan:Script/Wacatac.H!ml — the "Script/" being the PowerShell.
|
||||||
cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
|
//
|
||||||
|
// The wait it existed for is not needed: --post-update already makes the new
|
||||||
|
// instance patient with the single-instance mutex (see acquireInstance), so it
|
||||||
|
// can start while this one is still shutting down and simply wait its turn.
|
||||||
|
cmd := exec.Command(exe, "--post-update")
|
||||||
|
cmd.Dir = dir
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return fmt.Errorf("schedule relaunch: %w", err)
|
return fmt.Errorf("schedule relaunch: %w", err)
|
||||||
}
|
}
|
||||||
|
// Released rather than waited on: this process is about to exit, and a child
|
||||||
|
// that outlives its parent must not be left as a zombie handle.
|
||||||
|
_ = cmd.Process.Release()
|
||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
wruntime.Quit(a.ctx)
|
wruntime.Quit(a.ctx)
|
||||||
} else {
|
} else {
|
||||||
@@ -249,6 +257,12 @@ func (a *App) DownloadAndApplyUpdate(url string) error {
|
|||||||
// than an update that waits. Only a successful swap passes --post-update, so a
|
// than an update that waits. Only a successful swap passes --post-update, so a
|
||||||
// failure leaves the .new file in place for the next attempt rather than having
|
// failure leaves the .new file in place for the next attempt rather than having
|
||||||
// the cleanup delete the download.
|
// the cleanup delete the download.
|
||||||
|
// The LAST resort still needs a helper that outlives this process: nothing else
|
||||||
|
// can move a file over an image that is still running. It stays PowerShell —
|
||||||
|
// there is no smaller tool on a stock Windows that can wait for a pid and then
|
||||||
|
// move a file — but it is reached only when the rename above failed, which is
|
||||||
|
// rare, and never on the ordinary update path (see the relaunch there for why
|
||||||
|
// that matters to Defender).
|
||||||
func (a *App) scheduleDeferredSwap(exe, pending string) error {
|
func (a *App) scheduleDeferredSwap(exe, pending string) error {
|
||||||
// Clear the "downloaded from the internet" mark before it becomes the exe —
|
// Clear the "downloaded from the internet" mark before it becomes the exe —
|
||||||
// SmartScreen silently blocks a programmatic launch of a marked file, and the
|
// SmartScreen silently blocks a programmatic launch of a marked file, and the
|
||||||
|
|||||||
Reference in New Issue
Block a user