Compare commits

...
4 Commits
Author SHA1 Message Date
rouggy 9599c3e0b9 chore: release v0.25.9 2026-08-18 05:09:04 +02:00
rouggy a81125eab1 fix(icom): a set whose acknowledgement is lost is sent once more
Extends to frequency and mode what PTT already had. A missing FB is not a
missing command: the rig acts on the frame as it decodes it, and what
expires is our wait for the answer, on a bus shared with the rig's own
transceive updates. JTDX in "Split: Fake It" moves the dial and the mode
immediately before every key-down, so those acks queue behind each other.

Losing one is fatal to the client rather than merely untidy: rigctld answers
RPRT -9, JTDX reads that as losing rig control and tears the connection down
mid-over. An operator's log shows three set_freq failures and one set_mode,
each followed within 300 ms by a fresh rigctld client -- and shows the PTT
resend rescuing an over that would otherwise have ended there.

Opt-in per caller rather than folded into exec: only a command that says
"be in this state" can be repeated safely, and a relative one must never
come through here.

The acknowledgement loss itself is still unexplained. Every failure in that
log is preceded by a state read reporting SSB on a rig in DATA, which points
at CI-V frame desync rather than a slow rig, and needs a trace to pin down.
2026-08-17 16:23:54 +02:00
rouggy bbe1b3ce80 fix(tci): a refused un-key no longer leaves the rig keyed for good
The trx handler stamped its PTT cache BEFORE commanding the radio and left
it in place when the command failed. An operator running JTDX over TCI with
an Icom on CI-V lost an un-key to a lost acknowledgement: the cache recorded
"off" regardless, and from then on every trx:0,false was dismissed as a
repeat of a state the radio had never reached. The cache is per-server, not
per-connection, so reconnecting JTDX changed nothing either -- the
transmitter stayed keyed into the amplifier, with no drive, until the radio
was switched off by hand.

The cache is now written only on success, and a failure clears "known"
outright so the next command reaches the radio whatever it is.

Second guard: releasePTT drops a PTT this server asserted when the client
disconnects, and when the server stops -- before the CAT backend goes down,
while the rig is still reachable. rigctld has had that since a K3 sat in
transmit for 29 s; the TCI server was written without it, so an operator
moving from Hamlib to TCI silently lost the protection. A later log shows
the rig keyed for 40 s across a JTDX reconnect for exactly that reason.
2026-08-17 16:23:53 +02:00
rouggy 5e80c27f61 feat(appearance): the band/mode matrix colours can be chosen
The PH/CW/DIG grid in the Stats panel is the fastest read in the app and its
palette was fixed per theme. Settings -> Appearance now offers the six: the
four status fills, the never-worked fill, and the ring on the cell being
entered.

Stored as OVERRIDES, not as a palette. Each of the twelve themes ships an
--mx-* ramp tuned to its own background, so an operator who only wants a
different green must not thereby freeze the other four to the theme they
happened to be using that day. An empty value means "whatever the theme
says"; the chosen ones are stamped inline on <html>, where they win over
every theme; switching the feature off hands the colours straight back.

The pickers are seeded from what the matrix is painting at that moment
rather than from a fixed palette, so the choice starts from the colours in
front of the operator. A new --mx-cur token carries the current-entry ring:
it follows --warning by default, so it stays theme-correct on all twelve,
but can be recoloured without dragging every other warning in the app along.

