chore: release v0.22.9
This commit is contained in:
+8
-16
@@ -314,7 +314,12 @@ func (f *Flex) send(cmd string) int {
|
||||
debugLog.Printf("Flex: send %q failed: %v", cmd, err)
|
||||
return 0
|
||||
}
|
||||
debugLog.Printf("Flex: → %s", cmd)
|
||||
// Meter subscriptions are pure bookkeeping and a Flex announces meters one
|
||||
// status line at a time — tracing each "sub meter N" wrote dozens of lines
|
||||
// at every connect.
|
||||
if !strings.HasPrefix(cmd, "sub meter ") {
|
||||
debugLog.Printf("Flex: → %s", cmd)
|
||||
}
|
||||
return seq
|
||||
}
|
||||
|
||||
@@ -773,17 +778,11 @@ func (f *Flex) handleStatus(payload string) {
|
||||
f.meterMeta[num] = old
|
||||
}
|
||||
f.mu.Unlock()
|
||||
// One line for the whole batch, not one per meter: a Flex announces
|
||||
// dozens at connect and that was a wall of text every time.
|
||||
var names []string
|
||||
// Not logged: the radio announces meters ONE per status line, so even
|
||||
// a batched line meant dozens of them at every connect.
|
||||
for _, id := range newIDs {
|
||||
mi := f.meterMeta[id]
|
||||
names = append(names, nonEmpty(mi.name, strconv.Itoa(id)))
|
||||
f.subscribeMeter(id)
|
||||
}
|
||||
if len(names) > 0 {
|
||||
debugLog.Printf("Flex: subscribed to %d meter(s): %s", len(names), strings.Join(names, " "))
|
||||
}
|
||||
}
|
||||
// Spot status: "spot <index> …". Track the index so we can clear the
|
||||
// panadapter, and log it verbatim — a click on a panadapter spot pushes a
|
||||
@@ -2261,13 +2260,6 @@ func (f *Flex) subscribeMeter(id int) {
|
||||
f.send(fmt.Sprintf("sub meter %d", id))
|
||||
}
|
||||
|
||||
func nonEmpty(s, def string) string {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseFloatDefault(s string, def float64) float64 {
|
||||
if v, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil {
|
||||
return v
|
||||
|
||||
+66
-15
@@ -114,6 +114,15 @@ type IcomSerial struct {
|
||||
silentGrace time.Duration // current width of that tolerance (backs off, see ReadState)
|
||||
dspLoaded bool // readDSP has run since the rig became responsive (loads all
|
||||
// the panel's set-once controls once the rig actually answers)
|
||||
// When the console last asked for the DSP snapshot. The meters and the
|
||||
// front-panel rotation are polled ONLY while something is displaying them:
|
||||
// they are half of OpsLog's CI-V traffic, and on a shared bus — a microHAM
|
||||
// Router splitting one CI-V link into two virtual ports, OpsLog on one and
|
||||
// WSJT-X/MSHV on the other — every frame we don't need is a frame that can
|
||||
// collide with the other program's.
|
||||
dspWantMu sync.Mutex
|
||||
dspWantAt time.Time
|
||||
|
||||
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
|
||||
lastSetFreqAt time.Time
|
||||
|
||||
@@ -409,15 +418,25 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
||||
}
|
||||
|
||||
// Live meters + TX state for the Icom panel (the rig doesn't push these).
|
||||
// The meters are polled only while the console is actually on screen: they
|
||||
// were three CI-V round trips per cycle, every cycle, whether or not anyone
|
||||
// could see them. The previous readings are kept so the panel opens on the
|
||||
// last known values rather than on zeros.
|
||||
tx := b.readTX()
|
||||
sm, _ := b.readMeter(civ.SubMeterS)
|
||||
po, swr := 0, 0
|
||||
if tx {
|
||||
if v, ok := b.readMeter(civ.SubMeterPo); ok {
|
||||
po = v
|
||||
}
|
||||
if v, ok := b.readMeter(civ.SubMeterSWR); ok {
|
||||
swr = v
|
||||
watched := b.panelWatched()
|
||||
b.dspMu.Lock()
|
||||
sm, po, swr := b.dsp.SMeter, b.dsp.PowerMeter, b.dsp.SWRMeter
|
||||
b.dspMu.Unlock()
|
||||
if watched {
|
||||
sm, _ = b.readMeter(civ.SubMeterS)
|
||||
po, swr = 0, 0
|
||||
if tx {
|
||||
if v, ok := b.readMeter(civ.SubMeterPo); ok {
|
||||
po = v
|
||||
}
|
||||
if v, ok := b.readMeter(civ.SubMeterSWR); ok {
|
||||
swr = v
|
||||
}
|
||||
}
|
||||
}
|
||||
b.dspMu.Lock()
|
||||
@@ -434,11 +453,15 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
||||
// snapshot so the panel's antenna, sliders, RIT, notch, etc. reflect the rig
|
||||
// instead of sitting at their zero defaults. Runs once; ↻ Refresh re-reads on
|
||||
// demand, and a reconnect re-arms it (Connect clears dspLoaded).
|
||||
if !b.dspLoaded {
|
||||
b.readDSP()
|
||||
b.dspLoaded = true
|
||||
} else {
|
||||
b.refreshFrontPanel()
|
||||
// Both of these only make sense for the console — don't spend the bus on them
|
||||
// while it is closed. The snapshot then loads the first time it is opened.
|
||||
if watched {
|
||||
if !b.dspLoaded {
|
||||
b.readDSP()
|
||||
b.dspLoaded = true
|
||||
} else {
|
||||
b.refreshFrontPanel()
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
@@ -532,12 +555,25 @@ func (b *IcomSerial) SetPower(on bool) error {
|
||||
return fmt.Errorf("icom: not connected")
|
||||
}
|
||||
if on {
|
||||
buf := make([]byte, 0, 32)
|
||||
for i := 0; i < 25; i++ {
|
||||
// The preamble has to last long enough IN TIME for the sleeping rig to
|
||||
// notice it, so its length scales with the baud rate — Icom's own table
|
||||
// asks for 7 bytes at 4800 baud and 150 at 115200, which is a fixed
|
||||
// ~25 ms of carrier. A flat 25 bytes (what this sent before) is right at
|
||||
// 19200 and far too short above it, so a rig configured for 115200 simply
|
||||
// ignored the command.
|
||||
n := b.baud / 768
|
||||
if n < 7 {
|
||||
n = 7
|
||||
} else if n > 150 {
|
||||
n = 150
|
||||
}
|
||||
buf := make([]byte, 0, n+8)
|
||||
for i := 0; i < n; i++ {
|
||||
buf = append(buf, 0xFE)
|
||||
}
|
||||
buf = append(buf, 0xFE, 0xFE, b.rigAddr, civ.AddrController, civ.CmdPower, 0x01, 0xFD)
|
||||
_, err := b.port.Write(buf)
|
||||
applog.Printf("icom: power ON — %d-byte wake preamble at %d baud, addr 0x%02X (err=%v)", n, b.baud, b.rigAddr, err)
|
||||
return err
|
||||
}
|
||||
_, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, civ.CmdPower, 0x00))
|
||||
@@ -1289,11 +1325,26 @@ func (b *IcomSerial) modeCode(mode string) (code byte, data bool, err error) {
|
||||
// ── IcomController: receive-DSP controls for the Icom tab ───────────────────
|
||||
|
||||
func (b *IcomSerial) IcomState() IcomTXState {
|
||||
// Asking for the snapshot is what marks the console as being watched, which
|
||||
// is what re-enables the meter polling in ReadState.
|
||||
b.dspWantMu.Lock()
|
||||
b.dspWantAt = time.Now()
|
||||
b.dspWantMu.Unlock()
|
||||
|
||||
b.dspMu.Lock()
|
||||
defer b.dspMu.Unlock()
|
||||
return b.dsp
|
||||
}
|
||||
|
||||
// panelWatched reports whether the Icom console asked for the DSP snapshot
|
||||
// recently — i.e. whether anything is on screen to read the meters.
|
||||
func (b *IcomSerial) panelWatched() bool {
|
||||
b.dspWantMu.Lock()
|
||||
at := b.dspWantAt
|
||||
b.dspWantMu.Unlock()
|
||||
return !at.IsZero() && time.Since(at) < 3*time.Second
|
||||
}
|
||||
|
||||
// RefreshIcom re-reads the whole DSP snapshot from the rig. Runs on the CAT
|
||||
// goroutine (dispatched via IcomDo).
|
||||
func (b *IcomSerial) RefreshIcom() error {
|
||||
|
||||
@@ -196,7 +196,6 @@ func (s *Server) run() {
|
||||
}
|
||||
|
||||
func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
applog.Printf("udp: [%s] rx %d bytes from %s\n", s.cfg.Name, len(pkt), remote)
|
||||
ev := Event{ConfigID: s.cfg.ID, Service: s.cfg.ServiceType, Source: remote.String()}
|
||||
switch s.cfg.ServiceType {
|
||||
case ServiceWSJT:
|
||||
@@ -229,8 +228,12 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
ev.Mode = w.Mode
|
||||
break
|
||||
}
|
||||
applog.Printf("udp: [%s] WSJT decoded: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
|
||||
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
|
||||
// Only a logged QSO is worth a line — WSJT-X/MSHV send a Status packet
|
||||
// every second, and logging each one buried the rest of the file.
|
||||
if len(w.LoggedADIF) > 0 {
|
||||
applog.Printf("udp: [%s] WSJT QSO logged: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
|
||||
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
|
||||
}
|
||||
ev.DXCall = w.DXCall
|
||||
ev.DXGrid = w.DXGrid
|
||||
ev.Mode = w.Mode
|
||||
|
||||
@@ -9,6 +9,7 @@ package powergenius
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -36,6 +37,16 @@ type Status struct {
|
||||
FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST
|
||||
Temperature float64 `json:"temperature"`
|
||||
Operate bool `json:"operate"` // OPERATE vs STANDBY (optimistic until the amp reports it)
|
||||
|
||||
// Live power, read straight from the amplifier's own status frame rather
|
||||
// than sampled off the FlexRadio meter stream. PeakW is the amp's own peak
|
||||
// detector: a poll catches one instant of the envelope, so the plain forward
|
||||
// figure lands between syllables as often as on a peak.
|
||||
FwdW float64 `json:"fwd_w"` // forward power [W] (the frame reports dBm)
|
||||
PeakW float64 `json:"peak_w"` // peak forward power [W]
|
||||
Vswr float64 `json:"vswr"` // VSWR, derived from the frame's return loss in dB
|
||||
Id float64 `json:"id"` // drain current [A]
|
||||
PeakId float64 `json:"peak_id"` // peak drain current [A]
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
@@ -226,9 +237,9 @@ func (c *Client) parse(resp string) {
|
||||
c.statusMu.Lock()
|
||||
c.status.Connected = true
|
||||
c.status.LastError = ""
|
||||
// Log each DISTINCT status payload once: the PGXL's field set isn't fully
|
||||
// documented, so this is how we learn the real key for e.g. operate/standby.
|
||||
if data != c.lastRaw {
|
||||
// One raw frame per session is enough to learn the field set — the frames
|
||||
// carry live meter values, so "log on change" logged every frame.
|
||||
if c.lastRaw == "" {
|
||||
c.lastRaw = data
|
||||
applog.Printf("pgxl: status raw=%q", data)
|
||||
}
|
||||
@@ -252,7 +263,50 @@ func (c *Client) parse(resp string) {
|
||||
c.status.FanMode = dev
|
||||
case "temp":
|
||||
c.status.Temperature, _ = strconv.ParseFloat(kv[1], 64)
|
||||
case "fwd":
|
||||
if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
|
||||
c.status.FwdW = dbmToWatts(v)
|
||||
}
|
||||
case "peakfwd":
|
||||
if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
|
||||
c.status.PeakW = dbmToWatts(v)
|
||||
}
|
||||
case "swr":
|
||||
if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
|
||||
c.status.Vswr = returnLossToVswr(v)
|
||||
}
|
||||
case "id":
|
||||
c.status.Id, _ = strconv.ParseFloat(kv[1], 64)
|
||||
case "peakid":
|
||||
c.status.PeakId, _ = strconv.ParseFloat(kv[1], 64)
|
||||
}
|
||||
}
|
||||
c.statusMu.Unlock()
|
||||
}
|
||||
|
||||
// dbmToWatts converts a power reading in dBm to watts (0 dBm = 1 mW). The amp
|
||||
// reports power that way — "fwd=60.5" is 1122 W, not 60 W.
|
||||
func dbmToWatts(dbm float64) float64 {
|
||||
if dbm <= 0 {
|
||||
return 0
|
||||
}
|
||||
return math.Pow(10, (dbm-30)/10)
|
||||
}
|
||||
|
||||
// returnLossToVswr converts the amp's "swr" field — a return loss in dB, sent
|
||||
// negative for a good match — into the VSWR ratio an operator reads.
|
||||
func returnLossToVswr(swrDb float64) float64 {
|
||||
rl := math.Abs(swrDb)
|
||||
if rl <= 0 {
|
||||
return 0
|
||||
}
|
||||
rho := math.Pow(10, -rl/20)
|
||||
if rho >= 1 {
|
||||
return 0
|
||||
}
|
||||
vswr := (1 + rho) / (1 - rho)
|
||||
if vswr > 99.9 || math.IsInf(vswr, 0) || math.IsNaN(vswr) {
|
||||
return 0
|
||||
}
|
||||
return vswr
|
||||
}
|
||||
|
||||
+129
-26
@@ -33,13 +33,11 @@ const (
|
||||
cmdOperate byte = 0x0D // toggles STANDBY ↔ OPERATE (confirmed on hw)
|
||||
cmdStatus byte = 0x90 // request the status string
|
||||
|
||||
// Best-guess keystroke codes reconstructed from the APG command table after
|
||||
// correcting the PDF's note-wrap misalignment (anchored to the confirmed
|
||||
// OPERATE=0x0D): OFF=0x0A, POWER=0x0B. The POWER key doubles as power-on (when
|
||||
// the amp is off) and the output-level cycle L→M→H (when it's on) — same as the
|
||||
// single physical POWER button. To be verified on hardware.
|
||||
cmdOff byte = 0x0A // OFF key — switch the amplifier off
|
||||
cmdPower byte = 0x0B // POWER key — turn on / cycle output power level
|
||||
// From the official APG rev 1.1 command table. Note POWER does NOT switch a
|
||||
// sleeping amp on (that is the RTS/DTR wake, see PowerOn) — on a running amp
|
||||
// it cycles the output level L→M→H, which is all we use it for.
|
||||
cmdOff byte = 0x0A // SWITCH OFF key — how PowerOff switches the amp off
|
||||
cmdPower byte = 0x0B // POWER key — cycles the output power level
|
||||
|
||||
syncHost = 0x55
|
||||
syncAmp = 0xAA
|
||||
@@ -47,11 +45,16 @@ const (
|
||||
dialTimeout = 5 * time.Second
|
||||
ioTimeout = 3 * time.Second
|
||||
pollEvery = 800 * time.Millisecond
|
||||
|
||||
// How long a modem line is held LOW before being raised again, to give the
|
||||
// amp's edge-triggered remote-on input a transition it can see.
|
||||
wakePulse = time.Second
|
||||
)
|
||||
|
||||
// Status is the decoded amplifier state for the UI.
|
||||
type Status struct {
|
||||
Connected bool `json:"connected"`
|
||||
Transport string `json:"transport"` // "serial" | "tcp" — the UI enables power-ON on serial even when asleep
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
Model string `json:"model,omitempty"` // "20K" / "13K"
|
||||
Operate bool `json:"operate"` // true = OPERATE, false = STANDBY
|
||||
@@ -91,6 +94,8 @@ type Client struct {
|
||||
|
||||
stop chan struct{}
|
||||
running bool
|
||||
|
||||
lastConnErr string // last connect failure logged (log only on change)
|
||||
}
|
||||
|
||||
func New(cfg Config) *Client {
|
||||
@@ -123,7 +128,13 @@ func (c *Client) Stop() {
|
||||
func (c *Client) GetStatus() Status {
|
||||
c.statusMu.RLock()
|
||||
defer c.statusMu.RUnlock()
|
||||
return c.status
|
||||
s := c.status
|
||||
if c.cfg.Transport == "tcp" {
|
||||
s.Transport = "tcp"
|
||||
} else {
|
||||
s.Transport = "serial"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (c *Client) setErr(err error) {
|
||||
@@ -146,27 +157,79 @@ func (c *Client) Operate(on bool) error {
|
||||
// ToggleOperate flips STANDBY/OPERATE unconditionally.
|
||||
func (c *Client) ToggleOperate() error { return c.sendCmd(cmdOperate) }
|
||||
|
||||
// PowerOn turns the amplifier on. Per the SPE manual this is NOT a data command
|
||||
// but a hardware DTR pulse on the serial line (the "Remote_ON" pin), which needs
|
||||
// the special SPE cable — so it only applies to the serial transport. Over TCP
|
||||
// there is no DTR line and power-on isn't possible remotely.
|
||||
// PowerOn switches the amplifier on with a RISING EDGE on RTS, then on DTR.
|
||||
//
|
||||
// Confirmed on a live 1.3K-FA. The remote-on input is EDGE-triggered, which is
|
||||
// why holding the lines high does nothing: a plain port open already leaves both
|
||||
// high (the serial library's default), so there is no transition left to give.
|
||||
// Hence low → pause → high on each line, in that order. The keystroke route is a
|
||||
// dead end — a switched-off amp does not answer its UART at all, and the POWER
|
||||
// key (0x0B) merely cycles L/M/H on an amp that is already running.
|
||||
//
|
||||
// Serial only: over TCP there are no modem lines. The port is (re)opened first,
|
||||
// since the poll loop has dropped the connection to a sleeping amp long before
|
||||
// the user clicks ON.
|
||||
func (c *Client) PowerOn() error {
|
||||
if c.cfg.Transport == "tcp" {
|
||||
return fmt.Errorf("power-on needs the serial RTS/DTR lines; not available over a network bridge")
|
||||
}
|
||||
// On an amp that is already awake there is nothing to wake.
|
||||
if c.GetStatus().Connected {
|
||||
return nil
|
||||
}
|
||||
// The poll goroutine may still be inside a blocking read on the old handle;
|
||||
// Windows keeps the port "busy" until that read times out (ioTimeout). Retry
|
||||
// the open for a little longer than that.
|
||||
var err error
|
||||
deadline := time.Now().Add(ioTimeout + 3*time.Second)
|
||||
for {
|
||||
if err = c.ensureConn(); err == nil || time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
if err != nil {
|
||||
applog.Printf("spe: power ON failed — cannot open %s: %v", c.cfg.ComPort, err)
|
||||
return fmt.Errorf("power-on: %w", err)
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
sp, ok := c.conn.(serial.Port)
|
||||
c.mu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("power-on needs the serial DTR line (special SPE cable); not available over network")
|
||||
return fmt.Errorf("power-on needs a serial connection")
|
||||
}
|
||||
// A single positive pulse (~1.2s) on DTR, as the manual describes.
|
||||
if err := sp.SetDTR(true); err != nil {
|
||||
return err
|
||||
pulse := func(set func(bool) error) error {
|
||||
if err := set(false); err != nil {
|
||||
return err
|
||||
}
|
||||
time.Sleep(wakePulse)
|
||||
return set(true)
|
||||
}
|
||||
time.Sleep(1200 * time.Millisecond)
|
||||
return sp.SetDTR(false)
|
||||
if err = pulse(sp.SetRTS); err == nil {
|
||||
err = pulse(sp.SetDTR)
|
||||
}
|
||||
// Booting, the amp re-enumerates its USB interface, which leaves this handle
|
||||
// pointing at a device that no longer exists — the amp came up and OpsLog
|
||||
// still read "offline". Drop it; the poll loop reopens a fresh one as soon as
|
||||
// the port is back.
|
||||
c.mu.Lock()
|
||||
c.dropLocked()
|
||||
c.mu.Unlock()
|
||||
applog.Printf("spe: power ON — RTS then DTR pulsed on %s (err=%v)", c.cfg.ComPort, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// PowerOff presses the OFF key (switches the amp off).
|
||||
func (c *Client) PowerOff() error { return c.sendCmd(cmdOff) }
|
||||
// PowerOff presses the SWITCH OFF key (0x0A) — the amp is running, so it hears
|
||||
// its UART. Dropping DTR then RTS switches it off too (it is the mirror of the
|
||||
// wake, and does work when done by hand), but back-to-back from one click it
|
||||
// did not, while this keystroke has never once failed. The amp then stays off:
|
||||
// the poll loop keeps reopening the port with both lines high and that has
|
||||
// never woken it — only the deliberate low→high sequence in PowerOn does.
|
||||
func (c *Client) PowerOff() error {
|
||||
err := c.sendCmd(cmdOff)
|
||||
applog.Printf("spe: power OFF — SWITCH OFF key sent (err=%v)", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetPowerLevel cycles the output power level (L/M/H) to the requested one by
|
||||
// tapping the POWER key (0x0B) and WAITING for the amp to actually report the new
|
||||
@@ -206,9 +269,16 @@ func (c *Client) pollLoop() {
|
||||
return
|
||||
case <-t.C:
|
||||
if err := c.ensureConn(); err != nil {
|
||||
// Logged once per distinct message: "the amp is on but OpsLog
|
||||
// says offline" was impossible to diagnose without the reason.
|
||||
if msg := err.Error(); msg != c.lastConnErr {
|
||||
c.lastConnErr = msg
|
||||
applog.Printf("spe: connect %s failed: %s", c.cfg.ComPort, msg)
|
||||
}
|
||||
c.setErr(fmt.Errorf("connect: %w", err))
|
||||
continue
|
||||
}
|
||||
c.lastConnErr = ""
|
||||
// The amp streams status frames faster than one per poll, so a backlog
|
||||
// builds up and we'd read stale frames (the display lagged ~15s behind
|
||||
// the real amp). Flush anything pending, THEN request + read exactly one
|
||||
@@ -239,16 +309,48 @@ func (c *Client) ensureConn() error {
|
||||
nc, err = net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout)
|
||||
rwc = nc
|
||||
} else {
|
||||
rwc, err = serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
|
||||
var sp serial.Port
|
||||
// Plain open: both modem lines come up high (the library's default), which
|
||||
// is what the interface needs to talk. That is not enough to switch the amp
|
||||
// on — the remote-on input wants the deliberate low→high sequence PowerOn
|
||||
// sends — so reconnecting never wakes an amplifier the operator switched
|
||||
// off, and forcing either line low here would switch a running one off.
|
||||
sp, err = serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
|
||||
if err == nil {
|
||||
// Without this, a read on a sleeping amp blocks forever inside the
|
||||
// Windows serial driver — the poll goroutine survived the window and
|
||||
// the process took ~a minute to die.
|
||||
_ = sp.SetReadTimeout(ioTimeout)
|
||||
rwc = sp
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.conn = rwc
|
||||
c.r = bufio.NewReader(rwc)
|
||||
if sp, ok := rwc.(serial.Port); ok {
|
||||
// The driver reports a read timeout as "0 bytes, no error", which bufio
|
||||
// spins on; surface it as a real error so the poll loop drops and retries.
|
||||
c.r = bufio.NewReader(serialTimeoutReader{sp})
|
||||
} else {
|
||||
c.r = bufio.NewReader(rwc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// serialTimeoutReader converts the serial driver's timeout convention (n=0,
|
||||
// err=nil once SetReadTimeout expires) into an explicit error, so a bufio
|
||||
// reader fails fast instead of retrying an amp that is asleep.
|
||||
type serialTimeoutReader struct{ p serial.Port }
|
||||
|
||||
func (s serialTimeoutReader) Read(b []byte) (int, error) {
|
||||
n, err := s.p.Read(b)
|
||||
if n == 0 && err == nil {
|
||||
return 0, fmt.Errorf("spe: read timeout")
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *Client) dropLocked() {
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
@@ -361,9 +463,10 @@ func (c *Client) decodeCSV(payload string) {
|
||||
|
||||
c.statusMu.Lock()
|
||||
defer c.statusMu.Unlock()
|
||||
// Log the raw status frame ONCE per change, so field alignment can be verified
|
||||
// against real hardware (the doc's 19-field layout may differ per firmware).
|
||||
if payload != c.lastRaw {
|
||||
// One raw frame per session is enough to verify field alignment against real
|
||||
// hardware — the frame carries live meter values, so logging each change
|
||||
// meant logging nearly every frame.
|
||||
if c.lastRaw == "" {
|
||||
c.lastRaw = payload
|
||||
applog.Printf("spe: status raw=%q fields=%d", payload, len(f))
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ type Status struct {
|
||||
|
||||
FwdDbm float64 `json:"fwd_dbm"` // forward power [dBm], as reported
|
||||
FwdW float64 `json:"fwd_w"` // forward power [W], derived from dBm
|
||||
PeakW float64 `json:"peak_w"` // the device's own peak forward power [W] — what the meters show
|
||||
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)
|
||||
|
||||
@@ -405,7 +406,9 @@ func (c *Client) parse(resp string) {
|
||||
if !strings.HasPrefix(data, "status") {
|
||||
return
|
||||
}
|
||||
if data != c.lastRaw {
|
||||
// One raw frame per session is enough to verify field alignment — the
|
||||
// frames carry live meter values, so "log on change" logged every frame.
|
||||
if c.lastRaw == "" {
|
||||
c.lastRaw = data
|
||||
applog.Printf("tunergenius: status raw=%q", data)
|
||||
}
|
||||
@@ -439,6 +442,13 @@ func (c *Client) applyStatus(data string) {
|
||||
c.status.FwdDbm = v
|
||||
c.status.FwdW = dbmToWatts(v)
|
||||
}
|
||||
// The device's OWN peak detector. A poll samples one instant of the envelope,
|
||||
// so on SSB the plain "fwd" figure lands between syllables as often as on a
|
||||
// peak — the widget read a few hundred watts on a kilowatt transmission. This
|
||||
// is the number to display.
|
||||
if v, ok := parseFloat(kv["peak"]); ok {
|
||||
c.status.PeakW = dbmToWatts(v)
|
||||
}
|
||||
if v, ok := parseFloat(kv["swr"]); ok {
|
||||
c.status.SwrDb = v
|
||||
c.status.Vswr = returnLossToVswr(v)
|
||||
|
||||
Reference in New Issue
Block a user