Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c187f60415 | ||
|
|
82c5946ae3 | ||
|
|
c293cc1391 | ||
|
|
9e67ddcea4 | ||
|
|
fd93036f86 | ||
|
|
031fcbd543 | ||
|
|
72696a5c0c | ||
|
|
d0d29659cb | ||
|
|
6f9b996db8 | ||
|
|
9dcab0568b | ||
|
|
a8e870e098 | ||
|
|
7e6e1335e3 | ||
|
|
3fe14c2c77 | ||
|
|
e9816241e8 | ||
|
|
707f0bc848 | ||
|
|
b2e93e9164 | ||
|
|
eb06cc29b0 | ||
|
|
d25edd114c | ||
|
|
f090e845ff | ||
|
|
d2e62debe8 | ||
|
|
8359caab16 | ||
|
|
f998023a28 | ||
|
|
3b6269978c | ||
|
|
052fc4cb80 | ||
|
|
2615365684 | ||
|
|
580e5782f8 | ||
|
|
3ce74ef8d7 | ||
|
|
3af6299f32 | ||
|
|
136c5d5b6b | ||
|
|
d8c9f05d10 | ||
|
|
a88e871640 |
@@ -56,6 +56,7 @@ import (
|
|||||||
"hamlog/internal/powergenius"
|
"hamlog/internal/powergenius"
|
||||||
"hamlog/internal/profile"
|
"hamlog/internal/profile"
|
||||||
"hamlog/internal/pskr"
|
"hamlog/internal/pskr"
|
||||||
|
"hamlog/internal/pskrme"
|
||||||
"hamlog/internal/pskrtgt"
|
"hamlog/internal/pskrtgt"
|
||||||
"hamlog/internal/psu"
|
"hamlog/internal/psu"
|
||||||
"hamlog/internal/qslcard"
|
"hamlog/internal/qslcard"
|
||||||
@@ -773,6 +774,11 @@ type App struct {
|
|||||||
// The Chase new panel's OWN band filter, as the set switched off. Read per
|
// The Chase new panel's OWN band filter, as the set switched off. Read per
|
||||||
// message like the list above, so an atomic rather than a settings lookup.
|
// message like the list above, so an atomic rather than a settings lookup.
|
||||||
chaseNewBandsOff atomic.Value // map[string]bool
|
chaseNewBandsOff atomic.Value // map[string]bool
|
||||||
|
// hearMe is the THIRD PSK Reporter connection: who is reporting our own
|
||||||
|
// transmissions, for the reverse layer on the FT map. Its own connection
|
||||||
|
// for the same reason as pskTgt — a different slice of the feed, and one
|
||||||
|
// that must keep working with the opening watch switched off.
|
||||||
|
hearMe *pskrme.Watcher
|
||||||
// pskr is the PSK Reporter MQTT feed, up only while the opening watch is on.
|
// pskr is the PSK Reporter MQTT feed, up only while the opening watch is on.
|
||||||
// It is the source that makes VHF detection work at all: the cluster and RBN
|
// It is the source that makes VHF detection work at all: the cluster and RBN
|
||||||
// carry a handful of 6 m spots where PSK Reporter carries hundreds.
|
// carry a handful of 6 m spots where PSK Reporter carries hundreds.
|
||||||
@@ -1732,6 +1738,9 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
// PSK Reporter. After the operator's grid is known: without it there is no
|
// PSK Reporter. After the operator's grid is known: without it there is no
|
||||||
// distance to measure and no receiver squares to filter on, so it stays down.
|
// distance to measure and no receiver squares to filter on, so it stays down.
|
||||||
a.startBandOpenFeed()
|
a.startBandOpenFeed()
|
||||||
|
// The reverse layer. Needs the CALLSIGN rather than the grid, so it goes
|
||||||
|
// after the profile is scoped, which it already is by here.
|
||||||
|
a.startHearMe()
|
||||||
// Auto-call. After the watch list and the logbook: the ladder is meaningless
|
// Auto-call. After the watch list and the logbook: the ladder is meaningless
|
||||||
// without either, and it must never call anybody on a log it cannot read.
|
// without either, and it must never call anybody on a log it cannot read.
|
||||||
a.startAutoCall()
|
a.startAutoCall()
|
||||||
@@ -2367,7 +2376,10 @@ func (a *App) restoreWindowPosition() {
|
|||||||
applog.Printf("window: maximised with corner 0,0 — nothing to restore")
|
applog.Printf("window: maximised with corner 0,0 — nothing to restore")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !onSomeMonitor(ws.X, ws.Y, normalMinW, normalMinH) {
|
// The saved size, not the minimum: this asks "is that corner on a
|
||||||
|
// screen", and with no minimum any more the old arguments were a
|
||||||
|
// zero-sized rectangle.
|
||||||
|
if !onSomeMonitor(ws.X, ws.Y, max(ws.Width, windowSaneW), max(ws.Height, windowSaneH)) {
|
||||||
applog.Printf("window: saved maximised corner %d,%d is off every monitor — opening where Windows puts it", ws.X, ws.Y)
|
applog.Printf("window: saved maximised corner %d,%d is off every monitor — opening where Windows puts it", ws.X, ws.Y)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2378,7 +2390,7 @@ func (a *App) restoreWindowPosition() {
|
|||||||
applog.Printf("window: re-maximised at the saved corner — now at %d,%d", gx, gy)
|
applog.Printf("window: re-maximised at the saved corner — now at %d,%d", gx, gy)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH {
|
if ws.Width < windowSaneW || ws.Height < windowSaneH || ws.Width > maxW || ws.Height > maxH {
|
||||||
return // corrupt / absurd — leave the default placement
|
return // corrupt / absurd — leave the default placement
|
||||||
}
|
}
|
||||||
// The SIZE was sanity-checked above but the POSITION never was, and that is
|
// The SIZE was sanity-checked above but the POSITION never was, and that is
|
||||||
@@ -3094,8 +3106,7 @@ func (a *App) RestartApp() error {
|
|||||||
// --wait-pid, so it waits for this process to actually END rather than for a
|
// --wait-pid, so it waits for this process to actually END rather than for a
|
||||||
// window of time to pass: shutting down closes a logbook and a CAT session,
|
// window of time to pass: shutting down closes a logbook and a CAT session,
|
||||||
// and on a remote database that takes as long as it takes.
|
// and on a remote database that takes as long as it takes.
|
||||||
cmd := exec.Command(exe, "--relaunch", "--wait-pid", strconv.Itoa(os.Getpid()))
|
cmd := relaunchCmd(exe, "--relaunch", "--wait-pid", strconv.Itoa(os.Getpid()))
|
||||||
cmd.Dir = filepath.Dir(exe)
|
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return fmt.Errorf("relaunch OpsLog: %w", err)
|
return fmt.Errorf("relaunch OpsLog: %w", err)
|
||||||
}
|
}
|
||||||
@@ -5372,6 +5383,21 @@ var awardBandPlan = []struct {
|
|||||||
{"2mm", 134000000000, 149000000000}, {"1mm", 241000000000, 250000000000},
|
{"2mm", 134000000000, 149000000000}, {"1mm", 241000000000, 250000000000},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bandOrder ranks a band name by frequency, for sorting a list of them.
|
||||||
|
//
|
||||||
|
// The band plan is already written low to high, so its index IS the order —
|
||||||
|
// which beats sorting the names, where 10m lands between 1.25m and 12m.
|
||||||
|
// Anything not in the plan sorts last rather than first: an unrecognised band
|
||||||
|
// is almost always a typo in an imported file, and it belongs at the bottom.
|
||||||
|
func bandOrder(name string) int {
|
||||||
|
name = strings.ToLower(strings.TrimSpace(name))
|
||||||
|
for i, b := range awardBandPlan {
|
||||||
|
if b.name == name {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(awardBandPlan)
|
||||||
|
}
|
||||||
func bandForHz(hz int64) string {
|
func bandForHz(hz int64) string {
|
||||||
for _, b := range awardBandPlan {
|
for _, b := range awardBandPlan {
|
||||||
if hz >= b.lo && hz <= b.hi {
|
if hz >= b.lo && hz <= b.hi {
|
||||||
@@ -7556,34 +7582,88 @@ func (a *App) SendDecodeFreeText(instance, text string, send bool) error {
|
|||||||
// GridSquares returns the 4-character Maidenhead squares in the log, with
|
// GridSquares returns the 4-character Maidenhead squares in the log, with
|
||||||
// whether each is confirmed.
|
// whether each is confirmed.
|
||||||
//
|
//
|
||||||
// modeClass scopes it the way the rest of the app does: "DIGI", "CW", "PHONE",
|
// mode scopes it the way the rest of the app does — "DIGI", "CW", "PHONE", or
|
||||||
// or "" for every mode. "DIGI" is the one this was built for — a map of the
|
// "" for every mode — but it also takes ONE mode's own name, which is what an
|
||||||
// squares worked on FT8/FT4 answers "where have I actually been heard" in a way
|
// operator actually wants here: "digital" put a contest RTTY square beside an
|
||||||
// no list of callsigns does.
|
// FT8 one and called them the same answer, when the question behind this map is
|
||||||
|
// where a particular mode has been heard.
|
||||||
|
//
|
||||||
|
// band and satName narrow it further, both empty meaning no restriction. The
|
||||||
|
// satellite is the reason this map is worth filtering at all for anyone chasing
|
||||||
|
// grids through a bird: a VHF square worked terrestrially and one worked through
|
||||||
|
// AO-91 are not the same achievement, and nothing else told them apart.
|
||||||
//
|
//
|
||||||
// Confirmed means LoTW, a card or eQSL — the same three the award engine counts,
|
// Confirmed means LoTW, a card or eQSL — the same three the award engine counts,
|
||||||
// so a square cannot be green here and unconfirmed in the Awards panel.
|
// so a square cannot be green here and unconfirmed in the Awards panel.
|
||||||
func (a *App) GridSquares(modeClass string) ([]qso.GridSquare, error) {
|
func (a *App) GridSquares(mode, band, satName string) ([]qso.GridSquare, error) {
|
||||||
if a.qso == nil {
|
if a.qso == nil {
|
||||||
return nil, fmt.Errorf("db not initialized")
|
return nil, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
want := strings.ToUpper(strings.TrimSpace(modeClass))
|
want := strings.ToUpper(strings.TrimSpace(mode))
|
||||||
keep := func(mode string) bool {
|
wantBand := strings.ToLower(strings.TrimSpace(band))
|
||||||
|
wantSat := strings.ToUpper(strings.TrimSpace(satName))
|
||||||
|
keepMode := func(r qso.GridSquareRow) bool {
|
||||||
switch want {
|
switch want {
|
||||||
case "", "ALL":
|
case "", "ALL":
|
||||||
return true
|
return true
|
||||||
case "FTX":
|
case "FTX":
|
||||||
// The FT family alone, which is narrower than digital and usually the
|
// Retired from the UI, kept because a stored preference may still say
|
||||||
// honest answer beside an FTx panel: a square worked on RTTY in a
|
// it: the FT family alone, narrower than digital.
|
||||||
// contest is not a square worked on FT8.
|
return ftxModes[r.Mode] || ftxModes[r.Submode]
|
||||||
return ftxModes[strings.ToUpper(strings.TrimSpace(mode))]
|
case "PHONE", "CW", "DIGI":
|
||||||
|
return award.ModeClass(r.Mode) == want
|
||||||
default:
|
default:
|
||||||
return award.ModeClass(mode) == want
|
// One named mode. Matched against the SUBMODE as well, because ADIF
|
||||||
|
// files PSK63 under PSK: a log carrying the pair would answer nothing
|
||||||
|
// at all to the only name the operator recognises.
|
||||||
|
return r.Mode == want || r.Submode == want
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
keep := func(r qso.GridSquareRow) bool {
|
||||||
|
if !keepMode(r) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if wantBand != "" && wantBand != "all" && r.Band != wantBand {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if wantSat != "" && wantSat != "ALL" && r.SatName != wantSat {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
return a.qso.GridSquares(a.ctx, keep)
|
return a.qso.GridSquares(a.ctx, keep)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GridSquareChoices is what the grid map's three filters can offer: the modes,
|
||||||
|
// bands and satellites the squares in the log were actually worked on.
|
||||||
|
//
|
||||||
|
// Read from the log, not from a list here, so a filter never offers a choice
|
||||||
|
// with nothing behind it and never omits one with something. FT2 is the case
|
||||||
|
// that settled it: it is not a registered ADIF mode yet, and hardcoding the
|
||||||
|
// list meant either leaving out operators already using it or shipping a mode
|
||||||
|
// that is not official — a query does neither.
|
||||||
|
type GridSquareChoices struct {
|
||||||
|
Modes []string `json:"modes"`
|
||||||
|
Bands []string `json:"bands"`
|
||||||
|
Satellites []string `json:"satellites"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) GridSquareChoices() (GridSquareChoices, error) {
|
||||||
|
if a.qso == nil {
|
||||||
|
return GridSquareChoices{}, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
modes, bands, sats, err := a.qso.GridSquareChoices(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return GridSquareChoices{}, err
|
||||||
|
}
|
||||||
|
// Bands in frequency order, the way every other band list in the app reads;
|
||||||
|
// modes and satellites alphabetically, there being no other order for them.
|
||||||
|
sort.Slice(bands, func(i, j int) bool { return bandOrder(bands[i]) < bandOrder(bands[j]) })
|
||||||
|
sort.Strings(modes)
|
||||||
|
sort.Strings(sats)
|
||||||
|
return GridSquareChoices{Modes: modes, Bands: bands, Satellites: sats}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetCompactMode toggles a tiny always-on-top window that exposes just the
|
// SetCompactMode toggles a tiny always-on-top window that exposes just the
|
||||||
// QSO entry — useful when running on a single screen alongside WSJT-X,
|
// QSO entry — useful when running on a single screen alongside WSJT-X,
|
||||||
// JT-Alert or the cluster.
|
// JT-Alert or the cluster.
|
||||||
@@ -7594,9 +7674,16 @@ func (a *App) GridSquares(modeClass string) ([]qso.GridSquare, error) {
|
|||||||
// Min size must be reduced BEFORE resizing down, otherwise the OS clamps to
|
// Min size must be reduced BEFORE resizing down, otherwise the OS clamps to
|
||||||
// the previous (larger) min — and increased BEFORE resizing up.
|
// the previous (larger) min — and increased BEFORE resizing up.
|
||||||
const (
|
const (
|
||||||
compactW, compactH = 1240, 158
|
compactW, compactH = 1240, 158
|
||||||
normalW, normalH = 1400, 900
|
normalW, normalH = 1400, 900
|
||||||
normalMinW, normalMinH = 1100, 700
|
// No minimum on the normal window: 0 tells Wails not to constrain it at
|
||||||
|
// all, and the operator decides how small is useful. See main.go.
|
||||||
|
normalMinW, normalMinH = 0, 0
|
||||||
|
// windowSaneW/H is not a minimum the operator feels — it is the floor below
|
||||||
|
// which a SAVED geometry is treated as corrupt rather than as a choice. A
|
||||||
|
// window restored at 0x0 cannot be grabbed to fix it, and that is the one
|
||||||
|
// state there is no way back from.
|
||||||
|
windowSaneW, windowSaneH = 200, 150
|
||||||
// Large enough to never constrain a maximised window on big displays.
|
// Large enough to never constrain a maximised window on big displays.
|
||||||
maxW, maxH = 8000, 6000
|
maxW, maxH = 8000, 6000
|
||||||
)
|
)
|
||||||
@@ -7616,7 +7703,9 @@ func (a *App) SetCompactMode(on bool) {
|
|||||||
}
|
}
|
||||||
a.preCompactW, a.preCompactH = wruntime.WindowGetSize(a.ctx)
|
a.preCompactW, a.preCompactH = wruntime.WindowGetSize(a.ctx)
|
||||||
a.preCompactX, a.preCompactY = wruntime.WindowGetPosition(a.ctx)
|
a.preCompactX, a.preCompactY = wruntime.WindowGetPosition(a.ctx)
|
||||||
a.preCompactValid = a.preCompactW >= normalMinW && a.preCompactH >= normalMinH
|
// Against the sane floor, not a minimum — there is no longer one. This
|
||||||
|
// only asks whether the capture is believable enough to restore.
|
||||||
|
a.preCompactValid = a.preCompactW >= windowSaneW && a.preCompactH >= windowSaneH
|
||||||
}
|
}
|
||||||
a.compact = on
|
a.compact = on
|
||||||
if on {
|
if on {
|
||||||
@@ -17007,6 +17096,10 @@ func (a *App) reloadAfterProfileSwitch() {
|
|||||||
// Same reasoning for the satellite tracker: it transmits, and the new profile
|
// Same reasoning for the satellite tracker: it transmits, and the new profile
|
||||||
// may be a different station on a different antenna.
|
// may be a different station on a different antenna.
|
||||||
a.StopSatelliteTracking()
|
a.StopSatelliteTracking()
|
||||||
|
// A new profile is usually a new callsign, and the callsign IS this
|
||||||
|
// subscription — left alone it would go on reporting who hears the previous
|
||||||
|
// station.
|
||||||
|
a.startHearMe()
|
||||||
}
|
}
|
||||||
|
|
||||||
// DuplicateProfile clones an existing profile under newName. Useful when
|
// DuplicateProfile clones an existing profile under newName. Useful when
|
||||||
@@ -17094,7 +17187,7 @@ var rotatorTypes = []RotatorTypeInfo{
|
|||||||
{ID: "rotgenius", Label: "Rotator Genius (4O3A, native)", Network: true, DefaultPort: 9006},
|
{ID: "rotgenius", Label: "Rotator Genius (4O3A, native)", Network: true, DefaultPort: 9006},
|
||||||
{ID: "arco", Label: "GS-232 azimuth controller (microHAM ARCO, ERC)", Network: true, Serial: true, DefaultPort: 4001, DefaultBaud: 9600},
|
{ID: "arco", Label: "GS-232 azimuth controller (microHAM ARCO, ERC)", Network: true, Serial: true, DefaultPort: 4001, DefaultBaud: 9600},
|
||||||
{ID: "erc", Label: "ERC-M by DF9GR (Yaesu G-5500 az/el)", Elevation: true, Network: true, Serial: true, DefaultPort: 4001, DefaultBaud: 19200},
|
{ID: "erc", Label: "ERC-M by DF9GR (Yaesu G-5500 az/el)", Elevation: true, Network: true, Serial: true, DefaultPort: 4001, DefaultBaud: 19200},
|
||||||
{ID: "dcu1", Label: "Hy-Gain DCU-1 (RotorCard DXA, Rotor-EZ, Green Heron)", Network: true, Serial: true, DefaultPort: 4001, DefaultBaud: 4800},
|
{ID: "dcu1", Label: "Green Heron RT-21 / Hy-Gain DCU-1 (Rotor-EZ, RotorCard DXA)", Network: true, Serial: true, DefaultPort: 4001, DefaultBaud: 4800},
|
||||||
{ID: "spid", Label: "SPID / AlfaSpid (RAS, BIG-RAS, MD-01, MD-02)", Elevation: true, Serial: true, DefaultBaud: 600},
|
{ID: "spid", Label: "SPID / AlfaSpid (RAS, BIG-RAS, MD-01, MD-02)", Elevation: true, Serial: true, DefaultBaud: 600},
|
||||||
{ID: "easycomm", Label: "EasyComm II (SatPC32, Gpredict, K3NG…)", Elevation: true, Network: true, Serial: true, DefaultPort: 4533, DefaultBaud: 9600},
|
{ID: "easycomm", Label: "EasyComm II (SatPC32, Gpredict, K3NG…)", Elevation: true, Network: true, Serial: true, DefaultPort: 4533, DefaultBaud: 9600},
|
||||||
}
|
}
|
||||||
@@ -17326,6 +17419,10 @@ func (a *App) migrateLegacyRotors() []RotatorDevice {
|
|||||||
// SaveRotators persists the rotor list. Connections are per-call (no socket to
|
// SaveRotators persists the rotor list. Connections are per-call (no socket to
|
||||||
// (re)open) so no reload step is needed.
|
// (re)open) so no reload step is needed.
|
||||||
func (a *App) SaveRotators(list []RotatorDevice) error {
|
func (a *App) SaveRotators(list []RotatorDevice) error {
|
||||||
|
// Any kept DCU-1 session belongs to the OLD settings. Dropped before the new
|
||||||
|
// ones are stored, so a stale socket cannot hold a single-session controller
|
||||||
|
// against its replacement.
|
||||||
|
defer dropDCU1Clients()
|
||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return fmt.Errorf("db not initialized")
|
return fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
@@ -17445,10 +17542,58 @@ func easycommClient(l rotorLink) *easycomm.Client {
|
|||||||
return easycomm.New(l.Host, l.Port, l.MaxAz)
|
return easycomm.New(l.Host, l.Port, l.MaxAz)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dcu1Clients keeps ONE client per controller, because over TCP the client
|
||||||
|
// holds its session open between commands.
|
||||||
|
//
|
||||||
|
// Every other rotator backend here is built fresh per call, which is right for
|
||||||
|
// the UDP ones and wrong for a TCP embedded serial server: the heading is
|
||||||
|
// polled twice a second while the antenna turns and GoTo sends two commands,
|
||||||
|
// so a new client each time meant a new connect and close each time. Those
|
||||||
|
// modules commonly accept one session at a time. Cached, the churn is gone.
|
||||||
|
//
|
||||||
|
// Keyed on the controller's identity, so two rotors on two boxes get one
|
||||||
|
// client each and two rotors on the SAME box share it — sharing being the
|
||||||
|
// point, when only one session is on offer.
|
||||||
|
var (
|
||||||
|
dcu1Mu sync.Mutex
|
||||||
|
dcu1Clients = map[string]*dcu1.Client{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func dcu1Key(l rotorLink) string {
|
||||||
|
if l.Transport == "serial" {
|
||||||
|
return fmt.Sprintf("serial|%s|%d", strings.ToUpper(strings.TrimSpace(l.ComPort)), l.Baud)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("tcp|%s|%d", strings.ToLower(strings.TrimSpace(l.Host)), l.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropDCU1Clients closes every kept session. Called when the rotator settings
|
||||||
|
// are saved: a client left over from the previous host would hold the very
|
||||||
|
// session the new one needs, which is worse than the churn it replaced.
|
||||||
|
func dropDCU1Clients() {
|
||||||
|
dcu1Mu.Lock()
|
||||||
|
defer dcu1Mu.Unlock()
|
||||||
|
for k, c := range dcu1Clients {
|
||||||
|
c.Close()
|
||||||
|
delete(dcu1Clients, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// dcu1Client builds the Hy-Gain DCU-1 client for a rotor's transport: the
|
// dcu1Client builds the Hy-Gain DCU-1 client for a rotor's transport: the
|
||||||
// controller's COM port (the usual case — RotorCard DXA, Green Heron, Rotor-EZ)
|
// controller's COM port (the usual case — RotorCard DXA, Green Heron, Rotor-EZ)
|
||||||
// or a serial-over-IP bridge on TCP.
|
// or the controller's own Ethernet port / a serial-over-IP bridge on TCP.
|
||||||
func dcu1Client(l rotorLink) *dcu1.Client {
|
func dcu1Client(l rotorLink) *dcu1.Client {
|
||||||
|
key := dcu1Key(l)
|
||||||
|
dcu1Mu.Lock()
|
||||||
|
defer dcu1Mu.Unlock()
|
||||||
|
if c := dcu1Clients[key]; c != nil {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
c := newDCU1Client(l)
|
||||||
|
dcu1Clients[key] = c
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDCU1Client(l rotorLink) *dcu1.Client {
|
||||||
if l.Transport == "serial" {
|
if l.Transport == "serial" {
|
||||||
return dcu1.NewSerial(l.ComPort, l.Baud)
|
return dcu1.NewSerial(l.ComPort, l.Baud)
|
||||||
}
|
}
|
||||||
@@ -18212,6 +18357,10 @@ type motorAntenna interface {
|
|||||||
SetFrequency(khz, dir int) error
|
SetFrequency(khz, dir int) error
|
||||||
SetDirection(dir int) error
|
SetDirection(dir int) error
|
||||||
Retract() error
|
Retract() error
|
||||||
|
// Calibrate re-learns where the elements are by driving them to their end
|
||||||
|
// stops. SteppIR only: an Ultrabeam has no such command, and says so rather
|
||||||
|
// than pretending.
|
||||||
|
Calibrate() error
|
||||||
LastSetKHz() int
|
LastSetKHz() int
|
||||||
Status() motorStatus
|
Status() motorStatus
|
||||||
// Elements returns the per-element lengths (mm) when the antenna exposes them
|
// Elements returns the per-element lengths (mm) when the antenna exposes them
|
||||||
@@ -18239,11 +18388,14 @@ type ubAdapter struct {
|
|||||||
freqMin, freqMax int
|
freqMin, freqMax int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a ubAdapter) Start() error { return a.c.Start() }
|
func (a ubAdapter) Start() error { return a.c.Start() }
|
||||||
func (a ubAdapter) Stop() { a.c.Stop() }
|
func (a ubAdapter) Stop() { a.c.Stop() }
|
||||||
func (a ubAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d) }
|
func (a ubAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d) }
|
||||||
func (a ubAdapter) SetDirection(d int) error { return a.c.SetDirection(d) }
|
func (a ubAdapter) SetDirection(d int) error { return a.c.SetDirection(d) }
|
||||||
func (a ubAdapter) Retract() error { return a.c.Retract() }
|
func (a ubAdapter) Retract() error { return a.c.Retract() }
|
||||||
|
func (a ubAdapter) Calibrate() error {
|
||||||
|
return fmt.Errorf("calibration is a SteppIR command — an Ultrabeam controller has none")
|
||||||
|
}
|
||||||
func (a ubAdapter) LastSetKHz() int { return a.c.LastSetKHz() }
|
func (a ubAdapter) LastSetKHz() int { return a.c.LastSetKHz() }
|
||||||
func (a ubAdapter) SetElement(n, mm int) error { return a.c.ModifyElement(n, mm) }
|
func (a ubAdapter) SetElement(n, mm int) error { return a.c.ModifyElement(n, mm) }
|
||||||
func (a ubAdapter) ReadElements() ([]int, error) { return a.c.ReadElements() }
|
func (a ubAdapter) ReadElements() ([]int, error) { return a.c.ReadElements() }
|
||||||
@@ -18282,6 +18434,7 @@ func (a steppirAdapter) Stop() { a.c.Stop() }
|
|||||||
func (a steppirAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d) }
|
func (a steppirAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d) }
|
||||||
func (a steppirAdapter) SetDirection(d int) error { return a.c.SetDirection(d) }
|
func (a steppirAdapter) SetDirection(d int) error { return a.c.SetDirection(d) }
|
||||||
func (a steppirAdapter) Retract() error { return a.c.Retract() }
|
func (a steppirAdapter) Retract() error { return a.c.Retract() }
|
||||||
|
func (a steppirAdapter) Calibrate() error { return a.c.Calibrate() }
|
||||||
func (a steppirAdapter) LastSetKHz() int { return a.c.LastSetKHz() }
|
func (a steppirAdapter) LastSetKHz() int { return a.c.LastSetKHz() }
|
||||||
func (a steppirAdapter) Elements() []int { return nil } // SteppIR: no per-element control
|
func (a steppirAdapter) Elements() []int { return nil } // SteppIR: no per-element control
|
||||||
func (a steppirAdapter) SetElement(_, _ int) error {
|
func (a steppirAdapter) SetElement(_, _ int) error {
|
||||||
@@ -19198,6 +19351,24 @@ func (a *App) restartMotorFollow(s UltrabeamSettings) {
|
|||||||
go a.ultrabeamFollowLoop(a.motorAnt, s.TrackMode, s.StepKHz, s.Bands, stop)
|
go a.ultrabeamFollowLoop(a.motorAnt, s.TrackMode, s.StepKHz, s.Bands, stop)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MotorCalibrate runs a SteppIR calibration: the elements go to their end stops
|
||||||
|
// so the controller re-learns where zero is.
|
||||||
|
//
|
||||||
|
// The cure for an antenna that tunes to the wrong length — after a power cut
|
||||||
|
// mid-move, after the elements were pushed by hand, after a motor slipped. The
|
||||||
|
// controller counts steps from a remembered position, and once that memory is
|
||||||
|
// wrong, every frequency after it is wrong by the same amount.
|
||||||
|
//
|
||||||
|
// Minutes, and every element travels its full length. Not a contest-time button,
|
||||||
|
// which is why the interface asks first.
|
||||||
|
func (a *App) MotorCalibrate() error {
|
||||||
|
if a.motorAnt == nil {
|
||||||
|
return fmt.Errorf("antenna not connected")
|
||||||
|
}
|
||||||
|
applog.Printf("antenna: calibration started — the elements go to their end stops, this takes minutes")
|
||||||
|
return a.motorAnt.Calibrate()
|
||||||
|
}
|
||||||
|
|
||||||
// UltrabeamRetract retracts all elements (storage / safe position).
|
// UltrabeamRetract retracts all elements (storage / safe position).
|
||||||
func (a *App) UltrabeamRetract() error {
|
func (a *App) UltrabeamRetract() error {
|
||||||
if a.motorAnt == nil {
|
if a.motorAnt == nil {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// ── Who hears me (PSK Reporter) ────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The FT map draws what this station decodes. This is the other direction:
|
||||||
|
// which stations are reporting our own transmissions, which is the half an
|
||||||
|
// operator cannot see from their own receiver and the half that decides whether
|
||||||
|
// calling is worth the cycle.
|
||||||
|
//
|
||||||
|
// See internal/pskrme for why it costs almost nothing — the operator's callsign
|
||||||
|
// goes in the topic's TRANSMIT level, so the broker sends nothing else.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/pskrme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// keyHearMeOn is per-profile, not global: a second profile is usually a second
|
||||||
|
// callsign, and the answer to "who hears me" is not the same one.
|
||||||
|
const keyHearMeOn = "hearme.on"
|
||||||
|
|
||||||
|
// GetHearMe reports whether the feed is wanted.
|
||||||
|
func (a *App) GetHearMe() bool {
|
||||||
|
if a.settings == nil || !a.settingsScoped.Load() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
v, _ := a.settings.Get(a.ctx, keyHearMeOn)
|
||||||
|
return v == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHearMe turns the feed on or off and brings the subscription with it.
|
||||||
|
func (a *App) SetHearMe(on bool) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
if err := a.settings.Set(a.ctx, keyHearMeOn, boolStr(on)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.startHearMe()
|
||||||
|
if on {
|
||||||
|
// Not an error: the feed is wanted and will come up as soon as there is
|
||||||
|
// a callsign to watch for, and saying so beats a silent switch.
|
||||||
|
if strings.TrimSpace(a.opCall) == "" {
|
||||||
|
return fmt.Errorf("set the station callsign first — the feed watches for it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startHearMe rebuilds the watcher from the setting. Called at startup, when
|
||||||
|
// the setting changes, and on a profile switch — the callsign is what it
|
||||||
|
// subscribes to, so a new profile needs a new subscription.
|
||||||
|
func (a *App) startHearMe() {
|
||||||
|
if a.hearMe != nil {
|
||||||
|
a.hearMe.Stop()
|
||||||
|
a.hearMe = nil
|
||||||
|
}
|
||||||
|
if !a.GetHearMe() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
call := strings.ToUpper(strings.TrimSpace(a.opCall))
|
||||||
|
if call == "" {
|
||||||
|
applog.Printf("pskrme: no station callsign — nothing to watch for")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w := pskrme.New(pskrme.Config{MyCall: call, Logf: applog.Printf})
|
||||||
|
if err := w.Start(); err != nil {
|
||||||
|
applog.Printf("pskrme: feed did not start: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.hearMe = w
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWhoHearsMe is the map layer: one entry per station that has reported us
|
||||||
|
// inside the window, carrying its own square so the arc is arithmetic.
|
||||||
|
func (a *App) GetWhoHearsMe() []pskrme.Report {
|
||||||
|
if a.hearMe == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return a.hearMe.Reports()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHearMeStatus tells a working feed from a silent one: a connection that is
|
||||||
|
// up and reporting nothing looks exactly like a broken one until you can see a
|
||||||
|
// number moving.
|
||||||
|
func (a *App) GetHearMeStatus() pskrme.Status {
|
||||||
|
if a.hearMe == nil {
|
||||||
|
return pskrme.Status{Watching: strings.ToUpper(strings.TrimSpace(a.opCall))}
|
||||||
|
}
|
||||||
|
return a.hearMe.Status()
|
||||||
|
}
|
||||||
+52
-6
@@ -314,7 +314,12 @@ func (a *App) GetSatSettings() (SatSettings, error) {
|
|||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return SatSettings{}, fmt.Errorf("db not initialized")
|
return SatSettings{}, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
return a.satSettings(), nil
|
out := a.satSettings()
|
||||||
|
// Resolved HERE and not in satSettings, which is read during startup before
|
||||||
|
// the frequency plan is loaded. Saving the panel writes the resolved list
|
||||||
|
// back, so the rename settles itself the first time anything is changed.
|
||||||
|
out.Favorites = a.satFavorites()
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveSatSettings stores them.
|
// SaveSatSettings stores them.
|
||||||
@@ -359,6 +364,15 @@ func (a *App) SaveSatSettings(s SatSettings) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The Satellites tab is usually open BEHIND the settings window, and it
|
||||||
|
// read the followed list once when it was mounted: dropping QO-100 left it
|
||||||
|
// in the pass table and in the dropdown until the tab was reopened, which
|
||||||
|
// looks exactly like a setting that did not save. Every key written here
|
||||||
|
// changes what the tab should show — the followed list, the horizon, the
|
||||||
|
// window, the locator — so one event covers them all.
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "sat:settings")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,9 +549,8 @@ func (a *App) AddSatelliteElements(text string) (int, error) {
|
|||||||
// configuration problem into a satellite that "does not exist".
|
// configuration problem into a satellite that "does not exist".
|
||||||
func (a *App) GetSatelliteBirds() []SatBird {
|
func (a *App) GetSatelliteBirds() []SatBird {
|
||||||
store, birds, _ := a.satParts()
|
store, birds, _ := a.satParts()
|
||||||
set := a.satSettings()
|
|
||||||
fav := map[string]bool{}
|
fav := map[string]bool{}
|
||||||
for _, n := range set.Favorites {
|
for _, n := range a.satFavorites() {
|
||||||
fav[strings.ToUpper(n)] = true
|
fav[strings.ToUpper(n)] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -665,15 +678,48 @@ func satElement(store *sat.Store, b sat.Bird) (sat.Element, bool) {
|
|||||||
|
|
||||||
// ── Tracking ────────────────────────────────────────────────────────────────
|
// ── Tracking ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// satFavorites is the followed list, with every name resolved to the one the
|
||||||
|
// frequency plan uses now.
|
||||||
|
//
|
||||||
|
// A satellite is renamed when it is granted an OSCAR number — LILACSAT-2
|
||||||
|
// became LO-90 — and the plan carries the old name as an alias. The followed
|
||||||
|
// list, though, is stored as the plain text the operator picked: after such a
|
||||||
|
// rename his own choice was listed as having no elements while the same bird
|
||||||
|
// sat under its new name in the available column, so the satellite he had
|
||||||
|
// chosen had quietly become a stranger.
|
||||||
|
//
|
||||||
|
// Deduplicated on the way out, because an operator who followed both spellings
|
||||||
|
// must not now see the same bird twice.
|
||||||
|
func (a *App) satFavorites() []string {
|
||||||
|
names := a.satSettings().Favorites
|
||||||
|
_, birds, _ := a.satParts()
|
||||||
|
if birds == nil {
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(names))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, n := range names {
|
||||||
|
if b, ok := birds.Find(n); ok {
|
||||||
|
n = b.Name
|
||||||
|
}
|
||||||
|
k := strings.ToUpper(n)
|
||||||
|
if seen[k] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[k] = true
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// satNames resolves the names the UI asked for, falling back to the favourites
|
// satNames resolves the names the UI asked for, falling back to the favourites
|
||||||
// and then to every planned bird we hold elements for.
|
// and then to every planned bird we hold elements for.
|
||||||
func (a *App) satNames(names []string) []string {
|
func (a *App) satNames(names []string) []string {
|
||||||
if len(names) > 0 {
|
if len(names) > 0 {
|
||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
set := a.satSettings()
|
if favs := a.satFavorites(); len(favs) > 0 {
|
||||||
if len(set.Favorites) > 0 {
|
return favs
|
||||||
return set.Favorites
|
|
||||||
}
|
}
|
||||||
var out []string
|
var out []string
|
||||||
for _, b := range a.GetSatelliteBirds() {
|
for _, b := range a.GetSatelliteBirds() {
|
||||||
|
|||||||
+216
-13
@@ -20,6 +20,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -45,6 +46,14 @@ const satTickEvery = time.Second
|
|||||||
// smallest deliberate move anybody makes hunting a station on a transponder.
|
// smallest deliberate move anybody makes hunting a station on a transponder.
|
||||||
const satDialTolerance = 200
|
const satDialTolerance = 200
|
||||||
|
|
||||||
|
// satUpTrimLimit caps the uplink trim, in hertz.
|
||||||
|
//
|
||||||
|
// 20 kHz: wider than any transponder is off by, and narrower than the distance
|
||||||
|
// to a neighbouring band edge. It exists so a bad stored value, or a transmit
|
||||||
|
// VFO the operator swung across the band for some other reason, cannot become
|
||||||
|
// a permanent offset that puts the station outside the passband every pass.
|
||||||
|
const satUpTrimLimit = 20000
|
||||||
|
|
||||||
// satLightKmS is the speed of light in km/s, for turning a heard frequency back
|
// 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.
|
// into a nominal one. The same constant internal/sat corrects with.
|
||||||
const satLightKmS = 299792.458
|
const satLightKmS = 299792.458
|
||||||
@@ -60,8 +69,21 @@ type satTracker struct {
|
|||||||
nominalDown int64
|
nominalDown int64
|
||||||
lastDown int64 // what was last sent to the radio
|
lastDown int64 // what was last sent to the radio
|
||||||
lastUp int64
|
lastUp int64
|
||||||
status SatTrackStatus
|
// upTrim is what the operator has added to the computed uplink, in hertz.
|
||||||
fails int
|
//
|
||||||
|
// A transponder does not translate by exactly the published difference: the
|
||||||
|
// oscillator on board is decades old on some birds and a kilohertz or two
|
||||||
|
// out. So an operator who sounds right to themselves comes back off
|
||||||
|
// frequency, corrects it on the transmit VFO — and the tracker put it back
|
||||||
|
// one second later, every second, for the whole pass. Reported on an IC-9700
|
||||||
|
// against HRD, which keeps the shift the operator sets.
|
||||||
|
//
|
||||||
|
// Read back from the radio rather than typed into a box, because the
|
||||||
|
// transmit VFO is the control an operator already reaches for, and it is
|
||||||
|
// exactly how the DOWNLINK dial is already handled a few lines below.
|
||||||
|
upTrim int64
|
||||||
|
status SatTrackStatus
|
||||||
|
fails int
|
||||||
|
|
||||||
// The az/el rotator, built once at the start of the pass so a serial port is
|
// 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.
|
// opened once rather than on every command. nil when none is configured.
|
||||||
@@ -96,8 +118,17 @@ type SatTrackStatus struct {
|
|||||||
Az float64 `json:"az"`
|
Az float64 `json:"az"`
|
||||||
El float64 `json:"el"`
|
El float64 `json:"el"`
|
||||||
Visible bool `json:"visible"`
|
Visible bool `json:"visible"`
|
||||||
Radio string `json:"radio"` // what the rig is doing: "sat", "downlink-only", ""
|
// Where the satellite is, as opposed to where to point: an operator reads
|
||||||
Error string `json:"error"`
|
// the distance to know whether a pass is worth calling on, and the altitude
|
||||||
|
// to know how long it will last.
|
||||||
|
RangeKm float64 `json:"range_km"`
|
||||||
|
AltKm float64 `json:"alt_km"`
|
||||||
|
// UpTrimHz is the correction the operator has added to the uplink, in hertz.
|
||||||
|
// Shown so a trim taken silently from the transmit VFO is visible, and can be
|
||||||
|
// cleared — an offset nobody can see is a trap.
|
||||||
|
UpTrimHz int64 `json:"up_trim_hz"`
|
||||||
|
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
|
// 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
|
// from the last position it was TOLD to go to — a stuck rotator must not be
|
||||||
@@ -136,6 +167,14 @@ func (a *App) StartSatelliteTracking(name string, transponder int) error {
|
|||||||
wake: make(chan struct{}, 1),
|
wake: make(chan struct{}, 1),
|
||||||
}
|
}
|
||||||
t.status = SatTrackStatus{On: true, Name: b.Name, Transponder: b.Transponders[transponder].Label, Mode: b.Transponders[transponder].Mode}
|
t.status = SatTrackStatus{On: true, Name: b.Name, Transponder: b.Transponders[transponder].Label, Mode: b.Transponders[transponder].Mode}
|
||||||
|
// The correction this transponder was last left with: its translation error
|
||||||
|
// is a property of the hardware in orbit and does not change between passes.
|
||||||
|
trim := a.loadSatUplinkTrim(b.Name, transponder)
|
||||||
|
t.upTrim = trim
|
||||||
|
t.status.UpTrimHz = trim
|
||||||
|
if trim != 0 {
|
||||||
|
applog.Printf("sat: uplink starts %+d Hz off nominal, as it was left", trim)
|
||||||
|
}
|
||||||
|
|
||||||
// The rotator, if there is one. A geostationary bird is pointed at once and
|
// 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.
|
// left alone, so it gets one command rather than a loop.
|
||||||
@@ -333,7 +372,35 @@ func (a *App) satTrackStep(t *satTracker) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nomUp := tp.UplinkFor(nominal)
|
// Where did the operator leave the TRANSMITTER? The same question as above,
|
||||||
|
// and the same answer: what they landed on is what they want, so the
|
||||||
|
// difference becomes a standing correction rather than being overwritten.
|
||||||
|
//
|
||||||
|
// Absorbed as a trim on the NOMINAL uplink, not on the corrected one: a
|
||||||
|
// transponder's translation error is a fixed offset in the uplink band, not
|
||||||
|
// something that scales with the Doppler. (The difference either way is
|
||||||
|
// under a hundredth of a hertz, but only one of the two is a reason.)
|
||||||
|
//
|
||||||
|
// Not while transmitting: mid-over the operator is not turning the knob, and
|
||||||
|
// on an Icom this read switches bands to reach the uplink — not something to
|
||||||
|
// do under a carrier.
|
||||||
|
if lastUp > 0 && !a.satTransmitting() {
|
||||||
|
if actual, err := a.satTransmitHz(); err == nil && actual > 0 {
|
||||||
|
if drift := actual - lastUp; abs64i(drift) > satDialTolerance {
|
||||||
|
t.mu.Lock()
|
||||||
|
t.upTrim += drift
|
||||||
|
trim := t.upTrim
|
||||||
|
t.mu.Unlock()
|
||||||
|
applog.Printf("sat: uplink trimmed by %+d Hz (now %+d Hz) — the transmit VFO moved", drift, trim)
|
||||||
|
a.saveSatUplinkTrim(b.Name, t.tp, trim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
upTrim := t.upTrim
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
nomUp := tp.UplinkFor(nominal) + upTrim
|
||||||
sh := sat.Doppler(pos, nominal, nomUp)
|
sh := sat.Doppler(pos, nominal, nomUp)
|
||||||
down, up := sh.DownHz, sh.UpHz
|
down, up := sh.DownHz, sh.UpHz
|
||||||
|
|
||||||
@@ -350,6 +417,8 @@ func (a *App) satTrackStep(t *satTracker) {
|
|||||||
st.NominalDown, st.NominalUp = nominal, nomUp
|
st.NominalDown, st.NominalUp = nominal, nomUp
|
||||||
st.DownHz, st.UpHz = down, up
|
st.DownHz, st.UpHz = down, up
|
||||||
st.Az, st.El, st.Visible = pos.Az, pos.El, visible
|
st.Az, st.El, st.Visible = pos.Az, pos.El, visible
|
||||||
|
st.RangeKm, st.AltKm = pos.RangeKm, pos.AltKm
|
||||||
|
st.UpTrimHz = upTrim
|
||||||
t.status = st
|
t.status = st
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
|
|
||||||
@@ -545,6 +614,114 @@ func (a *App) satReceiveHz() (int64, error) {
|
|||||||
return st.FreqHz, nil
|
return st.FreqHz, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// satTransmitHz is where the transmitter actually is, or 0 when the radio
|
||||||
|
// cannot say. Only the satellite backends can: a rig working split reports one
|
||||||
|
// frequency and it is the receiver's.
|
||||||
|
func (a *App) satTransmitHz() (int64, error) {
|
||||||
|
if a.cat == nil {
|
||||||
|
return 0, fmt.Errorf("CAT is not running")
|
||||||
|
}
|
||||||
|
if !a.cat.SatCapable() {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
var hz int64
|
||||||
|
err := a.cat.SatDo(func(st cat.SatTuner) error {
|
||||||
|
v, e := st.SatTransmitHz()
|
||||||
|
hz = v
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
return hz, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// satTransmitting reports whether the rig is keyed, so the uplink readback can
|
||||||
|
// stay off the air while it is.
|
||||||
|
//
|
||||||
|
// Only the two backends that hold a satellite pair are asked, which are the
|
||||||
|
// only two this matters for. Unknown counts as NOT transmitting: refusing to
|
||||||
|
// read the uplink on a radio that cannot say would disable the trim entirely.
|
||||||
|
func (a *App) satTransmitting() bool {
|
||||||
|
if a.cat == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if st, ok := a.cat.FlexState(); ok {
|
||||||
|
return st.Transmitting
|
||||||
|
}
|
||||||
|
if st, ok := a.cat.IcomState(); ok {
|
||||||
|
return st.Transmitting
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The uplink trim, remembered ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Kept per satellite AND per transponder, because that is what it belongs to:
|
||||||
|
// a transponder's translation error is a property of the hardware in orbit,
|
||||||
|
// stable from one pass to the next and for years. An operator who found the
|
||||||
|
// right offset on FO-29 last week should not have to find it again tonight.
|
||||||
|
func keySatUpTrim(name string, tp int) string {
|
||||||
|
return fmt.Sprintf("sat.uptrim.%s.%d", strings.ToUpper(strings.TrimSpace(name)), tp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) loadSatUplinkTrim(name string, tp int) int64 {
|
||||||
|
if a.settings == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
v, _ := a.settings.Get(a.ctx, keySatUpTrim(name, tp))
|
||||||
|
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// A trim larger than the passband is a stored mistake, not a correction.
|
||||||
|
if n < -satUpTrimLimit || n > satUpTrimLimit {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) saveSatUplinkTrim(name string, tp int, hz int64) {
|
||||||
|
if a.settings == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hz < -satUpTrimLimit || hz > satUpTrimLimit {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.settings.Set(a.ctx, keySatUpTrim(name, tp), strconv.FormatInt(hz, 10)); err != nil {
|
||||||
|
applog.Printf("sat: could not store the uplink trim: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSatUplinkTrim is what the panel shows.
|
||||||
|
func (a *App) GetSatUplinkTrim(name string, transponder int) int64 {
|
||||||
|
return a.loadSatUplinkTrim(name, transponder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSatUplinkTrim stores a trim and applies it to a pass in progress.
|
||||||
|
//
|
||||||
|
// The panel needs this to CLEAR one: a trim taken from the transmit VFO can
|
||||||
|
// only be adjusted by the same VFO, and an operator who has drifted somewhere
|
||||||
|
// wrong has no way back to zero without it.
|
||||||
|
func (a *App) SetSatUplinkTrim(name string, transponder int, hz int64) error {
|
||||||
|
if hz < -satUpTrimLimit || hz > satUpTrimLimit {
|
||||||
|
return fmt.Errorf("a %d Hz trim is outside anything a transponder is off by", hz)
|
||||||
|
}
|
||||||
|
a.saveSatUplinkTrim(name, transponder, hz)
|
||||||
|
a.satTrackMu.Lock()
|
||||||
|
t := a.satTrack
|
||||||
|
a.satTrackMu.Unlock()
|
||||||
|
if t != nil {
|
||||||
|
t.mu.Lock()
|
||||||
|
on := t.status.On && strings.EqualFold(t.name, name) && t.tp == transponder
|
||||||
|
if on {
|
||||||
|
t.upTrim = hz
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
if on {
|
||||||
|
applog.Printf("sat: uplink trim set to %+d Hz", hz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func abs64i(v int64) int64 {
|
func abs64i(v int64) int64 {
|
||||||
if v < 0 {
|
if v < 0 {
|
||||||
return -v
|
return -v
|
||||||
@@ -649,8 +826,20 @@ func satBandLetter(hz int64) string {
|
|||||||
return "K" // 24 GHz and above
|
return "K" // 24 GHz and above
|
||||||
}
|
}
|
||||||
|
|
||||||
// applySatAntennas puts each satellite slice on the antenna configured for ITS
|
// flexBandAntKey is the key a frequency has in the per-band antenna and power
|
||||||
// band.
|
// maps.
|
||||||
|
//
|
||||||
|
// Those maps are keyed by the band name UPPERCASED ("70CM"), because that is
|
||||||
|
// how the settings panel writes them; bandForHz returns the band plan's own
|
||||||
|
// spelling ("70cm"). Every other caller happened to uppercase on the way in,
|
||||||
|
// the satellite tracker did not, and so it read an empty antenna out of a map
|
||||||
|
// the operator had filled in — which is not a mistake worth making twice.
|
||||||
|
func flexBandAntKey(hz int64) string {
|
||||||
|
return strings.ToUpper(bandForHz(hz))
|
||||||
|
}
|
||||||
|
|
||||||
|
// applySatRadio puts each satellite slice on the antenna configured for ITS
|
||||||
|
// band, and sets the uplink tone.
|
||||||
//
|
//
|
||||||
// Settings ▸ FlexRadio already holds a per-band RX/TX antenna map, and it was
|
// Settings ▸ FlexRadio already holds a per-band RX/TX antenna map, and it was
|
||||||
// only ever applied by the entry form on a band change — to the active slice.
|
// only ever applied by the entry form on a band change — to the active slice.
|
||||||
@@ -679,15 +868,25 @@ func (a *App) applySatRadio(tp sat.Transponder) {
|
|||||||
if err != nil || len(m) == 0 {
|
if err != nil || len(m) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The downlink is received, so it takes that band's RX antenna; the uplink
|
// Each slice gets BOTH antennas of ITS OWN band. Only two of the four were
|
||||||
// is transmitted, so it takes that band's TX antenna.
|
// set — the downlink's receive and the uplink's transmit — which left the
|
||||||
rxAnt := m[bandForHz(tp.DownLo)].RX
|
// downlink slice with no txant. It never keys, so nothing was wrong on the
|
||||||
txAnt := m[bandForHz(tp.UpLo)].TX
|
// air, but a half-configured slice uses whatever antenna it was last left
|
||||||
if strings.TrimSpace(rxAnt) == "" && strings.TrimSpace(txAnt) == "" {
|
// on the moment transmit focus moves to it.
|
||||||
|
downBand, upBand := flexBandAntKey(tp.DownLo), flexBandAntKey(tp.UpLo)
|
||||||
|
down, up := m[downBand], m[upBand]
|
||||||
|
if strings.TrimSpace(down.RX) == "" && strings.TrimSpace(down.TX) == "" &&
|
||||||
|
strings.TrimSpace(up.RX) == "" && strings.TrimSpace(up.TX) == "" {
|
||||||
|
// Worth a line: an operator who HAS configured the pair and still sees
|
||||||
|
// the wrong antenna has no other way to tell a setting he never made
|
||||||
|
// from a lookup that missed.
|
||||||
|
applog.Printf("sat: no antenna configured for this pass (down %s, up %s)", downBand, upBand)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
applog.Printf("sat: antennas down %s rx=%q tx=%q, up %s rx=%q tx=%q",
|
||||||
|
downBand, down.RX, down.TX, upBand, up.RX, up.TX)
|
||||||
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
|
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
|
||||||
return fc.SatAntennas(rxAnt, txAnt)
|
return fc.SatAntennas(down.RX, down.TX, up.RX, up.TX)
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
// Not fatal: a rig that is not a Flex has no such thing, and a pass with
|
// Not fatal: a rig that is not a Flex has no such thing, and a pass with
|
||||||
// the wrong antenna is still a pass.
|
// the wrong antenna is still a pass.
|
||||||
@@ -760,6 +959,10 @@ func (a *App) RetargetSatelliteTracking(name string, transponder int) error {
|
|||||||
// And so the antenna is commanded at once instead of waiting for the new
|
// And so the antenna is commanded at once instead of waiting for the new
|
||||||
// satellite to drift a step away from where the old one happened to be.
|
// satellite to drift a step away from where the old one happened to be.
|
||||||
t.rotSent = false
|
t.rotSent = false
|
||||||
|
// A different transponder is off by a different amount, and the one we were
|
||||||
|
// on has no bearing on it.
|
||||||
|
t.upTrim = a.loadSatUplinkTrim(b.Name, transponder)
|
||||||
|
t.status.UpTrimHz = t.upTrim
|
||||||
t.status.Name, t.status.Transponder, t.status.Mode = b.Name, tp.Label, tp.Mode
|
t.status.Name, t.status.Transponder, t.status.Mode = b.Name, tp.Label, tp.Mode
|
||||||
t.status.Error = ""
|
t.status.Error = ""
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hamlog/internal/sat"
|
"hamlog/internal/sat"
|
||||||
@@ -135,3 +136,77 @@ func TestSatSidebands(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The per-band antenna map is keyed by the UPPERCASED band name, because that
|
||||||
|
// is what the settings panel writes. The satellite tracker looked its two bands
|
||||||
|
// up with the band plan's own spelling, matched nothing, and ran the pass on
|
||||||
|
// whichever antenna the radio was last left on — a 70 cm downlink through a 2 m
|
||||||
|
// transverter, with no error anywhere. This pins the contract in the direction
|
||||||
|
// that broke.
|
||||||
|
func TestFlexBandAntKeyIsUppercased(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
hz int64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{435_400_000, "70CM"}, // an FM bird's downlink
|
||||||
|
{145_950_000, "2M"}, // its uplink
|
||||||
|
{1_269_000_000, "23CM"}, // AO-92's L band
|
||||||
|
{29_450_000, "10M"}, // AO-7 mode A
|
||||||
|
{9_000_000_000, ""}, // nothing in the plan: no key, and no antenna
|
||||||
|
} {
|
||||||
|
if got := flexBandAntKey(c.hz); got != c.want {
|
||||||
|
t.Errorf("flexBandAntKey(%d) = %q, want %q", c.hz, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A band list sorted as strings puts 10m between 1.25m and 12m, which is why
|
||||||
|
// the plan's own index is the order.
|
||||||
|
func TestBandOrderIsByFrequency(t *testing.T) {
|
||||||
|
got := []string{"70cm", "10m", "160m", "2m", "20m", "banana"}
|
||||||
|
sort.Slice(got, func(i, j int) bool { return bandOrder(got[i]) < bandOrder(got[j]) })
|
||||||
|
want := []string{"160m", "20m", "10m", "2m", "70cm", "banana"}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("sorted %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The uplink trim is a fixed offset on the NOMINAL uplink, so the Doppler
|
||||||
|
// correction is computed from the frequency the operator actually transmits on.
|
||||||
|
//
|
||||||
|
// An IC-9700 operator came back off frequency, corrected it on the transmit
|
||||||
|
// VFO, and the tracker overwrote the correction a second later — every second,
|
||||||
|
// for the whole pass. The trim is what survives that.
|
||||||
|
func TestUplinkTrimShiftsTheNominalUplink(t *testing.T) {
|
||||||
|
tp := sat.Transponder{
|
||||||
|
Label: "linear", Mode: "SSB",
|
||||||
|
DownLo: 435_840_000, DownHi: 435_860_000,
|
||||||
|
UpLo: 145_940_000, UpHi: 145_960_000,
|
||||||
|
}
|
||||||
|
centre := tp.Centre()
|
||||||
|
plain := tp.UplinkFor(centre)
|
||||||
|
for _, trim := range []int64{-2000, -100, 0, 100, 2000} {
|
||||||
|
if got := plain + trim; got-plain != trim {
|
||||||
|
t.Errorf("a %+d Hz trim moved the uplink by %+d", trim, got-plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// And it must not touch the downlink: the operator's receiver is their own,
|
||||||
|
// and a trim taken from the transmit VFO has nothing to say about it.
|
||||||
|
if tp.UplinkFor(centre) != plain {
|
||||||
|
t.Error("UplinkFor is not stable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stored trim is capped. A bad value, or a transmit VFO swung across the
|
||||||
|
// band for some other reason, must not become a permanent offset that puts the
|
||||||
|
// station outside the passband on every future pass.
|
||||||
|
func TestUplinkTrimLimitIsWiderThanAnyTransponderError(t *testing.T) {
|
||||||
|
if satUpTrimLimit < 5000 {
|
||||||
|
t.Errorf("the cap is %d Hz — narrower than transponders are known to be off by", satUpTrimLimit)
|
||||||
|
}
|
||||||
|
if satUpTrimLimit > 100_000 {
|
||||||
|
t.Errorf("the cap is %d Hz — wide enough to reach another band", satUpTrimLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,77 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.27.24",
|
||||||
|
"en": [
|
||||||
|
"The sky plot and the position readout move to their own column, left of the map. Width is draggable, and the column folds away like the one on the right.",
|
||||||
|
"Each block of the right-hand column now has a heading and folds away on its own, remembered between sessions.",
|
||||||
|
"The tune panel shows the centre frequency of the transponder. The Doppler-corrected one stays beside Tracking, where the radio is.",
|
||||||
|
"Badges on the satellite panel for the band, the Doppler offset, an inverting transponder, the passband width and a pass's peak elevation.",
|
||||||
|
"The satellite ground track is visible on every basemap — it was drawn in a colour the map could not use and came out near-white.",
|
||||||
|
"The satellite ground track no longer draws a straight line across the map when it crosses the antimeridian.",
|
||||||
|
"The decode panel no longer warns about band drift on a second slice: it now compares against every band the radio is receiving on, not just the transmit band.",
|
||||||
|
"OpsLog appears again after an update. The relaunch was starting the new build with its window hidden, so it ran with no window at all.",
|
||||||
|
"Each satellite slice now gets both of its antennas, RX and TX, from its own band — the downlink slice was left with no transmit antenna at all.",
|
||||||
|
"A correction you make on the transmit VFO is now kept for the whole pass, and remembered for that transponder. Doppler tracking used to undo it a second later.",
|
||||||
|
"The Green Heron RT-21 is named in the rotator list. It speaks the DCU-1 command set OpsLog already drives, over its COM port or straight over TCP with the Ethernet option — set the controller to DCU-1 / Rotor-EZ.",
|
||||||
|
"Retract and Calibrate on a SteppIR now show the elements moving, and inhibit the transmitter while they do. Neither said anything before — the same was missing on an Ultrabeam retract, where the element lengths counting down hid it.",
|
||||||
|
"The window can be made as small as you like. The 1100x700 floor is gone — only Windows own limit remains.",
|
||||||
|
"A DCU-1 controller reached over TCP now gets one connection held open, instead of a new one for every command — twice a second while the antenna turns. Controllers that accept a single session, an RT-21 with the Ethernet option among them, could not keep up."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Le tracé du ciel et la position passent dans leur propre colonne, à gauche de la carte. Largeur réglable, et la colonne se replie comme celle de droite.",
|
||||||
|
"Chaque bloc de la colonne de droite a désormais un titre et se replie séparément, avec son état retenu.",
|
||||||
|
"Le panneau d’accord affiche la fréquence centrale du transpondeur. La valeur corrigée du Doppler reste à côté de Tracking, là où est la radio.",
|
||||||
|
"Des pastilles sur le panneau satellite pour la bande, l’écart Doppler, un transpondeur inverseur, la largeur de bande passante et le pic d’élévation d’une passe.",
|
||||||
|
"Le tracé au sol des satellites est visible sur tous les fonds de carte — il était dessiné dans une couleur inexploitable et sortait presque blanc.",
|
||||||
|
"Le tracé au sol du satellite ne trace plus une ligne droite en travers de la carte lorsqu’il franchit l’antiméridien.",
|
||||||
|
"Le panneau de décodages n’avertit plus d’une dérive de bande sur une seconde tranche : la comparaison porte sur toutes les bandes reçues, pas seulement celle d’émission.",
|
||||||
|
"OpsLog réapparaît après une mise à jour. La relance démarrait la nouvelle version avec sa fenêtre masquée, donc sans aucune fenêtre.",
|
||||||
|
"Chaque slice satellite reçoit désormais ses deux antennes, RX et TX, depuis sa propre bande — la slice de descente restait sans antenne d’émission.",
|
||||||
|
"Une correction faite sur le VFO d’émission est désormais conservée pour toute la passe, et mémorisée pour ce transpondeur. Le suivi Doppler l’effaçait une seconde plus tard.",
|
||||||
|
"Le Green Heron RT-21 est nommé dans la liste des rotators. Il parle le jeu de commandes DCU-1 que OpsLog pilote déjà, via son port COM ou directement en TCP avec l’option Ethernet — régler le contrôleur sur DCU-1 / Rotor-EZ.",
|
||||||
|
"Rétracter et Calibrer sur une SteppIR montrent désormais les éléments en mouvement, et inhibent l’émission pendant ce temps. Ni l’un ni l’autre ne le signalait — même manque sur la rétraction d’une Ultrabeam, où le défilement des longueurs d’éléments le masquait.",
|
||||||
|
"La fenêtre peut être réduite autant que vous voulez. Le plancher de 1100x700 disparaît — il ne reste que la limite propre à Windows.",
|
||||||
|
"Un contrôleur DCU-1 joint en TCP reçoit désormais une seule connexion maintenue ouverte, au lieu d’une nouvelle à chaque commande — deux fois par seconde en rotation. Les contrôleurs n’acceptant qu’une session, dont un RT-21 avec l’option Ethernet, ne pouvaient pas suivre."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.23",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"SteppIR: a Calibrate button beside Retract, on Station Control.",
|
||||||
|
"On the rotor dial, the beam no longer takes the long way round when it crosses north on a 450° rotator.",
|
||||||
|
"The rotor widget stops lighting Stop when the wind moves the antenna, and stops going dark while it is genuinely turning.",
|
||||||
|
"LilacSat-2 gets its FM transponder — 144.350 up, 437.200 down — and its proper name, LO-90. The label says \"(scheduled)\": it runs to an announced schedule, not continuously.",
|
||||||
|
"A frequency OpsLog shipped wrong can now be corrected on a station that already has the satellite file. Entries you edited by hand are left alone.",
|
||||||
|
"The per-band FlexRadio antennas are applied to the satellite slices. A 70 cm downlink was staying on the 2 m transverter.",
|
||||||
|
"Tracking shows where the satellite is beside where the antenna points: azimuth, elevation, distance and altitude.",
|
||||||
|
"The two satellite lists in the settings are sorted by name, numerically. A satellite renamed on getting its OSCAR number is recognised under its old name too.",
|
||||||
|
"The grid-square map filters by one mode, by band and by satellite. The mode and satellite lists come from the log itself.",
|
||||||
|
"Saving the satellite settings refreshes the Satellites tab — un-following a satellite left it in the pass table until the tab was reopened.",
|
||||||
|
"The FT map takes one colour for every decode, from the corner above it. Leave it empty for the colour per band.",
|
||||||
|
"\"Later\" on an update notice now asks for how long: 1, 4, 12 or 24 hours. A newer release still appears at once.",
|
||||||
|
"The satellite frequency plan is checked against AMSAT's live FM and image lists: AO-123 gets its 67.0 Hz tone, SO-50 notes the 74.4 Hz arming tone, and eight SSTV satellites are added.",
|
||||||
|
"The FT map can show who hears YOU — the stations reporting your transmissions to PSK Reporter, as filled diamonds in a colour of their own. Off until asked for.",
|
||||||
|
"The FT map's controls no longer cover it: basemaps clear of the zoom on the left, colour and \"Who hears me\" on the right."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"SteppIR : un bouton Calibrer à côté de Rétracter, sur Station Control.",
|
||||||
|
"Sur le cadran du rotor, le faisceau ne fait plus le tour complet en passant le nord sur un rotor 450°.",
|
||||||
|
"Le widget rotor n’allume plus Stop quand le vent bouge l’antenne, et ne s’éteint plus pendant qu’elle tourne vraiment.",
|
||||||
|
"LilacSat-2 récupère son transpondeur FM — 144,350 en montée, 437,200 en descente — et son vrai nom, LO-90. Le libellé indique « (scheduled) » : il fonctionne selon un calendrier annoncé, pas en continu.",
|
||||||
|
"Une fréquence livrée fausse peut désormais être corrigée sur une station qui possède déjà le fichier satellites. Les entrées modifiées à la main sont préservées.",
|
||||||
|
"Les antennes FlexRadio par bande sont appliquées aux slices satellite. Une descente 70 cm restait sur le transverter 2 m.",
|
||||||
|
"Le suivi affiche où est le satellite à côté de là où pointe l’antenne : azimut, élévation, distance et altitude.",
|
||||||
|
"Les deux listes de satellites des réglages sont triées par nom, en tenant compte des nombres. Un satellite renommé lors de l’attribution de son numéro OSCAR est aussi reconnu sous son ancien nom.",
|
||||||
|
"La carte des carrés locator se filtre par mode précis, par bande et par satellite. Les listes de modes et de satellites viennent du journal.",
|
||||||
|
"Enregistrer les réglages satellite rafraîchit l’onglet Satellites — retirer un satellite le laissait dans le tableau des passes jusqu’à la réouverture.",
|
||||||
|
"La carte FT accepte une couleur unique pour tous les décodages, depuis le coin en haut à droite. Laissez vide pour la couleur par bande.",
|
||||||
|
"« Plus tard » sur une mise à jour demande désormais combien de temps : 1, 4, 12 ou 24 heures. Une version plus récente s’affiche quand même aussitôt.",
|
||||||
|
"Le plan de fréquences satellite est vérifié contre les listes FM et image d’AMSAT : AO-123 reçoit son ton de 67,0 Hz, SO-50 signale le ton d’armement de 74,4 Hz, et huit satellites SSTV sont ajoutés.",
|
||||||
|
"La carte FT peut montrer qui VOUS entend — les stations qui rapportent vos émissions à PSK Reporter, en losanges pleins d’une couleur propre. Éteint par défaut.",
|
||||||
|
"Les contrôles de la carte FT ne la recouvrent plus : fonds de carte dégagés du zoom à gauche, couleur et « Qui m’entend » à droite."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.27.22",
|
"version": "0.27.22",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
@@ -140,6 +140,23 @@ func main() {
|
|||||||
}
|
}
|
||||||
byNORAD[t.NORAD] = append(byNORAD[t.NORAD], t)
|
byNORAD[t.NORAD] = append(byNORAD[t.NORAD], t)
|
||||||
}
|
}
|
||||||
|
// A satellite whose only WORKABLE path is one SatNOGS calls inactive.
|
||||||
|
//
|
||||||
|
// LilacSat-2 is the case that taught this: its FM transponder (144.350 up,
|
||||||
|
// 437.200 down) is marked inactive because it runs on an announced schedule
|
||||||
|
// rather than continuously, so the generator dropped it and shipped the
|
||||||
|
// satellite with nothing but an APRS digipeater. An operator comparing
|
||||||
|
// against any other tracker then finds a frequency plan missing the only
|
||||||
|
// thing anybody works that bird on.
|
||||||
|
//
|
||||||
|
// Not fixed automatically: "inactive" is right far more often than it is
|
||||||
|
// wrong, and reviving every dead transponder would fill the list with
|
||||||
|
// satellites that answer nothing. It is REPORTED, so the next regeneration
|
||||||
|
// is read with this in front of it and the handful worth curating are
|
||||||
|
// curated — a scheduled transponder belongs in the plan with "(scheduled)"
|
||||||
|
// in its label, the way PO-101 and now LO-90 carry it.
|
||||||
|
reportRefused(txs, feed, covered, byNORAD)
|
||||||
|
|
||||||
added := 0
|
added := 0
|
||||||
for n, list := range byNORAD {
|
for n, list := range byNORAD {
|
||||||
b := sat.Bird{Name: displayName(feed[n]), NORAD: n}
|
b := sat.Bird{Name: displayName(feed[n]), NORAD: n}
|
||||||
@@ -442,3 +459,34 @@ func die(err error) {
|
|||||||
fmt.Fprintln(os.Stderr, "satgen:", err)
|
fmt.Fprintln(os.Stderr, "satgen:", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reportRefused names the satellites whose only two-way path was refused for
|
||||||
|
// being inactive, so the operator running this can decide about each one.
|
||||||
|
//
|
||||||
|
// The output is deliberately a question and not a change: SatNOGS calling a
|
||||||
|
// transponder inactive is usually correct, and the exceptions are the birds
|
||||||
|
// whose transponder is switched on to a schedule rather than left running.
|
||||||
|
func reportRefused(txs []transmitter, feed map[int]string, covered map[int]bool, kept map[int][]transmitter) {
|
||||||
|
var lines []string
|
||||||
|
for _, t := range txs {
|
||||||
|
if t.Status == "active" || t.UplinkLow <= 0 || t.DownlinkLow <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if feed[t.NORAD] == "" || covered[t.NORAD] || len(kept[t.NORAD]) > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf(" ? %5d %-22s %-4s %.3f up / %.3f down %q",
|
||||||
|
t.NORAD, displayName(feed[t.NORAD]), adifMode(t.Mode),
|
||||||
|
float64(t.UplinkLow)/1e6, float64(t.DownlinkLow)/1e6, t.Description))
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sort.Strings(lines)
|
||||||
|
fmt.Println("\nRefused as inactive, and nothing else was kept for these satellites.")
|
||||||
|
fmt.Println("A transponder that runs to a schedule looks exactly like a dead one here:")
|
||||||
|
for _, l := range lines {
|
||||||
|
fmt.Println(l)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|||||||
+50
-4
@@ -2455,6 +2455,33 @@ export default function App() {
|
|||||||
if (wlNoticeTimer.current) window.clearTimeout(wlNoticeTimer.current);
|
if (wlNoticeTimer.current) window.clearTimeout(wlNoticeTimer.current);
|
||||||
wlNoticeTimer.current = window.setTimeout(() => setWlNotice(null), 4000);
|
wlNoticeTimer.current = window.setTimeout(() => setWlNotice(null), 4000);
|
||||||
}), []);
|
}), []);
|
||||||
|
// An update deferred stays deferred.
|
||||||
|
//
|
||||||
|
// "Later" only hid the card, and the check behind it runs every five
|
||||||
|
// minutes — so the same notice came back four times an hour, all evening,
|
||||||
|
// for a version already declined. It now records until WHEN, and for which
|
||||||
|
// version: a release newer than the one put off is a different piece of
|
||||||
|
// news and appears at once, so a snooze can never bury an update for good.
|
||||||
|
//
|
||||||
|
// Deliberately not a portable UI pref — a machine told to wait four hours
|
||||||
|
// has said nothing about the operator's other machines.
|
||||||
|
const SNOOZE_KEY = 'opslog.updateSnooze';
|
||||||
|
const updateSnoozed = (version: string) => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SNOOZE_KEY);
|
||||||
|
if (!raw) return false;
|
||||||
|
const s = JSON.parse(raw) as { v?: string; until?: number };
|
||||||
|
return s?.v === version && Number(s?.until) > Date.now();
|
||||||
|
} catch { return false; } // unreadable is not snoozed
|
||||||
|
};
|
||||||
|
const snoozeUpdate = (hours: number) => {
|
||||||
|
try {
|
||||||
|
if (updateInfo) localStorage.setItem(SNOOZE_KEY, JSON.stringify({ v: updateInfo.latest, until: Date.now() + hours * 3600_000 }));
|
||||||
|
} catch { /* quota: the card just comes back, which is the old behaviour */ }
|
||||||
|
setLaterOpen(false);
|
||||||
|
setUpdateInfo(null);
|
||||||
|
};
|
||||||
|
const [laterOpen, setLaterOpen] = useState(false);
|
||||||
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||||
// Fresh update check on demand (opening About), so it never shows a stale
|
// Fresh update check on demand (opening About), so it never shows a stale
|
||||||
// "you're up to date". Clears updateInfo when the latest check finds nothing.
|
// "you're up to date". Clears updateInfo when the latest check finds nothing.
|
||||||
@@ -2472,7 +2499,11 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (localStorage.getItem('opslog.checkUpdates') === '0') return;
|
if (localStorage.getItem('opslog.checkUpdates') === '0') return;
|
||||||
const check = () => CheckForUpdate().then((u: any) => {
|
const check = () => CheckForUpdate().then((u: any) => {
|
||||||
if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') });
|
// The snooze is checked HERE and not in checkUpdateNow: opening About
|
||||||
|
// is a question, and it deserves the answer whatever was deferred.
|
||||||
|
if (u?.available && u?.latest && !updateSnoozed(String(u.latest))) {
|
||||||
|
setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') });
|
||||||
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
check();
|
check();
|
||||||
const id = window.setInterval(check, 5 * 60 * 1000);
|
const id = window.setInterval(check, 5 * 60 * 1000);
|
||||||
@@ -6613,6 +6644,9 @@ export default function App() {
|
|||||||
// Only while CAT is actually connected: an empty band means "nothing to
|
// Only while CAT is actually connected: an empty band means "nothing to
|
||||||
// compare with", never "the rig is on no band".
|
// compare with", never "the rig is on no band".
|
||||||
rigBand={catState.connected ? (catState.band || '') : ''}
|
rigBand={catState.connected ? (catState.band || '') : ''}
|
||||||
|
// Every band the radio is listening on, so a second slice on a second
|
||||||
|
// band does not read as a decoder that has lost CAT.
|
||||||
|
rigBands={catState.connected ? (catState.rx_bands ?? []) : []}
|
||||||
myCall={station.callsign}
|
myCall={station.callsign}
|
||||||
myGrid={station.my_grid}
|
myGrid={station.my_grid}
|
||||||
// A DOUBLE click answers the station: it hands the decode back to
|
// A DOUBLE click answers the station: it hands the decode back to
|
||||||
@@ -7422,16 +7456,28 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||||
<button onClick={startUpdate} className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">
|
<button onClick={startUpdate} className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">
|
||||||
{updateInfo.downloadUrl ? t('upd.install') : t('upd.download')}
|
{updateInfo.downloadUrl ? t('upd.install') : t('upd.download')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setUpdateInfo(null)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">{t('upd.later')}</button>
|
{laterOpen ? (
|
||||||
|
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||||
|
{t('upd.remindIn')}
|
||||||
|
{[1, 4, 12, 24].map((h) => (
|
||||||
|
<button key={h} onClick={() => snoozeUpdate(h)}
|
||||||
|
className="h-6 px-1.5 rounded border border-border text-[11px] tabular-nums hover:bg-muted text-foreground">
|
||||||
|
{h}h
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setLaterOpen(true)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">{t('upd.later')}</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!updating && (
|
{!updating && (
|
||||||
<button onClick={() => setUpdateInfo(null)} className="text-muted-foreground hover:text-foreground shrink-0" title="Dismiss">
|
<button onClick={() => snoozeUpdate(1)} className="text-muted-foreground hover:text-foreground shrink-0" title={t('upd.dismissHour')}>
|
||||||
<X className="size-4" />
|
<X className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ interface Props {
|
|||||||
// The band the RIG is on, when CAT is connected. Only ever compared with what
|
// The band the RIG is on, when CAT is connected. Only ever compared with what
|
||||||
// the decoder announces — see the drift warning.
|
// the decoder announces — see the drift warning.
|
||||||
rigBand?: string;
|
rigBand?: string;
|
||||||
|
// Every band the radio has a receiver on. A Flex running two slices has
|
||||||
|
// two, and a decoder on either of them is not drifting.
|
||||||
|
rigBands?: string[];
|
||||||
onCall: (d: Decode) => void;
|
onCall: (d: Decode) => void;
|
||||||
// A single click: take the station without transmitting — fill the entry, and
|
// A single click: take the station without transmitting — fill the entry, and
|
||||||
// point the panels at it. Absent, a click falls back to onCall.
|
// point the panels at it. Absent, a click falls back to onCall.
|
||||||
@@ -641,7 +644,7 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, myGrid, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: Props) {
|
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, rigBands, onCall, onSelect, myCall, myGrid, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
// Column widths, dragged in the header and shared by every row. Persisted
|
// Column widths, dragged in the header and shared by every row. Persisted
|
||||||
// through writeUiPref (not raw localStorage) so the layout travels with data/
|
// through writeUiPref (not raw localStorage) so the layout travels with data/
|
||||||
@@ -778,9 +781,19 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
|||||||
// Said, not decided. Using the rig's band instead would be wrong for anyone
|
// Said, not decided. Using the rig's band instead would be wrong for anyone
|
||||||
// decoding a second receiver on another band, and a warning costs that setup
|
// decoding a second receiver on another band, and a warning costs that setup
|
||||||
// nothing but a line it can read past.
|
// nothing but a line it can read past.
|
||||||
|
//
|
||||||
|
// Compared against every band the radio is RECEIVING on, not the transmit
|
||||||
|
// band. Two slices on two bands with a decoder on each is a normal setup,
|
||||||
|
// and it made this warning lie: with slice A on 20 m, slice B on 40 m and
|
||||||
|
// transmit focus on B, the 20 m decoder was told the rig was on 40 m while
|
||||||
|
// the slice it listens to was on 20 m all along. The warning is for a
|
||||||
|
// decoder announcing a band NOTHING on the radio is on, which is what a
|
||||||
|
// lost CAT link actually looks like.
|
||||||
const decoderBand = decodes.length ? (decodes[decodes.length - 1].band ?? '') : '';
|
const decoderBand = decodes.length ? (decodes[decodes.length - 1].band ?? '') : '';
|
||||||
const bandDrift = !!rigBand && !!decoderBand
|
const onAir = (rigBands && rigBands.length ? rigBands : (rigBand ? [rigBand] : []))
|
||||||
&& rigBand.toLowerCase() !== decoderBand.toLowerCase();
|
.map((b) => b.toLowerCase());
|
||||||
|
const bandDrift = onAir.length > 0 && !!decoderBand
|
||||||
|
&& !onAir.includes(decoderBand.toLowerCase());
|
||||||
const driftInstance = decodes.length ? (decodes[decodes.length - 1].instance ?? '') : '';
|
const driftInstance = decodes.length ? (decodes[decodes.length - 1].instance ?? '') : '';
|
||||||
|
|
||||||
const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode;
|
const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { gridToLatLon, greatCirclePoints, splitAtAntimeridian } from '@/lib/maidenhead';
|
import { gridToLatLon, greatCirclePoints, splitAtAntimeridian } from '@/lib/maidenhead';
|
||||||
@@ -7,6 +7,9 @@ import { cn } from '@/lib/utils';
|
|||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { loadMapView, saveMapView, MAP_VIEW_FT } from '@/lib/mapView';
|
import { loadMapView, saveMapView, MAP_VIEW_FT } from '@/lib/mapView';
|
||||||
import { loadMapBase, saveMapBase, MAP_BASE_FT } from '@/lib/mapBase';
|
import { loadMapBase, saveMapBase, MAP_BASE_FT } from '@/lib/mapBase';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { GetHearMe, SetHearMe, GetWhoHearsMe } from '../../wailsjs/go/main/App';
|
||||||
|
import { Ear } from 'lucide-react';
|
||||||
|
|
||||||
// FT Map — the live decode feed as geography: every station decoded in the
|
// FT Map — the live decode feed as geography: every station decoded in the
|
||||||
// last half hour, an arc from the operator's own square to theirs, coloured by
|
// last half hour, an arc from the operator's own square to theirs, coloured by
|
||||||
@@ -41,6 +44,52 @@ const BAND_COLOURS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
const bandColour = (b?: string) => BAND_COLOURS[(b ?? '').toLowerCase()] || '#9ca3af';
|
const bandColour = (b?: string) => BAND_COLOURS[(b ?? '').toLowerCase()] || '#9ca3af';
|
||||||
|
|
||||||
|
// One colour for every arc, overriding the palette. Empty means per band,
|
||||||
|
// which stays the default.
|
||||||
|
//
|
||||||
|
// The palette is only useful to somebody watching several bands at once, and
|
||||||
|
// it is calibrated against a plain map: 60m navy and 70cm olive all but
|
||||||
|
// disappear over the satellite imagery, and 20m yellow over the deserts. An
|
||||||
|
// operator on one band has nothing to lose by painting the whole map in a
|
||||||
|
// colour that shows up against the ground he chose.
|
||||||
|
const COL_KEY = 'opslog.ftMapColour';
|
||||||
|
|
||||||
|
// The reverse layer's own colour. Empty means the default below.
|
||||||
|
//
|
||||||
|
// Separate from COL_KEY on purpose: the decode colour exists because the
|
||||||
|
// band palette vanishes over some basemaps, and this one has exactly the
|
||||||
|
// same problem for exactly the same reason — cyan over a pale sea reads no
|
||||||
|
// better than 60m navy does. One control for both would have forced the
|
||||||
|
// two layers into one colour, which is the distinction it took a shape to
|
||||||
|
// make in the first place.
|
||||||
|
const HEARD_COL_KEY = 'opslog.ftMapHeardColour';
|
||||||
|
|
||||||
|
// A colour input only accepts #rrggbb, so anything else stored here is
|
||||||
|
// treated as no choice at all rather than driving the swatch to black.
|
||||||
|
const asHex = (v: string) => (/^#[0-9a-f]{6}$/i.test(v.trim()) ? v.trim() : '');
|
||||||
|
|
||||||
|
// One station reporting our own transmissions, from PSK Reporter.
|
||||||
|
type Heard = { call: string; grid: string; band: string; mode: string; snr: number; at: string };
|
||||||
|
|
||||||
|
// The reverse layer is marks only, in one colour whatever the band, and the
|
||||||
|
// mark is a DIAMOND.
|
||||||
|
//
|
||||||
|
// It started with an arc per station, like the decodes, and that was wrong:
|
||||||
|
// with a few dozen receivers reporting, the map became a fan of lines out of
|
||||||
|
// one square that buried the very arcs it sat beside. Nothing was gained by
|
||||||
|
// them either — an arc's job on the decode layer is to say WHICH of many
|
||||||
|
// stations a path belongs to, and here every path starts at the same place.
|
||||||
|
//
|
||||||
|
// Shape, then, rather than colour, carries the distinction: the decode dots
|
||||||
|
// are small filled circles, and the arcs already use fourteen colours, so a
|
||||||
|
// fifteenth would read as another band. A diamond is unmistakably not one of
|
||||||
|
// them at a glance.
|
||||||
|
//
|
||||||
|
// Filled, with a hairline white edge — the same trick the home marker uses.
|
||||||
|
// It was a ring, and a ring is an outline drawn over whatever is beneath it:
|
||||||
|
// eight pixels of it over the satellite imagery was barely there.
|
||||||
|
const HEARD_COLOUR = '#22d3ee'; // the default, when nothing is chosen
|
||||||
|
|
||||||
const MAX_ARCS = 300;
|
const MAX_ARCS = 300;
|
||||||
const MAX_AGE_MS = 30 * 60_000;
|
const MAX_AGE_MS = 30 * 60_000;
|
||||||
|
|
||||||
@@ -53,6 +102,14 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
|||||||
}) {
|
}) {
|
||||||
// Held in refs so the redraw below does not have to list them as dependencies
|
// Held in refs so the redraw below does not have to list them as dependencies
|
||||||
// and rebuild every arc whenever the parent re-renders.
|
// and rebuild every arc whenever the parent re-renders.
|
||||||
|
const [colour, setColour] = useState(() => asHex(localStorage.getItem(COL_KEY) ?? ''));
|
||||||
|
const [heardColour, setHeardColour] = useState(() => asHex(localStorage.getItem(HEARD_COL_KEY) ?? ''));
|
||||||
|
const heardInk = heardColour || HEARD_COLOUR;
|
||||||
|
// Whether the reverse feed is wanted lives in the DB, not here: it is what
|
||||||
|
// starts an MQTT subscription, so the backend has to be the one that knows.
|
||||||
|
const [hearMe, setHearMe] = useState(false);
|
||||||
|
const [heard, setHeard] = useState<Heard[]>([]);
|
||||||
|
const [heardBusy, setHeardBusy] = useState(false);
|
||||||
const selectRef = useRef(onSelect);
|
const selectRef = useRef(onSelect);
|
||||||
const callRef = useRef(onCall);
|
const callRef = useRef(onCall);
|
||||||
useEffect(() => { selectRef.current = onSelect; callRef.current = onCall; }, [onSelect, onCall]);
|
useEffect(() => { selectRef.current = onSelect; callRef.current = onCall; }, [onSelect, onCall]);
|
||||||
@@ -68,6 +125,10 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
|||||||
const divRef = useRef<HTMLDivElement>(null);
|
const divRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<L.Map | null>(null);
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
const layerRef = useRef<L.LayerGroup | null>(null);
|
const layerRef = useRef<L.LayerGroup | null>(null);
|
||||||
|
// Its own group: the reverse layer refreshes on its own clock, and clearing
|
||||||
|
// the decode arcs to redraw it would throw away three hundred polylines
|
||||||
|
// every twenty seconds for nothing.
|
||||||
|
const heardLayerRef = useRef<L.LayerGroup | null>(null);
|
||||||
const baseRef = useRef<L.TileLayer | null>(null);
|
const baseRef = useRef<L.TileLayer | null>(null);
|
||||||
const labelsRef = useRef<L.TileLayer | null>(null);
|
const labelsRef = useRef<L.TileLayer | null>(null);
|
||||||
const [basemap, setBasemap] = useState<BasemapKey>(() =>
|
const [basemap, setBasemap] = useState<BasemapKey>(() =>
|
||||||
@@ -95,6 +156,7 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
|||||||
});
|
});
|
||||||
mapRef.current = m;
|
mapRef.current = m;
|
||||||
layerRef.current = L.layerGroup().addTo(m);
|
layerRef.current = L.layerGroup().addTo(m);
|
||||||
|
heardLayerRef.current = L.layerGroup().addTo(m);
|
||||||
// Leaflet measures its container ONCE, when the map is created, and then
|
// Leaflet measures its container ONCE, when the map is created, and then
|
||||||
// draws tiles for that size for ever. This panel is mounted the moment its
|
// draws tiles for that size for ever. This panel is mounted the moment its
|
||||||
// tab is selected — before the flex layout has settled — and the window can
|
// tab is selected — before the flex layout has settled — and the window can
|
||||||
@@ -113,6 +175,7 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
|||||||
m.remove();
|
m.remove();
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
layerRef.current = null;
|
layerRef.current = null;
|
||||||
|
heardLayerRef.current = null;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -132,6 +195,34 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
|||||||
saveMapBase(MAP_BASE_FT, basemap);
|
saveMapBase(MAP_BASE_FT, basemap);
|
||||||
}, [basemap]);
|
}, [basemap]);
|
||||||
|
|
||||||
|
useEffect(() => { GetHearMe().then((v) => setHearMe(!!v)).catch(() => {}); }, []);
|
||||||
|
|
||||||
|
// Polled rather than pushed: the reports arrive from the broker in batches
|
||||||
|
// whenever an uploader gets round to it, and a fifteen-minute window redrawn
|
||||||
|
// every twenty seconds is as live as the data underneath it actually is.
|
||||||
|
const loadHeard = useCallback(() => {
|
||||||
|
GetWhoHearsMe().then((r: any) => setHeard((Array.isArray(r) ? r : []) as Heard[])).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hearMe) { setHeard([]); return; }
|
||||||
|
loadHeard();
|
||||||
|
const id = window.setInterval(loadHeard, 20_000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [hearMe, loadHeard]);
|
||||||
|
|
||||||
|
const toggleHearMe = async () => {
|
||||||
|
setHeardBusy(true);
|
||||||
|
const next = !hearMe;
|
||||||
|
try {
|
||||||
|
await SetHearMe(next);
|
||||||
|
setHearMe(next);
|
||||||
|
} catch {
|
||||||
|
// The usual cause is no station callsign, which is what it subscribes
|
||||||
|
// to. Read the state back rather than assuming either way.
|
||||||
|
try { setHearMe(!!(await GetHearMe())); } catch { /* leave it */ }
|
||||||
|
} finally { setHeardBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
// The arcs, redrawn when the decode list changes. Newest last so they paint
|
// The arcs, redrawn when the decode list changes. Newest last so they paint
|
||||||
// on top; opacity falls with age so the map reads as "now" with a memory.
|
// on top; opacity falls with age so the map reads as "now" with a memory.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -156,21 +247,21 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
|||||||
if (!to) continue;
|
if (!to) continue;
|
||||||
const age = now - Date.parse(d.at);
|
const age = now - Date.parse(d.at);
|
||||||
const fade = Math.max(0.15, 1 - age / MAX_AGE_MS);
|
const fade = Math.max(0.15, 1 - age / MAX_AGE_MS);
|
||||||
const colour = bandColour(d.band);
|
const stroke = colour || bandColour(d.band);
|
||||||
// Cut at the antimeridian: this map shows ONE world, so a path running
|
// Cut at the antimeridian: this map shows ONE world, so a path running
|
||||||
// past ±180 has to leave one edge and come back at the other. Without it
|
// past ±180 has to leave one edge and come back at the other. Without it
|
||||||
// every arc out of VK or ZL was drawn into the blank space off the side
|
// every arc out of VK or ZL was drawn into the blank space off the side
|
||||||
// of the map, its far end sitting alone on the opposite coast.
|
// of the map, its far end sitting alone on the opposite coast.
|
||||||
const pts = splitAtAntimeridian(greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48));
|
const pts = splitAtAntimeridian(greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48));
|
||||||
L.polyline(pts as L.LatLngExpression[][], {
|
L.polyline(pts as L.LatLngExpression[][], {
|
||||||
color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
|
color: stroke, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
|
||||||
}).addTo(layer);
|
}).addTo(layer);
|
||||||
const label = `${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`;
|
const label = `${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`;
|
||||||
const mk = L.circleMarker([to.lat, to.lon], {
|
const mk = L.circleMarker([to.lat, to.lon], {
|
||||||
// A three-pixel dot is a fine mark and a poor target, so the visible
|
// A three-pixel dot is a fine mark and a poor target, so the visible
|
||||||
// radius stays and an invisible one three times the size takes the
|
// radius stays and an invisible one three times the size takes the
|
||||||
// clicks.
|
// clicks.
|
||||||
radius: 3, color: colour, weight: 1, fillColor: colour, fillOpacity: 0.9 * fade,
|
radius: 3, color: stroke, weight: 1, fillColor: stroke, fillOpacity: 0.9 * fade,
|
||||||
}).bindTooltip(label, { direction: 'top' }).addTo(layer);
|
}).bindTooltip(label, { direction: 'top' }).addTo(layer);
|
||||||
// The tooltip goes on the HIT circle too, and it is the one that matters:
|
// The tooltip goes on the HIT circle too, and it is the one that matters:
|
||||||
// being on top, it takes the hover as well as the click, and binding it
|
// being on top, it takes the hover as well as the click, and binding it
|
||||||
@@ -196,7 +287,45 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [decodes, myGrid]);
|
}, [decodes, myGrid, colour]);
|
||||||
|
|
||||||
|
// The reverse layer: one mark where each station that reported us sits, and
|
||||||
|
// no line to it.
|
||||||
|
//
|
||||||
|
// A divIcon rather than a canvas circle, because canvas draws circles and
|
||||||
|
// nothing else, and the whole point is a shape that is not a circle. The
|
||||||
|
// cost is DOM nodes, which is affordable HERE and would not be on the decode
|
||||||
|
// layer: this is a few dozen receivers against three hundred arcs.
|
||||||
|
useEffect(() => {
|
||||||
|
const layer = heardLayerRef.current;
|
||||||
|
if (!layer) return;
|
||||||
|
layer.clearLayers();
|
||||||
|
if (!hearMe) return;
|
||||||
|
const now = Date.now();
|
||||||
|
for (const h of heard) {
|
||||||
|
const to = gridToLatLon(h.grid);
|
||||||
|
if (!to) continue;
|
||||||
|
const ageMs = Math.max(0, now - Date.parse(h.at));
|
||||||
|
const ageMin = Math.round(ageMs / 60_000);
|
||||||
|
// Faded with age over the window, as the decode arcs are: the freshest
|
||||||
|
// report is the one that says a path is open NOW.
|
||||||
|
const fade = Math.max(0.3, 1 - ageMs / (15 * 60_000));
|
||||||
|
const label = `${h.call} · ${h.grid} · ${h.snr > 0 ? '+' : ''}${h.snr} dB · ${h.band}${ageMin > 0 ? ` · ${ageMin}'` : ''}`;
|
||||||
|
// The box is bigger than the diamond so there is something to point at:
|
||||||
|
// a nine-pixel mark is a fine sight and a poor target.
|
||||||
|
const icon = L.divIcon({
|
||||||
|
className: '',
|
||||||
|
iconSize: [16, 16],
|
||||||
|
iconAnchor: [8, 8],
|
||||||
|
html: `<div style="width:16px;height:16px;display:flex;align-items:center;justify-content:center">`
|
||||||
|
+ `<div style="width:9px;height:9px;transform:rotate(45deg);background:${heardInk};`
|
||||||
|
+ `box-shadow:0 0 0 1px rgba(255,255,255,.75);opacity:${fade.toFixed(2)}"></div></div>`,
|
||||||
|
});
|
||||||
|
L.marker([to.lat, to.lon], { icon, interactive: true, keyboard: false })
|
||||||
|
.bindTooltip(label, { direction: 'top' })
|
||||||
|
.addTo(layer);
|
||||||
|
}
|
||||||
|
}, [heard, hearMe, heardInk]);
|
||||||
|
|
||||||
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
|
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
|
||||||
return (
|
return (
|
||||||
@@ -205,8 +334,11 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
|||||||
// keeps all of it inside this panel.
|
// keeps all of it inside this panel.
|
||||||
<div className="relative isolate z-0 h-full w-full min-h-0">
|
<div className="relative isolate z-0 h-full w-full min-h-0">
|
||||||
<div ref={divRef} className="absolute inset-0 rounded-lg overflow-hidden" />
|
<div ref={divRef} className="absolute inset-0 rounded-lg overflow-hidden" />
|
||||||
{/* Basemap picker, MainMap's own vocabulary. */}
|
{/* Basemap picker, MainMap's own vocabulary.
|
||||||
<div className="absolute top-2 left-12 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
|
left-16, not left-12: Leaflet's zoom control is 30 px of buttons plus
|
||||||
|
its 10 px margin and a border, and at 48 px this row started on top
|
||||||
|
of it — the − button took the click that was meant for Street. */}
|
||||||
|
<div className="absolute top-2 left-16 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
|
||||||
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
||||||
<button key={k} type="button" onClick={() => setBasemap(k)}
|
<button key={k} type="button" onClick={() => setBasemap(k)}
|
||||||
className={cn('px-2 py-0.5 rounded text-[11px]',
|
className={cn('px-2 py-0.5 rounded text-[11px]',
|
||||||
@@ -215,12 +347,71 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* Band legend — only the bands actually on screen. */}
|
{/* The two things that are not the basemap, on the OTHER side.
|
||||||
|
|
||||||
|
They sat in the same row, which grew until it reached the middle of
|
||||||
|
the map — and a control bar spanning half the width of a world map is
|
||||||
|
covering the Atlantic to save a corner that was empty the whole time.
|
||||||
|
Leaflet puts nothing top-right but the attribution, which is at the
|
||||||
|
bottom. */}
|
||||||
|
<div className="absolute top-2 right-2 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
|
||||||
|
{/* Opens on the crimson the home marker already uses — chosen to hold
|
||||||
|
up on every basemap, which is a better first suggestion than
|
||||||
|
whichever band colour happens to be first in the palette. */}
|
||||||
|
<input type="color" title={t('ftmap.colour')}
|
||||||
|
className="size-5 self-center rounded border border-border bg-transparent p-0 cursor-pointer"
|
||||||
|
value={colour || '#e11d48'}
|
||||||
|
onChange={(e) => { const v = asHex(e.target.value); setColour(v); writeUiPref(COL_KEY, v); }} />
|
||||||
|
{!!colour && (
|
||||||
|
<button type="button" title={t('ftmap.colourPerBand')}
|
||||||
|
onClick={() => { setColour(''); writeUiPref(COL_KEY, ''); }}
|
||||||
|
className="px-1 text-[11px] text-muted-foreground hover:text-foreground">↺</button>
|
||||||
|
)}
|
||||||
|
<span className="mx-0.5 w-px self-stretch bg-border" />
|
||||||
|
{/* The reverse layer. A switch, not a filter: it starts a subscription
|
||||||
|
at the broker, so it is off until asked for. */}
|
||||||
|
<button type="button" onClick={toggleHearMe} disabled={heardBusy}
|
||||||
|
title={t('ftmap.hearMeTip')}
|
||||||
|
className={cn('flex items-center gap-1 px-1.5 h-6 rounded text-[11px] disabled:opacity-50',
|
||||||
|
hearMe ? 'font-semibold' : 'text-muted-foreground hover:bg-muted')}
|
||||||
|
style={hearMe ? { color: heardInk } : undefined}>
|
||||||
|
<Ear className="size-3" />
|
||||||
|
{t('ftmap.hearMe')}
|
||||||
|
{hearMe && <span className="tabular-nums opacity-80">{heard.length}</span>}
|
||||||
|
</button>
|
||||||
|
{hearMe && (
|
||||||
|
<input type="color" title={t('ftmap.heardColour')}
|
||||||
|
className="size-5 self-center rounded border border-border bg-transparent p-0 cursor-pointer"
|
||||||
|
value={heardInk}
|
||||||
|
onChange={(e) => { const v = asHex(e.target.value); setHeardColour(v); writeUiPref(HEARD_COL_KEY, v); }} />
|
||||||
|
)}
|
||||||
|
{hearMe && !!heardColour && (
|
||||||
|
<button type="button" title={t('ftmap.heardColourReset')}
|
||||||
|
onClick={() => { setHeardColour(''); writeUiPref(HEARD_COL_KEY, ''); }}
|
||||||
|
className="px-1 text-[11px] text-muted-foreground hover:text-foreground">↺</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* What the diamonds are. In the legend rather than a tooltip because
|
||||||
|
they are the only thing on the map that is not a decode of ours, and
|
||||||
|
an unexplained second mark is worse than none.
|
||||||
|
|
||||||
|
Bottom-right, where the band legend is not: the two would otherwise
|
||||||
|
stack into one block and read as one key. */}
|
||||||
|
{hearMe && heard.length > 0 && (
|
||||||
|
<div className="absolute bottom-2 right-2 z-[1000] flex items-center gap-1.5 rounded-md bg-background/85 backdrop-blur px-2 py-1.5 border border-border text-[11px]">
|
||||||
|
<span className="inline-block size-2 rotate-45" style={{ background: heardInk }} />
|
||||||
|
{t('ftmap.hearMeLegend', { n: heard.length })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Band legend — only the bands actually on screen. With one colour
|
||||||
|
forced it keeps the band NAMES and drops the swatches: which bands
|
||||||
|
are up is still worth knowing, a colour key that no longer maps to
|
||||||
|
anything is not. */}
|
||||||
{bands.length > 0 && (
|
{bands.length > 0 && (
|
||||||
<div className="absolute bottom-2 left-2 z-[1000] flex flex-wrap gap-x-2.5 gap-y-1 rounded-md bg-background/85 backdrop-blur px-2 py-1.5 border border-border">
|
<div className="absolute bottom-2 left-2 z-[1000] flex flex-wrap gap-x-2.5 gap-y-1 rounded-md bg-background/85 backdrop-blur px-2 py-1.5 border border-border">
|
||||||
{bands.map((b) => (
|
{bands.map((b) => (
|
||||||
<span key={b} className="flex items-center gap-1 text-[11px] text-foreground">
|
<span key={b} className="flex items-center gap-1 text-[11px] text-foreground">
|
||||||
<span className="inline-block w-3 h-[3px] rounded" style={{ background: bandColour(b) }} />
|
{!colour && <span className="inline-block w-3 h-[3px] rounded" style={{ background: bandColour(b) }} />}
|
||||||
{b.toUpperCase()}
|
{b.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { Loader2, RefreshCw } from 'lucide-react';
|
import { Loader2, RefreshCw } from 'lucide-react';
|
||||||
import { GridSquares } from '../../wailsjs/go/main/App';
|
import { GridSquares, GridSquareChoices, GetListsSettings } from '../../wailsjs/go/main/App';
|
||||||
import { gridSquareBounds, gridToLatLon } from '@/lib/maidenhead';
|
import { gridSquareBounds, gridToLatLon } from '@/lib/maidenhead';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -47,20 +47,27 @@ function cssColour(token: string, fallback: string): string {
|
|||||||
} catch { return fallback; }
|
} catch { return fallback; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode scope. The names come straight from the backend's own classes ("ALL",
|
// Mode scope. The four broad classes the rest of the app uses, as buttons —
|
||||||
// "PHONE", "CW", "DIGI") plus FTX, which is narrower than digital and usually
|
// and then any single mode the log actually holds, from the dropdown beside
|
||||||
// the honest one beside an FTx panel — a square worked on RTTY in a contest is
|
// them.
|
||||||
// not a square worked on FT8.
|
//
|
||||||
|
// There used to be an FTx button here, lumping FT8, FT4 and FT2 together. It
|
||||||
|
// was the wrong grain in both directions: "digital" already put a contest RTTY
|
||||||
|
// square beside an FT8 one, and FTx then put FT8 beside FT4, when the question
|
||||||
|
// this map answers is where ONE mode has been heard. The specific modes are
|
||||||
|
// read from the log rather than listed here, so FT2 is offered to an operator
|
||||||
|
// already using it and needs no change here the day it becomes registered.
|
||||||
const SCOPES = [
|
const SCOPES = [
|
||||||
{ key: 'ALL', label: 'gsm.all' },
|
{ key: 'ALL', label: 'gsm.all' },
|
||||||
{ key: 'PHONE', label: 'gsm.phone' },
|
{ key: 'PHONE', label: 'gsm.phone' },
|
||||||
{ key: 'CW', label: 'gsm.cw' },
|
{ key: 'CW', label: 'gsm.cw' },
|
||||||
{ key: 'DIGI', label: 'gsm.digital' },
|
{ key: 'DIGI', label: 'gsm.digital' },
|
||||||
{ key: 'FTX', label: 'gsm.ftx' },
|
|
||||||
] as const;
|
] as const;
|
||||||
type ScopeKey = typeof SCOPES[number]['key'];
|
type ScopeKey = string;
|
||||||
|
|
||||||
const SCOPE_KEY = 'opslog.gridMapScope';
|
const SCOPE_KEY = 'opslog.gridMapScope';
|
||||||
|
const BAND_KEY = 'opslog.gridMapBand';
|
||||||
|
const SAT_KEY = 'opslog.gridMapSat';
|
||||||
// Chosen fill colours. Empty means "follow the theme", which is the default and
|
// Chosen fill colours. Empty means "follow the theme", which is the default and
|
||||||
// stays the default: the tokens already track the four themes, and freezing a
|
// stays the default: the tokens already track the four themes, and freezing a
|
||||||
// hex at first run would leave a dark-theme map painted in the light palette.
|
// hex at first run would leave a dark-theme map painted in the light palette.
|
||||||
@@ -83,9 +90,22 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
|||||||
const [squares, setSquares] = useState<Square[] | null>(null);
|
const [squares, setSquares] = useState<Square[] | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
const [scope, setScope] = useState<ScopeKey>(
|
// The stored scope is taken as given rather than checked against SCOPES: it
|
||||||
() => (SCOPES.some((s) => s.key === localStorage.getItem(SCOPE_KEY))
|
// may legitimately be a mode name now, and the backend answers a mode nothing
|
||||||
? (localStorage.getItem(SCOPE_KEY) as ScopeKey) : 'DIGI'));
|
// was worked on with no squares rather than an error.
|
||||||
|
const [scope, setScope] = useState<ScopeKey>(() => {
|
||||||
|
const v = localStorage.getItem(SCOPE_KEY) || 'DIGI';
|
||||||
|
// FTX was a button until the named modes replaced it. Left as it was, no
|
||||||
|
// control would show it selected while the map stayed filtered by it.
|
||||||
|
return v === 'FTX' ? 'DIGI' : v;
|
||||||
|
});
|
||||||
|
const [band, setBand] = useState(() => localStorage.getItem(BAND_KEY) ?? '');
|
||||||
|
const [sat, setSat] = useState(() => localStorage.getItem(SAT_KEY) ?? '');
|
||||||
|
// What the three filters can offer. The modes and satellites are the ones the
|
||||||
|
// squares were actually worked on; the bands are the station's own list too,
|
||||||
|
// so a band configured but not yet worked is still there to ask about.
|
||||||
|
const [choices, setChoices] = useState<{ modes: string[]; bands: string[]; satellites: string[] }>(
|
||||||
|
{ modes: [], bands: [], satellites: [] });
|
||||||
|
|
||||||
// This map's own imagery. It shared the world map's key until they were
|
// This map's own imagery. It shared the world map's key until they were
|
||||||
// separated, so a choice made back then is inherited rather than reset.
|
// separated, so a choice made back then is inherited rather than reset.
|
||||||
@@ -102,17 +122,53 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
|||||||
return () => obs.disconnect();
|
return () => obs.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const load = async (sc: ScopeKey = scope) => {
|
const load = async (sc: ScopeKey = scope, bd: string = band, st: string = sat) => {
|
||||||
setBusy(true); setErr('');
|
setBusy(true); setErr('');
|
||||||
try {
|
try {
|
||||||
const r = (await GridSquares(sc)) as any;
|
const r = (await GridSquares(sc, bd, st)) as any;
|
||||||
setSquares((Array.isArray(r) ? r : []) as Square[]);
|
setSquares((Array.isArray(r) ? r : []) as Square[]);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setErr(String(e?.message ?? e));
|
setErr(String(e?.message ?? e));
|
||||||
setSquares([]);
|
setSquares([]);
|
||||||
} finally { setBusy(false); }
|
} finally { setBusy(false); }
|
||||||
};
|
};
|
||||||
useEffect(() => { void load(scope); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [scope]);
|
useEffect(() => { void load(scope, band, sat); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [scope, band, sat]);
|
||||||
|
|
||||||
|
// Loaded once: what the filters can offer changes only when the log does, and
|
||||||
|
// the refresh button reloads it alongside the squares.
|
||||||
|
const loadChoices = async () => {
|
||||||
|
try {
|
||||||
|
const c: any = await GridSquareChoices();
|
||||||
|
let bands: string[] = (c?.bands ?? []) as string[];
|
||||||
|
try {
|
||||||
|
const ls: any = await GetListsSettings();
|
||||||
|
const have = new Set(bands.map((b) => b.toLowerCase()));
|
||||||
|
// Union, the station's own list first: a configured band with nothing
|
||||||
|
// worked on it is still a fair question, and a band worked but never
|
||||||
|
// configured must not become unreachable.
|
||||||
|
const extra = ((ls?.bands ?? []) as string[])
|
||||||
|
.map((b) => String(b).toLowerCase())
|
||||||
|
.filter((b) => b && !have.has(b));
|
||||||
|
bands = [...extra, ...bands];
|
||||||
|
} catch { /* the log's own bands are enough */ }
|
||||||
|
setChoices({
|
||||||
|
modes: (c?.modes ?? []) as string[],
|
||||||
|
bands,
|
||||||
|
satellites: (c?.satellites ?? []) as string[],
|
||||||
|
});
|
||||||
|
} catch { /* the class buttons still work without it */ }
|
||||||
|
};
|
||||||
|
useEffect(() => { void loadChoices(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, []);
|
||||||
|
|
||||||
|
// Only the modes NOT already a button: listing SSB and CW again would be two
|
||||||
|
// controls giving one answer.
|
||||||
|
const namedModes = useMemo(
|
||||||
|
() => choices.modes.filter((m) => !SCOPES.some((c) => c.key === m.toUpperCase())),
|
||||||
|
[choices.modes]);
|
||||||
|
const pick = (key: string, v: string, set: (v: string) => void) => {
|
||||||
|
set(v);
|
||||||
|
try { localStorage.setItem(key, v); } catch { /* quota */ }
|
||||||
|
};
|
||||||
|
|
||||||
// One-time map creation. preferCanvas: a busy digital log is a few thousand
|
// One-time map creation. preferCanvas: a busy digital log is a few thousand
|
||||||
// rectangles, and as SVG that is a few thousand DOM nodes to lay out on every
|
// rectangles, and as SVG that is a few thousand DOM nodes to lay out on every
|
||||||
@@ -244,13 +300,45 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
|||||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
{SCOPES.map((s, i) => (
|
{SCOPES.map((s, i) => (
|
||||||
<button key={s.key} type="button"
|
<button key={s.key} type="button"
|
||||||
onClick={() => { setScope(s.key); try { localStorage.setItem(SCOPE_KEY, s.key); } catch { /* quota */ } }}
|
onClick={() => pick(SCOPE_KEY, s.key, setScope)}
|
||||||
className={cn('px-1.5 h-6 text-[11px] whitespace-nowrap', i > 0 && 'border-l border-border',
|
className={cn('px-1.5 h-6 text-[11px] whitespace-nowrap', i > 0 && 'border-l border-border',
|
||||||
scope === s.key ? 'bg-primary text-primary-foreground' : 'hover:bg-muted text-muted-foreground')}>
|
scope === s.key ? 'bg-primary text-primary-foreground' : 'hover:bg-muted text-muted-foreground')}>
|
||||||
{t(s.label)}
|
{t(s.label)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{/* One named mode, where the FTx button used to be. It shares the scope
|
||||||
|
with the buttons rather than filtering on top of them: mode is one
|
||||||
|
question, and two controls that both answer it is how a map ends up
|
||||||
|
showing PHONE ∩ FT8, which is empty. Picking a mode here therefore
|
||||||
|
un-picks the buttons, and vice versa. */}
|
||||||
|
{namedModes.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={namedModes.includes(scope) ? scope : ''}
|
||||||
|
onChange={(e) => pick(SCOPE_KEY, e.target.value || 'ALL', setScope)}
|
||||||
|
title={t('gsm.oneMode')}
|
||||||
|
className="h-6 rounded border border-border bg-background px-1 text-[11px]"
|
||||||
|
>
|
||||||
|
<option value="">{t('gsm.oneMode')}</option>
|
||||||
|
{namedModes.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{choices.bands.length > 0 && (
|
||||||
|
<select value={band} onChange={(e) => pick(BAND_KEY, e.target.value, setBand)}
|
||||||
|
title={t('gsm.band')} className="h-6 rounded border border-border bg-background px-1 text-[11px]">
|
||||||
|
<option value="">{t('gsm.allBands')}</option>
|
||||||
|
{choices.bands.map((b) => <option key={b} value={b}>{b}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{/* Only for a station that has worked one. A satellite dropdown on a
|
||||||
|
purely terrestrial log is a control that can only ever be empty. */}
|
||||||
|
{choices.satellites.length > 0 && (
|
||||||
|
<select value={sat} onChange={(e) => pick(SAT_KEY, e.target.value, setSat)}
|
||||||
|
title={t('gsm.satellite')} className="h-6 rounded border border-border bg-background px-1 text-[11px]">
|
||||||
|
<option value="">{t('gsm.allSats')}</option>
|
||||||
|
{choices.satellites.map((n) => <option key={n} value={n}>{n}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||||
{t('gsm.count', { n: stats.total, c: stats.confirmed })}
|
{t('gsm.count', { n: stats.total, c: stats.confirmed })}
|
||||||
</span>
|
</span>
|
||||||
@@ -287,7 +375,7 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
|||||||
className="text-[11px] text-muted-foreground hover:text-foreground px-1">↺</button>
|
className="text-[11px] text-muted-foreground hover:text-foreground px-1">↺</button>
|
||||||
)}
|
)}
|
||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
<button type="button" onClick={() => void load()} disabled={busy} title={t('gsm.refresh')}
|
<button type="button" onClick={() => { void load(); void loadChoices(); }} disabled={busy} title={t('gsm.refresh')}
|
||||||
className="inline-flex items-center justify-center size-6 rounded border border-border hover:bg-muted disabled:opacity-50">
|
className="inline-flex items-center justify-center size-6 rounded border border-border hover:bg-muted disabled:opacity-50">
|
||||||
{busy ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
{busy ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
// how one of them quietly stops matching the other — a pattern button that sets
|
// how one of them quietly stops matching the other — a pattern button that sets
|
||||||
// a different direction, a band list that tunes somewhere else.
|
// a different direction, a band list that tunes somewhere else.
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { ArrowDownToLine, ChevronDown, ChevronUp, Loader2, Minus, Plus, RefreshCw, Antenna as AntennaIcon, X } from 'lucide-react';
|
import { ArrowDownToLine, ChevronDown, ChevronUp, Loader2, Minus, Plus, RefreshCw, Ruler, Antenna as AntennaIcon, X } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
SetUltrabeamDirection, UltrabeamRetract, MotorCalibrate, MotorSetElement, MotorReadElements,
|
||||||
MotorTuneKHz, MotorNudgeKHz, SetMotorFollow,
|
MotorTuneKHz, MotorNudgeKHz, SetMotorFollow,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
@@ -235,11 +235,29 @@ export function MotorAntennaWidget({ ant, refetch, t, onClose, essentialsOnly }:
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button type="button" disabled={!ant.connected}
|
{/* Home, and — on a SteppIR — calibrate beside it. Two buttons on one
|
||||||
onClick={() => run(UltrabeamRetract())}
|
row rather than a second full-width one: the widget is narrow and
|
||||||
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
already tall, and the pair reads as "the two things that move every
|
||||||
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
|
element at once", which is what they are. */}
|
||||||
</button>
|
<div className={cn('grid gap-1.5', isUB ? 'grid-cols-1' : 'grid-cols-2')}>
|
||||||
|
<button type="button" disabled={!ant.connected}
|
||||||
|
onClick={() => run(UltrabeamRetract())}
|
||||||
|
title={t('station.retractTip')}
|
||||||
|
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
||||||
|
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
|
||||||
|
</button>
|
||||||
|
{/* Asked first, always. Calibration runs every element to its end
|
||||||
|
stop and takes minutes — it is the right answer to an antenna that
|
||||||
|
tunes wrong, and the wrong answer to a stray click mid-contest. */}
|
||||||
|
{!isUB && (
|
||||||
|
<button type="button" disabled={!ant.connected}
|
||||||
|
onClick={() => { if (window.confirm(t('station.calibrateConfirm'))) run(MotorCalibrate()); }}
|
||||||
|
title={t('station.calibrateTip')}
|
||||||
|
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-border bg-muted/40 py-1 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
||||||
|
<Ruler className="size-3.5" /> {t('station.calibrate')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{isUB && !essentialsOnly && (
|
{isUB && !essentialsOnly && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -44,16 +44,26 @@ type RotorPreset = { label: string; azimuth: number };
|
|||||||
// A rotor is slow and its readout is coarse, so "is it moving" is inferred
|
// A rotor is slow and its readout is coarse, so "is it moving" is inferred
|
||||||
// rather than reported: a heading that changes by more than a degree means it
|
// rather than reported: a heading that changes by more than a degree means it
|
||||||
// is, and it is considered stopped once the readout has been still for a while.
|
// is, and it is considered stopped once the readout has been still for a while.
|
||||||
const MOVEMENT_SETTLE_MS = 1600;
|
// Long enough to outlast the gap between two readings of a turning rotor.
|
||||||
// Four degrees, not one.
|
|
||||||
//
|
//
|
||||||
// A rotor at rest does not report a constant heading: the potentiometer and the
|
// It was 1600 ms, shorter than the time a rotor takes to move far enough to be
|
||||||
// controller's rounding walk the reading a degree or two either side, and at one
|
// noticed at all: at a degree a second, four-degree steps are four seconds
|
||||||
// degree that jitter WAS movement — Stop lit for a second and a half, went out,
|
// apart, so Stop went dark between every one of them and lit again on the
|
||||||
// and lit again, for an antenna that had not turned all evening. The reference
|
// next — a rotation crossing a pass blinked the whole way round. The window
|
||||||
// is only moved when the threshold is crossed, so a rotor genuinely turning
|
// now only has to outlast ONE degree of progress, which even a slow mast
|
||||||
// accumulates towards it however slowly it goes; noise around a value never
|
// delivers about every two seconds while the poller is running fast.
|
||||||
// gets there.
|
const MOVEMENT_SETTLE_MS = 3000;
|
||||||
|
// Four degrees: the band inside which a reading is not a step at all.
|
||||||
|
//
|
||||||
|
// A rotor at rest does not report a constant heading — the potentiometer and
|
||||||
|
// the controller's rounding walk the reading a degree or two either side. But
|
||||||
|
// the threshold is only half the answer, and on its own it was not enough: WIND
|
||||||
|
// moves a beam further than four degrees and back again, and every one of those
|
||||||
|
// excursions counted, so Stop lit and went out all evening on an antenna that
|
||||||
|
// had not turned. Raising the number only raises the wind speed it takes.
|
||||||
|
//
|
||||||
|
// What actually separates a rotation from the weather is the DIRECTION — see the
|
||||||
|
// movement effect below.
|
||||||
const MOVEMENT_TRIGGER_DEG = 4;
|
const MOVEMENT_TRIGGER_DEG = 4;
|
||||||
// How long an order is given to produce movement before the widget stops
|
// How long an order is given to produce movement before the widget stops
|
||||||
// claiming the antenna is turning — the rotor may already have been there.
|
// claiming the antenna is turning — the rotor may already have been there.
|
||||||
@@ -124,6 +134,35 @@ function unwrapRotation(nextAngle: number, previousRotation: number | null): num
|
|||||||
return previousRotation + delta;
|
return previousRotation + delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// useUnwrappedRotation is unwrapRotation kept across renders, for a beam whose
|
||||||
|
// angle comes from the ANTENNA rather than from the mouse.
|
||||||
|
//
|
||||||
|
// This is what was missing, and on a rotator with an overlap it is unmissable:
|
||||||
|
// a G-2800 sitting at 020° turned anticlockwise reports 020, 010, 000, 359,
|
||||||
|
// 358 … 340, and the CSS transition from 20deg to 359deg travels the long way —
|
||||||
|
// the beam whips a full turn round the dial while the antenna moves forty
|
||||||
|
// degrees the other way. Reported from the air.
|
||||||
|
//
|
||||||
|
// The angle is therefore accumulated rather than reset: 020 → 000 → −001 →
|
||||||
|
// −020, which is the way the mast is actually moving. The ref is advanced only
|
||||||
|
// when the input changes, so a re-render for any other reason cannot make the
|
||||||
|
// beam creep.
|
||||||
|
function useUnwrappedRotation(angle: number | null): number | null {
|
||||||
|
const rotation = useRef<number | null>(null);
|
||||||
|
const lastInput = useRef<number | null>(null);
|
||||||
|
if (angle == null) {
|
||||||
|
rotation.current = null;
|
||||||
|
lastInput.current = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const a = normalizeAzimuth(angle);
|
||||||
|
if (lastInput.current !== a || rotation.current == null) {
|
||||||
|
rotation.current = unwrapRotation(a, rotation.current);
|
||||||
|
lastInput.current = a;
|
||||||
|
}
|
||||||
|
return rotation.current;
|
||||||
|
}
|
||||||
|
|
||||||
// ── The dial ───────────────────────────────────────────────────────────────
|
// ── The dial ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function RotorCompassDial({
|
function RotorCompassDial({
|
||||||
@@ -147,6 +186,12 @@ function RotorCompassDial({
|
|||||||
// is read by moving the eye, and this one is read while aiming.
|
// is read by moving the eye, and this one is read while aiming.
|
||||||
onHoverAzimuth?: (az: number | null) => void;
|
onHoverAzimuth?: (az: number | null) => void;
|
||||||
}) {
|
}) {
|
||||||
|
// The beams animate on an accumulated angle, so crossing north never sends
|
||||||
|
// them the long way round the dial. Both lobes of a bidirectional antenna get
|
||||||
|
// their own, because they cross north at different moments.
|
||||||
|
const antennaRotation = useUnwrappedRotation(azimuth ?? null);
|
||||||
|
const secondaryRotation = useUnwrappedRotation(secondary ?? null);
|
||||||
|
|
||||||
// Gradient and mask ids must be unique per instance: two compasses on one
|
// Gradient and mask ids must be unique per instance: two compasses on one
|
||||||
// screen (docked widget + Station Control) would otherwise share the first
|
// screen (docked widget + Station Control) would otherwise share the first
|
||||||
// one's definitions.
|
// one's definitions.
|
||||||
@@ -428,8 +473,8 @@ function RotorCompassDial({
|
|||||||
|
|
||||||
{/* The second lobe of a bidirectional antenna: the same beam, dimmed —
|
{/* The second lobe of a bidirectional antenna: the same beam, dimmed —
|
||||||
it radiates as much, and it is not where the operator aimed. */}
|
it radiates as much, and it is not where the operator aimed. */}
|
||||||
{secondary != null && renderBeam(normalizeAzimuth(secondary), 'antenna', 0.45, true)}
|
{secondaryRotation != null && renderBeam(secondaryRotation, 'antenna', 0.45, true)}
|
||||||
{azimuth != null && renderBeam(normalizeAzimuth(azimuth), 'antenna', 1, true)}
|
{antennaRotation != null && renderBeam(antennaRotation, 'antenna', 1, true)}
|
||||||
|
|
||||||
<circle cx={CENTER} cy={CENTER} r={CENTER_DOT_RADIUS} fill={COMPASS_ORANGE} pointerEvents="none" />
|
<circle cx={CENTER} cy={CENTER} r={CENTER_DOT_RADIUS} fill={COMPASS_ORANGE} pointerEvents="none" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -482,6 +527,14 @@ export function RotorCompass({
|
|||||||
const latestAzimuthRef = useRef<number | null>(displayAzimuth);
|
const latestAzimuthRef = useRef<number | null>(displayAzimuth);
|
||||||
const movementReferenceRef = useRef<number | null>(displayAzimuth);
|
const movementReferenceRef = useRef<number | null>(displayAzimuth);
|
||||||
const movementSeenRef = useRef(false);
|
const movementSeenRef = useRef(false);
|
||||||
|
// Which way the last accepted step went (+1 CW, −1 CCW, 0 none), and how many
|
||||||
|
// in a row have gone that way. Two make it a rotation; one is weather.
|
||||||
|
const movementDirRef = useRef(0);
|
||||||
|
const movementRunRef = useRef(0);
|
||||||
|
// The previous reading, whatever it was. movementReferenceRef deliberately
|
||||||
|
// holds still through sub-threshold steps so they can accumulate, which
|
||||||
|
// makes it useless for measuring progress poll to poll.
|
||||||
|
const lastRawRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const rememberTarget = (value: number | null) => {
|
const rememberTarget = (value: number | null) => {
|
||||||
if (value == null) rememberedTargets.delete(rotorKey);
|
if (value == null) rememberedTargets.delete(rotorKey);
|
||||||
@@ -512,6 +565,9 @@ export function RotorCompass({
|
|||||||
setTargetFading(false);
|
setTargetFading(false);
|
||||||
setIsMoving(rememberedTarget != null);
|
setIsMoving(rememberedTarget != null);
|
||||||
movementSeenRef.current = false;
|
movementSeenRef.current = false;
|
||||||
|
movementDirRef.current = 0;
|
||||||
|
movementRunRef.current = 0;
|
||||||
|
lastRawRef.current = nextAzimuth;
|
||||||
window.clearTimeout(movementTimerRef.current);
|
window.clearTimeout(movementTimerRef.current);
|
||||||
window.clearTimeout(commandTimerRef.current);
|
window.clearTimeout(commandTimerRef.current);
|
||||||
window.clearTimeout(targetArrivalTimerRef.current);
|
window.clearTimeout(targetArrivalTimerRef.current);
|
||||||
@@ -527,23 +583,74 @@ export function RotorCompass({
|
|||||||
window.clearTimeout(targetFadeTimerRef.current);
|
window.clearTimeout(targetFadeTimerRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Declare the antenna moving and start the clock on it stopping. Called from
|
||||||
|
// both halves of the test below, and re-arming the timer is the whole point:
|
||||||
|
// the antenna counts as stopped only once nothing has said otherwise for
|
||||||
|
// MOVEMENT_SETTLE_MS.
|
||||||
|
function armMovement() {
|
||||||
|
movementSeenRef.current = true;
|
||||||
|
setIsMoving(true);
|
||||||
|
window.clearTimeout(commandTimerRef.current);
|
||||||
|
window.clearTimeout(movementTimerRef.current);
|
||||||
|
movementTimerRef.current = window.setTimeout(() => {
|
||||||
|
setIsMoving(false);
|
||||||
|
movementSeenRef.current = false;
|
||||||
|
// Forget the direction too: the next real move starts its own run rather
|
||||||
|
// than inheriting one from a rotation that finished minutes ago.
|
||||||
|
movementDirRef.current = 0;
|
||||||
|
movementRunRef.current = 0;
|
||||||
|
}, MOVEMENT_SETTLE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
// Movement is inferred from the readout itself, so a rotor turned by its own
|
// Movement is inferred from the readout itself, so a rotor turned by its own
|
||||||
// controller — or by another program — reads as moving here too.
|
// controller — or by another program — reads as moving here too.
|
||||||
|
//
|
||||||
|
// The test is the DIRECTION, not the size of the step.
|
||||||
|
//
|
||||||
|
// A threshold alone does not work, whatever it is set to. Wind pushes a beam
|
||||||
|
// off its bearing and back — 100°, 105°, 100°, 106° — and every one of those
|
||||||
|
// excursions clears a four-degree threshold, so Stop lit and went out all
|
||||||
|
// evening on an antenna that had not turned. Raising the number only raises
|
||||||
|
// the wind speed it takes.
|
||||||
|
//
|
||||||
|
// What separates the two is not amplitude but sign: a rotor under power
|
||||||
|
// advances, gust after gust reverses. So a step is only movement when the
|
||||||
|
// PREVIOUS step went the same way. Wind gives +5, −5, +5 and never two in a
|
||||||
|
// row; a rotor gives +4, +4, +4 and is announced on the second — one poll,
|
||||||
|
// about a second, on a mast that takes half a minute to cross a pass.
|
||||||
|
//
|
||||||
|
// Getting IN is strict; staying in is not, and must not be. The same four
|
||||||
|
// degrees that keep the wind out are four seconds of travel on a real rotor,
|
||||||
|
// so waiting for the next four-degree step before believing it is still
|
||||||
|
// turning left Stop dark for most of the rotation. Once movement is
|
||||||
|
// established, ANY continued progress the way it was going keeps it alive —
|
||||||
|
// one degree the same way is not weather when the mast is already under
|
||||||
|
// power, and the strict test is what guarantees that it is.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rawAzimuth == null) return;
|
if (rawAzimuth == null) return;
|
||||||
|
// Signed and the short way round: crossing north is a small step, not 350°.
|
||||||
|
const short = (from: number, to: number) => ((to - from + 540) % 360) - 180;
|
||||||
|
const previous = lastRawRef.current;
|
||||||
|
lastRawRef.current = rawAzimuth;
|
||||||
|
if (movementSeenRef.current && previous != null && movementDirRef.current !== 0) {
|
||||||
|
const step = short(previous, rawAzimuth);
|
||||||
|
if (step !== 0 && Math.sign(step) === movementDirRef.current) armMovement();
|
||||||
|
}
|
||||||
const reference = movementReferenceRef.current;
|
const reference = movementReferenceRef.current;
|
||||||
if (reference == null) { movementReferenceRef.current = rawAzimuth; return; }
|
if (reference == null) { movementReferenceRef.current = rawAzimuth; return; }
|
||||||
if (angularDistance(rawAzimuth, reference) >= MOVEMENT_TRIGGER_DEG) {
|
const delta = short(reference, rawAzimuth);
|
||||||
movementSeenRef.current = true;
|
if (Math.abs(delta) < MOVEMENT_TRIGGER_DEG) return; // inside the noise band
|
||||||
setIsMoving(true);
|
const sign = delta > 0 ? 1 : -1;
|
||||||
window.clearTimeout(commandTimerRef.current);
|
if (movementDirRef.current === sign) {
|
||||||
window.clearTimeout(movementTimerRef.current);
|
movementRunRef.current += 1;
|
||||||
movementTimerRef.current = window.setTimeout(() => {
|
} else {
|
||||||
setIsMoving(false);
|
movementDirRef.current = sign;
|
||||||
movementSeenRef.current = false;
|
movementRunRef.current = 1;
|
||||||
}, MOVEMENT_SETTLE_MS);
|
|
||||||
movementReferenceRef.current = rawAzimuth;
|
|
||||||
}
|
}
|
||||||
|
movementReferenceRef.current = rawAzimuth;
|
||||||
|
if (movementRunRef.current < 2) return; // one step either way is weather
|
||||||
|
|
||||||
|
armMovement();
|
||||||
}, [rawAzimuth, rotorKey]);
|
}, [rawAzimuth, rotorKey]);
|
||||||
|
|
||||||
// Arrival: confirmed over time, then faded. Every check re-reads the
|
// Arrival: confirmed over time, then faded. Every check re-reads the
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen, Radar, Compass } from 'lucide-react';
|
import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen,
|
||||||
|
PanelLeftClose, PanelLeftOpen, Radar, Compass,
|
||||||
|
ChevronDown, Clock, Crosshair, SlidersHorizontal, ListOrdered } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
|
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
|
||||||
GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack,
|
GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack,
|
||||||
StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking, RetargetSatelliteTracking,
|
StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking, RetargetSatelliteTracking,
|
||||||
|
SetSatUplinkTrim,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -57,7 +60,8 @@ type Tuning = {
|
|||||||
type Track = {
|
type Track = {
|
||||||
on: boolean; name: string; transponder: string; mode: string;
|
on: boolean; name: string; transponder: string; mode: string;
|
||||||
nominal_down: number; nominal_up: number; down_hz: number; up_hz: number;
|
nominal_down: number; nominal_up: number; down_hz: number; up_hz: number;
|
||||||
az: number; el: number; visible: boolean;
|
az: number; el: number; visible: boolean; range_km: number; alt_km: number;
|
||||||
|
up_trim_hz: number;
|
||||||
radio: string; // "sat" | "downlink-only" | ""
|
radio: string; // "sat" | "downlink-only" | ""
|
||||||
error: string;
|
error: string;
|
||||||
rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean; rot_az_only: boolean;
|
rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean; rot_az_only: boolean;
|
||||||
@@ -73,6 +77,40 @@ const SIDE_W_KEY = 'opslog.satSideWidth';
|
|||||||
const SIDE_SHOWN_KEY = 'opslog.satSideShown';
|
const SIDE_SHOWN_KEY = 'opslog.satSideShown';
|
||||||
const SIDE_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720;
|
const SIDE_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720;
|
||||||
const SKY_SHOWN_KEY = 'opslog.satSkyShown';
|
const SKY_SHOWN_KEY = 'opslog.satSkyShown';
|
||||||
|
// The sky plot's column, to the LEFT of the map.
|
||||||
|
//
|
||||||
|
// It used to sit in the readout column, where it competed for width with the
|
||||||
|
// numbers and pushed the pass table off the bottom of the screen. There was
|
||||||
|
// an empty margin on the other side of the map the whole time, and a plot
|
||||||
|
// that wants to be square is exactly what belongs in a column of its own.
|
||||||
|
const SKY_W_KEY = 'opslog.satSkyWidth';
|
||||||
|
const SKY_W_DEFAULT = 260, SKY_W_MIN = 180, SKY_W_MAX = 480;
|
||||||
|
|
||||||
|
// Each block of the readout column, open or shut, remembered separately: an
|
||||||
|
// operator working FM birds never looks at the linear passband and one
|
||||||
|
// chasing a schedule never looks at the range rate.
|
||||||
|
const SEC_KEYS = {
|
||||||
|
pass: 'opslog.satSecPass',
|
||||||
|
where: 'opslog.satSecWhere',
|
||||||
|
tune: 'opslog.satSecTune',
|
||||||
|
passes: 'opslog.satSecPasses',
|
||||||
|
} as const;
|
||||||
|
type SecId = keyof typeof SEC_KEYS;
|
||||||
|
|
||||||
|
// The ground track, drawn canvas-safe.
|
||||||
|
//
|
||||||
|
// It was `var(--info)`, and this map renders with preferCanvas: a CSS
|
||||||
|
// variable handed to a canvas strokeStyle is not a colour, so the browser
|
||||||
|
// kept whatever was set last and the track came out a pale near-white that
|
||||||
|
// vanished over the imagery and the deserts alike. Every other map in this
|
||||||
|
// app passes hex for the same reason.
|
||||||
|
//
|
||||||
|
// Drawn twice: a dark casing underneath, then the bright line on top. That is
|
||||||
|
// how a road is drawn on a map, and for the same reason — one colour cannot
|
||||||
|
// hold up over both a pale sea and a dark continent, but a colour with an
|
||||||
|
// outline can.
|
||||||
|
const TRACK_INK = '#38bdf8';
|
||||||
|
const TRACK_CASING = '#0b1220';
|
||||||
|
|
||||||
// Four decimals — a hundred hertz, which is what a linear transponder is
|
// Four decimals — a hundred hertz, which is what a linear transponder is
|
||||||
// actually tuned to.
|
// actually tuned to.
|
||||||
@@ -306,6 +344,12 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
|
|
||||||
useEffect(() => { loadBirds(); loadTle(); loadPasses(); }, [loadBirds, loadTle, loadPasses]);
|
useEffect(() => { loadBirds(); loadTle(); loadPasses(); }, [loadBirds, loadTle, loadPasses]);
|
||||||
useEffect(() => EventsOn('sat:tle', () => { loadTle(); loadBirds(); loadPasses(); }), [loadTle, loadBirds, loadPasses]);
|
useEffect(() => EventsOn('sat:tle', () => { loadTle(); loadBirds(); loadPasses(); }), [loadTle, loadBirds, loadPasses]);
|
||||||
|
// Saving the satellite settings. The followed list, the lowest pass and the
|
||||||
|
// locator all change what belongs here, and this tab is normally open behind
|
||||||
|
// the settings window while they are edited. The selection repairs itself:
|
||||||
|
// a satellite that is no longer followed drops out of the dropdown, and the
|
||||||
|
// effect below moves to the first one that is.
|
||||||
|
useEffect(() => EventsOn('sat:settings', () => { loadBirds(); loadPasses(); }), [loadBirds, loadPasses]);
|
||||||
useEffect(() => { if (sel) localStorage.setItem('opslog.satSelected', sel); }, [sel]);
|
useEffect(() => { if (sel) localStorage.setItem('opslog.satSelected', sel); }, [sel]);
|
||||||
useEffect(() => { setTpIdx(0); }, [sel]);
|
useEffect(() => { setTpIdx(0); }, [sel]);
|
||||||
|
|
||||||
@@ -463,10 +507,45 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
const [sideShown, setSideShown] = useState(() => localStorage.getItem(SIDE_SHOWN_KEY) !== '0');
|
const [sideShown, setSideShown] = useState(() => localStorage.getItem(SIDE_SHOWN_KEY) !== '0');
|
||||||
useEffect(() => { writeUiPref(SIDE_W_KEY, String(Math.round(sideW))); }, [sideW]);
|
useEffect(() => { writeUiPref(SIDE_W_KEY, String(Math.round(sideW))); }, [sideW]);
|
||||||
useEffect(() => { writeUiPref(SIDE_SHOWN_KEY, sideShown ? '1' : '0'); }, [sideShown]);
|
useEffect(() => { writeUiPref(SIDE_SHOWN_KEY, sideShown ? '1' : '0'); }, [sideShown]);
|
||||||
|
const [skyW, setSkyW] = useState(() => {
|
||||||
|
const n = parseFloat(localStorage.getItem(SKY_W_KEY) || '');
|
||||||
|
return Number.isFinite(n) && n >= SKY_W_MIN && n <= SKY_W_MAX ? n : SKY_W_DEFAULT;
|
||||||
|
});
|
||||||
|
useEffect(() => { writeUiPref(SKY_W_KEY, String(Math.round(skyW))); }, [skyW]);
|
||||||
|
// Open by default, every one of them: a panel that starts shut is a feature
|
||||||
|
// nobody finds. Shutting one is a decision, and it is remembered.
|
||||||
|
const [secOpen, setSecOpen] = useState<Record<SecId, boolean>>(() => ({
|
||||||
|
pass: localStorage.getItem(SEC_KEYS.pass) !== '0',
|
||||||
|
where: localStorage.getItem(SEC_KEYS.where) !== '0',
|
||||||
|
tune: localStorage.getItem(SEC_KEYS.tune) !== '0',
|
||||||
|
passes: localStorage.getItem(SEC_KEYS.passes) !== '0',
|
||||||
|
}));
|
||||||
|
const toggleSec = (id: SecId) => setSecOpen((m) => {
|
||||||
|
const next = { ...m, [id]: !m[id] };
|
||||||
|
writeUiPref(SEC_KEYS[id], next[id] ? '1' : '0');
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
// Dragging the grip. Measured from where the pointer STARTED rather than from
|
// Dragging the grip. Measured from where the pointer STARTED rather than from
|
||||||
// the container, and with the pointer captured — without the capture the map
|
// the container, and with the pointer captured — without the capture the map
|
||||||
// underneath swallows the moves the instant the cursor crosses it.
|
// underneath swallows the moves the instant the cursor crosses it.
|
||||||
|
const startSkyDrag = (e: React.PointerEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
const x0 = e.clientX;
|
||||||
|
const w0 = skyW;
|
||||||
|
const onMove = (ev: PointerEvent) => {
|
||||||
|
// Plus, not minus: this handle is on the right of what it resizes.
|
||||||
|
setSkyW(Math.min(SKY_W_MAX, Math.max(SKY_W_MIN, Math.round(w0 + (ev.clientX - x0)))));
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
window.removeEventListener('pointermove', onMove);
|
||||||
|
window.removeEventListener('pointerup', onUp);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', onMove);
|
||||||
|
window.addEventListener('pointerup', onUp);
|
||||||
|
};
|
||||||
|
|
||||||
const startSideDrag = (e: React.PointerEvent) => {
|
const startSideDrag = (e: React.PointerEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
@@ -556,7 +635,10 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
if (track.length > 1) {
|
if (track.length > 1) {
|
||||||
const pts = splitAtAntimeridian(track.map((p) => [p.lat, p.lon] as [number, number]));
|
const pts = splitAtAntimeridian(track.map((p) => [p.lat, p.lon] as [number, number]));
|
||||||
L.polyline(pts as L.LatLngExpression[][], {
|
L.polyline(pts as L.LatLngExpression[][], {
|
||||||
color: 'var(--info)', weight: 1.2, opacity: 0.7, dashArray: '4 4', smoothFactor: 0,
|
color: TRACK_CASING, weight: 4, opacity: 0.4, smoothFactor: 0, interactive: false,
|
||||||
|
}).addTo(layer);
|
||||||
|
L.polyline(pts as L.LatLngExpression[][], {
|
||||||
|
color: TRACK_INK, weight: 1.8, opacity: 0.95, dashArray: '5 4', smoothFactor: 0, interactive: false,
|
||||||
}).addTo(layer);
|
}).addTo(layer);
|
||||||
}
|
}
|
||||||
const wanted = new Set(shown.map((b) => b.name));
|
const wanted = new Set(shown.map((b) => b.name));
|
||||||
@@ -719,7 +801,28 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
first thing they hide to get the map full width. */}
|
first thing they hide to get the map full width. */}
|
||||||
{tracking?.on && (
|
{tracking?.on && (
|
||||||
<div className="flex items-center gap-2.5 rounded-md border border-border bg-card/60 px-2 py-0.5 text-xs tabular-nums">
|
<div className="flex items-center gap-2.5 rounded-md border border-border bg-card/60 px-2 py-0.5 text-xs tabular-nums">
|
||||||
<span className="flex items-center gap-1" title={t('sat.down')}>
|
{/* Where the bird IS, which is not where the antenna is pointing:
|
||||||
|
these three say whether the pass is worth calling on, and the
|
||||||
|
rotator group further along says whether the mast has caught up
|
||||||
|
with them. Elevation goes dim below the horizon, so a satellite
|
||||||
|
still being tracked on its way up cannot be read as workable. */}
|
||||||
|
<span className="flex items-center gap-1.5" title={`${t('sat.tipAz')} / ${t('sat.tipEl')}`}>
|
||||||
|
<Radar className="size-3 text-muted-foreground" />
|
||||||
|
<span className={cn('font-medium', !tracking.visible && 'text-muted-foreground')}>
|
||||||
|
{Math.round(tracking.az)}° / {tracking.el.toFixed(1)}°
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{tracking.range_km > 0 && (
|
||||||
|
<span className="text-muted-foreground" title={t('sat.range')}>
|
||||||
|
{Math.round(tracking.range_km).toLocaleString()} km
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tracking.alt_km > 0 && (
|
||||||
|
<span className="text-muted-foreground" title={t('sat.altitude')}>
|
||||||
|
↑{Math.round(tracking.alt_km).toLocaleString()} km
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-1 border-l border-border pl-2.5" title={t('sat.down')}>
|
||||||
<ArrowDown className="size-3 text-muted-foreground" />
|
<ArrowDown className="size-3 text-muted-foreground" />
|
||||||
<span className="font-medium">{fmtHz(tracking.down_hz)}</span>
|
<span className="font-medium">{fmtHz(tracking.down_hz)}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -757,13 +860,18 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
>
|
>
|
||||||
{Object.entries(BASEMAPS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
{Object.entries(BASEMAPS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
{/* The left column, with the same kind of control as the right one.
|
||||||
|
|
||||||
|
It was a Radar icon, from when this toggled a plot inside the
|
||||||
|
readout column. It now opens and shuts a column of the window, so
|
||||||
|
it says so the way the other one does — the two are the same
|
||||||
|
gesture and an operator should not have to learn them twice. */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost" size="sm"
|
variant="ghost" size="sm" className="h-7 px-1.5"
|
||||||
className={cn('h-7 px-1.5', skyShown && 'text-success')}
|
|
||||||
onClick={() => setSkyShown((v) => !v)}
|
onClick={() => setSkyShown((v) => !v)}
|
||||||
title={skyShown ? t('sat.hideSky') : t('sat.showSky')}
|
title={skyShown ? t('sat.hideSky') : t('sat.showSky')}
|
||||||
>
|
>
|
||||||
<Radar className="size-3.5" />
|
{skyShown ? <PanelLeftClose className="size-3.5" /> : <PanelLeftOpen className="size-3.5" />}
|
||||||
</Button>
|
</Button>
|
||||||
{/* Put the whole window on the map. On a laptop the readout takes a
|
{/* 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
|
third of the screen, and there are moments — watching a footprint
|
||||||
@@ -780,6 +888,108 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
{err && <div className="px-2 text-[11px] text-danger shrink-0">{err}</div>}
|
{err && <div className="px-2 text-[11px] text-danger shrink-0">{err}</div>}
|
||||||
|
|
||||||
<div className="flex gap-1 flex-1 min-h-0">
|
<div className="flex gap-1 flex-1 min-h-0">
|
||||||
|
{/* WHERE IT IS: the sky plot and the position, in a column of their
|
||||||
|
own to the left of the map.
|
||||||
|
|
||||||
|
The plot is the sky seen from underneath — the centre is
|
||||||
|
straight up, the rim is the horizon, north is at the top — and
|
||||||
|
one glance says whether the pass comes over the roof or along
|
||||||
|
the treeline. The numbers below it are the same answer to the
|
||||||
|
digit: azimuth, elevation, distance, height. They belong
|
||||||
|
together, and having them apart meant reading a bearing off one
|
||||||
|
side of the window and finding it on the other.
|
||||||
|
|
||||||
|
Both were in the readout column, where a plot that wants to be
|
||||||
|
square competed for width with everything else and pushed the
|
||||||
|
pass table off the bottom of the screen — while the margin on
|
||||||
|
this side of the map sat empty the whole time. */}
|
||||||
|
{skyShown && (
|
||||||
|
<div className="shrink-0 flex flex-col gap-1 min-h-0 overflow-y-auto" style={{ width: skyW }}>
|
||||||
|
<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. Its elevation goes in the heading: above
|
||||||
|
or below the horizon is the one thing worth knowing with the
|
||||||
|
block shut. */}
|
||||||
|
<Section
|
||||||
|
title={t('sat.secWhere')}
|
||||||
|
icon={Crosshair}
|
||||||
|
open={secOpen.where}
|
||||||
|
onToggle={() => toggleSec('where')}
|
||||||
|
right={tuning && !secOpen.where ? (
|
||||||
|
<Pill tone={tuning.visible ? 'success' : 'muted'}>
|
||||||
|
{fmtDeg(tuning.el)} {tuning.visible ? t('sat.up') : t('sat.below')}
|
||||||
|
</Pill>
|
||||||
|
) : undefined}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
{/* No elevation when none is being driven: an undriven zero
|
||||||
|
draws an antenna lying on the horizon, which is a bearing
|
||||||
|
and not the absence of one. */}
|
||||||
|
<span className="font-medium">
|
||||||
|
{tracking.rot_az_only
|
||||||
|
? fmtDeg(tracking.rot_az)
|
||||||
|
: `${fmtDeg(tracking.rot_az)} / ${fmtDeg(tracking.rot_el)}`}
|
||||||
|
</span>
|
||||||
|
{tracking.rot_az_only && <span className="text-muted-foreground">{t('sat.rotAzOnly')}</span>}
|
||||||
|
{!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{skyShown && (
|
||||||
|
<div
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
title={t('sat.skyWidthTip')}
|
||||||
|
onPointerDown={startSkyDrag}
|
||||||
|
onDoubleClick={() => setSkyW(SKY_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>
|
||||||
|
)}
|
||||||
{/* The map. isolate is load-bearing, not tidiness: Leaflet stacks its
|
{/* The map. isolate is load-bearing, not tidiness: Leaflet stacks its
|
||||||
own panes and controls up to z-index 1000, which without a stacking
|
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
|
context of their own float over Preferences and every dialog in the
|
||||||
@@ -805,13 +1015,29 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
<div className={cn('shrink-0 flex flex-col gap-1 min-h-0', !sideShown && 'hidden')}
|
<div className={cn('shrink-0 flex flex-col gap-1 min-h-0', !sideShown && 'hidden')}
|
||||||
style={{ width: sideW }}>
|
style={{ width: sideW }}>
|
||||||
{/* The pass. The first thing an operator looks at and the reason they
|
{/* 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. */}
|
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')}>
|
The countdown is repeated in the heading, so shutting this block
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
still leaves the one number it exists for. */}
|
||||||
<span className="font-medium text-sm truncate">{bird?.name ?? '—'}</span>
|
<Section
|
||||||
<ModeBadge mode={tp?.mode} className="self-center" />
|
title={bird?.name ?? '—'}
|
||||||
</div>
|
icon={Clock}
|
||||||
|
open={secOpen.pass}
|
||||||
|
onToggle={() => toggleSec('pass')}
|
||||||
|
right={(
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ModeBadge mode={tp?.mode} />
|
||||||
|
{/* Only while it is SHUT. Open, the countdown is already
|
||||||
|
there in full a line below, and a heading that repeats
|
||||||
|
the body is just noise. */}
|
||||||
|
{!secOpen.pass && !bird?.geostationary && pass?.has_pass && (
|
||||||
|
<Pill tone={inPass ? 'success' : 'muted'}>
|
||||||
|
{inPass ? t('sat.los') : t('sat.aos')} {fmtCountdown((inPass ? losMs : aosMs) - now)}
|
||||||
|
</Pill>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
|
||||||
{bird?.geostationary ? (
|
{bird?.geostationary ? (
|
||||||
<div className="mt-1 text-[11px] text-muted-foreground">{t('sat.geoHint')}</div>
|
<div className="mt-1 text-[11px] text-muted-foreground">{t('sat.geoHint')}</div>
|
||||||
@@ -842,97 +1068,35 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
|
|
||||||
<div className="mt-1.5 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
|
<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.rise')} value={`${hhmm(pass.aos)} ${compass(pass.aos_az)}`} />
|
||||||
|
{/* The one number that decides whether the pass is worth
|
||||||
|
sitting down for, coloured like the pass table colours
|
||||||
|
it: a 70° pass overhead and a 12° scrape are not the
|
||||||
|
same evening. */}
|
||||||
<PassBit label={t('sat.peak')} value={`${Math.round(pass.max_el)}° ${compass(pass.max_el_az)}`}
|
<PassBit label={t('sat.peak')} value={`${Math.round(pass.max_el)}° ${compass(pass.max_el_az)}`}
|
||||||
strong={pass.max_el >= 30} />
|
strong valueClass={elClass(pass.max_el)} />
|
||||||
<PassBit label={t('sat.set')} value={`${hhmm(pass.los)} ${compass(pass.los_az)}`} />
|
<PassBit label={t('sat.set')} value={`${hhmm(pass.los)} ${compass(pass.los_az)}`} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
{/* No elevation when none is being driven: an undriven zero
|
|
||||||
draws an antenna lying on the horizon, which is a bearing
|
|
||||||
and not the absence of one. */}
|
|
||||||
<span className="font-medium">
|
|
||||||
{tracking.rot_az_only
|
|
||||||
? fmtDeg(tracking.rot_az)
|
|
||||||
: `${fmtDeg(tracking.rot_az)} / ${fmtDeg(tracking.rot_el)}`}
|
|
||||||
</span>
|
|
||||||
{tracking.rot_az_only && <span className="text-muted-foreground">{t('sat.rotAzOnly')}</span>}
|
|
||||||
{!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* What to tune. */}
|
{/* What to tune. */}
|
||||||
<div className="rounded-lg border border-border bg-card p-2">
|
<Section
|
||||||
{/* The mode leads, because it decides everything below it. */}
|
title={t('sat.secTune')}
|
||||||
|
icon={SlidersHorizontal}
|
||||||
|
open={secOpen.tune}
|
||||||
|
onToggle={() => toggleSec('tune')}
|
||||||
|
right={<ModeBadge mode={tp?.mode} />}
|
||||||
|
>
|
||||||
|
{/* What KIND of transponder, as badges rather than a row of grey
|
||||||
|
words: inverting decides which sideband to answer on, and a
|
||||||
|
passband width decides whether there is room to move. */}
|
||||||
<div className="flex items-center gap-1.5 mb-1.5">
|
<div className="flex items-center gap-1.5 mb-1.5">
|
||||||
<ModeBadge mode={tp?.mode} />
|
|
||||||
<span className="text-[11px] text-muted-foreground truncate">{tp?.label ?? '—'}</span>
|
<span className="text-[11px] text-muted-foreground truncate">{tp?.label ?? '—'}</span>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{tp?.inverting && (
|
{tp?.inverting && <Pill tone="warning" title={t('sat.invertingHint')}>{t('sat.inverting')}</Pill>}
|
||||||
<span className="shrink-0 text-[10px] uppercase tracking-wide text-warning">{t('sat.inverting')}</span>
|
{tp?.linear && <Pill tone="info">{Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz</Pill>}
|
||||||
)}
|
{bird?.geostationary && <Pill>{t('sat.geo')}</Pill>}
|
||||||
{tp?.linear && (
|
|
||||||
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">
|
|
||||||
{Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{bird?.geostationary && (
|
|
||||||
<span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground">{t('sat.geo')}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -940,6 +1104,30 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
<FreqRow label={t('sat.up')} hz={tuning?.up_hz ?? 0} nominal={tuning?.nominal_up ?? 0} />
|
<FreqRow label={t('sat.up')} hz={tuning?.up_hz ?? 0} nominal={tuning?.nominal_up ?? 0} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* The uplink correction, when there is one.
|
||||||
|
|
||||||
|
It is taken from the transmit VFO without being asked for, so it
|
||||||
|
has to be visible: an offset nobody can see is a trap, and the
|
||||||
|
VFO alone cannot bring it back to zero once the operator has
|
||||||
|
drifted somewhere wrong. Hence the reset. */}
|
||||||
|
{!!tracking?.on && !!tracking.up_trim_hz && (
|
||||||
|
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-center gap-2">
|
||||||
|
<span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t('sat.upTrim')}
|
||||||
|
</span>
|
||||||
|
<Pill tone="caution" title={t('sat.upTrimHint')}>
|
||||||
|
{tracking.up_trim_hz > 0 ? '+' : ''}{(tracking.up_trim_hz / 1000).toFixed(2)} kHz
|
||||||
|
</Pill>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button type="button"
|
||||||
|
className="text-[11px] text-muted-foreground hover:text-foreground"
|
||||||
|
title={t('sat.upTrimReset')}
|
||||||
|
onClick={() => { if (bird) void SetSatUplinkTrim(bird.name, tpIdx, 0).catch(() => {}); }}>
|
||||||
|
↺
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* The tone, on the FM birds, with the same weight as a frequency.
|
{/* The tone, on the FM birds, with the same weight as a frequency.
|
||||||
It IS one, as far as the outcome goes: a repeater called without
|
It IS one, as far as the outcome goes: a repeater called without
|
||||||
its tone does not answer, and the operator hears an empty
|
its tone does not answer, and the operator hears an empty
|
||||||
@@ -963,14 +1151,18 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
{/* What is coming. */}
|
{/* What is coming. */}
|
||||||
<div className="rounded-lg border border-border bg-card flex-1 min-h-0 flex flex-col">
|
<Section
|
||||||
<div className="px-2 py-1 text-[11px] font-medium text-muted-foreground border-b border-border shrink-0">
|
title={t('sat.nextPasses')}
|
||||||
{t('sat.nextPasses')}
|
icon={ListOrdered}
|
||||||
</div>
|
open={secOpen.passes}
|
||||||
<div className="flex-1 min-h-0 overflow-auto">
|
onToggle={() => toggleSec('passes')}
|
||||||
|
grow
|
||||||
|
right={passes.length > 0 ? <Pill>{passes.length}</Pill> : undefined}
|
||||||
|
>
|
||||||
|
<div className="min-h-0">
|
||||||
{passes.length === 0 && idle.length === 0 && (
|
{passes.length === 0 && idle.length === 0 && (
|
||||||
<div className="p-2 text-[11px] text-muted-foreground">{t('sat.noPasses')}</div>
|
<div className="p-2 text-[11px] text-muted-foreground">{t('sat.noPasses')}</div>
|
||||||
)}
|
)}
|
||||||
@@ -1050,7 +1242,7 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1071,11 +1263,75 @@ function Readout({ label, value, colour, sub }: { label: string; value: string;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PassBit({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
|
// Pill is a small semantic badge. The tones are the app's own status tokens,
|
||||||
|
// so a warning here is the same colour as a warning everywhere else — the
|
||||||
|
// point of having them is that an operator learns one vocabulary, not one per
|
||||||
|
// panel.
|
||||||
|
const PILL_TONE: Record<string, string> = {
|
||||||
|
muted: 'text-muted-foreground border-border bg-muted/40',
|
||||||
|
success: 'text-success border-success/45 bg-success/10',
|
||||||
|
warning: 'text-warning border-warning/45 bg-warning/10',
|
||||||
|
caution: 'text-caution border-caution/45 bg-caution/10',
|
||||||
|
danger: 'text-danger border-danger/45 bg-danger/10',
|
||||||
|
info: 'text-info border-info/45 bg-info/10',
|
||||||
|
};
|
||||||
|
function Pill({ tone = 'muted', title, className, children }:
|
||||||
|
{ tone?: keyof typeof PILL_TONE | string; title?: string; className?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span title={title}
|
||||||
|
className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide border tabular-nums',
|
||||||
|
PILL_TONE[tone] ?? PILL_TONE.muted, className)}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section is one collapsible block of the readout column.
|
||||||
|
//
|
||||||
|
// The blocks had no headings at all, which cost twice: nothing said what a
|
||||||
|
// group of numbers was, and there was nowhere to put the control that shuts
|
||||||
|
// it. An operator working FM birds never looks at the linear passband and one
|
||||||
|
// watching a schedule never looks at the range rate, so each one shuts on its
|
||||||
|
// own and stays shut.
|
||||||
|
//
|
||||||
|
// `right` is for a badge that must stay readable with the block CLOSED — the
|
||||||
|
// state of a pass, the mode being tuned. A heading that still answers the
|
||||||
|
// question is why shutting a block is worth doing.
|
||||||
|
function Section({ title, icon: Icon, open, onToggle, right, grow, children }: {
|
||||||
|
title: string;
|
||||||
|
icon: any;
|
||||||
|
open: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
right?: React.ReactNode;
|
||||||
|
grow?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn('rounded-lg border border-border bg-card flex flex-col overflow-hidden',
|
||||||
|
grow && open && 'flex-1 min-h-0')}>
|
||||||
|
<button type="button" onClick={onToggle}
|
||||||
|
className="shrink-0 flex items-center gap-1.5 px-2 py-1 text-left hover:bg-accent/40 transition-colors">
|
||||||
|
<Icon className="size-3 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{title}</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{right}
|
||||||
|
<ChevronDown className={cn('size-3 shrink-0 text-muted-foreground transition-transform', !open && '-rotate-90')} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className={cn('border-t border-border/60', grow ? 'flex-1 min-h-0 overflow-auto' : 'px-2 py-2')}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PassBit({ label, value, strong, valueClass }:
|
||||||
|
{ label: string; value: string; strong?: boolean; valueClass?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground truncate">{label}</div>
|
<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 className={cn('truncate', strong && 'font-semibold', valueClass ?? (strong ? 'text-foreground' : undefined))}>{value}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1083,14 +1339,47 @@ function PassBit({ label, value, strong }: { label: string; value: string; stron
|
|||||||
// The corrected frequency large, the nominal one small beside it. Showing only
|
// 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
|
// one of them leaves an operator unable to tell a Doppler correction from a
|
||||||
// mistuned transponder.
|
// mistuned transponder.
|
||||||
|
// satBand names the band a satellite frequency is in, for the badge.
|
||||||
|
//
|
||||||
|
// Only the bands satellites actually use, and by inspection rather than by
|
||||||
|
// asking the backend: it is a label beside a number that is already on
|
||||||
|
// screen, not a fact anything depends on.
|
||||||
|
function satBand(hz: number): string {
|
||||||
|
const mhz = hz / 1e6;
|
||||||
|
if (mhz >= 28 && mhz < 30) return '10m';
|
||||||
|
if (mhz >= 144 && mhz < 148) return '2m';
|
||||||
|
if (mhz >= 420 && mhz < 450) return '70cm';
|
||||||
|
if (mhz >= 1240 && mhz < 1300) return '23cm';
|
||||||
|
if (mhz >= 2300 && mhz < 2450) return '13cm';
|
||||||
|
if (mhz >= 10450 && mhz < 10500) return '3cm';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// FreqRow is the frequency to TUNE TO — the centre of the passband, not the
|
||||||
|
// Doppler-corrected one.
|
||||||
|
//
|
||||||
|
// It used to lead with the corrected figure, which is the wrong number to
|
||||||
|
// put in a reference panel: it moves every second, it is different for
|
||||||
|
// every operator, and it is not what the frequency plan, the AMSAT tables
|
||||||
|
// or anybody on the air calls the satellite's frequency. What the radio is
|
||||||
|
// actually on belongs beside Tracking, where the radio is, and that is
|
||||||
|
// where it now lives. The correction is still shown here, as the OFFSET
|
||||||
|
// that explains the difference between the two.
|
||||||
function FreqRow({ label, hz, nominal }: { label: string; hz: number; nominal: number }) {
|
function FreqRow({ label, hz, nominal }: { label: string; hz: number; nominal: number }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const shift = hz && nominal ? hz - nominal : 0;
|
const shift = hz && nominal ? hz - nominal : 0;
|
||||||
|
const centre = nominal || hz;
|
||||||
|
const band = satBand(centre);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span>
|
<span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span>
|
||||||
<span className="text-base font-semibold tabular-nums">{fmtHz(hz)}</span>
|
<span className="text-base font-semibold tabular-nums">{fmtHz(centre)}</span>
|
||||||
|
{!!band && <Pill className="self-center">{band}</Pill>}
|
||||||
|
<div className="flex-1" />
|
||||||
{!!shift && (
|
{!!shift && (
|
||||||
<span className="text-[10px] tabular-nums text-muted-foreground">{fmtShift(shift)}</span>
|
<Pill tone={shift > 0 ? 'success' : 'caution'} className="self-center" title={t('sat.shiftHint')}>
|
||||||
|
{fmtShift(shift)}
|
||||||
|
</Pill>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1468,6 +1468,13 @@ function SatelliteElementsBlock({ autoTle, onAutoTle }: { autoTle: boolean; onAu
|
|||||||
// Following none means following every satellite that has both elements and a
|
// Following none means following every satellite that has both elements and a
|
||||||
// frequency plan, which is the sensible thing for somebody who has not chosen
|
// frequency plan, which is the sensible thing for somebody who has not chosen
|
||||||
// yet and the reason the list does not start out empty-handed.
|
// yet and the reason the list does not start out empty-handed.
|
||||||
|
// byCallsign orders satellite names the way an operator reads them: alphabetical,
|
||||||
|
// but with the number taken as a number. Plain string order files AO-123 between
|
||||||
|
// AO-1 and AO-27, which is not where anybody looks for it.
|
||||||
|
function byCallsign(a: { name?: string }, b: { name?: string }) {
|
||||||
|
return String(a?.name ?? '').localeCompare(String(b?.name ?? ''), undefined, { numeric: true, sensitivity: 'base' });
|
||||||
|
}
|
||||||
|
|
||||||
function SatelliteFollowList({ followed, onChange }: { followed: string[]; onChange: (next: string[]) => void }) {
|
function SatelliteFollowList({ followed, onChange }: { followed: string[]; onChange: (next: string[]) => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [all, setAll] = useState<any[]>([]);
|
const [all, setAll] = useState<any[]>([]);
|
||||||
@@ -1485,8 +1492,13 @@ function SatelliteFollowList({ followed, onChange }: { followed: string[]; onCha
|
|||||||
const needle = q.trim().toLowerCase();
|
const needle = q.trim().toLowerCase();
|
||||||
const available = all.filter((b) => !followedSet.has(String(b.name).toUpperCase())
|
const available = all.filter((b) => !followedSet.has(String(b.name).toUpperCase())
|
||||||
&& (!withPlanOnly || (b.transponders?.length ?? 0) > 0)
|
&& (!withPlanOnly || (b.transponders?.length ?? 0) > 0)
|
||||||
&& (needle === '' || String(b.name).toLowerCase().includes(needle)));
|
&& (needle === '' || String(b.name).toLowerCase().includes(needle))).sort(byCallsign);
|
||||||
const chosen = followed.map((n) => byName.get(n) ?? { name: n, has_elements: false, transponders: [] });
|
// Sorted, both columns: the left one came in the order the frequency file
|
||||||
|
// happens to be written and the right one in the order the operator clicked,
|
||||||
|
// so finding AO-91 among sixteen followed birds meant reading all sixteen.
|
||||||
|
const chosen = followed
|
||||||
|
.map((n) => byName.get(n) ?? { name: n, has_elements: false, transponders: [] })
|
||||||
|
.sort(byCallsign);
|
||||||
|
|
||||||
const label = (b: any) => {
|
const label = (b: any) => {
|
||||||
const bits: string[] = [];
|
const bits: string[] = [];
|
||||||
|
|||||||
+28
-10
File diff suppressed because one or more lines are too long
@@ -186,8 +186,38 @@ export function greatCirclePoints(
|
|||||||
// opposite edge, at the SAME latitude, so the line leaves one side of the map
|
// opposite edge, at the SAME latitude, so the line leaves one side of the map
|
||||||
// and re-enters the other at the height it left. Leaflet takes the result as a
|
// and re-enters the other at the height it left. Leaflet takes the result as a
|
||||||
// multi-polyline, so one path is still one layer.
|
// multi-polyline, so one path is still one layer.
|
||||||
export function splitAtAntimeridian(pts: [number, number][]): [number, number][][] {
|
// unwrapLon makes a longitude series continuous.
|
||||||
if (pts.length === 0) return [];
|
//
|
||||||
|
// splitAtAntimeridian works from which COPY of the world each longitude is in,
|
||||||
|
// which requires the series to run past ±180 rather than jumping back. A great
|
||||||
|
// circle built by greatCirclePoints already does; a satellite ground track does
|
||||||
|
// not — SGP4 reports every longitude inside (−180, 180], so a track leaving
|
||||||
|
// Kamchatka at +179.9 and arriving in Alaska at −179.9 looked to the splitter
|
||||||
|
// like one step of 359.8° inside a single world. No split was made and the
|
||||||
|
// polyline drew the chord: a straight dashed line clean across the map, from
|
||||||
|
// one side of the planet to the other, on every crossing.
|
||||||
|
//
|
||||||
|
// A jump of more than 180° between two consecutive points is that wrap and
|
||||||
|
// nothing else: no real path steps half the globe between samples. Idempotent
|
||||||
|
// on a series that was already continuous, so both callers can share it.
|
||||||
|
function unwrapLon(pts: [number, number][]): [number, number][] {
|
||||||
|
if (pts.length < 2) return pts;
|
||||||
|
const out: [number, number][] = [pts[0]];
|
||||||
|
let turns = 0;
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
const [lat, lon] = pts[i];
|
||||||
|
const prevRaw = pts[i - 1][1];
|
||||||
|
const d = lon - prevRaw;
|
||||||
|
if (d > 180) turns -= 1;
|
||||||
|
else if (d < -180) turns += 1;
|
||||||
|
out.push([lat, lon + 360 * turns]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitAtAntimeridian(input: [number, number][]): [number, number][][] {
|
||||||
|
if (input.length === 0) return [];
|
||||||
|
const pts = unwrapLon(input);
|
||||||
// Which copy of the world a longitude belongs to: 0 is the map's own.
|
// Which copy of the world a longitude belongs to: 0 is the map's own.
|
||||||
const world = (lon: number) => Math.floor((lon + 180) / 360);
|
const world = (lon: number) => Math.floor((lon + 180) / 360);
|
||||||
const norm = (lon: number) => lon - 360 * world(lon);
|
const norm = (lon: number) => lon - 360 * world(lon);
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.clusterHideSpots', // cluster console: hide the DX spot flood so replies are readable
|
'opslog.clusterHideSpots', // cluster console: hide the DX spot flood so replies are readable
|
||||||
'opslog.clusterConsoleFollow', // cluster console: keep the view pinned to the newest line
|
'opslog.clusterConsoleFollow', // cluster console: keep the view pinned to the newest line
|
||||||
'opslog.gridMapColorConfirmed', 'opslog.gridMapColorWorked', // grid map: chosen fills (empty = follow the theme)
|
'opslog.gridMapColorConfirmed', 'opslog.gridMapColorWorked', // grid map: chosen fills (empty = follow the theme)
|
||||||
|
'opslog.ftMapColour', // FT map: one colour for every arc (empty = the band palette)
|
||||||
|
'opslog.ftMapHeardColour', // FT map: the who-hears-me diamonds (empty = the default cyan)
|
||||||
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
||||||
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
||||||
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
||||||
|
|||||||
@@ -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.22';
|
export const APP_VERSION = '0.27.24';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+18
-1
@@ -14,6 +14,7 @@ import {bandopen} from '../models';
|
|||||||
import {cluster} from '../models';
|
import {cluster} from '../models';
|
||||||
import {dxped} from '../models';
|
import {dxped} from '../models';
|
||||||
import {extsvc} from '../models';
|
import {extsvc} from '../models';
|
||||||
|
import {pskrme} from '../models';
|
||||||
import {powergenius} from '../models';
|
import {powergenius} from '../models';
|
||||||
import {pskrtgt} from '../models';
|
import {pskrtgt} from '../models';
|
||||||
import {pskr} from '../models';
|
import {pskr} from '../models';
|
||||||
@@ -517,6 +518,10 @@ export function GetGridCacheStatus():Promise<main.GridCacheStatus>;
|
|||||||
|
|
||||||
export function GetGridScopeSettings():Promise<main.GridScopeSettings>;
|
export function GetGridScopeSettings():Promise<main.GridScopeSettings>;
|
||||||
|
|
||||||
|
export function GetHearMe():Promise<boolean>;
|
||||||
|
|
||||||
|
export function GetHearMeStatus():Promise<pskrme.Status>;
|
||||||
|
|
||||||
export function GetIcomState():Promise<cat.IcomTXState>;
|
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||||
|
|
||||||
export function GetKenwoodState():Promise<cat.KenwoodTXState>;
|
export function GetKenwoodState():Promise<cat.KenwoodTXState>;
|
||||||
@@ -595,6 +600,8 @@ export function GetSPEStatus():Promise<spe.Status>;
|
|||||||
|
|
||||||
export function GetSatSettings():Promise<main.SatSettings>;
|
export function GetSatSettings():Promise<main.SatSettings>;
|
||||||
|
|
||||||
|
export function GetSatUplinkTrim(arg1:string,arg2:number):Promise<number>;
|
||||||
|
|
||||||
export function GetSatelliteBirds():Promise<Array<main.SatBird>>;
|
export function GetSatelliteBirds():Promise<Array<main.SatBird>>;
|
||||||
|
|
||||||
export function GetSatelliteGroundTrack(arg1:string,arg2:number):Promise<Array<sat.Position>>;
|
export function GetSatelliteGroundTrack(arg1:string,arg2:number):Promise<Array<sat.Position>>;
|
||||||
@@ -667,6 +674,8 @@ export function GetWebPublishStatus():Promise<main.WebPublishStatus>;
|
|||||||
|
|
||||||
export function GetWhatsNew():Promise<Array<main.ChangelogEntry>>;
|
export function GetWhatsNew():Promise<Array<main.ChangelogEntry>>;
|
||||||
|
|
||||||
|
export function GetWhoHearsMe():Promise<Array<pskrme.Report>>;
|
||||||
|
|
||||||
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
||||||
|
|
||||||
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||||
@@ -685,7 +694,9 @@ export function GetYaesuBandAntennas():Promise<Record<string, number>>;
|
|||||||
|
|
||||||
export function GetYaesuState():Promise<cat.YaesuTXState>;
|
export function GetYaesuState():Promise<cat.YaesuTXState>;
|
||||||
|
|
||||||
export function GridSquares(arg1:string):Promise<Array<qso.GridSquare>>;
|
export function GridSquareChoices():Promise<main.GridSquareChoices>;
|
||||||
|
|
||||||
|
export function GridSquares(arg1:string,arg2:string,arg3:string):Promise<Array<qso.GridSquare>>;
|
||||||
|
|
||||||
export function HaltAutoCall():Promise<void>;
|
export function HaltAutoCall():Promise<void>;
|
||||||
|
|
||||||
@@ -853,6 +864,8 @@ export function LookupCallsign(arg1:string,arg2:string):Promise<lookup.Result>;
|
|||||||
|
|
||||||
export function LookupCallsignFresh(arg1:string,arg2:string):Promise<lookup.Result>;
|
export function LookupCallsignFresh(arg1:string,arg2:string):Promise<lookup.Result>;
|
||||||
|
|
||||||
|
export function MotorCalibrate():Promise<void>;
|
||||||
|
|
||||||
export function MotorNudgeKHz(arg1:number):Promise<void>;
|
export function MotorNudgeKHz(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function MotorReadElements():Promise<Array<number>>;
|
export function MotorReadElements():Promise<Array<number>>;
|
||||||
@@ -1231,6 +1244,8 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
|||||||
|
|
||||||
export function SetFlexRSTChaseEnabled(arg1:boolean):Promise<void>;
|
export function SetFlexRSTChaseEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetHearMe(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetKenwoodAFGain(arg1:number):Promise<void>;
|
export function SetKenwoodAFGain(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetKenwoodAGC(arg1:string):Promise<void>;
|
export function SetKenwoodAGC(arg1:string):Promise<void>;
|
||||||
@@ -1283,6 +1298,8 @@ export function SetPSUOutput(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function SetPassphrase(arg1:string):Promise<void>;
|
export function SetPassphrase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetSatUplinkTrim(arg1:string,arg2:number,arg3:number):Promise<void>;
|
||||||
|
|
||||||
export function SetScpClublogEnabled(arg1:boolean):Promise<void>;
|
export function SetScpClublogEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|||||||
@@ -966,6 +966,14 @@ export function GetGridScopeSettings() {
|
|||||||
return window['go']['main']['App']['GetGridScopeSettings']();
|
return window['go']['main']['App']['GetGridScopeSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetHearMe() {
|
||||||
|
return window['go']['main']['App']['GetHearMe']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetHearMeStatus() {
|
||||||
|
return window['go']['main']['App']['GetHearMeStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetIcomState() {
|
export function GetIcomState() {
|
||||||
return window['go']['main']['App']['GetIcomState']();
|
return window['go']['main']['App']['GetIcomState']();
|
||||||
}
|
}
|
||||||
@@ -1122,6 +1130,10 @@ export function GetSatSettings() {
|
|||||||
return window['go']['main']['App']['GetSatSettings']();
|
return window['go']['main']['App']['GetSatSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetSatUplinkTrim(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['GetSatUplinkTrim'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetSatelliteBirds() {
|
export function GetSatelliteBirds() {
|
||||||
return window['go']['main']['App']['GetSatelliteBirds']();
|
return window['go']['main']['App']['GetSatelliteBirds']();
|
||||||
}
|
}
|
||||||
@@ -1266,6 +1278,10 @@ export function GetWhatsNew() {
|
|||||||
return window['go']['main']['App']['GetWhatsNew']();
|
return window['go']['main']['App']['GetWhatsNew']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetWhoHearsMe() {
|
||||||
|
return window['go']['main']['App']['GetWhoHearsMe']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetWinkeyerSettings() {
|
export function GetWinkeyerSettings() {
|
||||||
return window['go']['main']['App']['GetWinkeyerSettings']();
|
return window['go']['main']['App']['GetWinkeyerSettings']();
|
||||||
}
|
}
|
||||||
@@ -1302,8 +1318,12 @@ export function GetYaesuState() {
|
|||||||
return window['go']['main']['App']['GetYaesuState']();
|
return window['go']['main']['App']['GetYaesuState']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GridSquares(arg1) {
|
export function GridSquareChoices() {
|
||||||
return window['go']['main']['App']['GridSquares'](arg1);
|
return window['go']['main']['App']['GridSquareChoices']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GridSquares(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['GridSquares'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HaltAutoCall() {
|
export function HaltAutoCall() {
|
||||||
@@ -1638,6 +1658,10 @@ export function LookupCallsignFresh(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['LookupCallsignFresh'](arg1, arg2);
|
return window['go']['main']['App']['LookupCallsignFresh'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function MotorCalibrate() {
|
||||||
|
return window['go']['main']['App']['MotorCalibrate']();
|
||||||
|
}
|
||||||
|
|
||||||
export function MotorNudgeKHz(arg1) {
|
export function MotorNudgeKHz(arg1) {
|
||||||
return window['go']['main']['App']['MotorNudgeKHz'](arg1);
|
return window['go']['main']['App']['MotorNudgeKHz'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2394,6 +2418,10 @@ export function SetFlexRSTChaseEnabled(arg1) {
|
|||||||
return window['go']['main']['App']['SetFlexRSTChaseEnabled'](arg1);
|
return window['go']['main']['App']['SetFlexRSTChaseEnabled'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetHearMe(arg1) {
|
||||||
|
return window['go']['main']['App']['SetHearMe'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetKenwoodAFGain(arg1) {
|
export function SetKenwoodAFGain(arg1) {
|
||||||
return window['go']['main']['App']['SetKenwoodAFGain'](arg1);
|
return window['go']['main']['App']['SetKenwoodAFGain'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2498,6 +2526,10 @@ export function SetPassphrase(arg1) {
|
|||||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetSatUplinkTrim(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['SetSatUplinkTrim'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetScpClublogEnabled(arg1) {
|
export function SetScpClublogEnabled(arg1) {
|
||||||
return window['go']['main']['App']['SetScpClublogEnabled'](arg1);
|
return window['go']['main']['App']['SetScpClublogEnabled'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1154,6 +1154,7 @@ export namespace cat {
|
|||||||
split?: boolean;
|
split?: boolean;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
band?: string;
|
band?: string;
|
||||||
|
rx_bands?: string[];
|
||||||
vfo?: string;
|
vfo?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
// Go type: time
|
// Go type: time
|
||||||
@@ -1175,6 +1176,7 @@ export namespace cat {
|
|||||||
this.split = source["split"];
|
this.split = source["split"];
|
||||||
this.mode = source["mode"];
|
this.mode = source["mode"];
|
||||||
this.band = source["band"];
|
this.band = source["band"];
|
||||||
|
this.rx_bands = source["rx_bands"];
|
||||||
this.vfo = source["vfo"];
|
this.vfo = source["vfo"];
|
||||||
this.error = source["error"];
|
this.error = source["error"];
|
||||||
this.updated_at = this.convertValues(source["updated_at"], null);
|
this.updated_at = this.convertValues(source["updated_at"], null);
|
||||||
@@ -3075,6 +3077,22 @@ export namespace main {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class GridSquareChoices {
|
||||||
|
modes: string[];
|
||||||
|
bands: string[];
|
||||||
|
satellites: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new GridSquareChoices(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.modes = source["modes"];
|
||||||
|
this.bands = source["bands"];
|
||||||
|
this.satellites = source["satellites"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class HamlogCfmResult {
|
export class HamlogCfmResult {
|
||||||
total: number;
|
total: number;
|
||||||
confirmed: number;
|
confirmed: number;
|
||||||
@@ -4282,6 +4300,9 @@ export namespace main {
|
|||||||
az: number;
|
az: number;
|
||||||
el: number;
|
el: number;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
range_km: number;
|
||||||
|
alt_km: number;
|
||||||
|
up_trim_hz: number;
|
||||||
radio: string;
|
radio: string;
|
||||||
error: string;
|
error: string;
|
||||||
rot_on: boolean;
|
rot_on: boolean;
|
||||||
@@ -4307,6 +4328,9 @@ export namespace main {
|
|||||||
this.az = source["az"];
|
this.az = source["az"];
|
||||||
this.el = source["el"];
|
this.el = source["el"];
|
||||||
this.visible = source["visible"];
|
this.visible = source["visible"];
|
||||||
|
this.range_km = source["range_km"];
|
||||||
|
this.alt_km = source["alt_km"];
|
||||||
|
this.up_trim_hz = source["up_trim_hz"];
|
||||||
this.radio = source["radio"];
|
this.radio = source["radio"];
|
||||||
this.error = source["error"];
|
this.error = source["error"];
|
||||||
this.rot_on = source["rot_on"];
|
this.rot_on = source["rot_on"];
|
||||||
@@ -5431,6 +5455,74 @@ export namespace pskr {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace pskrme {
|
||||||
|
|
||||||
|
export class Report {
|
||||||
|
call: string;
|
||||||
|
grid: string;
|
||||||
|
band: string;
|
||||||
|
mode: string;
|
||||||
|
snr: number;
|
||||||
|
freq_hz: number;
|
||||||
|
// Go type: time
|
||||||
|
at: any;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Report(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.call = source["call"];
|
||||||
|
this.grid = source["grid"];
|
||||||
|
this.band = source["band"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.snr = source["snr"];
|
||||||
|
this.freq_hz = source["freq_hz"];
|
||||||
|
this.at = this.convertValues(source["at"], null);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Status {
|
||||||
|
enabled: boolean;
|
||||||
|
online: boolean;
|
||||||
|
reports: number;
|
||||||
|
watching: string;
|
||||||
|
error: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Status(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.online = source["online"];
|
||||||
|
this.reports = source["reports"];
|
||||||
|
this.watching = source["watching"];
|
||||||
|
this.error = source["error"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace pskrtgt {
|
export namespace pskrtgt {
|
||||||
|
|
||||||
export class Bin {
|
export class Bin {
|
||||||
|
|||||||
+72
-14
@@ -50,18 +50,28 @@ type interruptible interface {
|
|||||||
// and RxFreqHz is the active VFO (where they listen). When not split,
|
// and RxFreqHz is the active VFO (where they listen). When not split,
|
||||||
// RxFreqHz is 0 — the UI shouldn't show a redundant RX field.
|
// RxFreqHz is 0 — the UI shouldn't show a redundant RX field.
|
||||||
type RigState struct {
|
type RigState struct {
|
||||||
Enabled bool `json:"enabled"` // user toggled CAT on
|
Enabled bool `json:"enabled"` // user toggled CAT on
|
||||||
Connected bool `json:"connected"` // backend says rig is online
|
Connected bool `json:"connected"` // backend says rig is online
|
||||||
Backend string `json:"backend,omitempty"` // active backend name
|
Backend string `json:"backend,omitempty"` // active backend name
|
||||||
RigNum int `json:"rig_num,omitempty"` // OmniRig slot 1 or 2 (when applicable)
|
RigNum int `json:"rig_num,omitempty"` // OmniRig slot 1 or 2 (when applicable)
|
||||||
Rig string `json:"rig,omitempty"` // rig model (best-effort)
|
Rig string `json:"rig,omitempty"` // rig model (best-effort)
|
||||||
FreqHz int64 `json:"freq_hz,omitempty"` // TX freq (= active VFO when not split)
|
FreqHz int64 `json:"freq_hz,omitempty"` // TX freq (= active VFO when not split)
|
||||||
RxFreqHz int64 `json:"freq_rx_hz,omitempty"` // RX freq, only set when Split
|
RxFreqHz int64 `json:"freq_rx_hz,omitempty"` // RX freq, only set when Split
|
||||||
Split bool `json:"split,omitempty"` // rig is in split mode
|
Split bool `json:"split,omitempty"` // rig is in split mode
|
||||||
Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY)
|
Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY)
|
||||||
Band string `json:"band,omitempty"` // computed from FreqHz
|
Band string `json:"band,omitempty"` // computed from FreqHz
|
||||||
Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB"
|
// RxBands is every band the radio currently has a receiver on, Band
|
||||||
Error string `json:"error,omitempty"` // last connect/poll error if any
|
// included. One entry on a rig with one VFO; one per slice on a Flex.
|
||||||
|
//
|
||||||
|
// It exists because Band alone is the TRANSMIT band, and a station running
|
||||||
|
// two slices on two bands with a decoder on each is not drifting when one
|
||||||
|
// of them announces the band it is legitimately on. Reported for W4TE:
|
||||||
|
// slice A on 20 m with its own WSJT-X, slice B on 40 m holding transmit
|
||||||
|
// focus, and OpsLog telling him his 20 m decoder disagreed with a radio
|
||||||
|
// that was on 20 m — on that slice.
|
||||||
|
RxBands []string `json:"rx_bands,omitempty"`
|
||||||
|
Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB"
|
||||||
|
Error string `json:"error,omitempty"` // last connect/poll error if any
|
||||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,9 +594,9 @@ type FlexController interface {
|
|||||||
SetMute(bool) error
|
SetMute(bool) error
|
||||||
SetRXAntenna(string) error
|
SetRXAntenna(string) error
|
||||||
SetTXAntenna(string) error
|
SetTXAntenna(string) error
|
||||||
// SatAntennas sets the antenna on each SATELLITE slice — they are on two
|
// SatAntennas sets BOTH antennas on each SATELLITE slice — they are on two
|
||||||
// different bands and, with transverters, two different ports.
|
// different bands and, with transverters, two different ports.
|
||||||
SatAntennas(rxAnt, txAnt string) error
|
SatAntennas(downRX, downTX, upRX, upTX string) error
|
||||||
// SatTone sets the CTCSS tone the satellite uplink transmits (0 = off).
|
// SatTone sets the CTCSS tone the satellite uplink transmits (0 = off).
|
||||||
SatTone(hz float64) error
|
SatTone(hz float64) error
|
||||||
SetActiveSlice(int) error // focus slice idx so commands target it
|
SetActiveSlice(int) error // focus slice idx so commands target it
|
||||||
@@ -859,6 +869,18 @@ type SatTuner interface {
|
|||||||
// the input the whole tracker works from — without reading it back, a
|
// the input the whole tracker works from — without reading it back, a
|
||||||
// tracker fights the operator instead of helping them.
|
// tracker fights the operator instead of helping them.
|
||||||
SatReceiveHz() (int64, error)
|
SatReceiveHz() (int64, error)
|
||||||
|
// SatTransmitHz is where the TRANSMITTER actually is, for the same reason.
|
||||||
|
//
|
||||||
|
// A transponder does not translate by exactly the published difference —
|
||||||
|
// the oscillator on board is decades old on some birds — so an operator
|
||||||
|
// who sounds right to themselves comes back off frequency, and corrects it
|
||||||
|
// by ear on the transmit VFO. Without reading that back the tracker undoes
|
||||||
|
// the correction on its next tick, once a second, for the whole pass.
|
||||||
|
//
|
||||||
|
// Zero with no error means "this radio cannot say": a single-receiver rig
|
||||||
|
// working split has nothing to report, and the tracker then leaves the
|
||||||
|
// uplink entirely to the arithmetic, as before.
|
||||||
|
SatTransmitHz() (int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SatCapable reports whether the active backend can hold a satellite pair.
|
// SatCapable reports whether the active backend can hold a satellite pair.
|
||||||
@@ -1002,11 +1024,47 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
|
|||||||
if ns.FreqHz != 0 && ns.Band == "" {
|
if ns.FreqHz != 0 && ns.Band == "" {
|
||||||
ns.Band = BandFromHz(ns.FreqHz)
|
ns.Band = BandFromHz(ns.FreqHz)
|
||||||
}
|
}
|
||||||
|
ns.RxBands = m.rxBands(ns)
|
||||||
m.update(ns)
|
m.update(ns)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rxBands lists the bands the radio has a receiver on.
|
||||||
|
//
|
||||||
|
// Asked of the backend rather than derived from the state, because only the
|
||||||
|
// backend knows: a Flex reports its slices, and everything else has exactly
|
||||||
|
// one receiver whose band is already in the state. The transmit band is
|
||||||
|
// always included, even on a Flex, so this can never come back empty while
|
||||||
|
// the rig is on a frequency.
|
||||||
|
func (m *Manager) rxBands(st RigState) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
add := func(b string) {
|
||||||
|
if b = strings.ToLower(strings.TrimSpace(b)); b != "" && !seen[b] {
|
||||||
|
seen[b] = true
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(st.Band)
|
||||||
|
if st.RxFreqHz != 0 {
|
||||||
|
add(BandFromHz(st.RxFreqHz))
|
||||||
|
}
|
||||||
|
if fx, ok := m.FlexState(); ok {
|
||||||
|
for _, sl := range fx.Slices {
|
||||||
|
if sl.Band != "" {
|
||||||
|
add(sl.Band)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A slice the radio has reported a frequency but no band for.
|
||||||
|
if sl.FreqHz != 0 {
|
||||||
|
add(BandFromHz(sl.FreqHz))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) applyCommandDelay() {
|
func (m *Manager) applyCommandDelay() {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
d := m.cmdDelay
|
d := m.cmdDelay
|
||||||
|
|||||||
@@ -79,9 +79,13 @@ type Flex struct {
|
|||||||
// turns up LATE. Arming, the antennas, the tone and the mode all happen
|
// turns up LATE. Arming, the antennas, the tone and the mode all happen
|
||||||
// before the radio has necessarily reported the slice it was asked to
|
// before the radio has necessarily reported the slice it was asked to
|
||||||
// create; without this they were applied to an index of -1 and never again.
|
// create; without this they were applied to an index of -1 and never again.
|
||||||
satUpMode string
|
satUpMode string
|
||||||
satRXAnt string
|
// Both antennas of both slices: a slice has an rxant and a txant, and each
|
||||||
satTXAnt string
|
// pair belongs to that slice's own band.
|
||||||
|
satDownRX string
|
||||||
|
satDownTX string
|
||||||
|
satUpRX string
|
||||||
|
satUpTX string
|
||||||
satTone float64
|
satTone float64
|
||||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||||
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
|
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
|
||||||
|
|||||||
+47
-21
@@ -155,11 +155,13 @@ func (f *Flex) adoptSatSlice(role string, idx int) {
|
|||||||
// antenna and the tone are all sent ONCE — the step only re-sends
|
// antenna and the tone are all sent ONCE — the step only re-sends
|
||||||
// frequencies.
|
// frequencies.
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
mode, ant, tone := f.satUpMode, f.satTXAnt, f.satTone
|
mode, rxAnt, txAnt, tone := f.satUpMode, f.satUpRX, f.satUpTX, f.satTone
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
if strings.TrimSpace(ant) != "" {
|
if strings.TrimSpace(rxAnt) != "" {
|
||||||
f.send(fmt.Sprintf("slice s %d txant=%s", idx, ant))
|
f.send(fmt.Sprintf("slice s %d rxant=%s", idx, rxAnt))
|
||||||
f.send(fmt.Sprintf("slice s %d rxant=%s", idx, ant))
|
}
|
||||||
|
if strings.TrimSpace(txAnt) != "" {
|
||||||
|
f.send(fmt.Sprintf("slice s %d txant=%s", idx, txAnt))
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(mode) != "" {
|
if strings.TrimSpace(mode) != "" {
|
||||||
f.satMode(idx, mode, 0)
|
f.satMode(idx, mode, 0)
|
||||||
@@ -259,7 +261,22 @@ func (f *Flex) SatReceiveHz() (int64, error) {
|
|||||||
return s.freqHz, nil
|
return s.freqHz, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SatAntennas selects the antenna each satellite slice uses.
|
// SatTransmitHz is where the uplink slice sits. From the cache, like the
|
||||||
|
// downlink: SmartSDR pushes every slice change as it happens.
|
||||||
|
func (f *Flex) SatTransmitHz() (int64, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.satTX < 0 {
|
||||||
|
return 0, nil // no uplink slice: nothing to report, not an error
|
||||||
|
}
|
||||||
|
s := f.slices[f.satTX]
|
||||||
|
if s == nil || !s.inUse {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return s.freqHz, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatAntennas selects both antennas of each satellite slice.
|
||||||
//
|
//
|
||||||
// The two slices are on two different bands — a V/U bird receives on 70 cm and
|
// The two slices are on two different bands — a V/U bird receives on 70 cm and
|
||||||
// transmits on 2 m, a U/V one does the reverse — so they cannot share one
|
// transmits on 2 m, a U/V one does the reverse — so they cannot share one
|
||||||
@@ -274,30 +291,39 @@ func (f *Flex) SatReceiveHz() (int64, error) {
|
|||||||
// Empty strings are left alone: an operator who has configured 2 m and not
|
// Empty strings are left alone: an operator who has configured 2 m and not
|
||||||
// 70 cm should keep whatever the radio already had on the other side rather
|
// 70 cm should keep whatever the radio already had on the other side rather
|
||||||
// than have it cleared.
|
// than have it cleared.
|
||||||
func (f *Flex) SatAntennas(rxAnt, txAnt string) error {
|
// A slice has an rxant AND a txant, and both belong to the slice's own band.
|
||||||
|
// Only two of the four were being set — the downlink's receive antenna and
|
||||||
|
// the uplink's transmit one — which left the downlink slice with an empty
|
||||||
|
// txant. It never keys, so nothing was wrong on the air, but the slice was
|
||||||
|
// half-configured: move transmit focus to it and the radio uses whatever
|
||||||
|
// antenna it was last left on.
|
||||||
|
func (f *Flex) SatAntennas(downRX, downTX, upRX, upTX string) error {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
rx, tx := f.satRX, f.satTX
|
rx, tx := f.satRX, f.satTX
|
||||||
connected := f.conn != nil
|
connected := f.conn != nil
|
||||||
// Remembered so a slice that is reported late still gets its antenna.
|
// Remembered so a slice that is reported late still gets its antennas.
|
||||||
f.satRXAnt, f.satTXAnt = rxAnt, txAnt
|
f.satDownRX, f.satDownTX = downRX, downTX
|
||||||
|
f.satUpRX, f.satUpTX = upRX, upTX
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
if !connected {
|
if !connected {
|
||||||
return fmt.Errorf("flex: not connected")
|
return fmt.Errorf("flex: not connected")
|
||||||
}
|
}
|
||||||
// The downlink slice is the one being listened to, so it takes the receive
|
set := func(idx int, which, rxAnt, txAnt string) {
|
||||||
// antenna; the uplink slice is the one keyed, so it takes the transmit one.
|
if idx < 0 {
|
||||||
if rx >= 0 && strings.TrimSpace(rxAnt) != "" {
|
return
|
||||||
f.send(fmt.Sprintf("slice s %d rxant=%s", rx, rxAnt))
|
}
|
||||||
applog.Printf("flex: satellite downlink slice %d on antenna %s", rx, rxAnt)
|
if strings.TrimSpace(rxAnt) != "" {
|
||||||
}
|
f.send(fmt.Sprintf("slice s %d rxant=%s", idx, rxAnt))
|
||||||
if tx >= 0 && strings.TrimSpace(txAnt) != "" {
|
}
|
||||||
f.send(fmt.Sprintf("slice s %d txant=%s", tx, txAnt))
|
if strings.TrimSpace(txAnt) != "" {
|
||||||
// A transmit slice also has to HEAR its own band on some radios, and a
|
f.send(fmt.Sprintf("slice s %d txant=%s", idx, txAnt))
|
||||||
// transverter port is the only thing connected to it. Setting the
|
}
|
||||||
// receive antenna to match costs nothing when it is already right.
|
if strings.TrimSpace(rxAnt) != "" || strings.TrimSpace(txAnt) != "" {
|
||||||
f.send(fmt.Sprintf("slice s %d rxant=%s", tx, txAnt))
|
applog.Printf("flex: satellite %s slice %d rx=%s tx=%s", which, idx, rxAnt, txAnt)
|
||||||
applog.Printf("flex: satellite uplink slice %d on antenna %s", tx, txAnt)
|
}
|
||||||
}
|
}
|
||||||
|
set(rx, "downlink", downRX, downTX)
|
||||||
|
set(tx, "uplink", upRX, upTX)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,33 @@ func (b *IcomSerial) SatReceiveHz() (int64, error) {
|
|||||||
return b.readFreq()
|
return b.readFreq()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SatTransmitHz is where the transmitter is now.
|
||||||
|
//
|
||||||
|
// On a rig with native satellite mode the uplink is the SUB band, so this is
|
||||||
|
// the same dance TuneSatellite does to write it: select SUB, read, and go
|
||||||
|
// back to MAIN whatever happens. Leaving the rig on SUB would have every
|
||||||
|
// band-dependent thing in OpsLog — the log, the antenna, the amplifier —
|
||||||
|
// follow the transmitter onto the wrong band.
|
||||||
|
//
|
||||||
|
// Not attempted while transmitting: the operator is not turning the knob
|
||||||
|
// mid-over, and switching bands under a carrier is not something to do to
|
||||||
|
// somebody else's radio.
|
||||||
|
func (b *IcomSerial) SatTransmitHz() (int64, error) {
|
||||||
|
if !b.satNative {
|
||||||
|
// Split on one band. The rig reports one frequency and it is the
|
||||||
|
// receiver's; there is nothing to read.
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if err := b.exec(civ.CmdVFO, civ.SubVFOSub); err != nil {
|
||||||
|
return 0, fmt.Errorf("icom: could not select the sub band: %w", err)
|
||||||
|
}
|
||||||
|
hz, err := b.readFreq()
|
||||||
|
if merr := b.exec(civ.CmdVFO, civ.SubVFOMain); merr != nil {
|
||||||
|
applog.Printf("icom: could not return to the main band: %v", merr)
|
||||||
|
}
|
||||||
|
return hz, err
|
||||||
|
}
|
||||||
|
|
||||||
// tuneSatSingleBand is every other Icom: one receiver, one band.
|
// tuneSatSingleBand is every other Icom: one receiver, one band.
|
||||||
//
|
//
|
||||||
// The downlink is set, because that is what the operator is listening to. The
|
// The downlink is set, because that is what the operator is listening to. The
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RxBands is what stops the decode panel's band-drift warning from lying to a
|
||||||
|
// station running two slices.
|
||||||
|
//
|
||||||
|
// Reported for W4TE: slice A on 20 m with its own WSJT-X, slice B on 40 m
|
||||||
|
// holding transmit focus. RigState.Band is the TRANSMIT band, so the 20 m
|
||||||
|
// decoder was told "the rig is on 40M" while the slice it listens to had been
|
||||||
|
// on 20 m the whole time.
|
||||||
|
func TestRxBandsAlwaysCarriesTheTransmitBand(t *testing.T) {
|
||||||
|
m := &Manager{}
|
||||||
|
got := m.rxBands(RigState{FreqHz: 14074000, Band: "20m"})
|
||||||
|
if len(got) != 1 || got[0] != "20m" {
|
||||||
|
t.Fatalf("got %v, want [20m]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A split rig receives on one band and transmits on another — cross-band split
|
||||||
|
// is unusual but legal, and the receive side is where a decoder listens.
|
||||||
|
func TestRxBandsIncludesTheSplitReceiveBand(t *testing.T) {
|
||||||
|
m := &Manager{}
|
||||||
|
got := m.rxBands(RigState{FreqHz: 14074000, Band: "20m", RxFreqHz: 7074000, Split: true})
|
||||||
|
if !has(got, "20m") || !has(got, "40m") {
|
||||||
|
t.Fatalf("got %v, want both 20m and 40m", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing to report when the rig is on no frequency: an empty list means "there
|
||||||
|
// is nothing to compare with", and the panel treats it as such rather than as
|
||||||
|
// "the radio is on no band", which would warn about every decode.
|
||||||
|
func TestRxBandsIsEmptyWithNoFrequency(t *testing.T) {
|
||||||
|
m := &Manager{}
|
||||||
|
if got := m.rxBands(RigState{}); len(got) != 0 {
|
||||||
|
t.Fatalf("got %v, want nothing", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicated and lower-cased, because the panel compares strings: two slices
|
||||||
|
// on the same band are one band, and "20M" from a backend must match "20m" from
|
||||||
|
// BandFromHz.
|
||||||
|
func TestRxBandsIsNormalised(t *testing.T) {
|
||||||
|
m := &Manager{}
|
||||||
|
got := m.rxBands(RigState{FreqHz: 14074000, Band: "20M", RxFreqHz: 14080000})
|
||||||
|
if len(got) != 1 || got[0] != "20m" {
|
||||||
|
t.Fatalf("got %v, want [20m]", got)
|
||||||
|
}
|
||||||
|
for _, b := range got {
|
||||||
|
if b != strings.ToLower(b) {
|
||||||
|
t.Errorf("%q is not lower-cased", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func has(list []string, want string) bool {
|
||||||
|
for _, s := range list {
|
||||||
|
if s == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
// Package pskrme answers the other half of the decodes map: who is hearing ME.
|
||||||
|
//
|
||||||
|
// The FT map draws what this station decodes, which is one direction of every
|
||||||
|
// path on it. The reverse — which stations are reporting our own transmissions
|
||||||
|
// — is the half an operator cannot see from their own receiver at all, and on
|
||||||
|
// FT8 it is the half that decides whether calling is worth the cycle.
|
||||||
|
//
|
||||||
|
// It is the narrowest possible slice of the PSK Reporter feed. The v2 topic is
|
||||||
|
//
|
||||||
|
// pskr/filter/v2/<band>/<mode>/<tx call>/<rx call>/<tx grid>/<rx grid>/…
|
||||||
|
//
|
||||||
|
// so putting the operator's callsign in the TX level makes the broker send
|
||||||
|
// nothing else. That is the whole reason this is cheap: internal/pskr measured
|
||||||
|
// 83 messages a second for four bands unfiltered, and 0.2 to 1.2 a second once
|
||||||
|
// filtered on the receiver's square — one callsign in the transmit level is a
|
||||||
|
// handful of messages per FT8 cycle, however open the band is.
|
||||||
|
//
|
||||||
|
// Its own connection, like internal/pskrtgt has its own: the three want
|
||||||
|
// different slices of the feed, and none of them can be filtered out of
|
||||||
|
// another's. It also means this works with the band-opening watch switched off,
|
||||||
|
// which matters — tying it to that feed's lifecycle would have made it fail
|
||||||
|
// silently for anyone not chasing openings.
|
||||||
|
//
|
||||||
|
// Nothing is persisted. A report older than the window is dropped on the next
|
||||||
|
// read, and an empty window means nobody has reported us recently, which is the
|
||||||
|
// honest answer rather than a stale map.
|
||||||
|
package pskrme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultBroker is PSK Reporter's public MQTT endpoint, TLS.
|
||||||
|
const DefaultBroker = "tls://mqtt.pskreporter.info:1884"
|
||||||
|
|
||||||
|
// Window is how long a report keeps counting.
|
||||||
|
//
|
||||||
|
// Fifteen minutes. PSK Reporter's uploaders batch, many of them every five, so
|
||||||
|
// a tighter window shows a fraction of the stations that actually heard the
|
||||||
|
// last few calls — internal/pskrtgt widened its own to ten for exactly that
|
||||||
|
// reason. This one is looser still because it feeds a MAP: a receiver that
|
||||||
|
// heard us twelve minutes ago is a path worth seeing on it, where the same
|
||||||
|
// report as a live "can he hear me" verdict would be stale.
|
||||||
|
const Window = 15 * time.Minute
|
||||||
|
|
||||||
|
// Report is one station's reception of us, reduced to what a map needs.
|
||||||
|
type Report struct {
|
||||||
|
Call string `json:"call"` // who reported us
|
||||||
|
Grid string `json:"grid"` // their square, from the message itself
|
||||||
|
Band string `json:"band"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
SNR int `json:"snr"` // how they heard us, their report
|
||||||
|
FreqHz int64 `json:"freq_hz"` // where we were when they did
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is what the panel needs to tell a working feed from a silent one.
|
||||||
|
type Status struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Online bool `json:"online"`
|
||||||
|
Reports uint64 `json:"reports"` // accepted since start
|
||||||
|
Watching string `json:"watching"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is what the watcher needs to run.
|
||||||
|
type Config struct {
|
||||||
|
Broker string
|
||||||
|
// MyCall is the callsign to watch for in the TRANSMIT level. Without one
|
||||||
|
// there is no subscription to make: a wildcard there would be the whole
|
||||||
|
// feed, which is the one thing this package exists not to do.
|
||||||
|
MyCall string
|
||||||
|
Logf func(string, ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watcher owns the connection, its one subscription, and the sliding window.
|
||||||
|
type Watcher struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
cfg Config
|
||||||
|
client mqtt.Client
|
||||||
|
running bool
|
||||||
|
topic string
|
||||||
|
|
||||||
|
reports []Report
|
||||||
|
received uint64
|
||||||
|
lastErr string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) *Watcher {
|
||||||
|
if cfg.Broker == "" {
|
||||||
|
cfg.Broker = DefaultBroker
|
||||||
|
}
|
||||||
|
if cfg.Logf == nil {
|
||||||
|
cfg.Logf = func(string, ...any) {}
|
||||||
|
}
|
||||||
|
cfg.MyCall = strings.ToUpper(strings.TrimSpace(cfg.MyCall))
|
||||||
|
return &Watcher{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// message is the payload, the same shape internal/pskr documents.
|
||||||
|
type message struct {
|
||||||
|
Freq int64 `json:"f"`
|
||||||
|
Mode string `json:"md"`
|
||||||
|
SNR int `json:"rp"`
|
||||||
|
TxCall string `json:"sc"`
|
||||||
|
TxGrid string `json:"sl"`
|
||||||
|
RxCall string `json:"rc"`
|
||||||
|
RxGrid string `json:"rl"`
|
||||||
|
Band string `json:"b"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start brings the subscription up. Safe to call on a running watcher.
|
||||||
|
func (w *Watcher) Start() error {
|
||||||
|
w.mu.Lock()
|
||||||
|
if w.running {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
call := w.cfg.MyCall
|
||||||
|
w.mu.Unlock()
|
||||||
|
if call == "" {
|
||||||
|
return fmt.Errorf("pskrme: no station callsign")
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := mqtt.NewClientOptions().
|
||||||
|
AddBroker(w.cfg.Broker).
|
||||||
|
SetClientID(fmt.Sprintf("opslog-hearme-%d", time.Now().UnixNano())).
|
||||||
|
SetCleanSession(true).
|
||||||
|
SetAutoReconnect(true).
|
||||||
|
SetConnectRetry(true).
|
||||||
|
SetConnectRetryInterval(30 * time.Second).
|
||||||
|
SetConnectTimeout(15 * time.Second).
|
||||||
|
SetOrderMatters(false)
|
||||||
|
// Subscribed on every connect, reconnects included: the session is clean, so
|
||||||
|
// the broker remembers nothing and a silent reconnect would leave a feed
|
||||||
|
// that looks up and delivers nothing for the rest of the evening.
|
||||||
|
opts.OnConnect = func(c mqtt.Client) {
|
||||||
|
// Both wildcards deliberate: every band and every mode. The filter that
|
||||||
|
// matters is the callsign, and an operator wants to know who hears them
|
||||||
|
// wherever they happen to be.
|
||||||
|
topic := "pskr/filter/v2/+/+/" + call + "/#"
|
||||||
|
if tok := c.Subscribe(topic, 0, w.handle); tok.Wait() && tok.Error() != nil {
|
||||||
|
w.setErr(tok.Error().Error())
|
||||||
|
w.cfg.Logf("pskrme: subscribing to %s failed: %v", topic, tok.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.mu.Lock()
|
||||||
|
w.topic = topic
|
||||||
|
w.lastErr = ""
|
||||||
|
w.mu.Unlock()
|
||||||
|
w.cfg.Logf("pskrme: watching who reports %s", call)
|
||||||
|
}
|
||||||
|
opts.OnConnectionLost = func(_ mqtt.Client, err error) {
|
||||||
|
w.setErr(err.Error())
|
||||||
|
w.cfg.Logf("pskrme: connection lost: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := mqtt.NewClient(opts)
|
||||||
|
if tok := client.Connect(); tok.Wait() && tok.Error() != nil {
|
||||||
|
return fmt.Errorf("pskrme: connect: %w", tok.Error())
|
||||||
|
}
|
||||||
|
w.mu.Lock()
|
||||||
|
w.client, w.running = client, true
|
||||||
|
w.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop drops the connection and everything collected. A feed turned off must
|
||||||
|
// not leave a map showing who heard us before it was.
|
||||||
|
func (w *Watcher) Stop() {
|
||||||
|
w.mu.Lock()
|
||||||
|
client, running := w.client, w.running
|
||||||
|
w.client, w.running = nil, false
|
||||||
|
w.reports = nil
|
||||||
|
w.topic = ""
|
||||||
|
w.mu.Unlock()
|
||||||
|
if running && client != nil {
|
||||||
|
client.Disconnect(250)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) setErr(msg string) {
|
||||||
|
w.mu.Lock()
|
||||||
|
w.lastErr = msg
|
||||||
|
w.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle runs on the MQTT goroutine, so it does the least possible.
|
||||||
|
func (w *Watcher) handle(_ mqtt.Client, m mqtt.Message) {
|
||||||
|
var msg message
|
||||||
|
if err := json.Unmarshal(m.Payload(), &msg); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The topic filter already guarantees the transmitter, but a receiver with
|
||||||
|
// no callsign or no square cannot be drawn and is not a report of anything.
|
||||||
|
if strings.TrimSpace(msg.RxCall) == "" || len(strings.TrimSpace(msg.RxGrid)) < 4 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r := Report{
|
||||||
|
Call: strings.ToUpper(strings.TrimSpace(msg.RxCall)),
|
||||||
|
Grid: strings.ToUpper(strings.TrimSpace(msg.RxGrid)),
|
||||||
|
Band: strings.ToLower(strings.TrimSpace(msg.Band)),
|
||||||
|
Mode: strings.ToUpper(strings.TrimSpace(msg.Mode)),
|
||||||
|
SNR: msg.SNR,
|
||||||
|
FreqHz: msg.Freq,
|
||||||
|
At: time.Now(),
|
||||||
|
}
|
||||||
|
w.mu.Lock()
|
||||||
|
w.reports = append(w.reports, r)
|
||||||
|
w.received++
|
||||||
|
// A cap as well as the window, so a pathological feed cannot grow this
|
||||||
|
// without bound between two reads.
|
||||||
|
if len(w.reports) > 4000 {
|
||||||
|
w.reports = w.reports[len(w.reports)-2000:]
|
||||||
|
}
|
||||||
|
w.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reports is who has heard us inside the window, freshest report per callsign.
|
||||||
|
//
|
||||||
|
// One entry per STATION, not per message: the same receiver uploading every
|
||||||
|
// five minutes is one pair of ears on the map, and its latest report is the one
|
||||||
|
// that says whether the path is still there.
|
||||||
|
func (w *Watcher) Reports() []Report {
|
||||||
|
cutoff := time.Now().Add(-Window)
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
// Pruned on read rather than on a timer: the only thing that cares about the
|
||||||
|
// window is whoever is looking.
|
||||||
|
kept := w.reports[:0]
|
||||||
|
for _, r := range w.reports {
|
||||||
|
if r.At.After(cutoff) {
|
||||||
|
kept = append(kept, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.reports = kept
|
||||||
|
|
||||||
|
byCall := map[string]int{}
|
||||||
|
out := make([]Report, 0, len(kept))
|
||||||
|
for _, r := range kept {
|
||||||
|
if i, seen := byCall[r.Call]; seen {
|
||||||
|
if r.At.After(out[i].At) {
|
||||||
|
out[i] = r
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byCall[r.Call] = len(out)
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status reports the connection, for the panel.
|
||||||
|
func (w *Watcher) Status() Status {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
online := w.running && w.client != nil && w.client.IsConnected()
|
||||||
|
return Status{
|
||||||
|
Enabled: w.running,
|
||||||
|
Online: online,
|
||||||
|
Reports: w.received,
|
||||||
|
Watching: w.cfg.MyCall,
|
||||||
|
Error: w.lastErr,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package pskrme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// One station uploading every five minutes must be ONE pair of ears on the map,
|
||||||
|
// showing its freshest report — not four arcs to the same square, and not the
|
||||||
|
// oldest of them deciding whether the path still looks open.
|
||||||
|
func TestReportsKeepsTheFreshestPerStation(t *testing.T) {
|
||||||
|
w := New(Config{MyCall: "F4BPO"})
|
||||||
|
now := time.Now()
|
||||||
|
w.reports = []Report{
|
||||||
|
{Call: "OH5CX", Grid: "KP30", SNR: -18, At: now.Add(-9 * time.Minute)},
|
||||||
|
{Call: "W1AW", Grid: "FN31", SNR: -5, At: now.Add(-2 * time.Minute)},
|
||||||
|
{Call: "OH5CX", Grid: "KP30", SNR: -11, At: now.Add(-1 * time.Minute)},
|
||||||
|
}
|
||||||
|
got := w.Reports()
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %d stations, want 2: %+v", len(got), got)
|
||||||
|
}
|
||||||
|
for _, r := range got {
|
||||||
|
if r.Call == "OH5CX" && r.SNR != -11 {
|
||||||
|
t.Errorf("OH5CX kept the %d dB report, want the freshest (-11)", r.SNR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A report older than the window is gone, and gone from the slice too: the map
|
||||||
|
// must not show a path that stopped existing a quarter of an hour ago, and the
|
||||||
|
// window is what keeps this from growing all evening.
|
||||||
|
func TestReportsDropsWhatIsPastTheWindow(t *testing.T) {
|
||||||
|
w := New(Config{MyCall: "F4BPO"})
|
||||||
|
now := time.Now()
|
||||||
|
w.reports = []Report{
|
||||||
|
{Call: "OLD", Grid: "JN36", At: now.Add(-Window - time.Minute)},
|
||||||
|
{Call: "NEW", Grid: "JN36", At: now.Add(-time.Minute)},
|
||||||
|
}
|
||||||
|
got := w.Reports()
|
||||||
|
if len(got) != 1 || got[0].Call != "NEW" {
|
||||||
|
t.Fatalf("got %+v, want only NEW", got)
|
||||||
|
}
|
||||||
|
if len(w.reports) != 1 {
|
||||||
|
t.Errorf("the stale report is still held: %d kept", len(w.reports))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turning the feed off clears what it collected. Left in place, switching it
|
||||||
|
// back on would redraw a map of who heard us before it was on.
|
||||||
|
func TestStopForgetsTheReports(t *testing.T) {
|
||||||
|
w := New(Config{MyCall: "F4BPO"})
|
||||||
|
w.reports = []Report{{Call: "OH5CX", Grid: "KP30", At: time.Now()}}
|
||||||
|
w.Stop()
|
||||||
|
if got := w.Reports(); len(got) != 0 {
|
||||||
|
t.Errorf("got %+v after Stop, want nothing", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No callsign, no subscription: the transmit level would be a wildcard, which
|
||||||
|
// is the entire feed — the one thing this package exists not to ask for.
|
||||||
|
func TestStartRefusesWithoutACallsign(t *testing.T) {
|
||||||
|
if err := New(Config{}).Start(); err == nil {
|
||||||
|
t.Fatal("started with no callsign")
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
-5
@@ -1860,9 +1860,19 @@ type GridSquare struct {
|
|||||||
// return false to drop a QSO. Aggregation to 4 characters happens HERE rather
|
// return false to drop a QSO. Aggregation to 4 characters happens HERE rather
|
||||||
// than in SQL — the column holds 4, 6 and 8-character grids, and lower(substr)
|
// than in SQL — the column holds 4, 6 and 8-character grids, and lower(substr)
|
||||||
// in SQL would differ between SQLite and MySQL for no gain.
|
// in SQL would differ between SQLite and MySQL for no gain.
|
||||||
func (r *Repo) GridSquares(ctx context.Context, keep func(mode string) bool) ([]GridSquare, error) {
|
// GridSquareRow is one contact as the grid map decides whether to keep it.
|
||||||
|
//
|
||||||
|
// Submode is here beside Mode because the mode an operator filters by is not
|
||||||
|
// always the one in MODE: ADIF puts PSK63 in SUBMODE with PSK above it, so a
|
||||||
|
// filter that read MODE alone offered "PSK" for a log full of PSK63.
|
||||||
|
type GridSquareRow struct {
|
||||||
|
Mode, Submode, Band, SatName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) GridSquares(ctx context.Context, keep func(GridSquareRow) bool) ([]GridSquare, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT COALESCE(grid,''), UPPER(COALESCE(mode,'')), LOWER(COALESCE(band,'')),
|
SELECT COALESCE(grid,''), UPPER(COALESCE(mode,'')), UPPER(COALESCE(submode,'')),
|
||||||
|
LOWER(COALESCE(band,'')), UPPER(COALESCE(sat_name,'')),
|
||||||
COALESCE(lotw_rcvd,''), COALESCE(qsl_rcvd,''), COALESCE(eqsl_rcvd,'')
|
COALESCE(lotw_rcvd,''), COALESCE(qsl_rcvd,''), COALESCE(eqsl_rcvd,'')
|
||||||
FROM qso
|
FROM qso
|
||||||
WHERE grid IS NOT NULL AND grid != ''
|
WHERE grid IS NOT NULL AND grid != ''
|
||||||
@@ -1873,11 +1883,11 @@ func (r *Repo) GridSquares(ctx context.Context, keep func(mode string) bool) ([]
|
|||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
out := map[string]*GridSquare{}
|
out := map[string]*GridSquare{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var grid, mode, band, lotw, card, eqsl string
|
var grid, mode, submode, band, satName, lotw, card, eqsl string
|
||||||
if err := rows.Scan(&grid, &mode, &band, &lotw, &card, &eqsl); err != nil {
|
if err := rows.Scan(&grid, &mode, &submode, &band, &satName, &lotw, &card, &eqsl); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if keep != nil && !keep(mode) {
|
if keep != nil && !keep(GridSquareRow{Mode: mode, Submode: submode, Band: band, SatName: satName}) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
g := strings.ToUpper(strings.TrimSpace(grid))
|
g := strings.ToUpper(strings.TrimSpace(grid))
|
||||||
@@ -1914,6 +1924,51 @@ func (r *Repo) GridSquares(ctx context.Context, keep func(mode string) bool) ([]
|
|||||||
return list, nil
|
return list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GridSquareChoices is every mode, band and satellite that the squares on the
|
||||||
|
// map were actually worked on.
|
||||||
|
//
|
||||||
|
// Taken from the log rather than from a list in the code, so a filter can only
|
||||||
|
// ever offer something there is something to see behind — and so a mode that
|
||||||
|
// does not exist yet needs no change here the day an operator starts using it.
|
||||||
|
// The mode is the SUBMODE when there is one: PSK63 is the answer, not PSK.
|
||||||
|
func (r *Repo) GridSquareChoices(ctx context.Context) (modes, bands, sats []string, err error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT DISTINCT UPPER(COALESCE(mode,'')), UPPER(COALESCE(submode,'')),
|
||||||
|
LOWER(COALESCE(band,'')), UPPER(COALESCE(sat_name,''))
|
||||||
|
FROM qso
|
||||||
|
WHERE grid IS NOT NULL AND grid != ''`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, fmt.Errorf("query grid choices: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
seenM, seenB, seenS := map[string]bool{}, map[string]bool{}, map[string]bool{}
|
||||||
|
for rows.Next() {
|
||||||
|
var mode, submode, band, sat string
|
||||||
|
if err := rows.Scan(&mode, &submode, &band, &sat); err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
if m := strings.TrimSpace(submode); m != "" {
|
||||||
|
mode = m
|
||||||
|
}
|
||||||
|
if mode = strings.TrimSpace(mode); mode != "" && !seenM[mode] {
|
||||||
|
seenM[mode] = true
|
||||||
|
modes = append(modes, mode)
|
||||||
|
}
|
||||||
|
if band = strings.TrimSpace(band); band != "" && !seenB[band] {
|
||||||
|
seenB[band] = true
|
||||||
|
bands = append(bands, band)
|
||||||
|
}
|
||||||
|
if sat = strings.TrimSpace(sat); sat != "" && !seenS[sat] {
|
||||||
|
seenS[sat] = true
|
||||||
|
sats = append(sats, sat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
return modes, bands, sats, nil
|
||||||
|
}
|
||||||
|
|
||||||
// BandSlotQSOs returns every contact on one band that belongs to a slot of the
|
// BandSlotQSOs returns every contact on one band that belongs to a slot of the
|
||||||
// entry matrix: the exact callsign, or any callsign in the same DXCC entity.
|
// entry matrix: the exact callsign, or any callsign in the same DXCC entity.
|
||||||
// Mode is NOT filtered here — the class (phone / CW / digital) is a derived
|
// Mode is NOT filtered here — the class (phone / CW / digital) is a derived
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
// Package dcu1 drives rotator controllers that speak the Hy-Gain DCU-1 protocol,
|
// Package dcu1 drives rotator controllers that speak the Hy-Gain DCU-1 protocol,
|
||||||
// over a serial COM port (or a raw TCP socket, e.g. a serial-over-IP bridge).
|
// over a serial COM port (or a raw TCP socket, e.g. a serial-over-IP bridge).
|
||||||
//
|
//
|
||||||
// DCU-1 is used by the Hy-Gain DCU-1, the Idiom Press Rotor-EZ, Green Heron
|
// DCU-1 is used by the Hy-Gain DCU-1, the Green Heron RT-21, the Idiom Press
|
||||||
// controllers, and the RotorCard DXA (hamsupply) for Yaesu DXA rotors. It is a
|
// Rotor-EZ, and the RotorCard DXA (hamsupply) for Yaesu DXA rotors. It is a
|
||||||
// DIFFERENT command set from Yaesu GS-232 (see internal/rotator/gs232):
|
// DIFFERENT command set from Yaesu GS-232 (see internal/rotator/gs232):
|
||||||
// semicolon-terminated, azimuth only.
|
// semicolon-terminated, azimuth only.
|
||||||
//
|
//
|
||||||
|
// The RT-21 selects its protocol on the controller, and only its DCU-1 /
|
||||||
|
// Rotor-EZ setting is this one — an RT-21 left on GS-232 belongs to the gs232
|
||||||
|
// package instead. With the Ethernet option it is a TCP endpoint in its own
|
||||||
|
// right, so the TCP transport below reaches it without a serial-over-IP
|
||||||
|
// bridge. Its NATIVE Green Heron protocol is a third command set, with 0.1°
|
||||||
|
// readback and a real stop, and is not implemented here.
|
||||||
|
//
|
||||||
// Commands (';' terminated — roundTrip appends the ';'):
|
// Commands (';' terminated — roundTrip appends the ';'):
|
||||||
//
|
//
|
||||||
// AP1nnn set the target bearing nnn (000-359)
|
// AP1nnn set the target bearing nnn (000-359)
|
||||||
@@ -15,6 +22,21 @@
|
|||||||
//
|
//
|
||||||
// The base DCU-1 set has no dedicated stop; Stop re-commands the current bearing,
|
// The base DCU-1 set has no dedicated stop; Stop re-commands the current bearing,
|
||||||
// which halts rotation.
|
// which halts rotation.
|
||||||
|
//
|
||||||
|
// ONE TCP session, held open and serialised.
|
||||||
|
//
|
||||||
|
// This started out opening a connection per command, like the UDP backends
|
||||||
|
// beside it. Over TCP to an embedded serial server — which is what the RT-21's
|
||||||
|
// Ethernet option is — that is the wrong shape: the heading is polled twice a
|
||||||
|
// second while the antenna turns, GoTo sends two commands, and each was its
|
||||||
|
// own connect and close. Those modules commonly accept a SINGLE session and
|
||||||
|
// need a moment to release it, so the churn alone can look like a controller
|
||||||
|
// that ignores half of what it is told.
|
||||||
|
//
|
||||||
|
// So the socket is kept between calls and one mutex serialises every
|
||||||
|
// exchange, which also stops a poll and a command from holding two sessions
|
||||||
|
// at once. A write or read error drops the socket; the next call redials.
|
||||||
|
// Serial keeps its open-per-call, where a COM port has one owner anyway.
|
||||||
package dcu1
|
package dcu1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -24,6 +46,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.bug.st/serial"
|
"go.bug.st/serial"
|
||||||
@@ -34,8 +57,9 @@ const (
|
|||||||
ioTimeout = 2 * time.Second
|
ioTimeout = 2 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client is a stateless per-call sender, mirroring the gs232/pst/rotgenius idiom.
|
// Client talks to one controller. Exactly one of (Host, Port) or ComPort is
|
||||||
// Exactly one of (Host, Port) or ComPort is used.
|
// used. Hold onto it: over TCP it keeps its session open between calls, so a
|
||||||
|
// fresh Client per command would give the churn back.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
Host string
|
Host string
|
||||||
Port int
|
Port int
|
||||||
@@ -43,6 +67,29 @@ type Client struct {
|
|||||||
// Baud varies by controller (a Hy-Gain DCU-1 is 4800; Green Heron / RotorCard
|
// Baud varies by controller (a Hy-Gain DCU-1 is 4800; Green Heron / RotorCard
|
||||||
// can differ). Zero keeps 4800.
|
// can differ). Zero keeps 4800.
|
||||||
Baud int
|
Baud int
|
||||||
|
|
||||||
|
// mu serialises every exchange. Two goroutines are in here in normal use —
|
||||||
|
// the heading poll and the operator's own commands — and on a single-session
|
||||||
|
// controller their overlap is the fault, not just a race on one socket.
|
||||||
|
mu sync.Mutex
|
||||||
|
// conn is the kept TCP session. nil when not connected, or after an error
|
||||||
|
// dropped it. Unused on serial.
|
||||||
|
conn net.Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close drops the kept session. Safe to call at any time and on any Client.
|
||||||
|
func (c *Client) Close() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.dropLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropLocked closes the session so the next exchange redials. Caller holds mu.
|
||||||
|
func (c *Client) dropLocked() {
|
||||||
|
if c.conn != nil {
|
||||||
|
_ = c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a TCP Client (a serial-over-IP bridge in front of the controller).
|
// New returns a TCP Client (a serial-over-IP bridge in front of the controller).
|
||||||
@@ -65,27 +112,56 @@ func NewSerial(comPort string, baud int) *Client {
|
|||||||
// wantReply) reads until a 3-digit bearing is present. cmd must NOT carry the
|
// wantReply) reads until a 3-digit bearing is present. cmd must NOT carry the
|
||||||
// ';'.
|
// ';'.
|
||||||
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
|
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
|
||||||
var conn io.ReadWriteCloser
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
if c.ComPort != "" {
|
if c.ComPort != "" {
|
||||||
baud := c.Baud
|
return c.exchangeSerial(cmd, wantReply)
|
||||||
if baud <= 0 {
|
|
||||||
baud = 4800
|
|
||||||
}
|
|
||||||
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: baud})
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("open rotator %s @ %d baud: %w", c.ComPort, baud, err)
|
|
||||||
}
|
|
||||||
_ = sp.SetReadTimeout(200 * time.Millisecond)
|
|
||||||
conn = sp
|
|
||||||
} else {
|
|
||||||
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("connect DCU-1 %s:%d: %w", c.Host, c.Port, err)
|
|
||||||
}
|
|
||||||
_ = nc.SetDeadline(time.Now().Add(ioTimeout))
|
|
||||||
conn = nc
|
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
// A kept socket can be half-dead: the far end went away and the first write
|
||||||
|
// still succeeds because nothing has been acknowledged yet. So one retry on
|
||||||
|
// a FRESH connection, and only when the session was one we had already —
|
||||||
|
// a dial that fails is a dial that fails.
|
||||||
|
for attempt := 0; attempt < 2; attempt++ {
|
||||||
|
reused := c.conn != nil
|
||||||
|
if c.conn == nil {
|
||||||
|
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("connect DCU-1 %s:%d: %w", c.Host, c.Port, err)
|
||||||
|
}
|
||||||
|
c.conn = nc
|
||||||
|
}
|
||||||
|
_ = c.conn.SetDeadline(time.Now().Add(ioTimeout))
|
||||||
|
line, err := c.exchange(c.conn, cmd, wantReply)
|
||||||
|
if err == nil {
|
||||||
|
return line, nil
|
||||||
|
}
|
||||||
|
c.dropLocked()
|
||||||
|
if !reused {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no reply to %q", cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exchangeSerial opens the port for one exchange and closes it again. A COM
|
||||||
|
// port has one owner, so holding it open would lock out the controller's own
|
||||||
|
// software for the whole session.
|
||||||
|
func (c *Client) exchangeSerial(cmd string, wantReply bool) (string, error) {
|
||||||
|
baud := c.Baud
|
||||||
|
if baud <= 0 {
|
||||||
|
baud = 4800
|
||||||
|
}
|
||||||
|
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: baud})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("open rotator %s @ %d baud: %w", c.ComPort, baud, err)
|
||||||
|
}
|
||||||
|
defer sp.Close()
|
||||||
|
_ = sp.SetReadTimeout(200 * time.Millisecond)
|
||||||
|
return c.exchange(sp, cmd, wantReply)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exchange sends one ';'-terminated command and reads the reply, if any.
|
||||||
|
func (c *Client) exchange(conn io.ReadWriter, cmd string, wantReply bool) (string, error) {
|
||||||
if _, err := conn.Write([]byte(cmd + ";")); err != nil {
|
if _, err := conn.Write([]byte(cmd + ";")); err != nil {
|
||||||
return "", fmt.Errorf("send %q: %w", cmd, err)
|
return "", fmt.Errorf("send %q: %w", cmd, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package dcu1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeRT21 is a controller that accepts ONE session at a time and counts how
|
||||||
|
// many it was asked for, which is the thing under test.
|
||||||
|
type fakeRT21 struct {
|
||||||
|
ln net.Listener
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions int
|
||||||
|
cmds []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeRT21(t *testing.T) *fakeRT21 {
|
||||||
|
t.Helper()
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
f := &fakeRT21{ln: ln}
|
||||||
|
go f.serve()
|
||||||
|
t.Cleanup(func() { _ = ln.Close() })
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRT21) serve() {
|
||||||
|
for {
|
||||||
|
conn, err := f.ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.sessions++
|
||||||
|
f.mu.Unlock()
|
||||||
|
// Served one at a time, on purpose: a second caller waits in the accept
|
||||||
|
// queue rather than being talked to, which is how these modules behave.
|
||||||
|
f.handle(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRT21) handle(conn net.Conn) {
|
||||||
|
defer conn.Close()
|
||||||
|
buf := make([]byte, 64)
|
||||||
|
for {
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||||
|
n, err := conn.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
for _, cmd := range strings.Split(string(buf[:n]), ";") {
|
||||||
|
if cmd = strings.TrimSpace(cmd); cmd == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.cmds = append(f.cmds, cmd)
|
||||||
|
f.mu.Unlock()
|
||||||
|
if cmd == "AI1" {
|
||||||
|
_, _ = conn.Write([]byte(";123"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRT21) port() int {
|
||||||
|
return f.ln.Addr().(*net.TCPAddr).Port
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRT21) seen() (int, []string) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.sessions, append([]string(nil), f.cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One session for the whole conversation.
|
||||||
|
//
|
||||||
|
// It used to be one per command: the heading is polled twice a second while the
|
||||||
|
// antenna turns and GoTo sends two commands (AP1 then AM1), each with its own
|
||||||
|
// connect and close. An RT-21's Ethernet option — like most embedded serial
|
||||||
|
// servers — commonly accepts a single session and needs a moment to release it,
|
||||||
|
// so the churn alone looked like a controller ignoring half of what it was told.
|
||||||
|
func TestOneSessionServesEveryCommand(t *testing.T) {
|
||||||
|
f := newFakeRT21(t)
|
||||||
|
c := New("127.0.0.1", f.port())
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
if az, _, err := c.Heading(); err != nil || az != 123 {
|
||||||
|
t.Fatalf("Heading() = %d, %v; want 123", az, err)
|
||||||
|
}
|
||||||
|
if err := c.GoTo(240); err != nil {
|
||||||
|
t.Fatalf("GoTo: %v", err)
|
||||||
|
}
|
||||||
|
if az, _, err := c.Heading(); err != nil || az != 123 {
|
||||||
|
t.Fatalf("second Heading() = %d, %v", az, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions, cmds := f.seen()
|
||||||
|
if sessions != 1 {
|
||||||
|
t.Errorf("the controller was asked for %d sessions; four commands must share one", sessions)
|
||||||
|
}
|
||||||
|
want := []string{"AI1", "AP1240", "AM1", "AI1"}
|
||||||
|
if strings.Join(cmds, ",") != strings.Join(want, ",") {
|
||||||
|
t.Errorf("commands %v, want %v", cmds, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A session the controller has dropped is redialled, and the command that found
|
||||||
|
// it dead is retried rather than reported as a failure — a kept socket's first
|
||||||
|
// write succeeds long after the far end has gone.
|
||||||
|
func TestADroppedSessionIsRedialled(t *testing.T) {
|
||||||
|
f := newFakeRT21(t)
|
||||||
|
c := New("127.0.0.1", f.port())
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
if _, _, err := c.Heading(); err != nil {
|
||||||
|
t.Fatalf("first Heading: %v", err)
|
||||||
|
}
|
||||||
|
// The controller power-cycles: close our end the way a dropped session
|
||||||
|
// leaves it, then ask again.
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
_ = c.conn.Close() // closed underneath, but still held — a half-dead socket
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if az, _, err := c.Heading(); err != nil || az != 123 {
|
||||||
|
t.Fatalf("Heading after the session dropped = %d, %v; want 123", az, err)
|
||||||
|
}
|
||||||
|
if sessions, _ := f.seen(); sessions != 2 {
|
||||||
|
t.Errorf("%d sessions; the dropped one should have been redialled exactly once", sessions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing is kept open for a controller that is not there, and the error names
|
||||||
|
// the address so it can be checked.
|
||||||
|
func TestADeadControllerReportsWhereItLooked(t *testing.T) {
|
||||||
|
// Port 1 on loopback: nothing listens, and the refusal is immediate.
|
||||||
|
c := New("127.0.0.1", 1)
|
||||||
|
defer c.Close()
|
||||||
|
_, _, err := c.Heading()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("no error from a controller that is not there")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "127.0.0.1:"+strconv.Itoa(1)) {
|
||||||
|
t.Errorf("error %q does not name the address it tried", err)
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
held := c.conn != nil
|
||||||
|
c.mu.Unlock()
|
||||||
|
if held {
|
||||||
|
t.Error("a failed dial left a connection behind")
|
||||||
|
}
|
||||||
|
}
|
||||||
+187
-43
@@ -1,12 +1,15 @@
|
|||||||
package sat
|
package sat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -195,22 +198,44 @@ func LoadBirds(dir string) (*Birds, error) {
|
|||||||
_ = b.parse(shippedBirds)
|
_ = b.parse(shippedBirds)
|
||||||
return b, fmt.Errorf("sat: %s could not be read (%w) — the shipped list is in use for this session, and your file has been left alone", BirdsName, perr)
|
return b, fmt.Errorf("sat: %s could not be read (%w) — the shipped list is in use for this session, and your file has been left alone", BirdsName, perr)
|
||||||
}
|
}
|
||||||
// New satellites reach an EXISTING station too.
|
// New satellites AND corrections reach an existing station.
|
||||||
//
|
//
|
||||||
// The operator's copy is written once, on the first run, and was then
|
// The operator's copy is written on the first run and was then theirs
|
||||||
// theirs for ever — which meant a release that added nine Tevel-2
|
// for ever, which broke both ways: a release that added nine Tevel-2
|
||||||
// satellites reached nobody who had already opened the tab. Merging on
|
// satellites reached nobody who had opened the tab, and a frequency we
|
||||||
// each load fixes that without taking anything back: a satellite the
|
// had shipped WRONG could never be mended — LilacSat-2 went out with an
|
||||||
// operator already has is left exactly as it is, edits included, and
|
// APRS digipeater and no FM transponder, and the wrong data had become
|
||||||
// only the ones they have never seen are added. Deleting a bird from the
|
// the operator's own file.
|
||||||
// file therefore brings it back, which is the price of the trade — and
|
//
|
||||||
// the cheaper half of it, since an unwanted satellite is one row and a
|
// So the merge adds what is missing, replaces what they never touched,
|
||||||
// missing one is a pass nobody can work.
|
// and leaves alone what they edited. See mergeShipped for how the three
|
||||||
if n := b.addMissing(shippedBirds); n > 0 {
|
// are told apart.
|
||||||
|
added, updated, kept, replaced := b.mergeShipped(dir)
|
||||||
|
if added > 0 || updated > 0 {
|
||||||
|
// A one-time copy before the first run that can overwrite an entry
|
||||||
|
// we have no baseline for. Cheap insurance on a file an operator may
|
||||||
|
// have spent an evening correcting.
|
||||||
|
if len(replaced) > 0 {
|
||||||
|
if err := os.WriteFile(path+".bak", data, 0o644); err == nil {
|
||||||
|
log.Printf("sat: %s copied to %s.bak before the plan was brought up to date", BirdsName, BirdsName)
|
||||||
|
}
|
||||||
|
}
|
||||||
if out, merr := json.MarshalIndent(b.list, "", " "); merr == nil {
|
if out, merr := json.MarshalIndent(b.list, "", " "); merr == nil {
|
||||||
_ = os.WriteFile(path, append(out, '\n'), 0o644)
|
_ = os.WriteFile(path, append(out, '\n'), 0o644)
|
||||||
}
|
}
|
||||||
|
log.Printf("sat: frequency plan — %d satellites added, %d brought up to date", added, updated)
|
||||||
}
|
}
|
||||||
|
if len(replaced) > 0 {
|
||||||
|
log.Printf("sat: %s taken from the shipped plan (no record of what this station was given). "+
|
||||||
|
"If one of those was your own correction, it is in %s.bak", strings.Join(replaced, ", "), BirdsName)
|
||||||
|
}
|
||||||
|
if len(kept) > 0 {
|
||||||
|
// Named, not silent: an operator who corrected a frequency should be
|
||||||
|
// able to see that OpsLog noticed and stood down.
|
||||||
|
log.Printf("sat: your own edits kept for %s — delete them from %s to take the shipped plan instead",
|
||||||
|
strings.Join(kept, ", "), BirdsName)
|
||||||
|
}
|
||||||
|
writeBaseline(dir)
|
||||||
return b, nil
|
return b, nil
|
||||||
case os.IsNotExist(err):
|
case os.IsNotExist(err):
|
||||||
if perr := b.parse(shippedBirds); perr != nil {
|
if perr := b.parse(shippedBirds); perr != nil {
|
||||||
@@ -218,6 +243,7 @@ func LoadBirds(dir string) (*Birds, error) {
|
|||||||
}
|
}
|
||||||
if werr := os.MkdirAll(dir, 0o755); werr == nil {
|
if werr := os.MkdirAll(dir, 0o755); werr == nil {
|
||||||
_ = os.WriteFile(path, shippedBirds, 0o644)
|
_ = os.WriteFile(path, shippedBirds, 0o644)
|
||||||
|
writeBaseline(dir)
|
||||||
}
|
}
|
||||||
return b, nil
|
return b, nil
|
||||||
default:
|
default:
|
||||||
@@ -305,51 +331,169 @@ func (b *Birds) Len() int {
|
|||||||
return len(b.list)
|
return len(b.list)
|
||||||
}
|
}
|
||||||
|
|
||||||
// addMissing appends the satellites in `shipped` that this list does not already
|
// BaselineName records the shipped plan as it was last handed to this station.
|
||||||
// hold, and reports how many were added.
|
|
||||||
//
|
//
|
||||||
// "Already hold" is by catalog number first and by the loose name second, so an
|
// It exists so a CORRECTION can reach an operator who already has the file.
|
||||||
// operator who renamed a bird, or who has it under the feed's spelling, does not
|
// Without it the merge could only add satellites, never mend one: LilacSat-2
|
||||||
// get a second copy of it. Nothing existing is touched: their frequencies, their
|
// shipped with an APRS digipeater and no FM transponder, and every station that
|
||||||
// labels and their corrections all stand.
|
// had already opened the satellite tab was stuck with it for ever — the wrong
|
||||||
func (b *Birds) addMissing(shipped []byte) int {
|
// frequency was the operator's file now, and their file was sacred.
|
||||||
|
const BaselineName = "satellites.shipped.json"
|
||||||
|
|
||||||
|
// mergeShipped brings the shipped plan into the operator's list.
|
||||||
|
//
|
||||||
|
// Three cases, and the middle one is the point:
|
||||||
|
//
|
||||||
|
// - A satellite they do not have is ADDED. That is how new birds arrive.
|
||||||
|
// - A satellite they have, UNCHANGED from the plan they were given, is
|
||||||
|
// REPLACED by the current one. They never edited it, so it is not theirs to
|
||||||
|
// keep — it is our data, and ours was wrong.
|
||||||
|
// - A satellite they have EDITED is left exactly as it is, and said so in the
|
||||||
|
// log. A frequency somebody corrected by hand outranks anything shipped:
|
||||||
|
// they were on the air and we were not.
|
||||||
|
//
|
||||||
|
// "Unchanged" is decided against the baseline, so the comparison is with the
|
||||||
|
// plan THEY were given rather than with whatever ships today. Their edits
|
||||||
|
// therefore survive every future release, not just the next one.
|
||||||
|
func (b *Birds) mergeShipped(dir string) (added, updated int, kept, replaced []string) {
|
||||||
var list []Bird
|
var list []Bird
|
||||||
if err := json.Unmarshal(shipped, &list); err != nil {
|
if err := json.Unmarshal(shippedBirds, &list); err != nil {
|
||||||
return 0
|
return 0, 0, nil, nil
|
||||||
}
|
}
|
||||||
|
baseline := readBaseline(dir)
|
||||||
|
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
have := make(map[int]bool, len(b.list))
|
byNORAD := map[int]int{} // catalog number → index in b.list
|
||||||
for _, x := range b.list {
|
byName := map[string]int{}
|
||||||
|
for i, x := range b.list {
|
||||||
if x.NORAD != 0 {
|
if x.NORAD != 0 {
|
||||||
have[x.NORAD] = true
|
byNORAD[x.NORAD] = i
|
||||||
}
|
}
|
||||||
}
|
for _, n := range append([]string{x.Name}, x.Aliases...) {
|
||||||
added := 0
|
if k := loose(n); k != "" {
|
||||||
for _, cand := range list {
|
if _, seen := byName[k]; !seen {
|
||||||
if cand.NORAD != 0 && have[cand.NORAD] {
|
byName[k] = i
|
||||||
continue
|
|
||||||
}
|
|
||||||
known := false
|
|
||||||
for _, name := range append([]string{cand.Name}, cand.Aliases...) {
|
|
||||||
if k := loose(name); k != "" {
|
|
||||||
if _, ok := b.byKey[k]; ok {
|
|
||||||
known = true
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if known {
|
}
|
||||||
|
find := func(c Bird) int {
|
||||||
|
if c.NORAD != 0 {
|
||||||
|
if i, ok := byNORAD[c.NORAD]; ok {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range append([]string{c.Name}, c.Aliases...) {
|
||||||
|
if i, ok := byName[loose(n)]; ok {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cand := range list {
|
||||||
|
i := find(cand)
|
||||||
|
if i < 0 {
|
||||||
|
b.list = append(b.list, cand)
|
||||||
|
added++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
b.list = append(b.list, cand)
|
if sameBird(b.list[i], cand) {
|
||||||
if cand.NORAD != 0 {
|
continue // already current
|
||||||
have[cand.NORAD] = true
|
|
||||||
}
|
}
|
||||||
if k := loose(cand.Name); k != "" {
|
was, hadBaseline := baseline[birdKey(cand)]
|
||||||
b.byKey[k] = len(b.list) - 1
|
switch {
|
||||||
|
case !hadBaseline:
|
||||||
|
// FIRST run after baselines existed, and there is no record of what
|
||||||
|
// this station was given — so an edit of theirs and a mistake of
|
||||||
|
// ours are indistinguishable here.
|
||||||
|
//
|
||||||
|
// The shipped plan wins, ONCE, and the whole file is backed up
|
||||||
|
// first. Standing down instead would have been the safe-looking
|
||||||
|
// choice and the wrong one: the baseline written at the end of this
|
||||||
|
// run would then record their entry as "edited" and freeze a
|
||||||
|
// frequency we know to be wrong for the life of the install. A
|
||||||
|
// backup and a log line are recoverable; that is not.
|
||||||
|
replaced = append(replaced, b.list[i].Name)
|
||||||
|
b.list[i] = cand
|
||||||
|
updated++
|
||||||
|
case sameBird(b.list[i], was):
|
||||||
|
b.list[i] = cand
|
||||||
|
updated++
|
||||||
|
default:
|
||||||
|
kept = append(kept, b.list[i].Name)
|
||||||
}
|
}
|
||||||
added++
|
|
||||||
}
|
}
|
||||||
|
b.reindexLocked()
|
||||||
b.mu.Unlock()
|
b.mu.Unlock()
|
||||||
return added
|
return added, updated, kept, replaced
|
||||||
|
}
|
||||||
|
|
||||||
|
// birdKey identifies a satellite across versions: the catalog number when there
|
||||||
|
// is one, the loose name otherwise.
|
||||||
|
func birdKey(x Bird) string {
|
||||||
|
if x.NORAD != 0 {
|
||||||
|
return "n:" + strconv.Itoa(x.NORAD)
|
||||||
|
}
|
||||||
|
return "s:" + loose(x.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameBird compares two plans for one satellite by VALUE — the frequencies, the
|
||||||
|
// modes, the tone, the labels. Field by field through JSON rather than one
|
||||||
|
// comparison per field, so a transponder field added later cannot silently drop
|
||||||
|
// out of the test and start reporting equal plans as different.
|
||||||
|
func sameBird(a, c Bird) bool {
|
||||||
|
ja, ea := json.Marshal(a)
|
||||||
|
jc, ec := json.Marshal(c)
|
||||||
|
if ea != nil || ec != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return bytes.Equal(ja, jc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readBaseline loads the shipped plan this station was last given.
|
||||||
|
func readBaseline(dir string) map[string]Bird {
|
||||||
|
out := map[string]Bird{}
|
||||||
|
raw, err := os.ReadFile(filepath.Join(dir, BaselineName))
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
var list []Bird
|
||||||
|
if json.Unmarshal(raw, &list) != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, x := range list {
|
||||||
|
out[birdKey(x)] = x
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeBaseline records what was shipped, so the NEXT release can tell an
|
||||||
|
// operator's correction from one of ours.
|
||||||
|
func writeBaseline(dir string) {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(filepath.Join(dir, BaselineName), shippedBirds, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reindexLocked rebuilds the name lookup after the list has changed.
|
||||||
|
func (b *Birds) reindexLocked() {
|
||||||
|
byKey := make(map[string]int, len(b.list)*3)
|
||||||
|
put := func(name string, i int) {
|
||||||
|
if k := loose(name); k != "" {
|
||||||
|
if _, seen := byKey[k]; !seen {
|
||||||
|
byKey[k] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, bird := range b.list {
|
||||||
|
put(bird.Name, i)
|
||||||
|
}
|
||||||
|
for i, bird := range b.list {
|
||||||
|
for _, a := range bird.Aliases {
|
||||||
|
put(a, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.byKey = byKey
|
||||||
}
|
}
|
||||||
|
|||||||
+839
-704
File diff suppressed because it is too large
Load Diff
@@ -268,3 +268,146 @@ func TestGeneratedBirdsAreSane(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The three cases the merge has to tell apart. Getting the middle one wrong is
|
||||||
|
// how LilacSat-2 shipped with no FM transponder and could never be mended.
|
||||||
|
func TestMergeShippedRespectsEditsAndFixesOurs(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// First run: the shipped plan and its baseline are written.
|
||||||
|
if _, err := LoadBirds(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, BaselineName)); err != nil {
|
||||||
|
t.Fatalf("no baseline was recorded: %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, BirdsName)
|
||||||
|
|
||||||
|
// The operator corrects SO-50's tone and adds a satellite of their own.
|
||||||
|
var list []Bird
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &list); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var untouched Bird
|
||||||
|
for i := range list {
|
||||||
|
if list[i].Name == "SO-50" {
|
||||||
|
list[i].Transponders[0].CTCSS = 74.4
|
||||||
|
}
|
||||||
|
if list[i].Name == "AO-7" {
|
||||||
|
untouched = list[i] // left exactly as shipped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list = append(list, Bird{Name: "MY-SAT", Transponders: []Transponder{{Label: "mine", Mode: "FM", DownLo: 145000000, UpLo: 435000000}}})
|
||||||
|
out, _ := json.MarshalIndent(list, "", " ")
|
||||||
|
if err := os.WriteFile(path, out, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load again. Nothing shipped has changed, so nothing should move.
|
||||||
|
b, err := LoadBirds(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, ok := b.Find("SO-50")
|
||||||
|
if !ok || got.Transponders[0].CTCSS != 74.4 {
|
||||||
|
t.Errorf("the operator's tone was lost: %+v", got.Transponders)
|
||||||
|
}
|
||||||
|
if _, ok := b.Find("MY-SAT"); !ok {
|
||||||
|
t.Error("the operator's own satellite was dropped")
|
||||||
|
}
|
||||||
|
if a, ok := b.Find("AO-7"); !ok || !sameBird(a, untouched) {
|
||||||
|
t.Error("an untouched satellite was altered for no reason")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An entry the operator never touched is REPLACED when the shipped plan
|
||||||
|
// changes. That is the whole point: our data, and ours was wrong.
|
||||||
|
func TestMergeShippedUpdatesWhatWasNeverEdited(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if _, err := LoadBirds(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, BirdsName)
|
||||||
|
|
||||||
|
// Pretend an older release shipped SO-50 with a wrong downlink, and that the
|
||||||
|
// operator simply took it: their file AND the baseline both hold the wrong
|
||||||
|
// value, which is exactly what "never edited" looks like.
|
||||||
|
rewrite := func(p string, mutate func(*Bird)) {
|
||||||
|
raw, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var l []Bird
|
||||||
|
if err := json.Unmarshal(raw, &l); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := range l {
|
||||||
|
if l[i].Name == "SO-50" {
|
||||||
|
mutate(&l[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out, _ := json.MarshalIndent(l, "", " ")
|
||||||
|
if err := os.WriteFile(p, out, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wrong := func(x *Bird) { x.Transponders[0].DownLo = 1 }
|
||||||
|
rewrite(path, wrong)
|
||||||
|
rewrite(filepath.Join(dir, BaselineName), wrong)
|
||||||
|
|
||||||
|
b, err := LoadBirds(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, ok := b.Find("SO-50")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("SO-50 vanished")
|
||||||
|
}
|
||||||
|
if got.Transponders[0].DownLo == 1 {
|
||||||
|
t.Error("a value the operator never edited was not brought up to date — a shipped mistake is unfixable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With NO baseline — the first run after baselines existed — the shipped plan
|
||||||
|
// wins and the file is backed up. Standing down would freeze a known-wrong
|
||||||
|
// frequency for the life of the install.
|
||||||
|
func TestMergeShippedWithNoBaselineTakesShippedAndBacksUp(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if _, err := LoadBirds(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, BirdsName)
|
||||||
|
if err := os.Remove(filepath.Join(dir, BaselineName)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A wrong value, with nothing to say whether it is ours or theirs.
|
||||||
|
raw, _ := os.ReadFile(path)
|
||||||
|
var l []Bird
|
||||||
|
if err := json.Unmarshal(raw, &l); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := range l {
|
||||||
|
if l[i].Name == "SO-50" {
|
||||||
|
l[i].Transponders[0].DownLo = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out, _ := json.MarshalIndent(l, "", " ")
|
||||||
|
if err := os.WriteFile(path, out, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := LoadBirds(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := b.Find("SO-50")
|
||||||
|
if got.Transponders[0].DownLo == 1 {
|
||||||
|
t.Error("the shipped plan did not take over, so the wrong value is now frozen for ever")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path + ".bak"); err != nil {
|
||||||
|
t.Errorf("no backup was written before overwriting: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func TestAJustCommandedMoveReportsMotion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
c.moveCmdAt = time.Now()
|
c.moveUntil = time.Now().Add(moveOptimisticWindow)
|
||||||
c.statusMu.Unlock()
|
c.statusMu.Unlock()
|
||||||
|
|
||||||
st, _ := c.GetStatus()
|
st, _ := c.GetStatus()
|
||||||
@@ -33,9 +33,68 @@ func TestAJustCommandedMoveReportsMotion(t *testing.T) {
|
|||||||
// Bounded: an antenna that never reports motion must not latch the transmit
|
// Bounded: an antenna that never reports motion must not latch the transmit
|
||||||
// inhibit on for ever.
|
// inhibit on for ever.
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
c.moveCmdAt = time.Now().Add(-moveOptimisticWindow - time.Second)
|
c.moveUntil = time.Now().Add(-time.Second)
|
||||||
c.statusMu.Unlock()
|
c.statusMu.Unlock()
|
||||||
if st, _ := c.GetStatus(); st.MotorsMoving != 0 {
|
if st, _ := c.GetStatus(); st.MotorsMoving != 0 {
|
||||||
t.Error("the optimistic window never expires")
|
t.Error("the optimistic window never expires")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retract and Calibrate move every element, and they were the two commands that
|
||||||
|
// reported nothing at all: no "moving" on screen, the poll left on its
|
||||||
|
// two-second idle cadence instead of speeding up to watch, and no transmit
|
||||||
|
// inhibit while the elements travelled. Reported by an operator whose retract
|
||||||
|
// looked inert next to an Ultrabeam's.
|
||||||
|
func TestRetractAndCalibrateReportMotion(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
call func(*Client) error
|
||||||
|
}{
|
||||||
|
{"retract", (*Client).Retract},
|
||||||
|
{"calibrate", (*Client).Calibrate},
|
||||||
|
} {
|
||||||
|
c := &Client{}
|
||||||
|
c.lastStatus = &Status{Connected: true, Frequency: 14074}
|
||||||
|
// No connection, so the write fails and the command returns an error —
|
||||||
|
// which is the point: a command that did NOT reach the controller must
|
||||||
|
// not claim the antenna is moving.
|
||||||
|
if err := tc.call(c); err == nil {
|
||||||
|
t.Fatalf("%s: expected an error with no connection", tc.name)
|
||||||
|
}
|
||||||
|
if st, _ := c.GetStatus(); st.MotorsMoving != 0 {
|
||||||
|
t.Errorf("%s: a command that failed to send reports motion", tc.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And with the write accepted, motion is reported at once.
|
||||||
|
c.markMoving(retractOptimisticWindow)
|
||||||
|
if st, _ := c.GetStatus(); st.MotorsMoving == 0 {
|
||||||
|
t.Errorf("%s: a commanded move is not reported as motion", tc.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The retract bridge is longer than the tune bridge: winding every element into
|
||||||
|
// its hub takes tens of seconds, and a retract drops the controller out of
|
||||||
|
// AUTOTRACK, where its motor-bit reporting is less predictable.
|
||||||
|
func TestRetractBridgeOutlastsTheTuneBridge(t *testing.T) {
|
||||||
|
if retractOptimisticWindow <= moveOptimisticWindow {
|
||||||
|
t.Errorf("retract window %v is not longer than the tune window %v",
|
||||||
|
retractOptimisticWindow, moveOptimisticWindow)
|
||||||
|
}
|
||||||
|
// Bounded all the same — see the note on the constant.
|
||||||
|
if retractOptimisticWindow > time.Minute {
|
||||||
|
t.Errorf("retract window %v could latch the transmit inhibit on", retractOptimisticWindow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// markMoving extends, never shortens: a tune issued a moment after a retract
|
||||||
|
// must not cut the retract's bridge down to the tune's.
|
||||||
|
func TestMarkMovingOnlyExtends(t *testing.T) {
|
||||||
|
c := &Client{}
|
||||||
|
c.markMoving(retractOptimisticWindow)
|
||||||
|
far := c.moveUntil
|
||||||
|
c.markMoving(moveOptimisticWindow)
|
||||||
|
if c.moveUntil.Before(far) {
|
||||||
|
t.Error("a shorter bridge shortened a longer one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,6 +84,16 @@ const (
|
|||||||
// that never reports motion cannot latch the inhibit on for ever.
|
// that never reports motion cannot latch the inhibit on for ever.
|
||||||
const moveOptimisticWindow = 3 * time.Second
|
const moveOptimisticWindow = 3 * time.Second
|
||||||
|
|
||||||
|
// retractOptimisticWindow is the same bridge for a RETRACT or a CALIBRATE.
|
||||||
|
//
|
||||||
|
// Longer, because those are the two longest movements the antenna makes —
|
||||||
|
// tens of seconds to wind every element into its hub — and because a retract
|
||||||
|
// drops the controller out of AUTOTRACK, which is a state its motor-bit
|
||||||
|
// reporting is less predictable in. Still bounded, for the reason above: an
|
||||||
|
// antenna that never reports motion must not latch the transmit inhibit on
|
||||||
|
// for ever.
|
||||||
|
const retractOptimisticWindow = 10 * time.Second
|
||||||
|
|
||||||
// Transport says how to reach the controller.
|
// Transport says how to reach the controller.
|
||||||
type Transport struct {
|
type Transport struct {
|
||||||
Mode string // "tcp" | "serial"
|
Mode string // "tcp" | "serial"
|
||||||
@@ -122,8 +132,11 @@ type Client struct {
|
|||||||
statusMu sync.RWMutex
|
statusMu sync.RWMutex
|
||||||
lastStatus *Status
|
lastStatus *Status
|
||||||
lastSetKHz int
|
lastSetKHz int
|
||||||
// moveCmdAt is when a move was last COMMANDED — see moveOptimisticWindow.
|
// moveUntil is how long a commanded move is reported as moving without the
|
||||||
moveCmdAt time.Time
|
// controller having said so — see moveOptimisticWindow. A DEADLINE rather
|
||||||
|
// than the command's timestamp, because a retract needs a longer bridge than
|
||||||
|
// a tune and the caller is what knows which it asked for.
|
||||||
|
moveUntil time.Time
|
||||||
// lastDriftKHz is the frequency last reported for a controller that had gone
|
// lastDriftKHz is the frequency last reported for a controller that had gone
|
||||||
// somewhere other than where it was told, so the disagreement is stated once
|
// somewhere other than where it was told, so the disagreement is stated once
|
||||||
// and not on every poll. Zero when it is where it should be.
|
// and not on every poll. Zero when it is where it should be.
|
||||||
@@ -225,7 +238,25 @@ func (c *Client) GetStatus() (*Status, error) {
|
|||||||
// movingOptimisticallyLocked reports a move commanded too recently for the
|
// movingOptimisticallyLocked reports a move commanded too recently for the
|
||||||
// controller to have answered. Callers hold statusMu.
|
// controller to have answered. Callers hold statusMu.
|
||||||
func (c *Client) movingOptimisticallyLocked() bool {
|
func (c *Client) movingOptimisticallyLocked() bool {
|
||||||
return !c.moveCmdAt.IsZero() && time.Since(c.moveCmdAt) < moveOptimisticWindow
|
return !c.moveUntil.IsZero() && time.Now().Before(c.moveUntil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// markMoving reports motion for d, bridging the gap until the controller says
|
||||||
|
// so itself.
|
||||||
|
//
|
||||||
|
// Every command that MOVES something has to call this. Retract and Calibrate
|
||||||
|
// did not, and they are the two that need it most: nothing on screen said the
|
||||||
|
// elements were moving, the poll stayed on its two-second idle cadence
|
||||||
|
// instead of speeding up to watch, and the transmit inhibit was not engaged
|
||||||
|
// while the elements travelled. An operator reported the retract as showing
|
||||||
|
// nothing at all, next to an Ultrabeam that shows its element lengths
|
||||||
|
// counting down.
|
||||||
|
func (c *Client) markMoving(d time.Duration) {
|
||||||
|
c.statusMu.Lock()
|
||||||
|
if until := time.Now().Add(d); until.After(c.moveUntil) {
|
||||||
|
c.moveUntil = until
|
||||||
|
}
|
||||||
|
c.statusMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MovingOptimistically is the same question from outside the lock — the poll
|
// MovingOptimistically is the same question from outside the lock — the poll
|
||||||
@@ -693,7 +724,9 @@ func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
|||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
c.lastSetKHz = freqKhz
|
c.lastSetKHz = freqKhz
|
||||||
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
|
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
|
||||||
c.moveCmdAt = time.Now() // report motion at once — see moveOptimisticWindow
|
if until := time.Now().Add(moveOptimisticWindow); until.After(c.moveUntil) {
|
||||||
|
c.moveUntil = until // report motion at once — see moveOptimisticWindow
|
||||||
|
}
|
||||||
c.statusMu.Unlock()
|
c.statusMu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -726,7 +759,11 @@ func (c *Client) Retract() error {
|
|||||||
khz = 14000 // any in-range value; the controller just homes
|
khz = 14000 // any in-range value; the controller just homes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return c.writeCmd(buildSet(khz*1000, DirNormal, 'S'))
|
if err := c.writeCmd(buildSet(khz*1000, DirNormal, 'S')); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.markMoving(retractOptimisticWindow)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// portBusyHint turns "Serial port busy" into something actionable — see the
|
// portBusyHint turns "Serial port busy" into something actionable — see the
|
||||||
@@ -741,3 +778,32 @@ func portBusyHint(mode, com string, err error) string {
|
|||||||
}
|
}
|
||||||
return " — another program already has " + com + " open (the SteppIR control window, PstRotator, a terminal). A COM port has one owner: close the other program, then OpsLog can connect."
|
return " — another program already has " + com + " open (the SteppIR control window, PstRotator, a terminal). A COM port has one owner: close the other program, then OpsLog can connect."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calibrate runs the controller's calibration: the elements are driven to their
|
||||||
|
// end stops so the controller re-learns where zero is.
|
||||||
|
//
|
||||||
|
// It is the cure for an antenna that tunes to the wrong length after a power cut
|
||||||
|
// mid-move, after the elements have been retracted by hand, or after a motor has
|
||||||
|
// slipped — the controller counts steps from a remembered position, and once
|
||||||
|
// that memory is wrong every frequency after it is wrong by the same amount.
|
||||||
|
//
|
||||||
|
// It takes MINUTES and moves every element the whole way, so it is not something
|
||||||
|
// to do during a contest. Like Retract it drops the controller out of AUTOTRACK,
|
||||||
|
// which is handled transparently: the next SetFrequency re-issues AUTOTRACK ON.
|
||||||
|
func (c *Client) Calibrate() error {
|
||||||
|
// A valid frequency accompanies every SET frame; the controller ignores it
|
||||||
|
// for this command, but a malformed frame is refused outright.
|
||||||
|
khz := c.LastSetKHz()
|
||||||
|
if khz <= 0 {
|
||||||
|
if st, _ := c.GetStatus(); st != nil && st.Frequency > 0 {
|
||||||
|
khz = st.Frequency
|
||||||
|
} else {
|
||||||
|
khz = 14000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := c.writeCmd(buildSet(khz*1000, DirNormal, 'V')); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.markMoving(retractOptimisticWindow)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -894,9 +894,20 @@ func (c *Client) SetDirection(direction int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Retract retracts all elements (command 2)
|
// Retract retracts all elements (command 2)
|
||||||
|
//
|
||||||
|
// Reports motion at once, like a frequency change does: this is the longest
|
||||||
|
// move the antenna makes, and the flag it sets is what inhibits the
|
||||||
|
// transmitter while the elements travel. The element lengths counting down
|
||||||
|
// made the omission less visible here than on a SteppIR, which reports no
|
||||||
|
// lengths at all — but the inhibit was equally missing.
|
||||||
func (c *Client) Retract() error {
|
func (c *Client) Retract() error {
|
||||||
_, err := c.sendCommand(CMD_RETRACT, nil)
|
if _, err := c.sendCommand(CMD_RETRACT, nil); err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.moveCmdAt = time.Now()
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModifyElement modifies element length (command 12)
|
// ModifyElement modifies element length (command 12)
|
||||||
|
|||||||
@@ -230,11 +230,21 @@ func main() {
|
|||||||
bootLog("WebView2 profile: %q", webviewDataPath())
|
bootLog("WebView2 profile: %q", webviewDataPath())
|
||||||
bootLog("entering wails.Run (window %dx%d, state %v)", width, height, startState)
|
bootLog("entering wails.Run (window %dx%d, state %v)", width, height, startState)
|
||||||
err := wails.Run(&options.App{
|
err := wails.Run(&options.App{
|
||||||
Title: "OpsLog",
|
Title: "OpsLog",
|
||||||
Width: width,
|
Width: width,
|
||||||
Height: height,
|
Height: height,
|
||||||
MinWidth: 1100,
|
// No minimum. Wails treats 0 as "no constraint" (winc only fills
|
||||||
MinHeight: 700,
|
// PtMinTrackSize when the value is above zero), so Windows applies its
|
||||||
|
// own floor — about the width of the caption buttons — and the operator
|
||||||
|
// decides the rest.
|
||||||
|
//
|
||||||
|
// It was 1100x700, which is a fair guess at where the layout stops being
|
||||||
|
// comfortable and no business of ours to enforce: a second screen used as
|
||||||
|
// a narrow strip, a window parked beside a decoder, a small laptop — all
|
||||||
|
// of them ran into a wall with nothing to show for it. The panels already
|
||||||
|
// scroll and collapse.
|
||||||
|
MinWidth: 0,
|
||||||
|
MinHeight: 0,
|
||||||
WindowStartState: startState,
|
WindowStartState: startState,
|
||||||
// No OS title bar: it was a dead 32-pixel band above a window that already
|
// No OS title bar: it was a dead 32-pixel band above a window that already
|
||||||
// has its own title strip. The app header takes over — it carries the drag
|
// has its own title strip. The app header takes over — it carries the drag
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// relaunchCmd builds the command that starts OpsLog again — after an update, or
|
||||||
|
// after a database switch.
|
||||||
|
//
|
||||||
|
// It exists to hold one fact in one place: a relaunch of OPSLOG ITSELF must not
|
||||||
|
// suppress the new process's window.
|
||||||
|
//
|
||||||
|
// The auto-update relaunch used to go through hideConsole, which sets
|
||||||
|
// SysProcAttr{HideWindow: true}. On Windows that puts SW_HIDE into the
|
||||||
|
// STARTUPINFO handed to CreateProcess, and Windows applies it to the first
|
||||||
|
// top-level window the new process shows. So the updated OpsLog started
|
||||||
|
// perfectly, took the single-instance mutex, connected the rig — and never
|
||||||
|
// became visible. Reported by two operators on 0.27.23 as "it goes to reload
|
||||||
|
// and just fails to load": a process in the task manager, no window, killing it
|
||||||
|
// and starting it by hand working every time.
|
||||||
|
//
|
||||||
|
// It arrived with the removal of the PowerShell helper. PowerShell's
|
||||||
|
// Start-Process launched the exe with a normal show, and the direct
|
||||||
|
// exec.Command that replaced it borrowed hideConsole from the console tools
|
||||||
|
// beside it — where hiding a console window is exactly right, and where every
|
||||||
|
// other caller still belongs. Two self-relaunches then differed by that one
|
||||||
|
// line, and only the hidden one was ever reported broken.
|
||||||
|
//
|
||||||
|
// So: no SysProcAttr at all, which is what RestartApp already did and why the
|
||||||
|
// database-switch relaunch never showed the fault. There is no console to
|
||||||
|
// suppress either way — OpsLog is linked for the Windows GUI subsystem.
|
||||||
|
func relaunchCmd(exe string, args ...string) *exec.Cmd {
|
||||||
|
cmd := exec.Command(exe, args...)
|
||||||
|
cmd.Dir = filepath.Dir(exe)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A relaunch of OpsLog itself must not suppress the new process's window.
|
||||||
|
//
|
||||||
|
// SysProcAttr is where that damage was done: hideConsole sets HideWindow, which
|
||||||
|
// on Windows becomes SW_HIDE in the STARTUPINFO, and Windows applies it to the
|
||||||
|
// first top-level window the new process shows. The updated OpsLog started,
|
||||||
|
// took the single-instance mutex and connected the rig — invisibly. Two
|
||||||
|
// operators on 0.27.23 reported it as "it goes to reload and just fails to
|
||||||
|
// load": a process in the task manager, no window, and killing it then starting
|
||||||
|
// it by hand working every time.
|
||||||
|
//
|
||||||
|
// Nil, not "some specific value": there is nothing a self-relaunch needs from
|
||||||
|
// STARTUPINFO, and anything set there is a window flag waiting to be wrong.
|
||||||
|
func TestRelaunchCmdDoesNotTouchTheWindow(t *testing.T) {
|
||||||
|
cmd := relaunchCmd(filepath.Join("C:", "OpsLog", "OpsLog.exe"), "--post-update", "--wait-pid", "1234")
|
||||||
|
if cmd.SysProcAttr != nil {
|
||||||
|
t.Errorf("relaunchCmd set SysProcAttr = %+v; a self-relaunch must leave the window alone", cmd.SysProcAttr)
|
||||||
|
}
|
||||||
|
if len(cmd.Args) != 4 || cmd.Args[1] != "--post-update" || cmd.Args[3] != "1234" {
|
||||||
|
t.Errorf("args = %v, want the exe plus the three passed through", cmd.Args)
|
||||||
|
}
|
||||||
|
// The working directory matters: the new instance keeps its data folder
|
||||||
|
// beside the executable, and inheriting the old process's cwd would look for
|
||||||
|
// it somewhere else entirely.
|
||||||
|
if cmd.Dir != filepath.Join("C:", "OpsLog") {
|
||||||
|
t.Errorf("Dir = %q, want the executable's folder", cmd.Dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hideConsole is right for the console tools and wrong for OpsLog, and the two
|
||||||
|
// live a few lines apart. This is the guard that stops the update path
|
||||||
|
// borrowing it again — which is how it broke the first time, when the
|
||||||
|
// PowerShell helper was replaced by a direct exec.Command beside them.
|
||||||
|
func TestUpdateRelaunchDoesNotHideTheWindow(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("update.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read update.go: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(src), "hideConsole(") {
|
||||||
|
t.Error("update.go calls hideConsole — a relaunch of OpsLog must not hide its window (see relaunch.go)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(src), "relaunchCmd(exe,") {
|
||||||
|
t.Error("update.go no longer relaunches through relaunchCmd, where that rule is written down")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,3 +67,28 @@ func TestOnSomeMonitorTrustsSavedPositionWhenBoundsUnknown(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The window has no minimum size: an operator who wants OpsLog as a narrow
|
||||||
|
// strip beside a decoder gets to have one. Zero is how Wails says "do not
|
||||||
|
// constrain" — winc only fills PtMinTrackSize when the value is above zero — so
|
||||||
|
// Windows applies its own floor and nothing here adds to it.
|
||||||
|
func TestTheWindowHasNoMinimumSize(t *testing.T) {
|
||||||
|
if normalMinW != 0 || normalMinH != 0 {
|
||||||
|
t.Errorf("normalMin is %dx%d — anything but 0x0 is a wall the operator hits", normalMinW, normalMinH)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sane floor is a different thing from a minimum: it decides when a SAVED
|
||||||
|
// geometry is corrupt rather than chosen. It has to stay small enough not to
|
||||||
|
// second-guess a deliberately tiny window, and large enough that what is
|
||||||
|
// restored can be grabbed and resized — a window reopened at 0x0 is the one
|
||||||
|
// state there is no way back from.
|
||||||
|
func TestSavedGeometryFloorIsSmallButGrabbable(t *testing.T) {
|
||||||
|
if windowSaneW <= 0 || windowSaneH <= 0 {
|
||||||
|
t.Fatal("a zero floor would restore a window that cannot be grabbed")
|
||||||
|
}
|
||||||
|
if windowSaneW > 400 || windowSaneH > 300 {
|
||||||
|
t.Errorf("the floor is %dx%d — big enough to reject a window somebody chose",
|
||||||
|
windowSaneW, windowSaneH)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+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.22"
|
appVersion = "0.27.24"
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -228,16 +227,21 @@ func (a *App) DownloadAndApplyUpdate(url string) error {
|
|||||||
// side now. The new instance is told OUR pid and waits for this process to
|
// side now. The new instance is told OUR pid and waits for this process to
|
||||||
// end before taking the single-instance mutex.
|
// end before taking the single-instance mutex.
|
||||||
//
|
//
|
||||||
// Waiting on the mutex alone was not enough, and that is the bug this line
|
// Waiting on the mutex alone was not enough: shutting down is allowed thirty
|
||||||
// fixes: shutting down is allowed thirty seconds here (armExitWatchdog),
|
// seconds here (armExitWatchdog), because it closes a remote logbook, a CAT
|
||||||
// because it closes a remote logbook, a CAT session and sometimes a backup,
|
// session and sometimes a backup, while the new instance was only patient
|
||||||
// while the new instance was only patient for twenty. On a station where
|
// for twenty. On a station where that ran long, the new process gave up and
|
||||||
// that ran long, the new process gave up and exited — leaving the old one
|
// exited — leaving the old one still running and no new window, which is
|
||||||
// still running and no new window, which is precisely what the PowerShell
|
// precisely what the PowerShell helper never did: it waited for the pid,
|
||||||
// helper never did: it waited for the pid, however long it took.
|
// however long it took.
|
||||||
cmd := exec.Command(exe, "--post-update", "--wait-pid", strconv.Itoa(os.Getpid()))
|
//
|
||||||
cmd.Dir = dir
|
// And relaunchCmd rather than a command built here, because the OTHER half
|
||||||
hideConsole(cmd)
|
// of the same report was this line calling hideConsole: SW_HIDE in the
|
||||||
|
// STARTUPINFO, which Windows applies to the new process's first window. The
|
||||||
|
// updated OpsLog started, took the mutex, and stayed invisible. See
|
||||||
|
// relaunch.go — the fact belongs in one place, since two self-relaunches
|
||||||
|
// differing by one line is how only one of them was broken.
|
||||||
|
cmd := relaunchCmd(exe, "--post-update", "--wait-pid", strconv.Itoa(os.Getpid()))
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ one of these.
|
|||||||
| **Rotator Genius** (4O3A) | Az | TCP, port 9006 | Native. *Rotator #* picks which of the two the box drives; *Two rotors* adds the second as its own rotor. |
|
| **Rotator Genius** (4O3A) | Az | TCP, port 9006 | Native. *Rotator #* picks which of the two the box drives; *Two rotors* adds the second as its own rotor. |
|
||||||
| **GS-232 azimuth** (microHAM ARCO, ERC) | Az | LAN or USB | Set the controller's CONTROL PROTOCOL to *Yaesu GS-232A*. An **ERC** must be in GS-232 emulation, **not** Hy-Gain DCU-1. |
|
| **GS-232 azimuth** (microHAM ARCO, ERC) | Az | LAN or USB | Set the controller's CONTROL PROTOCOL to *Yaesu GS-232A*. An **ERC** must be in GS-232 emulation, **not** Hy-Gain DCU-1. |
|
||||||
| **ERC-M by DF9GR** | Az + El | USB COM or LAN | The az/el interface for a **Yaesu G-5500** and its relatives. GS-232 emulation, 19200 baud out of the box. Pick this rather than the GS-232 azimuth entry: it is what tells OpsLog the mast has an elevation motor. |
|
| **ERC-M by DF9GR** | Az + El | USB COM or LAN | The az/el interface for a **Yaesu G-5500** and its relatives. GS-232 emulation, 19200 baud out of the box. Pick this rather than the GS-232 azimuth entry: it is what tells OpsLog the mast has an elevation motor. |
|
||||||
| **Hy-Gain DCU-1** | Az | COM port or serial-over-IP | RotorCard DXA, Idiom Press Rotor-EZ, Green Heron. A DCU-1 is 4800 baud; others may differ — match the controller. |
|
| **Green Heron RT-21 / Hy-Gain DCU-1** | Az | COM port, or TCP | Also Idiom Press Rotor-EZ and RotorCard DXA. Set the RT-21 to DCU-1 / Rotor-EZ — on GS-232 use the GS-232 row instead. With the Ethernet option, TCP reaches it directly; no serial-over-IP bridge. A DCU-1 is 4800 baud; others may differ — match the controller. |
|
||||||
| **SPID / AlfaSpid** | Az + El (Rot2Prog) | COM port | Native, so PstRotator is not needed in between. See below. |
|
| **SPID / AlfaSpid** | Az + El (Rot2Prog) | COM port | Native, so PstRotator is not needed in between. See below. |
|
||||||
| **EasyComm II** | Az + El | COM port or TCP | What SatPC32, Gpredict, Hamlib and K3NG firmware speak — the usual choice for a home-built az/el controller. |
|
| **EasyComm II** | Az + El | COM port or TCP | What SatPC32, Gpredict, Hamlib and K3NG firmware speak — the usual choice for a home-built az/el controller. |
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user