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:
@@ -14120,8 +14120,9 @@ func (a *App) ActivateProfile(id int64) error {
|
|||||||
|
|
||||||
// reloadAfterProfileSwitch re-applies all settings-derived state for the newly
|
// reloadAfterProfileSwitch re-applies all settings-derived state for the newly
|
||||||
// active profile: lookup providers, upload-service accounts, CAT connection,
|
// active profile: lookup providers, upload-service accounts, CAT connection,
|
||||||
// and the QSO recorder (audio devices). The Winkeyer stays as-is (the operator
|
// the station hardware, and the QSO recorder (audio devices). The Winkeyer
|
||||||
// connects it explicitly). The frontend reloads its panels via profile:changed.
|
// stays as-is (the operator connects it explicitly). The frontend reloads its
|
||||||
|
// panels via profile:changed.
|
||||||
func (a *App) reloadAfterProfileSwitch() {
|
func (a *App) reloadAfterProfileSwitch() {
|
||||||
a.reloadLookupProviders()
|
a.reloadLookupProviders()
|
||||||
if a.extsvc != nil {
|
if a.extsvc != nil {
|
||||||
@@ -14131,6 +14132,21 @@ func (a *App) reloadAfterProfileSwitch() {
|
|||||||
// runs from ActivateProfile, a click, and a rig that is slow to release would
|
// runs from ActivateProfile, a click, and a rig that is slow to release would
|
||||||
// otherwise freeze the switch.
|
// otherwise freeze the switch.
|
||||||
a.restartAsync("cat", a.reloadCAT)
|
a.restartAsync("cat", a.reloadCAT)
|
||||||
|
// Every device below is configured PER PROFILE, and none of them used to
|
||||||
|
// follow the profile: they were built once at startup and again only when
|
||||||
|
// Settings was saved. An operator with an SPE on COM9 for HF and another on
|
||||||
|
// COM10 for 6 m — one amplifier per profile, which is the whole point of
|
||||||
|
// having two — switched profile and stayed connected to the previous port,
|
||||||
|
// then had to open Settings and press Save to get the right one. Save is not
|
||||||
|
// a connect button; switching profile is what asks for this hardware.
|
||||||
|
//
|
||||||
|
// Unconditional, exactly as a save is: the alternative is comparing every
|
||||||
|
// device's configuration between profiles, and a needless reconnect on a
|
||||||
|
// profile switch costs a second, while a missed one costs the amplifier.
|
||||||
|
a.restartAsync("amp", a.startAmps)
|
||||||
|
a.restartAsync("antenna", a.startUltrabeam)
|
||||||
|
a.restartAsync("antgenius", a.startAntGenius)
|
||||||
|
a.restartAsync("tuner", a.startTunerGenius)
|
||||||
a.startQSORecorderIfEnabled()
|
a.startQSORecorderIfEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -6,13 +6,15 @@
|
|||||||
"WinKeyer: the opening probe goes out as one write, matching a capture of a client that talks to the same K3NG keyer, and the handshake bytes are always logged so a keyer that stays silent can be diagnosed.",
|
"WinKeyer: the opening probe goes out as one write, matching a capture of a client that talks to the same K3NG keyer, and the handshake bytes are always logged so a keyer that stays silent can be diagnosed.",
|
||||||
"DX cluster: when spots arrive and every one is filtered out, the panel says so, names the filters doing it and offers to clear them — it used to say “waiting for spots” beside a counter reading 76 live.",
|
"DX cluster: when spots arrive and every one is filtered out, the panel says so, names the filters doing it and offers to clear them — it used to say “waiting for spots” beside a counter reading 76 live.",
|
||||||
"DX cluster: the log times the connection and the first spot, so a slow first launch can be told apart from a quiet node.",
|
"DX cluster: the log times the connection and the first spot, so a slow first launch can be told apart from a quiet node.",
|
||||||
"Generic HTTP relay: its URLs were never actually sent — fixed. {value} sends the relay's label, {relay-1} counts from zero, no host needed."
|
"Generic HTTP relay: its URLs were never actually sent — fixed. {value} sends the relay's label, {relay-1} counts from zero, no host needed.",
|
||||||
|
"Switching profile now reconnects the amplifier, antenna, Antenna Genius and tuner — they stayed on the previous profile's ports until you saved Settings."
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"WinKeyer : la sonde d’ouverture part en un seul envoi, calquée sur la capture d’un client qui dialogue avec le même manipulateur K3NG, et les octets de la poignée de main sont toujours journalisés pour diagnostiquer un manipulateur muet.",
|
"WinKeyer : la sonde d’ouverture part en un seul envoi, calquée sur la capture d’un client qui dialogue avec le même manipulateur K3NG, et les octets de la poignée de main sont toujours journalisés pour diagnostiquer un manipulateur muet.",
|
||||||
"Cluster DX : quand des spots arrivent et que tout est filtré, le panneau le dit, nomme les filtres responsables et propose de les effacer — il affichait « en attente de spots » à côté d’un compteur à 76 en direct.",
|
"Cluster DX : quand des spots arrivent et que tout est filtré, le panneau le dit, nomme les filtres responsables et propose de les effacer — il affichait « en attente de spots » à côté d’un compteur à 76 en direct.",
|
||||||
"Cluster DX : le journal chronomètre la connexion et le premier spot, pour distinguer un premier lancement lent d’un nœud silencieux.",
|
"Cluster DX : le journal chronomètre la connexion et le premier spot, pour distinguer un premier lancement lent d’un nœud silencieux.",
|
||||||
"Relais HTTP générique : ses URL n’étaient jamais envoyées — corrigé. {value} envoie le libellé du relais, {relay-1} compte de zéro, hôte inutile."
|
"Relais HTTP générique : ses URL n’étaient jamais envoyées — corrigé. {value} envoie le libellé du relais, {relay-1} compte de zéro, hôte inutile.",
|
||||||
|
"Changer de profil reconnecte l’ampli, l’antenne, l’Antenna Genius et le tuner — ils restaient sur les ports du profil précédent jusqu’à un Enregistrer."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user