Compare commits

...
76 Commits
Author SHA1 Message Date
rouggy 1d8cd25205 chore: release v0.23.9 2026-08-08 20:44:32 +02:00
rouggy 1f55cfe9fa feat(cluster): self-spot on the master node while logging
Settings -> DX Cluster: a toggle and an interval. When it is on, logging a QSO
announces the station on the master cluster — the QSO's own station callsign as
the DX, on the frequency the contact was made on — so callers find the run
without waiting for someone else to spot it. The node fills the DE field from
the login, so the spotter is us too: a self-spot.

It fires on the FIRST QSO of a frequency and then at most once per interval.
Both halves matter: announcing every QSO would flood the node and get the
station filtered out, while a pure timer would stay silent for minutes after a
band change. A drift of up to 500 Hz still counts as the same run, so nudging
the VFO mid-pileup does not re-announce.

Five minutes is the floor, clamped in SaveSelfSpotSettings as well as in the
input: the limit protects the node from us, so it must not depend on the
frontend. The interval input keeps raw text and clamps on blur — clamping per
keystroke rewrote "10" to "5" as soon as the "1" landed.

Wired into both log paths (manual entry and UDP auto-log) on the async side, so
a cluster that is slow or down never holds up logging. A send failure restores
the previous throttle state, so the next QSO retries instead of sitting out an
interval that produced no spot.
2026-08-08 20:34:10 +02:00
rouggy 90c6458af0 fix(rigctld): never leave the rig keyed when the client goes away
The Kenwood/Elecraft backend deliberately suspends its wire poll while PTT is
held — a K3 answers "?;" to IF; during transmit, and treating that as a fault
used to drop the whole CAT link. The consequence was that nothing watched the
transmitter: a client that crashed, was closed, or simply had its socket shut
under it left the rig on air.

And shutting the socket is routine. reloadCATShare tears the sharing server
down and rebuilds it on every settings save, so a Save while WSJT-X held PTT
was enough. A K3 operator's log shows exactly that: "TX;" at 17:53:09, no "RX;"
ever, the poll silent, and the rig still keyed 29 s later when the CAT link
happened to be rebuilt.

The server now drops PTT when a connection ends and when Stop() is called.
Stop() runs before reloadCAT restarts the backend, so the unkey still reaches
the radio. An atomic Swap keeps it once-only across the two paths.
2026-08-08 20:19:00 +02:00
rouggy 18f44a5aa3 fix(cat): read MD6 back as DATA on an Elecraft, not RTTY
MD6 is FSK on a Kenwood and DATA on a K3/K4. kenwoodModeToADIF decoded the
digit unconditionally as RTTY, so OpsLog contradicted the mode it had just
set: SetMode writes MD6 for a digital mode on an Elecraft, then ReadState
parsed the IF frame back as RTTY.

A K3 running FT8 therefore showed RTTY in the status bar, logged its QSOs on
RTTY, and — through the shared CAT server — told WSJT-X/JTDX the rig sat in
RTTY while they had just asked for a data mode. Found in a K3 operator's log:
every cat:state line read mode=RTTY on 21.074 FT8, two lines after OpsLog's
own "MD6;".

The digit now resolves to the configured digital mode whenever MD6 means DATA
on this rig — the Elecraft backend, and the "DATA A - MD6" data-mode option
that exists for it. A plain Kenwood still reads MD6 as RTTY.
2026-08-08 20:13:45 +02:00
rouggy 4d13cf7d15 feat(entry): normalise Name/QTH/Comment/Note case, upper-case the Recent QSOs search
One station reaches the log SHOUTED by QRZ, lower-cased by a hurried
operator and in whatever case an imported ADIF carried, so the same name
appears three ways across a log. Name and QTH are now title-cased word by
word; Comment and Note only get a capital first letter, because the rest
routinely holds callsigns and modes ("TNX QSO F5ABC, FT8 59") that
lower-casing would destroy.

Normalised on blur, never per keystroke — rewriting the value mid-word
fights the typist (the controlled-input trap in CLAUDE.md). Applied in
both entry layouts and in the QSO editor, since leaving the editor alone
would just reintroduce the mess on the first correction.

The Recent QSOs box only ever searches callsigns, so it upper-cases as
you type and carries an inline clear button.
2026-08-08 17:37:09 +02:00
rouggy 366d9df634 fix(grid): keep the award columns' position in the saved layout
Award columns were filtered out of the persisted column state entirely,
so applyColumnState({applyOrder:true}) had nowhere to put them and AG
Grid appended what the state does not mention. A column the operator
dragged to sit AFTER an award column therefore jumped back in front of
it on every reload.

Only the `hide` property is dropped now. Visibility stays owned by the
awardShown code-set — that is what stopped shown award columns from
vanishing on an awardCols rebuild — while the position survives the
round-trip.
2026-08-08 17:36:48 +02:00
rouggy f4b7c9dcf8 fix(settings): restore the settings-database buttons and unstack the backup options
The Settings -> Database panel had lost every action on the settings
database except "Open folder", which launches Windows Explorer — where a
.db cannot be selected at all, so double-clicking it only asks which
program should open it. The handlers were still there, just no longer
rendered; New / Open existing / Save a copy / Rename / Reset are back,
along with the restart banner (the DB pointer is only read at startup).

The backup block put the rotation field and three checkboxes on one flex
row inside max-w-xl. That fitted until "keep every backup" arrived with
its two-line hint, after which every label wrapped into an unreadable
sliver. The options are stacked now, and keep-all sits under the option
it depends on.
2026-08-08 17:36:31 +02:00
rouggy 48c448e6f3 chore(bindings): regenerate the Wails TS bindings
Stale since the 0.23.9 work: SetOpsLogQSLReceived, the backup keep_all
flag, the award PRIMARY search prefix and the default QSL message were
all missing from the generated bindings.
2026-08-08 17:35:53 +02:00
rouggy 9c757e5175 feat(cat): distinct Elecraft K3/K4 backend in the rig list
The Elecraft handling was buried in the Kenwood "data mode" option, so users
couldn't find it. Add "Elecraft K3/K4" as its own entry in the CAT backend
selector. It reuses the Kenwood-dialect transport and the Kenwood USB/network
settings (the K3 emulates the Kenwood command set — a separate transport would be
a near-total duplicate), with an `elecraft` flag on the client that forces
digital modes to DATA A (MD6+DT0) — no data-mode dropdown needed. RigState still
reports Backend "kenwood" so the CW-over-CAT keyer capability keeps working.
2026-08-08 14:15:55 +02:00
rouggy 626edca8ba feat(cat): Elecraft K3/K4 DATA A — send MD6 + DT0 on data mode (from the stale branch)
Integrates the fix/jtdx-kenwood-tx work onto main: the Kenwood/Elecraft backend's
"data" data-mode path now sends MD6 then DT0 so a K3/K4 lands in DATA A — the
audio sub-mode FT8 uses. MD6 alone could leave the rig in an FSK/PSK sub-mode
where it keyed but the rear sound-card audio never modulated ("transmits but
nothing comes out"). The data-mode option is relabelled "DATA A — MD6+DT0
(Elecraft K3/K4)". A shared Kenwood/Elecraft backend is kept (the K3 emulates the
Kenwood dialect); the Elecraft specifics live behind the data-mode seam rather
than a 7000-line duplicate backend.
2026-08-07 16:37:55 +02:00
rouggy b202d98ae5 fix(awards): add a Prefix field to the primary QSOFIELDS search
Only OrRule had a Prefix ("prepended to each found reference", e.g. postal 74 →
D74); the primary search passed "" for prefix, so an award whose references are
D01/D02… couldn't prepend the D on its main rule — only on OR fallbacks. Add
Def.Prefix, pass it into the primary run(), and expose a Prefix input next to the
primary Leading/Trailing fields in the award editor.
2026-08-07 14:23:41 +02:00
rouggy 949cc17ed1 feat(backup): "keep every backup" option — timestamped files per exit
Sub-option of "back up on every exit": when on, backup.Run/RunADIF stamp the
filename with the time (opslog-YYYY-MM-DD-HHMMSS.db) so each run is a distinct
file instead of overwriting the day's snapshot. Rotation still trims to the
newest N (raise Rotation for a longer history). Threaded as a `unique` flag
through runConfiguredBackup/backupLogADIF from BackupSettings.KeepAll
(keyBackupKeepAll); UI sub-checkbox shown only when EveryExit is on.
2026-08-07 12:28:52 +02:00
rouggy e9feeffdc3 fix(udp): forward WSJT/JTDX/MSHV-logged QSOs to the outbound ADIF integrations
autoLogFromUDP (the inbound WSJT-X/JTDX/MSHV log path) inserted the QSO but,
unlike the manual LogQSO path, never called a.udp.EmitLoggedADIF — so a QSO
received from MSHV was not re-emitted to the outbound ADIF listeners (Log4OM,
N1MM, GridTracker…). Emit it in the same async goroutine. A pathological
self-loop is broken by the existing ±2-min dedup, which returns before the emit.
2026-08-07 10:56:49 +02:00
rouggy 7d7d1042c0 feat(qsl): OpsLog QSL received marker + PSE/TNX card stamp + default QSL message
Received flag: new APP_OPSLOG_QSL_RCVD extra, toggled on the QSO edit window
next to QSL Message (immediate targeted write via SetOpsLogQSLReceived so it sets
AND clears reliably), plus a live PSE QSL / TNX indicator and a Recent-QSOs
column mirroring the sent one.

PSE/TNX card stamp: automatic — received → TNX, otherwise PSE QSL — exposed as
the {qso.pse_tnx} token (added to qslVars, so preview and send agree) and placed
in the default QSO-box footer; it can be moved to its own element in the designer.

Default QSL message: new qsl.default_message (QSLEmailTemplates.DefaultMessage),
edited under Settings → E-mail → QSL card e-mail. qslVars falls back to it when
the QSO's own QSLMSG is empty, so a per-QSO message always wins. Single choke
point covers both live preview and send.
2026-08-07 10:36:57 +02:00
rouggy 9cbfd39da0 ui(cluster): drop the value-looking placeholders in My rig/antenna/satellite
'Flex 8600', 'UB640', 'AO-91', 'U/V' read as real values, so an empty field
looked already filled. Removed so an empty field is visibly empty.
2026-08-07 09:53:01 +02:00
rouggy 5338ad0b29 feat(backup): option to back up on every exit, not just once a day
New BackupSettings.EveryExit (keyBackupEveryExit) with a Settings → Backup
checkbox. When on, the three shutdown-backup gates (plannedShutdownSteps,
runBackupForShutdown, maybeShutdownBackup) bypass the HasBackupToday /
HasADIFBackupToday "already done today" check and always run, so a second
session's QSOs are captured instead of skipped until the next day. The dated
backup file for today is refreshed (overwritten) each exit; prior days are still
kept by rotation.
2026-08-07 09:46:23 +02:00
rouggy 970127cc16 changelog: open empty 0.23.9 block 2026-08-07 00:17:42 +02:00
rouggy 3704dbdd99 chore: release v0.23.8 2026-08-07 00:16:56 +02:00
rouggy 3cdf16b501 perf(cluster): throttle the grid refreshCells to 200ms (coalesce RBN status bursts)
spotStatus updates ~20x/second under an RBN firehose; firing a full refreshCells
on each was pure churn on a slow PC. Coalesce into one refresh per 200ms. Keeps
the NEW/WORKED badge colours and the 'represents nothing' dimming — the dimming
itself is a trivial pure read now that the worked-index scan is cached, so it's
no longer a cost worth removing.
2026-08-07 00:13:23 +02:00
rouggy 9a72afd467 perf(cluster): cache the worked-index and bound the spot-status cache (RBN firehose)
A user on a slow PC with RBN saw OpsLog at 94% CPU and 8.5 GB RAM (Logger32: 3%
/ 34 MB on the same feeds). Two runaway costs under the spot firehose:

- ClusterSpotStatuses re-scanned the ENTIRE logbook (5-6 full-table maps) on
  every 50 ms spot batch — ~20×/second — its "one scan regardless of batch" doc
  was untrue. On a big log that's millions of row-scans/second → the pegged CPU.
  Now cached in clusterStatusCache (an immutable snapshot), rebuilt only when the
  logbook changes (noteWorked on a single log, invalidateAwardStats on bulk), so
  it's one scan per logged QSO instead of per batch.

- The frontend spotStatus map had no cap: one entry per call|band|mode ever seen,
  and RBN produces thousands of unique calls/hour → unbounded growth to GBs, plus
  a full {...prev} copy 20×/second. Now pruned back to the live (SPOTS_CAP=1000)
  spots once it drifts past 2×, with a cheap same-reference bail-out otherwise.
2026-08-07 00:08:24 +02:00
rouggy e2bfe73bdb fix(backup): back up the contacts (logbook), not just the settings db
Since the logbook was split into its own SQLite file, backup.Run was still
snapshotting a.db (the settings/config database) — so the scheduled and manual
backups silently stopped including the QSOs. The operator was backing up config
and thinking it was their log.

Track the resolved logbook file path (a.logDbPath, set in connectLogbook) and
route the backup through a new runConfiguredBackup: the CONTACTS become the
primary "opslog-*" backup (the logbook file on SQLite, an ADIF export on MySQL),
and the settings/config db is snapshotted separately as "opslogcfg-*" so nothing
is lost. backup.Run takes a name prefix; the two sets rotate independently.
2026-08-06 23:22:04 +02:00
rouggy 6e2e2cc3aa changelog: move the recording-email editor to 0.23.8 (0.23.7 is released) 2026-08-06 19:11:10 +02:00
rouggy 75d11a4069 feat(email): editable subject/body for the QSO recording e-mail
The recording e-mail's subject and body round-tripped through EmailSettings and
the backend (keyEmailSubject/keyEmailBody) but had no UI editor — only the QSL
card e-mail did, so the recording mail was stuck on the default text. Add the
same subject/body editor to the E-mail panel, above the QSL block, sharing the
{CALL}/{DATE}/{BAND}/{MODE}/{MYCALL} template variables.
2026-08-06 18:14:24 +02:00
rouggy cfe5d6f2a7 chore: release v0.23.7 2026-08-06 18:06:04 +02:00
rouggy 78c289d681 changelog: add Russian District Award (RDA) 2026-08-06 18:05:38 +02:00
rouggy 761f554ac0 fix(kenwood): keep the CAT link up while transmitting (K3 shared-CAT FT8)
A K3 (and other Kenwood-dialect rigs) answers "?;" to an IF; status poll while
it is transmitting. ask() latched that as "IF unsupported" and ReadState
returned an error, so the Manager dropped the whole CAT link mid-transmit — the
log shows connected=false / "IF rejected" the instant JTDX sent TX;. That tore
down the shared rigctld CAT the digital-mode software keys through.

Two fixes: (1) track PTT (SetPTT) and skip the wire poll while it's held —
ReadState hands back the cached last state, capped at 30 s so a missed
SetPTT(false) can't freeze it; (2) never latch IF/ID as unsupported — those are
universal on Kenwood/Elecraft, so a "?;" to them is a transient busy (mid-TX),
not a missing command.
2026-08-06 17:49:46 +02:00
rouggy 4c1b4fd1a8 ui(awards): keep the Followed column sorted alphabetically like Available 2026-08-06 17:36:09 +02:00
rouggy f2cc4f11cd fix(awards): stop the follow-list flicker/lost clicks (inline rows, no nested component)
The Row helper was defined inside AwardsSelectionPanel, so each render minted a
new component type and React remounted the whole list on every click — flicker,
and clicks lost between mousedown and click (hence needing many tries). Inline
the row markup so the keyed buttons keep a stable identity.
2026-08-06 17:32:25 +02:00
rouggy faf084ddfc feat(awards): follow-list picker in Settings → Awards, filter the Awards tab
New per-profile "tracked awards" selection: Settings → Awards (under User
configuration) is a two-column transfer list — every defined award on the left,
the ones you follow on the right, click to move either way. The Awards tab's
list is narrowed to the followed set; an empty set means "show them all" so the
tab is never blank.

Backend: app_awards_tracked.go adds keyAwardsTracked (per-profile JSON array of
award codes) with GetTrackedAwards/SaveTrackedAwards; saving emits
awards:tracked-changed so the Awards tab re-filters live. Award definitions stay
global — only the follow selection is per profile.
2026-08-06 17:29:00 +02:00
rouggy 01dcd91253 fix(motor-antenna): don't inhibit TX on a band the antenna doesn't cover
The follow filter correctly leaves the antenna put on an un-ticked band, but the
TX-inhibit loop ran independently: it gagged FlexRadio transmit whenever the
antenna reported "moving" (a stray polled Moving flag, or a move finishing from
the previous band), even on a band OpsLog was deliberately not managing. An
operator working 15 m on another antenna had their FT8 cut mid-transmission by
an "antenna moving" interlock.

