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:
+124
-20
@@ -24,6 +24,16 @@ const keySpotColors = "flex.spot_colors"
|
||||
type SpotColor struct {
|
||||
Text string `json:"text"`
|
||||
Bg string `json:"bg,omitempty"`
|
||||
// Hide keeps this status OFF the panadapter entirely.
|
||||
//
|
||||
// Expressed as "hide" and not "send" so the zero value means SEND: every
|
||||
// palette stored before this existed has to keep behaving as it did, and a
|
||||
// missing field must never be read as "the operator turned this off".
|
||||
//
|
||||
// A colour and a switch rather than one control: the operator who stops
|
||||
// drawing already-worked stations today may want them back tomorrow, and
|
||||
// their colour should still be there when they do.
|
||||
Hide bool `json:"hide,omitempty"`
|
||||
}
|
||||
|
||||
// SpotColors is the whole palette, keyed by the status names the cluster uses.
|
||||
@@ -97,14 +107,17 @@ func normSpotColors(s SpotColors) SpotColors {
|
||||
if !known[k] {
|
||||
continue
|
||||
}
|
||||
c := SpotColor{}
|
||||
c := SpotColor{Hide: v.Hide}
|
||||
if hexARGB(v.Text) {
|
||||
c.Text = strings.ToUpper(strings.TrimSpace(v.Text))
|
||||
}
|
||||
if hexARGB(v.Bg) {
|
||||
c.Bg = strings.ToUpper(strings.TrimSpace(v.Bg))
|
||||
}
|
||||
if c.Text != "" || c.Bg != "" {
|
||||
// Kept when it carries ANY decision — a status with no colours but "hide"
|
||||
// set is a real instruction, and dropping it would silently switch that
|
||||
// status back on.
|
||||
if c.Text != "" || c.Bg != "" || c.Hide {
|
||||
out.Colors[k] = c
|
||||
}
|
||||
}
|
||||
@@ -190,36 +203,127 @@ func (a *App) spotColorFor(status string) SpotColor {
|
||||
func spotStatusTag(status string) string {
|
||||
switch status {
|
||||
case "new":
|
||||
return "NEW DXCC"
|
||||
return "New DXCC"
|
||||
case "new-band-mode":
|
||||
return "NEW BAND+MODE"
|
||||
return "New B&M"
|
||||
case "new-band":
|
||||
return "NEW BAND"
|
||||
return "New Band"
|
||||
case "new-mode":
|
||||
return "NEW MODE"
|
||||
return "New Mode"
|
||||
case "new-slot":
|
||||
return "NEW SLOT"
|
||||
return "New Slot"
|
||||
case "new-pota":
|
||||
return "NEW POTA"
|
||||
return "New POTA"
|
||||
case "new-county":
|
||||
return "NEW COUNTY"
|
||||
return "New Cnty"
|
||||
case "new-pfx":
|
||||
return "NEW PFX"
|
||||
return "New Pfx"
|
||||
case "my-call":
|
||||
return "ME"
|
||||
return "Me"
|
||||
case "worked":
|
||||
return "Worked"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// spotComment appends the status tag to the cluster's own comment.
|
||||
func spotComment(comment, status string) string {
|
||||
c := strings.TrimSpace(comment)
|
||||
tag := spotStatusTag(status)
|
||||
if tag == "" {
|
||||
return c
|
||||
// spotComment builds what the panadapter shows under a spot:
|
||||
//
|
||||
// CQ up 2 [F4BPO] [Franz Josef Land] [New Slot]
|
||||
//
|
||||
// the cluster's own text, then WHO spotted it, then the entity, then what it is
|
||||
// worth. Same order and same shape as DXHunter, whose users read these at a
|
||||
// glance and should not have to learn a second convention.
|
||||
//
|
||||
// # The spaces are not spaces
|
||||
//
|
||||
// SmartSDR's command line separates parameters ON SPACES, so a comment written
|
||||
// with ordinary ones arrives at the radio as "CQup2[F4BPO]" — every word run
|
||||
// together, which is exactly how ours looked. Non-breaking spaces (U+00A0) pass
|
||||
// through the parser untouched and render as spaces on the panadapter. This is
|
||||
// the trick DXHunter uses, and there is no other way to get a readable comment
|
||||
// through that protocol.
|
||||
func spotComment(comment, spotter, country, status string) string {
|
||||
var b strings.Builder
|
||||
add := func(s string) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return
|
||||
}
|
||||
if b.Len() > 0 {
|
||||
b.WriteString(" ")
|
||||
}
|
||||
b.WriteString(s)
|
||||
}
|
||||
if c == "" {
|
||||
return "[" + tag + "]"
|
||||
// The radio has the last word on length: a comment past roughly sixty
|
||||
// characters comes back from SmartSDR cut mid-word — one was echoed as
|
||||
// "…[European Russia" with the closing bracket and the status gone. So the
|
||||
// parts are added in order of what an operator would keep, and the cluster's
|
||||
// own words — the longest and the least specific — are the ones trimmed.
|
||||
// The cluster's own text arrives padded: RBN lines are column-aligned, so a
|
||||
// comment reads "FT8 5 dB LN14 CQ" with runs of spaces holding the
|
||||
// columns apart. Those runs became runs of NON-BREAKING spaces, which the
|
||||
// panadapter faithfully rendered as a gap wide enough to push the rest of the
|
||||
// comment off the screen. Columns mean nothing once the text is out of its
|
||||
// table: one space between words.
|
||||
comment = strings.Join(strings.Fields(comment), " ")
|
||||
tag := bracket(spotStatusTag(status))
|
||||
spot := bracket(spotter)
|
||||
cty := bracket(country)
|
||||
room := panCommentMax - len(tag) - len(spot) - len(cty) - 3 // 3 separators
|
||||
add(trimTo(comment, room))
|
||||
add(spot)
|
||||
add(cty)
|
||||
add(tag)
|
||||
// Every space, including the ones already inside the cluster's comment.
|
||||
return strings.ReplaceAll(b.String(), " ", "\u00A0")
|
||||
}
|
||||
|
||||
// panCommentMax is what SmartSDR keeps of a spot comment. Measured against a
|
||||
// real radio's own status echo, with a margin: the cost of being wrong is a
|
||||
// comment cut mid-word, and nothing in the protocol reports the limit.
|
||||
const panCommentMax = 58
|
||||
|
||||
// trimTo shortens s to at most n characters, on a word boundary when it can.
|
||||
// A negative or tiny budget yields nothing at all — the brackets that follow
|
||||
// matter more than a two-letter fragment of the cluster's text.
|
||||
func trimTo(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if n < 4 {
|
||||
return ""
|
||||
}
|
||||
return c + " [" + tag + "]"
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
cut := s[:n]
|
||||
if i := strings.LastIndex(cut, " "); i > n/2 {
|
||||
cut = cut[:i]
|
||||
}
|
||||
return strings.TrimSpace(cut)
|
||||
}
|
||||
|
||||
// bracket wraps a non-empty value in square brackets.
|
||||
func bracket(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
return "[" + s + "]"
|
||||
}
|
||||
|
||||
// spotHidden reports whether this status should stay off the panadapter.
|
||||
//
|
||||
// Read from the same cached palette as the colours — see spotColorFor — because
|
||||
// it is consulted on every spot from every cluster.
|
||||
func (a *App) spotHidden(status string) bool {
|
||||
a.spotColorsMu.Lock()
|
||||
if a.spotColorsCache == nil {
|
||||
c := a.GetSpotColors()
|
||||
a.spotColorsCache = &c
|
||||
}
|
||||
p := a.spotColorsCache
|
||||
a.spotColorsMu.Unlock()
|
||||
if c, ok := p.Colors[status]; ok {
|
||||
return c.Hide
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user