feat: FlexRadio TX power per band and mode class

Settings → FlexRadio, beside the per-band antennas and applied the same way:
when the band or the mode changes. 1.5 kW that is fine on SSB has no business
going into an FT8 signal on the same band.

Three classes rather than one row per mode, because what decides the power is
duty cycle and what the amplifier will take of it, not the mode's name — FT8 and
RTTY punish an amplifier the same way, SSB and AM do not. The classification is
pinned by a test: a digital mode sorted as phone would deliver exactly the
1.5 kW this exists to prevent.

An empty box means "leave the power alone", never zero watts, and an unknown
mode changes nothing — setting a transmit power from a name we do not recognise
is not a good guess.

Unlike the antennas, a locked band does not skip it: the lock means "do not let
the rig drag my log entry around", not "let the amplifier keep whatever the last
mode left".
This commit is contained in:
2026-07-31 11:30:46 +02:00
parent bd2a8524dc
commit 62b6fe0a3a
8 changed files with 250 additions and 7 deletions
+100
View File
@@ -12230,6 +12230,106 @@ func (a *App) FlexApplyBandAntenna(band string) error {
})
}
// keyFlexBandPower stores the per-band, per-mode TX power map (global,
// machine-local — it describes this station's amplifier and antennas).
const keyFlexBandPower = "flex.band_power"
// FlexBandPower is the TX power to set on a band, per class of mode.
//
// Three classes rather than per-mode: what decides the power is the duty cycle
// and the amplifier's tolerance for it, not the mode's name. FT8 and RTTY hurt
// an amplifier the same way; SSB and AM do not.
//
// Zero means "leave the power alone" — an empty box must not silently mean
// zero watts, and an operator who configures 20 m only does not want every
// other band reset when they get there.
type FlexBandPower struct {
Phone int `json:"phone"`
CW int `json:"cw"`
Digi int `json:"digi"`
}
// GetFlexBandPower returns the band→power map (band key uppercased, "20M").
func (a *App) GetFlexBandPower() (map[string]FlexBandPower, error) {
out := map[string]FlexBandPower{}
if a.settings == nil {
return out, nil
}
v, _ := a.settings.GetGlobal(a.ctx, keyFlexBandPower)
if strings.TrimSpace(v) == "" {
return out, nil
}
_ = json.Unmarshal([]byte(v), &out)
return out, nil
}
// SaveFlexBandPower persists the band→power map.
func (a *App) SaveFlexBandPower(m map[string]FlexBandPower) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
b, err := json.Marshal(m)
if err != nil {
return err
}
return a.settings.SetGlobal(a.ctx, keyFlexBandPower, string(b))
}
// flexPowerClass sorts an ADIF mode into the three classes the power table uses.
// An unknown mode returns "" and changes nothing — guessing would be setting a
// transmit power from a name we do not recognise.
func flexPowerClass(mode string) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "SSB", "USB", "LSB", "AM", "FM", "DV":
return "phone"
case "CW", "CWR":
return "cw"
case "FT8", "FT4", "JT65", "JT9", "JS8", "RTTY", "PSK", "PSK31", "PSK63",
"BPSK", "QPSK", "MFSK", "OLIVIA", "CONTESTI", "DATA", "DIGITALVOICE",
"DIGU", "DIGL", "Q65", "MSK144", "WSPR", "FST4", "FST4W", "ARDOP", "VARA":
return "digi"
}
return ""
}
// FlexApplyBandPower sets the configured TX power for a band and mode.
//
// Called on band AND mode changes: the whole point is that switching to FT8 on
// a band where 1.5 kW is fine for SSB does not put 1.5 kW into a 100% duty-cycle
// signal. No mapping, or a zero, leaves the radio alone.
func (a *App) FlexApplyBandPower(band, mode string) error {
if a.cat == nil {
return nil
}
band = strings.ToUpper(strings.TrimSpace(band))
class := flexPowerClass(mode)
if band == "" || class == "" {
return nil
}
m, _ := a.GetFlexBandPower()
e, ok := m[band]
if !ok {
return nil
}
pct := 0
switch class {
case "phone":
pct = e.Phone
case "cw":
pct = e.CW
case "digi":
pct = e.Digi
}
if pct <= 0 {
return nil
}
if pct > 100 {
pct = 100
}
applog.Printf("flex: %s %s → TX power %d%%", band, class, pct)
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRFPower(pct) })
}
// RIT/XIT on the active slice. The offset is kept by the radio when the switch is
// off, so turning it back on restores it — the UI must not zero it on toggle.
func (a *App) FlexSetRIT(on bool) error {