Compare commits

..
11 Commits
Author SHA1 Message Date
rouggy 9e67ddcea4 feat(window): no minimum size
The window would not go below 1100x700. That was a fair guess at where
the layout stops being comfortable and no business of ours to enforce:
an operator wanting OpsLog as a narrow strip beside a decoder, parked on
a second screen, or on a small laptop, hit a wall with nothing to show
for it. The panels already scroll and collapse.

Zero is how Wails says "do not constrain" — winc fills PtMinTrackSize
only when the value is above zero, checked in the library rather than
assumed — so Windows applies its own floor, about the width of the
caption buttons, and nothing here adds to it.

Removing the constant meant three other users of it had to be sorted
out, since each wanted something different from "the minimum":

- The saved-geometry sanity check now has its own floor, 200x150. That
  is not a minimum the operator feels; it decides when a stored size is
  corrupt rather than chosen. A window restored at 0x0 cannot be grabbed
  to fix it, and that is the one state there is no way back from.
- The maximised-corner monitor check was passing the minimum AS a size,
  which would now be a zero-sized rectangle. It uses the saved size.
- Leaving compact mode validated its captured geometry against the
  minimum; it uses the sane floor.

SetCompactHeight keeps its 900 minimum: it returns early unless compact
is on, and leaving compact resets the minimum to none.
2026-09-10 20:20:05 +02:00
rouggy fd93036f86 fix(antenna): Retract and Calibrate report the elements moving
An operator reported that retracting a SteppIR shows nothing, while an
Ultrabeam visibly does. The cause is not the display: both antennas feed
one `moving` flag and the widget renders it the same way.

moveCmdAt — the bridge that reports motion from the moment a move is
COMMANDED, before the controller's own poll can confirm it — was set
only by SetFrequency. Retract and Calibrate write their frame directly
and armed nothing, so three things were missing at once: nothing said
the elements were moving, the poll stayed on its two-second idle cadence
instead of speeding up to 250 ms to watch, and the transmit inhibit was
not engaged while the elements travelled. That last one is the reason
this is not cosmetic.

The Ultrabeam's Retract armed nothing either. It was less visible there
because that controller reports element lengths, which count down on
screen; a SteppIR reports none at all, so the retract looked inert. The
inhibit was equally absent.

The window becomes a deadline rather than a timestamp, so the caller can
say how long a bridge it needs: three seconds for a tune, ten for a
retract or a calibrate. Those are tens of seconds of travel, and a
retract drops the controller out of AUTOTRACK where its motor-bit
reporting is less predictable. Still bounded — an antenna that never
reports motion must not latch the inhibit on for ever — and markMoving
only ever extends, so a tune issued just after a retract cannot cut the
retract's bridge down to its own.

Four tests, including that a command which failed to reach the
controller does not claim the antenna is moving.
2026-09-10 20:15:03 +02:00
rouggy 031fcbd543 docs(rotator): name the Green Heron RT-21, which was already supported
The RT-21 speaks the Hy-Gain DCU-1 command set in its default protocol
setting, and internal/rotator/dcu1 has driven that since it landed —
AP1nnn/AM1 to point, AI1 to read back, over a COM port or over TCP.
Nothing was missing except a way to find it: "Green Heron" sat in a
parenthesis after "Hy-Gain DCU-1", which an operator scanning the list
for RT-21 does not see.

So the dropdown leads with the controller people actually own, and the
hint says the two things that decide whether it works: the RT-21's
protocol must be set to DCU-1 / Rotor-EZ (on GS-232 it belongs to the
GS-232 entry instead), and with the Ethernet option TCP reaches it
directly rather than through a serial-over-IP bridge, which is what the
hint used to imply was necessary.

The package doc now also records what is NOT here: the native Green
Heron protocol is a third command set, with 0.1° readback and a real
stop, and writing it from memory is exactly the mistake this repo warns
about for wire protocols.
2026-09-10 18:58:04 +02:00
rouggy 72696a5c0c feat(sat): the uplink keeps the correction the operator makes
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 rest of the pass. Reported on an
IC-9700 against HRD, which keeps the shift the operator sets.

The tracker already worked this way for the RECEIVER: it reads the dial
back and treats a move as the operator choosing a new station. The
transmitter had no equivalent — its comment even said so, "derived from
the nominal and never argued with". Now it is read back too, and the
difference becomes a standing trim on the nominal uplink.

Applied to the nominal rather than the corrected frequency, because a
translation error is a fixed offset in the uplink band and not something
that scales with the Doppler. Read only while not transmitting: mid-over
nobody is turning the knob, and on an Icom this read switches to the SUB
band and back, which is the same path TuneSatellite already uses to
write the uplink and not something to do under a carrier.

Kept per satellite AND per transponder, because that is what it belongs
to: the error is a property of the hardware in orbit, stable from one
pass to the next. Capped at 20 kHz so a bad stored value cannot put the
station outside the passband for ever, and shown in the tune panel with
a reset — an offset taken silently from the VFO has to be visible, and
the VFO alone cannot bring it back to zero once the operator has drifted
somewhere wrong.

SatTuner gains SatTransmitHz, implemented for the native Icom satellite
mode and for the Flex uplink slice; anything else reports nothing and
the uplink is left to the arithmetic, as before.
2026-09-10 18:53:03 +02:00
rouggy d0d29659cb fix(maps): the ground track stops cutting straight across the map
splitAtAntimeridian works from which COPY of the world each longitude is
in, so it needs a series that runs past ±180 rather than jumping back.
greatCirclePoints produces one; a satellite ground track does not — SGP4
reports every longitude inside (−180, 180]. A track leaving Kamchatka at
+179.9 and arriving in Alaska at −179.9 therefore looked like a single
359.8° step inside one world, no split was made, and the polyline drew
the chord: a straight dashed line clean across the map on every crossing.

The series is now unwrapped first. A jump of more than 180° between
consecutive points is that wrap and nothing else — no real path steps
half the globe between samples — and the unwrap is idempotent, so the
great-circle caller is unaffected.

Checked on three series: a normalised crossing now yields two segments
meeting exactly at ±180 where it used to yield one chord; an already
continuous 170→183 still splits at 180; a track that never crosses comes
back untouched.
2026-09-10 17:48:39 +02:00
rouggy 6f9b996db8 fix(sat): both antennas on both satellite slices
A slice has an rxant and a txant, and each pair belongs to that slice's
own band. Only two of the four were being set — the downlink's receive
antenna and the uplink's transmit one — so the downlink slice was left
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 happened to be left on. Reported with XVTA on
2 m and XVTB on 70 cm, where the 70 cm slice showed one antenna and the
2 m slice showed two.

SatAntennas now takes all four, and the late-slice replay in
adoptSatSlice hands the uplink its own pair instead of putting the
transmit antenna on both of its ports.
2026-09-10 17:48:28 +02:00
rouggy 9dcab0568b docs(changelog): entries cut to one or two sentences
CLAUDE.md says one or two sentences with the rationale in the commit,
and I had been writing paragraphs: 0.27.23 ran to about 7 700 characters
over fifteen entries and 0.27.24 to 3 500 over eight. Release notes
nobody can scroll through are release notes nobody reads.

Same subjects, same order, outcome only — 1 950 and 955 characters.
Every "because", every measured number and every internal mechanism is
already in the commit that made the change, which is where they belong.
2026-09-10 15:43:53 +02:00
rouggy a8e870e098 fix(update): the relaunched OpsLog was starting invisibly
The relaunch after an update called hideConsole on the command that
starts the new build. That sets SysProcAttr{HideWindow: true}, which on
Windows becomes SW_HIDE in the STARTUPINFO handed to CreateProcess — and
Windows applies it to the first top-level window the new process shows.
So the updated OpsLog started correctly, took the single-instance mutex,
opened the logbook and connected the rig, and never appeared.

That is the report, in full: a process in the task manager, no window,
no autostart programs, and ending it then launching OpsLog by hand
working every time. Two operators, both on 0.27.23.

Why 0.27.19 did not fix it: that commit fixed the other half of the same
symptom — the new instance being less patient than the old one is slow —
which was real and is still fixed. The window was never part of it.

Where it came from: removing the PowerShell helper. Start-Process
launched the exe with a normal show; the direct exec.Command that
replaced it borrowed hideConsole from the console tools next to it,
where hiding a console window is exactly right and where the three
remaining callers (tasklist, taskkill, the deferred-swap PowerShell)
still belong.

The proof it was this and not the waiting: OpsLog relaunches itself in
two places, and RestartApp — the database switch — was byte-identical
except that it never called hideConsole. It has never been reported
broken. Both now go through relaunchCmd, so the rule lives in one place
with the reason written down, rather than in two call sites that differed
by one line.

Two tests: relaunchCmd leaves SysProcAttr nil, and update.go does not
call hideConsole at all.
2026-09-10 15:40:02 +02:00
rouggy 7e6e1335e3 fix(sat,decodes): the left column holds position too, and drift stops lying
Two things.

The sky plot and the position share the left column now, and the button
that opens it says so the way the right one does — it was a radar icon,
left over from when it toggled a plot inside the readout column, and the
gesture is the same one on both sides. The plot and the numbers are the
same answer at two precisions: azimuth and elevation drawn, then written
out to the digit. Having them at opposite ends of the window meant
reading a bearing off one side and finding it on the other.

And the band-drift warning, reported by W4TE. It compared the decoder's
announced band against RigState.Band, which is the TRANSMIT band — so
with slice A on 20 m running its own WSJT-X, 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 had been on 20 m throughout. The panel's own
comment had accepted this as a line that setup could read past; it is
worse than that, because the warning names a band and asserts something
false about the radio.

RigState now carries RxBands: every band the rig has a receiver on. One
entry on a single-VFO rig, one per slice on a Flex, the transmit band
always included so it cannot come back empty while the rig is on a
frequency. The warning fires only when the decoder announces a band
NOTHING on the radio is on, which is what it was always for and what a
lost CAT link actually looks like.
2026-09-10 15:25:54 +02:00
rouggy 3fe14c2c77 feat(sat): the Satellites tab laid out again, and a visible ground track
Everything asked for and not delivered last time.

The sky plot moves to its own column on the LEFT of the map. It was
stacked in the readout column, where a plot that wants to be square
competed for width with the numbers and pushed the pass table off the
bottom of the screen — while the margin on the other side of the map sat
empty throughout. Its own width, dragged from its right edge, with
double-click to reset.

Every block of the readout column gets a heading and a chevron: the
pass, the position, what to tune, the pass list. Each remembers its own
state, because an operator working FM birds never looks at the linear
passband and one watching a schedule never looks at the range rate.
There were no headings at all before, which cost twice — nothing said
what a group of numbers was, and there was nowhere to put the control
that shuts it. A shut block keeps the one number it exists for in its
heading, and drops that badge again when open rather than repeating the
body a line below.

The tune panel now leads with the CENTRE of the passband. The
Doppler-corrected figure was the wrong number for 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 — it is already there — so this keeps the
correction only as the offset that explains the difference.

Badges rather than rows of grey words, in the app's own status tones, on
the things that decide something: the band of each frequency, the
Doppler offset, an inverting transponder, the passband width, and the
peak elevation of a pass.

