Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e67ddcea4 | ||
|
|
fd93036f86 | ||
|
|
031fcbd543 | ||
|
|
72696a5c0c | ||
|
|
d0d29659cb | ||
|
|
6f9b996db8 | ||
|
|
9dcab0568b | ||
|
|
a8e870e098 | ||
|
|
7e6e1335e3 | ||
|
|
3fe14c2c77 | ||
|
|
e9816241e8 |
@@ -2376,7 +2376,10 @@ func (a *App) restoreWindowPosition() {
|
||||
applog.Printf("window: maximised with corner 0,0 — nothing to restore")
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -2387,7 +2390,7 @@ func (a *App) restoreWindowPosition() {
|
||||
applog.Printf("window: re-maximised at the saved corner — now at %d,%d", gx, gy)
|
||||
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
|
||||
}
|
||||
// The SIZE was sanity-checked above but the POSITION never was, and that is
|
||||
@@ -3103,8 +3106,7 @@ func (a *App) RestartApp() error {
|
||||
// --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,
|
||||
// and on a remote database that takes as long as it takes.
|
||||
cmd := exec.Command(exe, "--relaunch", "--wait-pid", strconv.Itoa(os.Getpid()))
|
||||
cmd.Dir = filepath.Dir(exe)
|
||||
cmd := relaunchCmd(exe, "--relaunch", "--wait-pid", strconv.Itoa(os.Getpid()))
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("relaunch OpsLog: %w", err)
|
||||
}
|
||||
@@ -7674,7 +7676,14 @@ func (a *App) GridSquareChoices() (GridSquareChoices, error) {
|
||||
const (
|
||||
compactW, compactH = 1240, 158
|
||||
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.
|
||||
maxW, maxH = 8000, 6000
|
||||
)
|
||||
@@ -7694,7 +7703,9 @@ func (a *App) SetCompactMode(on bool) {
|
||||
}
|
||||
a.preCompactW, a.preCompactH = wruntime.WindowGetSize(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
|
||||
if on {
|
||||
@@ -17176,7 +17187,7 @@ var rotatorTypes = []RotatorTypeInfo{
|
||||
{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: "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: "easycomm", Label: "EasyComm II (SatPC32, Gpredict, K3NG…)", Elevation: true, Network: true, Serial: true, DefaultPort: 4533, DefaultBaud: 9600},
|
||||
}
|
||||
|
||||
+187
-8
@@ -20,6 +20,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -45,6 +46,14 @@ const satTickEvery = time.Second
|
||||
// smallest deliberate move anybody makes hunting a station on a transponder.
|
||||
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
|
||||
// into a nominal one. The same constant internal/sat corrects with.
|
||||
const satLightKmS = 299792.458
|
||||
@@ -60,6 +69,19 @@ type satTracker struct {
|
||||
nominalDown int64
|
||||
lastDown int64 // what was last sent to the radio
|
||||
lastUp int64
|
||||
// upTrim is what the operator has added to the computed uplink, in hertz.
|
||||
//
|
||||
// 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
|
||||
|
||||
@@ -101,6 +123,10 @@ type SatTrackStatus struct {
|
||||
// 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"`
|
||||
|
||||
@@ -141,6 +167,14 @@ func (a *App) StartSatelliteTracking(name string, transponder int) error {
|
||||
wake: make(chan struct{}, 1),
|
||||
}
|
||||
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
|
||||
// left alone, so it gets one command rather than a loop.
|
||||
@@ -338,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)
|
||||
down, up := sh.DownHz, sh.UpHz
|
||||
|
||||
@@ -356,6 +418,7 @@ func (a *App) satTrackStep(t *satTracker) {
|
||||
st.DownHz, st.UpHz = down, up
|
||||
st.Az, st.El, st.Visible = pos.Az, pos.El, visible
|
||||
st.RangeKm, st.AltKm = pos.RangeKm, pos.AltKm
|
||||
st.UpTrimHz = upTrim
|
||||
t.status = st
|
||||
t.mu.Unlock()
|
||||
|
||||
@@ -551,6 +614,114 @@ func (a *App) satReceiveHz() (int64, error) {
|
||||
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 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
@@ -697,21 +868,25 @@ func (a *App) applySatRadio(tp sat.Transponder) {
|
||||
if err != nil || len(m) == 0 {
|
||||
return
|
||||
}
|
||||
// The downlink is received, so it takes that band's RX antenna; the uplink
|
||||
// is transmitted, so it takes that band's TX antenna.
|
||||
// Each slice gets BOTH antennas of ITS OWN band. Only two of the four were
|
||||
// set — the downlink's receive and the uplink's transmit — which left the
|
||||
// downlink slice with no txant. It never keys, so nothing was wrong on the
|
||||
// air, but a half-configured slice uses whatever antenna it was last left
|
||||
// on the moment transmit focus moves to it.
|
||||
downBand, upBand := flexBandAntKey(tp.DownLo), flexBandAntKey(tp.UpLo)
|
||||
rxAnt := m[downBand].RX
|
||||
txAnt := m[upBand].TX
|
||||
if strings.TrimSpace(rxAnt) == "" && strings.TrimSpace(txAnt) == "" {
|
||||
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
|
||||
}
|
||||
applog.Printf("sat: antennas rx=%q (%s) tx=%q (%s)", rxAnt, downBand, txAnt, upBand)
|
||||
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 {
|
||||
return fc.SatAntennas(rxAnt, txAnt)
|
||||
return fc.SatAntennas(down.RX, down.TX, up.RX, up.TX)
|
||||
}); err != nil {
|
||||
// Not fatal: a rig that is not a Flex has no such thing, and a pass with
|
||||
// the wrong antenna is still a pass.
|
||||
@@ -784,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
|
||||
// satellite to drift a step away from where the old one happened to be.
|
||||
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.Error = ""
|
||||
t.mu.Unlock()
|
||||
|
||||
@@ -172,3 +172,41 @@ func TestBandOrderIsByFrequency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
+63
-36
@@ -1,46 +1,73 @@
|
||||
[
|
||||
{
|
||||
"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 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.",
|
||||
"The satellite ground track no longer draws a straight line across the map when it crosses the antimeridian.",
|
||||
"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."
|
||||
],
|
||||
"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 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.",
|
||||
"Le tracé au sol du satellite ne trace plus une ligne droite en travers de la carte lorsqu’il franchit l’antiméridien.",
|
||||
"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."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.27.23",
|
||||
"date": "",
|
||||
"en": [
|
||||
"SteppIR: a Calibrate button, beside Retract. It drives every element to its end stop so the controller re-learns where zero is — the cure for an antenna that tunes to the wrong length after a power cut mid-move, after the elements were pushed by hand, or after a motor slipped. It takes minutes and the antenna is unusable until it finishes, so it asks first. Ultrabeam controllers have no such command and say so rather than pretending. Retracting the elements was already there and now explains itself: it is the storage position, and the next tune brings them back out on its own.",
|
||||
"On the rotor dial, the beam no longer whips a full turn round the compass when the antenna crosses north. A rotator at 020° turned anticlockwise reports 020, 010, 000, 359, 358 … and the animation travelled from 20° to 359° the long way — a complete revolution on screen while the mast moved forty degrees the other way. The beam now follows an accumulated angle, so what is drawn is the way the antenna is actually turning. Both lobes of a bidirectional antenna are handled separately, since they cross north at different moments.",
|
||||
"The rotor widget stops claiming the antenna is turning every time the wind moves it. A threshold alone could not tell the difference — a gust pushes a beam well past four degrees and back, and each excursion counted, so Stop lit and went out all evening on an antenna that had not turned. What separates a rotation from the weather is not how far the reading moves but which way: a rotor under power advances, gust after gust reverses. A step now counts only when the previous one went the same way, so movement is announced one poll later on a mast that takes half a minute to cross a pass, and never at all on a windy afternoon.",
|
||||
"LilacSat-2 gets its FM transponder — 144.350 up, 437.200 down — and its proper name, LO-90. It was shipped with nothing but an APRS digipeater, because the transponder database marks that transponder inactive: it runs to an announced schedule rather than continuously, which from a database looks exactly like a dead one. The label says \"(scheduled)\" so nobody wonders why it is quiet. The generator now also lists the satellites it refused on that ground and kept nothing else for, so the next regeneration is not silent about them.",
|
||||
"A frequency OpsLog got wrong can now be corrected on a station that already has the satellite file. Until now the file was copied out on the first run and was the operator's from then on, so a mistake we shipped — LilacSat-2 with no FM transponder — had become their data and could never be mended. The plan now adds what is missing, brings up to date what was never edited, and leaves alone what was: an entry you corrected by hand outranks anything shipped, and the log names the ones it stood down on. The first run after this cannot tell the two apart, so it takes the shipped plan and copies your file to satellites.json.bak first — after that your edits are recognised precisely and survive every release.",
|
||||
"The per-band antennas are applied to the satellite slices at last. The map is keyed by the band name in capitals, the tracker looked its two bands up in lower case, and so it read no antenna at all out of a table the operator had filled in — a 70 cm downlink stayed on the 2 m transverter. The log now names the band and the antenna it resolved, so a setting never made can be told apart from a lookup that missed.",
|
||||
"Tracking now shows where the satellite is beside where the antenna points: azimuth, elevation, distance and altitude, in the same strip as the two frequencies. The elevation goes dim below the horizon, so a bird still being followed on its way up cannot be read as workable.",
|
||||
"The two satellite lists in the settings are sorted by name, numerically — AO-27, AO-91, AO-123. The left one came in the order the frequency file happens to be written and the right one in the order they were clicked, so finding one bird among sixteen meant reading all sixteen. A satellite renamed on getting its OSCAR number, as LILACSAT-2 became LO-90, is also recognised in a followed list that still holds the old name instead of being listed as having no elements.",
|
||||
"The grid-square map filters by one mode, by band and by satellite. The FTx button is gone: it grouped FT8, FT4 and FT2 together, while the question the map answers is where a single mode has been heard. The named modes are read from the log itself, so each one is offered only if there is something behind it — and FT2 appears for whoever is already using it, with nothing to change here the day it becomes a registered mode. The band list is the station's own plus anything worked outside it, and the satellite list only appears once a square has been worked through a bird.",
|
||||
"Saving the satellite settings now refreshes the Satellites tab. It read the followed list once when it was opened, so un-following a satellite left it in the pass table and in the dropdown until the tab was reopened — which looks exactly like a setting that failed to save. The lowest pass, the window and the locator are picked up the same way, and a satellite that is no longer followed hands the selection to the first one that is.",
|
||||
"The FT map takes one colour for every decode, next to the basemap buttons. The band palette is calibrated against a plain map — 60m navy and 70cm olive all but vanish over the satellite imagery — and it is only useful to somebody watching several bands at once, which most operators are not. Leave it empty for the colour per band, as before. The legend then keeps the band names and drops the swatches, since which bands are up is still worth knowing.",
|
||||
"\"Later\" on an update notice now asks for how long: 1, 4, 12 or 24 hours. It only hid the card before, and the check behind it runs every five minutes, so the same notice came back four times an hour all evening for a version already declined. The delay is recorded against that version, so a newer release is different news and appears at once — a deferral can never bury an update. The cross hides it for an hour, and opening About still answers whatever was deferred, because asking is asking.",
|
||||
"Stop stays lit for the whole rotation instead of blinking. Keeping the wind out took a four-degree step, but four degrees is four seconds of travel on a real rotor, so between two of them nothing confirmed the mast was still turning and the button went dark and came back the whole way round. Getting in stays strict — two steps the same way, which weather never gives — while staying in now needs only continued progress in that direction: one degree the same way is not a gust when the rotor is already under power.",
|
||||
"The frequency plan is checked against AMSAT's live FM and image lists. AO-123 gets the 67.0 Hz tone its uplink needs, SO-50 says in its label that 74.4 Hz arms the timer before 67.0 can key it, and eight SSTV satellites are added — HC1PX, RS18S, RS27S, RS38S, RS40S, RS57S, RS58S and RS83S — as receive-only entries, with SSTV downlinks added to SONATE-2 and QMR-KWT-2. Every FM bird's uplink, downlink and tone now agrees with the published table.",
|
||||
"The FT map can now show who hears YOU. \"Who hears me\" draws, in dashed cyan, every station reporting your own transmissions to PSK Reporter, alongside the solid arcs of what you decode — the direction you cannot see from your own receiver, and on FT8 the one that says whether calling is worth the cycle. It costs almost nothing: your callsign goes in the topic's transmit level, so the broker sends nothing else, and it works with the band-opening watch switched off. Off until asked for, and each station appears once with its freshest report inside a fifteen-minute window.",
|
||||
"\"Who hears me\" is marks alone, no arcs. With a few dozen receivers reporting, a line to each made a fan out of one square that buried the decode arcs beside it — and the lines said nothing, since every one of these paths starts in the same place. The mark is a hollow diamond, fading with age: shape rather than colour carries the difference, the decode dots being filled circles and the arcs having used fourteen colours already.",
|
||||
"The FT map's controls stop covering it. The basemap buttons had crept over Leaflet's zoom control, so the minus button took clicks meant for Street, and the row had grown with the colour picker and the reverse layer until it reached the middle of a world map — covering the Atlantic to save a top-right corner that was empty the whole time. Basemaps stay on the left, clear of the zoom; the colour and \"Who hears me\" move to the right.",
|
||||
"The who-hears-me diamonds are filled and take a colour of their own, chosen beside the switch that turns them on. Filled with a hairline white edge, the trick the home marker already uses: a ring is an outline drawn over whatever is beneath it, and eight pixels of it over the satellite imagery was barely there. The colour is separate from the decode one because a single control would have forced both layers into one colour — the very distinction it took a shape to make."
|
||||
"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. Il amène chaque élément en butée pour que le contrôleur retrouve son zéro — le remède à une antenne qui s’accorde à la mauvaise longueur après une coupure en pleine course, après avoir poussé les éléments à la main, ou après un moteur qui a glissé. Cela prend plusieurs minutes et l’antenne est inutilisable jusqu’à la fin : une confirmation est donc demandée. Les contrôleurs Ultrabeam n’ont pas cette commande et le disent, au lieu de faire semblant. Rétracter les éléments existait déjà et s’explique maintenant : c’est la position de rangement, et le prochain accord les fait ressortir tout seuls.",
|
||||
"Sur le cadran du rotor, le faisceau ne fait plus un tour complet de la boussole quand l’antenne franchit le nord. Un rotor à 020° tourné dans le sens antihoraire annonce 020, 010, 000, 359, 358… et l’animation allait de 20° à 359° par le chemin long — une révolution complète à l’écran pendant que le pylône bougeait de quarante degrés dans l’autre sens. Le faisceau suit désormais un angle cumulé : ce qui est dessiné est le mouvement réel de l’antenne. Les deux lobes d’une antenne bidirectionnelle sont traités séparément, puisqu’ils ne franchissent pas le nord au même moment.",
|
||||
"Le widget rotor n’annonce plus une antenne en rotation chaque fois que le vent la bouge. Un simple seuil ne pouvait pas faire la différence — une rafale pousse une beam bien au-delà de quatre degrés puis la ramène, et chaque écart comptait : Stop s’allumait et s’éteignait toute la soirée sur une antenne qui n’avait pas tourné. Ce qui distingue une rotation de la météo n’est pas l’amplitude mais le sens : un rotor sous tension avance, une rafale revient. Un écart ne compte donc que si le précédent allait dans le même sens — la rotation est annoncée un relevé plus tard sur un pylône qui met trente secondes à traverser, et plus du tout par vent fort.",
|
||||
"LilacSat-2 récupère son transpondeur FM — 144,350 en montée, 437,200 en descente — et son vrai nom, LO-90. Il était livré avec un simple digipeater APRS, parce que la base de transpondeurs marque ce transpondeur comme inactif : il fonctionne selon un calendrier annoncé plutôt qu’en continu, ce qui, vu d’une base de données, ressemble exactement à un transpondeur mort. Le libellé indique « (scheduled) » pour que personne ne se demande pourquoi il est muet. Le générateur liste désormais aussi les satellites qu’il a écartés pour cette raison sans rien garder d’autre, afin que la prochaine régénération ne les passe plus sous silence.",
|
||||
"Une fréquence qu’OpsLog avait fausse peut désormais être corrigée sur une station qui possède déjà le fichier satellites. Jusqu’ici ce fichier était copié au premier lancement puis appartenait à l’opérateur, si bien qu’une erreur de notre part — LilacSat-2 sans transpondeur FM — devenait sa donnée et ne pouvait plus être réparée. Le plan ajoute maintenant ce qui manque, met à jour ce qui n’a jamais été modifié, et laisse intact ce qui l’a été : une entrée corrigée à la main prime sur tout ce qui est livré, et le journal nomme celles devant lesquelles il s’est effacé. Le premier lancement après ce changement ne peut pas distinguer les deux : il prend donc le plan livré et copie d’abord votre fichier en satellites.json.bak — ensuite vos modifications sont reconnues précisément et survivent à chaque version.",
|
||||
"Les antennes par bande sont enfin appliquées aux slices satellite. La table est indexée par le nom de bande en majuscules, le suivi cherchait ses deux bandes en minuscules, et il ne lisait donc aucune antenne dans un tableau que l’opérateur avait rempli — une descente 70 cm restait sur le transverter 2 m. Le journal indique désormais la bande et l’antenne retenues, pour distinguer un réglage jamais fait d’une recherche qui a échoué.",
|
||||
"Le suivi affiche désormais où est le satellite à côté de là où pointe l’antenne : azimut, élévation, distance et altitude, dans le même bandeau que les deux fréquences. L’élévation s’estompe sous l’horizon, pour qu’un satellite encore suivi avant son lever ne soit pas pris pour un satellite travaillable.",
|
||||
"Les deux listes de satellites des réglages sont triées par nom, en tenant compte des nombres — AO-27, AO-91, AO-123. Celle de gauche suivait l’ordre du fichier de fréquences et celle de droite l’ordre des clics : retrouver un satellite parmi seize obligeait à lire les seize. Un satellite renommé lors de l’attribution de son numéro OSCAR, comme LILACSAT-2 devenu LO-90, est aussi reconnu dans une liste de suivis qui porte encore l’ancien nom, au lieu d’y apparaître sans éléments.",
|
||||
"La carte des carrés locator se filtre par mode précis, par bande et par satellite. Le bouton FTx disparaît : il regroupait FT8, FT4 et FT2, alors que la question à laquelle répond cette carte est de savoir où un seul mode a été entendu. Les modes proposés sont lus dans le journal, donc chacun n’apparaît que s’il y a quelque chose derrière — et FT2 est proposé à qui l’utilise déjà, sans rien à changer ici le jour où il deviendra un mode officiel. La liste des bandes est celle de la station, complétée par ce qui a été travaillé en dehors, et la liste des satellites n’apparaît qu’une fois un carré travaillé par satellite.",
|
||||
"Enregistrer les réglages satellite rafraîchit désormais l’onglet Satellites. Il lisait la liste des suivis une seule fois à son ouverture : retirer un satellite le laissait dans le tableau des passes et dans la liste déroulante jusqu’à la réouverture de l’onglet — ce qui ressemble exactement à un réglage qui ne s’est pas enregistré. L’élévation minimale, la fenêtre de prévision et le locator sont pris en compte de la même façon, et un satellite qui n’est plus suivi passe la sélection au premier qui l’est.",
|
||||
"La carte FT accepte une couleur unique pour tous les décodages, à côté des boutons de fond de carte. La palette par bande est calibrée sur une carte claire — le bleu marine du 60 m et l’olive du 70 cm disparaissent presque sur l’imagerie satellite — et elle ne sert qu’à qui surveille plusieurs bandes à la fois, ce qui n’est pas le cas de la plupart. Laissez-la vide pour retrouver la couleur par bande. La légende conserve alors les noms de bandes et abandonne les pastilles, car savoir quelles bandes sont ouvertes reste utile.",
|
||||
"« Plus tard » sur une notification de mise à jour demande désormais combien de temps : 1, 4, 12 ou 24 heures. Le bouton ne faisait que masquer la fenêtre, et la vérification derrière tourne toutes les cinq minutes : la même notification revenait quatre fois par heure toute la soirée pour une version déjà refusée. Le report est enregistré pour cette version précise, donc une version plus récente est une autre nouvelle et s’affiche immédiatement — un report ne peut jamais enterrer une mise à jour. La croix masque pendant une heure, et ouvrir « À propos » répond toujours, même sur une version reportée : demander, c’est demander.",
|
||||
"Le bouton Stop reste allumé pendant toute la rotation au lieu de clignoter. Écarter le vent demandait un écart de quatre degrés, mais quatre degrés représentent quatre secondes de course sur un vrai rotor : entre deux d’entre eux, plus rien ne confirmait que le pylône tournait encore et le bouton s’éteignait puis se rallumait tout du long. L’entrée reste stricte — deux écarts dans le même sens, ce que la météo ne produit jamais — tandis que le maintien ne demande plus qu’une progression continue dans ce sens : un degré du même côté n’est pas une rafale quand le rotor est déjà sous tension.",
|
||||
"Le plan de fréquences est vérifié contre les listes FM et image d’AMSAT. AO-123 reçoit le ton de 67,0 Hz qu’exige sa montée, SO-50 indique dans son libellé que 74,4 Hz arme le minuteur avant que 67,0 puisse l’ouvrir, et huit satellites SSTV sont ajoutés — HC1PX, RS18S, RS27S, RS38S, RS40S, RS57S, RS58S et RS83S — en réception seule, avec une descente SSTV ajoutée à SONATE-2 et QMR-KWT-2. La montée, la descente et le ton de chaque satellite FM correspondent désormais à la table publiée.",
|
||||
"La carte FT peut désormais montrer qui VOUS entend. « Qui m’entend » trace en pointillés cyan chaque station qui rapporte vos propres émissions à PSK Reporter, à côté des arcs pleins de ce que vous décodez — la direction qu’on ne peut pas voir depuis son propre récepteur, et en FT8 celle qui dit si appeler vaut le cycle. Le coût est quasi nul : votre indicatif est placé dans le niveau émission du topic, donc le broker n’envoie rien d’autre, et cela fonctionne même avec la veille d’ouverture de bande éteinte. Éteint par défaut, et chaque station apparaît une fois avec son rapport le plus récent dans une fenêtre de quinze minutes.",
|
||||
"« Qui m’entend » n’affiche plus que des marques, sans arcs. Avec quelques dizaines de récepteurs, un trait vers chacun formait un éventail depuis un seul carré qui enterrait les arcs de décodage voisins — et ces traits ne disaient rien, puisque tous ces chemins partent du même endroit. La marque est un losange creux qui s’estompe avec l’âge : c’est la forme, et non la couleur, qui porte la différence, les points de décodage étant des cercles pleins et les arcs utilisant déjà quatorze couleurs.",
|
||||
"Les contrôles de la carte FT cessent de la recouvrir. Les boutons de fond de carte avaient débordé sur le zoom de Leaflet — le bouton moins prenait les clics destinés à Street — et la rangée s’était allongée avec le sélecteur de couleur et la couche inverse jusqu’à atteindre le milieu d’une carte du monde, masquant l’Atlantique pour épargner un coin haut-droit resté vide tout ce temps. Les fonds de carte restent à gauche, dégagés du zoom ; la couleur et « Qui m’entend » passent à droite.",
|
||||
"Les losanges de « qui m’entend » sont pleins et prennent leur propre couleur, choisie à côté de l’interrupteur qui les allume. Pleins avec un liseré blanc d’un pixel, le procédé qu’utilise déjà le marqueur de la station : un cercle creux n’est qu’un contour tracé sur ce qui se trouve dessous, et huit pixels de contour sur l’imagerie satellite se voyaient à peine. La couleur est distincte de celle des décodages, car un seul réglage aurait forcé les deux couches dans une même couleur — précisément la distinction qu’il a fallu une forme pour établir."
|
||||
"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."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6644,6 +6644,9 @@ export default function App() {
|
||||
// Only while CAT is actually connected: an empty band means "nothing to
|
||||
// compare with", never "the rig is on no 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}
|
||||
myGrid={station.my_grid}
|
||||
// A DOUBLE click answers the station: it hands the decode back to
|
||||
|
||||
@@ -102,6 +102,9 @@ interface Props {
|
||||
// The band the RIG is on, when CAT is connected. Only ever compared with what
|
||||
// the decoder announces — see the drift warning.
|
||||
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;
|
||||
// A single click: take the station without transmitting — fill the entry, and
|
||||
// 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();
|
||||
// Column widths, dragged in the header and shared by every row. Persisted
|
||||
// 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
|
||||
// decoding a second receiver on another band, and a warning costs that setup
|
||||
// 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 bandDrift = !!rigBand && !!decoderBand
|
||||
&& rigBand.toLowerCase() !== decoderBand.toLowerCase();
|
||||
const onAir = (rigBands && rigBands.length ? rigBands : (rigBand ? [rigBand] : []))
|
||||
.map((b) => b.toLowerCase());
|
||||
const bandDrift = onAir.length > 0 && !!decoderBand
|
||||
&& !onAir.includes(decoderBand.toLowerCase());
|
||||
const driftInstance = decodes.length ? (decodes[decodes.length - 1].instance ?? '') : '';
|
||||
|
||||
const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen, Radar, 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 {
|
||||
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
|
||||
GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack,
|
||||
StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking, RetargetSatelliteTracking,
|
||||
SetSatUplinkTrim,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -58,6 +61,7 @@ type Track = {
|
||||
on: boolean; name: string; transponder: string; mode: string;
|
||||
nominal_down: number; nominal_up: number; down_hz: number; up_hz: number;
|
||||
az: number; el: number; visible: boolean; range_km: number; alt_km: number;
|
||||
up_trim_hz: number;
|
||||
radio: string; // "sat" | "downlink-only" | ""
|
||||
error: string;
|
||||
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_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720;
|
||||
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
|
||||
// actually tuned to.
|
||||
@@ -469,10 +507,45 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
const [sideShown, setSideShown] = useState(() => localStorage.getItem(SIDE_SHOWN_KEY) !== '0');
|
||||
useEffect(() => { writeUiPref(SIDE_W_KEY, String(Math.round(sideW))); }, [sideW]);
|
||||
useEffect(() => { writeUiPref(SIDE_SHOWN_KEY, sideShown ? '1' : '0'); }, [sideShown]);
|
||||
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
|
||||
// the container, and with the pointer captured — without the capture the map
|
||||
// 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) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
@@ -562,7 +635,10 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
if (track.length > 1) {
|
||||
const pts = splitAtAntimeridian(track.map((p) => [p.lat, p.lon] as [number, number]));
|
||||
L.polyline(pts as L.LatLngExpression[][], {
|
||||
color: 'var(--info)', weight: 1.2, opacity: 0.7, dashArray: '4 4', smoothFactor: 0,
|
||||
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);
|
||||
}
|
||||
const wanted = new Set(shown.map((b) => b.name));
|
||||
@@ -784,13 +860,18 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
>
|
||||
{Object.entries(BASEMAPS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||
</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
|
||||
variant="ghost" size="sm"
|
||||
className={cn('h-7 px-1.5', skyShown && 'text-success')}
|
||||
variant="ghost" size="sm" className="h-7 px-1.5"
|
||||
onClick={() => setSkyShown((v) => !v)}
|
||||
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>
|
||||
{/* 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
|
||||
@@ -807,80 +888,23 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
{err && <div className="px-2 text-[11px] text-danger shrink-0">{err}</div>}
|
||||
|
||||
<div className="flex gap-1 flex-1 min-h-0">
|
||||
{/* The map. isolate is load-bearing, not tidiness: Leaflet stacks its
|
||||
own panes and controls up to z-index 1000, which without a stacking
|
||||
context of their own float over Preferences and every dialog in the
|
||||
app — the map ends up on top of the very buttons that would close
|
||||
it. */}
|
||||
<div className="relative isolate z-0 flex-1 min-w-0 rounded-lg overflow-hidden border border-border">
|
||||
<div ref={divRef} className="h-full w-full" />
|
||||
</div>
|
||||
{/* WHERE IT IS: the sky plot and the position, in a column of their
|
||||
own to the left of the map.
|
||||
|
||||
{sideShown && (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
title={t('sat.sideWidthTip')}
|
||||
onPointerDown={startSideDrag}
|
||||
onDoubleClick={() => setSideW(SIDE_W_DEFAULT)}
|
||||
className="group relative shrink-0 w-2 cursor-col-resize flex items-center justify-center"
|
||||
>
|
||||
<span className="h-10 w-[3px] rounded-full bg-border group-hover:bg-primary transition-colors" />
|
||||
</div>
|
||||
)}
|
||||
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.
|
||||
|
||||
<div className={cn('shrink-0 flex flex-col gap-1 min-h-0', !sideShown && 'hidden')}
|
||||
style={{ width: sideW }}>
|
||||
{/* The pass. The first thing an operator looks at and the reason they
|
||||
sit down: how long have I got, and how high does it get. */}
|
||||
<div className={cn('rounded-lg border bg-card p-2',
|
||||
inPass ? 'border-success/60' : 'border-border')}>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-medium text-sm truncate">{bird?.name ?? '—'}</span>
|
||||
<ModeBadge mode={tp?.mode} className="self-center" />
|
||||
</div>
|
||||
|
||||
{bird?.geostationary ? (
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">{t('sat.geoHint')}</div>
|
||||
) : !pass?.has_pass ? (
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">{t('sat.noPassSoon')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-1 flex items-baseline gap-2">
|
||||
<span className={cn('text-[10px] uppercase tracking-wide',
|
||||
inPass ? 'text-success' : 'text-muted-foreground')}>
|
||||
{inPass ? t('sat.los') : t('sat.aos')}
|
||||
</span>
|
||||
<span className={cn('text-xl font-semibold tabular-nums leading-none',
|
||||
inPass && 'text-success')}>
|
||||
{fmtCountdown((inPass ? losMs : aosMs) - now)}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums ml-auto">
|
||||
{hhmmss(inPass ? pass.los : pass.aos)}Z
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Where in the pass we are. A bar because the useful question
|
||||
mid-pass is not the clock but "am I past the peak". */}
|
||||
<div className="mt-1.5 h-1 rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-full bg-success transition-[width] duration-1000 ease-linear"
|
||||
style={{ width: `${Math.round(progress * 100)}%` }} />
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
|
||||
<PassBit label={t('sat.rise')} value={`${hhmm(pass.aos)} ${compass(pass.aos_az)}`} />
|
||||
<PassBit label={t('sat.peak')} value={`${Math.round(pass.max_el)}° ${compass(pass.max_el_az)}`}
|
||||
strong={pass.max_el >= 30} />
|
||||
<PassBit label={t('sat.set')} value={`${hhmm(pass.los)} ${compass(pass.los_az)}`} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The sky, seen from underneath it: the centre is straight up, the
|
||||
rim is the horizon, north is at the top. One glance says whether
|
||||
the pass comes over the roof or along the treeline. */}
|
||||
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}
|
||||
@@ -890,10 +914,21 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
visible={!!tuning?.visible}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Where it is, right now. */}
|
||||
<div className="rounded-lg border border-border bg-card p-2">
|
||||
{/* 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) : ''} />
|
||||
@@ -940,26 +975,128 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
{!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
|
||||
own panes and controls up to z-index 1000, which without a stacking
|
||||
context of their own float over Preferences and every dialog in the
|
||||
app — the map ends up on top of the very buttons that would close
|
||||
it. */}
|
||||
<div className="relative isolate z-0 flex-1 min-w-0 rounded-lg overflow-hidden border border-border">
|
||||
<div ref={divRef} className="h-full w-full" />
|
||||
</div>
|
||||
|
||||
{/* What to tune. */}
|
||||
<div className="rounded-lg border border-border bg-card p-2">
|
||||
{/* The mode leads, because it decides everything below it. */}
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
{sideShown && (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
title={t('sat.sideWidthTip')}
|
||||
onPointerDown={startSideDrag}
|
||||
onDoubleClick={() => setSideW(SIDE_W_DEFAULT)}
|
||||
className="group relative shrink-0 w-2 cursor-col-resize flex items-center justify-center"
|
||||
>
|
||||
<span className="h-10 w-[3px] rounded-full bg-border group-hover:bg-primary transition-colors" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn('shrink-0 flex flex-col gap-1 min-h-0', !sideShown && 'hidden')}
|
||||
style={{ width: sideW }}>
|
||||
{/* The pass. The first thing an operator looks at and the reason they
|
||||
sit down: how long have I got, and how high does it get.
|
||||
|
||||
The countdown is repeated in the heading, so shutting this block
|
||||
still leaves the one number it exists for. */}
|
||||
<Section
|
||||
title={bird?.name ?? '—'}
|
||||
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 ? (
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">{t('sat.geoHint')}</div>
|
||||
) : !pass?.has_pass ? (
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">{t('sat.noPassSoon')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-1 flex items-baseline gap-2">
|
||||
<span className={cn('text-[10px] uppercase tracking-wide',
|
||||
inPass ? 'text-success' : 'text-muted-foreground')}>
|
||||
{inPass ? t('sat.los') : t('sat.aos')}
|
||||
</span>
|
||||
<span className={cn('text-xl font-semibold tabular-nums leading-none',
|
||||
inPass && 'text-success')}>
|
||||
{fmtCountdown((inPass ? losMs : aosMs) - now)}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums ml-auto">
|
||||
{hhmmss(inPass ? pass.los : pass.aos)}Z
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Where in the pass we are. A bar because the useful question
|
||||
mid-pass is not the clock but "am I past the peak". */}
|
||||
<div className="mt-1.5 h-1 rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-full bg-success transition-[width] duration-1000 ease-linear"
|
||||
style={{ width: `${Math.round(progress * 100)}%` }} />
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
|
||||
<PassBit label={t('sat.rise')} value={`${hhmm(pass.aos)} ${compass(pass.aos_az)}`} />
|
||||
{/* 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)}`}
|
||||
strong valueClass={elClass(pass.max_el)} />
|
||||
<PassBit label={t('sat.set')} value={`${hhmm(pass.los)} ${compass(pass.los_az)}`} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* What to tune. */}
|
||||
<Section
|
||||
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">
|
||||
<span className="text-[11px] text-muted-foreground truncate">{tp?.label ?? '—'}</span>
|
||||
<div className="flex-1" />
|
||||
{tp?.inverting && (
|
||||
<span className="shrink-0 text-[10px] uppercase tracking-wide text-warning">{t('sat.inverting')}</span>
|
||||
)}
|
||||
{tp?.linear && (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">
|
||||
{Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz
|
||||
</span>
|
||||
)}
|
||||
{bird?.geostationary && (
|
||||
<span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground">{t('sat.geo')}</span>
|
||||
)}
|
||||
{tp?.inverting && <Pill tone="warning" title={t('sat.invertingHint')}>{t('sat.inverting')}</Pill>}
|
||||
{tp?.linear && <Pill tone="info">{Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz</Pill>}
|
||||
{bird?.geostationary && <Pill>{t('sat.geo')}</Pill>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
@@ -967,6 +1104,30 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
<FreqRow label={t('sat.up')} hz={tuning?.up_hz ?? 0} nominal={tuning?.nominal_up ?? 0} />
|
||||
</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.
|
||||
It IS one, as far as the outcome goes: a repeater called without
|
||||
its tone does not answer, and the operator hears an empty
|
||||
@@ -990,14 +1151,18 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* What is coming. */}
|
||||
<div className="rounded-lg border border-border bg-card flex-1 min-h-0 flex flex-col">
|
||||
<div className="px-2 py-1 text-[11px] font-medium text-muted-foreground border-b border-border shrink-0">
|
||||
{t('sat.nextPasses')}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<Section
|
||||
title={t('sat.nextPasses')}
|
||||
icon={ListOrdered}
|
||||
open={secOpen.passes}
|
||||
onToggle={() => toggleSec('passes')}
|
||||
grow
|
||||
right={passes.length > 0 ? <Pill>{passes.length}</Pill> : undefined}
|
||||
>
|
||||
<div className="min-h-0">
|
||||
{passes.length === 0 && idle.length === 0 && (
|
||||
<div className="p-2 text-[11px] text-muted-foreground">{t('sat.noPasses')}</div>
|
||||
)}
|
||||
@@ -1077,7 +1242,7 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1098,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 (
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground truncate">{label}</div>
|
||||
<div className={cn('truncate', strong && 'font-semibold text-foreground')}>{value}</div>
|
||||
<div className={cn('truncate', strong && 'font-semibold', valueClass ?? (strong ? 'text-foreground' : undefined))}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1110,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
|
||||
// one of them leaves an operator unable to tell a Doppler correction from a
|
||||
// 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 }) {
|
||||
const { t } = useI18n();
|
||||
const shift = hz && nominal ? hz - nominal : 0;
|
||||
const centre = nominal || hz;
|
||||
const band = satBand(centre);
|
||||
return (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span>
|
||||
<span className="text-base font-semibold tabular-nums">{fmtHz(hz)}</span>
|
||||
<span className="text-base font-semibold tabular-nums">{fmtHz(centre)}</span>
|
||||
{!!band && <Pill className="self-center">{band}</Pill>}
|
||||
<div className="flex-1" />
|
||||
{!!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>
|
||||
);
|
||||
|
||||
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
|
||||
// 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.
|
||||
export function splitAtAntimeridian(pts: [number, number][]): [number, number][][] {
|
||||
if (pts.length === 0) return [];
|
||||
// unwrapLon makes a longitude series continuous.
|
||||
//
|
||||
// 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.
|
||||
const world = (lon: number) => Math.floor((lon + 180) / 360);
|
||||
const norm = (lon: number) => lon - 360 * world(lon);
|
||||
|
||||
Vendored
+4
@@ -600,6 +600,8 @@ export function GetSPEStatus():Promise<spe.Status>;
|
||||
|
||||
export function GetSatSettings():Promise<main.SatSettings>;
|
||||
|
||||
export function GetSatUplinkTrim(arg1:string,arg2:number):Promise<number>;
|
||||
|
||||
export function GetSatelliteBirds():Promise<Array<main.SatBird>>;
|
||||
|
||||
export function GetSatelliteGroundTrack(arg1:string,arg2:number):Promise<Array<sat.Position>>;
|
||||
@@ -1296,6 +1298,8 @@ export function SetPSUOutput(arg1:boolean):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 SetScpEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
@@ -1130,6 +1130,10 @@ export function GetSatSettings() {
|
||||
return window['go']['main']['App']['GetSatSettings']();
|
||||
}
|
||||
|
||||
export function GetSatUplinkTrim(arg1, arg2) {
|
||||
return window['go']['main']['App']['GetSatUplinkTrim'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function GetSatelliteBirds() {
|
||||
return window['go']['main']['App']['GetSatelliteBirds']();
|
||||
}
|
||||
@@ -2522,6 +2526,10 @@ export function 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) {
|
||||
return window['go']['main']['App']['SetScpClublogEnabled'](arg1);
|
||||
}
|
||||
|
||||
@@ -1154,6 +1154,7 @@ export namespace cat {
|
||||
split?: boolean;
|
||||
mode?: string;
|
||||
band?: string;
|
||||
rx_bands?: string[];
|
||||
vfo?: string;
|
||||
error?: string;
|
||||
// Go type: time
|
||||
@@ -1175,6 +1176,7 @@ export namespace cat {
|
||||
this.split = source["split"];
|
||||
this.mode = source["mode"];
|
||||
this.band = source["band"];
|
||||
this.rx_bands = source["rx_bands"];
|
||||
this.vfo = source["vfo"];
|
||||
this.error = source["error"];
|
||||
this.updated_at = this.convertValues(source["updated_at"], null);
|
||||
@@ -4300,6 +4302,7 @@ export namespace main {
|
||||
visible: boolean;
|
||||
range_km: number;
|
||||
alt_km: number;
|
||||
up_trim_hz: number;
|
||||
radio: string;
|
||||
error: string;
|
||||
rot_on: boolean;
|
||||
@@ -4327,6 +4330,7 @@ export namespace main {
|
||||
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.error = source["error"];
|
||||
this.rot_on = source["rot_on"];
|
||||
|
||||
+60
-2
@@ -60,6 +60,16 @@ type RigState struct {
|
||||
Split bool `json:"split,omitempty"` // rig is in split mode
|
||||
Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY)
|
||||
Band string `json:"band,omitempty"` // computed from FreqHz
|
||||
// RxBands is every band the radio currently has a receiver on, Band
|
||||
// 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"`
|
||||
@@ -584,9 +594,9 @@ type FlexController interface {
|
||||
SetMute(bool) error
|
||||
SetRXAntenna(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.
|
||||
SatAntennas(rxAnt, txAnt string) error
|
||||
SatAntennas(downRX, downTX, upRX, upTX string) error
|
||||
// SatTone sets the CTCSS tone the satellite uplink transmits (0 = off).
|
||||
SatTone(hz float64) error
|
||||
SetActiveSlice(int) error // focus slice idx so commands target it
|
||||
@@ -859,6 +869,18 @@ type SatTuner interface {
|
||||
// the input the whole tracker works from — without reading it back, a
|
||||
// tracker fights the operator instead of helping them.
|
||||
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.
|
||||
@@ -1002,11 +1024,47 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
|
||||
if ns.FreqHz != 0 && ns.Band == "" {
|
||||
ns.Band = BandFromHz(ns.FreqHz)
|
||||
}
|
||||
ns.RxBands = m.rxBands(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() {
|
||||
m.mu.RLock()
|
||||
d := m.cmdDelay
|
||||
|
||||
@@ -80,8 +80,12 @@ type Flex struct {
|
||||
// before the radio has necessarily reported the slice it was asked to
|
||||
// create; without this they were applied to an index of -1 and never again.
|
||||
satUpMode string
|
||||
satRXAnt string
|
||||
satTXAnt string
|
||||
// Both antennas of both slices: a slice has an rxant and a txant, and each
|
||||
// pair belongs to that slice's own band.
|
||||
satDownRX string
|
||||
satDownTX string
|
||||
satUpRX string
|
||||
satUpTX string
|
||||
satTone float64
|
||||
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)
|
||||
|
||||
+46
-20
@@ -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
|
||||
// frequencies.
|
||||
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()
|
||||
if strings.TrimSpace(ant) != "" {
|
||||
f.send(fmt.Sprintf("slice s %d txant=%s", idx, ant))
|
||||
f.send(fmt.Sprintf("slice s %d rxant=%s", idx, ant))
|
||||
if strings.TrimSpace(rxAnt) != "" {
|
||||
f.send(fmt.Sprintf("slice s %d rxant=%s", idx, rxAnt))
|
||||
}
|
||||
if strings.TrimSpace(txAnt) != "" {
|
||||
f.send(fmt.Sprintf("slice s %d txant=%s", idx, txAnt))
|
||||
}
|
||||
if strings.TrimSpace(mode) != "" {
|
||||
f.satMode(idx, mode, 0)
|
||||
@@ -259,7 +261,22 @@ func (f *Flex) SatReceiveHz() (int64, error) {
|
||||
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
|
||||
// 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
|
||||
// 70 cm should keep whatever the radio already had on the other side rather
|
||||
// than have it cleared.
|
||||
func (f *Flex) SatAntennas(rxAnt, txAnt string) error {
|
||||
// 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()
|
||||
rx, tx := f.satRX, f.satTX
|
||||
connected := f.conn != nil
|
||||
// Remembered so a slice that is reported late still gets its antenna.
|
||||
f.satRXAnt, f.satTXAnt = rxAnt, txAnt
|
||||
// Remembered so a slice that is reported late still gets its antennas.
|
||||
f.satDownRX, f.satDownTX = downRX, downTX
|
||||
f.satUpRX, f.satUpTX = upRX, upTX
|
||||
f.mu.Unlock()
|
||||
if !connected {
|
||||
return fmt.Errorf("flex: not connected")
|
||||
}
|
||||
// The downlink slice is the one being listened to, so it takes the receive
|
||||
// antenna; the uplink slice is the one keyed, so it takes the transmit one.
|
||||
if rx >= 0 && strings.TrimSpace(rxAnt) != "" {
|
||||
f.send(fmt.Sprintf("slice s %d rxant=%s", rx, rxAnt))
|
||||
applog.Printf("flex: satellite downlink slice %d on antenna %s", rx, rxAnt)
|
||||
set := func(idx int, which, rxAnt, txAnt string) {
|
||||
if idx < 0 {
|
||||
return
|
||||
}
|
||||
if tx >= 0 && strings.TrimSpace(txAnt) != "" {
|
||||
f.send(fmt.Sprintf("slice s %d txant=%s", tx, txAnt))
|
||||
// A transmit slice also has to HEAR its own band on some radios, and a
|
||||
// transverter port is the only thing connected to it. Setting the
|
||||
// receive antenna to match costs nothing when it is already right.
|
||||
f.send(fmt.Sprintf("slice s %d rxant=%s", tx, txAnt))
|
||||
applog.Printf("flex: satellite uplink slice %d on antenna %s", tx, txAnt)
|
||||
if strings.TrimSpace(rxAnt) != "" {
|
||||
f.send(fmt.Sprintf("slice s %d rxant=%s", idx, rxAnt))
|
||||
}
|
||||
if strings.TrimSpace(txAnt) != "" {
|
||||
f.send(fmt.Sprintf("slice s %d txant=%s", idx, txAnt))
|
||||
}
|
||||
if strings.TrimSpace(rxAnt) != "" || strings.TrimSpace(txAnt) != "" {
|
||||
applog.Printf("flex: satellite %s slice %d rx=%s tx=%s", which, idx, rxAnt, txAnt)
|
||||
}
|
||||
}
|
||||
set(rx, "downlink", downRX, downTX)
|
||||
set(tx, "uplink", upRX, upTX)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +142,33 @@ func (b *IcomSerial) SatReceiveHz() (int64, error) {
|
||||
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.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
// 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).
|
||||
//
|
||||
// DCU-1 is used by the Hy-Gain DCU-1, the Idiom Press Rotor-EZ, Green Heron
|
||||
// controllers, and the RotorCard DXA (hamsupply) for Yaesu DXA rotors. It is a
|
||||
// DCU-1 is used by the Hy-Gain DCU-1, the Green Heron RT-21, the Idiom Press
|
||||
// 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):
|
||||
// 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 ';'):
|
||||
//
|
||||
// AP1nnn set the target bearing nnn (000-359)
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestAJustCommandedMoveReportsMotion(t *testing.T) {
|
||||
}
|
||||
|
||||
c.statusMu.Lock()
|
||||
c.moveCmdAt = time.Now()
|
||||
c.moveUntil = time.Now().Add(moveOptimisticWindow)
|
||||
c.statusMu.Unlock()
|
||||
|
||||
st, _ := c.GetStatus()
|
||||
@@ -33,9 +33,68 @@ func TestAJustCommandedMoveReportsMotion(t *testing.T) {
|
||||
// Bounded: an antenna that never reports motion must not latch the transmit
|
||||
// inhibit on for ever.
|
||||
c.statusMu.Lock()
|
||||
c.moveCmdAt = time.Now().Add(-moveOptimisticWindow - time.Second)
|
||||
c.moveUntil = time.Now().Add(-time.Second)
|
||||
c.statusMu.Unlock()
|
||||
if st, _ := c.GetStatus(); st.MotorsMoving != 0 {
|
||||
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.
|
||||
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.
|
||||
type Transport struct {
|
||||
Mode string // "tcp" | "serial"
|
||||
@@ -122,8 +132,11 @@ type Client struct {
|
||||
statusMu sync.RWMutex
|
||||
lastStatus *Status
|
||||
lastSetKHz int
|
||||
// moveCmdAt is when a move was last COMMANDED — see moveOptimisticWindow.
|
||||
moveCmdAt time.Time
|
||||
// moveUntil is how long a commanded move is reported as moving without the
|
||||
// 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
|
||||
// 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.
|
||||
@@ -225,7 +238,25 @@ func (c *Client) GetStatus() (*Status, error) {
|
||||
// movingOptimisticallyLocked reports a move commanded too recently for the
|
||||
// controller to have answered. Callers hold statusMu.
|
||||
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
|
||||
@@ -693,7 +724,9 @@ func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
||||
c.statusMu.Lock()
|
||||
c.lastSetKHz = freqKhz
|
||||
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()
|
||||
return nil
|
||||
}
|
||||
@@ -726,7 +759,11 @@ func (c *Client) Retract() error {
|
||||
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
|
||||
@@ -764,5 +801,9 @@ func (c *Client) Calibrate() error {
|
||||
khz = 14000
|
||||
}
|
||||
}
|
||||
return c.writeCmd(buildSet(khz*1000, DirNormal, 'V'))
|
||||
if err := c.writeCmd(buildSet(khz*1000, DirNormal, 'V')); err != nil {
|
||||
return err
|
||||
}
|
||||
c.markMoving(retractOptimisticWindow)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -894,10 +894,21 @@ func (c *Client) SetDirection(direction int) error {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
_, err := c.sendCommand(CMD_RETRACT, nil)
|
||||
if _, err := c.sendCommand(CMD_RETRACT, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
c.statusMu.Lock()
|
||||
c.moveCmdAt = time.Now()
|
||||
c.statusMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ModifyElement modifies element length (command 12)
|
||||
func (c *Client) ModifyElement(elementNum int, lengthMm int) error {
|
||||
|
||||
@@ -233,8 +233,18 @@ func main() {
|
||||
Title: "OpsLog",
|
||||
Width: width,
|
||||
Height: height,
|
||||
MinWidth: 1100,
|
||||
MinHeight: 700,
|
||||
// No minimum. Wails treats 0 as "no constraint" (winc only fills
|
||||
// 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,
|
||||
// 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
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"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
|
||||
// end before taking the single-instance mutex.
|
||||
//
|
||||
// Waiting on the mutex alone was not enough, and that is the bug this line
|
||||
// fixes: shutting down is allowed thirty seconds here (armExitWatchdog),
|
||||
// because it closes a remote logbook, a CAT session and sometimes a backup,
|
||||
// while the new instance was only patient for twenty. On a station where
|
||||
// that ran long, the new process gave up and exited — leaving the old one
|
||||
// still running and no new window, which is precisely what the PowerShell
|
||||
// helper never did: it waited for the pid, however long it took.
|
||||
cmd := exec.Command(exe, "--post-update", "--wait-pid", strconv.Itoa(os.Getpid()))
|
||||
cmd.Dir = dir
|
||||
hideConsole(cmd)
|
||||
// Waiting on the mutex alone was not enough: shutting down is allowed thirty
|
||||
// seconds here (armExitWatchdog), because it closes a remote logbook, a CAT
|
||||
// session and sometimes a backup, while the new instance was only patient
|
||||
// for twenty. On a station where that ran long, the new process gave up and
|
||||
// exited — leaving the old one still running and no new window, which is
|
||||
// precisely what the PowerShell helper never did: it waited for the pid,
|
||||
// however long it took.
|
||||
//
|
||||
// And relaunchCmd rather than a command built here, because the OTHER half
|
||||
// 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 {
|
||||
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. |
|
||||
| **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. |
|
||||
| **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. |
|
||||
| **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