Compare commits

...
32 Commits
Author SHA1 Message Date
rouggy 2de5bce998 chore: release v0.23.6 2026-08-05 15:22:02 +02:00
rouggy f0959c796d fix(bandmap): call all hooks before the unknown-band early return (React #300)
The Ctrl+↑/↓ keyboard-nav useEffect sat AFTER `if (!range) return`, so on a band
with no range — e.g. a spurious 33 cm frequency briefly reported by an Icom over
CAT — the component returned before that hook and the hook count changed between
renders, crashing the whole window with React #300. Moved the effect above the
early return; lo/hi already fall back to [0,1] when there's no range.
2026-08-05 15:13:47 +02:00
rouggy 16e64fd7b7 fix(ultrabeam): accept an 11-byte status frame from older controllers
An older controller behind an RS232-to-Ethernet bridge returns the status frame
without its trailing FreqMax byte (11 bytes, not 12). The packet checksum is
still valid, so it's a real short frame, not a fragment. queryStatus now accepts
>= 10 bytes and defaults the missing FreqMin/FreqMax rather than rejecting it as
"too short", which reconnect-looped the link. Whether the byte LAYOUT is
otherwise identical across controller versions still needs confirming from the
protocol reference.
2026-08-05 14:49:52 +02:00
rouggy 7e696a72bb feat(cluster): dim spots that represent nothing
A spot whose entity/band/mode is already worked, whose callsign isn't in the log,
and that carries no POTA/county/prefix novelty is dimmed (whole row, opacity) so
the eye skips it. "Nothing" follows the status, which already obeys the "same
slot" option and digital grouping. getRowStyle drives it; the spot-status effect
now redrawRows so the dimming re-applies when a status lands.
2026-08-05 14:38:19 +02:00
rouggy 9af2732314 fix(cluster): WORKED chip filters worked-call only, not the entity status
The WORKED filter also matched the entity status 'worked' (entity/band/mode
already worked, shown as a plain "—"), so selecting WORKED still listed spots of
calls never worked. Match the 'worked' key only via worked_call (the blue
WKD-CALL flag); a worked call still shows under NEW BAND / NEW SLOT.
2026-08-05 14:26:34 +02:00
rouggy f82d92565f feat(cluster): add a WORKED status-filter chip
The filter logic already matched worked_call spots against a 'worked' key; add
the chip to the status row so worked spots can be shown or isolated, not only
dropped via the "Hide worked" checkbox.
2026-08-05 14:17:57 +02:00
rouggy d0f25aea9b feat(cluster): "already worked only on the same slot" option
New DX Cluster setting (Settings → DX Cluster). Off (today's behaviour) a call
worked anywhere reads as already worked; on, the WORKED-call flag needs the same
band AND mode. It folds through the digital-mode grouping (Settings → General):
grouped, a 20m FT8 contact also marks a 20m FT4 spot worked; ungrouped they are
separate slots. Backend: qso.WorkedCallSlotKeys builds CALL|band and
CALL|band|mode(grouped) keys; ClusterSpotStatuses uses them when the option is on
(a spot with no inferable mode falls back to same-band).
2026-08-05 14:11:06 +02:00
rouggy f91b83ee12 fix(awards): upper-case the DXCC main prefix (3D2/c → 3D2/C)
cty.dat spells some sub-entity suffixes in lower case; the awards prefix column
now shows them upper-cased like every other DXCC prefix.
2026-08-05 14:05:09 +02:00
rouggy a554257d5f feat(entry/stats/awards): Alt+W clear, slot drill-down, DXCC prefix column
Five operator-requested items:

- Alt+W clears the QSO entry. Handled before the `typing` guard and above
  the keyer's key routing, so there is always one key that clears whatever
  else is running — Esc is not that key when the CW keyer reserves it.

- The Grid box no longer pops outside the entry panel on a narrow window.
  Row 2 needed 300+130+76+gaps = 538 px inside a panel whose min-width is
  520, so Grid was pushed out and clipped at every narrow width, not just
  extreme ones. QTH's min-width drops to 80 and the row wraps rather than
  overflowing if it ever still can't fit.

- Selecting a QSO in the log now drives the Stats (F1) matrix. Uses its own
  WorkedBefore call into separate state, NOT runWorkedBefore: that one owns
  the entry form's wbRef and can trigger a field backfill, which browsing
  the log must never do. The entry form wins whenever it holds a call.

- Clicking a coloured band/mode square lists the contacts behind it. Returns
  the exact callsign AND the rest of the entity, because that pair is what
  the cell's colour encodes; the call's own QSOs are bolded. The DXCC arm
  matches the stored dxcc column only — reconstructing it from the callsign
  here would disagree with the matrix above, which is built from that column.

- The Awards DXCC list shows each entity's primary prefix in its own sortable
  column. Derived live from cty.dat into Ref.Group rather than stored on the
  reference row, so an existing installation needs no re-seed.
2026-08-05 13:47:36 +02:00
rouggy a20d9a0b76 fix(spe): drop the link on power-off so status shows off and ON works
Switching an SPE amp off left the already-open connection reading as connected
(the amp stops answering once off, but the stale open handle kept the status at
"connected"), so the panel never showed OFF and PowerOn — which only wakes a
DISCONNECTED amp, and must NOT pulse RTS/DTR on a running one — did nothing
until the operator restarted OpsLog. PowerOff now drops the connection and marks
the amp offline, exactly as a fresh start would; the poll's plain reopen never
wakes a switched-off amp, so it stays off until the ON button's RTS/DTR edge.
2026-08-05 11:02:44 +02:00
rouggy 92ee8aadde fix(entry): stop stray QSO logging from freq-Enter and bare-number DX call
Two ways a bogus contact could be logged:
- Enter in the TX/RX frequency field bubbled to the entry container's
  onKeyDown, which calls save() on Enter-in-an-input. Typing a frequency and
  pressing Enter tuned the rig AND logged the QSO. The freq fields now
  stopPropagation so Enter only tunes.
- WSJT-X/JTDX broadcasts its DX-call field over UDP; when it holds a bare
  number ("1"), applyUdpCall dropped it into the callsign, and the next Enter
  logged a QSO with callsign "1". Reject any DX call with no letter.
2026-08-05 10:45:58 +02:00
rouggy 61c6666a4c ui(cat): trim verbose Kenwood/CAT-sharing help text
Removed the long explanatory hints under the Kenwood COM/baud/host, data-mode,
CAT-trace and PTT-hotkey fields; shortened the CAT-sharing hint to just the
client setup line; and replaced the DTR/RTS hint with a one-liner. Less clutter
in the CAT settings.
2026-08-05 10:17:54 +02:00
rouggy 9f5c482667 fix(catshare): echo PTT state so JTDX doesn't drop transmit
JTDX polls get_ptt DURING transmit to confirm the rig is keyed (WSJT-X does not,
which is why it worked and JTDX cut after ~2 s). We answered a blanket "0" (RX),
so JTDX concluded PTT had failed and dropped the over. get_ptt now echoes the
last PTT state commanded via set_ptt — always consistent with the client's own
command. Trace showed T 1 → three t→"0" polls → T 0.
2026-08-05 09:55:27 +02:00
rouggy b9ea077a64 fix(catshare): revert FT8 freq echo — it blocked JTDX transmit
The optimistic get_freq echo added in 0.23.5 (to stop the "Fake It" dial creep)
stopped JTDX/WSJT-X from going into transmit: the client reads frequency back
during its TX sequence, and echoing the commanded value instead of the rig's
live value interfered with it. No-TX is far worse than a 0.5 kHz drift, so revert
to reporting the rig's live frequency. get_freq/get_split_freq return s.rig.Freq()
again; the echo state, noteSetFreq, reportedFreq and the drift test are removed.
2026-08-05 09:26:33 +02:00
rouggy a48fa0b2e7 chore: add another callsign to the startup gate 2026-08-05 09:15:55 +02:00
rouggy 821883dfd3 fix(ultrabeam): flush stale replies (not seq-match) + instant moving flag
The antenna does NOT echo our sequence number — its replies carry their own
counter — so the previous seq-matching drained every reply and stalled status
updates. Revert to reading one reply per command, but flush any bytes left in
the stream before each command (drainStale): a reply left by a timed-out command
is discarded so the next read stays 1:1. readPacket also resyncs to the next STX.
This fixes the intermittent disconnects, phantom frequency jumps and wrong
element-length readings without depending on the seq.

Also: report motion for a short window right after a commanded move, so the
"moving" indicator and the Flex TX-inhibit fire the instant a band/pattern is
clicked instead of a poll (~2 s) later; the real motor state takes over once
polled.
2026-08-05 00:13:53 +02:00
rouggy ef087492cc perf(logviewer): 512 KB tail instead of 1 MB
1 MB (~6500 lines) fetched, split and re-rendered every second made the window
lag. 512 KB (~3200 lines) keeps a useful backlog — double the original 256 KB —
while staying responsive.
2026-08-04 23:58:38 +02:00
rouggy 98f11ee3d0 fix(ultrabeam): match replies to requests by seq (stop stream desync)
sendCommand discarded the reply's sequence number and accepted whatever frame
came back. On a slow remote link a command that timed out left its late reply in
the stream, and the NEXT command read it as its own — crossing STATUS with
READ_BANDS/PROGRESS. That surfaced as phantom frequency jumps (a spurious
follow-loop re-tune), intermittent "reply too short" disconnects, and wrong
element-length readings (READ_BANDS getting the status frame).

Now every reply is matched to its request seq and stale/malformed frames are
drained (bounded), with readPacket resyncing to the next STX. Tested with a
net.Pipe that injects a stale reply ahead of the real one.
2026-08-04 23:56:42 +02:00
rouggy 058f164ab6 docs(changelog): move log-viewer entry to 0.23.6 (0.23.5 already released) 2026-08-04 23:40:59 +02:00
rouggy 6c47c27775 feat(logviewer): keep ~4x more history (1 MB tail)
The viewer fetched only the last 256 KB (~1600 lines) of opslog.log. During a
busy trace the oldest lines scrolled out of the window while the operator was
still reading them. Raise the tail to 1 MB (~6500 lines); the backend already
allows up to 4 MB.
2026-08-04 23:37:55 +02:00
rouggy 7410fa4825 chore: release v0.23.5 2026-08-04 23:23:40 +02:00
rouggy 246d9d1b6a chore: call gate checks the active profile only
Checking every profile made a denied call unrecoverable — the process quits
before the operator can change it, and no profile switch could get back in.
Gating on the active profile alone keeps "--profile <other>" as a way back in,
and still turns away a denied operator whose active call is the blocked one.
2026-08-04 23:21:40 +02:00
rouggy d12cfe94cd chore: build call gate on the station callsign at startup
A tiny denylist of station callsigns, stored as SHA-256 of the base call so no
call appears in the source or the binary. Checked once at startup across every
profile; a match exits the process silently before any further wiring. Not in
the changelog.
2026-08-04 23:13:15 +02:00
rouggy ede81d3926 fix(catshare): stop FT8 dial drift in shared CAT (rigctld freq echo)
A digital app (JTDX/WSJT-X) sharing OpsLog's rig in "Fake It" split follows
the dial by polling get_freq. Freq() is the last polled value and lags a
set_freq by a poll cycle, so right after a transmission the client read the
still-shifted frequency, mistook it for a manual QSY and adopted it — the dial
crept down every over and never came back. get_freq now echoes the last
commanded frequency until the rig confirms it (or a short deadline passes),
closing the race. Backend-agnostic, so it fixes every rig, not just Kenwood.
2026-08-04 23:02:44 +02:00
rouggy f27faa81a6 chore: release v0.23.4 2026-08-04 19:41:06 +02:00
rouggy 69e6c20b97 docs(changelog): merge the two QSL Manager entries into one for 0.23.4 2026-08-04 19:39:52 +02:00
rouggy d93c40ebb3 fix(qsl): clearer QRZ download summary (confirmed vs total)
QRZ hands back the whole logbook; a confirmation download only acts on the
QSOs QRZ marks confirmed. The old "(of 7950 returned)" read as if QRZ had
returned only a fraction of a 26k-QSO logbook. Now it states "N of M are
confirmed — the rest aren't confirmed yet", so nothing looks lost.
2026-08-04 19:35:47 +02:00
rouggy dfe3afbf2d fix(qsl): cancel an in-flight confirmation download on close / new download
The LoTW/QRZ confirmation download ran on the app-lifetime context, so it
kept going after the QSL Manager was closed and a new download didn't stop the
previous one. A slow QRZ sync therefore bled its "flag cleared" log into a
freshly started LoTW download — the operator saw QRZ activity during a LoTW
run. Now each download gets a cancellable context: starting one cancels the
previous, closing the window cancels it (CancelConfirmations), and the parse
loops bail promptly on cancel. (A LoTW http 503 is separate — ARRL's server.)
2026-08-04 19:25:14 +02:00
rouggy bb3542c920 fix(ultrabeam): hold a commanded pattern over a remote link until confirmed
Changing Normal/180/bi over a remote connection reverted to the old pattern
after ~4s: the fixed 4s optimistic window expired while the motors were still
flipping the elements, and the lagging remote status poll then overrode the UI.
Hold the commanded direction while the motors are moving, then a short grace
window after they stop for the confirmation poll to land (SteppIR already used
a generous 45s hold, which is why only Ultrabeam-remote hit this).
2026-08-04 18:56:21 +02:00
rouggy a966abd9df fix(cw): show the Kenwood/Elecraft keyer as connected when CAT is up
The keyer panel synthesised a "connected" status for the icom/flex/yaesu
rig engines but not kenwood, so the Kenwood/Elecraft engine fell back to the
(disconnected) WinKeyer hardware status and always showed "not connected"
even though its CAT link was up.
2026-08-04 18:46:44 +02:00
rouggy 95482ec1d8 feat(cat): per-rig Kenwood/Elecraft data-mode choice (USB / MD6 / keep)
No single Kenwood mode digit fits every rig for a data mode: a K3/K4 wants
DATA (MD6), a TS-590SG/TS-990S data mode is a USB modifier set on the rig,
and MD6 on a plain Kenwood is FSK/RTTY. So the operator now chooses in
Settings → CAT: USB (default), DATA (MD6), or leave the rig's mode unchanged.

Only soundcard/data modes (FT8, PSK, JT…) follow this; RTTY/FSK stays its own
native mode, and voice/CW are untouched (isKenwoodDataMode + test).
2026-08-04 18:14:26 +02:00
rouggy 500fe8bfec fix(cw): activate the Kenwood/Elecraft keyer engine when selected
The frontend engine loader had no "kenwood" case, so a saved engine of
"kenwood" fell through to the WinKeyer default — the Kenwood/Elecraft
CW-over-CAT engine never ran even though the setting showed it selected.

Also clarify the Kenwood data-mode comment: there is no single mode digit
right for both a K3 (DATA=MD6) and a TS-590SG (MD6=FSK), so data stays on
USB as the safe common base; a rig-specific DATA mode is a follow-up.
2026-08-04 17:59:30 +02:00
28 changed files with 1086 additions and 143 deletions
+154 -8
View File
@@ -126,6 +126,7 @@ const (
// backend's setting.
keyCATYaesuLowLines = "cat.yaesu.low_dtr_rts" // deassert DTR/RTS on connect
keyCATKenwoodLowLines = "cat.kenwood.low_dtr_rts" // deassert DTR/RTS on connect
keyCATKenwoodDataMode = "cat.kenwood.data_mode" // data modes → "usb" | "data" (MD6) | "keep"
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200)
keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98)
@@ -403,6 +404,10 @@ type CATSettings struct {
// adapter). NOT the radios own RJ45, which speaks Kenwoods KNS/ARCP.
KenwoodPort string `json:"kenwood_port"` // Kenwood CAT serial port (TS-590/890/2000, Elecraft)
KenwoodBaud int `json:"kenwood_baud"` // Kenwood CAT baud (TS-590 default 9600)
// What a DATA/digital mode (FT8, PSK…) sets on the rig: "usb" (default), "data"
// (MD6 — Elecraft K3/K4 DATA mode) or "keep" (leave the rig's mode untouched,
// for a TS-590SG/TS-990S whose data mode is a USB modifier set on the rig).
KenwoodDataMode string `json:"kenwood_data_mode"`
// Per-backend: deassert DTR and RTS after opening the CAT port, for
// interfaces that read either line as PTT. Off by default: lowering them
// stops some USB-serial interfaces transmitting at all.
@@ -617,6 +622,12 @@ type App struct {
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
dvkPttKeyed bool // we keyed PTT for a voice message; unkey 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
// one instead of leaving it running against the app-lifetime context (which made
// a still-running QRZ sync bleed its log into a freshly started LoTW download).
confDLMu sync.Mutex
confDLCancel context.CancelFunc
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets)
relayAutoMu sync.Mutex // serialises relay auto-control evaluation
@@ -975,6 +986,12 @@ func (a *App) startup(ctx context.Context) {
}
a.settings.SetProfile(active.ID)
a.settingsScoped.Store(true) // per-profile settings reads (GetUIPref…) are now safe
// Evaluate the build call gate on the ACTIVE profile's station callsign, before
// any further wiring. A denied call exits here, silently. Only the active
// profile is checked (not every profile) so that "--profile <other>" is always
// a way back in — a blocked call is otherwise unrecoverable, since the process
// quits before the operator can change it.
enforceCallGate(active.Callsign)
// US county resolver — its own local SQLite (data/uls.db), populated on demand
// by DownloadULSCounties. Opening (creating an empty store) is cheap and never
// fatal: county resolution simply stays inert until the operator downloads it.
@@ -2383,6 +2400,19 @@ func (a *App) groupDigitalSlots() bool {
return v == "1"
}
// clusterWorkedSameSlot reports the "consider a call already worked only if
// worked on the SAME band+mode slot" cluster preference (Settings → DX Cluster).
// Off (default) → a call worked on any band/mode reads as already worked. On →
// the WORKED-call flag needs the same band and mode (digital-grouped when that
// option is also on).
func (a *App) clusterWorkedSameSlot() bool {
if a.settings == nil {
return false
}
v, _ := a.settings.Get(a.ctx, "ui.opslog.clusterWorkedSameSlot")
return v == "1"
}
func (a *App) GetUIPref(key string) (string, error) {
if a.settings == nil || !a.settingsScoped.Load() {
// Distinct from a genuinely-empty pref: the (LOCAL SQLite) settings store
@@ -3612,6 +3642,29 @@ func (a *App) computeAwardsForMode(defs []award.Def, modeClass string) ([]award.
}
refMetas := a.awardRefMetas(defs)
results := award.Compute(defs, all, refMetas, nameOf)
// DXCC: fill each reference's Group with the entity's primary prefix (XE,
// DL, F…). Sorting/searching a 340-row list by bare entity number is
// hopeless — the prefix is how operators actually identify an entity.
// Derived from the live cty.dat rather than stored on the reference row, so
// it needs no re-seed of an existing installation.
if a.dxcc != nil {
var pfx map[int]string
for i := range results {
if !strings.EqualFold(results[i].Code, "DXCC") {
continue
}
if pfx == nil {
if pfx = a.dxcc.PrefixByDXCC(); pfx == nil {
break
}
}
for j := range results[i].Refs {
if n, err := strconv.Atoi(results[i].Refs[j].Ref); err == nil {
results[i].Refs[j].Group = pfx[n]
}
}
}
}
// Dynamic awards (POTA/SOTA/…) aren't fully loaded into the engine — their
// list can be huge. Enrich them after the fact: real Total from the stored
// count, and reference names for the worked references only.
@@ -6037,6 +6090,38 @@ func (a *App) WorkedBefore(callsign string, dxccHint int) (qso.WorkedBefore, err
return wb, err
}
// BandSlotQSOs lists the contacts behind ONE cell of the entry Stats (F1)
// matrix — "everything I have on 20m CW with this entity". Same drill-down the
// Awards grid offers, for the panel an operator actually stares at while
// working a station.
//
// modeClass is the matrix column class ("PH", "CW", "DIG"); empty means all.
// Both the exact callsign and the entity are returned: a cell is amber for the
// entity and green for the call, and the useful answer to "who gave me this
// slot?" is the whole list.
func (a *App) BandSlotQSOs(callsign string, dxccNum int, band, modeClass string) ([]qso.QSO, error) {
if a.qso == nil {
return nil, fmt.Errorf("db not initialized")
}
all, err := a.qso.BandSlotQSOs(a.ctx, callsign, dxccNum, band)
if err != nil {
return nil, err
}
// "PH"/"DIG" are the matrix's column labels; award.ModeClass speaks
// "PHONE"/"DIGI". Translate rather than teach one of them the other's names.
want := map[string]string{"PH": "PHONE", "CW": "CW", "DIG": "DIGI"}[strings.ToUpper(strings.TrimSpace(modeClass))]
if want == "" {
return all, nil
}
out := make([]qso.QSO, 0, len(all))
for _, q := range all {
if award.ModeClass(q.Mode) == want {
out = append(out, q)
}
}
return out, nil
}
// SetCompactMode toggles a tiny always-on-top window that exposes just the
// QSO entry — useful when running on a single screen alongside WSJT-X,
// JT-Alert or the cluster.
@@ -6855,7 +6940,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, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
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)
if err != nil {
return CATSettings{}, err
}
@@ -6878,6 +6963,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
KenwoodBaud: 9600,
YaesuLowLines: m[keyCATYaesuLowLines] == "1",
KenwoodLowLines: m[keyCATKenwoodLowLines] == "1",
KenwoodDataMode: m[keyCATKenwoodDataMode],
IcomPort: m[keyCATIcomPort],
IcomBaud: 115200,
IcomAddr: 0x98, // IC-7610 default
@@ -7047,6 +7133,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATKenwoodBaud: strconv.Itoa(s.KenwoodBaud),
keyCATYaesuLowLines: b01(s.YaesuLowLines),
keyCATKenwoodLowLines: b01(s.KenwoodLowLines),
keyCATKenwoodDataMode: strings.ToLower(strings.TrimSpace(s.KenwoodDataMode)),
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
@@ -10173,13 +10260,35 @@ func (a *App) DownloadConfirmations(service string, addNotFound bool, since stri
}
svc := extsvc.Service(service)
cfg := a.loadExternalServices()
go a.runDownloadConfirmations(svc, cfg, addNotFound, since)
// Cancel any download still running (a slow QRZ sync, say) before starting this
// one, so the two don't run at once and interleave their logs — and derive a
// cancellable context so closing the QSL Manager can stop it too.
a.confDLMu.Lock()
if a.confDLCancel != nil {
a.confDLCancel()
}
ctx, cancel := context.WithCancel(a.ctx)
a.confDLCancel = cancel
a.confDLMu.Unlock()
go a.runDownloadConfirmations(ctx, svc, cfg, addNotFound, since)
return nil
}
func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalServices, addNotFound bool, since string) {
// CancelConfirmations stops the in-flight LoTW/QRZ download — the QSL Manager
// calls it when the window closes, so work doesn't keep running in the background.
func (a *App) CancelConfirmations() {
a.confDLMu.Lock()
if a.confDLCancel != nil {
a.confDLCancel()
a.confDLCancel = nil
}
a.confDLMu.Unlock()
}
func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service, cfg extsvc.ExternalServices, addNotFound bool, since string) {
emit := func(line string) {
if a.ctx != nil {
// Don't keep logging into a closed/cancelled window.
if a.ctx != nil && ctx.Err() == nil {
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
}
}
@@ -10188,7 +10297,6 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": matched, "total": total})
}
}
ctx := a.ctx
matched, total, added := 0, 0, 0
// resolveSince turns the UI's request into a concrete date (or ""):
@@ -10240,6 +10348,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
var items []ConfirmationItem
var unmatched []string
perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
if ctx.Err() != nil {
return ctx.Err() // window closed / superseded
}
q, ok := adif.RecordToQSO(rec)
if !ok {
return nil
@@ -10422,6 +10533,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
// QRZ FETCH returns headerless ADIF (no <EOH>); prepend one so the
// parser treats the stream as records.
perr := adif.Parse(strings.NewReader("<EOH>\n"+adifText), func(rec adif.Record) error {
if ctx.Err() != nil {
return ctx.Err() // window closed / superseded — stop clearing flags
}
parsed++
for k := range rec {
allKeys[k] = true
@@ -10539,7 +10653,11 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
}
sort.Strings(keys)
emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", ")))
emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total))
// Spell out the funnel: QRZ hands back the WHOLE logbook, but a confirmation
// download only acts on the ones QRZ marks confirmed — the "26165 but only
// 7950?" question. Nothing is lost; the rest simply aren't confirmed yet.
emit(fmt.Sprintf("%d of your %d QRZ QSOs are confirmed by the other station — the rest aren't confirmed yet, so there's nothing to import for them.", total, parsed))
emit(fmt.Sprintf("Of those %d confirmed: %d newly marked, %d added.", total, matched, added))
if unconfirmed > 0 {
// Worth its own line and not a footnote: these QSOs were showing as
// confirmed a moment ago, and an award total may move because of it.
@@ -10591,6 +10709,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
var items []ConfirmationItem
var unmatched []string
perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
if ctx.Err() != nil {
return ctx.Err() // window closed / superseded
}
q, ok := adif.RecordToQSO(rec)
if !ok {
return nil
@@ -12965,10 +13086,13 @@ func (a *App) reloadCAT() {
// more deliberate setting, and silently preferring the wire would leave an
// operator staring at a host they typed and a radio that never answers.
if h := strings.TrimSpace(s.KenwoodHost); h != "" {
a.cat.Start(cat.NewKenwoodTCP(h, s.DigitalDefault))
kw := cat.NewKenwoodTCP(h, s.DigitalDefault)
kw.SetDataMode(s.KenwoodDataMode)
a.cat.Start(kw)
} else {
kw := cat.NewKenwood(s.KenwoodPort, s.KenwoodBaud, s.DigitalDefault)
kw.SetLowerLines(s.KenwoodLowLines)
kw.SetDataMode(s.KenwoodDataMode)
a.cat.Start(kw)
}
case "icom":
@@ -15814,6 +15938,14 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
// "I've already QSO'd this exact station" even when the band/mode
// makes the entity check say "new-band" or "new-slot".
workedCalls, _ := a.qso.WorkedCallsigns(a.ctx)
// "Already worked only on the same slot" option (Settings → DX Cluster): the
// WORKED-call flag then needs the SAME band and mode (digital-grouped through
// the same normMode when that option is on) rather than the call anywhere.
sameSlot := a.clusterWorkedSameSlot()
var workedCallSlots map[string]struct{}
if sameSlot {
workedCallSlots, _ = a.qso.WorkedCallSlotKeys(a.ctx, normMode)
}
// Orthogonal dimensions: worked US counties (for the ULS callsign→county
// lookup) and worked POTA parks. Both built once per batch.
workedCounties, _ := a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
@@ -15834,7 +15966,21 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
Band: strings.ToLower(q.Band),
Mode: strings.ToUpper(q.Mode),
}
if _, ok := workedCalls[strings.ToUpper(q.Call)]; ok {
if sameSlot {
// Already worked ONLY when this exact band+mode slot was worked. With no
// inferable mode, fall back to same-band (better than claiming the whole
// call is fresh). Digital grouping folds through the same normMode.
upCall := strings.ToUpper(q.Call)
if out[i].Mode == "" {
_, out[i].WorkedCall = workedCallSlots[upCall+"|"+out[i].Band]
} else {
cm := out[i].Mode
if normMode != nil {
cm = normMode(cm)
}
_, out[i].WorkedCall = workedCallSlots[upCall+"|"+out[i].Band+"|"+cm]
}
} else if _, ok := workedCalls[strings.ToUpper(q.Call)]; ok {
out[i].WorkedCall = true
}
// NEW PFX: the spot's CQ WPX prefix, never worked before.
+60
View File
@@ -0,0 +1,60 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"os"
"strings"
)
// deniedCallHashes lists station callsigns not permitted to run this build,
// stored as SHA-256 hex of the base call so the calls themselves appear nowhere
// in the source or the compiled binary. Enforcement is best-effort by design —
// the call is entered by the operator and can be changed — so this only turns
// away straightforward use, not a determined one.
var deniedCallHashes = map[string]struct{}{
"2282a88b3e5e4eebc6b8174d005bb46d32025758b7741bdcf34f2b3621c02205": {},
"0ee09fe60817a2a4982f5e5b14a60b8dbb11cff55b3d36661213c4cbdd0933ea": {},
"46fb61e71afb40627cb6493a6a59c9d4d0231ee3cad1a2bf3fcc4cdfce36fabc": {},
}
// callDenied reports whether a callsign is on deniedCallHashes. The call is
// reduced to its base form (upper-cased, portable prefix/suffix dropped) the
// same way extsvc does, so F4XYZ/P matches F4XYZ.
func callDenied(call string) bool {
base := denyBaseCall(call)
if base == "" {
return false
}
sum := sha256.Sum256([]byte(base))
_, bad := deniedCallHashes[hex.EncodeToString(sum[:])]
return bad
}
// denyBaseCall mirrors extsvc.baseCall: for a slashed form it returns the
// longest token (the real call), otherwise the call itself, upper-cased.
func denyBaseCall(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
if !strings.Contains(s, "/") {
return s
}
best := ""
for _, part := range strings.Split(s, "/") {
if len(part) > len(best) {
best = part
}
}
return best
}
// enforceCallGate exits the process silently when any of the supplied callsigns
// is denied. Called once at startup, after the profiles are loaded: the first
// launch is where the operator enters and saves the call, so a denied build
// simply does not come back up on the next launch. No window, no message.
func enforceCallGate(calls ...string) {
for _, c := range calls {
if callDenied(c) {
os.Exit(0)
}
}
}
+68
View File
@@ -1,4 +1,72 @@
[
{
"version": "0.23.6",
"date": "",
"en": [
"Log viewer: the window now keeps twice as much history (512 KB instead of 256 KB, ~3200 lines). During a busy trace the oldest lines used to scroll out of the buffer while you were still reading them; the larger window holds them.",
"Ultrabeam over a remote link: fixed intermittent connection drops, phantom frequency jumps and wrong element-length readings. On a slow link a command that timed out left its late reply in the stream, and the next command read it as its own — crossing a status query with an element-length one, for instance. The stream is now flushed of any stale reply before each command, keeping the exchange in step.",
"Ultrabeam: the 'moving' indicator and the Flex TX-inhibit now react the instant you click a band or pattern, instead of up to a status poll (~2 s) later. A commanded move reports motion immediately; the real motor state takes over once the next poll reads it.",
"CAT sharing (FT8): JTDX/WSJT-X transmit correctly again through OpsLog's shared rig. get_ptt now echoes the PTT state the client last commanded — clients poll it DURING transmit to confirm the rig is keyed, and OpsLog always answering 'receiving' made them give up and drop the over after a second or two. The 0.23.5 'Fake It' anti-drift tweak was also reverted (it had made this total — no transmit at all); the small dial creep in 'Fake It' split can return, and JTDX's 'Split Operation: None' avoids it.",
"CAT settings: trimmed the long help text under the Kenwood and CAT-sharing fields down to the essentials — less clutter.",
"Entry form: fixed two ways a stray QSO could sneak in. Pressing Enter in the TX/RX frequency field now only tunes the radio — it was also submitting the contact. And a bare number with no letter (JTDX/WSJT-X sometimes broadcasts \"1\" as the DX call) is no longer accepted into the callsign field, where it could get logged as callsign \"1\".",
"SPE amplifier: switching the amp off in OpsLog now shows \"off\" straight away and lets you switch it back on without restarting. The already-open connection kept reading as connected after the off keystroke, so the panel stayed \"on\" and the ON button did nothing until a restart. OpsLog now drops the link on power-off, exactly as a fresh start would — the ON button's RTS/DTR wake then works.",
"Alt+W clears the QSO entry, the shortcut used by most other loggers. Unlike Esc it always works, even when the CW keyer has Esc reserved.",
"The Grid box no longer pops outside the QSO entry panel when the window is narrowed — it wraps to its own line instead.",
"Selecting a QSO in the Recent QSOs list now shows that station in the Stats (F1) matrix, instead of leaving it blank.",
"Stats (F1): click any coloured band/mode square to list the contacts behind it.",
"Awards: the DXCC list now shows each entitys main prefix (XE, DL, F…) in its own sortable, searchable column.",
"DX Cluster: new option \"Already worked only on the same slot\" (Settings → DX Cluster). With it on, a spot reads \"worked\" only when you worked that callsign on the SAME band and mode — not just anywhere. It respects the digital-mode grouping (Settings → General): grouped, a 20m FT8 contact also marks a 20m FT4 spot as worked; ungrouped, FT8 and FT4 are separate slots.",
"DX Cluster: added a WORKED status-filter chip, so you can show — or, with the other chips off, isolate — already-worked spots, not only hide them with the \"Hide worked\" checkbox.",
"DX Cluster: spots that represent nothing — entity/band/mode already worked, the callsign not in your log, no POTA/county/prefix novelty — are now dimmed, so your eye skips them and the spots worth working stand out. What counts as \"nothing\" follows the \"same slot\" option and the digital-mode grouping.",
"Ultrabeam: an older controller (seen over an RS232-to-Ethernet bridge) answers with an 11-byte status frame instead of 12. OpsLog rejected it as \"too short\" and reconnect-looped; it now accepts a shorter checksum-valid frame and defaults the missing tail field.",
"Fixed a crash (React #300) that could take down the whole window when the rig briefly reported an unusual band — for example a spurious 33 cm reading from an Icom. The Band Map had a keyboard-navigation hook after its early return for unknown bands, so the number of hooks changed between renders. The hook now always runs."
],
"fr": [
"Visionneuse de log : la fenêtre conserve deux fois plus d'historique (512 Ko au lieu de 256 Ko, ~3200 lignes). Lors d'une trace chargée, les plus vieilles lignes défilaient hors du buffer pendant qu'on les lisait encore ; la fenêtre agrandie les garde.",
"Ultrabeam en remote : coupures de connexion intermittentes, sauts de fréquence fantômes et longueurs d'éléments erronées corrigés. Sur un lien lent, une commande qui expirait laissait sa réponse tardive dans le flux, et la commande suivante la lisait comme la sienne — croisant par exemple une requête de statut avec une requête de longueurs d'éléments. Le flux est désormais vidé de toute réponse périmée avant chaque commande, gardant l'échange synchronisé.",
"Ultrabeam : l'indicateur « en mouvement » et l'inhibition d'émission Flex réagissent désormais dès que vous cliquez sur une bande ou un diagramme, au lieu d'attendre jusqu'à un poll de statut (~2 s). Un mouvement commandé signale le déplacement immédiatement ; l'état réel des moteurs prend le relais dès le poll suivant.",
"Partage CAT (FT8) : JTDX/WSJT-X émettent de nouveau correctement via la radio partagée d'OpsLog. get_ptt renvoie désormais l'état PTT commandé en dernier par le client — les clients l'interrogent PENDANT l'émission pour confirmer que la radio est en émission, et comme OpsLog répondait toujours « réception », ils abandonnaient et coupaient l'émission au bout d'une seconde ou deux. L'ajustement anti-drift « Fake It » de la 0.23.5 a aussi été annulé (il rendait le problème total — aucune émission) ; le léger glissement du VFO en split « Fake It » peut réapparaître, et « Split Operation : None » dans JTDX l'évite.",
"Réglages CAT : textes d'aide raccourcis sous les champs Kenwood et partage CAT — moins de fouillis.",
"Formulaire de saisie : deux façons de logguer un QSO parasite corrigées. Appuyer sur Entrée dans le champ fréquence TX/RX ne fait plus que syntoniser la radio — cela validait aussi le contact. Et un nombre nu sans lettre (JTDX/WSJT-X diffuse parfois « 1 » comme DX call) n'est plus accepté dans le champ indicatif, où il pouvait être loggué comme indicatif « 1 ».",
"Ampli SPE : éteindre l'ampli depuis OpsLog affiche désormais « off » tout de suite et permet de le rallumer sans redémarrer. La connexion déjà ouverte continuait d'être vue comme connectée après la touche d'extinction, le panneau restait donc « on » et le bouton ON ne faisait rien jusqu'à un redémarrage. OpsLog largue maintenant le lien à l'extinction, exactement comme un démarrage à neuf — le réveil RTS/DTR du bouton ON fonctionne alors.",
"Alt+W efface la saisie du QSO, le raccourci utilisé par la plupart des autres logiciels. Contrairement à Échap, il fonctionne toujours, même quand Échap est réservé au manipulateur CW.",
"Le champ Locator ne déborde plus du panneau de saisie quand la fenêtre est rétrécie — il passe à la ligne.",
"Sélectionner un QSO dans la liste des QSO récents affiche désormais cette station dans la matrice Stats (F1), au lieu de la laisser vide.",
"Stats (F1) : cliquer sur une case bande/mode colorée liste les contacts correspondants.",
"Diplômes : la liste DXCC affiche le préfixe principal de chaque entité (XE, DL, F…) dans une colonne triable et cherchable.",
"DX Cluster : nouvelle option « Déjà contacté seulement sur le même slot » (Réglages → DX Cluster). Activée, un spot n'affiche « contacté » que si vous avez contacté cet indicatif sur la MÊME bande et le MÊME mode — pas juste n'importe où. Elle respecte le groupage des modes numériques (Réglages → Général) : groupé, un contact 20m FT8 marque aussi un spot 20m FT4 comme contacté ; dégroupé, FT8 et FT4 sont des slots distincts.",
"DX Cluster : ajout d'un filtre de statut WORKED, pour afficher — ou, en désactivant les autres, isoler — les spots déjà contactés, pas seulement les masquer avec la case « Masquer les contactés ».",
"DX Cluster : les spots qui ne représentent rien — entité/bande/mode déjà faite, indicatif absent du journal, aucune nouveauté POTA/comté/préfixe — sont désormais atténués, pour que l'œil les ignore et que les spots à travailler ressortent. Ce qui compte comme « rien » suit l'option « même slot » et le groupage des modes numériques.",
"Ultrabeam : un contrôleur plus ancien (vu derrière un pont RS232-Ethernet) répond avec une trame de statut de 11 octets au lieu de 12. OpsLog la rejetait comme « trop courte » et bouclait en reconnexion ; il accepte désormais une trame valide plus courte et met par défaut le champ de fin manquant.",
"Correction d'un plantage (React #300) qui pouvait faire tomber toute la fenêtre quand la radio rapportait brièvement une bande inhabituelle — par exemple une lecture 33 cm parasite d'un Icom. La Band Map avait un hook de navigation clavier après son retour anticipé pour les bandes inconnues, changeant le nombre de hooks entre deux rendus. Le hook est désormais toujours exécuté."
]
},
{
"version": "0.23.5",
"date": "",
"en": [
"CAT sharing (Hamlib rigctld): fixed a slow frequency drift in FT8. With a digital app (JTDX/WSJT-X) sharing OpsLog's rig in \"Fake It\" split, each transmit crept the dial down and never came back. The app follows the dial by polling, and our reply lagged the last tune by a poll cycle, so right after a transmission it read the still-shifted frequency, took it for a manual QSY and adopted it. Reads now echo the last commanded frequency until the rig confirms it — the dial holds. Applies to every backend, not just Kenwood."
],
"fr": [
"Partage CAT (rigctld Hamlib) : dérive lente de fréquence en FT8 corrigée. Avec une app numérique (JTDX/WSJT-X) partageant la radio d'OpsLog en split « Fake It », chaque passage en émission faisait descendre le VFO sans jamais revenir. L'app suit le VFO en l'interrogeant, et notre réponse était en retard d'un cycle sur la dernière syntonisation : juste après une émission elle lisait la fréquence encore décalée, la prenait pour un QSY manuel et l'adoptait. Les lectures renvoient désormais la dernière fréquence commandée jusqu'à confirmation de la radio — le VFO tient. Vaut pour tous les backends, pas seulement Kenwood."
]
},
{
"version": "0.23.4",
"date": "",
"en": [
"CW keyer (Kenwood/Elecraft): choosing the Kenwood/Elecraft engine now actually switches to it. A bug left the keyer on WinKeyer even though the setting showed Kenwood/Elecraft, so its CW-over-CAT never ran.",
"Kenwood/Elecraft data mode: a new setting (Settings → CAT) chooses what a data mode (FT8/PSK…) sets on the rig — USB (default), DATA mode (MD6, for an Elecraft K3/K4), or leave the rig's mode unchanged (safest for a TS-590SG/TS-990S, whose data mode is a USB modifier set on the radio). There is no single command that fits every rig, so it's now the operator's choice.",
"Ultrabeam over a remote link: changing the pattern (Normal / 180° / bidirectional) no longer snaps back to the old one after a few seconds. The commanded pattern is now held while the motors are still moving plus a grace window after they stop, so the slow remote status poll — which lagged behind the antenna — can't revert the display while the change is in flight.",
"QSL Manager: closing the window — or starting another download — now cancels the one still in progress, so a slow QRZ sync no longer keeps running in the background and bleeds its log into a freshly started LoTW run. The QRZ download summary also spells out that only confirmed QSOs are acted on (e.g. \"7950 of 26164 are confirmed\") — a full QRZ logbook has many not-yet-confirmed QSOs, so nothing is lost even though the old wording read as if only a fraction came back. (A LoTW \"http 503\" is separate: the ARRL server being down — retry later.)"
],
"fr": [
"Keyer CW (Kenwood/Elecraft) : choisir le moteur Kenwood/Elecraft y bascule désormais réellement. Un bug laissait le keyer sur WinKeyer alors que le réglage affichait Kenwood/Elecraft, son CW-sur-CAT ne démarrait donc jamais.",
"Mode data Kenwood/Elecraft : un nouveau réglage (Réglages → CAT) choisit ce qu'un mode data (FT8/PSK…) règle sur la radio — USB (défaut), mode DATA (MD6, pour un Elecraft K3/K4), ou ne pas changer le mode de la radio (le plus sûr pour un TS-590SG/TS-990S dont le mode data est un modificateur d'USB réglé sur la radio). Aucune commande unique ne convient à toutes les radios, c'est donc désormais au choix de l'opérateur.",
"Ultrabeam en remote : changer le diagramme (Normal / 180° / bidirectionnel) ne revient plus à l'ancien au bout de quelques secondes. Le diagramme commandé est maintenu tant que les moteurs bougent, plus une fenêtre de grâce après leur arrêt — le poll de statut distant, lent et en retard sur l'antenne, ne peut donc plus faire revenir l'affichage pendant que le changement est en cours.",
"QSL Manager : fermer la fenêtre — ou lancer un autre téléchargement — annule désormais celui en cours, une synchro QRZ lente ne continue donc plus en arrière-plan à mélanger son journal à un téléchargement LoTW fraîchement lancé. Le récapitulatif QRZ indique aussi clairement que seuls les QSO confirmés sont traités (ex. « 7950 sur 26164 confirmés ») — un logbook QRZ complet contient beaucoup de QSO pas encore confirmés, rien n'est donc perdu même si l'ancien texte donnait l'impression qu'une fraction seulement était revenue. (Un « http 503 » sur LoTW est un autre sujet : serveur de l'ARRL indisponible — réessayez plus tard.)"
]
},
{
"version": "0.23.3",
"date": "",
+69 -15
View File
@@ -1512,6 +1512,11 @@ export default function App() {
const [deletingIds, setDeletingIds] = useState<number[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
// Call/band/mode of the QSO highlighted in the Recent-QSOs grid. Drives the
// Stats (F1) matrix whenever the entry form is empty — clicking a past contact
// is the natural way to ask "what else have I got with this one?", and until
// now the panel just sat blank.
const [selQso, setSelQso] = useState<{ call: string; band: string; mode: string } | null>(null);
const [bulkEditIds, setBulkEditIds] = useState<number[]>([]);
const [bulkEditOpen, setBulkEditOpen] = useState(false);
const [showSettings, setShowSettings] = useState(false);
@@ -2607,6 +2612,11 @@ export default function App() {
const applyUdpCall = (raw: string, force = false): boolean => {
const call = String(raw ?? '').trim();
if (!call) return false;
// A callsign always has at least one letter. WSJT-X/JTDX (and relayed spot
// clicks) sometimes broadcast a bare number in the DX-call field — famously
// "1" — which would land in the entry and, on the next Enter, log a QSO
// with callsign "1". Reject anything with no letter.
if (!/[a-z]/i.test(call)) return false;
const upper = call.toUpperCase();
const current = callsignValRef.current.trim().toUpperCase();
const prev = lastUdpCallRef.current;
@@ -2706,8 +2716,9 @@ export default function App() {
s.engine === 'icom' ? 'icom'
: s.engine === 'flex' ? 'flex'
: s.engine === 'yaesu' ? 'yaesu'
: s.engine === 'serial' ? 'serial'
: 'winkeyer');
: s.engine === 'kenwood' ? 'kenwood'
: s.engine === 'serial' ? 'serial'
: 'winkeyer');
} catch { /* keyer not configured */ }
}, []);
@@ -3309,6 +3320,24 @@ export default function App() {
finally { setDeletingAll(false); }
}
// Worked-before for the QSO highlighted in the grid. Deliberately NOT
// runWorkedBefore: that one owns the entry form's wbRef and can trigger a
// field backfill, which browsing the log must never do.
const [selWb, setSelWb] = useState<WB | null>(null);
const [selWbBusy, setSelWbBusy] = useState(false);
useEffect(() => {
const call = (selQso?.call ?? '').trim().toUpperCase();
// The entry form wins whenever it has a call — it's the live QSO.
if (!call || callsign.trim()) { setSelWb(null); return; }
let dead = false;
setSelWbBusy(true);
WorkedBefore(call, 0)
.then((w: any) => { if (!dead) setSelWb(w); })
.catch(() => { if (!dead) setSelWb(null); })
.finally(() => { if (!dead) setSelWbBusy(false); });
return () => { dead = true; };
}, [selQso, callsign]);
async function runWorkedBefore(call: string, dxccHint: number = 0) {
setWbBusy(true);
try {
@@ -3853,6 +3882,16 @@ export default function App() {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'o') {
e.preventDefault(); importAdif(); return;
}
// Alt+W: wipe the entry. The near-universal logger convention (N1MM,
// Log4OM, DXLog), and unlike ESC it is never swallowed by the CW keyer —
// so there is always one key that clears, whatever else is running.
// Alt-combos carry no character on Windows, so this is safe while typing.
if (e.altKey && !e.ctrlKey && !e.metaKey && e.key.toLowerCase() === 'w') {
e.preventDefault();
resetEntry();
callsignRef.current?.focus();
return;
}
if (typing) return;
if (selectedId !== null) {
if (e.key === 'Delete') { e.preventDefault(); askDeleteSelected(); return; }
@@ -4125,7 +4164,7 @@ export default function App() {
</div>
</>
) : (
<div className="flex flex-col flex-1 min-w-[130px]"><Label className="mb-1 h-3.5">{t('field.qth')}</Label>
<div className="flex flex-col flex-1 min-w-[80px]"><Label className="mb-1 h-3.5">{t('field.qth')}</Label>
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
</div>
);
@@ -4354,7 +4393,7 @@ export default function App() {
title={catState.connected ? t('field.freqTuneHint') : undefined}
onFocus={() => setFreqFocused(true)}
onBlur={() => setFreqFocused(false)}
onKeyDown={(e) => { if (e.key === 'Enter') tuneRadioTo(freqMhz); }}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); tuneRadioTo(freqMhz); } }}
onChange={(e) => { setFreqMhz(e.target.value); noteManualEdit(); const b = bandForMHz(parseFloat(e.target.value)); if (b) setBand(b); }}
/>
</div>
@@ -4369,7 +4408,7 @@ export default function App() {
title={catState.connected ? t('field.freqTuneHint') : undefined}
onFocus={() => setFreqFocused(true)}
onBlur={() => setFreqFocused(false)}
onKeyDown={(e) => { if (e.key === 'Enter') tuneRadioTo(rxFreqMhz); }}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); tuneRadioTo(rxFreqMhz); } }}
onChange={(e) => { setRxFreqMhz(e.target.value); noteManualEdit(); const rb = bandForMHz(parseFloat(e.target.value)); if (rb) setBandRx(rb); }}
className={cn('font-mono', catState.split && 'bg-danger-muted/40 border-danger-border focus:bg-card')}
/>
@@ -4406,7 +4445,7 @@ export default function App() {
</Button>
)}
<Button type="button" size="sm" variant="outline" onClick={() => { resetEntry(); callsignRef.current?.focus(); }} className="h-8 px-2.5 text-xs"
title="Clear the QSO entry (always unlike Esc which may be reserved for the CW keyer)">
title="Clear the QSO entry — Alt+W (always works, unlike Esc which may be reserved for the CW keyer)">
<Eraser className="size-3.5" />
{t('btn.clear')}
</Button>
@@ -4451,11 +4490,14 @@ export default function App() {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
const e = spotStatus[k];
const st = (e?.status || '') as SpotStatusKey;
// A previously-worked call counts as WORKED for filtering even when its
// entity status is still new-band/new-slot (the grid flags it WKD CALL),
// matching the "Hide worked" toggle. Additive: it still matches its own
// entity status too, so it stays visible under NEW BAND / NEW SLOT.
const matches = clusterStatusFilter.has(st)
// WORKED means "I've worked THIS callsign" — the blue WKD-CALL flag —
// NOT the entity status 'worked' (entity/band/mode already worked, which
// the grid shows as a plain "—"). So the 'worked' key is matched ONLY via
// worked_call below; exclude the entity status 'worked' from the generic
// match, or every "already-worked-entity" spot would slip through. A
// worked call still matches its own entity status too (new-band/new-slot),
// so it stays visible under those chips.
const matches = (st !== 'worked' && clusterStatusFilter.has(st))
|| (!!e?.worked_call && clusterStatusFilter.has('worked'))
|| (!!e?.new_pota && clusterStatusFilter.has('new-pota'))
|| (!!e?.new_county && clusterStatusFilter.has('new-county'))
@@ -4580,7 +4622,10 @@ export default function App() {
{ k: 'new-pota' as SpotFilterKey, label: 'NEW POTA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
{ k: 'new-county' as SpotFilterKey, label: 'NEW COUNTY', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
{ k: 'new-pfx' as SpotFilterKey, label: 'NEW PFX', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
// (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
// Blue, like the already-worked call in the grid. Selecting it keeps
// worked spots; the separate "Hide worked" checkbox drops them — they
// are opposite controls, so don't use both at once.
{ k: 'worked' as SpotFilterKey, label: 'WORKED', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
]).map((s) => {
const on = clusterStatusFilter.has(s.k);
return (
@@ -4740,6 +4785,7 @@ export default function App() {
onExportFiltered={exportFilteredADIF}
onDelete={(ids) => setDeletingIds(ids)}
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
onRowSelectedQso={(r) => setSelQso(r ? { call: String(r.callsign ?? ""), band: String(r.band ?? ""), mode: String(r.mode ?? "") } : null)}
/>
</div>
);
@@ -5477,7 +5523,10 @@ export default function App() {
{/* Row 2: Name fixed to the Band/Mode/Country column width (300px) so
its right edge lines up with that column below; QTH grows to fill.
gap-4 matches Row 3 so QTH's left edge aligns with Comment/Note. */}
<div className="flex gap-4 items-end">
{/* flex-wrap, not overflow: at the narrowest window the Grid box used
to be pushed outside the entry panel and clipped. Wrapping drops
it to its own line instead, which stays readable. */}
<div className="flex gap-4 items-end flex-wrap">
<div className="flex flex-col w-[300px] shrink-0"><Label className="mb-1 h-3.5">Name</Label>
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
</div>
@@ -5543,6 +5592,10 @@ export default function App() {
band={band}
mode={mode}
bands={bands}
{...(!callsign.trim() && selQso ? {
slotCall: selQso.call, slotBand: selQso.band, slotMode: selQso.mode,
slotWb: selWb, slotWbBusy: selWbBusy,
} : {})}
tab={detailTab}
onTab={setDetailTab}
keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled}
@@ -5702,11 +5755,11 @@ export default function App() {
// exactly when its CAT backend is. Yaesu was missing from this
// list, so the panel fell back to the WinKeyer status — which
// reported disconnected, no WinKeyer being attached.
status={cwSource === 'icom' || cwSource === 'flex' || cwSource === 'yaesu'
status={cwSource === 'icom' || cwSource === 'flex' || cwSource === 'yaesu' || cwSource === 'kenwood'
? {
connected: catState.backend === cwSource && catState.connected,
busy: false, wpm: wkWpm, version: 0,
port: cwSource === 'flex' ? 'CWX' : cwSource === 'yaesu' ? 'CAT' : 'CI-V',
port: cwSource === 'flex' ? 'CWX' : cwSource === 'yaesu' || cwSource === 'kenwood' ? 'CAT' : 'CI-V',
}
: wkStatus}
ports={wkPorts}
@@ -5993,6 +6046,7 @@ export default function App() {
onExportCabrilloFiltered={exportFilteredCabrillo}
onDelete={(ids) => setDeletingIds(ids)}
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
onRowSelectedQso={(r) => setSelQso(r ? { call: String(r.callsign ?? ""), band: String(r.band ?? ""), mode: String(r.mode ?? "") } : null)}
/>
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
<div className="flex items-center gap-3">
+40 -7
View File
@@ -211,11 +211,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
// Alicante" — which reference order scatters across the whole table.
// Remembered through writeUiPref rather than localStorage, so the choice
// travels with a copied data/ folder like every other UI preference here.
const [refSort, setRefSort] = useState<'ref' | 'name'>(
() => (localStorage.getItem('opslog.awardRefSort') === 'name' ? 'name' : 'ref'));
const [refSort, setRefSort] = useState<'ref' | 'name' | 'group'>(
() => {
const v = localStorage.getItem('opslog.awardRefSort');
return v === 'name' || v === 'group' ? v : 'ref';
});
const [refSortDir, setRefSortDir] = useState<'asc' | 'desc'>(
() => (localStorage.getItem('opslog.awardRefSortDir') === 'desc' ? 'desc' : 'asc'));
const toggleRefSort = (k: 'ref' | 'name') => {
const toggleRefSort = (k: 'ref' | 'name' | 'group') => {
if (k === refSort) {
const d = refSortDir === 'asc' ? 'desc' : 'asc';
setRefSortDir(d);
@@ -258,10 +261,26 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
if (c !== 0) return c * dir;
return a.ref.localeCompare(b.ref) * dir; // stable tie-break
}
if (refSort === 'group') {
// Prefix order (XE, YV, ZL…) — how a DX operator actually thinks about
// entities. Blank prefixes sink to the bottom regardless of direction,
// so an unresolved entity never heads the list.
const ag = a.group ?? '', bg = b.group ?? '';
if (!ag !== !bg) return ag ? -1 : 1;
const c = ag.localeCompare(bg, undefined, { sensitivity: 'base' });
if (c !== 0) return c * dir;
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
}
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
});
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]);
// The group column earns its width only when the list actually carries one
// (DXCC prefixes, POTA locations); most custom lists have none. For DXCC the
// value IS the primary prefix, so it is labelled as such.
const hasGroup = useMemo(() => filteredRefs.some((r) => !!r.group), [filteredRefs]);
const groupColLabel = current && current.code.toUpperCase() === 'DXCC' ? t('awp.prefixCol') : t('awp.groupCol');
return (
<div className="flex h-full min-h-0">
{/* Award list */}
@@ -459,6 +478,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
{hasGroup && (
<th className="text-left py-1.5 pr-3 font-medium border-b border-border w-20">
<button type="button" onClick={() => toggleRefSort('group')} className="inline-flex items-center gap-1 hover:text-foreground">
{groupColLabel}
{refSort === 'group' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
)}
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
{t('awp.description')}
@@ -474,9 +501,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
{filteredRefs.map((r) => (
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
<td className="sticky left-0 bg-card hover:bg-accent/20 py-1 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">
{r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''}
</td>
{hasGroup && (
<td className="py-1 pr-3 font-mono font-semibold border-b border-border/30">{r.group}</td>
)}
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">{r.name}</td>
{gridBands.map((b) => {
const s = cellStatus(r, b);
return (
@@ -513,7 +541,12 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
<th className="py-1.5 pr-2 font-medium w-40">{t('awp.groupCol')}</th>
<th className="py-1.5 pr-2 font-medium w-40">
<button type="button" onClick={() => toggleRefSort('group')} className="inline-flex items-center gap-1 hover:text-foreground">
{groupColLabel}
{refSort === 'group' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
</tr>
+37 -32
View File
@@ -358,6 +358,43 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
return () => el.removeEventListener('wheel', onWheel);
}, [range]);
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
// Only active on the docked Main-view map (keyNav) and ignored while typing.
// MUST stay ABOVE the `if (!range)` early return below: on an unknown band
// (e.g. a spurious 33 cm CAT reading from an Icom) that return skipped this
// hook, so the hook count changed between renders and React crashed with #300
// ("rendered fewer hooks than expected"). lo/hi are always defined (they fall
// back to [0,1] when there's no range), so it's safe to run here.
useEffect(() => {
if (!keyNav) return;
const onKey = (e: KeyboardEvent) => {
if (!e.ctrlKey || e.altKey || e.metaKey) return;
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
const ae = document.activeElement as HTMLElement | null;
const tag = (ae?.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select' || ae?.isContentEditable) return;
const list = spots
.filter((s) => (s.band ?? '') === band && s.freq_hz > 0)
.slice()
.sort((a, b) => a.freq_hz - b.freq_hz);
if (!list.length) return;
const cur = currentFreqHz || (lo + hi) * 500; // mid-band kHz→Hz when no rig freq
const EPS = 50; // Hz, so we don't re-pick the spot we're already sitting on
let target: Spot | undefined;
if (e.key === 'ArrowUp') {
target = list.find((s) => s.freq_hz > cur + EPS);
} else {
for (let i = list.length - 1; i >= 0; i--) { if (list[i].freq_hz < cur - EPS) { target = list[i]; break; } }
}
if (!target) return;
e.preventDefault();
onSpotClick(target);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [keyNav, spots, band, currentFreqHz, lo, hi, onSpotClick]);
if (!range) {
return (
<div className="h-full w-full flex flex-col items-center justify-center text-xs text-muted-foreground p-3 bg-muted/20">
@@ -388,38 +425,6 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
scrollerRef.current.scrollTop = Math.max(0, y - containerH / 2);
}
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
// Only active on the docked Main-view map (keyNav) and ignored while typing.
useEffect(() => {
if (!keyNav) return;
const onKey = (e: KeyboardEvent) => {
if (!e.ctrlKey || e.altKey || e.metaKey) return;
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
const ae = document.activeElement as HTMLElement | null;
const tag = (ae?.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select' || ae?.isContentEditable) return;
const list = spots
.filter((s) => (s.band ?? '') === band && s.freq_hz > 0)
.slice()
.sort((a, b) => a.freq_hz - b.freq_hz);
if (!list.length) return;
const cur = currentFreqHz || (lo + hi) * 500; // mid-band kHz→Hz when no rig freq
const EPS = 50; // Hz, so we don't re-pick the spot we're already sitting on
let target: Spot | undefined;
if (e.key === 'ArrowUp') {
target = list.find((s) => s.freq_hz > cur + EPS);
} else {
for (let i = list.length - 1; i >= 0; i--) { if (list[i].freq_hz < cur - EPS) { target = list[i]; break; } }
}
if (!target) return;
e.preventDefault();
onSpotClick(target);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [keyNav, spots, band, currentFreqHz, lo, hi, onSpotClick]);
const currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0;
const showRigPointer = currentKHz >= lo && currentKHz <= hi;
const rigY = freqToY(currentKHz);
+123 -4
View File
@@ -1,8 +1,9 @@
import { useMemo } from 'react';
import { Star, Radio, Sunrise, Sunset } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Star, Radio, Sunrise, Sunset, X, Loader2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { sunTimes } from '@/lib/sun';
import { BandSlotQSOs } from '../../wailsjs/go/main/App';
import type { WorkedBeforeView } from '@/types';
type WorkedBefore = WorkedBeforeView;
@@ -18,6 +19,9 @@ interface Props {
// to an entity with no position at all, and the block simply does not appear.
lat?: number;
lon?: number;
// Set when the matrix is showing a QSO picked in the log grid rather than the
// entry form — labelled so the two can never be confused.
forCall?: string;
}
// Compact column label for a band tag: keep the classic V/U for 2m/70cm,
@@ -89,7 +93,9 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
}
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon }: Props) {
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall }: Props) {
// 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
// bands they actually use), falling back to the built-in default set.
const cols = useMemo(
@@ -208,6 +214,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
</>
) : hasDxcc ? (
<>
{/* Says WHOSE stats these are when they come from a row picked in
the log rather than from what's being typed. */}
{forCall && (
<Badge variant="outline" className="px-2 py-0.5 text-[10px] font-mono shrink-0"
title="Stats for the QSO selected in the log">
{forCall}
</Badge>
)}
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
{dxccName || `DXCC #${dxcc}`}
</Badge>
@@ -282,10 +296,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
return (
<td
key={b.tag}
title={cellTitle(b.tag, cls, st, isCurrent)}
title={cellTitle(b.tag, cls, st, isCurrent) + (st ? ' — click to list the QSOs' : '')}
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
className={cn(
'w-[28px] h-[24px] rounded transition-colors p-0',
st ? STATUS_CLASSES[st] : 'bg-mx-none',
// 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',
)}
/>
@@ -313,6 +331,107 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
))}
</div>
</div>
{slot && (
<SlotQSOModal
call={wb?.callsign ?? ''}
dxcc={dxcc}
entity={dxccName}
band={slot.band}
cls={slot.cls}
onClose={() => setSlot(null)}
/>
)}
</section>
);
}
// ── Cell drill-down ──────────────────────────────────────────────────────────
// The contacts behind one band+class cell: this exact callsign AND anyone else
// in the entity, because that is the pair of facts the cell's colour encodes.
// The call's own QSOs are marked so the two never blur together.
function SlotQSOModal({ call, dxcc, entity, band, cls, onClose }: {
call: string; dxcc: number; entity: string; band: string; cls: string; onClose: () => void;
}) {
const [rows, setRows] = useState<any[] | null>(null);
const [err, setErr] = useState('');
useEffect(() => {
let dead = false;
BandSlotQSOs(call, dxcc, band, cls)
.then((r: any) => { if (!dead) setRows((r ?? []) as any[]); })
.catch((e: any) => { if (!dead) { setErr(String(e?.message ?? e)); setRows([]); } });
return () => { dead = true; };
}, [call, dxcc, band, cls]);
useEffect(() => {
// Capture phase: the app's global ESC handler resets the entry form, and
// closing a dialog must not also wipe what the operator was typing.
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') { e.stopImmediatePropagation(); e.preventDefault(); onClose(); }
}
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div className="bg-card border border-border rounded-lg shadow-xl w-[720px] max-w-[92vw] max-h-[70vh] flex flex-col"
onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
<span className="font-semibold text-sm">
{band} · {cls}
{entity && <span className="text-muted-foreground font-normal"> {entity}</span>}
</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{rows ? `${rows.length} QSO` : ''}
</span>
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="size-4" />
</button>
</div>
<div className="flex-1 overflow-auto">
{err && <p className="p-3 text-xs text-destructive">{err}</p>}
{!rows ? (
<p className="p-4 text-xs text-muted-foreground flex items-center gap-2">
<Loader2 className="size-3.5 animate-spin" /> Loading
</p>
) : rows.length === 0 ? (
<p className="p-4 text-xs text-muted-foreground italic">No QSOs.</p>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-muted/60 backdrop-blur text-muted-foreground">
<tr>
<th className="text-left font-medium px-2 py-1">Date UTC</th>
<th className="text-left font-medium px-2 py-1">Callsign</th>
<th className="text-left font-medium px-2 py-1">Mode</th>
<th className="text-left font-medium px-2 py-1">Freq</th>
<th className="text-left font-medium px-2 py-1">Name</th>
<th className="text-left font-medium px-2 py-1">Cfm</th>
</tr>
</thead>
<tbody>
{rows.map((q, i) => {
const cfm = q.lotw_rcvd === 'Y' || q.eqsl_rcvd === 'Y' || q.qsl_rcvd === 'Y';
return (
<tr key={q.id ?? i} className="border-t border-border/40">
<td className="px-2 py-1 tabular-nums text-muted-foreground whitespace-nowrap">
{String(q.qso_date ?? '').slice(0, 16).replace('T', ' ')}
</td>
<td className={cn('px-2 py-1 font-mono', q.callsign === call && 'font-bold text-primary')}>{q.callsign}</td>
<td className="px-2 py-1">{q.mode}</td>
<td className="px-2 py-1 tabular-nums text-muted-foreground">{q.freq_hz ? (q.freq_hz / 1e6).toFixed(3) : ''}</td>
<td className="px-2 py-1 text-muted-foreground truncate max-w-[160px]">{q.name}</td>
<td className="px-2 py-1">{cfm ? <span className="text-success"></span> : ''}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
</div>
);
}
+17 -1
View File
@@ -140,6 +140,19 @@ function statusColor(s: SpotStatusEntry | undefined): string | null {
}
}
// isDull reports a spot that "represents nothing" under the current worked/slot
// rules: the entity/band/mode is already worked (status resolved, not new in any
// dimension), the callsign itself isn't in the log, and there's no POTA / county
// / prefix novelty. Such rows are dimmed so the eye skips them. Because the
// status obeys the DX-cluster "same slot" option and the digital-mode grouping,
// what counts as dull follows those settings automatically. Unresolved statuses
// (still loading, or entity unknown) are NOT dimmed — that would flicker.
function isDull(s: SpotStatusEntry | undefined): boolean {
if (!s || !s.status) return false;
if (s.status === 'new' || s.status === 'new-band' || s.status === 'new-mode' || s.status === 'new-slot') return false;
return !(s.worked_call || s.new_pota || s.new_county || s.new_pfx);
}
const makeColCatalog = (t: TFn): ColEntry[] => [
{
group: 'Spot', label: t('clg2.c.time'), colId: 'time',
@@ -410,7 +423,9 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
// won't re-render those cells on its own — force a refresh so e.g. a worked call
// turns blue once its status loads.
useEffect(() => {
gridRef.current?.api?.refreshCells({ force: true });
// redrawRows (not refreshCells) so getRowStyle re-runs too — the whole-row
// dimming of "represents nothing" spots depends on the status that lands here.
gridRef.current?.api?.redrawRows();
}, [spotStatus]);
// Restore AFTER the profile scope is known — this grid has no key= remount to
@@ -496,6 +511,7 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
onColumnVisible={saveColumnState}
onSortChanged={saveColumnState}
onRowClicked={handleRowClicked}
getRowStyle={(p: any) => (isDull(statusFor(p)) ? { opacity: 0.4 } : undefined)}
animateRows={false}
suppressCellFocus
getRowId={(p) => `${(p.data as any).received_at}-${(p.data as any).dx_call}-${(p.data as any).source_id}`}
+17 -2
View File
@@ -69,6 +69,14 @@ interface Props {
band: string;
mode: string;
bands?: string[]; // configured bands for the worked-before matrix columns
// When the entry form is empty and a QSO is selected in the log grid, the
// Stats (F1) matrix shows THAT contact's entity instead of sitting blank.
// Only the matrix is redirected — every other tab still edits the live entry.
slotCall?: string;
slotBand?: string;
slotMode?: string;
slotWb?: any;
slotWbBusy?: boolean;
imageUrl?: string;
onOpenImage?: () => void;
// Optional controlled active tab (so the app can switch it via keyboard).
@@ -139,7 +147,7 @@ function Field({ label, span = 1, className, children }: { label: string; span?:
);
}
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive }: Props) {
const { t } = useI18n();
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
const open = tab ?? internalOpen; // controlled when `tab` is provided
@@ -272,7 +280,14 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
{open === 'stats' && (
<div className="px-3 py-2.5">
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''}
<BandSlotGrid
wb={slotCall ? slotWb : wb}
busy={slotCall ? !!slotWbBusy : !!wbBusy}
currentBand={slotCall ? (slotBand ?? '') : band}
currentMode={slotCall ? (slotMode ?? '') : mode}
bands={bands}
hasCall={slotCall ? true : callsign.trim() !== ''}
forCall={slotCall}
lat={dxLL?.lat} lon={dxLL?.lon} />
</div>
)}
+7 -1
View File
@@ -21,7 +21,13 @@ import { TailLogFile, GetLogFilePath } from '../../wailsjs/go/main/App';
// 256 KB blob: with "cluster" or "acom" typed in, the window becomes exactly
// the subsystem trace you would have grepped for.
// • It polls only while open. A closed dialog costs nothing.
const TAIL_BYTES = 256 * 1024;
// 512 KB ≈ 3200 lines. The window is a sliding tail: once new lines push past it
// the oldest fall out of the fetched text entirely, so a small buffer dropped
// lines the operator was still reading during a busy trace (CAT/cluster/antenna
// polling). This is double the old 256 KB — 1 MB was tried but the per-second
// fetch + split + re-render of ~6500 lines made the window lag. 512 KB keeps a
// useful backlog while staying smooth. The backend caps this at 4 MB.
const TAIL_BYTES = 512 * 1024;
const POLL_MS = 1000;
export function LogViewer({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
+5 -1
View File
@@ -8,7 +8,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { EventsOn } from '../../wailsjs/runtime/runtime';
@@ -120,6 +120,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
}, [service]);
// Closing the QSL Manager (the tab's ×) unmounts this panel — cancel any download
// still running so a slow QRZ/LoTW sync doesn't keep going in the background and
// bleed its log into the next one.
useEffect(() => () => { CancelConfirmations().catch(() => {}); }, []);
const [potaSyncing, setPotaSyncing] = useState(false);
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
const [potaErr, setPotaErr] = useState('');
+6 -1
View File
@@ -56,6 +56,10 @@ type Props = {
onRowDoubleClicked?: (q: QSOForm) => void;
onRowClicked?: (q: QSOForm) => void;
onRowSelected?: (ids: number[]) => void;
// The full first selected row — the Stats (F1) panel shows its entity when the
// entry form is empty, so browsing the log answers "what have I got with this
// one?" without retyping the call.
onRowSelectedQso?: (row: QSOForm | null) => void;
onUpdateFromCty?: (ids: number[]) => void;
onUpdateFromQRZ?: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
@@ -283,7 +287,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
const stripAwardCols = (st: any[] | null | undefined): any[] =>
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
@@ -542,6 +546,7 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
setSelCount(sel.length);
setDispCount(api?.getDisplayedRowCount?.() ?? 0);
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
onRowSelectedQso?.(sel[0] ?? null);
}
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
useEffect(() => {
+22 -16
View File
@@ -1136,7 +1136,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
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,
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
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,
digital_default: 'FT8', share_enabled: false, share_port: 4532,
@@ -1246,6 +1246,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
// Password-encryption (secret vault) state.
@@ -2595,9 +2596,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={civTrace} onCheckedChange={(c) => { setCivTrace(!!c); SetCIVTrace(!!c); }} />
{t('cat.civTrace')}
</label>
<p className="text-[10px] text-muted-foreground mt-1">{t('cat.civTraceHint')}</p>
</div>
</label> </div>
)}
{catCfg.backend === 'kenwood' && (
<>
@@ -2613,9 +2612,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</Select>
<Button type="button" variant="outline" size="sm"
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}></Button>
</div>
<span className="text-xs text-muted-foreground">{t('cat.kenwoodPortHint')}</span>
</div>
</div> </div>
<div className="space-y-1">
<Label>{t('cat.baud')}</Label>
<Select value={String(catCfg.kenwood_baud || 9600)} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_baud: parseInt(v) || 9600 }))}>
@@ -2623,18 +2620,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SelectContent>
{[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)}
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">{t('cat.kenwoodBaudHint')}</span>
</div>
</Select> </div>
<div className="space-y-1">
<Label>{t('cat.kenwoodHost')}</Label>
<Input
value={catCfg.kenwood_host || ''}
placeholder="192.168.1.50:4532"
onChange={(e) => setCatCfg((s) => ({ ...s, kenwood_host: e.target.value }))}
/>
<span className="text-xs text-muted-foreground">{t('cat.kenwoodHostHint')}</span>
</div>
/> </div>
<div className="space-y-1">
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox checked={!!catCfg.kenwood_low_lines} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, kenwood_low_lines: !!c }))} />
@@ -2642,6 +2635,16 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</label>
<span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
</div>
<div className="space-y-1">
<Label>{t('cat.kwDataMode')}</Label>
<Select value={(catCfg as any).kenwood_data_mode || 'usb'} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_data_mode: v } as any))}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="usb">{t('cat.kwDataUsb')}</SelectItem>
<SelectItem value="data">{t('cat.kwDataMd6')}</SelectItem>
<SelectItem value="keep">{t('cat.kwDataKeep')}</SelectItem>
</SelectContent>
</Select> </div>
</>
)}
{catCfg.backend === 'icom' && (
@@ -2855,9 +2858,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, ptt_hotkey_toggle: !!c } as any))}
/>
{t('cat.pttKeyToggle')}
</label>
<p className="text-[11px] text-muted-foreground">{t('cat.pttKeyHint')}</p>
</>
</label> </>
)}
</div>
{catCfg.backend === 'omnirig' && (
@@ -4000,6 +4001,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<span className="font-mono">dx.maritimecontestclub.net:7300</span>,{' '}
<span className="font-mono">w8avi.net:7300</span>.
</p>
<label className="flex items-start gap-2 text-sm cursor-pointer border-t border-border/60 pt-3">
<Checkbox checked={clusterWorkedSameSlot} className="mt-0.5"
onCheckedChange={(c) => { const v = !!c; setClusterWorkedSameSlot(v); writeUiPref('opslog.clusterWorkedSameSlot', v ? '1' : '0'); }} />
<span>{t('clu.workedSameSlot')} <span className="text-xs text-muted-foreground">{t('clu.workedSameSlotHint')}</span></span>
</label>
</div>
{editingServer && (
+8 -4
View File
@@ -262,6 +262,8 @@ const en: Dict = {
'clu.moveUp': 'Move up', 'clu.moveDown': 'Move down', 'clu.edit': 'Edit', 'clu.delete': 'Delete', 'clu.none': 'No cluster nodes saved yet.', 'clu.connect': 'Connect', 'clu.disconnect': 'Disconnect',
'clu.add': 'Add cluster', 'clu.connectAll': 'Connect all', 'clu.disconnectAll': 'Disconnect all', 'clu.autoConnect': 'Auto-connect all enabled on app start',
'clu.freeNodes': 'Free public nodes:',
'clu.workedSameSlot': 'Already worked only on the same slot',
'clu.workedSameSlotHint': '— a spot shows "worked" only if you worked that call on the SAME band and mode, not just anywhere. Combines with digital-mode grouping (Settings → General): with it on, a call worked on 20m FT8 also counts as worked for a 20m FT4 spot; with it off, FT8 and FT4 are separate slots.',
// Backup panel
'bk.hintMysql': 'On close (once/day) OpsLog snapshots the local SQLite (config) AND exports the shared MySQL log to ADIF — opslog-log-<date>.adi — so your contacts are protected even though they live on the server. Rotation keeps the last N of each.',
'bk.hint': 'OpsLog can copy the SQLite database to a folder of your choice when you close it, once per day. Rotation keeps the last N copies and deletes older ones.',
@@ -277,7 +279,7 @@ const en: Dict = {
'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.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
// CAT panel body
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'A native CAT backend owns the radio serial port, so no other program can reach it. This lets WSJT-X, JTDX, MSHV or Log4OM talk to the rig THROUGH OpsLog: in the other program pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532. Works with every backend, not only the native ones.', 'cat.sharePort': 'Sharing port', 'cat.pttKey': 'Enable PTT hotkey', 'cat.pttKeyPress': 'Press a key…', 'cat.pttKeyNone': 'Click to set a key', 'cat.pttKeyClear': 'Clear', 'cat.pttKeyToggle': 'Toggle mode (press to key, press again to unkey)', 'cat.pttKeyHint': 'While OpsLog is focused, this key keys the transmitter — held down by default (release to stop), or latched in toggle mode. It uses the Audio → PTT method (CAT / RTS / DTR), falling back to CAT keying. Pick a key you never type while logging (e.g. Pause, ScrollLock, or a footswitch mapped to one) — OpsLog swallows it so it never lands in a field.', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Xiegu CAT port', 'cat.xieguBaudHint': 'Must match the radio menu (G90 default: 19200).', 'cat.xieguAddrHint': 'Factory CI-V address of the G90/X6100 family: 0x70.', 'cat.xieguPTTLine': 'How the rig is keyed', 'cat.xieguPTTCiv': 'CI-V command', 'cat.xieguPTTHint': 'A G90 does not transmit on the CI-V command: interfaces like the DE-19 key it on RTS or DTR. Pick the line yours uses \u2014 it is also what lets WSJT-X transmit through the shared CAT link.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, network)', 'cat.civTrace': 'Log the CAT protocol', 'cat.civTraceHint': 'Writes every CAT frame to and from the radio into the log \u2014 CI-V as hex, Kenwood as the text it exchanges. For reporting a rig that answers oddly \u2014 a button that does the wrong thing, a frequency that jumps. Session only: it is a diagnostic, and it makes the log large.', 'cat.kenwoodPort': 'Kenwood COM port', 'cat.kenwoodPortHint': 'The rig\u2019s CAT/USB serial port (TS-590, TS-890, TS-990, TS-2000, and Elecraft K3/K4 which speak the same dialect).', 'cat.kenwoodBaudHint': 'Must match MENU on the radio: a TS-590 leaves the factory at 9600, a TS-890 at 115200.', 'cat.kenwoodHost': 'Or over the network (host:port)', 'cat.kenwoodHostHint': 'A serial-over-network bridge \u2014 ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio. Filled in, it is used INSTEAD of the COM port above. This is not the radio\u2019s own RJ45 socket, which speaks Kenwood\u2019s KNS protocol and is not supported.', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.lowerLines': 'Lower the DTR and RTS lines on connect', 'cat.lowerLinesHint': 'Windows raises both when the port opens, and some interfaces read either line as PTT — the radio then transmits for as long as OpsLog runs. Tick this only if that happens: other interfaces need RTS raised to transmit at all, and lowering it makes the rig go silent.', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V network)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 power per band and mode', 'flxpw.hint': 'TX power applied when the band or mode changes. Leave a box empty to leave the power alone.', 'flxpw.band': 'Band', 'flxb.title': 'FlexRadio \u2014 per-band antennas and power', 'flxb.hint': 'Antennas are applied when the band changes; power when the band or the mode changes. Leave a power box empty to leave it alone.', 'flxb.rxAnt': 'RX antenna', 'flxb.txAnt': 'TX antenna', 'flxb.noRadio': 'No antennas reported yet \u2014 connect the FlexRadio, then reopen this panel.', 'flxpw.phone': 'Phone', 'flxpw.digi': 'Digital', 'flxpw.noBands': 'No bands configured \u2014 add them in Lists \u2192 Bands.', 'flxpw.saved': 'Saved',
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'In the other program pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532. Works with every backend, not only the native ones.', 'cat.sharePort': 'Sharing port', 'cat.pttKey': 'Enable PTT hotkey', 'cat.pttKeyPress': 'Press a key…', 'cat.pttKeyNone': 'Click to set a key', 'cat.pttKeyClear': 'Clear', 'cat.pttKeyToggle': 'Toggle mode (press to key, press again to unkey)', 'cat.pttKeyHint': 'While OpsLog is focused, this key keys the transmitter — held down by default (release to stop), or latched in toggle mode. It uses the Audio → PTT method (CAT / RTS / DTR), falling back to CAT keying. Pick a key you never type while logging (e.g. Pause, ScrollLock, or a footswitch mapped to one) — OpsLog swallows it so it never lands in a field.', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Xiegu CAT port', 'cat.xieguBaudHint': 'Must match the radio menu (G90 default: 19200).', 'cat.xieguAddrHint': 'Factory CI-V address of the G90/X6100 family: 0x70.', 'cat.xieguPTTLine': 'How the rig is keyed', 'cat.xieguPTTCiv': 'CI-V command', 'cat.xieguPTTHint': 'A G90 does not transmit on the CI-V command: interfaces like the DE-19 key it on RTS or DTR. Pick the line yours uses \u2014 it is also what lets WSJT-X transmit through the shared CAT link.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, network)', 'cat.civTrace': 'Log the CAT protocol', 'cat.civTraceHint': 'Writes every CAT frame to and from the radio into the log \u2014 CI-V as hex, Kenwood as the text it exchanges. For reporting a rig that answers oddly \u2014 a button that does the wrong thing, a frequency that jumps. Session only: it is a diagnostic, and it makes the log large.', 'cat.kenwoodPort': 'Kenwood COM port', 'cat.kenwoodPortHint': 'The rig\u2019s CAT/USB serial port (TS-590, TS-890, TS-990, TS-2000, and Elecraft K3/K4 which speak the same dialect).', 'cat.kenwoodBaudHint': 'Must match MENU on the radio: a TS-590 leaves the factory at 9600, a TS-890 at 115200.', 'cat.kenwoodHost': 'Or over the network (host:port)', 'cat.kenwoodHostHint': 'A serial-over-network bridge \u2014 ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio. Filled in, it is used INSTEAD of the COM port above. This is not the radio\u2019s own RJ45 socket, which speaks Kenwood\u2019s KNS protocol and is not supported.', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.lowerLines': 'Lower the DTR and RTS lines on connect', 'cat.lowerLinesHint': 'If your radio is always on TX, tick this.', 'cat.kwDataMode': 'Data modes (FT8/PSK…) use', 'cat.kwDataUsb': 'USB', 'cat.kwDataMd6': 'DATA mode — MD6 (Elecraft K3/K4)', 'cat.kwDataKeep': 'Leave the rigs mode unchanged', 'cat.kwDataHint': 'What OpsLog sets on the rig for a data mode. No single command fits every rig: an Elecraft K3/K4 wants DATA (MD6); a TS-590SG/TS-990S data mode is a USB modifier set on the rig, so pick USB or, safest, "Leave unchanged" and switch the rig to DATA yourself. On MD6 a plain Kenwood (TS-590/990) would land on FSK/RTTY — do not use it there.', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V network)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 power per band and mode', 'flxpw.hint': 'TX power applied when the band or mode changes. Leave a box empty to leave the power alone.', 'flxpw.band': 'Band', 'flxb.title': 'FlexRadio \u2014 per-band antennas and power', 'flxb.hint': 'Antennas are applied when the band changes; power when the band or the mode changes. Leave a power box empty to leave it alone.', 'flxb.rxAnt': 'RX antenna', 'flxb.txAnt': 'TX antenna', 'flxb.noRadio': 'No antennas reported yet \u2014 connect the FlexRadio, then reopen this panel.', 'flxpw.phone': 'Phone', 'flxpw.digi': 'Digital', 'flxpw.noBands': 'No bands configured \u2014 add them in Lists \u2192 Bands.', 'flxpw.saved': 'Saved',
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
@@ -376,7 +378,7 @@ const en: Dict = {
// Awards (ref picker / ref selector / awards panel / award editor)
'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.',
'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.',
'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.modePhone': 'Phone', 'awp.modeDigital': 'Digital', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.',
'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.modePhone': 'Phone', 'awp.modeDigital': 'Digital', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.prefixCol': 'Prefix', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.',
'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.oneRef': 'One reference per QSO', 'awed.oneRefHint': 'When several references match the same contact, assign none and list it under missing references. For awards where two entries can share a description — two DOKs called Gießen — picking one at random would write the wrong one into the log.', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Fallback searches', 'awed.orAlsoMatch': '— tried in order, only if nothing matched yet; first hit wins', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference',
'awed.updateAvailable': 'An updated version of this award is available', 'awed.updateOverwrites': 'You have modified this award, so the update was not applied. Taking it replaces your definition and reference list.', 'awed.updateApply': 'Update', 'awed.updateKeepMine': 'Keep mine',
'awed.tabTest': 'Test', 'awed.testCallsign': 'Test against callsign', 'awed.testRun': 'Test', 'awed.testSavedOnly': 'Tests the SAVED award — save your changes first.', 'awed.testNoMatch': 'no match', 'awed.testOutOfScope': 'QSO out of scope — no rule was run.', 'awed.testSkipped': 'not run: an earlier rule already matched', 'awed.testFieldValue': 'Field', 'awed.testEmptyField': 'empty', 'awed.testNoCandidate': 'produced no candidate', 'awed.testManual': 'Manual override', 'awed.testAmbiguous': 'Ambiguous', 'awed.testAmbiguousHint': '— none kept, this award allows one reference per QSO. Assign the right one by hand.', 'awed.testSameAs': '+{n} other QSO(s), same result',
@@ -672,6 +674,8 @@ const fr: Dict = {
'clu.moveUp': 'Monter', 'clu.moveDown': 'Descendre', 'clu.edit': 'Éditer', 'clu.delete': 'Supprimer', 'clu.none': 'Aucun nœud cluster enregistré.', 'clu.connect': 'Connecter', 'clu.disconnect': 'Déconnecter',
'clu.add': 'Ajouter cluster', 'clu.connectAll': 'Tout connecter', 'clu.disconnectAll': 'Tout déconnecter', 'clu.autoConnect': 'Connexion auto de tous les activés au démarrage',
'clu.freeNodes': 'Nœuds publics gratuits :',
'clu.workedSameSlot': 'Déjà contacté seulement sur le même slot',
'clu.workedSameSlotHint': '— un spot n\'affiche « contacté » que si vous avez contacté cet indicatif sur la MÊME bande et le MÊME mode, pas juste n\'importe où. Se combine avec le groupage des modes numériques (Réglages → Général) : activé, un indicatif contacté en 20m FT8 compte aussi comme contacté pour un spot 20m FT4 ; désactivé, FT8 et FT4 sont des slots distincts.',
'bk.hintMysql': "À la fermeture (1×/jour) OpsLog sauvegarde le SQLite local (config) ET exporte le log MySQL partagé en ADIF — opslog-log-<date>.adi — pour protéger tes contacts même s'ils sont sur le serveur. La rotation garde les N derniers de chaque.",
'bk.hint': "OpsLog peut copier la base SQLite dans un dossier de ton choix à la fermeture, une fois par jour. La rotation garde les N dernières copies et supprime les plus anciennes.",
'bk.auto': 'Sauvegarde auto à la fermeture d\'OpsLog (max 1×/jour)', 'bk.folder': 'Dossier de sauvegarde', 'bk.folderPh': 'vide = dossier par défaut', 'bk.browse': 'Parcourir…',
@@ -684,7 +688,7 @@ const fr: Dict = {
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', '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.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.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Un backend CAT natif occupe le port série de la radio, aucun autre logiciel ne peut donc y accéder. Ceci permet à WSJT-X, JTDX, MSHV ou Log4OM de dialoguer avec la radio À TRAVERS OpsLog : dans l'autre logiciel, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532. Fonctionne avec tous les backends, pas seulement les natifs.", 'cat.sharePort': 'Port de partage', 'cat.pttKey': 'Activer la touche PTT', 'cat.pttKeyPress': 'Appuyez sur une touche…', 'cat.pttKeyNone': 'Cliquez pour définir une touche', 'cat.pttKeyClear': 'Effacer', 'cat.pttKeyToggle': 'Mode bascule (appui = émission, nouvel appui = arrêt)', 'cat.pttKeyHint': "Quand OpsLog a le focus, cette touche passe la radio en émission — maintenue par défaut (relâcher pour arrêter), ou verrouillée en mode bascule. Elle utilise la méthode PTT de Audio → PTT (CAT / RTS / DTR), avec repli sur le CAT. Choisissez une touche que vous ne tapez jamais en journalisant (ex. Pause, Arrêt défil., ou une pédale mappée dessus) — OpsLog l'intercepte pour qu'elle n'atterrisse pas dans un champ.", 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Port CAT Xiegu', 'cat.xieguBaudHint': 'Doit correspondre au menu de la radio (G90 par défaut : 19200).', 'cat.xieguAddrHint': "Adresse CI-V d'usine de la famille G90/X6100 : 0x70.", 'cat.xieguPTTLine': 'Passage en \u00e9mission', 'cat.xieguPTTCiv': 'Commande CI-V', 'cat.xieguPTTHint': 'Un G90 ne passe pas en \u00e9mission sur la commande CI-V : les interfaces comme le DE-19 le pilotent par RTS ou DTR. Choisissez la ligne de la v\u00f4tre \u2014 c\u2019est aussi ce qui permet \u00e0 WSJT-X d\u2019\u00e9mettre via le partage CAT.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, réseau)', 'cat.civTrace': 'Journaliser le protocole CAT', 'cat.civTraceHint': '\u00c9crit dans le journal chaque trame CAT \u00e9chang\u00e9e avec la radio \u2014 CI-V en hexad\u00e9cimal, Kenwood en texte. Pour signaler une radio qui r\u00e9pond de travers \u2014 un bouton qui fait autre chose, une fr\u00e9quence qui saute. Valable pour la session seulement : c\u2019est un diagnostic, et le journal grossit vite.', 'cat.kenwoodPort': 'Port COM Kenwood', 'cat.kenwoodPortHint': 'Le port s\u00e9rie CAT/USB de la radio (TS-590, TS-890, TS-990, TS-2000, ainsi que les Elecraft K3/K4 qui parlent le m\u00eame dialecte).', 'cat.kenwoodBaudHint': 'Doit correspondre au MENU de la radio : un TS-590 sort d\u2019usine \u00e0 9600, un TS-890 \u00e0 115200.', 'cat.kenwoodHost': 'Ou par le r\u00e9seau (h\u00f4te:port)', 'cat.kenwoodHostHint': 'Un pont s\u00e9rie-r\u00e9seau \u2014 ser2net, un bo\u00eetier Ethernet-s\u00e9rie, un Raspberry Pi pr\u00e8s de la radio. S\u2019il est rempli, il est utilis\u00e9 \u00c0 LA PLACE du port COM ci-dessus. Il ne s\u2019agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood et n\u2019est pas prise en charge.', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.lowerLines': 'Abaisser les lignes DTR et RTS à la connexion', 'cat.lowerLinesHint': 'Windows les lève à louverture du port, et certaines interfaces lisent lune ou lautre comme PTT — la radio émet alors tant quOpsLog tourne. À cocher seulement dans ce cas : dautres interfaces ont besoin de RTS haut pour émettre, et labaisser rend la radio muette.', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V réseau)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 puissance par bande et par mode', 'flxpw.hint': 'Puissance d\u2019\u00e9mission appliqu\u00e9e au changement de bande ou de mode. Laissez une case vide pour ne pas toucher \u00e0 la puissance.', 'flxpw.band': 'Bande', 'flxb.title': 'FlexRadio \u2014 antennes et puissance par bande', 'flxb.hint': 'Les antennes sont appliqu\u00e9es au changement de bande ; la puissance au changement de bande ou de mode. Laissez une case de puissance vide pour ne pas y toucher.', 'flxb.rxAnt': 'Antenne RX', 'flxb.txAnt': 'Antenne TX', 'flxb.noRadio': 'Aucune antenne remont\u00e9e \u2014 connectez le FlexRadio, puis rouvrez ce panneau.', 'flxpw.phone': 'Phonie', 'flxpw.digi': 'Num\u00e9rique', 'flxpw.noBands': 'Aucune bande configur\u00e9e \u2014 ajoutez-les dans Listes \u2192 Bandes.', 'flxpw.saved': 'Enregistr\u00e9',
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Dans l'autre logiciel, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532. Fonctionne avec tous les backends, pas seulement les natifs.", 'cat.sharePort': 'Port de partage', 'cat.pttKey': 'Activer la touche PTT', 'cat.pttKeyPress': 'Appuyez sur une touche…', 'cat.pttKeyNone': 'Cliquez pour définir une touche', 'cat.pttKeyClear': 'Effacer', 'cat.pttKeyToggle': 'Mode bascule (appui = émission, nouvel appui = arrêt)', 'cat.pttKeyHint': "Quand OpsLog a le focus, cette touche passe la radio en émission — maintenue par défaut (relâcher pour arrêter), ou verrouillée en mode bascule. Elle utilise la méthode PTT de Audio → PTT (CAT / RTS / DTR), avec repli sur le CAT. Choisissez une touche que vous ne tapez jamais en journalisant (ex. Pause, Arrêt défil., ou une pédale mappée dessus) — OpsLog l'intercepte pour qu'elle n'atterrisse pas dans un champ.", 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Port CAT Xiegu', 'cat.xieguBaudHint': 'Doit correspondre au menu de la radio (G90 par défaut : 19200).', 'cat.xieguAddrHint': "Adresse CI-V d'usine de la famille G90/X6100 : 0x70.", 'cat.xieguPTTLine': 'Passage en \u00e9mission', 'cat.xieguPTTCiv': 'Commande CI-V', 'cat.xieguPTTHint': 'Un G90 ne passe pas en \u00e9mission sur la commande CI-V : les interfaces comme le DE-19 le pilotent par RTS ou DTR. Choisissez la ligne de la v\u00f4tre \u2014 c\u2019est aussi ce qui permet \u00e0 WSJT-X d\u2019\u00e9mettre via le partage CAT.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, réseau)', 'cat.civTrace': 'Journaliser le protocole CAT', 'cat.civTraceHint': '\u00c9crit dans le journal chaque trame CAT \u00e9chang\u00e9e avec la radio \u2014 CI-V en hexad\u00e9cimal, Kenwood en texte. Pour signaler une radio qui r\u00e9pond de travers \u2014 un bouton qui fait autre chose, une fr\u00e9quence qui saute. Valable pour la session seulement : c\u2019est un diagnostic, et le journal grossit vite.', 'cat.kenwoodPort': 'Port COM Kenwood', 'cat.kenwoodPortHint': 'Le port s\u00e9rie CAT/USB de la radio (TS-590, TS-890, TS-990, TS-2000, ainsi que les Elecraft K3/K4 qui parlent le m\u00eame dialecte).', 'cat.kenwoodBaudHint': 'Doit correspondre au MENU de la radio : un TS-590 sort d\u2019usine \u00e0 9600, un TS-890 \u00e0 115200.', 'cat.kenwoodHost': 'Ou par le r\u00e9seau (h\u00f4te:port)', 'cat.kenwoodHostHint': 'Un pont s\u00e9rie-r\u00e9seau \u2014 ser2net, un bo\u00eetier Ethernet-s\u00e9rie, un Raspberry Pi pr\u00e8s de la radio. S\u2019il est rempli, il est utilis\u00e9 \u00c0 LA PLACE du port COM ci-dessus. Il ne s\u2019agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood et n\u2019est pas prise en charge.', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.lowerLines': 'Abaisser les lignes DTR et RTS à la connexion', 'cat.lowerLinesHint': 'Si votre radio est toujours en émission, cochez ceci.', 'cat.kwDataMode': 'Modes data (FT8/PSK…)', 'cat.kwDataUsb': 'USB', 'cat.kwDataMd6': 'Mode DATA — MD6 (Elecraft K3/K4)', 'cat.kwDataKeep': 'Ne pas changer le mode de la radio', 'cat.kwDataHint': "Ce qu'OpsLog règle sur la radio pour un mode data. Aucune commande unique ne convient à toutes : un Elecraft K3/K4 veut DATA (MD6) ; sur un TS-590SG/TS-990S le mode data est un modificateur d'USB réglé sur la radio, choisissez donc USB ou, plus sûr, « Ne pas changer » et passez la radio en DATA vous-même. En MD6 un Kenwood classique (TS-590/990) tomberait en FSK/RTTY — à ne pas utiliser là.", 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V réseau)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 puissance par bande et par mode', 'flxpw.hint': 'Puissance d\u2019\u00e9mission appliqu\u00e9e au changement de bande ou de mode. Laissez une case vide pour ne pas toucher \u00e0 la puissance.', 'flxpw.band': 'Bande', 'flxb.title': 'FlexRadio \u2014 antennes et puissance par bande', 'flxb.hint': 'Les antennes sont appliqu\u00e9es au changement de bande ; la puissance au changement de bande ou de mode. Laissez une case de puissance vide pour ne pas y toucher.', 'flxb.rxAnt': 'Antenne RX', 'flxb.txAnt': 'Antenne TX', 'flxb.noRadio': 'Aucune antenne remont\u00e9e \u2014 connectez le FlexRadio, puis rouvrez ce panneau.', 'flxpw.phone': 'Phonie', 'flxpw.digi': 'Num\u00e9rique', 'flxpw.noBands': 'Aucune bande configur\u00e9e \u2014 ajoutez-les dans Listes \u2192 Bandes.', 'flxpw.saved': 'Enregistr\u00e9',
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
'cat.icomNetAudio': 'Diffuser laudio RX par le réseau (expérimental)',
@@ -775,7 +779,7 @@ const fr: Dict = {
'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.newCounty': 'NOUV', 'detp.newCountyTip': 'Comté jamais contacté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'CQ', 'detp.ituZone': 'ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.ambiguous': 'Ambigu — choisissez :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact',
'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.',
'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.',
'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.modePhone': 'Phonie', 'awp.modeDigital': 'Numérique', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.',
'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.modePhone': 'Phonie', 'awp.modeDigital': 'Numérique', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.prefixCol': 'Préfixe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.',
'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.oneRef': 'Une seule référence par QSO', 'awed.oneRefHint': 'Quand plusieurs références correspondent au même contact, n'+'en affecter aucune et le lister dans les références manquantes. Pour les diplômes où deux entrées partagent une description — deux DOK nommés Gießen — en choisir une au hasard inscrirait la mauvaise dans le journal.', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches de repli', 'awed.orAlsoMatch': "— essayées dans l'ordre, seulement si rien n'a encore été trouvé ; la première qui marche gagne", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence',
'awed.updateAvailable': 'Une nouvelle version de ce diplôme est disponible', 'awed.updateOverwrites': "Tu as modifié ce diplôme, la mise à jour n'a donc pas été appliquée. L'accepter remplacera ta définition et ta liste de références.", 'awed.updateApply': 'Mettre à jour', 'awed.updateKeepMine': 'Garder les miennes',
'awed.tabTest': 'Test', 'awed.testCallsign': 'Tester avec un indicatif', 'awed.testRun': 'Tester', 'awed.testSavedOnly': 'Teste le diplôme ENREGISTRÉ — enregistre tes modifications avant.', 'awed.testNoMatch': 'aucune correspondance', 'awed.testOutOfScope': "QSO hors périmètre — aucune règle n'a été exécutée.", 'awed.testSkipped': "non exécutée : une règle précédente a déjà trouvé", 'awed.testFieldValue': 'Champ', 'awed.testEmptyField': 'vide', 'awed.testNoCandidate': "n'a produit aucun candidat", 'awed.testManual': 'Référence forcée à la main', 'awed.testAmbiguous': 'Ambigu', 'awed.testAmbiguousHint': '— aucune retenue, ce diplôme nadmet quune référence par QSO. Affectez la bonne à la main.', 'awed.testSameAs': '+{n} autre(s) QSO, même résultat',
+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.23.3';
export const APP_VERSION = '0.23.6';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+4
View File
@@ -87,6 +87,8 @@ export function AwardsFolder():Promise<string>;
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
export function BandSlotQSOs(arg1:string,arg2:number,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
export function BrowseExecutable():Promise<string>;
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
@@ -97,6 +99,8 @@ export function CIVTraceEnabled():Promise<boolean>;
export function CWDecoderRunning():Promise<boolean>;
export function CancelConfirmations():Promise<void>;
export function ChatAvailable():Promise<boolean>;
export function CheckForUpdate():Promise<main.UpdateInfo>;
+8
View File
@@ -122,6 +122,10 @@ export function BackfillUSCounties() {
return window['go']['main']['App']['BackfillUSCounties']();
}
export function BandSlotQSOs(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['BandSlotQSOs'](arg1, arg2, arg3, arg4);
}
export function BrowseExecutable() {
return window['go']['main']['App']['BrowseExecutable']();
}
@@ -142,6 +146,10 @@ export function CWDecoderRunning() {
return window['go']['main']['App']['CWDecoderRunning']();
}
export function CancelConfirmations() {
return window['go']['main']['App']['CancelConfirmations']();
}
export function ChatAvailable() {
return window['go']['main']['App']['ChatAvailable']();
}
+2
View File
@@ -1930,6 +1930,7 @@ export namespace main {
kenwood_host: string;
kenwood_port: string;
kenwood_baud: number;
kenwood_data_mode: string;
yaesu_low_lines: boolean;
kenwood_low_lines: boolean;
icom_port: string;
@@ -1975,6 +1976,7 @@ export namespace main {
this.kenwood_host = source["kenwood_host"];
this.kenwood_port = source["kenwood_port"];
this.kenwood_baud = source["kenwood_baud"];
this.kenwood_data_mode = source["kenwood_data_mode"];
this.yaesu_low_lines = source["yaesu_low_lines"];
this.kenwood_low_lines = source["kenwood_low_lines"];
this.icom_port = source["icom_port"];
+42 -5
View File
@@ -56,6 +56,12 @@ type Kenwood struct {
// a wire, which is how most operators actually put a rig on the network.
host string
digital string // mode name logged for data (FT8 by default)
// dataMode chooses what CAT mode a DATA/digital mode (FT8, PSK…) sets, because
// no single Kenwood mode digit is right for every rig: "usb" → USB (the SSB-data
// base, default), "data" → the DATA mode digit MD6 (Elecraft K3/K4), "keep" →
// leave the rig's current mode untouched (safest for a TS-590SG/TS-990S whose
// data mode is a USB modifier the operator sets on the rig). See SetMode.
dataMode string
mu sync.Mutex
port serial.Port
@@ -360,6 +366,17 @@ func (k *Kenwood) SetMode(mode string) error {
if k.port == nil {
return fmt.Errorf("kenwood: not connected")
}
// Honour the operator's DATA-mode preference for a digital mode. No single
// mode digit fits every rig, so the operator picks: keep the rig's mode, use
// MD6 (K3/K4 DATA), or fall through to the default USB from kenwoodModeDigit.
if isKenwoodDataMode(mode) {
switch k.dataMode {
case "keep":
return nil // leave whatever data mode the operator set on the rig
case "data":
return k.write("MD6;") // Elecraft K3/K4 DATA mode
}
}
d := kenwoodModeDigit(mode, k.curFreq)
if d == 0 {
return fmt.Errorf("kenwood: no CAT mode for %q", mode)
@@ -367,6 +384,25 @@ func (k *Kenwood) SetMode(mode string) error {
return k.write(fmt.Sprintf("MD%c;", d))
}
// SetDataMode configures how a DATA/digital mode is set: "usb" (default), "data"
// (MD6, Elecraft K3/K4) or "keep" (don't change the rig's mode).
func (k *Kenwood) SetDataMode(m string) {
k.mu.Lock()
k.dataMode = strings.ToLower(strings.TrimSpace(m))
k.mu.Unlock()
}
// isKenwoodDataMode reports whether a mode name is a soundcard/data mode (FT8,
// PSK, JT…) rather than a voice/CW/RTTY mode the rig sets natively.
func isKenwoodDataMode(mode string) bool {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "", "LSB", "USB", "SSB", "CW", "CW-R", "CWR", "FM", "NFM", "AM",
"RTTY", "FSK", "RTTY-R", "FSK-R":
return false
}
return true
}
func (k *Kenwood) SetPTT(on bool) error {
k.mu.Lock()
defer k.mu.Unlock()
@@ -551,11 +587,12 @@ func kenwoodModeDigit(mode string, hz int64) byte {
}
return '2'
}
// Any other digital mode (FT8, PSK, JT…) rides the DATA input, and by universal
// convention that is ALWAYS the UPPER sideband, on every band. The SSB rule
// (LSB below 10 MHz) must NOT be applied here: doing so put a 40/80 m data spot
// on LSB, which an Elecraft K3 shows as "DATA REV" — the reversed sideband.
// (RTTY/FSK is handled above as its own FSK mode, so it isn't affected.)
// Any other digital mode (FT8, PSK, JT…) → USB on every band. There is NO single
// mode digit that is right for both families: a K3 wants DATA (MD6), a TS-590SG
// wants USB-DATA (MD2 + its DATA modifier), and MD6 on the TS-590SG is FSK/RTTY
// — wrong for FT8. USB is the safe common base (the reversed-sideband "DATA REV"
// only came from the old LSB-below-10-MHz rule). A rig-specific DATA mode is a
// follow-up that needs the model, not a blind default.
return '2'
}
+17
View File
@@ -111,6 +111,23 @@ func TestKenwoodModeDigit(t *testing.T) {
}
}
// The DATA-mode preference only applies to soundcard/data modes, never to voice,
// CW or native RTTY — getting this wrong would hijack an SSB or CW mode change.
func TestIsKenwoodDataMode(t *testing.T) {
data := []string{"FT8", "FT4", "PSK31", "JT65", "DATA", "DIGITAL", "OLIVIA"}
notData := []string{"", "LSB", "USB", "SSB", "CW", "CW-R", "FM", "AM", "RTTY", "FSK", "RTTY-R"}
for _, m := range data {
if !isKenwoodDataMode(m) {
t.Errorf("isKenwoodDataMode(%q) = false, want true", m)
}
}
for _, m := range notData {
if isKenwoodDataMode(m) {
t.Errorf("isKenwoodDataMode(%q) = true, want false", m)
}
}
}
// What the log records for each mode digit the rig reports.
func TestKenwoodModeToADIF(t *testing.T) {
cases := []struct {
+33
View File
@@ -173,6 +173,39 @@ func (m *Manager) EntityNames() []string {
return out
}
// PrefixByDXCC maps ADIF DXCC entity number → canonical primary prefix (F, DL,
// XE…) from the loaded cty.dat. cty.dat is keyed by entity NAME, so the join
// goes through EntityDXCC; entries whose name doesn't resolve are skipped
// rather than guessed. Empty until cty.dat has loaded.
func (m *Manager) PrefixByDXCC() map[int]string {
m.mu.RLock()
db := m.db
m.mu.RUnlock()
if db == nil {
return nil
}
out := make(map[int]string, 400)
for _, e := range db.Entities() {
p := strings.TrimSpace(e.Primary)
if p == "" {
continue
}
// cty.dat marks non-DXCC sub-entities with a leading '*' — they report
// under a parent entity and must not overwrite the parent's prefix.
if strings.HasPrefix(p, "*") {
continue
}
if n := EntityDXCC(e.Name); n > 0 {
if _, dup := out[n]; !dup {
// DXCC prefixes are conventionally upper-case; cty.dat spells some
// sub-entity suffixes in lower case (3D2/c → 3D2/C).
out[n] = strings.ToUpper(p)
}
}
}
return out
}
// Info returns metadata about the currently-loaded cty.dat (or zero value
// if nothing loaded).
func (m *Manager) Info() ctySource {
+75
View File
@@ -1461,6 +1461,51 @@ func (r *Repo) IterateByIDs(ctx context.Context, ids []int64, fn func(QSO) error
return rows.Err()
}
// BandSlotQSOs returns every contact on one band that belongs to a slot of the
// entry matrix: the exact callsign, or any callsign in the same DXCC entity.
// Mode is NOT filtered here — the class (phone / CW / digital) is a derived
// notion the caller owns, so it filters the (small) band-scoped result itself.
//
// The DXCC arm matches the stored dxcc column only. A QSO logged before the
// entity was resolved has a NULL there and is genuinely unattributed; guessing
// it back from the callsign here would disagree with the matrix above, which is
// built from the same column.
func (r *Repo) BandSlotQSOs(ctx context.Context, callsign string, dxccNum int, band string) ([]QSO, error) {
call := upperTrim(callsign)
b := strings.TrimSpace(band)
if b == "" || (call == "" && dxccNum <= 0) {
return []QSO{}, nil
}
where := "band = ?"
args := []any{b}
switch {
case call != "" && dxccNum > 0:
where += " AND (callsign = ? OR dxcc = ?)"
args = append(args, call, dxccNum)
case call != "":
where += " AND callsign = ?"
args = append(args, call)
default:
where += " AND dxcc = ?"
args = append(args, dxccNum)
}
rows, err := r.db.QueryContext(ctx,
`SELECT `+selectCols+` FROM qso WHERE `+where+` ORDER BY qso_date DESC, id DESC LIMIT 500`, args...)
if err != nil {
return nil, fmt.Errorf("query band slot: %w", err)
}
defer rows.Close()
out := []QSO{}
for rows.Next() {
q, err := scanQSO(rows)
if err != nil {
return nil, err
}
out = append(out, q)
}
return out, rows.Err()
}
// WorkedBefore summarises prior contacts at two granularities:
// - by exact callsign → shown as "this call worked N×"
// - by DXCC entity → drives NEW ONE / NEW BAND / NEW MODE / NEW SLOT
@@ -2118,6 +2163,36 @@ func (r *Repo) WorkedCallBandModeKeys(ctx context.Context) (map[string]struct{},
return out, rows.Err()
}
// WorkedCallSlotKeys backs the cluster "already worked only on the same slot"
// option. It returns each worked slot keyed BOTH as "CALL|band" (mode-agnostic,
// for a spot whose mode couldn't be inferred) and "CALL|band|MODE" with the mode
// run through normMode — so when digital grouping is on, FT8/FT4/RTTY fold into
// one "DIG" and a call worked on 20m FT8 also matches a 20m FT4 spot. Call is
// upper-cased and band lower-cased to match the spot keys ClusterSpotStatuses
// builds. normMode may be nil (no grouping).
func (r *Repo) WorkedCallSlotKeys(ctx context.Context, normMode func(string) string) (map[string]struct{}, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT upper(callsign), lower(band), upper(mode) FROM qso
WHERE callsign != '' AND band IS NOT NULL AND band != '' AND mode IS NOT NULL AND mode != ''`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]struct{}, 4096)
for rows.Next() {
var c, b, m string
if err := rows.Scan(&c, &b, &m); err != nil {
return nil, err
}
if normMode != nil {
m = normMode(m)
}
out[c+"|"+b] = struct{}{}
out[c+"|"+b+"|"+m] = struct{}{}
}
return out, rows.Err()
}
// WorkedPOTARefs returns the set of POTA park references already worked
// (upper-cased). A QSO's pota_ref may hold several comma-separated parks
// (an n-fer); each is added separately.
+15 -4
View File
@@ -36,6 +36,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
@@ -59,6 +60,13 @@ type Server struct {
ln net.Listener
conns map[net.Conn]struct{}
closed bool
// ptt mirrors the last PTT state a client commanded via set_ptt. WSJT-X/JTDX
// poll get_ptt DURING transmit to confirm the rig is keyed; if get_ptt reads
// RX they conclude PTT failed and abort the over after a second or two. We
// don't read PTT back from every backend, so echo what the client last set —
// always consistent with its own command, and enough to satisfy the check.
ptt atomic.Bool
}
func New(port int, rig Rig, logf func(string, ...any)) *Server {
@@ -228,10 +236,12 @@ func (s *Server) handle(line string) (resp string, quit bool) {
return rprt(0), false
case "t", "\\get_ptt":
// We do not read PTT back from every backend, and answering "transmitting"
// wrongly would make a client hold off for ever. Reporting RX is the safe
// direction: the worst case is a client that transmits when we said it
// could, which is what it was going to do anyway.
// Echo the last commanded PTT state. WSJT-X/JTDX poll this WHILE
// transmitting to confirm the rig is keyed; answering a blanket "0" (RX)
// made them decide PTT had failed and abort the over after ~1-2 s.
if s.ptt.Load() {
return "1\n", false
}
return "0\n", false
case "T", "\\set_ptt":
if len(args) < 1 {
@@ -242,6 +252,7 @@ func (s *Server) handle(line string) (resp string, quit bool) {
s.log("rigctld: set_ptt %v failed: %v", on, err)
return rprt(-9), false
}
s.ptt.Store(on)
return rprt(0), false
case "v", "\\get_vfo":
+22
View File
@@ -138,6 +138,28 @@ func TestHandleReportsBackendFailure(t *testing.T) {
}
}
// get_ptt must echo the last commanded PTT state. WSJT-X/JTDX poll get_ptt while
// transmitting to confirm the rig is keyed; a blanket "0" made them decide PTT
// had failed and abort the over after a second or two.
func TestGetPTTEchoesSetPTT(t *testing.T) {
s := New(0, &fakeRig{}, nil)
if got, _ := s.handle("t"); got != "0\n" {
t.Fatalf("initial get_ptt = %q, want 0", got)
}
if got, _ := s.handle("T 1"); got != "RPRT 0\n" {
t.Fatalf("set_ptt 1 = %q, want RPRT 0", got)
}
if got, _ := s.handle("t"); got != "1\n" {
t.Fatalf("get_ptt after T 1 = %q, want 1 — client would abort TX", got)
}
if got, _ := s.handle("T 0"); got != "RPRT 0\n" {
t.Fatalf("set_ptt 0 = %q, want RPRT 0", got)
}
if got, _ := s.handle("t"); got != "0\n" {
t.Fatalf("get_ptt after T 0 = %q, want 0", got)
}
}
// dump_state is parsed POSITIONALLY by Hamlib clients: WSJT-X reads the first
// line as the protocol version and refuses to continue if the block is short or
// misshapen. Pinning its shape is what stops a well-meaning edit from silently
+12
View File
@@ -228,6 +228,18 @@ func (c *Client) PowerOn() error {
func (c *Client) PowerOff() error {
err := c.sendCmd(cmdOff)
applog.Printf("spe: power OFF — SWITCH OFF key sent (err=%v)", err)
// Drop the link and mark the amp offline at once, exactly as a fresh start
// would see it. The amp stops answering its UART once off, but the connection
// that was already open BEFORE the off keystroke kept reading as "connected"
// (stale/queued frames), so the UI never showed OFF and PowerOn — which only
// wakes a DISCONNECTED amp — did nothing until the operator restarted OpsLog.
// The poll loop reopens the port on its next tick; a plain reopen never wakes a
// switched-off amp (only PowerOn's RTS/DTR edge does), so it stays offline.
time.Sleep(100 * time.Millisecond) // let the 6-byte OFF command flush before we close
c.mu.Lock()
c.dropLocked()
c.mu.Unlock()
c.setErr(fmt.Errorf("switched off"))
return err
}
+127 -40
View File
@@ -23,6 +23,12 @@ const (
ubReadTimeout = 4 * time.Second // was 1s — too tight for a remote link
ubKeepAlive = 15 * time.Second // OS-level TCP keepalive
ubMaxPollTimeout = 3 // consecutive read timeouts tolerated before reconnecting
// How long a just-commanded direction is trusted AFTER the motors have stopped
// but before the antenna's status confirms it. The timer is held off entirely
// while the motors are still moving, so this is only the grace period for the
// confirmation poll to arrive once the elements have settled — generous, because
// over a remote link that poll lags by several seconds.
ubPendingDirGrace = 8 * time.Second
)
// Protocol constants
@@ -82,8 +88,22 @@ type Client struct {
// yet (Frequency==0) — otherwise the deadband is bypassed and every small QSY
// re-tunes the motors.
lastSetKHz int
// moveCmdAt is when a move (frequency or direction) was last COMMANDED. The
// status poll runs every couple of seconds, so without this the "moving" flag
// — which drives the UI indicator and the Flex TX-inhibit — appeared up to a
// poll late even though the elements start moving at once. GetStatus reports
// motion during a short window after a command so both react immediately.
moveCmdAt time.Time
}
// ubMoveOptimisticWindow is how long after a commanded move GetStatus reports
// "moving" before the status poll has had a chance to read the real motor state.
// It only needs to bridge one poll interval; once a poll sees real motion, that
// takes over. Bounded, so if the antenna never reports motion the flag still
// clears rather than latching the TX-inhibit on for ever.
const ubMoveOptimisticWindow = 3 * time.Second
// LastSetKHz returns the frequency (kHz) most recently commanded to the antenna,
// or 0 if none yet.
func (c *Client) LastSetKHz() int {
@@ -221,12 +241,30 @@ func (c *Client) pollLoop() {
}
c.statusMu.Lock()
// Keep a just-commanded direction until the antenna reports it.
// Keep a just-commanded direction until the antenna actually reports it.
// Over a remote link the confirmation arrives several seconds after the
// command — the motors flip the elements first — so the old fixed 4 s
// timeout expired WHILE the change was still in flight, and the stale poll
// reverted the UI to the old pattern even though the antenna was on its way
// to the new one. Now: while the motors are still moving the change is in
// progress, so hold the commanded pattern and keep resetting the timer;
// only once the motors have stopped does the short grace window run, giving
// the confirmation poll time to land. The poll only wins if the motors are
// idle AND the antenna still reports a different pattern past that window —
// i.e. the command genuinely did not take.
if c.pendingDirSet {
if time.Since(c.pendingDirAt) > 4*time.Second || status.Direction == c.pendingDir {
c.pendingDirSet = false
} else {
status.Direction = c.pendingDir
if status.MotorsMoving != 0 {
c.pendingDirAt = time.Now() // still repositioning — don't start the grace timer
}
switch {
case status.Direction == c.pendingDir:
c.pendingDirSet = false // confirmed by the antenna
case time.Since(c.pendingDirAt) > ubPendingDirGrace:
c.pendingDirSet = false // motors idle, still unconfirmed → accept the poll
log.Printf("Ultrabeam: antenna never confirmed direction %d (reports %d) — dropping the hold",
c.pendingDir, status.Direction)
default:
status.Direction = c.pendingDir // still changing, or within the grace window
}
}
c.lastStatus = status
@@ -246,7 +284,19 @@ func (c *Client) GetStatus() (*Status, error) {
return &Status{Connected: false}, nil
}
return c.lastStatus, nil
// Copy so the optimistic-motion tweak below never mutates the cached status
// the poll goroutine owns.
st := *c.lastStatus
// Optimistic motion: right after a commanded move, report "moving" until a
// status poll can read the real motor state (~one poll interval). The elements
// start moving the instant the operator clicks a band/pattern, so this makes
// the UI indicator and the Flex TX-inhibit react immediately instead of a poll
// later. Real polled motion takes over once seen; the window is bounded so the
// flag can't latch the inhibit on for ever.
if st.Connected && st.MotorsMoving == 0 && !c.moveCmdAt.IsZero() && time.Since(c.moveCmdAt) < ubMoveOptimisticWindow {
st.MotorsMoving = 1 // sentinel: optimistically moving (read only as != 0)
}
return &st, nil
}
// getNextSeq returns the next sequence number
@@ -277,10 +327,9 @@ func quoteByte(b byte) []byte {
return []byte{b}
}
// buildPacket creates a complete packet with checksum and escaping
func (c *Client) buildPacket(cmd byte, data []byte) []byte {
seq := c.getNextSeq()
// buildPacket creates a complete packet with checksum and escaping. The seq is
// supplied by the caller so sendCommand can match the reply against it.
func (c *Client) buildPacket(seq, cmd byte, data []byte) []byte {
// Calculate checksum on unquoted data
payload := append([]byte{seq, cmd}, data...)
chk := calculateChecksum(payload)
@@ -365,39 +414,27 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
return nil, fmt.Errorf("not connected")
}
// Build and send packet
packet := c.buildPacket(cmd, data)
// Flush anything already sitting in the stream before we send. The antenna
// does NOT echo our sequence number — its replies carry their own counter — so
// a reply cannot be matched to its request. Instead we keep the exchange 1:1:
// a reply left behind by an earlier timed-out command is discarded here, so
// the reply we read next belongs to THIS command. Reading a stale reply as the
// current one crossed STATUS with READ_BANDS/PROGRESS — phantom frequencies (a
// spurious follow-loop re-tune), dropped connections and wrong element lengths.
c.drainStale()
_, err := c.conn.Write(packet)
if err != nil {
seq := c.getNextSeq()
packet := c.buildPacket(seq, cmd, data)
if _, err := c.conn.Write(packet); err != nil {
return nil, fmt.Errorf("failed to write: %w", err)
}
// Read reply with timeout (generous — tolerates remote-link latency).
// Read the reply with a timeout generous enough for a remote link.
c.conn.SetReadDeadline(time.Now().Add(ubReadTimeout))
// Read until we get a complete packet
var buffer []byte
for {
b, err := c.reader.ReadByte()
if err != nil {
return nil, fmt.Errorf("failed to read: %w", err)
}
buffer = append(buffer, b)
// Check if we have a complete packet
if b == ETX && len(buffer) > 0 && buffer[0] == STX {
break
}
// Prevent infinite loop
if len(buffer) > 256 {
return nil, fmt.Errorf("packet too long")
}
buffer, err := c.readPacket()
if err != nil {
return nil, err
}
// Parse reply
_, replyCmd, payload, err := parsePacket(buffer)
if err != nil {
return nil, fmt.Errorf("failed to parse reply: %w", err)
@@ -426,6 +463,45 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
}
}
// drainStale discards any bytes already waiting in the stream — a reply left
// behind by a command that timed out. A short read deadline lets it consume what
// is there and stop quickly when the stream is clean. Caller holds connMu.
func (c *Client) drainStale() {
c.conn.SetReadDeadline(time.Now().Add(5 * time.Millisecond))
buf := make([]byte, 256)
for {
n, err := c.reader.Read(buf)
if n == 0 || err != nil {
return
}
}
}
// readPacket reads one complete STX…ETX frame, skipping any leading bytes until
// an STX so a partial/garbage remnant left in the stream can't derail the parse.
// STX/ETX/DLE are all high-bit (0xF5/0xFA/0xF6) and the escaping clears the MSB,
// so a raw ETX only ever appears as the real terminator. Caller holds connMu and
// has set a read deadline.
func (c *Client) readPacket() ([]byte, error) {
var buffer []byte
for {
b, err := c.reader.ReadByte()
if err != nil {
return nil, fmt.Errorf("failed to read: %w", err)
}
if len(buffer) == 0 && b != STX {
continue // resync to the start of a frame
}
buffer = append(buffer, b)
if b == ETX {
return buffer, nil
}
if len(buffer) > 256 {
return nil, fmt.Errorf("packet too long")
}
}
}
// queryStatus queries general status (command 1)
func (c *Client) queryStatus() (*Status, error) {
reply, err := c.sendCommand(CMD_STATUS, nil)
@@ -433,7 +509,13 @@ func (c *Client) queryStatus() (*Status, error) {
return nil, err
}
if len(reply) < 12 {
// An older controller — seen behind an RS232-to-Ethernet bridge — answers with
// an 11-byte status frame: the standard one WITHOUT the trailing FreqMax byte.
// The packet checksum was already verified, so a short-but-valid frame is real,
// not a fragment. Accept it and default the missing tail fields instead of
// reconnect-looping on "reply too short". reply[9]/[10] (MotorsMoving, FreqMin)
// are the last we truly need, so 10 bytes is the floor.
if len(reply) < 10 {
return nil, fmt.Errorf("status reply too short: %d bytes", len(reply))
}
@@ -446,8 +528,12 @@ func (c *Client) queryStatus() (*Status, error) {
Direction: int(reply[6] & 0x0F),
OffState: (reply[7] & 0x02) != 0,
MotorsMoving: int(reply[9]),
FreqMin: int(reply[10]),
FreqMax: int(reply[11]),
}
if len(reply) > 10 {
status.FreqMin = int(reply[10])
}
if len(reply) > 11 {
status.FreqMax = int(reply[11])
}
return status, nil
@@ -513,6 +599,7 @@ func (c *Client) SetFrequency(freqKhz int, direction int) error {
c.statusMu.Lock()
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
c.lastSetKHz = freqKhz
c.moveCmdAt = time.Now() // start reporting motion at once — see ubMoveOptimisticWindow
if c.lastStatus != nil {
c.lastStatus.Direction = direction // reflect immediately
}
+94
View File
@@ -0,0 +1,94 @@
package ultrabeam
import (
"bufio"
"bytes"
"net"
"testing"
"time"
)
// sendCommand must flush any bytes already in the stream (a reply left by an
// earlier timed-out command) before reading, then return the reply to THIS
// command. The antenna does not echo our sequence number, so keeping the stream
// clean is the only way to stay in sync.
func TestSendCommandFlushesStaleThenReadsReply(t *testing.T) {
srvConn, cliConn := net.Pipe()
defer srvConn.Close()
defer cliConn.Close()
c := &Client{conn: cliConn, reader: bufio.NewReader(cliConn)}
srv := &Client{reader: bufio.NewReader(srvConn)}
errc := make(chan error, 1)
go func() {
// Leftover from a "previous" command still sitting in the stream. The Write
// blocks until drainStale consumes it, so it is guaranteed flushed before
// the real exchange.
if _, err := srvConn.Write(c.buildPacket(9, UB_OK, []byte{0xDE, 0xAD})); err != nil {
errc <- err
return
}
// Now serve the actual request.
req, err := srv.readPacket()
if err != nil {
errc <- err
return
}
seq, _, _, err := parsePacket(req)
if err != nil {
errc <- err
return
}
_, err = srvConn.Write(c.buildPacket(seq, UB_OK, []byte{0x11, 0x22, 0x33}))
errc <- err
}()
payload, err := c.sendCommand(CMD_STATUS, nil)
if err != nil {
t.Fatalf("sendCommand: %v", err)
}
if !bytes.Equal(payload, []byte{0x11, 0x22, 0x33}) {
t.Fatalf("payload = % X, want 11 22 33 — stale reply not flushed", payload)
}
if err := <-errc; err != nil {
t.Fatalf("server: %v", err)
}
}
// readPacket must resynchronise to the next STX, dropping any partial/garbage
// bytes left in the stream, so one corrupt frame can't misalign every frame
// after it.
func TestReadPacketResyncsToSTX(t *testing.T) {
frame := (&Client{}).buildPacket(5, UB_OK, []byte{0x01, 0x02})
stream := append([]byte{0x11, 0x22, 0x33}, frame...) // leading garbage, then a real frame
c := &Client{reader: bufio.NewReader(bytes.NewReader(stream))}
got, err := c.readPacket()
if err != nil {
t.Fatalf("readPacket: %v", err)
}
seq, cmd, payload, err := parsePacket(got)
if err != nil {
t.Fatalf("parsePacket: %v", err)
}
if seq != 5 || cmd != UB_OK || !bytes.Equal(payload, []byte{0x01, 0x02}) {
t.Fatalf("seq=%d cmd=%d payload=% X, want 5 / OK / 01 02", seq, cmd, payload)
}
}
// A commanded move must report motion immediately (before the next status poll),
// then fall back to the real motor state once the window elapses.
func TestOptimisticMotionAfterCommand(t *testing.T) {
c := &Client{lastStatus: &Status{Connected: true, MotorsMoving: 0}}
c.moveCmdAt = time.Now()
if st, _ := c.GetStatus(); st.MotorsMoving == 0 {
t.Fatal("just after a move command, GetStatus should report motion")
}
c.moveCmdAt = time.Now().Add(-ubMoveOptimisticWindow - time.Second)
if st, _ := c.GetStatus(); st.MotorsMoving != 0 {
t.Fatal("past the window with motors idle, GetStatus must report no motion")
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.23.3"
appVersion = "0.23.6"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.