fix(profiles): station hardware must follow the active profile

Every setting is per profile, amplifiers included — that is the whole point of
one profile per station. But the amplifier clients were built once at startup
and again only when Settings was saved, so switching profile left them on the
previous profile's port. An operator with an SPE on COM9 for HF and another on
COM10 for 6 m had to open Settings and press Save after every switch. Save is
not a connect button; switching profile is what asks for that hardware.

The amplifier is what was reported, but it was never alone: the motorized
antenna, the Antenna Genius and the Tuner Genius are configured the same way and
were started in the same two places. All four now restart on a profile switch,
asynchronously, exactly as a save does.

Rotators were already correct — their client is built per command rather than
held open, so they read the active profile every time.

Unconditional, like a save: comparing each device's configuration across
profiles to skip a reconnect would trade a second of downtime for the chance of
missing one. A test now keeps the two lists in lockstep — anything started at
boot must be re-applied on a profile switch or be named, with its reason, as
something that must not be.
This commit is contained in:
2026-08-16 00:18:11 +02:00
parent 480d24384b
commit 62e20de69c
3 changed files with 90 additions and 4 deletions
+68
View File
@@ -0,0 +1,68 @@
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",
}
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
}