motorTXInhibitLoop now takes the covered-band set and forces moving=false when
the current rig band isn't in it — un-ticking a band means hands off entirely:
no tune AND no inhibit. Also correct the RCU-01 references to RCU-06 (the actual
controller behind the short 11-byte status frame).
2026-08-06 17:06:49 +02:00
rouggy 3d603d34aa debug(motor-antenna): trace the follow band-gate decision (why the antenna did/didn't move) 2026-08-06 16:33:42 +02:00
rouggy 3d1602c4bd docs(wiki): cover every ADIF import option (dup modes, cty/ClubLog, station fill, field remap) 2026-08-06 16:16:52 +02:00
rouggy a10fb2413a ui(antenna): drop the Covered bands help paragraph 2026-08-06 16:12:46 +02:00
rouggy bd8ca2e9de feat(motor-antenna): band selector for the follow filter (replaces min/max range)
A contiguous FreqMin/FreqMax range can't drop a single band while keeping its
neighbours — so it couldn't express "40 m and 20 m yes, 30 m no" for an antenna
whose 30 m extension isn't fitted. Replace it with a Covered bands selector
(40 m–6 m) that applies to both the Ultrabeam and the SteppIR.

The follow loop and immediate re-tune now gate on band membership
(motorBandAllowed) instead of a MHz range: only bands in the operator's set are
followed, and 80 m/160 m are never followed (outside a beam's reach) regardless.
The legacy FreqMin/FreqMax is migrated to a band set on load and still
round-trips, so existing configs are unchanged. UI swaps the two number inputs
for a row of toggle chips.
2026-08-06 16:07:42 +02:00
rouggy df56391eb8 fix(motor-antenna): honor the configured tunable range for the Ultrabeam
The follow logic skips out-of-range frequencies only when it knows BOTH edges
of the antenna's range. The Ultrabeam path took those edges from the
controller's status frame, but an RCU-01 behind an RS232-to-Ethernet bridge
answers with the short frame that omits FreqMax → the guard saw FreqMax==0 and
disabled itself → OpsLog forwarded every rig frequency, so 80 m (un-tunable)
reached the controller, which clamped the elements down to its lowest band
(~30 m).

ubAdapter now carries the operator-configured FreqMin/FreqMax from Settings →
Antenna (like steppirAdapter already did) and uses them as a hard floor/ceiling,
falling back to the controller-reported bound only when one is left unset. This
guarantees both bounds are present so the guard actually engages. The Tunable
range editor in the Antenna panel, previously SteppIR-only, is now shown for the
Ultrabeam too, with a generic bilingual hint.
2026-08-06 11:02:14 +02:00
rouggy 82ce3353c4 feat(rotator): add Hy-Gain DCU-1 backend (RotorCard DXA, Rotor-EZ, Green Heron)
New internal/rotator/dcu1 client speaking the DCU-1 command set (AP1nnn / AM1
to go-to, AI1 to read bearing), over a serial COM port (4800 baud default) or a
raw TCP serial-over-IP bridge. Azimuth-only; Stop re-commands the current
bearing since the base set has no stop opcode.

Wired through app.go (normRotorType, default port 4001, GoTo/Heading/Stop/test
switches, park returns the same "PstRotator only" note as ARCO) and exposed in
the rotor settings as "Hy-Gain DCU-1 (RotorCard DXA, Rotor-EZ, Green Heron)"
with the ARCO's serial/TCP transport chooser and a bilingual hint. Protocol is
implemented from the standard DCU-1 spec and still needs field confirmation
against a real RotorCard DXA.
2026-08-06 10:40:36 +02:00
rouggy 20dc3e83a6 ui(stats): prettier slot drill-down + click a callsign to edit the QSO
The pop-up listing the QSOs behind a Stats band/mode square gets a cleaner header
(band chip, hint), banded/hover rows and roomier spacing. Callsigns are now
clickable and open the QSO editor (App.openEdit threaded through DetailsPanel →
BandSlotGrid → SlotQSOModal as onEditQso), matching the double-click-to-edit of
the log grids.
2026-08-06 10:30:34 +02:00
rouggy 62cdabe114 feat(relay): accept a full URL host so relays work behind an HTTPS proxy
Network relay boards (WebSwitch/KMTronic/Dingtian) were always reached at
http://<host>, so a board on the LAN behind a reverse proxy — the common remote
setup where the operator's 80/443 already go to Nginx Proxy Manager — couldn't be
reached from outside. relayBase() now keeps a scheme the operator supplies
(https://relay.example.com, optional sub-path) and only defaults to http:// for a
bare host/host:port. UI hint + test added.
2026-08-06 09:26:16 +02:00
rouggy 9bfb44925b fix(cluster): refresh spot statuses by merge, not by clearing the cache
setSpotStatus({}) blanked EVERY spot's status, so logging one contact wiped all
the other NEW/NEW-BAND badges and they popped back a moment later. Instead,
re-fetch the shown spots and OVERWRITE each status in place (merge) — the just-
worked spot updates while the rest stay put, no flicker. Same visibility gate
and 2 s debounce; backend cost is one status batch (one logbook scan) as before.
2026-08-06 01:36:17 +02:00
rouggy fb1af9b6a2 feat(cluster): refresh spot statuses after a log — visibility-gated & debounced
A just-worked call/entity/POTA/prefix/slot kept its NEW badge because per-spot
status is cached and never expires. Drop the cache on qso:logged so the grid
re-evaluates. Kept cheap for old machines / big logbooks: only fires while the
cluster is on screen (a log while hidden marks it dirty and refreshes once on
next open), and debounced 2 s so a fast run coalesces instead of re-scanning the
logbook per QSO.
2026-08-06 01:13:22 +02:00
rouggy 57a9bed145 perf(cluster): dim spots via cell class, not redrawRows (0.23.6 slowdown)
The "dim spots that represent nothing" feature drove the dimming with getRowStyle
and called api.redrawRows() on EVERY spotStatus update to re-apply it — a full
grid re-render on a firehose of status updates, which pegged the CPU on a busy
cluster (reported as PCs slowing down since 0.23.6). Move the dimming to a
defaultColDef cellClassRules ('opacity-40') that the existing light
refreshCells({force:true}) re-applies; drop redrawRows and getRowStyle.
2026-08-06 00:55:49 +02:00
rouggy fbd323af26 fix(pgxl): fan mode needs the "setup" prefix — "setup fanmode=VALUE"
Confirmed live: "setup fanmode=CONTEST" is accepted (reply code 0) while the
bare "fanmode=CONTEST" we switched to earlier is rejected (0x50000015), so the
fan mode snapped back and never changed on the amp. Restored the "setup " verb
and now check the reply code, returning an error if the amp rejects the set.
2026-08-05 17:32:53 +02:00
rouggy 689d1cc902 feat(qso-edit): QRZ button to open the station's qrz.com profile
Mirrors the main entry form: a QRZ ↗ button by the callsign in the QSO edit
modal opens https://www.qrz.com/db/<call> via OpenExternalURL.
2026-08-05 17:13:34 +02:00
rouggy 67602fd485 docs(changelog): open empty 0.23.7 block 2026-08-05 16:43:32 +02:00
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
44 changed files with 43130 additions and 410 deletions
+666 -101
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
package main
import (
"encoding/json"
"fmt"
"strings"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// keyAwardsTracked holds the PER-PROFILE list of award codes the operator wants
// to follow (JSON array of award.Def.Code). Award definitions themselves are
// global (keyAwardDefs, shared across profiles), but WHICH awards a station
// tracks is a per-profile choice — a DX profile follows DXCC/WPX, a POTA profile
// follows POTA/WWFF. An empty/unset list means "track them all" so the Awards
// tab is never blank before the operator has picked anything.
const keyAwardsTracked = "awards.tracked"
// GetTrackedAwards returns the active profile's followed award codes. An empty
// slice means the operator has not narrowed the list — the Awards tab then shows
// every award.
func (a *App) GetTrackedAwards() ([]string, error) {
out := []string{}
if a.settings == nil {
return out, nil
}
s, _ := a.settings.Get(a.ctx, keyAwardsTracked)
if strings.TrimSpace(s) == "" {
return out, nil
}
if err := json.Unmarshal([]byte(s), &out); err != nil {
return []string{}, nil
}
return out, nil
}
// SaveTrackedAwards persists the followed award codes for the active profile and
// notifies the Awards tab to re-filter its list. Codes are stored verbatim; the
// Awards tab intersects them with the live award definitions, so a code that no
// longer exists is simply ignored (not an error).
func (a *App) SaveTrackedAwards(codes []string) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if codes == nil {
codes = []string{}
}
b, err := json.Marshal(codes)
if err != nil {
return err
}
if err := a.settings.Set(a.ctx, keyAwardsTracked, string(b)); err != nil {
return err
}
wruntime.EventsEmit(a.ctx, "awards:tracked-changed")
return nil
}
+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)
}
}
}
+54 -4
View File
@@ -32,12 +32,19 @@ const (
keyQSLEmailSubject = "qsl.email_subject" keyQSLEmailSubject = "qsl.email_subject"
keyQSLEmailBody = "qsl.email_body" keyQSLEmailBody = "qsl.email_body"
keyQSLAutoSend = "qsl.auto_send" // "1" → render+send an eQSL on log when an e-mail and default template exist keyQSLAutoSend = "qsl.auto_send" // "1" → render+send an eQSL on log when an e-mail and default template exist
keyQSLDefaultMsg = "qsl.default_message" // fallback QSL message printed on the card when the QSO's own QSLMSG is empty
) )
// appQSLCardSentField is the ADIF APP_ field stamping when OpsLog e-mailed its // appQSLCardSentField is the ADIF APP_ field stamping when OpsLog e-mailed its
// own QSL card. Deliberately NOT eqsl_sent (that's eQSL.cc's, kept independent). // own QSL card. Deliberately NOT eqsl_sent (that's eQSL.cc's, kept independent).
const appQSLCardSentField = "APP_OPSLOG_QSL_SENT" const appQSLCardSentField = "APP_OPSLOG_QSL_SENT"
// appQSLCardRcvdField marks that a QSL was RECEIVED for this QSO (set by the
// operator, e.g. when a card arrives by e-mail). It drives the PSE/TNX stamp on
// the card: received → "TNX" (thanks for your card), not received → "PSE QSL"
// (please send one). Independent of ADIF qsl_rcvd, like the sent field.
const appQSLCardRcvdField = "APP_OPSLOG_QSL_RCVD"
const ( const (
defaultQSLEmailSubject = "eQSL — {CALL} de {MYCALL}" defaultQSLEmailSubject = "eQSL — {CALL} de {MYCALL}"
defaultQSLEmailBody = "Hi,\n\nThank you for our QSO! Please find attached your eQSL card.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}" defaultQSLEmailBody = "Hi,\n\nThank you for our QSO! Please find attached your eQSL card.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}"
@@ -453,11 +460,37 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
return nil return nil
} }
// QSLEmailTemplates is the eQSL e-mail subject/body plus the auto-send toggle. // qslDefaultMessage returns the operator's default QSL message (Settings), used
// on the card when a QSO has no QSLMSG of its own.
func (a *App) qslDefaultMessage() string {
if a.settings == nil {
return ""
}
s, _ := a.settings.Get(a.ctx, keyQSLDefaultMsg)
return s
}
// SetOpsLogQSLReceived marks (or clears) that a QSL was received for a QSO. This
// flips the card's PSE/TNX stamp. Stored as a timestamp in the QSO extras (a
// single-key UPDATE, like the sent marker) so it survives concurrent uploads.
func (a *App) SetOpsLogQSLReceived(qsoID int64, on bool) error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
v := ""
if on {
v = time.Now().UTC().Format(time.RFC3339)
}
return a.qso.SetExtra(a.ctx, qsoID, appQSLCardRcvdField, v)
}
// QSLEmailTemplates is the eQSL e-mail subject/body, the auto-send toggle, and
// the default QSL message printed on the card when a QSO has none of its own.
type QSLEmailTemplates struct { type QSLEmailTemplates struct {
Subject string `json:"subject"` Subject string `json:"subject"`
Body string `json:"body"` Body string `json:"body"`
AutoSend bool `json:"auto_send"` AutoSend bool `json:"auto_send"`
DefaultMessage string `json:"default_message"`
} }
// QSLGetEmailTemplates returns the eQSL e-mail templates (with defaults). // QSLGetEmailTemplates returns the eQSL e-mail templates (with defaults).
@@ -466,7 +499,7 @@ func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
if a.settings == nil { if a.settings == nil {
return out, nil return out, nil
} }
m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody, keyQSLAutoSend) m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody, keyQSLAutoSend, keyQSLDefaultMsg)
if err != nil { if err != nil {
return out, err return out, err
} }
@@ -477,6 +510,7 @@ func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
out.Body = b out.Body = b
} }
out.AutoSend = m[keyQSLAutoSend] == "1" out.AutoSend = m[keyQSLAutoSend] == "1"
out.DefaultMessage = m[keyQSLDefaultMsg]
return out, nil return out, nil
} }
@@ -495,7 +529,10 @@ func (a *App) QSLSaveEmailTemplates(t QSLEmailTemplates) error {
if t.AutoSend { if t.AutoSend {
v = "1" v = "1"
} }
return a.settings.Set(a.ctx, keyQSLAutoSend, v) if err := a.settings.Set(a.ctx, keyQSLAutoSend, v); err != nil {
return err
}
return a.settings.Set(a.ctx, keyQSLDefaultMsg, t.DefaultMessage)
} }
// maybeAutoSendEQSL fires an eQSL render+send for a freshly-logged QSO when the // maybeAutoSendEQSL fires an eQSL render+send for a freshly-logged QSO when the
@@ -634,6 +671,18 @@ func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error)
} }
return strconv.Itoa(z) return strconv.Itoa(z)
} }
// The QSL message on the card: the QSO's own QSLMSG wins; when it's empty the
// operator's default message (Settings) is used instead.
msg := q.QSLMsg
if strings.TrimSpace(msg) == "" {
msg = a.qslDefaultMessage()
}
// PSE/TNX stamp, chosen automatically by whether a QSL was received for this
// QSO (appQSLCardRcvdField): received → thank them, otherwise ask for a card.
pseTnx := "PSE QSL"
if q.Extras != nil && strings.TrimSpace(q.Extras[appQSLCardRcvdField]) != "" {
pseTnx = "TNX"
}
vars := map[string]string{ vars := map[string]string{
"profile.callsign": info.Callsign, "profile.callsign": info.Callsign,
"profile.operator_name": info.Operator, "profile.operator_name": info.Operator,
@@ -650,7 +699,8 @@ func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error)
"qso.mode": q.Mode, "qso.mode": q.Mode,
"qso.submode": q.Submode, "qso.submode": q.Submode,
"qso.rst_sent": q.RSTSent, "qso.rst_sent": q.RSTSent,
"qso.qsl_msg": q.QSLMsg, "qso.qsl_msg": msg,
"qso.pse_tnx": pseTnx,
"qso.name": q.Name, "qso.name": q.Name,
} }
vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q) vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q)
+18902
View File
File diff suppressed because it is too large Load Diff
+152
View File
@@ -1,4 +1,156 @@
[ [
{
"version": "0.23.9",
"date": "",
"en": [
"Backup: new \"Back up on every exit\" option (Settings → Backup). With it on, OpsLog backs up the database each time you quit instead of only the first time of the day — so a second session's QSOs are always captured.",
"Cluster details (My station): removed the example placeholders in My rig / My antenna (and the satellite name/mode) — they looked like real values, so an empty field read as already filled.",
"QSL: new \"OpsLog QSL received\" marker on the QSO edit window (next to QSL Message), with a live PSE QSL / TNX indicator. When a QSL was received the card prints TNX (thanks), otherwise PSE QSL (please send one) — automatic. The stamp uses the {qso.pse_tnx} token (in the default card footer; placeable anywhere in the designer). A \"QSL received\" column was added to Recent QSOs.",
"QSL: a default QSL message (Settings → E-mail, under the QSL card e-mail) now prints on the card when a QSO has no QSL Message of its own — a QSO's own QSL Message always takes precedence.",
"UDP outbound: a QSO logged from WSJT-X / JTDX / MSHV (via the inbound UDP listener) is now forwarded to the outbound ADIF integrations too — previously only QSOs typed directly into OpsLog were re-emitted.",
"Backup: with \"back up on every exit\", a new \"Keep every backup\" sub-option writes a distinct time-stamped file per exit (opslog-YYYY-MM-DD-HHMMSS.db) instead of overwriting the day's file. Rotation still keeps the newest N — raise it for more history.",
"Awards editor: the PRIMARY QSOFIELDS search now has a Prefix field too (prepended to each found reference, e.g. captured 01 → D01) — previously only the OR fallback searches could add a prefix, so a DOK-style award couldn't match on its main rule.",
"CAT: new Elecraft K3/K4 backend in the rig list. Select it (it reuses the Kenwood USB/network settings) and digital modes automatically use DATA A (MD6+DT0) — the sub-mode FT8 audio needs, so the K3 no longer keys the transmitter without modulating.",
"Settings database: the New / Open existing / Save a copy / Rename / Reset buttons are back (Settings → Database). Only \"Open folder\" was left, which opens Windows Explorer — where a .db can't be selected, it just asks which program should open it. Picking a database now uses a proper file dialog and offers a one-click restart.",
"Recent QSOs: a column dragged to sit AFTER an award column now stays there. Award columns were left out of the saved layout, so on reload they were pushed to the far right and jumped back in front of the column you had placed after them.",
"Entry form and QSO editor: Name and QTH are capitalised word by word (\"JEAN-PIERRE\" → \"Jean-Pierre\"), Comment and Note get a capital first letter. Applied when you leave the field, so typing is never interrupted; the rest of a comment is left alone, since it usually holds callsigns and modes.",
"Recent QSOs: the search box only ever takes callsigns, so it now upper-cases as you type and carries a small clear button.",
"CAT Elecraft K3/K4: the rig's mode is read back correctly. MD6 is DATA on a K3 but FSK on a Kenwood, and OpsLog decoded it as RTTY — so a K3 running FT8 showed RTTY, logged its QSOs as RTTY, and told WSJT-X/JTDX over the shared CAT link that the rig was in RTTY while they had asked for a data mode. Applies to the Elecraft K3/K4 backend and to the \"DATA A — MD6\" option; a plain Kenwood still reads MD6 as RTTY.",
"CAT sharing: the transmitter is dropped when the program keying it goes away. If WSJT-X/JTDX crashed, was closed, or simply had its connection cut — which is what saving the settings does, since the sharing server is rebuilt — the rig stayed on air with nobody watching: the Kenwood/Elecraft link stops polling while PTT is held, so nothing noticed. A K3 was seen transmitting for 29 seconds.",
"DX cluster: new \"Self-spot while I log\" option (Settings → DX Cluster). When you log a QSO, OpsLog announces YOU on the master cluster — your station callsign as the DX, on the frequency you just worked — so callers find you without waiting for someone else to spot you. It fires on the first QSO of a frequency, then no more often than the interval you set; five minutes is the minimum, since a self-spot is traffic every user of the node sees."
],
"fr": [
"Sauvegarde : nouvelle option \"Sauvegarder à chaque fermeture\" (Réglages → Sauvegarde). Activée, OpsLog sauvegarde la base à chaque fois que tu quittes au lieu d'une seule fois par jour — les QSO d'une seconde session sont ainsi toujours pris.",
"Détails cluster (Ma station) : suppression des exemples en filigrane dans Mon rig / Mon antenne (et nom/mode satellite) — ils ressemblaient à de vraies valeurs, faisant croire qu'un champ vide était déjà rempli.",
"QSL : nouveau marqueur \"QSL OpsLog reçue\" dans la fenêtre d'édition du QSO (à côté du Message QSL), avec un indicateur PSE QSL / TNX en direct. Si une QSL a été reçue, la carte imprime TNX (merci), sinon PSE QSL (envoie-moi une carte) — automatique. Le tampon utilise le token {qso.pse_tnx} (dans le pied de carte par défaut ; plaçable où tu veux dans le designer). Une colonne \"QSL reçue\" a été ajoutée aux QSO récents.",
"QSL : un message QSL par défaut (Réglages → E-mail, sous l'e-mail de carte QSL) s'imprime désormais sur la carte quand un QSO n'a pas son propre Message QSL — le Message QSL du QSO l'emporte toujours.",
"UDP sortant : un QSO enregistré depuis WSJT-X / JTDX / MSHV (via l'écouteur UDP entrant) est désormais aussi transmis aux intégrations ADIF sortantes — avant, seuls les QSO saisis directement dans OpsLog étaient réémis.",
"Sauvegarde : avec \"sauvegarder à chaque fermeture\", une nouvelle sous-option \"Garder chaque sauvegarde\" écrit un fichier horodaté distinct par fermeture (opslog-AAAA-MM-JJ-HHMMSS.db) au lieu d'écraser le fichier du jour. La rotation garde les N plus récents — augmente-la pour plus d'historique.",
"Éditeur de diplômes : la recherche PRINCIPALE QSOFIELDS a aussi un champ Préfixe (ajouté devant chaque référence trouvée, ex. 01 capturé → D01) — avant, seules les recherches OR de repli pouvaient ajouter un préfixe, empêchant un diplôme type DOK de matcher sur sa règle principale.",
"CAT : nouveau backend Elecraft K3/K4 dans la liste des rigs. Sélectionne-le (il réutilise les réglages USB/réseau Kenwood) et les modes numériques passent automatiquement en DATA A (MD6+DT0) — le sous-mode dont l'audio FT8 a besoin, donc le K3 ne passe plus en émission sans moduler.",
"Base de réglages : les boutons Nouvelle base / Ouvrir existante / Enregistrer une copie / Renommer / Réinitialiser sont de retour (Réglages → Base de données). Il ne restait que \"Ouvrir le dossier\", qui lance l'Explorateur Windows — où un .db n'est pas sélectionnable, il demande juste avec quel logiciel l'ouvrir. Choisir une base passe désormais par un vrai sélecteur de fichier, avec redémarrage en un clic.",
"QSO récents : une colonne déplacée APRÈS une colonne de diplôme y reste. Les colonnes de diplômes étaient exclues de la disposition enregistrée, donc au rechargement elles étaient repoussées tout à droite et repassaient devant la colonne que tu avais placée après elles.",
"Saisie et éditeur de QSO : Name et QTH sont capitalisés mot par mot (« JEAN-PIERRE » → « Jean-Pierre »), Comment et Note prennent une majuscule en première lettre. Appliqué quand tu quittes le champ, donc la frappe n'est jamais perturbée ; la suite d'un commentaire est laissée telle quelle, puisqu'elle contient souvent des indicatifs et des modes.",
"QSO récents : le champ de recherche ne prend que des indicatifs — il passe donc en majuscules à la frappe et reçoit un petit bouton d'effacement.",
"CAT Elecraft K3/K4 : le mode de la radio est relu correctement. MD6 vaut DATA sur un K3 mais FSK sur un Kenwood, et OpsLog le décodait en RTTY — un K3 en FT8 affichait donc RTTY, enregistrait ses QSO en RTTY, et annonçait RTTY à WSJT-X/JTDX via le partage CAT alors qu'ils avaient demandé un mode data. Vaut pour le backend Elecraft K3/K4 et pour l'option « DATA A — MD6 » ; un Kenwood classique lit toujours MD6 comme RTTY.",
"Partage CAT : l'émission est coupée quand le logiciel qui la commandait disparaît. Si WSJT-X/JTDX plantait, était fermé, ou voyait simplement sa connexion coupée — ce que fait un enregistrement des réglages, puisque le serveur de partage est reconstruit — la radio restait en émission sans surveillance : la liaison Kenwood/Elecraft cesse d'interroger le poste tant que le PTT est tenu, donc rien ne s'en apercevait. Un K3 a été vu en émission pendant 29 secondes.",
"Cluster DX : nouvelle option « M'auto-spotter quand j'enregistre » (Réglages → DX Cluster). À chaque QSO enregistré, OpsLog t'annonce sur le cluster maître — ton indicatif de station en DX, sur la fréquence que tu viens de travailler — pour qu'on te trouve sans attendre que quelqu'un te spotte. Déclenché au premier QSO d'une fréquence, puis pas plus souvent que l'intervalle choisi ; cinq minutes est le minimum, un auto-spot étant du trafic que voient tous les utilisateurs du nœud."
]
},
{
"version": "0.23.8",
"date": "",
"en": [
"E-mail: the QSO recording e-mail (subject and body) is now editable in Settings → E-mail, like the QSL card e-mail — with the {CALL} {DATE} {BAND} {MODE} {MYCALL} variables.",
"Backup fix: the backup now saves your CONTACTS (the logbook), not just the settings. Once the logbook was split into its own file, the backup kept snapshotting the settings database and silently missed the QSOs. It now writes the log to opslog-*.db and the configuration separately to opslogcfg-*.db (MySQL logs still export to ADIF).",
"Performance: much lower CPU and memory on a busy cluster (RBN and other high-volume feeds). The Cluster tab was re-scanning the entire logbook for every batch of spots (~20×/second) and its spot-status cache grew without limit — on a firehose that could climb to gigabytes of RAM and peg a CPU. The worked-index is now cached (rebuilt only when you log a QSO) and the status cache is bounded to the spots actually shown."
],
"fr": [
"E-mail : le texte de l'e-mail d'enregistrement QSO (objet et corps) est désormais modifiable dans Réglages → E-mail, comme l'e-mail de carte QSL — avec les variables {CALL} {DATE} {BAND} {MODE} {MYCALL}.",
"Correction sauvegarde : la sauvegarde enregistre désormais tes CONTACTS (le journal), et plus seulement les réglages. Depuis que le journal a été séparé dans son propre fichier, la sauvegarde continuait à copier la base des réglages et oubliait les QSO. Elle écrit maintenant le log dans opslog-*.db et la configuration à part dans opslogcfg-*.db (les logs MySQL restent exportés en ADIF).",
"Performances : CPU et mémoire nettement réduits sur un cluster chargé (RBN et autres flux à fort volume). L'onglet Cluster rescannait tout le journal à chaque lot de spots (~20×/seconde) et son cache de statuts grossissait sans limite — sur un flux intense cela pouvait atteindre des gigaoctets de RAM et saturer un cœur. L'index des contacts est désormais mis en cache (reconstruit seulement quand tu logues un QSO) et le cache de statuts est borné aux spots réellement affichés."
]
},
{
"version": "0.23.7",
"date": "",
"en": [
"QSO edit: added the QRZ ↗ button next to the callsign, like the main entry form — one click opens that station's qrz.com profile.",
"PowerGenius XL: the fan mode (Standard / Contest / Broadcast) changes on the amp again. A previous fix dropped the \"setup\" prefix the amp requires (\"setup fanmode=…\"), so the amp rejected the command and the mode snapped straight back. The correct command is restored.",
"Performance: fixed a slowdown introduced in 0.23.6. To dim the \"represents nothing\" spots, the DX Cluster grid was redrawing EVERY row on each spot-status update, which pegged the CPU on a busy cluster (some PCs became sluggish). The dimming now updates with a light per-cell refresh instead — same look, no more churn.",
"DX Cluster: after you log a QSO, the spots update — a callsign, entity, POTA, prefix or band/mode slot you just worked stops showing its NEW badge, instead of staying \"new\" until a restart. Kept fast: it re-evaluates only while the cluster is actually on screen (nothing runs otherwise — a QSO logged while it's hidden refreshes once when you next open it), and it's debounced so a quick run doesn't re-scan on every QSO.",
"Station Control relays (WebSwitch / KMTronic / Dingtian): the Host field now accepts a full URL, so you can reach a network relay board through an HTTPS reverse proxy — e.g. https://relay.yourdomain.com.",
"Stats slot drill-down: the pop-up listing the QSOs behind a band/mode square looks tidier (banded rows, cleaner header), and you can now click a callsign in it to open that QSO for editing.",
"DCU-1 rotor support: OpsLog can now steer controllers that speak the Hy-Gain DCU-1 protocol (RotorCard DXA for Yaesu DXA rotors, Idiom Press Rotor-EZ, Green Heron) over their COM port or a serial-over-IP bridge.",
"Motorized antenna: Settings → Antenna now has a Covered bands selector (40 m6 m) instead of a min/max range, for both the Ultrabeam and the SteppIR. Tick only the bands your antenna does — untick one you can't (e.g. 30 m without its extension) while keeping the rest — and OpsLog leaves the antenna put on every other band. This overrides an unreliable controller, so 80 m no longer clamps the elements down to 30 m. Existing range settings migrate automatically.",
"Motorized antenna: on a band you did not tick in Covered bands, OpsLog no longer inhibits FlexRadio transmit for \"antenna moving\". Un-ticking a band now means hands off completely — no tuning and no TX block — so working it on another antenna is never gagged.",
"Awards: a new Settings → Awards picker (under User configuration) lets you choose which awards to follow — a two-column list, all awards on the left, the ones you track on the right. The Awards tab then shows only those you follow (per profile; leave the right side empty to show them all).",
"Kenwood/Elecraft CAT: the link no longer drops while the rig is transmitting. A K3 (and other Kenwood-dialect rigs) answers \"?;\" to a status poll during transmit; OpsLog used to read that as \"rig doesn't support IF\" and disconnect — which tore down the shared CAT that WSJT-X / JTDX key through, so the radio wouldn't transmit properly. OpsLog now pauses status polling while PTT is held and keeps the link up.",
"New award: Russian District Award (RDA) — tracks the 2660 Russian districts, worked and confirmed per band."
],
"fr": [
"Édition de QSO : ajout du bouton QRZ ↗ à côté de l'indicatif, comme dans le formulaire de saisie — un clic ouvre le profil qrz.com de la station.",
"PowerGenius XL : le mode ventilateur (Standard / Contest / Broadcast) change de nouveau sur l'ampli. Un correctif précédent avait retiré le préfixe « setup » exigé par l'ampli (« setup fanmode=… »), qui rejetait donc la commande et le mode revenait aussitôt en arrière. La bonne commande est rétablie.",
"Performance : correction d'un ralentissement apparu en 0.23.6. Pour atténuer les spots « qui ne représentent rien », la grille du DX Cluster redessinait TOUTES les lignes à chaque mise à jour de statut, ce qui saturait le CPU sur un cluster actif (des PC devenaient lents). L'atténuation se met désormais à jour via un rafraîchissement léger par cellule — même rendu, sans le brassage.",
"DX Cluster : après avoir loggué un QSO, les spots se mettent à jour — un indicatif, une entité, un POTA, un préfixe ou un slot bande/mode que vous venez de contacter cesse d'afficher son badge NEW, au lieu de rester « new » jusqu'au redémarrage. Reste rapide : la réévaluation n'a lieu que si le cluster est affiché (sinon rien ne tourne — un QSO loggué cluster caché se rafraîchit une fois à sa réouverture), et c'est débouncé pour ne pas re-scanner à chaque QSO en série.",
"Relais du Contrôle station (WebSwitch / KMTronic / Dingtian) : le champ Hôte accepte désormais une URL complète ex. https://relais.tondomaine.com derrière Nginx Proxy Manager. Avant, OpsLog forçait http://<hôte>, donc une carte sur le LAN derrière un proxy (quand vos 80/443 vont déjà ailleurs) était injoignable de l'extérieur.",
"Détail dun slot Stats : la fenêtre listant les QSO derrière une case bande/mode est plus soignée (lignes alternées, en-tête plus propre), et vous pouvez maintenant cliquer un indicatif pour ouvrir ce QSO en édition.",
"Prise en charge des rotors DCU-1 : OpsLog pilote désormais les contrôleurs parlant le protocole Hy-Gain DCU-1 (RotorCard DXA pour rotors Yaesu DXA, Idiom Press Rotor-EZ, Green Heron) via leur port COM ou un pont série-sur-IP.",
"Antenne motorisée : Réglages → Antenne propose désormais un sélecteur Bandes couvertes (40 m6 m) au lieu d'une plage min/max, pour l'Ultrabeam comme pour la SteppIR. Coche seulement les bandes que fait ton antenne — décoche celle que tu ne fais pas (p. ex. le 30 m sans son extension) en gardant les autres — et OpsLog laisse l'antenne en place sur toutes les autres. Ceci prime sur un contrôleur peu fiable : le 80 m ne replie plus les éléments sur le 30 m. Les anciens réglages de plage sont migrés automatiquement.",
"Antenne motorisée : sur une bande non cochée dans Bandes couvertes, OpsLog n'inhibe plus l'émission du FlexRadio pour « antenne en mouvement ». Décocher une bande signifie désormais ne plus y toucher du tout — ni accord ni blocage TX — pour ne jamais couper le trafic sur une autre antenne.",
"Diplômes : un nouveau sélecteur Réglages → Diplômes (dans Configuration utilisateur) permet de choisir les diplômes à suivre — une liste à deux colonnes, tous les diplômes à gauche, ceux que tu suis à droite. L'onglet Awards n'affiche alors que ceux-là (par profil ; laisse la colonne de droite vide pour tous les afficher).",
"CAT Kenwood/Elecraft : le lien ne tombe plus pendant que le rig émet. Un K3 (et d'autres rigs en dialecte Kenwood) répond \"?;\" à une interrogation d'état pendant l'émission ; OpsLog le prenait pour \"le rig ne supporte pas IF\" et se déconnectait — ce qui cassait le CAT partagé que WSJT-X / JTDX utilisent pour passer en émission, d'où l'absence d'émission. OpsLog suspend désormais l'interrogation d'état tant que le PTT est actif et garde le lien.",
"Nouveau diplôme : Russian District Award (RDA) — suit les 2660 districts russes, travaillés et confirmés par bande."
]
},
{
"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", "version": "0.23.3",
"date": "", "date": "",
+174 -21
View File
@@ -103,6 +103,7 @@ import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/Winkeye
import { RotorCompass } from '@/components/RotorCompass'; import { RotorCompass } from '@/components/RotorCompass';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { formatDateTimeUTC } from '@/lib/dateFormat'; import { formatDateTimeUTC } from '@/lib/dateFormat';
import { titleCase, sentenceCase } from '@/lib/textCase';
import { setGridPrefsProfile, flushGridPrefs } from '@/lib/gridPrefs'; import { setGridPrefsProfile, flushGridPrefs } from '@/lib/gridPrefs';
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel'; import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
@@ -1500,6 +1501,83 @@ export default function App() {
// still need resolving without re-subscribing the cluster:spot listener. // still need resolving without re-subscribing the cluster:spot listener.
const spotStatusRef = useRef(spotStatus); const spotStatusRef = useRef(spotStatus);
useEffect(() => { spotStatusRef.current = spotStatus; }, [spotStatus]); useEffect(() => { spotStatusRef.current = spotStatus; }, [spotStatus]);
// Mirror of spots so the log-triggered refresh reads the current list without
// a stale closure.
const spotsRef = useRef(spots);
useEffect(() => { spotsRef.current = spots; }, [spots]);
// Bound the status cache. Keyed per call|band|mode, it otherwise kept an entry
// for every station ever seen — under an RBN firehose (thousands of unique
// calls/hour) that grew without limit to gigabytes. Prune it back to the live
// (SPOTS_CAP-limited) spots once it drifts well past them. The size check bails
// cheaply the rest of the time (returning the same reference, so no dependent
// memo re-runs); an evicted spot is just re-resolved if it reappears.
useEffect(() => {
setSpotStatus((prev) => {
const keys = Object.keys(prev);
if (keys.length <= SPOTS_CAP * 2) return prev;
const live = new Set(spots.map((x) => spotStatusKey(x.dx_call, x.band ?? '', x.comment ?? '', x.freq_hz)));
const pruned: typeof prev = {};
for (const k of keys) if (live.has(k)) pruned[k] = prev[k];
return pruned;
});
}, [spots]);
// Re-fetch the status of every SHOWN spot and OVERWRITE the cache (merge, never
// clear). Overwriting keeps the other NEW badges on screen until their fresh
// value lands, instead of blanking the whole grid and letting the badges pop
// back a moment later. The backend cost is one status batch regardless of spot
// count (it scans the logbook once), so this is as cheap as the poll already is.
const refreshSpotStatuses = useCallback(async () => {
const cur = spotsRef.current;
if (!cur.length) return;
const queries: { call: string; band: string; mode: string; pota_ref: string }[] = [];
const seen = new Set<string>();
for (const s of cur) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
if (seen.has(k)) continue;
seen.add(k);
queries.push({ call: s.dx_call, band: s.band ?? '', mode: inferSpotMode(s.comment ?? '', s.freq_hz), pota_ref: (s as any).pota_ref ?? '' });
}
if (!queries.length) return;
try {
const res = await ClusterSpotStatuses(queries as any);
setSpotStatus((prev) => {
const next = { ...prev };
for (const r of res) {
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
next[k] = {
status: r.status ?? '', country: r.country, continent: (r as any).continent,
worked_call: !!(r as any).worked_call, new_county: !!(r as any).new_county,
new_pota: !!(r as any).new_pota, new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx,
};
}
return next;
});
} catch { /* leave the old statuses in place on failure */ }
}, []);
// After a QSO is logged, refresh the shown spots so a call/entity/POTA/prefix/
// slot just worked drops its NEW badge — WITHOUT blanking the others (see the
// merge above). Kept cheap: only while the cluster is on screen (a log while
// hidden marks it dirty and refreshes ONCE on next open), and debounced so a
// fast run coalesces instead of re-scanning per QSO.
const clusterVisibleRef = useRef(false);
const spotsDirtyRef = useRef(false);
useEffect(() => {
const vis = mainPaneLeft === 'cluster' || mainPaneRight === 'cluster' || activeTab === 'cluster';
if (vis && !clusterVisibleRef.current && spotsDirtyRef.current) {
spotsDirtyRef.current = false;
void refreshSpotStatuses();
}
clusterVisibleRef.current = vis;
}, [mainPaneLeft, mainPaneRight, activeTab, refreshSpotStatuses]);
useEffect(() => {
let t: number | undefined;
const off = EventsOn('qso:logged', () => {
if (!clusterVisibleRef.current) { spotsDirtyRef.current = true; return; }
if (t) window.clearTimeout(t);
t = window.setTimeout(() => void refreshSpotStatuses(), 2000);
});
return () => { off(); if (t) window.clearTimeout(t); };
}, [refreshSpotStatuses]);
// Incoming spots are staged here for a few ms, their status resolved, then // Incoming spots are staged here for a few ms, their status resolved, then
// committed to `spots` together — so a row paints with its NEW BAND/MODE badge // committed to `spots` together — so a row paints with its NEW BAND/MODE badge
// already on, instead of flashing plain text then flipping to the pill. // already on, instead of flashing plain text then flipping to the pill.
@@ -1512,6 +1590,11 @@ export default function App() {
const [deletingIds, setDeletingIds] = useState<number[]>([]); const [deletingIds, setDeletingIds] = useState<number[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null); const [selectedId, setSelectedId] = useState<number | null>(null);
const [selectedIds, setSelectedIds] = useState<number[]>([]); 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 [bulkEditIds, setBulkEditIds] = useState<number[]>([]);
const [bulkEditOpen, setBulkEditOpen] = useState(false); const [bulkEditOpen, setBulkEditOpen] = useState(false);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
@@ -2607,6 +2690,11 @@ export default function App() {
const applyUdpCall = (raw: string, force = false): boolean => { const applyUdpCall = (raw: string, force = false): boolean => {
const call = String(raw ?? '').trim(); const call = String(raw ?? '').trim();
if (!call) return false; 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 upper = call.toUpperCase();
const current = callsignValRef.current.trim().toUpperCase(); const current = callsignValRef.current.trim().toUpperCase();
const prev = lastUdpCallRef.current; const prev = lastUdpCallRef.current;
@@ -2706,6 +2794,7 @@ export default function App() {
s.engine === 'icom' ? 'icom' s.engine === 'icom' ? 'icom'
: s.engine === 'flex' ? 'flex' : s.engine === 'flex' ? 'flex'
: s.engine === 'yaesu' ? 'yaesu' : s.engine === 'yaesu' ? 'yaesu'
: s.engine === 'kenwood' ? 'kenwood'
: s.engine === 'serial' ? 'serial' : s.engine === 'serial' ? 'serial'
: 'winkeyer'); : 'winkeyer');
} catch { /* keyer not configured */ } } catch { /* keyer not configured */ }
@@ -3309,6 +3398,24 @@ export default function App() {
finally { setDeletingAll(false); } 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) { async function runWorkedBefore(call: string, dxccHint: number = 0) {
setWbBusy(true); setWbBusy(true);
try { try {
@@ -3853,6 +3960,16 @@ export default function App() {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'o') { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'o') {
e.preventDefault(); importAdif(); return; 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 (typing) return;
if (selectedId !== null) { if (selectedId !== null) {
if (e.key === 'Delete') { e.preventDefault(); askDeleteSelected(); return; } if (e.key === 'Delete') { e.preventDefault(); askDeleteSelected(); return; }
@@ -4106,7 +4223,8 @@ export default function App() {
); );
const nameBlock = ( const nameBlock = (
<div className="flex flex-col flex-1 min-w-[110px]"><Label className="mb-1 h-3.5">{t('field.name')}</Label> <div className="flex flex-col flex-1 min-w-[110px]"><Label className="mb-1 h-3.5">{t('field.name')}</Label>
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} /> <Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }}
onBlur={() => setName(titleCase)} />
</div> </div>
); );
// In contest mode QTH is replaced by the sent (read-only, auto) and received // In contest mode QTH is replaced by the sent (read-only, auto) and received
@@ -4125,8 +4243,9 @@ export default function App() {
</div> </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'); }} /> <Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }}
onBlur={() => setQth(titleCase)} />
</div> </div>
); );
const gridBlock = ( const gridBlock = (
@@ -4271,7 +4390,7 @@ export default function App() {
); );
const commentSm = ( const commentSm = (
<div className="flex flex-col w-40"><Label className="mb-1 h-3.5">{t('field.comment')}</Label> <div className="flex flex-col w-40"><Label className="mb-1 h-3.5">{t('field.comment')}</Label>
<Input value={comment} onChange={(e) => setComment(e.target.value)} /> <Input value={comment} onChange={(e) => setComment(e.target.value)} onBlur={() => setComment(sentenceCase)} />
</div> </div>
); );
// Inline-label variants (label to the LEFT of the control, Log4OM-style) — // Inline-label variants (label to the LEFT of the control, Log4OM-style) —
@@ -4354,7 +4473,7 @@ export default function App() {
title={catState.connected ? t('field.freqTuneHint') : undefined} title={catState.connected ? t('field.freqTuneHint') : undefined}
onFocus={() => setFreqFocused(true)} onFocus={() => setFreqFocused(true)}
onBlur={() => setFreqFocused(false)} 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); }} onChange={(e) => { setFreqMhz(e.target.value); noteManualEdit(); const b = bandForMHz(parseFloat(e.target.value)); if (b) setBand(b); }}
/> />
</div> </div>
@@ -4369,7 +4488,7 @@ export default function App() {
title={catState.connected ? t('field.freqTuneHint') : undefined} title={catState.connected ? t('field.freqTuneHint') : undefined}
onFocus={() => setFreqFocused(true)} onFocus={() => setFreqFocused(true)}
onBlur={() => setFreqFocused(false)} 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); }} 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')} className={cn('font-mono', catState.split && 'bg-danger-muted/40 border-danger-border focus:bg-card')}
/> />
@@ -4387,12 +4506,12 @@ export default function App() {
// column). No flex-1 so they stay one row tall. // column). No flex-1 so they stay one row tall.
const commentLine = ( const commentLine = (
<div className="flex flex-col"><Label className="mb-1 h-3.5">{t('field.comment')}</Label> <div className="flex flex-col"><Label className="mb-1 h-3.5">{t('field.comment')}</Label>
<Input value={comment} onChange={(e) => setComment(e.target.value)} /> <Input value={comment} onChange={(e) => setComment(e.target.value)} onBlur={() => setComment(sentenceCase)} />
</div> </div>
); );
const noteLine = ( const noteLine = (
<div className="flex flex-col"><Label className="mb-1 h-3.5">{t('field.note')}</Label> <div className="flex flex-col"><Label className="mb-1 h-3.5">{t('field.note')}</Label>
<Input value={note} onChange={(e) => setNote(e.target.value)} /> <Input value={note} onChange={(e) => setNote(e.target.value)} onBlur={() => setNote(sentenceCase)} />
</div> </div>
); );
const logButtons = ( const logButtons = (
@@ -4406,7 +4525,7 @@ export default function App() {
</Button> </Button>
)} )}
<Button type="button" size="sm" variant="outline" onClick={() => { resetEntry(); callsignRef.current?.focus(); }} className="h-8 px-2.5 text-xs" <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" /> <Eraser className="size-3.5" />
{t('btn.clear')} {t('btn.clear')}
</Button> </Button>
@@ -4451,11 +4570,14 @@ export default function App() {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz); const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
const e = spotStatus[k]; const e = spotStatus[k];
const st = (e?.status || '') as SpotStatusKey; const st = (e?.status || '') as SpotStatusKey;
// A previously-worked call counts as WORKED for filtering even when its // WORKED means "I've worked THIS callsign" — the blue WKD-CALL flag —
// entity status is still new-band/new-slot (the grid flags it WKD CALL), // NOT the entity status 'worked' (entity/band/mode already worked, which
// matching the "Hide worked" toggle. Additive: it still matches its own // the grid shows as a plain "—"). So the 'worked' key is matched ONLY via
// entity status too, so it stays visible under NEW BAND / NEW SLOT. // worked_call below; exclude the entity status 'worked' from the generic
const matches = clusterStatusFilter.has(st) // 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?.worked_call && clusterStatusFilter.has('worked'))
|| (!!e?.new_pota && clusterStatusFilter.has('new-pota')) || (!!e?.new_pota && clusterStatusFilter.has('new-pota'))
|| (!!e?.new_county && clusterStatusFilter.has('new-county')) || (!!e?.new_county && clusterStatusFilter.has('new-county'))
@@ -4580,7 +4702,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-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-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' }, { 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) => { ]).map((s) => {
const on = clusterStatusFilter.has(s.k); const on = clusterStatusFilter.has(s.k);
return ( return (
@@ -4740,6 +4865,7 @@ export default function App() {
onExportFiltered={exportFilteredADIF} onExportFiltered={exportFilteredADIF}
onDelete={(ids) => setDeletingIds(ids)} onDelete={(ids) => setDeletingIds(ids)}
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }} 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> </div>
); );
@@ -5477,9 +5603,13 @@ export default function App() {
{/* Row 2: Name fixed to the Band/Mode/Country column width (300px) so {/* 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. 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. */} 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> <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'); }} /> <Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }}
onBlur={() => setName(titleCase)} />
</div> </div>
{qthBlock} {qthBlock}
{gridBlock} {gridBlock}
@@ -5543,6 +5673,11 @@ export default function App() {
band={band} band={band}
mode={mode} mode={mode}
bands={bands} bands={bands}
onEditQso={openEdit}
{...(!callsign.trim() && selQso ? {
slotCall: selQso.call, slotBand: selQso.band, slotMode: selQso.mode,
slotWb: selWb, slotWbBusy: selWbBusy,
} : {})}
tab={detailTab} tab={detailTab}
onTab={setDetailTab} onTab={setDetailTab}
keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled} keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled}
@@ -5702,11 +5837,11 @@ export default function App() {
// exactly when its CAT backend is. Yaesu was missing from this // exactly when its CAT backend is. Yaesu was missing from this
// list, so the panel fell back to the WinKeyer status — which // list, so the panel fell back to the WinKeyer status — which
// reported disconnected, no WinKeyer being attached. // 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, connected: catState.backend === cwSource && catState.connected,
busy: false, wpm: wkWpm, version: 0, 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} : wkStatus}
ports={wkPorts} ports={wkPorts}
@@ -5914,12 +6049,29 @@ export default function App() {
<TabsContent value="recent" className="mt-0 flex flex-col min-h-0 flex-1"> <TabsContent value="recent" className="mt-0 flex flex-col min-h-0 flex-1">
<div className="flex gap-2 p-2.5 border-b border-border/60"> <div className="flex gap-2 p-2.5 border-b border-border/60">
{/* Callsign-only search: forced upper-case (a call is never
lower-case, and the operator shouldn't have to hold shift)
with an inline clear so wiping the filter is one click
instead of a select-all + delete. */}
<div className="relative flex-1">
<Input <Input
className="flex-1" className="w-full pr-8 font-mono"
placeholder="Search callsign…" placeholder="Search callsign…"
value={filterCallsign} value={filterCallsign}
onChange={(e) => setFilterCallsign(e.target.value)} onChange={(e) => setFilterCallsign(e.target.value.toUpperCase())}
/> />
{filterCallsign && (
<button
type="button"
aria-label={t('btn.clear')}
title={t('btn.clear')}
className="absolute right-1.5 top-1/2 -translate-y-1/2 inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:text-foreground hover:bg-foreground/10"
onClick={() => setFilterCallsign('')}
>
<X className="size-3.5" />
</button>
)}
</div>
<Button variant="outline" size="sm" onClick={() => refresh()}> <Button variant="outline" size="sm" onClick={() => refresh()}>
<RefreshCw className="size-3.5" /> Refresh <RefreshCw className="size-3.5" /> Refresh
</Button> </Button>
@@ -5993,6 +6145,7 @@ export default function App() {
onExportCabrilloFiltered={exportFilteredCabrillo} onExportCabrilloFiltered={exportFilteredCabrillo}
onDelete={(ids) => setDeletingIds(ids)} onDelete={(ids) => setDeletingIds(ids)}
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }} 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="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"> <div className="flex items-center gap-3">
+3 -2
View File
@@ -34,7 +34,7 @@ export type AwardDef = {
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string; url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
ref_display?: string; // grid column shows: ref | name | both ref_display?: string; // grid column shows: ref | name | both
type?: string; field: string; match_by?: string; exact_match?: boolean; one_ref_per_qso?: boolean; pattern: string; type?: string; field: string; match_by?: string; exact_match?: boolean; one_ref_per_qso?: boolean; pattern: string;
leading_str?: string; trailing_str?: string; dynamic?: boolean; leading_str?: string; trailing_str?: string; prefix?: string; dynamic?: boolean;
or_rules?: AwardOrRule[]; or_rules?: AwardOrRule[];
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[]; dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean; confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
@@ -556,9 +556,10 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]"><Checkbox checked={!!cur.exact_match} onCheckedChange={(c) => patch({ exact_match: !!c })} /> {t('awed.exactMatch')}</label> <label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]"><Checkbox checked={!!cur.exact_match} onCheckedChange={(c) => patch({ exact_match: !!c })} /> {t('awed.exactMatch')}</label>
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]" title={t('awed.oneRefHint')}><Checkbox checked={!!cur.one_ref_per_qso} onCheckedChange={(c) => patch({ one_ref_per_qso: !!c })} /> {t('awed.oneRef')}</label> <label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]" title={t('awed.oneRefHint')}><Checkbox checked={!!cur.one_ref_per_qso} onCheckedChange={(c) => patch({ one_ref_per_qso: !!c })} /> {t('awed.oneRef')}</label>
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder={t('awed.patternPlaceholder')} /></Field2> <Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder={t('awed.patternPlaceholder')} /></Field2>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-3 gap-3">
<Field2 label={t('awed.leadingString')}><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2> <Field2 label={t('awed.leadingString')}><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
<Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2> <Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
<Field2 label={t('awed.prefix')}><Input className="h-8 font-mono text-xs" value={cur.prefix ?? ''} onChange={(e) => patch({ prefix: e.target.value })} placeholder={t('awed.prefixPlaceholder')} title={t('awed.prefixTitle')} /></Field2>
</div> </div>
{/* Fallback searches: tried in order, only while nothing {/* Fallback searches: tried in order, only while nothing
+58 -11
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react'; import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react';
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs, RescanAwards } from '../../wailsjs/go/main/App'; import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs, RescanAwards, GetTrackedAwards } from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
@@ -137,13 +138,20 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
} }
} }
// Load the award list (no QSO scan), then compute only the first award. // Load the award list (no QSO scan), then compute only the first award. The
// list is narrowed to the awards the operator follows (Settings → Awards); an
// empty follow-set means "show them all" so the tab is never blank.
async function loadList() { async function loadList() {
try { try {
const defs = ((await GetAwardDefs()) ?? []) as any[]; const [defs, tracked] = await Promise.all([
const list: AwardListItem[] = defs GetAwardDefs().then((d) => (d ?? []) as any[]),
GetTrackedAwards().then((t) => (t ?? []) as string[]).catch(() => [] as string[]),
]);
const follow = new Set(tracked);
let list: AwardListItem[] = defs
.map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [], emission: d.emission ?? [] })) .map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [], emission: d.emission ?? [] }))
.sort((a, b) => a.code.localeCompare(b.code)); .sort((a, b) => a.code.localeCompare(b.code));
if (follow.size > 0) list = list.filter((a) => follow.has(a.code));
setAwardList(list); setAwardList(list);
const first = list.find((a) => a.code === selected) ?? list[0]; const first = list.find((a) => a.code === selected) ?? list[0];
if (first) compute(first.code); if (first) compute(first.code);
@@ -152,6 +160,12 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
} }
} }
useEffect(() => { loadList(); }, []); useEffect(() => { loadList(); }, []);
// Re-filter when the operator changes their followed awards in Settings.
useEffect(() => {
const off = EventsOn('awards:tracked-changed', () => { loadList(); });
return () => { off(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const current = byCode[`${selected}|${modeFilter}`]; const current = byCode[`${selected}|${modeFilter}`];
// Recompute when the mode class changes: the bands, counts and confirmations // Recompute when the mode class changes: the bands, counts and confirmations
@@ -211,11 +225,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
// Alicante" — which reference order scatters across the whole table. // Alicante" — which reference order scatters across the whole table.
// Remembered through writeUiPref rather than localStorage, so the choice // Remembered through writeUiPref rather than localStorage, so the choice
// travels with a copied data/ folder like every other UI preference here. // travels with a copied data/ folder like every other UI preference here.
const [refSort, setRefSort] = useState<'ref' | 'name'>( const [refSort, setRefSort] = useState<'ref' | 'name' | 'group'>(
() => (localStorage.getItem('opslog.awardRefSort') === 'name' ? 'name' : 'ref')); () => {
const v = localStorage.getItem('opslog.awardRefSort');
return v === 'name' || v === 'group' ? v : 'ref';
});
const [refSortDir, setRefSortDir] = useState<'asc' | 'desc'>( const [refSortDir, setRefSortDir] = useState<'asc' | 'desc'>(
() => (localStorage.getItem('opslog.awardRefSortDir') === 'desc' ? 'desc' : 'asc')); () => (localStorage.getItem('opslog.awardRefSortDir') === 'desc' ? 'desc' : 'asc'));
const toggleRefSort = (k: 'ref' | 'name') => { const toggleRefSort = (k: 'ref' | 'name' | 'group') => {
if (k === refSort) { if (k === refSort) {
const d = refSortDir === 'asc' ? 'desc' : 'asc'; const d = refSortDir === 'asc' ? 'desc' : 'asc';
setRefSortDir(d); setRefSortDir(d);
@@ -258,10 +275,26 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
if (c !== 0) return c * dir; if (c !== 0) return c * dir;
return a.ref.localeCompare(b.ref) * dir; // stable tie-break 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; return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
}); });
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]); }, [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 ( return (
<div className="flex h-full min-h-0"> <div className="flex h-full min-h-0">
{/* Award list */} {/* Award list */}
@@ -459,6 +492,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)} {refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button> </button>
</th> </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"> <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"> <button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
{t('awp.description')} {t('awp.description')}
@@ -474,9 +515,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
{filteredRefs.map((r) => ( {filteredRefs.map((r) => (
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}> <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="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"> {hasGroup && (
{r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''} <td className="py-1 pr-3 font-mono font-semibold border-b border-border/30">{r.group}</td>
</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) => { {gridBands.map((b) => {
const s = cellStatus(r, b); const s = cellStatus(r, b);
return ( return (
@@ -513,7 +555,12 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)} {refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button> </button>
</th> </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 pr-2 font-medium w-24">{t('awp.status')}</th>
<th className="py-1.5 font-medium">{t('awp.bands')}</th> <th className="py-1.5 font-medium">{t('awp.bands')}</th>
</tr> </tr>
+37 -32
View File
@@ -358,6 +358,43 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
return () => el.removeEventListener('wheel', onWheel); return () => el.removeEventListener('wheel', onWheel);
}, [range]); }, [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) { if (!range) {
return ( return (
<div className="h-full w-full flex flex-col items-center justify-center text-xs text-muted-foreground p-3 bg-muted/20"> <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); 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 currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0;
const showRigPointer = currentKHz >= lo && currentKHz <= hi; const showRigPointer = currentKHz >= lo && currentKHz <= hi;
const rigY = freqToY(currentKHz); const rigY = freqToY(currentKHz);
+138 -4
View File
@@ -1,8 +1,9 @@
import { useMemo } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Star, Radio, Sunrise, Sunset } from 'lucide-react'; import { Star, Radio, Sunrise, Sunset, X, Loader2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { sunTimes } from '@/lib/sun'; import { sunTimes } from '@/lib/sun';
import { BandSlotQSOs } from '../../wailsjs/go/main/App';
import type { WorkedBeforeView } from '@/types'; import type { WorkedBeforeView } from '@/types';
type WorkedBefore = WorkedBeforeView; type WorkedBefore = WorkedBeforeView;
@@ -18,6 +19,12 @@ interface Props {
// to an entity with no position at all, and the block simply does not appear. // to an entity with no position at all, and the block simply does not appear.
lat?: number; lat?: number;
lon?: 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;
// Open the QSO editor for a contact — makes callsigns in the cell drill-down
// clickable. Threaded down from App.openEdit.
onEditQso?: (id: number) => void;
} }
// Compact column label for a band tag: keep the classic V/U for 2m/70cm, // Compact column label for a band tag: keep the classic V/U for 2m/70cm,
@@ -89,7 +96,9 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`; 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, onEditQso }: 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 // 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. // bands they actually use), falling back to the built-in default set.
const cols = useMemo( const cols = useMemo(
@@ -208,6 +217,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
</> </>
) : hasDxcc ? ( ) : 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"> <Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
{dxccName || `DXCC #${dxcc}`} {dxccName || `DXCC #${dxcc}`}
</Badge> </Badge>
@@ -282,10 +299,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
return ( return (
<td <td
key={b.tag} 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( className={cn(
'w-[28px] h-[24px] rounded transition-colors p-0', 'w-[28px] h-[24px] rounded transition-colors p-0',
st ? STATUS_CLASSES[st] : 'bg-mx-none', 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', isCurrent && 'ring-2 ring-warning ring-inset',
)} )}
/> />
@@ -313,6 +334,119 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
))} ))}
</div> </div>
</div> </div>
{slot && (
<SlotQSOModal
call={wb?.callsign ?? ''}
dxcc={dxcc}
entity={dxccName}
band={slot.band}
cls={slot.cls}
onClose={() => setSlot(null)}
onEdit={onEditQso}
/>
)}
</section> </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, onEdit }: {
call: string; dxcc: number; entity: string; band: string; cls: string; onClose: () => void; onEdit?: (id: number) => 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/60 backdrop-blur-[2px] p-4" onClick={onClose}>
<div className="bg-card border border-border rounded-xl shadow-2xl w-[760px] max-w-full max-h-[72vh] flex flex-col overflow-hidden ring-1 ring-black/5"
onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2.5 px-4 py-2.5 border-b border-border bg-muted/30">
<span className="inline-flex items-center justify-center h-6 min-w-6 px-1.5 rounded-md bg-primary/15 text-primary text-[11px] font-bold shrink-0 tabular-nums">{band}</span>
<div className="min-w-0">
<div className="font-semibold text-sm leading-tight truncate">
{cls}{entity && <span className="text-muted-foreground font-normal"> · {entity}</span>}
</div>
{onEdit && <div className="text-[10px] text-muted-foreground leading-tight">Click a callsign to edit the QSO</div>}
</div>
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">{rows ? `${rows.length} QSO${rows.length > 1 ? 's' : ''}` : ''}</span>
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground shrink-0 rounded p-0.5 hover:bg-muted transition-colors">
<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 border-collapse">
<thead className="sticky top-0 z-10 bg-muted/80 backdrop-blur text-[10px] uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left font-medium px-3 py-1.5">Date UTC</th>
<th className="text-left font-medium px-3 py-1.5">Callsign</th>
<th className="text-left font-medium px-3 py-1.5">Mode</th>
<th className="text-left font-medium px-3 py-1.5">Freq</th>
<th className="text-left font-medium px-3 py-1.5">Name</th>
<th className="text-center font-medium px-3 py-1.5">Cfm</th>
</tr>
</thead>
<tbody>
{rows.map((q, i) => {
const cfm = q.lotw_rcvd === 'Y' || q.eqsl_rcvd === 'Y' || q.qsl_rcvd === 'Y';
const mine = q.callsign === call;
return (
<tr key={q.id ?? i} className="border-t border-border/40 even:bg-muted/[0.06] hover:bg-primary/[0.06] transition-colors">
<td className="px-3 py-1.5 tabular-nums text-muted-foreground whitespace-nowrap">
{String(q.qso_date ?? '').slice(0, 16).replace('T', ' ')}
</td>
<td className="px-3 py-1.5">
{onEdit ? (
<button type="button" onClick={() => { onEdit(q.id as number); onClose(); }} title="Edit this QSO"
className={cn('font-mono cursor-pointer text-left hover:underline underline-offset-2', mine ? 'font-bold text-primary' : 'text-foreground hover:text-primary')}>
{q.callsign}
</button>
) : (
<span className={cn('font-mono', mine && 'font-bold text-primary')}>{q.callsign}</span>
)}
</td>
<td className="px-3 py-1.5">{q.mode}</td>
<td className="px-3 py-1.5 tabular-nums text-muted-foreground">{q.freq_hz ? (q.freq_hz / 1e6).toFixed(3) : ''}</td>
<td className="px-3 py-1.5 text-muted-foreground truncate max-w-[180px]">{q.name}</td>
<td className="px-3 py-1.5 text-center">{cfm ? <span className="text-success"></span> : <span className="text-muted-foreground/30">·</span>}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
</div>
);
}
+31 -3
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[] => [ const makeColCatalog = (t: TFn): ColEntry[] => [
{ {
group: 'Spot', label: t('clg2.c.time'), colId: 'time', group: 'Spot', label: t('clg2.c.time'), colId: 'time',
@@ -397,6 +410,11 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
const defaultColDef = useMemo<ColDef>(() => ({ const defaultColDef = useMemo<ColDef>(() => ({
sortable: true, resizable: true, filter: true, suppressMovable: false, sortable: true, resizable: true, filter: true, suppressMovable: false,
// Dim "represents nothing" spots at the CELL level, via a class that
// refreshCells re-applies. Doing it with a row STYLE needed redrawRows() on
// every status update, which re-rendered the whole grid continuously and
// pegged the CPU on a busy cluster (the 0.23.6 slowdown).
cellClassRules: { 'opacity-40': (p: any) => isDull(statusFor(p)) },
}), []); }), []);
// Pass spotStatus through AG Grid's context so cell renderers can look up // Pass spotStatus through AG Grid's context so cell renderers can look up
@@ -406,12 +424,22 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
const context = useMemo(() => ({ spotStatus }), [spotStatus]); const context = useMemo(() => ({ spotStatus }), [spotStatus]);
// Spot statuses arrive asynchronously (~after the rows render). The Call/Band/ // Spot statuses arrive asynchronously (~after the rows render). The Call/Band/
// Mode cellStyles depend on them but their cell VALUE doesn't change, so ag-grid // Mode cellStyles and the dimmed "represents nothing" class depend on them but
// won't re-render those cells on its own — force a refresh so e.g. a worked call // the cell VALUE doesn't change, so ag-grid won't re-render on its own — force a
// turns blue once its status loads. // refresh so e.g. a worked call turns blue once its status loads.
//
// THROTTLED: under an RBN firehose spotStatus updates ~20×/second, and firing a
// full refreshCells that often is pure churn on a slow PC. Coalesce the bursts
// into one refresh every 200 ms (still imperceptible) instead of one per update.
const refreshPending = useRef<number | undefined>(undefined);
useEffect(() => { useEffect(() => {
if (refreshPending.current !== undefined) return; // a refresh is already queued
refreshPending.current = window.setTimeout(() => {
refreshPending.current = undefined;
gridRef.current?.api?.refreshCells({ force: true }); gridRef.current?.api?.refreshCells({ force: true });
}, 200);
}, [spotStatus]); }, [spotStatus]);
useEffect(() => () => { if (refreshPending.current !== undefined) window.clearTimeout(refreshPending.current); }, []);
// Restore AFTER the profile scope is known — this grid has no key= remount to // Restore AFTER the profile scope is known — this grid has no key= remount to
// save it from reading the wrong (unscoped) cache key at first paint. // save it from reading the wrong (unscoped) cache key at first paint.
+24 -6
View File
@@ -69,6 +69,14 @@ interface Props {
band: string; band: string;
mode: string; mode: string;
bands?: string[]; // configured bands for the worked-before matrix columns 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; imageUrl?: string;
onOpenImage?: () => void; onOpenImage?: () => void;
// Optional controlled active tab (so the app can switch it via keyboard). // Optional controlled active tab (so the app can switch it via keyboard).
@@ -77,6 +85,8 @@ interface Props {
// When the WinKeyer is active, F1-F12 fire macros, so the tab shortcut is // When the WinKeyer is active, F1-F12 fire macros, so the tab shortcut is
// shown as Ctrl+F1…F5 instead of F1…F5. // shown as Ctrl+F1…F5 instead of F1…F5.
keyerActive?: boolean; keyerActive?: boolean;
// Open the QSO editor — makes callsigns clickable in the band-slot drill-down.
onEditQso?: (id: number) => void;
} }
export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended'; export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
@@ -139,7 +149,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, onEditQso }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const [internalOpen, setInternalOpen] = useState<TabName>('stats'); const [internalOpen, setInternalOpen] = useState<TabName>('stats');
const open = tab ?? internalOpen; // controlled when `tab` is provided const open = tab ?? internalOpen; // controlled when `tab` is provided
@@ -272,7 +282,15 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}> <div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
{open === 'stats' && ( {open === 'stats' && (
<div className="px-3 py-2.5"> <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}
onEditQso={onEditQso}
lat={dxLL?.lat} lon={dxLL?.lon} /> lat={dxLL?.lat} lon={dxLL?.lon} />
</div> </div>
)} )}
@@ -447,18 +465,18 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
</Select> </Select>
</Field> </Field>
<Field label={t('detp.rig')} span={3}> <Field label={t('detp.rig')} span={3}>
<Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} /> <Input value={details.my_rig} onChange={(e) => onChange({ my_rig: e.target.value })} />
</Field> </Field>
<Field label={t('detp.antenna')} span={3}> <Field label={t('detp.antenna')} span={3}>
<Input value={details.my_antenna} placeholder="UB640" onChange={(e) => onChange({ my_antenna: e.target.value })} /> <Input value={details.my_antenna} onChange={(e) => onChange({ my_antenna: e.target.value })} />
</Field> </Field>
{satelliteMode && ( {satelliteMode && (
<> <>
<Field label={t('detp.satName')} span={3}> <Field label={t('detp.satName')} span={3}>
<Input value={details.sat_name} placeholder="AO-91" onChange={(e) => onChange({ sat_name: e.target.value })} /> <Input value={details.sat_name} onChange={(e) => onChange({ sat_name: e.target.value })} />
</Field> </Field>
<Field label={t('detp.satelliteMode')} span={3}> <Field label={t('detp.satelliteMode')} span={3}>
<Input value={details.sat_mode} placeholder="U/V" onChange={(e) => onChange({ sat_mode: e.target.value })} /> <Input value={details.sat_mode} onChange={(e) => onChange({ sat_mode: e.target.value })} />
</Field> </Field>
</> </>
)} )}
+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 // 256 KB blob: with "cluster" or "acom" typed in, the window becomes exactly
// the subsystem trace you would have grepped for. // the subsystem trace you would have grepped for.
// • It polls only while open. A closed dialog costs nothing. // • 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; const POLL_MS = 1000;
export function LogViewer({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) { 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, Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { cn } from '@/lib/utils'; 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 { Input } from '@/components/ui/input';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { EventsOn } from '../../wailsjs/runtime/runtime'; 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; } if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall('')); UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
}, [service]); }, [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 [potaSyncing, setPotaSyncing] = useState(false);
const [potaRes, setPotaRes] = useState<POTASync | null>(null); const [potaRes, setPotaRes] = useState<POTASync | null>(null);
const [potaErr, setPotaErr] = useState(''); const [potaErr, setPotaErr] = useState('');
+54 -6
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react'; import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App'; import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings, OpenExternalURL, SetOpsLogQSLReceived } from '../../wailsjs/go/main/App';
import { rstOptions, type RSTLists } from '@/lib/rst'; import { rstOptions, type RSTLists } from '@/lib/rst';
import { AwardRefSelector } from '@/components/AwardRefSelector'; import { AwardRefSelector } from '@/components/AwardRefSelector';
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor'; import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
@@ -22,6 +22,7 @@ import { Combobox } from '@/components/ui/combobox';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { flagURL } from '@/lib/flags'; import { flagURL } from '@/lib/flags';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { titleCase, sentenceCase } from '@/lib/textCase';
import type { QSOForm } from '@/types'; import type { QSOForm } from '@/types';
type QSO = QSOForm; type QSO = QSOForm;
@@ -428,6 +429,22 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
[draft.extras], [draft.extras],
); );
// OpsLog QSL "received" marker (ADIF extra). Drives the card's PSE/TNX stamp:
// received → TNX (thanks for your card), otherwise PSE QSL (please send one).
// Saved with the normal Save via draft.extras.
const OPSLOG_QSL_RCVD = 'APP_OPSLOG_QSL_RCVD';
const qslReceived = !!String(draft.extras?.[OPSLOG_QSL_RCVD] ?? '').trim();
const toggleQslReceived = (on: boolean) => {
// Reflect immediately in the draft (for the PSE/TNX indicator)…
const next = { ...(draft.extras ?? {}) };
if (on) next[OPSLOG_QSL_RCVD] = new Date().toISOString();
else delete next[OPSLOG_QSL_RCVD];
set('extras', next as any);
// …and persist right away with a targeted write that reliably sets OR clears
// the key (the modal's extras-merge on Save wouldn't clear a removed key).
if ((draft as any).id) SetOpsLogQSLReceived((draft as any).id, on).catch(() => {});
};
return ( return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}> <Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0"> <DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
@@ -466,7 +483,22 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
{/* Top: Callsign + RST + Fetch */} {/* Top: Callsign + RST + Fetch */}
<div className="flex items-end gap-2 mb-3"> <div className="flex items-end gap-2 mb-3">
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0">
<div className="flex items-center">
<Label>{t('qedit.callsign')}</Label> <Label>{t('qedit.callsign')}</Label>
{(draft.callsign ?? '').trim() && (
<button
type="button"
title={t('qrz.openTitle', { call: (draft.callsign ?? '').trim().toUpperCase() })}
onClick={() => {
const c = (draft.callsign ?? '').trim().toUpperCase().split('/').map(encodeURIComponent).join('/');
if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch((e: any) => setLocalErr(String(e?.message ?? e)));
}}
className="ml-auto shrink-0 inline-flex items-center gap-0.5 text-[9px] font-semibold normal-case tracking-wider text-primary hover:underline"
>
QRZ
</button>
)}
</div>
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-10" <Input className="font-mono text-lg font-bold tracking-wider uppercase h-10"
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} /> value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
</div> </div>
@@ -483,7 +515,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5"> <div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
{/* ── Left column ── */} {/* ── Left column ── */}
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
<div><Label>{t('qedit.name')}</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></div> <div><Label>{t('qedit.name')}</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)}
onBlur={() => set('name', titleCase(draft.name ?? '') as any)} /></div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="w-20 shrink-0">{t('qedit.band')}</Label> <Label className="w-20 shrink-0">{t('qedit.band')}</Label>
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}> <Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
@@ -549,8 +582,10 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<div className="flex flex-col flex-1"><Label>{t('qedit.grid')}</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div> <div className="flex flex-col flex-1"><Label>{t('qedit.grid')}</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
<div className="flex flex-col w-24"><Label>PFX</Label><Input readOnly value={pfxOf(draft.callsign ?? '')} className="font-mono bg-muted/40" /></div> <div className="flex flex-col w-24"><Label>PFX</Label><Input readOnly value={pfxOf(draft.callsign ?? '')} className="font-mono bg-muted/40" /></div>
</div> </div>
<div><Label>{t('qedit.comment')}</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></div> <div><Label>{t('qedit.comment')}</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)}
<div><Label>{t('qedit.note')}</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></div> onBlur={() => set('comment', sentenceCase(draft.comment ?? '') as any)} /></div>
<div><Label>{t('qedit.note')}</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)}
onBlur={() => set('notes', sentenceCase(draft.notes ?? '') as any)} /></div>
</div> </div>
</div> </div>
</TabsContent> </TabsContent>
@@ -571,7 +606,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></div> <div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)}
onBlur={() => set('qth', titleCase(draft.qth ?? '') as any)} /></div>
<div><Label>{t('qedit.address')}</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div> <div><Label>{t('qedit.address')}</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
</div> </div>
{/* Right column */} {/* Right column */}
@@ -581,7 +617,19 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<div className="flex flex-col flex-1"><Label>Lat</Label><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} className="font-mono" /></div> <div className="flex flex-col flex-1"><Label>Lat</Label><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
<div className="flex flex-col flex-1"><Label>Lon</Label><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} className="font-mono" /></div> <div className="flex flex-col flex-1"><Label>Lon</Label><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
</div> </div>
<div><Label>{t('qedit.qslMsg')}</Label><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></div> <div>
<Label>{t('qedit.qslMsg')}</Label>
<Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} />
<div className="mt-1 flex items-center gap-2">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={qslReceived} onCheckedChange={(c) => toggleQslReceived(!!c)} />
{t('qedit.qslReceived')}
</label>
<span className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground" title={t('qedit.pseTnxHint')}>
{qslReceived ? 'TNX' : 'PSE QSL'}
</span>
</div>
</div>
<div><Label>{t('qedit.qslVia')}</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></div> <div><Label>{t('qedit.qslVia')}</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></div>
</div> </div>
</div> </div>
+28 -11
View File
@@ -56,6 +56,10 @@ type Props = {
onRowDoubleClicked?: (q: QSOForm) => void; onRowDoubleClicked?: (q: QSOForm) => void;
onRowClicked?: (q: QSOForm) => void; onRowClicked?: (q: QSOForm) => void;
onRowSelected?: (ids: number[]) => 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; onUpdateFromCty?: (ids: number[]) => void;
onUpdateFromQRZ?: (ids: number[]) => void; onUpdateFromQRZ?: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void; onUpdateFromClublog?: (ids: number[]) => void;
@@ -191,6 +195,8 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc. // App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true }, { group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
// App-specific: operator marked a QSL as RECEIVED for this QSO (drives PSE/TNX).
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_rcvd'), colId: 'opslog_qsl_card_rcvd', headerName: t('rqg.c.opslog_qsl_card_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_QSL_RCVD'] ? 'Y' : 'N'; }, defaultVisible: false },
// App-specific: when the QSO's audio recording was e-mailed to the station. // App-specific: when the QSO's audio recording was e-mailed to the station.
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false }, { group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
@@ -276,14 +282,24 @@ const GRP_KEYS: Record<string, string> = {
}; };
export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g); export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
// Award columns are governed SOLELY by the awardShown code-set, never by AG // Award-column VISIBILITY is governed SOLELY by the awardShown code-set, never
// Grid's saved column state. Stripping them here (on both save and restore) // by AG Grid's saved column state: dropping `hide` here (on both save and
// stops a stale saved state from re-hiding a shown award column on every // restore) stops a stale saved state from re-hiding a shown award column on
// awardCols rebuild — the desync that made award columns vanish mid-session. // every awardCols rebuild — the desync that made award columns vanish
const stripAwardCols = (st: any[] | null | undefined): any[] => // mid-session.
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_')); //
// Their POSITION, however, has to stay in the state. Filtering the entries out
// entirely left applyColumnState({applyOrder:true}) with no place for them, and
// AG Grid appends what the state doesn't mention — so a column the operator
// dragged to sit AFTER an award column jumped back in front of it on reload.
const sanitizeAwardCols = (st: any[] | null | undefined): any[] =>
(st ?? []).map((s) => {
if (!String(s?.colId ?? '').startsWith('award_')) return s;
const { hide: _hide, ...rest } = s as any;
return rest;
});
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 { t } = useI18n();
const gridRef = useRef<any>(null); const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
@@ -464,12 +480,12 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
function onGridReady(e: GridReadyEvent) { function onGridReady(e: GridReadyEvent) {
onGridApi?.(e.api); onGridApi?.(e.api);
const local = loadLocal(colStateKey); const local = loadLocal(colStateKey);
if (local) e.api.applyColumnState({ state: sanitizeState(stripAwardCols(local)) as ColumnState[], applyOrder: true }); if (local) e.api.applyColumnState({ state: sanitizeState(sanitizeAwardCols(local)) as ColumnState[], applyOrder: true });
// Fall back to the portable DB copy when the local cache is empty // Fall back to the portable DB copy when the local cache is empty
// (fresh machine / after a reinstall), then re-seed the cache. // (fresh machine / after a reinstall), then re-seed the cache.
loadRemote(colStateKey).then((remote) => { loadRemote(colStateKey).then((remote) => {
if (remote && !local) { if (remote && !local) {
e.api.applyColumnState({ state: sanitizeState(stripAwardCols(remote)) as ColumnState[], applyOrder: true }); e.api.applyColumnState({ state: sanitizeState(sanitizeAwardCols(remote)) as ColumnState[], applyOrder: true });
seedLocal(colStateKey, remote); seedLocal(colStateKey, remote);
} }
}); });
@@ -493,7 +509,7 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
if (restoringRef.current) return; // ignore the events fired by a column rebuild if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState(); const state = gridRef.current?.api?.getColumnState();
if (!state) return; if (!state) return;
saveState(colStateKey, stripAwardCols(state)); saveState(colStateKey, sanitizeAwardCols(state));
// Award columns are stripped above, so persist their widths on the side. // Award columns are stripped above, so persist their widths on the side.
let changed = false; let changed = false;
for (const s of state) { for (const s of state) {
@@ -521,7 +537,7 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
useEffect(() => { useEffect(() => {
const api = gridRef.current?.api; const api = gridRef.current?.api;
const local = loadLocal(colStateKey); const local = loadLocal(colStateKey);
if (api && local) api.applyColumnState({ state: sanitizeState(stripAwardCols(local)) as ColumnState[], applyOrder: true }); if (api && local) api.applyColumnState({ state: sanitizeState(sanitizeAwardCols(local)) as ColumnState[], applyOrder: true });
// Re-enable saving once AG Grid has settled the column events from the rebuild. // Re-enable saving once AG Grid has settled the column events from the rebuild.
const t = window.setTimeout(() => { restoringRef.current = false; }, 0); const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t); return () => window.clearTimeout(t);
@@ -542,6 +558,7 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
setSelCount(sel.length); setSelCount(sel.length);
setDispCount(api?.getDisplayedRowCount?.() ?? 0); setDispCount(api?.getDisplayedRowCount?.() ?? 0);
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null)); 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). // Select every row when the caller bumps selectAllSignal (QSL Manager search).
useEffect(() => { useEffect(() => {
+263 -49
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2, ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight, ChevronDown, ChevronRight,
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon, User, Database, Radio, Cog, Server, Antenna as AntennaIcon,
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Pencil, Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Pencil,
} from 'lucide-react'; } from 'lucide-react';
import { import {
@@ -26,7 +26,7 @@ import {
AudioStartMonitor, AudioStopMonitor, AudioMonitorActive, AudioStartMonitor, AudioStopMonitor, AudioMonitorActive,
AudioStartTX, AudioStopTX, AudioTXActive, AudioStartTX, AudioStopTX, AudioTXActive,
ListClusterServers, SaveClusterServer, DeleteClusterServer, ListClusterServers, SaveClusterServer, DeleteClusterServer,
GetClusterAutoConnect, SetClusterAutoConnect, GetClusterAutoConnect, SetClusterAutoConnect, GetSelfSpotSettings, SaveSelfSpotSettings,
ConnectClusterServer, DisconnectClusterServer, ConnectClusterServer, DisconnectClusterServer,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder, GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
@@ -50,6 +50,7 @@ import {
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, GetFlexBandPower, SaveFlexBandPower, GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, GetFlexBandPower, SaveFlexBandPower,
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile, GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
GetRelayAuto, SaveRelayAuto, GetStationDevices, GetRelayAuto, SaveRelayAuto, GetStationDevices,
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models'; import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -254,6 +255,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
{ kind: 'item', label: t('sec.profiles'), id: 'profiles' }, { kind: 'item', label: t('sec.profiles'), id: 'profiles' },
{ kind: 'item', label: t('sec.operating'), id: 'operating' }, { kind: 'item', label: t('sec.operating'), id: 'operating' },
{ kind: 'item', label: t('sec.confirmations'), id: 'confirmations' }, { kind: 'item', label: t('sec.confirmations'), id: 'confirmations' },
{ kind: 'item', label: t('sec.awards'), id: 'awards' },
{ kind: 'item', label: t('sec.external'), id: 'external-services' }, { kind: 'item', label: t('sec.external'), id: 'external-services' },
], ],
}, },
@@ -673,6 +675,9 @@ type AmpUI = { id: string; name: string; enabled: boolean; type: string; transpo
type RelayRuleUI = { device_id: string; relay: number; mode: string; freq_lo_khz: number; freq_hi_khz: number; bands: string[] }; type RelayRuleUI = { device_id: string; relay: number; mode: string; freq_lo_khz: number; freq_hi_khz: number; bands: string[] };
type StationDevUI = { id: string; type: string; name: string; labels: string[] }; type StationDevUI = { id: string; type: string; name: string; labels: string[] };
const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm']; const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];
// Bands a motorized HF/6 m antenna (Ultrabeam / SteppIR) can cover — the follow
// filter is a subset of these. Must match motorBands in app.go, low → high.
const MOTOR_BANDS = ['40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'];
const relayCountUI = (type: string) => (type === 'kmtronic' || type === 'denkovi' ? 8 : 5); const relayCountUI = (type: string) => (type === 'kmtronic' || type === 'denkovi' ? 8 : 5);
// Live status + OPERATE/STANDBY toggle for ONE configured amplifier (by config // Live status + OPERATE/STANDBY toggle for ONE configured amplifier (by config
@@ -941,6 +946,91 @@ function FlexDiscover({ onPick }: { onPick: (ip: string, port: number) => void }
); );
} }
// AwardsSelectionPanel is a two-column transfer list: every defined award on the
// left, the ones the operator follows on the right. The Awards tab shows only the
// followed set (empty = all). Per-profile, saved immediately (local SQLite).
function AwardsSelectionPanel({ profile }: { profile?: { name?: string; callsign?: string } }) {
const { t } = useI18n();
const [all, setAll] = useState<{ code: string; name: string }[]>([]);
const [tracked, setTracked] = useState<string[]>([]);
const [err, setErr] = useState('');
const [q, setQ] = useState('');
useEffect(() => {
(async () => {
try {
const [defs, tr] = await Promise.all([
GetAwardDefs().then((d) => (d ?? []) as any[]),
GetTrackedAwards().then((v) => (v ?? []) as string[]).catch(() => [] as string[]),
]);
setAll(defs.map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code)));
setTracked(tr);
} catch (e: any) { setErr(String(e?.message ?? e)); }
})();
}, []);
async function persist(next: string[]) {
setTracked(next);
try { await SaveTrackedAwards(next); } catch (e: any) { setErr(String(e?.message ?? e)); }
}
const trackedSet = new Set(tracked);
const byCode = new Map(all.map((a) => [a.code, a] as const));
const needle = q.trim().toLowerCase();
const available = all.filter((a) => !trackedSet.has(a.code)
&& (needle === '' || `${a.code} ${a.name}`.toLowerCase().includes(needle)));
const trackedItems = (tracked.map((c) => byCode.get(c)).filter(Boolean) as { code: string; name: string }[])
.sort((a, b) => a.code.localeCompare(b.code));
return (
<div>
<SectionHeader title={t('sec.awards')} hint={t('awards.followHint')} />
<ProfileScopeNote profile={profile} />
{err && <div className="mb-2 text-xs text-destructive">{err}</div>}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border border-border bg-card/40 flex flex-col">
<div className="flex items-center justify-between px-3 py-2 border-b border-border/60">
<span className="text-sm font-medium">{t('awards.available')} <span className="text-muted-foreground">({available.length})</span></span>
<button type="button" className="text-xs text-primary hover:underline disabled:opacity-40"
disabled={available.length === 0} onClick={() => persist(all.map((a) => a.code))}>{t('awards.addAll')}</button>
</div>
<div className="p-2 border-b border-border/60">
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder={t('awards.search')} className="h-8" />
</div>
<div className="max-h-[340px] overflow-y-auto p-1.5 space-y-0.5">
{available.map((a) => (
<button key={a.code} type="button" onClick={() => persist([...tracked, a.code])}
className="group w-full flex items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-primary/10">
<span className="font-mono text-xs shrink-0">{a.code}</span>
<span className="text-xs text-muted-foreground truncate flex-1">{a.name}</span>
<span className="text-primary opacity-0 group-hover:opacity-100"></span>
</button>
))}
{available.length === 0 && <div className="p-2 text-xs text-muted-foreground">{t('awards.allTracked')}</div>}
</div>
</div>
<div className="rounded-lg border border-primary/40 bg-primary/5 flex flex-col">
<div className="flex items-center justify-between px-3 py-2 border-b border-border/60">
<span className="text-sm font-medium">{t('awards.followed')} <span className="text-muted-foreground">({tracked.length})</span></span>
<button type="button" className="text-xs text-primary hover:underline disabled:opacity-40"
disabled={tracked.length === 0} onClick={() => persist([])}>{t('awards.clear')}</button>
</div>
<div className="max-h-[392px] overflow-y-auto p-1.5 space-y-0.5">
{trackedItems.map((a) => (
<button key={a.code} type="button" onClick={() => persist(tracked.filter((c) => c !== a.code))}
className="group w-full flex items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-primary/10">
<span className="text-muted-foreground opacity-0 group-hover:opacity-100"></span>
<span className="font-mono text-xs shrink-0">{a.code}</span>
<span className="text-xs text-muted-foreground truncate flex-1">{a.name}</span>
</button>
))}
{tracked.length === 0 && <div className="p-2 text-xs text-muted-foreground">{t('awards.noneFollowed')}</div>}
</div>
</div>
</div>
</div>
);
}
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) { function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
const label = SECTION_LABELS[id] ?? id; const label = SECTION_LABELS[id] ?? id;
const IconCmp = Icon ?? Construction; const IconCmp = Icon ?? Construction;
@@ -1136,7 +1226,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [modeDraft, setModeDraft] = useState(''); const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({ const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
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, icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8', share_enabled: false, share_port: 4532, digital_default: 'FT8', share_enabled: false, share_port: 4532,
@@ -1149,8 +1239,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null); const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
// Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings. // Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings.
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; tx_inhibit: boolean; freq_min_mhz: number; freq_max_mhz: number }>({ const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; tx_inhibit: boolean; bands: string[]; freq_min_mhz: number; freq_max_mhz: number }>({
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, tx_inhibit: false, freq_min_mhz: 13, freq_max_mhz: 54, enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, tx_inhibit: false, bands: [], freq_min_mhz: 13, freq_max_mhz: 54,
}); });
const [ubTesting, setUbTesting] = useState(false); const [ubTesting, setUbTesting] = useState(false);
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null); const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
@@ -1246,6 +1336,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1'); const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1'); const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '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 [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1'); const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
// Password-encryption (secret vault) state. // Password-encryption (secret vault) state.
@@ -1282,8 +1373,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [emailMsg, setEmailMsg] = useState(''); const [emailMsg, setEmailMsg] = useState('');
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch })); const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
// eQSL card e-mail (subject/body templates + auto-send on log). // eQSL card e-mail (subject/body templates + auto-send on log).
type EQSLCfg = { subject: string; body: string; auto_send: boolean }; type EQSLCfg = { subject: string; body: string; auto_send: boolean; default_message: string };
const [eqslCfg, setEqslCfg] = useState<EQSLCfg>({ subject: '', body: '', auto_send: false }); const [eqslCfg, setEqslCfg] = useState<EQSLCfg>({ subject: '', body: '', auto_send: false, default_message: '' });
const setEqslField = (patch: Partial<EQSLCfg>) => setEqslCfg((s) => ({ ...s, ...patch })); const setEqslField = (patch: Partial<EQSLCfg>) => setEqslCfg((s) => ({ ...s, ...patch }));
// ClubLog Country File (cty.xml) exception status. // ClubLog Country File (cty.xml) exception status.
type ClubInfo = { enabled: boolean; loaded: boolean; date: string; count: number }; type ClubInfo = { enabled: boolean; loaded: boolean; date: string; count: number };
@@ -1433,19 +1524,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]); const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
const [clusterAutoConnect, setClusterAutoConnectState] = useState(false); const [clusterAutoConnect, setClusterAutoConnectState] = useState(false);
// Self-spot. SELF_SPOT_MIN_MIN mirrors the backend floor — the input clamps on
// blur, not per keystroke, or typing "10" would be rewritten to "5" the moment
// the "1" landed and the field would fight the operator.
const SELF_SPOT_MIN_MIN = 5;
const [selfSpot, setSelfSpot] = useState({ enabled: false, minutes: SELF_SPOT_MIN_MIN });
const [selfSpotText, setSelfSpotText] = useState(String(SELF_SPOT_MIN_MIN));
const [clusterStatuses, setClusterStatuses] = useState<ClusterServerStatus[]>([]); const [clusterStatuses, setClusterStatuses] = useState<ClusterServerStatus[]>([]);
const [editingServer, setEditingServer] = useState<ClusterServer | null>(null); const [editingServer, setEditingServer] = useState<ClusterServer | null>(null);
async function reloadClusterServers() { async function reloadClusterServers() {
try { try {
const [list, ac, st] = await Promise.all([ const [list, ac, st, ss] = await Promise.all([
ListClusterServers(), ListClusterServers(),
GetClusterAutoConnect(), GetClusterAutoConnect(),
GetClusterStatus(), GetClusterStatus(),
GetSelfSpotSettings(),
]); ]);
setClusterServers((list ?? []) as ClusterServer[]); setClusterServers((list ?? []) as ClusterServer[]);
setClusterAutoConnectState(ac); setClusterAutoConnectState(ac);
setClusterStatuses((st ?? []) as ClusterServerStatus[]); setClusterStatuses((st ?? []) as ClusterServerStatus[]);
setSelfSpot(ss as any);
setSelfSpotText(String((ss as any).minutes ?? SELF_SPOT_MIN_MIN));
} catch (e: any) { setErr(String(e?.message ?? e)); } } catch (e: any) { setErr(String(e?.message ?? e)); }
} }
@@ -1758,6 +1858,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
await SaveQSLDefaults(qslDefaults as any); await SaveQSLDefaults(qslDefaults as any);
await SaveExternalServices(extSvc as any); await SaveExternalServices(extSvc as any);
await SetClusterAutoConnect(clusterAutoConnect); await SetClusterAutoConnect(clusterAutoConnect);
await SaveSelfSpotSettings(selfSpot as any);
setMsg('Settings saved.'); setMsg('Settings saved.');
onSaved(); onSaved();
@@ -2436,6 +2537,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SelectItem value="flex">{t('cat.optFlex')}</SelectItem> <SelectItem value="flex">{t('cat.optFlex')}</SelectItem>
<SelectItem value="yaesu">{t('cat.optYaesu')}</SelectItem> <SelectItem value="yaesu">{t('cat.optYaesu')}</SelectItem>
<SelectItem value="kenwood">{t('cat.optKenwood')}</SelectItem> <SelectItem value="kenwood">{t('cat.optKenwood')}</SelectItem>
<SelectItem value="elecraft">{t('cat.optElecraft')}</SelectItem>
<SelectItem value="xiegu">{t('cat.optXiegu')}</SelectItem> <SelectItem value="xiegu">{t('cat.optXiegu')}</SelectItem>
<SelectItem value="icom">{t('cat.optIcom')}</SelectItem> <SelectItem value="icom">{t('cat.optIcom')}</SelectItem>
<SelectItem value="icom-net">{t('cat.optIcomNet')}</SelectItem> <SelectItem value="icom-net">{t('cat.optIcomNet')}</SelectItem>
@@ -2595,11 +2697,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={civTrace} onCheckedChange={(c) => { setCivTrace(!!c); SetCIVTrace(!!c); }} /> <Checkbox checked={civTrace} onCheckedChange={(c) => { setCivTrace(!!c); SetCIVTrace(!!c); }} />
{t('cat.civTrace')} {t('cat.civTrace')}
</label> </label> </div>
<p className="text-[10px] text-muted-foreground mt-1">{t('cat.civTraceHint')}</p>
</div>
)} )}
{catCfg.backend === 'kenwood' && ( {(catCfg.backend === 'kenwood' || catCfg.backend === 'elecraft') && (
<> <>
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('cat.kenwoodPort')}</Label> <Label>{t('cat.kenwoodPort')}</Label>
@@ -2613,9 +2713,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</Select> </Select>
<Button type="button" variant="outline" size="sm" <Button type="button" variant="outline" size="sm"
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}></Button> onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}></Button>
</div> </div> </div>
<span className="text-xs text-muted-foreground">{t('cat.kenwoodPortHint')}</span>
</div>
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('cat.baud')}</Label> <Label>{t('cat.baud')}</Label>
<Select value={String(catCfg.kenwood_baud || 9600)} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_baud: parseInt(v) || 9600 }))}> <Select value={String(catCfg.kenwood_baud || 9600)} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_baud: parseInt(v) || 9600 }))}>
@@ -2623,18 +2721,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SelectContent> <SelectContent>
{[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)} {[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)}
</SelectContent> </SelectContent>
</Select> </Select> </div>
<span className="text-xs text-muted-foreground">{t('cat.kenwoodBaudHint')}</span>
</div>
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('cat.kenwoodHost')}</Label> <Label>{t('cat.kenwoodHost')}</Label>
<Input <Input
value={catCfg.kenwood_host || ''} value={catCfg.kenwood_host || ''}
placeholder="192.168.1.50:4532" placeholder="192.168.1.50:4532"
onChange={(e) => setCatCfg((s) => ({ ...s, kenwood_host: e.target.value }))} onChange={(e) => setCatCfg((s) => ({ ...s, kenwood_host: e.target.value }))}
/> /> </div>
<span className="text-xs text-muted-foreground">{t('cat.kenwoodHostHint')}</span>
</div>
<div className="space-y-1"> <div className="space-y-1">
<label className="flex items-center gap-2 text-xs cursor-pointer"> <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 }))} /> <Checkbox checked={!!catCfg.kenwood_low_lines} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, kenwood_low_lines: !!c }))} />
@@ -2642,6 +2736,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</label> </label>
<span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span> <span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
</div> </div>
{catCfg.backend === 'kenwood' ? (
<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>
) : (
<span className="text-xs text-muted-foreground">{t('cat.elecraftHint')}</span>
)}
</> </>
)} )}
{catCfg.backend === 'icom' && ( {catCfg.backend === 'icom' && (
@@ -2855,9 +2964,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, ptt_hotkey_toggle: !!c } as any))} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, ptt_hotkey_toggle: !!c } as any))}
/> />
{t('cat.pttKeyToggle')} {t('cat.pttKeyToggle')}
</label> </label> </>
<p className="text-[11px] text-muted-foreground">{t('cat.pttKeyHint')}</p>
</>
)} )}
</div> </div>
{catCfg.backend === 'omnirig' && ( {catCfg.backend === 'omnirig' && (
@@ -3011,20 +3118,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
)} )}
</div> </div>
{isSteppir && ( {(isSteppir || ultrabeam.type === 'ultrabeam') && (
<div className="border-t border-border/60 pt-3 space-y-1"> <div className="border-t border-border/60 pt-3 space-y-2">
<div className="flex items-center gap-2 flex-wrap"> <Label className="text-sm">{t('hw.motorBands')}</Label>
<Label className="text-sm">{t('hw.steppirRange')}</Label> <div className="flex items-center gap-1.5 flex-wrap">
<input type="number" min={1} max={60} value={ultrabeam.freq_min_mhz} {MOTOR_BANDS.map((b) => {
onChange={(e) => setUltrabeam((s) => ({ ...s, freq_min_mhz: parseInt(e.target.value, 10) || 0 }))} const on = ultrabeam.bands.includes(b);
className="h-8 w-16 rounded-md border border-input bg-background px-2 text-sm" /> return (
<span className="text-xs text-muted-foreground"></span> <button key={b} type="button"
<input type="number" min={1} max={60} value={ultrabeam.freq_max_mhz} onClick={() => setUltrabeam((s) => ({
onChange={(e) => setUltrabeam((s) => ({ ...s, freq_max_mhz: parseInt(e.target.value, 10) || 0 }))} ...s,
className="h-8 w-16 rounded-md border border-input bg-background px-2 text-sm" /> bands: on ? s.bands.filter((x) => x !== b) : [...s.bands, b],
<span className="text-xs text-muted-foreground">MHz</span> }))}
className={`h-8 min-w-[3rem] rounded-md border px-2 text-sm font-medium transition-colors ${
on
? 'border-primary bg-primary/15 text-primary'
: 'border-input bg-background text-muted-foreground hover:bg-muted'
}`}>
{b}
</button>
);
})}
</div> </div>
<p className="text-xs text-muted-foreground">{t('hw.steppirRangeHint')}</p>
</div> </div>
)} )}
<div className="border-t border-border/60 pt-3 space-y-1"> <div className="border-t border-border/60 pt-3 space-y-1">
@@ -3387,6 +3502,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const dev = d as any; const dev = d as any;
const isRG = dev.type === 'rotgenius'; const isRG = dev.type === 'rotgenius';
const isARCO = dev.type === 'arco'; const isARCO = dev.type === 'arco';
const isDCU1 = dev.type === 'dcu1';
const isSerialCap = isARCO || isDCU1; // COM-port or serial-over-IP controllers
const transport = dev.transport ?? 'tcp'; const transport = dev.transport ?? 'tcp';
return ( return (
<div key={dev.id || i} className="rounded-xl border border-border bg-card/40 p-3 space-y-3"> <div key={dev.id || i} className="rounded-xl border border-border bg-card/40 p-3 space-y-3">
@@ -3405,12 +3522,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
{/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001 {/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
(placeholder must match the ARCO's LAN menu), PstRotator 12000. */} (placeholder must match the ARCO's LAN menu), PstRotator 12000. */}
<Select value={dev.type ?? 'pst'} <Select value={dev.type ?? 'pst'}
onValueChange={(v) => patch(i, { type: v as any, port: v === 'rotgenius' ? 9006 : v === 'arco' ? 4001 : 12000 })}> onValueChange={(v) => patch(i, { type: v as any, port: v === 'rotgenius' ? 9006 : (v === 'arco' || v === 'dcu1') ? 4001 : 12000, ...(v === 'dcu1' ? { transport: 'serial' } : {}) })}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger> <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="pst">PstRotator (UDP)</SelectItem> <SelectItem value="pst">PstRotator (UDP)</SelectItem>
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem> <SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
<SelectItem value="arco">GS-232A controller (microHAM ARCO, ERC)</SelectItem> <SelectItem value="arco">GS-232A controller (microHAM ARCO, ERC)</SelectItem>
<SelectItem value="dcu1">Hy-Gain DCU-1 (RotorCard DXA, Rotor-EZ, Green Heron)</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -3427,8 +3545,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</Select> </Select>
</div> </div>
)} )}
{/* The ARCO is reachable over the LAN (TCP) or its USB virtual COM. */} {/* ARCO and DCU-1 controllers reach over the LAN (TCP) or a serial COM. */}
{isARCO && ( {isSerialCap && (
<div className="space-y-1"> <div className="space-y-1">
<Label>Connection</Label> <Label>Connection</Label>
<Select value={transport} onValueChange={(v) => patch(i, { transport: v as any })}> <Select value={transport} onValueChange={(v) => patch(i, { transport: v as any })}>
@@ -3448,7 +3566,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
{t('rot.rgDual')} {t('rot.rgDual')}
</label> </label>
)} )}
{isARCO && transport === 'serial' ? ( {isSerialCap && transport === 'serial' ? (
<div className="space-y-1 max-w-xs"> <div className="space-y-1 max-w-xs">
<Label>COM port</Label> <Label>COM port</Label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -3477,16 +3595,16 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className="space-y-1 col-span-2"> <div className="space-y-1 col-span-2">
<Label>Host / IP</Label> <Label>Host / IP</Label>
<Input value={dev.host ?? ''} onChange={(e) => patch(i, { host: e.target.value })} <Input value={dev.host ?? ''} onChange={(e) => patch(i, { host: e.target.value })}
placeholder={isRG || isARCO ? '192.168.1.60' : '127.0.0.1'} className="font-mono" /> placeholder={isRG || isSerialCap ? '192.168.1.60' : '127.0.0.1'} className="font-mono" />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label> <Label>{isRG || isSerialCap ? 'TCP port' : 'UDP port'}</Label>
<PortInput value={dev.port} onChange={(n) => patch(i, { port: n })} <PortInput value={dev.port} onChange={(n) => patch(i, { port: n })}
fallback={isRG ? 9006 : isARCO ? 4001 : 12000} className="font-mono" /> fallback={isRG ? 9006 : isSerialCap ? 4001 : 12000} className="font-mono" />
</div> </div>
</div> </div>
)} )}
{!isRG && !isARCO && ( {!isRG && !isSerialCap && (
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={!!dev.has_elevation} onCheckedChange={(c) => patch(i, { has_elevation: !!c })} /> <Checkbox checked={!!dev.has_elevation} onCheckedChange={(c) => patch(i, { has_elevation: !!c })} />
This rotator supports elevation (VHF / satellite) This rotator supports elevation (VHF / satellite)
@@ -3494,6 +3612,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
)} )}
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>} {isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>} {isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
{isDCU1 && <p className="text-xs text-muted-foreground">{t('rot.dcu1Hint')}</p>}
{/* Which antenna this rotor carries — only relevant with >1 rotor. */} {/* Which antenna this rotor carries — only relevant with >1 rotor. */}
{multi && ( {multi && (
<div className="space-y-1 max-w-xs"> <div className="space-y-1 max-w-xs">
@@ -4000,6 +4119,44 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<span className="font-mono">dx.maritimecontestclub.net:7300</span>,{' '} <span className="font-mono">dx.maritimecontestclub.net:7300</span>,{' '}
<span className="font-mono">w8avi.net:7300</span>. <span className="font-mono">w8avi.net:7300</span>.
</p> </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>
{/* Self-spot. The interval only shows once it's on an interval for
something switched off is just a question the operator can't act on. */}
<div className="border-t border-border/60 pt-3 space-y-2">
<div className="flex items-center gap-3 flex-wrap">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={selfSpot.enabled}
onCheckedChange={(c) => setSelfSpot((s) => ({ ...s, enabled: !!c }))} />
{t('clu.selfSpot')}
</label>
{selfSpot.enabled && (
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{t('clu.selfSpotEvery')}</span>
<Input
type="number"
min={SELF_SPOT_MIN_MIN}
max={240}
className="w-20 h-8 font-mono text-xs"
value={selfSpotText}
onChange={(e) => setSelfSpotText(e.target.value)}
onBlur={() => {
const n = Math.floor(Number(selfSpotText));
const v = Number.isFinite(n) && n > SELF_SPOT_MIN_MIN ? Math.min(n, 240) : SELF_SPOT_MIN_MIN;
setSelfSpotText(String(v));
setSelfSpot((s) => ({ ...s, minutes: v }));
}}
/>
<span className="text-muted-foreground">{t('clu.selfSpotMinutes')}</span>
</div>
)}
</div>
<p className="text-xs text-muted-foreground leading-relaxed">{t('clu.selfSpotHint')}</p>
</div>
</div> </div>
{editingServer && ( {editingServer && (
@@ -4197,7 +4354,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
title={t('sec.backup')} title={t('sec.backup')}
hint={mysqlCfg.enabled ? t('bk.hintMysql') : t('bk.hint')} hint={mysqlCfg.enabled ? t('bk.hintMysql') : t('bk.hint')}
/> />
<div className="space-y-4 max-w-xl"> <div className="space-y-5 max-w-2xl">
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox <Checkbox
checked={!!backupCfg.enabled} checked={!!backupCfg.enabled}
@@ -4238,7 +4395,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
</div> </div>
<div className="flex items-end gap-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">{t('bk.rotation')}</Label> <Label className="text-xs text-muted-foreground">{t('bk.rotation')}</Label>
<Input <Input
@@ -4253,13 +4409,39 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
}} }}
/> />
</div> </div>
<label className="flex items-center gap-2 text-sm cursor-pointer pb-2">
{/* Stacked, not one flex row: these labels are full sentences and the
"keep every backup" hint is two lines side by side they wrapped
into unreadable slivers. It also puts keep-all under the option it
depends on rather than beside it. */}
<div className="space-y-2.5">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox <Checkbox
checked={!!backupCfg.zip} checked={!!backupCfg.zip}
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, zip: !!c }))} onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, zip: !!c }))}
/> />
<span>{t('bk.zip')}</span> <span>{t('bk.zip')}</span>
</label> </label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={!!(backupCfg as any).every_exit}
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, every_exit: !!c } as any))}
/>
<span>{t('bk.everyExit')}</span>
</label>
{!!(backupCfg as any).every_exit && (
<label className="flex items-start gap-2 text-sm cursor-pointer pl-6">
<Checkbox
className="mt-0.5"
checked={!!(backupCfg as any).keep_all}
onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, keep_all: !!c } as any))}
/>
<span>
{t('bk.keepAll')}
<span className="block text-xs text-muted-foreground mt-0.5 leading-relaxed">{t('bk.keepAllHint')}</span>
</span>
</label>
)}
</div> </div>
<div className="border-t border-border/60 pt-3 flex items-center gap-3"> <div className="border-t border-border/60 pt-3 flex items-center gap-3">
@@ -5071,8 +5253,23 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
<p className="text-[11px] text-muted-foreground">{t('db.appDbHint')}</p> <p className="text-[11px] text-muted-foreground">{t('db.appDbHint')}</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
<Button variant="outline" size="sm" onClick={openExisting}><Database className="size-3.5" /> {t('db.openExisting')}</Button>
<Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
<Button variant="outline" size="sm" onClick={renameDb} title={t('db.renameTip')}><Pencil className="size-3.5" /> {t('db.rename')}</Button>
<Button variant="outline" size="sm" onClick={revealFolder}><FolderOpen className="size-3.5" /> {t('db.openFolder')}</Button> <Button variant="outline" size="sm" onClick={revealFolder}><FolderOpen className="size-3.5" /> {t('db.openFolder')}</Button>
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
</div> </div>
{/* The DB pointer is only read at startup, so offer the restart inline. */}
{dbMsg && (
<div className="text-[11px] text-success space-y-1 pt-1">
<div>{t('db.savedRestart')} <span className="font-mono break-all">{dbMsg}</span></div>
<Button size="sm" className="h-7" onClick={() => RestartApp().catch((e: any) => setErr(String(e?.message ?? e)))}>
<Power className="size-3.5" /> {t('db.restartNow')}
</Button>
<span className="ml-2 text-muted-foreground">{t('db.restartHint')}</span>
</div>
)}
</div> </div>
{/* Logbook (QSOs) — this profile: default SQLite file, a dedicated file, or MySQL. */} {/* Logbook (QSOs) — this profile: default SQLite file, a dedicated file, or MySQL. */}
@@ -5694,6 +5891,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<span className="text-[11px] text-muted-foreground">{emailMsg}</span> <span className="text-[11px] text-muted-foreground">{emailMsg}</span>
</div> </div>
<div className="pt-2 mt-2 border-t border-border space-y-2">
<Label className="text-sm font-semibold">{t('em.recEmail')}</Label>
<div className="text-[11px] text-muted-foreground">
{t('em.recVarsHint')} {'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'}.
</div>
<Input className="h-8" placeholder={t('em.subject')} value={emailCfg.subject}
onChange={(e) => setEmailField({ subject: e.target.value })} />
<Textarea rows={3} className="text-sm" placeholder={t('em.body')} value={emailCfg.body}
onChange={(e) => setEmailField({ body: e.target.value })} />
</div>
<div className="pt-2 mt-2 border-t border-border space-y-2"> <div className="pt-2 mt-2 border-t border-border space-y-2">
<Label className="text-sm font-semibold">{t('em.qslCardEmail')}</Label> <Label className="text-sm font-semibold">{t('em.qslCardEmail')}</Label>
<div className="text-[11px] text-muted-foreground"> <div className="text-[11px] text-muted-foreground">
@@ -5710,6 +5918,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className="text-[11px] text-muted-foreground"> <div className="text-[11px] text-muted-foreground">
{t('em.autoSendHint')} {t('em.autoSendHint')}
</div> </div>
<div className="pt-2 space-y-1">
<Label className="text-sm">{t('em.qslDefaultMsg')}</Label>
<Input className="h-8" placeholder={t('em.qslDefaultMsgPh')} value={eqslCfg.default_message ?? ''}
onChange={(e) => setEqslField({ default_message: e.target.value })} />
<div className="text-[11px] text-muted-foreground">{t('em.qslDefaultMsgHint')}</div>
</div>
</div> </div>
</div> </div>
</> </>
@@ -5801,7 +6015,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
database: DatabasePanel, database: DatabasePanel,
uscounties: USCountiesPanel, uscounties: USCountiesPanel,
autostart: () => <AutostartPanelComponent />, autostart: () => <AutostartPanelComponent />,
awards: () => <ComingSoon id="awards" icon={Award} />, awards: () => <AwardsSelectionPanel profile={activeProfile ?? undefined} />,
cat: CATPanel, cat: CATPanel,
rotator: RotatorPanel, rotator: RotatorPanel,
winkeyer: WinkeyerPanel, winkeyer: WinkeyerPanel,
@@ -672,6 +672,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
<div className={cn('space-y-1', (isKM || isDingtian) ? '' : 'max-w-xs')}> <div className={cn('space-y-1', (isKM || isDingtian) ? '' : 'max-w-xs')}>
<Label>{t('station.host')}</Label> <Label>{t('station.host')}</Label>
<Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} /> <Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
<span className="text-[10px] text-muted-foreground">{t('station.hostHint')}</span>
</div> </div>
{/* Dingtian: both are OFF on a factory board the session ID only when {/* Dingtian: both are OFF on a factory board the session ID only when
"HTTP Session" is enabled in its web page, the password only when a "HTTP Session" is enabled in its web page, the password only when a
File diff suppressed because one or more lines are too long
+28
View File
@@ -0,0 +1,28 @@
// Capitalisation helpers for the free-text QSO fields (Name / QTH / Comment /
// Note).
//
// The same station reaches the log SHOUTED by QRZ, lower-cased by a hurried
// operator, and in whatever case the other logger stored it in an imported
// ADIF — so one callsign ends up as "JEAN", "jean" and "Jean" across a log,
// and sorting or reading a QTH column becomes a mess.
//
// Both helpers are meant to run on BLUR, never per keystroke: rewriting the
// value while the operator is still typing a word fights them mid-word (see
// the controlled-input note in CLAUDE.md).
// titleCase upper-cases the first letter of every word and lower-cases the
// rest. Separators are kept, so "SAINT-JULIEN" → "Saint-Julien" and
// "o'brien" → "O'Brien".
export function titleCase(s: string): string {
return s
.toLowerCase()
.replace(/(^|[\s\-'/.])(\p{L})/gu, (_m, sep: string, ch: string) => sep + ch.toUpperCase());
}
// sentenceCase upper-cases the first letter and leaves everything after it as
// typed. A comment routinely carries callsigns, modes and abbreviations
// ("TNX QSO F5ABC, FT8 59") that lower-casing would quietly destroy — which is
// why this is NOT titleCase.
export function sentenceCase(s: string): string {
return s.replace(/^(\s*)(\p{L})/u, (_m, sp: string, ch: string) => sp + ch.toUpperCase());
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // 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). // 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.9';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+14
View File
@@ -87,6 +87,8 @@ export function AwardsFolder():Promise<string>;
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>; 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 BrowseExecutable():Promise<string>;
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>; 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 CWDecoderRunning():Promise<boolean>;
export function CancelConfirmations():Promise<void>;
export function ChatAvailable():Promise<boolean>; export function ChatAvailable():Promise<boolean>;
export function CheckForUpdate():Promise<main.UpdateInfo>; export function CheckForUpdate():Promise<main.UpdateInfo>;
@@ -477,6 +481,8 @@ export function GetScpStatus():Promise<main.ScpStatus>;
export function GetSecretStatus():Promise<main.SecretStatus>; export function GetSecretStatus():Promise<main.SecretStatus>;
export function GetSelfSpotSettings():Promise<main.SelfSpotSettings>;
export function GetSlotStats():Promise<qso.SlotStats>; export function GetSlotStats():Promise<qso.SlotStats>;
export function GetSolarData():Promise<solar.Data>; export function GetSolarData():Promise<solar.Data>;
@@ -491,6 +497,8 @@ export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
export function GetTelemetryEnabled():Promise<boolean>; export function GetTelemetryEnabled():Promise<boolean>;
export function GetTrackedAwards():Promise<Array<string>>;
export function GetTunerGeniusSettings():Promise<main.TunerGeniusSettings>; export function GetTunerGeniusSettings():Promise<main.TunerGeniusSettings>;
export function GetTunerGeniusStatus():Promise<tunergenius.Status>; export function GetTunerGeniusStatus():Promise<tunergenius.Status>;
@@ -889,10 +897,14 @@ export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>; export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>; export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>; export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
export function SaveTrackedAwards(arg1:Array<string>):Promise<void>;
export function SaveTunerGeniusSettings(arg1:main.TunerGeniusSettings):Promise<void>; export function SaveTunerGeniusSettings(arg1:main.TunerGeniusSettings):Promise<void>;
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>; export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
@@ -941,6 +953,8 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
export function SetKenwoodKeySpeed(arg1:number):Promise<void>; export function SetKenwoodKeySpeed(arg1:number):Promise<void>;
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
export function SetPassphrase(arg1:string):Promise<void>; export function SetPassphrase(arg1:string):Promise<void>;
export function SetScpEnabled(arg1:boolean):Promise<void>; export function SetScpEnabled(arg1:boolean):Promise<void>;
+28
View File
@@ -122,6 +122,10 @@ export function BackfillUSCounties() {
return window['go']['main']['App']['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() { export function BrowseExecutable() {
return window['go']['main']['App']['BrowseExecutable'](); return window['go']['main']['App']['BrowseExecutable']();
} }
@@ -142,6 +146,10 @@ export function CWDecoderRunning() {
return window['go']['main']['App']['CWDecoderRunning'](); return window['go']['main']['App']['CWDecoderRunning']();
} }
export function CancelConfirmations() {
return window['go']['main']['App']['CancelConfirmations']();
}
export function ChatAvailable() { export function ChatAvailable() {
return window['go']['main']['App']['ChatAvailable'](); return window['go']['main']['App']['ChatAvailable']();
} }
@@ -902,6 +910,10 @@ export function GetSecretStatus() {
return window['go']['main']['App']['GetSecretStatus'](); return window['go']['main']['App']['GetSecretStatus']();
} }
export function GetSelfSpotSettings() {
return window['go']['main']['App']['GetSelfSpotSettings']();
}
export function GetSlotStats() { export function GetSlotStats() {
return window['go']['main']['App']['GetSlotStats'](); return window['go']['main']['App']['GetSlotStats']();
} }
@@ -930,6 +942,10 @@ export function GetTelemetryEnabled() {
return window['go']['main']['App']['GetTelemetryEnabled'](); return window['go']['main']['App']['GetTelemetryEnabled']();
} }
export function GetTrackedAwards() {
return window['go']['main']['App']['GetTrackedAwards']();
}
export function GetTunerGeniusSettings() { export function GetTunerGeniusSettings() {
return window['go']['main']['App']['GetTunerGeniusSettings'](); return window['go']['main']['App']['GetTunerGeniusSettings']();
} }
@@ -1726,6 +1742,10 @@ export function SaveRotators(arg1) {
return window['go']['main']['App']['SaveRotators'](arg1); return window['go']['main']['App']['SaveRotators'](arg1);
} }
export function SaveSelfSpotSettings(arg1) {
return window['go']['main']['App']['SaveSelfSpotSettings'](arg1);
}
export function SaveStationDevices(arg1) { export function SaveStationDevices(arg1) {
return window['go']['main']['App']['SaveStationDevices'](arg1); return window['go']['main']['App']['SaveStationDevices'](arg1);
} }
@@ -1734,6 +1754,10 @@ export function SaveStationSettings(arg1) {
return window['go']['main']['App']['SaveStationSettings'](arg1); return window['go']['main']['App']['SaveStationSettings'](arg1);
} }
export function SaveTrackedAwards(arg1) {
return window['go']['main']['App']['SaveTrackedAwards'](arg1);
}
export function SaveTunerGeniusSettings(arg1) { export function SaveTunerGeniusSettings(arg1) {
return window['go']['main']['App']['SaveTunerGeniusSettings'](arg1); return window['go']['main']['App']['SaveTunerGeniusSettings'](arg1);
} }
@@ -1830,6 +1854,10 @@ export function SetKenwoodKeySpeed(arg1) {
return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1); return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1);
} }
export function SetOpsLogQSLReceived(arg1, arg2) {
return window['go']['main']['App']['SetOpsLogQSLReceived'](arg1, arg2);
}
export function SetPassphrase(arg1) { export function SetPassphrase(arg1) {
return window['go']['main']['App']['SetPassphrase'](arg1); return window['go']['main']['App']['SetPassphrase'](arg1);
} }
+26
View File
@@ -315,6 +315,7 @@ export namespace award {
pattern: string; pattern: string;
leading_str?: string; leading_str?: string;
trailing_str?: string; trailing_str?: string;
prefix?: string;
dynamic?: boolean; dynamic?: boolean;
one_ref_per_qso?: boolean; one_ref_per_qso?: boolean;
or_rules?: OrRule[]; or_rules?: OrRule[];
@@ -356,6 +357,7 @@ export namespace award {
this.pattern = source["pattern"]; this.pattern = source["pattern"];
this.leading_str = source["leading_str"]; this.leading_str = source["leading_str"];
this.trailing_str = source["trailing_str"]; this.trailing_str = source["trailing_str"];
this.prefix = source["prefix"];
this.dynamic = source["dynamic"]; this.dynamic = source["dynamic"];
this.one_ref_per_qso = source["one_ref_per_qso"]; this.one_ref_per_qso = source["one_ref_per_qso"];
this.or_rules = this.convertValues(source["or_rules"], OrRule); this.or_rules = this.convertValues(source["or_rules"], OrRule);
@@ -1894,6 +1896,8 @@ export namespace main {
folder: string; folder: string;
rotation: number; rotation: number;
zip: boolean; zip: boolean;
every_exit: boolean;
keep_all: boolean;
last_backup_at: string; last_backup_at: string;
default_folder: string; default_folder: string;
@@ -1907,6 +1911,8 @@ export namespace main {
this.folder = source["folder"]; this.folder = source["folder"];
this.rotation = source["rotation"]; this.rotation = source["rotation"];
this.zip = source["zip"]; this.zip = source["zip"];
this.every_exit = source["every_exit"];
this.keep_all = source["keep_all"];
this.last_backup_at = source["last_backup_at"]; this.last_backup_at = source["last_backup_at"];
this.default_folder = source["default_folder"]; this.default_folder = source["default_folder"];
} }
@@ -1930,6 +1936,7 @@ export namespace main {
kenwood_host: string; kenwood_host: string;
kenwood_port: string; kenwood_port: string;
kenwood_baud: number; kenwood_baud: number;
kenwood_data_mode: string;
yaesu_low_lines: boolean; yaesu_low_lines: boolean;
kenwood_low_lines: boolean; kenwood_low_lines: boolean;
icom_port: string; icom_port: string;
@@ -1975,6 +1982,7 @@ export namespace main {
this.kenwood_host = source["kenwood_host"]; this.kenwood_host = source["kenwood_host"];
this.kenwood_port = source["kenwood_port"]; this.kenwood_port = source["kenwood_port"];
this.kenwood_baud = source["kenwood_baud"]; this.kenwood_baud = source["kenwood_baud"];
this.kenwood_data_mode = source["kenwood_data_mode"];
this.yaesu_low_lines = source["yaesu_low_lines"]; this.yaesu_low_lines = source["yaesu_low_lines"];
this.kenwood_low_lines = source["kenwood_low_lines"]; this.kenwood_low_lines = source["kenwood_low_lines"];
this.icom_port = source["icom_port"]; this.icom_port = source["icom_port"];
@@ -2633,6 +2641,7 @@ export namespace main {
subject: string; subject: string;
body: string; body: string;
auto_send: boolean; auto_send: boolean;
default_message: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new QSLEmailTemplates(source); return new QSLEmailTemplates(source);
@@ -2643,6 +2652,7 @@ export namespace main {
this.subject = source["subject"]; this.subject = source["subject"];
this.body = source["body"]; this.body = source["body"];
this.auto_send = source["auto_send"]; this.auto_send = source["auto_send"];
this.default_message = source["default_message"];
} }
} }
export class QSLFontInfo { export class QSLFontInfo {
@@ -2904,6 +2914,20 @@ export namespace main {
this.unlocked = source["unlocked"]; this.unlocked = source["unlocked"];
} }
} }
export class SelfSpotSettings {
enabled: boolean;
minutes: number;
static createFrom(source: any = {}) {
return new SelfSpotSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.minutes = source["minutes"];
}
}
export class SpotQuery { export class SpotQuery {
call: string; call: string;
band: string; band: string;
@@ -3154,6 +3178,7 @@ export namespace main {
follow: boolean; follow: boolean;
step_khz: number; step_khz: number;
tx_inhibit: boolean; tx_inhibit: boolean;
bands: string[];
freq_min_mhz: number; freq_min_mhz: number;
freq_max_mhz: number; freq_max_mhz: number;
@@ -3173,6 +3198,7 @@ export namespace main {
this.follow = source["follow"]; this.follow = source["follow"];
this.step_khz = source["step_khz"]; this.step_khz = source["step_khz"];
this.tx_inhibit = source["tx_inhibit"]; this.tx_inhibit = source["tx_inhibit"];
this.bands = source["bands"];
this.freq_min_mhz = source["freq_min_mhz"]; this.freq_min_mhz = source["freq_min_mhz"];
this.freq_max_mhz = source["freq_max_mhz"]; this.freq_max_mhz = source["freq_max_mhz"];
} }
+2 -1
View File
@@ -74,6 +74,7 @@ type Def struct {
Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
Prefix string `json:"prefix,omitempty"` // prepended to each found reference (e.g. captured "01" + "D" → "D01"), same as OrRule.Prefix
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts) Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
// OneRefPerQSO refuses an AMBIGUOUS match rather than guessing. // OneRefPerQSO refuses an AMBIGUOUS match rather than guessing.
// //
@@ -960,7 +961,7 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
// Testing the raw candidate would call that a hit, skip every fallback, and // Testing the raw candidate would call that a hit, skip every fallback, and
// only then drop it as unlisted — leaving the QSO unmatched even though the // only then drop it as unlisted — leaving the QSO unmatched even though the
// next rule ("find the code inside the QTH") would have found BG. // next rule ("find the code inside the QTH") would have found BG.
found := run("primary", d.Field, d.MatchBy, d.Pattern, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "") found := run("primary", d.Field, d.MatchBy, d.Pattern, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, d.Prefix)
for i := range d.OrRules { for i := range d.OrRules {
r := &d.OrRules[i] r := &d.OrRules[i]
label := fmt.Sprintf("OR %d", i+1) label := fmt.Sprintf("OR %d", i+1)
File diff suppressed because it is too large Load Diff
+18 -12
View File
@@ -26,7 +26,7 @@ type Settings struct {
Folder string `json:"folder"` // empty → DefaultFolder Folder string `json:"folder"` // empty → DefaultFolder
Rotation int `json:"rotation"` // how many backups to keep; 0/neg = 5 Rotation int `json:"rotation"` // how many backups to keep; 0/neg = 5
Zip bool `json:"zip"` // compress with deflate Zip bool `json:"zip"` // compress with deflate
LastBackupAt string `json:"last_backup_at"`// RFC3339; empty if never LastBackupAt string `json:"last_backup_at"` // RFC3339; empty if never
} }
// DefaultFolder returns the folder used when Settings.Folder is empty. // DefaultFolder returns the folder used when Settings.Folder is empty.
@@ -44,7 +44,7 @@ func DefaultFolder(dataDir string) string {
// statement (no torn-copy window while the app keeps writing), and compacts // statement (no torn-copy window while the app keeps writing), and compacts
// the destination as a bonus. It replaces the old "checkpoint + raw io.Copy", // the destination as a bonus. It replaces the old "checkpoint + raw io.Copy",
// which could capture a half-written page during a concurrent write. // which could capture a half-written page during a concurrent write.
func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation int, doZip bool) (string, error) { func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation int, doZip bool, prefix string, unique bool) (string, error) {
if dbConn == nil { if dbConn == nil {
return "", fmt.Errorf("nil db connection") return "", fmt.Errorf("nil db connection")
} }
@@ -54,12 +54,20 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
if folder == "" { if folder == "" {
return "", fmt.Errorf("backup folder not set") return "", fmt.Errorf("backup folder not set")
} }
if prefix == "" {
prefix = "opslog"
}
if err := os.MkdirAll(folder, 0o755); err != nil { if err := os.MkdirAll(folder, 0o755); err != nil {
return "", fmt.Errorf("create backup folder: %w", err) return "", fmt.Errorf("create backup folder: %w", err)
} }
// One file per DAY by default (same-day runs overwrite). With `unique`, add the
// time so every run is a distinct file — rotation still keeps the newest N.
stamp := time.Now().Format("2006-01-02") stamp := time.Now().Format("2006-01-02")
base := fmt.Sprintf("opslog-%s", stamp) if unique {
stamp = time.Now().Format("2006-01-02-150405")
}
base := fmt.Sprintf("%s-%s", prefix, stamp)
// VACUUM INTO requires a non-existent target → use a temp file, then // VACUUM INTO requires a non-existent target → use a temp file, then
// move/zip it into place. // move/zip it into place.
@@ -92,7 +100,7 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
} }
} }
if err := rotate(folder, rotation); err != nil { if err := rotateMatch(folder, rotation, prefix+"-", ".db", ".db.zip"); err != nil {
// Rotation errors are non-fatal — the backup itself succeeded. // Rotation errors are non-fatal — the backup itself succeeded.
return dstPath, fmt.Errorf("rotate: %w (backup OK at %s)", err, dstPath) return dstPath, fmt.Errorf("rotate: %w (backup OK at %s)", err, dstPath)
} }
@@ -103,7 +111,7 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
// log lives on a shared MySQL server, where VACUUM INTO can't snapshot it — an // log lives on a shared MySQL server, where VACUUM INTO can't snapshot it — an
// ADIF export is a portable, re-importable backup of the actual contacts. // ADIF export is a portable, re-importable backup of the actual contacts.
// writeADIF must write the full ADIF to the path it's handed. // writeADIF must write the full ADIF to the path it's handed.
func RunADIF(folder string, rotation int, doZip bool, writeADIF func(path string) error) (string, error) { func RunADIF(folder string, rotation int, doZip bool, unique bool, writeADIF func(path string) error) (string, error) {
if rotation <= 0 { if rotation <= 0 {
rotation = 5 rotation = 5
} }
@@ -113,7 +121,11 @@ func RunADIF(folder string, rotation int, doZip bool, writeADIF func(path string
if err := os.MkdirAll(folder, 0o755); err != nil { if err := os.MkdirAll(folder, 0o755); err != nil {
return "", fmt.Errorf("create backup folder: %w", err) return "", fmt.Errorf("create backup folder: %w", err)
} }
base := "opslog-log-" + time.Now().Format("2006-01-02") stamp := time.Now().Format("2006-01-02")
if unique {
stamp = time.Now().Format("2006-01-02-150405")
}
base := "opslog-log-" + stamp
tmp := filepath.Join(folder, base+".adi.tmp") tmp := filepath.Join(folder, base+".adi.tmp")
_ = os.Remove(tmp) _ = os.Remove(tmp)
if err := writeADIF(tmp); err != nil { if err := writeADIF(tmp); err != nil {
@@ -203,12 +215,6 @@ func copyZipped(src, dst, innerName string) error {
return out.Close() return out.Close()
} }
// rotate keeps the most recent `keep` SQLite backups (opslog-*.db /
// opslog-*.db.zip) and deletes the rest.
func rotate(folder string, keep int) error {
return rotateMatch(folder, keep, "opslog-", ".db", ".db.zip")
}
// rotateMatch keeps the most recent `keep` files in folder whose name has the // rotateMatch keeps the most recent `keep` files in folder whose name has the
// given prefix and one of the given suffixes, deleting older ones. Only matching // given prefix and one of the given suffixes, deleting older ones. Only matching
// files are touched — never unrelated user files in the same folder. The suffix // files are touched — never unrelated user files in the same folder. The suffix
+121 -9
View File
@@ -56,6 +56,16 @@ type Kenwood struct {
// a wire, which is how most operators actually put a rig on the network. // a wire, which is how most operators actually put a rig on the network.
host string host string
digital string // mode name logged for data (FT8 by default) 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
// elecraft marks a K3/K4 (the "Elecraft" backend). It reuses this Kenwood-dialect
// client but, being an Elecraft, always drives digital modes to DATA A (MD6+DT0)
// — the sub-mode FT8 audio needs — instead of going through the dataMode option.
elecraft bool
mu sync.Mutex mu sync.Mutex
port serial.Port port serial.Port
@@ -109,6 +119,17 @@ type Kenwood struct {
// nothing. Off by default: that is how this backend behaved for its whole // nothing. Off by default: that is how this backend behaved for its whole
// life before the question came up. // life before the question came up.
lowerLines bool lowerLines bool
// tx tracks whether we currently hold PTT (SetPTT true). A Kenwood/Elecraft rig
// answers "?;" to a status poll (IF;) WHILE TRANSMITTING; OpsLog used to read
// that as "the rig doesn't support IF;", latch it off and drop the whole CAT
// link — which tore down the shared-CAT PTT that WSJT-X / JTDX key through, so a
// K3 keyed but the logger's transmission fell apart. While PTT is held we skip
// the wire poll and hand back the last good state (lastState) instead. txAt caps
// the skip so a missed SetPTT(false) can't freeze the state for ever.
tx bool
txAt time.Time
lastState RigState
} }
// SetLowerLines chooses whether DTR and RTS are deasserted on connect. Set // SetLowerLines chooses whether DTR and RTS are deasserted on connect. Set
@@ -249,6 +270,15 @@ func (k *Kenwood) ReadState() (RigState, error) {
if k.port == nil { if k.port == nil {
return RigState{}, fmt.Errorf("kenwood: not connected") return RigState{}, fmt.Errorf("kenwood: not connected")
} }
// While transmitting, don't poll: the rig returns "?;" to IF; during TX, and
// treating that as a fault dropped the shared CAT link the digital-mode
// software keys through. Hand back the last known state. The 30 s cap resyncs
// if a SetPTT(false) was somehow missed, so a stuck TX can't freeze state.
if k.tx && !k.txAt.IsZero() && time.Since(k.txAt) < 30*time.Second {
s := k.lastState
s.Connected = true
return s, nil
}
raw, err := k.ask("IF;") raw, err := k.ask("IF;")
if err != nil { if err != nil {
return RigState{}, err return RigState{}, err
@@ -261,7 +291,10 @@ func (k *Kenwood) ReadState() (RigState, error) {
s := RigState{Connected: true, Backend: "kenwood"} s := RigState{Connected: true, Backend: "kenwood"}
k.curVFO = f.VFO k.curVFO = f.VFO
s.Vfo = f.VFO s.Vfo = f.VFO
s.Mode = kenwoodModeToADIF(f.Mode, k.digital) // Both branches of SetMode that emit MD6 identify a K3/K4: the Elecraft
// backend, and the "MD6" data-mode option that exists for it. Decode the
// read-back the same way, or OpsLog contradicts the mode it just set.
s.Mode = kenwoodModeToADIF(f.Mode, k.digital, k.elecraft || k.dataMode == "data")
s.Rig = k.model s.Rig = k.model
// IF reports the frequency of the VFO in USE (what the operator hears). // IF reports the frequency of the VFO in USE (what the operator hears).
@@ -333,6 +366,7 @@ func (k *Kenwood) ReadState() (RigState, error) {
if s.Split { if s.Split {
k.curRXFreq = s.RxFreqHz k.curRXFreq = s.RxFreqHz
} }
k.lastState = s // cache for the transmit window, where we can't poll
return s, nil return s, nil
} }
@@ -360,6 +394,36 @@ func (k *Kenwood) SetMode(mode string) error {
if k.port == nil { if k.port == nil {
return fmt.Errorf("kenwood: not connected") 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) {
// An Elecraft always uses DATA A for a soundcard digital mode — that's what
// the "Elecraft" backend means, so the dataMode option doesn't apply.
if k.elecraft {
if err := k.write("MD6;"); err != nil {
return err
}
return k.write("DT0;")
}
switch k.dataMode {
case "keep":
return nil // leave whatever data mode the operator set on the rig
case "data":
// Elecraft K3/K4 DATA mode (MD6) PLUS the DATA-A submode (DT0). MD6 alone
// can leave the rig in an FSK/PSK data submode (from a prior RTTY/PSK
// session), where FT8 keys the transmitter but the rear sound-card audio
// never modulates — "transmits but nothing comes out". DT0 forces DATA A,
// the audio submode FT8 needs. This is the Elecraft path (the option is
// labelled K3/K4); a real K3/K4 answers DT with nothing. A plain Kenwood
// has no DT command and would "?;" it — but that's a misconfiguration
// (pick USB there), and IF is never latched so the link self-recovers.
if err := k.write("MD6;"); err != nil {
return err
}
return k.write("DT0;")
}
}
d := kenwoodModeDigit(mode, k.curFreq) d := kenwoodModeDigit(mode, k.curFreq)
if d == 0 { if d == 0 {
return fmt.Errorf("kenwood: no CAT mode for %q", mode) return fmt.Errorf("kenwood: no CAT mode for %q", mode)
@@ -367,13 +431,42 @@ func (k *Kenwood) SetMode(mode string) error {
return k.write(fmt.Sprintf("MD%c;", d)) 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()
}
// SetElecraft marks this client as driving a K3/K4 (the "Elecraft" backend), so
// digital modes always land in DATA A (MD6+DT0). Set before Start.
func (k *Kenwood) SetElecraft(v bool) {
k.mu.Lock()
k.elecraft = v
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 { func (k *Kenwood) SetPTT(on bool) error {
k.mu.Lock() k.mu.Lock()
defer k.mu.Unlock() defer k.mu.Unlock()
if k.port == nil { if k.port == nil {
return fmt.Errorf("kenwood: not connected") return fmt.Errorf("kenwood: not connected")
} }
k.tx = on
if on { if on {
k.txAt = time.Now()
return k.write("TX;") return k.write("TX;")
} }
return k.write("RX;") return k.write("RX;")
@@ -431,10 +524,15 @@ func (k *Kenwood) ask(cmd string) (string, error) {
k.rx = k.rx[i+1:] k.rx = k.rx[i+1:]
traceText("kenwood", "RX", frame) traceText("kenwood", "RX", frame)
if frame == "?;" { if frame == "?;" {
// The rig rejected the command. Remember it so the poll loop stops // IF; and ID; are universal on Kenwood/Elecraft — a "?;" to them is a
// paying a 600 ms timeout for it on every cycle. // transient "busy" (typically mid-transmit, or a menu open on the rig),
// NOT "unsupported". Latching them off would blind the poll loop for
// good and read as "lost the rig". Only remember the OPTIONAL commands
// (FR/FT/…) so the poll loop stops paying a 600 ms timeout for those.
if want != "IF" && want != "ID" {
k.unsupported[want] = true k.unsupported[want] = true
debugLog.Printf("kenwood: this rig does not support %q — not asking again", cmd) debugLog.Printf("kenwood: this rig does not support %q — not asking again", cmd)
}
return "", fmt.Errorf("kenwood: %s rejected", want) return "", fmt.Errorf("kenwood: %s rejected", want)
} }
if strings.HasPrefix(frame, want) { if strings.HasPrefix(frame, want) {
@@ -551,11 +649,12 @@ func kenwoodModeDigit(mode string, hz int64) byte {
} }
return '2' return '2'
} }
// Any other digital mode (FT8, PSK, JT…) rides the DATA input, and by universal // Any other digital mode (FT8, PSK, JT…) → USB on every band. There is NO single
// convention that is ALWAYS the UPPER sideband, on every band. The SSB rule // mode digit that is right for both families: a K3 wants DATA (MD6), a TS-590SG
// (LSB below 10 MHz) must NOT be applied here: doing so put a 40/80 m data spot // wants USB-DATA (MD2 + its DATA modifier), and MD6 on the TS-590SG is FSK/RTTY
// on LSB, which an Elecraft K3 shows as "DATA REV" — the reversed sideband. // — wrong for FT8. USB is the safe common base (the reversed-sideband "DATA REV"
// (RTTY/FSK is handled above as its own FSK mode, so it isn't affected.) // 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' return '2'
} }
@@ -563,7 +662,11 @@ func kenwoodModeDigit(mode string, hz int64) byte {
// Digital modes are indistinguishable from SSB over CAT — the rig only knows // Digital modes are indistinguishable from SSB over CAT — the rig only knows
// it is on USB — so the operator's configured digital mode is used, exactly as // it is on USB — so the operator's configured digital mode is used, exactly as
// the other backends do. // the other backends do.
func kenwoodModeToADIF(d byte, digital string) string { //
// md6IsData says whether MD6 means DATA on THIS rig — true for an Elecraft
// (the K3/K4 backend, and the "MD6" data-mode option that targets it), false
// for a plain Kenwood. See below.
func kenwoodModeToADIF(d byte, digital string, md6IsData bool) string {
switch d { switch d {
case '1': case '1':
return "LSB" return "LSB"
@@ -576,6 +679,15 @@ func kenwoodModeToADIF(d byte, digital string) string {
case '5': case '5':
return "AM" return "AM"
case '6', '9': case '6', '9':
// One digit, two meanings. On a Kenwood MD6/MD9 is FSK/FSK-R; on an
// Elecraft it is DATA / DATA-REV — and DATA is precisely what SetMode
// puts a K3/K4 into for FT8. Reading it straight back as RTTY meant a K3
// running FT8 reported RTTY: the QSO was logged on the wrong mode, and
// the shared CAT link told WSJT-X/JTDX the rig sat in RTTY while they had
// asked for a data mode.
if md6IsData {
return digital
}
return "RTTY" return "RTTY"
} }
return "" return ""
+33 -1
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. // What the log records for each mode digit the rig reports.
func TestKenwoodModeToADIF(t *testing.T) { func TestKenwoodModeToADIF(t *testing.T) {
cases := []struct { cases := []struct {
@@ -124,8 +141,23 @@ func TestKenwoodModeToADIF(t *testing.T) {
{'0', ""}, // unknown → say nothing rather than guess {'0', ""}, // unknown → say nothing rather than guess
} }
for _, c := range cases { for _, c := range cases {
if got := kenwoodModeToADIF(c.d, "FT8"); got != c.want { if got := kenwoodModeToADIF(c.d, "FT8", false); got != c.want {
t.Errorf("kenwoodModeToADIF(%q) = %q, want %q", c.d, got, c.want) t.Errorf("kenwoodModeToADIF(%q) = %q, want %q", c.d, got, c.want)
} }
} }
} }
// On a K3/K4 the same digits mean DATA / DATA-REV, not FSK — and DATA is what
// OpsLog itself sets for FT8. Decoding them as RTTY logged a K3 running FT8 on
// the wrong mode and told the shared-CAT clients the rig was in RTTY.
func TestKenwoodModeToADIFElecraftData(t *testing.T) {
for _, d := range []byte{'6', '9'} {
if got := kenwoodModeToADIF(d, "FT8", true); got != "FT8" {
t.Errorf("kenwoodModeToADIF(%q, elecraft) = %q, want %q", d, got, "FT8")
}
}
// Everything else is unaffected by the Elecraft dialect.
if got := kenwoodModeToADIF('2', "FT8", true); got != "USB" {
t.Errorf("kenwoodModeToADIF('2', elecraft) = %q, want USB", got)
}
}
+33
View File
@@ -173,6 +173,39 @@ func (m *Manager) EntityNames() []string {
return out 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 // Info returns metadata about the currently-loaded cty.dat (or zero value
// if nothing loaded). // if nothing loaded).
func (m *Manager) Info() ctySource { func (m *Manager) Info() ctySource {
+23 -5
View File
@@ -125,14 +125,19 @@ func (c *Client) SetFanMode(mode string) error {
default: default:
return fmt.Errorf("powergenius: invalid fan mode %q", mode) return fmt.Errorf("powergenius: invalid fan mode %q", mode)
} }
// Set with the bare "key=value" verb the amp uses for its own status fields // The verb the amp wants is "setup fanmode=VALUE" — confirmed LIVE: with the
// (same convention as "operate=1"). The earlier "setup fanmode=…" carried a // "setup " prefix the amp replies code 0 (accepted), while the bare
// bogus prefix the amp silently ignored, so the fan never changed and the next // "fanmode=VALUE" we switched to earlier is rejected (reply code 0x50000015).
// status kept reporting the old mode — the revert-to-Contest the operator saw. // That regression is what stopped the fan from changing; restoring "setup "
reply, err := c.command("fanmode=" + m) // fixes it.
reply, err := c.command("setup fanmode=" + m)
if err != nil { if err != nil {
return err return err
} }
if code := replyCode(reply); code != "" && code != "0" {
applog.Printf("pgxl: set fanmode=%s REJECTED, reply=%q", m, reply)
return fmt.Errorf("powergenius: amp rejected fanmode=%s (code %s)", m, code)
}
applog.Printf("pgxl: set fanmode=%s reply=%q", m, reply) applog.Printf("pgxl: set fanmode=%s reply=%q", m, reply)
c.statusMu.Lock() c.statusMu.Lock()
c.status.FanMode = m // optimistic c.status.FanMode = m // optimistic
@@ -141,6 +146,19 @@ func (c *Client) SetFanMode(mode string) error {
return nil return nil
} }
// replyCode returns the result code from an "R<id>|<code>|…" reply — "0" means
// the amp accepted the command. Returns "" when the line isn't an R reply.
func replyCode(reply string) string {
if !strings.HasPrefix(reply, "R") {
return ""
}
p := strings.SplitN(reply, "|", 3)
if len(p) < 2 {
return ""
}
return strings.TrimSpace(p[1])
}
// SetOperate puts the amp in OPERATE (1) or STANDBY (0). // SetOperate puts the amp in OPERATE (1) or STANDBY (0).
func (c *Client) SetOperate(on bool) error { func (c *Client) SetOperate(on bool) error {
v := "0" v := "0"
+4 -1
View File
@@ -734,7 +734,10 @@ func placeQSOBox(profile ProfileInfo, zone pxRect, occupied []pxRect) QSOBox {
BG: "#ffffff", BGOpacity: 0.88, Radius: 12, BG: "#ffffff", BGOpacity: 0.88, Radius: 12,
Title: "Confirming QSO with {qso.callsign}", Title: "Confirming QSO with {qso.callsign}",
Fields: []string{"qso_date", "time_on", "band", "mode", "rst_sent"}, Fields: []string{"qso_date", "time_on", "band", "mode", "rst_sent"},
Footer: "{qso.qsl_msg}", // PSE/TNX stamp (auto: TNX if a QSL was received, else PSE QSL) leads the
// message. {qso.pse_tnx} is a normal token — it can be moved to its own
// element anywhere on the card in the designer.
Footer: "{qso.pse_tnx} {qso.qsl_msg}",
} }
box.Y = cardH - box.H - 110 box.Y = cardH - box.H - 110
if zone.y+zone.h/2 > cardH/2 { if zone.y+zone.h/2 > cardH/2 {
+75
View File
@@ -1461,6 +1461,51 @@ func (r *Repo) IterateByIDs(ctx context.Context, ids []int64, fn func(QSO) error
return rows.Err() 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: // WorkedBefore summarises prior contacts at two granularities:
// - by exact callsign → shown as "this call worked N×" // - by exact callsign → shown as "this call worked N×"
// - by DXCC entity → drives NEW ONE / NEW BAND / NEW MODE / NEW SLOT // - 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() 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 // WorkedPOTARefs returns the set of POTA park references already worked
// (upper-cased). A QSO's pota_ref may hold several comma-separated parks // (upper-cased). A QSO's pota_ref may hold several comma-separated parks
// (an n-fer); each is added separately. // (an n-fer); each is added separately.
+23 -7
View File
@@ -61,6 +61,22 @@ func get(ctx context.Context, url, user, pass string) ([]byte, error) {
return body, nil return body, nil
} }
// relayBase turns a configured host into the base URL of a network relay board.
// A bare host or host:port keeps the default http:// (the boards serve plain
// HTTP on their LAN). A host that already carries a scheme is used verbatim, so
// a board can be reached through an HTTPS reverse proxy — e.g. a Nginx Proxy
// Manager entry "https://relay.example.com" fronting the LAN board's port 80,
// which is how an operator reaches shack relays from outside when 80/443 are
// already taken by the proxy. A trailing slash is trimmed so path parts append
// cleanly; an optional sub-path in the host is preserved (a sub-path proxy).
func relayBase(host string) string {
h := strings.TrimSpace(host)
if strings.HasPrefix(h, "http://") || strings.HasPrefix(h, "https://") {
return strings.TrimRight(h, "/")
}
return "http://" + h
}
// ── WebSwitch 1216H ──────────────────────────────────────────────────── // ── WebSwitch 1216H ────────────────────────────────────────────────────
type webswitch struct { type webswitch struct {
@@ -82,7 +98,7 @@ func (w *webswitch) Set(ctx context.Context, relay int, on bool) error {
if on { if on {
action = "on" action = "on"
} }
_, err := get(ctx, fmt.Sprintf("http://%s/relaycontrol/%s/%d", w.host, action, relay), "", "") _, err := get(ctx, fmt.Sprintf("%s/relaycontrol/%s/%d", relayBase(w.host), action, relay), "", "")
return err return err
} }
@@ -93,7 +109,7 @@ func (w *webswitch) Status(ctx context.Context) ([]bool, error) {
sel.WriteString(strconv.Itoa(i)) sel.WriteString(strconv.Itoa(i))
sel.WriteByte('$') sel.WriteByte('$')
} }
body, err := get(ctx, fmt.Sprintf("http://%s/relaystate/get2/%s", w.host, sel.String()), "", "") body, err := get(ctx, fmt.Sprintf("%s/relaystate/get2/%s", relayBase(w.host), sel.String()), "", "")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -140,7 +156,7 @@ func (k *kmtronic) Set(ctx context.Context, relay int, on bool) error {
state = "01" state = "01"
} }
// FF<rr><ss>: e.g. FF0101 = relay 1 on, FF0800 = relay 8 off. // FF<rr><ss>: e.g. FF0101 = relay 1 on, FF0800 = relay 8 off.
_, err := get(ctx, fmt.Sprintf("http://%s/FF%02d%s", k.host, relay, state), k.user, k.pass) _, err := get(ctx, fmt.Sprintf("%s/FF%02d%s", relayBase(k.host), relay, state), k.user, k.pass)
return err return err
} }
@@ -154,7 +170,7 @@ type kmStatus struct {
} }
func (k *kmtronic) Status(ctx context.Context) ([]bool, error) { func (k *kmtronic) Status(ctx context.Context) ([]bool, error) {
body, err := get(ctx, fmt.Sprintf("http://%s/status.xml", k.host), k.user, k.pass) body, err := get(ctx, fmt.Sprintf("%s/status.xml", relayBase(k.host)), k.user, k.pass)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -259,8 +275,8 @@ func (d *dingtian) Set(ctx context.Context, relay int, on bool) error {
} }
// type=0 is plain ON/OFF (1 = jogging, 2 = delay, 3 = flash, 4 = toggle), // type=0 is plain ON/OFF (1 = jogging, 2 = delay, 3 = flash, 4 = toggle),
// and time is then unused. // and time is then unused.
body, err := d.getCGI(ctx, fmt.Sprintf("http://%s/relay_cgi.cgi?type=0&relay=%d&on=%d&time=0&pwd=%s&", body, err := d.getCGI(ctx, fmt.Sprintf("%s/relay_cgi.cgi?type=0&relay=%d&on=%d&time=0&pwd=%s&",
d.host, relay-1, state, d.pwd)) relayBase(d.host), relay-1, state, d.pwd))
if err != nil { if err != nil {
return err return err
} }
@@ -274,7 +290,7 @@ func (d *dingtian) Set(ctx context.Context, relay int, on bool) error {
} }
func (d *dingtian) Status(ctx context.Context) ([]bool, error) { func (d *dingtian) Status(ctx context.Context) ([]bool, error) {
body, err := d.getCGI(ctx, fmt.Sprintf("http://%s/relay_cgi_load.cgi", d.host)) body, err := d.getCGI(ctx, fmt.Sprintf("%s/relay_cgi_load.cgi", relayBase(d.host)))
if err != nil { if err != nil {
return nil, err return nil, err
} }
+16
View File
@@ -172,3 +172,19 @@ func TestDingtianSetRefusalIsAnError(t *testing.T) {
t.Fatal("a refused command answered HTTP 200 and was reported as success") t.Fatal("a refused command answered HTTP 200 and was reported as success")
} }
} }
func TestRelayBase(t *testing.T) {
cases := map[string]string{
"192.168.1.5": "http://192.168.1.5",
"192.168.1.5:8080": "http://192.168.1.5:8080",
"https://relay.example.com": "https://relay.example.com",
"https://relay.example.com/": "https://relay.example.com",
"http://relay.example.com/sw/": "http://relay.example.com/sw",
" relay.local ": "http://relay.local",
}
for in, want := range cases {
if got := relayBase(in); got != want {
t.Errorf("relayBase(%q) = %q, want %q", in, got, want)
}
}
}
+42 -4
View File
@@ -36,6 +36,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
) )
@@ -59,6 +60,13 @@ type Server struct {
ln net.Listener ln net.Listener
conns map[net.Conn]struct{} conns map[net.Conn]struct{}
closed bool 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 { func New(port int, rig Rig, logf func(string, ...any)) *Server {
@@ -110,7 +118,32 @@ func (s *Server) Start() error {
return nil return nil
} }
// releasePTT drops the transmitter when whoever was holding it goes away.
//
// Nothing else will. The Kenwood/Elecraft backend deliberately suspends its
// wire poll while PTT is held (a K3 answers "?;" to IF; during transmit), so a
// client that crashes, is killed, or simply has its socket closed under it
// leaves the rig keyed with nobody watching — and closing the socket is exactly
// what Stop() does on every settings save. Seen in the field: a K3 sat in
// transmit for 29 s, until the CAT link happened to be rebuilt.
//
// Swap makes this once-only, so the Stop() path and the per-connection defer it
// triggers can both call it without double-unkeying.
func (s *Server) releasePTT(why string) {
if !s.ptt.Swap(false) {
return
}
s.log("rigctld: %s while the rig was keyed — dropping PTT", why)
if err := s.rig.SetPTT(false); err != nil {
s.log("rigctld: emergency unkey failed: %v", err)
}
}
func (s *Server) Stop() { func (s *Server) Stop() {
// Before anything is torn down: reloadCAT calls us BEFORE it restarts the CAT
// backend, so the rig is still reachable here and an unkey still lands.
s.releasePTT("CAT sharing stopped")
s.mu.Lock() s.mu.Lock()
s.closed = true s.closed = true
ln := s.ln ln := s.ln
@@ -138,6 +171,8 @@ func (s *Server) serve(c net.Conn) {
delete(s.conns, c) delete(s.conns, c)
s.mu.Unlock() s.mu.Unlock()
_ = c.Close() _ = c.Close()
// A client that walks away mid-over must not leave the rig transmitting.
s.releasePTT(fmt.Sprintf("client %s left", c.RemoteAddr()))
}() }()
s.log("rigctld: client connected from %s", c.RemoteAddr()) s.log("rigctld: client connected from %s", c.RemoteAddr())
r := bufio.NewReader(c) r := bufio.NewReader(c)
@@ -228,10 +263,12 @@ func (s *Server) handle(line string) (resp string, quit bool) {
return rprt(0), false return rprt(0), false
case "t", "\\get_ptt": case "t", "\\get_ptt":
// We do not read PTT back from every backend, and answering "transmitting" // Echo the last commanded PTT state. WSJT-X/JTDX poll this WHILE
// wrongly would make a client hold off for ever. Reporting RX is the safe // transmitting to confirm the rig is keyed; answering a blanket "0" (RX)
// direction: the worst case is a client that transmits when we said it // made them decide PTT had failed and abort the over after ~1-2 s.
// could, which is what it was going to do anyway. if s.ptt.Load() {
return "1\n", false
}
return "0\n", false return "0\n", false
case "T", "\\set_ptt": case "T", "\\set_ptt":
if len(args) < 1 { if len(args) < 1 {
@@ -242,6 +279,7 @@ func (s *Server) handle(line string) (resp string, quit bool) {
s.log("rigctld: set_ptt %v failed: %v", on, err) s.log("rigctld: set_ptt %v failed: %v", on, err)
return rprt(-9), false return rprt(-9), false
} }
s.ptt.Store(on)
return rprt(0), false return rprt(0), false
case "v", "\\get_vfo": case "v", "\\get_vfo":
+83
View File
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"sync" "sync"
"testing" "testing"
"time"
) )
// fakeRig stands in for the CAT manager. // fakeRig stands in for the CAT manager.
@@ -138,6 +139,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 // 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 // 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 // misshapen. Pinning its shape is what stops a well-meaning edit from silently
@@ -212,6 +235,66 @@ func TestServerOverTCP(t *testing.T) {
} }
} }
// A client that vanishes mid-over must not leave the transmitter keyed. Nothing
// else would notice: the Kenwood/Elecraft backend stops polling while PTT is
// held, so a K3 was seen sitting in transmit for 29 s after WSJT-X's socket was
// closed under it by a settings save.
func TestPTTIsDroppedWhenTheClientDisappears(t *testing.T) {
rig := &fakeRig{freq: 14074000, mode: "FT8"}
s := New(0, rig, nil)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
s.mu.Lock()
s.ln = ln
s.mu.Unlock()
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go s.serve(c)
}
}()
defer s.Stop()
c, err := net.DialTimeout("tcp", ln.Addr().String(), dialTimeout)
if err != nil {
t.Fatalf("dial: %v", err)
}
r := bufio.NewReader(c)
// "T 3" is what WSJT-X sends for PTT_ON_DATA — the form seen in the field.
if _, err := c.Write([]byte("T 3\n")); err != nil {
t.Fatalf("write: %v", err)
}
line, _ := r.ReadString('\n')
if strings.TrimSpace(line) != "RPRT 0" {
t.Fatalf("set_ptt = %q, want RPRT 0", strings.TrimSpace(line))
}
rig.mu.Lock()
keyed := rig.ptt
rig.mu.Unlock()
if !keyed {
t.Fatalf("rig is not keyed after T 3 — the test proves nothing")
}
_ = c.Close()
// The unkey happens on the serve goroutine's defer, so poll briefly.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
rig.mu.Lock()
keyed = rig.ptt
rig.mu.Unlock()
if !keyed {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Error("rig still keyed after the client disconnected — the transmitter was left on air")
}
func TestModeMapping(t *testing.T) { func TestModeMapping(t *testing.T) {
for _, c := range []struct{ adif, hamlib string }{ for _, c := range []struct{ adif, hamlib string }{
{"SSB", "USB"}, {"LSB", "LSB"}, {"CW", "CW"}, {"RTTY", "RTTY"}, {"SSB", "USB"}, {"LSB", "LSB"}, {"CW", "CW"}, {"RTTY", "RTTY"},
+158
View File
@@ -0,0 +1,158 @@
// Package dcu1 drives rotator controllers that speak the Hy-Gain DCU-1 protocol,
// over a serial COM port (or a raw TCP socket, e.g. a serial-over-IP bridge).
//
// DCU-1 is used by the Hy-Gain DCU-1, the Idiom Press Rotor-EZ, Green Heron
// controllers, and the RotorCard DXA (hamsupply) for Yaesu DXA rotors. It is a
// DIFFERENT command set from Yaesu GS-232 (see internal/rotator/gs232):
// semicolon-terminated, azimuth only.
//
// Commands (';' terminated — roundTrip appends the ';'):
//
// AP1nnn set the target bearing nnn (000-359)
// AM1 rotate to the target (some controllers move on AP1 alone; AM1 is
// harmless and makes the Rotor-EZ/DCU-1 variants that need it work)
// AI1 query the current bearing → the reply carries the 3-digit azimuth
//
// The base DCU-1 set has no dedicated stop; Stop re-commands the current bearing,
// which halts rotation.
package dcu1
import (
"fmt"
"io"
"net"
"regexp"
"strconv"
"strings"
"time"
"go.bug.st/serial"
)
const (
dialTimeout = 3 * time.Second
ioTimeout = 2 * time.Second
)
// Client is a stateless per-call sender, mirroring the gs232/pst/rotgenius idiom.
// Exactly one of (Host, Port) or ComPort is used.
type Client struct {
Host string
Port int
ComPort string // serial transport: "COM5" etc.
// Baud varies by controller (a Hy-Gain DCU-1 is 4800; Green Heron / RotorCard
// can differ). Zero keeps 4800.
Baud int
}
// New returns a TCP Client (a serial-over-IP bridge in front of the controller).
func New(host string, port int) *Client {
if host == "" {
host = "127.0.0.1"
}
if port <= 0 || port > 65535 {
port = 4001
}
return &Client{Host: host, Port: port}
}
// NewSerial returns a Client over the controller's COM port.
func NewSerial(comPort string, baud int) *Client {
return &Client{ComPort: comPort, Baud: baud}
}
// roundTrip opens a connection, sends one ';'-terminated command and (when
// wantReply) reads until a 3-digit bearing is present. cmd must NOT carry the
// ';'.
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
var conn io.ReadWriteCloser
if c.ComPort != "" {
baud := c.Baud
if baud <= 0 {
baud = 4800
}
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: baud})
if err != nil {
return "", fmt.Errorf("open rotator %s @ %d baud: %w", c.ComPort, baud, err)
}
_ = sp.SetReadTimeout(200 * time.Millisecond)
conn = sp
} else {
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
if err != nil {
return "", fmt.Errorf("connect DCU-1 %s:%d: %w", c.Host, c.Port, err)
}
_ = nc.SetDeadline(time.Now().Add(ioTimeout))
conn = nc
}
defer conn.Close()
if _, err := conn.Write([]byte(cmd + ";")); err != nil {
return "", fmt.Errorf("send %q: %w", cmd, err)
}
if !wantReply {
return "", nil
}
buf := make([]byte, 64)
var sb strings.Builder
deadline := time.Now().Add(ioTimeout)
for time.Now().Before(deadline) {
n, err := conn.Read(buf)
if n > 0 {
sb.Write(buf[:n])
// The DCU-1 reply carries the bearing as three digits (framing varies —
// ";nnn", "nnn;", "+0nnn"). Stop once we have them rather than on a
// specific terminator, so any flavour reads cleanly.
if azRe.MatchString(sb.String()) {
break
}
}
// A serial read that times out returns (0, nil) — keep polling until the
// overall deadline; a real error ends the read.
if err != nil {
break
}
}
line := strings.TrimSpace(sb.String())
if line == "" {
return "", fmt.Errorf("no reply to %q", cmd)
}
return line, nil
}
// GoTo points the antenna at az (0-359): set the target, then rotate.
func (c *Client) GoTo(az int) error {
az = ((az % 360) + 360) % 360
if _, err := c.roundTrip(fmt.Sprintf("AP1%03d", az), false); err != nil {
return err
}
_, err := c.roundTrip("AM1", false)
return err
}
// Stop halts rotation. The base DCU-1 set has no stop command, so re-command the
// current bearing — the controller stops when the target equals where it is.
func (c *Client) Stop() error {
az, _, err := c.Heading()
if err != nil {
return err
}
_, err = c.roundTrip(fmt.Sprintf("AP1%03d", az), false)
return err
}
// azRe matches the 3-digit bearing in any of the DCU-1 reply framings.
var azRe = regexp.MustCompile(`(\d{3})`)
// Heading queries the current azimuth. Returns the raw reply for diagnostics.
func (c *Client) Heading() (az int, raw string, err error) {
raw, err = c.roundTrip("AI1", true)
if err != nil {
return 0, raw, err
}
m := azRe.FindStringSubmatch(raw)
if m == nil {
return 0, raw, fmt.Errorf("unrecognised azimuth reply %q", raw)
}
az, _ = strconv.Atoi(m[1])
return az % 360, raw, nil
}
+12
View File
@@ -228,6 +228,18 @@ func (c *Client) PowerOn() error {
func (c *Client) PowerOff() error { func (c *Client) PowerOff() error {
err := c.sendCmd(cmdOff) err := c.sendCmd(cmdOff)
applog.Printf("spe: power OFF — SWITCH OFF key sent (err=%v)", err) 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 return err
} }
+126 -39
View File
@@ -23,6 +23,12 @@ const (
ubReadTimeout = 4 * time.Second // was 1s — too tight for a remote link ubReadTimeout = 4 * time.Second // was 1s — too tight for a remote link
ubKeepAlive = 15 * time.Second // OS-level TCP keepalive ubKeepAlive = 15 * time.Second // OS-level TCP keepalive
ubMaxPollTimeout = 3 // consecutive read timeouts tolerated before reconnecting 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 // Protocol constants
@@ -82,8 +88,22 @@ type Client struct {
// yet (Frequency==0) — otherwise the deadband is bypassed and every small QSY // yet (Frequency==0) — otherwise the deadband is bypassed and every small QSY
// re-tunes the motors. // re-tunes the motors.
lastSetKHz int 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, // LastSetKHz returns the frequency (kHz) most recently commanded to the antenna,
// or 0 if none yet. // or 0 if none yet.
func (c *Client) LastSetKHz() int { func (c *Client) LastSetKHz() int {
@@ -221,12 +241,30 @@ func (c *Client) pollLoop() {
} }
c.statusMu.Lock() 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 c.pendingDirSet {
if time.Since(c.pendingDirAt) > 4*time.Second || status.Direction == c.pendingDir { if status.MotorsMoving != 0 {
c.pendingDirSet = false c.pendingDirAt = time.Now() // still repositioning — don't start the grace timer
} else { }
status.Direction = c.pendingDir 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 c.lastStatus = status
@@ -246,7 +284,19 @@ func (c *Client) GetStatus() (*Status, error) {
return &Status{Connected: false}, nil 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 // getNextSeq returns the next sequence number
@@ -277,10 +327,9 @@ func quoteByte(b byte) []byte {
return []byte{b} return []byte{b}
} }
// buildPacket creates a complete packet with checksum and escaping // buildPacket creates a complete packet with checksum and escaping. The seq is
func (c *Client) buildPacket(cmd byte, data []byte) []byte { // supplied by the caller so sendCommand can match the reply against it.
seq := c.getNextSeq() func (c *Client) buildPacket(seq, cmd byte, data []byte) []byte {
// Calculate checksum on unquoted data // Calculate checksum on unquoted data
payload := append([]byte{seq, cmd}, data...) payload := append([]byte{seq, cmd}, data...)
chk := calculateChecksum(payload) chk := calculateChecksum(payload)
@@ -365,39 +414,27 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
return nil, fmt.Errorf("not connected") return nil, fmt.Errorf("not connected")
} }
// Build and send packet // Flush anything already sitting in the stream before we send. The antenna
packet := c.buildPacket(cmd, data) // 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) seq := c.getNextSeq()
if err != nil { packet := c.buildPacket(seq, cmd, data)
if _, err := c.conn.Write(packet); err != nil {
return nil, fmt.Errorf("failed to write: %w", err) 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)) c.conn.SetReadDeadline(time.Now().Add(ubReadTimeout))
buffer, err := c.readPacket()
// Read until we get a complete packet
var buffer []byte
for {
b, err := c.reader.ReadByte()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read: %w", err) return nil, 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")
}
}
// Parse reply
_, replyCmd, payload, err := parsePacket(buffer) _, replyCmd, payload, err := parsePacket(buffer)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse reply: %w", err) 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) // queryStatus queries general status (command 1)
func (c *Client) queryStatus() (*Status, error) { func (c *Client) queryStatus() (*Status, error) {
reply, err := c.sendCommand(CMD_STATUS, nil) reply, err := c.sendCommand(CMD_STATUS, nil)
@@ -433,7 +509,13 @@ func (c *Client) queryStatus() (*Status, error) {
return nil, err return nil, err
} }
if len(reply) < 12 { // An RCU-06 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)) 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), Direction: int(reply[6] & 0x0F),
OffState: (reply[7] & 0x02) != 0, OffState: (reply[7] & 0x02) != 0,
MotorsMoving: int(reply[9]), 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 return status, nil
@@ -513,6 +599,7 @@ func (c *Client) SetFrequency(freqKhz int, direction int) error {
c.statusMu.Lock() c.statusMu.Lock()
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
c.lastSetKHz = freqKhz c.lastSetKHz = freqKhz
c.moveCmdAt = time.Now() // start reporting motion at once — see ubMoveOptimisticWindow
if c.lastStatus != nil { if c.lastStatus != nil {
c.lastStatus.Direction = direction // reflect immediately 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 ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.23.3" appVersion = "0.23.9"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.
+50 -12
View File
@@ -6,19 +6,54 @@ export modes.
## Import ## Import
Import an `.adi` / `.adif` file into the active profile's logbook. On import, **File → Import** (`Ctrl+O`) → pick an `.adi` / `.adif` file the **Import ADIF**
OpsLog parses every field, maps standard fields to columns and keeps unknown but dialog opens with the options below. Import runs into the **active profile's**
valid ADIF fields in a per-QSO **extras** store (editable later). Country / logbook. A file **without a header** (records pasted from another log) imports
zones are enriched from `cty.dat` where missing. A file **without a header** fine. Progress shows live; a summary follows — imported / updated / duplicates /
(records pasted from another log) imports fine. skipped / total — with expandable **error** and **duplicate** lists.
- **Field remapping**: the import dialog can move a field as it reads it — ### Duplicate handling
e.g. the RSGB IOTA contest exports the island reference in `STATE`; add a
`STATE → IOTA` row and it lands where the award looks. A **duplicate** is a record with the same **callsign + UTC minute + band + mode**
- **Fill from profile**: optionally stamps your station fields *and* your as a QSO already in the logbook. Pick how they are treated:
default confirmation statuses (paper QSL, LoTW, eQSL, Club Log, HRDLog,
QRZ.com) on QSOs the file leaves empty — a WSJT-X log carries almost none. | Mode | What it does |
Existing values are never overwritten. |------|--------------|
| **Skip duplicates** *(default)* | Adds only new QSOs; existing ones left untouched. Safe default. |
| **Update duplicates** | Merges the file's non-empty fields (QSL / LoTW / eQSL / QRZ statuses & dates, etc.) onto the matching QSO — fields the file omits are kept. Use to re-sync from Log4OM or LoTW. |
| **Import everything** | Inserts every record, duplicates included. For deliberately merging two overlapping logs. |
### Enrichment options
- **Fix country & zones (cty.dat + ClubLog)** *(on by default)* — recompute
Country, DXCC and CQ/ITU zones from `cty.dat`, overriding the file. Corrects
what contest software exports wrong (e.g. RG2Y tagged Asiatic instead of
European Russia). ClubLog's date-ranged DXpedition overrides are applied on top
per QSO date (e.g. TO2A in 2012 → French Guiana) whenever the ClubLog data is
downloaded. Everything else in the ADIF is kept as-is. *Tip: combine with
**Update duplicates** to re-fix QSOs already in your log.*
- **Fill my station fields from my profile** *(off by default)* — backfill empty
`MY_*` fields (grid, rig, antenna, address, city, state, county, SOTA/POTA ref,
TX power…) plus Operator and Owner callsign from the active profile. Existing
values are kept; only `STATION_CALLSIGN` is left untouched so a mixed-call log
isn't re-routed. It **also** stamps your default confirmation statuses (paper
QSL, LoTW, eQSL, Club Log, HRDLog, QRZ.com — sent & received) on QSOs the file
leaves empty. Enable when importing **your own** log; leave off for someone
else's.
### Field remapping (move fields on import)
Contest software stores the exchange where its own module keeps it. The RSGB IOTA
contest, for example, exports the island reference in `STATE` — imported as-is it
becomes a US state and the IOTA award stays empty. Click **"The file puts a field
in the wrong place…"**, then add a `STATE → IOTA` row (add as many rows as you
need). The destination is filled **only where the file left it blank**.
### Unknown fields
Any valid ADIF field OpsLog doesn't promote to a column is preserved in a per-QSO
**extras** store — editable later and re-exported intact. Nothing valid is
dropped.
## Export ## Export
@@ -41,4 +76,7 @@ not lose your award-reference assignments.
- To move a batch of QSOs between logs, export selected → import into the other - To move a batch of QSOs between logs, export selected → import into the other
profile. profile.
- Re-syncing statuses from another logger (Log4OM, LoTW, QRZ)? Import the file
with **Update duplicates** — it merges the new confirmations onto your existing
QSOs without creating copies.
- Use **Find duplicates** (Tools) after importing to catch overlaps. - Use **Find duplicates** (Tools) after importing to catch overlaps.