Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
707f0bc848 | ||
|
|
b2e93e9164 | ||
|
|
eb06cc29b0 | ||
|
|
d25edd114c | ||
|
|
f090e845ff | ||
|
|
d2e62debe8 | ||
|
|
8359caab16 | ||
|
|
f998023a28 | ||
|
|
3b6269978c | ||
|
|
052fc4cb80 | ||
|
|
2615365684 | ||
|
|
580e5782f8 | ||
|
|
3ce74ef8d7 | ||
|
|
3af6299f32 | ||
|
|
136c5d5b6b | ||
|
|
d8c9f05d10 | ||
|
|
a88e871640 | ||
|
|
ce7b3686f5 | ||
|
|
b0a973d390 | ||
|
|
e2fe406445 | ||
|
|
3c93684b2b | ||
|
|
3dad00f8ad | ||
|
|
b8491f3038 | ||
|
|
5ddc5e38a9 | ||
|
|
8f643b5b67 | ||
|
|
37b798e9f5 | ||
|
|
3ac6f7e49c | ||
|
|
0add56fb2d |
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
@@ -55,6 +56,7 @@ import (
|
||||
"hamlog/internal/powergenius"
|
||||
"hamlog/internal/profile"
|
||||
"hamlog/internal/pskr"
|
||||
"hamlog/internal/pskrme"
|
||||
"hamlog/internal/pskrtgt"
|
||||
"hamlog/internal/psu"
|
||||
"hamlog/internal/qslcard"
|
||||
@@ -772,6 +774,11 @@ type App struct {
|
||||
// The Chase new panel's OWN band filter, as the set switched off. Read per
|
||||
// message like the list above, so an atomic rather than a settings lookup.
|
||||
chaseNewBandsOff atomic.Value // map[string]bool
|
||||
// hearMe is the THIRD PSK Reporter connection: who is reporting our own
|
||||
// transmissions, for the reverse layer on the FT map. Its own connection
|
||||
// for the same reason as pskTgt — a different slice of the feed, and one
|
||||
// that must keep working with the opening watch switched off.
|
||||
hearMe *pskrme.Watcher
|
||||
// pskr is the PSK Reporter MQTT feed, up only while the opening watch is on.
|
||||
// It is the source that makes VHF detection work at all: the cluster and RBN
|
||||
// carry a handful of 6 m spots where PSK Reporter carries hundreds.
|
||||
@@ -950,6 +957,7 @@ type App struct {
|
||||
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
|
||||
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
|
||||
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
|
||||
startupWarn string // a non-fatal warning worth putting in front of the operator at launch
|
||||
startupErr string // captured for surfacing to the frontend
|
||||
settingsScoped atomic.Bool // true once a.settings is scoped to the active profile — GetUIPref/SetUIPref (per-profile) must wait for it, else an early call reads the wrong scope and e.g. resets the theme
|
||||
dbPath string // settings/config database file (settings + profiles); may be a user-chosen location
|
||||
@@ -1194,6 +1202,25 @@ func (a *App) startup(ctx context.Context) {
|
||||
boot.DeletePending = ""
|
||||
_ = writeBootstrap(dataDir, boot)
|
||||
}
|
||||
// About to create a database in a folder that already has one.
|
||||
//
|
||||
// This is the shape of every "everything I set up is gone" report: the
|
||||
// pointer was lost or the default moved, OpsLog opened a NEW empty database
|
||||
// beside the full one, and the operator was shown a program that had
|
||||
// forgotten them. Nothing is changed here — guessing which file is theirs is
|
||||
// how the wrong one gets picked — but it is said loudly, in the startup log
|
||||
// and on screen, while the old file is still sitting there untouched.
|
||||
if !fileExists(a.dbPath) {
|
||||
if others := otherDatabasesIn(dataDir, a.dbPath); len(others) > 0 {
|
||||
msg := fmt.Sprintf("OpsLog is starting a NEW, EMPTY settings database (%s) although this folder already holds %s. "+
|
||||
"Nothing has been deleted. If your settings have gone, that other file is where they are: "+
|
||||
"Settings ▸ Database ▸ open an existing database, and pick it.",
|
||||
filepath.Base(a.dbPath), strings.Join(others, ", "))
|
||||
bootLog("%s", msg)
|
||||
fmt.Println("OpsLog:", msg)
|
||||
a.startupWarn = msg
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil {
|
||||
a.startupErr = "cannot create db folder: " + err.Error()
|
||||
fmt.Println("OpsLog:", a.startupErr)
|
||||
@@ -1711,6 +1738,9 @@ func (a *App) startup(ctx context.Context) {
|
||||
// PSK Reporter. After the operator's grid is known: without it there is no
|
||||
// distance to measure and no receiver squares to filter on, so it stays down.
|
||||
a.startBandOpenFeed()
|
||||
// The reverse layer. Needs the CALLSIGN rather than the grid, so it goes
|
||||
// after the profile is scoped, which it already is by here.
|
||||
a.startHearMe()
|
||||
// Auto-call. After the watch list and the logbook: the ladder is meaningless
|
||||
// without either, and it must never call anybody on a log it cannot read.
|
||||
a.startAutoCall()
|
||||
@@ -1741,6 +1771,10 @@ type StartupStatus struct {
|
||||
OK bool `json:"ok"`
|
||||
Err string `json:"err"`
|
||||
DBPath string `json:"db_path"`
|
||||
// Warn is not a failure and must not be swallowed: OpsLog started, and
|
||||
// something about WHERE it started is worth an operator seeing before they
|
||||
// conclude their configuration has been thrown away.
|
||||
Warn string `json:"warn"`
|
||||
}
|
||||
|
||||
// GetStartupStatus exposes whatever happened during startup so the UI
|
||||
@@ -1771,6 +1805,7 @@ func (a *App) GetStartupStatus() StartupStatus {
|
||||
OK: a.startupErr == "",
|
||||
Err: a.startupErr,
|
||||
DBPath: a.dbPath,
|
||||
Warn: a.startupWarn,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2405,11 +2440,37 @@ func overlapsEnough(x, y, w, h, vx, vy, vw, vh int) bool {
|
||||
// value if the file is missing/unreadable.
|
||||
func readBootstrap(dataDir string) dbPointer {
|
||||
var c dbPointer
|
||||
b, err := os.ReadFile(dbPointerPath(dataDir))
|
||||
path := dbPointerPath(dataDir)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
// No pointer at all: a first run, or a folder that never had one. The
|
||||
// caller falls back to this folder's own default, which is correct.
|
||||
return c
|
||||
}
|
||||
_ = json.Unmarshal(b, &c)
|
||||
if uerr := json.Unmarshal(b, &c); uerr != nil || len(bytes.TrimSpace(b)) == 0 {
|
||||
// A pointer that EXISTS and cannot be read is not the same thing as no
|
||||
// pointer, and treating it as one is how an operator loses an evening:
|
||||
// the database moves back to this folder's default, the default is
|
||||
// empty, and the program opens having forgotten everything. Reported
|
||||
// twice, both times just after an update.
|
||||
//
|
||||
// The previous contents are kept beside it for exactly this, so the
|
||||
// answer is usually one file away.
|
||||
bootLog("config.json is unreadable (%v, %d bytes) — trying the backup", uerr, len(b))
|
||||
var prev dbPointer
|
||||
if bk, berr := os.ReadFile(path + ".bak"); berr == nil && json.Unmarshal(bk, &prev) == nil {
|
||||
bootLog("config.json restored from its backup (database %q)", prev.DBPath)
|
||||
c = prev
|
||||
// Put it back, so the next launch does not have to do this again.
|
||||
_ = os.WriteFile(path, bk, 0o644)
|
||||
} else {
|
||||
// Nothing to restore from. Keep the broken file rather than
|
||||
// overwriting it — it is evidence, and it may still be readable by
|
||||
// hand.
|
||||
_ = os.Rename(path, path+".broken")
|
||||
bootLog("no usable backup — the broken config.json was kept as config.json.broken")
|
||||
}
|
||||
}
|
||||
// Stored relative when it lives inside the app folder, so the pointer follows
|
||||
// the folder from C:OpsLog to D:OpsLog or to a stick.
|
||||
c.DBPath = resolvePath(dataDir, c.DBPath)
|
||||
@@ -2417,11 +2478,51 @@ func readBootstrap(dataDir string) dbPointer {
|
||||
return c
|
||||
}
|
||||
|
||||
// writeBootstrap saves the pointer ATOMICALLY, and keeps the previous one.
|
||||
//
|
||||
// os.WriteFile truncates the file and then fills it, so a process that stops
|
||||
// in between — a crash, a power cut, an update's watchdog force-exiting the old
|
||||
// instance — leaves an empty or half-written config.json. That file is the only
|
||||
// record of WHERE the database is, and losing it moved an operator's whole
|
||||
// station back to an empty default. It has happened twice.
|
||||
//
|
||||
// A temporary file renamed into place cannot do that: on Windows and on Unix
|
||||
// alike the rename is atomic within a volume, so config.json is either entirely
|
||||
// the old contents or entirely the new. The previous contents are kept as a
|
||||
// .bak because a pointer is a few dozen bytes and an evening of configuration
|
||||
// is not.
|
||||
func writeBootstrap(dataDir string, c dbPointer) error {
|
||||
c.DBPath = portablePath(c.DBPath)
|
||||
c.DeletePending = portablePath(c.DeletePending)
|
||||
b, _ := json.MarshalIndent(c, "", " ")
|
||||
return os.WriteFile(dbPointerPath(dataDir), b, 0o644)
|
||||
b, err := json.MarshalIndent(c, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := dbPointerPath(dataDir)
|
||||
if old, rerr := os.ReadFile(path); rerr == nil && len(bytes.TrimSpace(old)) > 0 {
|
||||
_ = os.WriteFile(path+".bak", old, 0o644)
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(b); err != nil {
|
||||
f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
// On disk before the rename, or the rename can publish an empty file.
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// readDBPointer returns the user-chosen DB path, or "" for the default.
|
||||
@@ -5280,6 +5381,21 @@ var awardBandPlan = []struct {
|
||||
{"2mm", 134000000000, 149000000000}, {"1mm", 241000000000, 250000000000},
|
||||
}
|
||||
|
||||
// bandOrder ranks a band name by frequency, for sorting a list of them.
|
||||
//
|
||||
// The band plan is already written low to high, so its index IS the order —
|
||||
// which beats sorting the names, where 10m lands between 1.25m and 12m.
|
||||
// Anything not in the plan sorts last rather than first: an unrecognised band
|
||||
// is almost always a typo in an imported file, and it belongs at the bottom.
|
||||
func bandOrder(name string) int {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
for i, b := range awardBandPlan {
|
||||
if b.name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(awardBandPlan)
|
||||
}
|
||||
func bandForHz(hz int64) string {
|
||||
for _, b := range awardBandPlan {
|
||||
if hz >= b.lo && hz <= b.hi {
|
||||
@@ -7464,34 +7580,88 @@ func (a *App) SendDecodeFreeText(instance, text string, send bool) error {
|
||||
// GridSquares returns the 4-character Maidenhead squares in the log, with
|
||||
// whether each is confirmed.
|
||||
//
|
||||
// modeClass scopes it the way the rest of the app does: "DIGI", "CW", "PHONE",
|
||||
// or "" for every mode. "DIGI" is the one this was built for — a map of the
|
||||
// squares worked on FT8/FT4 answers "where have I actually been heard" in a way
|
||||
// no list of callsigns does.
|
||||
// mode scopes it the way the rest of the app does — "DIGI", "CW", "PHONE", or
|
||||
// "" for every mode — but it also takes ONE mode's own name, which is what an
|
||||
// operator actually wants here: "digital" put a contest RTTY square beside an
|
||||
// FT8 one and called them the same answer, when the question behind this map is
|
||||
// where a particular mode has been heard.
|
||||
//
|
||||
// band and satName narrow it further, both empty meaning no restriction. The
|
||||
// satellite is the reason this map is worth filtering at all for anyone chasing
|
||||
// grids through a bird: a VHF square worked terrestrially and one worked through
|
||||
// AO-91 are not the same achievement, and nothing else told them apart.
|
||||
//
|
||||
// Confirmed means LoTW, a card or eQSL — the same three the award engine counts,
|
||||
// so a square cannot be green here and unconfirmed in the Awards panel.
|
||||
func (a *App) GridSquares(modeClass string) ([]qso.GridSquare, error) {
|
||||
func (a *App) GridSquares(mode, band, satName string) ([]qso.GridSquare, error) {
|
||||
if a.qso == nil {
|
||||
return nil, fmt.Errorf("db not initialized")
|
||||
}
|
||||
want := strings.ToUpper(strings.TrimSpace(modeClass))
|
||||
keep := func(mode string) bool {
|
||||
want := strings.ToUpper(strings.TrimSpace(mode))
|
||||
wantBand := strings.ToLower(strings.TrimSpace(band))
|
||||
wantSat := strings.ToUpper(strings.TrimSpace(satName))
|
||||
keepMode := func(r qso.GridSquareRow) bool {
|
||||
switch want {
|
||||
case "", "ALL":
|
||||
return true
|
||||
case "FTX":
|
||||
// The FT family alone, which is narrower than digital and usually the
|
||||
// honest answer beside an FTx panel: a square worked on RTTY in a
|
||||
// contest is not a square worked on FT8.
|
||||
return ftxModes[strings.ToUpper(strings.TrimSpace(mode))]
|
||||
// Retired from the UI, kept because a stored preference may still say
|
||||
// it: the FT family alone, narrower than digital.
|
||||
return ftxModes[r.Mode] || ftxModes[r.Submode]
|
||||
case "PHONE", "CW", "DIGI":
|
||||
return award.ModeClass(r.Mode) == want
|
||||
default:
|
||||
return award.ModeClass(mode) == want
|
||||
// One named mode. Matched against the SUBMODE as well, because ADIF
|
||||
// files PSK63 under PSK: a log carrying the pair would answer nothing
|
||||
// at all to the only name the operator recognises.
|
||||
return r.Mode == want || r.Submode == want
|
||||
}
|
||||
}
|
||||
keep := func(r qso.GridSquareRow) bool {
|
||||
if !keepMode(r) {
|
||||
return false
|
||||
}
|
||||
if wantBand != "" && wantBand != "all" && r.Band != wantBand {
|
||||
return false
|
||||
}
|
||||
if wantSat != "" && wantSat != "ALL" && r.SatName != wantSat {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return a.qso.GridSquares(a.ctx, keep)
|
||||
}
|
||||
|
||||
// GridSquareChoices is what the grid map's three filters can offer: the modes,
|
||||
// bands and satellites the squares in the log were actually worked on.
|
||||
//
|
||||
// Read from the log, not from a list here, so a filter never offers a choice
|
||||
// with nothing behind it and never omits one with something. FT2 is the case
|
||||
// that settled it: it is not a registered ADIF mode yet, and hardcoding the
|
||||
// list meant either leaving out operators already using it or shipping a mode
|
||||
// that is not official — a query does neither.
|
||||
type GridSquareChoices struct {
|
||||
Modes []string `json:"modes"`
|
||||
Bands []string `json:"bands"`
|
||||
Satellites []string `json:"satellites"`
|
||||
}
|
||||
|
||||
func (a *App) GridSquareChoices() (GridSquareChoices, error) {
|
||||
if a.qso == nil {
|
||||
return GridSquareChoices{}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
modes, bands, sats, err := a.qso.GridSquareChoices(a.ctx)
|
||||
if err != nil {
|
||||
return GridSquareChoices{}, err
|
||||
}
|
||||
// Bands in frequency order, the way every other band list in the app reads;
|
||||
// modes and satellites alphabetically, there being no other order for them.
|
||||
sort.Slice(bands, func(i, j int) bool { return bandOrder(bands[i]) < bandOrder(bands[j]) })
|
||||
sort.Strings(modes)
|
||||
sort.Strings(sats)
|
||||
return GridSquareChoices{Modes: modes, Bands: bands, Satellites: sats}, nil
|
||||
}
|
||||
|
||||
// SetCompactMode toggles a tiny always-on-top window that exposes just the
|
||||
// QSO entry — useful when running on a single screen alongside WSJT-X,
|
||||
// JT-Alert or the cluster.
|
||||
@@ -16915,6 +17085,10 @@ func (a *App) reloadAfterProfileSwitch() {
|
||||
// Same reasoning for the satellite tracker: it transmits, and the new profile
|
||||
// may be a different station on a different antenna.
|
||||
a.StopSatelliteTracking()
|
||||
// A new profile is usually a new callsign, and the callsign IS this
|
||||
// subscription — left alone it would go on reporting who hears the previous
|
||||
// station.
|
||||
a.startHearMe()
|
||||
}
|
||||
|
||||
// DuplicateProfile clones an existing profile under newName. Useful when
|
||||
@@ -17486,7 +17660,7 @@ func linkHeading(l rotorLink) (az, el float64, hasEl bool, raw string, err error
|
||||
func linkGoTo(l rotorLink, az, el int) error {
|
||||
switch l.Type {
|
||||
case "rotgenius":
|
||||
return rotgenius.New(l.Host, l.Port).GoTo(l.Num, az)
|
||||
return rotgeniusGoTo(l, az)
|
||||
case "arco":
|
||||
return arcoClient(l).GoTo(az)
|
||||
case "erc":
|
||||
@@ -18120,6 +18294,10 @@ type motorAntenna interface {
|
||||
SetFrequency(khz, dir int) error
|
||||
SetDirection(dir int) error
|
||||
Retract() error
|
||||
// Calibrate re-learns where the elements are by driving them to their end
|
||||
// stops. SteppIR only: an Ultrabeam has no such command, and says so rather
|
||||
// than pretending.
|
||||
Calibrate() error
|
||||
LastSetKHz() int
|
||||
Status() motorStatus
|
||||
// Elements returns the per-element lengths (mm) when the antenna exposes them
|
||||
@@ -18152,6 +18330,9 @@ func (a ubAdapter) Stop() { a.c.Stop() }
|
||||
func (a ubAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d) }
|
||||
func (a ubAdapter) SetDirection(d int) error { return a.c.SetDirection(d) }
|
||||
func (a ubAdapter) Retract() error { return a.c.Retract() }
|
||||
func (a ubAdapter) Calibrate() error {
|
||||
return fmt.Errorf("calibration is a SteppIR command — an Ultrabeam controller has none")
|
||||
}
|
||||
func (a ubAdapter) LastSetKHz() int { return a.c.LastSetKHz() }
|
||||
func (a ubAdapter) SetElement(n, mm int) error { return a.c.ModifyElement(n, mm) }
|
||||
func (a ubAdapter) ReadElements() ([]int, error) { return a.c.ReadElements() }
|
||||
@@ -18190,6 +18371,7 @@ func (a steppirAdapter) Stop() { a.c.Stop() }
|
||||
func (a steppirAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d) }
|
||||
func (a steppirAdapter) SetDirection(d int) error { return a.c.SetDirection(d) }
|
||||
func (a steppirAdapter) Retract() error { return a.c.Retract() }
|
||||
func (a steppirAdapter) Calibrate() error { return a.c.Calibrate() }
|
||||
func (a steppirAdapter) LastSetKHz() int { return a.c.LastSetKHz() }
|
||||
func (a steppirAdapter) Elements() []int { return nil } // SteppIR: no per-element control
|
||||
func (a steppirAdapter) SetElement(_, _ int) error {
|
||||
@@ -19106,6 +19288,24 @@ func (a *App) restartMotorFollow(s UltrabeamSettings) {
|
||||
go a.ultrabeamFollowLoop(a.motorAnt, s.TrackMode, s.StepKHz, s.Bands, stop)
|
||||
}
|
||||
|
||||
// MotorCalibrate runs a SteppIR calibration: the elements go to their end stops
|
||||
// so the controller re-learns where zero is.
|
||||
//
|
||||
// The cure for an antenna that tunes to the wrong length — after a power cut
|
||||
// mid-move, after the elements were pushed by hand, after a motor slipped. The
|
||||
// controller counts steps from a remembered position, and once that memory is
|
||||
// wrong, every frequency after it is wrong by the same amount.
|
||||
//
|
||||
// Minutes, and every element travels its full length. Not a contest-time button,
|
||||
// which is why the interface asks first.
|
||||
func (a *App) MotorCalibrate() error {
|
||||
if a.motorAnt == nil {
|
||||
return fmt.Errorf("antenna not connected")
|
||||
}
|
||||
applog.Printf("antenna: calibration started — the elements go to their end stops, this takes minutes")
|
||||
return a.motorAnt.Calibrate()
|
||||
}
|
||||
|
||||
// UltrabeamRetract retracts all elements (storage / safe position).
|
||||
func (a *App) UltrabeamRetract() error {
|
||||
if a.motorAnt == nil {
|
||||
@@ -22517,3 +22717,68 @@ func (a *App) IcomConsolePTT(on bool) error {
|
||||
}
|
||||
return a.cat.SetPTT(on)
|
||||
}
|
||||
|
||||
// rotgeniusGoTo picks which way round to reach a bearing when the controller
|
||||
// has an overlap to offer.
|
||||
//
|
||||
// THE GENIUS DECIDES, and it needs no setting from us. It reports the limits it
|
||||
// is configured with on every heading query, and those are the truth about what
|
||||
// is bolted to the tower: if the far side of an overlap is reachable it says so,
|
||||
// and if it is not, asking anyway turns a working command into a rejected one.
|
||||
// Its manual is unambiguous — "you will not be able to give it a target beyond
|
||||
// the limits".
|
||||
//
|
||||
// In practice today that means the plain bearing, every time. A Rotator Genius
|
||||
// is a 360° controller: its Limits fields say where the mechanical stop sits
|
||||
// within one turn ("5 to 4" is a dead zone at four and a half degrees), not how
|
||||
// far the mast can travel, and an operator with a 450° rotator gets 360° of it.
|
||||
// The overlap branch stays because the decision is made from what the device
|
||||
// reports rather than from an assumption about it — a controller that one day
|
||||
// answers 450 will be driven through the overlap without a line changing here.
|
||||
//
|
||||
// Which of the two forms is right depends on where the antenna IS, so the
|
||||
// heading is read first and the nearer one wins: a beam at 350° heading for 010°
|
||||
// should cross north, not travel the other 340 degrees.
|
||||
func rotgeniusGoTo(l rotorLink, az int) error {
|
||||
c := rotgenius.New(l.Host, l.Port)
|
||||
a := ((az % 360) + 360) % 360
|
||||
st, _, err := c.Heading(l.Num)
|
||||
if err != nil || !st.Connected || st.LimitCW <= 360 {
|
||||
return c.GoTo(l.Num, a)
|
||||
}
|
||||
if alt := a + 360; alt <= st.LimitCW && absInt(alt-st.Azimuth) < absInt(a-st.Azimuth) {
|
||||
applog.Printf("rotator: %d° is nearer as %d° from the antenna's %d° (Genius limit %d)",
|
||||
a, alt, st.Azimuth, st.LimitCW)
|
||||
return c.GoTo(l.Num, alt)
|
||||
}
|
||||
return c.GoTo(l.Num, a)
|
||||
}
|
||||
|
||||
func absInt(v int) int {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// otherDatabasesIn lists the settings databases sitting in the data folder that
|
||||
// are NOT the one about to be opened.
|
||||
//
|
||||
// Only the two names OpsLog itself ever uses, and only files with something in
|
||||
// them: a stray .db from another program is not evidence, and a zero-byte file
|
||||
// is not a lost configuration. The point is to recognise the one situation that
|
||||
// matters — a full database next to a new empty one — and to say so before the
|
||||
// operator concludes their evening is gone.
|
||||
func otherDatabasesIn(dataDir, chosen string) []string {
|
||||
var out []string
|
||||
for _, name := range []string{"settings.db", "opslog.db"} {
|
||||
p := filepath.Join(dataDir, name)
|
||||
if p == chosen {
|
||||
continue
|
||||
}
|
||||
if fi, err := os.Stat(p); err == nil && fi.Size() > 0 {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
// ── Who hears me (PSK Reporter) ────────────────────────────────────────────
|
||||
//
|
||||
// The FT map draws what this station decodes. This is the other direction:
|
||||
// which stations are reporting our own transmissions, which is the half an
|
||||
// operator cannot see from their own receiver and the half that decides whether
|
||||
// calling is worth the cycle.
|
||||
//
|
||||
// See internal/pskrme for why it costs almost nothing — the operator's callsign
|
||||
// goes in the topic's TRANSMIT level, so the broker sends nothing else.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/pskrme"
|
||||
)
|
||||
|
||||
// keyHearMeOn is per-profile, not global: a second profile is usually a second
|
||||
// callsign, and the answer to "who hears me" is not the same one.
|
||||
const keyHearMeOn = "hearme.on"
|
||||
|
||||
// GetHearMe reports whether the feed is wanted.
|
||||
func (a *App) GetHearMe() bool {
|
||||
if a.settings == nil || !a.settingsScoped.Load() {
|
||||
return false
|
||||
}
|
||||
v, _ := a.settings.Get(a.ctx, keyHearMeOn)
|
||||
return v == "1"
|
||||
}
|
||||
|
||||
// SetHearMe turns the feed on or off and brings the subscription with it.
|
||||
func (a *App) SetHearMe(on bool) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if err := a.settings.Set(a.ctx, keyHearMeOn, boolStr(on)); err != nil {
|
||||
return err
|
||||
}
|
||||
a.startHearMe()
|
||||
if on {
|
||||
// Not an error: the feed is wanted and will come up as soon as there is
|
||||
// a callsign to watch for, and saying so beats a silent switch.
|
||||
if strings.TrimSpace(a.opCall) == "" {
|
||||
return fmt.Errorf("set the station callsign first — the feed watches for it")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// startHearMe rebuilds the watcher from the setting. Called at startup, when
|
||||
// the setting changes, and on a profile switch — the callsign is what it
|
||||
// subscribes to, so a new profile needs a new subscription.
|
||||
func (a *App) startHearMe() {
|
||||
if a.hearMe != nil {
|
||||
a.hearMe.Stop()
|
||||
a.hearMe = nil
|
||||
}
|
||||
if !a.GetHearMe() {
|
||||
return
|
||||
}
|
||||
call := strings.ToUpper(strings.TrimSpace(a.opCall))
|
||||
if call == "" {
|
||||
applog.Printf("pskrme: no station callsign — nothing to watch for")
|
||||
return
|
||||
}
|
||||
w := pskrme.New(pskrme.Config{MyCall: call, Logf: applog.Printf})
|
||||
if err := w.Start(); err != nil {
|
||||
applog.Printf("pskrme: feed did not start: %v", err)
|
||||
return
|
||||
}
|
||||
a.hearMe = w
|
||||
}
|
||||
|
||||
// GetWhoHearsMe is the map layer: one entry per station that has reported us
|
||||
// inside the window, carrying its own square so the arc is arithmetic.
|
||||
func (a *App) GetWhoHearsMe() []pskrme.Report {
|
||||
if a.hearMe == nil {
|
||||
return nil
|
||||
}
|
||||
return a.hearMe.Reports()
|
||||
}
|
||||
|
||||
// GetHearMeStatus tells a working feed from a silent one: a connection that is
|
||||
// up and reporting nothing looks exactly like a broken one until you can see a
|
||||
// number moving.
|
||||
func (a *App) GetHearMeStatus() pskrme.Status {
|
||||
if a.hearMe == nil {
|
||||
return pskrme.Status{Watching: strings.ToUpper(strings.TrimSpace(a.opCall))}
|
||||
}
|
||||
return a.hearMe.Status()
|
||||
}
|
||||
+52
-6
@@ -314,7 +314,12 @@ func (a *App) GetSatSettings() (SatSettings, error) {
|
||||
if a.settings == nil {
|
||||
return SatSettings{}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.satSettings(), nil
|
||||
out := a.satSettings()
|
||||
// Resolved HERE and not in satSettings, which is read during startup before
|
||||
// the frequency plan is loaded. Saving the panel writes the resolved list
|
||||
// back, so the rename settles itself the first time anything is changed.
|
||||
out.Favorites = a.satFavorites()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveSatSettings stores them.
|
||||
@@ -359,6 +364,15 @@ func (a *App) SaveSatSettings(s SatSettings) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// The Satellites tab is usually open BEHIND the settings window, and it
|
||||
// read the followed list once when it was mounted: dropping QO-100 left it
|
||||
// in the pass table and in the dropdown until the tab was reopened, which
|
||||
// looks exactly like a setting that did not save. Every key written here
|
||||
// changes what the tab should show — the followed list, the horizon, the
|
||||
// window, the locator — so one event covers them all.
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "sat:settings")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -535,9 +549,8 @@ func (a *App) AddSatelliteElements(text string) (int, error) {
|
||||
// configuration problem into a satellite that "does not exist".
|
||||
func (a *App) GetSatelliteBirds() []SatBird {
|
||||
store, birds, _ := a.satParts()
|
||||
set := a.satSettings()
|
||||
fav := map[string]bool{}
|
||||
for _, n := range set.Favorites {
|
||||
for _, n := range a.satFavorites() {
|
||||
fav[strings.ToUpper(n)] = true
|
||||
}
|
||||
|
||||
@@ -665,15 +678,48 @@ func satElement(store *sat.Store, b sat.Bird) (sat.Element, bool) {
|
||||
|
||||
// ── Tracking ────────────────────────────────────────────────────────────────
|
||||
|
||||
// satFavorites is the followed list, with every name resolved to the one the
|
||||
// frequency plan uses now.
|
||||
//
|
||||
// A satellite is renamed when it is granted an OSCAR number — LILACSAT-2
|
||||
// became LO-90 — and the plan carries the old name as an alias. The followed
|
||||
// list, though, is stored as the plain text the operator picked: after such a
|
||||
// rename his own choice was listed as having no elements while the same bird
|
||||
// sat under its new name in the available column, so the satellite he had
|
||||
// chosen had quietly become a stranger.
|
||||
//
|
||||
// Deduplicated on the way out, because an operator who followed both spellings
|
||||
// must not now see the same bird twice.
|
||||
func (a *App) satFavorites() []string {
|
||||
names := a.satSettings().Favorites
|
||||
_, birds, _ := a.satParts()
|
||||
if birds == nil {
|
||||
return names
|
||||
}
|
||||
out := make([]string, 0, len(names))
|
||||
seen := map[string]bool{}
|
||||
for _, n := range names {
|
||||
if b, ok := birds.Find(n); ok {
|
||||
n = b.Name
|
||||
}
|
||||
k := strings.ToUpper(n)
|
||||
if seen[k] {
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// satNames resolves the names the UI asked for, falling back to the favourites
|
||||
// and then to every planned bird we hold elements for.
|
||||
func (a *App) satNames(names []string) []string {
|
||||
if len(names) > 0 {
|
||||
return names
|
||||
}
|
||||
set := a.satSettings()
|
||||
if len(set.Favorites) > 0 {
|
||||
return set.Favorites
|
||||
if favs := a.satFavorites(); len(favs) > 0 {
|
||||
return favs
|
||||
}
|
||||
var out []string
|
||||
for _, b := range a.GetSatelliteBirds() {
|
||||
|
||||
+176
-12
@@ -77,6 +77,10 @@ type satTracker struct {
|
||||
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
// wake makes the loop take a step NOW instead of at the next tick. Changing
|
||||
// satellite has to move the radio at once: a second of the old bird's
|
||||
// frequencies is a second of the wrong pass.
|
||||
wake chan struct{}
|
||||
}
|
||||
|
||||
// SatTrackStatus is what the tracker is doing, for the panel.
|
||||
@@ -92,6 +96,11 @@ type SatTrackStatus struct {
|
||||
Az float64 `json:"az"`
|
||||
El float64 `json:"el"`
|
||||
Visible bool `json:"visible"`
|
||||
// Where the satellite is, as opposed to where to point: an operator reads
|
||||
// the distance to know whether a pass is worth calling on, and the altitude
|
||||
// to know how long it will last.
|
||||
RangeKm float64 `json:"range_km"`
|
||||
AltKm float64 `json:"alt_km"`
|
||||
Radio string `json:"radio"` // what the rig is doing: "sat", "downlink-only", ""
|
||||
Error string `json:"error"`
|
||||
|
||||
@@ -129,6 +138,7 @@ func (a *App) StartSatelliteTracking(name string, transponder int) error {
|
||||
nominalDown: b.Transponders[transponder].Centre(),
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
wake: make(chan struct{}, 1),
|
||||
}
|
||||
t.status = SatTrackStatus{On: true, Name: b.Name, Transponder: b.Transponders[transponder].Label, Mode: b.Transponders[transponder].Mode}
|
||||
|
||||
@@ -158,6 +168,7 @@ func (a *App) StartSatelliteTracking(name string, transponder int) error {
|
||||
t.status.Error = err.Error()
|
||||
} else {
|
||||
radio = "sat"
|
||||
a.applySatRadio(b.Transponders[transponder])
|
||||
}
|
||||
}
|
||||
t.status.Radio = radio
|
||||
@@ -258,6 +269,7 @@ func (a *App) satTrackLoop(t *satTracker) {
|
||||
select {
|
||||
case <-t.stop:
|
||||
return
|
||||
case <-t.wake:
|
||||
case <-tick.C:
|
||||
}
|
||||
}
|
||||
@@ -331,22 +343,29 @@ func (a *App) satTrackStep(t *satTracker) {
|
||||
down, up := sh.DownHz, sh.UpHz
|
||||
|
||||
t.mu.Lock()
|
||||
t.status = SatTrackStatus{
|
||||
On: true, Name: b.Name, Transponder: tp.Label, Mode: tp.Mode,
|
||||
NominalDown: nominal, NominalUp: nomUp,
|
||||
DownHz: down, UpHz: up,
|
||||
Az: pos.Az, El: pos.El, Visible: visible,
|
||||
Radio: t.status.Radio, Error: t.status.Error,
|
||||
}
|
||||
// Rebuilt from the old one, not from nothing.
|
||||
//
|
||||
// The rotator fields are written by readRotator, which runs at most every
|
||||
// three seconds — a controller query binds a socket and waits. Building a
|
||||
// fresh status here dropped them on every OTHER tick, so the antenna
|
||||
// readout appeared for one second in three and vanished again, which reads
|
||||
// as a rotator that keeps disconnecting.
|
||||
st := t.status
|
||||
st.On, st.Name, st.Transponder, st.Mode = true, b.Name, tp.Label, tp.Mode
|
||||
st.NominalDown, st.NominalUp = nominal, nomUp
|
||||
st.DownHz, st.UpHz = down, up
|
||||
st.Az, st.El, st.Visible = pos.Az, pos.El, visible
|
||||
st.RangeKm, st.AltKm = pos.RangeKm, pos.AltKm
|
||||
t.status = st
|
||||
t.mu.Unlock()
|
||||
|
||||
t.pointRotator(pos, b.Geostationary)
|
||||
t.readRotator()
|
||||
|
||||
t.mu.Lock()
|
||||
st := t.status
|
||||
out := t.status
|
||||
t.mu.Unlock()
|
||||
a.emitSatTrack(st)
|
||||
a.emitSatTrack(out)
|
||||
|
||||
// Only send what has actually moved. The step is the smallest change worth a
|
||||
// command: on SSB a listener hears twenty hertz, on an FM channel nothing
|
||||
@@ -359,11 +378,12 @@ func (a *App) satTrackStep(t *satTracker) {
|
||||
return
|
||||
}
|
||||
|
||||
mode := tp.Mode
|
||||
downMode, upMode := satSidebands(tp)
|
||||
if lastDown != 0 {
|
||||
mode = "" // set once, at the start of the pass — see satMode/satSetMode
|
||||
// Set once, at the start of the pass — see satMode/satSetMode.
|
||||
downMode, upMode = "", ""
|
||||
}
|
||||
err := a.satTune(down, up, mode, mode)
|
||||
err := a.satTune(down, up, downMode, upMode)
|
||||
t.mu.Lock()
|
||||
if err == nil {
|
||||
t.lastDown, t.lastUp, t.fails = down, up, 0
|
||||
@@ -634,3 +654,147 @@ func satBandLetter(hz int64) string {
|
||||
}
|
||||
return "K" // 24 GHz and above
|
||||
}
|
||||
|
||||
// flexBandAntKey is the key a frequency has in the per-band antenna and power
|
||||
// maps.
|
||||
//
|
||||
// Those maps are keyed by the band name UPPERCASED ("70CM"), because that is
|
||||
// how the settings panel writes them; bandForHz returns the band plan's own
|
||||
// spelling ("70cm"). Every other caller happened to uppercase on the way in,
|
||||
// the satellite tracker did not, and so it read an empty antenna out of a map
|
||||
// the operator had filled in — which is not a mistake worth making twice.
|
||||
func flexBandAntKey(hz int64) string {
|
||||
return strings.ToUpper(bandForHz(hz))
|
||||
}
|
||||
|
||||
// applySatRadio puts each satellite slice on the antenna configured for ITS
|
||||
// band, and sets the uplink tone.
|
||||
//
|
||||
// Settings ▸ FlexRadio already holds a per-band RX/TX antenna map, and it was
|
||||
// only ever applied by the entry form on a band change — to the active slice.
|
||||
// A pass never goes through that path: the tracker arms two slices itself, on
|
||||
// two different bands, and both were left on whatever the radio last used. A
|
||||
// station with transverters (XVTA on 2 m, XVTB on 70 cm) therefore heard
|
||||
// nothing at all, having configured exactly the thing that was being ignored.
|
||||
//
|
||||
// The bands come from the NOMINAL frequencies, not the Doppler-corrected ones:
|
||||
// a correction of ten kilohertz cannot change the band, and the nominal pair is
|
||||
// what the operator's configuration is written against.
|
||||
func (a *App) applySatRadio(tp sat.Transponder) {
|
||||
if a.cat == nil || !a.cat.SatCapable() {
|
||||
return
|
||||
}
|
||||
// The CTCSS tone first: an FM bird will not answer without it, and it is the
|
||||
// one setting an operator cannot make from the front panel once a pass has
|
||||
// started. Zero turns it off, which is what a linear bird needs.
|
||||
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
|
||||
return fc.SatTone(tp.CTCSS)
|
||||
}); err != nil {
|
||||
applog.Printf("sat: could not set the uplink tone: %v", err)
|
||||
}
|
||||
|
||||
m, err := a.GetFlexBandAntennas()
|
||||
if err != nil || len(m) == 0 {
|
||||
return
|
||||
}
|
||||
// The downlink is received, so it takes that band's RX antenna; the uplink
|
||||
// is transmitted, so it takes that band's TX antenna.
|
||||
downBand, upBand := flexBandAntKey(tp.DownLo), flexBandAntKey(tp.UpLo)
|
||||
rxAnt := m[downBand].RX
|
||||
txAnt := m[upBand].TX
|
||||
if strings.TrimSpace(rxAnt) == "" && strings.TrimSpace(txAnt) == "" {
|
||||
// Worth a line: an operator who HAS configured the pair and still sees
|
||||
// the wrong antenna has no other way to tell a setting he never made
|
||||
// from a lookup that missed.
|
||||
applog.Printf("sat: no antenna configured for this pass (down %s, up %s)", downBand, upBand)
|
||||
return
|
||||
}
|
||||
applog.Printf("sat: antennas rx=%q (%s) tx=%q (%s)", rxAnt, downBand, txAnt, upBand)
|
||||
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
|
||||
return fc.SatAntennas(rxAnt, txAnt)
|
||||
}); err != nil {
|
||||
// Not fatal: a rig that is not a Flex has no such thing, and a pass with
|
||||
// the wrong antenna is still a pass.
|
||||
applog.Printf("sat: could not set the satellite antennas: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// satSidebands is which sideband to set on each side of a linear transponder.
|
||||
//
|
||||
// The two are NOT the same when the transponder inverts, and FO-29, RS-44 and
|
||||
// AO-73 all do: the passband is turned over, so a signal transmitted on lower
|
||||
// sideband comes back on upper. Setting USB at both ends — which is what
|
||||
// happened until now — put the operator's own audio through the transponder
|
||||
// upside down, which is unreadable at the far end and sounds like nothing much
|
||||
// at ours.
|
||||
//
|
||||
// Anything that is not SSB is the same on both sides: an FM repeater is FM up
|
||||
// and FM down, and CW is CW whichever way round the passband runs.
|
||||
func satSidebands(tp sat.Transponder) (downMode, upMode string) {
|
||||
if !strings.EqualFold(strings.TrimSpace(tp.Mode), "SSB") {
|
||||
return tp.Mode, tp.Mode
|
||||
}
|
||||
// Every satellite is above 30 MHz, so the downlink is upper sideband — even
|
||||
// on the AO-7 10 m downlink, which would be lower sideband on HF.
|
||||
if tp.Inverting {
|
||||
return "USB", "LSB"
|
||||
}
|
||||
return "USB", "USB"
|
||||
}
|
||||
|
||||
// RetargetSatelliteTracking points the tracker at a different satellite without
|
||||
// letting go of the radio.
|
||||
//
|
||||
// Two birds are often up at once, and an operator switching between them found
|
||||
// the frequencies stayed on the first: the panel's selection is the DISPLAY's,
|
||||
// while the tracker held its own name and went on following what it was started
|
||||
// with. Stopping and starting worked, which is how it was discovered, and is
|
||||
// also how a Flex loses and rebuilds both its slices for no reason.
|
||||
//
|
||||
// So the radio stays armed and the rotator stays open, and only what is being
|
||||
// followed changes. Everything derived from the old satellite is cleared so the
|
||||
// next step sets it afresh: the frequencies, the mode on both slices (set once
|
||||
// per satellite, not per tick), the antennas and the tone — the new bird may be
|
||||
// U/V where the old one was V/U, which swaps which slice is on which band.
|
||||
func (a *App) RetargetSatelliteTracking(name string, transponder int) error {
|
||||
a.satTrackMu.Lock()
|
||||
t := a.satTrack
|
||||
a.satTrackMu.Unlock()
|
||||
if t == nil {
|
||||
// Not tracking: this is simply a start.
|
||||
return a.StartSatelliteTracking(name, transponder)
|
||||
}
|
||||
|
||||
_, birds, _ := a.satParts()
|
||||
b, ok := birds.Find(name)
|
||||
if !ok || len(b.Transponders) == 0 {
|
||||
return fmt.Errorf("%s has no frequency plan to tune to", name)
|
||||
}
|
||||
if transponder < 0 || transponder >= len(b.Transponders) {
|
||||
transponder = 0
|
||||
}
|
||||
tp := b.Transponders[transponder]
|
||||
|
||||
t.mu.Lock()
|
||||
t.name, t.tp = b.Name, transponder
|
||||
t.nominalDown = tp.Centre()
|
||||
// Zeroed so the next step tunes and sets the mode again rather than deciding
|
||||
// nothing has changed.
|
||||
t.lastDown, t.lastUp, t.fails = 0, 0, 0
|
||||
// And so the antenna is commanded at once instead of waiting for the new
|
||||
// satellite to drift a step away from where the old one happened to be.
|
||||
t.rotSent = false
|
||||
t.status.Name, t.status.Transponder, t.status.Mode = b.Name, tp.Label, tp.Mode
|
||||
t.status.Error = ""
|
||||
t.mu.Unlock()
|
||||
|
||||
if a.cat != nil && a.cat.SatCapable() {
|
||||
a.applySatRadio(tp)
|
||||
}
|
||||
select {
|
||||
case t.wake <- struct{}{}:
|
||||
default: // a step is already pending; it will pick this up
|
||||
}
|
||||
applog.Printf("sat: now tracking %s (%s)", b.Name, tp.Label)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/sat"
|
||||
@@ -106,3 +107,68 @@ func (c *countingRotator) Point(az, el float64) error {
|
||||
}
|
||||
func (c *countingRotator) Heading() (float64, float64, bool, error) { return 0, 0, false, nil }
|
||||
func (c *countingRotator) Close() {}
|
||||
|
||||
// Which sideband goes on each slice.
|
||||
//
|
||||
// An inverting transponder turns the passband over, so a signal transmitted on
|
||||
// lower sideband comes back on upper. Setting USB at both ends put the
|
||||
// operator's own audio through upside down — unreadable at the far end, and on
|
||||
// FO-29, RS-44 and AO-73 that is every contact attempted.
|
||||
func TestSatSidebands(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
tp sat.Transponder
|
||||
wantDown, want string
|
||||
}{
|
||||
{"inverting linear: LSB up, USB down",
|
||||
sat.Transponder{Mode: "SSB", Inverting: true}, "USB", "LSB"},
|
||||
{"non-inverting linear: USB both ways",
|
||||
sat.Transponder{Mode: "SSB"}, "USB", "USB"},
|
||||
// A tone is transmitted and received in FM whichever way the passband
|
||||
// runs, and CW is CW.
|
||||
{"FM is FM both ways", sat.Transponder{Mode: "FM"}, "FM", "FM"},
|
||||
{"CW ignores inversion", sat.Transponder{Mode: "CW", Inverting: true}, "CW", "CW"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
down, up := satSidebands(c.tp)
|
||||
if down != c.wantDown || up != c.want {
|
||||
t.Errorf("%s: got %s/%s, want %s/%s", c.name, down, up, c.wantDown, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The per-band antenna map is keyed by the UPPERCASED band name, because that
|
||||
// is what the settings panel writes. The satellite tracker looked its two bands
|
||||
// up with the band plan's own spelling, matched nothing, and ran the pass on
|
||||
// whichever antenna the radio was last left on — a 70 cm downlink through a 2 m
|
||||
// transverter, with no error anywhere. This pins the contract in the direction
|
||||
// that broke.
|
||||
func TestFlexBandAntKeyIsUppercased(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
hz int64
|
||||
want string
|
||||
}{
|
||||
{435_400_000, "70CM"}, // an FM bird's downlink
|
||||
{145_950_000, "2M"}, // its uplink
|
||||
{1_269_000_000, "23CM"}, // AO-92's L band
|
||||
{29_450_000, "10M"}, // AO-7 mode A
|
||||
{9_000_000_000, ""}, // nothing in the plan: no key, and no antenna
|
||||
} {
|
||||
if got := flexBandAntKey(c.hz); got != c.want {
|
||||
t.Errorf("flexBandAntKey(%d) = %q, want %q", c.hz, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A band list sorted as strings puts 10m between 1.25m and 12m, which is why
|
||||
// the plan's own index is the order.
|
||||
func TestBandOrderIsByFrequency(t *testing.T) {
|
||||
got := []string{"70cm", "10m", "160m", "2m", "20m", "banana"}
|
||||
sort.Slice(got, func(i, j int) bool { return bandOrder(got[i]) < bandOrder(got[j]) })
|
||||
want := []string{"160m", "20m", "10m", "2m", "70cm", "banana"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("sorted %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,90 @@
|
||||
[
|
||||
{
|
||||
"version": "0.27.23",
|
||||
"date": "",
|
||||
"en": [
|
||||
"SteppIR: a Calibrate button, beside Retract. It drives every element to its end stop so the controller re-learns where zero is — the cure for an antenna that tunes to the wrong length after a power cut mid-move, after the elements were pushed by hand, or after a motor slipped. It takes minutes and the antenna is unusable until it finishes, so it asks first. Ultrabeam controllers have no such command and say so rather than pretending. Retracting the elements was already there and now explains itself: it is the storage position, and the next tune brings them back out on its own.",
|
||||
"On the rotor dial, the beam no longer whips a full turn round the compass when the antenna crosses north. A rotator at 020° turned anticlockwise reports 020, 010, 000, 359, 358 … and the animation travelled from 20° to 359° the long way — a complete revolution on screen while the mast moved forty degrees the other way. The beam now follows an accumulated angle, so what is drawn is the way the antenna is actually turning. Both lobes of a bidirectional antenna are handled separately, since they cross north at different moments.",
|
||||
"The rotor widget stops claiming the antenna is turning every time the wind moves it. A threshold alone could not tell the difference — a gust pushes a beam well past four degrees and back, and each excursion counted, so Stop lit and went out all evening on an antenna that had not turned. What separates a rotation from the weather is not how far the reading moves but which way: a rotor under power advances, gust after gust reverses. A step now counts only when the previous one went the same way, so movement is announced one poll later on a mast that takes half a minute to cross a pass, and never at all on a windy afternoon.",
|
||||
"LilacSat-2 gets its FM transponder — 144.350 up, 437.200 down — and its proper name, LO-90. It was shipped with nothing but an APRS digipeater, because the transponder database marks that transponder inactive: it runs to an announced schedule rather than continuously, which from a database looks exactly like a dead one. The label says \"(scheduled)\" so nobody wonders why it is quiet. The generator now also lists the satellites it refused on that ground and kept nothing else for, so the next regeneration is not silent about them.",
|
||||
"A frequency OpsLog got wrong can now be corrected on a station that already has the satellite file. Until now the file was copied out on the first run and was the operator's from then on, so a mistake we shipped — LilacSat-2 with no FM transponder — had become their data and could never be mended. The plan now adds what is missing, brings up to date what was never edited, and leaves alone what was: an entry you corrected by hand outranks anything shipped, and the log names the ones it stood down on. The first run after this cannot tell the two apart, so it takes the shipped plan and copies your file to satellites.json.bak first — after that your edits are recognised precisely and survive every release.",
|
||||
"The per-band antennas are applied to the satellite slices at last. The map is keyed by the band name in capitals, the tracker looked its two bands up in lower case, and so it read no antenna at all out of a table the operator had filled in — a 70 cm downlink stayed on the 2 m transverter. The log now names the band and the antenna it resolved, so a setting never made can be told apart from a lookup that missed.",
|
||||
"Tracking now shows where the satellite is beside where the antenna points: azimuth, elevation, distance and altitude, in the same strip as the two frequencies. The elevation goes dim below the horizon, so a bird still being followed on its way up cannot be read as workable.",
|
||||
"The two satellite lists in the settings are sorted by name, numerically — AO-27, AO-91, AO-123. The left one came in the order the frequency file happens to be written and the right one in the order they were clicked, so finding one bird among sixteen meant reading all sixteen. A satellite renamed on getting its OSCAR number, as LILACSAT-2 became LO-90, is also recognised in a followed list that still holds the old name instead of being listed as having no elements.",
|
||||
"The grid-square map filters by one mode, by band and by satellite. The FTx button is gone: it grouped FT8, FT4 and FT2 together, while the question the map answers is where a single mode has been heard. The named modes are read from the log itself, so each one is offered only if there is something behind it — and FT2 appears for whoever is already using it, with nothing to change here the day it becomes a registered mode. The band list is the station's own plus anything worked outside it, and the satellite list only appears once a square has been worked through a bird.",
|
||||
"Saving the satellite settings now refreshes the Satellites tab. It read the followed list once when it was opened, so un-following a satellite left it in the pass table and in the dropdown until the tab was reopened — which looks exactly like a setting that failed to save. The lowest pass, the window and the locator are picked up the same way, and a satellite that is no longer followed hands the selection to the first one that is.",
|
||||
"The FT map takes one colour for every decode, next to the basemap buttons. The band palette is calibrated against a plain map — 60m navy and 70cm olive all but vanish over the satellite imagery — and it is only useful to somebody watching several bands at once, which most operators are not. Leave it empty for the colour per band, as before. The legend then keeps the band names and drops the swatches, since which bands are up is still worth knowing.",
|
||||
"\"Later\" on an update notice now asks for how long: 1, 4, 12 or 24 hours. It only hid the card before, and the check behind it runs every five minutes, so the same notice came back four times an hour all evening for a version already declined. The delay is recorded against that version, so a newer release is different news and appears at once — a deferral can never bury an update. The cross hides it for an hour, and opening About still answers whatever was deferred, because asking is asking.",
|
||||
"Stop stays lit for the whole rotation instead of blinking. Keeping the wind out took a four-degree step, but four degrees is four seconds of travel on a real rotor, so between two of them nothing confirmed the mast was still turning and the button went dark and came back the whole way round. Getting in stays strict — two steps the same way, which weather never gives — while staying in now needs only continued progress in that direction: one degree the same way is not a gust when the rotor is already under power.",
|
||||
"The frequency plan is checked against AMSAT's live FM and image lists. AO-123 gets the 67.0 Hz tone its uplink needs, SO-50 says in its label that 74.4 Hz arms the timer before 67.0 can key it, and eight SSTV satellites are added — HC1PX, RS18S, RS27S, RS38S, RS40S, RS57S, RS58S and RS83S — as receive-only entries, with SSTV downlinks added to SONATE-2 and QMR-KWT-2. Every FM bird's uplink, downlink and tone now agrees with the published table.",
|
||||
"The FT map can now show who hears YOU. \"Who hears me\" draws, in dashed cyan, every station reporting your own transmissions to PSK Reporter, alongside the solid arcs of what you decode — the direction you cannot see from your own receiver, and on FT8 the one that says whether calling is worth the cycle. It costs almost nothing: your callsign goes in the topic's transmit level, so the broker sends nothing else, and it works with the band-opening watch switched off. Off until asked for, and each station appears once with its freshest report inside a fifteen-minute window.",
|
||||
"\"Who hears me\" is marks alone, no arcs. With a few dozen receivers reporting, a line to each made a fan out of one square that buried the decode arcs beside it — and the lines said nothing, since every one of these paths starts in the same place. The mark is a hollow diamond, fading with age: shape rather than colour carries the difference, the decode dots being filled circles and the arcs having used fourteen colours already.",
|
||||
"The FT map's controls stop covering it. The basemap buttons had crept over Leaflet's zoom control, so the minus button took clicks meant for Street, and the row had grown with the colour picker and the reverse layer until it reached the middle of a world map — covering the Atlantic to save a top-right corner that was empty the whole time. Basemaps stay on the left, clear of the zoom; the colour and \"Who hears me\" move to the right.",
|
||||
"The who-hears-me diamonds are filled and take a colour of their own, chosen beside the switch that turns them on. Filled with a hairline white edge, the trick the home marker already uses: a ring is an outline drawn over whatever is beneath it, and eight pixels of it over the satellite imagery was barely there. The colour is separate from the decode one because a single control would have forced both layers into one colour — the very distinction it took a shape to make."
|
||||
],
|
||||
"fr": [
|
||||
"SteppIR : un bouton Calibrer, à côté de Rétracter. Il amène chaque élément en butée pour que le contrôleur retrouve son zéro — le remède à une antenne qui s’accorde à la mauvaise longueur après une coupure en pleine course, après avoir poussé les éléments à la main, ou après un moteur qui a glissé. Cela prend plusieurs minutes et l’antenne est inutilisable jusqu’à la fin : une confirmation est donc demandée. Les contrôleurs Ultrabeam n’ont pas cette commande et le disent, au lieu de faire semblant. Rétracter les éléments existait déjà et s’explique maintenant : c’est la position de rangement, et le prochain accord les fait ressortir tout seuls.",
|
||||
"Sur le cadran du rotor, le faisceau ne fait plus un tour complet de la boussole quand l’antenne franchit le nord. Un rotor à 020° tourné dans le sens antihoraire annonce 020, 010, 000, 359, 358… et l’animation allait de 20° à 359° par le chemin long — une révolution complète à l’écran pendant que le pylône bougeait de quarante degrés dans l’autre sens. Le faisceau suit désormais un angle cumulé : ce qui est dessiné est le mouvement réel de l’antenne. Les deux lobes d’une antenne bidirectionnelle sont traités séparément, puisqu’ils ne franchissent pas le nord au même moment.",
|
||||
"Le widget rotor n’annonce plus une antenne en rotation chaque fois que le vent la bouge. Un simple seuil ne pouvait pas faire la différence — une rafale pousse une beam bien au-delà de quatre degrés puis la ramène, et chaque écart comptait : Stop s’allumait et s’éteignait toute la soirée sur une antenne qui n’avait pas tourné. Ce qui distingue une rotation de la météo n’est pas l’amplitude mais le sens : un rotor sous tension avance, une rafale revient. Un écart ne compte donc que si le précédent allait dans le même sens — la rotation est annoncée un relevé plus tard sur un pylône qui met trente secondes à traverser, et plus du tout par vent fort.",
|
||||
"LilacSat-2 récupère son transpondeur FM — 144,350 en montée, 437,200 en descente — et son vrai nom, LO-90. Il était livré avec un simple digipeater APRS, parce que la base de transpondeurs marque ce transpondeur comme inactif : il fonctionne selon un calendrier annoncé plutôt qu’en continu, ce qui, vu d’une base de données, ressemble exactement à un transpondeur mort. Le libellé indique « (scheduled) » pour que personne ne se demande pourquoi il est muet. Le générateur liste désormais aussi les satellites qu’il a écartés pour cette raison sans rien garder d’autre, afin que la prochaine régénération ne les passe plus sous silence.",
|
||||
"Une fréquence qu’OpsLog avait fausse peut désormais être corrigée sur une station qui possède déjà le fichier satellites. Jusqu’ici ce fichier était copié au premier lancement puis appartenait à l’opérateur, si bien qu’une erreur de notre part — LilacSat-2 sans transpondeur FM — devenait sa donnée et ne pouvait plus être réparée. Le plan ajoute maintenant ce qui manque, met à jour ce qui n’a jamais été modifié, et laisse intact ce qui l’a été : une entrée corrigée à la main prime sur tout ce qui est livré, et le journal nomme celles devant lesquelles il s’est effacé. Le premier lancement après ce changement ne peut pas distinguer les deux : il prend donc le plan livré et copie d’abord votre fichier en satellites.json.bak — ensuite vos modifications sont reconnues précisément et survivent à chaque version.",
|
||||
"Les antennes par bande sont enfin appliquées aux slices satellite. La table est indexée par le nom de bande en majuscules, le suivi cherchait ses deux bandes en minuscules, et il ne lisait donc aucune antenne dans un tableau que l’opérateur avait rempli — une descente 70 cm restait sur le transverter 2 m. Le journal indique désormais la bande et l’antenne retenues, pour distinguer un réglage jamais fait d’une recherche qui a échoué.",
|
||||
"Le suivi affiche désormais où est le satellite à côté de là où pointe l’antenne : azimut, élévation, distance et altitude, dans le même bandeau que les deux fréquences. L’élévation s’estompe sous l’horizon, pour qu’un satellite encore suivi avant son lever ne soit pas pris pour un satellite travaillable.",
|
||||
"Les deux listes de satellites des réglages sont triées par nom, en tenant compte des nombres — AO-27, AO-91, AO-123. Celle de gauche suivait l’ordre du fichier de fréquences et celle de droite l’ordre des clics : retrouver un satellite parmi seize obligeait à lire les seize. Un satellite renommé lors de l’attribution de son numéro OSCAR, comme LILACSAT-2 devenu LO-90, est aussi reconnu dans une liste de suivis qui porte encore l’ancien nom, au lieu d’y apparaître sans éléments.",
|
||||
"La carte des carrés locator se filtre par mode précis, par bande et par satellite. Le bouton FTx disparaît : il regroupait FT8, FT4 et FT2, alors que la question à laquelle répond cette carte est de savoir où un seul mode a été entendu. Les modes proposés sont lus dans le journal, donc chacun n’apparaît que s’il y a quelque chose derrière — et FT2 est proposé à qui l’utilise déjà, sans rien à changer ici le jour où il deviendra un mode officiel. La liste des bandes est celle de la station, complétée par ce qui a été travaillé en dehors, et la liste des satellites n’apparaît qu’une fois un carré travaillé par satellite.",
|
||||
"Enregistrer les réglages satellite rafraîchit désormais l’onglet Satellites. Il lisait la liste des suivis une seule fois à son ouverture : retirer un satellite le laissait dans le tableau des passes et dans la liste déroulante jusqu’à la réouverture de l’onglet — ce qui ressemble exactement à un réglage qui ne s’est pas enregistré. L’élévation minimale, la fenêtre de prévision et le locator sont pris en compte de la même façon, et un satellite qui n’est plus suivi passe la sélection au premier qui l’est.",
|
||||
"La carte FT accepte une couleur unique pour tous les décodages, à côté des boutons de fond de carte. La palette par bande est calibrée sur une carte claire — le bleu marine du 60 m et l’olive du 70 cm disparaissent presque sur l’imagerie satellite — et elle ne sert qu’à qui surveille plusieurs bandes à la fois, ce qui n’est pas le cas de la plupart. Laissez-la vide pour retrouver la couleur par bande. La légende conserve alors les noms de bandes et abandonne les pastilles, car savoir quelles bandes sont ouvertes reste utile.",
|
||||
"« Plus tard » sur une notification de mise à jour demande désormais combien de temps : 1, 4, 12 ou 24 heures. Le bouton ne faisait que masquer la fenêtre, et la vérification derrière tourne toutes les cinq minutes : la même notification revenait quatre fois par heure toute la soirée pour une version déjà refusée. Le report est enregistré pour cette version précise, donc une version plus récente est une autre nouvelle et s’affiche immédiatement — un report ne peut jamais enterrer une mise à jour. La croix masque pendant une heure, et ouvrir « À propos » répond toujours, même sur une version reportée : demander, c’est demander.",
|
||||
"Le bouton Stop reste allumé pendant toute la rotation au lieu de clignoter. Écarter le vent demandait un écart de quatre degrés, mais quatre degrés représentent quatre secondes de course sur un vrai rotor : entre deux d’entre eux, plus rien ne confirmait que le pylône tournait encore et le bouton s’éteignait puis se rallumait tout du long. L’entrée reste stricte — deux écarts dans le même sens, ce que la météo ne produit jamais — tandis que le maintien ne demande plus qu’une progression continue dans ce sens : un degré du même côté n’est pas une rafale quand le rotor est déjà sous tension.",
|
||||
"Le plan de fréquences est vérifié contre les listes FM et image d’AMSAT. AO-123 reçoit le ton de 67,0 Hz qu’exige sa montée, SO-50 indique dans son libellé que 74,4 Hz arme le minuteur avant que 67,0 puisse l’ouvrir, et huit satellites SSTV sont ajoutés — HC1PX, RS18S, RS27S, RS38S, RS40S, RS57S, RS58S et RS83S — en réception seule, avec une descente SSTV ajoutée à SONATE-2 et QMR-KWT-2. La montée, la descente et le ton de chaque satellite FM correspondent désormais à la table publiée.",
|
||||
"La carte FT peut désormais montrer qui VOUS entend. « Qui m’entend » trace en pointillés cyan chaque station qui rapporte vos propres émissions à PSK Reporter, à côté des arcs pleins de ce que vous décodez — la direction qu’on ne peut pas voir depuis son propre récepteur, et en FT8 celle qui dit si appeler vaut le cycle. Le coût est quasi nul : votre indicatif est placé dans le niveau émission du topic, donc le broker n’envoie rien d’autre, et cela fonctionne même avec la veille d’ouverture de bande éteinte. Éteint par défaut, et chaque station apparaît une fois avec son rapport le plus récent dans une fenêtre de quinze minutes.",
|
||||
"« Qui m’entend » n’affiche plus que des marques, sans arcs. Avec quelques dizaines de récepteurs, un trait vers chacun formait un éventail depuis un seul carré qui enterrait les arcs de décodage voisins — et ces traits ne disaient rien, puisque tous ces chemins partent du même endroit. La marque est un losange creux qui s’estompe avec l’âge : c’est la forme, et non la couleur, qui porte la différence, les points de décodage étant des cercles pleins et les arcs utilisant déjà quatorze couleurs.",
|
||||
"Les contrôles de la carte FT cessent de la recouvrir. Les boutons de fond de carte avaient débordé sur le zoom de Leaflet — le bouton moins prenait les clics destinés à Street — et la rangée s’était allongée avec le sélecteur de couleur et la couche inverse jusqu’à atteindre le milieu d’une carte du monde, masquant l’Atlantique pour épargner un coin haut-droit resté vide tout ce temps. Les fonds de carte restent à gauche, dégagés du zoom ; la couleur et « Qui m’entend » passent à droite.",
|
||||
"Les losanges de « qui m’entend » sont pleins et prennent leur propre couleur, choisie à côté de l’interrupteur qui les allume. Pleins avec un liseré blanc d’un pixel, le procédé qu’utilise déjà le marqueur de la station : un cercle creux n’est qu’un contour tracé sur ce qui se trouve dessous, et huit pixels de contour sur l’imagerie satellite se voyaient à peine. La couleur est distincte de celle des décodages, car un seul réglage aurait forcé les deux couches dans une même couleur — précisément la distinction qu’il a fallu une forme pour établir."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.27.22",
|
||||
"date": "",
|
||||
"en": [
|
||||
"If OpsLog is about to create a new, empty settings database in a folder that already holds a full one, it says so — in the startup log and on screen — instead of opening quietly as though nothing were configured. Nothing is deleted and nothing is guessed: the message names the other file, which is where your settings still are.",
|
||||
"config.json — the one file that records WHERE your database is — is now written atomically, and a copy of the previous one is kept beside it. It was written by truncating the file and then filling it, so a process that stopped in between (a crash, a power cut, an update closing the old instance) left it empty; OpsLog then read \"no database chosen\", opened a new empty one, and started having forgotten everything. An unreadable config.json is now restored from its backup, and one that cannot be restored is KEPT as config.json.broken rather than silently replaced.",
|
||||
"The antenna readout no longer flickers in and out during a pass. The rotator is asked where it is every three seconds, but the tracking status was rebuilt from scratch every second and dropped the answer in between — so the antenna appeared for one second in three, which reads as a rotator that keeps disconnecting.",
|
||||
"While tracking, the two frequencies and the antenna bearing sit beside the Tracking button. During a pass an operator watches the radio and the antenna, not a column on the far side of the window — and that column is the first thing hidden to get the map full width. The compass spins while the antenna is still slewing: a mast takes tens of seconds to cross a pass, and the difference between \"on its way\" and \"stuck\" is the whole reason to look at it.",
|
||||
"Satellite frequencies are shown to a hundred hertz instead of one. The Doppler moves about sixty hertz a second on 70 cm, so the last two digits changed every tick and the display was a blur of numbers nobody could read and nobody needed. The radio still gets the whole figure — this is only how much of it is worth putting in front of you. The shift beside it now reads \"+9.7 kHz\" rather than \"+9741 Hz\".",
|
||||
"Rotator Genius: OpsLog no longer clamps a target to 360°, and reads the limits the Genius reports so it can drive an overlap when the controller offers one. In practice a Rotator Genius is a 360° controller — its Limits fields say where the mechanical stop sits within one turn, not how far the mast travels — so an operator with a 450° rotator still gets 360° of it, and that limit is the controller’s, not OpsLog’s. The rotator range is therefore not offered for it: a setting that can only ever be refused by the box is worse than none.",
|
||||
"FlexRadio, satellite: the uplink slice is properly armed. Creating a slice is asynchronous — the radio reports its number afterwards — and OpsLog carried on without waiting, so everything meant for the uplink went nowhere: it was never tuned (it sat at the 435.100 it was created with), never got its sideband, its antenna or its CTCSS tone, and never became the transmitter, leaving the radio transmitting on the DOWNLINK slice. Arming now waits for both slices, adopts one that the radio announces without a reply of its own, and gives a late-arriving uplink everything it was owed."
|
||||
],
|
||||
"fr": [
|
||||
"Si OpsLog s’apprête à créer une base de réglages neuve et vide dans un dossier qui en contient déjà une pleine, il le dit — dans le journal de démarrage et à l’écran — au lieu de s’ouvrir sans bruit comme si rien n’était configuré. Rien n’est supprimé et rien n’est deviné : le message nomme l’autre fichier, là où vos réglages sont toujours.",
|
||||
"config.json — le seul fichier qui note OÙ se trouve votre base — est désormais écrit de façon atomique, avec une copie de la version précédente conservée à côté. Il était écrit en tronquant le fichier puis en le remplissant : un processus interrompu entre les deux (plantage, coupure de courant, mise à jour fermant l’ancienne instance) le laissait vide. OpsLog lisait alors « aucune base choisie », en ouvrait une neuve et vide, et démarrait en ayant tout oublié. Un config.json illisible est maintenant restauré depuis sa sauvegarde, et celui qu’on ne peut pas restaurer est CONSERVÉ sous le nom config.json.broken au lieu d’être remplacé en silence.",
|
||||
"L’affichage de l’antenne ne clignote plus pendant un passage. Le rotor est interrogé toutes les trois secondes, mais l’état du suivi était reconstruit de zéro chaque seconde et perdait la réponse entre-temps — l’antenne apparaissait donc une seconde sur trois, ce qui se lit comme un rotor qui se déconnecte sans arrêt.",
|
||||
"Pendant le suivi, les deux fréquences et le cap de l’antenne sont affichés à côté du bouton Tracking. Pendant un passage, on regarde la radio et l’antenne, pas une colonne à l’autre bout de la fenêtre — et c’est la première chose qu’on masque pour avoir la carte en pleine largeur. La boussole tourne tant que l’antenne est en mouvement : un pylône met des dizaines de secondes à traverser un passage, et distinguer « en route » de « bloqué » est toute la raison de la regarder.",
|
||||
"Les fréquences satellite sont affichées à la centaine de hertz au lieu du hertz. Le Doppler se déplace d’environ soixante hertz par seconde en 70 cm : les deux derniers chiffres changeaient à chaque tick et l’affichage était une bouillie de chiffres illisible et inutile. La radio reçoit toujours la valeur complète — il ne s’agit que de ce qui vaut la peine d’être mis sous vos yeux. Le décalage à côté indique désormais « +9,7 kHz » plutôt que « +9741 Hz ».",
|
||||
"Rotator Genius : OpsLog n’écrête plus une consigne à 360° et lit les limites que le Genius rapporte, de façon à exploiter un recouvrement quand le contrôleur en offre un. Dans les faits, le Rotator Genius est un contrôleur 360° — ses champs Limits indiquent où se trouve la butée mécanique dans un tour, pas la course du pylône — donc un rotor 450° n’en donne que 360, et cette limite est celle du contrôleur, pas d’OpsLog. L’amplitude du rotor n’est donc pas proposée pour lui : un réglage que le boîtier ne pourra que refuser est pire que pas de réglage du tout.",
|
||||
"FlexRadio, satellite : la tranche de montée est correctement armée. Créer une tranche est asynchrone — la radio annonce son numéro ensuite — et OpsLog continuait sans attendre : tout ce qui était destiné à la montée partait dans le vide. Elle n’était jamais accordée (elle restait sur le 435,100 de sa création), ne recevait ni sa bande latérale, ni son antenne, ni sa tonalité CTCSS, et ne devenait jamais l’émettrice — la radio émettait donc sur la tranche de DESCENTE. L’armement attend maintenant les deux tranches, adopte celle que la radio annonce sans réponse propre, et donne à une montée arrivée en retard tout ce qui lui était dû."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.27.21",
|
||||
"date": "",
|
||||
"en": [
|
||||
"The Doppler correction was wrong — by a factor of about 250, and in the wrong direction. The SGP4 library reports a range rate that is not one: the ISS closing at 5.5 km/s came back as +2036 km/s, which moved a 2 m downlink two megahertz instead of three kilohertz, and moved it the wrong way. OpsLog now measures the range rate from the range itself, which cannot disagree with physics. A 2 m downlink shifts about ±3.5 kHz across a pass and a 70 cm one about ±10 kHz, as they should.",
|
||||
"FlexRadio, satellite: the per-band antennas you configured are now applied to the satellite slices. They were not — the entry form applied them on a band change, to the active slice, and a pass never goes through that path. The two slices are on two different bands, so each gets its own: the downlink takes the receive antenna for its band, the uplink the transmit antenna for its. On a station with transverters (XVTA on 2 m, XVTB on 70 cm) the downlink was left on whatever the radio last used, and heard nothing.",
|
||||
"FlexRadio, satellite: on an inverting transponder the uplink is set to LSB and the downlink to USB, instead of USB at both ends. The passband is turned over, so audio transmitted on the wrong sideband comes back through it upside down — which is every attempted contact on FO-29, RS-44 and AO-73.",
|
||||
"FlexRadio, satellite: the CTCSS tone is set on the uplink slice from the satellite's frequency plan. An FM bird does not answer without it, and it is the one setting an operator cannot reach from the front panel once a pass has started.",
|
||||
"Changing satellite while tracking now moves the radio to the new one at once, and the antenna with it. The selection on the page was the display's; the tracker held its own and went on following whatever it was started with, so with two birds up at the same time the frequencies stayed on the first — the only way through was to stop tracking and start it again. The radio stays armed through the change, so a Flex no longer throws away and rebuilds both its slices for nothing.",
|
||||
"WinKeyer: a keyer that will not connect is now woken up instead of given up on. An operator with a WinKey2 USB had to run K1EL's WKdemo and close it again before OpsLog could open the keyer at all — so the second attempt now does what closing WKdemo does: Host Close in case a session that ended badly left the keyer waiting for a host that went away, Admin Reset for a parser stuck part-way through a command, and a DTR pulse, which on a WKUSB or an Arduino clone is a power-on reset in all but name. A keyer that echoes but refuses to open is also closed and asked again, which is the same leftover-session case seen from the other side. A port known to need this gets it straight away next time."
|
||||
],
|
||||
"fr": [
|
||||
"La correction Doppler était fausse — d’un facteur d’environ 250, et dans le mauvais sens. La bibliothèque SGP4 renvoie une vitesse radiale qui n’en est pas une : l’ISS se rapprochant à 5,5 km/s était rapportée à +2036 km/s, ce qui déplaçait une descente 2 m de deux mégahertz au lieu de trois kilohertz, et dans la mauvaise direction. OpsLog mesure désormais cette vitesse à partir de la distance elle-même, ce qui ne peut pas contredire la physique. Une descente 2 m se décale d’environ ±3,5 kHz sur un passage et une 70 cm d’environ ±10 kHz, comme il se doit.",
|
||||
"FlexRadio, satellite : les antennes par bande que vous avez configurées sont désormais appliquées aux tranches satellite. Elles ne l’étaient pas — la fenêtre de saisie les appliquait au changement de bande, sur la tranche active, et un passage ne passe jamais par là. Les deux tranches sont sur deux bandes différentes, donc chacune reçoit la sienne : la descente prend l’antenne de réception de sa bande, la montée l’antenne d’émission de la sienne. Sur une station à transverters (XVTA en 2 m, XVTB en 70 cm), la descente restait sur ce que la radio utilisait en dernier, et n’entendait rien.",
|
||||
"FlexRadio, satellite : sur un transpondeur inverseur, la montée est mise en LSB et la descente en USB, au lieu d’USB des deux côtés. La bande passante est retournée : une audio émise sur la mauvaise bande latérale revient à l’envers — soit tous les QSO tentés sur FO-29, RS-44 et AO-73.",
|
||||
"FlexRadio, satellite : la tonalité CTCSS est réglée sur la tranche de montée depuis le plan de fréquences du satellite. Un satellite FM ne répond pas sans elle, et c’est le seul réglage qu’un OM ne peut pas atteindre en façade une fois le passage commencé.",
|
||||
"Changer de satellite pendant le suivi déplace désormais la radio sur le nouveau immédiatement, et l’antenne avec. La sélection de la page était celle de l’affichage ; le tracker gardait la sienne et continuait de suivre celui avec lequel il avait démarré, donc avec deux satellites en passage simultané les fréquences restaient sur le premier — il fallait arrêter puis relancer le suivi. La radio reste armée pendant le changement : un Flex ne jette plus ses deux tranches pour les reconstruire inutilement.",
|
||||
"WinKeyer : un keyer qui refuse de se connecter est désormais réveillé au lieu d’être abandonné. Un OM avec un WinKey2 USB devait lancer le WKdemo de K1EL puis le refermer avant qu’OpsLog puisse ouvrir le keyer — la seconde tentative fait donc maintenant ce que fait la fermeture de WKdemo : Host Close au cas où une session mal terminée aurait laissé le keyer à attendre un hôte disparu, Admin Reset pour un analyseur bloqué au milieu d’une commande, et une impulsion sur DTR, qui sur un WKUSB ou un clone Arduino est une remise sous tension ou presque. Un keyer qui répond à l’écho mais refuse de s’ouvrir est également refermé puis redemandé — le même cas de session résiduelle, vu de l’autre côté. Un port connu pour en avoir besoin y a droit d’emblée la fois suivante."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.27.20",
|
||||
"date": "",
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// Command satdiag answers "is this pass real, and is that Doppler right?" from
|
||||
// a station's own cached elements, without launching OpsLog.
|
||||
//
|
||||
// go run ./cmd/satdiag <data dir> <locator> <satellite>
|
||||
//
|
||||
// It prints which element set the satellite resolved to and how old it is, the
|
||||
// look angle now, the range rate BOTH as the propagator reports it and as the
|
||||
// range actually changes, the Doppler each transponder would be given, and the
|
||||
// next passes. It exists because a wrong Doppler and a wrong satellite look the
|
||||
// same from the front — an operator saying "the frequency moves enormously" —
|
||||
// and the two are told apart by these numbers in a second.
|
||||
//
|
||||
// It found the range rate the SGP4 library reports being wrong by a factor of
|
||||
// 250 and of the wrong sign. Not part of the build.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/sat"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dir := os.Args[1]
|
||||
grid := os.Args[2]
|
||||
name := os.Args[3]
|
||||
|
||||
f := sat.NewFetcher(dir)
|
||||
els, at, err := f.LoadCache()
|
||||
if err != nil {
|
||||
fmt.Println("cache:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
store := sat.NewStore()
|
||||
store.Replace(els, at)
|
||||
fmt.Printf("elements: %d, fetched %s (%s ago)\n\n", len(els), at.Format(time.RFC3339), time.Since(at).Round(time.Minute))
|
||||
|
||||
birds, err := sat.LoadBirds(dir)
|
||||
if err != nil {
|
||||
fmt.Println("birds:", err)
|
||||
}
|
||||
b, ok := birds.Find(name)
|
||||
if !ok {
|
||||
fmt.Println("no frequency plan for", name)
|
||||
os.Exit(1)
|
||||
}
|
||||
// The same resolution the app does.
|
||||
var el sat.Element
|
||||
found := false
|
||||
if e, ok := store.GetNORAD(b.NORAD); ok {
|
||||
el, found = e, true
|
||||
fmt.Printf("elements found BY NORAD %d → %q\n", b.NORAD, e.Name)
|
||||
} else if e, ok := store.Get(b.Name); ok {
|
||||
el, found = e, true
|
||||
fmt.Printf("elements found by name → %q (NORAD %d)\n", e.Name, e.NORAD)
|
||||
} else {
|
||||
for _, a := range b.Aliases {
|
||||
if e, ok := store.Get(a); ok {
|
||||
el, found = e, true
|
||||
fmt.Printf("elements found by alias %q → %q (NORAD %d)\n", a, e.Name, e.NORAD)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
// Last resort, exactly as satElement does: scan every element name and
|
||||
// compare on letters and digits alone. This is how "JAS-2 (FO-29)" and
|
||||
// "FO-29" meet, and leaving it out of the diagnostic made a satellite
|
||||
// that resolves perfectly well in the app look unresolvable here.
|
||||
for _, n := range store.Names() {
|
||||
if b.Matches(n) {
|
||||
if e, ok := store.Get(n); ok {
|
||||
el, found = e, true
|
||||
fmt.Printf("elements found by SCAN → %q (NORAD %d)\n", e.Name, e.NORAD)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
fmt.Println("NO ELEMENTS")
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("epoch: %s (%s old)\n", el.Epoch.Format(time.RFC3339), time.Since(el.Epoch).Round(time.Hour))
|
||||
fmt.Println("line1:", el.Line1)
|
||||
|
||||
lat, lon, okGrid := gridToLatLon(grid)
|
||||
if !okGrid {
|
||||
fmt.Println("bad locator:", grid)
|
||||
os.Exit(1)
|
||||
}
|
||||
obs := sat.Observer{Lat: lat, Lon: lon}
|
||||
fmt.Printf("observer: %s → %.4f, %.4f\n\n", grid, obs.Lat, obs.Lon)
|
||||
|
||||
now := time.Now().UTC()
|
||||
p, err := el.Track(obs, now)
|
||||
if err != nil {
|
||||
fmt.Println("track:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("NOW %s : az %.1f el %.1f range %.0f km\n", now.Format("15:04:05"), p.Az, p.El, p.RangeKm)
|
||||
fmt.Printf(" range rate REPORTED by the library : %+10.3f km/s\n", p.RangeRate)
|
||||
fmt.Printf(" range rate MEASURED (d range / dt) : %+10.3f km/s\n", numericRate(el, obs, now))
|
||||
|
||||
for _, tp := range b.Transponders {
|
||||
sh := sat.Doppler(p, tp.DownLo, tp.UpLo)
|
||||
fmt.Printf(" %-28s down %d → %d (%+d Hz) up %d → %d (%+d Hz)\n",
|
||||
tp.Label, tp.DownLo, sh.DownHz, sh.DownHz-tp.DownLo, tp.UpLo, sh.UpHz, sh.UpHz-tp.UpLo)
|
||||
}
|
||||
|
||||
fmt.Println("\nnext passes (min el 0):")
|
||||
passes, err := store.Passes(el.Name, obs, now, now.Add(12*time.Hour), 0)
|
||||
if err != nil {
|
||||
fmt.Println("passes:", err)
|
||||
}
|
||||
for i, ps := range passes {
|
||||
if i >= 8 {
|
||||
break
|
||||
}
|
||||
fmt.Printf(" %s → %s max %.1f° az %.0f→%.0f\n",
|
||||
ps.AOS.Format("15:04:05"), ps.LOS.Format("15:04:05"), ps.MaxEl, ps.AOSAz, ps.LOSAz)
|
||||
}
|
||||
|
||||
// The extremes of the Doppler across the next pass, which is the honest
|
||||
// answer to "does it move that much".
|
||||
if len(passes) > 0 {
|
||||
ps := passes[0]
|
||||
var lo, hi int64
|
||||
for tt := ps.AOS; tt.Before(ps.LOS); tt = tt.Add(10 * time.Second) {
|
||||
q, err := el.Track(obs, tt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
d := sat.Doppler(q, b.Transponders[0].DownLo, 0).DownHz - b.Transponders[0].DownLo
|
||||
if d < lo {
|
||||
lo = d
|
||||
}
|
||||
if d > hi {
|
||||
hi = d
|
||||
}
|
||||
}
|
||||
fmt.Printf("\ndownlink Doppler across that pass: %+d Hz … %+d Hz (span %d Hz)\n", lo, hi, hi-lo)
|
||||
}
|
||||
}
|
||||
|
||||
// gridToLatLon is the six-character Maidenhead centre.
|
||||
func gridToLatLon(g string) (float64, float64, bool) {
|
||||
g = strings.ToUpper(strings.TrimSpace(g))
|
||||
if len(g) < 4 {
|
||||
return 0, 0, false
|
||||
}
|
||||
lon := float64(g[0]-'A')*20 - 180
|
||||
lat := float64(g[1]-'A')*10 - 90
|
||||
lon += float64(g[2]-'0') * 2
|
||||
lat += float64(g[3]-'0') * 1
|
||||
if len(g) >= 6 {
|
||||
lon += float64(g[4]-'A') * (2.0 / 24)
|
||||
lat += float64(g[5]-'A') * (1.0 / 24)
|
||||
lon += (2.0 / 24) / 2
|
||||
lat += (1.0 / 24) / 2
|
||||
} else {
|
||||
lon += 1
|
||||
lat += 0.5
|
||||
}
|
||||
return lat, lon, true
|
||||
}
|
||||
|
||||
// numericRate is the range rate measured rather than reported: the distance a
|
||||
// second later minus the distance a second earlier, over two seconds. It cannot
|
||||
// disagree with physics, so it is the reference the library's own figure is
|
||||
// checked against.
|
||||
func numericRate(el sat.Element, obs sat.Observer, at time.Time) float64 {
|
||||
a, e1 := el.Track(obs, at.Add(-time.Second))
|
||||
b, e2 := el.Track(obs, at.Add(time.Second))
|
||||
if e1 != nil || e2 != nil {
|
||||
return 0
|
||||
}
|
||||
return (b.RangeKm - a.RangeKm) / 2
|
||||
}
|
||||
@@ -140,6 +140,23 @@ func main() {
|
||||
}
|
||||
byNORAD[t.NORAD] = append(byNORAD[t.NORAD], t)
|
||||
}
|
||||
// A satellite whose only WORKABLE path is one SatNOGS calls inactive.
|
||||
//
|
||||
// LilacSat-2 is the case that taught this: its FM transponder (144.350 up,
|
||||
// 437.200 down) is marked inactive because it runs on an announced schedule
|
||||
// rather than continuously, so the generator dropped it and shipped the
|
||||
// satellite with nothing but an APRS digipeater. An operator comparing
|
||||
// against any other tracker then finds a frequency plan missing the only
|
||||
// thing anybody works that bird on.
|
||||
//
|
||||
// Not fixed automatically: "inactive" is right far more often than it is
|
||||
// wrong, and reviving every dead transponder would fill the list with
|
||||
// satellites that answer nothing. It is REPORTED, so the next regeneration
|
||||
// is read with this in front of it and the handful worth curating are
|
||||
// curated — a scheduled transponder belongs in the plan with "(scheduled)"
|
||||
// in its label, the way PO-101 and now LO-90 carry it.
|
||||
reportRefused(txs, feed, covered, byNORAD)
|
||||
|
||||
added := 0
|
||||
for n, list := range byNORAD {
|
||||
b := sat.Bird{Name: displayName(feed[n]), NORAD: n}
|
||||
@@ -442,3 +459,34 @@ func die(err error) {
|
||||
fmt.Fprintln(os.Stderr, "satgen:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// reportRefused names the satellites whose only two-way path was refused for
|
||||
// being inactive, so the operator running this can decide about each one.
|
||||
//
|
||||
// The output is deliberately a question and not a change: SatNOGS calling a
|
||||
// transponder inactive is usually correct, and the exceptions are the birds
|
||||
// whose transponder is switched on to a schedule rather than left running.
|
||||
func reportRefused(txs []transmitter, feed map[int]string, covered map[int]bool, kept map[int][]transmitter) {
|
||||
var lines []string
|
||||
for _, t := range txs {
|
||||
if t.Status == "active" || t.UplinkLow <= 0 || t.DownlinkLow <= 0 {
|
||||
continue
|
||||
}
|
||||
if feed[t.NORAD] == "" || covered[t.NORAD] || len(kept[t.NORAD]) > 0 {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" ? %5d %-22s %-4s %.3f up / %.3f down %q",
|
||||
t.NORAD, displayName(feed[t.NORAD]), adifMode(t.Mode),
|
||||
float64(t.UplinkLow)/1e6, float64(t.DownlinkLow)/1e6, t.Description))
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
sort.Strings(lines)
|
||||
fmt.Println("\nRefused as inactive, and nothing else was kept for these satellites.")
|
||||
fmt.Println("A transponder that runs to a schedule looks exactly like a dead one here:")
|
||||
for _, l := range lines {
|
||||
fmt.Println(l)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// config.json is the only record of WHERE the database is. Losing it moves an
|
||||
// operator's whole station back to an empty default, and it has happened twice.
|
||||
// These are the two ways it was lost.
|
||||
|
||||
// A half-written file must never be publishable. os.WriteFile truncates and
|
||||
// then fills, so a process that stops in between leaves an empty pointer; the
|
||||
// rename cannot.
|
||||
func TestWriteBootstrapIsAtomicAndKeepsABackup(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Absolute and OUTSIDE the application folder, so portablePath stores them
|
||||
// verbatim — the round trip is what is under test, not the re-rooting.
|
||||
first := filepath.Join(t.TempDir(), "first", "one.db")
|
||||
second := filepath.Join(t.TempDir(), "second", "two.db")
|
||||
if err := writeBootstrap(dir, dbPointer{DBPath: first}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeBootstrap(dir, dbPointer{DBPath: second}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// No temporary left lying around to be mistaken for the real thing.
|
||||
if _, err := os.Stat(dbPointerPath(dir) + ".tmp"); err == nil {
|
||||
t.Error("the temporary file was left behind")
|
||||
}
|
||||
// The previous contents are still there.
|
||||
var prev dbPointer
|
||||
b, err := os.ReadFile(dbPointerPath(dir) + ".bak")
|
||||
if err != nil {
|
||||
t.Fatalf("no backup was kept: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(b, &prev); err != nil {
|
||||
t.Fatalf("the backup does not parse: %v", err)
|
||||
}
|
||||
if prev.DBPath != first {
|
||||
t.Errorf("the backup holds %q, want the previous pointer", prev.DBPath)
|
||||
}
|
||||
if got := readBootstrap(dir); got.DBPath != second {
|
||||
t.Errorf("read back %q, want the current pointer", got.DBPath)
|
||||
}
|
||||
}
|
||||
|
||||
// A pointer that EXISTS and cannot be read is not the same thing as no pointer.
|
||||
// Treating it as one is what opened an empty database and presented an operator
|
||||
// with a program that had forgotten them.
|
||||
func TestReadBootstrapRecoversFromABrokenPointer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mine := filepath.Join(t.TempDir(), "mine", "station.db")
|
||||
if err := writeBootstrap(dir, dbPointer{DBPath: mine}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Two writes, so there is a backup of the good one to fall back to.
|
||||
if err := writeBootstrap(dir, dbPointer{DBPath: mine}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Now truncate it, exactly as an interrupted write would.
|
||||
if err := os.WriteFile(dbPointerPath(dir), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := readBootstrap(dir)
|
||||
if got.DBPath != mine {
|
||||
t.Fatalf("read %q — the database the operator chose was lost", got.DBPath)
|
||||
}
|
||||
// And it is put back, so the next launch does not have to recover again.
|
||||
if again := readBootstrap(dir); again.DBPath != mine {
|
||||
t.Errorf("the restored pointer did not stick: %q", again.DBPath)
|
||||
}
|
||||
}
|
||||
|
||||
// With nothing to restore from, the broken file is KEPT. It is evidence, and it
|
||||
// may still be readable by hand.
|
||||
func TestReadBootstrapKeepsAnUnrecoverablePointer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(dbPointerPath(dir), []byte("{oops"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readBootstrap(dir); got.DBPath != "" {
|
||||
t.Errorf("invented a path out of a broken file: %q", got.DBPath)
|
||||
}
|
||||
if _, err := os.Stat(dbPointerPath(dir) + ".broken"); err != nil {
|
||||
t.Errorf("the broken pointer was not kept: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The warning that turns a silent loss into a sentence: a new empty database
|
||||
// about to be created in a folder that already holds a full one.
|
||||
func TestOtherDatabasesInSpotsTheFullOneNextDoor(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
full := filepath.Join(dir, "opslog.db")
|
||||
if err := os.WriteFile(full, []byte("not empty"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chosen := filepath.Join(dir, "settings.db")
|
||||
if got := otherDatabasesIn(dir, chosen); len(got) != 1 || got[0] != "opslog.db" {
|
||||
t.Errorf("got %v, want the full database next door", got)
|
||||
}
|
||||
// A zero-byte file is not a lost configuration.
|
||||
if err := os.WriteFile(full, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := otherDatabasesIn(dir, chosen); len(got) != 0 {
|
||||
t.Errorf("an empty file was reported as a database: %v", got)
|
||||
}
|
||||
// And the one being opened is never reported against itself.
|
||||
if err := os.WriteFile(chosen, []byte("in use"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := otherDatabasesIn(dir, chosen); len(got) != 0 {
|
||||
t.Errorf("the chosen database was reported as another: %v", got)
|
||||
}
|
||||
}
|
||||
+52
-4
@@ -2455,6 +2455,33 @@ export default function App() {
|
||||
if (wlNoticeTimer.current) window.clearTimeout(wlNoticeTimer.current);
|
||||
wlNoticeTimer.current = window.setTimeout(() => setWlNotice(null), 4000);
|
||||
}), []);
|
||||
// An update deferred stays deferred.
|
||||
//
|
||||
// "Later" only hid the card, and the check behind it runs every five
|
||||
// minutes — so the same notice came back four times an hour, all evening,
|
||||
// for a version already declined. It now records until WHEN, and for which
|
||||
// version: a release newer than the one put off is a different piece of
|
||||
// news and appears at once, so a snooze can never bury an update for good.
|
||||
//
|
||||
// Deliberately not a portable UI pref — a machine told to wait four hours
|
||||
// has said nothing about the operator's other machines.
|
||||
const SNOOZE_KEY = 'opslog.updateSnooze';
|
||||
const updateSnoozed = (version: string) => {
|
||||
try {
|
||||
const raw = localStorage.getItem(SNOOZE_KEY);
|
||||
if (!raw) return false;
|
||||
const s = JSON.parse(raw) as { v?: string; until?: number };
|
||||
return s?.v === version && Number(s?.until) > Date.now();
|
||||
} catch { return false; } // unreadable is not snoozed
|
||||
};
|
||||
const snoozeUpdate = (hours: number) => {
|
||||
try {
|
||||
if (updateInfo) localStorage.setItem(SNOOZE_KEY, JSON.stringify({ v: updateInfo.latest, until: Date.now() + hours * 3600_000 }));
|
||||
} catch { /* quota: the card just comes back, which is the old behaviour */ }
|
||||
setLaterOpen(false);
|
||||
setUpdateInfo(null);
|
||||
};
|
||||
const [laterOpen, setLaterOpen] = useState(false);
|
||||
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||
// Fresh update check on demand (opening About), so it never shows a stale
|
||||
// "you're up to date". Clears updateInfo when the latest check finds nothing.
|
||||
@@ -2472,7 +2499,11 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem('opslog.checkUpdates') === '0') return;
|
||||
const check = () => CheckForUpdate().then((u: any) => {
|
||||
if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') });
|
||||
// The snooze is checked HERE and not in checkUpdateNow: opening About
|
||||
// is a question, and it deserves the answer whatever was deferred.
|
||||
if (u?.available && u?.latest && !updateSnoozed(String(u.latest))) {
|
||||
setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') });
|
||||
}
|
||||
}).catch(() => {});
|
||||
check();
|
||||
const id = window.setInterval(check, 5 * 60 * 1000);
|
||||
@@ -3530,6 +3561,11 @@ export default function App() {
|
||||
try {
|
||||
const st = await GetStartupStatus();
|
||||
if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; }
|
||||
// Started, but somewhere that deserves saying out loud — a new, empty
|
||||
// settings database opened beside a full one. An operator who is not
|
||||
// told this concludes their configuration was thrown away, when the
|
||||
// file holding it is sitting right there.
|
||||
if (st.warn) setError(st.warn);
|
||||
// First launch (or a never-configured profile): collect the mandatory
|
||||
// station identity before anything else.
|
||||
try {
|
||||
@@ -7417,16 +7453,28 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||
<button onClick={startUpdate} className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">
|
||||
{updateInfo.downloadUrl ? t('upd.install') : t('upd.download')}
|
||||
</button>
|
||||
<button onClick={() => setUpdateInfo(null)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">{t('upd.later')}</button>
|
||||
{laterOpen ? (
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
{t('upd.remindIn')}
|
||||
{[1, 4, 12, 24].map((h) => (
|
||||
<button key={h} onClick={() => snoozeUpdate(h)}
|
||||
className="h-6 px-1.5 rounded border border-border text-[11px] tabular-nums hover:bg-muted text-foreground">
|
||||
{h}h
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<button onClick={() => setLaterOpen(true)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">{t('upd.later')}</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!updating && (
|
||||
<button onClick={() => setUpdateInfo(null)} className="text-muted-foreground hover:text-foreground shrink-0" title="Dismiss">
|
||||
<button onClick={() => snoozeUpdate(1)} className="text-muted-foreground hover:text-foreground shrink-0" title={t('upd.dismissHour')}>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { gridToLatLon, greatCirclePoints, splitAtAntimeridian } from '@/lib/maidenhead';
|
||||
@@ -7,6 +7,9 @@ import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { loadMapView, saveMapView, MAP_VIEW_FT } from '@/lib/mapView';
|
||||
import { loadMapBase, saveMapBase, MAP_BASE_FT } from '@/lib/mapBase';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { GetHearMe, SetHearMe, GetWhoHearsMe } from '../../wailsjs/go/main/App';
|
||||
import { Ear } from 'lucide-react';
|
||||
|
||||
// FT Map — the live decode feed as geography: every station decoded in the
|
||||
// last half hour, an arc from the operator's own square to theirs, coloured by
|
||||
@@ -41,6 +44,52 @@ const BAND_COLOURS: Record<string, string> = {
|
||||
};
|
||||
const bandColour = (b?: string) => BAND_COLOURS[(b ?? '').toLowerCase()] || '#9ca3af';
|
||||
|
||||
// One colour for every arc, overriding the palette. Empty means per band,
|
||||
// which stays the default.
|
||||
//
|
||||
// The palette is only useful to somebody watching several bands at once, and
|
||||
// it is calibrated against a plain map: 60m navy and 70cm olive all but
|
||||
// disappear over the satellite imagery, and 20m yellow over the deserts. An
|
||||
// operator on one band has nothing to lose by painting the whole map in a
|
||||
// colour that shows up against the ground he chose.
|
||||
const COL_KEY = 'opslog.ftMapColour';
|
||||
|
||||
// The reverse layer's own colour. Empty means the default below.
|
||||
//
|
||||
// Separate from COL_KEY on purpose: the decode colour exists because the
|
||||
// band palette vanishes over some basemaps, and this one has exactly the
|
||||
// same problem for exactly the same reason — cyan over a pale sea reads no
|
||||
// better than 60m navy does. One control for both would have forced the
|
||||
// two layers into one colour, which is the distinction it took a shape to
|
||||
// make in the first place.
|
||||
const HEARD_COL_KEY = 'opslog.ftMapHeardColour';
|
||||
|
||||
// A colour input only accepts #rrggbb, so anything else stored here is
|
||||
// treated as no choice at all rather than driving the swatch to black.
|
||||
const asHex = (v: string) => (/^#[0-9a-f]{6}$/i.test(v.trim()) ? v.trim() : '');
|
||||
|
||||
// One station reporting our own transmissions, from PSK Reporter.
|
||||
type Heard = { call: string; grid: string; band: string; mode: string; snr: number; at: string };
|
||||
|
||||
// The reverse layer is marks only, in one colour whatever the band, and the
|
||||
// mark is a DIAMOND.
|
||||
//
|
||||
// It started with an arc per station, like the decodes, and that was wrong:
|
||||
// with a few dozen receivers reporting, the map became a fan of lines out of
|
||||
// one square that buried the very arcs it sat beside. Nothing was gained by
|
||||
// them either — an arc's job on the decode layer is to say WHICH of many
|
||||
// stations a path belongs to, and here every path starts at the same place.
|
||||
//
|
||||
// Shape, then, rather than colour, carries the distinction: the decode dots
|
||||
// are small filled circles, and the arcs already use fourteen colours, so a
|
||||
// fifteenth would read as another band. A diamond is unmistakably not one of
|
||||
// them at a glance.
|
||||
//
|
||||
// Filled, with a hairline white edge — the same trick the home marker uses.
|
||||
// It was a ring, and a ring is an outline drawn over whatever is beneath it:
|
||||
// eight pixels of it over the satellite imagery was barely there.
|
||||
const HEARD_COLOUR = '#22d3ee'; // the default, when nothing is chosen
|
||||
|
||||
const MAX_ARCS = 300;
|
||||
const MAX_AGE_MS = 30 * 60_000;
|
||||
|
||||
@@ -53,6 +102,14 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
||||
}) {
|
||||
// Held in refs so the redraw below does not have to list them as dependencies
|
||||
// and rebuild every arc whenever the parent re-renders.
|
||||
const [colour, setColour] = useState(() => asHex(localStorage.getItem(COL_KEY) ?? ''));
|
||||
const [heardColour, setHeardColour] = useState(() => asHex(localStorage.getItem(HEARD_COL_KEY) ?? ''));
|
||||
const heardInk = heardColour || HEARD_COLOUR;
|
||||
// Whether the reverse feed is wanted lives in the DB, not here: it is what
|
||||
// starts an MQTT subscription, so the backend has to be the one that knows.
|
||||
const [hearMe, setHearMe] = useState(false);
|
||||
const [heard, setHeard] = useState<Heard[]>([]);
|
||||
const [heardBusy, setHeardBusy] = useState(false);
|
||||
const selectRef = useRef(onSelect);
|
||||
const callRef = useRef(onCall);
|
||||
useEffect(() => { selectRef.current = onSelect; callRef.current = onCall; }, [onSelect, onCall]);
|
||||
@@ -68,6 +125,10 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const layerRef = useRef<L.LayerGroup | null>(null);
|
||||
// Its own group: the reverse layer refreshes on its own clock, and clearing
|
||||
// the decode arcs to redraw it would throw away three hundred polylines
|
||||
// every twenty seconds for nothing.
|
||||
const heardLayerRef = useRef<L.LayerGroup | null>(null);
|
||||
const baseRef = useRef<L.TileLayer | null>(null);
|
||||
const labelsRef = useRef<L.TileLayer | null>(null);
|
||||
const [basemap, setBasemap] = useState<BasemapKey>(() =>
|
||||
@@ -95,6 +156,7 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
||||
});
|
||||
mapRef.current = m;
|
||||
layerRef.current = L.layerGroup().addTo(m);
|
||||
heardLayerRef.current = L.layerGroup().addTo(m);
|
||||
// Leaflet measures its container ONCE, when the map is created, and then
|
||||
// draws tiles for that size for ever. This panel is mounted the moment its
|
||||
// tab is selected — before the flex layout has settled — and the window can
|
||||
@@ -113,6 +175,7 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
||||
m.remove();
|
||||
mapRef.current = null;
|
||||
layerRef.current = null;
|
||||
heardLayerRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -132,6 +195,34 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
||||
saveMapBase(MAP_BASE_FT, basemap);
|
||||
}, [basemap]);
|
||||
|
||||
useEffect(() => { GetHearMe().then((v) => setHearMe(!!v)).catch(() => {}); }, []);
|
||||
|
||||
// Polled rather than pushed: the reports arrive from the broker in batches
|
||||
// whenever an uploader gets round to it, and a fifteen-minute window redrawn
|
||||
// every twenty seconds is as live as the data underneath it actually is.
|
||||
const loadHeard = useCallback(() => {
|
||||
GetWhoHearsMe().then((r: any) => setHeard((Array.isArray(r) ? r : []) as Heard[])).catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!hearMe) { setHeard([]); return; }
|
||||
loadHeard();
|
||||
const id = window.setInterval(loadHeard, 20_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [hearMe, loadHeard]);
|
||||
|
||||
const toggleHearMe = async () => {
|
||||
setHeardBusy(true);
|
||||
const next = !hearMe;
|
||||
try {
|
||||
await SetHearMe(next);
|
||||
setHearMe(next);
|
||||
} catch {
|
||||
// The usual cause is no station callsign, which is what it subscribes
|
||||
// to. Read the state back rather than assuming either way.
|
||||
try { setHearMe(!!(await GetHearMe())); } catch { /* leave it */ }
|
||||
} finally { setHeardBusy(false); }
|
||||
};
|
||||
|
||||
// The arcs, redrawn when the decode list changes. Newest last so they paint
|
||||
// on top; opacity falls with age so the map reads as "now" with a memory.
|
||||
useEffect(() => {
|
||||
@@ -156,21 +247,21 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
||||
if (!to) continue;
|
||||
const age = now - Date.parse(d.at);
|
||||
const fade = Math.max(0.15, 1 - age / MAX_AGE_MS);
|
||||
const colour = bandColour(d.band);
|
||||
const stroke = colour || bandColour(d.band);
|
||||
// Cut at the antimeridian: this map shows ONE world, so a path running
|
||||
// past ±180 has to leave one edge and come back at the other. Without it
|
||||
// every arc out of VK or ZL was drawn into the blank space off the side
|
||||
// of the map, its far end sitting alone on the opposite coast.
|
||||
const pts = splitAtAntimeridian(greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48));
|
||||
L.polyline(pts as L.LatLngExpression[][], {
|
||||
color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
|
||||
color: stroke, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
|
||||
}).addTo(layer);
|
||||
const label = `${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`;
|
||||
const mk = L.circleMarker([to.lat, to.lon], {
|
||||
// A three-pixel dot is a fine mark and a poor target, so the visible
|
||||
// radius stays and an invisible one three times the size takes the
|
||||
// clicks.
|
||||
radius: 3, color: colour, weight: 1, fillColor: colour, fillOpacity: 0.9 * fade,
|
||||
radius: 3, color: stroke, weight: 1, fillColor: stroke, fillOpacity: 0.9 * fade,
|
||||
}).bindTooltip(label, { direction: 'top' }).addTo(layer);
|
||||
// The tooltip goes on the HIT circle too, and it is the one that matters:
|
||||
// being on top, it takes the hover as well as the click, and binding it
|
||||
@@ -196,7 +287,45 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [decodes, myGrid]);
|
||||
}, [decodes, myGrid, colour]);
|
||||
|
||||
// The reverse layer: one mark where each station that reported us sits, and
|
||||
// no line to it.
|
||||
//
|
||||
// A divIcon rather than a canvas circle, because canvas draws circles and
|
||||
// nothing else, and the whole point is a shape that is not a circle. The
|
||||
// cost is DOM nodes, which is affordable HERE and would not be on the decode
|
||||
// layer: this is a few dozen receivers against three hundred arcs.
|
||||
useEffect(() => {
|
||||
const layer = heardLayerRef.current;
|
||||
if (!layer) return;
|
||||
layer.clearLayers();
|
||||
if (!hearMe) return;
|
||||
const now = Date.now();
|
||||
for (const h of heard) {
|
||||
const to = gridToLatLon(h.grid);
|
||||
if (!to) continue;
|
||||
const ageMs = Math.max(0, now - Date.parse(h.at));
|
||||
const ageMin = Math.round(ageMs / 60_000);
|
||||
// Faded with age over the window, as the decode arcs are: the freshest
|
||||
// report is the one that says a path is open NOW.
|
||||
const fade = Math.max(0.3, 1 - ageMs / (15 * 60_000));
|
||||
const label = `${h.call} · ${h.grid} · ${h.snr > 0 ? '+' : ''}${h.snr} dB · ${h.band}${ageMin > 0 ? ` · ${ageMin}'` : ''}`;
|
||||
// The box is bigger than the diamond so there is something to point at:
|
||||
// a nine-pixel mark is a fine sight and a poor target.
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8],
|
||||
html: `<div style="width:16px;height:16px;display:flex;align-items:center;justify-content:center">`
|
||||
+ `<div style="width:9px;height:9px;transform:rotate(45deg);background:${heardInk};`
|
||||
+ `box-shadow:0 0 0 1px rgba(255,255,255,.75);opacity:${fade.toFixed(2)}"></div></div>`,
|
||||
});
|
||||
L.marker([to.lat, to.lon], { icon, interactive: true, keyboard: false })
|
||||
.bindTooltip(label, { direction: 'top' })
|
||||
.addTo(layer);
|
||||
}
|
||||
}, [heard, hearMe, heardInk]);
|
||||
|
||||
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
|
||||
return (
|
||||
@@ -205,8 +334,11 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
||||
// keeps all of it inside this panel.
|
||||
<div className="relative isolate z-0 h-full w-full min-h-0">
|
||||
<div ref={divRef} className="absolute inset-0 rounded-lg overflow-hidden" />
|
||||
{/* Basemap picker, MainMap's own vocabulary. */}
|
||||
<div className="absolute top-2 left-12 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
|
||||
{/* Basemap picker, MainMap's own vocabulary.
|
||||
left-16, not left-12: Leaflet's zoom control is 30 px of buttons plus
|
||||
its 10 px margin and a border, and at 48 px this row started on top
|
||||
of it — the − button took the click that was meant for Street. */}
|
||||
<div className="absolute top-2 left-16 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
|
||||
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
||||
<button key={k} type="button" onClick={() => setBasemap(k)}
|
||||
className={cn('px-2 py-0.5 rounded text-[11px]',
|
||||
@@ -215,12 +347,71 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Band legend — only the bands actually on screen. */}
|
||||
{/* The two things that are not the basemap, on the OTHER side.
|
||||
|
||||
They sat in the same row, which grew until it reached the middle of
|
||||
the map — and a control bar spanning half the width of a world map is
|
||||
covering the Atlantic to save a corner that was empty the whole time.
|
||||
Leaflet puts nothing top-right but the attribution, which is at the
|
||||
bottom. */}
|
||||
<div className="absolute top-2 right-2 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
|
||||
{/* Opens on the crimson the home marker already uses — chosen to hold
|
||||
up on every basemap, which is a better first suggestion than
|
||||
whichever band colour happens to be first in the palette. */}
|
||||
<input type="color" title={t('ftmap.colour')}
|
||||
className="size-5 self-center rounded border border-border bg-transparent p-0 cursor-pointer"
|
||||
value={colour || '#e11d48'}
|
||||
onChange={(e) => { const v = asHex(e.target.value); setColour(v); writeUiPref(COL_KEY, v); }} />
|
||||
{!!colour && (
|
||||
<button type="button" title={t('ftmap.colourPerBand')}
|
||||
onClick={() => { setColour(''); writeUiPref(COL_KEY, ''); }}
|
||||
className="px-1 text-[11px] text-muted-foreground hover:text-foreground">↺</button>
|
||||
)}
|
||||
<span className="mx-0.5 w-px self-stretch bg-border" />
|
||||
{/* The reverse layer. A switch, not a filter: it starts a subscription
|
||||
at the broker, so it is off until asked for. */}
|
||||
<button type="button" onClick={toggleHearMe} disabled={heardBusy}
|
||||
title={t('ftmap.hearMeTip')}
|
||||
className={cn('flex items-center gap-1 px-1.5 h-6 rounded text-[11px] disabled:opacity-50',
|
||||
hearMe ? 'font-semibold' : 'text-muted-foreground hover:bg-muted')}
|
||||
style={hearMe ? { color: heardInk } : undefined}>
|
||||
<Ear className="size-3" />
|
||||
{t('ftmap.hearMe')}
|
||||
{hearMe && <span className="tabular-nums opacity-80">{heard.length}</span>}
|
||||
</button>
|
||||
{hearMe && (
|
||||
<input type="color" title={t('ftmap.heardColour')}
|
||||
className="size-5 self-center rounded border border-border bg-transparent p-0 cursor-pointer"
|
||||
value={heardInk}
|
||||
onChange={(e) => { const v = asHex(e.target.value); setHeardColour(v); writeUiPref(HEARD_COL_KEY, v); }} />
|
||||
)}
|
||||
{hearMe && !!heardColour && (
|
||||
<button type="button" title={t('ftmap.heardColourReset')}
|
||||
onClick={() => { setHeardColour(''); writeUiPref(HEARD_COL_KEY, ''); }}
|
||||
className="px-1 text-[11px] text-muted-foreground hover:text-foreground">↺</button>
|
||||
)}
|
||||
</div>
|
||||
{/* What the diamonds are. In the legend rather than a tooltip because
|
||||
they are the only thing on the map that is not a decode of ours, and
|
||||
an unexplained second mark is worse than none.
|
||||
|
||||
Bottom-right, where the band legend is not: the two would otherwise
|
||||
stack into one block and read as one key. */}
|
||||
{hearMe && heard.length > 0 && (
|
||||
<div className="absolute bottom-2 right-2 z-[1000] flex items-center gap-1.5 rounded-md bg-background/85 backdrop-blur px-2 py-1.5 border border-border text-[11px]">
|
||||
<span className="inline-block size-2 rotate-45" style={{ background: heardInk }} />
|
||||
{t('ftmap.hearMeLegend', { n: heard.length })}
|
||||
</div>
|
||||
)}
|
||||
{/* Band legend — only the bands actually on screen. With one colour
|
||||
forced it keeps the band NAMES and drops the swatches: which bands
|
||||
are up is still worth knowing, a colour key that no longer maps to
|
||||
anything is not. */}
|
||||
{bands.length > 0 && (
|
||||
<div className="absolute bottom-2 left-2 z-[1000] flex flex-wrap gap-x-2.5 gap-y-1 rounded-md bg-background/85 backdrop-blur px-2 py-1.5 border border-border">
|
||||
{bands.map((b) => (
|
||||
<span key={b} className="flex items-center gap-1 text-[11px] text-foreground">
|
||||
<span className="inline-block w-3 h-[3px] rounded" style={{ background: bandColour(b) }} />
|
||||
{!colour && <span className="inline-block w-3 h-[3px] rounded" style={{ background: bandColour(b) }} />}
|
||||
{b.toUpperCase()}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { Loader2, RefreshCw } from 'lucide-react';
|
||||
import { GridSquares } from '../../wailsjs/go/main/App';
|
||||
import { GridSquares, GridSquareChoices, GetListsSettings } from '../../wailsjs/go/main/App';
|
||||
import { gridSquareBounds, gridToLatLon } from '@/lib/maidenhead';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -47,20 +47,27 @@ function cssColour(token: string, fallback: string): string {
|
||||
} catch { return fallback; }
|
||||
}
|
||||
|
||||
// Mode scope. The names come straight from the backend's own classes ("ALL",
|
||||
// "PHONE", "CW", "DIGI") plus FTX, which is narrower than digital and usually
|
||||
// the honest one beside an FTx panel — a square worked on RTTY in a contest is
|
||||
// not a square worked on FT8.
|
||||
// Mode scope. The four broad classes the rest of the app uses, as buttons —
|
||||
// and then any single mode the log actually holds, from the dropdown beside
|
||||
// them.
|
||||
//
|
||||
// There used to be an FTx button here, lumping FT8, FT4 and FT2 together. It
|
||||
// was the wrong grain in both directions: "digital" already put a contest RTTY
|
||||
// square beside an FT8 one, and FTx then put FT8 beside FT4, when the question
|
||||
// this map answers is where ONE mode has been heard. The specific modes are
|
||||
// read from the log rather than listed here, so FT2 is offered to an operator
|
||||
// already using it and needs no change here the day it becomes registered.
|
||||
const SCOPES = [
|
||||
{ key: 'ALL', label: 'gsm.all' },
|
||||
{ key: 'PHONE', label: 'gsm.phone' },
|
||||
{ key: 'CW', label: 'gsm.cw' },
|
||||
{ key: 'DIGI', label: 'gsm.digital' },
|
||||
{ key: 'FTX', label: 'gsm.ftx' },
|
||||
] as const;
|
||||
type ScopeKey = typeof SCOPES[number]['key'];
|
||||
type ScopeKey = string;
|
||||
|
||||
const SCOPE_KEY = 'opslog.gridMapScope';
|
||||
const BAND_KEY = 'opslog.gridMapBand';
|
||||
const SAT_KEY = 'opslog.gridMapSat';
|
||||
// Chosen fill colours. Empty means "follow the theme", which is the default and
|
||||
// stays the default: the tokens already track the four themes, and freezing a
|
||||
// hex at first run would leave a dark-theme map painted in the light palette.
|
||||
@@ -83,9 +90,22 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
||||
const [squares, setSquares] = useState<Square[] | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [scope, setScope] = useState<ScopeKey>(
|
||||
() => (SCOPES.some((s) => s.key === localStorage.getItem(SCOPE_KEY))
|
||||
? (localStorage.getItem(SCOPE_KEY) as ScopeKey) : 'DIGI'));
|
||||
// The stored scope is taken as given rather than checked against SCOPES: it
|
||||
// may legitimately be a mode name now, and the backend answers a mode nothing
|
||||
// was worked on with no squares rather than an error.
|
||||
const [scope, setScope] = useState<ScopeKey>(() => {
|
||||
const v = localStorage.getItem(SCOPE_KEY) || 'DIGI';
|
||||
// FTX was a button until the named modes replaced it. Left as it was, no
|
||||
// control would show it selected while the map stayed filtered by it.
|
||||
return v === 'FTX' ? 'DIGI' : v;
|
||||
});
|
||||
const [band, setBand] = useState(() => localStorage.getItem(BAND_KEY) ?? '');
|
||||
const [sat, setSat] = useState(() => localStorage.getItem(SAT_KEY) ?? '');
|
||||
// What the three filters can offer. The modes and satellites are the ones the
|
||||
// squares were actually worked on; the bands are the station's own list too,
|
||||
// so a band configured but not yet worked is still there to ask about.
|
||||
const [choices, setChoices] = useState<{ modes: string[]; bands: string[]; satellites: string[] }>(
|
||||
{ modes: [], bands: [], satellites: [] });
|
||||
|
||||
// This map's own imagery. It shared the world map's key until they were
|
||||
// separated, so a choice made back then is inherited rather than reset.
|
||||
@@ -102,17 +122,53 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
const load = async (sc: ScopeKey = scope) => {
|
||||
const load = async (sc: ScopeKey = scope, bd: string = band, st: string = sat) => {
|
||||
setBusy(true); setErr('');
|
||||
try {
|
||||
const r = (await GridSquares(sc)) as any;
|
||||
const r = (await GridSquares(sc, bd, st)) as any;
|
||||
setSquares((Array.isArray(r) ? r : []) as Square[]);
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
setSquares([]);
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
useEffect(() => { void load(scope); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [scope]);
|
||||
useEffect(() => { void load(scope, band, sat); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [scope, band, sat]);
|
||||
|
||||
// Loaded once: what the filters can offer changes only when the log does, and
|
||||
// the refresh button reloads it alongside the squares.
|
||||
const loadChoices = async () => {
|
||||
try {
|
||||
const c: any = await GridSquareChoices();
|
||||
let bands: string[] = (c?.bands ?? []) as string[];
|
||||
try {
|
||||
const ls: any = await GetListsSettings();
|
||||
const have = new Set(bands.map((b) => b.toLowerCase()));
|
||||
// Union, the station's own list first: a configured band with nothing
|
||||
// worked on it is still a fair question, and a band worked but never
|
||||
// configured must not become unreachable.
|
||||
const extra = ((ls?.bands ?? []) as string[])
|
||||
.map((b) => String(b).toLowerCase())
|
||||
.filter((b) => b && !have.has(b));
|
||||
bands = [...extra, ...bands];
|
||||
} catch { /* the log's own bands are enough */ }
|
||||
setChoices({
|
||||
modes: (c?.modes ?? []) as string[],
|
||||
bands,
|
||||
satellites: (c?.satellites ?? []) as string[],
|
||||
});
|
||||
} catch { /* the class buttons still work without it */ }
|
||||
};
|
||||
useEffect(() => { void loadChoices(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, []);
|
||||
|
||||
// Only the modes NOT already a button: listing SSB and CW again would be two
|
||||
// controls giving one answer.
|
||||
const namedModes = useMemo(
|
||||
() => choices.modes.filter((m) => !SCOPES.some((c) => c.key === m.toUpperCase())),
|
||||
[choices.modes]);
|
||||
const pick = (key: string, v: string, set: (v: string) => void) => {
|
||||
set(v);
|
||||
try { localStorage.setItem(key, v); } catch { /* quota */ }
|
||||
};
|
||||
|
||||
// One-time map creation. preferCanvas: a busy digital log is a few thousand
|
||||
// rectangles, and as SVG that is a few thousand DOM nodes to lay out on every
|
||||
@@ -244,13 +300,45 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
{SCOPES.map((s, i) => (
|
||||
<button key={s.key} type="button"
|
||||
onClick={() => { setScope(s.key); try { localStorage.setItem(SCOPE_KEY, s.key); } catch { /* quota */ } }}
|
||||
onClick={() => pick(SCOPE_KEY, s.key, setScope)}
|
||||
className={cn('px-1.5 h-6 text-[11px] whitespace-nowrap', i > 0 && 'border-l border-border',
|
||||
scope === s.key ? 'bg-primary text-primary-foreground' : 'hover:bg-muted text-muted-foreground')}>
|
||||
{t(s.label)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* One named mode, where the FTx button used to be. It shares the scope
|
||||
with the buttons rather than filtering on top of them: mode is one
|
||||
question, and two controls that both answer it is how a map ends up
|
||||
showing PHONE ∩ FT8, which is empty. Picking a mode here therefore
|
||||
un-picks the buttons, and vice versa. */}
|
||||
{namedModes.length > 0 && (
|
||||
<select
|
||||
value={namedModes.includes(scope) ? scope : ''}
|
||||
onChange={(e) => pick(SCOPE_KEY, e.target.value || 'ALL', setScope)}
|
||||
title={t('gsm.oneMode')}
|
||||
className="h-6 rounded border border-border bg-background px-1 text-[11px]"
|
||||
>
|
||||
<option value="">{t('gsm.oneMode')}</option>
|
||||
{namedModes.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{choices.bands.length > 0 && (
|
||||
<select value={band} onChange={(e) => pick(BAND_KEY, e.target.value, setBand)}
|
||||
title={t('gsm.band')} className="h-6 rounded border border-border bg-background px-1 text-[11px]">
|
||||
<option value="">{t('gsm.allBands')}</option>
|
||||
{choices.bands.map((b) => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{/* Only for a station that has worked one. A satellite dropdown on a
|
||||
purely terrestrial log is a control that can only ever be empty. */}
|
||||
{choices.satellites.length > 0 && (
|
||||
<select value={sat} onChange={(e) => pick(SAT_KEY, e.target.value, setSat)}
|
||||
title={t('gsm.satellite')} className="h-6 rounded border border-border bg-background px-1 text-[11px]">
|
||||
<option value="">{t('gsm.allSats')}</option>
|
||||
{choices.satellites.map((n) => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{t('gsm.count', { n: stats.total, c: stats.confirmed })}
|
||||
</span>
|
||||
@@ -287,7 +375,7 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground px-1">↺</button>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
<button type="button" onClick={() => void load()} disabled={busy} title={t('gsm.refresh')}
|
||||
<button type="button" onClick={() => { void load(); void loadChoices(); }} disabled={busy} title={t('gsm.refresh')}
|
||||
className="inline-flex items-center justify-center size-6 rounded border border-border hover:bg-muted disabled:opacity-50">
|
||||
{busy ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||
</button>
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
// how one of them quietly stops matching the other — a pattern button that sets
|
||||
// a different direction, a band list that tunes somewhere else.
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ArrowDownToLine, ChevronDown, ChevronUp, Loader2, Minus, Plus, RefreshCw, Antenna as AntennaIcon, X } from 'lucide-react';
|
||||
import { ArrowDownToLine, ChevronDown, ChevronUp, Loader2, Minus, Plus, RefreshCw, Ruler, Antenna as AntennaIcon, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||
SetUltrabeamDirection, UltrabeamRetract, MotorCalibrate, MotorSetElement, MotorReadElements,
|
||||
MotorTuneKHz, MotorNudgeKHz, SetMotorFollow,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
@@ -235,11 +235,29 @@ export function MotorAntennaWidget({ ant, refetch, t, onClose, essentialsOnly }:
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Home, and — on a SteppIR — calibrate beside it. Two buttons on one
|
||||
row rather than a second full-width one: the widget is narrow and
|
||||
already tall, and the pair reads as "the two things that move every
|
||||
element at once", which is what they are. */}
|
||||
<div className={cn('grid gap-1.5', isUB ? 'grid-cols-1' : 'grid-cols-2')}>
|
||||
<button type="button" disabled={!ant.connected}
|
||||
onClick={() => run(UltrabeamRetract())}
|
||||
title={t('station.retractTip')}
|
||||
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
||||
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
|
||||
</button>
|
||||
{/* Asked first, always. Calibration runs every element to its end
|
||||
stop and takes minutes — it is the right answer to an antenna that
|
||||
tunes wrong, and the wrong answer to a stray click mid-contest. */}
|
||||
{!isUB && (
|
||||
<button type="button" disabled={!ant.connected}
|
||||
onClick={() => { if (window.confirm(t('station.calibrateConfirm'))) run(MotorCalibrate()); }}
|
||||
title={t('station.calibrateTip')}
|
||||
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-border bg-muted/40 py-1 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
||||
<Ruler className="size-3.5" /> {t('station.calibrate')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isUB && !essentialsOnly && (
|
||||
<div>
|
||||
|
||||
@@ -44,16 +44,26 @@ type RotorPreset = { label: string; azimuth: number };
|
||||
// A rotor is slow and its readout is coarse, so "is it moving" is inferred
|
||||
// rather than reported: a heading that changes by more than a degree means it
|
||||
// is, and it is considered stopped once the readout has been still for a while.
|
||||
const MOVEMENT_SETTLE_MS = 1600;
|
||||
// Four degrees, not one.
|
||||
// Long enough to outlast the gap between two readings of a turning rotor.
|
||||
//
|
||||
// A rotor at rest does not report a constant heading: the potentiometer and the
|
||||
// controller's rounding walk the reading a degree or two either side, and at one
|
||||
// degree that jitter WAS movement — Stop lit for a second and a half, went out,
|
||||
// and lit again, for an antenna that had not turned all evening. The reference
|
||||
// is only moved when the threshold is crossed, so a rotor genuinely turning
|
||||
// accumulates towards it however slowly it goes; noise around a value never
|
||||
// gets there.
|
||||
// It was 1600 ms, shorter than the time a rotor takes to move far enough to be
|
||||
// noticed at all: at a degree a second, four-degree steps are four seconds
|
||||
// apart, so Stop went dark between every one of them and lit again on the
|
||||
// next — a rotation crossing a pass blinked the whole way round. The window
|
||||
// now only has to outlast ONE degree of progress, which even a slow mast
|
||||
// delivers about every two seconds while the poller is running fast.
|
||||
const MOVEMENT_SETTLE_MS = 3000;
|
||||
// Four degrees: the band inside which a reading is not a step at all.
|
||||
//
|
||||
// A rotor at rest does not report a constant heading — the potentiometer and
|
||||
// the controller's rounding walk the reading a degree or two either side. But
|
||||
// the threshold is only half the answer, and on its own it was not enough: WIND
|
||||
// moves a beam further than four degrees and back again, and every one of those
|
||||
// excursions counted, so Stop lit and went out all evening on an antenna that
|
||||
// had not turned. Raising the number only raises the wind speed it takes.
|
||||
//
|
||||
// What actually separates a rotation from the weather is the DIRECTION — see the
|
||||
// movement effect below.
|
||||
const MOVEMENT_TRIGGER_DEG = 4;
|
||||
// How long an order is given to produce movement before the widget stops
|
||||
// claiming the antenna is turning — the rotor may already have been there.
|
||||
@@ -124,6 +134,35 @@ function unwrapRotation(nextAngle: number, previousRotation: number | null): num
|
||||
return previousRotation + delta;
|
||||
}
|
||||
|
||||
// useUnwrappedRotation is unwrapRotation kept across renders, for a beam whose
|
||||
// angle comes from the ANTENNA rather than from the mouse.
|
||||
//
|
||||
// This is what was missing, and on a rotator with an overlap it is unmissable:
|
||||
// a G-2800 sitting at 020° turned anticlockwise reports 020, 010, 000, 359,
|
||||
// 358 … 340, and the CSS transition from 20deg to 359deg travels the long way —
|
||||
// the beam whips a full turn round the dial while the antenna moves forty
|
||||
// degrees the other way. Reported from the air.
|
||||
//
|
||||
// The angle is therefore accumulated rather than reset: 020 → 000 → −001 →
|
||||
// −020, which is the way the mast is actually moving. The ref is advanced only
|
||||
// when the input changes, so a re-render for any other reason cannot make the
|
||||
// beam creep.
|
||||
function useUnwrappedRotation(angle: number | null): number | null {
|
||||
const rotation = useRef<number | null>(null);
|
||||
const lastInput = useRef<number | null>(null);
|
||||
if (angle == null) {
|
||||
rotation.current = null;
|
||||
lastInput.current = null;
|
||||
return null;
|
||||
}
|
||||
const a = normalizeAzimuth(angle);
|
||||
if (lastInput.current !== a || rotation.current == null) {
|
||||
rotation.current = unwrapRotation(a, rotation.current);
|
||||
lastInput.current = a;
|
||||
}
|
||||
return rotation.current;
|
||||
}
|
||||
|
||||
// ── The dial ───────────────────────────────────────────────────────────────
|
||||
|
||||
function RotorCompassDial({
|
||||
@@ -147,6 +186,12 @@ function RotorCompassDial({
|
||||
// is read by moving the eye, and this one is read while aiming.
|
||||
onHoverAzimuth?: (az: number | null) => void;
|
||||
}) {
|
||||
// The beams animate on an accumulated angle, so crossing north never sends
|
||||
// them the long way round the dial. Both lobes of a bidirectional antenna get
|
||||
// their own, because they cross north at different moments.
|
||||
const antennaRotation = useUnwrappedRotation(azimuth ?? null);
|
||||
const secondaryRotation = useUnwrappedRotation(secondary ?? null);
|
||||
|
||||
// Gradient and mask ids must be unique per instance: two compasses on one
|
||||
// screen (docked widget + Station Control) would otherwise share the first
|
||||
// one's definitions.
|
||||
@@ -428,8 +473,8 @@ function RotorCompassDial({
|
||||
|
||||
{/* The second lobe of a bidirectional antenna: the same beam, dimmed —
|
||||
it radiates as much, and it is not where the operator aimed. */}
|
||||
{secondary != null && renderBeam(normalizeAzimuth(secondary), 'antenna', 0.45, true)}
|
||||
{azimuth != null && renderBeam(normalizeAzimuth(azimuth), 'antenna', 1, true)}
|
||||
{secondaryRotation != null && renderBeam(secondaryRotation, 'antenna', 0.45, true)}
|
||||
{antennaRotation != null && renderBeam(antennaRotation, 'antenna', 1, true)}
|
||||
|
||||
<circle cx={CENTER} cy={CENTER} r={CENTER_DOT_RADIUS} fill={COMPASS_ORANGE} pointerEvents="none" />
|
||||
</svg>
|
||||
@@ -482,6 +527,14 @@ export function RotorCompass({
|
||||
const latestAzimuthRef = useRef<number | null>(displayAzimuth);
|
||||
const movementReferenceRef = useRef<number | null>(displayAzimuth);
|
||||
const movementSeenRef = useRef(false);
|
||||
// Which way the last accepted step went (+1 CW, −1 CCW, 0 none), and how many
|
||||
// in a row have gone that way. Two make it a rotation; one is weather.
|
||||
const movementDirRef = useRef(0);
|
||||
const movementRunRef = useRef(0);
|
||||
// The previous reading, whatever it was. movementReferenceRef deliberately
|
||||
// holds still through sub-threshold steps so they can accumulate, which
|
||||
// makes it useless for measuring progress poll to poll.
|
||||
const lastRawRef = useRef<number | null>(null);
|
||||
|
||||
const rememberTarget = (value: number | null) => {
|
||||
if (value == null) rememberedTargets.delete(rotorKey);
|
||||
@@ -512,6 +565,9 @@ export function RotorCompass({
|
||||
setTargetFading(false);
|
||||
setIsMoving(rememberedTarget != null);
|
||||
movementSeenRef.current = false;
|
||||
movementDirRef.current = 0;
|
||||
movementRunRef.current = 0;
|
||||
lastRawRef.current = nextAzimuth;
|
||||
window.clearTimeout(movementTimerRef.current);
|
||||
window.clearTimeout(commandTimerRef.current);
|
||||
window.clearTimeout(targetArrivalTimerRef.current);
|
||||
@@ -527,13 +583,11 @@ export function RotorCompass({
|
||||
window.clearTimeout(targetFadeTimerRef.current);
|
||||
}, []);
|
||||
|
||||
// Movement is inferred from the readout itself, so a rotor turned by its own
|
||||
// controller — or by another program — reads as moving here too.
|
||||
useEffect(() => {
|
||||
if (rawAzimuth == null) return;
|
||||
const reference = movementReferenceRef.current;
|
||||
if (reference == null) { movementReferenceRef.current = rawAzimuth; return; }
|
||||
if (angularDistance(rawAzimuth, reference) >= MOVEMENT_TRIGGER_DEG) {
|
||||
// Declare the antenna moving and start the clock on it stopping. Called from
|
||||
// both halves of the test below, and re-arming the timer is the whole point:
|
||||
// the antenna counts as stopped only once nothing has said otherwise for
|
||||
// MOVEMENT_SETTLE_MS.
|
||||
function armMovement() {
|
||||
movementSeenRef.current = true;
|
||||
setIsMoving(true);
|
||||
window.clearTimeout(commandTimerRef.current);
|
||||
@@ -541,9 +595,62 @@ export function RotorCompass({
|
||||
movementTimerRef.current = window.setTimeout(() => {
|
||||
setIsMoving(false);
|
||||
movementSeenRef.current = false;
|
||||
// Forget the direction too: the next real move starts its own run rather
|
||||
// than inheriting one from a rotation that finished minutes ago.
|
||||
movementDirRef.current = 0;
|
||||
movementRunRef.current = 0;
|
||||
}, MOVEMENT_SETTLE_MS);
|
||||
movementReferenceRef.current = rawAzimuth;
|
||||
}
|
||||
|
||||
// Movement is inferred from the readout itself, so a rotor turned by its own
|
||||
// controller — or by another program — reads as moving here too.
|
||||
//
|
||||
// The test is the DIRECTION, not the size of the step.
|
||||
//
|
||||
// A threshold alone does not work, whatever it is set to. Wind pushes a beam
|
||||
// off its bearing and back — 100°, 105°, 100°, 106° — and every one of those
|
||||
// excursions clears a four-degree threshold, so Stop lit and went out all
|
||||
// evening on an antenna that had not turned. Raising the number only raises
|
||||
// the wind speed it takes.
|
||||
//
|
||||
// What separates the two is not amplitude but sign: a rotor under power
|
||||
// advances, gust after gust reverses. So a step is only movement when the
|
||||
// PREVIOUS step went the same way. Wind gives +5, −5, +5 and never two in a
|
||||
// row; a rotor gives +4, +4, +4 and is announced on the second — one poll,
|
||||
// about a second, on a mast that takes half a minute to cross a pass.
|
||||
//
|
||||
// Getting IN is strict; staying in is not, and must not be. The same four
|
||||
// degrees that keep the wind out are four seconds of travel on a real rotor,
|
||||
// so waiting for the next four-degree step before believing it is still
|
||||
// turning left Stop dark for most of the rotation. Once movement is
|
||||
// established, ANY continued progress the way it was going keeps it alive —
|
||||
// one degree the same way is not weather when the mast is already under
|
||||
// power, and the strict test is what guarantees that it is.
|
||||
useEffect(() => {
|
||||
if (rawAzimuth == null) return;
|
||||
// Signed and the short way round: crossing north is a small step, not 350°.
|
||||
const short = (from: number, to: number) => ((to - from + 540) % 360) - 180;
|
||||
const previous = lastRawRef.current;
|
||||
lastRawRef.current = rawAzimuth;
|
||||
if (movementSeenRef.current && previous != null && movementDirRef.current !== 0) {
|
||||
const step = short(previous, rawAzimuth);
|
||||
if (step !== 0 && Math.sign(step) === movementDirRef.current) armMovement();
|
||||
}
|
||||
const reference = movementReferenceRef.current;
|
||||
if (reference == null) { movementReferenceRef.current = rawAzimuth; return; }
|
||||
const delta = short(reference, rawAzimuth);
|
||||
if (Math.abs(delta) < MOVEMENT_TRIGGER_DEG) return; // inside the noise band
|
||||
const sign = delta > 0 ? 1 : -1;
|
||||
if (movementDirRef.current === sign) {
|
||||
movementRunRef.current += 1;
|
||||
} else {
|
||||
movementDirRef.current = sign;
|
||||
movementRunRef.current = 1;
|
||||
}
|
||||
movementReferenceRef.current = rawAzimuth;
|
||||
if (movementRunRef.current < 2) return; // one step either way is weather
|
||||
|
||||
armMovement();
|
||||
}, [rawAzimuth, rotorKey]);
|
||||
|
||||
// Arrival: confirmed over time, then faded. Every check re-reads the
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen, Radar } from 'lucide-react';
|
||||
import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen, Radar, Compass } from 'lucide-react';
|
||||
import {
|
||||
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
|
||||
GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack,
|
||||
StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking,
|
||||
StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking, RetargetSatelliteTracking,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -57,7 +57,7 @@ type Tuning = {
|
||||
type Track = {
|
||||
on: boolean; name: string; transponder: string; mode: string;
|
||||
nominal_down: number; nominal_up: number; down_hz: number; up_hz: number;
|
||||
az: number; el: number; visible: boolean;
|
||||
az: number; el: number; visible: boolean; range_km: number; alt_km: number;
|
||||
radio: string; // "sat" | "downlink-only" | ""
|
||||
error: string;
|
||||
rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean; rot_az_only: boolean;
|
||||
@@ -74,11 +74,28 @@ const SIDE_SHOWN_KEY = 'opslog.satSideShown';
|
||||
const SIDE_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720;
|
||||
const SKY_SHOWN_KEY = 'opslog.satSkyShown';
|
||||
|
||||
// Four decimals — a hundred hertz, which is what a linear transponder is
|
||||
// actually tuned to.
|
||||
//
|
||||
// It used to be six, and the last two digits changed every tick: the Doppler
|
||||
// moves about sixty hertz a second on 70 cm, so the display was a blur of
|
||||
// numbers nobody could read and nobody needed. The RADIO still gets the whole
|
||||
// figure — the correction is computed and sent to the hertz — this is only how
|
||||
// much of it is worth putting in front of an operator. The shift beside it, in
|
||||
// kilohertz, is where the fine movement shows.
|
||||
const fmtHz = (hz: number) => {
|
||||
if (!hz) return '—';
|
||||
// Six decimals: a linear transponder is tuned to the hundred hertz, and the
|
||||
// Doppler correction moves the last three digits every second.
|
||||
return (hz / 1e6).toFixed(6).replace(/(\d)(?=(\d{3})+\.)/g, '$1 ');
|
||||
return (hz / 1e6).toFixed(4).replace(/(\d)(?=(\d{3})+\.)/g, '$1 ');
|
||||
};
|
||||
|
||||
// The Doppler shift, as an operator would say it: hertz while it is small
|
||||
// enough to say in hertz, kilohertz once it is not. "+9741 Hz" is four digits
|
||||
// of precision on a number that is only ever read as "about ten kilohertz".
|
||||
const fmtShift = (hz: number) => {
|
||||
const sign = hz > 0 ? '+' : '−';
|
||||
const a = Math.abs(hz);
|
||||
if (a < 1000) return `${sign}${Math.round(a)} Hz`;
|
||||
return `${sign}${(a / 1000).toFixed(1)} kHz`;
|
||||
};
|
||||
const fmtDeg = (d: number) => `${d.toFixed(1)}°`;
|
||||
const fmtKm = (km: number) => `${Math.round(km).toLocaleString()} km`;
|
||||
@@ -289,6 +306,12 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
|
||||
useEffect(() => { loadBirds(); loadTle(); loadPasses(); }, [loadBirds, loadTle, loadPasses]);
|
||||
useEffect(() => EventsOn('sat:tle', () => { loadTle(); loadBirds(); loadPasses(); }), [loadTle, loadBirds, loadPasses]);
|
||||
// Saving the satellite settings. The followed list, the lowest pass and the
|
||||
// locator all change what belongs here, and this tab is normally open behind
|
||||
// the settings window while they are edited. The selection repairs itself:
|
||||
// a satellite that is no longer followed drops out of the dropdown, and the
|
||||
// effect below moves to the first one that is.
|
||||
useEffect(() => EventsOn('sat:settings', () => { loadBirds(); loadPasses(); }), [loadBirds, loadPasses]);
|
||||
useEffect(() => { if (sel) localStorage.setItem('opslog.satSelected', sel); }, [sel]);
|
||||
useEffect(() => { setTpIdx(0); }, [sel]);
|
||||
|
||||
@@ -397,6 +420,24 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
};
|
||||
|
||||
// Changing satellite WHILE tracking moves the radio to the new one at once.
|
||||
//
|
||||
// The selection here is the display's; the tracker held its own and went on
|
||||
// following what it was started with, so two birds up at the same time meant
|
||||
// switching between them and watching the frequencies stay on the first.
|
||||
// Stopping and restarting worked, and is also how a Flex throws away and
|
||||
// rebuilds both its slices for nothing.
|
||||
//
|
||||
// Guarded on tracking being on, so selecting a satellite with the radio idle
|
||||
// stays what it has always been: a look, not a command.
|
||||
const trackingOn = !!tracking?.on;
|
||||
useEffect(() => {
|
||||
if (!trackingOn || !sel) return;
|
||||
RetargetSatelliteTracking(sel, tpIdx)
|
||||
.then(async () => setTracking((await GetSatelliteTracking()) as any))
|
||||
.catch((e: any) => setErr(String(e?.message ?? e)));
|
||||
}, [sel, tpIdx, trackingOn]);
|
||||
|
||||
// ── Map ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
@@ -601,6 +642,14 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
|
||||
// The pass, as a countdown and a bar. Both derived here from two timestamps,
|
||||
// so they move every second without asking Go anything.
|
||||
// Is the antenna still on its way? The rotator is asked where it is every
|
||||
// three seconds and a mast takes tens of seconds to cross a pass, so a
|
||||
// difference between where it is and where the satellite is means it is
|
||||
// moving — which is exactly what a number alone cannot show, and the
|
||||
// difference between "on its way" and "stuck" is the whole reason to look.
|
||||
const antennaMoving = !!tracking?.rot_on && !!tracking.rot_live &&
|
||||
Math.abs(((tracking.az - tracking.rot_az + 540) % 360) - 180) > 3;
|
||||
|
||||
const aosMs = pass?.has_pass ? Date.parse(pass.aos) : 0;
|
||||
const losMs = pass?.has_pass ? Date.parse(pass.los) : 0;
|
||||
const inPass = !!pass?.has_pass && now >= aosMs && now < losMs;
|
||||
@@ -669,6 +718,61 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
{tracking?.on && tracking.radio === 'downlink-only' && (
|
||||
<span className="text-[11px] text-warning">{t('sat.downlinkOnly')}</span>
|
||||
)}
|
||||
|
||||
{/* What the station is actually doing, beside the button that started
|
||||
it. During a pass an operator watches the radio and the antenna, not
|
||||
a column on the far side of the window — and that column is the
|
||||
first thing they hide to get the map full width. */}
|
||||
{tracking?.on && (
|
||||
<div className="flex items-center gap-2.5 rounded-md border border-border bg-card/60 px-2 py-0.5 text-xs tabular-nums">
|
||||
{/* Where the bird IS, which is not where the antenna is pointing:
|
||||
these three say whether the pass is worth calling on, and the
|
||||
rotator group further along says whether the mast has caught up
|
||||
with them. Elevation goes dim below the horizon, so a satellite
|
||||
still being tracked on its way up cannot be read as workable. */}
|
||||
<span className="flex items-center gap-1.5" title={`${t('sat.tipAz')} / ${t('sat.tipEl')}`}>
|
||||
<Radar className="size-3 text-muted-foreground" />
|
||||
<span className={cn('font-medium', !tracking.visible && 'text-muted-foreground')}>
|
||||
{Math.round(tracking.az)}° / {tracking.el.toFixed(1)}°
|
||||
</span>
|
||||
</span>
|
||||
{tracking.range_km > 0 && (
|
||||
<span className="text-muted-foreground" title={t('sat.range')}>
|
||||
{Math.round(tracking.range_km).toLocaleString()} km
|
||||
</span>
|
||||
)}
|
||||
{tracking.alt_km > 0 && (
|
||||
<span className="text-muted-foreground" title={t('sat.altitude')}>
|
||||
↑{Math.round(tracking.alt_km).toLocaleString()} km
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 border-l border-border pl-2.5" title={t('sat.down')}>
|
||||
<ArrowDown className="size-3 text-muted-foreground" />
|
||||
<span className="font-medium">{fmtHz(tracking.down_hz)}</span>
|
||||
</span>
|
||||
{!!tracking.up_hz && (
|
||||
<span className="flex items-center gap-1" title={t('sat.up')}>
|
||||
<ArrowUp className="size-3 text-muted-foreground" />
|
||||
<span className="font-medium">{fmtHz(tracking.up_hz)}</span>
|
||||
</span>
|
||||
)}
|
||||
{tracking.rot_on && (
|
||||
<span className={cn('flex items-center gap-1 border-l border-border pl-2.5',
|
||||
antennaMoving && 'text-caution')} title={t('sat.antenna')}>
|
||||
{/* The needle spins while the antenna is slewing. A rotator
|
||||
takes tens of seconds to cross a pass, and the difference
|
||||
between "on its way" and "stuck" is the whole reason to look
|
||||
at it — a number alone cannot show movement. */}
|
||||
<Compass className={cn('size-3', antennaMoving ? 'animate-spin' : 'text-muted-foreground')}
|
||||
style={antennaMoving ? { animationDuration: '3s' } : undefined} />
|
||||
<span className="font-medium">
|
||||
{Math.round(tracking.rot_az)}°
|
||||
{!tracking.rot_az_only && ` / ${Math.round(tracking.rot_el)}°`}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{/* Elements are maintenance, so only their AGE is here — and only when
|
||||
it has become a reason the panel might be wrong. */}
|
||||
@@ -1013,9 +1117,7 @@ function FreqRow({ label, hz, nominal }: { label: string; hz: number; nominal: n
|
||||
<span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span>
|
||||
<span className="text-base font-semibold tabular-nums">{fmtHz(hz)}</span>
|
||||
{!!shift && (
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground">
|
||||
{shift > 0 ? '+' : '−'}{Math.abs(Math.round(shift))} Hz
|
||||
</span>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground">{fmtShift(shift)}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1468,6 +1468,13 @@ function SatelliteElementsBlock({ autoTle, onAutoTle }: { autoTle: boolean; onAu
|
||||
// Following none means following every satellite that has both elements and a
|
||||
// frequency plan, which is the sensible thing for somebody who has not chosen
|
||||
// yet and the reason the list does not start out empty-handed.
|
||||
// byCallsign orders satellite names the way an operator reads them: alphabetical,
|
||||
// but with the number taken as a number. Plain string order files AO-123 between
|
||||
// AO-1 and AO-27, which is not where anybody looks for it.
|
||||
function byCallsign(a: { name?: string }, b: { name?: string }) {
|
||||
return String(a?.name ?? '').localeCompare(String(b?.name ?? ''), undefined, { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
function SatelliteFollowList({ followed, onChange }: { followed: string[]; onChange: (next: string[]) => void }) {
|
||||
const { t } = useI18n();
|
||||
const [all, setAll] = useState<any[]>([]);
|
||||
@@ -1485,8 +1492,13 @@ function SatelliteFollowList({ followed, onChange }: { followed: string[]; onCha
|
||||
const needle = q.trim().toLowerCase();
|
||||
const available = all.filter((b) => !followedSet.has(String(b.name).toUpperCase())
|
||||
&& (!withPlanOnly || (b.transponders?.length ?? 0) > 0)
|
||||
&& (needle === '' || String(b.name).toLowerCase().includes(needle)));
|
||||
const chosen = followed.map((n) => byName.get(n) ?? { name: n, has_elements: false, transponders: [] });
|
||||
&& (needle === '' || String(b.name).toLowerCase().includes(needle))).sort(byCallsign);
|
||||
// Sorted, both columns: the left one came in the order the frequency file
|
||||
// happens to be written and the right one in the order the operator clicked,
|
||||
// so finding AO-91 among sixteen followed birds meant reading all sixteen.
|
||||
const chosen = followed
|
||||
.map((n) => byName.get(n) ?? { name: n, has_elements: false, transponders: [] })
|
||||
.sort(byCallsign);
|
||||
|
||||
const label = (b: any) => {
|
||||
const bits: string[] = [];
|
||||
@@ -5135,6 +5147,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
// controller. PstRotator knows which machine is on the other end and
|
||||
// does its own overlap; two programs each deciding to go the long
|
||||
// way round is how an antenna unwinds mid-pass.
|
||||
//
|
||||
// NOT offered for a Rotator Genius. Its manual is plain — "you will
|
||||
// not be able to give it a target beyond the limits" — and its
|
||||
// Limits fields say where the mechanical stop sits within ONE turn
|
||||
// ("5 to 4" is a dead zone at four and a half degrees), not how far
|
||||
// the mast travels. Offering 450° there would be offering a setting
|
||||
// that can only ever be refused by the box. Whether the overlap is
|
||||
// used is decided from what the Genius itself reports, in
|
||||
// rotgeniusGoTo, and needs no setting at all.
|
||||
const ownsOverlap = isERC || isEasycomm;
|
||||
return (
|
||||
<div key={dev.id || i} className="rounded-xl border border-border bg-card/40 p-3 space-y-3">
|
||||
|
||||
@@ -19,7 +19,7 @@ const en: Dict = {
|
||||
'live.offlineTip': 'Offline — no QSO logged in the last 5 minutes',
|
||||
'live.stationsTitle': 'Stations on air', 'live.stationsEmpty': 'No station reporting yet.', 'live.stationsHide': 'Hide',
|
||||
'upd.available': 'OpsLog v{v} available', 'upd.current': "You're on v{v}.",
|
||||
'upd.install': 'Update now', 'upd.download': 'Download', 'upd.later': 'Later',
|
||||
'upd.install': 'Update now', 'upd.download': 'Download', 'upd.later': 'Later', 'upd.remindIn': 'Remind me in', 'upd.dismissHour': 'Hide this for an hour',
|
||||
'upd.downloading': 'Downloading…', 'upd.installing': 'Installing…',
|
||||
'upd.restartNote': 'OpsLog will restart on the new version.',
|
||||
'upd.retry': 'Retry', 'upd.browser': 'Open page', 'upd.checking': 'Checking for updates…', 'upd.upToDate': "You're up to date",
|
||||
@@ -199,7 +199,7 @@ const en: Dict = {
|
||||
'wlc.title': 'Contest', 'wlc.pattern': 'Auto-add on', 'wlc.patternPh': 'WWA', 'wlc.patternHint': 'any spotted callsign CONTAINING this joins the watchlist as a contest entry (TM29WWA, HB9WWA, F4WWA/P)',
|
||||
'wlc.calls': 'And these callsigns, one per line', 'wlc.callsPh': 'R7W DL0ABC', 'wlc.callsHint': 'For the entries a pattern cannot catch: a station taking part under a callsign that says nothing about the event. Named here, it joins the contest watchlist the moment it is spotted. Commas and spaces work too.',
|
||||
// FTx decodes panel (Tools -> FT decodes)
|
||||
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Set your station grid in Preferences to place the map.', 'dec.tab': 'FT decodes', 'dec.title': 'Decodes', 'dec.bandDrift': '{app} says {dec}, the rig is on {rig}', 'dec.bandDriftApp': 'the decoder', 'dec.bandDriftTip': 'The decoding application is announcing a band the radio is not on — it has most likely lost its CAT link and is repeating the last frequency it knew. Every decode below carries that band, so the NEW / NEW BAND verdicts are judged against it.', 'dec.cqOnly': 'CQ only', 'dec.sortTip': 'Sort this slot by this column. Click again to reverse it, once more for the order the decoder heard them in.',
|
||||
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Set your station grid in Preferences to place the map.', 'ftmap.colour': 'One colour for every decode, whatever the band', 'ftmap.colourPerBand': 'Back to the colour per band', 'ftmap.hearMe': 'Who hears me', 'ftmap.hearMeTip': 'Draw the stations reporting your own transmissions to PSK Reporter, dashed, alongside what you decode. Watches one topic — your callsign — so it costs almost nothing.', 'ftmap.hearMeLegend': 'reporting you ({n})', 'ftmap.heardColour': 'Colour for the stations reporting you', 'ftmap.heardColourReset': 'Back to the default colour', 'dec.tab': 'FT decodes', 'dec.title': 'Decodes', 'dec.bandDrift': '{app} says {dec}, the rig is on {rig}', 'dec.bandDriftApp': 'the decoder', 'dec.bandDriftTip': 'The decoding application is announcing a band the radio is not on — it has most likely lost its CAT link and is repeating the last frequency it knew. Every decode below carries that band, so the NEW / NEW BAND verdicts are judged against it.', 'dec.cqOnly': 'CQ only', 'dec.sortTip': 'Sort this slot by this column. Click again to reverse it, once more for the order the decoder heard them in.',
|
||||
'dec.allBands': 'All bands', 'dec.allModes': 'All modes', 'toast.qsoLogged': 'QSO logged', 'dec.contsHint': 'Continents: click to keep one or several', 'dec.allConts': 'All continents',
|
||||
'dec.minSnrTitle': 'Hide anything weaker than this SNR', 'dec.searchPh': 'Call, grid or message',
|
||||
'dec.clearFilters': 'Clear', 'dec.live': 'live', 'dec.tx': 'TX',
|
||||
@@ -271,6 +271,10 @@ const en: Dict = {
|
||||
'station.rigOff': 'CAT is switched off', 'station.noPort': 'no port configured',
|
||||
'station.connect': 'Connect', 'station.noVoiceMsg': 'No message recorded — Settings ▸ Audio.',
|
||||
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotateTo': 'Rotate to {az}°', 'station.rotatorNoRead': 'No heading read', 'station.bands': 'Bands', 'station.nudgeUp': 'Up {n} kHz', 'station.nudgeDown': 'Down {n} kHz', 'station.trackOn': 'Tracking on', 'station.trackOff': 'Tracking off', 'station.trackStepTip': 'Re-tune only when the rig has moved this far', 'station.trackModeTip': 'When the antenna is allowed to re-tune', 'station.trackAlways': 'Every frequency change', 'station.trackStep': 'Past a step', 'station.trackBand': 'Band change only', 'station.trackAlwaysTip': 'Follow every frequency change. Best resonance, but the motors run constantly — and on a SteppIR every move blocks transmit while the elements travel.', 'station.trackStepTipMode': 'Re-tune only once the rig has moved further than the step. Follows a QSY, ignores tuning around.', 'station.trackBandTip': 'Re-tune only when the band changes. The motors move a few times a day and are left alone within a band.', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.motorWidgetShow': 'Motorised antenna · click to show', 'station.motorWidgetHide': 'Motorised antenna — shown · click to hide', 'station.hideWidget': 'Hide this widget', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag the grip handle on the left of a panel to move it. Pick a column count to lay them out in a grid.', 'station.dragMove': 'Drag to move this panel', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.hostHint': 'LAN IP for local use. To reach the board from OUTSIDE, put a full URL here — e.g. https://relay.yourdomain.com — pointing at a reverse proxy (Nginx Proxy Manager…) that fronts the board’s HTTP port.', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save', 'station.test': 'Test connection', 'station.testOk': 'Connected — {n} relays', 'station.testFail': 'Not connected', 'station.detectNone': 'No FTDI board found — check the cable and that the D2XX driver is installed.', 'station.detectFound': '{n} board(s) detected.',
|
||||
'station.retractTip': 'Home the elements into the hubs — the storage position, for a gale or a winter. The next time the antenna is tuned it comes back out on its own.',
|
||||
'station.calibrate': 'Calibrate',
|
||||
'station.calibrateTip': 'SteppIR: drive 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, after a motor slipped.',
|
||||
'station.calibrateConfirm': 'Calibrate the antenna?\n\nEvery element travels its full length and this takes several minutes. Do not start it during a contest — the antenna is unusable until it finishes.',
|
||||
'awards.followHint': 'Awards shown in the Awards tab. Leave the right side empty to show them all.', 'awards.available': 'All awards', 'awards.followed': 'Followed', 'awards.search': 'Filter…', 'awards.addAll': 'Add all', 'awards.clear': 'Clear', 'awards.allTracked': 'All awards are followed.', 'awards.noneFollowed': 'Nothing followed yet — the Awards tab shows all.',
|
||||
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer',
|
||||
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.tunergenius': 'Tuner Genius', 'sec.pgxl': 'Amplifier', 'sec.psu': 'Power supply',
|
||||
@@ -388,7 +392,7 @@ const en: Dict = {
|
||||
'clu.slotHighlightHint': '(by callsign, whatever the entity status says)',
|
||||
'rq.searchPh': 'Search callsign… 4S · *4S · *4S*', 'rq.searchTip': 'A plain word matches the START of a callsign: 4S finds 4S7AB. * is any run of characters and ? is exactly one, so *4S ends with 4S, *4S* contains it anywhere, and F?BPO matches F4BPO.',
|
||||
'chg.mode': 'Chase', 'chg.sources': 'Confirmed by', 'chg.card': 'QSL card', 'dec.unconfTip': 'Worked but not confirmed — a QSL to chase', 'gsc.scope': 'Match a square by', 'gsc.hunt': 'Chase', 'gsc.huntNew': 'New — never worked', 'gsc.huntUnconf': 'New and unconfirmed', 'gsc.scope_band_digi': 'This band + any digital mode', 'gsc.scope_band_mode': 'This band + this exact mode', 'gsc.scope_band_ftx': 'This band + any FT mode (FT8/FT4/FT2)', 'gsc.scope_mix_digi': 'Any band + any digital mode', 'gsc.scope_mix_mode': 'Any band + this exact mode', 'gsc.scope_mix_ftx': 'Any band + any FT mode (FT8/FT4/FT2)', 'gsc.hint': 'Decides when a square stops being NEW. Narrower means more squares to chase: per band and per exact mode is the most demanding, any band and any digital mode the least. Chasing unconfirmed as well keeps a square wanted until a QSL, LoTW or eQSL confirmation arrives — it is still missing from the award until then.',
|
||||
'gsm.basemap': 'Basemap', 'gsm.title': 'Grid squares', 'gsm.all': 'All', 'gsm.phone': 'Phone', 'gsm.cw': 'CW', 'gsm.digital': 'Digital', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmed', 'gsm.worked': 'worked', 'gsm.colConfirmed': 'Colour for confirmed squares', 'gsm.colWorked': 'Colour for worked (unconfirmed) squares', 'gsm.colReset': 'Back to the theme colours', 'gsm.refresh': 'Recount from the log', 'gsm.count': '{n} squares · {c} confirmed',
|
||||
'gsm.basemap': 'Basemap', 'gsm.title': 'Grid squares', 'gsm.all': 'All', 'gsm.phone': 'Phone', 'gsm.cw': 'CW', 'gsm.digital': 'Digital', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmed', 'gsm.worked': 'worked', 'gsm.colConfirmed': 'Colour for confirmed squares', 'gsm.colWorked': 'Colour for worked (unconfirmed) squares', 'gsm.colReset': 'Back to the theme colours', 'gsm.refresh': 'Recount from the log', 'gsm.count': '{n} squares · {c} confirmed', 'gsm.oneMode': 'One mode', 'gsm.band': 'Band', 'gsm.allBands': 'All bands', 'gsm.satellite': 'Satellite', 'gsm.allSats': 'All satellites',
|
||||
'bo.nearKm': 'Count receivers within', 'bo.nearKmHint': 'A report proves YOUR path only if it was collected near you. Smaller is more local but leaves fewer receivers listening — too small and the watch has nothing to look at. 300 km borrows a whole region and 100 km suits 2 m, where a duct is narrow; where stations are far apart — VK, ZL, much of North America — 1000 to 2000 km may be what it takes to find any receivers at all.',
|
||||
'bo.open': 'open', 'bo.liveTip': '{band} is open — {n} stations, ~{km} km, {sector}{season}. Click for the band map.', 'bo.enable': 'Watch for band openings', 'bo.enableHint': '(10, 12, 6, 4 and 2 m. Switching this on adds the two RBN nodes and subscribes to the PSK Reporter feed — the detection needs far more ears than a cluster can give it.)', 'bo.feedUp': 'PSK Reporter feed up — {n} decodes seen', 'bo.feedDown': 'PSK Reporter feed down — needs your station grid, and a moment to connect', 'clu.spotTtl': 'Spot lifetime', 'clu.spotTtlNever': 'Keep', 'clu.spotMax': 'Spots kept', 'clu.spotMaxHint': '', 'clu.spotTtlHint': 'minutes — 0 keeps them.', 'clu.pillConnect': 'Click to connect', 'clu.pillDisconnect': 'Click to disconnect', 'clu.chasePota': 'Chase POTA', 'clu.chasePotaHint': 'Off: no NEW POTA badge or filter, and the POTA column stays empty — a new-band + new-POTA spot reads NEW BAND alone.', 'clu.chaseSota': 'Chase SOTA', 'clu.chaseSotaHint': 'Off: the SOTA column stays empty.', 'clu.chaseCounty': 'Chase US counties', 'clu.chaseCountyHint': 'Off: no NEW COUNTY badge or filter in the cluster.', 'clu.chaseState': 'Chase US states', 'clu.chaseStateHint': 'Off: no NEW STATE badge or filter.', 'clu.chasePfx': 'Chase new prefixes', 'clu.chasePfxHint': 'Off: no NEW PFX badge or filter in the cluster.', 'clu.chaseGrids': 'Chase new grids', 'clu.chaseGridsHint': '(learns locators from your own WSJT-X decodes AND from PSK Reporter, and keeps them in their own database so the cluster shows them from the first second)', 'clu.chaseGridsStat': '{n} locators known — {p} waiting to be written', 'clu.workedSameSlot': 'Already worked only on the same slot',
|
||||
'clu.macros': 'Command buttons', 'clu.macrosHint': 'A named button beside the cluster command box. Leave the command empty and the button is not shown.',
|
||||
@@ -836,7 +840,7 @@ const fr: Dict = {
|
||||
'wlc.title': 'Contest', 'wlc.pattern': 'Ajout auto sur', 'wlc.patternPh': 'WWA', 'wlc.patternHint': 'tout indicatif spotté CONTENANT ceci rejoint la watchlist comme entrée contest (TM29WWA, HB9WWA, F4WWA/P)',
|
||||
'wlc.calls': 'Et ces indicatifs, un par ligne', 'wlc.callsPh': 'R7W DL0ABC', 'wlc.callsHint': 'Pour les participants qu’aucun motif ne peut attraper : une station engagée sous un indicatif qui ne dit rien de l’événement. Nommée ici, elle rejoint la watchlist contest dès qu’elle est spottée. Les virgules et les espaces marchent aussi.',
|
||||
// Panneau des decodes FTx (Outils -> Decodes FT)
|
||||
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Renseigne ton locator dans les Préférences pour placer la carte.', 'dec.tab': 'Decodes FT', 'dec.title': 'Decodes', 'dec.bandDrift': '{app} annonce {dec}, le poste est sur {rig}', 'dec.bandDriftApp': 'le décodeur', 'dec.bandDriftTip': 'Le logiciel de décodage annonce une bande sur laquelle la radio n’est pas — il a très probablement perdu sa liaison CAT et répète la dernière fréquence connue. Tous les décodages ci-dessous portent cette bande, et les verdicts NOUVEAU / NOUVELLE BANDE sont jugés dessus.', 'dec.cqOnly': 'CQ seulement', 'dec.sortTip': 'Trier ce créneau sur cette colonne. Un second clic inverse, un troisième rend l’ordre dans lequel le décodeur les a entendus.',
|
||||
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Renseigne ton locator dans les Préférences pour placer la carte.', 'ftmap.colour': 'Une seule couleur pour tous les décodages, quelle que soit la bande', 'ftmap.colourPerBand': 'Revenir à la couleur par bande', 'ftmap.hearMe': 'Qui m’entend', 'ftmap.hearMeTip': 'Trace en pointillés les stations qui rapportent vos émissions à PSK Reporter, à côté de ce que vous décodez. Un seul topic surveillé — votre indicatif — donc un coût quasi nul.', 'ftmap.hearMeLegend': 'vous rapportent ({n})', 'ftmap.heardColour': 'Couleur des stations qui vous rapportent', 'ftmap.heardColourReset': 'Revenir à la couleur par défaut', 'dec.tab': 'Decodes FT', 'dec.title': 'Decodes', 'dec.bandDrift': '{app} annonce {dec}, le poste est sur {rig}', 'dec.bandDriftApp': 'le décodeur', 'dec.bandDriftTip': 'Le logiciel de décodage annonce une bande sur laquelle la radio n’est pas — il a très probablement perdu sa liaison CAT et répète la dernière fréquence connue. Tous les décodages ci-dessous portent cette bande, et les verdicts NOUVEAU / NOUVELLE BANDE sont jugés dessus.', 'dec.cqOnly': 'CQ seulement', 'dec.sortTip': 'Trier ce créneau sur cette colonne. Un second clic inverse, un troisième rend l’ordre dans lequel le décodeur les a entendus.',
|
||||
'dec.allBands': 'Toutes bandes', 'dec.allModes': 'Tous modes', 'toast.qsoLogged': 'QSO enregistré', 'dec.contsHint': 'Continents : clique pour en garder un ou plusieurs', 'dec.allConts': 'Tous continents',
|
||||
'dec.minSnrTitle': 'Masquer tout ce qui est plus faible que ce rapport', 'dec.searchPh': 'Indicatif, locator ou message',
|
||||
'dec.clearFilters': 'Effacer', 'dec.live': 'en direct', 'dec.tx': 'TX',
|
||||
@@ -908,10 +912,14 @@ const fr: Dict = {
|
||||
'station.rigOff': 'le CAT est désactivé', 'station.noPort': 'aucun port configuré',
|
||||
'station.connect': 'Connecter', 'station.noVoiceMsg': 'Aucun message enregistré — Réglages ▸ Audio.',
|
||||
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotateTo': 'Tourner vers {az}°', 'station.rotatorNoRead': 'Azimut non lu', 'station.bands': 'Bandes', 'station.nudgeUp': 'Monter de {n} kHz', 'station.nudgeDown': 'Descendre de {n} kHz', 'station.trackOn': 'Suivi actif', 'station.trackOff': 'Suivi inactif', 'station.trackStepTip': 'Ne réaccorder que si le rig a bougé d au moins ça', 'station.trackModeTip': "Quand l'antenne a le droit de se réaccorder", 'station.trackAlways': 'À chaque changement', 'station.trackStep': 'Au-delà d un pas', 'station.trackBand': 'Changement de bande', 'station.trackAlwaysTip': "Suivre chaque changement de fréquence. Résonance idéale, mais les moteurs tournent en permanence — et sur une SteppIR chaque déplacement bloque l'émission le temps du mouvement.", 'station.trackStepTipMode': "Ne réaccorder qu'une fois le rig sorti du pas. Suit un QSY, ignore la recherche autour.", 'station.trackBandTip': "Ne réaccorder qu'au changement de bande. Les moteurs bougent quelques fois par jour et restent tranquilles dans une bande.", 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.motorWidgetShow': 'Antenne motorisée · cliquer pour afficher', 'station.motorWidgetHide': 'Antenne motorisée — affichée · cliquer pour masquer', 'station.hideWidget': 'Masquer ce widget', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse la poignée à gauche d un panneau pour le déplacer. Choisis un nombre de colonnes pour la disposition.', 'station.dragMove': 'Glisser pour déplacer ce panneau', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.hostHint': "IP du LAN en local. Pour joindre la carte depuis L'EXTÉRIEUR, saisis une URL complète ici — ex. https://relais.tondomaine.com — pointant vers un reverse proxy (Nginx Proxy Manager…) qui expose le port HTTP de la carte.", 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer', 'station.test': 'Tester la connexion', 'station.testOk': 'Connecté — {n} relais', 'station.testFail': 'Non connecté', 'station.detectNone': 'Aucune carte FTDI trouvée — vérifie le câble et que le driver D2XX est installé.', 'station.detectFound': '{n} carte(s) détectée(s).',
|
||||
'station.retractTip': 'Rentrer les éléments dans les moyeux — la position de rangement, pour un coup de vent ou pour l’hiver. Au prochain accord, l’antenne ressort d’elle-même.',
|
||||
'station.calibrate': 'Calibrer',
|
||||
'station.calibrateTip': 'SteppIR : amener chaque élément en butée pour que le contrôleur retrouve son zéro. Le remède à une antenne qui s’accorde à la mauvaise longueur — après une coupure en pleine course, après avoir poussé les éléments à la main, après un moteur qui a glissé.',
|
||||
'station.calibrateConfirm': 'Calibrer l’antenne ?\n\nChaque élément parcourt toute sa course et cela prend plusieurs minutes. À ne pas lancer pendant un concours — l’antenne est inutilisable jusqu’à la fin.',
|
||||
'awards.followHint': 'Diplômes affichés dans l’onglet Awards. Laisse la colonne de droite vide pour tous les afficher.', 'awards.available': 'Tous les diplômes', 'awards.followed': 'Suivis', 'awards.search': 'Filtrer…', 'awards.addAll': 'Tout ajouter', 'awards.clear': 'Vider', 'awards.allTracked': 'Tous les diplômes sont suivis.', 'awards.noneFollowed': 'Aucun pour l’instant — l’onglet Awards les montre tous.',
|
||||
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',
|
||||
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.tunergenius': 'Tuner Genius', 'sec.pgxl': 'Amplificateur', 'sec.psu': 'Alimentation',
|
||||
'upd.available': 'OpsLog v{v} disponible', 'upd.current': 'Tu es en v{v}.', 'upd.install': 'Mettre à jour maintenant', 'upd.download': 'Télécharger', 'upd.later': 'Plus tard', 'upd.downloading': 'Téléchargement…', 'upd.installing': 'Installation…', 'upd.restartNote': 'OpsLog redémarrera sur la nouvelle version.', 'upd.retry': 'Réessayer', 'upd.browser': 'Ouvrir la page',
|
||||
'upd.available': 'OpsLog v{v} disponible', 'upd.current': 'Tu es en v{v}.', 'upd.install': 'Mettre à jour maintenant', 'upd.download': 'Télécharger', 'upd.later': 'Plus tard', 'upd.remindIn': 'Me rappeler dans', 'upd.dismissHour': 'Masquer pendant une heure', 'upd.downloading': 'Téléchargement…', 'upd.installing': 'Installation…', 'upd.restartNote': 'OpsLog redémarrera sur la nouvelle version.', 'upd.retry': 'Réessayer', 'upd.browser': 'Ouvrir la page',
|
||||
'psu.title': 'Alimentation de laboratoire (Modbus RTU)', 'psu.hint': 'Une alimentation programmable sur port série — BSIDE, Wanptek et les autres alimentations Modbus RTU utilisant les codes fonction 03 et 06. OpsLog affiche ce qu’elle débite et commute sa sortie.', 'psu.enable': 'Piloter cette alimentation', 'psu.port': 'Port COM', 'psu.baud': 'Vitesse', 'psu.address': 'Adresse Modbus', 'psu.wireHint': '9600 bauds, 8 bits de données, sans parité, 1 bit de stop, adresse 1 — les réglages d’usine. Ne les changez ici que si vous les avez changés sur l’alimentation.', 'psu.writeScope': 'OpsLog n’écrit jamais que la marche/arrêt de la sortie. La tension, le courant et les protections sont lus et affichés, jamais modifiés — ils restent sur la face avant de l’alimentation.', 'psu.output': 'Sortie', 'psu.setTo': 'réglée sur', 'psu.tripped': 'PROTECTION DÉCLENCHÉE', 'psu.offline': 'Ne répond pas',
|
||||
'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
|
||||
// Panneau Manipulateur CW
|
||||
@@ -1019,7 +1027,7 @@ const fr: Dict = {
|
||||
'clu.slotHighlightHint': "(par indicatif, quel que soit le statut de l'entité)",
|
||||
'rq.searchPh': 'Chercher un indicatif… 4S · *4S · *4S*', 'rq.searchTip': 'Un mot simple correspond au DÉBUT de l’indicatif : 4S trouve 4S7AB. * remplace n’importe quelle suite de caractères et ? exactement un, donc *4S se termine par 4S, *4S* le contient n’importe où, et F?BPO correspond à F4BPO.',
|
||||
'chg.mode': 'Chasse', 'chg.sources': 'Confirmé par', 'chg.card': 'Carte QSL', 'dec.unconfTip': 'Contacté mais non confirmé — une QSL à chasser', 'gsc.scope': 'Carré déjà fait selon', 'gsc.hunt': 'Chasser', 'gsc.huntNew': 'Nouveau — jamais contacté', 'gsc.huntUnconf': 'Nouveau et non confirmé', 'gsc.scope_band_digi': 'Cette bande + tout mode numérique', 'gsc.scope_band_mode': 'Cette bande + ce mode exact', 'gsc.scope_band_ftx': 'Cette bande + tout mode FT (FT8/FT4/FT2)', 'gsc.scope_mix_digi': 'Toutes bandes + tout mode numérique', 'gsc.scope_mix_mode': 'Toutes bandes + ce mode exact', 'gsc.scope_mix_ftx': 'Toutes bandes + tout mode FT (FT8/FT4/FT2)', 'gsc.hint': 'Détermine quand un carré cesse d’être NEW. Plus c’est étroit, plus il y a de carrés à chasser : par bande et par mode exact est le plus exigeant, toutes bandes et tout numérique le moins. Chasser aussi les non confirmés garde un carré recherché jusqu’à une confirmation QSL, LoTW ou eQSL — il manque toujours au diplôme d’ici là.',
|
||||
'gsm.basemap': 'Fond de carte', 'gsm.title': 'Carrés locator', 'gsm.all': 'Tout', 'gsm.phone': 'Phonie', 'gsm.cw': 'CW', 'gsm.digital': 'Numérique', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmés', 'gsm.worked': 'contactés', 'gsm.colConfirmed': 'Couleur des carrés confirmés', 'gsm.colWorked': 'Couleur des carrés contactés (non confirmés)', 'gsm.colReset': 'Revenir aux couleurs du thème', 'gsm.refresh': 'Recompter depuis le journal', 'gsm.count': '{n} carrés · {c} confirmés',
|
||||
'gsm.basemap': 'Fond de carte', 'gsm.title': 'Carrés locator', 'gsm.all': 'Tout', 'gsm.phone': 'Phonie', 'gsm.cw': 'CW', 'gsm.digital': 'Numérique', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmés', 'gsm.worked': 'contactés', 'gsm.colConfirmed': 'Couleur des carrés confirmés', 'gsm.colWorked': 'Couleur des carrés contactés (non confirmés)', 'gsm.colReset': 'Revenir aux couleurs du thème', 'gsm.refresh': 'Recompter depuis le journal', 'gsm.count': '{n} carrés · {c} confirmés', 'gsm.oneMode': 'Un mode', 'gsm.band': 'Bande', 'gsm.allBands': 'Toutes les bandes', 'gsm.satellite': 'Satellite', 'gsm.allSats': 'Tous les satellites',
|
||||
'bo.nearKm': 'Compter les récepteurs à moins de', 'bo.nearKmHint': 'Un report ne prouve TON chemin que s’il a été collecté près de chez toi. Plus petit est plus local, mais laisse moins de récepteurs à l’écoute — trop petit, la veille n’a plus rien à observer. 300 km emprunte les oreilles de toute une région et 100 km convient au 2 m, où un conduit est étroit ; là où les stations sont très dispersées — VK, ZL, une bonne partie de l’Amérique du Nord — 1000 à 2000 km sont parfois nécessaires pour trouver le moindre récepteur.',
|
||||
'bo.open': 'ouvert', 'bo.liveTip': '{band} est ouvert — {n} stations, ~{km} km, {sector}{season}. Cliquer pour le bandmap.', 'bo.enable': 'Surveiller les ouvertures de bande', 'bo.enableHint': "(10, 12, 6, 4 et 2 m. Activer ajoute les deux nœuds RBN et souscrit au flux PSK Reporter — la détection a besoin de bien plus d oreilles qu un cluster ne peut en fournir.)", 'bo.feedUp': 'Flux PSK Reporter actif — {n} décodages vus', 'bo.feedDown': 'Flux PSK Reporter inactif — il faut ton locator, et un instant pour se connecter', 'clu.workedSameSlot': 'Déjà contacté seulement sur le même slot',
|
||||
'clu.macros': 'Boutons de commande', 'clu.macrosHint': 'Un bouton nommé à côté du champ de commande du cluster. Laisse la commande vide et le bouton n’est pas affiché.',
|
||||
|
||||
@@ -69,6 +69,8 @@ const PORTABLE_KEYS = [
|
||||
'opslog.clusterHideSpots', // cluster console: hide the DX spot flood so replies are readable
|
||||
'opslog.clusterConsoleFollow', // cluster console: keep the view pinned to the newest line
|
||||
'opslog.gridMapColorConfirmed', 'opslog.gridMapColorWorked', // grid map: chosen fills (empty = follow the theme)
|
||||
'opslog.ftMapColour', // FT map: one colour for every arc (empty = the band palette)
|
||||
'opslog.ftMapHeardColour', // FT map: the who-hears-me diamonds (empty = the default cyan)
|
||||
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
||||
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
||||
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.27.20';
|
||||
export const APP_VERSION = '0.27.23';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+16
-1
@@ -14,6 +14,7 @@ import {bandopen} from '../models';
|
||||
import {cluster} from '../models';
|
||||
import {dxped} from '../models';
|
||||
import {extsvc} from '../models';
|
||||
import {pskrme} from '../models';
|
||||
import {powergenius} from '../models';
|
||||
import {pskrtgt} from '../models';
|
||||
import {pskr} from '../models';
|
||||
@@ -517,6 +518,10 @@ export function GetGridCacheStatus():Promise<main.GridCacheStatus>;
|
||||
|
||||
export function GetGridScopeSettings():Promise<main.GridScopeSettings>;
|
||||
|
||||
export function GetHearMe():Promise<boolean>;
|
||||
|
||||
export function GetHearMeStatus():Promise<pskrme.Status>;
|
||||
|
||||
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||
|
||||
export function GetKenwoodState():Promise<cat.KenwoodTXState>;
|
||||
@@ -667,6 +672,8 @@ export function GetWebPublishStatus():Promise<main.WebPublishStatus>;
|
||||
|
||||
export function GetWhatsNew():Promise<Array<main.ChangelogEntry>>;
|
||||
|
||||
export function GetWhoHearsMe():Promise<Array<pskrme.Report>>;
|
||||
|
||||
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
||||
|
||||
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||
@@ -685,7 +692,9 @@ export function GetYaesuBandAntennas():Promise<Record<string, number>>;
|
||||
|
||||
export function GetYaesuState():Promise<cat.YaesuTXState>;
|
||||
|
||||
export function GridSquares(arg1:string):Promise<Array<qso.GridSquare>>;
|
||||
export function GridSquareChoices():Promise<main.GridSquareChoices>;
|
||||
|
||||
export function GridSquares(arg1:string,arg2:string,arg3:string):Promise<Array<qso.GridSquare>>;
|
||||
|
||||
export function HaltAutoCall():Promise<void>;
|
||||
|
||||
@@ -853,6 +862,8 @@ export function LookupCallsign(arg1:string,arg2:string):Promise<lookup.Result>;
|
||||
|
||||
export function LookupCallsignFresh(arg1:string,arg2:string):Promise<lookup.Result>;
|
||||
|
||||
export function MotorCalibrate():Promise<void>;
|
||||
|
||||
export function MotorNudgeKHz(arg1:number):Promise<void>;
|
||||
|
||||
export function MotorReadElements():Promise<Array<number>>;
|
||||
@@ -1045,6 +1056,8 @@ export function RestartApp():Promise<void>;
|
||||
|
||||
export function RestartQSORecorder():Promise<void>;
|
||||
|
||||
export function RetargetSatelliteTracking(arg1:string,arg2:number):Promise<void>;
|
||||
|
||||
export function RetryOfflineSync():Promise<number>;
|
||||
|
||||
export function RevealDataFolder():Promise<void>;
|
||||
@@ -1229,6 +1242,8 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||
|
||||
export function SetFlexRSTChaseEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetHearMe(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetKenwoodAFGain(arg1:number):Promise<void>;
|
||||
|
||||
export function SetKenwoodAGC(arg1:string):Promise<void>;
|
||||
|
||||
@@ -966,6 +966,14 @@ export function GetGridScopeSettings() {
|
||||
return window['go']['main']['App']['GetGridScopeSettings']();
|
||||
}
|
||||
|
||||
export function GetHearMe() {
|
||||
return window['go']['main']['App']['GetHearMe']();
|
||||
}
|
||||
|
||||
export function GetHearMeStatus() {
|
||||
return window['go']['main']['App']['GetHearMeStatus']();
|
||||
}
|
||||
|
||||
export function GetIcomState() {
|
||||
return window['go']['main']['App']['GetIcomState']();
|
||||
}
|
||||
@@ -1266,6 +1274,10 @@ export function GetWhatsNew() {
|
||||
return window['go']['main']['App']['GetWhatsNew']();
|
||||
}
|
||||
|
||||
export function GetWhoHearsMe() {
|
||||
return window['go']['main']['App']['GetWhoHearsMe']();
|
||||
}
|
||||
|
||||
export function GetWinkeyerSettings() {
|
||||
return window['go']['main']['App']['GetWinkeyerSettings']();
|
||||
}
|
||||
@@ -1302,8 +1314,12 @@ export function GetYaesuState() {
|
||||
return window['go']['main']['App']['GetYaesuState']();
|
||||
}
|
||||
|
||||
export function GridSquares(arg1) {
|
||||
return window['go']['main']['App']['GridSquares'](arg1);
|
||||
export function GridSquareChoices() {
|
||||
return window['go']['main']['App']['GridSquareChoices']();
|
||||
}
|
||||
|
||||
export function GridSquares(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['GridSquares'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function HaltAutoCall() {
|
||||
@@ -1638,6 +1654,10 @@ export function LookupCallsignFresh(arg1, arg2) {
|
||||
return window['go']['main']['App']['LookupCallsignFresh'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function MotorCalibrate() {
|
||||
return window['go']['main']['App']['MotorCalibrate']();
|
||||
}
|
||||
|
||||
export function MotorNudgeKHz(arg1) {
|
||||
return window['go']['main']['App']['MotorNudgeKHz'](arg1);
|
||||
}
|
||||
@@ -2022,6 +2042,10 @@ export function RestartQSORecorder() {
|
||||
return window['go']['main']['App']['RestartQSORecorder']();
|
||||
}
|
||||
|
||||
export function RetargetSatelliteTracking(arg1, arg2) {
|
||||
return window['go']['main']['App']['RetargetSatelliteTracking'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function RetryOfflineSync() {
|
||||
return window['go']['main']['App']['RetryOfflineSync']();
|
||||
}
|
||||
@@ -2390,6 +2414,10 @@ export function SetFlexRSTChaseEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetFlexRSTChaseEnabled'](arg1);
|
||||
}
|
||||
|
||||
export function SetHearMe(arg1) {
|
||||
return window['go']['main']['App']['SetHearMe'](arg1);
|
||||
}
|
||||
|
||||
export function SetKenwoodAFGain(arg1) {
|
||||
return window['go']['main']['App']['SetKenwoodAFGain'](arg1);
|
||||
}
|
||||
|
||||
@@ -3075,6 +3075,22 @@ export namespace main {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class GridSquareChoices {
|
||||
modes: string[];
|
||||
bands: string[];
|
||||
satellites: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new GridSquareChoices(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.modes = source["modes"];
|
||||
this.bands = source["bands"];
|
||||
this.satellites = source["satellites"];
|
||||
}
|
||||
}
|
||||
export class HamlogCfmResult {
|
||||
total: number;
|
||||
confirmed: number;
|
||||
@@ -4282,6 +4298,8 @@ export namespace main {
|
||||
az: number;
|
||||
el: number;
|
||||
visible: boolean;
|
||||
range_km: number;
|
||||
alt_km: number;
|
||||
radio: string;
|
||||
error: string;
|
||||
rot_on: boolean;
|
||||
@@ -4307,6 +4325,8 @@ export namespace main {
|
||||
this.az = source["az"];
|
||||
this.el = source["el"];
|
||||
this.visible = source["visible"];
|
||||
this.range_km = source["range_km"];
|
||||
this.alt_km = source["alt_km"];
|
||||
this.radio = source["radio"];
|
||||
this.error = source["error"];
|
||||
this.rot_on = source["rot_on"];
|
||||
@@ -4578,6 +4598,7 @@ export namespace main {
|
||||
ok: boolean;
|
||||
err: string;
|
||||
db_path: string;
|
||||
warn: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new StartupStatus(source);
|
||||
@@ -4588,6 +4609,7 @@ export namespace main {
|
||||
this.ok = source["ok"];
|
||||
this.err = source["err"];
|
||||
this.db_path = source["db_path"];
|
||||
this.warn = source["warn"];
|
||||
}
|
||||
}
|
||||
export class StationDevice {
|
||||
@@ -5429,6 +5451,74 @@ export namespace pskr {
|
||||
|
||||
}
|
||||
|
||||
export namespace pskrme {
|
||||
|
||||
export class Report {
|
||||
call: string;
|
||||
grid: string;
|
||||
band: string;
|
||||
mode: string;
|
||||
snr: number;
|
||||
freq_hz: number;
|
||||
// Go type: time
|
||||
at: any;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Report(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.call = source["call"];
|
||||
this.grid = source["grid"];
|
||||
this.band = source["band"];
|
||||
this.mode = source["mode"];
|
||||
this.snr = source["snr"];
|
||||
this.freq_hz = source["freq_hz"];
|
||||
this.at = this.convertValues(source["at"], null);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class Status {
|
||||
enabled: boolean;
|
||||
online: boolean;
|
||||
reports: number;
|
||||
watching: string;
|
||||
error: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Status(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.online = source["online"];
|
||||
this.reports = source["reports"];
|
||||
this.watching = source["watching"];
|
||||
this.error = source["error"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export namespace pskrtgt {
|
||||
|
||||
export class Bin {
|
||||
|
||||
@@ -584,6 +584,11 @@ type FlexController interface {
|
||||
SetMute(bool) error
|
||||
SetRXAntenna(string) error
|
||||
SetTXAntenna(string) error
|
||||
// SatAntennas sets the antenna on each SATELLITE slice — they are on two
|
||||
// different bands and, with transverters, two different ports.
|
||||
SatAntennas(rxAnt, txAnt string) error
|
||||
// SatTone sets the CTCSS tone the satellite uplink transmits (0 = off).
|
||||
SatTone(hz float64) error
|
||||
SetActiveSlice(int) error // focus slice idx so commands target it
|
||||
// ZoomPan sets the visible width (MHz) of the active slice's panadapter and
|
||||
// keeps freqMHz inside it, re-centring when it must. See Flex.ZoomPan.
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
// Flex is a native FlexRadio (SmartSDR) CAT backend. It speaks the radio's TCP
|
||||
@@ -73,6 +75,14 @@ type Flex struct {
|
||||
satRX int
|
||||
satTX int
|
||||
satCreatedTX bool
|
||||
// What the uplink slice is owed, kept so it can be given to a slice that
|
||||
// turns up LATE. Arming, the antennas, the tone and the mode all happen
|
||||
// before the radio has necessarily reported the slice it was asked to
|
||||
// create; without this they were applied to an index of -1 and never again.
|
||||
satUpMode string
|
||||
satRXAnt string
|
||||
satTXAnt string
|
||||
satTone float64
|
||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
|
||||
spotFreq map[int]int64 // spot index → Hz, so a click can report where it was (the trigger message carries only the index)
|
||||
@@ -1034,7 +1044,29 @@ func (f *Flex) handleStatus(payload string) {
|
||||
s.filterHi = atoiDefault(val, s.filterHi)
|
||||
}
|
||||
}
|
||||
// A satellite uplink slice that arrived without our hearing about it.
|
||||
//
|
||||
// satCreate correlates the "slice create" reply by sequence number, and when
|
||||
// that correlation misses, the slice exists on the radio and OpsLog does not
|
||||
// know its index. Everything then silently does nothing: the uplink is never
|
||||
// tuned, never gets its mode, never gets its antenna or its CTCSS tone, and
|
||||
// — worst — never becomes the transmitter, so the radio goes on transmitting
|
||||
// on the DOWNLINK slice. Seen on the air: slice B sitting at the 435.100000
|
||||
// it was created with, both slices in USB, and the red TX badge on the 2 m
|
||||
// downlink.
|
||||
//
|
||||
// The status message needs no correlation. If satellite mode is armed, the
|
||||
// uplink is still unknown, and a slice is in use that is not the downlink,
|
||||
// that is the slice — the radio is telling us plainly.
|
||||
adopt := -1
|
||||
if f.satOn && f.satTX < 0 && idx != f.satRX && s.inUse {
|
||||
adopt = idx
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if adopt >= 0 {
|
||||
applog.Printf("flex: adopting slice %d as the satellite uplink from its status — the create reply never came back", adopt)
|
||||
f.adoptSatSlice("tx", adopt)
|
||||
}
|
||||
}
|
||||
|
||||
// defInt returns v, or def when v is zero (so sliders show sane defaults before
|
||||
|
||||
+136
-2
@@ -3,6 +3,7 @@ package cat
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
@@ -65,10 +66,41 @@ func (f *Flex) SetSatellite(on bool) error {
|
||||
} else {
|
||||
f.send(fmt.Sprintf("slice s %d tx=1", txIdx))
|
||||
}
|
||||
// WAIT for the slices before saying the pair is armed.
|
||||
//
|
||||
// Creating a slice is asynchronous: the index comes back in a later reply.
|
||||
// Returning before it arrives meant everything downstream ran against an
|
||||
// uplink of -1 — no antenna, no CTCSS tone, no mode, never tuned, and never
|
||||
// made the transmitter, so the radio went on transmitting on the DOWNLINK.
|
||||
// Seen on the air, and it is the one failure here that can put a signal
|
||||
// somewhere it must not go.
|
||||
rxIdx, txIdx = f.awaitSatSlices(3 * time.Second)
|
||||
if rxIdx < 0 || txIdx < 0 {
|
||||
applog.Printf("flex: satellite armed but the radio did not report both slices (rx %d, tx %d) — "+
|
||||
"the uplink will be picked up when it does", rxIdx, txIdx)
|
||||
return nil
|
||||
}
|
||||
applog.Printf("flex: satellite armed (rx slice %d, tx slice %d)", rxIdx, txIdx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// awaitSatSlices waits for both slice indices to be known, and returns whatever
|
||||
// it has when the time is up. Polled rather than signalled: the indices arrive
|
||||
// on the reader goroutine by two different routes — the create reply and the
|
||||
// slice status — and a poll is indifferent to which of them got there first.
|
||||
func (f *Flex) awaitSatSlices(d time.Duration) (rx, tx int) {
|
||||
deadline := time.Now().Add(d)
|
||||
for {
|
||||
f.mu.Lock()
|
||||
rx, tx = f.satRX, f.satTX
|
||||
f.mu.Unlock()
|
||||
if (rx >= 0 && tx >= 0) || time.Now().After(deadline) {
|
||||
return rx, tx
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Flex) satDisarm() error {
|
||||
f.mu.Lock()
|
||||
rx, tx, created := f.satRX, f.satTX, f.satCreatedTX
|
||||
@@ -118,6 +150,24 @@ func (f *Flex) adoptSatSlice(role string, idx int) {
|
||||
f.mu.Unlock()
|
||||
if role == "tx" {
|
||||
f.send(fmt.Sprintf("slice s %d tx=1", idx))
|
||||
// Everything this slice was owed while nobody knew where it was. Set
|
||||
// here rather than left to the next Doppler step, because the mode, the
|
||||
// antenna and the tone are all sent ONCE — the step only re-sends
|
||||
// frequencies.
|
||||
f.mu.Lock()
|
||||
mode, ant, tone := f.satUpMode, f.satTXAnt, f.satTone
|
||||
f.mu.Unlock()
|
||||
if strings.TrimSpace(ant) != "" {
|
||||
f.send(fmt.Sprintf("slice s %d txant=%s", idx, ant))
|
||||
f.send(fmt.Sprintf("slice s %d rxant=%s", idx, ant))
|
||||
}
|
||||
if strings.TrimSpace(mode) != "" {
|
||||
f.satMode(idx, mode, 0)
|
||||
}
|
||||
if tone > 0 {
|
||||
f.send(fmt.Sprintf("slice s %d fm_tone_value=%.1f", idx, tone))
|
||||
f.send(fmt.Sprintf("slice s %d fm_tone_mode=CTCSS_TX", idx))
|
||||
}
|
||||
}
|
||||
applog.Printf("flex: satellite %s slice is %d", role, idx)
|
||||
}
|
||||
@@ -146,6 +196,11 @@ func (f *Flex) TuneSatellite(downHz, upHz int64, downMode, upMode string) error
|
||||
f.send(fmt.Sprintf("slice t %d %.6f", rx, float64(downHz)/1e6))
|
||||
f.satMode(rx, downMode, downHz)
|
||||
}
|
||||
if strings.TrimSpace(upMode) != "" {
|
||||
f.mu.Lock()
|
||||
f.satUpMode = upMode
|
||||
f.mu.Unlock()
|
||||
}
|
||||
if tx >= 0 && upHz > 0 {
|
||||
f.send(fmt.Sprintf("slice t %d %.6f", tx, float64(upHz)/1e6))
|
||||
f.satMode(tx, upMode, upHz)
|
||||
@@ -161,8 +216,11 @@ func (f *Flex) satMode(idx int, mode string, freqHz int64) {
|
||||
if mode == "" {
|
||||
return
|
||||
}
|
||||
// USB on both sides above 30 MHz, which is every satellite worth the name —
|
||||
// including the parts of a passband that would be an LSB band down on HF.
|
||||
// A bare "SSB" still means upper sideband above 30 MHz, which is every
|
||||
// satellite worth the name — including the parts of a passband that would be
|
||||
// an LSB band down on HF. An explicit USB or LSB from the caller is left
|
||||
// alone: on an INVERTING transponder the two sides are different sidebands,
|
||||
// and only the caller knows which way round this bird runs.
|
||||
if strings.EqualFold(mode, "SSB") && freqHz > 30_000_000 {
|
||||
mode = "USB"
|
||||
}
|
||||
@@ -200,3 +258,79 @@ func (f *Flex) SatReceiveHz() (int64, error) {
|
||||
}
|
||||
return s.freqHz, nil
|
||||
}
|
||||
|
||||
// SatAntennas selects the antenna each satellite slice uses.
|
||||
//
|
||||
// The two slices are on two different bands — a V/U bird receives on 70 cm and
|
||||
// transmits on 2 m, a U/V one does the reverse — so they cannot share one
|
||||
// antenna setting. On a station with transverters they are not even the same
|
||||
// port: XVTA for 2 m, XVTB for 70 cm, and a downlink slice left on the HF
|
||||
// antenna hears nothing at all.
|
||||
//
|
||||
// Per SLICE, not through sendSlice, which addresses whichever slice is active.
|
||||
// During a pass the active slice is the downlink, so the uplink's antenna would
|
||||
// never have been set.
|
||||
//
|
||||
// Empty strings are left alone: an operator who has configured 2 m and not
|
||||
// 70 cm should keep whatever the radio already had on the other side rather
|
||||
// than have it cleared.
|
||||
func (f *Flex) SatAntennas(rxAnt, txAnt string) error {
|
||||
f.mu.Lock()
|
||||
rx, tx := f.satRX, f.satTX
|
||||
connected := f.conn != nil
|
||||
// Remembered so a slice that is reported late still gets its antenna.
|
||||
f.satRXAnt, f.satTXAnt = rxAnt, txAnt
|
||||
f.mu.Unlock()
|
||||
if !connected {
|
||||
return fmt.Errorf("flex: not connected")
|
||||
}
|
||||
// The downlink slice is the one being listened to, so it takes the receive
|
||||
// antenna; the uplink slice is the one keyed, so it takes the transmit one.
|
||||
if rx >= 0 && strings.TrimSpace(rxAnt) != "" {
|
||||
f.send(fmt.Sprintf("slice s %d rxant=%s", rx, rxAnt))
|
||||
applog.Printf("flex: satellite downlink slice %d on antenna %s", rx, rxAnt)
|
||||
}
|
||||
if tx >= 0 && strings.TrimSpace(txAnt) != "" {
|
||||
f.send(fmt.Sprintf("slice s %d txant=%s", tx, txAnt))
|
||||
// A transmit slice also has to HEAR its own band on some radios, and a
|
||||
// transverter port is the only thing connected to it. Setting the
|
||||
// receive antenna to match costs nothing when it is already right.
|
||||
f.send(fmt.Sprintf("slice s %d rxant=%s", tx, txAnt))
|
||||
applog.Printf("flex: satellite uplink slice %d on antenna %s", tx, txAnt)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SatTone sets the CTCSS tone the uplink slice transmits, in Hz. Zero turns it
|
||||
// off.
|
||||
//
|
||||
// On the UPLINK slice, because that is the one that keys: a tone is something
|
||||
// transmitted, and the repeater on the satellite will not open without it. This
|
||||
// is the whole difference between an operator hearing a pass and hearing
|
||||
// nothing on SO-50, AO-91, PO-101 and every other FM bird with a tone — and it
|
||||
// is exactly the setting that cannot be made by hand mid-pass.
|
||||
func (f *Flex) SatTone(hz float64) error {
|
||||
f.mu.Lock()
|
||||
tx := f.satTX
|
||||
connected := f.conn != nil
|
||||
f.satTone = hz
|
||||
f.mu.Unlock()
|
||||
if !connected {
|
||||
return fmt.Errorf("flex: not connected")
|
||||
}
|
||||
if tx < 0 {
|
||||
return nil // the slice has not come back yet; the next arming will set it
|
||||
}
|
||||
if hz <= 0 {
|
||||
f.send(fmt.Sprintf("slice s %d fm_tone_mode=OFF", tx))
|
||||
applog.Printf("flex: satellite uplink tone off")
|
||||
return nil
|
||||
}
|
||||
// Value before mode: a radio that is told CTCSS_TX while still holding the
|
||||
// previous tone transmits the previous tone for as long as it takes the
|
||||
// second command to arrive.
|
||||
f.send(fmt.Sprintf("slice s %d fm_tone_value=%.1f", tx, hz))
|
||||
f.send(fmt.Sprintf("slice s %d fm_tone_mode=CTCSS_TX", tx))
|
||||
applog.Printf("flex: satellite uplink tone %.1f Hz on slice %d", hz, tx)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
// Package pskrme answers the other half of the decodes map: who is hearing ME.
|
||||
//
|
||||
// The FT map draws what this station decodes, which is one direction of every
|
||||
// path on it. The reverse — which stations are reporting our own transmissions
|
||||
// — is the half an operator cannot see from their own receiver at all, and on
|
||||
// FT8 it is the half that decides whether calling is worth the cycle.
|
||||
//
|
||||
// It is the narrowest possible slice of the PSK Reporter feed. The v2 topic is
|
||||
//
|
||||
// pskr/filter/v2/<band>/<mode>/<tx call>/<rx call>/<tx grid>/<rx grid>/…
|
||||
//
|
||||
// so putting the operator's callsign in the TX level makes the broker send
|
||||
// nothing else. That is the whole reason this is cheap: internal/pskr measured
|
||||
// 83 messages a second for four bands unfiltered, and 0.2 to 1.2 a second once
|
||||
// filtered on the receiver's square — one callsign in the transmit level is a
|
||||
// handful of messages per FT8 cycle, however open the band is.
|
||||
//
|
||||
// Its own connection, like internal/pskrtgt has its own: the three want
|
||||
// different slices of the feed, and none of them can be filtered out of
|
||||
// another's. It also means this works with the band-opening watch switched off,
|
||||
// which matters — tying it to that feed's lifecycle would have made it fail
|
||||
// silently for anyone not chasing openings.
|
||||
//
|
||||
// Nothing is persisted. A report older than the window is dropped on the next
|
||||
// read, and an empty window means nobody has reported us recently, which is the
|
||||
// honest answer rather than a stale map.
|
||||
package pskrme
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
// DefaultBroker is PSK Reporter's public MQTT endpoint, TLS.
|
||||
const DefaultBroker = "tls://mqtt.pskreporter.info:1884"
|
||||
|
||||
// Window is how long a report keeps counting.
|
||||
//
|
||||
// Fifteen minutes. PSK Reporter's uploaders batch, many of them every five, so
|
||||
// a tighter window shows a fraction of the stations that actually heard the
|
||||
// last few calls — internal/pskrtgt widened its own to ten for exactly that
|
||||
// reason. This one is looser still because it feeds a MAP: a receiver that
|
||||
// heard us twelve minutes ago is a path worth seeing on it, where the same
|
||||
// report as a live "can he hear me" verdict would be stale.
|
||||
const Window = 15 * time.Minute
|
||||
|
||||
// Report is one station's reception of us, reduced to what a map needs.
|
||||
type Report struct {
|
||||
Call string `json:"call"` // who reported us
|
||||
Grid string `json:"grid"` // their square, from the message itself
|
||||
Band string `json:"band"`
|
||||
Mode string `json:"mode"`
|
||||
SNR int `json:"snr"` // how they heard us, their report
|
||||
FreqHz int64 `json:"freq_hz"` // where we were when they did
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// Status is what the panel needs to tell a working feed from a silent one.
|
||||
type Status struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Online bool `json:"online"`
|
||||
Reports uint64 `json:"reports"` // accepted since start
|
||||
Watching string `json:"watching"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// Config is what the watcher needs to run.
|
||||
type Config struct {
|
||||
Broker string
|
||||
// MyCall is the callsign to watch for in the TRANSMIT level. Without one
|
||||
// there is no subscription to make: a wildcard there would be the whole
|
||||
// feed, which is the one thing this package exists not to do.
|
||||
MyCall string
|
||||
Logf func(string, ...any)
|
||||
}
|
||||
|
||||
// Watcher owns the connection, its one subscription, and the sliding window.
|
||||
type Watcher struct {
|
||||
mu sync.Mutex
|
||||
cfg Config
|
||||
client mqtt.Client
|
||||
running bool
|
||||
topic string
|
||||
|
||||
reports []Report
|
||||
received uint64
|
||||
lastErr string
|
||||
}
|
||||
|
||||
func New(cfg Config) *Watcher {
|
||||
if cfg.Broker == "" {
|
||||
cfg.Broker = DefaultBroker
|
||||
}
|
||||
if cfg.Logf == nil {
|
||||
cfg.Logf = func(string, ...any) {}
|
||||
}
|
||||
cfg.MyCall = strings.ToUpper(strings.TrimSpace(cfg.MyCall))
|
||||
return &Watcher{cfg: cfg}
|
||||
}
|
||||
|
||||
// message is the payload, the same shape internal/pskr documents.
|
||||
type message struct {
|
||||
Freq int64 `json:"f"`
|
||||
Mode string `json:"md"`
|
||||
SNR int `json:"rp"`
|
||||
TxCall string `json:"sc"`
|
||||
TxGrid string `json:"sl"`
|
||||
RxCall string `json:"rc"`
|
||||
RxGrid string `json:"rl"`
|
||||
Band string `json:"b"`
|
||||
}
|
||||
|
||||
// Start brings the subscription up. Safe to call on a running watcher.
|
||||
func (w *Watcher) Start() error {
|
||||
w.mu.Lock()
|
||||
if w.running {
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
call := w.cfg.MyCall
|
||||
w.mu.Unlock()
|
||||
if call == "" {
|
||||
return fmt.Errorf("pskrme: no station callsign")
|
||||
}
|
||||
|
||||
opts := mqtt.NewClientOptions().
|
||||
AddBroker(w.cfg.Broker).
|
||||
SetClientID(fmt.Sprintf("opslog-hearme-%d", time.Now().UnixNano())).
|
||||
SetCleanSession(true).
|
||||
SetAutoReconnect(true).
|
||||
SetConnectRetry(true).
|
||||
SetConnectRetryInterval(30 * time.Second).
|
||||
SetConnectTimeout(15 * time.Second).
|
||||
SetOrderMatters(false)
|
||||
// Subscribed on every connect, reconnects included: the session is clean, so
|
||||
// the broker remembers nothing and a silent reconnect would leave a feed
|
||||
// that looks up and delivers nothing for the rest of the evening.
|
||||
opts.OnConnect = func(c mqtt.Client) {
|
||||
// Both wildcards deliberate: every band and every mode. The filter that
|
||||
// matters is the callsign, and an operator wants to know who hears them
|
||||
// wherever they happen to be.
|
||||
topic := "pskr/filter/v2/+/+/" + call + "/#"
|
||||
if tok := c.Subscribe(topic, 0, w.handle); tok.Wait() && tok.Error() != nil {
|
||||
w.setErr(tok.Error().Error())
|
||||
w.cfg.Logf("pskrme: subscribing to %s failed: %v", topic, tok.Error())
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.topic = topic
|
||||
w.lastErr = ""
|
||||
w.mu.Unlock()
|
||||
w.cfg.Logf("pskrme: watching who reports %s", call)
|
||||
}
|
||||
opts.OnConnectionLost = func(_ mqtt.Client, err error) {
|
||||
w.setErr(err.Error())
|
||||
w.cfg.Logf("pskrme: connection lost: %v", err)
|
||||
}
|
||||
|
||||
client := mqtt.NewClient(opts)
|
||||
if tok := client.Connect(); tok.Wait() && tok.Error() != nil {
|
||||
return fmt.Errorf("pskrme: connect: %w", tok.Error())
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.client, w.running = client, true
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop drops the connection and everything collected. A feed turned off must
|
||||
// not leave a map showing who heard us before it was.
|
||||
func (w *Watcher) Stop() {
|
||||
w.mu.Lock()
|
||||
client, running := w.client, w.running
|
||||
w.client, w.running = nil, false
|
||||
w.reports = nil
|
||||
w.topic = ""
|
||||
w.mu.Unlock()
|
||||
if running && client != nil {
|
||||
client.Disconnect(250)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) setErr(msg string) {
|
||||
w.mu.Lock()
|
||||
w.lastErr = msg
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
// handle runs on the MQTT goroutine, so it does the least possible.
|
||||
func (w *Watcher) handle(_ mqtt.Client, m mqtt.Message) {
|
||||
var msg message
|
||||
if err := json.Unmarshal(m.Payload(), &msg); err != nil {
|
||||
return
|
||||
}
|
||||
// The topic filter already guarantees the transmitter, but a receiver with
|
||||
// no callsign or no square cannot be drawn and is not a report of anything.
|
||||
if strings.TrimSpace(msg.RxCall) == "" || len(strings.TrimSpace(msg.RxGrid)) < 4 {
|
||||
return
|
||||
}
|
||||
r := Report{
|
||||
Call: strings.ToUpper(strings.TrimSpace(msg.RxCall)),
|
||||
Grid: strings.ToUpper(strings.TrimSpace(msg.RxGrid)),
|
||||
Band: strings.ToLower(strings.TrimSpace(msg.Band)),
|
||||
Mode: strings.ToUpper(strings.TrimSpace(msg.Mode)),
|
||||
SNR: msg.SNR,
|
||||
FreqHz: msg.Freq,
|
||||
At: time.Now(),
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.reports = append(w.reports, r)
|
||||
w.received++
|
||||
// A cap as well as the window, so a pathological feed cannot grow this
|
||||
// without bound between two reads.
|
||||
if len(w.reports) > 4000 {
|
||||
w.reports = w.reports[len(w.reports)-2000:]
|
||||
}
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
// Reports is who has heard us inside the window, freshest report per callsign.
|
||||
//
|
||||
// One entry per STATION, not per message: the same receiver uploading every
|
||||
// five minutes is one pair of ears on the map, and its latest report is the one
|
||||
// that says whether the path is still there.
|
||||
func (w *Watcher) Reports() []Report {
|
||||
cutoff := time.Now().Add(-Window)
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
// Pruned on read rather than on a timer: the only thing that cares about the
|
||||
// window is whoever is looking.
|
||||
kept := w.reports[:0]
|
||||
for _, r := range w.reports {
|
||||
if r.At.After(cutoff) {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
}
|
||||
w.reports = kept
|
||||
|
||||
byCall := map[string]int{}
|
||||
out := make([]Report, 0, len(kept))
|
||||
for _, r := range kept {
|
||||
if i, seen := byCall[r.Call]; seen {
|
||||
if r.At.After(out[i].At) {
|
||||
out[i] = r
|
||||
}
|
||||
continue
|
||||
}
|
||||
byCall[r.Call] = len(out)
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Status reports the connection, for the panel.
|
||||
func (w *Watcher) Status() Status {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
online := w.running && w.client != nil && w.client.IsConnected()
|
||||
return Status{
|
||||
Enabled: w.running,
|
||||
Online: online,
|
||||
Reports: w.received,
|
||||
Watching: w.cfg.MyCall,
|
||||
Error: w.lastErr,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package pskrme
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// One station uploading every five minutes must be ONE pair of ears on the map,
|
||||
// showing its freshest report — not four arcs to the same square, and not the
|
||||
// oldest of them deciding whether the path still looks open.
|
||||
func TestReportsKeepsTheFreshestPerStation(t *testing.T) {
|
||||
w := New(Config{MyCall: "F4BPO"})
|
||||
now := time.Now()
|
||||
w.reports = []Report{
|
||||
{Call: "OH5CX", Grid: "KP30", SNR: -18, At: now.Add(-9 * time.Minute)},
|
||||
{Call: "W1AW", Grid: "FN31", SNR: -5, At: now.Add(-2 * time.Minute)},
|
||||
{Call: "OH5CX", Grid: "KP30", SNR: -11, At: now.Add(-1 * time.Minute)},
|
||||
}
|
||||
got := w.Reports()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d stations, want 2: %+v", len(got), got)
|
||||
}
|
||||
for _, r := range got {
|
||||
if r.Call == "OH5CX" && r.SNR != -11 {
|
||||
t.Errorf("OH5CX kept the %d dB report, want the freshest (-11)", r.SNR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A report older than the window is gone, and gone from the slice too: the map
|
||||
// must not show a path that stopped existing a quarter of an hour ago, and the
|
||||
// window is what keeps this from growing all evening.
|
||||
func TestReportsDropsWhatIsPastTheWindow(t *testing.T) {
|
||||
w := New(Config{MyCall: "F4BPO"})
|
||||
now := time.Now()
|
||||
w.reports = []Report{
|
||||
{Call: "OLD", Grid: "JN36", At: now.Add(-Window - time.Minute)},
|
||||
{Call: "NEW", Grid: "JN36", At: now.Add(-time.Minute)},
|
||||
}
|
||||
got := w.Reports()
|
||||
if len(got) != 1 || got[0].Call != "NEW" {
|
||||
t.Fatalf("got %+v, want only NEW", got)
|
||||
}
|
||||
if len(w.reports) != 1 {
|
||||
t.Errorf("the stale report is still held: %d kept", len(w.reports))
|
||||
}
|
||||
}
|
||||
|
||||
// Turning the feed off clears what it collected. Left in place, switching it
|
||||
// back on would redraw a map of who heard us before it was on.
|
||||
func TestStopForgetsTheReports(t *testing.T) {
|
||||
w := New(Config{MyCall: "F4BPO"})
|
||||
w.reports = []Report{{Call: "OH5CX", Grid: "KP30", At: time.Now()}}
|
||||
w.Stop()
|
||||
if got := w.Reports(); len(got) != 0 {
|
||||
t.Errorf("got %+v after Stop, want nothing", got)
|
||||
}
|
||||
}
|
||||
|
||||
// No callsign, no subscription: the transmit level would be a wildcard, which
|
||||
// is the entire feed — the one thing this package exists not to ask for.
|
||||
func TestStartRefusesWithoutACallsign(t *testing.T) {
|
||||
if err := New(Config{}).Start(); err == nil {
|
||||
t.Fatal("started with no callsign")
|
||||
}
|
||||
}
|
||||
+60
-5
@@ -1860,9 +1860,19 @@ type GridSquare struct {
|
||||
// return false to drop a QSO. Aggregation to 4 characters happens HERE rather
|
||||
// than in SQL — the column holds 4, 6 and 8-character grids, and lower(substr)
|
||||
// in SQL would differ between SQLite and MySQL for no gain.
|
||||
func (r *Repo) GridSquares(ctx context.Context, keep func(mode string) bool) ([]GridSquare, error) {
|
||||
// GridSquareRow is one contact as the grid map decides whether to keep it.
|
||||
//
|
||||
// Submode is here beside Mode because the mode an operator filters by is not
|
||||
// always the one in MODE: ADIF puts PSK63 in SUBMODE with PSK above it, so a
|
||||
// filter that read MODE alone offered "PSK" for a log full of PSK63.
|
||||
type GridSquareRow struct {
|
||||
Mode, Submode, Band, SatName string
|
||||
}
|
||||
|
||||
func (r *Repo) GridSquares(ctx context.Context, keep func(GridSquareRow) bool) ([]GridSquare, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT COALESCE(grid,''), UPPER(COALESCE(mode,'')), LOWER(COALESCE(band,'')),
|
||||
SELECT COALESCE(grid,''), UPPER(COALESCE(mode,'')), UPPER(COALESCE(submode,'')),
|
||||
LOWER(COALESCE(band,'')), UPPER(COALESCE(sat_name,'')),
|
||||
COALESCE(lotw_rcvd,''), COALESCE(qsl_rcvd,''), COALESCE(eqsl_rcvd,'')
|
||||
FROM qso
|
||||
WHERE grid IS NOT NULL AND grid != ''
|
||||
@@ -1873,11 +1883,11 @@ func (r *Repo) GridSquares(ctx context.Context, keep func(mode string) bool) ([]
|
||||
defer rows.Close()
|
||||
out := map[string]*GridSquare{}
|
||||
for rows.Next() {
|
||||
var grid, mode, band, lotw, card, eqsl string
|
||||
if err := rows.Scan(&grid, &mode, &band, &lotw, &card, &eqsl); err != nil {
|
||||
var grid, mode, submode, band, satName, lotw, card, eqsl string
|
||||
if err := rows.Scan(&grid, &mode, &submode, &band, &satName, &lotw, &card, &eqsl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if keep != nil && !keep(mode) {
|
||||
if keep != nil && !keep(GridSquareRow{Mode: mode, Submode: submode, Band: band, SatName: satName}) {
|
||||
continue
|
||||
}
|
||||
g := strings.ToUpper(strings.TrimSpace(grid))
|
||||
@@ -1914,6 +1924,51 @@ func (r *Repo) GridSquares(ctx context.Context, keep func(mode string) bool) ([]
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// GridSquareChoices is every mode, band and satellite that the squares on the
|
||||
// map were actually worked on.
|
||||
//
|
||||
// Taken from the log rather than from a list in the code, so a filter can only
|
||||
// ever offer something there is something to see behind — and so a mode that
|
||||
// does not exist yet needs no change here the day an operator starts using it.
|
||||
// The mode is the SUBMODE when there is one: PSK63 is the answer, not PSK.
|
||||
func (r *Repo) GridSquareChoices(ctx context.Context) (modes, bands, sats []string, err error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT DISTINCT UPPER(COALESCE(mode,'')), UPPER(COALESCE(submode,'')),
|
||||
LOWER(COALESCE(band,'')), UPPER(COALESCE(sat_name,''))
|
||||
FROM qso
|
||||
WHERE grid IS NOT NULL AND grid != ''`)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("query grid choices: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
seenM, seenB, seenS := map[string]bool{}, map[string]bool{}, map[string]bool{}
|
||||
for rows.Next() {
|
||||
var mode, submode, band, sat string
|
||||
if err := rows.Scan(&mode, &submode, &band, &sat); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if m := strings.TrimSpace(submode); m != "" {
|
||||
mode = m
|
||||
}
|
||||
if mode = strings.TrimSpace(mode); mode != "" && !seenM[mode] {
|
||||
seenM[mode] = true
|
||||
modes = append(modes, mode)
|
||||
}
|
||||
if band = strings.TrimSpace(band); band != "" && !seenB[band] {
|
||||
seenB[band] = true
|
||||
bands = append(bands, band)
|
||||
}
|
||||
if sat = strings.TrimSpace(sat); sat != "" && !seenS[sat] {
|
||||
seenS[sat] = true
|
||||
sats = append(sats, sat)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return modes, bands, sats, nil
|
||||
}
|
||||
|
||||
// BandSlotQSOs returns every contact on one band that belongs to a slot of the
|
||||
// entry matrix: the exact callsign, or any callsign in the same DXCC entity.
|
||||
// Mode is NOT filtered here — the class (phone / CW / digital) is a derived
|
||||
|
||||
@@ -33,10 +33,16 @@ const (
|
||||
|
||||
// Status is one rotator's live state parsed from a |h reply.
|
||||
type Status struct {
|
||||
Azimuth int // current heading in degrees (0..360)
|
||||
Azimuth int // current heading in degrees (0..450 on an overlap rotator)
|
||||
Connected bool // false when the sensor reports 999 (not connected)
|
||||
Moving int // 0 not moving, 1 CW, 2 CCW
|
||||
Target int // target azimuth when moving (else -1)
|
||||
// The soft limits the Genius itself is configured with, as it reports them.
|
||||
// Read rather than assumed: an operator with a 450° mast has told the
|
||||
// Genius so, and that is the authority on how far it will go — OpsLog
|
||||
// asking for 400° on a box configured for 360 is a command it will refuse.
|
||||
LimitCW int
|
||||
LimitCCW int
|
||||
}
|
||||
|
||||
// Client is a stateless connector: each call opens a short-lived TCP connection,
|
||||
@@ -128,15 +134,30 @@ func (c *Client) Read(rotator int) (Status, error) {
|
||||
cur := atoiField(string(p[base : base+3]))
|
||||
moving := atoiField(string(p[base+10 : base+11]))
|
||||
target := atoiField(string(p[base+15 : base+18]))
|
||||
st := Status{Azimuth: cur, Moving: moving, Connected: cur != 999, Target: -1}
|
||||
st := Status{
|
||||
Azimuth: cur, Moving: moving, Connected: cur != 999, Target: -1,
|
||||
LimitCW: atoiField(string(p[base+3 : base+6])),
|
||||
LimitCCW: atoiField(string(p[base+6 : base+9])),
|
||||
}
|
||||
if target != 999 {
|
||||
st.Target = target
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// GoTo moves the rotator to az (0..360). The reply's status byte is 'K' on
|
||||
// accept, 'F' on reject.
|
||||
// GoTo moves the rotator to az. The reply's status byte is 'K' on accept, 'F'
|
||||
// on reject.
|
||||
//
|
||||
// The ceiling is 450 and not 360, which is the whole point: a rotator with an
|
||||
// overlap can be asked for 010° as either 10 or 370, and only the second reaches
|
||||
// it without unwinding the cable back through north. The command carries three
|
||||
// digits, so the range was never the protocol's — it was ours, and it left an
|
||||
// operator with a 450° mast clicking "clockwise" by hand every time a bearing
|
||||
// crossed north.
|
||||
//
|
||||
// A Genius configured for a 360° rotator refuses a target beyond its own limit,
|
||||
// which is the correct place for that decision: it knows what is bolted to the
|
||||
// tower, and OpsLog does not.
|
||||
func (c *Client) GoTo(rotator, az int) error {
|
||||
if rotator != 1 && rotator != 2 {
|
||||
rotator = 1
|
||||
@@ -144,8 +165,8 @@ func (c *Client) GoTo(rotator, az int) error {
|
||||
if az < 0 {
|
||||
az = 0
|
||||
}
|
||||
if az > 360 {
|
||||
az = 360
|
||||
if az > 450 {
|
||||
az = 450
|
||||
}
|
||||
reply, err := c.exchange(fmt.Sprintf("|A%d%03d", rotator, az), 8)
|
||||
if err != nil {
|
||||
|
||||
+190
-46
@@ -1,12 +1,15 @@
|
||||
package sat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
@@ -195,22 +198,44 @@ func LoadBirds(dir string) (*Birds, error) {
|
||||
_ = b.parse(shippedBirds)
|
||||
return b, fmt.Errorf("sat: %s could not be read (%w) — the shipped list is in use for this session, and your file has been left alone", BirdsName, perr)
|
||||
}
|
||||
// New satellites reach an EXISTING station too.
|
||||
// New satellites AND corrections reach an existing station.
|
||||
//
|
||||
// The operator's copy is written once, on the first run, and was then
|
||||
// theirs for ever — which meant a release that added nine Tevel-2
|
||||
// satellites reached nobody who had already opened the tab. Merging on
|
||||
// each load fixes that without taking anything back: a satellite the
|
||||
// operator already has is left exactly as it is, edits included, and
|
||||
// only the ones they have never seen are added. Deleting a bird from the
|
||||
// file therefore brings it back, which is the price of the trade — and
|
||||
// the cheaper half of it, since an unwanted satellite is one row and a
|
||||
// missing one is a pass nobody can work.
|
||||
if n := b.addMissing(shippedBirds); n > 0 {
|
||||
// The operator's copy is written on the first run and was then theirs
|
||||
// for ever, which broke both ways: a release that added nine Tevel-2
|
||||
// satellites reached nobody who had opened the tab, and a frequency we
|
||||
// had shipped WRONG could never be mended — LilacSat-2 went out with an
|
||||
// APRS digipeater and no FM transponder, and the wrong data had become
|
||||
// the operator's own file.
|
||||
//
|
||||
// So the merge adds what is missing, replaces what they never touched,
|
||||
// and leaves alone what they edited. See mergeShipped for how the three
|
||||
// are told apart.
|
||||
added, updated, kept, replaced := b.mergeShipped(dir)
|
||||
if added > 0 || updated > 0 {
|
||||
// A one-time copy before the first run that can overwrite an entry
|
||||
// we have no baseline for. Cheap insurance on a file an operator may
|
||||
// have spent an evening correcting.
|
||||
if len(replaced) > 0 {
|
||||
if err := os.WriteFile(path+".bak", data, 0o644); err == nil {
|
||||
log.Printf("sat: %s copied to %s.bak before the plan was brought up to date", BirdsName, BirdsName)
|
||||
}
|
||||
}
|
||||
if out, merr := json.MarshalIndent(b.list, "", " "); merr == nil {
|
||||
_ = os.WriteFile(path, append(out, '\n'), 0o644)
|
||||
}
|
||||
log.Printf("sat: frequency plan — %d satellites added, %d brought up to date", added, updated)
|
||||
}
|
||||
if len(replaced) > 0 {
|
||||
log.Printf("sat: %s taken from the shipped plan (no record of what this station was given). "+
|
||||
"If one of those was your own correction, it is in %s.bak", strings.Join(replaced, ", "), BirdsName)
|
||||
}
|
||||
if len(kept) > 0 {
|
||||
// Named, not silent: an operator who corrected a frequency should be
|
||||
// able to see that OpsLog noticed and stood down.
|
||||
log.Printf("sat: your own edits kept for %s — delete them from %s to take the shipped plan instead",
|
||||
strings.Join(kept, ", "), BirdsName)
|
||||
}
|
||||
writeBaseline(dir)
|
||||
return b, nil
|
||||
case os.IsNotExist(err):
|
||||
if perr := b.parse(shippedBirds); perr != nil {
|
||||
@@ -218,6 +243,7 @@ func LoadBirds(dir string) (*Birds, error) {
|
||||
}
|
||||
if werr := os.MkdirAll(dir, 0o755); werr == nil {
|
||||
_ = os.WriteFile(path, shippedBirds, 0o644)
|
||||
writeBaseline(dir)
|
||||
}
|
||||
return b, nil
|
||||
default:
|
||||
@@ -305,51 +331,169 @@ func (b *Birds) Len() int {
|
||||
return len(b.list)
|
||||
}
|
||||
|
||||
// addMissing appends the satellites in `shipped` that this list does not already
|
||||
// hold, and reports how many were added.
|
||||
// BaselineName records the shipped plan as it was last handed to this station.
|
||||
//
|
||||
// "Already hold" is by catalog number first and by the loose name second, so an
|
||||
// operator who renamed a bird, or who has it under the feed's spelling, does not
|
||||
// get a second copy of it. Nothing existing is touched: their frequencies, their
|
||||
// labels and their corrections all stand.
|
||||
func (b *Birds) addMissing(shipped []byte) int {
|
||||
// It exists so a CORRECTION can reach an operator who already has the file.
|
||||
// Without it the merge could only add satellites, never mend one: LilacSat-2
|
||||
// shipped with an APRS digipeater and no FM transponder, and every station that
|
||||
// had already opened the satellite tab was stuck with it for ever — the wrong
|
||||
// frequency was the operator's file now, and their file was sacred.
|
||||
const BaselineName = "satellites.shipped.json"
|
||||
|
||||
// mergeShipped brings the shipped plan into the operator's list.
|
||||
//
|
||||
// Three cases, and the middle one is the point:
|
||||
//
|
||||
// - A satellite they do not have is ADDED. That is how new birds arrive.
|
||||
// - A satellite they have, UNCHANGED from the plan they were given, is
|
||||
// REPLACED by the current one. They never edited it, so it is not theirs to
|
||||
// keep — it is our data, and ours was wrong.
|
||||
// - A satellite they have EDITED is left exactly as it is, and said so in the
|
||||
// log. A frequency somebody corrected by hand outranks anything shipped:
|
||||
// they were on the air and we were not.
|
||||
//
|
||||
// "Unchanged" is decided against the baseline, so the comparison is with the
|
||||
// plan THEY were given rather than with whatever ships today. Their edits
|
||||
// therefore survive every future release, not just the next one.
|
||||
func (b *Birds) mergeShipped(dir string) (added, updated int, kept, replaced []string) {
|
||||
var list []Bird
|
||||
if err := json.Unmarshal(shipped, &list); err != nil {
|
||||
return 0
|
||||
if err := json.Unmarshal(shippedBirds, &list); err != nil {
|
||||
return 0, 0, nil, nil
|
||||
}
|
||||
baseline := readBaseline(dir)
|
||||
|
||||
b.mu.Lock()
|
||||
have := make(map[int]bool, len(b.list))
|
||||
for _, x := range b.list {
|
||||
byNORAD := map[int]int{} // catalog number → index in b.list
|
||||
byName := map[string]int{}
|
||||
for i, x := range b.list {
|
||||
if x.NORAD != 0 {
|
||||
have[x.NORAD] = true
|
||||
byNORAD[x.NORAD] = i
|
||||
}
|
||||
for _, n := range append([]string{x.Name}, x.Aliases...) {
|
||||
if k := loose(n); k != "" {
|
||||
if _, seen := byName[k]; !seen {
|
||||
byName[k] = i
|
||||
}
|
||||
}
|
||||
added := 0
|
||||
}
|
||||
}
|
||||
find := func(c Bird) int {
|
||||
if c.NORAD != 0 {
|
||||
if i, ok := byNORAD[c.NORAD]; ok {
|
||||
return i
|
||||
}
|
||||
}
|
||||
for _, n := range append([]string{c.Name}, c.Aliases...) {
|
||||
if i, ok := byName[loose(n)]; ok {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
for _, cand := range list {
|
||||
if cand.NORAD != 0 && have[cand.NORAD] {
|
||||
continue
|
||||
}
|
||||
known := false
|
||||
for _, name := range append([]string{cand.Name}, cand.Aliases...) {
|
||||
if k := loose(name); k != "" {
|
||||
if _, ok := b.byKey[k]; ok {
|
||||
known = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if known {
|
||||
continue
|
||||
}
|
||||
i := find(cand)
|
||||
if i < 0 {
|
||||
b.list = append(b.list, cand)
|
||||
if cand.NORAD != 0 {
|
||||
have[cand.NORAD] = true
|
||||
}
|
||||
if k := loose(cand.Name); k != "" {
|
||||
b.byKey[k] = len(b.list) - 1
|
||||
}
|
||||
added++
|
||||
continue
|
||||
}
|
||||
if sameBird(b.list[i], cand) {
|
||||
continue // already current
|
||||
}
|
||||
was, hadBaseline := baseline[birdKey(cand)]
|
||||
switch {
|
||||
case !hadBaseline:
|
||||
// FIRST run after baselines existed, and there is no record of what
|
||||
// this station was given — so an edit of theirs and a mistake of
|
||||
// ours are indistinguishable here.
|
||||
//
|
||||
// The shipped plan wins, ONCE, and the whole file is backed up
|
||||
// first. Standing down instead would have been the safe-looking
|
||||
// choice and the wrong one: the baseline written at the end of this
|
||||
// run would then record their entry as "edited" and freeze a
|
||||
// frequency we know to be wrong for the life of the install. A
|
||||
// backup and a log line are recoverable; that is not.
|
||||
replaced = append(replaced, b.list[i].Name)
|
||||
b.list[i] = cand
|
||||
updated++
|
||||
case sameBird(b.list[i], was):
|
||||
b.list[i] = cand
|
||||
updated++
|
||||
default:
|
||||
kept = append(kept, b.list[i].Name)
|
||||
}
|
||||
}
|
||||
b.reindexLocked()
|
||||
b.mu.Unlock()
|
||||
return added
|
||||
return added, updated, kept, replaced
|
||||
}
|
||||
|
||||
// birdKey identifies a satellite across versions: the catalog number when there
|
||||
// is one, the loose name otherwise.
|
||||
func birdKey(x Bird) string {
|
||||
if x.NORAD != 0 {
|
||||
return "n:" + strconv.Itoa(x.NORAD)
|
||||
}
|
||||
return "s:" + loose(x.Name)
|
||||
}
|
||||
|
||||
// sameBird compares two plans for one satellite by VALUE — the frequencies, the
|
||||
// modes, the tone, the labels. Field by field through JSON rather than one
|
||||
// comparison per field, so a transponder field added later cannot silently drop
|
||||
// out of the test and start reporting equal plans as different.
|
||||
func sameBird(a, c Bird) bool {
|
||||
ja, ea := json.Marshal(a)
|
||||
jc, ec := json.Marshal(c)
|
||||
if ea != nil || ec != nil {
|
||||
return false
|
||||
}
|
||||
return bytes.Equal(ja, jc)
|
||||
}
|
||||
|
||||
// readBaseline loads the shipped plan this station was last given.
|
||||
func readBaseline(dir string) map[string]Bird {
|
||||
out := map[string]Bird{}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, BaselineName))
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
var list []Bird
|
||||
if json.Unmarshal(raw, &list) != nil {
|
||||
return out
|
||||
}
|
||||
for _, x := range list {
|
||||
out[birdKey(x)] = x
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeBaseline records what was shipped, so the NEXT release can tell an
|
||||
// operator's correction from one of ours.
|
||||
func writeBaseline(dir string) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(dir, BaselineName), shippedBirds, 0o644)
|
||||
}
|
||||
|
||||
// reindexLocked rebuilds the name lookup after the list has changed.
|
||||
func (b *Birds) reindexLocked() {
|
||||
byKey := make(map[string]int, len(b.list)*3)
|
||||
put := func(name string, i int) {
|
||||
if k := loose(name); k != "" {
|
||||
if _, seen := byKey[k]; !seen {
|
||||
byKey[k] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, bird := range b.list {
|
||||
put(bird.Name, i)
|
||||
}
|
||||
for i, bird := range b.list {
|
||||
for _, a := range bird.Aliases {
|
||||
put(a, i)
|
||||
}
|
||||
}
|
||||
b.byKey = byKey
|
||||
}
|
||||
|
||||
+176
-41
@@ -1,34 +1,4 @@
|
||||
[
|
||||
{
|
||||
"name": "AO-123",
|
||||
"norad": 61781,
|
||||
"aliases": [
|
||||
"ASRTU-1 (AO-123)"
|
||||
],
|
||||
"transponders": [
|
||||
{
|
||||
"label": "Mode V/U - FM Transceiver",
|
||||
"mode": "FM",
|
||||
"down_lo": 435400000,
|
||||
"up_lo": 145850000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "AO-27",
|
||||
"norad": 22825,
|
||||
"aliases": [
|
||||
"EYESAT A (AO-27)"
|
||||
],
|
||||
"transponders": [
|
||||
{
|
||||
"label": "Mode V/U FM",
|
||||
"mode": "FM",
|
||||
"down_lo": 436795000,
|
||||
"up_lo": 145850000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "AO-7",
|
||||
"norad": 7530,
|
||||
@@ -56,6 +26,21 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "AO-27",
|
||||
"norad": 22825,
|
||||
"aliases": [
|
||||
"EYESAT A (AO-27)"
|
||||
],
|
||||
"transponders": [
|
||||
{
|
||||
"label": "Mode V/U FM",
|
||||
"mode": "FM",
|
||||
"down_lo": 436795000,
|
||||
"up_lo": 145850000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "AO-73",
|
||||
"norad": 39444,
|
||||
@@ -93,6 +78,22 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "AO-123",
|
||||
"norad": 61781,
|
||||
"aliases": [
|
||||
"ASRTU-1 (AO-123)"
|
||||
],
|
||||
"transponders": [
|
||||
{
|
||||
"label": "Mode V/U - FM Transceiver",
|
||||
"mode": "FM",
|
||||
"down_lo": 435400000,
|
||||
"up_lo": 145850000,
|
||||
"ctcss": 67
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "BEESAT-1",
|
||||
"norad": 35933,
|
||||
@@ -260,18 +261,16 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "IO-117",
|
||||
"norad": 53109,
|
||||
"name": "HC1PX",
|
||||
"aliases": [
|
||||
"GREENCUBE",
|
||||
"MEZTLI"
|
||||
"GALAPAGOS-UTE-SWSU",
|
||||
"GALAPAGOS"
|
||||
],
|
||||
"transponders": [
|
||||
{
|
||||
"label": "Digipeater (1200 bd GMSK)",
|
||||
"mode": "DATA",
|
||||
"down_lo": 435310000,
|
||||
"up_lo": 435310000
|
||||
"label": "SSTV (Robot 36)",
|
||||
"mode": "FM",
|
||||
"down_lo": 437050000
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -292,6 +291,22 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "IO-117",
|
||||
"norad": 53109,
|
||||
"aliases": [
|
||||
"GREENCUBE",
|
||||
"MEZTLI"
|
||||
],
|
||||
"transponders": [
|
||||
{
|
||||
"label": "Digipeater (1200 bd GMSK)",
|
||||
"mode": "DATA",
|
||||
"down_lo": 435310000,
|
||||
"up_lo": 435310000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ISAT",
|
||||
"norad": 43879,
|
||||
@@ -327,7 +342,7 @@
|
||||
"up_lo": 145825000
|
||||
},
|
||||
{
|
||||
"label": "SSTV",
|
||||
"label": "SSTV (PD120)",
|
||||
"mode": "FM",
|
||||
"down_lo": 145800000
|
||||
}
|
||||
@@ -390,15 +405,25 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "LILACSAT-2",
|
||||
"name": "LO-90",
|
||||
"norad": 40908,
|
||||
"transponders": [
|
||||
{
|
||||
"label": "FM voice transponder (scheduled)",
|
||||
"mode": "FM",
|
||||
"down_lo": 437200000,
|
||||
"up_lo": 144350000
|
||||
},
|
||||
{
|
||||
"label": "APRS Digipeater",
|
||||
"mode": "DATA",
|
||||
"down_lo": 144390000,
|
||||
"up_lo": 144390000
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"LILACSAT-2",
|
||||
"CAS-3H"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -458,6 +483,11 @@
|
||||
"down_lo": 436950000,
|
||||
"up_lo": 145920000,
|
||||
"ctcss": 67
|
||||
},
|
||||
{
|
||||
"label": "SSTV (Robot 36)",
|
||||
"mode": "FM",
|
||||
"down_lo": 436950000
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -507,6 +537,106 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "RS18S",
|
||||
"aliases": [
|
||||
"SAKHACUBE-CHOLBON",
|
||||
"SAKHACUBE"
|
||||
],
|
||||
"transponders": [
|
||||
{
|
||||
"label": "SSTV (Robot 36)",
|
||||
"mode": "FM",
|
||||
"down_lo": 437350000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "RS27S",
|
||||
"norad": 57203,
|
||||
"aliases": [
|
||||
"UTMN-2",
|
||||
"UTMN 2"
|
||||
],
|
||||
"transponders": [
|
||||
{
|
||||
"label": "SSTV (Robot 36)",
|
||||
"mode": "FM",
|
||||
"down_lo": 436125000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "RS38S",
|
||||
"norad": 57189,
|
||||
"aliases": [
|
||||
"VIZARD-METEO",
|
||||
"VIZARD-meteo"
|
||||
],
|
||||
"transponders": [
|
||||
{
|
||||
"label": "SSTV (Robot 36)",
|
||||
"mode": "FM",
|
||||
"down_lo": 437825000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "RS40S",
|
||||
"norad": 57172,
|
||||
"aliases": [
|
||||
"UMKA-1",
|
||||
"UMKA 1"
|
||||
],
|
||||
"transponders": [
|
||||
{
|
||||
"label": "SSTV (Robot 36)",
|
||||
"mode": "FM",
|
||||
"down_lo": 437625000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "RS57S",
|
||||
"norad": 57182,
|
||||
"aliases": [
|
||||
"MONITOR-4"
|
||||
],
|
||||
"transponders": [
|
||||
{
|
||||
"label": "SSTV (Robot 36)",
|
||||
"mode": "FM",
|
||||
"down_lo": 436080000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "RS58S",
|
||||
"norad": 57180,
|
||||
"aliases": [
|
||||
"MONITOR-3"
|
||||
],
|
||||
"transponders": [
|
||||
{
|
||||
"label": "SSTV (Robot 36)",
|
||||
"mode": "FM",
|
||||
"down_lo": 435290000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "RS83S",
|
||||
"aliases": [
|
||||
"LOBACHEVSKY"
|
||||
],
|
||||
"transponders": [
|
||||
{
|
||||
"label": "SSTV / SSDV",
|
||||
"mode": "FM",
|
||||
"down_lo": 436320000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "SO-50",
|
||||
"norad": 27607,
|
||||
@@ -516,7 +646,7 @@
|
||||
],
|
||||
"transponders": [
|
||||
{
|
||||
"label": "FM voice repeater",
|
||||
"label": "FM voice repeater (arm with 74.4 Hz)",
|
||||
"mode": "FM",
|
||||
"down_lo": 436795000,
|
||||
"up_lo": 145850000,
|
||||
@@ -533,6 +663,11 @@
|
||||
"mode": "DATA",
|
||||
"down_lo": 145825000,
|
||||
"up_lo": 145825000
|
||||
},
|
||||
{
|
||||
"label": "SSTV (Martin M1)",
|
||||
"mode": "FM",
|
||||
"down_lo": 145880000
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -268,3 +268,146 @@ func TestGeneratedBirdsAreSane(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The three cases the merge has to tell apart. Getting the middle one wrong is
|
||||
// how LilacSat-2 shipped with no FM transponder and could never be mended.
|
||||
func TestMergeShippedRespectsEditsAndFixesOurs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// First run: the shipped plan and its baseline are written.
|
||||
if _, err := LoadBirds(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, BaselineName)); err != nil {
|
||||
t.Fatalf("no baseline was recorded: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, BirdsName)
|
||||
|
||||
// The operator corrects SO-50's tone and adds a satellite of their own.
|
||||
var list []Bird
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &list); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var untouched Bird
|
||||
for i := range list {
|
||||
if list[i].Name == "SO-50" {
|
||||
list[i].Transponders[0].CTCSS = 74.4
|
||||
}
|
||||
if list[i].Name == "AO-7" {
|
||||
untouched = list[i] // left exactly as shipped
|
||||
}
|
||||
}
|
||||
list = append(list, Bird{Name: "MY-SAT", Transponders: []Transponder{{Label: "mine", Mode: "FM", DownLo: 145000000, UpLo: 435000000}}})
|
||||
out, _ := json.MarshalIndent(list, "", " ")
|
||||
if err := os.WriteFile(path, out, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Load again. Nothing shipped has changed, so nothing should move.
|
||||
b, err := LoadBirds(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := b.Find("SO-50")
|
||||
if !ok || got.Transponders[0].CTCSS != 74.4 {
|
||||
t.Errorf("the operator's tone was lost: %+v", got.Transponders)
|
||||
}
|
||||
if _, ok := b.Find("MY-SAT"); !ok {
|
||||
t.Error("the operator's own satellite was dropped")
|
||||
}
|
||||
if a, ok := b.Find("AO-7"); !ok || !sameBird(a, untouched) {
|
||||
t.Error("an untouched satellite was altered for no reason")
|
||||
}
|
||||
}
|
||||
|
||||
// An entry the operator never touched is REPLACED when the shipped plan
|
||||
// changes. That is the whole point: our data, and ours was wrong.
|
||||
func TestMergeShippedUpdatesWhatWasNeverEdited(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := LoadBirds(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, BirdsName)
|
||||
|
||||
// Pretend an older release shipped SO-50 with a wrong downlink, and that the
|
||||
// operator simply took it: their file AND the baseline both hold the wrong
|
||||
// value, which is exactly what "never edited" looks like.
|
||||
rewrite := func(p string, mutate func(*Bird)) {
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var l []Bird
|
||||
if err := json.Unmarshal(raw, &l); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range l {
|
||||
if l[i].Name == "SO-50" {
|
||||
mutate(&l[i])
|
||||
}
|
||||
}
|
||||
out, _ := json.MarshalIndent(l, "", " ")
|
||||
if err := os.WriteFile(p, out, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
wrong := func(x *Bird) { x.Transponders[0].DownLo = 1 }
|
||||
rewrite(path, wrong)
|
||||
rewrite(filepath.Join(dir, BaselineName), wrong)
|
||||
|
||||
b, err := LoadBirds(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := b.Find("SO-50")
|
||||
if !ok {
|
||||
t.Fatal("SO-50 vanished")
|
||||
}
|
||||
if got.Transponders[0].DownLo == 1 {
|
||||
t.Error("a value the operator never edited was not brought up to date — a shipped mistake is unfixable")
|
||||
}
|
||||
}
|
||||
|
||||
// With NO baseline — the first run after baselines existed — the shipped plan
|
||||
// wins and the file is backed up. Standing down would freeze a known-wrong
|
||||
// frequency for the life of the install.
|
||||
func TestMergeShippedWithNoBaselineTakesShippedAndBacksUp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := LoadBirds(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, BirdsName)
|
||||
if err := os.Remove(filepath.Join(dir, BaselineName)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A wrong value, with nothing to say whether it is ours or theirs.
|
||||
raw, _ := os.ReadFile(path)
|
||||
var l []Bird
|
||||
if err := json.Unmarshal(raw, &l); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range l {
|
||||
if l[i].Name == "SO-50" {
|
||||
l[i].Transponders[0].DownLo = 1
|
||||
}
|
||||
}
|
||||
out, _ := json.MarshalIndent(l, "", " ")
|
||||
if err := os.WriteFile(path, out, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b, err := LoadBirds(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := b.Find("SO-50")
|
||||
if got.Transponders[0].DownLo == 1 {
|
||||
t.Error("the shipped plan did not take over, so the wrong value is now frozen for ever")
|
||||
}
|
||||
if _, err := os.Stat(path + ".bak"); err != nil {
|
||||
t.Errorf("no backup was written before overwriting: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+37
-4
@@ -271,9 +271,6 @@ func (e Element) Track(obs Observer, at time.Time) (Position, error) {
|
||||
if err != nil {
|
||||
return Position{}, fmt.Errorf("sat: %q: %w", e.Name, err)
|
||||
}
|
||||
// The state vector carries the position AND the velocity, which is what the
|
||||
// look angle needs for the range rate — and the range rate is the whole of
|
||||
// the Doppler shift.
|
||||
sv := &sgp4.StateVector{
|
||||
X: eci.Position.X, Y: eci.Position.Y, Z: eci.Position.Z,
|
||||
VX: eci.Velocity.X, VY: eci.Velocity.Y, VZ: eci.Velocity.Z,
|
||||
@@ -292,10 +289,46 @@ func (e Element) Track(obs Observer, at time.Time) (Position, error) {
|
||||
Az: o.LookAngles.Azimuth,
|
||||
El: o.LookAngles.Elevation,
|
||||
RangeKm: o.LookAngles.Range,
|
||||
RangeRate: o.LookAngles.RangeRate,
|
||||
RangeRate: e.rangeRate(loc, at.UTC()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// rangeRate is how fast the satellite is closing or opening, in km/s.
|
||||
//
|
||||
// MEASURED, not taken from the propagator. The library reports a range rate
|
||||
// that is wrong by a factor of some 250 AND has the wrong sign — the ISS at
|
||||
// −5.5 km/s (closing) came back as +2036 km/s — which put the Doppler
|
||||
// correction hundreds of kilohertz out and moved it the wrong way. The
|
||||
// difference between two ranges a second apart cannot be wrong in either
|
||||
// respect: it differentiates the very number the panel displays.
|
||||
//
|
||||
// Two extra propagations per call. SGP4 costs microseconds and this runs at
|
||||
// most a few hundred times a second across every satellite on screen, so the
|
||||
// price of being right here is not worth optimising away.
|
||||
func (e Element) rangeRate(loc *sgp4.Location, at time.Time) float64 {
|
||||
const dt = time.Second // ±1 s: far below any curvature in the range, far above float noise
|
||||
before, ok1 := e.rangeAt(loc, at.Add(-dt))
|
||||
after, ok2 := e.rangeAt(loc, at.Add(dt))
|
||||
if !ok1 || !ok2 {
|
||||
return 0
|
||||
}
|
||||
return (after - before) / (2 * dt.Seconds())
|
||||
}
|
||||
|
||||
// rangeAt is the distance to the satellite at one instant, in km.
|
||||
func (e Element) rangeAt(loc *sgp4.Location, at time.Time) (float64, bool) {
|
||||
eci, err := e.tle.FindPositionAtTime(at.UTC())
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
sv := &sgp4.StateVector{X: eci.Position.X, Y: eci.Position.Y, Z: eci.Position.Z}
|
||||
o, err := sv.GetLookAngle(loc, at.UTC())
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return o.LookAngles.Range, true
|
||||
}
|
||||
|
||||
// earthRadiusKm is the mean radius — the footprint is a circle drawn on a
|
||||
// sphere, and a metre of flattening does not show at that scale.
|
||||
const earthRadiusKm = 6371.0
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/akhenakh/sgp4"
|
||||
)
|
||||
|
||||
// A real ISS element set, and the answers a second tracker agrees with. The
|
||||
@@ -16,6 +18,9 @@ const (
|
||||
issLine2 = "2 25544 51.6392 121.4587 0007976 86.1587 27.9639 15.50126585478227"
|
||||
)
|
||||
|
||||
// testLoc is the same observer, in the form the internal range helper takes.
|
||||
var testLoc = sgp4.Location{Latitude: 48.5, Longitude: 3.0}
|
||||
|
||||
func issElement(t *testing.T) Element {
|
||||
t.Helper()
|
||||
e, err := ParseElement(issName, issLine1, issLine2)
|
||||
@@ -170,3 +175,71 @@ func TestStoreReplaceKeepsOrderAndStampsTheFetch(t *testing.T) {
|
||||
t.Error("an unknown satellite was tracked anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// The range rate is the whole of the Doppler shift, and it was wrong in both
|
||||
// magnitude and sign — the propagator library reported +2036 km/s for an ISS
|
||||
// that was closing at 5.5, which moved the correction hundreds of kilohertz the
|
||||
// wrong way. These are the two things about it that cannot be argued with.
|
||||
func TestRangeRateIsPhysical(t *testing.T) {
|
||||
e := issElement(t)
|
||||
obs := Observer{Lat: 48.5, Lon: 3.0}
|
||||
// A day's worth, sampled across every geometry a pass goes through.
|
||||
base := e.Epoch.Add(2 * time.Hour)
|
||||
for i := 0; i < 240; i++ {
|
||||
at := base.Add(time.Duration(i) * 6 * time.Minute)
|
||||
p, err := e.Track(obs, at)
|
||||
if err != nil {
|
||||
t.Fatalf("track: %v", err)
|
||||
}
|
||||
// Nothing in low earth orbit closes faster than it flies, and it flies
|
||||
// at about 7.7 km/s. A figure outside this is a units mistake.
|
||||
if math.Abs(p.RangeRate) > 8 {
|
||||
t.Fatalf("%s: range rate %.1f km/s — faster than orbital velocity", at.Format(time.RFC3339), p.RangeRate)
|
||||
}
|
||||
// And it must be the derivative of the range we display, sign included.
|
||||
before, _ := e.rangeAt(&testLoc, at.Add(-2*time.Second))
|
||||
after, _ := e.rangeAt(&testLoc, at.Add(2*time.Second))
|
||||
want := (after - before) / 4
|
||||
if math.Abs(p.RangeRate-want) > 0.05 {
|
||||
t.Errorf("%s: range rate %.3f but the range moves at %.3f km/s",
|
||||
at.Format(time.RFC3339), p.RangeRate, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Doppler that comes out of it, on the two bands satellites are worked on.
|
||||
// A LEO gives about ±3.5 kHz on 2 m and ±10 kHz on 70 cm; ten times either is
|
||||
// the bug this pins.
|
||||
func TestDopplerStaysWithinTheTextbookRange(t *testing.T) {
|
||||
e := issElement(t)
|
||||
obs := Observer{Lat: 48.5, Lon: 3.0}
|
||||
base := e.Epoch.Add(2 * time.Hour)
|
||||
var maxVHF, maxUHF int64
|
||||
for i := 0; i < 480; i++ {
|
||||
p, err := e.Track(obs, base.Add(time.Duration(i)*3*time.Minute))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
vhf := Doppler(p, 145_800_000, 0).DownHz - 145_800_000
|
||||
uhf := Doppler(p, 437_800_000, 0).DownHz - 437_800_000
|
||||
if a := abs64(vhf); a > maxVHF {
|
||||
maxVHF = a
|
||||
}
|
||||
if a := abs64(uhf); a > maxUHF {
|
||||
maxUHF = a
|
||||
}
|
||||
}
|
||||
if maxVHF < 1_500 || maxVHF > 5_000 {
|
||||
t.Errorf("2 m Doppler peaks at %d Hz, expected roughly 3.5 kHz", maxVHF)
|
||||
}
|
||||
if maxUHF < 5_000 || maxUHF > 14_000 {
|
||||
t.Errorf("70 cm Doppler peaks at %d Hz, expected roughly 10 kHz", maxUHF)
|
||||
}
|
||||
}
|
||||
|
||||
func abs64(v int64) int64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -741,3 +741,28 @@ func portBusyHint(mode, com string, err error) string {
|
||||
}
|
||||
return " — another program already has " + com + " open (the SteppIR control window, PstRotator, a terminal). A COM port has one owner: close the other program, then OpsLog can connect."
|
||||
}
|
||||
|
||||
// Calibrate runs the controller's calibration: the elements are driven to their
|
||||
// end stops so the controller re-learns where zero is.
|
||||
//
|
||||
// It is the cure for an antenna that tunes to the wrong length after a power cut
|
||||
// mid-move, after the elements have been retracted by hand, or after a motor has
|
||||
// slipped — the controller counts steps from a remembered position, and once
|
||||
// that memory is wrong every frequency after it is wrong by the same amount.
|
||||
//
|
||||
// It takes MINUTES and moves every element the whole way, so it is not something
|
||||
// to do during a contest. Like Retract it drops the controller out of AUTOTRACK,
|
||||
// which is handled transparently: the next SetFrequency re-issues AUTOTRACK ON.
|
||||
func (c *Client) Calibrate() error {
|
||||
// A valid frequency accompanies every SET frame; the controller ignores it
|
||||
// for this command, but a malformed frame is refused outright.
|
||||
khz := c.LastSetKHz()
|
||||
if khz <= 0 {
|
||||
if st, _ := c.GetStatus(); st != nil && st.Frequency > 0 {
|
||||
khz = st.Frequency
|
||||
} else {
|
||||
khz = 14000
|
||||
}
|
||||
}
|
||||
return c.writeCmd(buildSet(khz*1000, DirNormal, 'V'))
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@ import (
|
||||
const (
|
||||
cmdNull = 0x13
|
||||
cmdAdmin = 0x00
|
||||
adminReset = 0x01
|
||||
adminOpen = 0x02
|
||||
adminClose = 0x03
|
||||
adminEcho = 0x04
|
||||
echoProbe = 0x55 // K1EL's own choice; any byte works, this one is 0b01010101
|
||||
bootDelay = 400 * time.Millisecond
|
||||
@@ -75,7 +77,11 @@ func hostOpen(p serial.Port, slowBoot bool) (ver int, needsSlowBoot bool, err er
|
||||
if attempt > 1 || slowBoot {
|
||||
wait = resetDelay
|
||||
}
|
||||
ver, err := hostOpenOnce(p, wait)
|
||||
// A port already known to need the slow path gets the wake-up on the
|
||||
// FIRST attempt too: it needed it last time, and making the operator
|
||||
// wait through a failure to earn it again is a connect that takes twice
|
||||
// as long for no new information.
|
||||
ver, err := hostOpenOnce(p, wait, attempt > 1 || slowBoot)
|
||||
if err == nil {
|
||||
if attempt > 1 {
|
||||
applog.Printf("winkeyer: answered on attempt %d — this keyer needs %s to boot (a K3NG or another Arduino keyer with auto-reset on); remembering that for this port", attempt, wait)
|
||||
@@ -90,7 +96,10 @@ func hostOpen(p serial.Port, slowBoot bool) (ver int, needsSlowBoot bool, err er
|
||||
return 0, false, lastErr
|
||||
}
|
||||
|
||||
func hostOpenOnce(p serial.Port, boot time.Duration) (int, error) {
|
||||
func hostOpenOnce(p serial.Port, boot time.Duration, hard bool) (int, error) {
|
||||
if hard {
|
||||
recoverKeyer(p)
|
||||
}
|
||||
// The keyer may still be booting off the DTR line we just raised.
|
||||
time.Sleep(boot)
|
||||
drain(p)
|
||||
@@ -130,12 +139,60 @@ func hostOpenOnce(p serial.Port, boot time.Duration) (int, error) {
|
||||
}
|
||||
ver, ok := readByte(p, openTimeout)
|
||||
traceHandshake("RX", nil, ver, ok)
|
||||
if !ok {
|
||||
// It answered the echo, so there IS a keyer on this port — it simply
|
||||
// will not open. A keyer already IN host mode does exactly that: a
|
||||
// previous session that ended badly never sent Host Close, and it has
|
||||
// been waiting ever since for a host that went away. Close it and ask
|
||||
// again.
|
||||
applog.Printf("winkeyer: echoed but did not open — closing a host session left over from last time, and asking again")
|
||||
if _, err := p.Write([]byte{cmdAdmin, adminClose}); err != nil {
|
||||
return 0, fmt.Errorf("host close: %w", err)
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
drain(p)
|
||||
traceHandshake("TX", open, 0, false)
|
||||
if _, err := p.Write(open); err != nil {
|
||||
return 0, fmt.Errorf("host open: %w", err)
|
||||
}
|
||||
ver, ok = readByte(p, openTimeout)
|
||||
traceHandshake("RX", nil, ver, ok)
|
||||
if !ok {
|
||||
return 0, errors.New("host open: the keyer echoed but did not return its firmware version")
|
||||
}
|
||||
}
|
||||
return int(ver), nil
|
||||
}
|
||||
|
||||
// recoverKeyer does to the keyer what running K1EL's WKdemo and closing it
|
||||
// again does — which is the workaround an operator found for a WKUSB that
|
||||
// OpsLog could not open until they had.
|
||||
//
|
||||
// Three things, in an order that survives each of them failing:
|
||||
//
|
||||
// - Host Close, in case the keyer is still in host mode from a session that
|
||||
// ended without one: a crash, a cable pulled, a machine switched off.
|
||||
// - Admin Reset, which returns it to its power-up state. A parser stuck
|
||||
// part-way through a command whose parameters will never arrive cannot be
|
||||
// talked out of it any other way.
|
||||
// - A DTR pulse. That is what closing another program actually does to the
|
||||
// line, and on the boxes that wire DTR to the processor's reset — a WKUSB,
|
||||
// and every Arduino-based clone — it is a power-on reset in all but name.
|
||||
//
|
||||
// RTS is left alone throughout: on a serial WinKeyer it is the negative rail
|
||||
// the RS-232 swing comes from, and driving it starves the chip.
|
||||
func recoverKeyer(p serial.Port) {
|
||||
applog.Printf("winkeyer: waking the keyer — host close, reset, then a DTR pulse")
|
||||
_, _ = p.Write([]byte{cmdNull, cmdNull, cmdNull, cmdAdmin, adminClose})
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
_, _ = p.Write([]byte{cmdAdmin, adminReset})
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
_ = p.SetDTR(false)
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
_ = p.SetDTR(true)
|
||||
drain(p)
|
||||
}
|
||||
|
||||
// traceHandshake puts the opening exchange in the log, ALWAYS — unlike the
|
||||
// running trace beside it, which is behind the diagnostic option.
|
||||
//
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.27.20"
|
||||
appVersion = "0.27.23"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user