The motorised antenna gagged the transmitter for a second or two after it had finished moving. Three delays were stacked: the antenna polled every two seconds, the gag held for three after the command whatever the antenna said, and the widget refreshed every three. The antenna is now asked four times a second WHILE IT MOVES — an idle one has nothing to say and stays at two seconds — the gag only bridges the command itself (900 ms), and the widget follows at half a second while moving. WSJT-X highlighting: - A watch-list station already worked on this band and mode is no longer painted as one to call. The list is a statement of intent, not of what is left to do, and its pink outranked every other verdict including the log's, so a worked station stayed pink for the session with nothing to tell it from one still needed. - The four colours are the operator's to choose (Settings → UDP). Only the background: the text colour is derived by luma, so a dark blue cannot come back as black-on-black in somebody else's window. Changing one clears the installed highlights, or the de-duplication would keep showing yesterday's colour until a callsign changed verdict.
308 lines
11 KiB
Go
308 lines
11 KiB
Go
package main
|
|
|
|
// Log-aware colours in WSJT-X / JTDX's own Band Activity window (message 13),
|
|
// the way JTAlert paints them: a decode of a watchlist member, a new DXCC or a
|
|
// new band for its entity is highlighted where the operator is actually
|
|
// looking. The verdicts come from the same cluster status cache that colours
|
|
// the spot grid, so the two windows can never disagree.
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"hamlog/internal/applog"
|
|
"hamlog/internal/dxcc"
|
|
udp "hamlog/internal/integrations/udp"
|
|
)
|
|
|
|
const (
|
|
keyWsjtHighlight = "udp.wsjt.highlight"
|
|
keyWsjtFollowMode = "udp.wsjt.followmode" // spot clicks switch the decoder's mode
|
|
keyWsjtHLWorked = "udp.wsjt.highlight_worked"
|
|
// One key per verdict, holding "#RRGGBB". Only the BACKGROUND is stored: the
|
|
// text colour is computed from it, so a chosen colour can never come out
|
|
// unreadable in somebody else's window.
|
|
keyWsjtColWatchlist = "udp.wsjt.colour.watchlist"
|
|
keyWsjtColNewDXCC = "udp.wsjt.colour.new_dxcc"
|
|
keyWsjtColNewBand = "udp.wsjt.colour.new_band"
|
|
keyWsjtColWorked = "udp.wsjt.colour.worked"
|
|
)
|
|
|
|
// wsjtModes are the modes a Configure message can meaningfully ask for — the
|
|
// decoder's own vocabulary. Anything else (CW, SSB, RTTY) is none of its
|
|
// business and is not sent.
|
|
var wsjtModes = map[string]bool{
|
|
"FT8": true, "FT4": true, "JT65": true, "JT9": true,
|
|
"MSK144": true, "Q65": true, "FST4": true, "JS8": false, // JS8Call speaks another protocol
|
|
}
|
|
|
|
// GetWsjtFollowMode reports whether spot clicks retune the decoder's mode.
|
|
func (a *App) GetWsjtFollowMode() bool {
|
|
return a.settingOr(keyWsjtFollowMode, "1") == "1"
|
|
}
|
|
|
|
// SetWsjtFollowMode flips it.
|
|
func (a *App) SetWsjtFollowMode(on bool) {
|
|
v := "0"
|
|
if on {
|
|
v = "1"
|
|
}
|
|
a.setSetting(keyWsjtFollowMode, v)
|
|
}
|
|
|
|
// ConfigureDecoderMode asks the connected decoders to switch mode — called by
|
|
// the frontend after a spot click has tuned the radio. A no-op for modes the
|
|
// decoder does not speak, and when the option is off or nothing is connected.
|
|
func (a *App) ConfigureDecoderMode(mode string) {
|
|
mode = strings.ToUpper(strings.TrimSpace(mode))
|
|
if a.udp == nil || !wsjtModes[mode] || !a.GetWsjtFollowMode() {
|
|
return
|
|
}
|
|
a.udp.SendConfigureMode(mode)
|
|
}
|
|
|
|
// The DEFAULT palette. Fixed colours, not theme tokens — they are painted into
|
|
// another application's window, which has no idea what theme OpsLog wears, and
|
|
// the operator can change each of them (see WsjtHighlightColours).
|
|
var (
|
|
hlWatchlist = udp.RGB{R: 244, G: 114, B: 182} // the watchlist pink
|
|
hlNewDXCC = udp.RGB{R: 22, G: 130, B: 60} // green
|
|
hlNewBand = udp.RGB{R: 226, G: 122, B: 24} // orange
|
|
hlWhite = udp.RGB{R: 255, G: 255, B: 255}
|
|
hlBlack = udp.RGB{R: 20, G: 20, B: 20}
|
|
// Worked already, on this band and in this mode. Grey on purpose, and the
|
|
// only DIM colour of the four: the others say "look at this", and this one
|
|
// says the opposite — it has to recede, not compete with them.
|
|
hlWorked = udp.RGB{R: 75, G: 85, B: 99}
|
|
)
|
|
|
|
// WsjtHighlightColours is the operator's palette, one background per verdict.
|
|
type WsjtHighlightColours struct {
|
|
Watchlist string `json:"watchlist"`
|
|
NewDXCC string `json:"new_dxcc"`
|
|
NewBand string `json:"new_band"`
|
|
Worked string `json:"worked"`
|
|
}
|
|
|
|
// GetWsjtHighlightColours returns the palette in "#RRGGBB", defaults included.
|
|
func (a *App) GetWsjtHighlightColours() WsjtHighlightColours {
|
|
return WsjtHighlightColours{
|
|
Watchlist: a.settingOr(keyWsjtColWatchlist, hexOfRGB(hlWatchlist)),
|
|
NewDXCC: a.settingOr(keyWsjtColNewDXCC, hexOfRGB(hlNewDXCC)),
|
|
NewBand: a.settingOr(keyWsjtColNewBand, hexOfRGB(hlNewBand)),
|
|
Worked: a.settingOr(keyWsjtColWorked, hexOfRGB(hlWorked)),
|
|
}
|
|
}
|
|
|
|
// SetWsjtHighlightColours stores the palette and repaints.
|
|
//
|
|
// The repaint is the whole point of clearing: the de-duplication remembers what
|
|
// it has already told each decoder, so without this a callsign keeps yesterday's
|
|
// colour until it changes verdict — and the operator, having just picked a new
|
|
// one, sees nothing happen.
|
|
func (a *App) SetWsjtHighlightColours(c WsjtHighlightColours) {
|
|
set := func(key, v, def string) {
|
|
if _, ok := parseHexRGB(v); !ok {
|
|
v = def
|
|
}
|
|
a.setSetting(key, strings.ToUpper(strings.TrimSpace(v)))
|
|
}
|
|
set(keyWsjtColWatchlist, c.Watchlist, hexOfRGB(hlWatchlist))
|
|
set(keyWsjtColNewDXCC, c.NewDXCC, hexOfRGB(hlNewDXCC))
|
|
set(keyWsjtColNewBand, c.NewBand, hexOfRGB(hlNewBand))
|
|
set(keyWsjtColWorked, c.Worked, hexOfRGB(hlWorked))
|
|
a.clearWsjtHighlights()
|
|
applog.Printf("wsjt highlight: palette changed — repainting")
|
|
}
|
|
|
|
// colourFor reads one verdict's background and picks a legible foreground.
|
|
//
|
|
// The text colour is DERIVED, never stored: an operator choosing a dark blue
|
|
// would otherwise get black text on it in somebody else's window and conclude
|
|
// the feature is broken. Rec. 601 luma, the same rule a browser's contrast
|
|
// checker uses, with the threshold where black stops being readable.
|
|
func (a *App) colourFor(key, def string) (udp.RGB, udp.RGB) {
|
|
bg, ok := parseHexRGB(a.settingOr(key, def))
|
|
if !ok {
|
|
bg, _ = parseHexRGB(def)
|
|
}
|
|
luma := (299*int(bg.R) + 587*int(bg.G) + 114*int(bg.B)) / 1000
|
|
if luma < 140 {
|
|
return bg, hlWhite
|
|
}
|
|
return bg, hlBlack
|
|
}
|
|
|
|
func hexOfRGB(c udp.RGB) string { return fmt.Sprintf("#%02X%02X%02X", c.R, c.G, c.B) }
|
|
|
|
// parseHexRGB reads "#RRGGBB" (or "RRGGBB"). Anything else is refused rather
|
|
// than half-read: a colour that silently becomes black is worse than a default.
|
|
func parseHexRGB(s string) (udp.RGB, bool) {
|
|
s = strings.TrimPrefix(strings.TrimSpace(s), "#")
|
|
if len(s) != 6 {
|
|
return udp.RGB{}, false
|
|
}
|
|
v, err := strconv.ParseUint(s, 16, 32)
|
|
if err != nil {
|
|
return udp.RGB{}, false
|
|
}
|
|
return udp.RGB{R: byte(v >> 16), G: byte(v >> 8), B: byte(v)}, true
|
|
}
|
|
|
|
// GetWsjtHighlightWorked reports whether stations already worked on this band
|
|
// and mode are greyed out as well.
|
|
//
|
|
// Its own switch, and off by default. The other three verdicts pick out a
|
|
// handful of decodes in a period; this one can match most of them on a
|
|
// well-filled log, and a screen where nearly every line is coloured has stopped
|
|
// saying anything. It is worth having only for an operator who wants the dupes
|
|
// struck out rather than the catches picked out.
|
|
func (a *App) GetWsjtHighlightWorked() bool {
|
|
return a.settingOr(keyWsjtHLWorked, "0") == "1"
|
|
}
|
|
|
|
// SetWsjtHighlightWorked flips it, and repaints.
|
|
//
|
|
// Turning it OFF has to clear what it painted: those callsigns keep their grey
|
|
// in the decoder's window otherwise, and nothing would ever say another word
|
|
// about them — the de-duplication remembers that they were already told.
|
|
func (a *App) SetWsjtHighlightWorked(on bool) {
|
|
v := "0"
|
|
if on {
|
|
v = "1"
|
|
}
|
|
a.setSetting(keyWsjtHLWorked, v)
|
|
a.wsjtHLWorkedOn.Store(on)
|
|
a.clearWsjtHighlights()
|
|
applog.Printf("wsjt highlight: worked stations %v", on)
|
|
}
|
|
|
|
// clearWsjtHighlights wipes every instruction OpsLog installed, in every
|
|
// running decoder, and forgets what it had said.
|
|
func (a *App) clearWsjtHighlights() {
|
|
if a.udp == nil {
|
|
return
|
|
}
|
|
for _, inst := range a.udp.Instances() {
|
|
_ = a.udp.SendClearHighlights(inst)
|
|
}
|
|
a.wsjtHLMu.Lock()
|
|
a.wsjtHLSent = map[string]string{}
|
|
a.wsjtHLMu.Unlock()
|
|
}
|
|
|
|
// GetWsjtHighlight reports whether decode highlighting is on.
|
|
func (a *App) GetWsjtHighlight() bool {
|
|
return a.settingOr(keyWsjtHighlight, "0") == "1"
|
|
}
|
|
|
|
// SetWsjtHighlight turns decode highlighting on or off. Turning it OFF also
|
|
// clears every instruction OpsLog installed in the running applications — a
|
|
// disabled option that leaves stale colours behind looks broken, not disabled.
|
|
func (a *App) SetWsjtHighlight(on bool) {
|
|
v := "0"
|
|
if on {
|
|
v = "1"
|
|
}
|
|
a.setSetting(keyWsjtHighlight, v)
|
|
a.wsjtHighlightOn.Store(on)
|
|
if !on {
|
|
a.clearWsjtHighlights()
|
|
applog.Printf("wsjt highlight: off — cleared in every instance")
|
|
}
|
|
}
|
|
|
|
// maybeHighlightDecode paints one decoded callsign in the instance that heard
|
|
// it, when the option is on and the verdict is worth a colour. De-duplicated
|
|
// per instance+call+verdict: a station CQing all evening is decoded four times
|
|
// a minute, and the instruction only needs to be said once.
|
|
func (a *App) maybeHighlightDecode(instance, call, band, mode string) {
|
|
if !a.wsjtHighlightOn.Load() || a.udp == nil || call == "" || instance == "" {
|
|
return
|
|
}
|
|
bg, fg, verdict := a.decodeHighlightVerdict(call, band, mode)
|
|
// The MODE is part of the key: one receiver can be handed FT8 and FT4 in
|
|
// the same session, and the verdict on a callsign is not the same in both.
|
|
key := instance + "|" + strings.ToUpper(call) + "|" + band + "|" + strings.ToUpper(mode)
|
|
a.wsjtHLMu.Lock()
|
|
if a.wsjtHLSent == nil {
|
|
a.wsjtHLSent = map[string]string{}
|
|
}
|
|
if len(a.wsjtHLSent) > 4000 { // bounded; a long session just re-says a few
|
|
a.wsjtHLSent = map[string]string{}
|
|
}
|
|
prev, had := a.wsjtHLSent[key]
|
|
if had && prev == verdict {
|
|
a.wsjtHLMu.Unlock()
|
|
return
|
|
}
|
|
a.wsjtHLSent[key] = verdict
|
|
a.wsjtHLMu.Unlock()
|
|
if verdict == "" {
|
|
// Was highlighted under an earlier verdict and no longer deserves it
|
|
// (the operator just worked them): clear that one callsign.
|
|
if had && prev != "" {
|
|
_ = a.udp.SendHighlight(instance, call, nil, nil, false)
|
|
}
|
|
return
|
|
}
|
|
_ = a.udp.SendHighlight(instance, call, bg, fg, false)
|
|
}
|
|
|
|
// decodeHighlightVerdict ranks a callsign: watchlist beats new-DXCC beats
|
|
// new-band, and "already worked here" comes last of all — it is the only
|
|
// verdict that says do NOT call, so anything worth calling for outranks it.
|
|
// Anything else is "no colour", and the empty verdict doubles as the clear
|
|
// signal in maybeHighlightDecode.
|
|
func (a *App) decodeHighlightVerdict(call, band, mode string) (bg, fg *udp.RGB, verdict string) {
|
|
c := a.clusterStatusMaps()
|
|
// ALREADY WORKED HERE, whatever else the station is.
|
|
//
|
|
// Settled first because it is the one fact that cancels the others. A watch
|
|
// list entry worked on this band and mode stayed pink for the rest of the
|
|
// session — the list is a statement of intent, not of what is left to do, and
|
|
// the colour that means "call this one" was being shown for a station already
|
|
// in the log. From the operator's side there was no way to tell the two
|
|
// apart, which is the only thing the colours are for.
|
|
workedHere := false
|
|
if band != "" && mode != "" && c.workedCallSlots != nil {
|
|
m := strings.ToUpper(strings.TrimSpace(mode))
|
|
if c.normMode != nil {
|
|
m = c.normMode(m)
|
|
}
|
|
_, workedHere = c.workedCallSlots[strings.ToUpper(call)+"|"+strings.ToLower(band)+"|"+m]
|
|
}
|
|
if a.watchlist != nil && !workedHere {
|
|
if _, ok := a.watchlist.Match(call); ok {
|
|
bgc, fgc := a.colourFor(keyWsjtColWatchlist, hexOfRGB(hlWatchlist))
|
|
return &bgc, &fgc, "watchlist"
|
|
}
|
|
}
|
|
if a.dxcc != nil {
|
|
if m, ok := a.dxcc.Lookup(call); ok && m.Entity != nil {
|
|
num := dxcc.EntityDXCC(m.Entity.Name)
|
|
ent := c.entities[num]
|
|
if ent == nil {
|
|
bgc, fgc := a.colourFor(keyWsjtColNewDXCC, hexOfRGB(hlNewDXCC))
|
|
return &bgc, &fgc, "new-dxcc"
|
|
}
|
|
if band != "" {
|
|
if _, workedBand := ent.Bands[strings.ToLower(band)]; !workedBand {
|
|
bgc, fgc := a.colourFor(keyWsjtColNewBand, hexOfRGB(hlNewBand))
|
|
return &bgc, &fgc, "new-band"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// A dupe, judged by the same ledger and the same digital-mode grouping the
|
|
// cluster uses, so the two windows cannot disagree about what "worked" means.
|
|
// Its own option: on a well-filled log this matches most of a period, and a
|
|
// screen where nearly every line is coloured has stopped saying anything.
|
|
if workedHere && a.wsjtHLWorkedOn.Load() {
|
|
bgc, fgc := a.colourFor(keyWsjtColWorked, hexOfRGB(hlWorked))
|
|
return &bgc, &fgc, "worked"
|
|
}
|
|
return nil, nil, ""
|
|
}
|