The legend under the matrix and its cell tooltips were hardcoded English.
They now go through t() with the same keys as the pickers, so the grid and
the settings cannot disagree about which green is which.
2026-08-17 16:23:39 +02:00
26 changed files with 1456 additions and 114 deletions
+257 -3
View File
@@ -114,6 +114,7 @@ const (
keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend)
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter
keyCATFlexDVKDax = "cat.flex.dvk_dax" // raise the transmit-bar DAX button while a voice message plays
keyCATFlexDecodeSpots = "cat.flex.decode_spots" // push WSJT-X decodes (heard stations) to the panadapter
keyCATFlexDecodeSecs = "cat.flex.decode_secs" // decode spot display duration (seconds) before auto-removal
keyCATPollMs = "cat.poll_ms"
@@ -245,6 +246,10 @@ const (
keyMotorBandFreqs = "motor.band_freqs" // per-band tune frequency: "40m=7100,20m=14150"
keyChaseNewGrids = "cluster.chase_grids" // "1" → persist learnt locators across restarts
keyRowColors = "appearance.row_colors"
keyRotorPresets = "rotator.presets" // quick-turn buttons on the rotor widget
keyCATOffsetHz = "cat.offset_hz" // transverter offset: real frequency rig frequency
keyCATOffsetOn = "cat.offset_on" // "1" → apply the transverter offset
keyMatrixColors = "appearance.matrix_colors" // band/mode matrix palette overrides
keyMotorType = "ultrabeam.type" // "ultrabeam" | "steppir" (default ultrabeam)
keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp)
keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0)
@@ -426,6 +431,7 @@ type CATSettings struct {
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
FlexDVKDax bool `json:"flex_dvk_dax"` // raise transmit DAX while a voice message plays, lower it after
FlexDecodeSpots bool `json:"flex_decode_spots"` // push WSJT-X decodes (heard stations) to the panadapter
FlexDecodeSecs int `json:"flex_decode_secs"` // decode spot display duration (s) before removal (default 120)
XieguPort string `json:"xiegu_port"` // Xiegu CI-V serial port (G90/X6100…)
@@ -459,6 +465,11 @@ type CATSettings struct {
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
// Transverter offset: real frequency rig frequency, in Hz. A 28 MHz IF
// driving a 144 MHz transverter is +116000000. Kept as a separate on/off so
// the number survives switching the transverter out of line for an evening.
OffsetOn bool `json:"offset_on"`
OffsetHz int64 `json:"offset_hz"`
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs
SharePort int `json:"share_port"` // TCP port for the rigctl server (default 4532)
@@ -739,6 +750,7 @@ type App struct {
startupProfile string // --profile <name> from the command line (activate at startup)
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
flexDaxRaised bool // we pressed the Flex transmit DAX button for it; restore when it ends
pttMu sync.Mutex
// confDLMu/confDLCancel cancel the in-flight confirmation download (LoTW/QRZ)
// so closing the QSL Manager — or starting another download — stops the previous
@@ -773,6 +785,7 @@ type App struct {
offlineMode bool // last write failed because the DB was unreachable
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
catFlexDVKDax bool // raise the Flex transmit DAX button around a voice message
catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter
catFlexDecodeSecs int // decode spot display duration (seconds)
liveActMu sync.Mutex // guards the entry-strip activity reported for live status
@@ -1399,6 +1412,10 @@ func (a *App) startup(ctx context.Context) {
if keyed {
go a.dvkUnkeyPTT(gen)
}
// And give the microphone back, if we took it (see raiseFlexDVKDax).
// Off the callback's goroutine: this talks to the radio, and the
// audio manager is reporting a state change, not waiting on us.
go a.lowerFlexDVKDax()
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "audio:status", st)
@@ -7507,7 +7524,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort)
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort)
if err != nil {
return CATSettings{}, err
}
@@ -7518,6 +7535,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
FlexHost: m[keyCATFlexHost],
FlexPort: 4992,
FlexSpots: m[keyCATFlexSpots] == "1",
FlexDVKDax: m[keyCATFlexDVKDax] == "1",
FlexDecodeSpots: m[keyCATFlexDecodeSpots] == "1",
FlexDecodeSecs: 120,
XieguPort: m[keyCATXieguPort],
@@ -7546,6 +7564,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
PTTHotkeyToggle: m[keyCATPttHotkeyToggle] == "1",
PollMs: 250,
DelayMs: 0,
OffsetOn: m[keyCATOffsetOn] == "1",
DigitalDefault: m[keyCATDigitalDefault],
ShareEnabled: m[keyCATShareEnabled] == "1",
SharePort: 4532,
@@ -7555,6 +7574,11 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
out.FlexPort = n
}
// Signed: a transverter can sit either side of its IF (a 144 MHz IF feeding a
// 28 MHz receive converter is a negative offset).
if n, err := strconv.ParseInt(strings.TrimSpace(m[keyCATOffsetHz]), 10, 64); err == nil {
out.OffsetHz = n
}
if n, _ := strconv.Atoi(m[keyCATFlexDecodeSecs]); n >= 10 && n <= 3600 {
out.FlexDecodeSecs = n
}
@@ -7665,6 +7689,14 @@ func (a *App) SaveCATSettings(s CATSettings) error {
if s.FlexSpots {
flexSpots = "1"
}
flexDVKDax := "0"
if s.FlexDVKDax {
flexDVKDax = "1"
}
offsetOn := "0"
if s.OffsetOn {
offsetOn = "1"
}
flexDecodeSpots := "0"
if s.FlexDecodeSpots {
flexDecodeSpots = "1"
@@ -7701,6 +7733,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
keyCATFlexPort: strconv.Itoa(s.FlexPort),
keyCATFlexSpots: flexSpots,
keyCATFlexDVKDax: flexDVKDax,
keyCATFlexDecodeSpots: flexDecodeSpots,
keyCATFlexDecodeSecs: strconv.Itoa(s.FlexDecodeSecs),
keyCATXieguPort: strings.TrimSpace(s.XieguPort),
@@ -7730,6 +7763,8 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATPttHotkeyToggle: b01(s.PTTHotkeyToggle),
keyCATPollMs: strconv.Itoa(s.PollMs),
keyCATDelayMs: strconv.Itoa(s.DelayMs),
keyCATOffsetOn: offsetOn,
keyCATOffsetHz: strconv.FormatInt(s.OffsetHz, 10),
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
keyCATShareEnabled: shareEnabled,
keyCATSharePort: strconv.Itoa(s.SharePort),
@@ -8590,6 +8625,9 @@ func (a *App) QSOAudioPlayOnAir() error {
}
cfg := cfgEarly
// Same as the voice keyer: this goes out over the air, so on a Flex the
// transmit source has to be DAX before the carrier comes up.
a.raiseFlexDVKDax()
if err := a.pttKey(cfg); err != nil {
applog.Printf("qso-rec: PTT on failed before playback: %v", err)
// Keep going — the audio still reaches the rig and the operator may use VOX.
@@ -8608,6 +8646,7 @@ func (a *App) QSOAudioPlayOnAir() error {
if keyed {
go a.dvkUnkeyPTT(gen)
}
a.lowerFlexDVKDax() // nothing went out — give the microphone straight back
return err
}
applog.Printf("qso-rec: playing the recording on the air (%d bytes)", len(pcm))
@@ -9528,6 +9567,10 @@ func (a *App) DVKPlay(slot int) error {
return fmt.Errorf("no recording in slot %d", slot)
}
cfg, _ := a.GetAudioSettings()
// Before PTT: on a Flex the transmit audio comes from the microphone or from
// DAX, so the source has to be right before the carrier goes up — otherwise
// the first syllables are transmitted as silence.
a.raiseFlexDVKDax()
if err := a.pttKey(cfg); err != nil {
applog.Printf("dvk: PTT on failed: %v", err)
// Keep going — the audio still reaches the rig; the user may use VOX.
@@ -9545,11 +9588,77 @@ func (a *App) DVKPlay(slot int) error {
if keyed {
go a.dvkUnkeyPTT(gen)
}
// Nothing is going out, so the microphone must come straight back — the
// status callback that normally does it never fires for a play that
// never started.
a.lowerFlexDVKDax()
return err
}
return nil
}
// raiseFlexDVKDax presses SmartSDR's transmit-bar DAX button before a voice
// message goes out, and remembers whether it was already down.
//
// On a FlexRadio the transmit audio comes from EITHER the microphone or DAX,
// never both. A voice keyer plays into the DAX TX stream, so with the button up
// the message is transmitted as silence; with it left down afterwards the
// operator's own microphone is dead. Pressing it by hand around every message is
// exactly the kind of thing that gets forgotten mid-pileup, hence the option.
//
// The PREVIOUS state is what gets restored, not a blanket "off": an operator
// running WSJT-X has DAX down on purpose, and lowering it for them after a voice
// message would break the digital setup to solve a problem they do not have. In
// the case this option exists for — microphone operating, DAX up — restoring the
// previous state is precisely "give me my microphone back".
//
// Silent no-op when the option is off or the radio is not a Flex: this sits on
// the voice-keyer path, which must not fail because a rig cannot do DAX.
func (a *App) raiseFlexDVKDax() {
if a.cat == nil || !a.catFlexDVKDax {
return
}
var was bool
err := a.cat.FlexDo(func(fc cat.FlexController) error {
was = fc.FlexState().TXDAX
if was {
return nil // already down — nothing to press, nothing to restore
}
return fc.SetTXDAX(true)
})
if err != nil {
applog.Printf("dvk: could not raise the Flex TX DAX button: %v", err)
return
}
if was {
return
}
a.pttMu.Lock()
a.flexDaxRaised = true
a.pttMu.Unlock()
applog.Printf("dvk: Flex TX DAX on for the voice message")
}
// lowerFlexDVKDax puts the transmit-bar DAX button back where it was, and only
// when raiseFlexDVKDax actually moved it. Called where the voice-message PTT is
// released, so the microphone comes back at the moment the message ends.
func (a *App) lowerFlexDVKDax() {
a.pttMu.Lock()
raised := a.flexDaxRaised
a.flexDaxRaised = false
a.pttMu.Unlock()
if !raised || a.cat == nil {
return
}
if err := a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXDAX(false) }); err != nil {
// Worth saying loudly: the microphone stays dead until it is pressed by
// hand, and an operator whose voice is not going out has no other clue.
applog.Printf("dvk: could not lower the Flex TX DAX button — the microphone stays off air until you press it in SmartSDR: %v", err)
return
}
applog.Printf("dvk: Flex TX DAX off — microphone back on air")
}
// dvkUnkeyPTT releases PTT after a short tail so the rig doesn't clip the end
// of the message — but ONLY if no newer key happened since (gen unchanged). A
// rapid replay (or a Test PTT) starts a fresh transmission whose key must not
@@ -11243,6 +11352,12 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
done(matched, total)
return
}
// Second pass, at mode-CLASS granularity: LoTW answers with the mode
// GROUP ("DATA"), never the submode it was given, so an FT4/FT8/FT2
// contact can never match on the exact string and the operator is told
// their own QSO is missing from their log.
classIDs, _ := a.qso.DedupeClassKeyIDs(ctx)
byClass := 0
// Snapshot award-valid confirmations (LoTW + paper QSL — the only two
// that count for ARRL awards) so each incoming one is flagged NEW.
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"})
@@ -11262,8 +11377,20 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
date = time.Now().UTC().Format("20060102")
}
a.enrichContactedFromCty(&q) // country/dxcc/zones from cty.dat
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
if id, found := keyIDs[key]; found {
minute := q.QSODate.UTC().Format("2006-01-02T15:04")
key := qso.DedupeKey(q.Callsign, minute, q.Band, q.Mode)
classKey := qso.DedupeClassKey(q.Callsign, minute, q.Band, q.Mode)
id, found := keyIDs[key]
if !found {
// The mode differs but the station, minute, band and mode class
// agree — the same contact under LoTW's group name. An ambiguous
// class key (id 0) is deliberately NOT taken: see DedupeClassKeyIDs.
if cid, ok := classIDs[classKey]; ok && cid != 0 {
id, found = cid, true
byClass++
}
}
if found {
if e := a.qso.MarkLoTWConfirmed(ctx, id, date); e == nil {
matched++
}
@@ -11273,6 +11400,7 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
q.LOTWRcvdDate = date
if newID, e := a.qso.Add(ctx, q); e == nil {
keyIDs[key] = newID // guard against dup records in the report
classIDs[classKey] = newID
added++
}
} else {
@@ -11323,6 +11451,14 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
} else {
emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total))
}
if byClass > 0 {
// Said out loud rather than folded silently into the total: these
// matched on the mode CLASS, not the mode. LoTW hands back "DATA" for
// every digital submode, so this is the normal case for FT4/FT8 — but
// an operator reconciling a log deserves to know the match was looser
// than call+minute+band+mode.
emit(fmt.Sprintf(" (%d matched on mode class — LoTW reports the group, e.g. DATA, where your log has the exact mode)", byClass))
}
// Surface confirmations with no local match so the user sees WHICH one
// and why (time off by a minute, FT4 logged as MFSK, portable call, or
// never logged). Tick "Add not-found" to import them instead.
@@ -14013,9 +14149,18 @@ func (a *App) reloadCAT() {
}
a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond)
a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond)
// Transverter offset. Zero when switched off — the manager treats "no offset"
// and "disabled" as the same thing, so the number stays in the settings for
// the next time the transverter goes back in line.
if s.OffsetOn {
a.cat.SetFreqOffset(s.OffsetHz)
} else {
a.cat.SetFreqOffset(0)
}
a.catFlexSpots = s.Enabled && ((s.Backend == "flex" && s.FlexSpots) || (s.Backend == "tci" && s.TCISpots))
a.catFlexDecodeSpots = s.Enabled && s.Backend == "flex" && s.FlexDecodeSpots
a.catFlexDecodeSecs = s.FlexDecodeSecs
a.catFlexDVKDax = s.Enabled && s.Backend == "flex" && s.FlexDVKDax
a.reloadCATShare(s)
if !s.Enabled {
a.cat.Stop()
@@ -14895,6 +15040,115 @@ func (a *App) RotatorStop() error {
}
}
// RotorPreset is one quick-turn button on the rotor widget: a short label and
// the azimuth it swings to.
type RotorPreset struct {
Label string `json:"label"`
Azimuth int `json:"azimuth"`
}
// rotorPresetMax bounds both the number of buttons and the label length. The
// widget lays them out in a fixed grid beside the dial, and a label that does not
// fit is a button whose meaning is guessed rather than read.
const (
rotorPresetMax = 8
rotorPresetLabelMax = 6
)
// rotorPresetRegions are the out-of-the-box buttons, each with a representative
// point rather than a ready-made azimuth.
//
// The bearing is COMPUTED from the operator's own square. A bearing is only
// meaningful from somewhere: a table of degrees that suits one station points
// every other one at the wrong continent, and "EU 090" is worse than no default
// at all for a station in Japan. With no square known they come out at 0 and the
// operator fills them in — which is what the editor in Settings is for.
var rotorPresetRegions = []struct {
label string
lat, lon float64
}{
{"EU", 50, 15}, // central Europe
{"NA", 40, -95}, // central United States
{"SA", -15, -60}, // Brazil
{"VK", -25, 135}, // central Australia
{"JA", 36, 138}, // Japan
{"AF", 0, 20}, // central Africa
}
// defaultRotorPresets builds the six regions as seen from the operator's grid.
func (a *App) defaultRotorPresets() []RotorPreset {
out := make([]RotorPreset, 0, len(rotorPresetRegions))
for _, r := range rotorPresetRegions {
az := 0
if a.opSet {
az = int(initialBearingDeg(a.opLat, a.opLon, r.lat, r.lon) + 0.5)
}
out = append(out, RotorPreset{Label: r.label, Azimuth: az % 360})
}
return out
}
// normRotorPresets keeps the list within what the widget can draw and the rotor
// can accept. A blank label drops the button entirely — that is how an operator
// removes one without a delete control.
func normRotorPresets(in []RotorPreset) []RotorPreset {
out := make([]RotorPreset, 0, len(in))
for _, p := range in {
label := strings.TrimSpace(p.Label)
if label == "" {
continue
}
if len(label) > rotorPresetLabelMax {
label = label[:rotorPresetLabelMax]
}
az := p.Azimuth % 360
if az < 0 {
az += 360
}
out = append(out, RotorPreset{Label: label, Azimuth: az})
if len(out) == rotorPresetMax {
break
}
}
return out
}
// GetRotorPresets returns the quick-turn buttons, falling back to the regions
// computed from the operator's square so the widget is useful before anyone
// visits the settings.
func (a *App) GetRotorPresets() []RotorPreset {
if raw := a.settingOr(keyRotorPresets, ""); raw != "" {
var saved []RotorPreset
if err := json.Unmarshal([]byte(raw), &saved); err == nil {
// A saved-but-empty list is a deliberate "no buttons", not a missing
// setting — otherwise clearing them all would resurrect the defaults.
return normRotorPresets(saved)
}
}
return a.defaultRotorPresets()
}
// SaveRotorPresets persists the buttons.
func (a *App) SaveRotorPresets(list []RotorPreset) error {
b, err := json.Marshal(normRotorPresets(list))
if err != nil {
return err
}
a.setSetting(keyRotorPresets, string(b))
return nil
}
// ResetRotorPresets puts back the six regions, recomputed from the operator's
// current square — the way to fix them after moving, or after a first run with
// no grid set.
func (a *App) ResetRotorPresets() []RotorPreset {
def := a.defaultRotorPresets()
if b, err := json.Marshal(def); err == nil {
a.setSetting(keyRotorPresets, string(b))
}
return def
}
// RotatorPark moves the active rotor to its parked position (PstRotator only;
// the 4O3A native and ARCO GS-232 protocols have no park command).
func (a *App) RotatorPark() error {
+64
View File
@@ -138,6 +138,70 @@ func normRowColors(s RowColorSettings) RowColorSettings {
return out
}
// MatrixColors recolours the band/mode matrix — the PH/CW/DIG grid in the Stats
// panel, whose five fills and current-entry ring are the fastest read in the
// whole app and the one an operator is most likely to want in their own colours.
//
// Every colour is OPTIONAL and an empty one keeps whatever the active theme
// paints. That is why this stores OVERRIDES rather than a palette: each of the
// twelve themes ships a matrix ramp tuned to its own background, and an operator
// who only wants a different green must not thereby freeze the other four to the
// theme they happened to be using the day they picked it.
type MatrixColors struct {
// Enabled off leaves the theme's own ramp untouched, so switching it off is a
// genuine revert and not "some other set of colours".
Enabled bool `json:"enabled"`
CallConfirmed string `json:"call_confirmed"`
CallWorked string `json:"call_worked"`
EntityConfirmed string `json:"entity_confirmed"`
EntityWorked string `json:"entity_worked"`
NotWorked string `json:"not_worked"`
CurrentEntry string `json:"current_entry"`
}
// normMatrixColors keeps only plain hex values. Anything else becomes "" — i.e.
// "use the theme's" — because these are written straight into a CSS custom
// property, and the same rule as hexColor's own comment applies: what cannot be
// trusted into a stylesheet is refused rather than passed through.
func normMatrixColors(c MatrixColors) MatrixColors {
clean := func(s string) string {
s = strings.TrimSpace(s)
if hexColor.MatchString(s) {
return strings.ToLower(s)
}
return ""
}
return MatrixColors{
Enabled: c.Enabled,
CallConfirmed: clean(c.CallConfirmed),
CallWorked: clean(c.CallWorked),
EntityConfirmed: clean(c.EntityConfirmed),
EntityWorked: clean(c.EntityWorked),
NotWorked: clean(c.NotWorked),
CurrentEntry: clean(c.CurrentEntry),
}
}
// GetMatrixColors returns the operator's matrix palette overrides. All-empty
// (the default) means "whatever the theme says".
func (a *App) GetMatrixColors() MatrixColors {
var c MatrixColors
if raw := a.settingOr(keyMatrixColors, ""); raw != "" {
_ = json.Unmarshal([]byte(raw), &c)
}
return normMatrixColors(c)
}
// SaveMatrixColors persists them.
func (a *App) SaveMatrixColors(c MatrixColors) error {
b, err := json.Marshal(normMatrixColors(c))
if err != nil {
return err
}
a.setSetting(keyMatrixColors, string(b))
return nil
}
// GetRowColors returns the row-colouring configuration, defaults included so the
// panel never has to invent one.
func (a *App) GetRowColors() RowColorSettings {
+26
View File
@@ -1,4 +1,30 @@
[
{
"version": "0.25.9",
"date": "",
"en": [
"The band/mode matrix colours can be chosen in Appearance, starting from the ones your theme already paints. Its legend is translated too.",
"TCI sharing: a refused un-key no longer leaves the rig stuck transmitting, and PTT is dropped if the client dies mid-over.",
"Icom: a frequency or mode change whose acknowledgement is lost is sent again, like PTT — losing one made JTDX drop the radio.",
"FlexRadio: transmit audio can switch to DAX for a voice message and back afterwards, so your microphone is not left off air.",
"Rotor widget: quick-turn buttons you can name and aim yourself, plus an azimuth box where Enter turns the antenna.",
"A station portable in another entity no longer inherits its home county, state and grid — TI8/W2RE was logged in New York.",
"LoTW confirmations for FT2, FT4 and FT8 match again: LoTW answers “DATA”, and the exact mode never lined up with the log.",
"CAT: a transverter offset, so a 28 MHz IF behind a 144 MHz transverter logs, spots and tunes on the band you are really on.",
"Band map: zoom goes down to 2 px/kHz for a whole band in one screen, and each band keeps the zoom you left it at."
],
"fr": [
"Les couleurs de la matrice bandes/modes se choisissent dans Apparence, à partir de celles du thème. Sa légende est traduite aussi.",
"Partage TCI : un retour en réception refusé ne laisse plus le poste bloqué en émission, et le PTT retombe si le logiciel meurt.",
"Icom : un changement de fréquence ou de mode dont laccusé se perd est renvoyé, comme le PTT — en perdre un faisait lâcher JTDX.",
"FlexRadio : laudio d’émission peut basculer sur DAX le temps dun message vocal et revenir après, pour ne pas perdre le micro.",
"Widget rotor : des boutons de rotation rapide que tu nommes et vises toi-même, et un champ azimut où Entrée lance lantenne.",
"Une station portable dans une autre entité nhérite plus de son comté, son état et son locator dorigine : TI8/W2RE sortait à New York.",
"Les confirmations LoTW des QSO FT2, FT4 et FT8 se retrouvent : LoTW répond « DATA », et le mode exact ne correspondait jamais au log.",
"CAT : un décalage transverter, pour quune FI 28 MHz derrière un transverter 144 MHz logue et accorde sur la vraie bande.",
"Carte de bande : le zoom descend à 2 px/kHz pour voir toute la bande dun coup, et chaque bande retient son zoom."
]
},
{
"version": "0.25.8",
"date": "",
+15 -2
View File
@@ -94,7 +94,8 @@ import { ShutdownProgress } from '@/components/ShutdownProgress';
import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
import { applySpotDisplay, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
import { GetRowColors, GetSpotTTLMinutes, IsNewUSCounty } from '../wailsjs/go/main/App';
import { GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, IsNewUSCounty } from '../wailsjs/go/main/App';
import { applyMatrixColors } from '@/lib/matrixColors';
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
import { NetControlPanel } from '@/components/NetControlPanel';
import { ContestPanel, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestPanel';
@@ -2017,6 +2018,16 @@ export default function App() {
// settings dialog closes, which is the only place it changes.
const [rowColors, setRowColors] = useState<any>(null);
useEffect(() => { GetRowColors().then(setRowColors).catch(() => {}); }, [showSettings]);
// Rotor quick-turn buttons (Settings → Rotator). Same reload trigger as the
// row colours: the settings dialog is the only place they change.
const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]);
useEffect(() => { GetRotorPresets().then((p) => setRotorPresets((p ?? []) as any)).catch(() => {}); }, [showSettings]);
// Band/mode matrix palette overrides (Settings → Appearance). Stamped onto
// <html> rather than held in state: the matrix reads CSS custom properties, so
// nothing re-renders and no component has to be told about the colours. Same
// reload trigger as the row colours — the settings dialog is where they change,
// and the panel already previews live while it is open.
useEffect(() => { GetMatrixColors().then((c) => applyMatrixColors(c as any)).catch(() => {}); }, [showSettings]);
// Spot lifetime (Settings → DX Cluster). Spots are actually REMOVED rather
// than filtered at render: the cluster list, every band map and the counts all
// read the same array, so pruning it once is what makes the setting mean the
@@ -6303,8 +6314,10 @@ export default function App() {
{/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
rotator is configured or a DX bearing exists. */}
{showRotor && (rotatorHeading.enabled || dxPath) && (
<div className="w-[186px] shrink-0 min-h-0">
<div className="w-[320px] shrink-0 min-h-0">
<RotorCompass
presets={rotorPresets}
onStop={() => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
bearing={dxPath?.bearingShort ?? null}
headings={beamHeadings}
boomHeading={boomHeading}
+112 -1
View File
@@ -1,9 +1,13 @@
import { useEffect, useState } from 'react';
import { GetRowColors, SaveRowColors } from '../../wailsjs/go/main/App';
import { GetMatrixColors, GetRowColors, SaveMatrixColors, SaveRowColors } from '../../wailsjs/go/main/App';
import { Checkbox } from '@/components/ui/checkbox';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import type { RowColorSettings } from '@/lib/rowColors';
import {
MATRIX_VARS, applyMatrixColors, effectiveMatrixColor, emptyMatrixColors,
type MatrixColors,
} from '@/lib/matrixColors';
// A fixed palette plus a free picker. Muted values on purpose: they are
// composited at low opacity over a dark grid, where a saturated colour reads as
@@ -30,6 +34,111 @@ const CHANNEL_LABELS: Record<string, string> = {
qsl: 'appr.chQsl', lotw: 'LoTW', eqsl: 'eQSL', qrz: 'QRZ.com',
};
// MatrixColorsSection recolours the band/mode matrix — the PH/CW/DIG grid in the
// Stats panel.
//
// The pickers are seeded from what the matrix is painting RIGHT NOW (the active
// theme's ramp, or an existing override), not from a fixed palette: the operator
// starts from the colours in front of them and moves one, instead of being
// handed six values that may belong to a theme they stopped using. Every change
// is applied to the live document at once, so the sample row below is the real
// thing rather than a mock-up of it.
function MatrixColorsSection() {
const { t } = useI18n();
const [cfg, setCfg] = useState<MatrixColors | null>(null);
useEffect(() => {
(async () => {
try {
setCfg((await GetMatrixColors()) as any);
} catch {
setCfg(emptyMatrixColors());
}
})();
}, []);
const save = (next: MatrixColors) => {
setCfg(next);
applyMatrixColors(next); // live, before the round trip — the panel must not lag the choice
SaveMatrixColors(next as any).catch(() => {});
};
// Turning it ON with nothing stored would change nothing at all and read as a
// broken switch, so the empty slots are filled from the theme's current ramp:
// the operator sees six swatches that match the grid and edits from there.
const enable = (on: boolean) => {
if (!cfg) return;
if (!on) {
save({ ...cfg, enabled: false });
return;
}
const seeded = { ...cfg, enabled: true };
for (const { key, cssVar } of MATRIX_VARS) {
if (!String(seeded[key] ?? '').trim()) seeded[key] = effectiveMatrixColor(cssVar);
}
save(seeded);
};
// Reset clears the overrides but keeps the section switched on, then re-seeds
// from the theme — "back to the theme's colours", which is what an operator
// means by reset here, rather than "switch the whole feature off".
const reset = () => {
if (!cfg) return;
applyMatrixColors({ ...emptyMatrixColors(), enabled: false });
const seeded = { ...emptyMatrixColors(), enabled: true };
for (const { key, cssVar } of MATRIX_VARS) seeded[key] = effectiveMatrixColor(cssVar);
save(seeded);
};
if (!cfg) return null;
return (
<div className="space-y-3 border-t border-border/60 pt-4">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={cfg.enabled} className="mt-0.5" onCheckedChange={(c) => enable(!!c)} />
<span>
{t('appr.matrixEnable')}{' '}
<span className="text-xs text-muted-foreground">{t('appr.matrixHint')}</span>
</span>
</label>
{cfg.enabled && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
{MATRIX_VARS.map(({ key, cssVar, label }) => (
<label key={key} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="color"
value={String(cfg[key] || '').trim() || effectiveMatrixColor(cssVar)}
onChange={(e) => save({ ...cfg, [key]: e.target.value })}
className="size-6 rounded-md border border-border bg-transparent p-0 cursor-pointer shrink-0"
/>
{t(label)}
</label>
))}
</div>
{/* The matrix as it will actually look: same tokens, same shapes. */}
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground w-10 shrink-0">{t('appr.matrixSample')}</span>
<span className="inline-block w-7 h-5 rounded bg-mx-call-conf" />
<span className="inline-block w-7 h-5 rounded bg-mx-call-work" />
<span className="inline-block w-7 h-5 rounded bg-mx-dx-conf" />
<span className="inline-block w-7 h-5 rounded bg-mx-dx-work" />
<span className="inline-block w-7 h-5 rounded bg-mx-none" />
<span className="inline-block w-7 h-5 rounded bg-mx-none ring-2 ring-mx-cur ring-inset" />
</div>
<button type="button" onClick={reset}
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground">
{t('appr.matrixReset')}
</button>
</div>
)}
</div>
);
}
export function AppearancePanel() {
const { t } = useI18n();
const [cfg, setCfg] = useState<RowColorSettings | null>(null);
@@ -148,6 +257,8 @@ export function AppearancePanel() {
))}
</div>
)}
<MatrixColorsSection />
</div>
);
}
+60 -9
View File
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
import { writeUiPref } from '@/lib/uiPref';
// BandMap — vertical spectrum panel inspired by Log4OM.
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
@@ -217,7 +218,37 @@ function statusStyle(s: string): { pill: string; bar: string; line: string; dot:
// Pixels-per-kHz at each zoom step. Base 8 px/kHz means a stacked spot
// every 2.75 kHz fits without anti-overlap kicking in — comfortable for
// most bands. Higher levels are for fine inspection of crowded sub-bands.
const PX_PER_KHZ = [8, 16, 32, 64, 128, 256];
// Zoom steps, px per kHz. 2 and 4 sit below the old floor of 8 so a whole band
// fits without FIT taking the scale away from you: 20 m end to end is 700 px at
// 2 px/kHz, which scrolls in one screen on most displays.
const PX_PER_KHZ = [2, 4, 8, 16, 32, 64, 128, 256];
const DEFAULT_ZOOM_IDX = PX_PER_KHZ.indexOf(32);
// Zoom is remembered PER BAND, in ONE json key rather than one key per band:
// lib/uiPref keeps an explicit list of the preferences that travel with data/,
// and thirteen entries there to say the same thing would be thirteen chances to
// forget one.
const ZOOM_KEY = 'opslog.bandMapZoom';
function readZoomMap(): Record<string, number> {
try {
const m = JSON.parse(localStorage.getItem(ZOOM_KEY) || '{}');
return m && typeof m === 'object' ? m : {};
} catch {
return {};
}
}
function readZoom(band: string): number {
const n = readZoomMap()[band];
return Number.isInteger(n) && n >= 0 && n < PX_PER_KHZ.length ? n : DEFAULT_ZOOM_IDX;
}
function writeZoom(band: string, idx: number): void {
const m = readZoomMap();
m[band] = idx;
writeUiPref(ZOOM_KEY, JSON.stringify(m));
}
const SCALE_W = 56;
const PILL_H = 22; // px — height of each callsign pill
const PILL_GAP = 32; // px between scale border and first pill (room for leader)
@@ -252,7 +283,22 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight]);
const range = BAND_RANGES[band];
const segments = SEGMENT_COLORS[band] ?? [];
const [zoomIdx, setZoomIdx] = useState(2); // default 32 px/kHz
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
// The docked map follows the rig, so a band change must bring up THAT band's
// remembered zoom.
useEffect(() => { setZoomIdx(readZoom(band)); }, [band]);
// Stored from the interaction, not from an effect on zoomIdx: an effect would
// also fire on the band change above, and in the same commit it would still be
// holding the PREVIOUS band's index — writing it over the new band's. Doing it
// where the operator actually turns the wheel keeps the two apart. (Running
// twice under StrictMode is harmless: the same value is stored.)
const changeZoom = useCallback((delta: number) => {
setZoomIdx((z) => {
const n = Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + delta));
if (n !== z) writeZoom(band, n);
return n;
});
}, [band]);
const scrollerRef = useRef<HTMLDivElement | null>(null);
const [containerH, setContainerH] = useState(400);
@@ -421,7 +467,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
if (!range) return;
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
setZoomIdx((z) => Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + (e.deltaY > 0 ? -1 : 1))));
changeZoom(e.deltaY > 0 ? -1 : 1);
}
};
el.addEventListener('wheel', onWheel, { passive: false });
@@ -476,8 +522,10 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
// Tick step (where small marks land) and label step (where the kHz
// number is printed) are decoupled so the scale shows ~10-20 numeric
// labels per viewport regardless of zoom — at base 8 px/kHz we want
// labels every 25 kHz, not every 250.
// labels per viewport regardless of zoom — at 8 px/kHz we want labels
// every 25 kHz, not every 250. The bare values below are the floor, and
// they are what the 2 px/kHz step lands on: a whole band in one screen
// wants a number every 100 kHz, not every 25.
let tickStep = 50;
let labelStep = 100;
if (pxPerKHz >= 4) { tickStep = 25; labelStep = 50; }
@@ -502,14 +550,17 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
return (
<div className="h-full w-full flex flex-col min-h-0 bg-card">
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex flex-nowrap items-center gap-0.5 shrink-0">
<span className="flex-1 min-w-0 truncate">{t('bmp.map')} · {band}</span>
<button type="button" onClick={() => setZoomIdx((z) => Math.max(0, z - 1))} disabled={fitToBand || zoomIdx === 0}
{/* The band alone. "Map · 20M" spent a third of a narrow header saying
what the panel obviously is, and with four band maps side by side it
was four times the same word. */}
<span className="flex-1 min-w-0 truncate">{band}</span>
<button type="button" onClick={() => changeZoom(-1)} disabled={fitToBand || zoomIdx === 0}
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
title={t('bmp.zoomOut')}>
<Minus className="size-3" />
</button>
<span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}</span>
<button type="button" onClick={() => setZoomIdx((z) => Math.min(PX_PER_KHZ.length - 1, z + 1))} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
<button type="button" onClick={() => changeZoom(1)} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
title={t('bmp.zoomIn')}>
<Plus className="size-3" />
+23 -19
View File
@@ -4,6 +4,7 @@ import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { sunTimes } from '@/lib/sun';
import { isQSLConfirmed } from '@/lib/qsl';
import { useI18n } from '@/lib/i18n';
import { BandSlotQSOs } from '../../wailsjs/go/main/App';
import type { WorkedBeforeView } from '@/types';
@@ -76,28 +77,31 @@ const STATUS_CLASSES: Record<string, string> = {
dxcc_w: 'bg-mx-dx-work',
};
// Legend entries, in the same colour order as the cells. swatch = the
// background class (or a special ring marker for the current-entry cell).
// Legend entries, in the same colour order as the cells — and the same order and
// i18n keys the Appearance panel's colour pickers use, so the two can never
// disagree about which green is which. swatch = the background class (or a
// special ring marker for the current-entry cell).
const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [
{ swatch: 'bg-mx-call-conf', label: 'Call confirmed' },
{ swatch: 'bg-mx-call-work', label: 'Call worked' },
{ swatch: 'bg-mx-dx-conf', label: 'Entity confirmed' },
{ swatch: 'bg-mx-dx-work', label: 'Entity worked' },
{ swatch: 'bg-mx-none', label: 'Not worked' },
{ swatch: 'bg-mx-none', ring: true, label: 'Current entry' },
{ swatch: 'bg-mx-call-conf', label: 'mx.callConf' },
{ swatch: 'bg-mx-call-work', label: 'mx.callWork' },
{ swatch: 'bg-mx-dx-conf', label: 'mx.dxConf' },
{ swatch: 'bg-mx-dx-work', label: 'mx.dxWork' },
{ swatch: 'bg-mx-none', label: 'mx.none' },
{ swatch: 'bg-mx-none', ring: true, label: 'mx.current' },
];
function cellTitle(band: string, cls: string, status: string, current: boolean): string {
function cellTitle(t: (k: string) => string, band: string, cls: string, status: string, current: boolean): string {
const desc =
status === 'call_c' ? 'This callsign confirmed' :
status === 'call_w' ? 'This callsign worked (not confirmed)' :
status === 'dxcc_c' ? 'Entity confirmed (other callsign)' :
status === 'dxcc_w' ? 'Entity worked (other callsign)' :
'Never worked';
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
status === 'call_c' ? t('mx.tipCallConf') :
status === 'call_w' ? t('mx.tipCallWork') :
status === 'dxcc_c' ? t('mx.tipDxConf') :
status === 'dxcc_w' ? t('mx.tipDxWork') :
t('mx.tipNone');
return `${band} ${cls}: ${desc}${current ? ' — ' + t('mx.current') : ''}`;
}
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
const { t } = useI18n();
// Cell drill-down: which band+class the operator clicked, or null.
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
// Columns from the operator's configured bands (so the matrix shows only the
@@ -308,7 +312,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
return (
<td
key={b.tag}
title={cellTitle(b.tag, cls, st, isCurrent) + (st ? ' — click to list the QSOs' : '')}
title={cellTitle(t, b.tag, cls, st, isCurrent) + (st ? ' — ' + t('mx.tipClick') : '')}
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
className={cn(
'w-[28px] h-[24px] rounded transition-colors p-0',
@@ -316,7 +320,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
// Only a filled cell has anything to show — an empty one
// stays inert rather than opening a "no QSOs" dialog.
st && 'cursor-pointer hover:brightness-110',
isCurrent && 'ring-2 ring-warning ring-inset',
isCurrent && 'ring-2 ring-mx-cur ring-inset',
)}
/>
);
@@ -335,10 +339,10 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
className={cn(
'inline-block size-3 rounded shrink-0',
l.swatch,
l.ring && 'ring-2 ring-warning ring-inset',
l.ring && 'ring-2 ring-mx-cur ring-inset',
)}
/>
{l.label}
{t(l.label)}
</span>
))}
</div>
+155 -25
View File
@@ -5,12 +5,13 @@
// when an Ultrabeam is bidirectional, the opposite one when reversed); a small
// red marker on the bezel shows the short-path bearing to the DX. Click the dial
// to turn the antenna there.
import { useMemo } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { geoAzimuthalEquidistant, geoPath, geoGraticule10 } from 'd3-geo';
import { feature } from 'topojson-client';
import landTopo from 'world-atlas/land-110m.json';
import { Compass, X } from 'lucide-react';
import { Compass, X, Play, Square } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
// Decode the coastline outline once (≈110 m simplified land polygons).
const LAND = feature(landTopo as any, (landTopo as any).objects.land);
@@ -29,6 +30,11 @@ interface Props {
onSelectRotor?: (i: number) => void; // switch the active rotor
onGoto?: (az: number) => void; // click-to-turn
onClose?: () => void;
// Quick-turn buttons and the azimuth box, shown only where the caller wants
// them: Station Control draws its own GoTo/Stop around this compass, and two
// sets of the same controls side by side would be nothing but confusing.
presets?: { label: string; azimuth: number }[];
onStop?: () => void;
}
const SIZE = 168;
@@ -41,7 +47,43 @@ function pt(az: number, radius: number): [number, number] {
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
}
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose }: Props) {
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose, presets, onStop }: Props) {
const { t } = useI18n();
// Raw text, not a number: binding the input to a normalised value makes
// Backspace fight the operator on the way from "230" to "23". It is parsed
// when it is sent, and only then.
const [azText, setAzText] = useState('');
const showControls = !!(presets || onStop);
// Which preset was just pressed, so it can light up for a moment.
//
// A rotor takes seconds to start moving and the needle barely twitches at
// first, so without this the only answer to "did that register?" is to press
// it again — which is how an antenna ends up ordered somewhere twice. The
// acknowledgement has to come from the button itself, at once.
const [flashIdx, setFlashIdx] = useState<number | null>(null);
const flashTimer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(flashTimer.current), []);
const pressPreset = (i: number, az: number) => {
if (!onGoto) return;
setFlashIdx(i);
window.clearTimeout(flashTimer.current);
flashTimer.current = window.setTimeout(() => setFlashIdx(null), 450);
onGoto(az);
};
// 0-359 and nothing else. 360 is refused rather than folded to 0 — it is
// almost always a typo for 36 or 306, and a rotor swinging through north on a
// slip of the finger is worth one rejected keypress.
const sendAz = () => {
const s = azText.trim();
if (s === '' || !onGoto) return;
const n = Number(s);
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0 || n > 359) return;
onGoto(n);
setAzText('');
};
const cardinals = useMemo(
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
@@ -72,6 +114,35 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
const headLabel = headings.length ? headings[0] : null;
// Short and long path to the DX, in figures.
//
// The bezel already carries the short path as a red marker, but a marker is a
// direction, not a number — the same pair sits in the status bar at 10px and
// operators reported not being able to read it. It lives here because it
// belongs to the compass: every place that draws one gets the readout, instead
// of each caller inventing its own. Clickable when the caller can turn, like
// the status bar's. Built as a value because it is placed in one of two
// columns depending on whether the controls are shown.
const pathReadout = (
<div className="flex gap-1.5 mt-2 font-mono w-full">
{([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => (
<button key={lbl} type="button" disabled={az == null || !onGoto}
onClick={() => { if (az != null && onGoto) onGoto(Math.round(az)); }}
title={az == null ? '' : `${lbl} ${Math.round(az)}°`}
className={
'flex-1 rounded-md border py-1 text-xs font-semibold tabular-nums transition-colors active:scale-95 ' +
(az == null
? 'border-border text-muted-foreground/50 cursor-not-allowed'
: onGoto
? 'border-info-border text-info-muted-foreground hover:bg-info-muted cursor-pointer'
: 'border-border text-muted-foreground cursor-default')
}>
{lbl} {az == null ? '—' : `${Math.round(az)}°`}
</button>
))}
</div>
);
return (
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
{/* Header — matches the WinKeyer / Voice keyer panels. */}
@@ -128,9 +199,13 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
</div>
)}
{/* Dial on the left, controls on the right. The controls column is sized to
fit WITHIN the dial's height: the widget sits in a row whose height is
set by the entry strip, so it may grow sideways but never downwards. */}
<div className="flex items-start gap-2 p-2 min-h-0">
{/* flex-col: the readout goes BELOW the dial. This wrapper was a row, so a
sibling of the <svg> landed beside it. */}
<div className="flex flex-col items-center justify-center p-2 min-h-0">
<div className="flex flex-col items-center justify-center min-h-0 shrink-0">
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
@@ -191,30 +266,85 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
</svg>
{/* Short and long path to the DX, in figures.
The bezel already carries the short path as a red marker, but a
marker is a direction, not a number — the same pair sits in the
status bar at 10px and operators reported not being able to read it.
Here because it belongs to the compass: every place that draws one
gets the readout, instead of each caller inventing its own.
Clickable when the caller can turn, like the status bar's. */}
<div className="flex gap-1.5 mt-2 font-mono w-full">
{([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => (
<button key={lbl} type="button" disabled={az == null || !onGoto}
onClick={() => { if (az != null && onGoto) onGoto(Math.round(az)); }}
title={az == null ? '' : `${lbl} ${Math.round(az)}°`}
className={
'flex-1 rounded-md border py-1 text-xs font-semibold tabular-nums transition-colors ' +
(az == null
? 'border-border text-muted-foreground/50 cursor-not-allowed'
: onGoto
? 'border-info-border text-info-muted-foreground hover:bg-info-muted cursor-pointer'
: 'border-border text-muted-foreground cursor-default')
}>
{lbl} {az == null ? '—' : `${Math.round(az)}°`}
{/* With the controls column present the readout goes at the FOOT OF IT
instead: the dial sets the widget's height, the controls are shorter
than the dial, and that leftover space is exactly the right size for
the pair. Under the dial it would push the whole widget taller. */}
{!showControls && pathReadout}
</div>
{/* Quick turns + free azimuth + Stop. */}
{showControls && (
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
{/* Two columns so six regions fit beside the dial rather than under
it. An operator with fewer keeps the same compact block. */}
{!!presets?.length && (
<div className="grid grid-cols-2 gap-1">
{presets.map((p, i) => (
<button
key={`${p.label}-${i}`}
type="button"
disabled={!onGoto}
onClick={() => pressPreset(i, p.azimuth)}
title={`${p.label}${p.azimuth}°`}
className={cn(
'rounded-md border py-1 text-xs font-semibold truncate transition-all duration-150 active:scale-95',
flashIdx === i
// Lit, and showing the azimuth it just sent: the label alone
// would only say the press landed, not what was ordered.
? 'border-success bg-success text-success-foreground scale-95'
: 'border-border bg-muted/40',
onGoto && flashIdx !== i ? 'hover:bg-muted' : '',
!onGoto ? 'opacity-50 cursor-not-allowed' : '',
)}
>
{flashIdx === i ? `${p.azimuth}°` : p.label}
</button>
))}
</div>
)}
{/* Free azimuth: Enter sends, so the whole thing is type-three-digits
-and-go without reaching for the mouse. */}
<div className="flex gap-1">
<input
type="text"
inputMode="numeric"
value={azText}
onChange={(e) => setAzText(e.target.value.replace(/[^0-9]/g, '').slice(0, 3))}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendAz(); } }}
placeholder={t('rotor.azPh')}
title={t('rotor.azTitle')}
disabled={!onGoto}
className="min-w-0 flex-1 rounded-md border border-border bg-background px-2 py-1 text-xs font-mono tabular-nums text-center disabled:opacity-50"
/>
<button
type="button"
onClick={sendAz}
disabled={!onGoto || azText.trim() === ''}
title={t('rotor.go')}
className="flex items-center gap-1 rounded-md border border-success/60 bg-success-muted px-2 py-1 text-xs font-bold text-success-muted-foreground transition-transform hover:bg-success/25 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Play className="size-3" /> {t('rotor.go')}
</button>
</div>
{onStop && (
<button
type="button"
onClick={onStop}
title={t('rotor.stop')}
className="flex items-center justify-center gap-1.5 rounded-md border border-destructive/60 bg-destructive/15 py-1 text-xs font-bold text-destructive hover:bg-destructive/25"
>
<Square className="size-3 fill-current" /> {t('rotor.stop')}
</button>
)}
{/* mt-auto: the readout sits at the FOOT of the column, level with the
bottom of the dial, instead of floating under the Stop button. */}
<div className="mt-auto">{pathReadout}</div>
</div>
)}
</div>
</section>
);
+81 -2
View File
@@ -11,6 +11,7 @@ import {
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
GetAntGeniusSettings, SaveAntGeniusSettings,
GetTunerGeniusSettings, SaveTunerGeniusSettings,
@@ -1399,16 +1400,25 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [bandDraft, setBandDraft] = useState('');
const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false,
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001,
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false,
});
// While true, the next key press is captured as the PTT hotkey.
const [capturingPtt, setCapturingPtt] = useState(false);
const [rotors, setRotors] = useState<RotatorDevice[]>([]);
const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]);
// Whether the presets have actually been READ back yet.
//
// An empty list is a legitimate answer — an operator may want no quick-turn
// buttons at all — so the backend stores it as one. That makes "not loaded
// yet" and "deliberately none" the same value, and a Save landing in the first
// state wiped the buttons for good. Saving is gated on having read them, so
// the only empty list that can ever reach the store is one the operator made.
const [rotorPresetsLoaded, setRotorPresetsLoaded] = useState(false);
const [rotatorTesting, setRotatorTesting] = useState(false);
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
@@ -1825,6 +1835,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
await reloadClusterServers();
setCatCfg(c);
setRotors((r ?? []) as any);
// Loaded HERE, in the loader that runs on mount — not only in the
// event-driven one below. Missing from this one, the state stayed empty
// on a normal open and Save then wrote an empty list over the operator's
// buttons. See rotorPresetsLoaded for the belt to this brace.
try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
@@ -1868,6 +1883,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
try { setLookup(await GetLookupSettings() as any); } catch {}
try { setCatCfg(await GetCATSettings() as any); } catch {}
try { setRotors(((await GetRotators()) ?? []) as any); } catch {}
try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
@@ -2062,6 +2078,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
await SaveLookupSettings(lookup as any);
await SaveCATSettings(catCfg as any);
await SaveRotators(rotors as any);
// Only once they have been read back — see rotorPresetsLoaded.
if (rotorPresetsLoaded) await SaveRotorPresets(rotorPresets as any);
await SaveUltrabeamSettings(ultrabeam as any);
await SaveAntGeniusSettings(antgenius as any);
await SaveTunerGeniusSettings(tunergenius as any);
@@ -2862,6 +2880,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<span className="text-xs text-muted-foreground">{t('cat.flexDecodeSecsHint')}</span>
</div>
)}
<label className="col-span-2 flex items-start gap-2 text-sm cursor-pointer">
<Checkbox className="mt-0.5" checked={!!catCfg.flex_dvk_dax} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_dvk_dax: !!c }))} />
<span>{t('cat.flexDvkDax')} <span className="text-xs text-muted-foreground">{t('cat.flexDvkDaxHint')}</span></span>
</label>
</>
)}
{catCfg.backend === 'xiegu' && (
@@ -3142,6 +3164,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
</>
)}
{/* Transverter offset. OUTSIDE the per-backend blocks on purpose: a
transverter hangs off the IF of whatever radio you own, and the
offset is applied in the CAT manager, above every backend. Tucked
under the OmniRig/Icom section it would have been invisible to the
Yaesu, Kenwood, Xiegu, Flex and TCI operators who need it just as
much. */}
<label className="col-span-2 flex items-start gap-2 text-sm cursor-pointer">
<Checkbox className="mt-0.5" checked={!!catCfg.offset_on}
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, offset_on: !!c }))} />
<span>{t('cat.offsetOn')} <span className="text-xs text-muted-foreground">{t('cat.offsetHint')}</span></span>
</label>
{catCfg.offset_on && (
<div className="col-span-2 flex items-center gap-2 pl-6">
<Label className="text-sm">{t('cat.offsetMhz')}</Label>
<Input
type="number" step="0.000001" className="w-40"
value={(catCfg.offset_hz ?? 0) / 1e6}
onChange={(e) => setCatCfg((s) => ({ ...s, offset_hz: Math.round((parseFloat(e.target.value) || 0) * 1e6) }))}
/>
<span className="text-xs text-muted-foreground">{t('cat.offsetExample')}</span>
</div>
)}
<div className="space-y-1 col-span-2">
<Label>{t('cat.digitalDefault')}</Label>
<Select
@@ -4135,6 +4179,41 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
)}
</div>
)}
{/* Quick-turn buttons for the rotor widget. Their azimuths start out
computed from the station square, so they are right for THIS QTH
rather than copied from someone else's. */}
<div className="border-t border-border/60 pt-3 space-y-2">
<div className="text-sm font-semibold">{t('rot.presets')}</div>
<p className="text-xs text-muted-foreground">{t('rot.presetsHint')}</p>
<div className="space-y-1.5">
{rotorPresets.map((p, i) => (
<div key={i} className="flex items-center gap-2">
<Input className="w-24" maxLength={6} value={p.label}
placeholder={t('rot.presetName')}
onChange={(e) => setRotorPresets((list) => list.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)))} />
<Input className="w-24" type="number" min={0} max={359} value={p.azimuth}
onChange={(e) => setRotorPresets((list) => list.map((x, j) => (j === i ? { ...x, azimuth: Math.max(0, Math.min(359, parseInt(e.target.value, 10) || 0)) } : x)))} />
<span className="text-xs text-muted-foreground">°</span>
<Button variant="ghost" size="sm" title={t('rot.remove')}
onClick={() => setRotorPresets((list) => list.filter((_, j) => j !== i))}>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
</div>
<div className="flex items-center gap-2">
{rotorPresets.length < 8 && (
<Button variant="outline" size="sm" onClick={() => setRotorPresets((l) => [...l, { label: '', azimuth: 0 }])}>
<Plus className="size-3.5 mr-1" /> {t('rot.presetAdd')}
</Button>
)}
<Button variant="outline" size="sm"
onClick={() => { ResetRotorPresets().then((p) => setRotorPresets((p ?? []) as any)).catch((e) => setErr(String(e?.message ?? e))); }}>
{t('rot.presetsReset')}
</Button>
</div>
</div>
{rotatorTest && (
<div className={cn(
'text-xs rounded-md p-2.5 border',
+40 -4
View File
@@ -114,7 +114,17 @@ const en: Dict = {
'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists',
'sec.station': 'Station Information', 'sec.profiles': 'Profiles', 'sec.operating': 'Operating conditions',
'sec.confirmations': 'Confirmations', 'sec.external': 'External services',
'sec.general': 'General', 'sec.appearance': 'Appearance', 'appr.enable': 'Colour whole rows by QSL status', 'appr.enableHint': '(in the log grid, like Logger32)', 'appr.orderHint': 'A contact is often several of these at once — the first rule that matches decides the colour.', 'appr.ruleToSend': 'To be sent', 'appr.ruleConfirmed': 'Confirmed', 'appr.ruleSent': 'QSL sent', 'appr.ruleWorked': 'Worked, nothing sent', 'appr.chQsl': 'Paper QSL', 'appr.custom': 'Pick any colour', 'appr.style': 'Style', 'appr.styleBar': 'Left stripe', 'appr.styleTint': 'Filled row', 'appr.styleBoth': 'Both', 'appr.intensity': 'Strength', 'appr.bandmapLotw': 'Mark LoTW users on the band map', 'appr.bandmapLotwHint': '(the same L badge the cluster list uses)', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup',
'sec.general': 'General', 'sec.appearance': 'Appearance', 'appr.enable': 'Colour whole rows by QSL status', 'appr.enableHint': '(in the log grid, like Logger32)', 'appr.orderHint': 'A contact is often several of these at once — the first rule that matches decides the colour.', 'appr.ruleToSend': 'To be sent', 'appr.ruleConfirmed': 'Confirmed', 'appr.ruleSent': 'QSL sent', 'appr.ruleWorked': 'Worked, nothing sent', 'appr.chQsl': 'Paper QSL', 'appr.custom': 'Pick any colour', 'appr.style': 'Style', 'appr.styleBar': 'Left stripe', 'appr.styleTint': 'Filled row', 'appr.styleBoth': 'Both', 'appr.intensity': 'Strength', 'appr.bandmapLotw': 'Mark LoTW users on the band map', 'appr.bandmapLotwHint': '(the same L badge the cluster list uses)',
'appr.matrixEnable': 'Choose the band/mode matrix colours', 'appr.matrixHint': '(the PH/CW/DIG grid in Stats — off, each theme uses its own)',
'appr.matrixSample': 'Sample', 'appr.matrixReset': 'Back to the themes colours',
// Matrix legend + colour names. One set of labels for the grid's legend, its
// cell tooltips and the colour pickers, so they can never drift apart.
'mx.callConf': 'Call confirmed', 'mx.callWork': 'Call worked', 'mx.dxConf': 'Entity confirmed',
'mx.dxWork': 'Entity worked', 'mx.none': 'Not worked', 'mx.current': 'Current entry',
'mx.tipCallConf': 'This callsign confirmed', 'mx.tipCallWork': 'This callsign worked (not confirmed)',
'mx.tipDxConf': 'Entity confirmed (other callsign)', 'mx.tipDxWork': 'Entity worked (other callsign)',
'mx.tipNone': 'Never worked', 'mx.tipClick': 'click to list the QSOs',
'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup',
'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster',
'sec.udp': 'Connections', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup', 'sec.uscounties': 'US Counties',
'sec.webpublish': 'Web publishing', 'wpub.hint': 'Publishes your log as a file for a website: a standalone HTML page or a CSV, written locally and optionally uploaded by FTP. It is refreshed when you log a QSO and, if you set an interval, on a timer.', 'wpub.enable': 'Publish the log to a file', 'wpub.fileSection': 'The file', 'wpub.format': 'Format', 'wpub.formatHtml': 'HTML page', 'wpub.formatCsv': 'CSV', 'wpub.folder': 'Output folder', 'wpub.browse': 'Browse…', 'wpub.fileName': 'File name', 'wpub.title': 'Page title', 'wpub.titlePh': 'blank = your callsign', 'wpub.count': 'Last N QSOs', 'wpub.every': 'Refresh every', 'wpub.everyHint': 'minutes — 0 = only when a QSO is logged', 'wpub.columns': 'Columns', 'wpub.columnsCount': '{n} of {total} chosen', 'wpub.columnsPick': 'Choose columns…', 'wpub.columnsSearch': 'Search a field…', 'wpub.removeColumn': 'Click to remove', 'wpub.columnsHint': 'Click to add or remove. The order shown here is the order in the file.', 'wpub.ftpEnable': 'Upload by FTP', 'wpub.ftpHost': 'Server / port', 'wpub.ftpUser': 'User', 'wpub.ftpPassword': 'Password', 'wpub.ftpFolder': 'Remote folder', 'wpub.ftpFileName': 'Remote file name', 'wpub.ftpTls': 'Use TLS (FTPS)', 'wpub.publishNow': 'Publish now', 'wpub.testFtp': 'Test connection', 'wpub.lastRun': 'Last run:', 'sec.adifmon': 'ADIF monitor',
@@ -290,6 +300,11 @@ const en: Dict = {
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
'tg2.hint': 'OpsLog talks to the 4O3A Tuner Genius XL over TCP (port fixed at 9010), so only the device IP is needed. A docked widget then shows SWR and forward power and offers Tune, Bypass and Operate/Standby. Control it directly (not through the radio) so OpsLog uses just one of the box\'s connection slots.', 'tg2.enable': 'Enable Tuner Genius control', 'tg2.portHint': 'The TCP port is fixed at 9010 on the device.', 'tg2.password': 'Remote code', 'tg2.passwordPh': 'blank on LAN', 'tg2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AUTH" and rejects commands until you log in. Leave blank on the local network.',
'rot.spidHint': 'Native SPID protocol over the controllers COM port — no PstRotator needed. Pick the dialect above: Rot2Prog answers with azimuth and elevation at 600 baud, Rot1Prog with azimuth only at 1200.', 'rot.spidModel': 'SPID protocol',
'rot.presets': 'Quick-turn buttons', 'rot.presetName': 'Name', 'rot.presetAdd': 'Add button',
'rot.presetsHint': 'Shown beside the rotor dial. The azimuths started out computed from your station square, so they point at those regions FROM HERE — change either field, or clear a name to drop the button.',
'rot.presetsReset': 'Recompute from my square',
'rotor.go': 'GO', 'rotor.stop': 'STOP', 'rotor.azPh': '0 359',
'rotor.azTitle': 'Azimuth 0-359, Enter to turn the rotor',
'rot.enable': 'Enable rotator control', 'rot.testOkRG': 'Connected — the Rotator Genius accepted the command (moving to 0°).', 'rot.testOkRead': 'Connected — the controller answered with its heading. Nothing was moved: this test only reads the position.', 'rot.type': 'Rotator type', 'rot.rotatorNum': 'Rotator #', 'rot.dual': 'Two rotors', 'rot.rotor1': 'Rotor 1', 'rot.rotor2': 'Rotor 2', 'rot.name': 'Name', 'rot.antenna': 'Antenna', 'rot.antenna1': 'Rotor 1 antenna', 'rot.antenna2': 'Rotor 2 antenna', 'rot.name1': 'Rotor 1 name', 'rot.name2': 'Rotor 2 name', 'rot.antStd': 'Standard antenna', 'rot.antMotor': 'Motorized (Ultrabeam/SteppIR)', 'rot.add': 'Add rotor', 'rot.remove': 'Remove rotor', 'rot.none': 'No rotor configured. Click “Add rotor” to add one.', 'rot.rgDual': 'This Rotator Genius drives two rotors (adds a second one)', 'rot.testRotor1': 'Test rotor 1', 'rot.testRotor2': 'Test rotor 2', 'rot.dualHint': 'Two independent rotors of any type — for example an ARCO and a Rotator Genius side by side. Configure each below; OpsLog shows a Rotor 1/2 toggle on the compass to pick which one it turns and displays. Set each rotor to "Motorized" so the boom/pattern paths (reverse, bidirectional) show only for the one that carries the Ultrabeam/SteppIR. (For a single Rotator Genius driving two rotors, set both to Rotator Genius with the same host and rotator # 1 and 2.)', 'rot.rgHint': 'Talks directly to a 4O3A Rotator Genius over TCP (default port 9006) — no PstRotator needed. Rotator # selects which of the two rotators to drive.', 'rot.arcoHint': "Talks directly to any controller set to Yaesu GS-232A — no PstRotator needed. microHAM ARCO: set Config → LAN → CONTROL PROTOCOL (or USB CONTROL PROTOCOL) to 'Yaesu GS-232A'; on USB the baud rate does not matter. ERC (Easy Rotor Control, incl. ERC Mini): its emulation MUST be set to GS-232 — an ERC left on Hy-Gain DCU-1 speaks a different command set and will not answer — then pick its COM port and the same baud rate as in the ERC configuration.", 'rot.dcu1Hint': "Speaks the Hy-Gain DCU-1 command set (RotorCard DXA, Idiom Press Rotor-EZ, Green Heron). Connect over the controller's COM port (a DCU-1 is 4800 baud; RotorCard/Green Heron may differ — match the controller) or over TCP through a serial-over-IP bridge. Azimuth only, no elevation. New backend — please report if your controller needs a different command or baud.", 'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 12 min delay so a mis-logged QSO can still be fixed first).',
'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 1354 MHz (20 m6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.motorBands': 'Covered bands', 'hw.motorStep': 'Re-tune step', 'hw.motorBandFreqHint': 'Frequency each band button tunes the antenna to (kHz). Leave empty for the default shown.', 'hw.motorFollow': 'Follow rig frequency (auto-tune the antenna)', 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
@@ -300,8 +315,11 @@ const en: Dict = {
'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
'cat.icomNetAudioHint': 'Play the rigs received audio through your Listening device (Settings → Audio) over the 50003 stream. Experimental — the audio framing is pending on-rig verification; leave off if control misbehaves.',
'cat.omnirigRig': 'OmniRig rig slot', 'cat.omnirigVfo': 'VFO to read', 'cat.omnirigVfoAuto': 'As reported by the rig file', 'cat.omnirigVfoA': 'Always VFO A (main)', 'cat.omnirigVfoB': 'Always VFO B (sub)', 'cat.omnirigVfoHint': 'OmniRig reports the active VFO from the rig file, and some files get it wrong — the frequency then follows the other VFO and appears frozen. Force one here if that happens.', 'cat.flexIp': 'FlexRadio IP', 'cat.port': 'Port', 'cat.flexSpots': 'Show cluster spots on the panadapter', 'cat.flexSpotsHint': "(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)", 'cat.flexDecodeSpots': 'Show WSJT-X decodes on the panadapter', 'cat.flexDecodeSpotsHint': '(heard FT8/FT4 stations from your WSJT-X/JTDX UDP feed, one spot per call)', 'cat.flexDecodeSecs': 'Display for', 'cat.flexDecodeSecsHint': 'seconds before a station is removed',
'cat.flexDvkDax': 'Switch transmit audio to DAX for voice messages',
'cat.flexDvkDaxHint': '(pressed while a DVK message or a QSO recording goes out, and put back afterwards so your microphone works again)',
'cat.icomPort': 'Icom CI-V port', 'cat.selectCom': 'Select COM port', 'cat.noPorts': 'No ports found', 'cat.baud': 'Baud rate', 'cat.icomModel': 'Rig model', 'cat.icomModelOther': 'Other (custom address)', 'cat.civAddr': 'CI-V address (hex)', 'cat.civHint': 'Pick your model to set the CI-V address automatically (or choose "Other" and type it). Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.',
'cat.tciHost': 'TCI host', 'cat.tciHint': 'Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.', 'cat.tciSpots': 'Show cluster spots on the panorama', 'cat.tciSpotsHint': "(spots from OpsLog's DX cluster appear on the SDR panadapter)",
'cat.offsetOn': 'Transverter offset', 'cat.offsetHint': '(the rig shows its IF; OpsLog logs, spots and tunes on the real band)', 'cat.offsetMhz': 'Offset (MHz)', 'cat.offsetExample': 'e.g. 116 for a 28 MHz IF on 144 MHz — negative is allowed',
'cat.pollMs': 'Poll interval (ms)', 'cat.delayMs': 'CAT delay (ms)', 'cat.digitalDefault': 'Default digital mode (when rig reports DIG)', 'cat.modeBeforeFreq': 'Set mode before frequency', 'cat.modeBeforeFreqHint': '(older rigs that drop the mode after a band change)',
'cat.omnirigHint': 'Configure your rig (COM port, baud rate, model) in OmniRig\'s own settings GUI first. OpsLog will read whichever Rig slot you select here. Set CAT delay above 0 if your rig drops commands sent back-to-back (some older Kenwood/Yaesu). OmniRig only reports generic "DIG" for digital modes — Default digital mode is the specific mode OpsLog will surface (and log).',
'cat.rotatorOk': "Packet sent — antenna should swing to 0° (north). If it didn't, check PstRotator host/port and that PstRotator's UDP listener is enabled.",
@@ -352,7 +370,7 @@ const en: Dict = {
'chp.lotwRcvd': 'LoTW rcvd', 'chp.bureauRcvd': 'Bureau rcvd', 'chp.olderQsos': '+ {n} older QSOs',
'bmp.statusNew': 'NEW DXCC (entity never worked)', 'bmp.statusNewBand': 'NEW BAND (entity not worked on this band)', 'bmp.statusNewSlot': 'NEW SLOT (mode not worked on this band)', 'bmp.statusNewCall': 'NEW CALL (this callsign never worked on this band and mode)', 'bmp.statusNewMode': 'NEW MODE (mode never worked for this entity)',
'bmp.statusWorked': 'Worked (this band + mode already in log)', 'bmp.statusUnresolved': 'Entity not resolved', 'bmp.bandMap': 'Band map', 'bmp.notConfigured': 'Not configured for {band}.',
'bmp.map': 'Map', 'bmp.zoomOut': 'Zoom out', 'bmp.zoomIn': 'Zoom in', 'bmp.scrollToRig': 'Scroll to current rig frequency', 'bmp.moveLeft': 'Move band map to the left', 'bmp.moveRight': 'Move band map to the right', 'bmp.hide': 'Hide band map',
'bmp.zoomOut': 'Zoom out', 'bmp.zoomIn': 'Zoom in', 'bmp.scrollToRig': 'Scroll to current rig frequency', 'bmp.moveLeft': 'Move band map to the left', 'bmp.moveRight': 'Move band map to the right', 'bmp.hide': 'Hide band map',
'bmp.bandsLabel': 'Bands:', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Hide FTx', 'bmp.hideFtTitle': 'Hide all digital (FT8/FT4/JS8/…) spots on every band map', 'bmp.fitBand': 'Fit to band', 'bmp.widthTip': 'Drag to resize — double-click to reset', 'bmp.fitTitle': 'Size each band map to show the whole band edge-to-edge',
'bmp.legendNewDxcc': 'New DXCC', 'bmp.legendNewBand': 'New band', 'bmp.legendNewSlot': 'New slot (mode)', 'bmp.openUnusual': 'unusual for the season', 'bmp.legendWorked': 'Worked', 'bmp.legendNewPota': 'New POTA', 'bmp.legendNewCounty': 'New county', 'bmp.legendWorkedCall': 'Callsign already worked', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Data', 'bmp.legendPhone': 'Phone', 'bmp.footerHint': 'scroll · ctrl+wheel = zoom · ◎ = jump to rig', 'bmp.spotsHidden': '{n} FT8/FT4 spots hidden — top {max} kept (CW/SSB all shown)',
'frm.welcome': 'Welcome to OpsLog', 'frm.intro': 'Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).',
@@ -552,7 +570,17 @@ const fr: Dict = {
'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes',
'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération",
'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes',
'sec.general': 'Général', 'sec.appearance': 'Apparence', 'appr.enable': 'Colorer les lignes entières selon le statut QSL', 'appr.enableHint': '(dans le tableau du log, comme Logger32)', 'appr.orderHint': "Un contact est souvent plusieurs de ces états à la fois — la première règle qui correspond décide de la couleur.", 'appr.ruleToSend': 'À envoyer', 'appr.ruleConfirmed': 'Confirmé', 'appr.ruleSent': 'QSL envoyée', 'appr.ruleWorked': 'Contacté, rien envoyé', 'appr.chQsl': 'QSL papier', 'appr.custom': 'Choisir une couleur', 'appr.style': 'Style', 'appr.styleBar': 'Barre à gauche', 'appr.styleTint': 'Ligne remplie', 'appr.styleBoth': 'Les deux', 'appr.intensity': 'Intensité', 'appr.bandmapLotw': 'Marquer les utilisateurs LoTW sur la band map', 'appr.bandmapLotwHint': '(le même badge L que la liste du cluster)', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif",
'sec.general': 'Général', 'sec.appearance': 'Apparence', 'appr.enable': 'Colorer les lignes entières selon le statut QSL', 'appr.enableHint': '(dans le tableau du log, comme Logger32)', 'appr.orderHint': "Un contact est souvent plusieurs de ces états à la fois — la première règle qui correspond décide de la couleur.", 'appr.ruleToSend': 'À envoyer', 'appr.ruleConfirmed': 'Confirmé', 'appr.ruleSent': 'QSL envoyée', 'appr.ruleWorked': 'Contacté, rien envoyé', 'appr.chQsl': 'QSL papier', 'appr.custom': 'Choisir une couleur', 'appr.style': 'Style', 'appr.styleBar': 'Barre à gauche', 'appr.styleTint': 'Ligne remplie', 'appr.styleBoth': 'Les deux', 'appr.intensity': 'Intensité', 'appr.bandmapLotw': 'Marquer les utilisateurs LoTW sur la band map', 'appr.bandmapLotwHint': '(le même badge L que la liste du cluster)',
'appr.matrixEnable': 'Choisir les couleurs de la matrice bandes/modes', 'appr.matrixHint': '(la grille PH/CW/DIG des Stats — décoché, chaque thème garde les siennes)',
'appr.matrixSample': 'Aperçu', 'appr.matrixReset': 'Revenir aux couleurs du thème',
// Légende de la matrice + noms des couleurs. Un seul jeu de libellés pour la
// légende, les infobulles des cases et les sélecteurs de couleur.
'mx.callConf': 'Indicatif confirmé', 'mx.callWork': 'Indicatif contacté', 'mx.dxConf': 'Entité confirmée',
'mx.dxWork': 'Entité contactée', 'mx.none': 'Jamais contacté', 'mx.current': 'Saisie en cours',
'mx.tipCallConf': 'Cet indicatif est confirmé', 'mx.tipCallWork': 'Cet indicatif est contacté (non confirmé)',
'mx.tipDxConf': 'Entité confirmée (autre indicatif)', 'mx.tipDxWork': 'Entité contactée (autre indicatif)',
'mx.tipNone': 'Jamais contacté', 'mx.tipClick': 'cliquer pour lister les QSO',
'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif",
'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster',
'sec.udp': 'Connexions', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'sec.uscounties': 'Comtés US',
'sec.webpublish': 'Publication web', 'wpub.hint': "Publie ton journal dans un fichier destiné à un site web : une page HTML autonome ou un CSV, écrit en local et envoyé par FTP si tu le souhaites. Il est rafraîchi à chaque QSO enregistré et, si tu règles un intervalle, périodiquement.", 'wpub.enable': 'Publier le journal dans un fichier', 'wpub.fileSection': 'Le fichier', 'wpub.format': 'Format', 'wpub.formatHtml': 'Page HTML', 'wpub.formatCsv': 'CSV', 'wpub.folder': 'Dossier de sortie', 'wpub.browse': 'Parcourir…', 'wpub.fileName': 'Nom du fichier', 'wpub.title': 'Titre de la page', 'wpub.titlePh': 'vide = ton indicatif', 'wpub.count': 'N derniers QSO', 'wpub.every': 'Rafraîchir toutes les', 'wpub.everyHint': 'minutes — 0 = seulement à chaque QSO', 'wpub.columns': 'Colonnes', 'wpub.columnsCount': '{n} sur {total} choisis', 'wpub.columnsPick': 'Choisir les colonnes…', 'wpub.columnsSearch': 'Chercher un champ…', 'wpub.removeColumn': 'Cliquer pour retirer', 'wpub.columnsHint': 'Clique pour ajouter ou retirer. L ordre affiché ici est celui du fichier.', 'wpub.ftpEnable': 'Envoyer par FTP', 'wpub.ftpHost': 'Serveur / port', 'wpub.ftpUser': 'Utilisateur', 'wpub.ftpPassword': 'Mot de passe', 'wpub.ftpFolder': 'Dossier distant', 'wpub.ftpFileName': 'Nom du fichier distant', 'wpub.ftpTls': 'Utiliser TLS (FTPS)', 'wpub.publishNow': 'Publier maintenant', 'wpub.testFtp': 'Tester la connexion', 'wpub.lastRun': 'Dernière exécution :', 'sec.adifmon': 'Moniteur ADIF',
@@ -720,6 +748,11 @@ const fr: Dict = {
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
'tg2.hint': "OpsLog dialogue avec le 4O3A Tuner Genius XL en TCP (port fixé à 9010), seule l'IP de l'appareil est nécessaire. Un widget ancré affiche le ROS et la puissance directe et propose Accord, Bypass et Operate/Standby. Pilotage direct (pas via la radio) pour n'utiliser qu'une des connexions de la boîte.", 'tg2.enable': "Activer le contrôle du Tuner Genius", 'tg2.portHint': "Le port TCP est fixé à 9010 sur l'appareil.", 'tg2.password': 'Code distant', 'tg2.passwordPh': 'vide en LAN', 'tg2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
'rot.spidHint': 'Protocole SPID natif sur le port COM du contrôleur — sans PstRotator. Choisissez le dialecte ci-dessus : Rot2Prog répond azimut et élévation à 600 bauds, Rot1Prog azimut seul à 1200.', 'rot.spidModel': 'Protocole SPID',
'rot.presets': 'Boutons de rotation rapide', 'rot.presetName': 'Nom', 'rot.presetAdd': 'Ajouter un bouton',
'rot.presetsHint': "Affichés à côté du cadran du rotor. Les azimuts ont été calculés depuis ton carré : ils visent ces régions DEPUIS ICI. Modifie l'un ou l'autre champ, ou vide le nom pour retirer le bouton.",
'rot.presetsReset': 'Recalculer depuis mon carré',
'rotor.go': 'GO', 'rotor.stop': 'STOP', 'rotor.azPh': '0 359',
'rotor.azTitle': 'Azimut 0-359, Entrée pour lancer le rotor',
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.testOkRead': 'Connecté — le contrôleur a répondu avec son azimut. Rien na bougé : ce test ne fait que lire la position.', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.dual': 'Deux rotors', 'rot.rotor1': 'Rotor 1', 'rot.rotor2': 'Rotor 2', 'rot.name': 'Nom', 'rot.antenna': 'Antenne', 'rot.antenna1': 'Antenne rotor 1', 'rot.antenna2': 'Antenne rotor 2', 'rot.name1': 'Nom du rotor 1', 'rot.name2': 'Nom du rotor 2', 'rot.antStd': 'Antenne standard', 'rot.antMotor': 'Motorisée (Ultrabeam/SteppIR)', 'rot.add': 'Ajouter un rotor', 'rot.remove': 'Supprimer le rotor', 'rot.none': 'Aucun rotor configuré. Cliquez sur « Ajouter un rotor ».', 'rot.rgDual': 'Ce Rotator Genius pilote deux rotors (en ajoute un second)', 'rot.testRotor1': 'Tester rotor 1', 'rot.testRotor2': 'Tester rotor 2', 'rot.dualHint': 'Deux rotors indépendants de n\'importe quel type — par exemple un ARCO et un Rotator Genius côte à côte. Configurez chacun ci-dessous ; OpsLog affiche un sélecteur Rotor 1/2 sur le compas pour choisir celui qu\'il tourne et affiche. Réglez chaque rotor sur « Motorisée » pour que les tracés de boom/diagramme (inverse, bidirectionnel) n\'apparaissent que pour celui qui porte l\'Ultrabeam/SteppIR. (Pour un seul Rotator Genius pilotant deux rotors, réglez les deux sur Rotator Genius avec le même hôte et le rotator n° 1 et 2.)', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à tout contrôleur réglé sur Yaesu GS-232A — sans PstRotator. microHAM ARCO : régler Config → LAN → CONTROL PROTOCOL (ou USB CONTROL PROTOCOL) sur « Yaesu GS-232A » ; en USB la vitesse est sans importance. ERC (Easy Rotor Control, ERC Mini compris) : son émulation DOIT être réglée sur GS-232 — un ERC laissé en Hy-Gain DCU-1 parle un autre jeu de commandes et ne répondra pas — puis choisir son port COM et la même vitesse que dans la configuration de l'ERC.", 'rot.dcu1Hint': "Parle le jeu de commandes Hy-Gain DCU-1 (RotorCard DXA, Idiom Press Rotor-EZ, Green Heron). Connexion via le port COM du contrôleur (un DCU-1 est à 4800 bauds ; RotorCard/Green Heron peuvent différer — reprendre la vitesse du contrôleur) ou en TCP via un pont série-sur-IP. Azimut uniquement, pas d'élévation. Nouveau pilote — signalez si votre contrôleur nécessite une autre commande ou vitesse.", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 12 min pour corriger un QSO mal saisi avant).",
'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.motorBands': 'Bandes couvertes', 'hw.motorStep': 'Pas de réaccord', 'hw.motorBandFreqHint': "Fréquence sur laquelle chaque bouton de bande accorde l'antenne (kHz). Laisser vide pour le défaut affiché.", 'hw.motorFollow': "Suivre la fréquence du rig (accord auto de l'antenne)", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
@@ -729,8 +762,11 @@ const fr: Dict = {
'cat.icomNetAudio': 'Diffuser laudio RX par le réseau (expérimental)',
'cat.icomNetAudioHint': 'Écoute laudio reçu du poste sur ton périphérique d’écoute (Réglages → Audio) via le flux 50003. Expérimental — le format audio reste à vérifier sur le poste ; laisse désactivé si le contrôle se comporte mal.',
'cat.omnirigRig': 'Slot OmniRig', 'cat.omnirigVfo': 'VFO à lire', 'cat.omnirigVfoAuto': 'Selon le fichier radio', 'cat.omnirigVfoA': 'Toujours le VFO A (principal)', 'cat.omnirigVfoB': 'Toujours le VFO B (secondaire)', 'cat.omnirigVfoHint': "OmniRig indique le VFO actif d'après le fichier radio, et certains fichiers se trompent — la fréquence suit alors l'autre VFO et paraît figée. Forcez-en un ici le cas échéant.", 'cat.flexIp': 'IP FlexRadio', 'cat.port': 'Port', 'cat.flexSpots': 'Afficher les spots cluster sur le panadapter', 'cat.flexSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur la radio, expirent après 30 min)", 'cat.flexDecodeSpots': 'Afficher les décodes WSJT-X sur le panadapter', 'cat.flexDecodeSpotsHint': '(stations FT8/FT4 entendues via ton flux UDP WSJT-X/JTDX, un spot par station)', 'cat.flexDecodeSecs': 'Affichage pendant', 'cat.flexDecodeSecsHint': 'secondes avant retrait d\'une station',
'cat.flexDvkDax': "Basculer l'audio d'émission sur DAX pour les messages vocaux",
'cat.flexDvkDaxHint': "(enfoncé le temps d'un message DVK ou d'un enregistrement de QSO, puis remis comme avant pour retrouver ton micro)",
'cat.icomPort': 'Port CI-V Icom', 'cat.selectCom': 'Choisir un port COM', 'cat.noPorts': 'Aucun port trouvé', 'cat.baud': 'Débit (baud)', 'cat.icomModel': 'Modèle de poste', 'cat.icomModelOther': 'Autre (adresse perso)', 'cat.civAddr': 'Adresse CI-V (hex)', 'cat.civHint': 'Choisis ton modèle pour fixer ladresse CI-V automatiquement (ou « Autre » et saisis-la). Mets « CI-V USB Echo Back » sur OFF et fais correspondre le débit CI-V sur le poste.',
'cat.tciHost': 'Hôte TCI', 'cat.tciHint': 'Active le serveur TCI dans ExpertSDR2/EESDR (Options → TCI). Port par défaut 40001. Utilise 127.0.0.1 si OpsLog tourne sur le même PC.', 'cat.tciSpots': 'Afficher les spots cluster sur le panorama', 'cat.tciSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur le panadapter SDR)",
'cat.offsetOn': 'Décalage transverter', 'cat.offsetHint': "(le poste affiche sa FI ; OpsLog logue, spotte et accorde sur la vraie bande)", 'cat.offsetMhz': 'Décalage (MHz)', 'cat.offsetExample': 'p. ex. 116 pour une FI 28 MHz sur 144 MHz — négatif accepté',
'cat.pollMs': 'Intervalle de poll (ms)', 'cat.delayMs': 'Délai CAT (ms)', 'cat.digitalDefault': 'Mode numérique par défaut (quand le poste indique DIG)', 'cat.modeBeforeFreq': 'Régler le mode avant la fréquence', 'cat.modeBeforeFreqHint': '(anciens postes qui perdent le mode après un changement de bande)',
'cat.omnirigHint': "Configure d'abord ton poste (port COM, débit, modèle) dans l'interface de réglages d'OmniRig. OpsLog lira le slot Rig que tu choisis ici. Mets le délai CAT au-dessus de 0 si ton poste perd des commandes envoyées coup sur coup (certains anciens Kenwood/Yaesu). OmniRig ne rapporte qu'un « DIG » générique pour les modes numériques — le mode numérique par défaut est le mode précis qu'OpsLog affichera (et loggera).",
'cat.rotatorOk': "Paquet envoyé — l'antenne devrait tourner vers 0° (nord). Sinon, vérifie l'hôte/port PstRotator et que l'écouteur UDP de PstRotator est activé.",
@@ -776,7 +812,7 @@ const fr: Dict = {
'chp.lotwRcvd': 'LoTW reçue', 'chp.bureauRcvd': 'Bureau reçue', 'chp.olderQsos': '+ {n} QSO plus anciens',
'bmp.statusNew': 'NOUVEAU DXCC (entité jamais contactée)', 'bmp.statusNewBand': 'NOUVELLE BANDE (entité non contactée sur cette bande)', 'bmp.statusNewSlot': 'NOUVEAU MODE (mode non contacté sur cette bande)', 'bmp.statusNewCall': "CALL NEUF (indicatif jamais contacté sur cette bande et ce mode)", 'bmp.statusNewMode': 'NOUVEAU MODE (mode jamais contacté pour cette entité)',
'bmp.statusWorked': 'Contacté (cette bande + mode déjà au log)', 'bmp.statusUnresolved': 'Entité non résolue', 'bmp.bandMap': 'Carte de bande', 'bmp.notConfigured': 'Non configurée pour {band}.',
'bmp.map': 'Carte', 'bmp.zoomOut': 'Dézoomer', 'bmp.zoomIn': 'Zoomer', 'bmp.scrollToRig': 'Aller à la fréquence actuelle du poste', 'bmp.moveLeft': 'Déplacer la carte de bande à gauche', 'bmp.moveRight': 'Déplacer la carte de bande à droite', 'bmp.hide': 'Masquer la carte de bande',
'bmp.zoomOut': 'Dézoomer', 'bmp.zoomIn': 'Zoomer', 'bmp.scrollToRig': 'Aller à la fréquence actuelle du poste', 'bmp.moveLeft': 'Déplacer la carte de bande à gauche', 'bmp.moveRight': 'Déplacer la carte de bande à droite', 'bmp.hide': 'Masquer la carte de bande',
'bmp.bandsLabel': 'Bandes :', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Masquer FTx', 'bmp.hideFtTitle': 'Masquer tous les spots numériques (FT8/FT4/JS8/…) sur toutes les cartes', 'bmp.fitBand': 'Ajuster à la bande', 'bmp.widthTip': 'Glisser pour redimensionner — double-clic pour réinitialiser', 'bmp.fitTitle': 'Dimensionner chaque carte pour afficher toute la bande',
'bmp.legendNewDxcc': 'Nouveau DXCC', 'bmp.legendNewBand': 'Nouvelle bande', 'bmp.legendNewSlot': 'Nouveau mode', 'bmp.openUnusual': 'inhabituel pour la saison', 'bmp.legendWorked': 'Contacté', 'bmp.legendNewPota': 'Nouveau POTA', 'bmp.legendNewCounty': 'Nouveau comté', 'bmp.legendWorkedCall': 'Indicatif déjà contacté', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Numérique', 'bmp.legendPhone': 'Phonie', 'bmp.footerHint': 'défiler · ctrl+molette = zoom · ◎ = aller au poste', 'bmp.spotsHidden': '{n} spots FT8/FT4 masqués — {max} meilleurs conservés (CW/SSB tous affichés)',
'frm.welcome': 'Bienvenue dans OpsLog', 'frm.intro': 'Configure ta station pour commencer à logger. Ces champs sont inscrits sur chaque QSO et peuvent être modifiés plus tard dans Préférences → Informations station (et par profil).',
+66
View File
@@ -0,0 +1,66 @@
// Operator overrides for the band/mode matrix palette.
//
// Applied as inline custom properties on <html>, which is what lets them be
// OVERRIDES rather than a palette: the twelve themes each define their own
// --mx-* ramp in style.css, an inline value wins over all of them, and removing
// it hands the colour straight back to the theme. Nothing has to know which
// theme is active, and switching theme with overrides off is a clean revert.
export type MatrixColors = {
enabled: boolean;
call_confirmed: string;
call_worked: string;
entity_confirmed: string;
entity_worked: string;
not_worked: string;
current_entry: string;
};
// The six settings fields and the CSS custom property each one drives. Also the
// display order — the same order the legend under the matrix reads in, so the
// settings panel and the grid can never disagree about which green is which.
export const MATRIX_VARS: { key: keyof Omit<MatrixColors, 'enabled'>; cssVar: string; label: string }[] = [
{ key: 'call_confirmed', cssVar: '--mx-call-conf', label: 'mx.callConf' },
{ key: 'call_worked', cssVar: '--mx-call-work', label: 'mx.callWork' },
{ key: 'entity_confirmed', cssVar: '--mx-dx-conf', label: 'mx.dxConf' },
{ key: 'entity_worked', cssVar: '--mx-dx-work', label: 'mx.dxWork' },
{ key: 'not_worked', cssVar: '--mx-none', label: 'mx.none' },
{ key: 'current_entry', cssVar: '--mx-cur', label: 'mx.current' },
];
export const emptyMatrixColors = (): MatrixColors => ({
enabled: false,
call_confirmed: '', call_worked: '', entity_confirmed: '',
entity_worked: '', not_worked: '', current_entry: '',
});
// applyMatrixColors stamps (or clears) the overrides on <html>. Safe to call as
// often as you like — it is the whole rendering path, so the settings panel uses
// it for a live preview and the app uses it once at startup.
export function applyMatrixColors(c?: MatrixColors | null): void {
const root = document.documentElement;
for (const { key, cssVar } of MATRIX_VARS) {
const v = c?.enabled ? String(c[key] ?? '').trim() : '';
if (v) root.style.setProperty(cssVar, v);
else root.style.removeProperty(cssVar);
}
}
// effectiveMatrixColor reads what the matrix is ACTUALLY painting right now —
// the override if there is one, else the active theme's value. It is what seeds
// the colour pickers, so the operator starts from the colours in front of them
// instead of from a hardcoded palette that may belong to a different theme.
//
// A custom property's computed value has its var() references substituted, so
// --mx-cur resolves to the theme's --warning rather than to the literal text.
export function effectiveMatrixColor(cssVar: string): string {
try {
const v = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim();
// <input type="color"> only accepts #rrggbb. Anything else (a theme that
// ever moves to oklch, an empty read during boot) falls back to mid grey
// rather than silently resetting the picker to black.
return /^#[0-9a-f]{6}$/i.test(v) ? v.toLowerCase() : '#808080';
} catch {
return '#808080';
}
}
+1
View File
@@ -44,6 +44,7 @@ const PORTABLE_KEYS = [
'opslog.clusterSlotHighlight', // cluster/band map: colour calls not worked on this band+mode
'opslog.bandMapWidth', // docked band map: column width (px)
'opslog.bandMapTabWidth', // Band map tab: shared card width (px)
'opslog.bandMapZoom', // band map zoom (px/kHz step) remembered per band, as one {band: index} map
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
+7
View File
@@ -85,6 +85,12 @@
--mx-dx-conf: #3730a3; /* entity confirmed*/
--mx-dx-work: #a5b4fc; /* entity worked */
--mx-none: #e7e5e4; /* never worked */
/* Ring on the cell the operator is entering. Declared ONCE, on :root, and
deliberately not repeated per theme: it follows --warning, which every theme
already tunes to its own background. The token exists so the matrix ring can
be recoloured on its own without dragging every other warning in the app
with it (Appearance → matrix colours). */
--mx-cur: var(--warning);
--scrollbar-thumb: #b8a880;
--scrollbar-thumb-hover: #968455;
@@ -974,6 +980,7 @@
--color-mx-dx-conf: var(--mx-dx-conf);
--color-mx-dx-work: var(--mx-dx-work);
--color-mx-none: var(--mx-none);
--color-mx-cur: var(--mx-cur);
--radius: 0.5rem;
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.25.8';
export const APP_VERSION = '0.25.9';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+10
View File
@@ -477,6 +477,8 @@ export function GetLogbookRevision():Promise<string>;
export function GetLookupSettings():Promise<main.LookupSettings>;
export function GetMatrixColors():Promise<main.MatrixColors>;
export function GetMySQLSettings():Promise<main.MySQLSettings>;
export function GetOfflineStatus():Promise<main.OfflineStatus>;
@@ -511,6 +513,8 @@ export function GetRotatorHeading():Promise<main.RotatorHeading>;
export function GetRotators():Promise<Array<main.RotatorDevice>>;
export function GetRotorPresets():Promise<Array<main.RotorPreset>>;
export function GetRowColors():Promise<main.RowColorSettings>;
export function GetSPEStatus():Promise<spe.Status>;
@@ -881,6 +885,8 @@ export function ResetAwardDefs():Promise<Array<award.Def>>;
export function ResetDatabaseToDefault():Promise<void>;
export function ResetRotorPresets():Promise<Array<main.RotorPreset>>;
export function RestartApp():Promise<void>;
export function RestartQSORecorder():Promise<void>;
@@ -949,6 +955,8 @@ export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
export function SaveMatrixColors(arg1:main.MatrixColors):Promise<void>;
export function SaveMySQLSettings(arg1:main.MySQLSettings):Promise<void>;
export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.Antenna>;
@@ -969,6 +977,8 @@ export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
export function SaveRotorPresets(arg1:Array<main.RotorPreset>):Promise<void>;
export function SaveRowColors(arg1:main.RowColorSettings):Promise<void>;
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
+20
View File
@@ -894,6 +894,10 @@ export function GetLookupSettings() {
return window['go']['main']['App']['GetLookupSettings']();
}
export function GetMatrixColors() {
return window['go']['main']['App']['GetMatrixColors']();
}
export function GetMySQLSettings() {
return window['go']['main']['App']['GetMySQLSettings']();
}
@@ -962,6 +966,10 @@ export function GetRotators() {
return window['go']['main']['App']['GetRotators']();
}
export function GetRotorPresets() {
return window['go']['main']['App']['GetRotorPresets']();
}
export function GetRowColors() {
return window['go']['main']['App']['GetRowColors']();
}
@@ -1702,6 +1710,10 @@ export function ResetDatabaseToDefault() {
return window['go']['main']['App']['ResetDatabaseToDefault']();
}
export function ResetRotorPresets() {
return window['go']['main']['App']['ResetRotorPresets']();
}
export function RestartApp() {
return window['go']['main']['App']['RestartApp']();
}
@@ -1838,6 +1850,10 @@ export function SaveLookupSettings(arg1) {
return window['go']['main']['App']['SaveLookupSettings'](arg1);
}
export function SaveMatrixColors(arg1) {
return window['go']['main']['App']['SaveMatrixColors'](arg1);
}
export function SaveMySQLSettings(arg1) {
return window['go']['main']['App']['SaveMySQLSettings'](arg1);
}
@@ -1878,6 +1894,10 @@ export function SaveRotators(arg1) {
return window['go']['main']['App']['SaveRotators'](arg1);
}
export function SaveRotorPresets(arg1) {
return window['go']['main']['App']['SaveRotorPresets'](arg1);
}
export function SaveRowColors(arg1) {
return window['go']['main']['App']['SaveRowColors'](arg1);
}
+44
View File
@@ -2021,6 +2021,7 @@ export namespace main {
flex_host: string;
flex_port: number;
flex_spots: boolean;
flex_dvk_dax: boolean;
flex_decode_spots: boolean;
flex_decode_secs: number;
xiegu_port: string;
@@ -2047,6 +2048,8 @@ export namespace main {
tci_spots: boolean;
poll_ms: number;
delay_ms: number;
offset_on: boolean;
offset_hz: number;
digital_default: string;
share_enabled: boolean;
share_port: number;
@@ -2069,6 +2072,7 @@ export namespace main {
this.flex_host = source["flex_host"];
this.flex_port = source["flex_port"];
this.flex_spots = source["flex_spots"];
this.flex_dvk_dax = source["flex_dvk_dax"];
this.flex_decode_spots = source["flex_decode_spots"];
this.flex_decode_secs = source["flex_decode_secs"];
this.xiegu_port = source["xiegu_port"];
@@ -2095,6 +2099,8 @@ export namespace main {
this.tci_spots = source["tci_spots"];
this.poll_ms = source["poll_ms"];
this.delay_ms = source["delay_ms"];
this.offset_on = source["offset_on"];
this.offset_hz = source["offset_hz"];
this.digital_default = source["digital_default"];
this.share_enabled = source["share_enabled"];
this.share_port = source["share_port"];
@@ -2683,6 +2689,30 @@ export namespace main {
this.cache_ttl_days = source["cache_ttl_days"];
}
}
export class MatrixColors {
enabled: boolean;
call_confirmed: string;
call_worked: string;
entity_confirmed: string;
entity_worked: string;
not_worked: string;
current_entry: string;
static createFrom(source: any = {}) {
return new MatrixColors(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.call_confirmed = source["call_confirmed"];
this.call_worked = source["call_worked"];
this.entity_confirmed = source["entity_confirmed"];
this.entity_worked = source["entity_worked"];
this.not_worked = source["not_worked"];
this.current_entry = source["current_entry"];
}
}
export class MySQLSettings {
enabled: boolean;
@@ -3182,6 +3212,20 @@ export namespace main {
this.motorized = source["motorized"];
}
}
export class RotorPreset {
label: string;
azimuth: number;
static createFrom(source: any = {}) {
return new RotorPreset(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.label = source["label"];
this.azimuth = source["azimuth"];
}
}
export class RowColorRule {
id: string;
color: string;
+52
View File
@@ -79,6 +79,9 @@ type Manager struct {
pollEvery time.Duration
cmdDelay time.Duration // pause after each command (some rigs need it)
// freqOffset is the transverter offset in Hz — see SetFreqOffset. Added to
// what the rig reports, taken off what it is told.
freqOffset int64
}
func NewManager(emit func(RigState)) *Manager {
@@ -185,8 +188,39 @@ func (m *Manager) stopLocked() {
}
}
// SetFreqOffset sets the transverter offset: the number of hertz between what
// the RIG is tuned to and where the station is actually on the air.
//
// A 28 MHz IF driving a 144 MHz transverter is an offset of +116 MHz. Everything
// above this layer — the entry form, the band, the log, the cluster, the shared
// CAT servers — then works in real frequencies, and the rig keeps seeing its own.
//
// Zero disables it, which is why it is a plain number and not a flag plus a
// number: "enabled with an offset of nothing" and "disabled" are the same thing
// to everyone downstream.
func (m *Manager) SetFreqOffset(hz int64) {
m.mu.Lock()
m.freqOffset = hz
m.mu.Unlock()
}
// freqOffsetHz reads the offset.
func (m *Manager) freqOffsetHz() int64 {
m.mu.RLock()
defer m.mu.RUnlock()
return m.freqOffset
}
// SetFrequency dispatches a SetFreq call to the CAT goroutine.
//
// The caller speaks in REAL frequencies (a 2 m spot is 144.300), so the offset
// comes back off before the rig hears it. Without this the offset would be a
// display trick: the readout would say 144 and every spot click, band change and
// memory recall would send the rig somewhere 116 MHz away.
func (m *Manager) SetFrequency(hz int64) error {
if off := m.freqOffsetHz(); off != 0 && hz > off {
hz -= off
}
return m.exec(func(b Backend) error { return b.SetFrequency(hz) })
}
@@ -215,6 +249,10 @@ type splitSetter interface {
// band when it was transmitting on the DX's own frequency. A refusal WSJT-X can
// report is worth far more than a success it cannot check.
func (m *Manager) SetSplit(on bool, txHz int64) error {
// Real frequency in, IF frequency out — same as SetFrequency.
if off := m.freqOffsetHz(); off != 0 && txHz > off {
txHz -= off
}
return m.exec(func(b Backend) error {
s, ok := b.(splitSetter)
if !ok {
@@ -744,6 +782,20 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
ns.Enabled = true
ns.Backend = b.Name()
ns.UpdatedAt = time.Now()
// Transverter offset: the rig reports its IF, the operator is on the
// real band. Applied BEFORE the band is worked out, or a 28 MHz IF
// behind a 2 m transverter would log every contact on 10 m — and the
// band the backend may already have filled in is the IF's, so it is
// recomputed rather than trusted.
if off := m.freqOffsetHz(); off != 0 {
if ns.FreqHz != 0 {
ns.FreqHz += off
ns.Band = ""
}
if ns.RxFreqHz != 0 {
ns.RxFreqHz += off
}
}
if ns.FreqHz != 0 && ns.Band == "" {
ns.Band = BandFromHz(ns.FreqHz)
}
+25 -17
View File
@@ -514,7 +514,8 @@ func (b *IcomSerial) SetFrequency(hz int64) error {
return fmt.Errorf("invalid frequency")
}
b.lastSetFreq, b.lastSetFreqAt = hz, time.Now()
return b.exec(append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)...)
return b.execIdempotent(fmt.Sprintf("set frequency %d Hz", hz),
append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)...)
}
func (b *IcomSerial) SetMode(mode string) error {
@@ -524,7 +525,7 @@ 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.exec(civ.CmdSetMode, code); err != nil {
if err := b.execIdempotent("set mode "+mode, civ.CmdSetMode, code); err != nil {
return err
}
dataByte := byte(0)
@@ -532,7 +533,7 @@ func (b *IcomSerial) SetMode(mode string) error {
dataByte = 1
}
// Filter 0x01 (FIL1) is the conventional default for the data-mode set.
_ = b.exec(civ.CmdExtra, civ.SubDataMode, dataByte, 0x01)
_ = b.execIdempotent("set data mode", civ.CmdExtra, civ.SubDataMode, dataByte, 0x01)
return nil
}
@@ -542,31 +543,38 @@ func (b *IcomSerial) SetMode(mode string) error {
// a formatted string so callers can tell the two apart.
var errIcomAckLost = errors.New("icom: timeout waiting for response")
// SetPTT keys or unkeys the transmitter (CI-V 0x1C 0x00), retrying ONCE when the
// execIdempotent runs a SET command and sends it ONCE MORE if the
// acknowledgement is lost.
//
// A missing FB is not a missing command — the rig acts on the frame as soon as it
// decodes it, and what expires is our wait for the answer on a bus shared with
// the rig's own transceive updates. JTDX in "Split Operating: Fake It" moves the
// dial immediately before every key-down, so the PTT ack queues behind that
// traffic, and one lost ack was fatal: rigctld answered RPRT -9, JTDX read that
// as losing rig control and tore the connection down mid-over, reopening it a
// moment later (an operator's log shows exactly that, twice, a new rigctld client
// within 300 ms of each failure). The same session over TCI never failed, because
// TCI carries no CI-V and needs no Fake It.
// dial and the mode immediately before every key-down, so those acks queue behind
// each other, and losing one was fatal: rigctld answers RPRT -9, JTDX reads that
// as losing rig control and tears the connection down mid-over. An operator's log
// shows it happening on set_ptt, on set_freq and on set_mode alike, each failure
// followed within 300 ms by a fresh rigctld client — and shows this resend
// rescuing a PTT that would otherwise have ended the over.
//
// Re-sending is safe: asking for a state the rig is already in changes nothing.
// Only for commands that say "be in this state": re-sending one changes nothing
// if the first arrived. A relative or incremental command must not come through
// here, which is why this is opt-in per caller rather than folded into exec.
func (b *IcomSerial) execIdempotent(what string, payload ...byte) error {
err := b.exec(payload...)
if err == nil || !errors.Is(err, errIcomAckLost) {
return err
}
applog.Printf("icom: %s — no acknowledgement in %s, sending it once more", what, icomCmdTimeout)
return b.exec(payload...)
}
// SetPTT keys or unkeys the transmitter (CI-V 0x1C 0x00).
func (b *IcomSerial) SetPTT(on bool) error {
state := byte(0)
if on {
state = 1
}
err := b.exec(civ.CmdPTT, civ.SubPTT, state)
if err == nil || !errors.Is(err, errIcomAckLost) {
return err
}
applog.Printf("icom: PTT %v — no acknowledgement in %s, sending it once more", on, icomCmdTimeout)
return b.exec(civ.CmdPTT, civ.SubPTT, state)
return b.execIdempotent(fmt.Sprintf("PTT %v", on), civ.CmdPTT, civ.SubPTT, state)
}
// SetPower turns the transceiver on or off (CI-V 0x18). Power-ON is prefixed with
+39 -4
View File
@@ -195,10 +195,7 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
// operator was looking the call up for, and it came back empty while
// the same lookup without the suffix answered perfectly.
if !saysNothingAboutLocation(call) {
r.Country, r.Continent = "", ""
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
r.Lat, r.Lon = 0, 0
r.Grid, r.State, r.County = "", "", ""
clearHomeLocation(&r)
}
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
normalizeNames(&r)
@@ -378,6 +375,17 @@ func titleCase(s string) string {
// the DXCC number since QRZ's value is wrong and we don't have an entity
// → DXCC# table yet.
// Returns true if any field was filled.
// clearHomeLocation drops the fields that say WHERE a callbook record's operator
// lives, and keeps the ones that say WHO they are — name, address, QSL route.
// A portable operator's cards still go to the home address, so that address is
// not wrong; their county is.
func clearHomeLocation(r *Result) {
r.Country, r.Continent = "", ""
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
r.Lat, r.Lon = 0, 0
r.Grid, r.State, r.County = "", "", ""
}
func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
if dxcc == nil {
return false
@@ -387,6 +395,33 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
return false
}
filled := false
// A subdivision belongs to an ENTITY, so a record describing one entity has
// nothing to say about a station operating from another.
//
// TI8/W2RE came back as a Costa Rica contact in Dutchess County, New York, on
// square FN31 — W2RE's home details, from a QRZ page that carries the home
// mailing address as most portable pages do. CNTY is *defined* as a US
// county, so that is not a cosmetic slip: it is a US county award credit
// recorded against a Costa Rica QSO, and a distance and beam heading computed
// from a square 3,600 km from where the station actually is.
//
// The home-call fallback above already clears these, but it only runs when NO
// provider had the slashed form. An operator with a page for their portable
// call never reached it. Cleared here instead, where the operating entity is
// known — which also heals rows already in the cache, since every cache hit
// comes back through here.
//
// Same-entity portables (F4BPO/P, W2RE/2) are untouched: the entities match,
// and there the home details ARE where the operator is.
if dxccNum != 0 && strings.ContainsRune(r.Callsign, '/') && !saysNothingAboutLocation(r.Callsign) {
if home := homeCall(r.Callsign); home != "" && home != r.Callsign {
if homeNum, _, _, _, _, _, _, homeOK := dxcc.Resolve(home); homeOK && homeNum != 0 && homeNum != dxccNum {
clearHomeLocation(r)
filled = true
}
}
}
if country != "" {
r.Country = country
filled = true
+93
View File
@@ -0,0 +1,93 @@
package lookup
import "testing"
// fakeDXCC resolves a handful of calls, enough to stand in for cty.dat.
type fakeDXCC map[string]struct {
num int
country string
cont string
cqz, ituz int
lat, lon float64
}
func (f fakeDXCC) Resolve(call string) (int, string, string, int, int, float64, float64, bool) {
e, ok := f[call]
if !ok {
return 0, "", "", 0, 0, 0, 0, false
}
return e.num, e.country, e.cont, e.cqz, e.ituz, e.lat, e.lon, true
}
func testDXCC() fakeDXCC {
return fakeDXCC{
// Costa Rica, as cty.dat reads the OPERATING call.
"TI8/W2RE": {num: 308, country: "Costa Rica", cont: "NA", cqz: 7, ituz: 11, lat: 9.9, lon: -84.1},
// The home call: United States.
"W2RE": {num: 291, country: "United States", cont: "NA", cqz: 5, ituz: 8, lat: 39.8, lon: -98.5},
// A same-entity portable: still France either way.
"F4BPO/P": {num: 227, country: "France", cont: "EU", cqz: 14, ituz: 27, lat: 46.2, lon: 2.2},
"F4BPO": {num: 227, country: "France", cont: "EU", cqz: 14, ituz: 27, lat: 46.2, lon: 2.2},
}
}
// A callbook record for a portable call routinely carries the operator's HOME
// address — most QRZ pages for a portable call do — and a subdivision belongs to
// an entity. Carrying the home county across an entity change is not cosmetic:
// CNTY is defined as a US county, so TI8/W2RE was coming out as a Costa Rica
// contact credited to Dutchess County, New York, with the distance and beam
// heading taken from a square 3,600 km from where the station actually was.
func TestPortableInAnotherEntityDropsTheHomeSubdivision(t *testing.T) {
r := Result{
Callsign: "TI8/W2RE",
Name: "Raymond", // who they are — kept
QTH: "Poughquag",
Address: "499 Pleasant Ridge Road", // cards still go there — kept
State: "NY",
County: "Dutchess",
Grid: "FN31",
Lat: 41.6, Lon: -73.7,
DXCC: 291,
}
fillFromDXCC(&r, testDXCC())
if r.County != "" {
t.Errorf("county = %q, want empty — a US county on a Costa Rica QSO is a false award credit", r.County)
}
if r.State != "" {
t.Errorf("state = %q, want empty — a subdivision of the entity that is not being worked", r.State)
}
if r.Grid != "" {
t.Errorf("grid = %q, want empty — it is the home square, 3,600 km from the operation", r.Grid)
}
// The entity and its centroid take over, so distance and bearing mean
// something again.
if r.DXCC != 308 || r.Country != "Costa Rica" {
t.Errorf("entity = %d %q, want 308 Costa Rica", r.DXCC, r.Country)
}
if r.Lat != 9.9 || r.Lon != -84.1 {
t.Errorf("lat/lon = %v/%v, want the Costa Rica centroid — the home coordinates must not survive", r.Lat, r.Lon)
}
// Who they are, and where their cards go, is unchanged.
if r.Name != "Raymond" || r.Address != "499 Pleasant Ridge Road" {
t.Errorf("name/address were cleared (%q / %q) — a portable operator's post still reaches home", r.Name, r.Address)
}
}
// The other half of the rule: a portable WITHIN the same entity is at home as
// far as the entity is concerned, and its details must survive untouched.
func TestSameEntityPortableKeepsItsLocation(t *testing.T) {
r := Result{
Callsign: "F4BPO/P",
State: "77", County: "Seine-et-Marne", Grid: "JN18cs",
Lat: 48.8, Lon: 2.4, DXCC: 227,
}
fillFromDXCC(&r, testDXCC())
if r.Grid != "JN18cs" || r.County != "Seine-et-Marne" || r.State != "77" {
t.Errorf("a same-entity portable lost its location: grid=%q county=%q state=%q", r.Grid, r.County, r.State)
}
if r.Lat != 48.8 || r.Lon != 2.4 {
t.Errorf("lat/lon = %v/%v — the precise home position was replaced by the entity centroid", r.Lat, r.Lon)
}
}
+84
View File
@@ -0,0 +1,84 @@
package qso
import (
"context"
"testing"
"time"
)
// LoTW never hands back the submode it was given: every digital contact comes
// back as the mode GROUP, "DATA". Matched on the exact mode string that is
// simply never equal, so an operator downloading their confirmations was told
// their own QSOs were not in their log — reported for FT2 contacts confirmed as
// DATA, and true of FT4 and FT8 alike.
func TestClassKeyMatchesAConfirmationCarryingTheModeGroup(t *testing.T) {
r := openRepo(t)
ctx := context.Background()
when := time.Date(2026, 8, 16, 16, 29, 0, 0, time.UTC)
id, err := r.Add(ctx, QSO{Callsign: "F1NQP", QSODate: when, Band: "20m", Mode: "FT2"})
if err != nil {
t.Fatal(err)
}
minute := when.Format("2006-01-02T15:04")
exact, err := r.DedupeKeyIDs(ctx)
if err != nil {
t.Fatal(err)
}
if _, found := exact[DedupeKey("F1NQP", minute, "20m", "DATA")]; found {
t.Fatal("the exact index matched DATA against FT2 — the test proves nothing")
}
byClass, err := r.DedupeClassKeyIDs(ctx)
if err != nil {
t.Fatal(err)
}
got, found := byClass[DedupeClassKey("F1NQP", minute, "20m", "DATA")]
if !found || got != id {
t.Errorf("class match = (%d, %v), want (%d, true) — the confirmation would be reported as having no local QSO", got, found, id)
}
}
// Two digital contacts with the same station, same band, same minute is barely
// physical — but if it happens, stamping the confirmation on whichever row the
// map happened to keep is a silent error in an award credit. Ambiguity maps to
// 0 so the caller reports it unmatched instead of guessing.
func TestAnAmbiguousClassKeyIsRefusedRatherThanGuessed(t *testing.T) {
r := openRepo(t)
ctx := context.Background()
when := time.Date(2026, 8, 16, 16, 29, 0, 0, time.UTC)
for _, m := range []string{"FT8", "RTTY"} {
if _, err := r.Add(ctx, QSO{Callsign: "F1NQP", QSODate: when, Band: "20m", Mode: m}); err != nil {
t.Fatal(err)
}
}
byClass, err := r.DedupeClassKeyIDs(ctx)
if err != nil {
t.Fatal(err)
}
if got := byClass[DedupeClassKey("F1NQP", when.Format("2006-01-02T15:04"), "20m", "DATA")]; got != 0 {
t.Errorf("ambiguous class key resolved to %d — one of two QSOs was picked at random", got)
}
}
// Phone and CW keep their own classes: a CW confirmation must never land on an
// SSB contact just because the rest of the key agrees.
func TestClassMatchDoesNotCrossPhoneAndCW(t *testing.T) {
r := openRepo(t)
ctx := context.Background()
when := time.Date(2026, 8, 16, 16, 29, 0, 0, time.UTC)
if _, err := r.Add(ctx, QSO{Callsign: "F1NQP", QSODate: when, Band: "20m", Mode: "SSB"}); err != nil {
t.Fatal(err)
}
byClass, err := r.DedupeClassKeyIDs(ctx)
if err != nil {
t.Fatal(err)
}
minute := when.Format("2006-01-02T15:04")
if _, found := byClass[DedupeClassKey("F1NQP", minute, "20m", "CW")]; found {
t.Error("a CW confirmation matched an SSB contact")
}
if _, found := byClass[DedupeClassKey("F1NQP", minute, "20m", "USB")]; !found {
t.Error("USB did not match the SSB contact — the phone sidebands are one class")
}
}
+45
View File
@@ -2691,6 +2691,51 @@ func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {
return out, rows.Err()
}
// DedupeClassKey is DedupeKey with the mode collapsed to its CLASS (Phone / CW /
// Digital).
//
// For matching a downloaded confirmation whose mode is not the one you logged.
// LoTW does not hand back the submode it was given: a contact uploaded as FT4,
// FT8 or anything else digital comes back as the mode GROUP, "DATA". Matched on
// the exact string that is simply never equal, and the operator is told their
// own QSO is not in their log — which is how a confirmed contact goes unrecorded.
func DedupeClassKey(callsign, qsoDateMinute, band, mode string) string {
return strings.ToUpper(callsign) + "|" + qsoDateMinute + "|" + strings.ToLower(band) + "|" + modeClass(mode)
}
// DedupeClassKeyIDs is DedupeKeyIDs at mode-CLASS granularity, for the second
// pass when the exact key misses.
//
// A key shared by more than one QSO maps to 0 — AMBIGUOUS, not "pick one". Two
// contacts with the same station, on the same band, in the same minute, in two
// digital modes is barely physical; but if it ever happens, stamping the
// confirmation on whichever row the map happened to keep would be a silent
// error in someone's award credit. Better to report it unmatched.
func (r *Repo) DedupeClassKeyIDs(ctx context.Context) (map[string]int64, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, callsign, substr(qso_date, 1, 16), band, mode
FROM qso`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]int64, 1024)
for rows.Next() {
var id int64
var call, when, band, mode string
if err := rows.Scan(&id, &call, &when, &band, &mode); err != nil {
return nil, err
}
k := DedupeClassKey(call, when, band, mode)
if prev, seen := out[k]; seen && prev != id {
out[k] = 0
continue
}
out[k] = id
}
return out, rows.Err()
}
// matchRef is one local QSO's time + id, for time-window confirmation matching.
type matchRef struct {
when time.Time
+54 -1
View File
@@ -178,7 +178,41 @@ func (s *Server) Start() error {
}
// Stop closes the listener and every client.
// releasePTT drops a PTT this server asserted, and does it once.
//
// A client that dies mid-over — or a settings save that closes the server —
// leaves the rig keyed with nobody left to un-key it, into an amplifier that has
// no idea the transmission ended. rigctld has had this guard for a while (a K3
// once sat in transmit for 29 s until the CAT link happened to be rebuilt); the
// TCI server was written without it, so an operator who moved from Hamlib to TCI
// silently lost the protection.
//
// pttKnown is cleared whatever happens: after an emergency unkey the radio's
// state is a guess, and the next command must reach it rather than be dismissed
// as a repeat.
func (s *Server) releasePTT(why string) {
s.mu.Lock()
keyed := s.ptt
s.ptt, s.pttKnown = false, false
s.mu.Unlock()
if !keyed {
return
}
s.log("tci server: %s while the rig was keyed — dropping PTT", why)
if err := s.rig.SetPTT(false); err != nil {
s.log("tci server: emergency unkey FAILED: %v", err)
return
}
// Any client still attached is told, so a second logger's transmit indicator
// does not stay lit over a rig that is back in receive.
s.broadcast("trx:0,false;")
}
func (s *Server) Stop() {
// Before anything is torn down: the CAT backend is still up here, so an unkey
// still lands. Same ordering as rigctld.Stop for the same reason.
s.releasePTT("TCI server stopped")
s.mu.Lock()
if s.closed {
s.mu.Unlock()
@@ -266,6 +300,8 @@ func (s *Server) serve(c *client, remote string) {
s.mu.Unlock()
_ = c.conn.Close()
s.log("tci server: %s disconnected", remote)
// A client that walks away mid-over must not leave the rig transmitting.
s.releasePTT("client " + remote + " left")
}
// initBlock is the initialisation set from §4.1 of the protocol document, in
@@ -484,16 +520,33 @@ func (s *Server) handle(c *client, cmd string) string {
// knowing how the radio was left.
s.mu.Lock()
known, prev := s.pttKnown, s.ptt
s.ptt, s.pttKnown = on, true
s.mu.Unlock()
if known && prev == on {
s.broadcast(fmt.Sprintf("trx:0,%t;", on))
return ""
}
if err := s.rig.SetPTT(on); err != nil {
// The cache is stamped ONLY on success, and a failure clears "known"
// outright so the NEXT command — whatever it is — reaches the radio.
//
// It used to be written before the radio was commanded and left in
// place when the command failed. That is how a rig got stuck keyed for
// good: the un-key failed on a lost CI-V acknowledgement, the cache
// recorded "off" regardless, and from then on every trx:0,false was
// dismissed as a repeat of a state the radio had never reached. Not
// even reconnecting the client cleared it — this cache is per-server,
// not per-connection — so the transmitter stayed keyed into the
// amplifier until the operator switched the radio off. A cache must
// never claim something the radio refused.
s.mu.Lock()
s.pttKnown = false
s.mu.Unlock()
s.log("tci server: PTT %v refused: %v", on, err)
return ""
}
s.mu.Lock()
s.ptt, s.pttKnown = on, true
s.mu.Unlock()
s.log("tci server: PTT %s", map[bool]string{true: "ON", false: "off"}[on])
s.broadcast(fmt.Sprintf("trx:0,%t;", on))
return ""
+56
View File
@@ -16,6 +16,7 @@ type fakeRig struct {
txHz int64
ptt bool
splitErr error
pttErr error
calls []string
}
@@ -34,6 +35,10 @@ func (r *fakeRig) SetMode(m string) error {
return nil
}
func (r *fakeRig) SetPTT(on bool) error {
// Refused BEFORE the state moves, like a radio that never got the frame.
if r.pttErr != nil {
return r.pttErr
}
r.calls = append(r.calls, fmt.Sprintf("ptt=%v", on))
r.ptt = on
return nil
@@ -328,3 +333,54 @@ func TestRepeatedPTTIsNotResentToTheRadio(t *testing.T) {
t.Errorf("the radio was told %v, want the change through and the repeat dropped", r.calls)
}
}
// A refused un-key must never be remembered as done.
//
// The failure an operator hit running JTDX over TCI with an Icom on CI-V: the
// rig went to transmit, the un-key was refused on a lost acknowledgement, and
// from then on NOTHING could take it out of transmit. The cache had stamped
// "off" before the radio was even commanded and kept it after the refusal, so
// every later trx:0,false was dismissed as a repeat of a state the radio had
// never reached. It is per-server, not per-connection, so reconnecting the
// client changed nothing either — the transmitter stayed keyed into the
// amplifier, with no drive, until the radio was switched off by hand.
func TestARefusedUnkeyIsNotRememberedAsDone(t *testing.T) {
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
s := srv(r)
ask(t, s, "trx:0,true")
if !r.ptt {
t.Fatal("the rig was never keyed — the test would prove nothing")
}
r.pttErr = fmt.Errorf("icom: timeout waiting for response")
ask(t, s, "trx:0,false")
if !r.ptt {
t.Fatal("the fake rig un-keyed on a refusal — the test would prove nothing")
}
// The client asks again, and this time the radio answers. It MUST be told.
r.pttErr = nil
ask(t, s, "trx:0,false")
if r.ptt {
t.Error("still keyed: the refused un-key was cached as done and the retry was dropped as a repeat")
}
}
// A client that walks away mid-over must not leave the rig transmitting, and
// the release must be once-only — a second call has nothing to un-key and must
// not re-command a radio that is already receiving.
func TestReleasePTTUnkeysOnceWhenTheClientLeaves(t *testing.T) {
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
s := srv(r)
ask(t, s, "trx:0,true")
s.releasePTT("client left")
if r.ptt {
t.Error("the rig is still keyed after the client left")
}
n := len(r.calls)
s.releasePTT("client left")
if len(r.calls) != n {
t.Errorf("released twice — the radio was told %v", r.calls)
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.25.8"
appVersion = "0.25.9"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.