feat: Tuner Genius XL — A/B channels, plus FlexRadio panel + Station Control cards
Push the tuner control further so it mirrors the native 4O3A app and the way the PowerGenius XL is surfaced. Backend (internal/tunergenius): - Status now carries both RF channels A and B (source/mode, band, frequency, bound Flex nickname, per-channel bypass, antenna, PTT), the active channel, the C1/L/C2 relay-network positions, and the 3-way-vs-SO2R hardware variant (learned once from the `info` reply). Flat freq/antenna still mirror the active channel for the compact widget. - New TunerGeniusActivate(ch) binding → `activate ch=N` (or `ant=N` on 3-way). Frontend: - New shared TunerCard, styled exactly like AmpCard (PWR/SWR meter bars, A/B channel selector with source+freq+antenna, Tune/Bypass/Operate). Used in BOTH the FlexRadio panel (its own card, like the PGXL) and Station Control. - Docked TunerGeniusPanel widget now shows the two channels A/B with their source/frequency/antenna and lets you click to make one active — the missing A/B state the user flagged. - i18n EN/FR for the new labels (channels, antenna, in-line/bypassed, title). Still UNTESTED on hardware — verify the per-channel field names/units and the activate behaviour on the real box.
This commit is contained in:
@@ -35,6 +35,21 @@ const (
|
||||
reconnectDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
// Channel is the live state of one of the tuner's two RF channels (A / B). The
|
||||
// Tuner Genius XL is a dual (SO2R) coupler, so each channel tracks its own
|
||||
// source, band, frequency and antenna — mirroring the two rows the native 4O3A
|
||||
// app shows.
|
||||
type Channel struct {
|
||||
PTT bool `json:"ptt"` // this channel is keyed
|
||||
Band int `json:"band"` // band as reported by the device (0 = unknown)
|
||||
Mode int `json:"mode"` // 0=RF Sense 1=FLEX 2=CAT 3=P2B 4=BCD
|
||||
ModeStr string `json:"mode_str"` // human-readable mode/source
|
||||
Flex string `json:"flex"` // bound Flex radio nickname (FLEX mode)
|
||||
FreqMHz float64 `json:"freq_mhz"` // current frequency
|
||||
Bypass bool `json:"bypass"` // this channel bypassed
|
||||
Antenna int `json:"antenna"` // antenna in use (3WAY / SO2R-with-AG; 0 = n/a)
|
||||
}
|
||||
|
||||
// Status is the snapshot the UI renders. Power/SWR come from the device's
|
||||
// "status" reply; the booleans mirror the tuner's operating state.
|
||||
type Status struct {
|
||||
@@ -42,22 +57,50 @@ type Status struct {
|
||||
Host string `json:"host,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
|
||||
FwdDbm float64 `json:"fwd_dbm"` // forward power [dBm], as reported
|
||||
FwdW float64 `json:"fwd_w"` // forward power [W], derived from dBm
|
||||
SwrDb float64 `json:"swr_db"` // return loss [dB] as reported (negative = good match)
|
||||
Vswr float64 `json:"vswr"` // VSWR ratio, derived from swr_db (1.0 = perfect)
|
||||
FreqMHz float64 `json:"freq_mhz"` // active channel frequency
|
||||
FwdDbm float64 `json:"fwd_dbm"` // forward power [dBm], as reported
|
||||
FwdW float64 `json:"fwd_w"` // forward power [W], derived from dBm
|
||||
SwrDb float64 `json:"swr_db"` // return loss [dB] as reported (negative = good match)
|
||||
Vswr float64 `json:"vswr"` // VSWR ratio, derived from swr_db (1.0 = perfect)
|
||||
|
||||
Operate bool `json:"operate"` // state == OPERATE (1) vs STANDBY (0)
|
||||
Bypass bool `json:"bypass"` // device global bypass engaged
|
||||
Tuning bool `json:"tuning"` // autotune in progress
|
||||
Active int `json:"active"` // active channel (1 = A, 2 = B)
|
||||
Antenna int `json:"antenna"` // antenna in use (3WAY / SO2R-with-AG)
|
||||
ThreeWay bool `json:"three_way"` // 3-way (vs SO2R) hardware variant
|
||||
|
||||
A Channel `json:"a"` // channel A
|
||||
B Channel `json:"b"` // channel B
|
||||
|
||||
RelayC1 int `json:"relay_c1"` // tuner network position (0–255)
|
||||
RelayL int `json:"relay_l"`
|
||||
RelayC2 int `json:"relay_c2"`
|
||||
|
||||
// FreqMHz / Antenna mirror the ACTIVE channel, kept for the compact docked
|
||||
// widget that shows a single readout.
|
||||
FreqMHz float64 `json:"freq_mhz"`
|
||||
Antenna int `json:"antenna"`
|
||||
|
||||
Message string `json:"message,omitempty"` // last M| warning/info (empty = cleared)
|
||||
}
|
||||
|
||||
// modeName maps the device's numeric mode/source to a label.
|
||||
func modeName(m int) string {
|
||||
switch m {
|
||||
case 0:
|
||||
return "RF Sense"
|
||||
case 1:
|
||||
return "Flex"
|
||||
case 2:
|
||||
return "CAT"
|
||||
case 3:
|
||||
return "P2B"
|
||||
case 4:
|
||||
return "BCD"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
host string
|
||||
port int
|
||||
@@ -174,9 +217,17 @@ func (c *Client) pollLoop() {
|
||||
case <-c.stop:
|
||||
return
|
||||
case <-t.C:
|
||||
if err := c.ensureConnected(); err != nil {
|
||||
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() })
|
||||
continue
|
||||
fresh := false
|
||||
if c.needConnect() {
|
||||
if err := c.ensureConnected(); err != nil {
|
||||
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() })
|
||||
continue
|
||||
}
|
||||
fresh = true
|
||||
}
|
||||
// One-shot on a fresh link: learn the hardware variant (3-way vs SO2R).
|
||||
if fresh {
|
||||
_, _ = c.command("info")
|
||||
}
|
||||
if _, err := c.command("status"); err != nil {
|
||||
c.dropConn()
|
||||
@@ -186,6 +237,14 @@ func (c *Client) pollLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// needConnect reports whether the TCP link is currently down (so the poll loop
|
||||
// knows a fresh connect + one-shot info query is needed).
|
||||
func (c *Client) needConnect() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.conn == nil
|
||||
}
|
||||
|
||||
func (c *Client) ensureConnected() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -308,6 +367,15 @@ func (c *Client) parse(resp string) {
|
||||
default:
|
||||
return
|
||||
}
|
||||
// "info …" carries the hardware variant (3way=1 on the 3-way model, absent on
|
||||
// SO2R) — parsed once so the UI knows whether channels are A/B or antennas.
|
||||
if strings.HasPrefix(data, "info") {
|
||||
tw := strings.Contains(data, "3way=1")
|
||||
c.statusMu.Lock()
|
||||
c.status.ThreeWay = tw
|
||||
c.statusMu.Unlock()
|
||||
return
|
||||
}
|
||||
// Only the "status …" payload carries the live state we render.
|
||||
if !strings.HasPrefix(data, "status") {
|
||||
return
|
||||
@@ -319,8 +387,8 @@ func (c *Client) parse(resp string) {
|
||||
c.applyStatus(data)
|
||||
}
|
||||
|
||||
// applyStatus maps the "status k=v …" fields onto the snapshot. Per-channel
|
||||
// fields (A/B) are folded down to the active channel for the single-radio UI.
|
||||
// applyStatus maps the "status k=v …" fields onto the snapshot, filling both
|
||||
// channels (A/B) plus the global power/SWR and operating state.
|
||||
func (c *Client) applyStatus(data string) {
|
||||
kv := map[string]string{}
|
||||
for _, tok := range strings.Fields(data) {
|
||||
@@ -338,6 +406,9 @@ func (c *Client) applyStatus(data string) {
|
||||
c.status.Operate = kv["state"] == "1"
|
||||
c.status.Bypass = kv["bypass"] == "1"
|
||||
c.status.Tuning = kv["tuning"] == "1"
|
||||
c.status.RelayC1 = atoiDefault(kv["relayC1"], 0)
|
||||
c.status.RelayL = atoiDefault(kv["relayL"], 0)
|
||||
c.status.RelayC2 = atoiDefault(kv["relayC2"], 0)
|
||||
|
||||
if v, ok := parseFloat(kv["fwd"]); ok {
|
||||
c.status.FwdDbm = v
|
||||
@@ -347,15 +418,34 @@ func (c *Client) applyStatus(data string) {
|
||||
c.status.SwrDb = v
|
||||
c.status.Vswr = returnLossToVswr(v)
|
||||
}
|
||||
// Active-channel frequency + antenna (suffix A for ch1, B for ch2).
|
||||
suffix := "A"
|
||||
|
||||
c.status.A = channelFrom(kv, "A")
|
||||
c.status.B = channelFrom(kv, "B")
|
||||
|
||||
// Mirror the active channel into the flat fields the compact widget uses.
|
||||
act := c.status.A
|
||||
if active == 2 {
|
||||
suffix = "B"
|
||||
act = c.status.B
|
||||
}
|
||||
if v, ok := parseFloat(kv["freq"+suffix]); ok {
|
||||
c.status.FreqMHz = v
|
||||
c.status.FreqMHz = act.FreqMHz
|
||||
c.status.Antenna = act.Antenna
|
||||
}
|
||||
|
||||
// channelFrom extracts one channel's fields (suffix "A" or "B") from the parsed
|
||||
// status map.
|
||||
func channelFrom(kv map[string]string, suffix string) Channel {
|
||||
mode := atoiDefault(kv["mode"+suffix], 0)
|
||||
freq, _ := parseFloat(kv["freq"+suffix])
|
||||
return Channel{
|
||||
PTT: kv["ptt"+suffix] == "1",
|
||||
Band: atoiDefault(kv["band"+suffix], 0),
|
||||
Mode: mode,
|
||||
ModeStr: modeName(mode),
|
||||
Flex: strings.TrimSpace(kv["flex"+suffix]),
|
||||
FreqMHz: freq,
|
||||
Bypass: kv["bypass"+suffix] == "1",
|
||||
Antenna: atoiDefault(kv["ant"+suffix], 0),
|
||||
}
|
||||
c.status.Antenna = atoiDefault(kv["ant"+suffix], 0)
|
||||
}
|
||||
|
||||
func boolNum(on bool) string {
|
||||
|
||||
Reference in New Issue
Block a user