The hard part of satellite tuning is not the arithmetic, it is deciding who owns the dial. A tracker that forces both frequencies fights the operator every time they turn the knob to follow a station across a linear transponder; one that never touches the receiver leaves them chasing a signal that slides nine kilohertz across a 70 cm pass. So the operator owns the receiver and the tracker follows them. Every second it asks the radio where the receiver actually is. Where it put it, nothing has changed. Further than a dial-turn's tolerance, and the operator has chosen a station: what they landed on is converted back into a nominal frequency, and the transmitter is derived from that. Which is the division of labour on a linear bird — the operator listens, the radio does the sums. Three ways to reach the radio, because a satellite pair is a shape of operating rather than a manufacturer's feature. An IC-9700 or IC-9100 is asked for its OWN satellite mode: it pairs main and sub, gives full duplex, and keeps the dials linked the way its designers meant, which is always better than an imitation built out of split. A Flex gets two slices, A the downlink and B the uplink, created when missing, because "slice B does not exist" is not something to make an operator fix at the start of a ten-minute pass. Everything else gets the downlink, and is told so — half the job announced beats half the job hidden. What goes in the log is the NOMINAL pair. Two stations working each other through a transponder read different numbers off their dials at the same instant; the only figure they can both agree on is the transponder's own. FREQ is the uplink and FREQ_RX the downlink — the one place a satellite QSO differs from every other kind, and the reason FREQ alone cannot describe one.
80 lines
3.3 KiB
Go
80 lines
3.3 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Every setting in OpsLog is per profile, station hardware included. A device
|
|
// started at boot and never again therefore stays on the PREVIOUS profile's
|
|
// port until Settings is opened and saved — which is how an operator running an
|
|
// SPE on COM9 for HF and another on COM10 for 6 m, one per profile, found the
|
|
// amplifier still on the old port after switching. Save is not a connect
|
|
// button.
|
|
//
|
|
// This test keeps startup and reloadAfterProfileSwitch in lockstep: anything
|
|
// started at boot must either be re-applied on a profile switch or be listed
|
|
// below with the reason it must not be. Adding a device makes the choice
|
|
// explicit instead of leaving the fifth one to be found by a user.
|
|
func TestProfileSwitchReappliesEveryStartupDevice(t *testing.T) {
|
|
src, err := os.ReadFile("app.go")
|
|
if err != nil {
|
|
t.Fatalf("read app.go: %v", err)
|
|
}
|
|
|
|
// Started at boot but deliberately NOT re-run on a profile switch:
|
|
notPerProfile := map[string]string{
|
|
"startAllEnabledClusters": "the cluster panel reconnects itself; its servers are a global list",
|
|
"startGridCache": "a shared on-disk grid cache, not a profile's",
|
|
"startBandOpenFeed": "PSK Reporter, keyed on the operator grid it re-reads itself",
|
|
// The auto-call loop is ONE goroutine for the life of the process —
|
|
// starting a second on every profile switch would give one engine two
|
|
// sweepers. Its settings do follow the profile: reloadAfterProfileSwitch
|
|
// calls applyAutoCall, which re-reads them and clears the target.
|
|
"startAutoCall": "a single sweeper goroutine; applyAutoCall in the reload carries the settings",
|
|
// The elements and the frequency plan are FILES, shared by every
|
|
// profile — there is one sky. What is per profile (the favourites, the
|
|
// minimum elevation, the locator) is read live on every call, so a
|
|
// switch is already reflected without rebuilding anything. The tracker,
|
|
// which does transmit, IS stopped by reloadAfterProfileSwitch.
|
|
"startSatellites": "one sky: the elements and the plan are shared files, and the per-profile settings are read live",
|
|
}
|
|
|
|
startup := body(t, string(src), "func (a *App) startup(ctx context.Context) {")
|
|
reload := body(t, string(src), "func (a *App) reloadAfterProfileSwitch() {")
|
|
|
|
call := regexp.MustCompile(`a\.(start[A-Z][A-Za-z]*)\b`)
|
|
seen := map[string]bool{}
|
|
for _, m := range call.FindAllStringSubmatch(startup, -1) {
|
|
name := m[1]
|
|
if seen[name] || notPerProfile[name] != "" {
|
|
continue
|
|
}
|
|
seen[name] = true
|
|
if !strings.Contains(reload, "a."+name) {
|
|
t.Errorf("%s runs at startup but not on a profile switch — the device stays on the previous profile's settings.\n"+
|
|
"Add it to reloadAfterProfileSwitch, or to notPerProfile here with the reason it must not follow the profile.", name)
|
|
}
|
|
}
|
|
if len(seen) == 0 {
|
|
t.Fatal("no startup device starters found — this test has stopped checking anything")
|
|
}
|
|
}
|
|
|
|
// body returns the source of the function opening with the given signature,
|
|
// up to the closing brace in column 0.
|
|
func body(t *testing.T, src, signature string) string {
|
|
t.Helper()
|
|
i := strings.Index(src, signature)
|
|
if i < 0 {
|
|
t.Fatalf("%q not found in app.go", signature)
|
|
}
|
|
rest := src[i+len(signature):]
|
|
if j := strings.Index(rest, "\n}"); j >= 0 {
|
|
return rest[:j]
|
|
}
|
|
return rest
|
|
}
|