And the ground track. It was `color: 'var(--info)'` on a map that
renders with preferCanvas, and a CSS variable is not a colour a canvas
strokeStyle can take — 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. This is the same trap internal to GridSquareMap's own
comment: every other map in this app passes hex. Now a real colour over
a dark casing, the way a road is drawn, because one line cannot hold up
over both a pale sea and a dark continent but a line with an outline
can.
2026-09-10 15:09:27 +02:00
rouggy e9816241e8 docs(changelog): 0.27.23 describes what ships, not how it was built
Eighteen entries down to fifteen. Three subjects were written as they
were built — announced, then corrected, then corrected again — and a
block is read top to bottom by an operator who saw none of the states in
between. The reverse FT-map layer was the worst of it: one entry
promised dashed cyan arcs, the next said the arcs were gone, a third
filled the marks and gave them a colour. Nobody outside this machine
ever had the version being corrected.

- The wind fix and the blinking fix are one change to one test: strict
  on the way into movement, loose on the way out.
- The who-hears-me layer, its diamonds and its colour are one feature.
- The control layout says where things are, without the detour through
  where they were first put.

Also: the decode colour no longer claims to sit next to the basemap
buttons, which is where it was for two commits.
2026-09-10 14:54:01 +02:00
27 changed files with 1254 additions and 237 deletions
+18 -7
View File
@@ -2376,7 +2376,10 @@ func (a *App) restoreWindowPosition() {
applog.Printf("window: maximised with corner 0,0 — nothing to restore") applog.Printf("window: maximised with corner 0,0 — nothing to restore")
return return
} }
if !onSomeMonitor(ws.X, ws.Y, normalMinW, normalMinH) { // The saved size, not the minimum: this asks "is that corner on a
// screen", and with no minimum any more the old arguments were a
// zero-sized rectangle.
if !onSomeMonitor(ws.X, ws.Y, max(ws.Width, windowSaneW), max(ws.Height, windowSaneH)) {
applog.Printf("window: saved maximised corner %d,%d is off every monitor — opening where Windows puts it", ws.X, ws.Y) applog.Printf("window: saved maximised corner %d,%d is off every monitor — opening where Windows puts it", ws.X, ws.Y)
return return
} }
@@ -2387,7 +2390,7 @@ func (a *App) restoreWindowPosition() {
applog.Printf("window: re-maximised at the saved corner — now at %d,%d", gx, gy) applog.Printf("window: re-maximised at the saved corner — now at %d,%d", gx, gy)
return return
} }
if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH { if ws.Width < windowSaneW || ws.Height < windowSaneH || ws.Width > maxW || ws.Height > maxH {
return // corrupt / absurd — leave the default placement return // corrupt / absurd — leave the default placement
} }
// The SIZE was sanity-checked above but the POSITION never was, and that is // The SIZE was sanity-checked above but the POSITION never was, and that is
@@ -3103,8 +3106,7 @@ func (a *App) RestartApp() error {
// --wait-pid, so it waits for this process to actually END rather than for a // --wait-pid, so it waits for this process to actually END rather than for a
// window of time to pass: shutting down closes a logbook and a CAT session, // window of time to pass: shutting down closes a logbook and a CAT session,
// and on a remote database that takes as long as it takes. // and on a remote database that takes as long as it takes.
cmd := exec.Command(exe, "--relaunch", "--wait-pid", strconv.Itoa(os.Getpid())) cmd := relaunchCmd(exe, "--relaunch", "--wait-pid", strconv.Itoa(os.Getpid()))
cmd.Dir = filepath.Dir(exe)
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return fmt.Errorf("relaunch OpsLog: %w", err) return fmt.Errorf("relaunch OpsLog: %w", err)
} }
@@ -7674,7 +7676,14 @@ func (a *App) GridSquareChoices() (GridSquareChoices, error) {
const ( const (
compactW, compactH = 1240, 158 compactW, compactH = 1240, 158
normalW, normalH = 1400, 900 normalW, normalH = 1400, 900
normalMinW, normalMinH = 1100, 700 // No minimum on the normal window: 0 tells Wails not to constrain it at
// all, and the operator decides how small is useful. See main.go.
normalMinW, normalMinH = 0, 0
// windowSaneW/H is not a minimum the operator feels — it is the floor below
// which a SAVED geometry is treated as corrupt rather than as a choice. A
// window restored at 0x0 cannot be grabbed to fix it, and that is the one
// state there is no way back from.
windowSaneW, windowSaneH = 200, 150
// Large enough to never constrain a maximised window on big displays. // Large enough to never constrain a maximised window on big displays.
maxW, maxH = 8000, 6000 maxW, maxH = 8000, 6000
) )
@@ -7694,7 +7703,9 @@ func (a *App) SetCompactMode(on bool) {
} }
a.preCompactW, a.preCompactH = wruntime.WindowGetSize(a.ctx) a.preCompactW, a.preCompactH = wruntime.WindowGetSize(a.ctx)
a.preCompactX, a.preCompactY = wruntime.WindowGetPosition(a.ctx) a.preCompactX, a.preCompactY = wruntime.WindowGetPosition(a.ctx)
a.preCompactValid = a.preCompactW >= normalMinW && a.preCompactH >= normalMinH // Against the sane floor, not a minimum — there is no longer one. This
// only asks whether the capture is believable enough to restore.
a.preCompactValid = a.preCompactW >= windowSaneW && a.preCompactH >= windowSaneH
} }
a.compact = on a.compact = on
if on { if on {
@@ -17176,7 +17187,7 @@ var rotatorTypes = []RotatorTypeInfo{
{ID: "rotgenius", Label: "Rotator Genius (4O3A, native)", Network: true, DefaultPort: 9006}, {ID: "rotgenius", Label: "Rotator Genius (4O3A, native)", Network: true, DefaultPort: 9006},
{ID: "arco", Label: "GS-232 azimuth controller (microHAM ARCO, ERC)", Network: true, Serial: true, DefaultPort: 4001, DefaultBaud: 9600}, {ID: "arco", Label: "GS-232 azimuth controller (microHAM ARCO, ERC)", Network: true, Serial: true, DefaultPort: 4001, DefaultBaud: 9600},
{ID: "erc", Label: "ERC-M by DF9GR (Yaesu G-5500 az/el)", Elevation: true, Network: true, Serial: true, DefaultPort: 4001, DefaultBaud: 19200}, {ID: "erc", Label: "ERC-M by DF9GR (Yaesu G-5500 az/el)", Elevation: true, Network: true, Serial: true, DefaultPort: 4001, DefaultBaud: 19200},
{ID: "dcu1", Label: "Hy-Gain DCU-1 (RotorCard DXA, Rotor-EZ, Green Heron)", Network: true, Serial: true, DefaultPort: 4001, DefaultBaud: 4800}, {ID: "dcu1", Label: "Green Heron RT-21 / Hy-Gain DCU-1 (Rotor-EZ, RotorCard DXA)", Network: true, Serial: true, DefaultPort: 4001, DefaultBaud: 4800},
{ID: "spid", Label: "SPID / AlfaSpid (RAS, BIG-RAS, MD-01, MD-02)", Elevation: true, Serial: true, DefaultBaud: 600}, {ID: "spid", Label: "SPID / AlfaSpid (RAS, BIG-RAS, MD-01, MD-02)", Elevation: true, Serial: true, DefaultBaud: 600},
{ID: "easycomm", Label: "EasyComm II (SatPC32, Gpredict, K3NG…)", Elevation: true, Network: true, Serial: true, DefaultPort: 4533, DefaultBaud: 9600}, {ID: "easycomm", Label: "EasyComm II (SatPC32, Gpredict, K3NG…)", Elevation: true, Network: true, Serial: true, DefaultPort: 4533, DefaultBaud: 9600},
} }
+187 -8
View File
@@ -20,6 +20,7 @@ package main
import ( import (
"fmt" "fmt"
"math" "math"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -45,6 +46,14 @@ const satTickEvery = time.Second
// smallest deliberate move anybody makes hunting a station on a transponder. // smallest deliberate move anybody makes hunting a station on a transponder.
const satDialTolerance = 200 const satDialTolerance = 200
// satUpTrimLimit caps the uplink trim, in hertz.
//
// 20 kHz: wider than any transponder is off by, and narrower than the distance
// to a neighbouring band edge. It exists so a bad stored value, or a transmit
// VFO the operator swung across the band for some other reason, cannot become
// a permanent offset that puts the station outside the passband every pass.
const satUpTrimLimit = 20000
// satLightKmS is the speed of light in km/s, for turning a heard frequency back // satLightKmS is the speed of light in km/s, for turning a heard frequency back
// into a nominal one. The same constant internal/sat corrects with. // into a nominal one. The same constant internal/sat corrects with.
const satLightKmS = 299792.458 const satLightKmS = 299792.458
@@ -60,6 +69,19 @@ type satTracker struct {
nominalDown int64 nominalDown int64
lastDown int64 // what was last sent to the radio lastDown int64 // what was last sent to the radio
lastUp int64 lastUp int64
// 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 status SatTrackStatus
fails int fails int
@@ -101,6 +123,10 @@ type SatTrackStatus struct {
// to know how long it will last. // to know how long it will last.
RangeKm float64 `json:"range_km"` RangeKm float64 `json:"range_km"`
AltKm float64 `json:"alt_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", "" Radio string `json:"radio"` // what the rig is doing: "sat", "downlink-only", ""
Error string `json:"error"` Error string `json:"error"`
@@ -141,6 +167,14 @@ func (a *App) StartSatelliteTracking(name string, transponder int) error {
wake: make(chan struct{}, 1), wake: make(chan struct{}, 1),
} }
t.status = SatTrackStatus{On: true, Name: b.Name, Transponder: b.Transponders[transponder].Label, Mode: b.Transponders[transponder].Mode} t.status = SatTrackStatus{On: true, Name: b.Name, Transponder: b.Transponders[transponder].Label, Mode: b.Transponders[transponder].Mode}
// The correction this transponder was last left with: its translation error
// is a property of the hardware in orbit and does not change between passes.
trim := a.loadSatUplinkTrim(b.Name, transponder)
t.upTrim = trim
t.status.UpTrimHz = trim
if trim != 0 {
applog.Printf("sat: uplink starts %+d Hz off nominal, as it was left", trim)
}
// The rotator, if there is one. A geostationary bird is pointed at once and // The rotator, if there is one. A geostationary bird is pointed at once and
// left alone, so it gets one command rather than a loop. // left alone, so it gets one command rather than a loop.
@@ -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) sh := sat.Doppler(pos, nominal, nomUp)
down, up := sh.DownHz, sh.UpHz down, up := sh.DownHz, sh.UpHz
@@ -356,6 +418,7 @@ func (a *App) satTrackStep(t *satTracker) {
st.DownHz, st.UpHz = down, up st.DownHz, st.UpHz = down, up
st.Az, st.El, st.Visible = pos.Az, pos.El, visible st.Az, st.El, st.Visible = pos.Az, pos.El, visible
st.RangeKm, st.AltKm = pos.RangeKm, pos.AltKm st.RangeKm, st.AltKm = pos.RangeKm, pos.AltKm
st.UpTrimHz = upTrim
t.status = st t.status = st
t.mu.Unlock() t.mu.Unlock()
@@ -551,6 +614,114 @@ func (a *App) satReceiveHz() (int64, error) {
return st.FreqHz, nil return st.FreqHz, nil
} }
// satTransmitHz is where the transmitter actually is, or 0 when the radio
// cannot say. Only the satellite backends can: a rig working split reports one
// frequency and it is the receiver's.
func (a *App) satTransmitHz() (int64, error) {
if a.cat == nil {
return 0, fmt.Errorf("CAT is not running")
}
if !a.cat.SatCapable() {
return 0, nil
}
var hz int64
err := a.cat.SatDo(func(st cat.SatTuner) error {
v, e := st.SatTransmitHz()
hz = v
return e
})
return hz, err
}
// satTransmitting reports whether the rig is keyed, so the uplink readback can
// stay off the air while it is.
//
// Only the two backends that hold a satellite pair are asked, which are the
// only two this matters for. Unknown counts as NOT transmitting: refusing to
// read the uplink on a radio that cannot say would disable the trim entirely.
func (a *App) satTransmitting() bool {
if a.cat == nil {
return false
}
if st, ok := a.cat.FlexState(); ok {
return st.Transmitting
}
if st, ok := a.cat.IcomState(); ok {
return st.Transmitting
}
return false
}
// ── The uplink trim, remembered ─────────────────────────────────────────────
//
// Kept per satellite AND per transponder, because that is what it belongs to:
// a transponder's translation error is a property of the hardware in orbit,
// stable from one pass to the next and for years. An operator who found the
// right offset on FO-29 last week should not have to find it again tonight.
func keySatUpTrim(name string, tp int) string {
return fmt.Sprintf("sat.uptrim.%s.%d", strings.ToUpper(strings.TrimSpace(name)), tp)
}
func (a *App) loadSatUplinkTrim(name string, tp int) int64 {
if a.settings == nil {
return 0
}
v, _ := a.settings.Get(a.ctx, keySatUpTrim(name, tp))
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil {
return 0
}
// A trim larger than the passband is a stored mistake, not a correction.
if n < -satUpTrimLimit || n > satUpTrimLimit {
return 0
}
return n
}
func (a *App) saveSatUplinkTrim(name string, tp int, hz int64) {
if a.settings == nil {
return
}
if hz < -satUpTrimLimit || hz > satUpTrimLimit {
return
}
if err := a.settings.Set(a.ctx, keySatUpTrim(name, tp), strconv.FormatInt(hz, 10)); err != nil {
applog.Printf("sat: could not store the uplink trim: %v", err)
}
}
// GetSatUplinkTrim is what the panel shows.
func (a *App) GetSatUplinkTrim(name string, transponder int) int64 {
return a.loadSatUplinkTrim(name, transponder)
}
// SetSatUplinkTrim stores a trim and applies it to a pass in progress.
//
// The panel needs this to CLEAR one: a trim taken from the transmit VFO can
// only be adjusted by the same VFO, and an operator who has drifted somewhere
// wrong has no way back to zero without it.
func (a *App) SetSatUplinkTrim(name string, transponder int, hz int64) error {
if hz < -satUpTrimLimit || hz > satUpTrimLimit {
return fmt.Errorf("a %d Hz trim is outside anything a transponder is off by", hz)
}
a.saveSatUplinkTrim(name, transponder, hz)
a.satTrackMu.Lock()
t := a.satTrack
a.satTrackMu.Unlock()
if t != nil {
t.mu.Lock()
on := t.status.On && strings.EqualFold(t.name, name) && t.tp == transponder
if on {
t.upTrim = hz
}
t.mu.Unlock()
if on {
applog.Printf("sat: uplink trim set to %+d Hz", hz)
}
}
return nil
}
func abs64i(v int64) int64 { func abs64i(v int64) int64 {
if v < 0 { if v < 0 {
return -v return -v
@@ -697,21 +868,25 @@ func (a *App) applySatRadio(tp sat.Transponder) {
if err != nil || len(m) == 0 { if err != nil || len(m) == 0 {
return return
} }
// The downlink is received, so it takes that band's RX antenna; the uplink // Each slice gets BOTH antennas of ITS OWN band. Only two of the four were
// is transmitted, so it takes that band's TX antenna. // set — the downlink's receive and the uplink's transmit — which left the
// 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) downBand, upBand := flexBandAntKey(tp.DownLo), flexBandAntKey(tp.UpLo)
rxAnt := m[downBand].RX down, up := m[downBand], m[upBand]
txAnt := m[upBand].TX if strings.TrimSpace(down.RX) == "" && strings.TrimSpace(down.TX) == "" &&
if strings.TrimSpace(rxAnt) == "" && strings.TrimSpace(txAnt) == "" { strings.TrimSpace(up.RX) == "" && strings.TrimSpace(up.TX) == "" {
// Worth a line: an operator who HAS configured the pair and still sees // 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 // the wrong antenna has no other way to tell a setting he never made
// from a lookup that missed. // from a lookup that missed.
applog.Printf("sat: no antenna configured for this pass (down %s, up %s)", downBand, upBand) applog.Printf("sat: no antenna configured for this pass (down %s, up %s)", downBand, upBand)
return 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 { if err := a.cat.FlexDo(func(fc cat.FlexController) error {
return fc.SatAntennas(rxAnt, txAnt) return fc.SatAntennas(down.RX, down.TX, up.RX, up.TX)
}); err != nil { }); err != nil {
// Not fatal: a rig that is not a Flex has no such thing, and a pass with // Not fatal: a rig that is not a Flex has no such thing, and a pass with
// the wrong antenna is still a pass. // the wrong antenna is still a pass.
@@ -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 // And so the antenna is commanded at once instead of waiting for the new
// satellite to drift a step away from where the old one happened to be. // satellite to drift a step away from where the old one happened to be.
t.rotSent = false t.rotSent = false
// A different transponder is off by a different amount, and the one we were
// on has no bearing on it.
t.upTrim = a.loadSatUplinkTrim(b.Name, transponder)
t.status.UpTrimHz = t.upTrim
t.status.Name, t.status.Transponder, t.status.Mode = b.Name, tp.Label, tp.Mode t.status.Name, t.status.Transponder, t.status.Mode = b.Name, tp.Label, tp.Mode
t.status.Error = "" t.status.Error = ""
t.mu.Unlock() t.mu.Unlock()
+38
View File
@@ -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
View File
@@ -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 daccord 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 dune 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 navertit plus dune 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 lorsquil franchit lantimé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 leffaç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 loption 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 lun ni lautre ne le signalait — même manque sur la rétraction dune 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", "version": "0.27.23",
"date": "", "date": "",
"en": [ "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.", "SteppIR: a Calibrate button beside Retract, on Station Control.",
"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.", "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 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.", "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. 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.", "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 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.", "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 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.", "The per-band FlexRadio antennas are applied to the satellite slices. A 70 cm downlink was staying on the 2 m transverter.",
"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.", "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 — 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 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 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.", "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 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.", "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, 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.", "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. 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.", "\"Later\" on an update notice now asks for how long: 1, 4, 12 or 24 hours. A newer release still appears at once.",
"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 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 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 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 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.", "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."
"\"Who hears me\" is marks alone, no arcs. With a few dozen receivers reporting, a line to each made a fan out of one square that buried the decode arcs beside it — and the lines said nothing, since every one of these paths starts in the same place. The mark is a hollow diamond, fading with age: shape rather than colour carries the difference, the decode dots being filled circles and the arcs having used fourteen colours already.",
"The FT map's controls stop covering it. The basemap buttons had crept over Leaflet's zoom control, so the minus button took clicks meant for Street, and the row had grown with the colour picker and the reverse layer until it reached the middle of a world map — covering the Atlantic to save a top-right corner that was empty the whole time. Basemaps stay on the left, clear of the zoom; the colour and \"Who hears me\" move to the right.",
"The who-hears-me diamonds are filled and take a colour of their own, chosen beside the switch that turns them on. Filled with a hairline white edge, the trick the home marker already uses: a ring is an outline drawn over whatever is beneath it, and eight pixels of it over the satellite imagery was barely there. The colour is separate from the decode one because a single control would have forced both layers into one colour — the very distinction it took a shape to make."
], ],
"fr": [ "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 saccorde à 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 lantenne est inutilisable jusqu’à la fin : une confirmation est donc demandée. Les contrôleurs Ultrabeam nont pas cette commande et le disent, au lieu de faire semblant. Rétracter les éléments existait déjà et sexplique maintenant : cest la position de rangement, et le prochain accord les fait ressortir tout seuls.", "SteppIR : un bouton Calibrer à côté de Rétracter, sur Station Control.",
"Sur le cadran du rotor, le faisceau ne fait plus un tour complet de la boussole quand lantenne franchit le nord. Un rotor à 020° tourné dans le sens antihoraire annonce 020, 010, 000, 359, 358… et lanimation 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 lautre sens. Le faisceau suit désormais un angle cumulé : ce qui est dessiné est le mouvement réel de lantenne. Les deux lobes dune antenne bidirectionnelle sont traités séparément, puisquils ne franchissent pas le nord au même moment.", "Sur le cadran du rotor, le faisceau ne fait plus le tour complet en passant le nord sur un rotor 450°.",
"Le widget rotor nannonce 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 sallumait et s’éteignait toute la soirée sur une antenne qui navait pas tourné. Ce qui distingue une rotation de la météo nest pas lamplitude 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.", "Le widget rotor nallume plus Stop quand le vent bouge lantenne, et ne s’éteint plus pendant quelle 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. 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 quen continu, ce qui, vu dune 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 quil a écartés pour cette raison sans rien garder dautre, afin que la prochaine régénération ne les passe plus sous silence.", "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 quOpsLog avait fausse peut désormais être corrigée sur une station qui possède déjà le fichier satellites. Jusquici ce fichier était copié au premier lancement puis appartenait à lopérateur, si bien quune 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 na jamais été modifié, et laisse intact ce qui la été : une entrée corrigée à la main prime sur tout ce qui est livré, et le journal nomme celles devant lesquelles il sest effacé. Le premier lancement après ce changement ne peut pas distinguer les deux : il prend donc le plan livré et copie dabord votre fichier en satellites.json.bak — ensuite vos modifications sont reconnues précisément et survivent à chaque version.", "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 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 lopérateur avait rempli — une descente 70 cm restait sur le transverter 2 m. Le journal indique désormais la bande et lantenne retenues, pour distinguer un réglage jamais fait dune recherche qui a échoué.", "Les antennes FlexRadio par bande sont appliquées aux slices satellite. Une descente 70 cm restait sur le transverter 2 m.",
"Le suivi affiche désormais où est le satellite à côté de là où pointe lantenne : azimut, élévation, distance et altitude, dans le même bandeau que les deux fréquences. L’élévation sestompe sous lhorizon, pour quun satellite encore suivi avant son lever ne soit pas pris pour un satellite travaillable.", "Le suivi affiche où est le satellite à côté de là où pointe lantenne : azimut, élévation, distance et altitude.",
"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 lordre du fichier de fréquences et celle de droite lordre des clics : retrouver un satellite parmi seize obligeait à lire les seize. Un satellite renommé lors de lattribution de son numéro OSCAR, comme LILACSAT-2 devenu LO-90, est aussi reconnu dans une liste de suivis qui porte encore lancien nom, au lieu dy apparaître sans éléments.", "Les deux listes de satellites des réglages sont triées par nom, en tenant compte des nombres. Un satellite renommé lors de lattribution 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. 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 napparaît que sil y a quelque chose derrière — et FT2 est proposé à qui lutilise 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 napparaît quune fois un carré travaillé par satellite.", "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 désormais longlet 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 longlet — ce qui ressemble exactement à un réglage qui ne sest 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 nest plus suivi passe la sélection au premier qui lest.", "Enregistrer les réglages satellite rafraîchit longlet 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, à 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 lolive du 70 cm disparaissent presque sur limagerie satellite — et elle ne sert qu’à qui surveille plusieurs bandes à la fois, ce qui nest 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.", "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 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 saffiche 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, cest demander.", "« Plus tard » sur une mise à jour demande désormais combien de temps : 1, 4, 12 ou 24 heures. Une version plus récente saffiche quand même aussitôt.",
"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 dentre eux, plus rien ne confirmait que le pylône tournait encore et le bouton s’éteignait puis se rallumait tout du long. Lentré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 quune progression continue dans ce sens : un degré du même côté nest pas une rafale quand le rotor est déjà sous tension.", "Le plan de fréquences satellite est vérifié contre les listes FM et image dAMSAT : AO-123 reçoit son ton de 67,0 Hz, SO-50 signale le ton darmement de 74,4 Hz, et huit satellites SSTV sont ajoutés.",
"Le plan de fréquences est vérifié contre les listes FM et image dAMSAT. AO-123 reçoit le ton de 67,0 Hz quexige sa montée, SO-50 indique dans son libellé que 74,4 Hz arme le minuteur avant que 67,0 puisse louvrir, 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 montrer qui VOUS entend — les stations qui rapportent vos émissions à PSK Reporter, en losanges pleins dune couleur propre. Éteint par défaut.",
"La carte FT peut désormais montrer qui VOUS entend. « Qui mentend » 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 quon 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 nenvoie rien dautre, et cela fonctionne même avec la veille douverture 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.", "Les contrôles de la carte FT ne la recouvrent plus : fonds de carte dégagés du zoom à gauche, couleur et « Qui mentend » à droite."
"« Qui mentend » naffiche 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 sestompe avec l’âge : cest 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 dune carte du monde, masquant lAtlantique 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 mentend » passent à droite.",
"Les losanges de « qui mentend » sont pleins et prennent leur propre couleur, choisie à côté de linterrupteur qui les allume. Pleins avec un liseré blanc dun pixel, le procédé quutilise déjà le marqueur de la station : un cercle creux nest quun contour tracé sur ce qui se trouve dessous, et huit pixels de contour sur limagerie 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 quil a fallu une forme pour établir."
] ]
}, },
{ {
+3
View File
@@ -6644,6 +6644,9 @@ export default function App() {
// Only while CAT is actually connected: an empty band means "nothing to // Only while CAT is actually connected: an empty band means "nothing to
// compare with", never "the rig is on no band". // compare with", never "the rig is on no band".
rigBand={catState.connected ? (catState.band || '') : ''} rigBand={catState.connected ? (catState.band || '') : ''}
// Every band the radio is listening on, so a second slice on a second
// band does not read as a decoder that has lost CAT.
rigBands={catState.connected ? (catState.rx_bands ?? []) : []}
myCall={station.callsign} myCall={station.callsign}
myGrid={station.my_grid} myGrid={station.my_grid}
// A DOUBLE click answers the station: it hands the decode back to // A DOUBLE click answers the station: it hands the decode back to
+16 -3
View File
@@ -102,6 +102,9 @@ interface Props {
// The band the RIG is on, when CAT is connected. Only ever compared with what // The band the RIG is on, when CAT is connected. Only ever compared with what
// the decoder announces — see the drift warning. // the decoder announces — see the drift warning.
rigBand?: string; rigBand?: string;
// Every band the radio has a receiver on. A Flex running two slices has
// two, and a decoder on either of them is not drifting.
rigBands?: string[];
onCall: (d: Decode) => void; onCall: (d: Decode) => void;
// A single click: take the station without transmitting — fill the entry, and // A single click: take the station without transmitting — fill the entry, and
// point the panels at it. Absent, a click falls back to onCall. // point the panels at it. Absent, a click falls back to onCall.
@@ -641,7 +644,7 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
})); }));
} }
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, myGrid, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: Props) { export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, rigBands, onCall, onSelect, myCall, myGrid, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: Props) {
const { t } = useI18n(); const { t } = useI18n();
// Column widths, dragged in the header and shared by every row. Persisted // Column widths, dragged in the header and shared by every row. Persisted
// through writeUiPref (not raw localStorage) so the layout travels with data/ // through writeUiPref (not raw localStorage) so the layout travels with data/
@@ -778,9 +781,19 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
// Said, not decided. Using the rig's band instead would be wrong for anyone // Said, not decided. Using the rig's band instead would be wrong for anyone
// decoding a second receiver on another band, and a warning costs that setup // decoding a second receiver on another band, and a warning costs that setup
// nothing but a line it can read past. // nothing but a line it can read past.
//
// Compared against every band the radio is RECEIVING on, not the transmit
// band. Two slices on two bands with a decoder on each is a normal setup,
// and it made this warning lie: with slice A on 20 m, slice B on 40 m and
// transmit focus on B, the 20 m decoder was told the rig was on 40 m while
// the slice it listens to was on 20 m all along. The warning is for a
// decoder announcing a band NOTHING on the radio is on, which is what a
// lost CAT link actually looks like.
const decoderBand = decodes.length ? (decodes[decodes.length - 1].band ?? '') : ''; const decoderBand = decodes.length ? (decodes[decodes.length - 1].band ?? '') : '';
const bandDrift = !!rigBand && !!decoderBand const onAir = (rigBands && rigBands.length ? rigBands : (rigBand ? [rigBand] : []))
&& rigBand.toLowerCase() !== decoderBand.toLowerCase(); .map((b) => b.toLowerCase());
const bandDrift = onAir.length > 0 && !!decoderBand
&& !onAir.includes(decoderBand.toLowerCase());
const driftInstance = decodes.length ? (decodes[decodes.length - 1].instance ?? '') : ''; const driftInstance = decodes.length ? (decodes[decodes.length - 1].instance ?? '') : '';
const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode; const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode;
+367 -105
View File
@@ -1,11 +1,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen, Radar, Compass } from 'lucide-react'; import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen,
PanelLeftClose, PanelLeftOpen, Radar, Compass,
ChevronDown, Clock, Crosshair, SlidersHorizontal, ListOrdered } from 'lucide-react';
import { import {
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning, GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack, GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack,
StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking, RetargetSatelliteTracking, StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking, RetargetSatelliteTracking,
SetSatUplinkTrim,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime'; import { EventsOn } from '../../wailsjs/runtime/runtime';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -58,6 +61,7 @@ type Track = {
on: boolean; name: string; transponder: string; mode: string; on: boolean; name: string; transponder: string; mode: string;
nominal_down: number; nominal_up: number; down_hz: number; up_hz: number; nominal_down: number; nominal_up: number; down_hz: number; up_hz: number;
az: number; el: number; visible: boolean; range_km: number; alt_km: number; az: number; el: number; visible: boolean; range_km: number; alt_km: number;
up_trim_hz: number;
radio: string; // "sat" | "downlink-only" | "" radio: string; // "sat" | "downlink-only" | ""
error: string; error: string;
rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean; rot_az_only: boolean; rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean; rot_az_only: boolean;
@@ -73,6 +77,40 @@ const SIDE_W_KEY = 'opslog.satSideWidth';
const SIDE_SHOWN_KEY = 'opslog.satSideShown'; const SIDE_SHOWN_KEY = 'opslog.satSideShown';
const SIDE_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720; const SIDE_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720;
const SKY_SHOWN_KEY = 'opslog.satSkyShown'; const SKY_SHOWN_KEY = 'opslog.satSkyShown';
// The sky plot's column, to the LEFT of the map.
//
// It used to sit in the readout column, where it competed for width with the
// numbers and pushed the pass table off the bottom of the screen. There was
// an empty margin on the other side of the map the whole time, and a plot
// that wants to be square is exactly what belongs in a column of its own.
const SKY_W_KEY = 'opslog.satSkyWidth';
const SKY_W_DEFAULT = 260, SKY_W_MIN = 180, SKY_W_MAX = 480;
// Each block of the readout column, open or shut, remembered separately: an
// operator working FM birds never looks at the linear passband and one
// chasing a schedule never looks at the range rate.
const SEC_KEYS = {
pass: 'opslog.satSecPass',
where: 'opslog.satSecWhere',
tune: 'opslog.satSecTune',
passes: 'opslog.satSecPasses',
} as const;
type SecId = keyof typeof SEC_KEYS;
// The ground track, drawn canvas-safe.
//
// It was `var(--info)`, and this map renders with preferCanvas: a CSS
// variable handed to a canvas strokeStyle is not a colour, so the browser
// kept whatever was set last and the track came out a pale near-white that
// vanished over the imagery and the deserts alike. Every other map in this
// app passes hex for the same reason.
//
// Drawn twice: a dark casing underneath, then the bright line on top. That is
// how a road is drawn on a map, and for the same reason — one colour cannot
// hold up over both a pale sea and a dark continent, but a colour with an
// outline can.
const TRACK_INK = '#38bdf8';
const TRACK_CASING = '#0b1220';
// Four decimals — a hundred hertz, which is what a linear transponder is // Four decimals — a hundred hertz, which is what a linear transponder is
// actually tuned to. // actually tuned to.
@@ -469,10 +507,45 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
const [sideShown, setSideShown] = useState(() => localStorage.getItem(SIDE_SHOWN_KEY) !== '0'); const [sideShown, setSideShown] = useState(() => localStorage.getItem(SIDE_SHOWN_KEY) !== '0');
useEffect(() => { writeUiPref(SIDE_W_KEY, String(Math.round(sideW))); }, [sideW]); useEffect(() => { writeUiPref(SIDE_W_KEY, String(Math.round(sideW))); }, [sideW]);
useEffect(() => { writeUiPref(SIDE_SHOWN_KEY, sideShown ? '1' : '0'); }, [sideShown]); useEffect(() => { writeUiPref(SIDE_SHOWN_KEY, sideShown ? '1' : '0'); }, [sideShown]);
const [skyW, setSkyW] = useState(() => {
const n = parseFloat(localStorage.getItem(SKY_W_KEY) || '');
return Number.isFinite(n) && n >= SKY_W_MIN && n <= SKY_W_MAX ? n : SKY_W_DEFAULT;
});
useEffect(() => { writeUiPref(SKY_W_KEY, String(Math.round(skyW))); }, [skyW]);
// Open by default, every one of them: a panel that starts shut is a feature
// nobody finds. Shutting one is a decision, and it is remembered.
const [secOpen, setSecOpen] = useState<Record<SecId, boolean>>(() => ({
pass: localStorage.getItem(SEC_KEYS.pass) !== '0',
where: localStorage.getItem(SEC_KEYS.where) !== '0',
tune: localStorage.getItem(SEC_KEYS.tune) !== '0',
passes: localStorage.getItem(SEC_KEYS.passes) !== '0',
}));
const toggleSec = (id: SecId) => setSecOpen((m) => {
const next = { ...m, [id]: !m[id] };
writeUiPref(SEC_KEYS[id], next[id] ? '1' : '0');
return next;
});
// Dragging the grip. Measured from where the pointer STARTED rather than from // Dragging the grip. Measured from where the pointer STARTED rather than from
// the container, and with the pointer captured — without the capture the map // the container, and with the pointer captured — without the capture the map
// underneath swallows the moves the instant the cursor crosses it. // underneath swallows the moves the instant the cursor crosses it.
const startSkyDrag = (e: React.PointerEvent) => {
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
const x0 = e.clientX;
const w0 = skyW;
const onMove = (ev: PointerEvent) => {
// Plus, not minus: this handle is on the right of what it resizes.
setSkyW(Math.min(SKY_W_MAX, Math.max(SKY_W_MIN, Math.round(w0 + (ev.clientX - x0)))));
};
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
};
const startSideDrag = (e: React.PointerEvent) => { const startSideDrag = (e: React.PointerEvent) => {
e.preventDefault(); e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
@@ -562,7 +635,10 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
if (track.length > 1) { if (track.length > 1) {
const pts = splitAtAntimeridian(track.map((p) => [p.lat, p.lon] as [number, number])); const pts = splitAtAntimeridian(track.map((p) => [p.lat, p.lon] as [number, number]));
L.polyline(pts as L.LatLngExpression[][], { L.polyline(pts as L.LatLngExpression[][], {
color: 'var(--info)', weight: 1.2, opacity: 0.7, dashArray: '4 4', smoothFactor: 0, color: TRACK_CASING, weight: 4, opacity: 0.4, smoothFactor: 0, interactive: false,
}).addTo(layer);
L.polyline(pts as L.LatLngExpression[][], {
color: TRACK_INK, weight: 1.8, opacity: 0.95, dashArray: '5 4', smoothFactor: 0, interactive: false,
}).addTo(layer); }).addTo(layer);
} }
const wanted = new Set(shown.map((b) => b.name)); const wanted = new Set(shown.map((b) => b.name));
@@ -784,13 +860,18 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
> >
{Object.entries(BASEMAPS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)} {Object.entries(BASEMAPS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select> </select>
{/* The left column, with the same kind of control as the right one.
It was a Radar icon, from when this toggled a plot inside the
readout column. It now opens and shuts a column of the window, so
it says so the way the other one does — the two are the same
gesture and an operator should not have to learn them twice. */}
<Button <Button
variant="ghost" size="sm" variant="ghost" size="sm" className="h-7 px-1.5"
className={cn('h-7 px-1.5', skyShown && 'text-success')}
onClick={() => setSkyShown((v) => !v)} onClick={() => setSkyShown((v) => !v)}
title={skyShown ? t('sat.hideSky') : t('sat.showSky')} title={skyShown ? t('sat.hideSky') : t('sat.showSky')}
> >
<Radar className="size-3.5" /> {skyShown ? <PanelLeftClose className="size-3.5" /> : <PanelLeftOpen className="size-3.5" />}
</Button> </Button>
{/* Put the whole window on the map. On a laptop the readout takes a {/* Put the whole window on the map. On a laptop the readout takes a
third of the screen, and there are moments — watching a footprint third of the screen, and there are moments — watching a footprint
@@ -807,80 +888,23 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
{err && <div className="px-2 text-[11px] text-danger shrink-0">{err}</div>} {err && <div className="px-2 text-[11px] text-danger shrink-0">{err}</div>}
<div className="flex gap-1 flex-1 min-h-0"> <div className="flex gap-1 flex-1 min-h-0">
{/* The map. isolate is load-bearing, not tidiness: Leaflet stacks its {/* WHERE IT IS: the sky plot and the position, in a column of their
own panes and controls up to z-index 1000, which without a stacking own to the left of the map.
context of their own float over Preferences and every dialog in the
app — the map ends up on top of the very buttons that would close
it. */}
<div className="relative isolate z-0 flex-1 min-w-0 rounded-lg overflow-hidden border border-border">
<div ref={divRef} className="h-full w-full" />
</div>
{sideShown && ( The plot is the sky seen from underneath — the centre is
<div straight up, the rim is the horizon, north is at the top — and
role="separator" one glance says whether the pass comes over the roof or along
aria-orientation="vertical" the treeline. The numbers below it are the same answer to the
title={t('sat.sideWidthTip')} digit: azimuth, elevation, distance, height. They belong
onPointerDown={startSideDrag} together, and having them apart meant reading a bearing off one
onDoubleClick={() => setSideW(SIDE_W_DEFAULT)} side of the window and finding it on the other.
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')} Both were in the readout column, where a plot that wants to be
style={{ width: sideW }}> square competed for width with everything else and pushed the
{/* The pass. The first thing an operator looks at and the reason they pass table off the bottom of the screen — while the margin on
sit down: how long have I got, and how high does it get. */} this side of the map sat empty the whole time. */}
<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. */}
{skyShown && ( {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"> <div className="rounded-lg border border-border bg-card p-2">
<SkyPlot <SkyPlot
track={sky} track={sky}
@@ -890,10 +914,21 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
visible={!!tuning?.visible} visible={!!tuning?.visible}
/> />
</div> </div>
)}
{/* Where it is, right now. */} {/* Where it is, right now. Its elevation goes in the heading: above
<div className="rounded-lg border border-border bg-card p-2"> 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"> <div className="grid grid-cols-2 gap-2">
<Readout label={t('sat.az')} value={tuning ? fmtDeg(tuning.az) : '—'} <Readout label={t('sat.az')} value={tuning ? fmtDeg(tuning.az) : '—'}
sub={tuning ? compass(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>} {!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
</div> </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> </div>
{/* What to tune. */} {sideShown && (
<div className="rounded-lg border border-border bg-card p-2"> <div
{/* The mode leads, because it decides everything below it. */} role="separator"
<div className="flex items-center gap-1.5 mb-1.5"> 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} /> <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> <span className="text-[11px] text-muted-foreground truncate">{tp?.label ?? '—'}</span>
<div className="flex-1" /> <div className="flex-1" />
{tp?.inverting && ( {tp?.inverting && <Pill tone="warning" title={t('sat.invertingHint')}>{t('sat.inverting')}</Pill>}
<span className="shrink-0 text-[10px] uppercase tracking-wide text-warning">{t('sat.inverting')}</span> {tp?.linear && <Pill tone="info">{Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz</Pill>}
)} {bird?.geostationary && <Pill>{t('sat.geo')}</Pill>}
{tp?.linear && (
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">
{Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz
</span>
)}
{bird?.geostationary && (
<span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground">{t('sat.geo')}</span>
)}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@@ -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} /> <FreqRow label={t('sat.up')} hz={tuning?.up_hz ?? 0} nominal={tuning?.nominal_up ?? 0} />
</div> </div>
{/* The uplink correction, when there is one.
It is taken from the transmit VFO without being asked for, so it
has to be visible: an offset nobody can see is a trap, and the
VFO alone cannot bring it back to zero once the operator has
drifted somewhere wrong. Hence the reset. */}
{!!tracking?.on && !!tracking.up_trim_hz && (
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-center gap-2">
<span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">
{t('sat.upTrim')}
</span>
<Pill tone="caution" title={t('sat.upTrimHint')}>
{tracking.up_trim_hz > 0 ? '+' : ''}{(tracking.up_trim_hz / 1000).toFixed(2)} kHz
</Pill>
<div className="flex-1" />
<button type="button"
className="text-[11px] text-muted-foreground hover:text-foreground"
title={t('sat.upTrimReset')}
onClick={() => { if (bird) void SetSatUplinkTrim(bird.name, tpIdx, 0).catch(() => {}); }}>
</button>
</div>
)}
{/* The tone, on the FM birds, with the same weight as a frequency. {/* The tone, on the FM birds, with the same weight as a frequency.
It IS one, as far as the outcome goes: a repeater called without It IS one, as far as the outcome goes: a repeater called without
its tone does not answer, and the operator hears an empty its tone does not answer, and the operator hears an empty
@@ -990,14 +1151,18 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
)} )}
</div> </div>
)} )}
</div> </Section>
{/* What is coming. */} {/* What is coming. */}
<div className="rounded-lg border border-border bg-card flex-1 min-h-0 flex flex-col"> <Section
<div className="px-2 py-1 text-[11px] font-medium text-muted-foreground border-b border-border shrink-0"> title={t('sat.nextPasses')}
{t('sat.nextPasses')} icon={ListOrdered}
</div> open={secOpen.passes}
<div className="flex-1 min-h-0 overflow-auto"> onToggle={() => toggleSec('passes')}
grow
right={passes.length > 0 ? <Pill>{passes.length}</Pill> : undefined}
>
<div className="min-h-0">
{passes.length === 0 && idle.length === 0 && ( {passes.length === 0 && idle.length === 0 && (
<div className="p-2 text-[11px] text-muted-foreground">{t('sat.noPasses')}</div> <div className="p-2 text-[11px] text-muted-foreground">{t('sat.noPasses')}</div>
)} )}
@@ -1077,7 +1242,7 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
</table> </table>
)} )}
</div> </div>
</div> </Section>
</div> </div>
</div> </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 ( return (
<div className="min-w-0"> <div className="min-w-0">
<div className="text-[10px] uppercase tracking-wide text-muted-foreground truncate">{label}</div> <div className="text-[10px] uppercase tracking-wide text-muted-foreground truncate">{label}</div>
<div className={cn('truncate', strong && 'font-semibold text-foreground')}>{value}</div> <div className={cn('truncate', strong && 'font-semibold', valueClass ?? (strong ? 'text-foreground' : undefined))}>{value}</div>
</div> </div>
); );
} }
@@ -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 // The corrected frequency large, the nominal one small beside it. Showing only
// one of them leaves an operator unable to tell a Doppler correction from a // one of them leaves an operator unable to tell a Doppler correction from a
// mistuned transponder. // mistuned transponder.
// satBand names the band a satellite frequency is in, for the badge.
//
// Only the bands satellites actually use, and by inspection rather than by
// asking the backend: it is a label beside a number that is already on
// screen, not a fact anything depends on.
function satBand(hz: number): string {
const mhz = hz / 1e6;
if (mhz >= 28 && mhz < 30) return '10m';
if (mhz >= 144 && mhz < 148) return '2m';
if (mhz >= 420 && mhz < 450) return '70cm';
if (mhz >= 1240 && mhz < 1300) return '23cm';
if (mhz >= 2300 && mhz < 2450) return '13cm';
if (mhz >= 10450 && mhz < 10500) return '3cm';
return '';
}
// FreqRow is the frequency to TUNE TO — the centre of the passband, not the
// Doppler-corrected one.
//
// It used to lead with the corrected figure, which is the wrong number to
// put in a reference panel: it moves every second, it is different for
// every operator, and it is not what the frequency plan, the AMSAT tables
// or anybody on the air calls the satellite's frequency. What the radio is
// actually on belongs beside Tracking, where the radio is, and that is
// where it now lives. The correction is still shown here, as the OFFSET
// that explains the difference between the two.
function FreqRow({ label, hz, nominal }: { label: string; hz: number; nominal: number }) { function FreqRow({ label, hz, nominal }: { label: string; hz: number; nominal: number }) {
const { t } = useI18n();
const shift = hz && nominal ? hz - nominal : 0; const shift = hz && nominal ? hz - nominal : 0;
const centre = nominal || hz;
const band = satBand(centre);
return ( return (
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span> <span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span>
<span className="text-base font-semibold tabular-nums">{fmtHz(hz)}</span> <span className="text-base font-semibold tabular-nums">{fmtHz(centre)}</span>
{!!band && <Pill className="self-center">{band}</Pill>}
<div className="flex-1" />
{!!shift && ( {!!shift && (
<span className="text-[10px] tabular-nums text-muted-foreground">{fmtShift(shift)}</span> <Pill tone={shift > 0 ? 'success' : 'caution'} className="self-center" title={t('sat.shiftHint')}>
{fmtShift(shift)}
</Pill>
)} )}
</div> </div>
); );
File diff suppressed because one or more lines are too long
+32 -2
View File
@@ -186,8 +186,38 @@ export function greatCirclePoints(
// opposite edge, at the SAME latitude, so the line leaves one side of the map // opposite edge, at the SAME latitude, so the line leaves one side of the map
// and re-enters the other at the height it left. Leaflet takes the result as a // and re-enters the other at the height it left. Leaflet takes the result as a
// multi-polyline, so one path is still one layer. // multi-polyline, so one path is still one layer.
export function splitAtAntimeridian(pts: [number, number][]): [number, number][][] { // unwrapLon makes a longitude series continuous.
if (pts.length === 0) return []; //
// splitAtAntimeridian works from which COPY of the world each longitude is in,
// which requires the series to run past ±180 rather than jumping back. A great
// circle built by greatCirclePoints already does; a satellite ground track does
// not — SGP4 reports every longitude inside (180, 180], so a track leaving
// Kamchatka at +179.9 and arriving in Alaska at 179.9 looked to the splitter
// like one step of 359.8° inside a single world. No split was made and the
// polyline drew the chord: a straight dashed line clean across the map, from
// one side of the planet to the other, on every crossing.
//
// A jump of more than 180° between two consecutive points is that wrap and
// nothing else: no real path steps half the globe between samples. Idempotent
// on a series that was already continuous, so both callers can share it.
function unwrapLon(pts: [number, number][]): [number, number][] {
if (pts.length < 2) return pts;
const out: [number, number][] = [pts[0]];
let turns = 0;
for (let i = 1; i < pts.length; i++) {
const [lat, lon] = pts[i];
const prevRaw = pts[i - 1][1];
const d = lon - prevRaw;
if (d > 180) turns -= 1;
else if (d < -180) turns += 1;
out.push([lat, lon + 360 * turns]);
}
return out;
}
export function splitAtAntimeridian(input: [number, number][]): [number, number][][] {
if (input.length === 0) return [];
const pts = unwrapLon(input);
// Which copy of the world a longitude belongs to: 0 is the map's own. // Which copy of the world a longitude belongs to: 0 is the map's own.
const world = (lon: number) => Math.floor((lon + 180) / 360); const world = (lon: number) => Math.floor((lon + 180) / 360);
const norm = (lon: number) => lon - 360 * world(lon); const norm = (lon: number) => lon - 360 * world(lon);
+4
View File
@@ -600,6 +600,8 @@ export function GetSPEStatus():Promise<spe.Status>;
export function GetSatSettings():Promise<main.SatSettings>; export function GetSatSettings():Promise<main.SatSettings>;
export function GetSatUplinkTrim(arg1:string,arg2:number):Promise<number>;
export function GetSatelliteBirds():Promise<Array<main.SatBird>>; export function GetSatelliteBirds():Promise<Array<main.SatBird>>;
export function GetSatelliteGroundTrack(arg1:string,arg2:number):Promise<Array<sat.Position>>; export function GetSatelliteGroundTrack(arg1:string,arg2:number):Promise<Array<sat.Position>>;
@@ -1296,6 +1298,8 @@ export function SetPSUOutput(arg1:boolean):Promise<void>;
export function SetPassphrase(arg1:string):Promise<void>; export function SetPassphrase(arg1:string):Promise<void>;
export function SetSatUplinkTrim(arg1:string,arg2:number,arg3:number):Promise<void>;
export function SetScpClublogEnabled(arg1:boolean):Promise<void>; export function SetScpClublogEnabled(arg1:boolean):Promise<void>;
export function SetScpEnabled(arg1:boolean):Promise<void>; export function SetScpEnabled(arg1:boolean):Promise<void>;
+8
View File
@@ -1130,6 +1130,10 @@ export function GetSatSettings() {
return window['go']['main']['App']['GetSatSettings'](); return window['go']['main']['App']['GetSatSettings']();
} }
export function GetSatUplinkTrim(arg1, arg2) {
return window['go']['main']['App']['GetSatUplinkTrim'](arg1, arg2);
}
export function GetSatelliteBirds() { export function GetSatelliteBirds() {
return window['go']['main']['App']['GetSatelliteBirds'](); return window['go']['main']['App']['GetSatelliteBirds']();
} }
@@ -2522,6 +2526,10 @@ export function SetPassphrase(arg1) {
return window['go']['main']['App']['SetPassphrase'](arg1); return window['go']['main']['App']['SetPassphrase'](arg1);
} }
export function SetSatUplinkTrim(arg1, arg2, arg3) {
return window['go']['main']['App']['SetSatUplinkTrim'](arg1, arg2, arg3);
}
export function SetScpClublogEnabled(arg1) { export function SetScpClublogEnabled(arg1) {
return window['go']['main']['App']['SetScpClublogEnabled'](arg1); return window['go']['main']['App']['SetScpClublogEnabled'](arg1);
} }
+4
View File
@@ -1154,6 +1154,7 @@ export namespace cat {
split?: boolean; split?: boolean;
mode?: string; mode?: string;
band?: string; band?: string;
rx_bands?: string[];
vfo?: string; vfo?: string;
error?: string; error?: string;
// Go type: time // Go type: time
@@ -1175,6 +1176,7 @@ export namespace cat {
this.split = source["split"]; this.split = source["split"];
this.mode = source["mode"]; this.mode = source["mode"];
this.band = source["band"]; this.band = source["band"];
this.rx_bands = source["rx_bands"];
this.vfo = source["vfo"]; this.vfo = source["vfo"];
this.error = source["error"]; this.error = source["error"];
this.updated_at = this.convertValues(source["updated_at"], null); this.updated_at = this.convertValues(source["updated_at"], null);
@@ -4300,6 +4302,7 @@ export namespace main {
visible: boolean; visible: boolean;
range_km: number; range_km: number;
alt_km: number; alt_km: number;
up_trim_hz: number;
radio: string; radio: string;
error: string; error: string;
rot_on: boolean; rot_on: boolean;
@@ -4327,6 +4330,7 @@ export namespace main {
this.visible = source["visible"]; this.visible = source["visible"];
this.range_km = source["range_km"]; this.range_km = source["range_km"];
this.alt_km = source["alt_km"]; this.alt_km = source["alt_km"];
this.up_trim_hz = source["up_trim_hz"];
this.radio = source["radio"]; this.radio = source["radio"];
this.error = source["error"]; this.error = source["error"];
this.rot_on = source["rot_on"]; this.rot_on = source["rot_on"];
+60 -2
View File
@@ -60,6 +60,16 @@ type RigState struct {
Split bool `json:"split,omitempty"` // rig is in split mode Split bool `json:"split,omitempty"` // rig is in split mode
Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY) Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY)
Band string `json:"band,omitempty"` // computed from FreqHz Band string `json:"band,omitempty"` // computed from FreqHz
// 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" Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB"
Error string `json:"error,omitempty"` // last connect/poll error if any Error string `json:"error,omitempty"` // last connect/poll error if any
UpdatedAt time.Time `json:"updated_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty"`
@@ -584,9 +594,9 @@ type FlexController interface {
SetMute(bool) error SetMute(bool) error
SetRXAntenna(string) error SetRXAntenna(string) error
SetTXAntenna(string) error SetTXAntenna(string) error
// SatAntennas sets the antenna on each SATELLITE slice — they are on two // SatAntennas sets BOTH antennas on each SATELLITE slice — they are on two
// different bands and, with transverters, two different ports. // different bands and, with transverters, two different ports.
SatAntennas(rxAnt, txAnt string) error SatAntennas(downRX, downTX, upRX, upTX string) error
// SatTone sets the CTCSS tone the satellite uplink transmits (0 = off). // SatTone sets the CTCSS tone the satellite uplink transmits (0 = off).
SatTone(hz float64) error SatTone(hz float64) error
SetActiveSlice(int) error // focus slice idx so commands target it SetActiveSlice(int) error // focus slice idx so commands target it
@@ -859,6 +869,18 @@ type SatTuner interface {
// the input the whole tracker works from — without reading it back, a // the input the whole tracker works from — without reading it back, a
// tracker fights the operator instead of helping them. // tracker fights the operator instead of helping them.
SatReceiveHz() (int64, error) SatReceiveHz() (int64, error)
// SatTransmitHz is where the TRANSMITTER actually is, for the same reason.
//
// A transponder does not translate by exactly the published difference —
// the oscillator on board is decades old on some birds — so an operator
// who sounds right to themselves comes back off frequency, and corrects it
// by ear on the transmit VFO. Without reading that back the tracker undoes
// the correction on its next tick, once a second, for the whole pass.
//
// Zero with no error means "this radio cannot say": a single-receiver rig
// working split has nothing to report, and the tracker then leaves the
// uplink entirely to the arithmetic, as before.
SatTransmitHz() (int64, error)
} }
// SatCapable reports whether the active backend can hold a satellite pair. // SatCapable reports whether the active backend can hold a satellite pair.
@@ -1002,11 +1024,47 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
if ns.FreqHz != 0 && ns.Band == "" { if ns.FreqHz != 0 && ns.Band == "" {
ns.Band = BandFromHz(ns.FreqHz) ns.Band = BandFromHz(ns.FreqHz)
} }
ns.RxBands = m.rxBands(ns)
m.update(ns) m.update(ns)
} }
} }
} }
// rxBands lists the bands the radio has a receiver on.
//
// Asked of the backend rather than derived from the state, because only the
// backend knows: a Flex reports its slices, and everything else has exactly
// one receiver whose band is already in the state. The transmit band is
// always included, even on a Flex, so this can never come back empty while
// the rig is on a frequency.
func (m *Manager) rxBands(st RigState) []string {
seen := map[string]bool{}
var out []string
add := func(b string) {
if b = strings.ToLower(strings.TrimSpace(b)); b != "" && !seen[b] {
seen[b] = true
out = append(out, b)
}
}
add(st.Band)
if st.RxFreqHz != 0 {
add(BandFromHz(st.RxFreqHz))
}
if fx, ok := m.FlexState(); ok {
for _, sl := range fx.Slices {
if sl.Band != "" {
add(sl.Band)
continue
}
// A slice the radio has reported a frequency but no band for.
if sl.FreqHz != 0 {
add(BandFromHz(sl.FreqHz))
}
}
}
return out
}
func (m *Manager) applyCommandDelay() { func (m *Manager) applyCommandDelay() {
m.mu.RLock() m.mu.RLock()
d := m.cmdDelay d := m.cmdDelay
+6 -2
View File
@@ -80,8 +80,12 @@ type Flex struct {
// before the radio has necessarily reported the slice it was asked to // before the radio has necessarily reported the slice it was asked to
// create; without this they were applied to an index of -1 and never again. // create; without this they were applied to an index of -1 and never again.
satUpMode string satUpMode string
satRXAnt string // Both antennas of both slices: a slice has an rxant and a txant, and each
satTXAnt string // pair belongs to that slice's own band.
satDownRX string
satDownTX string
satUpRX string
satUpTX string
satTone float64 satTone float64
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click) spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode) spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
+46 -20
View File
@@ -155,11 +155,13 @@ func (f *Flex) adoptSatSlice(role string, idx int) {
// antenna and the tone are all sent ONCE — the step only re-sends // antenna and the tone are all sent ONCE — the step only re-sends
// frequencies. // frequencies.
f.mu.Lock() f.mu.Lock()
mode, ant, tone := f.satUpMode, f.satTXAnt, f.satTone mode, rxAnt, txAnt, tone := f.satUpMode, f.satUpRX, f.satUpTX, f.satTone
f.mu.Unlock() f.mu.Unlock()
if strings.TrimSpace(ant) != "" { if strings.TrimSpace(rxAnt) != "" {
f.send(fmt.Sprintf("slice s %d txant=%s", idx, ant)) f.send(fmt.Sprintf("slice s %d rxant=%s", idx, rxAnt))
f.send(fmt.Sprintf("slice s %d rxant=%s", idx, ant)) }
if strings.TrimSpace(txAnt) != "" {
f.send(fmt.Sprintf("slice s %d txant=%s", idx, txAnt))
} }
if strings.TrimSpace(mode) != "" { if strings.TrimSpace(mode) != "" {
f.satMode(idx, mode, 0) f.satMode(idx, mode, 0)
@@ -259,7 +261,22 @@ func (f *Flex) SatReceiveHz() (int64, error) {
return s.freqHz, nil return s.freqHz, nil
} }
// SatAntennas selects the antenna each satellite slice uses. // SatTransmitHz is where the uplink slice sits. From the cache, like the
// downlink: SmartSDR pushes every slice change as it happens.
func (f *Flex) SatTransmitHz() (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.satTX < 0 {
return 0, nil // no uplink slice: nothing to report, not an error
}
s := f.slices[f.satTX]
if s == nil || !s.inUse {
return 0, nil
}
return s.freqHz, nil
}
// SatAntennas selects both antennas of each satellite slice.
// //
// The two slices are on two different bands — a V/U bird receives on 70 cm and // The two slices are on two different bands — a V/U bird receives on 70 cm and
// transmits on 2 m, a U/V one does the reverse — so they cannot share one // transmits on 2 m, a U/V one does the reverse — so they cannot share one
@@ -274,30 +291,39 @@ func (f *Flex) SatReceiveHz() (int64, error) {
// Empty strings are left alone: an operator who has configured 2 m and not // Empty strings are left alone: an operator who has configured 2 m and not
// 70 cm should keep whatever the radio already had on the other side rather // 70 cm should keep whatever the radio already had on the other side rather
// than have it cleared. // than have it cleared.
func (f *Flex) SatAntennas(rxAnt, txAnt string) error { // A slice has an rxant AND a txant, and both belong to the slice's own band.
// Only two of the four were being set — the downlink's receive antenna and
// the uplink's transmit one — which left the downlink slice with an empty
// txant. It never keys, so nothing was wrong on the air, but the slice was
// half-configured: move transmit focus to it and the radio uses whatever
// antenna it was last left on.
func (f *Flex) SatAntennas(downRX, downTX, upRX, upTX string) error {
f.mu.Lock() f.mu.Lock()
rx, tx := f.satRX, f.satTX rx, tx := f.satRX, f.satTX
connected := f.conn != nil connected := f.conn != nil
// Remembered so a slice that is reported late still gets its antenna. // Remembered so a slice that is reported late still gets its antennas.
f.satRXAnt, f.satTXAnt = rxAnt, txAnt f.satDownRX, f.satDownTX = downRX, downTX
f.satUpRX, f.satUpTX = upRX, upTX
f.mu.Unlock() f.mu.Unlock()
if !connected { if !connected {
return fmt.Errorf("flex: not connected") return fmt.Errorf("flex: not connected")
} }
// The downlink slice is the one being listened to, so it takes the receive set := func(idx int, which, rxAnt, txAnt string) {
// antenna; the uplink slice is the one keyed, so it takes the transmit one. if idx < 0 {
if rx >= 0 && strings.TrimSpace(rxAnt) != "" { return
f.send(fmt.Sprintf("slice s %d rxant=%s", rx, rxAnt))
applog.Printf("flex: satellite downlink slice %d on antenna %s", rx, rxAnt)
} }
if tx >= 0 && strings.TrimSpace(txAnt) != "" { if strings.TrimSpace(rxAnt) != "" {
f.send(fmt.Sprintf("slice s %d txant=%s", tx, txAnt)) f.send(fmt.Sprintf("slice s %d rxant=%s", idx, rxAnt))
// 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(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 return nil
} }
+27
View File
@@ -142,6 +142,33 @@ func (b *IcomSerial) SatReceiveHz() (int64, error) {
return b.readFreq() return b.readFreq()
} }
// SatTransmitHz is where the transmitter is now.
//
// On a rig with native satellite mode the uplink is the SUB band, so this is
// the same dance TuneSatellite does to write it: select SUB, read, and go
// back to MAIN whatever happens. Leaving the rig on SUB would have every
// band-dependent thing in OpsLog — the log, the antenna, the amplifier —
// follow the transmitter onto the wrong band.
//
// Not attempted while transmitting: the operator is not turning the knob
// mid-over, and switching bands under a carrier is not something to do to
// somebody else's radio.
func (b *IcomSerial) SatTransmitHz() (int64, error) {
if !b.satNative {
// Split on one band. The rig reports one frequency and it is the
// receiver's; there is nothing to read.
return 0, nil
}
if err := b.exec(civ.CmdVFO, civ.SubVFOSub); err != nil {
return 0, fmt.Errorf("icom: could not select the sub band: %w", err)
}
hz, err := b.readFreq()
if merr := b.exec(civ.CmdVFO, civ.SubVFOMain); merr != nil {
applog.Printf("icom: could not return to the main band: %v", merr)
}
return hz, err
}
// tuneSatSingleBand is every other Icom: one receiver, one band. // tuneSatSingleBand is every other Icom: one receiver, one band.
// //
// The downlink is set, because that is what the operator is listening to. The // The downlink is set, because that is what the operator is listening to. The
+66
View File
@@ -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
}
+9 -2
View File
@@ -1,11 +1,18 @@
// Package dcu1 drives rotator controllers that speak the Hy-Gain DCU-1 protocol, // Package dcu1 drives rotator controllers that speak the Hy-Gain DCU-1 protocol,
// over a serial COM port (or a raw TCP socket, e.g. a serial-over-IP bridge). // over a serial COM port (or a raw TCP socket, e.g. a serial-over-IP bridge).
// //
// DCU-1 is used by the Hy-Gain DCU-1, the Idiom Press Rotor-EZ, Green Heron // DCU-1 is used by the Hy-Gain DCU-1, the Green Heron RT-21, the Idiom Press
// controllers, and the RotorCard DXA (hamsupply) for Yaesu DXA rotors. It is a // Rotor-EZ, and the RotorCard DXA (hamsupply) for Yaesu DXA rotors. It is a
// DIFFERENT command set from Yaesu GS-232 (see internal/rotator/gs232): // DIFFERENT command set from Yaesu GS-232 (see internal/rotator/gs232):
// semicolon-terminated, azimuth only. // semicolon-terminated, azimuth only.
// //
// The RT-21 selects its protocol on the controller, and only its DCU-1 /
// Rotor-EZ setting is this one — an RT-21 left on GS-232 belongs to the gs232
// package instead. With the Ethernet option it is a TCP endpoint in its own
// right, so the TCP transport below reaches it without a serial-over-IP
// bridge. Its NATIVE Green Heron protocol is a third command set, with 0.1°
// readback and a real stop, and is not implemented here.
//
// Commands (';' terminated — roundTrip appends the ';'): // Commands (';' terminated — roundTrip appends the ';'):
// //
// AP1nnn set the target bearing nnn (000-359) // AP1nnn set the target bearing nnn (000-359)
+61 -2
View File
@@ -19,7 +19,7 @@ func TestAJustCommandedMoveReportsMotion(t *testing.T) {
} }
c.statusMu.Lock() c.statusMu.Lock()
c.moveCmdAt = time.Now() c.moveUntil = time.Now().Add(moveOptimisticWindow)
c.statusMu.Unlock() c.statusMu.Unlock()
st, _ := c.GetStatus() st, _ := c.GetStatus()
@@ -33,9 +33,68 @@ func TestAJustCommandedMoveReportsMotion(t *testing.T) {
// Bounded: an antenna that never reports motion must not latch the transmit // Bounded: an antenna that never reports motion must not latch the transmit
// inhibit on for ever. // inhibit on for ever.
c.statusMu.Lock() c.statusMu.Lock()
c.moveCmdAt = time.Now().Add(-moveOptimisticWindow - time.Second) c.moveUntil = time.Now().Add(-time.Second)
c.statusMu.Unlock() c.statusMu.Unlock()
if st, _ := c.GetStatus(); st.MotorsMoving != 0 { if st, _ := c.GetStatus(); st.MotorsMoving != 0 {
t.Error("the optimistic window never expires") t.Error("the optimistic window never expires")
} }
} }
// Retract and Calibrate move every element, and they were the two commands that
// reported nothing at all: no "moving" on screen, the poll left on its
// two-second idle cadence instead of speeding up to watch, and no transmit
// inhibit while the elements travelled. Reported by an operator whose retract
// looked inert next to an Ultrabeam's.
func TestRetractAndCalibrateReportMotion(t *testing.T) {
for _, tc := range []struct {
name string
call func(*Client) error
}{
{"retract", (*Client).Retract},
{"calibrate", (*Client).Calibrate},
} {
c := &Client{}
c.lastStatus = &Status{Connected: true, Frequency: 14074}
// No connection, so the write fails and the command returns an error —
// which is the point: a command that did NOT reach the controller must
// not claim the antenna is moving.
if err := tc.call(c); err == nil {
t.Fatalf("%s: expected an error with no connection", tc.name)
}
if st, _ := c.GetStatus(); st.MotorsMoving != 0 {
t.Errorf("%s: a command that failed to send reports motion", tc.name)
}
// And with the write accepted, motion is reported at once.
c.markMoving(retractOptimisticWindow)
if st, _ := c.GetStatus(); st.MotorsMoving == 0 {
t.Errorf("%s: a commanded move is not reported as motion", tc.name)
}
}
}
// The retract bridge is longer than the tune bridge: winding every element into
// its hub takes tens of seconds, and a retract drops the controller out of
// AUTOTRACK, where its motor-bit reporting is less predictable.
func TestRetractBridgeOutlastsTheTuneBridge(t *testing.T) {
if retractOptimisticWindow <= moveOptimisticWindow {
t.Errorf("retract window %v is not longer than the tune window %v",
retractOptimisticWindow, moveOptimisticWindow)
}
// Bounded all the same — see the note on the constant.
if retractOptimisticWindow > time.Minute {
t.Errorf("retract window %v could latch the transmit inhibit on", retractOptimisticWindow)
}
}
// markMoving extends, never shortens: a tune issued a moment after a retract
// must not cut the retract's bridge down to the tune's.
func TestMarkMovingOnlyExtends(t *testing.T) {
c := &Client{}
c.markMoving(retractOptimisticWindow)
far := c.moveUntil
c.markMoving(moveOptimisticWindow)
if c.moveUntil.Before(far) {
t.Error("a shorter bridge shortened a longer one")
}
}
+47 -6
View File
@@ -84,6 +84,16 @@ const (
// that never reports motion cannot latch the inhibit on for ever. // that never reports motion cannot latch the inhibit on for ever.
const moveOptimisticWindow = 3 * time.Second const moveOptimisticWindow = 3 * time.Second
// retractOptimisticWindow is the same bridge for a RETRACT or a CALIBRATE.
//
// Longer, because those are the two longest movements the antenna makes —
// tens of seconds to wind every element into its hub — and because a retract
// drops the controller out of AUTOTRACK, which is a state its motor-bit
// reporting is less predictable in. Still bounded, for the reason above: an
// antenna that never reports motion must not latch the transmit inhibit on
// for ever.
const retractOptimisticWindow = 10 * time.Second
// Transport says how to reach the controller. // Transport says how to reach the controller.
type Transport struct { type Transport struct {
Mode string // "tcp" | "serial" Mode string // "tcp" | "serial"
@@ -122,8 +132,11 @@ type Client struct {
statusMu sync.RWMutex statusMu sync.RWMutex
lastStatus *Status lastStatus *Status
lastSetKHz int lastSetKHz int
// moveCmdAt is when a move was last COMMANDED — see moveOptimisticWindow. // moveUntil is how long a commanded move is reported as moving without the
moveCmdAt time.Time // controller having said so — see moveOptimisticWindow. A DEADLINE rather
// than the command's timestamp, because a retract needs a longer bridge than
// a tune and the caller is what knows which it asked for.
moveUntil time.Time
// lastDriftKHz is the frequency last reported for a controller that had gone // lastDriftKHz is the frequency last reported for a controller that had gone
// somewhere other than where it was told, so the disagreement is stated once // somewhere other than where it was told, so the disagreement is stated once
// and not on every poll. Zero when it is where it should be. // and not on every poll. Zero when it is where it should be.
@@ -225,7 +238,25 @@ func (c *Client) GetStatus() (*Status, error) {
// movingOptimisticallyLocked reports a move commanded too recently for the // movingOptimisticallyLocked reports a move commanded too recently for the
// controller to have answered. Callers hold statusMu. // controller to have answered. Callers hold statusMu.
func (c *Client) movingOptimisticallyLocked() bool { func (c *Client) movingOptimisticallyLocked() bool {
return !c.moveCmdAt.IsZero() && time.Since(c.moveCmdAt) < moveOptimisticWindow return !c.moveUntil.IsZero() && time.Now().Before(c.moveUntil)
}
// markMoving reports motion for d, bridging the gap until the controller says
// so itself.
//
// Every command that MOVES something has to call this. Retract and Calibrate
// did not, and they are the two that need it most: nothing on screen said the
// elements were moving, the poll stayed on its two-second idle cadence
// instead of speeding up to watch, and the transmit inhibit was not engaged
// while the elements travelled. An operator reported the retract as showing
// nothing at all, next to an Ultrabeam that shows its element lengths
// counting down.
func (c *Client) markMoving(d time.Duration) {
c.statusMu.Lock()
if until := time.Now().Add(d); until.After(c.moveUntil) {
c.moveUntil = until
}
c.statusMu.Unlock()
} }
// MovingOptimistically is the same question from outside the lock — the poll // MovingOptimistically is the same question from outside the lock — the poll
@@ -693,7 +724,9 @@ func (c *Client) SetFrequency(freqKhz int, direction int) error {
c.statusMu.Lock() c.statusMu.Lock()
c.lastSetKHz = freqKhz c.lastSetKHz = freqKhz
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
c.moveCmdAt = time.Now() // report motion at once — see moveOptimisticWindow if until := time.Now().Add(moveOptimisticWindow); until.After(c.moveUntil) {
c.moveUntil = until // report motion at once — see moveOptimisticWindow
}
c.statusMu.Unlock() c.statusMu.Unlock()
return nil return nil
} }
@@ -726,7 +759,11 @@ func (c *Client) Retract() error {
khz = 14000 // any in-range value; the controller just homes khz = 14000 // any in-range value; the controller just homes
} }
} }
return c.writeCmd(buildSet(khz*1000, DirNormal, 'S')) if err := c.writeCmd(buildSet(khz*1000, DirNormal, 'S')); err != nil {
return err
}
c.markMoving(retractOptimisticWindow)
return nil
} }
// portBusyHint turns "Serial port busy" into something actionable — see the // portBusyHint turns "Serial port busy" into something actionable — see the
@@ -764,5 +801,9 @@ func (c *Client) Calibrate() error {
khz = 14000 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
} }
+12 -1
View File
@@ -894,10 +894,21 @@ func (c *Client) SetDirection(direction int) error {
} }
// Retract retracts all elements (command 2) // Retract retracts all elements (command 2)
//
// Reports motion at once, like a frequency change does: this is the longest
// move the antenna makes, and the flag it sets is what inhibits the
// transmitter while the elements travel. The element lengths counting down
// made the omission less visible here than on a SteppIR, which reports no
// lengths at all — but the inhibit was equally missing.
func (c *Client) Retract() error { func (c *Client) Retract() error {
_, err := c.sendCommand(CMD_RETRACT, nil) if _, err := c.sendCommand(CMD_RETRACT, nil); err != nil {
return err return err
} }
c.statusMu.Lock()
c.moveCmdAt = time.Now()
c.statusMu.Unlock()
return nil
}
// ModifyElement modifies element length (command 12) // ModifyElement modifies element length (command 12)
func (c *Client) ModifyElement(elementNum int, lengthMm int) error { func (c *Client) ModifyElement(elementNum int, lengthMm int) error {
+12 -2
View File
@@ -233,8 +233,18 @@ func main() {
Title: "OpsLog", Title: "OpsLog",
Width: width, Width: width,
Height: height, Height: height,
MinWidth: 1100, // No minimum. Wails treats 0 as "no constraint" (winc only fills
MinHeight: 700, // PtMinTrackSize when the value is above zero), so Windows applies its
// own floor — about the width of the caption buttons — and the operator
// decides the rest.
//
// It was 1100x700, which is a fair guess at where the layout stops being
// comfortable and no business of ours to enforce: a second screen used as
// a narrow strip, a window parked beside a decoder, a small laptop — all
// of them ran into a wall with nothing to show for it. The panels already
// scroll and collapse.
MinWidth: 0,
MinHeight: 0,
WindowStartState: startState, WindowStartState: startState,
// No OS title bar: it was a dead 32-pixel band above a window that already // No OS title bar: it was a dead 32-pixel band above a window that already
// has its own title strip. The app header takes over — it carries the drag // has its own title strip. The app header takes over — it carries the drag
+37
View File
@@ -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
}
+53
View File
@@ -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")
}
}
+25
View File
@@ -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)
}
}
+15 -11
View File
@@ -7,7 +7,6 @@ import (
"io" "io"
"net/http" "net/http"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -228,16 +227,21 @@ func (a *App) DownloadAndApplyUpdate(url string) error {
// side now. The new instance is told OUR pid and waits for this process to // side now. The new instance is told OUR pid and waits for this process to
// end before taking the single-instance mutex. // end before taking the single-instance mutex.
// //
// Waiting on the mutex alone was not enough, and that is the bug this line // Waiting on the mutex alone was not enough: shutting down is allowed thirty
// fixes: shutting down is allowed thirty seconds here (armExitWatchdog), // seconds here (armExitWatchdog), because it closes a remote logbook, a CAT
// because it closes a remote logbook, a CAT session and sometimes a backup, // session and sometimes a backup, while the new instance was only patient
// while the new instance was only patient for twenty. On a station where // for twenty. On a station where that ran long, the new process gave up and
// that ran long, the new process gave up and exited — leaving the old one // exited — leaving the old one still running and no new window, which is
// still running and no new window, which is precisely what the PowerShell // precisely what the PowerShell helper never did: it waited for the pid,
// helper never did: it waited for the pid, however long it took. // however long it took.
cmd := exec.Command(exe, "--post-update", "--wait-pid", strconv.Itoa(os.Getpid())) //
cmd.Dir = dir // And relaunchCmd rather than a command built here, because the OTHER half
hideConsole(cmd) // of the same report was this line calling hideConsole: SW_HIDE in the
// STARTUPINFO, which Windows applies to the new process's first window. The
// updated OpsLog started, took the mutex, and stayed invisible. See
// relaunch.go — the fact belongs in one place, since two self-relaunches
// differing by one line is how only one of them was broken.
cmd := relaunchCmd(exe, "--post-update", "--wait-pid", strconv.Itoa(os.Getpid()))
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return fmt.Errorf("schedule relaunch: %w", err) return fmt.Errorf("schedule relaunch: %w", err)
} }
+1 -1
View File
@@ -42,7 +42,7 @@ one of these.
| **Rotator Genius** (4O3A) | Az | TCP, port 9006 | Native. *Rotator #* picks which of the two the box drives; *Two rotors* adds the second as its own rotor. | | **Rotator Genius** (4O3A) | Az | TCP, port 9006 | Native. *Rotator #* picks which of the two the box drives; *Two rotors* adds the second as its own rotor. |
| **GS-232 azimuth** (microHAM ARCO, ERC) | Az | LAN or USB | Set the controller's CONTROL PROTOCOL to *Yaesu GS-232A*. An **ERC** must be in GS-232 emulation, **not** Hy-Gain DCU-1. | | **GS-232 azimuth** (microHAM ARCO, ERC) | Az | LAN or USB | Set the controller's CONTROL PROTOCOL to *Yaesu GS-232A*. An **ERC** must be in GS-232 emulation, **not** Hy-Gain DCU-1. |
| **ERC-M by DF9GR** | Az + El | USB COM or LAN | The az/el interface for a **Yaesu G-5500** and its relatives. GS-232 emulation, 19200 baud out of the box. Pick this rather than the GS-232 azimuth entry: it is what tells OpsLog the mast has an elevation motor. | | **ERC-M by DF9GR** | Az + El | USB COM or LAN | The az/el interface for a **Yaesu G-5500** and its relatives. GS-232 emulation, 19200 baud out of the box. Pick this rather than the GS-232 azimuth entry: it is what tells OpsLog the mast has an elevation motor. |
| **Hy-Gain DCU-1** | Az | COM port or serial-over-IP | RotorCard DXA, Idiom Press Rotor-EZ, Green Heron. A DCU-1 is 4800 baud; others may differ — match the controller. | | **Green Heron RT-21 / Hy-Gain DCU-1** | Az | COM port, or TCP | Also Idiom Press Rotor-EZ and RotorCard DXA. Set the RT-21 to DCU-1 / Rotor-EZ — on GS-232 use the GS-232 row instead. With the Ethernet option, TCP reaches it directly; no serial-over-IP bridge. A DCU-1 is 4800 baud; others may differ — match the controller. |
| **SPID / AlfaSpid** | Az + El (Rot2Prog) | COM port | Native, so PstRotator is not needed in between. See below. | | **SPID / AlfaSpid** | Az + El (Rot2Prog) | COM port | Native, so PstRotator is not needed in between. See below. |
| **EasyComm II** | Az + El | COM port or TCP | What SatPC32, Gpredict, Hamlib and K3NG firmware speak — the usual choice for a home-built az/el controller. | | **EasyComm II** | Az + El | COM port or TCP | What SatPC32, Gpredict, Hamlib and K3NG firmware speak — the usual choice for a home-built az/el controller. |