feat: panadapter spots that read, a CI-V link that survives, no auto-call
Panadapter spots are now worth reading. The comment carries the spotter, the
entity and the status in DXHunter's own shape — "CQ up 2 [F4BPO] [Franz Josef
Land] [New Slot]" — which needed two things nothing documents: SmartSDR splits
its command line on SPACES, so the words ran together until every space became
non-breaking; and it truncates past ~60 characters, so the cluster's own words
are trimmed first and the three brackets always survive. RBN column padding is
collapsed on the way in, or a preserved run of spaces opened a gap wide enough
to push the rest off screen.
"Already worked" means the CALLSIGN is in the log, not the entity: saying it of
a station never contacted was simply wrong. Each status can also be kept off the
panadapter entirely, and the WSJT-X decode spots obey the same switches — the
palette governs the panadapter, not one of the two things that feed it.
And the radio is no longer hammered: a spot whose frequency, colour and comment
are unchanged is not removed and redrawn. A busy skimmer feed re-spots the same
station every few seconds; one two-minute session sent 2128 adds, 88 of them for
a single callsign, and the display did not move a pixel for any of them.
CI-V, from an IC-7850 that kept killing JTDX: a reply the rig sent to another
controller on the same bus is no longer taken for ours, and a set_ptt, set_freq
or set_mode whose acknowledgement goes missing is verified by reading the rig
back instead of being reported as a failure. WSJT-X and JTDX answer a failed
command with a Rig Control Error and drop the link mid-over — 98 keyings, 6 lost
acknowledgements, 2 dropped connections in one session. The check waits 700 ms,
not the poll's 150: the rig has just failed to answer twice because it was
retuning, and a short probe would fail for the same reason.
Auto-call is withdrawn — it duplicated DXHunter, which already answers decodes,
and two programs deciding that from one shack key over each other. The library
is kept whole and dormant; a guard in App.tsx makes sure a stored preference
cannot key a transmitter whose switch no longer exists.
Also:
- the log rotates while running, not only at startup: the CI-V trace left on
wrote 416 MB and nothing would have stopped it before the disk did. Closing
it now releases the crash file too — the runtime keeps its own duplicate.
- the interface zoom announces itself, with a badge, a click back to 100% and
a View menu; Ctrl+wheel and Ctrl+0 always worked and nothing said so.
- no more elastic bounce, and no swipe-to-navigate out of the app.
- Edit QSO: your own TX power and the contacted station's extended locator
were saved and written back with no box to set them.
- FT decodes: continents are a multiple choice; a compound MSHV message that
answers two stations in one line is recognised as addressed to you.
This commit is contained in:
@@ -20,9 +20,15 @@ var (
|
||||
mu sync.Mutex
|
||||
file *os.File
|
||||
path string
|
||||
written int64 // bytes in the CURRENT file, for the size check in Printf
|
||||
crashFile *os.File // kept open so the runtime can write a crash traceback to it
|
||||
)
|
||||
|
||||
// maxLogBytes is where the file rolls over, at startup AND while running.
|
||||
// A variable, not a constant, so the rotation can be exercised in a test without
|
||||
// writing ten megabytes to do it.
|
||||
var maxLogBytes int64 = 10 * 1024 * 1024
|
||||
|
||||
// Init opens (creates) the log file in dataDir. On rotation we truncate
|
||||
// at startup if the file is too big; for now it's a single file, no
|
||||
// rolling — the volume is low (a few KB per session).
|
||||
@@ -47,12 +53,12 @@ func Init(dataDir string) (string, error) {
|
||||
_ = os.Rename(oldLog, logPath)
|
||||
}
|
||||
}
|
||||
// Rotate (don't delete) once the file grows past ~10MB: rename it to
|
||||
// Rotate (don't delete) once the file grows past maxLogBytes: rename it to
|
||||
// opslog.log.1 so the PREVIOUS session's log survives. Deleting it outright used
|
||||
// to erase exactly the diagnostics we needed when a user reported an issue from
|
||||
// the session that just ended. One generation kept — enough, without unbounded growth.
|
||||
if fi, err := os.Stat(logPath); err == nil && fi.Size() > 10*1024*1024 {
|
||||
_ = os.Remove(logPath + ".1") // drop the older generation
|
||||
if fi, err := os.Stat(logPath); err == nil && fi.Size() > maxLogBytes {
|
||||
_ = os.Remove(logPath + ".1") // drop the older generation
|
||||
if err := os.Rename(logPath, logPath+".1"); err != nil {
|
||||
_ = os.Remove(logPath) // rename failed (locked?) → fall back to the old behaviour
|
||||
}
|
||||
@@ -96,13 +102,57 @@ func Printf(format string, args ...any) {
|
||||
msg = msg[:len(msg)-1]
|
||||
}
|
||||
if file != nil {
|
||||
fmt.Fprintf(file, "%s %s\n", stamp, msg)
|
||||
n, _ := fmt.Fprintf(file, "%s %s\n", stamp, msg)
|
||||
written += int64(n)
|
||||
rotateIfBigLocked()
|
||||
}
|
||||
// Also dump to stderr in case the binary was launched with a console
|
||||
// attached (wails dev, custom build).
|
||||
fmt.Fprintf(os.Stderr, "%s %s\n", stamp, msg)
|
||||
}
|
||||
|
||||
// rotateIfBigLocked rolls the file over mid-session once it passes maxLogBytes.
|
||||
//
|
||||
// The check used to happen at STARTUP only, on the reasonable belief that a
|
||||
// session writes a few kilobytes. The CI-V wire trace disproved it: left on for
|
||||
// a day it wrote 416 MB, and nothing would have stopped it before the disk did.
|
||||
// A log that has to be rotated by quitting the program is not a bound.
|
||||
//
|
||||
// Same one-generation scheme as the startup rotation, so the previous stretch
|
||||
// survives — which is the half an operator usually needs, the part just before
|
||||
// the thing they are reporting.
|
||||
//
|
||||
// Caller holds mu.
|
||||
func rotateIfBigLocked() {
|
||||
if written < maxLogBytes || file == nil || path == "" {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(file, "%s ── log reached %d MB, rotating to %s.1 ──\n",
|
||||
time.Now().Format("15:04:05.000"), maxLogBytes/(1024*1024), filepath.Base(path))
|
||||
_ = file.Close()
|
||||
file = nil
|
||||
_ = os.Remove(path + ".1")
|
||||
if err := os.Rename(path, path+".1"); err != nil {
|
||||
// Rename refused (the file is held open elsewhere, a virus scanner): keep
|
||||
// writing to the old handle rather than losing the log entirely, and try
|
||||
// again in another maxLogBytes rather than on every single line.
|
||||
f, oerr := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if oerr == nil {
|
||||
file = f
|
||||
}
|
||||
written = 0
|
||||
return
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return // nothing more to be done; the next Printf simply writes nowhere
|
||||
}
|
||||
file = f
|
||||
written = 0
|
||||
fmt.Fprintf(file, "%s ── continued from %s.1 ──\n",
|
||||
time.Now().Format("15:04:05.000"), filepath.Base(path))
|
||||
}
|
||||
|
||||
// Path returns where the file is so the UI can surface it.
|
||||
func Path() string {
|
||||
mu.Lock()
|
||||
@@ -118,4 +168,19 @@ func Close() {
|
||||
_ = file.Close()
|
||||
file = nil
|
||||
}
|
||||
// The crash file is held open for the whole run so the runtime can dump a
|
||||
// traceback into it without allocating; on the way out it has to be released
|
||||
// like any other handle, or the data directory cannot be moved or removed.
|
||||
if crashFile != nil {
|
||||
// The RUNTIME holds its own duplicate of this handle (SetCrashOutput), so
|
||||
// closing ours releases nothing on Windows — the data directory stays
|
||||
// locked. Hand the runtime a nil first. Anything fatal in the last moments
|
||||
// of shutdown is no longer captured, which is the right trade at a point
|
||||
// where the databases are already closed.
|
||||
_ = debug.SetCrashOutput(nil, debug.CrashOptions{})
|
||||
_ = crashFile.Close()
|
||||
crashFile = nil
|
||||
}
|
||||
path = ""
|
||||
written = 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package applog
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The log has to be bounded WHILE RUNNING, not only at startup. The CI-V wire
|
||||
// trace left on for a day wrote 416 MB, and nothing would have stopped it
|
||||
// before the disk did.
|
||||
func TestRotatesMidSession(t *testing.T) {
|
||||
orig := maxLogBytes
|
||||
maxLogBytes = 8 * 1024 // small, so the test costs milliseconds
|
||||
defer func() { maxLogBytes = orig }()
|
||||
|
||||
dir := t.TempDir()
|
||||
p, err := Init(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer Close()
|
||||
|
||||
line := strings.Repeat("x", 256)
|
||||
for i := 0; i < 100; i++ { // ~25 KB: several times the limit
|
||||
Printf("%s", line)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(p + ".1"); err != nil {
|
||||
t.Fatalf("no previous generation kept: %v", err)
|
||||
}
|
||||
fi, err := os.Stat(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fi.Size() > maxLogBytes {
|
||||
t.Fatalf("current log is %d bytes, over the %d limit — it did not roll over", fi.Size(), maxLogBytes)
|
||||
}
|
||||
// The new file says where the rest went: a log that starts mid-sentence with
|
||||
// no explanation is how an operator concludes the app lost its history.
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(b), "continued from "+filepath.Base(p)+".1") {
|
||||
t.Error("the new file does not point at the one it continues")
|
||||
}
|
||||
}
|
||||
+34
-1
@@ -74,6 +74,15 @@ type Flex struct {
|
||||
// end up outside the new window — see ZoomPan.
|
||||
panWindow map[string]panView
|
||||
|
||||
// spotSig is what each live spot LOOKS like, and when it was drawn. A
|
||||
// callsign spotted again with the same frequency, colour and comment does not
|
||||
// need to be removed and re-added: the panadapter would not change by one
|
||||
// pixel, and a busy RBN feed re-spots the same station every few seconds. One
|
||||
// two-minute session sent 2 128 adds and 1 634 removes, 88 of them for a
|
||||
// single callsign — see SendSpot.
|
||||
spotSig map[string]string
|
||||
spotSent map[string]time.Time
|
||||
|
||||
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
|
||||
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
||||
|
||||
@@ -205,7 +214,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
||||
return &Flex{
|
||||
host: strings.TrimSpace(host), port: port,
|
||||
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotFreq: map[int]int64{}, pendingSpotFreq: map[int]int64{}, panWindow: map[string]panView{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotFreq: map[int]int64{}, pendingSpotFreq: map[int]int64{}, panWindow: map[string]panView{}, spotSig: map[string]string{}, spotSent: map[string]time.Time{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
||||
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||
pinnedSlice: -1,
|
||||
@@ -859,6 +868,13 @@ func (f *Flex) handleStatus(payload string) {
|
||||
}
|
||||
f.mu.Lock()
|
||||
if removed {
|
||||
// The radio expired or dropped this spot: forget what it looked
|
||||
// like, or the next identical spot would be skipped as "already
|
||||
// drawn" and the station would never come back.
|
||||
if c, ok := f.spotCall[idx]; ok {
|
||||
delete(f.spotSig, strings.ToUpper(c))
|
||||
delete(f.spotSent, strings.ToUpper(c))
|
||||
}
|
||||
delete(f.spotIdx, idx)
|
||||
delete(f.spotCall, idx)
|
||||
delete(f.spotMode, idx)
|
||||
@@ -1390,7 +1406,22 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
||||
// and Go mutexes aren't reentrant (calling send while locked deadlocks the
|
||||
// whole Flex goroutine → the radio drops OFFLINE).
|
||||
upperCall := strings.ToUpper(s.Callsign)
|
||||
// Unchanged and still fresh → leave it alone.
|
||||
//
|
||||
// Refreshed once past half its lifetime, so a long-lived spot still gets its
|
||||
// timer wound on and never quietly expires off the panadapter; anything that
|
||||
// actually differs (a new frequency, a status that has changed colour, a
|
||||
// different comment) is redrawn at once.
|
||||
sig := fmt.Sprintf("%d|%s|%s|%s|%s", s.FreqHz, s.Mode, s.Color, s.BackgroundColor, s.Comment)
|
||||
f.mu.Lock()
|
||||
if prev, ok := f.spotSig[upperCall]; ok && prev == sig {
|
||||
if at, ok := f.spotSent[upperCall]; ok && time.Since(at) < time.Duration(life)*time.Second/2 {
|
||||
f.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
f.spotSig[upperCall] = sig
|
||||
f.spotSent[upperCall] = time.Now()
|
||||
old, hadOld := f.spotByCall[upperCall]
|
||||
if hadOld {
|
||||
delete(f.spotByCall, upperCall)
|
||||
@@ -1464,6 +1495,8 @@ func (f *Flex) ClearSpots() error {
|
||||
f.spotCall = map[int]string{}
|
||||
f.spotMode = map[int]string{}
|
||||
f.spotFreq = map[int]int64{}
|
||||
f.spotSig = map[string]string{}
|
||||
f.spotSent = map[string]time.Time{}
|
||||
f.spotByCall = map[string]int{}
|
||||
connected := f.conn != nil
|
||||
f.mu.Unlock()
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/cat/civ"
|
||||
)
|
||||
|
||||
// A CI-V bus can carry a second controller, and the rig answers each of them on
|
||||
// the same wire. A reply addressed to somebody else must not be taken for ours —
|
||||
// on an IC-7850 one arrived between a PTT command and its acknowledgement, the
|
||||
// acknowledgement was never matched, and the failure was handed to JTDX as a rig
|
||||
// control error.
|
||||
func TestForeignCIVRepliesAreIgnored(t *testing.T) {
|
||||
const rig = 0x8E
|
||||
frames, _ := civ.Scan([]byte{
|
||||
0xFE, 0xFE, 0x01, rig, 0x1C, 0x00, 0x01, 0xFD, // to controller 01 — not ours
|
||||
0xFE, 0xFE, civ.AddrController, rig, 0xFB, 0xFD, // to us: the acknowledgement
|
||||
0xFE, 0xFE, 0x00, rig, 0x00, 0x00, 0x25, 0x09, 0x14, 0x00, 0xFD, // transceive broadcast
|
||||
})
|
||||
if len(frames) != 3 {
|
||||
t.Fatalf("scanned %d frames, want 3", len(frames))
|
||||
}
|
||||
var kept []civ.Decoded
|
||||
for _, f := range frames {
|
||||
if f.From != rig {
|
||||
continue
|
||||
}
|
||||
if f.To != civ.AddrController && f.To != 0x00 {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, f)
|
||||
}
|
||||
if len(kept) != 2 {
|
||||
t.Fatalf("kept %d frames, want 2 (ours + the broadcast)", len(kept))
|
||||
}
|
||||
if kept[0].Cmd != 0xFB {
|
||||
t.Errorf("the acknowledgement was not the first kept frame: % X", kept[0].Cmd)
|
||||
}
|
||||
if kept[1].To != 0x00 {
|
||||
t.Errorf("the transceive broadcast was dropped")
|
||||
}
|
||||
}
|
||||
@@ -514,8 +514,21 @@ func (b *IcomSerial) SetFrequency(hz int64) error {
|
||||
return fmt.Errorf("invalid frequency")
|
||||
}
|
||||
b.lastSetFreq, b.lastSetFreqAt = hz, time.Now()
|
||||
return b.execIdempotent(fmt.Sprintf("set frequency %d Hz", hz),
|
||||
err := b.execIdempotent(fmt.Sprintf("set frequency %d Hz", hz),
|
||||
append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)...)
|
||||
// Same reasoning as SetPTT: a lost acknowledgement is not a failed command,
|
||||
// and WSJT-X or JTDX answer a failed set_freq by dropping the link — an
|
||||
// IC-7850 log shows exactly that at 15:16:15, the disconnect four seconds
|
||||
// later. The rig is asked what frequency it is on rather than being called a
|
||||
// failure on the strength of a missing "FB".
|
||||
if err == nil || !errors.Is(err, errIcomAckLost) {
|
||||
return err
|
||||
}
|
||||
if got, rerr := b.readFreq(); rerr == nil && got == hz {
|
||||
applog.Printf("icom: frequency %d Hz not acknowledged, but the rig is on it — treating as done", hz)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *IcomSerial) SetMode(mode string) error {
|
||||
@@ -526,7 +539,16 @@ func (b *IcomSerial) SetMode(mode string) error {
|
||||
// Set the base mode (keeping the rig's current filter by sending only the
|
||||
// mode byte), then set the data-mode flag for digital modes.
|
||||
if err := b.execIdempotent("set mode "+mode, civ.CmdSetMode, code); err != nil {
|
||||
return err
|
||||
// A lost acknowledgement is not a refused mode — see SetPTT. The mode is
|
||||
// readable, so ask rather than report a failure that would cost the
|
||||
// digital application its connection.
|
||||
if !errors.Is(err, errIcomAckLost) {
|
||||
return err
|
||||
}
|
||||
if got, ok := b.readMode(); !ok || got != code {
|
||||
return err
|
||||
}
|
||||
applog.Printf("icom: mode %s not acknowledged, but the rig reports it — treating as done", mode)
|
||||
}
|
||||
dataByte := byte(0)
|
||||
if data {
|
||||
@@ -569,12 +591,43 @@ func (b *IcomSerial) execIdempotent(what string, payload ...byte) error {
|
||||
}
|
||||
|
||||
// SetPTT keys or unkeys the transmitter (CI-V 0x1C 0x00).
|
||||
// A lost acknowledgement is NOT proof the rig ignored us, and treating it as one
|
||||
// is expensive: OpsLog fronts a Hamlib rigctl port, so the failure is handed
|
||||
// straight to WSJT-X or JTDX, which answer a failed set_ptt with a Rig Control
|
||||
// Error and drop the link mid-over. An IC-7850 log showed exactly that, three
|
||||
// times in a session, on a CI-V bus with a second controller corrupting frames.
|
||||
//
|
||||
// So when the acknowledgement does not arrive, ASK the rig what it is doing. PTT
|
||||
// is readable (0x1C 0x00), the answer is unambiguous, and a rig that is keyed
|
||||
// received the command whatever became of its reply.
|
||||
func (b *IcomSerial) SetPTT(on bool) error {
|
||||
state := byte(0)
|
||||
if on {
|
||||
state = 1
|
||||
}
|
||||
return b.execIdempotent(fmt.Sprintf("PTT %v", on), civ.CmdPTT, civ.SubPTT, state)
|
||||
err := b.execIdempotent(fmt.Sprintf("PTT %v", on), civ.CmdPTT, civ.SubPTT, state)
|
||||
if err == nil || !errors.Is(err, errIcomAckLost) {
|
||||
return err
|
||||
}
|
||||
// A GENEROUS window for the verification, not the poll's 150 ms.
|
||||
//
|
||||
// The rig has just failed to answer twice in 800 ms, and the reason seen on a
|
||||
// real IC-7850 is that it was retuning: JTDX moves the transmit frequency and
|
||||
// keys in the same breath. Asking it again with the short probe timeout would
|
||||
// fail for exactly the same reason and prove nothing.
|
||||
keyed, ok := b.readTXWithin(icomPTTVerifyTimeout)
|
||||
if ok && keyed == on {
|
||||
applog.Printf("icom: PTT %v not acknowledged, but the rig reports it — treating as done", on)
|
||||
return nil
|
||||
}
|
||||
// Logged either way, because "the fix did not work" and "the rig really did
|
||||
// not key" look identical from outside and need telling apart.
|
||||
if ok {
|
||||
applog.Printf("icom: PTT %v not acknowledged, and the rig reports %v — reporting the failure", on, keyed)
|
||||
} else {
|
||||
applog.Printf("icom: PTT %v not acknowledged, and the rig did not answer the check either", on)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// SetPower turns the transceiver on or off (CI-V 0x18). Power-ON is prefixed with
|
||||
@@ -686,6 +739,19 @@ func (b *IcomSerial) reader(port civTransport, done chan struct{}) {
|
||||
if f.From != b.rigAddr {
|
||||
continue // echo of our own command
|
||||
}
|
||||
// Addressed to US, or broadcast.
|
||||
//
|
||||
// A CI-V bus can carry a second controller — RS-BA1, wfview, another
|
||||
// logger — and the rig answers each of them in turn on the same wire.
|
||||
// Those replies come FROM the rig, so the check above lets them through,
|
||||
// and one can be taken for the answer to our own command: an IC-7850 log
|
||||
// showed "FE FE 01 8E …" (to controller 01) arriving between our PTT
|
||||
// command and its acknowledgement, with bus collisions around it.
|
||||
// Transceive broadcasts (to 0x00) stay welcome — that is how the rig
|
||||
// announces a knob turn.
|
||||
if f.To != civ.AddrController && f.To != 0x00 {
|
||||
continue
|
||||
}
|
||||
if f.Cmd == civ.CmdScope {
|
||||
b.route(b.specCh, f)
|
||||
continue
|
||||
@@ -1273,16 +1339,30 @@ func (b *IcomSerial) readTXFreq() (int64, bool) {
|
||||
|
||||
// readTX reads the transmit state (CI-V 0x1C 0x00): non-zero data = keyed.
|
||||
func (b *IcomSerial) readTX() bool {
|
||||
on, _ := b.readTXWithin(icomDSPTimeout)
|
||||
return on
|
||||
}
|
||||
|
||||
// icomPTTVerifyTimeout is how long the PTT check waits. Long, deliberately: it
|
||||
// runs only after a command was already given up on, so it costs nothing in the
|
||||
// normal case and everything depends on it in the abnormal one.
|
||||
const icomPTTVerifyTimeout = 700 * time.Millisecond
|
||||
|
||||
// readTXWithin reads the transmit state, reporting whether the rig ANSWERED.
|
||||
//
|
||||
// The two are different questions and the caller usually needs both: "not
|
||||
// transmitting" and "did not say" are the same bool but not the same fact.
|
||||
func (b *IcomSerial) readTXWithin(timeout time.Duration) (on bool, answered bool) {
|
||||
if err := b.write(civ.CmdPTT, civ.SubPTT); err != nil {
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
||||
f, err := b.recv(timeout, func(d civ.Decoded) bool {
|
||||
return d.Cmd == civ.CmdPTT && len(d.Data) >= 2 && d.Data[0] == civ.SubPTT
|
||||
})
|
||||
if err != nil {
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
return f.Data[1] != 0
|
||||
return f.Data[1] != 0, true
|
||||
}
|
||||
|
||||
// readMeter reads a meter (CI-V 0x15) and returns it scaled to 0-100.
|
||||
|
||||
Reference in New Issue
Block a user