fix: release TX when the elements stop; WSJT-X colours, and worked beats the watch list

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.
This commit is contained in:
2026-09-06 18:43:08 +02:00
parent 07ee48e20c
commit d001616767
11 changed files with 306 additions and 31 deletions
+115 -23
View File
@@ -7,6 +7,8 @@ package main
// the spot grid, so the two windows can never disagree.
import (
"fmt"
"strconv"
"strings"
"hamlog/internal/applog"
@@ -18,6 +20,13 @@ 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
@@ -53,8 +62,9 @@ func (a *App) ConfigureDecoderMode(mode string) {
a.udp.SendConfigureMode(mode)
}
// The palette. Fixed colours, not theme tokens — they are painted into another
// application's window, which has no idea what theme OpsLog wears.
// 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
@@ -64,10 +74,82 @@ var (
// 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}
hlWorkedFg = udp.RGB{R: 203, G: 213, B: 225}
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.
//
@@ -174,42 +256,52 @@ func (a *App) maybeHighlightDecode(instance, call, band, mode string) {
// 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) {
if a.watchlist != nil {
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 {
c := hlWatchlist
f := hlBlack
return &c, &f, "watchlist"
bgc, fgc := a.colourFor(keyWsjtColWatchlist, hexOfRGB(hlWatchlist))
return &bgc, &fgc, "watchlist"
}
}
c := a.clusterStatusMaps()
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 := hlNewDXCC, hlWhite
bgc, fgc := a.colourFor(keyWsjtColNewDXCC, hexOfRGB(hlNewDXCC))
return &bgc, &fgc, "new-dxcc"
}
if band != "" {
if _, workedBand := ent.Bands[strings.ToLower(band)]; !workedBand {
bgc, fgc := hlNewBand, hlBlack
bgc, fgc := a.colourFor(keyWsjtColNewBand, hexOfRGB(hlNewBand))
return &bgc, &fgc, "new-band"
}
}
}
}
// Worked already, this exact callsign on this band in this mode — 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.
if a.wsjtHLWorkedOn.Load() && band != "" && mode != "" {
m := strings.ToUpper(strings.TrimSpace(mode))
if c.normMode != nil {
m = c.normMode(m)
}
if _, ok := c.workedCallSlots[strings.ToUpper(call)+"|"+strings.ToLower(band)+"|"+m]; ok {
bgc, fgc := hlWorked, hlWorkedFg
return &bgc, &fgc, "worked"
}
// 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, ""
}