Compare commits

...
99 Commits
Author SHA1 Message Date
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
rouggy 87ff0a9e16 chore: release v0.23.3 2026-08-04 16:46:32 +02:00
rouggy 18f9b915c5 fix: PGXL meters/auth/fan, TCI spots+click, PTT-over-CAT, CW <LOGQSO>
- PGXL: remote AUTH password; direct-link meter fallback (VITA-first) with
  250 ms poll + peak-hold; fan mode uses bare "fanmode=" (was ignored).
- TCI: spot colour sent as decimal ARGB (ExpertSDR dropped the hex string);
  spot click handled via CLICKED_ON_SPOT / RX_CLICKED_ON_SPOT to fill the entry.
- FlexRadio: clicking an OpsLog spot on the panadapter now applies the mode too.
- Filter presets: the trash icon deletes again (Radix pointer-down intercept).
- PTT hotkey: keys over CAT when a backend is active (was hijacked by a stale
  Audio-tab RTS/DTR serial setting); AltGr keys allowed; presses logged.
- CW macro <LOGQSO>: logs after the preceding CW is sent, not on the first letter.
2026-08-04 15:55:10 +02:00
rouggy b3fdd0b7fa feat(rotator): multiple rotors of any type, plus a CAT PTT hotkey
Settings -> Rotator is now a list like the amplifiers: add/remove rotors,
mix PstRotator / Rotator Genius / ARCO, and a Rotator Genius can drive both
its ports (one entry = two rotors). The compass gains a rotor selector, and
a per-rotor motorized-antenna flag so the boom/pattern paths show only for
the rotor carrying the Ultrabeam/SteppIR.

Also in this batch: a keyboard PTT hotkey (hold-to-talk or toggle, reusing
the audio PTT method with a CAT fallback), and TCI spot push to the
panadapter with click-to-fill of the callsign.
2026-08-04 09:01:21 +02:00
rouggy 9846354bf9 chore: release v0.23.2 2026-08-02 23:51:46 +02:00
rouggy 3da1d71323 chore: release v0.23.1 2026-08-02 20:29:30 +02:00
rouggy 72f692aa04 chore: release v0.23.0 2026-08-02 20:03:16 +02:00
rouggy f6cd6e999a chore: release v0.22.9 2026-08-02 06:40:10 +02:00
rouggy 296a4a55c0 refactor(cat): one DTR/RTS setting per backend, none shared
The 0.22.7/0.22.8 breakage came from a Xiegu-reported fault being fixed
inside the Yaesu and Kenwood backends. The lesson is structural: nothing
a backend does may be steered by another backend's report or another
backend's setting.

The lower-lines checkbox introduced for 0.22.9 was still one shared key
applied to both Yaesu and Kenwood — the same reflex in miniature. It is
now cat.yaesu.low_dtr_rts and cat.kenwood.low_dtr_rts, each checkbox in
its backend's own settings block, each wired only to its backend. The
shared key never shipped, so nothing needs migrating.
2026-08-01 20:35:57 +02:00
rouggy acf73c73c7 fix(panels): the wheel moved whatever slider the pointer was over
The rig-panel sliders take a non-passive wheel listener and acted on
hover, so scrolling the panel fed the gesture to whichever control sat
under the cursor — RF power included, mid-QSO. An operator scrolling to
read the bottom of the panel could change his transmit power without
touching anything.

They now require focus, i.e. a deliberate click on that slider first.
Without it the event is left alone and the panel scrolls, which is what
the operator meant. Same fix on the Flex and Yaesu panels, which share
the pattern.

This is a fault in its own right. It is NOT an explanation for the
"transmit power moves since 0.22.8" report: that release changed nothing
in the Icom path, and this behaviour is older than it.
2026-08-01 20:00:03 +02:00
rouggy 5777c119cb feat(cat): make the DTR/RTS lowering a setting
Both defaults break someone. 0.22.7 lowered the lines on Yaesu and
Kenwood and silenced a TS-990 whose interface needs RTS raised to
transmit; 0.22.8 stopped lowering them and an FT8 station reported its
output power wandering, cured by closing OpsLog — Windows raises both on
open and the interface reads one as PTT.

So it is now a checkbox on those two backends, off by default: that is
how they behaved before any of this. Xiegu keeps lowering them
unconditionally (its own report, its own explicit keying setting), and
the CI-V backend keeps the behaviour it has always had.
2026-08-01 19:25:04 +02:00
rouggy 85bf0da006 fix(awards): the mode filter only hid rows, it did not filter the award
Selecting CW showed FT8 and SSB contacts. The filter ran client-side over
the reference list and nothing else, so a reference kept because it had
one CW contact still displayed the band cells it earned on SSB, still
counted its SSB confirmations, and opening it listed every contact
regardless of mode.

The class now narrows the log BEFORE the engine runs, so the bands, the
totals and the confirmations all describe the selected mode, and
AwardCellQSOs takes the same class. The panel cache is keyed by
"CODE|MODECLASS" — one key per award showed the previous mode's numbers
after switching.

The snapshot is cached by log revision, so this costs a matching pass and
no database read.
2026-08-01 16:10:22 +02:00
rouggy b6ea07e3a3 chore(changelog): open the 0.22.9 block 2026-08-01 13:20:16 +02:00
rouggy 549adc8c0e chore: release v0.22.8 2026-08-01 13:19:49 +02:00
rouggy 0e1e7d9f3c feat(cat): ID 022 is a TS-990S
Reported by one in the field. Kenwood's documentation gives 024 for that
radio; both are mapped rather than betting on which is right, and the log
no longer reads "Kenwood (022)".
2026-08-01 12:09:57 +02:00
rouggy 3dd428d748 fix(cat): stop deasserting DTR/RTS on Kenwood and Yaesu
d4bfd30 fixed a Xiegu G90 behind a DE-19 that keyed on connect, and I
applied the same line-lowering to Kenwood and Yaesu with no evidence that
either needed it. It silenced them: many USB-serial interfaces will not
transmit with RTS low — hardware flow control, or an output stage the
line enables. A TS-990 on COM3 opened cleanly and answered nothing, which
reached us as "CAT stopped working after the update".

Xiegu keeps the behaviour: that is where the fault was reported, and that
backend now has an explicit setting for which line keys the rig. The CI-V
backend keeps its own, which predates all of this.

Kenwood also distinguishes a silent port from one sending data that never
answers, and quotes what arrived — "the rig is not answering" sent an
operator checking the power switch on a radio whose frames were visibly
on the wire.
2026-08-01 12:09:11 +02:00
rouggy c62d992ad6 fix(cat): keep the Kenwood read buffer across commands; log the CAT error
A TS-480 connected, answered ID, then never reported a state:

  discarding " 000000000010000000;" while waiting for IF
  connected on COM3 @ 115200 baud
  cat:state → connected=false freq=0

A headless frame tail can only come from bytes that were thrown away.
ask() buffered into a local slice, so everything left after the matched
frame — including the head of a frame still arriving — went with it, and
the link could sit one answer behind its questions. The buffer now lives
on the backend, and Connect drains whatever the rig said before AI0 took
effect rather than inheriting it.

Also: the cat:state diagnostic prints RigState.Error. It read
"connected=false freq=0" and said nothing about why, so every report of a
CAT failure arrived without the one fact that explains it — the reason
only ever reached a tooltip.

New work moves to 0.22.8; 0.22.7 is released.
2026-08-01 12:04:32 +02:00
rouggy a83acb0f9a feat(main): draggable divider between the two panes
The Main tab was a fixed 50/50 grid, but a map and a cluster list want
very different widths and which one deserves the room changes with what
the operator is doing.

The share is clamped to 15..85: a pane can be made small but never
dragged out of existence, because recovering from that would mean
grabbing a divider no longer on screen. Double-click restores even.

The drag uses pointer capture on the divider — without it Leaflet
swallows the moves as soon as the cursor crosses the map. Persisted
through writeUiPref, so it travels with data/ like the other UI prefs.
2026-08-01 11:11:32 +02:00
rouggy d8b7b86eb7 chore: release v0.22.7 2026-08-01 10:05:00 +02:00
rouggy 3e1c36d186 docs(changelog): DARC DOK award updated to v2 (one reference per QSO) 2026-08-01 09:47:20 +02:00
rouggy 6255a9f218 feat(details): NEW badge on a county never worked
The cluster already answers this question for a spot; the entry panel did
not answer it for the station actually being worked. A county hunter
needs to know before the QSO ends — by the time the county shows up in
the awards table the station is long gone.

qso.CountyWorked asks about one county instead of loading the whole
worked set, narrowed to the state so it stays a few dozen rows on a
remote MySQL. It compares through award.USCountyKey on both sides rather
than in SQL, because logged spellings vary ("Los Angeles" against
"LOS ANGELES, CA") and that key function is what resolves it everywhere
else.

An empty or non-US county is not "new" but unknown, and shows nothing: a
badge on a guess is worse than no badge.
2026-08-01 01:46:32 +02:00
rouggy 599621a998 fix(ui): drop the focus ring on tab panels
Radix gives the content element focus when a tab is selected, so the
focus-visible ring fired on an ordinary click and outlined the whole
lower pane — visible as an orange rule under the tab strip. A tab panel
is a container; the controls inside it carry their own focus styling.
2026-08-01 01:38:23 +02:00
rouggy d7bcf21203 style(details): label the zone fields CQ and ITU
At two grid columns the labels wrapped onto a second line, which set the
row height for everything beside them. The unit is obvious from the
neighbouring DXCC # field and the numbers themselves.
2026-08-01 01:25:31 +02:00
rouggy 589e0b9b6a style(details): size the Info tab fields to their contents
Six equal columns gave a US county name the same width as a two-digit CQ
zone: the county truncated while the zones, the prefix and the DXCC
number sat mostly empty. The row is twelve columns now, spans set per
field.

The bearing and the two distances are read-only and never exceed
"12345 km", so they move to a flex row at a fixed width and the address —
the one field here that was never wide enough — takes the remainder.
2026-08-01 01:22:35 +02:00
rouggy c21d6cc23a fix(awards): live detection only saw the address, never the QTH
The award editor's Test tab matched DK2FJ on its OR rule "qth / exact"
and reported the two Aachen DOKs as ambiguous. The F3 panel, on the same
contact, showed nothing — it builds its own QSO for ComputeQSOAwardRefs
and that object carried address, state, county, zones and continent only.
Any award keyed on the town could not fire there, so live detection was
silently blind to a whole class of awards and the operator only saw them
after logging.

It now passes the entry-strip text as well: QTH, name, country, comment,
note and grid.
2026-08-01 01:15:05 +02:00
rouggy ae21ddb9d7 feat(awards): offer the ambiguous references at log time
The refusal to guess was only visible in the award editor's test tab and
in the awards table — both of them places an operator goes long after the
contact is over. It has to appear while the station is still in front of
you, so ComputeQSOAwardRefs now reports the candidates it declined to
assign and the F3 panel lists them as buttons: click one and it is
written to award_refs, replacing any sibling picked for that award.

They are never auto-added — adding one would be exactly the guess the
setting exists to refuse.

Also: the dark-theme fix for the native calendar glyph named only
input[type=date], so date-time fields kept an invisible icon. All the
temporal input types now share the rule.
2026-08-01 01:10:59 +02:00
rouggy a4623e9ea3 feat(awards): refuse an ambiguous match on one-reference awards
Two DARC DOKs are both called "Gießen" — two clubs share the town. Exact
matching finds both and both are right as far as the matcher can tell, so
it kept both and the operator got a QSO credited to a DOK that may well
have been the other one.

Picking one is not an option: it writes a reference into the log with no
evidence behind it. So a Def can now be marked one reference per QSO, and
an ambiguous match on such an award assigns none, names the candidates in
the trace and leaves the contact under missing references — where the
operator's choice now sticks (06e3437).

Off by default, and deliberately NOT a revival of the old `Multi` flag: a
field holding several references (an n-fer POTA activation, a VUCC grid
line) is several references, correctly, and stays that way.
2026-08-01 01:04:17 +02:00
rouggy 06e34372ed fix: a hand-assigned award reference no longer loses to the matcher
Reported on the DARC DOK award, with DL2FDM: a German address matches two or
three DOKs, the operator deletes the wrong ones and assigns the right one, and
the next recompute puts the wrong ones straight back.

The manual reference was APPENDED to what the matcher found. Storage already
kept exactly one manual entry per award — setOverrideRef drops the previous one
— so it was an override everywhere except at the one place it mattered. The
operator was arguing with the matcher and could not win.

It now replaces. The trace records what was superseded, so a corrected QSO can
still be explained rather than being merely different. A QSO with no correction
keeps every automatic reference, which the test also pins: an override must not
quietly disable matching for everyone else.

This does not address the over-matching itself — an address containing "Berlin
Mitte, 10115 Berlin" genuinely matches two DOK descriptions. That is the next
question, and a separate one.
2026-08-01 00:57:34 +02:00
rouggy d23c4c4037 fix: RST fields too narrow for a 59+30 report
I took the callsign field's extra width from the RST pair, on the assumption
that five characters was the most they would ever hold. A signal report of
"59+30" is exactly five characters plus its padding, and it was clipped.

The RST fields are back to their original width. The callsign keeps its extra
room — the row has space for both.
2026-08-01 00:50:48 +02:00
rouggy d4bfd30636 fix: serial CAT keyed the radio on connect; Xiegu can key on RTS/DTR
Reported on a Xiegu G90. With the blue programming cable — three wires, no
modem lines — CAT works perfectly. Behind a DE-19, which carries audio, CAT and
PTT, the radio went into transmit the moment OpsLog connected and stayed there.

Windows raises DTR and RTS when a serial port is opened, and the DE-19 reads
them as PTT; Xiegu's own documentation asks for both low. The CI-V backend has
dropped them since it was written, for the same reason on Icom rigs with USB
SEND mapped to a line. Xiegu, Yaesu and Kenwood did not. They do now.

The second half of the report: WSJT-X through OpsLog's rigctld decoded fine and
never transmitted. Nothing was broken in that chain — set_ptt reaches the
backend, which sends the CI-V PTT command, which a G90 ignores. That is why
Xiegu keys on a hardware line instead. The backend can now do that: Settings →
CAT → Xiegu → how the rig is keyed (CI-V / RTS / DTR).

Untested here — no G90 in reach. The operator who reported it offered to try.
2026-07-31 22:16:03 +02:00
rouggy 258fa717f1 docs: give 0.22.6 its own changelog block
Nine entries had been appended to the 0.22.5 block after 0.22.5 was already out,
so what shipped as 0.22.6 had no block at all — and the release script said so:
"changelog.json has no 'fr' entries for 0.22.6 - skipping". No Discord
announcement, and no What's-new for anyone updating.

The split is not guesswork: the changelog at tag v0.22.5 carried exactly one
entry, so everything after it belongs to 0.22.6.
2026-07-31 22:06:19 +02:00
rouggy aa995c250c chore: release v0.22.6 2026-07-31 22:04:09 +02:00
rouggy 72fee7a090 fix: a quiet cluster was treated as a dead one and reconnected
Reported by an operator whose node sends little: nothing for two minutes, then a
reconnect — losing the login, the filters, and any spot that arrived during the
gap.

The read had a 120 s deadline and ANY error ended the session, a timeout
included. That reads a silence as a verdict on the link. It is not: a cluster on
a dead band legitimately says nothing for minutes.

Only a real error ends a session now. A genuinely dead peer is still caught, and
by the mechanism meant for it — TCP keepalive probes an idle connection and its
failure surfaces as an error on Read, not as a timeout. The dialer sets it
explicitly rather than inheriting a default, since it is now what the design
depends on.

One trap the advice this came from did not mention: ReadString returns the bytes
it HAD read along with the timeout. Discarding them would lose the first half of
any spot line straddling a deadline, so the partial is carried over.

Tested: a listener that goes silent past the deadline and then sends a spot —
the spot arrives on the ORIGINAL connection, and the listener never accepts a
second one. The tick is a var so that test runs in four seconds instead of
sixty; a slow test is a test that gets skipped.
2026-07-31 21:48:05 +02:00
rouggy ed67ed7fe3 fix: replaying a QSO used the voice keyer's level and overdrove the rig
A panadapter shot settles it: answering an S5 station, the replayed recording
goes out several times wider and taller than the station being answered — in WAV
as well as MP3, so this is not the encoder.

One setting served two sources that have nothing in common. A voice-keyer
message is a microphone at speaking distance and legitimately wants 195%; a QSO
recording is a receiver's line output, which is far hotter. The same 195% put it
into the transmitter flat out, ALC pinned, noise and voice alike.

The QSO playback now has its own level, defaulting to 100%. The voice keyer
keeps the setting it had, so nobody's F-key messages change.
2026-07-31 21:32:55 +02:00
rouggy fddb3c45c4 fix: MP3 recordings carried a hiss the WAV of the same audio did not
The operator split it in one test: WAV is fine, MP3 is not. The MP3 path is the
only one that resamples — 16 kHz up to 32 kHz, because the encoder emits broken
frames at MPEG-2 rates — and it did so by averaging neighbouring samples.

That is an upsampler in name only. Every component at f is mirrored to
16 kHz − f, and the test measures the mirror just 7 dB below the wanted signal:
a second copy of the whole spectrum. On speech it adds brightness; on receiver
noise, which is broadband, it lays a second noise floor over the top of the
band. Hence "a hiss that is not the QRM, louder than the voice" — and hence its
absence from the WAV, which resamples nothing.

Zero-stuffing plus a 63-tap windowed sinc at the old Nyquist puts the image
56.8 dB down instead of 7, at a cost measured in microseconds against the MP3
encode that follows. Two tests pin it: the image suppression, and that the level
is unchanged — a fix that quietly halved every recording would be a second
surprise on top of the one being repaired.
2026-07-31 21:27:57 +02:00
rouggy da1f3eb2bd fix: say which devices a recording is captured from
A station with two radios has two sets of audio endpoints, and the recorder will
happily capture the one nobody is listening to — a Flex DAX channel while the
operator works a Yaesu over USB. The file is then full of hiss with no relation
to what they hear, unaffected by the rig's AGC, and nothing anywhere said which
receiver it came from.

The device names are now logged, resolved to their friendly names, once when
capture starts.
2026-07-31 21:20:35 +02:00
rouggy 5e0bb6e68e fix: recordings buried under hiss — the cheap resampler was folding it in
Reported precisely: on the air the voice is well clear of the noise; in the
recording the noise is louder than the voice. Same audio, so the fault is in how
it is captured, not in what is captured.

AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY was set alongside AUTOCONVERTPCM. The
name reads like a sensible default and is not: it selects the CHEAP sample-rate
converter, for cases where quality does not matter. Taking a rig's 48 kHz stream
down to our 16 kHz, it folds everything above 8 kHz back into the audio band —
and receiver hiss is mostly high frequencies. The voice keeps its level, the
noise arrives twice.

Dropped, so Windows uses its normal converter, which filters before decimating.
This affects every capture and render path in the app, the voice keyer included.
2026-07-31 21:12:59 +02:00
rouggy 2b61f43780 fix: a level set by the slider was lost when a recording started
The monitor followed the slider, but two takes made at 10% and 300% came out
identical: starting a recording re-reads the STORED settings and calls SetGains
with them, so the live value was overwritten the moment a take began.

The slider now persists as it applies. Saving Settings still works as before —
this only means the stored value is already right when a take starts, which is
what every path downstream reads.
2026-07-31 21:04:20 +02:00
rouggy 2b66e5bc7f fix: the audio level sliders did nothing until Settings were saved
Reported again after the monitor was wired up: 300% still sounded like 10%. The
monitor fix was right but could not show, because the sliders only changed the
settings panel's own state — the value reached the audio engine when the
operator clicked Save.

A level is set by ear, by dragging. It now applies on every move, to the monitor
and to the recorder mix alike; saving still persists it. Both halves were needed
and neither works alone.
2026-07-31 20:58:40 +02:00
rouggy 84b06ed47b fix: padlocks unclickable, From-radio level inert, callsign field too narrow
Padlocks. LockBtn was declared INSIDE the App component, so every render gave
React a new component type and the button was unmounted and rebuilt — about four
times a second while the CAT polls. It flickered under the pointer and swallowed
clicks, because the node being clicked no longer existed when the click landed.
The padlock now lives at module level and is passed its state.

From-radio level. It reached the QSO recorder and nothing else, so an operator
listening through OpsLog heard no difference between 10% and 150% — the control
was not weak, it was disconnected. It now scales the monitor too, applied on the
captured chunk so the network-fed path keeps its own levels, and a running
monitor picks up a change immediately: making someone restart the monitor to
hear the slider is how a working control gets reported as broken.

Callsign field widened to w-56, the room coming from the RST pair which never
needs more than five characters. It is the one field always typed into, it
carries the REC badges, and a long portable call has to stay readable.
2026-07-31 20:33:39 +02:00
rouggy 39bd1ff414 feat: the play button stops the transmission while it is going out
A second press restarted the message from the beginning. What the operator wants
there is to cut it short — enough heard, or pressed by mistake with the
transmitter keyed — and then to be able to send it again at once.

It now shows a stop square while playing and releases the PTT through the same
path the natural end uses, so nothing is left keyed. Together with the device
handover fixed a moment ago, sending again immediately afterwards works.
2026-07-31 20:23:03 +02:00
rouggy 6d54100bc9 fix: a new playback raced the previous one for the audio device
Reported precisely: press play again and it stops the current message, but you
then have to wait the whole length of the message before it will play — otherwise
the radio is keyed and the PTT released at once.

Play only SIGNALLED the previous playback to stop and started a new one straight
away. The old goroutine still held the WASAPI render client for a moment, so the
new client could not start; it returned immediately and the PTT tail was all that
was left. The log now shows both halves of that: a play line, then a release
120 ms later.

Stopping now waits for the goroutine to have returned — the point of stopping
before starting again — bounded at 1.5 s so a wedged device cannot freeze the
operator's click, and saying so in the log if it ever comes to that.
2026-07-31 19:05:09 +02:00
rouggy 4fe5811fd2 fix: a playback that never starts no longer fails silently
From an operator's log, playing a recording on the air repeatedly:

  18:52:40.148 play → 18:52:48.420 PTT released   8.2 s, the whole take
  18:52:57.759 play → 18:52:57.879 PTT released   0.12 s, nothing came out

Those 120 ms are the PTT release tail alone: the render device refused to start
and playPCM returned an error that was thrown away. The call reported success
every time, which is what "it plays once and then never again" looks like from
the outside — and why the search went to the recorder, which was innocent.

The error is now logged with the device name.
2026-07-31 18:55:48 +02:00
rouggy d42e6669e1 fix: name the field when playback has no output device
An operator emptied "Recording mic" and got "no audio output to the radio is
configured", and read it as a restriction: that a recording of the other station
alone cannot be played back. No such rule exists — the mic is an INPUT, one more
source to record, while this message is about the OUTPUT that feeds the
transmitter.

The message now names the setting exactly as the panel labels it, and the log
line carries the other two device values so the next report says which field was
actually empty.
2026-07-31 18:50:48 +02:00
rouggy c14353a399 test: playing a recording must not consume it
Reported: the recording plays once and never again, "as if it threw the sound
away after playing it". PeekQSO is what the playback reads and it is meant to
copy — TakeQSO is the one that clears — so this pins that, and that the copy is
independent of the recorder's own buffer.

It passes, which is the useful part: the take survives, so the one-shot failure
is downstream of it and the search moves to the file and the player.
2026-07-31 18:45:49 +02:00
rouggy 734931cf54 chore: release v0.22.5 2026-07-31 17:18:18 +02:00
rouggy cad4ec4b72 feat: filter cluster spots on new POTA and new county
The grid already badges them; only the filter was missing.

They are not entity statuses — a new park or county is another dimension of the
same spot — so the filter key is now a superset of the status, and matching is
an OR across all of them, as it already was for a previously-worked callsign.
The chips carry the colours their badges use in the grid: a filter that does not
look like what it selects has to be learned twice.
2026-07-31 17:04:01 +02:00
92 changed files with 47559 additions and 1309 deletions
+1206 -334
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)
}
}
}
+18902
View File
File diff suppressed because it is too large Load Diff
+282
View File
@@ -1,4 +1,286 @@
[
{
"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",
"date": "",
"en": [
"Multiple rotors: Settings → Rotator is now a list, like the amplifiers — add rotors with “Add rotor”, remove with the trash icon. Mix any types (an ARCO and a Rotator Genius); a Rotator Genius set to “drives two rotors” adds a second one. The compass has a selector to pick the active rotor, which it turns, displays and points to the DX; each rotor is named, and you mark which one carries the motorized antenna (Ultrabeam/SteppIR) so the boom and reverse/bidirectional pattern paths show only for it.",
"CAT PTT hotkey: pick a key in Settings → CAT and it keys the transmitter over CAT while OpsLog is focused — hold-to-talk or toggle (a rig with no CAT link falls back to the Audio-tab RTS/DTR method). Keys that need AltGr on your layout (e.g. \"\\\" on AZERTY) work, and each press is written to the diagnostic log.",
"TCI spots (ExpertSDR / SunSDR): OpsLog's cluster spots now show on the panorama — the colour is sent as the decimal integer the protocol expects (a hex string was silently dropped), with a valid modulation — and clicking one fills the entry (callsign + frequency) via the CLICKED_ON_SPOT message.",
"FlexRadio spot click: clicking one of OpsLog's spots on the SmartSDR panadapter now switches the mode too, not just the frequency.",
"PowerGenius XL: the widget and the FlexRadio amplifier card now read forward power, drain current and temperature from the amp's own link (polled every 250 ms with peak-hold), so the meters keep working — and stay steady — over a remote link where the radio's VITA stream never reaches OpsLog. Adds a remote-access password (Settings → Amplifier) to reach the amp from outside the LAN, and fixes the fan mode (Standard/Contest/Broadcast), which was reverting to Contest.",
"CW macro <LOGQSO>: a macro ending in the token (e.g. \"BK UR <STX> TU <LOGQSO>\") now logs the QSO once the CW before it has been sent, not the instant the macro starts — it was logging on the first keyed letter.",
"Filter presets: the trash icon next to a saved preset deletes it again — clicking it was loading the preset instead of removing it.",
"Kenwood/Elecraft (K3/K4): data modes (FT8/PSK/JT) now go out on USB on every band — below 10 MHz they were landing on LSB, which a K3 shows as \"DATA REV\". The frequency refreshes promptly again (the split double-check no longer runs on every poll). And a new CW keyer engine (Settings → CW Keyer → Kenwood/Elecraft) sends CW over the CAT link with the KY command — no WinKeyer and no second COM port — for a rig whose single USB port is the CAT port. Needs on-air testing on a K3."
],
"fr": [
"Plusieurs rotors : Réglages → Rotator est désormais une liste, comme les amplis — ajoutez des rotors avec « Ajouter un rotor », supprimez avec la corbeille. Mélangez les types (un ARCO et un Rotator Genius) ; un Rotator Genius réglé sur « pilote deux rotors » en ajoute un second. Le compas a un sélecteur pour choisir le rotor actif, qu'il tourne, affiche et pointe vers le DX ; chaque rotor est nommé, et vous indiquez lequel porte l'antenne motorisée (Ultrabeam/SteppIR) pour que les tracés de boom et de diagramme inverse/bidirectionnel n'apparaissent que pour lui.",
"Touche PTT CAT : choisissez une touche dans Réglages → CAT et elle passe la radio en émission via CAT quand OpsLog a le focus — maintien ou bascule (une radio sans liaison CAT retombe sur la méthode RTS/DTR de l'onglet Audio). Les touches nécessitant AltGr (ex. « \\ » en AZERTY) fonctionnent, et chaque appui est tracé dans le journal de diagnostic.",
"Spots TCI (ExpertSDR / SunSDR) : les spots cluster d'OpsLog apparaissent sur le panorama — la couleur est envoyée en entier décimal attendu par le protocole (une chaîne hexadécimale était silencieusement rejetée), avec une modulation valide — et cliquer un spot remplit la saisie (indicatif + fréquence) via le message CLICKED_ON_SPOT.",
"Clic sur un spot FlexRadio : cliquer un spot d'OpsLog sur le panadapter SmartSDR change désormais aussi le mode, plus seulement la fréquence.",
"PowerGenius XL : le widget et la carte ampli du panneau FlexRadio lisent maintenant puissance directe, courant de drain et température depuis la liaison propre de l'ampli (interrogée toutes les 250 ms avec maintien de crête) — les meters continuent donc de fonctionner, et restent stables, sur une liaison distante où le flux VITA de la radio n'atteint jamais OpsLog. Ajoute un mot de passe d'accès distant (Réglages → Amplificateur) pour joindre l'ampli hors du réseau local, et corrige le mode ventilateur (Standard/Contest/Broadcast) qui repassait sur Contest.",
"Macro CW <LOGQSO> : une macro se terminant par le token (ex. « BK UR <STX> TU <LOGQSO> ») journalise le QSO une fois le CW qui précède envoyé, et non à l'instant où la macro démarre — elle journalisait dès la première lettre manipulée.",
"Presets de filtre : la corbeille à côté d'un preset enregistré le supprime de nouveau — le clic chargeait le preset au lieu de le retirer.",
"Kenwood/Elecraft (K3/K4) : les modes data (FT8/PSK/JT) partent désormais en USB sur toutes les bandes — sous 10 MHz ils passaient en LSB, ce qu'un K3 affiche comme « DATA REV ». La fréquence se rafraîchit de nouveau rapidement (la double-vérification du split ne se fait plus à chaque cycle). Et un nouveau moteur de keyer (Réglages → Keyer CW → Kenwood/Elecraft) envoie le CW sur la liaison CAT avec la commande KY — sans WinKeyer ni second port COM — pour une radio dont l'unique port USB est le port CAT. À tester sur l'air avec un K3."
]
},
{
"version": "0.23.2",
"date": "",
"en": [
"Lookup: the locator now keeps whichever is more precise. A 4-character square from QRZ or HamQTH no longer overwrites the full 6-character locator confirmed on a previous contact.",
"LoTW badge: when ARRL's own user list has not been rebuilt for a while, the tooltip says so with its date — a station shown as \"uploaded 7 days ago\" may simply be sitting in a frozen list.",
"FlexRadio decode spots: with two WSJT-X instances on two slices, decodes are no longer placed on the wrong panadapter, and a station heard on both is now spotted on both.",
"DX cluster: a spot glued to the login prompt is no longer dropped. Clusters that print the prompt without a newline cost you the first spot after connecting.",
"Help → Diagnostic log: read the log live, without leaving OpsLog. Filter the lines, and follow the end or scroll back freely.",
"Entity stats: the DX station's sunrise and sunset (UTC) now show on the right, next to Most Wanted. Midnight sun and polar night are named as such."
],
"fr": [
"Recherche : le locator garde désormais le plus précis des deux. Un carré en 4 caractères venant de QRZ ou HamQTH n'écrase plus le locator complet en 6 caractères confirmé lors d'un contact précédent.",
"Badge LoTW : quand la liste ARRL elle-même n'a pas été régénérée depuis un moment, l'infobulle le dit avec sa date — une station annoncée « dernier envoi il y a 7 jours » peut simplement figurer dans une liste figée.",
"Spots de décodes FlexRadio : avec deux instances WSJT-X sur deux slices, les décodes ne s'affichent plus sur le mauvais panadapter, et une station entendue sur les deux est désormais spottée sur les deux.",
"Cluster DX : un spot collé à l'invite de connexion n'est plus perdu. Les clusters qui affichent leur invite sans retour à la ligne coûtaient le premier spot après la connexion.",
"Aide → Journal de diagnostic : lire le journal en direct sans quitter OpsLog. Filtrage des lignes, et suivi de la fin qu'on peut relâcher pour remonter.",
"Statistiques d'entité : le lever et le coucher du soleil de la station DX (UTC) s'affichent à droite, à côté du Most Wanted. Soleil de minuit et nuit polaire sont nommés."
]
},
{
"version": "0.23.1",
"date": "",
"en": [
"DX cluster: NEW COUNTY is green, like NEW POTA.",
"CW: pressing ESC during a macro no longer turns the report from 599 into 59 when \"ESC clears the callsign too\" is on."
],
"fr": [
"Cluster DX : NOUVEAU COMTÉ passe en vert, comme NOUVEAU POTA.",
"CW : appuyer sur Échap pendant une macro ne fait plus passer le report de 599 à 59 quand l'option « Échap efface aussi l'indicatif » est cochée."
]
},
{
"version": "0.23.0",
"date": "",
"en": [
"Station Control: Dingtian IOT relay boards (2 to 32 relays, LAN or WiFi) can now be added.",
"ACOM amplifiers: the card now shows TUNE while the tuner is running, instead of an unknown state.",
"DX cluster: the status pills are gone. The call, band and mode turn yellow when that one is new, and a callsign already in the log turns blue.",
"Reports: starting OpsLog with the rig already in CW no longer leaves the RST fields on 59 — they now follow the mode you are actually on. (Thanks HB9HBY)",
"DX cluster: NEW PFX marks a spot whose CQ WPX prefix has never been worked, in the status column and as a filter."
],
"fr": [
"Contrôle station : les cartes relais Dingtian IOT (2 à 32 relais, LAN ou WiFi) peuvent désormais être ajoutées.",
"Amplificateurs ACOM : la carte affiche TUNE pendant l'accord, au lieu d'un état inconnu.",
"Cluster DX : les pastilles de statut disparaissent. L'indicatif, la bande et le mode passent en jaune quand celui-ci est nouveau, et un indicatif déjà dans le log passe en bleu.",
"Reports : lancer OpsLog avec le poste déjà en CW ne laisse plus les RST à 59 — ils suivent maintenant le mode réellement en cours. (Merci HB9HBY)",
"Cluster DX : NOUVEAU PFX signale un spot dont le préfixe CQ WPX n'a jamais été contacté, dans la colonne de statut et en filtre."
]
},
{
"version": "0.22.9",
"date": "",
"en": [
"Awards: the CW / Phone / Digital filter now applies to the whole award — bands, counts, confirmations and the contacts behind a cell — instead of only hiding rows from the reference list.",
"Yaesu and Kenwood: a new option lowers the DTR and RTS lines on connect, for interfaces that read either as PTT and keep the radio transmitting while OpsLog is open. Off by default — lowering them stops other interfaces transmitting at all.",
"SPE amplifiers: the ON and OFF buttons now switch the amplifier on and off over USB.",
"Quieter diagnostic log: no more FlexRadio meter subscriptions, per-packet WSJT-X/MSHV lines, or repeated Tuner Genius / PGXL / SPE status frames.",
"Station Control: the cards now line up on a grid — same width, and same height across a row — and the column selector goes up to 6.",
"New amplifier widget beside the keyers (flame icon): Operate, power ON/OFF, the SPE power level and live watts / SWR / temperature. With several amplifiers a dropdown on the icon picks the one to watch, or all of them.",
"Icom: the meters and front-panel readings are polled only while the Icom console is on screen. They were half of the CI-V traffic at all times, which matters when the link is shared — a microHAM Router splitting one CI-V port between OpsLog and WSJT-X, for instance.",
"Icom: the power-ON button sends a wake preamble sized for the CI-V speed. It was a fixed length that only suited 19200 baud, so a radio set faster — 115200 is the default — ignored the command.",
"The Tuner Genius power meter now shows the peak the device itself measured, instead of whatever instant the poll happened to catch — a kilowatt transmission could read a few hundred watts. Every power meter also clears the moment the carrier drops, instead of trailing several seconds behind it."
],
"fr": [
"Diplômes : le filtre CW / Phonie / Numérique sapplique désormais à tout le diplôme — bandes, comptes, confirmations et QSO derrière une case — au lieu de masquer seulement des lignes de la liste des références.",
"Yaesu et Kenwood : une option abaisse les lignes DTR et RTS à la connexion, pour les interfaces qui les lisent comme PTT et laissent la radio en émission tant quOpsLog est ouvert. Désactivée par défaut — les abaisser empêche dautres interfaces d’émettre.",
"Amplificateurs SPE : les boutons ON et OFF allument et éteignent désormais lamplificateur par lUSB.",
"Journal de diagnostic allégé : plus dabonnements aux mètres FlexRadio, de ligne par paquet WSJT-X/MSHV, ni de trames statut Tuner Genius / PGXL / SPE répétées.",
"Contrôle station : les cartes salignent désormais sur une grille — même largeur, et même hauteur sur une ligne — et le sélecteur de colonnes va jusqu’à 6.",
"Nouveau widget amplificateur à côté des manipulateurs (icône flamme) : Operate, marche/arrêt, le niveau de puissance SPE et les watts / ROS / température en direct. Avec plusieurs amplis, une liste déroulante sur licône choisit celui à surveiller, ou tous.",
"Icom : les mesures et les réglages de façade ne sont interrogés que lorsque la console Icom est affichée. Ils représentaient la moitié du trafic CI-V en permanence, ce qui compte quand la liaison est partagée — un Router microHAM qui répartit un port CI-V entre OpsLog et WSJT-X, par exemple.",
"Icom : le bouton de mise sous tension envoie un préambule de réveil dimensionné pour la vitesse CI-V. Sa longueur était fixe et ne convenait qu’à 19200 bauds, si bien quune radio réglée plus vite — 115200 par défaut — ignorait la commande.",
"Le mètre de puissance du Tuner Genius affiche désormais la crête mesurée par lappareil lui-même, au lieu de linstant capté par linterrogation — une émission dun kilowatt pouvait afficher quelques centaines de watts. Tous les mètres de puissance retombent aussi dès la fin de l’émission, au lieu de traîner plusieurs secondes."
]
},
{
"version": "0.22.8",
"date": "",
"en": [
"Main tab: drag the divider between the two panes to resize them. The setting is remembered and travels with the data folder; double-click the divider to even them out.",
"Kenwood: bytes arriving glued to the previous answer are no longer discarded, and the link starts from a clean buffer — a rig whose replies ran together could end up one answer behind and never report connected.",
"The CAT log line now carries the error explaining a disconnection.",
"Kenwood and Yaesu CAT connect again: 0.22.7 lowered the DTR and RTS lines on those ports, which stops many USB-serial interfaces from transmitting. Only Xiegu, where the problem was reported, still does it.",
"Kenwood: a port that opens but sends nothing is now reported differently from one sending data that does not answer, and the error quotes what arrived."
],
"fr": [
"Onglet Principal : faites glisser le séparateur entre les deux volets pour les redimensionner. Le réglage est mémorisé et suit le dossier de données ; double-clic pour les égaliser.",
"Kenwood : les octets reçus collés à la réponse précédente ne sont plus jetés, et la liaison démarre sur un tampon vide — une radio dont les réponses senchaînent pouvait rester une réponse en retard et ne jamais apparaître connectée.",
"La ligne de journal CAT indique désormais lerreur expliquant une déconnexion.",
"Le CAT Kenwood et Yaesu se connecte de nouveau : la 0.22.7 abaissait les lignes DTR et RTS sur ces ports, ce qui empêche beaucoup dinterfaces USB-série d’émettre. Seul Xiegu, où le problème avait été signalé, le fait encore.",
"Kenwood : un port qui souvre sans rien émettre est désormais distingué dun port qui émet sans répondre, et lerreur cite ce qui est arrivé."
]
},
{
"version": "0.22.7",
"date": "",
"en": [
"Serial CAT no longer keys the radio just by connecting: DTR and RTS are dropped on Xiegu, Yaesu and Kenwood as they already were on Icom — a Xiegu G90 behind a DE-19 went straight into transmit and stayed there.",
"Xiegu: choose how the rig is keyed — CI-V command, RTS or DTR. A G90 does not transmit on the CI-V command, so this is also what lets WSJT-X transmit through the shared CAT link.",
"A reference you assign by hand now replaces what the matcher found instead of being added to it, so a correction survives the next recompute.",
"Awards: an award can be marked one reference per QSO. When several references then match the same contact it keeps none and lists them, so you pick — a contact does not silently get the wrong DOK. Off by default, so an n-fer POTA activation still counts every park.",
"Dark themes: the calendar icon of date and date-time fields is visible again.",
"Live award detection now sees the QTH, name, country, comment, note and grid you typed, not just the looked-up address — an award matching on the town stayed silent until the QSO was logged.",
"Info (F2) panel: the fields are sized for what they hold — a wider county, narrower prefix and zones, and the address takes the space left by the bearing and distances.",
"Clicking in the lower half no longer draws an orange rule under the tab strip.",
"A green NEW badge appears on the County field in Info (F2) when the contacted US county has never been worked.",
"DARC DOK award updated to v2 in the catalogue: one reference per QSO."
],
"fr": [
"Le CAT série ne met plus la radio en émission par le seul fait de se connecter : DTR et RTS sont abaissés sur Xiegu, Yaesu et Kenwood comme ils l'étaient déjà sur Icom — un Xiegu G90 derrière un DE-19 partait en émission et y restait.",
"Xiegu : choix du passage en émission — commande CI-V, RTS ou DTR. Un G90 n'émet pas sur la commande CI-V, c'est donc aussi ce qui permet à WSJT-X d'émettre via le partage CAT.",
"Une référence que vous affectez à la main remplace désormais ce que la détection avait trouvé au lieu de s'y ajouter : une correction survit au recalcul suivant.",
"Diplômes : un diplôme peut être marqué une seule référence par QSO. Si plusieurs références correspondent alors au même contact, aucune nest retenue et elles sont listées pour que vous choisissiez — un contact ne reçoit plus silencieusement le mauvais DOK. Désactivé par défaut, donc une activation POTA n-fer compte toujours tous les parcs.",
"Thèmes sombres : licône calendrier des champs date et date-heure est de nouveau visible.",
"La détection des diplômes en direct voit maintenant le QTH, le nom, le pays, le commentaire, la note et le locator saisis, et plus seulement ladresse issue de la recherche — un diplôme qui matche sur la ville restait muet jusqu’à lenregistrement du QSO.",
"Panneau Info (F2) : les champs sont dimensionnés selon leur contenu — comté plus large, préfixe et zones plus étroits, et ladresse récupère la place laissée par lazimut et les distances.",
"Un clic dans la moitié basse ne trace plus de trait orange sous la barre donglets.",
"Un badge vert NOUV apparaît sur le champ Comté dans Info (F2) quand le comté américain contacté na jamais été travaillé.",
"Diplôme DARC DOK mis à jour en v2 dans le catalogue : une seule référence par QSO."
]
},
{
"version": "0.22.6",
"date": "",
"en": [
"A playback that fails to start now says so in the log instead of ending silently after a tenth of a second.",
"Replaying a recording before the previous one has finished now works: the new playback waits for the audio device to be free instead of keying the radio and releasing at once.",
"The play button becomes a stop button while the recording is going out: it cuts the transmission and releases the PTT, and you can send it again straight away.",
"The \"From radio\" and mic levels take effect as you drag the slider, and the RX level now applies to what you HEAR through OpsLog, not only to recordings.",
"The padlocks on frequency, band, mode and times no longer flicker under the pointer and refuse the click.",
"The callsign field is wider, taking the room from the RST pair.",
"MP3 recordings no longer carry a hiss of their own: the 16→32 kHz conversion mirrored the whole spectrum only 7 dB down. It is now 57 dB down.",
"Replaying a QSO recording on the air has its own level, separate from the voice keyer: a recording comes from a receiver line output and needs far less gain than a message spoken into a microphone.",
"A quiet DX cluster no longer looks like a broken one: silence stopped counting as a disconnection, so a node with few spots keeps its session, its login and its filters."
],
"fr": [
"Une lecture qui ne démarre pas le signale désormais dans le journal, au lieu de s'arrêter en silence au bout d'un dixième de seconde.",
"Relancer un enregistrement avant la fin du précédent fonctionne désormais : la nouvelle lecture attend que le périphérique audio soit libre, au lieu de passer en émission et de relâcher aussitôt.",
"Le bouton de lecture devient un bouton d'arrêt pendant l'émission de l'enregistrement : il coupe l'émission et relâche le PTT, et vous pouvez le renvoyer aussitôt.",
"Les niveaux « From radio » et micro agissent pendant que vous déplacez le curseur, et le niveau RX s'applique désormais à ce que vous ENTENDEZ dans OpsLog, pas seulement aux enregistrements.",
"Les cadenas de fréquence, bande, mode et heures ne scintillent plus sous le curseur et acceptent le clic.",
"Le champ indicatif est plus large, la place venant de la paire RST.",
"Les enregistrements MP3 ne portent plus un souffle qui leur est propre : la conversion 16→32 kHz recopiait tout le spectre à seulement 7 dB en dessous. Il est désormais à 57 dB.",
"La relecture d'un enregistrement de QSO a son propre niveau, distinct du manipulateur vocal : une prise vient de la sortie ligne d'un récepteur et demande bien moins de gain qu'un message dit au micro.",
"Un cluster calme ne passe plus pour un cluster mort : le silence ne compte plus comme une déconnexion, donc un nœud avec peu de spots garde sa session, son login et ses filtres."
]
},
{
"version": "0.22.5",
"date": "",
"en": [
"Cluster filters gain NEW POTA and NEW COUNTY, alongside the existing status chips."
],
"fr": [
"Les filtres du cluster gagnent NEW POTA et NEW COUNTY, à côté des pastilles de statut existantes."
]
},
{
"version": "0.22.4",
"date": "",
+511 -107
View File
File diff suppressed because it is too large Load Diff
+13 -5
View File
@@ -51,11 +51,14 @@ type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgx
export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) {
// Peak-hold so the jittery VITA-49 meters read steadily (own ref per card).
// One second: the same window as the docked widget, so the two never show
// different figures for the same amplifier, and neither trails the end of a
// transmission long enough to look like lag.
const peak = useRef<Record<string, { v: number; t: number }>>({});
const peakHold = (key: string, val: number) => {
const now = Date.now();
const p = peak.current[key];
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
if (!p || val >= p.v || now - p.t > 1000) { peak.current[key] = { v: val, t: now }; return val; }
return p.v;
};
@@ -73,9 +76,12 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{spe.operate ? 'OPERATE' : 'STANDBY'}
</button>
{/* Power ON pulses RTS then DTR — it must stay clickable while the amp
is off (no status = not "connected"), and serial only. */}
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
<button type="button" disabled={!spe.connected}
<button type="button" disabled={!(spe.connected || spe.transport === 'serial')}
onClick={() => AmpPower(amp.id, true).catch(() => {})}
title={spe.transport === 'serial' ? 'Power on (RTS then DTR pulse)' : 'Power-on needs the serial RTS/DTR lines — not available over a network bridge'}
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
<button type="button" disabled={!spe.connected}
onClick={() => AmpPower(amp.id, false).catch(() => {})}
@@ -177,9 +183,6 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{operate ? 'OPERATE' : 'STANDBY'}
</button>
<span className="text-xs text-muted-foreground">
{operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
</span>
{(pg.host || pg.connected) && (
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
@@ -208,6 +211,11 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
if (amps.length === 0) return null;
// Power comes from the radio's meter stream and nothing else. The
// amplifier also reports a "peakfwd", and using it was a mistake twice
// over: it is a latched maximum that is never reset, and it survives in
// the last-known status after the amp disconnects — so it claimed
// 1350 W from an old transmission while the radio was putting out 10.
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
{amps.map((m) => {
+288
View File
@@ -0,0 +1,288 @@
import { useRef } from 'react';
import { Flame, X, Power } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { MeterBar } from '@/components/MeterBar';
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
// AmpWidget — the docked amplifier widget, the compact counterpart of AmpCard
// (which is the full-size card shown in the FlexRadio panel and Station
// Control). It covers all three families, since an operator running an SPE and
// a PowerGenius wants one widget, not two:
// • SPE Expert — OPERATE, power ON/OFF, L/M/H, live power / SWR / temp
// • ACOM — OPERATE, power ON/OFF, live power / SWR / temp
// • PowerGenius — OPERATE and meters ride on the FlexRadio, fan mode on GSCP
// With several amplifiers configured the caller passes a selection ("all" or an
// amp id) chosen from the toolbar icon's dropdown.
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
// Full-scale watts for the output bar, from the model string.
function maxW(model?: string, fallback = 1300): number {
const m = (model || '').toUpperCase();
if (m.includes('20K') || m.includes('2K')) return 2000;
if (m.includes('15K')) return 1500;
if (m.includes('13K')) return 1300;
return fallback;
}
function swrColour(swr?: number): string {
if (!swr || swr <= 0) return 'text-muted-foreground';
if (swr <= 1.5) return 'text-success';
if (swr <= 2.0) return 'text-warning';
return 'text-danger';
}
// PowerMeter — the output-power LED bar that closes each column, straight under
// the controls (no filler above it: pushed to the bottom it left a band of dead
// space in the middle of the column). Uses the app-wide MeterBar, compact size,
// so it reads exactly like the meters on the rig panels.
function PowerMeter({ label, watts, maxWatts }: { label: string; watts: number; maxWatts: number }) {
return (
<MeterBar compact label={label} value={watts} unit="W" lo={0} hi={maxWatts}
display={`${Math.round(watts)} W`}
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
);
}
// usePeakHold keeps the highest reading seen for a short window, so a reading
// sampled between two syllables doesn't collapse the meter. `live` is the key
// part: pass false the moment the carrier drops and the held value is thrown
// away at once — holding it there made releasing the PTT look like a five
// second lag, which read as the whole app being slow.
function usePeakHold(windowMs = 1000) {
const peak = useRef<Record<string, { v: number; t: number }>>({});
return (key: string, val: number, live = true) => {
if (!live) { delete peak.current[key]; return val; }
const now = Date.now();
const p = peak.current[key];
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
return p.v;
};
}
// Stat — one small labelled figure (power, SWR, temperature).
function Stat({ label, value, className }: { label: string; value: string; className?: string }) {
return (
<div className="rounded-lg border border-border bg-card/70 px-1.5 py-1 text-center">
<div className="text-[9px] uppercase tracking-wider text-muted-foreground leading-tight">{label}</div>
<div className={cn('text-sm font-bold font-mono leading-tight', className ?? 'text-foreground/80')}>{value}</div>
</div>
);
}
// OperateButton — the STANDBY/OPERATE toggle, identical in behaviour across the
// three families (only where the state comes from differs).
function OperateButton({ operate, disabled, onClick, t }: {
operate: boolean; disabled: boolean; onClick: () => void; t: (k: string, v?: any) => string;
}) {
return (
<button type="button" disabled={disabled} onClick={onClick}
className={cn('w-full inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-[0.98] disabled:opacity-40',
operate
? 'bg-gradient-to-b from-amber-400 to-amber-600 text-white border-amber-300/60 shadow-[0_0_9px_rgba(245,158,11,0.45)]'
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground')}>
<Power className="size-3.5" />
{operate ? t('ampw.operate') : t('ampw.standby')}
</button>
);
}
// AmpBlock — one amplifier inside the widget.
function AmpBlock({ amp, flex, showName, t }: {
amp: Amp; flex: any; showName: boolean; t: (k: string, v?: any) => string;
}) {
const spe = amp.spe, acom = amp.acom;
const hold = usePeakHold();
if (spe || acom) {
const s = spe || acom;
// The amp reports zero watts on receive, so its TX flag clears the meter as
// soon as the operator lets go — and the radio's own flag, when we have one,
// gets there first (the amp is polled on its own slower cycle).
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : s.tx !== false;
const w = hold('w', Number(spe ? s.output_w : s.fwd_w) || 0, txing);
const swr = Number(spe ? s.swr_ant : s.swr) || 0;
const hi = spe ? maxW(s.model) : (Number(s.max_w) || 800);
// Power ON drives the remote-on control lines, so it stays available while
// the amplifier is off and reporting nothing — but only over a serial link.
const canPowerOn = spe ? (s.connected || s.transport === 'serial') : (s.port_open && s.transport === 'serial');
return (
<div className="h-full flex flex-col gap-1.5">
{showName && (
<div className="flex items-center gap-1.5">
<span className={cn('size-1.5 rounded-full shrink-0', s.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || (spe ? 'SPE' : 'ACOM')}</span>
{s.connected && s.band && <span className="ml-auto text-[9px] text-muted-foreground shrink-0">{s.band}</span>}
</div>
)}
<OperateButton operate={!!s.operate} disabled={!s.connected} t={t}
onClick={() => AmpOperate(amp.id, !s.operate).catch(() => {})} />
<div className="flex gap-1.5">
<button type="button" disabled={!canPowerOn}
onClick={() => AmpPower(amp.id, true).catch(() => {})}
className="flex-1 rounded-lg border border-success/60 bg-card py-1 text-[11px] font-bold text-success hover:bg-success/15 disabled:opacity-30">
{t('ampw.on')}
</button>
<button type="button" disabled={!s.connected}
onClick={() => AmpPower(amp.id, false).catch(() => {})}
className="flex-1 rounded-lg border border-danger/60 bg-card py-1 text-[11px] font-bold text-danger hover:bg-danger/15 disabled:opacity-30">
{t('ampw.off')}
</button>
</div>
{/* Output level — SPE only; an ACOM has no L/M/H over CAT. */}
{spe && (
<div className="grid grid-cols-3 gap-1">
{(['L', 'M', 'H'] as const).map((lvl) => {
const active = (s.power_level || '').trim().toUpperCase() === lvl;
return (
<button key={lvl} type="button" disabled={!s.connected}
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
className={cn('rounded-md border py-0.5 text-[10px] font-bold transition-colors disabled:opacity-30',
active ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
{t(`ampw.lvl${lvl}`)}
</button>
);
})}
</div>
)}
{s.connected ? (
<div className="grid grid-cols-3 gap-1">
<Stat label={t('ampw.pwr')} value={`${Math.round(w)}`} />
<Stat label={t('ampw.swr')} value={swr > 0 ? swr.toFixed(1) : '—'} className={swrColour(swr)} />
<Stat label={t('ampw.temp')} value={`${Number(s.temp_c) || 0}°`} />
</div>
) : (
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
)}
{(s.warnings || s.alarms || s.err_text) && (
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
{s.err_text || `${s.warnings || ''} ${s.alarms || ''}`.trim()}
</div>
)}
<PowerMeter label={t('flxp.outputPower')} watts={s.connected ? w : 0} maxWatts={hi} />
</div>
);
}
// PowerGenius XL: OPERATE and the meters come from the radio when the Flex
// reports the amp; the fan mode rides on our own GSCP link.
const pg = amp.pgxl || {};
const viaFlex = !!flex?.amp_available;
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
const connected = !!pg.connected || viaFlex;
const fault = flex?.amp_fault;
const meters = ((flex?.meters as any[]) || []).filter((m) => (m.src || '').toUpperCase().includes('AMP'));
const meter = (re: RegExp) => meters.find((m) => re.test((m.name || '').trim()));
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
const fwd = meter(/^fwd|pwr/i);
const flexW = fwd ? (/dbm/i.test(fwd.unit || '') ? dbmToW(fwd.value) : fwd.value) : 0;
// Whether there is power is the RADIO's transmit flag: it flips the instant
// the PTT does, where the amplifier's own status poll answers a second and a
// half late and holds TRANSMIT past the carrier.
//
// How MUCH is the live reading — the radio's meter stream, or the amp's own
// forward figure when there is no Flex. NOT the amp's "peakfwd": that is a
// latched maximum which is never reset AND survives in the last-known status
// after the amp disconnects, so it reported 1350 W from an old transmission
// while the radio was putting out 10 W into a disconnected amplifier.
const txing = typeof flex?.transmitting === 'boolean'
? flex.transmitting
: fwd ? flexW >= 1 : /TRANSMIT/i.test(pg.state || '');
// Prefer the radio's VITA meter stream when it is actually flowing (local or
// over a VPN) — it is fast and matches SmartSDR. Fall back to the amp's OWN
// link only when the radio isn't streaming its meters (a remote link without
// the VITA path), so the reading survives but is naturally slower.
const liveW = flexW > 0 ? flexW : (pg.connected ? Number(pg.fwd_w) || 0 : 0);
const fwdW = hold('fwd', txing ? liveW : 0, txing);
const id = meter(/^ID$|current/i);
// Live drain current, same source preference. peak_id latches like peak_w, so
// use the instantaneous 'id' and gate it on the radio's transmit flag.
const idA = id ? Number(id.value) : (txing ? Number(pg.id) || undefined : 0);
const temp = meter(/temp/i);
// VITA temp matches SmartSDR (the amp's direct link exposes a different sensor
// that can read ~10°C higher); fall back to the direct temp when no VITA.
const tempC = temp ? temp.value : (pg.temperature > 0 ? pg.temperature : undefined);
return (
<div className="h-full flex flex-col gap-1.5">
{showName && (
<div className="flex items-center gap-1.5">
<span className={cn('size-1.5 rounded-full shrink-0', connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || 'PGXL'}</span>
</div>
)}
<OperateButton operate={operate} disabled={!connected} t={t}
onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})} />
{connected ? (
<div className="grid grid-cols-3 gap-1">
<Stat label={t('ampw.pwr')} value={`${Math.round(fwdW)}`} />
<Stat label={t('ampw.id')} value={idA != null ? idA.toFixed(1) : '—'} />
<Stat label={t('ampw.temp')} value={tempC != null ? `${Math.round(tempC)}°` : '—'} />
</div>
) : (
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
)}
{(pg.host || pg.connected) && (
<select
disabled={!pg.connected}
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
onChange={(e) => AmpFanMode(amp.id, e.target.value).catch(() => {})}
title={t('ampw.fan')}
className="w-full h-7 rounded-md border border-border bg-card px-1.5 text-[10px] font-semibold outline-none focus:border-warning disabled:opacity-40">
<option value="STANDARD">{t('flxp.fanStandard')}</option>
<option value="CONTEST">{t('flxp.fanContest')}</option>
<option value="BROADCAST">{t('flxp.fanBroadcast')}</option>
</select>
)}
{fault && fault !== 'NONE' && (
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
{t('flxp.fault')}: {fault}
</div>
)}
<PowerMeter label={t('flxp.outputPower')} watts={connected ? fwdW : 0} maxWatts={2000} />
</div>
);
}
export function AmpWidget({ amps, flex, sel, onClose }: {
amps: Amp[];
flex: any;
sel: string; // "all" or an amp id
onClose: () => void;
}) {
const { t } = useI18n();
// An id that no longer matches (the amp was removed in Settings) falls back to
// showing everything rather than an empty widget.
const shown = sel === 'all' ? amps : (amps.filter((a) => a.id === sel).length ? amps.filter((a) => a.id === sel) : amps);
const anyOnline = shown.some((a) => a.spe?.connected || a.acom?.connected || a.pgxl?.connected || flex?.amp_available);
const title = shown.length === 1 ? (shown[0].name || t('ampw.title')) : t('ampw.title');
return (
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
<Flame className={cn('size-4', anyOnline ? 'text-warning drop-shadow-[0_0_3px_rgba(245,158,11,0.55)]' : 'text-muted-foreground')} />
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80 truncate">{title}</span>
<span className="flex-1" />
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('ampw.close')}>
<X className="size-3.5" />
</button>
</div>
{/* Several amplifiers sit SIDE BY SIDE, one column each: stacked, two amps
made a widget taller than the dock row and both ended up half hidden. */}
<div className="flex-1 min-h-0 overflow-y-auto p-2.5">
{shown.length === 0 ? (
<div className="text-[11px] text-center text-muted-foreground italic py-2">{t('ampw.none')}</div>
) : (
<div className="grid gap-2.5 h-full" style={{ gridTemplateColumns: `repeat(${shown.length}, minmax(0, 1fr))` }}>
{shown.map((a, i) => (
<div key={a.id} className={cn(i > 0 && 'pl-2.5 border-l border-border/50')}>
<AmpBlock amp={a} flex={flex} showName={shown.length > 1} t={t} />
</div>
))}
</div>
)}
</div>
</div>
);
}
+10 -2
View File
@@ -33,7 +33,7 @@ export type AwardDef = {
code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
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
type?: string; field: string; match_by?: string; exact_match?: 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;
or_rules?: AwardOrRule[];
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
@@ -227,7 +227,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
};
type Explanation = {
code: string; in_scope: boolean; scope_error?: string; predefined: boolean;
ref_count: number; steps: Step[]; manual?: string[]; result: string[];
ref_count: number; steps: Step[]; manual?: string[]; ambiguous?: string[]; result: string[];
};
type TestRow = { qso: any; explanation: Explanation };
const [testCall, setTestCall] = useState('');
@@ -554,6 +554,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
</div>
</Field2>
<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>
<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">
<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>
@@ -708,6 +709,13 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
)}
</div>
))}
{(ex.ambiguous ?? []).length > 0 && (
<div className="px-3 py-2 text-xs">
<span className="font-semibold uppercase text-[10px] tracking-wide text-warning">{t('awed.testAmbiguous')}</span>{' '}
<span className="font-mono text-warning">{(ex.ambiguous ?? []).join(', ')}</span>{' '}
<span className="text-muted-foreground">{t('awed.testAmbiguousHint')}</span>
</div>
)}
{(ex.manual ?? []).length > 0 && (
<div className="px-3 py-2 text-xs">
<span className="font-semibold uppercase text-[10px] tracking-wide">{t('awed.testManual')}</span>{' '}
+74 -20
View File
@@ -1,6 +1,7 @@
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 { 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 { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
@@ -69,6 +70,9 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
// Computed results are cached per award code — each award is scanned only the
// first time it's selected (or when explicitly rescanned).
// Keyed by "CODE|MODECLASS": the same award computed for CW and for all modes
// are different results, and caching them under one key showed the previous
// modes numbers after switching.
const [byCode, setByCode] = useState<Record<string, AwardResult>>({});
const [loading, setLoading] = useState(false);
const [err, setErr] = useState('');
@@ -119,13 +123,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
// Compute one award (cached). force=true bypasses the cache (Rescan).
async function compute(code: string, force = false) {
if (!code) return;
if (!force && byCode[code]) { setSelected(code); return; }
const key = `${code}|${modeFilter}`;
if (!force && byCode[key]) { setSelected(code); return; }
setSelected(code);
setLoading(true);
setErr('');
try {
const r = (await GetAward(code)) as any as AwardResult;
setByCode((m) => ({ ...m, [code]: r }));
const r = (await GetAward(code, modeFilter === 'all' ? '' : modeFilter)) as any as AwardResult;
setByCode((m) => ({ ...m, [key]: r }));
} catch (e: any) {
setErr(String(e?.message ?? e));
} finally {
@@ -133,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() {
try {
const defs = ((await GetAwardDefs()) ?? []) as any[];
const list: AwardListItem[] = defs
const [defs, tracked] = await Promise.all([
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 ?? [] }))
.sort((a, b) => a.code.localeCompare(b.code));
if (follow.size > 0) list = list.filter((a) => follow.has(a.code));
setAwardList(list);
const first = list.find((a) => a.code === selected) ?? list[0];
if (first) compute(first.code);
@@ -148,8 +160,17 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
}
}
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];
const current = byCode[`${selected}|${modeFilter}`];
// Recompute when the mode class changes: the bands, counts and confirmations
// all describe the selected mode, so they cannot be filtered client-side.
useEffect(() => { if (selected) compute(selected); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [modeFilter]);
// Bands relevant to the selected award, used by BOTH the grid and the stats
// matrix so neither is padded with bands the award doesn't use. Rule: the
@@ -204,11 +225,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
// Alicante" — which reference order scatters across the whole table.
// Remembered through writeUiPref rather than localStorage, so the choice
// travels with a copied data/ folder like every other UI preference here.
const [refSort, setRefSort] = useState<'ref' | 'name'>(
() => (localStorage.getItem('opslog.awardRefSort') === 'name' ? 'name' : 'ref'));
const [refSort, setRefSort] = useState<'ref' | 'name' | 'group'>(
() => {
const v = localStorage.getItem('opslog.awardRefSort');
return v === 'name' || v === 'group' ? v : 'ref';
});
const [refSortDir, setRefSortDir] = useState<'asc' | 'desc'>(
() => (localStorage.getItem('opslog.awardRefSortDir') === 'desc' ? 'desc' : 'asc'));
const toggleRefSort = (k: 'ref' | 'name') => {
const toggleRefSort = (k: 'ref' | 'name' | 'group') => {
if (k === refSort) {
const d = refSortDir === 'asc' ? 'desc' : 'asc';
setRefSortDir(d);
@@ -251,10 +275,26 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
if (c !== 0) return c * dir;
return a.ref.localeCompare(b.ref) * dir; // stable tie-break
}
if (refSort === 'group') {
// Prefix order (XE, YV, ZL…) — how a DX operator actually thinks about
// entities. Blank prefixes sink to the bottom regardless of direction,
// so an unresolved entity never heads the list.
const ag = a.group ?? '', bg = b.group ?? '';
if (!ag !== !bg) return ag ? -1 : 1;
const c = ag.localeCompare(bg, undefined, { sensitivity: 'base' });
if (c !== 0) return c * dir;
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
}
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
});
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]);
// The group column earns its width only when the list actually carries one
// (DXCC prefixes, POTA locations); most custom lists have none. For DXCC the
// value IS the primary prefix, so it is labelled as such.
const hasGroup = useMemo(() => filteredRefs.some((r) => !!r.group), [filteredRefs]);
const groupColLabel = current && current.code.toUpperCase() === 'DXCC' ? t('awp.prefixCol') : t('awp.groupCol');
return (
<div className="flex h-full min-h-0">
{/* Award list */}
@@ -292,7 +332,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
<div className="flex-1 overflow-auto">
{err && <div className="p-3 text-xs text-destructive">{err}</div>}
{awardList.map((a) => {
const r = byCode[a.code];
const r = byCode[`${a.code}|${modeFilter}`];
return (
<button
key={a.code}
@@ -452,6 +492,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
{hasGroup && (
<th className="text-left py-1.5 pr-3 font-medium border-b border-border w-20">
<button type="button" onClick={() => toggleRefSort('group')} className="inline-flex items-center gap-1 hover:text-foreground">
{groupColLabel}
{refSort === 'group' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
)}
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
{t('awp.description')}
@@ -467,9 +515,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
{filteredRefs.map((r) => (
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
<td className="sticky left-0 bg-card hover:bg-accent/20 py-1 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">
{r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''}
</td>
{hasGroup && (
<td className="py-1 pr-3 font-mono font-semibold border-b border-border/30">{r.group}</td>
)}
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">{r.name}</td>
{gridBands.map((b) => {
const s = cellStatus(r, b);
return (
@@ -506,7 +555,12 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
<th className="py-1.5 pr-2 font-medium w-40">{t('awp.groupCol')}</th>
<th className="py-1.5 pr-2 font-medium w-40">
<button type="button" onClick={() => toggleRefSort('group')} className="inline-flex items-center gap-1 hover:text-foreground">
{groupColLabel}
{refSort === 'group' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
</tr>
@@ -539,7 +593,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
</div>
{cell && current && (
<CellQSOModal code={current.code} cell={cell} onClose={() => setCell(null)} />
<CellQSOModal code={current.code} cell={cell} modeClass={modeFilter === 'all' ? '' : modeFilter} onClose={() => setCell(null)} />
)}
{showMissing && current && (
<MissingQSOModal code={current.code} name={current.name} onClose={() => setShowMissing(false)} onEditQSO={onEditQSO} />
@@ -724,17 +778,17 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
}
// CellQSOModal lists the QSOs behind one award-grid cell (reference × band).
function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; onClose: () => void }) {
function CellQSOModal({ code, cell, modeClass, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; modeClass: string; onClose: () => void }) {
const { t } = useI18n();
const [qsos, setQsos] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
AwardCellQSOs(code, cell.ref, cell.band)
AwardCellQSOs(code, cell.ref, cell.band, modeClass)
.then((r) => setQsos((r ?? []) as any))
.catch(() => setQsos([]))
.finally(() => setLoading(false));
}, [code, cell.ref, cell.band]);
}, [code, cell.ref, cell.band, modeClass]);
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
+37 -32
View File
@@ -358,6 +358,43 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
return () => el.removeEventListener('wheel', onWheel);
}, [range]);
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
// Only active on the docked Main-view map (keyNav) and ignored while typing.
// MUST stay ABOVE the `if (!range)` early return below: on an unknown band
// (e.g. a spurious 33 cm CAT reading from an Icom) that return skipped this
// hook, so the hook count changed between renders and React crashed with #300
// ("rendered fewer hooks than expected"). lo/hi are always defined (they fall
// back to [0,1] when there's no range), so it's safe to run here.
useEffect(() => {
if (!keyNav) return;
const onKey = (e: KeyboardEvent) => {
if (!e.ctrlKey || e.altKey || e.metaKey) return;
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
const ae = document.activeElement as HTMLElement | null;
const tag = (ae?.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select' || ae?.isContentEditable) return;
const list = spots
.filter((s) => (s.band ?? '') === band && s.freq_hz > 0)
.slice()
.sort((a, b) => a.freq_hz - b.freq_hz);
if (!list.length) return;
const cur = currentFreqHz || (lo + hi) * 500; // mid-band kHz→Hz when no rig freq
const EPS = 50; // Hz, so we don't re-pick the spot we're already sitting on
let target: Spot | undefined;
if (e.key === 'ArrowUp') {
target = list.find((s) => s.freq_hz > cur + EPS);
} else {
for (let i = list.length - 1; i >= 0; i--) { if (list[i].freq_hz < cur - EPS) { target = list[i]; break; } }
}
if (!target) return;
e.preventDefault();
onSpotClick(target);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [keyNav, spots, band, currentFreqHz, lo, hi, onSpotClick]);
if (!range) {
return (
<div className="h-full w-full flex flex-col items-center justify-center text-xs text-muted-foreground p-3 bg-muted/20">
@@ -388,38 +425,6 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
scrollerRef.current.scrollTop = Math.max(0, y - containerH / 2);
}
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
// Only active on the docked Main-view map (keyNav) and ignored while typing.
useEffect(() => {
if (!keyNav) return;
const onKey = (e: KeyboardEvent) => {
if (!e.ctrlKey || e.altKey || e.metaKey) return;
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
const ae = document.activeElement as HTMLElement | null;
const tag = (ae?.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select' || ae?.isContentEditable) return;
const list = spots
.filter((s) => (s.band ?? '') === band && s.freq_hz > 0)
.slice()
.sort((a, b) => a.freq_hz - b.freq_hz);
if (!list.length) return;
const cur = currentFreqHz || (lo + hi) * 500; // mid-band kHz→Hz when no rig freq
const EPS = 50; // Hz, so we don't re-pick the spot we're already sitting on
let target: Spot | undefined;
if (e.key === 'ArrowUp') {
target = list.find((s) => s.freq_hz > cur + EPS);
} else {
for (let i = list.length - 1; i >= 0; i--) { if (list[i].freq_hz < cur - EPS) { target = list[i]; break; } }
}
if (!target) return;
e.preventDefault();
onSpotClick(target);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [keyNav, spots, band, currentFreqHz, lo, hi, onSpotClick]);
const currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0;
const showRigPointer = currentKHz >= lo && currentKHz <= hi;
const rigY = freqToY(currentKHz);
+175 -5
View File
@@ -1,7 +1,9 @@
import { useMemo } from 'react';
import { Star, Radio } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Star, Radio, Sunrise, Sunset, X, Loader2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { sunTimes } from '@/lib/sun';
import { BandSlotQSOs } from '../../wailsjs/go/main/App';
import type { WorkedBeforeView } from '@/types';
type WorkedBefore = WorkedBeforeView;
@@ -13,6 +15,16 @@ interface Props {
currentMode: string;
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
// DX station coordinates, for its sunrise/sunset. Optional: many spots resolve
// to an entity with no position at all, and the block simply does not appear.
lat?: number;
lon?: number;
// Set when the matrix is showing a QSO picked in the log grid rather than the
// entry form — labelled so the two can never be confused.
forCall?: string;
// 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,
@@ -84,7 +96,9 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
}
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true }: 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
// bands they actually use), falling back to the built-in default set.
const cols = useMemo(
@@ -151,6 +165,36 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
</Badge>
) : null;
// Sunrise / sunset AT THE DX, in UTC. A glance tells you whether the path is
// about to open or close on their side, which is the reason to look at a DX
// station's grey line at all. Recomputed only when the position changes.
const sun = useMemo(
() => (lat == null || lon == null ? null : sunTimes(new Date(), lat, lon)),
[lat, lon],
);
const sunBlock = sun ? (
<div className="ml-auto flex items-center gap-3 text-xs shrink-0"
title="Sunrise / sunset at the DX station (UTC)">
{sun.polarDay ? (
<span className="font-semibold text-warning">midnight sun</span>
) : sun.polarNight ? (
<span className="font-semibold text-info">polar night</span>
) : (
<>
<span className="flex items-center gap-1">
<Sunrise className="size-3.5 text-warning" />
<span className="font-mono tabular-nums">{sun.rise || '—'}</span>
</span>
<span className="flex items-center gap-1">
<Sunset className="size-3.5 text-info" />
<span className="font-mono tabular-nums">{sun.set || '—'}</span>
</span>
<span className="text-muted-foreground">UTC</span>
</>
)}
</div>
) : null;
return (
<section
className={cn(
@@ -158,7 +202,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg',
)}
>
<div className="flex items-center gap-2 min-w-[220px]">
<div className="flex items-center gap-2 min-w-[220px] flex-1">
{newOne ? (
<>
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
@@ -173,6 +217,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
</>
) : hasDxcc ? (
<>
{/* Says WHOSE stats these are when they come from a row picked in
the log rather than from what's being typed. */}
{forCall && (
<Badge variant="outline" className="px-2 py-0.5 text-[10px] font-mono shrink-0"
title="Stats for the QSO selected in the log">
{forCall}
</Badge>
)}
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
{dxccName || `DXCC #${dxcc}`}
</Badge>
@@ -207,6 +259,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
Type a callsign to see entity stats
</span>
)}
{sunBlock}
</div>
<div className="flex flex-col gap-2">
@@ -246,10 +299,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
return (
<td
key={b.tag}
title={cellTitle(b.tag, cls, st, isCurrent)}
title={cellTitle(b.tag, cls, st, isCurrent) + (st ? ' — click to list the QSOs' : '')}
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
className={cn(
'w-[28px] h-[24px] rounded transition-colors p-0',
st ? STATUS_CLASSES[st] : 'bg-mx-none',
// Only a filled cell has anything to show — an empty one
// stays inert rather than opening a "no QSOs" dialog.
st && 'cursor-pointer hover:brightness-110',
isCurrent && 'ring-2 ring-warning ring-inset',
)}
/>
@@ -277,6 +334,119 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
))}
</div>
</div>
{slot && (
<SlotQSOModal
call={wb?.callsign ?? ''}
dxcc={dxcc}
entity={dxccName}
band={slot.band}
cls={slot.cls}
onClose={() => setSlot(null)}
onEdit={onEditQso}
/>
)}
</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>
);
}
+85 -63
View File
@@ -53,6 +53,8 @@ export type SpotStatusEntry = {
worked_call?: boolean;
new_county?: boolean;
new_pota?: boolean;
new_pfx?: boolean;
pfx?: string;
};
type Props = {
@@ -99,50 +101,58 @@ function statusFor(p: any): SpotStatusEntry | undefined {
];
}
// statusBadge maps a resolved spot status to a short labelled badge for the
// Status column, using the same colours as the per-cell fills (NEW DXCC =
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
// there's nothing notable to show.
type Badge = { text: string; fg: string; bg: string; bd: string };
// Spot status is shown by COLOURING THE TEXT, never by a pill or a badge.
//
// The pills were dropped: a rounded box has its own height and padding, so it
// sat off the row's baseline and the callsign inside it no longer lined up with
// the plain callsigns above and below. A whole column of them read as chrome
// rather than as data. Same reasoning — and the same semantic tokens — as the
// Y/N/R QSL columns in lib/qslStatus.ts.
//
// Which cell is coloured IS the message, so one colour is enough for all three:
//
// yellow call → new DXCC yellow band → new band yellow mode → new mode
// blue call → already worked
const NEW = 'var(--warning)'; // yellow: something here is new
const WKD = 'var(--info)'; // blue: this callsign is already in the log
// tok builds a badge from a semantic status token (success/warning/caution/
// danger/info/neutral) so the colours adapt to every theme via CSS variables,
// instead of the hard-coded light-theme pastels that looked wrong in dark mode.
function tok(name: string, text: string): Badge {
if (name === 'neutral') return { text, fg: 'var(--muted-foreground)', bg: 'var(--muted)', bd: 'var(--border)' };
return { text, fg: `var(--${name}-muted-foreground)`, bg: `var(--${name}-muted)`, bd: `var(--${name}-border)` };
}
// cellChip wraps a Band/Mode cell value in a small rounded pill (same look as the
// Status badges) when the slot is notable, instead of flooding the whole cell with
// a heavy muted fill that turned into an ugly olive block on dark themes. `name` is
// null → plain text, no pill.
function cellChip(value: any, name: string | null): any {
// cellText renders a cell value in an optional colour. An empty value keeps the
// muted dash the grid used before, so blank cells still read as "nothing here"
// rather than as a gap.
function cellText(value: any, color: string | null): any {
const txt = value === undefined || value === null || value === '' ? '' : String(value);
if (!name) return txt || <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
// Inherit the column's font size (no fixed 9px / height) so a pill around a
// callsign stays the same size as the plain callsigns next to it — just tinted
// and rounded, not shrunk.
return (
<span style={{
display: 'inline-flex', alignItems: 'center', lineHeight: 1.1,
backgroundColor: `var(--${name}-muted)`, color: `var(--${name}-muted-foreground)`,
border: `1px solid var(--${name}-border)`, fontWeight: 700,
padding: '1px 6px', borderRadius: 999, whiteSpace: 'nowrap',
}}>{txt}</span>
);
if (!txt) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
return <span style={color ? { color, fontWeight: 700 } : undefined}>{txt}</span>;
}
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): Badge | null {
// statusColor is the colour for a resolved status in the Status column. County,
// POTA and prefix keep their own tokens: they are orthogonal to the band/mode/
// DXCC story and an operator filters on them separately.
function statusColor(s: SpotStatusEntry | undefined): string | null {
switch (s?.status) {
case 'new': return tok('danger', t('clg2.newDxcc'));
case 'new-band': return tok('warning', t('clg2.newBand'));
case 'new-mode': return tok('caution', t('clg2.newMode'));
case 'new-slot': return tok('caution', t('clg2.newSlot'));
default: return s?.worked_call ? tok('neutral', t('clg2.wkdCall')) : null;
case 'new':
case 'new-band':
case 'new-mode':
case 'new-slot':
return NEW;
default:
return s?.worked_call ? WKD : null;
}
}
// isDull reports a spot that "represents nothing" under the current worked/slot
// rules: the entity/band/mode is already worked (status resolved, not new in any
// dimension), the callsign itself isn't in the log, and there's no POTA / county
// / prefix novelty. Such rows are dimmed so the eye skips them. Because the
// status obeys the DX-cluster "same slot" option and the digital-mode grouping,
// what counts as dull follows those settings automatically. Unresolved statuses
// (still loading, or entity unknown) are NOT dimmed — that would flicker.
function isDull(s: SpotStatusEntry | undefined): boolean {
if (!s || !s.status) return false;
if (s.status === 'new' || s.status === 'new-band' || s.status === 'new-mode' || s.status === 'new-slot') return false;
return !(s.worked_call || s.new_pota || s.new_county || s.new_pfx);
}
const makeColCatalog = (t: TFn): ColEntry[] => [
{
group: 'Spot', label: t('clg2.c.time'), colId: 'time',
@@ -166,15 +176,12 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
defaultVisible: true,
cellClass: 'font-mono',
// NEW DXCC → a danger pill around the call, consistent with the NEW BAND /
// NEW MODE pills (same look, same tokens). Worked-call stays blue bold text;
// a plain spot inherits the theme's normal text colour so callsigns blend in
// with the rest of the row instead of always shouting a colour.
// NEW DXCC → yellow call. Already worked → blue call. Anything else keeps
// the theme's normal ink so ordinary callsigns don't shout.
cellRenderer: (p: any) => {
const s = statusFor(p);
if (s?.status === 'new') return cellChip(p.value, 'danger');
const color = s?.worked_call ? 'var(--info)' : undefined;
return <span style={{ color, fontWeight: 700 }}>{p.value ?? ''}</span>;
const color = s?.status === 'new' ? NEW : s?.worked_call ? WKD : null;
return <span style={{ color: color ?? undefined, fontWeight: 700 }}>{p.value ?? ''}</span>;
},
tooltipValueGetter: (p: any) => {
const s = statusFor(p);
@@ -185,9 +192,10 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
headerName: t('clg2.c.status'), width: 120, sortable: true,
defaultVisible: true,
// Spells out the slot status as a text badge so NEW SLOT (and the others)
// is obvious at the row level, not just a single coloured cell. NEW COUNTY
// and NEW POTA are orthogonal, so they stack as extra badges.
// Spells the status out in words so NEW SLOT (and the others) is obvious at
// the row level, not just a single coloured cell NEW SLOT in particular
// colours no cell at all, since neither the band nor the mode is new on its
// own. NEW COUNTY and NEW POTA are orthogonal, so they stack after it.
valueGetter: (p: any) => {
const s = statusFor(p);
const parts: string[] = [];
@@ -198,25 +206,31 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
if (s?.new_county) parts.push(t('clg2.newCounty'));
if (s?.new_pota) parts.push(t('clg2.newPota'));
if (s?.new_pfx) parts.push(t('clg2.newPfx'));
return parts.join(' ');
},
cellRenderer: (p: any) => {
const s = statusFor(p);
const badges: Badge[] = [];
const b = statusBadge(t, s);
if (b) badges.push(b);
if (s?.new_county) badges.push(tok('info', t('clg2.newCounty')));
if (s?.new_pota) badges.push(tok('success', t('clg2.newPota')));
if (badges.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
const parts: { text: string; color: string }[] = [];
const main = statusColor(s);
if (main) {
const label = s?.status === 'new' ? t('clg2.newDxcc')
: s?.status === 'new-band' ? t('clg2.newBand')
: s?.status === 'new-mode' ? t('clg2.newMode')
: s?.status === 'new-slot' ? t('clg2.newSlot')
: t('clg2.wkdCall');
parts.push({ text: label, color: main });
}
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: 'var(--success)' });
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: 'var(--success)' });
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: 'var(--caution)' });
if (parts.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
return (
<span style={{ display: 'flex', height: '100%', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
{badges.map((bd, i) => (
<span key={i} style={{
display: 'inline-flex', alignItems: 'center', height: 15, lineHeight: 1,
backgroundColor: bd.bg, color: bd.fg, border: `1px solid ${bd.bd}`,
fontWeight: 700, fontSize: 9,
padding: '0 5px', borderRadius: 999, letterSpacing: 0.3, whiteSpace: 'nowrap',
}}>{bd.text}</span>
<span style={{ whiteSpace: 'nowrap' }}>
{parts.map((pt, i) => (
<span key={i} style={{ color: pt.color, fontWeight: 700 }}>
{i > 0 ? ' · ' : ''}{pt.text}
</span>
))}
</span>
);
@@ -249,8 +263,8 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
defaultVisible: true,
cellClass: 'font-mono',
// NEW BAND for this entity → small warning pill around the band text.
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-band' ? 'warning' : null),
// NEW BAND for this entity → the band text turns yellow.
cellRenderer: (p: any) => cellText(p.value, statusFor(p)?.status === 'new-band' ? NEW : null),
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
},
{
@@ -263,7 +277,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
// for the entity. NEW SLOT means band AND mode were each worked before (just
// not together), so highlighting the mode cell would wrongly imply "CW is new";
// that case is signalled by the Status badge alone.
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-mode' ? 'caution' : null),
cellRenderer: (p: any) => cellText(p.value, statusFor(p)?.status === 'new-mode' ? NEW : null),
tooltipValueGetter: (p: any) => {
const st = statusFor(p)?.status;
if (st === 'new-mode') return t('clg2.tipNewMode');
@@ -396,6 +410,11 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
const defaultColDef = useMemo<ColDef>(() => ({
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
@@ -409,6 +428,9 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
// won't re-render those cells on its own — force a refresh so e.g. a worked call
// turns blue once its status loads.
useEffect(() => {
// Light refresh so status-dependent styling — the Call/Band/Mode colours AND
// the dimmed "represents nothing" class (cellClassRules above) — re-applies
// once a status lands, WITHOUT redrawing whole rows (which pegged the CPU).
gridRef.current?.api?.refreshCells({ force: true });
}, [spotStatus]);
+165 -52
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
import { ComputeQSOAwardRefs, IsNewUSCounty } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
@@ -53,6 +53,14 @@ interface Props {
prefix: string;
operatorGrid: string; // station.my_grid — origin for bearing/distance
remoteGrid: string; // entry-strip Grid value — destination
// Entry-strip text fields. Live award detection matches on these too — a DARC
// DOK keys off QTH, not the address — and sending only the address meant such
// an award stayed silent until the QSO was logged.
qth?: string;
name?: string;
country?: string;
comment?: string;
note?: string;
details: DetailsState;
onChange: (patch: Partial<DetailsState>) => void;
// Stats (F1) tab content: the worked-before matrix + optional QRZ image.
@@ -61,6 +69,14 @@ interface Props {
band: string;
mode: string;
bands?: string[]; // configured bands for the worked-before matrix columns
// When the entry form is empty and a QSO is selected in the log grid, the
// Stats (F1) matrix shows THAT contact's entity instead of sitting blank.
// Only the matrix is redirected — every other tab still edits the live entry.
slotCall?: string;
slotBand?: string;
slotMode?: string;
slotWb?: any;
slotWbBusy?: boolean;
imageUrl?: string;
onOpenImage?: () => void;
// Optional controlled active tab (so the app can switch it via keyboard).
@@ -69,6 +85,8 @@ interface Props {
// When the WinKeyer is active, F1-F12 fire macros, so the tab shortcut is
// shown as Ctrl+F1…F5 instead of F1…F5.
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';
@@ -113,28 +131,57 @@ function numOrUndef(v: string): number | undefined {
return isNaN(n) ? undefined : n;
}
// Column spans written out rather than built as `col-span-${n}`: Tailwind scans
// for literal class names, so an interpolated one is never emitted.
const SPAN_CLASS: Record<number, string> = {
1: '', 2: 'col-span-2', 3: 'col-span-3', 4: 'col-span-4', 5: 'col-span-5',
6: 'col-span-6', 7: 'col-span-7', 8: 'col-span-8', 9: 'col-span-9',
10: 'col-span-10', 11: 'col-span-11', 12: 'col-span-12',
};
// Compact field helper to keep the JSX dense.
function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 4 | 6; children: React.ReactNode }) {
function Field({ label, span = 1, className, children }: { label: string; span?: number; className?: string; children: React.ReactNode }) {
return (
<div className={cn('flex flex-col min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 4 && 'col-span-4', span === 6 && 'col-span-6')}>
<div className={cn('flex flex-col min-w-0', SPAN_CLASS[span], className)}>
<Label className="mb-1">{label}</Label>
{children}
</div>
);
}
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, 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 [internalOpen, setInternalOpen] = useState<TabName>('stats');
const open = tab ?? internalOpen; // controlled when `tab` is provided
// Live award detection: run the SAME engine used at log time over the current
// contact (callsign + looked-up address/state/zones) so award references the
// QSO will earn — e.g. WAPC matching "Beijing" inside the address — are
// surfaced and auto-added the moment you enter a call or click a spot, instead
// of only appearing after logging. Pickable matches are merged into award_refs
// contact callsign, the looked-up address/state/zones AND the entry-strip
// text (QTH, name, country, comment, note, grid). Sending only the address was
// a silent hole: a DARC DOK matches on QTH, so "Aachen" produced nothing here
// while the award editor's Test tab, which passes the whole QSO, matched it.
// References the QSO will earn — WAPC matching "Beijing" inside the address,
// a DOK matching the town — are surfaced and auto-added the moment you enter a
// call or click a spot, instead of only appearing after logging. Pickable
// matches are merged into award_refs
// (idempotent; award_refs is NOT a dependency, so removing one by hand sticks).
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean }>>([]);
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean; ambiguous?: boolean }>>([]);
// NEW badge on the county: the same question the cluster's NEW CTY badge
// answers, for the station being worked right now. Debounced because the
// county field is typed into as well as filled by the lookup.
const [newCounty, setNewCounty] = useState(false);
useEffect(() => {
const cnty = (details.cnty ?? '').trim();
if (!cnty) { setNewCounty(false); return; }
let alive = true;
const id = window.setTimeout(async () => {
try {
const isNew = await IsNewUSCounty(details.state ?? '', cnty);
if (alive) setNewCounty(!!isNew);
} catch { if (alive) setNewCounty(false); }
}, 300);
return () => { alive = false; window.clearTimeout(id); };
}, [details.state, details.cnty]);
useEffect(() => {
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
const t = window.setTimeout(async () => {
@@ -142,6 +189,8 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
const q: any = {
callsign, band, mode,
address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '',
qth: qth ?? '', name: name ?? '', country: country ?? '',
comment: comment ?? '', notes: note ?? '', grid: remoteGrid ?? '',
cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz,
qso_date: new Date().toISOString(),
};
@@ -151,7 +200,9 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
const have = new Set(cur.split(';').filter(Boolean));
let next = cur;
for (const r of all) {
if (!r.pickable) continue;
// An ambiguous candidate is offered, never auto-added: adding one would
// be the guess the award's "one reference per QSO" setting exists to refuse.
if (!r.pickable || r.ambiguous) continue;
const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`;
if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); }
}
@@ -160,7 +211,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
}, 400);
return () => window.clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, band, mode]);
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, qth, name, country, comment, note, remoteGrid, band, mode]);
// Bearing/distance from operator's home grid to the remote station.
// Recomputed only when either grid actually changes.
const path = useMemo(() => {
@@ -174,6 +225,15 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
}
return null;
}, [operatorGrid, remoteGrid, details.lat, details.lon]);
// Where the DX actually is, for its sunrise/sunset. The locator wins when we
// have one — it is what the operator can see and check — and the provider's
// raw coordinates cover entities that resolve through cty.dat with no grid.
const dxLL = useMemo(() => {
const byGrid = gridToLatLon(remoteGrid);
if (byGrid) return byGrid;
if (details.lat != null && details.lon != null) return { lat: details.lat, lon: details.lon };
return null;
}, [remoteGrid, details.lat, details.lon]);
const fmtDeg = (n: number) => `${Math.round(n)}°`;
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
@@ -222,19 +282,40 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
{open === 'stats' && (
<div className="px-3 py-2.5">
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
<BandSlotGrid
wb={slotCall ? slotWb : wb}
busy={slotCall ? !!slotWbBusy : !!wbBusy}
currentBand={slotCall ? (slotBand ?? '') : band}
currentMode={slotCall ? (slotMode ?? '') : mode}
bands={bands}
hasCall={slotCall ? true : callsign.trim() !== ''}
forCall={slotCall}
onEditQso={onEditQso}
lat={dxLL?.lat} lon={dxLL?.lon} />
</div>
)}
{open === 'info' && (
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
<Field label={t('detp.statePref')}>
<div className="grid grid-cols-12 gap-2 px-3 py-2.5">
{/* Twelve columns, not six: the fields hold very different things. A US
county name is long, a CQ zone is two digits — on an even six-column
grid they were the same width, so the county truncated while the
zones sat mostly empty. */}
<Field label={t('detp.statePref')} span={2}>
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
</Field>
<Field label={t('detp.county')}>
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} />
<Field label={t('detp.county')} span={4} className="relative">
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} className={cn(newCounty && 'pr-14')} />
{newCounty && (
<span
title={t('detp.newCountyTip')}
className="absolute right-1.5 bottom-1.5 rounded px-1.5 py-0.5 text-[10px] font-bold tracking-wide bg-success text-success-foreground pointer-events-none"
>
{t('detp.newCounty')}
</span>
)}
</Field>
<Field label={t('detp.prefix')}>
<Field label={t('detp.prefix')} span={2}>
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
</Field>
<Field label={t('detp.cqZone')}>
@@ -248,7 +329,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
{/* DXCC # closes the top row (next to the zones); Continent and
Azimuth SP live in the main entry strip / bandeau. The long-path
bearing and distances move to the row below. */}
<Field label={t('detp.dxcc')}>
<Field label={t('detp.dxcc')} span={2}>
<Input
readOnly
tabIndex={-1}
@@ -257,40 +338,45 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
placeholder="—"
/>
</Field>
<Field label={t('detp.azimuthLp')}>
<Input
readOnly
tabIndex={-1}
className="font-mono bg-muted/40 cursor-default"
value={path ? fmtDeg(path.bearingLong) : ''}
placeholder="—"
/>
</Field>
<Field label={t('detp.distanceSp')}>
<Input
readOnly
tabIndex={-1}
className="font-mono bg-muted/40 cursor-default"
value={path ? fmtKm(path.distanceShort) : ''}
placeholder="—"
/>
</Field>
<Field label={t('detp.distanceLp')}>
<Input
readOnly
tabIndex={-1}
className="font-mono bg-muted/40 cursor-default"
value={path ? fmtKm(path.distanceLong) : ''}
placeholder="—"
/>
</Field>
<Field label={t('detp.address')} span={3}>
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
</Field>
<Field label={t('detp.qslMessage')} span={3}>
{/* The bearing and the two distances are read-only and hold at most
"12345 km", so they take a fixed width and the address — the one
field here that is never wide enough — gets everything left over. */}
<div className="col-span-12 flex gap-2">
<Field label={t('detp.azimuthLp')} className="w-20 shrink-0">
<Input
readOnly
tabIndex={-1}
className="font-mono bg-muted/40 cursor-default"
value={path ? fmtDeg(path.bearingLong) : ''}
placeholder="—"
/>
</Field>
<Field label={t('detp.distanceSp')} className="w-24 shrink-0">
<Input
readOnly
tabIndex={-1}
className="font-mono bg-muted/40 cursor-default"
value={path ? fmtKm(path.distanceShort) : ''}
placeholder="—"
/>
</Field>
<Field label={t('detp.distanceLp')} className="w-24 shrink-0">
<Input
readOnly
tabIndex={-1}
className="font-mono bg-muted/40 cursor-default"
value={path ? fmtKm(path.distanceLong) : ''}
placeholder="—"
/>
</Field>
<Field label={t('detp.address')} className="flex-1 min-w-0">
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
</Field>
</div>
<Field label={t('detp.qslMessage')} span={7}>
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
</Field>
<Field label={t('detp.qslVia')} span={2}>
<Field label={t('detp.qslVia')} span={5}>
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
</Field>
</div>
@@ -305,10 +391,37 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
heightClass="flex-1 min-h-0"
/>
{detected.length > 0 && (
{/* Ambiguous candidates: the award allows one reference per QSO and
several matched, so none was kept. Click one to settle it while the
contact is still in front of you. */}
{detected.some((r) => r.ambiguous) && (
<div className="mt-2 text-[11px] shrink-0 leading-snug border-t border-warning/40 pt-1.5">
<span className="font-medium text-warning">{t('detp.ambiguous')}</span>{' '}
{detected.filter((r) => r.ambiguous).map((r) => (
<button
key={`amb-${r.code}@${r.ref}`}
type="button"
title={r.name ?? ''}
onClick={() => {
const entry = `${r.code.toUpperCase()}@${r.ref.toUpperCase()}`;
const cur = details.award_refs ?? '';
// Only one of the candidates can be right — replace any
// sibling already picked for this award rather than stacking.
const kept = cur.split(';').filter((e) => e && e.split('@')[0].toUpperCase() !== r.code.toUpperCase());
onChange({ award_refs: [...kept, entry].join(';') });
setDetected((d) => d.filter((x) => !x.ambiguous || x.code.toUpperCase() !== r.code.toUpperCase()));
}}
className="inline-block mr-1.5 px-1.5 py-0.5 rounded font-mono whitespace-nowrap border border-warning/50 text-warning hover:bg-warning/10"
>
{r.code}@{r.ref}{r.name ? `${r.name}` : ''}
</button>
))}
</div>
)}
{detected.some((r) => !r.ambiguous) && (
<div className="mt-2 text-[11px] text-muted-foreground shrink-0 max-h-14 overflow-y-auto leading-snug border-t border-border/50 pt-1.5">
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
{detected.map((r) => (
{detected.filter((r) => !r.ambiguous).map((r) => (
<span key={`${r.code}@${r.ref}`} className="inline-block mr-1.5 font-mono whitespace-nowrap" title={r.name ?? ''}>
<span className="text-foreground/80">{r.code}{r.ref ? `@${r.ref}` : ''}</span>
</span>
+8 -1
View File
@@ -234,7 +234,14 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
<SelectItem key={n} value={n}>
<span className="inline-flex items-center gap-2">
{n}
<Trash2 className="size-3 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation(); deletePreset(n); }} />
{/* Radix SelectItem selects on pointer-up, so an onClick here
fires too late — the menu has already loaded the preset and
closed. Intercept pointer-down (preventDefault stops Radix
claiming it) and delete there instead. */}
<Trash2
className="size-3 text-muted-foreground hover:text-destructive"
onPointerDown={(e) => { e.preventDefault(); e.stopPropagation(); deletePreset(n); }}
/>
</span>
</SelectItem>
))}
+47 -17
View File
@@ -323,13 +323,18 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [st.cw_speed]);
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
// read steadily instead of jumping every poll.
// Peak-hold: keep the highest reading for a second so the jittery VITA-49
// meters read steadily instead of jumping every poll. One second, not two —
// longer and the meters visibly trail the end of a transmission, which reads
// as the app lagging rather than as a peak being held.
const peak = useRef<Record<string, { v: number; t: number }>>({});
const peakHold = (key: string, val: number) => {
const peakHold = (key: string, val: number, windowMs = 1000, live = true) => {
// live=false throws the held value away at once (e.g. the carrier dropped),
// so the meter falls immediately instead of coasting for the window.
if (!live) { delete peak.current[key]; return val; }
const now = Date.now();
const p = peak.current[key];
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
return p.v;
};
@@ -355,12 +360,12 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
}, []);
// PowerGenius XL direct connection (fan mode), independent of the Flex link.
const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string }>({ connected: false });
const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string; state?: string; peak_w?: number }>({ connected: false });
useEffect(() => {
let alive = true;
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
tick();
const id = window.setInterval(tick, 2000);
const id = window.setInterval(tick, 250); // match the amp's 250 ms poll so the card catches every envelope peak
return () => { alive = false; window.clearInterval(id); };
}, []);
@@ -978,10 +983,12 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{spe.operate ? 'OPERATE' : 'STANDBY'}
</button>
{/* Power ON / OFF (best-guess keystrokes from the APG — verify on hw). */}
{/* Power ON pulses RTS then DTR — it must stay clickable while the amp
is off (no status = not "connected"), and serial only. */}
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
<button type="button" disabled={!spe.connected}
<button type="button" disabled={!(spe.connected || spe.transport === 'serial')}
onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
title={spe.transport === 'serial' ? 'Power on (RTS then DTR pulse)' : 'Power-on needs the serial RTS/DTR lines — not available over a network bridge'}
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
<button type="button" disabled={!spe.connected}
onClick={() => AmpPower(selAmp.id, false).catch(() => {})}
@@ -1083,9 +1090,6 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
st.amp_operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{st.amp_operate ? 'OPERATE' : 'STANDBY'}
</button>
<span className="text-xs text-muted-foreground">
{st.amp_operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
</span>
{/* Fan mode — shown when the PowerGenius is configured (Settings →
PowerGenius). The dot shows the direct-connection state; the
selector is disabled until connected (hover it for the error). */}
@@ -1113,11 +1117,38 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
{/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
and shown compact so they sit with the amp controls. */}
{(() => {
// Prefer the radio's VITA amp meters when they are flowing (local or
// over a VPN) — fast, and the same values SmartSDR shows (incl. the
// right temperature sensor). Fall back to the amp's OWN link only when
// the radio isn't streaming them (a remote link without VITA).
const meters = st.meters || [];
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
if (off || amp.length === 0) return null;
if (off || amp.length === 0) {
const P = pg as any;
if (!P.connected) return null;
// Direct-link fallback: a 500 ms poll samples the SSB envelope at
// random points, so peak-hold it (live=txing drops it the instant
// PTT releases). The LDMOS pair can pull well past 25 A → 50 A scale.
const txing = typeof st.transmitting === 'boolean' ? st.transmitting : /TRANSMIT/i.test(P.state || '');
const w = txing ? Math.round(Number(P.fwd_w) || 0) : 0;
const idA = txing ? Number(P.id) || 0 : 0;
const temp = Number(P.temperature) || 0;
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
<MeterBar label={t('ampw.pwr')} value={peakHold('pgw', w, 2500, txing)} unit="W" lo={0} hi={2000} accent="#dc2626" />
<MeterBar label={t('ampw.id')} value={peakHold('pgid', idA, 2500, txing)} unit="A" lo={0} hi={50} accent="#16a34a" />
<MeterBar label={t('ampw.temp')} value={temp} unit="°C" lo={0} hi={80} accent="#ea580c" />
</div>
);
}
// Power comes from the radio's meter stream and nothing else. The
// amplifier also reports a "peakfwd", and using it was a mistake
// twice over: it is a latched maximum that is never reset, and it
// survives in the last-known status after the amp disconnects — so
// it claimed 1350 W from an old transmission while the radio was
// putting out 10.
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
{amp.map((m) => {
@@ -1126,14 +1157,13 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
}
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
// Drain current (ID): the PGXL reports a full-scale far too small
// for this meter (~3 A), so 1.5 A pinned the bar near half. The
// value itself is fine — only the bar's range was wrong. Give it a
// fixed 25 A scale (the PGXL's LDMOS pair tops out around 20 A), and
// only override an unusable reported range, not a sane one.
// for this meter, so it pinned the bar. Give it a fixed 50 A scale
// (the LDMOS pair can pull ~45-50 A near full output), overriding an
// unusable reported range but keeping a sane one.
let lo = m.lo, hi = m.hi;
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
lo = 0;
hi = m.hi >= 25 ? m.hi : 25;
hi = m.hi >= 50 ? m.hi : 50;
}
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
})}
+143
View File
@@ -0,0 +1,143 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { useI18n } from '@/lib/i18n';
import { TailLogFile, GetLogFilePath } from '../../wailsjs/go/main/App';
// LogViewer shows the tail of opslog.log and keeps it current while open.
//
// This exists because diagnosing a problem used to mean asking the operator to
// find a file in their data directory, open it in a text editor, and re-open it
// after every attempt. Half the round-trips in a bug report were spent on that.
//
// Three behaviours matter more than they look:
// • Auto-scroll is a checkbox the operator owns, AND it releases itself the
// moment they scroll up — otherwise the line they are reading is yanked away
// a second later, which defeats the one thing this window is for. Scrolling
// back to the bottom re-arms it, so following is never a dead end.
// • The filter keeps only matching LINES rather than highlighting inside a
// 256 KB blob: with "cluster" or "acom" typed in, the window becomes exactly
// the subsystem trace you would have grepped for.
// • It polls only while open. A closed dialog costs nothing.
// 512 KB ≈ 3200 lines. The window is a sliding tail: once new lines push past it
// the oldest fall out of the fetched text entirely, so a small buffer dropped
// lines the operator was still reading during a busy trace (CAT/cluster/antenna
// polling). This is double the old 256 KB — 1 MB was tried but the per-second
// fetch + split + re-render of ~6500 lines made the window lag. 512 KB keeps a
// useful backlog while staying smooth. The backend caps this at 4 MB.
const TAIL_BYTES = 512 * 1024;
const POLL_MS = 1000;
export function LogViewer({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
const { t } = useI18n();
const [text, setText] = useState('');
const [path, setPath] = useState('');
const [query, setQuery] = useState('');
const [autoScroll, setAutoScroll] = useState(true);
// Shown next to the checkbox so a QUIET log still proves it is being polled —
// without it there is no way to tell 'nothing new' from 'window is dead'.
const [lastPull, setLastPull] = useState('');
const boxRef = useRef<HTMLPreElement>(null);
const autoRef = useRef(true);
useEffect(() => { autoRef.current = autoScroll; }, [autoScroll]);
// stick pins the view to the bottom on the NEXT frame. Setting scrollTop
// straight away is not enough while the dialog is still animating in: the box
// has no height yet, scrollHeight is wrong, and the scroll silently does
// nothing — leaving the operator staring at the top of a 256 KB buffer, i.e.
// at lines from hours ago, which looks exactly like a frozen window.
const stick = useCallback(() => {
if (!autoRef.current) return;
requestAnimationFrame(() => {
const el = boxRef.current;
if (el) el.scrollTop = el.scrollHeight;
});
}, []);
const pull = useCallback(() => {
TailLogFile(TAIL_BYTES).then((s: any) => {
setText(String(s ?? ''));
setLastPull(new Date().toLocaleTimeString());
}).catch(() => {});
}, []);
useEffect(() => {
if (!open) return;
setAutoScroll(true);
autoRef.current = true;
GetLogFilePath().then((p: any) => setPath(String(p ?? ''))).catch(() => {});
pull();
// Re-assert the bottom after the open animation has settled. A quiet log
// never changes, so the content-driven scroll below would never fire again
// and the first attempt would be the only one.
const t1 = window.setTimeout(stick, 120);
const t2 = window.setTimeout(stick, 450);
const id = window.setInterval(pull, POLL_MS);
return () => { window.clearInterval(id); window.clearTimeout(t1); window.clearTimeout(t2); };
}, [open, pull, stick]);
const all = useMemo(() => text.split('\n'), [text]);
const shown = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return all;
return all.filter((l) => l.toLowerCase().includes(q));
}, [all, query]);
// Stick to the bottom after each refresh, but only while auto-scroll is on.
useEffect(() => { if (open) stick(); }, [shown, open, stick]);
// Scrolling away from the bottom releases auto-scroll; coming back re-arms it.
function onScroll() {
const el = boxRef.current;
if (!el) return;
setAutoScroll(el.scrollHeight - el.scrollTop - el.clientHeight < 24);
}
const body = shown.join('\n');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle>{t('logview.title')}</DialogTitle>
<DialogDescription className="font-mono text-[11px] break-all">{path}</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
<Input className="h-8 flex-1 font-mono text-xs" value={query} placeholder={t('logview.searchPh')}
onChange={(e) => setQuery(e.target.value)} />
{query.trim() !== '' && (
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
{t('logview.matches', { n: shown.length, total: all.length })}
</span>
)}
</div>
<pre ref={boxRef} onScroll={onScroll}
className="h-[60vh] overflow-auto rounded-md border border-border bg-muted/30 p-2 text-[11px] leading-snug font-mono whitespace-pre-wrap break-all">
{body.trim() === '' ? t('logview.empty') : body}
</pre>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox checked={autoScroll} onCheckedChange={(c) => {
const on = !!c;
setAutoScroll(on);
if (on) { const el = boxRef.current; if (el) el.scrollTop = el.scrollHeight; }
}} />
{t('logview.autoScroll')}
</label>
{lastPull && <span className="text-xs text-muted-foreground tabular-nums">{t('logview.updated', { time: lastPull })}</span>}
<div className="flex-1" />
<Button variant="outline" size="sm" onClick={() => navigator.clipboard?.writeText(body)}>
{t('logview.copy')}
</Button>
<Button variant="outline" size="sm" onClick={() => { setQuery(''); setAutoScroll(true); pull(); }}>
{t('logview.toEnd')}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+5 -1
View File
@@ -8,7 +8,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { EventsOn } from '../../wailsjs/runtime/runtime';
@@ -120,6 +120,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
}, [service]);
// Closing the QSL Manager (the tab's ×) unmounts this panel — cancel any download
// still running so a slow QRZ/LoTW sync doesn't keep going in the background and
// bleed its log into the next one.
useEffect(() => () => { CancelConfirmations().catch(() => {}); }, []);
const [potaSyncing, setPotaSyncing] = useState(false);
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
const [potaErr, setPotaErr] = useState('');
+17 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from '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 } from '../../wailsjs/go/main/App';
import { rstOptions, type RSTLists } from '@/lib/rst';
import { AwardRefSelector } from '@/components/AwardRefSelector';
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
@@ -466,7 +466,22 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
{/* Top: Callsign + RST + Fetch */}
<div className="flex items-end gap-2 mb-3">
<div className="flex flex-col flex-1 min-w-0">
<Label>{t('qedit.callsign')}</Label>
<div className="flex items-center">
<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"
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
</div>
+6 -1
View File
@@ -56,6 +56,10 @@ type Props = {
onRowDoubleClicked?: (q: QSOForm) => void;
onRowClicked?: (q: QSOForm) => void;
onRowSelected?: (ids: number[]) => void;
// The full first selected row — the Stats (F1) panel shows its entity when the
// entry form is empty, so browsing the log answers "what have I got with this
// one?" without retyping the call.
onRowSelectedQso?: (row: QSOForm | null) => void;
onUpdateFromCty?: (ids: number[]) => void;
onUpdateFromQRZ?: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
@@ -283,7 +287,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
const stripAwardCols = (st: any[] | null | undefined): any[] =>
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
@@ -542,6 +546,7 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
setSelCount(sel.length);
setDispCount(api?.getDisplayedRowCount?.() ?? 0);
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
onRowSelectedQso?.(sel[0] ?? null);
}
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
useEffect(() => {
+29 -1
View File
@@ -24,6 +24,9 @@ interface Props {
centerLat?: number | null; // operator latitude (projection centre)
centerLon?: number | null; // operator longitude
rotorEnabled?: boolean;
rotors?: string[]; // logical rotor names; >1 → show a selector
activeRotor?: number; // index of the selected rotor (0-based)
onSelectRotor?: (i: number) => void; // switch the active rotor
onGoto?: (az: number) => void; // click-to-turn
onClose?: () => void;
}
@@ -38,7 +41,7 @@ function pt(az: number, radius: number): [number, number] {
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
}
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, onGoto, onClose }: Props) {
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose }: Props) {
const cardinals = useMemo(
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
@@ -100,6 +103,31 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
)}
</div>
{/* Multiple rotors — pick which one the dial shows and turns. */}
{rotors && rotors.length > 1 && (
<div className="flex flex-wrap gap-1 px-2 pt-1.5">
{rotors.map((nm, i) => {
const active = (activeRotor ?? 0) === i;
const label = nm?.trim() || `Rotor ${i + 1}`;
return (
<button
key={i}
onClick={() => onSelectRotor?.(i)}
className={cn(
'flex-1 min-w-[48px] px-1.5 py-0.5 rounded text-[10px] font-semibold truncate transition-colors',
active
? 'bg-success text-success-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/70',
)}
title={label}
>
{label}
</button>
);
})}
</div>
)}
<div className="flex items-center justify-center p-2 min-h-0">
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
+453 -167
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
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,
} from 'lucide-react';
import {
@@ -10,13 +10,13 @@ import {
GetListsSettings, SaveListsSettings,
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
GetAntGeniusSettings, SaveAntGeniusSettings,
GetTunerGeniusSettings, SaveTunerGeniusSettings,
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
GetAudioSettings, SaveAudioSettings, AudioApplyLevels, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
GetClublogMostWantedInfo, SetClublogMostWantedEnabled, DownloadClublogMostWanted,
GetSecretStatus, SetPassphrase, RemovePassphrase,
@@ -50,6 +50,7 @@ import {
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, GetFlexBandPower, SaveFlexBandPower,
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
GetRelayAuto, SaveRelayAuto, GetStationDevices,
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -80,7 +81,7 @@ type StationSettings = StationSettingsForm;
type ListsSettings = ListsSettingsForm;
type ModePreset = ModePresetForm;
type CATSettings = Omit<mainModels.CATSettings, 'convertValues'>;
type RotatorSettings = Omit<mainModels.RotatorSettings, 'convertValues'>;
type RotatorDevice = Omit<mainModels.RotatorDevice, 'convertValues'>;
type ClusterServer = Omit<clusterModels.ServerConfig, 'convertValues'>;
type ClusterServerStatus = Omit<clusterModels.ServerStatus, 'convertValues'>;
type Profile = Omit<profileModels.Profile, 'convertValues'>;
@@ -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.operating'), id: 'operating' },
{ 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' },
],
},
@@ -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 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'];
// 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);
// 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 }) {
const label = SECTION_LABELS[id] ?? id;
const IconCmp = Icon ?? Construction;
@@ -1136,20 +1226,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
yaesu_port: '', yaesu_baud: 38400, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70,
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8', share_enabled: false, share_port: 4532,
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false,
});
const [rotator, setRotator] = useState<RotatorSettings>({
enabled: false, type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false, rotator_num: 1,
} as any);
// While true, the next key press is captured as the PTT hotkey.
const [capturingPtt, setCapturingPtt] = useState(false);
const [rotors, setRotors] = useState<RotatorDevice[]>([]);
const [rotatorTesting, setRotatorTesting] = useState(false);
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
// 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 }>({
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,
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, bands: [], freq_min_mhz: 13, freq_max_mhz: 54,
});
const [ubTesting, setUbTesting] = useState(false);
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
@@ -1198,13 +1289,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
from_radio: string; to_radio: string; recording_device: string; listening_device: string;
qso_record: boolean; qso_dir: string; preroll_seconds: number;
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3';
from_gain: number; mic_gain: number; tx_gain: number;
from_gain: number; mic_gain: number; tx_gain: number; qso_play_gain: number;
};
type AudioDev = { id: string; name: string; default: boolean };
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav',
from_gain: 100, mic_gain: 100, tx_gain: 100,
from_gain: 100, mic_gain: 100, tx_gain: 100, qso_play_gain: 100,
});
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
const [audioOutputs, setAudioOutputs] = useState<AudioDev[]>([]);
@@ -1245,6 +1336,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
// Password-encryption (secret vault) state.
@@ -1498,7 +1590,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
try {
const [l, ls, c, ap, r, b, qd, es] = await Promise.all([
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
GetRotatorSettings(), GetBackupSettings(), GetQSLDefaults(), GetExternalServices(),
GetRotators(), GetBackupSettings(), GetQSLDefaults(), GetExternalServices(),
]);
setLookup(l);
setActiveProfile(ap as Profile);
@@ -1511,7 +1603,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
await reloadProfiles();
await reloadClusterServers();
setCatCfg(c);
setRotator(r);
setRotors((r ?? []) as any);
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
@@ -1552,7 +1644,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
try { setActiveProfile(await GetActiveProfile() as Profile); } catch {}
try { setLookup(await GetLookupSettings() as any); } catch {}
try { setCatCfg(await GetCATSettings() as any); } catch {}
try { setRotator(await GetRotatorSettings() as any); } catch {}
try { setRotors(((await GetRotators()) ?? []) as any); } catch {}
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
@@ -1744,7 +1836,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
}
await SaveLookupSettings(lookup as any);
await SaveCATSettings(catCfg as any);
await SaveRotatorSettings(rotator as any);
await SaveRotators(rotors as any);
await SaveUltrabeamSettings(ultrabeam as any);
await SaveAntGeniusSettings(antgenius as any);
await SaveTunerGeniusSettings(tunergenius as any);
@@ -2538,6 +2630,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
}} />
<span className="text-xs text-muted-foreground">{t('cat.xieguAddrHint')}</span>
</div>
<div className="space-y-1">
<Label>{t('cat.xieguPTTLine')}</Label>
<Select value={catCfg.xiegu_ptt_line || 'civ'}
onValueChange={(v) => setCatCfg((s) => ({ ...s, xiegu_ptt_line: v === 'civ' ? '' : v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="civ">{t('cat.xieguPTTCiv')}</SelectItem>
<SelectItem value="rts">RTS</SelectItem>
<SelectItem value="dtr">DTR</SelectItem>
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">{t('cat.xieguPTTHint')}</span>
</div>
</>
)}
{catCfg.backend === 'yaesu' && (
@@ -2567,6 +2672,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</Select>
<span className="text-xs text-muted-foreground">{t('cat.yaesuBaudHint')}</span>
</div>
<div className="space-y-1">
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox checked={!!catCfg.yaesu_low_lines} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, yaesu_low_lines: !!c }))} />
{t('cat.lowerLines')}
</label>
<span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
</div>
</>
)}
{['icom', 'xiegu', 'kenwood'].includes(catCfg.backend) && (
@@ -2574,9 +2686,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={civTrace} onCheckedChange={(c) => { setCivTrace(!!c); SetCIVTrace(!!c); }} />
{t('cat.civTrace')}
</label>
<p className="text-[10px] text-muted-foreground mt-1">{t('cat.civTraceHint')}</p>
</div>
</label> </div>
)}
{catCfg.backend === 'kenwood' && (
<>
@@ -2592,9 +2702,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</Select>
<Button type="button" variant="outline" size="sm"
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}></Button>
</div>
<span className="text-xs text-muted-foreground">{t('cat.kenwoodPortHint')}</span>
</div>
</div> </div>
<div className="space-y-1">
<Label>{t('cat.baud')}</Label>
<Select value={String(catCfg.kenwood_baud || 9600)} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_baud: parseInt(v) || 9600 }))}>
@@ -2602,18 +2710,31 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SelectContent>
{[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)}
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">{t('cat.kenwoodBaudHint')}</span>
</div>
</Select> </div>
<div className="space-y-1">
<Label>{t('cat.kenwoodHost')}</Label>
<Input
value={catCfg.kenwood_host || ''}
placeholder="192.168.1.50:4532"
onChange={(e) => setCatCfg((s) => ({ ...s, kenwood_host: e.target.value }))}
/>
<span className="text-xs text-muted-foreground">{t('cat.kenwoodHostHint')}</span>
/> </div>
<div className="space-y-1">
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox checked={!!catCfg.kenwood_low_lines} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, kenwood_low_lines: !!c }))} />
{t('cat.lowerLines')}
</label>
<span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
</div>
<div className="space-y-1">
<Label>{t('cat.kwDataMode')}</Label>
<Select value={(catCfg as any).kenwood_data_mode || 'usb'} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_data_mode: v } as any))}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="usb">{t('cat.kwDataUsb')}</SelectItem>
<SelectItem value="data">{t('cat.kwDataMd6')}</SelectItem>
<SelectItem value="keep">{t('cat.kwDataKeep')}</SelectItem>
</SelectContent>
</Select> </div>
</>
)}
{catCfg.backend === 'icom' && (
@@ -2784,6 +2905,52 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
)}
</div>
{/* PTT hotkey a keyboard key that keys the rig while OpsLog is focused.
Uses the Audio PTT method (CAT / RTS / DTR), falling back to CAT. */}
<div className="border-t border-border/60 pt-3 space-y-2">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={!!(catCfg as any).ptt_hotkey_enabled}
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, ptt_hotkey_enabled: !!c } as any))}
/>
{t('cat.pttKey')}
</label>
{(catCfg as any).ptt_hotkey_enabled && (
<>
<div className="flex items-center gap-2">
<button
type="button"
onKeyDown={(e) => {
if (!capturingPtt) return;
e.preventDefault();
if (e.code === 'Escape') { setCapturingPtt(false); return; }
setCatCfg((s) => ({ ...s, ptt_hotkey: e.code } as any));
setCapturingPtt(false);
}}
onClick={() => setCapturingPtt(true)}
className="min-w-[140px] rounded-md border border-border bg-muted/40 px-3 py-1.5 text-sm font-mono hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary"
>
{capturingPtt ? t('cat.pttKeyPress') : ((catCfg as any).ptt_hotkey || t('cat.pttKeyNone'))}
</button>
{(catCfg as any).ptt_hotkey && !capturingPtt && (
<button
type="button"
onClick={() => setCatCfg((s) => ({ ...s, ptt_hotkey: '' } as any))}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t('cat.pttKeyClear')}
</button>
)}
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={!!(catCfg as any).ptt_hotkey_toggle}
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, ptt_hotkey_toggle: !!c } as any))}
/>
{t('cat.pttKeyToggle')}
</label> </>
)}
</div>
{catCfg.backend === 'omnirig' && (
<>
<label className="flex items-center gap-2 text-sm cursor-pointer">
@@ -2803,12 +2970,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
);
}
async function testRotator() {
async function testRotator(dev: RotatorDevice, sub = 1) {
setRotatorTesting(true);
setRotatorTest(null);
try {
await TestRotator(rotator as any);
setRotatorTest({ ok: true, msg: (rotator as any).type === 'rotgenius' ? t('rot.testOkRG') : t('cat.rotatorOk') });
await TestRotatorDevice(dev as any, sub);
setRotatorTest({ ok: true, msg: (dev as any).type === 'rotgenius' ? t('rot.testOkRG') : t('cat.rotatorOk') });
} catch (e: any) {
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
} finally {
@@ -2935,20 +3102,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
)}
</div>
{isSteppir && (
<div className="border-t border-border/60 pt-3 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<Label className="text-sm">{t('hw.steppirRange')}</Label>
<input type="number" min={1} max={60} value={ultrabeam.freq_min_mhz}
onChange={(e) => setUltrabeam((s) => ({ ...s, freq_min_mhz: parseInt(e.target.value, 10) || 0 }))}
className="h-8 w-16 rounded-md border border-input bg-background px-2 text-sm" />
<span className="text-xs text-muted-foreground"></span>
<input type="number" min={1} max={60} value={ultrabeam.freq_max_mhz}
onChange={(e) => setUltrabeam((s) => ({ ...s, freq_max_mhz: parseInt(e.target.value, 10) || 0 }))}
className="h-8 w-16 rounded-md border border-input bg-background px-2 text-sm" />
<span className="text-xs text-muted-foreground">MHz</span>
{(isSteppir || ultrabeam.type === 'ultrabeam') && (
<div className="border-t border-border/60 pt-3 space-y-2">
<Label className="text-sm">{t('hw.motorBands')}</Label>
<div className="flex items-center gap-1.5 flex-wrap">
{MOTOR_BANDS.map((b) => {
const on = ultrabeam.bands.includes(b);
return (
<button key={b} type="button"
onClick={() => setUltrabeam((s) => ({
...s,
bands: on ? s.bands.filter((x) => x !== b) : [...s.bands, b],
}))}
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>
<p className="text-xs text-muted-foreground">{t('hw.steppirRangeHint')}</p>
</div>
)}
<div className="border-t border-border/60 pt-3 space-y-1">
@@ -3197,6 +3372,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
</div>
)}
{/* PowerGenius XL asks for a remote-access code when reached from
outside the LAN (banner "V… AUTH"), like the Tuner/Antenna Genius.
Blank on the LAN. */}
{isPGXL && (
<div className="space-y-1 max-w-xs">
<Label>{t('amp.password')}</Label>
<Input type="password" value={(amp as any).password ?? ''}
onChange={(e) => patchAmp(i, { password: e.target.value } as any)}
placeholder={t('amp.passwordPh')} className="font-mono" />
<p className="text-[11px] text-muted-foreground">{t('amp.passwordHint')}</p>
</div>
)}
{/* Band-follow, for any amp that takes its band from a transceiver
CAT link (ACOM and SPE both do) never PowerGenius, which is
@@ -3271,136 +3458,206 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Button variant="outline" size="sm" onClick={addAmp}>
<Plus className="size-3.5 mr-1" /> {t('amp.add')}
</Button>
</div>
</>
);
}
function RotatorPanel() {
const isRG = (rotator as any).type === 'rotgenius';
const isARCO = (rotator as any).type === 'arco';
// Rotors are a list, like amplifiers: add with +, remove with the trash icon.
// A Rotator Genius with "two rotors" contributes 2 addressable rotors.
const patch = (i: number, p: Partial<RotatorDevice>) => setRotors((l) => l.map((d, j) => (j === i ? { ...d, ...p } : d)) as any);
const addRotor = () => setRotors((l) => [...l, {
id: '', name: '', type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false,
rotator_num: 1, dual: false, motorized: true, name2: '', motorized2: false,
transport: 'tcp', com_port: '', baud: 9600,
} as any]);
const removeRotor = (i: number) => setRotors((l) => l.filter((_, j) => j !== i));
const anyPst = rotors.some((d) => ((d as any).type ?? 'pst') === 'pst');
// The motorized-antenna choice only matters once more than one rotor exists.
const multi = rotors.length > 1 || rotors.some((d) => (d as any).type === 'rotgenius' && (d as any).dual);
return (
<>
<SectionHeader
title="Rotator"
hint={isRG || isARCO ? undefined : t('rot.hint')}
/>
<SectionHeader title="Rotator" hint={anyPst ? t('rot.hint') : undefined} />
<div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={rotator.enabled} onCheckedChange={(c) => setRotator((s) => ({ ...s, enabled: !!c }))} />
{t('rot.enable')}
</label>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>{t('rot.type')}</Label>
{/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
(placeholder must match the ARCO's LAN menu), PstRotator 12000. */}
<Select value={(rotator as any).type ?? 'pst'}
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : v === 'arco' ? 4001 : 12000 } as any))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
<SelectItem value="arco">GS-232A controller (microHAM ARCO, ERC)</SelectItem>
</SelectContent>
</Select>
</div>
{isRG && (
<div className="space-y-1">
<Label>{t('rot.rotatorNum')}</Label>
<Select value={String((rotator as any).rotator_num ?? 1)}
onValueChange={(v) => setRotator((s) => ({ ...s, rotator_num: parseInt(v, 10) || 1 } as any))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
</SelectContent>
</Select>
{rotors.length === 0 && <p className="text-sm text-muted-foreground">{t('rot.none')}</p>}
{rotors.map((d, i) => {
const dev = d as any;
const isRG = dev.type === 'rotgenius';
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';
return (
<div key={dev.id || i} className="rounded-xl border border-border bg-card/40 p-3 space-y-3">
<div className="flex items-center gap-2">
<Compass className="size-4 text-primary shrink-0" />
<Input className="h-8 flex-1" value={dev.name ?? ''} placeholder={`Rotor ${i + 1}`}
onChange={(e) => patch(i, { name: e.target.value })} />
<Button size="icon" variant="ghost" className="size-8 text-muted-foreground hover:text-destructive"
onClick={() => removeRotor(i)} title={t('rot.remove')}>
<Trash2 className="size-4" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>{t('rot.type')}</Label>
{/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
(placeholder must match the ARCO's LAN menu), PstRotator 12000. */}
<Select value={dev.type ?? 'pst'}
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>
<SelectContent>
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</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>
</Select>
</div>
{isRG && !dev.dual && (
<div className="space-y-1">
<Label>{t('rot.rotatorNum')}</Label>
<Select value={String(dev.rotator_num ?? 1)}
onValueChange={(v) => patch(i, { rotator_num: parseInt(v, 10) || 1 })}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* ARCO and DCU-1 controllers reach over the LAN (TCP) or a serial COM. */}
{isSerialCap && (
<div className="space-y-1">
<Label>Connection</Label>
<Select value={transport} onValueChange={(v) => patch(i, { transport: v as any })}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="tcp">Network (LAN)</SelectItem>
<SelectItem value="serial">USB (serial COM)</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{/* Rotator Genius drives both its ports — one entry = two rotors. */}
{isRG && (
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={!!dev.dual} onCheckedChange={(c) => patch(i, { dual: !!c })} />
{t('rot.rgDual')}
</label>
)}
{isSerialCap && transport === 'serial' ? (
<div className="space-y-1 max-w-xs">
<Label>COM port</Label>
<div className="flex items-center gap-2">
<Select value={dev.com_port || '_'} onValueChange={(v) => patch(i, { com_port: v === '_' ? '' : v })}>
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
<SelectContent>
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
<ArrowDown className="size-3.5 rotate-90" />
</Button>
<Select value={String(dev.baud || 9600)} onValueChange={(v) => patch(i, { baud: Number(v) })}>
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
<SelectContent>
{[4800, 9600, 19200, 38400, 57600].map((b) => (
<SelectItem key={b} value={String(b)}>{b} baud</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
) : (
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>Host / IP</Label>
<Input value={dev.host ?? ''} onChange={(e) => patch(i, { host: e.target.value })}
placeholder={isRG || isSerialCap ? '192.168.1.60' : '127.0.0.1'} className="font-mono" />
</div>
<div className="space-y-1">
<Label>{isRG || isSerialCap ? 'TCP port' : 'UDP port'}</Label>
<PortInput value={dev.port} onChange={(n) => patch(i, { port: n })}
fallback={isRG ? 9006 : isSerialCap ? 4001 : 12000} className="font-mono" />
</div>
</div>
)}
{!isRG && !isSerialCap && (
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={!!dev.has_elevation} onCheckedChange={(c) => patch(i, { has_elevation: !!c })} />
This rotator supports elevation (VHF / satellite)
</label>
)}
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</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. */}
{multi && (
<div className="space-y-1 max-w-xs">
<Label>{isRG && dev.dual ? t('rot.antenna1') : t('rot.antenna')}</Label>
<Select value={dev.motorized ? 'motor' : 'std'} onValueChange={(v) => patch(i, { motorized: v === 'motor' })}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="std">{t('rot.antStd')}</SelectItem>
<SelectItem value="motor">{t('rot.antMotor')}</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Second logical rotor of a dual Rotator Genius: its own name + antenna. */}
{isRG && dev.dual && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>{t('rot.name2')}</Label>
<Input value={dev.name2 ?? ''} placeholder={`${dev.name || 'Rotor'} 2`}
onChange={(e) => patch(i, { name2: e.target.value })} />
</div>
<div className="space-y-1">
<Label>{t('rot.antenna2')}</Label>
<Select value={dev.motorized2 ? 'motor' : 'std'} onValueChange={(v) => patch(i, { motorized2: v === 'motor' })}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="std">{t('rot.antStd')}</SelectItem>
<SelectItem value="motor">{t('rot.antMotor')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => testRotator(d, 1)} disabled={rotatorTesting}>
{rotatorTesting ? t('hw.sending') : (isRG && dev.dual ? t('rot.testRotor1') : t('hw.testRotator'))}
</Button>
{isRG && dev.dual && (
<Button variant="outline" size="sm" onClick={() => testRotator(d, 2)} disabled={rotatorTesting}>
{t('rot.testRotor2')}
</Button>
)}
</div>
</div>
)}
{/* The ARCO is reachable over the LAN (TCP) or its USB virtual COM. */}
{isARCO && (
<div className="space-y-1">
<Label>Connection</Label>
<Select value={(rotator as any).transport ?? 'tcp'}
onValueChange={(v) => setRotator((s) => ({ ...s, transport: v } as any))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="tcp">Network (LAN)</SelectItem>
<SelectItem value="serial">USB (serial COM)</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{isARCO && (rotator as any).transport === 'serial' ? (
<div className="space-y-1 max-w-xs">
<Label>COM port</Label>
<div className="flex items-center gap-2">
<Select value={(rotator as any).com_port || '_'}
onValueChange={(v) => setRotator((s) => ({ ...s, com_port: v === '_' ? '' : v } as any))}>
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
<SelectContent>
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
<ArrowDown className="size-3.5 rotate-90" />
</Button>
<Select value={String((rotator as any).baud || 9600)}
onValueChange={(v) => setRotator((s) => ({ ...s, baud: Number(v) } as any))}>
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
<SelectContent>
{[4800, 9600, 19200, 38400, 57600].map((b) => (
<SelectItem key={b} value={String(b)}>{b} baud</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
) : (
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>Host / IP</Label>
<Input
value={rotator.host ?? ''}
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
placeholder={isRG || isARCO ? '192.168.1.60' : '127.0.0.1'}
className="font-mono"
/>
</div>
<div className="space-y-1">
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
<PortInput
value={rotator.port}
onChange={(n) => setRotator((s) => ({ ...s, port: n }))} fallback={isRG ? 9006 : isARCO ? 4001 : 12000}
className="font-mono"
/>
</div>
</div>
)}
{!isRG && !isARCO && (
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
This rotator supports elevation (VHF / satellite)
</label>
)}
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
<div className="flex items-center gap-2 pt-2">
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
</Button>
<Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
Stop
</Button>
{!isRG && (
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
Park
);
})}
<Button variant="outline" size="sm" onClick={addRotor}>
<Plus className="size-3.5 mr-1" /> {t('rot.add')}
</Button>
{rotors.length > 0 && (
<div className="flex items-center gap-2 pt-2">
<Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))}>
Stop
</Button>
)}
</div>
{anyPst && (
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))}>
Park
</Button>
)}
</div>
)}
{rotatorTest && (
<div className={cn(
'text-xs rounded-md p-2.5 border',
@@ -3412,8 +3669,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
)}
<p className="text-xs text-muted-foreground">
From the main entry strip, click the bearing pill to rotate to the short-path azimuth.
Shift+click for long-path, Ctrl+click to stop.
From the main entry strip, click the bearing pill to rotate the active rotor to the short-path
azimuth. Shift+click for long-path, Ctrl+click to stop. With several rotors, pick the active one
on the compass widget.
</p>
</div>
</>
@@ -3451,6 +3709,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SelectItem value="serial">{t('wk.engSerial')}</SelectItem>
<SelectItem value="icom">{t('wk.engIcom')}</SelectItem>
<SelectItem value="yaesu">{t('wk.engYaesu')}</SelectItem>
<SelectItem value="kenwood">{t('wk.engKenwood')}</SelectItem>
<SelectItem value="flex">{t('wk.engFlex')}</SelectItem>
<SelectItem value="tci" disabled>{t('wk.engTci')}</SelectItem>
</SelectContent>
@@ -3497,6 +3756,22 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
</div>
</>
) : wk.engine === 'kenwood' ? (
<>
{(!catCfg.enabled || catCfg.backend !== 'kenwood') && (
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
<span aria-hidden></span>
<span>{t('wk.catWarnKenwood', { backend: catCfg.enabled ? (catCfg.backend || 'none') : 'disabled' })}</span>
</p>
)}
<p className="text-xs text-muted-foreground -mt-1">{t('wk.kenwoodHint')}</p>
<div className="grid grid-cols-4 gap-3">
<div className="space-y-1">
<Label>{t('wk.speed')}</Label>
<Input type="number" min={4} max={60} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
</div>
</div>
</>
) : wk.engine === 'flex' ? (
<>
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
@@ -3828,6 +4103,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<span className="font-mono">dx.maritimecontestclub.net:7300</span>,{' '}
<span className="font-mono">w8avi.net:7300</span>.
</p>
<label className="flex items-start gap-2 text-sm cursor-pointer border-t border-border/60 pt-3">
<Checkbox checked={clusterWorkedSameSlot} className="mt-0.5"
onCheckedChange={(c) => { const v = !!c; setClusterWorkedSameSlot(v); writeUiPref('opslog.clusterWorkedSameSlot', v ? '1' : '0'); }} />
<span>{t('clu.workedSameSlot')} <span className="text-xs text-muted-foreground">{t('clu.workedSameSlotHint')}</span></span>
</label>
</div>
{editingServer && (
@@ -5115,13 +5395,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Label className="text-sm">{t('aud.fromLevel')}</Label>
<div className="flex items-center gap-2">
<input type="range" min={10} max={300} step={5} value={audioCfg.from_gain}
onChange={(e) => setAudioField({ from_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
onChange={(e) => { const v = parseInt(e.target.value, 10); setAudioField({ from_gain: v }); AudioApplyLevels(v, audioCfg.mic_gain); }} className="w-48 accent-primary" />
<span className="font-mono text-xs w-12 text-right">{audioCfg.from_gain}%</span>
</div>
<Label className="text-sm">{t('aud.qsoPlayLevel')}</Label>
<div className="flex items-center gap-2">
<input type="range" min={10} max={300} step={5} value={audioCfg.qso_play_gain}
onChange={(e) => setAudioField({ qso_play_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
<span className="font-mono text-xs w-12 text-right">{audioCfg.qso_play_gain}%</span>
</div>
<Label className="text-sm">{t('aud.micLevel')}</Label>
<div className="flex items-center gap-2">
<input type="range" min={10} max={300} step={5} value={audioCfg.mic_gain}
onChange={(e) => setAudioField({ mic_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
onChange={(e) => { const v = parseInt(e.target.value, 10); setAudioField({ mic_gain: v }); AudioApplyLevels(audioCfg.from_gain, v); }} className="w-48 accent-primary" />
<span className="font-mono text-xs w-12 text-right">{audioCfg.mic_gain}%</span>
</div>
</div>
@@ -5623,7 +5909,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
database: DatabasePanel,
uscounties: USCountiesPanel,
autostart: () => <AutostartPanelComponent />,
awards: () => <ComingSoon id="awards" icon={Award} />,
awards: () => <AwardsSelectionPanel profile={activeProfile ?? undefined} />,
cat: CATPanel,
rotator: RotatorPanel,
winkeyer: WinkeyerPanel,
+90 -36
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -13,7 +13,7 @@ import { TunerCard } from '@/components/TunerCard';
import type { TGStatus } from '@/components/TunerGeniusPanel';
import {
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
GetRotatorHeading, RotatorGoTo, RotatorStop,
GetRotatorHeading, RotatorGoTo, RotatorStop, SetActiveRotor,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
GetAmpStatuses, GetFlexState,
@@ -26,19 +26,28 @@ type Device = { id: string; type: string; name: string; host: string; user?: str
type Relay = { number: number; label: string; on: boolean };
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8 };
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)' };
// Dashboard geometry: a grid of EQUAL columns whose cards also share a common
// HEIGHT per row — every card stretches to the tallest one beside it. Masonry
// packed tighter but left the row edges ragged, which read as untidy on a wide
// screen; aligned rows are what an operator expects from a dashboard.
const CARD_MIN = 430; // narrowest a card may get before "Auto" drops a column
const GRID_GAP = 16; // px between cards, both axes
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8, dingtian: 2 };
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)', dingtian: 'Dingtian IOT relay' };
// Relay count for a configured device: fixed by type, except Denkovi (4/8) and
// the generic USB-serial board, whose channel count the user picks.
const chanCount = (d: Device): number =>
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8) : (RELAY_COUNT[d.type] ?? 5);
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8)
: d.type === 'dingtian' ? (d.channels && d.channels >= 1 ? d.channels : 2)
: (RELAY_COUNT[d.type] ?? 5);
function blankDevice(): Device {
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
}
type Heading = { enabled: boolean; ok: boolean; azimuth: number };
type Heading = { enabled: boolean; ok: boolean; azimuth: number; rotors?: string[]; active?: number };
// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
// Station Control tab: a live compass you can click to turn, a heading readout, a
@@ -73,6 +82,9 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
centerLat={centerLat ?? null}
centerLon={centerLon ?? null}
rotorEnabled={hd.ok}
rotors={hd.rotors}
activeRotor={hd.active}
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
onGoto={(az) => turn(az)}
/>
<div className="flex-1 min-w-0 space-y-2">
@@ -269,6 +281,23 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
// Max columns per row (persisted). "Auto" fills the window; a number caps the
// row so cards wrap onto further lines even when there's horizontal room.
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
// How many columns the dashboard shows. "Auto" fits as many CARD_MIN-wide
// cards as the window allows; an explicit count is honoured as asked, even
// past that width — the operator can see his own screen.
const gridRef = useRef<HTMLDivElement | null>(null);
const [colCount, setColCount] = useState(1);
useLayoutEffect(() => {
if (cols !== 'auto') { setColCount(Number(cols) || 1); return; }
const el = gridRef.current;
if (!el) return;
const measure = () => setColCount(Math.max(1, Math.floor((el.clientWidth + GRID_GAP) / (CARD_MIN + GRID_GAP))));
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [cols]);
// Amplifiers (Settings → Amplifier): shown here so operators without a
// FlexRadio panel still get the controls. EVERY configured amp gets its own
// card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the
@@ -415,10 +444,10 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
);
};
// `wide` cards span the whole dashboard instead of sitting in one masonry
// column. The amplifier and tuner carry meter rows and a channel selector that
// are unreadable squeezed into a ~430px column — they are the same cards the
// FlexRadio panel shows full-width, and they need that room here too.
// `wide` cards take two grid columns instead of one. The amplifier and tuner
// carry meter rows and a channel selector that are unreadable squeezed into a
// single ~430px column — they are the same cards the FlexRadio panel shows
// full-width, and they need that room here too.
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
if (rot.enabled) {
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
@@ -446,7 +475,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
{/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a
card can sit on a further line even with horizontal room to spare. */}
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
{(['auto', '1', '2', '3', '4'] as const).map((c) => (
{(['auto', '1', '2', '3', '4', '5', '6'] as const).map((c) => (
<button key={c} type="button"
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
@@ -466,31 +495,36 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
</div>
)}
{/* Masonry dashboard: fixed-width cards flow into balanced CSS columns so
they pack tightly by height (no ragged gaps under short cards). "Auto"
fits as many ~430px columns as the window allows; a fixed count caps the
container width to that many columns. Each card has a grip handle (left
rail) as the drag initiator (the body is full of buttons). */}
<div style={{ columnWidth: '430px', columnGap: '1rem', columnFill: 'balance',
...(cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : {}) }}>
{/* Dashboard grid: equal columns, and every card stretches to the height of
the tallest one on its row, so the rows line up instead of ending
ragged. One column each, two for the amplifier/tuner whose meter rows
need the room. Each card has a grip handle (left rail) as the drag
initiator, since the body is full of buttons. */}
<div ref={gridRef} style={{
display: 'grid',
gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))`,
gap: `${GRID_GAP}px`,
}}>
{ordered.map((w) => (
// column-span:all lifts a wide card out of the columns and across the
// full container, while keeping it in document order — so drag-reorder
// still works between wide and normal cards. Capped so it stays a card
// and not a banner on an ultra-wide window.
<div key={w.id} className="flex items-stretch break-inside-avoid mb-4"
style={w.wide ? { columnSpan: 'all', maxWidth: '900px' } : undefined}
<div key={w.id}
// A wide card takes two columns — but never more than there are, or
// the grid would grow a phantom column on a narrow window.
style={{ gridColumn: w.wide ? `span ${Math.min(2, colCount)}` : undefined }}
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
<div draggable
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
onDragEnd={() => { dragId.current = null; }}
title={t('station.dragMove')}
className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors',
dragId.current === w.id && 'opacity-60')}>
<GripVertical className="size-4" />
{/* h-full down the chain is what makes the card fill the row height
rather than sit at its natural size in a taller cell. */}
<div className="flex items-stretch h-full">
<div draggable
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
onDragEnd={() => { dragId.current = null; }}
title={t('station.dragMove')}
className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors',
dragId.current === w.id && 'opacity-60')}>
<GripVertical className="size-4" />
</div>
<div className="flex-1 min-w-0 [&>*]:h-full">{w.node}</div>
</div>
<div className="flex-1 min-w-0">{w.node}</div>
</div>
))}
</div>
@@ -523,6 +557,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
onChange({ ...device, channels, labels });
};
const isKM = device.type === 'kmtronic';
const isDingtian = device.type === 'dingtian';
const isDenkovi = device.type === 'denkovi';
const isUsbRelay = device.type === 'usbrelay';
// COM ports for the generic USB-serial relay picker.
@@ -573,6 +608,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
<SelectContent>
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
<SelectItem value="dingtian">Dingtian IOT relay (LAN / WiFi)</SelectItem>
<SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
<SelectItem value="usbrelay">Denkovi USB (serial / COM)</SelectItem>
</SelectContent>
@@ -583,13 +619,13 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
</div>
</div>
{(isDenkovi || isUsbRelay) && (
{(isDenkovi || isUsbRelay || isDingtian) && (
<div className="space-y-1 max-w-[10rem]">
<Label>{t('station.channels')}</Label>
<Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
{(isDenkovi ? [4, 8] : [1, 2, 4, 8, 16]).map((n) => (
{(isDenkovi ? [4, 8] : isDingtian ? [2, 4, 8, 16, 24, 32] : [1, 2, 4, 8, 16]).map((n) => (
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
))}
</SelectContent>
@@ -632,11 +668,29 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
</div>
) : (
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
<div className={cn('grid gap-3', (isKM || isDingtian) ? 'grid-cols-3' : 'grid-cols-1')}>
<div className={cn('space-y-1', (isKM || isDingtian) ? '' : 'max-w-xs')}>
<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 })} />
<span className="text-[10px] text-muted-foreground">{t('station.hostHint')}</span>
</div>
{/* 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
relay password is set. */}
{isDingtian && (
<>
<div className="space-y-1">
<Label>{t('station.dtSession')}</Label>
<Input className="font-mono" value={device.user ?? ''} placeholder={t('station.optional')}
onChange={(e) => onChange({ ...device, user: e.target.value })} />
</div>
<div className="space-y-1">
<Label>{t('station.dtPwd')}</Label>
<Input className="font-mono" value={device.pass ?? ''} placeholder="0"
onChange={(e) => onChange({ ...device, pass: e.target.value.replace(/[^0-9]/g, '') })} />
</div>
</>
)}
{isKM && (
<>
<div className="space-y-1">
+8 -4
View File
@@ -76,13 +76,16 @@ export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?:
const peakHold = (key: string, val: number) => {
const now = Date.now();
const p = peak.current[key];
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
if (!p || val >= p.v || now - p.t > 1000) { peak.current[key] = { v: val, t: now }; return val; }
return p.v;
};
const connected = !!status.connected;
const rawVswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
const rawFwdW = status.fwd_w && status.fwd_w >= 1 ? status.fwd_w : 0;
// Prefer the device's own peak detector over the sampled reading; the JS hold
// below still smooths what is left between polls.
const rawPeak = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
const rawFwdW = rawPeak >= 1 ? rawPeak : 0;
const fwdW = peakHold('fwd', rawFwdW);
// SWR needs RF to mean anything: sampled on receive the device reports a
@@ -93,13 +96,14 @@ export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?:
// That expiry is the point: the first version of this held the last TX value
// with no timeout at all, so the meter sat frozen on the previous transmission
// indefinitely — reading 1.39:1 with the radio plainly in RX and 0 W forward.
// Same 2 s window as the power peak above, so the two meters clear together.
// Same one-second window as the power peak above, so the two meters clear
// together.
const swrHold = useRef<{ v: number; t: number } | null>(null);
if (rawFwdW >= 1 && rawVswr) swrHold.current = { v: rawVswr, t: Date.now() };
const heldSwr = swrHold.current;
const vswr = rawFwdW >= 1
? rawVswr
: heldSwr && Date.now() - heldSwr.t < 2000
: heldSwr && Date.now() - heldSwr.t < 1000
? heldSwr.v
: undefined;
const active = status.active ?? 1;
+6 -2
View File
@@ -8,7 +8,7 @@ export type TGChannel = {
};
export type TGStatus = {
connected: boolean; host?: string; last_error?: string;
fwd_dbm?: number; fwd_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number;
fwd_dbm?: number; fwd_w?: number; peak_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number;
operate?: boolean; bypass?: boolean; tuning?: boolean; active?: number;
antenna?: number; three_way?: boolean; message?: string;
a?: TGChannel; b?: TGChannel;
@@ -69,6 +69,10 @@ export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActiva
const { t } = useI18n();
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
const active = status.active ?? 1;
// The device's own peak, not the instantaneous reading: a 400 ms poll lands
// between syllables as often as on a peak, so the plain figure showed a few
// hundred watts on a kilowatt transmission.
const fwdW = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
return (
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
@@ -103,7 +107,7 @@ export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActiva
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.power')}</div>
<div className="text-lg font-bold font-mono leading-tight text-foreground/80">
{status.fwd_w && status.fwd_w >= 1 ? `${Math.round(status.fwd_w)} W` : '—'}
{fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
</div>
</div>
</div>
+4 -4
View File
@@ -26,7 +26,7 @@ interface Props {
wpm: number;
macros: WKMacro[];
sent: string; // text echoed back by the keyer as it transmits
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu'; // CW output engine (chosen in Settings → CW Keyer)
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | 'kenwood'; // CW output engine (chosen in Settings → CW Keyer)
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
onSetBreakIn?: (mode: number) => void;
onSelectPort: (p: string) => void;
@@ -101,16 +101,16 @@ export function WinkeyerPanel({
<Radio className="size-4 text-primary shrink-0" />
{/* CW output engine (chosen in Settings → CW Keyer). */}
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : 'WinKeyer'}
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : source === 'kenwood' ? 'Kenwood CW' : 'WinKeyer'}
</span>
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
<div className="flex-1" />
{source === 'icom' || source === 'flex' || source === 'yaesu' ? (
{source === 'icom' || source === 'flex' || source === 'yaesu' || source === 'kenwood' ? (
<span className="text-[11px] font-medium text-muted-foreground">
{source === 'flex'
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
: source === 'yaesu'
: source === 'yaesu' || source === 'kenwood'
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
</span>
+5 -1
View File
@@ -37,7 +37,11 @@ const TabsContent = React.forwardRef<
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn('mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', className)}
// No focus ring on the panel itself. Radix makes the content focusable and
// moves focus into it when a tab is selected, so clicking anywhere in the
// lower half drew a full-width orange rule under the tab strip. The panel is
// a container — what it holds carries its own focus styling.
className={cn('mt-2 focus-visible:outline-none', className)}
{...props}
/>
));
File diff suppressed because one or more lines are too long
+83
View File
@@ -0,0 +1,83 @@
// Sunrise / sunset for a station, in UTC.
//
// Built on greyline.ts's sunPosition() rather than on a second implementation of
// the solar equations: the map's terminator and the entry strip's sunrise must
// never disagree, and there is a whole comment block over there about a sign
// error that produced six months of plausible-looking wrong answers. One source
// of astronomy, one place to be wrong.
//
// Everything is UTC. Amateur radio runs on UTC, the log is in UTC, and a local
// time would raise the question "local to whom — me or the DX?".
import { sunPosition } from './greyline';
const DEG = Math.PI / 180;
// Standard altitude of the Sun's centre at rise/set: half a solar diameter below
// the horizon plus atmospheric refraction.
const H0 = -0.833;
// solarAltitude returns the Sun's elevation in degrees at a place and instant.
function solarAltitude(date: Date, latDeg: number, lonDeg: number): number {
const { dec, gha } = sunPosition(date);
const ha = (gha + lonDeg) * DEG; // local hour angle
const d = dec * DEG;
const lat = latDeg * DEG;
return Math.asin(Math.sin(lat) * Math.sin(d) + Math.cos(lat) * Math.cos(d) * Math.cos(ha)) / DEG;
}
export type SunTimes = {
rise: string; // "HH:MM" UTC, "" when the Sun does not rise that day
set: string;
polarDay: boolean; // Sun stays up all day
polarNight: boolean; // Sun never comes up
};
// sunTimes scans the UTC day a minute at a time and reports where the Sun
// crosses the rise/set altitude.
//
// A scan, not the closed-form hour-angle formula, because the closed form needs
// special-casing for the polar cases and for days where the declination shifts
// enough to matter, and it silently returns NaN rather than saying "no sunrise
// here today". 1440 evaluations is nothing, and the failure mode is honest: no
// crossing found means no sunrise, which is a real answer above the Arctic
// circle. The crossing minute is then refined by linear interpolation, so the
// result is accurate to well under a minute despite the coarse scan.
export function sunTimes(date: Date, latDeg: number, lonDeg: number): SunTimes {
const out: SunTimes = { rise: '', set: '', polarDay: false, polarNight: false };
if (!isFinite(latDeg) || !isFinite(lonDeg)) return out;
const start = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
const minute = 60000;
let prev = solarAltitude(new Date(start), latDeg, lonDeg);
const anyUp = prev > H0;
let anyDown = prev <= H0;
let sawUp = anyUp;
for (let i = 1; i <= 1440; i++) {
const alt = solarAltitude(new Date(start + i * minute), latDeg, lonDeg);
if (alt > H0) sawUp = true; else anyDown = true;
if (prev <= H0 && alt > H0 && !out.rise) {
out.rise = fmt(start + (i - 1 + frac(prev, alt)) * minute);
} else if (prev > H0 && alt <= H0 && !out.set) {
out.set = fmt(start + (i - 1 + frac(prev, alt)) * minute);
}
prev = alt;
}
if (!out.rise && !out.set) {
out.polarDay = sawUp && !anyDown;
out.polarNight = !sawUp;
}
return out;
}
// frac is where between two samples the altitude crosses H0.
function frac(a: number, b: number): number {
const d = b - a;
return d === 0 ? 0 : (H0 - a) / d;
}
function fmt(ms: number): string {
const d = new Date(ms);
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
}
+3
View File
@@ -17,6 +17,8 @@ const PORTABLE_KEYS = [
'opslog.autofocusWB', // auto-focus Worked-before
'hamlog.filterPresets', // Filter Builder saved presets
'opslog.showRotor', // rotor compass shown next to the keyers
'opslog.showAmpWidget', // amplifier widget shown next to the keyers
'opslog.ampSel.widget', // which amplifier that widget shows ("all" or an amp id)
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
'opslog.showQsoRate', // QSO-rate meter (10/60 min) shown in the header
@@ -36,6 +38,7 @@ const PORTABLE_KEYS = [
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
'opslog.activeTab', // last selected tab
'opslog.mainSplit', // Main tab: width share of the left pane (percent)
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
+6 -6
View File
@@ -679,10 +679,10 @@
disappears on the dark themes. Invert it there the control itself is worth
keeping (OS calendar, locale date order, keyboard entry), only its icon needs
help. */
[data-theme='dim-slate'] input[type='date']::-webkit-calendar-picker-indicator,
[data-theme='dark-warm'] input[type='date']::-webkit-calendar-picker-indicator,
[data-theme='dark-graphite'] input[type='date']::-webkit-calendar-picker-indicator,
[data-theme='high-contrast'] input[type='date']::-webkit-calendar-picker-indicator {
filter: invert(1) brightness(1.6);
opacity: 0.75;
Every temporal input carries the same glyph, so the rule names them all:
datetime-local was left out at first and its icon stayed invisible. */
:is([data-theme='dim-slate'], [data-theme='dark-warm'], [data-theme='dark-graphite'], [data-theme='high-contrast'])
:is(input[type='date'], input[type='datetime-local'], input[type='time'], input[type='month'], input[type='week'])::-webkit-calendar-picker-indicator {
filter: invert(1) brightness(1.8);
opacity: 0.9;
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.22.4';
export const APP_VERSION = '0.23.7';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+31 -5
View File
@@ -61,6 +61,8 @@ export function ApplyAwardUpdate(arg1:string):Promise<void>;
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
export function AudioApplyLevels(arg1:number,arg2:number):Promise<void>;
export function AudioMonitorActive():Promise<boolean>;
export function AudioStartMonitor():Promise<void>;
@@ -73,7 +75,7 @@ export function AudioStopTX():Promise<void>;
export function AudioTXActive():Promise<boolean>;
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
export function AwardFields():Promise<Array<string>>;
@@ -85,6 +87,8 @@ export function AwardsFolder():Promise<string>;
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
export function BandSlotQSOs(arg1:string,arg2:number,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
export function BrowseExecutable():Promise<string>;
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
@@ -95,6 +99,8 @@ export function CIVTraceEnabled():Promise<boolean>;
export function CWDecoderRunning():Promise<boolean>;
export function CancelConfirmations():Promise<void>;
export function ChatAvailable():Promise<boolean>;
export function CheckForUpdate():Promise<main.UpdateInfo>;
@@ -363,7 +369,7 @@ export function GetAudioSettings():Promise<main.AudioSettings>;
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
export function GetAward(arg1:string):Promise<award.Result>;
export function GetAward(arg1:string,arg2:string):Promise<award.Result>;
export function GetAwardDefs():Promise<Array<award.Def>>;
@@ -467,7 +473,7 @@ export function GetRelayAuto():Promise<main.RelayAutoConfig>;
export function GetRotatorHeading():Promise<main.RotatorHeading>;
export function GetRotatorSettings():Promise<main.RotatorSettings>;
export function GetRotators():Promise<Array<main.RotatorDevice>>;
export function GetSPEStatus():Promise<spe.Status>;
@@ -489,6 +495,8 @@ export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
export function GetTelemetryEnabled():Promise<boolean>;
export function GetTrackedAwards():Promise<Array<string>>;
export function GetTunerGeniusSettings():Promise<main.TunerGeniusSettings>;
export function GetTunerGeniusStatus():Promise<tunergenius.Status>;
@@ -603,6 +611,12 @@ export function ImportAwards():Promise<main.AwardImportResult>;
export function InspectAwardImport():Promise<main.AwardImportPreview>;
export function IsNewUSCounty(arg1:string,arg2:string):Promise<boolean>;
export function KenwoodSendCW(arg1:string):Promise<void>;
export function KenwoodStopCW():Promise<void>;
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
@@ -703,6 +717,10 @@ export function PGXLSetFanMode(arg1:string):Promise<void>;
export function PGXLSetOperate(arg1:boolean):Promise<void>;
export function PTTHotkeyDown():Promise<void>;
export function PTTHotkeyUp():Promise<void>;
export function PickADIFMonitorFile():Promise<string>;
export function PickAudioFolder():Promise<string>;
@@ -875,12 +893,14 @@ export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
export function SaveStationDevices(arg1:Array<main.StationDevice>):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 SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
@@ -905,6 +925,8 @@ export function SendLogToDeveloper():Promise<void>;
export function SendQSORecordingEmail(arg1:number):Promise<void>;
export function SetActiveRotor(arg1:number):Promise<void>;
export function SetAlertEmailTo(arg1:string):Promise<void>;
export function SetCATFrequency(arg1:number):Promise<void>;
@@ -925,6 +947,8 @@ export function SetCompactMode(arg1:boolean):Promise<void>;
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
export function SetKenwoodKeySpeed(arg1:number):Promise<void>;
export function SetPassphrase(arg1:string):Promise<void>;
export function SetScpEnabled(arg1:boolean):Promise<void>;
@@ -985,6 +1009,8 @@ export function SwitchCATRig(arg1:number):Promise<void>;
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
export function TailLogFile(arg1:number):Promise<string>;
export function TestCloudlogUpload():Promise<string>;
export function TestClublogUpload():Promise<string>;
@@ -1005,7 +1031,7 @@ export function TestPTT(arg1:main.AudioSettings):Promise<void>;
export function TestQRZUpload():Promise<string>;
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
export function TestRotatorDevice(arg1:main.RotatorDevice,arg2:number):Promise<void>;
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
+62 -10
View File
@@ -70,6 +70,10 @@ export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
}
export function AudioApplyLevels(arg1, arg2) {
return window['go']['main']['App']['AudioApplyLevels'](arg1, arg2);
}
export function AudioMonitorActive() {
return window['go']['main']['App']['AudioMonitorActive']();
}
@@ -94,8 +98,8 @@ export function AudioTXActive() {
return window['go']['main']['App']['AudioTXActive']();
}
export function AwardCellQSOs(arg1, arg2, arg3) {
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
export function AwardCellQSOs(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3, arg4);
}
export function AwardFields() {
@@ -118,6 +122,10 @@ export function BackfillUSCounties() {
return window['go']['main']['App']['BackfillUSCounties']();
}
export function BandSlotQSOs(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['BandSlotQSOs'](arg1, arg2, arg3, arg4);
}
export function BrowseExecutable() {
return window['go']['main']['App']['BrowseExecutable']();
}
@@ -138,6 +146,10 @@ export function CWDecoderRunning() {
return window['go']['main']['App']['CWDecoderRunning']();
}
export function CancelConfirmations() {
return window['go']['main']['App']['CancelConfirmations']();
}
export function ChatAvailable() {
return window['go']['main']['App']['ChatAvailable']();
}
@@ -674,8 +686,8 @@ export function GetAutostartPrograms() {
return window['go']['main']['App']['GetAutostartPrograms']();
}
export function GetAward(arg1) {
return window['go']['main']['App']['GetAward'](arg1);
export function GetAward(arg1, arg2) {
return window['go']['main']['App']['GetAward'](arg1, arg2);
}
export function GetAwardDefs() {
@@ -882,8 +894,8 @@ export function GetRotatorHeading() {
return window['go']['main']['App']['GetRotatorHeading']();
}
export function GetRotatorSettings() {
return window['go']['main']['App']['GetRotatorSettings']();
export function GetRotators() {
return window['go']['main']['App']['GetRotators']();
}
export function GetSPEStatus() {
@@ -926,6 +938,10 @@ export function GetTelemetryEnabled() {
return window['go']['main']['App']['GetTelemetryEnabled']();
}
export function GetTrackedAwards() {
return window['go']['main']['App']['GetTrackedAwards']();
}
export function GetTunerGeniusSettings() {
return window['go']['main']['App']['GetTunerGeniusSettings']();
}
@@ -1154,6 +1170,18 @@ export function InspectAwardImport() {
return window['go']['main']['App']['InspectAwardImport']();
}
export function IsNewUSCounty(arg1, arg2) {
return window['go']['main']['App']['IsNewUSCounty'](arg1, arg2);
}
export function KenwoodSendCW(arg1) {
return window['go']['main']['App']['KenwoodSendCW'](arg1);
}
export function KenwoodStopCW() {
return window['go']['main']['App']['KenwoodStopCW']();
}
export function LaunchAutostartProgram(arg1) {
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
}
@@ -1354,6 +1382,14 @@ export function PGXLSetOperate(arg1) {
return window['go']['main']['App']['PGXLSetOperate'](arg1);
}
export function PTTHotkeyDown() {
return window['go']['main']['App']['PTTHotkeyDown']();
}
export function PTTHotkeyUp() {
return window['go']['main']['App']['PTTHotkeyUp']();
}
export function PickADIFMonitorFile() {
return window['go']['main']['App']['PickADIFMonitorFile']();
}
@@ -1698,8 +1734,8 @@ export function SaveRelayAuto(arg1) {
return window['go']['main']['App']['SaveRelayAuto'](arg1);
}
export function SaveRotatorSettings(arg1) {
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
export function SaveRotators(arg1) {
return window['go']['main']['App']['SaveRotators'](arg1);
}
export function SaveStationDevices(arg1) {
@@ -1710,6 +1746,10 @@ export function SaveStationSettings(arg1) {
return window['go']['main']['App']['SaveStationSettings'](arg1);
}
export function SaveTrackedAwards(arg1) {
return window['go']['main']['App']['SaveTrackedAwards'](arg1);
}
export function SaveTunerGeniusSettings(arg1) {
return window['go']['main']['App']['SaveTunerGeniusSettings'](arg1);
}
@@ -1758,6 +1798,10 @@ export function SendQSORecordingEmail(arg1) {
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
}
export function SetActiveRotor(arg1) {
return window['go']['main']['App']['SetActiveRotor'](arg1);
}
export function SetAlertEmailTo(arg1) {
return window['go']['main']['App']['SetAlertEmailTo'](arg1);
}
@@ -1798,6 +1842,10 @@ export function SetDVKLabel(arg1, arg2) {
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
}
export function SetKenwoodKeySpeed(arg1) {
return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1);
}
export function SetPassphrase(arg1) {
return window['go']['main']['App']['SetPassphrase'](arg1);
}
@@ -1918,6 +1966,10 @@ export function SyncPOTAHunterLog(arg1, arg2) {
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
}
export function TailLogFile(arg1) {
return window['go']['main']['App']['TailLogFile'](arg1);
}
export function TestCloudlogUpload() {
return window['go']['main']['App']['TestCloudlogUpload']();
}
@@ -1958,8 +2010,8 @@ export function TestQRZUpload() {
return window['go']['main']['App']['TestQRZUpload']();
}
export function TestRotator(arg1) {
return window['go']['main']['App']['TestRotator'](arg1);
export function TestRotatorDevice(arg1, arg2) {
return window['go']['main']['App']['TestRotatorDevice'](arg1, arg2);
}
export function TestStationDevice(arg1) {
+94 -28
View File
@@ -316,6 +316,7 @@ export namespace award {
leading_str?: string;
trailing_str?: string;
dynamic?: boolean;
one_ref_per_qso?: boolean;
or_rules?: OrRule[];
dxcc_filter: number[];
valid_bands?: string[];
@@ -356,6 +357,7 @@ export namespace award {
this.leading_str = source["leading_str"];
this.trailing_str = source["trailing_str"];
this.dynamic = source["dynamic"];
this.one_ref_per_qso = source["one_ref_per_qso"];
this.or_rules = this.convertValues(source["or_rules"], OrRule);
this.dxcc_filter = source["dxcc_filter"];
this.valid_bands = source["valid_bands"];
@@ -461,6 +463,8 @@ export namespace award {
ref_count: number;
steps: Step[];
manual?: string[];
superseded?: string[];
ambiguous?: string[];
result: string[];
static createFrom(source: any = {}) {
@@ -476,6 +480,8 @@ export namespace award {
this.ref_count = source["ref_count"];
this.steps = this.convertValues(source["steps"], Step);
this.manual = source["manual"];
this.superseded = source["superseded"];
this.ambiguous = source["ambiguous"];
this.result = source["result"];
}
@@ -1436,6 +1442,8 @@ export namespace lotwusers {
is_user: boolean;
last_upload?: string;
days_ago: number;
feed_date?: string;
feed_stale: boolean;
static createFrom(source: any = {}) {
return new Info(source);
@@ -1446,6 +1454,8 @@ export namespace lotwusers {
this.is_user = source["is_user"];
this.last_upload = source["last_upload"];
this.days_ago = source["days_ago"];
this.feed_date = source["feed_date"];
this.feed_stale = source["feed_stale"];
}
}
@@ -1510,6 +1520,7 @@ export namespace main {
transport: string;
host: string;
port: number;
password: string;
com_port: string;
baud: number;
freq_out: boolean;
@@ -1530,6 +1541,7 @@ export namespace main {
this.transport = source["transport"];
this.host = source["host"];
this.port = source["port"];
this.password = source["password"];
this.com_port = source["com_port"];
this.baud = source["baud"];
this.freq_out = source["freq_out"];
@@ -1608,6 +1620,7 @@ export namespace main {
from_gain: number;
mic_gain: number;
tx_gain: number;
qso_play_gain: number;
static createFrom(source: any = {}) {
return new AudioSettings(source);
@@ -1628,6 +1641,7 @@ export namespace main {
this.from_gain = source["from_gain"];
this.mic_gain = source["mic_gain"];
this.tx_gain = source["tx_gain"];
this.qso_play_gain = source["qso_play_gain"];
}
}
export class AutostartLaunchResult {
@@ -1910,11 +1924,15 @@ export namespace main {
xiegu_port: string;
xiegu_baud: number;
xiegu_addr: number;
xiegu_ptt_line: string;
yaesu_port: string;
yaesu_baud: number;
kenwood_host: string;
kenwood_port: string;
kenwood_baud: number;
kenwood_data_mode: string;
yaesu_low_lines: boolean;
kenwood_low_lines: boolean;
icom_port: string;
icom_baud: number;
icom_addr: number;
@@ -1930,6 +1948,9 @@ export namespace main {
digital_default: string;
share_enabled: boolean;
share_port: number;
ptt_hotkey_enabled: boolean;
ptt_hotkey: string;
ptt_hotkey_toggle: boolean;
static createFrom(source: any = {}) {
return new CATSettings(source);
@@ -1949,11 +1970,15 @@ export namespace main {
this.xiegu_port = source["xiegu_port"];
this.xiegu_baud = source["xiegu_baud"];
this.xiegu_addr = source["xiegu_addr"];
this.xiegu_ptt_line = source["xiegu_ptt_line"];
this.yaesu_port = source["yaesu_port"];
this.yaesu_baud = source["yaesu_baud"];
this.kenwood_host = source["kenwood_host"];
this.kenwood_port = source["kenwood_port"];
this.kenwood_baud = source["kenwood_baud"];
this.kenwood_data_mode = source["kenwood_data_mode"];
this.yaesu_low_lines = source["yaesu_low_lines"];
this.kenwood_low_lines = source["kenwood_low_lines"];
this.icom_port = source["icom_port"];
this.icom_baud = source["icom_baud"];
this.icom_addr = source["icom_addr"];
@@ -1969,6 +1994,9 @@ export namespace main {
this.digital_default = source["digital_default"];
this.share_enabled = source["share_enabled"];
this.share_port = source["share_port"];
this.ptt_hotkey_enabled = source["ptt_hotkey_enabled"];
this.ptt_hotkey = source["ptt_hotkey"];
this.ptt_hotkey_toggle = source["ptt_hotkey_toggle"];
}
}
export class CabrilloResult {
@@ -2698,6 +2726,7 @@ export namespace main {
ref: string;
name?: string;
pickable: boolean;
ambiguous?: boolean;
static createFrom(source: any = {}) {
return new QSOAwardRef(source);
@@ -2709,6 +2738,7 @@ export namespace main {
this.ref = source["ref"];
this.name = source["name"];
this.pickable = source["pickable"];
this.ambiguous = source["ambiguous"];
}
}
export class QSORate {
@@ -2784,11 +2814,52 @@ export namespace main {
}
}
export class RotatorDevice {
id: string;
name: string;
type: string;
host: string;
port: number;
has_elevation: boolean;
rotator_num: number;
dual: boolean;
motorized: boolean;
name2: string;
motorized2: boolean;
transport: string;
com_port: string;
baud: number;
static createFrom(source: any = {}) {
return new RotatorDevice(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.type = source["type"];
this.host = source["host"];
this.port = source["port"];
this.has_elevation = source["has_elevation"];
this.rotator_num = source["rotator_num"];
this.dual = source["dual"];
this.motorized = source["motorized"];
this.name2 = source["name2"];
this.motorized2 = source["motorized2"];
this.transport = source["transport"];
this.com_port = source["com_port"];
this.baud = source["baud"];
}
}
export class RotatorHeading {
enabled: boolean;
ok: boolean;
azimuth: number;
raw: string;
rotors: string[];
active: number;
motorized: boolean;
static createFrom(source: any = {}) {
return new RotatorHeading(source);
@@ -2800,34 +2871,9 @@ export namespace main {
this.ok = source["ok"];
this.azimuth = source["azimuth"];
this.raw = source["raw"];
}
}
export class RotatorSettings {
enabled: boolean;
type: string;
host: string;
port: number;
has_elevation: boolean;
rotator_num: number;
transport: string;
com_port: string;
baud: number;
static createFrom(source: any = {}) {
return new RotatorSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.type = source["type"];
this.host = source["host"];
this.port = source["port"];
this.has_elevation = source["has_elevation"];
this.rotator_num = source["rotator_num"];
this.transport = source["transport"];
this.com_port = source["com_port"];
this.baud = source["baud"];
this.rotors = source["rotors"];
this.active = source["active"];
this.motorized = source["motorized"];
}
}
export class ScpStatus {
@@ -2888,6 +2934,8 @@ export namespace main {
worked_call: boolean;
new_county: boolean;
new_pota: boolean;
new_pfx: boolean;
pfx?: string;
static createFrom(source: any = {}) {
return new SpotStatus(source);
@@ -2904,6 +2952,8 @@ export namespace main {
this.worked_call = source["worked_call"];
this.new_county = source["new_county"];
this.new_pota = source["new_pota"];
this.new_pfx = source["new_pfx"];
this.pfx = source["pfx"];
}
}
export class StartupStatus {
@@ -3106,6 +3156,7 @@ export namespace main {
follow: boolean;
step_khz: number;
tx_inhibit: boolean;
bands: string[];
freq_min_mhz: number;
freq_max_mhz: number;
@@ -3125,6 +3176,7 @@ export namespace main {
this.follow = source["follow"];
this.step_khz = source["step_khz"];
this.tx_inhibit = source["tx_inhibit"];
this.bands = source["bands"];
this.freq_min_mhz = source["freq_min_mhz"];
this.freq_max_mhz = source["freq_max_mhz"];
}
@@ -3485,6 +3537,11 @@ export namespace powergenius {
fan_mode?: string;
temperature: number;
operate: boolean;
fwd_w: number;
peak_w: number;
vswr: number;
id: number;
peak_id: number;
static createFrom(source: any = {}) {
return new Status(source);
@@ -3499,6 +3556,11 @@ export namespace powergenius {
this.fan_mode = source["fan_mode"];
this.temperature = source["temperature"];
this.operate = source["operate"];
this.fwd_w = source["fwd_w"];
this.peak_w = source["peak_w"];
this.vswr = source["vswr"];
this.id = source["id"];
this.peak_id = source["peak_id"];
}
}
@@ -4544,6 +4606,7 @@ export namespace spe {
export class Status {
connected: boolean;
transport: string;
last_error?: string;
model?: string;
operate: boolean;
@@ -4567,6 +4630,7 @@ export namespace spe {
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.connected = source["connected"];
this.transport = source["transport"];
this.last_error = source["last_error"];
this.model = source["model"];
this.operate = source["operate"];
@@ -4621,6 +4685,7 @@ export namespace tunergenius {
last_error?: string;
fwd_dbm: number;
fwd_w: number;
peak_w: number;
swr_db: number;
vswr: number;
operate: boolean;
@@ -4648,6 +4713,7 @@ export namespace tunergenius {
this.last_error = source["last_error"];
this.fwd_dbm = source["fwd_dbm"];
this.fwd_w = source["fwd_w"];
this.peak_w = source["peak_w"];
this.swr_db = source["swr_db"];
this.vswr = source["vswr"];
this.operate = source["operate"];
+86
View File
@@ -0,0 +1,86 @@
# ACOM built-in ATU — what we know, and why OpsLog does not drive it
Session of 2026-08-02 on F4BPO's friend's **ACOM 500S**, serial link, telemetry
trace. Written down so nobody repeats the work. **Conclusion first: the tuner
cannot be started over the serial link, and the attempt to work around that was
abandoned.**
## The tuner is PA state 8
Frame byte 3's high nibble is the PA status. ACOM's own list (recovered by the
ACOM-Controller project) has no entry for 8; it is the antenna tuner. Confirmed
twice, front-panel TUNE, with the byte-level trace running:
```
14:47:20.745 state 8 step 0 ← TUNE pressed. err=FF
14:47:22.588 operator keys a carrier (FT-891, FM, 31 W, 3.6358 MHz)
14:47:24.006 state 8 step 2
14:47:25.201 state 8 step 4
14:47:27.457 state 8 step 5
14:47:30.092 state 5 STANDBY ← accord finished
```
Byte 3's **low** nibble is a step counter through the accord: `0 → 2 → 4 → 5`.
Step 0 means *armed, waiting for RF* — in the first of the two runs the
amplifier sat there **30 seconds** untouched before the operator transmitted.
`paStatusNames[8] = "TUNE"` in acom.go is the only thing kept from all of this.
## The command does not exist in the state-command family
Every action byte of `55 81 08 02 00 XX 00 CHK` was tried, each one from a
**verified** STANDBY (an earlier sweep was worthless because its second code
parked the amp in SERVICE, where it silently refuses everything — 17 false
negatives). Result:
| byte | effect |
|------|--------|
| 0x01, 0x02 | **reboot the amplifier** — it prints `AMPLIFIER: ACOM 500S / HELLO ME…` |
| 0x04 | → SERVICE (state 4) |
| 0x05 | → STANDBY (state 5) |
| 0x06 | → OPERATE (state 6) |
| 0x0A | → OFF (state 10) |
| 0x03, 0x07..0x09, 0x0B..0x18 | nothing, state unchanged |
The action byte **is** the target PA state — four independent confirmations.
Which makes 0x08 the obvious candidate for TUNE. **It was tried, from a verified
STANDBY, and does nothing.** State 8 is reachable from the front panel only.
Going further would mean varying the other bytes of the frame. That is a large
space, and 0x01/0x02 prove that bad values reboot a 500 W amplifier. Not worth
it.
## The workaround that was built and removed
Since the amp waits, armed, for a carrier, OpsLog could watch for state 8 and
supply one: memorise the mode, drop the power, switch to FM, key the PTT, wait
for state 8 to clear, unkey, restore. It was implemented (`acomtune.go`, opt-in,
Settings → Amplifier) and **removed** after the first hardware test:
- the power change did not take effect, and
- **the PTT was not released when the tune finished.**
A logger that can leave a transceiver keyed is not acceptable, and the whole
thing was scaffolding around a protocol we do not actually control. Removed at
the operator's request: *"je veux qu'OpsLog soit solide et ça c'est de la
bidouille."*
If it is ever revisited, the unexplained part is why the tune-finished detection
did not fire — `anyAcomTuning()` polled `StateRaw == 8` every 200 ms and should
have seen the drop to STANDBY. Suspect the status snapshot, not the amplifier.
## Loose ends worth knowing
- **Frame byte 6 is a multiplexer.** The tail of the frame changes meaning with
it: `0x93` = bytes 48/49 carry the TX frequency in kHz (`62 1B` = 7010 while
the rig was on 7.010 MHz), `0x9D` = something else. Byte 70 looked like a
tuner flag for a while purely because of this — it toggles on its own several
times a minute. Do not read tail bytes without checking byte 6.
- **Byte 5 bit 0x80 alternates frame to frame.** It is a sequence/parity bit,
not a command acknowledgement.
- **Byte 66 (the error code) shows 0x14 / 0x8E / 0x69 / 0x0F during a normal
tune and during transmission.** `errText` renders those as "ERROR — see
display" and "Remove drive power", so an ACOM operator probably sees a false
alarm. This was never confirmed with the amplifier in OPERATE (it was in
STANDBY throughout, where drive genuinely is an error) — worth a look with a
clean log before touching the table.
+6 -1
View File
@@ -77,9 +77,14 @@ var models = map[string]model{
}
// paStatusNames maps the PAstatus nibble to a display string.
// 8 is absent from the state list the ACOM-Controller project recovered; it is
// the antenna tuner, read off F4BPO's 500S on 2026-08-02. Pressing TUNE on the
// front panel took byte 3 from 0x51 to 0x80, the amp then WAITED 19 s doing
// nothing until the operator keyed a carrier, ran the tune over ~8 s while the
// low nibble stepped 0→2→4→5, and dropped back to 0x51 (STANDBY).
var paStatusNames = map[int]string{
1: "RESET", 2: "INIT", 3: "DEBUG", 4: "SERVICE",
5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 9: "SYSTEM", 10: "OFF",
5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 8: "TUNE", 9: "SYSTEM", 10: "OFF",
}
// acomBands maps the band nibble to a band label.
+11 -1
View File
@@ -80,7 +80,17 @@ func pcmFormat() *wca.WAVEFORMATEX {
}
}
const autoConvert = wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | wca.AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY
// autoConvert lets WASAPI resample between the device format and ours.
//
// SRC_DEFAULT_QUALITY used to be set alongside it, and that name is misleading:
// it selects the CHEAP converter, meant for cases where quality does not matter.
// Going from a rig 48 kHz stream down to our 16 kHz, it folds everything above
// 8 kHz back into the audio band — and a receiver hiss is mostly high
// frequencies. The result was a recording where the noise sat ON TOP of the
// voice, while the same audio heard live had the voice well clear of it.
//
// Without the flag Windows uses its normal converter, which filters first.
const autoConvert = wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM
// recordPCM captures from a device into 16 kHz mono 16-bit PCM bytes until the
// stop channel is closed.
+89 -10
View File
@@ -5,16 +5,28 @@ package audio
import (
"fmt"
"sync"
"time"
)
// Manager owns the DVK record/playback lifecycle: at most one recording and
// one playback at a time. Device ids are passed per call so the host can route
// recording to the mic and playback to the rig (or the preview speakers).
type Manager struct {
mu sync.Mutex
recStop chan struct{}
recDone chan recResult
playStop chan struct{}
mu sync.Mutex
recStop chan struct{}
recDone chan recResult
// monGainPct scales what the RX monitor plays, 100 = as captured.
//
// The "From radio" slider used to reach only the QSO recorder, so an
// operator listening through OpsLog heard no difference between 10% and
// 150% — the setting looked broken because it was, for the thing they were
// listening to.
monGainPct int
playStop chan struct{}
// playDone closes when the playback goroutine has returned and the audio
// device is free again. Without it, the next Play raced the old one for the
// device and lost.
playDone chan struct{}
monStop chan struct{} // RX monitor passthrough (capture → render)
monRing *pcmRing // live audio hand-off, also fed by the network stream
txStop chan struct{} // TX audio passthrough (mic → rig)
@@ -125,18 +137,38 @@ func (m *Manager) Play(deviceID, path string, gainPct int) error {
pcm[i], pcm[i+1] = byte(uint16(v)), byte(uint16(v)>>8)
}
}
// Waits for any previous playback to have RELEASED THE DEVICE.
//
// It used to only signal the old one to stop and start a new one at once.
// The old goroutine still held the WASAPI render client for a moment, so the
// new client could not start: it returned immediately, the PTT was keyed and
// released a tenth of a second later, and nothing came out. On the air that
// looked like "press play again and you must wait the whole length of the
// message before it will play at all".
m.StopPlayback()
stop := make(chan struct{})
done := make(chan struct{})
m.mu.Lock()
m.playStop = stop
m.playDone = done
m.mu.Unlock()
go func() {
_ = playPCM(deviceID, pcm, rate, ch, bits, stop)
// The error was discarded. A device that refuses to start returns here
// instantly, the PTT is released 120 ms later, and NOTHING says why —
// which is exactly what a station heard as "it plays once, then never
// again": the call succeeded, the sound did not.
if err := playPCM(deviceID, pcm, rate, ch, bits, stop); err != nil {
LogSink("audio: playback on %q failed: %v", DeviceName(deviceID), err)
}
m.mu.Lock()
if m.playStop == stop {
m.playStop = nil
}
if m.playDone == done {
m.playDone = nil
}
m.mu.Unlock()
close(done) // the device is free from here
m.notify()
}()
m.notify()
@@ -147,12 +179,24 @@ func (m *Manager) Play(deviceID, path string, gainPct int) error {
func (m *Manager) StopPlayback() {
m.mu.Lock()
stop := m.playStop
done := m.playDone
m.playStop = nil
m.mu.Unlock()
if stop != nil {
close(stop)
m.notify()
if stop == nil {
return
}
close(stop)
// Wait for the goroutine to release the device — that is the whole point of
// stopping before starting again. Bounded: a wedged WASAPI call must not
// freeze the caller, which here is the operator clicking a button.
if done != nil {
select {
case <-done:
case <-time.After(1500 * time.Millisecond):
LogSink("audio: previous playback did not release the device within 1.5 s")
}
}
m.notify()
}
// ---- RX audio monitor (Phase 2: USB codec passthrough) --------------------
@@ -188,9 +232,12 @@ func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
m.mu.Unlock()
if capture {
// Producer: capture the rig's USB audio into the ring.
// Producer: capture the rig's USB audio into the ring, at the level the
// operator set. Applied HERE rather than on the render side so the
// network-fed path (PushMonitorAudio) keeps its own untouched levels —
// that stream is already scaled by the radio.
go func() {
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(chunk) })
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(m.scaleMonitor(chunk)) })
}()
}
// Consumer: render the ring to the output device at the internal 16 kHz mono.
@@ -201,6 +248,38 @@ func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
return nil
}
// SetMonitorGain sets the RX monitor level in percent (100 = as captured).
// Takes effect on the next captured chunk — no need to restart the monitor.
func (m *Manager) SetMonitorGain(pct int) {
if pct <= 0 {
pct = 100
}
m.mu.Lock()
m.monGainPct = pct
m.mu.Unlock()
}
// scaleMonitor applies the monitor level to one captured chunk, returning a
// buffer the ring may keep. At unity it hands the chunk straight back: the
// common case must not pay for a copy 30 times a second.
func (m *Manager) scaleMonitor(chunk []byte) []byte {
m.mu.Lock()
pct := m.monGainPct
m.mu.Unlock()
if pct == 0 || pct == 100 {
return chunk
}
g := float64(pct) / 100
out := make([]byte, len(chunk))
copy(out, chunk)
for i := 0; i+1 < len(out); i += 2 {
v := int16(uint16(out[i]) | uint16(out[i+1])<<8)
v = scalePCM(v, g)
out[i], out[i+1] = byte(uint16(v)), byte(uint16(v)>>8)
}
return out
}
// StopMonitor stops the RX monitor passthrough.
func (m *Manager) StopMonitor() {
m.mu.Lock()
+70 -7
View File
@@ -3,6 +3,7 @@
package audio
import (
"math"
"os"
"github.com/braheezy/shine-mp3/pkg/mp3"
@@ -50,19 +51,81 @@ func writeMP3(path string, pcm []byte) error {
return enc.Write(f, stereo)
}
// upsample2 doubles the sample rate with linear interpolation (16 kHz → 32 kHz).
// upsample2 doubles the sample rate, 16 kHz → 32 kHz, WITH the interpolation
// filter that makes the operation legitimate.
//
// It used to interpolate linearly — each new sample the average of its
// neighbours. That is an upsampler in name only: every component at f is
// mirrored to 16 kHz f, measured just 7 dB down (see the test). On speech it
// adds brightness; on receiver noise, which is broadband, it lays a second
// noise floor across the top of the band. Operators heard it as a hiss louder
// than the voice, present in the MP3 and absent from the WAV of the very same
// audio.
//
// Zero-stuffing followed by a windowed-sinc low-pass at the old Nyquist is the
// textbook answer, and it puts the image below 40 dB. 63 taps is a few hundred
// microseconds of work on an eight-second recording — nothing, next to the MP3
// encode that follows.
func upsample2(in []int16) []int16 {
if len(in) == 0 {
return in
}
out := make([]int16, len(in)*2)
for i := range in {
out[2*i] = in[i]
if i+1 < len(in) {
out[2*i+1] = int16((int32(in[i]) + int32(in[i+1])) / 2)
} else {
out[2*i+1] = in[i]
half := len(upsampleFIR) / 2
for i := range out {
var acc float64
// The zero-stuffed signal is non-zero only at even indices, so only
// every other tap contributes — the loop skips the zeros rather than
// multiplying by them.
start := (i - half + 1) &^ 1 // first even index in the window
for j := start; j <= i+half; j += 2 {
k := i - j + half
if k < 0 || k >= len(upsampleFIR) {
continue
}
src := j / 2
if src < 0 || src >= len(in) {
continue
}
acc += float64(in[src]) * upsampleFIR[k]
}
// ×2 for the energy lost to zero-stuffing, then clamp.
v := acc * 2
if v > 32767 {
v = 32767
} else if v < -32768 {
v = -32768
}
out[i] = int16(v)
}
return out
}
// upsampleFIR is a 63-tap Hamming-windowed sinc, cut off at a quarter of the
// NEW rate (8 kHz at 32 kHz) — exactly the old Nyquist, which is where the
// images begin.
var upsampleFIR = func() []float64 {
const n = 63
const fc = 0.25 // cycles/sample at the new rate
h := make([]float64, n)
mid := (n - 1) / 2
var sum float64
for i := 0; i < n; i++ {
m := float64(i - mid)
var v float64
if m == 0 {
v = 2 * fc
} else {
v = math.Sin(2*math.Pi*fc*m) / (math.Pi * m)
}
// Hamming window: a rectangular one would ring and put the stopband
// only ~21 dB down, which is not enough to be worth the filter.
v *= 0.54 - 0.46*math.Cos(2*math.Pi*float64(i)/float64(n-1))
h[i] = v
sum += v
}
for i := range h {
h[i] /= sum // unity gain at DC
}
return h
}()
+56
View File
@@ -0,0 +1,56 @@
package audio
import (
"testing"
"time"
)
// Playing a stopped take must not consume it.
//
// Reported on the air: the recording plays once, then nothing — "as if it threw
// the sound away after playing it". PeekQSO is what the playback reads, and it
// is meant to COPY; TakeQSO is the one that clears.
func TestPeekDoesNotConsumeTheTake(t *testing.T) {
r := &Recorder{running: true, active: true, paused: true, prerollSamples: sampleRate}
r.acc = make([]int16, 4000)
for i := range r.acc {
r.acc[i] = int16(i % 500)
}
first, err := r.PeekQSO()
if err != nil {
t.Fatalf("first peek: %v", err)
}
second, err := r.PeekQSO()
if err != nil {
t.Fatalf("second peek: %v — the take was consumed by the first", err)
}
if len(first) != len(second) {
t.Errorf("second peek returned %d bytes, first %d", len(second), len(first))
}
// And the copy must be independent: the caller holds these bytes while the
// recorder may still be appending to its own slice.
if len(first) > 0 {
first[0] = ^first[0]
again, _ := r.PeekQSO()
if len(again) > 0 && again[0] == first[0] {
t.Error("PeekQSO handed out its internal buffer, not a copy")
}
}
// Resuming and stopping again keeps everything captured so far.
r.ResumeQSO()
r.mu.Lock()
r.acc = append(r.acc, make([]int16, 1000)...)
r.mu.Unlock()
r.PauseQSO()
third, err := r.PeekQSO()
if err != nil {
t.Fatalf("peek after resume: %v", err)
}
if len(third) <= len(second) {
t.Errorf("after resuming, the take is %d bytes — it should have grown past %d", len(third), len(second))
}
_ = time.Now
}
+12
View File
@@ -204,6 +204,18 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
}()
}
// Name the devices being recorded FROM, once, at the start.
//
// A station with two radios has two sets of endpoints, and the recorder will
// happily capture the one that is not being listened to: the result is a
// file full of hiss with no relation to what the operator hears, and nothing
// anywhere said which receiver it came from.
if twoSrc {
LogSink("recorder: capturing %q + %q", DeviceName(fromDev), DeviceName(micDev))
} else {
LogSink("recorder: capturing %q", DeviceName(fromDev))
}
// Watchdog. A WASAPI device can open cleanly and then produce nothing at
// all — a DAX channel with no stream behind it does exactly that, and it is
// indistinguishable from silence until the recording turns out to be empty
+79
View File
@@ -0,0 +1,79 @@
package audio
import (
"math"
"testing"
)
// goertzel returns the magnitude of one frequency bin, so a test can ask "how
// much energy is at 10 kHz" without pulling in an FFT.
func goertzel(x []int16, fs, freq float64) float64 {
w := 2 * math.Pi * freq / fs
c := 2 * math.Cos(w)
var s1, s2 float64
for _, v := range x {
s := float64(v) + c*s1 - s2
s2, s1 = s1, s
}
return math.Hypot(s1-s2*math.Cos(w), s2*math.Sin(w))
}
// Upsampling 16 kHz → 32 kHz must not create an audible mirror image.
//
// MP3 recordings came back with a hiss that is not on the air and is not in the
// WAV of the same audio — "louder than the voice", from a station that hears the
// voice well clear of the noise. The MP3 path is the only one that resamples,
// and it did so by linear interpolation: every component at f is mirrored to
// 16 kHz f, which for broadband receiver noise means a second noise floor
// spread across the top of the band.
func TestUpsampleSuppressesTheImage(t *testing.T) {
const fs = 16000.0
const tone = 6000.0 // its image lands at 16000 6000 = 10 kHz
in := make([]int16, 4096)
for i := range in {
in[i] = int16(12000 * math.Sin(2*math.Pi*tone*float64(i)/fs))
}
out := upsample2(in)
if len(out) != 2*len(in) {
t.Fatalf("upsample2 returned %d samples for %d", len(out), len(in))
}
wanted := goertzel(out, 2*fs, tone)
image := goertzel(out, 2*fs, 2*fs-tone-16000) // = 10 kHz
if wanted == 0 {
t.Fatal("the tone itself did not survive upsampling")
}
db := 20 * math.Log10(image/wanted)
t.Logf("image at 10 kHz is %.1f dB below the 6 kHz tone", db)
if db > -35 {
t.Errorf("image only %.1f dB down — it is audible as added hiss; want at least 35 dB", db)
}
}
// The filter must not change the level, or every existing recording would come
// back quieter (or clipped) after the fix — a second surprise on top of the one
// being repaired.
func TestUpsampleKeepsTheLevel(t *testing.T) {
const fs = 16000.0
in := make([]int16, 4096)
for i := range in {
in[i] = int16(10000 * math.Sin(2*math.Pi*1000*float64(i)/fs))
}
rms := func(x []int16) float64 {
var acc float64
for _, v := range x {
acc += float64(v) * float64(v)
}
return math.Sqrt(acc / float64(len(x)))
}
// Skip the filter's warm-up and tail, where the window is only partly fed.
out := upsample2(in)
got, want := rms(out[200:len(out)-200]), rms(in[100:len(in)-100])
ratio := got / want
t.Logf("level after upsampling: ×%.3f", ratio)
if ratio < 0.9 || ratio > 1.1 {
t.Errorf("level changed by ×%.3f — want within 10%%", ratio)
}
}
+49 -6
View File
@@ -75,10 +75,22 @@ type Def struct {
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
// NOTE: there is no "one reference per QSO" switch, and there was never any
// point in one. A QSO ALWAYS yields every reference its field holds — an n-fer
// POTA activation ("US-6544,US-0680"), a VUCC contact on a grid line. The old
// `Multi` flag was read by nothing; its checkbox changed nothing.
// OneRefPerQSO refuses an AMBIGUOUS match rather than guessing.
//
// This is not the old `Multi` flag, which was removed for good reason: a QSO
// always yields every reference its FIELD holds — an n-fer POTA activation
// ("US-6544,US-0680"), a VUCC contact on a grid line. That is several
// references, correctly.
//
// The case here is the opposite. The field holds ONE value and two entries in
// the reference list carry it: two German DOKs are both called "Gießen",
// because two clubs share the town. The matcher is not wrong, the data is
// ambiguous — and picking one would write a reference into the log that may
// well be the other. So on an award where a QSO can only ever count for one
// reference, an ambiguous match assigns NONE and the contact appears under
// "missing references", where the operator picks. Their choice then sticks:
// a manual reference overrides the matcher.
OneRefPerQSO bool `json:"one_ref_per_qso,omitempty"`
// OrRules are ordered FALLBACK searches for the primary one above: they are
// tried IN ORDER and only while nothing has matched yet — the first rule that
@@ -859,7 +871,15 @@ type Explanation struct {
RefCount int `json:"ref_count"` // size of that list
Steps []Step `json:"steps"`
Manual []string `json:"manual,omitempty"` // references the operator assigned by hand
Result []string `json:"result"` // what the QSO finally counts for
// Superseded is what the matcher had found before the operator's choice
// replaced it — shown in the trace so a correction is explainable rather
// than mysterious.
Superseded []string `json:"superseded,omitempty"`
// Ambiguous lists the references that matched when the award allows only one
// — none of them was kept. Shown so the operator sees WHICH ones to choose
// between rather than an unexplained blank.
Ambiguous []string `json:"ambiguous,omitempty"`
Result []string `json:"result"` // what the QSO finally counts for
}
// Explain runs the matcher on a single QSO and reports what it did. It goes
@@ -977,8 +997,31 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
if ex != nil {
ex.Manual = manual
}
found = append(found, manual...)
// An operator's choice REPLACES what the matcher found. It does not join it.
//
// Adding to it was the behaviour, and it made a correction impossible: a
// German address matching two or three DOKs kept them all, so deleting the
// wrong ones and assigning the right one lasted exactly until the next
// recompute, which put them straight back. The operator was arguing with
// the matcher and could not win.
//
// One manual entry is stored per award (setOverrideRef drops the previous
// one), so this is an override in the storage as well as in the name.
if len(manual) > 0 {
if ex != nil {
ex.Superseded = dedupe(found)
}
found = manual
}
out := dedupe(found)
// Ambiguity is not a result. See OneRefPerQSO.
if d.OneRefPerQSO && len(manual) == 0 && len(out) > 1 {
if ex != nil {
ex.Ambiguous = out
ex.Result = []string{}
}
return nil
}
if ex != nil {
ex.Result = out
}
+5 -2
View File
@@ -12,11 +12,14 @@
"type": "QSOFIELDS",
"field": "address",
"match_by": "description",
"exact_match": true,
"pattern": "",
"one_ref_per_qso": true,
"or_rules": [
{
"field": "qth",
"match_by": "description"
"match_by": "description",
"exact_match": true
}
],
"dxcc_filter": [
@@ -34,7 +37,7 @@
],
"total": 0,
"builtin": true,
"version": 1
"version": 2
},
"references": [
{
File diff suppressed because it is too large Load Diff
+100
View File
@@ -0,0 +1,100 @@
package award
import (
"strings"
"testing"
"hamlog/internal/qso"
)
// An operator's correction must survive a recompute.
//
// Reported on the DARC DOK award: a German address matches two or three DOKs,
// the operator deletes the wrong ones and assigns the right one, and the next
// recompute puts the wrong ones straight back. The manual reference was being
// ADDED to what the matcher found instead of replacing it, so the correction
// could never win.
func TestManualReferenceReplacesTheMatch(t *testing.T) {
// An award that reads the address and matches a reference's description —
// the shape the DOK award uses, and the shape that over-matches.
def := Def{
Code: "DLD", Name: "DARC DOK", Field: "address",
MatchBy: "description", Type: "QSOFIELDS", Valid: true,
}
metas := []RefMeta{
{Code: "A01", Name: "Berlin", Valid: true},
{Code: "B05", Name: "Berlin Mitte", Valid: true},
}
// An address that legitimately matches both.
q := &qso.QSO{
Callsign: "DL2FDM",
Address: "Berlin Mitte, 10115 Berlin",
}
auto := Explain(def, metas, q)
if len(auto.Result) < 2 {
t.Fatalf("the test needs an over-matching case; got %v", auto.Result)
}
// The operator picks the right one.
q.Extras = map[string]string{ManualRefsKey: "DLD@B05"}
fixed := Explain(def, metas, q)
if got := strings.Join(fixed.Result, ","); got != "B05" {
t.Errorf("after the correction the QSO counts for %q — want B05 alone", got)
}
if len(fixed.Superseded) == 0 {
t.Error("the trace does not say what the correction replaced")
}
// A QSO with no correction still gets everything the matcher found: the
// override must not disable matching for everyone else.
q2 := &qso.QSO{Callsign: "DL3XYZ", Address: "Berlin Mitte, 10115 Berlin"}
if len(Explain(def, metas, q2).Result) < 2 {
t.Error("an untouched QSO lost its automatic references")
}
}
// On an award where a QSO can only count for ONE reference, an ambiguous match
// assigns none.
//
// Two DARC DOKs are called "Gießen" because two clubs share the town. Exact
// matching finds both, and both are right as far as the matcher can tell.
// Picking one would write a reference into the log that may well be the other —
// so the contact is left for the operator, who now has the last word.
func TestAmbiguousMatchIsRefusedWhenOnlyOneReferenceIsAllowed(t *testing.T) {
def := Def{
Code: "DLD", Name: "DARC DOK", Field: "qth",
MatchBy: "description", ExactMatch: true, Type: "QSOFIELDS", Valid: true,
OneRefPerQSO: true,
}
metas := []RefMeta{
{Code: "F07", Name: "Gießen", Valid: true},
{Code: "F61", Name: "Gießen", Valid: true},
{Code: "B11", Name: "Kassel", Valid: true},
}
amb := Explain(def, metas, &qso.QSO{Callsign: "DL2FDM", QTH: "Gießen"})
if len(amb.Result) != 0 {
t.Errorf("an ambiguous match produced %v — it should produce nothing", amb.Result)
}
if len(amb.Ambiguous) != 2 {
t.Errorf("the trace lists %v — it should name both candidates", amb.Ambiguous)
}
// An unambiguous one is untouched: this must not make the award stricter in
// general, only where the data genuinely does not decide.
ok := Explain(def, metas, &qso.QSO{Callsign: "DL3XYZ", QTH: "Kassel"})
if len(ok.Result) != 1 || ok.Result[0] != "B11" {
t.Errorf("an unambiguous QSO gave %v — want B11", ok.Result)
}
// And the operator's choice still wins over the ambiguity.
fixed := Explain(def, metas, &qso.QSO{
Callsign: "DL2FDM", QTH: "Gießen",
Extras: map[string]string{ManualRefsKey: "DLD@F61"},
})
if len(fixed.Result) != 1 || fixed.Result[0] != "F61" {
t.Errorf("after the operator picked F61 the QSO counts for %v", fixed.Result)
}
}
+19
View File
@@ -869,3 +869,22 @@ func (m *Manager) YaesuDo(fn func(YaesuController) error) error {
return fn(yc)
})
}
// KenwoodController is the Kenwood/Elecraft CW-over-CAT capability (the KY keyer),
// so a K3 can key CW through its single CAT link instead of a second COM port.
type KenwoodController interface {
SendCW(string) error
StopCW() error
SetKeySpeed(int) error
}
// KenwoodDo dispatches a Kenwood control onto the CAT goroutine.
func (m *Manager) KenwoodDo(fn func(KenwoodController) error) error {
return m.exec(func(b Backend) error {
kc, ok := b.(KenwoodController)
if !ok {
return fmt.Errorf("active CAT backend is not a Kenwood/Elecraft")
}
return fn(kc)
})
}
+29 -22
View File
@@ -61,10 +61,12 @@ type Flex struct {
spotsEnabled bool // push cluster spots + manage the panadapter overlay
spotIdx map[int]bool // panadapter spot indices currently known to the radio
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
pendingSpotMode map[int]string // seq → ADIF mode, paired with pendingSpot
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
// OnSpotClick is called (off the reader goroutine's hot path) when the user
@@ -185,7 +187,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
return &Flex{
host: strings.TrimSpace(host), port: port,
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
pinnedSlice: -1,
@@ -314,7 +316,12 @@ func (f *Flex) send(cmd string) int {
debugLog.Printf("Flex: send %q failed: %v", cmd, err)
return 0
}
debugLog.Printf("Flex: → %s", cmd)
// Meter subscriptions are pure bookkeeping and a Flex announces meters one
// status line at a time — tracing each "sub meter N" wrote dozens of lines
// at every connect.
if !strings.HasPrefix(cmd, "sub meter ") {
debugLog.Printf("Flex: → %s", cmd)
}
return seq
}
@@ -333,11 +340,17 @@ func (f *Flex) reader(conn net.Conn) {
if idx, err := strconv.Atoi(mm[1]); err == nil {
f.mu.Lock()
call := f.spotCall[idx]
mode := f.spotMode[idx]
handler := f.OnSpotClick
f.mu.Unlock()
if call != "" && handler != nil {
debugLog.Printf("Flex: spot %d triggered → %s", idx, call)
debugLog.Printf("Flex: spot %d triggered → %s (mode %s)", idx, call, mode)
go handler(call, 0)
// SmartSDR tunes the spot's frequency on click but does NOT apply
// its mode=, so set the slice mode ourselves from what we spotted.
if mode != "" {
go func(m string) { _ = f.SetMode(m) }(mode)
}
}
}
}
@@ -374,12 +387,15 @@ func (f *Flex) reader(conn net.Conn) {
// pair it with the callsign we stashed under this seq.
f.mu.Lock()
call, pending := f.pendingSpot[seq]
spotMode := f.pendingSpotMode[seq]
if pending {
delete(f.pendingSpot, seq)
delete(f.pendingSpotMode, seq)
}
if pending && ok && len(parts) >= 3 {
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
f.spotCall[idx] = call
f.spotMode[idx] = spotMode
f.spotIdx[idx] = true
f.spotByCall[strings.ToUpper(call)] = idx
}
@@ -773,17 +789,11 @@ func (f *Flex) handleStatus(payload string) {
f.meterMeta[num] = old
}
f.mu.Unlock()
// One line for the whole batch, not one per meter: a Flex announces
// dozens at connect and that was a wall of text every time.
var names []string
// Not logged: the radio announces meters ONE per status line, so even
// a batched line meant dozens of them at every connect.
for _, id := range newIDs {
mi := f.meterMeta[id]
names = append(names, nonEmpty(mi.name, strconv.Itoa(id)))
f.subscribeMeter(id)
}
if len(names) > 0 {
debugLog.Printf("Flex: subscribed to %d meter(s): %s", len(names), strings.Join(names, " "))
}
}
// Spot status: "spot <index> …". Track the index so we can clear the
// panadapter, and log it verbatim — a click on a panadapter spot pushes a
@@ -802,6 +812,7 @@ func (f *Flex) handleStatus(payload string) {
if removed {
delete(f.spotIdx, idx)
delete(f.spotCall, idx)
delete(f.spotMode, idx)
} else {
f.spotIdx[idx] = true
}
@@ -1264,6 +1275,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
if hadOld {
delete(f.spotByCall, upperCall)
delete(f.spotCall, old)
delete(f.spotMode, old)
delete(f.spotIdx, old)
}
f.mu.Unlock()
@@ -1289,6 +1301,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
// (trigger) can be resolved back to the callsign.
f.mu.Lock()
f.pendingSpot[seq] = s.Callsign
f.pendingSpotMode[seq] = s.Mode
f.mu.Unlock()
}
return nil
@@ -1321,6 +1334,7 @@ func (f *Flex) ClearSpots() error {
f.mu.Lock()
f.spotIdx = map[int]bool{}
f.spotCall = map[int]string{}
f.spotMode = map[int]string{}
f.spotByCall = map[string]int{}
connected := f.conn != nil
f.mu.Unlock()
@@ -2261,13 +2275,6 @@ func (f *Flex) subscribeMeter(id int) {
f.send(fmt.Sprintf("sub meter %d", id))
}
func nonEmpty(s, def string) string {
if s == "" {
return def
}
return s
}
func parseFloatDefault(s string, def float64) float64 {
if v, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil {
return v
+66 -15
View File
@@ -114,6 +114,15 @@ type IcomSerial struct {
silentGrace time.Duration // current width of that tolerance (backs off, see ReadState)
dspLoaded bool // readDSP has run since the rig became responsive (loads all
// the panel's set-once controls once the rig actually answers)
// When the console last asked for the DSP snapshot. The meters and the
// front-panel rotation are polled ONLY while something is displaying them:
// they are half of OpsLog's CI-V traffic, and on a shared bus — a microHAM
// Router splitting one CI-V link into two virtual ports, OpsLog on one and
// WSJT-X/MSHV on the other — every frame we don't need is a frame that can
// collide with the other program's.
dspWantMu sync.Mutex
dspWantAt time.Time
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
lastSetFreqAt time.Time
@@ -409,15 +418,25 @@ func (b *IcomSerial) ReadState() (RigState, error) {
}
// Live meters + TX state for the Icom panel (the rig doesn't push these).
// The meters are polled only while the console is actually on screen: they
// were three CI-V round trips per cycle, every cycle, whether or not anyone
// could see them. The previous readings are kept so the panel opens on the
// last known values rather than on zeros.
tx := b.readTX()
sm, _ := b.readMeter(civ.SubMeterS)
po, swr := 0, 0
if tx {
if v, ok := b.readMeter(civ.SubMeterPo); ok {
po = v
}
if v, ok := b.readMeter(civ.SubMeterSWR); ok {
swr = v
watched := b.panelWatched()
b.dspMu.Lock()
sm, po, swr := b.dsp.SMeter, b.dsp.PowerMeter, b.dsp.SWRMeter
b.dspMu.Unlock()
if watched {
sm, _ = b.readMeter(civ.SubMeterS)
po, swr = 0, 0
if tx {
if v, ok := b.readMeter(civ.SubMeterPo); ok {
po = v
}
if v, ok := b.readMeter(civ.SubMeterSWR); ok {
swr = v
}
}
}
b.dspMu.Lock()
@@ -434,11 +453,15 @@ func (b *IcomSerial) ReadState() (RigState, error) {
// snapshot so the panel's antenna, sliders, RIT, notch, etc. reflect the rig
// instead of sitting at their zero defaults. Runs once; ↻ Refresh re-reads on
// demand, and a reconnect re-arms it (Connect clears dspLoaded).
if !b.dspLoaded {
b.readDSP()
b.dspLoaded = true
} else {
b.refreshFrontPanel()
// Both of these only make sense for the console — don't spend the bus on them
// while it is closed. The snapshot then loads the first time it is opened.
if watched {
if !b.dspLoaded {
b.readDSP()
b.dspLoaded = true
} else {
b.refreshFrontPanel()
}
}
return s, nil
}
@@ -532,12 +555,25 @@ func (b *IcomSerial) SetPower(on bool) error {
return fmt.Errorf("icom: not connected")
}
if on {
buf := make([]byte, 0, 32)
for i := 0; i < 25; i++ {
// The preamble has to last long enough IN TIME for the sleeping rig to
// notice it, so its length scales with the baud rate — Icom's own table
// asks for 7 bytes at 4800 baud and 150 at 115200, which is a fixed
// ~25 ms of carrier. A flat 25 bytes (what this sent before) is right at
// 19200 and far too short above it, so a rig configured for 115200 simply
// ignored the command.
n := b.baud / 768
if n < 7 {
n = 7
} else if n > 150 {
n = 150
}
buf := make([]byte, 0, n+8)
for i := 0; i < n; i++ {
buf = append(buf, 0xFE)
}
buf = append(buf, 0xFE, 0xFE, b.rigAddr, civ.AddrController, civ.CmdPower, 0x01, 0xFD)
_, err := b.port.Write(buf)
applog.Printf("icom: power ON — %d-byte wake preamble at %d baud, addr 0x%02X (err=%v)", n, b.baud, b.rigAddr, err)
return err
}
_, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, civ.CmdPower, 0x00))
@@ -1289,11 +1325,26 @@ func (b *IcomSerial) modeCode(mode string) (code byte, data bool, err error) {
// ── IcomController: receive-DSP controls for the Icom tab ───────────────────
func (b *IcomSerial) IcomState() IcomTXState {
// Asking for the snapshot is what marks the console as being watched, which
// is what re-enables the meter polling in ReadState.
b.dspWantMu.Lock()
b.dspWantAt = time.Now()
b.dspWantMu.Unlock()
b.dspMu.Lock()
defer b.dspMu.Unlock()
return b.dsp
}
// panelWatched reports whether the Icom console asked for the DSP snapshot
// recently — i.e. whether anything is on screen to read the meters.
func (b *IcomSerial) panelWatched() bool {
b.dspWantMu.Lock()
at := b.dspWantAt
b.dspWantMu.Unlock()
return !at.IsZero() && time.Since(at) < 3*time.Second
}
// RefreshIcom re-reads the whole DSP snapshot from the rig. Runs on the CAT
// goroutine (dispatched via IcomDo).
func (b *IcomSerial) RefreshIcom() error {
+231 -38
View File
@@ -29,6 +29,7 @@ package cat
// from the radio, and the rig file decides what a "Freq" property means.
import (
"bytes"
"errors"
"fmt"
"net"
@@ -55,6 +56,12 @@ type Kenwood struct {
// a wire, which is how most operators actually put a rig on the network.
host string
digital string // mode name logged for data (FT8 by default)
// dataMode chooses what CAT mode a DATA/digital mode (FT8, PSK…) sets, because
// no single Kenwood mode digit is right for every rig: "usb" → USB (the SSB-data
// base, default), "data" → the DATA mode digit MD6 (Elecraft K3/K4), "keep" →
// leave the rig's current mode untouched (safest for a TS-590SG/TS-990S whose
// data mode is a USB modifier the operator sets on the rig). See SetMode.
dataMode string
mu sync.Mutex
port serial.Port
@@ -69,8 +76,73 @@ type Kenwood struct {
curFreq int64
curRXFreq int64
curVFO string // "A" or "B"
// Split is confirmed with FR;/FT; because IF's split bit is empty on some
// Kenwood-dialect rigs — but that check is THROTTLED so it doesn't run every
// poll: the two extra commands tripled the poll time on a slow K3 and made the
// frequency (read from IF at the top of the poll) lag by seconds. Between
// checks the last result stands.
splitCheckAt time.Time
splitCached bool
splitVFOCached string
keyWPM int // CW keyer speed, for pacing the KY buffer (see kenwood_cw.go)
// Commands this rig answered "?;" to — asked once, then never again.
unsupported map[string]bool
// rx holds bytes read but not yet consumed, ACROSS calls to ask.
//
// It has to survive: a rig answers faster than we ask, so one Read often
// returns a whole reply plus the start of the next frame. When this buffer
// was local to ask, everything after the matched frame was dropped — half a
// frame included — and the link desynchronised permanently: every ask then
// found the PREVIOUS command's answer and timed out waiting for its own.
// That is the "discarding \" 000000000010000000;\" while waiting for IF"
// a TS-480 reported, followed by connected=false for ever.
rx []byte
// heard is whatever arrived during Connect that was not a reply we wanted.
// Kept only to put it in the error message: "not answering" and "answering
// something unreadable" are different faults with different fixes.
heard string
// lowerLines deasserts DTR and RTS after opening the port.
//
// Neither default is safe for everyone, which is why this is a setting and
// not a decision. Windows raises both lines on open: an interface that reads
// them as PTT then keys the rig for as long as OpsLog is running — reported
// as FT8 output power wandering, and gone the moment OpsLog was closed.
// Lowering them instead silences the radios whose USB-serial interface needs
// RTS asserted to transmit at all — a TS-990 that opened cleanly and answered
// nothing. Off by default: that is how this backend behaved for its whole
// life before the question came up.
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
// before Connect.
func (k *Kenwood) SetLowerLines(v bool) {
k.mu.Lock()
k.lowerLines = v
k.mu.Unlock()
}
// where names the link for a message, so an error does not read "COM @ 0 baud"
// after a network connect.
func (k *Kenwood) where() string {
if k.host != "" {
return k.host
}
return k.portName
}
// NewKenwoodTCP builds a backend that reaches the rig over a socket instead of
@@ -122,6 +194,14 @@ func (k *Kenwood) Connect() error {
// request/response pairs and make a reply impossible to attribute. We poll.
_ = k.write("AI0;")
// Start from silence. A reconnect inherits whatever the rig said last —
// unsolicited frames sent before AI0 landed, the tail of an answer nobody
// read — and one stale frame is enough to leave every ask one reply behind
// its question for the rest of the session.
k.rx = nil
k.drain(250 * time.Millisecond)
k.heard = ""
answered := false
if id, err := k.ask("ID;"); err == nil && strings.HasPrefix(id, "ID") {
answered = true
@@ -140,10 +220,22 @@ func (k *Kenwood) Connect() error {
}
if !answered {
k.model = ""
if k.host != "" {
return fmt.Errorf("kenwood: %s accepted the connection but the rig is not answering — check that the serial bridge points at the radio and that the radio is switched on", k.host)
if k.heard == "" && len(k.rx) > 0 {
k.heard = string(k.rx) // an unterminated fragment is evidence too
}
return fmt.Errorf("kenwood: %s opened but the rig is not answering — check that it is switched on and set to %d baud", k.portName, k.baud)
// Distinguish silence from noise. "The rig is not answering" sent an
// operator checking the power switch and the baud rate on a radio that was
// visibly talking — its frames were arriving, they just did not match what
// was asked (wrong baud garbles them; an interface echoing our own
// commands back produces the same). Say which of the two it is, and quote
// what came back, because that is the fact that decides where to look.
if seen := k.heard; seen != "" {
return fmt.Errorf("kenwood: %s is sending data but no reply to ID; or IF; — got %q. Check the baud rate (set to %d here) and that nothing else is echoing the port", k.where(), seen, k.baud)
}
if k.host != "" {
return fmt.Errorf("kenwood: %s accepted the connection but the rig sent nothing — check that the serial bridge points at the radio and that the radio is switched on", k.host)
}
return fmt.Errorf("kenwood: %s opened but the rig sent nothing — check that it is switched on and set to %d baud", k.portName, k.baud)
}
// Name what was actually connected to. A log reading "connected on @ 0 baud"
// after a network connect is the kind of line that sends someone hunting a
@@ -174,6 +266,15 @@ func (k *Kenwood) ReadState() (RigState, error) {
if k.port == nil {
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;")
if err != nil {
return RigState{}, err
@@ -203,18 +304,31 @@ func (k *Kenwood) ReadState() (RigState, error) {
// this costs two short commands per poll only where it actually works.
split := f.Split
if !split {
rxv, rxOK := k.askVFO("FR;")
txv, txOK := k.askVFO("FT;")
if rxOK && txOK && rxv != txv {
// Confirm with FR;/FT; only once a second, not every poll. Running the two
// extra commands each cycle tripled the poll time on a slow K3, so the
// frequency — already read from IF above — only surfaced every couple of
// seconds. Between checks the last FR/FT result stands.
if time.Since(k.splitCheckAt) >= time.Second {
k.splitCheckAt = time.Now()
k.splitCached, k.splitVFOCached = false, ""
rxv, rxOK := k.askVFO("FR;")
txv, txOK := k.askVFO("FT;")
if rxOK && txOK && rxv != txv {
k.splitCached = true
// Trust FR over IF for which VFO is in use: they were asked in the
// same breath, and a rig that leaves the split bit empty may be just
// as vague about the VFO field. The block below picks the TRANSMIT
// VFO as "the other one" from f.VFO, so leaving them disagreeing
// would read the transmit frequency off the wrong dial.
if rxv == "A" || rxv == "B" {
k.splitVFOCached = rxv
}
}
}
if k.splitCached {
split = true
// Trust FR over IF for which VFO is in use: they were asked in the
// same breath, and a rig that leaves the split bit empty may be just
// as vague about the VFO field.
// f.VFO too, not just the reported state: the block below picks the
// TRANSMIT VFO as "the other one" from f.VFO, and leaving the two
// disagreeing would read the transmit frequency off the wrong dial.
if rxv == "A" || rxv == "B" {
k.curVFO, s.Vfo, f.VFO = rxv, rxv, rxv
if k.splitVFOCached != "" {
k.curVFO, s.Vfo, f.VFO = k.splitVFOCached, k.splitVFOCached, k.splitVFOCached
}
}
}
@@ -245,6 +359,7 @@ func (k *Kenwood) ReadState() (RigState, error) {
if s.Split {
k.curRXFreq = s.RxFreqHz
}
k.lastState = s // cache for the transmit window, where we can't poll
return s, nil
}
@@ -272,6 +387,17 @@ func (k *Kenwood) SetMode(mode string) error {
if k.port == nil {
return fmt.Errorf("kenwood: not connected")
}
// Honour the operator's DATA-mode preference for a digital mode. No single
// mode digit fits every rig, so the operator picks: keep the rig's mode, use
// MD6 (K3/K4 DATA), or fall through to the default USB from kenwoodModeDigit.
if isKenwoodDataMode(mode) {
switch k.dataMode {
case "keep":
return nil // leave whatever data mode the operator set on the rig
case "data":
return k.write("MD6;") // Elecraft K3/K4 DATA mode
}
}
d := kenwoodModeDigit(mode, k.curFreq)
if d == 0 {
return fmt.Errorf("kenwood: no CAT mode for %q", mode)
@@ -279,13 +405,34 @@ func (k *Kenwood) SetMode(mode string) error {
return k.write(fmt.Sprintf("MD%c;", d))
}
// SetDataMode configures how a DATA/digital mode is set: "usb" (default), "data"
// (MD6, Elecraft K3/K4) or "keep" (don't change the rig's mode).
func (k *Kenwood) SetDataMode(m string) {
k.mu.Lock()
k.dataMode = strings.ToLower(strings.TrimSpace(m))
k.mu.Unlock()
}
// isKenwoodDataMode reports whether a mode name is a soundcard/data mode (FT8,
// PSK, JT…) rather than a voice/CW/RTTY mode the rig sets natively.
func isKenwoodDataMode(mode string) bool {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "", "LSB", "USB", "SSB", "CW", "CW-R", "CWR", "FM", "NFM", "AM",
"RTTY", "FSK", "RTTY-R", "FSK-R":
return false
}
return true
}
func (k *Kenwood) SetPTT(on bool) error {
k.mu.Lock()
defer k.mu.Unlock()
if k.port == nil {
return fmt.Errorf("kenwood: not connected")
}
k.tx = on
if on {
k.txAt = time.Now()
return k.write("TX;")
}
return k.write("RX;")
@@ -300,6 +447,23 @@ func (k *Kenwood) write(cmd string) error {
return err
}
// drain reads and throws away whatever the rig has already sent, until it stays
// quiet for one read timeout or the budget runs out.
func (k *Kenwood) drain(budget time.Duration) {
if k.port == nil {
return
}
tmp := make([]byte, 256)
deadline := time.Now().Add(budget)
for time.Now().Before(deadline) {
n, err := k.port.Read(tmp)
if err != nil || n == 0 {
return // an error here is not interesting: we are throwing this away
}
traceText("kenwood", "RX-drop", string(tmp[:n]))
}
}
// ask sends a query and returns the reply belonging to THAT command. Anything
// else on the wire is discarded: a stray frame parsed as a frequency reads as
// "lost the rig" to the Manager, which then reconnects — the CAT link dropping
@@ -312,40 +476,54 @@ func (k *Kenwood) ask(cmd string) (string, error) {
if err := k.write(cmd); err != nil {
return "", err
}
buf := make([]byte, 0, 64)
tmp := make([]byte, 64)
deadline := time.Now().Add(600 * time.Millisecond)
for time.Now().Before(deadline) {
n, err := k.port.Read(tmp)
if err != nil {
return "", err
}
if n == 0 {
continue // read timeout — the rig may still be composing its answer
}
buf = append(buf, tmp[:n]...)
for {
// Consume whatever is already buffered BEFORE reading more: the answer
// may have arrived attached to the previous one.
for {
i := strings.IndexByte(string(buf), ';')
i := bytes.IndexByte(k.rx, ';')
if i < 0 {
break
}
frame := string(buf[:i+1])
buf = buf[i+1:]
frame := string(k.rx[:i+1])
k.rx = k.rx[i+1:]
traceText("kenwood", "RX", frame)
if frame == "?;" {
// The rig rejected the command. Remember it so the poll loop stops
// paying a 600 ms timeout for it on every cycle.
k.unsupported[want] = true
debugLog.Printf("kenwood: this rig does not support %q — not asking again", cmd)
// IF; and ID; are universal on Kenwood/Elecraft — a "?;" to them is a
// 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
debugLog.Printf("kenwood: this rig does not support %q — not asking again", cmd)
}
return "", fmt.Errorf("kenwood: %s rejected", want)
}
if strings.HasPrefix(frame, want) {
return frame, nil
}
debugLog.Printf("kenwood: discarding %q while waiting for %s", frame, want)
// Remember the first unexpected frame: if the whole handshake fails, this
// is what tells the operator the radio was talking after all.
if k.heard == "" {
k.heard = frame
}
}
if !time.Now().Before(deadline) {
return "", fmt.Errorf("kenwood: timeout answering %q", cmd)
}
n, err := k.port.Read(tmp)
if err != nil {
return "", err
}
if n > 0 {
k.rx = append(k.rx, tmp[:n]...)
}
// n == 0 is a read timeout, not silence for good: the rig may still be
// composing its answer.
}
return "", fmt.Errorf("kenwood: timeout answering %q", cmd)
}
// ── Frame parsing ──────────────────────────────────────────────────────────
@@ -437,11 +615,12 @@ func kenwoodModeDigit(mode string, hz int64) byte {
}
return '2'
}
// Any other digital mode rides on the data sideband, which on Kenwood is
// plain USB/LSB with the rig's DATA input selected.
if hz > 0 && hz < 10_000_000 {
return '1'
}
// Any other digital mode (FT8, PSK, JT…) → USB on every band. There is NO single
// mode digit that is right for both families: a K3 wants DATA (MD6), a TS-590SG
// wants USB-DATA (MD2 + its DATA modifier), and MD6 on the TS-590SG is FSK/RTTY
// — wrong for FT8. USB is the safe common base (the reversed-sideband "DATA REV"
// only came from the old LSB-below-10-MHz rule). A rig-specific DATA mode is a
// follow-up that needs the model, not a blind default.
return '2'
}
@@ -475,6 +654,10 @@ var kenwoodModels = map[string]string{
"019": "TS-2000",
"020": "TS-480",
"021": "TS-590S",
// 022 reported by a real TS-990S in the field. Kenwood's documentation gives
// 024 for that radio, so both are kept: the observed value wins where they
// disagree, and neither maps to anything else.
"022": "TS-990S",
"023": "TS-590SG",
"024": "TS-990S",
"025": "TS-890S",
@@ -492,7 +675,17 @@ func (k *Kenwood) openPort() (serial.Port, error) {
}
return &tcpSerial{conn: c}, nil
}
return serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
p, err := serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
if err != nil {
return nil, err
}
// The modem lines are only touched when the operator asks for it. See
// lowerLines: both defaults break somebody's station.
if k.lowerLines {
_ = p.SetDTR(false)
_ = p.SetRTS(false)
}
return p, nil
}
// tcpSerial presents a TCP connection as a serial.Port, so the backend has one
+198
View File
@@ -0,0 +1,198 @@
package cat
// CW keying through the Kenwood/Elecraft keyer — the KY command.
//
// Same idea as the Yaesu KY engine and the Icom / Flex keyers: the radio holds
// the text and keys it with its own timing, so an Elecraft K3 (or any rig that
// speaks this dialect) needs NO WinKeyer and NO second COM port — the single CAT
// link does frequency, mode AND CW. That matters on a K3, whose one USB port is
// the CAT port; a separate serial keyer would need a second cable OpsLog can't
// give it.
//
// KY; → KYn; n=0 buffer has room, n=1 buffer full
// KY <text>; queue up to 24 characters (the space after KY is part
// of the command, not padding)
// KS nnn; keyer speed in WPM (three digits)
// RX; drop to receive — used to abort a send
//
// KY is the Elecraft-documented CW-over-CAT path on the K3/K4. A rig that refuses
// it answers "?;", which SendCW turns into a stated reason rather than silence.
import (
"fmt"
"strings"
"time"
)
// kenwoodCWChunk is the most characters one KY command accepts.
const kenwoodCWChunk = 24
// kenwoodCWAllowed is what the keyer can send; anything else is dropped, since an
// unsupported byte can abort the buffer and lose the rest of the message.
const kenwoodCWAllowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 /?.,-=+:;()"
// SendCW queues a message on the rig's keyer, fed in 24-character pieces, waiting
// for buffer room between pieces so a long macro doesn't lose its tail.
func (k *Kenwood) SendCW(text string) error {
msg := filterKenwoodCW(text)
if msg == "" {
return nil
}
k.mu.Lock()
defer k.mu.Unlock()
if k.port == nil {
return fmt.Errorf("kenwood: not connected")
}
for len(msg) > 0 {
n := kenwoodCWChunk
if len(msg) < n {
n = len(msg)
}
chunk := msg[:n]
msg = msg[n:]
k.waitCWBuffer(3 * time.Second)
if err := k.write("KY " + chunk + ";"); err != nil {
return err
}
if err := k.afterKY(); err != nil {
return err
}
// Pace the next piece by how long this one takes to key, so we never overrun
// the 24-character buffer (the rig silently drops what doesn't fit).
if len(msg) > 0 {
time.Sleep(kenwoodCWDuration(chunk, k.keyerWPM()))
}
}
return nil
}
// afterKY reads briefly after a KY write. An accepted KY says nothing; a REJECT
// answers "?;". Reading it straight off the port (not through the shared rx
// buffer) keeps that stray frame from being picked up by the next poll's ask —
// which would mis-mark an unrelated command unsupported and desync the link — and
// turns a silent non-transmission into a stated reason. The caller holds k.mu.
func (k *Kenwood) afterKY() error {
deadline := time.Now().Add(150 * time.Millisecond)
tmp := make([]byte, 64)
var buf []byte
for time.Now().Before(deadline) {
n, err := k.port.Read(tmp)
if err != nil {
break
}
if n > 0 {
buf = append(buf, tmp[:n]...)
}
}
if strings.Contains(string(buf), "?;") {
return fmt.Errorf("this radio rejected CW over CAT (it answered \"?;\" to KY). " +
"Switch the keyer engine to the serial-port keyer (DTR=CW) on a COM port instead")
}
return nil
}
// waitCWBuffer blocks until the keyer reports room, or the deadline passes. A rig
// that never answers the KY; status query is not a reason to refuse to send — we
// go ahead, and the per-chunk pacing covers the worst case. The caller holds k.mu.
func (k *Kenwood) waitCWBuffer(within time.Duration) {
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
if k.unsupported["KY"] {
return // this rig doesn't report buffer state — pacing covers it
}
r, err := k.ask("KY;")
if err != nil {
return // unsupported / timeout — send anyway, pacing covers it
}
if !kenwoodCWBufferFull(r) {
return
}
time.Sleep(50 * time.Millisecond)
}
}
// kenwoodCWBufferFull reads the KY; status reply. Deliberately asymmetric: only a
// clear "1" after KY means full. Anything else reads as "go ahead" — refusing to
// send because a status line was phrased unexpectedly is the worse failure.
func kenwoodCWBufferFull(reply string) bool {
r := strings.TrimSpace(reply)
if !strings.HasPrefix(strings.ToUpper(r), "KY") {
return false
}
for _, c := range r[2:] {
switch c {
case '0':
return false
case '1':
return true
case ' ', ';':
continue
default:
return false
}
}
return false
}
// StopCW aborts the message being sent. Kenwood documents no KY buffer-clear, so
// this drops the transmitter — RX; forces receive, which is what an operator
// pressing Escape wants; anything still queued is not keyed on the air.
func (k *Kenwood) StopCW() error {
k.mu.Lock()
defer k.mu.Unlock()
if k.port == nil {
return fmt.Errorf("kenwood: not connected")
}
return k.write("RX;")
}
// SetKeySpeed sets the keyer speed (WPM) via KS and remembers it for pacing.
func (k *Kenwood) SetKeySpeed(wpm int) error {
if wpm < 4 {
wpm = 4
}
if wpm > 99 {
wpm = 99 // KS is three digits but the K3 keyer tops out well below 100
}
k.mu.Lock()
defer k.mu.Unlock()
k.keyWPM = wpm
if k.port == nil {
return fmt.Errorf("kenwood: not connected")
}
return k.write(fmt.Sprintf("KS%03d;", wpm))
}
// keyerWPM is the speed to pace the buffer by. The caller holds k.mu.
func (k *Kenwood) keyerWPM() int {
if k.keyWPM >= 4 {
return k.keyWPM
}
return 20
}
// kenwoodCWDuration estimates how long a piece of text takes to key (PARIS
// timing: a character averages 10 dits, a dit is 1.2/wpm seconds).
func kenwoodCWDuration(text string, wpm int) time.Duration {
if wpm < 4 {
wpm = 20
}
ditMs := 1200.0 / float64(wpm)
return time.Duration(float64(len(text))*10*ditMs) * time.Millisecond
}
// filterKenwoodCW upper-cases and strips what the keyer cannot send; whitespace
// becomes a single word gap.
func filterKenwoodCW(text string) string {
var b strings.Builder
for _, r := range strings.ToUpper(text) {
if r == '\t' || r == '\n' || r == '\r' {
b.WriteByte(' ')
continue
}
if strings.ContainsRune(kenwoodCWAllowed, r) {
b.WriteRune(r)
}
}
return strings.Join(strings.Fields(b.String()), " ")
}
+30 -2
View File
@@ -265,8 +265,8 @@ func TestKenwoodSilentRigIsNotConnected(t *testing.T) {
if err == nil {
t.Fatal("a silent port was reported as a connected rig")
}
if !strings.Contains(err.Error(), "not answering") {
t.Errorf("error was %q — it should say the rig is not answering", err)
if !strings.Contains(err.Error(), "sent nothing") {
t.Errorf("error was %q — a silent port should be reported as silence", err)
}
if k.model != "" {
t.Errorf("a stale model survived a failed connect: %q", k.model)
@@ -300,6 +300,11 @@ func TestKenwoodSplitFromFRFT(t *testing.T) {
// Split on, IF still silent about it: receive on A, transmit on B.
rig.split = true
// The FR/FT split re-check is throttled to once a second (it tripled the poll
// time on a slow K3), so back-to-back reads reuse the last result. Simulate the
// recheck window having elapsed — in the field the 250 ms poll covers it inside
// a second.
k.splitCheckAt = time.Time{}
if s, err = k.ReadState(); err != nil {
t.Fatalf("read: %v", err)
}
@@ -355,3 +360,26 @@ func TestKenwoodSplitWhenFRFTRejected(t *testing.T) {
t.Errorf("IF-reported split broke: split=%v tx=%d rx=%d", s.Split, s.FreqHz, s.RxFreqHz)
}
}
// A rig that talks but never answers what was asked must not be reported as a
// silent one.
//
// The two faults need opposite responses: silence means the radio is off, the
// wrong port, or a dead cable; noise means the baud rate is wrong or something
// is echoing the line. "The rig is not answering" sent an operator checking the
// power switch on a radio whose frames were visibly arriving.
func TestKenwoodNoisyRigIsReportedAsNoiseNotSilence(t *testing.T) {
k := NewKenwood("COM-TEST", 9600, "FT8")
k.dialPort = func() (serial.Port, error) {
return &fakeSerial{toRig: &strings.Builder{}, answer: func(cmd string) string {
return "XX9999;" // something, but never the reply to ID; or IF;
}}, nil
}
err := k.Connect()
if err == nil {
t.Fatal("a rig answering gibberish was reported as connected")
}
if !strings.Contains(err.Error(), "sending data") || !strings.Contains(err.Error(), "XX9999;") {
t.Errorf("error was %q — it should say data arrived, and quote it", err)
}
}
+19 -2
View File
@@ -100,8 +100,8 @@ func TestKenwoodModeDigit(t *testing.T) {
{"RTTY", 14080000, '6'},
{"AM", 7150000, '5'},
{"FM", 145000000, '4'},
{"FT8", 7074000, '1'}, // data rides on the sideband for the band
{"FT8", 14074000, '2'},
{"FT8", 7074000, '2'}, // data is ALWAYS USB, even below 10 MHz (K3 "DATA REV" otherwise)
{"FT8", 14074000, '2'}, // …and above
{"", 14074000, 0}, // nothing to set
}
for _, c := range cases {
@@ -111,6 +111,23 @@ func TestKenwoodModeDigit(t *testing.T) {
}
}
// The DATA-mode preference only applies to soundcard/data modes, never to voice,
// CW or native RTTY — getting this wrong would hijack an SSB or CW mode change.
func TestIsKenwoodDataMode(t *testing.T) {
data := []string{"FT8", "FT4", "PSK31", "JT65", "DATA", "DIGITAL", "OLIVIA"}
notData := []string{"", "LSB", "USB", "SSB", "CW", "CW-R", "FM", "AM", "RTTY", "FSK", "RTTY-R"}
for _, m := range data {
if !isKenwoodDataMode(m) {
t.Errorf("isKenwoodDataMode(%q) = false, want true", m)
}
}
for _, m := range notData {
if isKenwoodDataMode(m) {
t.Errorf("isKenwoodDataMode(%q) = true, want false", m)
}
}
}
// What the log records for each mode digit the rig reports.
func TestKenwoodModeToADIF(t *testing.T) {
cases := []struct {
+71 -6
View File
@@ -28,6 +28,12 @@ type TCI struct {
digitalDefault string // surfaced when the rig reports a digital mode (FT8/…)
spotsEnabled bool // mirror cluster spots onto the TCI panorama
// OnSpotClick is called when the user clicks one of our spots on the TCI
// panorama (callsign + freq), so the host can fill the entry form. Set before
// Connect. Mirrors the FlexRadio panadapter-click flow.
OnSpotClick func(callsign string, freqHz int64)
unhandledSeen map[string]bool // log each unknown TCI message type once
mu sync.Mutex // guards conn + writes + state
conn *websocket.Conn
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop)
@@ -110,15 +116,32 @@ func (t *TCI) SendSpot(s SpotInfo) error {
if call == "" || s.FreqHz <= 0 {
return nil
}
color := strings.TrimSpace(s.Color)
if color == "" {
color = "#FFFFA500" // opaque orange default
// TCI's SPOT command wants the colour as a signed 32-bit DECIMAL integer in
// 0xAARRGGBB order — NOT a "0x…" hex string (e.g. "spot:UN7GK,cw,14025000,
// -16776961,test;"). ExpertSDR silently drops a spot whose colour field it
// can't parse as a number, which is why spots never showed on the panorama
// while tuning (a separate command) still worked.
hex := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(s.Color), "#"), "0x")
if hex == "" {
hex = "FFFFA500" // opaque orange default
}
if len(hex) == 6 {
hex = "FF" + hex // add full-opacity alpha when only RGB was supplied
}
argb, err := strconv.ParseUint(hex, 16, 32)
if err != nil {
argb = 0xFFFFA500
}
// Use a valid TCI modulation (usb/lsb/cw/digl…) so ExpertSDR accepts the spot;
// fall back to the raw label if we can't map it. The click-to-tune path already
// maps the mode separately, so this only affects the spot's displayed mode.
mode := adifToTCIMode(s.Mode, s.FreqHz)
if mode == "" {
mode = strings.ToLower(strings.TrimSpace(s.Mode))
}
color = "0x" + strings.TrimPrefix(color, "#")
mode := strings.ToLower(strings.TrimSpace(s.Mode))
// Commas/semicolons would break TCI's comma-separated argument parsing.
text := strings.NewReplacer(",", " ", ";", " ").Replace(s.Comment)
return t.send(fmt.Sprintf("spot:%s,%s,%d,%s,%s;", call, mode, s.FreqHz, color, text))
return t.send(fmt.Sprintf("spot:%s,%s,%d,%d,%s;", call, mode, s.FreqHz, int32(argb), text))
}
// Disconnect closes the WebSocket; the reader goroutine then exits.
@@ -294,6 +317,48 @@ func (t *TCI) handle(msg string) {
if get(0) == "0" {
t.tx = get(1) == "true"
}
default:
lname := strings.ToLower(name)
// A click on one of our panorama spots comes back as
// CLICKED_ON_SPOT:<call>,<hz> (legacy)
// RX_CLICKED_ON_SPOT:<rx>,<ch>,<call>,<hz>
// Neither name starts with "spot", which is why the click was silently
// ignored before. Read the callsign (the one non-numeric field) and the
// frequency (the large numeric field) positionally-independently, so both
// shapes work without depending on the exact arg order.
if strings.Contains(lname, "spot") {
var call string
var hz int64
for _, raw := range f {
v := strings.TrimSpace(raw)
if v == "" {
continue
}
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
if n >= 10000 { // a real frequency, not an rx/channel index
hz = n
}
} else if call == "" {
call = strings.ToUpper(v) // callsigns always carry letters
}
}
debugLog.Printf("TCI: spot click %q → call=%s freq=%d", msg, call, hz)
if call != "" && t.OnSpotClick != nil {
cb := t.OnSpotClick
go cb(call, hz)
}
return
}
// Log every OTHER unknown message TYPE once, so the protocol (incl. any
// spot-click notification named differently) is discoverable from the log
// without flooding it with the frequent streamed messages.
if t.unhandledSeen == nil {
t.unhandledSeen = map[string]bool{}
}
if !t.unhandledSeen[lname] {
t.unhandledSeen[lname] = true
debugLog.Printf("TCI: (unhandled once) %s", msg)
}
}
}
+38
View File
@@ -56,6 +56,9 @@ type Xiegu struct {
// asking every cycle — a Xiegu that has no split must not cost a timeout per
// poll, which would slow the whole loop to a crawl.
splitSupported bool
// pttLine is "", "rts" or "dtr": which hardware line keys the rig, when the
// CI-V command does not.
pttLine string
}
func NewXiegu(portName string, baud int, addr int, digital string) *Xiegu {
@@ -74,6 +77,14 @@ func NewXiegu(portName string, baud int, addr int, digital string) *Xiegu {
}
}
// SetPTTLine selects the hardware line that keys this rig: "rts", "dtr", or ""
// for the CI-V command. Set before Connect.
func (x *Xiegu) SetPTTLine(line string) {
x.mu.Lock()
x.pttLine = strings.ToLower(strings.TrimSpace(line))
x.mu.Unlock()
}
func (x *Xiegu) Name() string { return "xiegu" }
func (x *Xiegu) Connect() error {
@@ -87,6 +98,20 @@ func (x *Xiegu) Connect() error {
return fmt.Errorf("xiegu: open %s @ %d baud: %w", x.portName, x.baud, err)
}
p.SetReadTimeout(200 * time.Millisecond)
// Deassert DTR and RTS.
//
// Windows raises both when a serial port is opened, and a great many
// interfaces read them as PTT: a Xiegu G90 behind a DE-19 goes into
// transmit the moment OpsLog connects and STAYS there — Xiegu's own
// documentation asks for RTS and DTR low. The same applies to an Icom with
// USB SEND mapped to a line (which is why the CI-V backend has done this
// from the start) and to any rig keyed by a home-made cable.
//
// PTT on this backend is a CAT command, so neither line should ever be
// asserted here. A station keying by RTS/DTR configures that separately,
// on its own port.
_ = p.SetDTR(false)
_ = p.SetRTS(false)
x.port = p
x.splitSupported = true
@@ -177,12 +202,25 @@ func (x *Xiegu) SetMode(mode string) error {
return x.send(civ.CmdSetMode, m)
}
// SetPTT keys the transmitter, by CI-V or by a hardware line.
//
// A G90 does not transmit on the CI-V PTT command. Xiegu's own interfaces key
// on RTS or DTR instead — the DE-19 does exactly that — which is why the line
// can be selected here. Without it, WSJT-X talking to OpsLog's rigctld server
// decoded perfectly and never transmitted: the command left, the radio ignored
// it, and nothing in the chain was wrong enough to complain.
func (x *Xiegu) SetPTT(on bool) error {
x.mu.Lock()
defer x.mu.Unlock()
if x.port == nil {
return fmt.Errorf("xiegu: not connected")
}
switch x.pttLine {
case "rts":
return x.port.SetRTS(on)
case "dtr":
return x.port.SetDTR(on)
}
v := byte(0x00)
if on {
v = 0x01
+20 -3
View File
@@ -82,9 +82,12 @@ var yaesuModeToADIF = map[byte]string{
}
type Yaesu struct {
portName string
baud int
digital string // ADIF mode reported for DATA (FT8, RTTY…)
// lowerLines deasserts DTR and RTS on connect — see Kenwood.lowerLines for
// why neither default is safe for every station.
lowerLines bool
portName string
baud int
digital string // ADIF mode reported for DATA (FT8, RTTY…)
mu sync.Mutex
port serial.Port
@@ -127,6 +130,14 @@ func NewYaesu(portName string, baud int, digital string) *Yaesu {
return &Yaesu{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
}
// SetLowerLines chooses whether DTR and RTS are deasserted on connect. Set
// before Connect.
func (y *Yaesu) SetLowerLines(v bool) {
y.mu.Lock()
y.lowerLines = v
y.mu.Unlock()
}
func (y *Yaesu) Name() string { return "yaesu" }
func (y *Yaesu) Connect() error {
@@ -150,6 +161,12 @@ func (y *Yaesu) Connect() error {
if err != nil {
return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err)
}
// The modem lines are only touched when the operator asks for it — see the
// note on Kenwood.lowerLines. Both defaults break somebody's station.
if y.lowerLines {
_ = p.SetDTR(false)
_ = p.SetRTS(false)
}
p.SetReadTimeout(300 * time.Millisecond)
y.port = p
+61 -3
View File
@@ -11,6 +11,7 @@ package cluster
import (
"bufio"
"errors"
"fmt"
"net"
"regexp"
@@ -346,12 +347,29 @@ func (s *session) run() {
}
}
// idleTick is how often the read is interrupted so the loop can notice a stop
// request. It is NOT a liveness test — see the read loop.
//
// A var, not a const, so the test that proves a silent node survives can shorten
// it: at 30 s that test spends a minute doing nothing, and a slow test is a test
// that gets skipped.
var idleTick = 30 * time.Second
// quietNotice is the silence after which the log says so, once.
const quietNotice = 10 * time.Minute
// runOnce dials, optionally logs in, sends init commands, parses spots.
// Returns the moment we marked the link "connected" (zero if dial failed)
// and the error that ended the session (nil if stopCh).
func (s *session) runOnce() (time.Time, error) {
addr := net.JoinHostPort(s.cfg.Host, fmt.Sprintf("%d", s.cfg.Port)) // IPv6-safe
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
// KeepAlive is set explicitly rather than left to the package default: it is
// what actually detects a dead peer here, so it should be visible in the
// code that depends on it. The OS probes an idle connection and a genuine
// failure surfaces as an error on Read — which is the only thing that ends
// a session below.
d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
conn, err := d.Dial("tcp", addr)
if err != nil {
return time.Time{}, fmt.Errorf("dial %s: %w", addr, err)
}
@@ -423,6 +441,8 @@ func (s *session) runOnce() (time.Time, error) {
}
var connectedAt time.Time
var quiet time.Duration // how long the node has said nothing
var pending string // a line cut in half by a read deadline
rd := bufio.NewReader(conn)
for {
select {
@@ -431,11 +451,37 @@ func (s *session) runOnce() (time.Time, error) {
default:
}
_ = conn.SetReadDeadline(time.Now().Add(120 * time.Second))
line, err := rd.ReadString('\n')
// The deadline exists to keep this loop responsive to stopCh, NOT to
// judge the link. A quiet node is a quiet node: some clusters send
// nothing for minutes on a dead band, and tearing the socket down over
// it produced a reconnect every two minutes — which loses the login,
// the filters, and any spot that arrived during the gap.
//
// Only a REAL error ends the session. A dead peer still gets caught:
// TCP keepalive probes an idle connection and its failure arrives here
// as an error, not as a timeout.
_ = conn.SetReadDeadline(time.Now().Add(idleTick))
chunk, err := rd.ReadString('\n')
if err != nil {
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
// ReadString hands back what it HAD read along with the timeout.
// Keep it: a spot line straddling the deadline would otherwise lose
// its first half and arrive as nonsense, or vanish entirely.
pending += chunk
quiet += idleTick
// Said once at the first long silence, so a genuinely mute node is
// visible without a line every tick.
if quiet == quietNotice {
applog.Printf("cluster[%s] no traffic for %s — still connected", s.cfg.Name, quiet)
}
continue
}
return connectedAt, fmt.Errorf("read: %w", err)
}
quiet = 0
line := pending + chunk
pending = ""
line = strings.TrimRight(line, "\r\n")
// Strip terminal BELLs (\a) and other control bytes that some nodes
// (e.g. F5LEN) append to spot lines — "DX de …0935Z KN04\a\a" — which
@@ -602,6 +648,18 @@ func parseShowDX(line string) (Spot, bool) {
}
func parseSpot(line string) (Spot, bool) {
// A cluster prints its prompt WITHOUT a trailing newline, so the first spot
// to arrive gets glued onto it and the line reads
// "login: DX de YO2CK-#: 21074.0 PY4PTO FT8 …". The anchor then fails
// and the spot is dropped — silently, apart from an UNPARSED log line.
//
// Cutting everything ahead of "DX de" rather than un-anchoring the pattern:
// an un-anchored match would also accept a talk/announce line that merely
// QUOTES a spot, and start injecting other people's conversations as real
// spots.
if i := strings.Index(line, "DX de"); i > 0 {
line = line[i:]
}
m := spotRE.FindStringSubmatch(line)
if m == nil {
return Spot{}, false
+6
View File
@@ -14,6 +14,12 @@ func TestParseSpot(t *testing.T) {
`DX de DK0SWL: 14195.5 W1AW CQ Field Day 1745Z`,
"W1AW", 14195.5, "20m", "",
},
{
// The cluster prints its prompt with no trailing newline, so the
// first spot arrives glued to it. Seen live on 2026-08-02.
`login: DX de YO2CK-#: 21074.0 PY4PTO FT8 -16 dB 1733Z`,
"PY4PTO", 21074.0, "15m", "",
},
{
`DX de F4XYZ-#: 7074.0 3DA0RU FT8 -10 0823Z JN18`,
"3DA0RU", 7074.0, "40m", "JN18",
+89
View File
@@ -0,0 +1,89 @@
package cluster
import (
"net"
"strconv"
"testing"
"time"
)
// A quiet cluster must not be treated as a broken one.
//
// Reported by an operator whose node sends little: nothing for two minutes and
// OpsLog reconnected — losing the login, the filters, and any spot that landed
// during the gap. The read deadline was being read as a verdict on the link
// rather than as a way to stay responsive to a stop request.
//
// The test holds the connection open, silent for longer than one deadline, then
// sends a spot. A session that survives receives it; one that reconnects does
// not, because this listener never accepts twice.
func TestSilenceDoesNotEndTheSession(t *testing.T) {
// Shorten the tick so the test exercises the real code path in seconds.
defer func(orig time.Duration) { idleTick = orig }(idleTick)
idleTick = time.Second
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
accepted := make(chan net.Conn, 2)
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
accepted <- c
}
}()
host, portStr, _ := net.SplitHostPort(ln.Addr().String())
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatal(err)
}
spots := make(chan Spot, 4)
s := &session{
cfg: ServerConfig{Name: "quiet", Host: host, Port: port},
onSpot: func(sp Spot) { spots <- sp },
onLine: func(Line) {},
onStatus: func() {},
stopCh: make(chan struct{}),
}
done := make(chan error, 1)
go func() { _, err := s.runOnce(); done <- err }()
conn := <-accepted
defer conn.Close()
// Silent for longer than one deadline: the loop must ride through it.
time.Sleep(3 * idleTick)
select {
case c := <-accepted:
c.Close()
t.Fatal("the session reconnected during the silence")
case err := <-done:
t.Fatalf("the session ended during the silence: %v", err)
default:
}
// Now a spot: it proves the ORIGINAL connection is still the live one.
if _, err := conn.Write([]byte("DX de F4BPO: 14074.0 OY1CT FT8 1234Z\r\n")); err != nil {
t.Fatal(err)
}
select {
case sp := <-spots:
if sp.DXCall != "OY1CT" {
t.Errorf("spot from the wrong station: %+v", sp)
}
case <-time.After(5 * time.Second):
t.Fatal("the spot sent after the silence never arrived")
}
close(s.stopCh)
<-done
}
+33
View File
@@ -173,6 +173,39 @@ func (m *Manager) EntityNames() []string {
return out
}
// PrefixByDXCC maps ADIF DXCC entity number → canonical primary prefix (F, DL,
// XE…) from the loaded cty.dat. cty.dat is keyed by entity NAME, so the join
// goes through EntityDXCC; entries whose name doesn't resolve are skipped
// rather than guessed. Empty until cty.dat has loaded.
func (m *Manager) PrefixByDXCC() map[int]string {
m.mu.RLock()
db := m.db
m.mu.RUnlock()
if db == nil {
return nil
}
out := make(map[int]string, 400)
for _, e := range db.Entities() {
p := strings.TrimSpace(e.Primary)
if p == "" {
continue
}
// cty.dat marks non-DXCC sub-entities with a leading '*' — they report
// under a parent entity and must not overwrite the parent's prefix.
if strings.HasPrefix(p, "*") {
continue
}
if n := EntityDXCC(e.Name); n > 0 {
if _, dup := out[n]; !dup {
// DXCC prefixes are conventionally upper-case; cty.dat spells some
// sub-entity suffixes in lower case (3D2/c → 3D2/C).
out[n] = strings.ToUpper(p)
}
}
}
return out
}
// Info returns metadata about the currently-loaded cty.dat (or zero value
// if nothing loaded).
func (m *Manager) Info() ctySource {
+22 -7
View File
@@ -86,7 +86,13 @@ type Server struct {
stopped bool
mu sync.Mutex
lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets
// WSJT: dial frequency from the last Status, added to Decode audio offsets
// keyed by the WSJT-X instance id, NOT one value per listener. Two instances
// share one multicast group, so a single value was overwritten by whichever
// sent Status last: decodes from the 50.313 receiver were placed on the
// 50.400 panadapter. WSJT-X requires --rig-name for a second instance, so the
// id is distinct whenever there is more than one.
dialHz map[string]int64
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
}
@@ -196,7 +202,6 @@ func (s *Server) run() {
}
func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
applog.Printf("udp: [%s] rx %d bytes from %s\n", s.cfg.Name, len(pkt), remote)
ev := Event{ConfigID: s.cfg.ID, Service: s.cfg.ServiceType, Source: remote.String()}
switch s.cfg.ServiceType {
case ServiceWSJT:
@@ -212,15 +217,21 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
// offsets can be turned into RF frequencies for the panadapter.
if w.FreqHz > 0 && !w.IsDecode {
s.mu.Lock()
s.lastDialHz = w.FreqHz
if s.dialHz == nil {
s.dialHz = map[string]int64{}
}
s.dialHz[w.ProgramID] = w.FreqHz
s.mu.Unlock()
}
if w.IsDecode {
s.mu.Lock()
dial := s.lastDialHz
dial := s.dialHz[w.ProgramID]
s.mu.Unlock()
if dial <= 0 {
return // no dial freq yet (Status not seen) → can't place the spot
// No Status from THIS instance yet. Guessing with another
// instance's dial is what produced the wrong-panadapter spots,
// so drop the decode and wait — Status arrives every second.
return
}
ev.DecodeCall = w.DecodeCall
ev.DecodeFreqHz = dial + w.DeltaFreqHz
@@ -229,8 +240,12 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
ev.Mode = w.Mode
break
}
applog.Printf("udp: [%s] WSJT decoded: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
// Only a logged QSO is worth a line — WSJT-X/MSHV send a Status packet
// every second, and logging each one buried the rest of the file.
if len(w.LoggedADIF) > 0 {
applog.Printf("udp: [%s] WSJT QSO logged: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
}
ev.DXCall = w.DXCall
ev.DXGrid = w.DXGrid
ev.Mode = w.Mode
+74
View File
@@ -0,0 +1,74 @@
# QRZ.com XML API — what a free account returns, and what a subscription adds
Captured 2026-08-02 on F4BPO's two accounts, same callsign looked up twice
within a minute. Kept because the difference is the answer to a question that
comes back regularly: *"someone told me their logger gets the 6-character
locator from QRZ without paying"*. It does not.
## Free account — `SubExp: non-subscriber`
```xml
<Callsign>
<call>F4BPO</call>
<fname>Gregory</fname>
<name>Salaun</name>
<addr2>Veigy-Foncenex</addr2>
<country>France</country>
</Callsign>
<Session>
<SubExp>non-subscriber</SubExp>
<Message>A subscription is required to access the complete record.</Message>
</Session>
```
**Five fields.** No `grid`, no `lat`/`lon`, no `dxcc`, no zones, no email, no
street, no image.
## Subscriber — `SubExp: Sat Jul 3 21:45:38 2027`
```xml
<Callsign>
<call>F4BPO</call> <dxcc>227</dxcc> <nickname>Greg</nickname>
<fname>Gregory</fname> <name>Salaun</name>
<addr2>Veigy-Foncenex</addr2><zip>74140</zip> <country>France</country>
<lat>46.103333</lat> <lon>6.290000</lon> <grid>JN36dc</grid>
<ccode>97</ccode> <land>France</land> <class>2</class>
<codes>E</codes> <qslmgr>Lotw, Buro, Direct</qslmgr>
<email>…</email> <u_views>56966</u_views>
<bio>4414</bio> <biodate>2026-07-08 15:30:58</biodate>
<image>https://cdn-xml.qrz.com/o/f4bpo/F4BPO_Greg_png.jpg</image>
<imageinfo>800:1200:221041</imageinfo>
<moddate>2025-08-24 19:55:31</moddate>
<eqsl>0</eqsl> <mqsl>1</mqsl> <lotw>1</lotw>
<cqzone>14</cqzone> <ituzone>27</ituzone>
<geoloc>user</geoloc> <name_fmt>Gregory "Greg" Salaun</name_fmt>
<serial>1000134</serial>
</Callsign>
```
## Consequences
- **A free QRZ account can never fill the locator, the coordinates, the zones
or the e-mail.** If someone reports a logger doing it on a free account, they
are either using HamQTH (free, returns a 6-character `grid`) or they hold an
XML subscription without realising it — the QRZ.com account and the "XML
Logbook Data" subscription are sold separately. `SubExp` in the session block
settles it in one request.
- **Profile edits take a few minutes to reach the XML feed.** Changing the grid
on the website and looking the callsign up immediately returns the OLD value,
which looks exactly like a stale cache. Re-test after a few minutes before
suspecting the logger. (This cost an hour on 2026-08-02.)
## Fields OpsLog does not use yet
`qrz.go` maps call, name, addr1/2, zip, state, county, country, grid, lat, lon,
dxcc, cqzone, ituzone, cont, email, qslmgr and image. Left on the table:
- **`geoloc`** — `user` when the operator entered the position by hand, versus a
geocoded value derived from the postal address. This is a direct statement of
how much the coordinates and the locator can be trusted, and it is exactly
the question the grid-precision rule in App.tsx tries to answer by comparing
string lengths. Worth wiring if the locator ever misbehaves again.
- **`eqsl` / `mqsl` / `lotw`** — the station's QSL preferences as flags, more
reliable than parsing the free-text `qslmgr`.
- `nickname` / `name_fmt`, `class`, `moddate`, `bio`.
+24 -2
View File
@@ -29,6 +29,13 @@ type Info struct {
IsUser bool `json:"is_user"`
LastUpload string `json:"last_upload,omitempty"` // YYYY-MM-DD
DaysAgo int `json:"days_ago"` // days since last upload (-1 if unknown)
// FeedDate is the newest upload date anywhere in ARRL's file, i.e. when ARRL
// last regenerated it, and FeedStale says that date is more than two days old.
// Without them "last uploaded 7 days ago" is unreadable: it may mean the
// station went quiet, or that the FEED has been frozen for a week — which is
// exactly what ARRL served on 2026-08-02, its newest line dated 2026-07-27.
FeedDate string `json:"feed_date,omitempty"`
FeedStale bool `json:"feed_stale"`
}
// Manager holds the parsed list + cache location.
@@ -36,6 +43,7 @@ type Manager struct {
mu sync.RWMutex
users map[string]time.Time // UPPER(callsign) → last-upload date (UTC)
updated time.Time // when the cache was last refreshed
newest time.Time // newest upload date in the file = when ARRL built it
dir string
client *http.Client
}
@@ -100,6 +108,7 @@ func (m *Manager) Download(ctx context.Context) (int, error) {
// parse loads the CSV bytes into the map and returns the count.
func (m *Manager) parse(data []byte) int {
users := make(map[string]time.Time, 1<<20)
var newest time.Time
sc := bufio.NewScanner(bytes.NewReader(data))
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
for sc.Scan() {
@@ -120,16 +129,29 @@ func (m *Manager) parse(data []byte) int {
continue
}
users[call] = t
if t.After(newest) {
newest = t
}
}
if len(users) == 0 {
return 0
}
m.mu.Lock()
m.users = users
m.newest = newest
m.mu.Unlock()
return len(users)
}
// feedInfo fills the feed-age fields shared by every Lookup result.
func (m *Manager) feedInfo(i Info) Info {
if !m.newest.IsZero() {
i.FeedDate = m.newest.Format("2006-01-02")
i.FeedStale = time.Since(m.newest) > 48*time.Hour
}
return i
}
// Lookup reports whether callsign is a LoTW user and how long ago it uploaded.
// Tries the exact call, then (for portable calls like "EA8/DL1ABC" or "F5ABC/P")
// the longest slash-separated segment — the base call LoTW is keyed on.
@@ -154,13 +176,13 @@ func (m *Manager) Lookup(callsign string) Info {
t, ok = m.users[base]
}
if !ok {
return Info{DaysAgo: -1}
return m.feedInfo(Info{DaysAgo: -1})
}
days := int(time.Since(t).Hours() / 24)
if days < 0 {
days = 0
}
return Info{IsUser: true, LastUpload: t.Format("2006-01-02"), DaysAgo: days}
return m.feedInfo(Info{IsUser: true, LastUpload: t.Format("2006-01-02"), DaysAgo: days})
}
// Count returns how many callsigns are loaded.
+147 -11
View File
@@ -9,6 +9,7 @@ package powergenius
import (
"bufio"
"fmt"
"math"
"net"
"strconv"
"strings"
@@ -23,7 +24,13 @@ const (
defaultPort = 9008
dialTimeout = 5 * time.Second
ioTimeout = 3 * time.Second
pollEvery = 1500 * time.Millisecond
// Poll fast enough that the amp's OWN forward/current figures make a usable
// live meter on their own — the UI prefers them over the FlexRadio VITA stream
// (which never traverses a public-IP/NAT link), so this direct reading is what
// an operator watches when running the amp over the internet. At 250 ms the
// SSB envelope is sampled often enough that peak-hold keeps a steady reading
// instead of collapsing to ~1 W in the gaps between syllables.
pollEvery = 250 * time.Millisecond
reconnectDelay = 2 * time.Second
)
@@ -36,11 +43,22 @@ type Status struct {
FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST
Temperature float64 `json:"temperature"`
Operate bool `json:"operate"` // OPERATE vs STANDBY (optimistic until the amp reports it)
// Live power, read straight from the amplifier's own status frame rather
// than sampled off the FlexRadio meter stream. PeakW is the amp's own peak
// detector: a poll catches one instant of the envelope, so the plain forward
// figure lands between syllables as often as on a peak.
FwdW float64 `json:"fwd_w"` // forward power [W] (the frame reports dBm)
PeakW float64 `json:"peak_w"` // peak forward power [W]
Vswr float64 `json:"vswr"` // VSWR, derived from the frame's return loss in dB
Id float64 `json:"id"` // drain current [A]
PeakId float64 `json:"peak_id"` // peak drain current [A]
}
type Client struct {
host string
port int
host string
port int
password string // remote-access code; sent as "auth <code>" when the banner announces AUTH
mu sync.Mutex // serialises command send/recv on the connection
conn net.Conn
@@ -59,11 +77,11 @@ type Client struct {
running bool
}
func New(host string, port int) *Client {
func New(host string, port int, password string) *Client {
if port <= 0 || port > 65535 {
port = defaultPort
}
return &Client{host: host, port: port, stop: make(chan struct{}), status: Status{Host: host}}
return &Client{host: host, port: port, password: strings.TrimSpace(password), stop: make(chan struct{}), status: Status{Host: host}}
}
func (c *Client) Start() error {
@@ -107,9 +125,20 @@ func (c *Client) SetFanMode(mode string) error {
default:
return fmt.Errorf("powergenius: invalid fan mode %q", mode)
}
if _, err := c.command("setup fanmode=" + m); err != nil {
// The verb the amp wants is "setup fanmode=VALUE" — confirmed LIVE: with the
// "setup " prefix the amp replies code 0 (accepted), while the bare
// "fanmode=VALUE" we switched to earlier is rejected (reply code 0x50000015).
// That regression is what stopped the fan from changing; restoring "setup "
// fixes it.
reply, err := c.command("setup fanmode=" + m)
if err != nil {
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)
c.statusMu.Lock()
c.status.FanMode = m // optimistic
c.fanPending, c.fanPendingAt = m, time.Now()
@@ -117,6 +146,19 @@ func (c *Client) SetFanMode(mode string) error {
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).
func (c *Client) SetOperate(on bool) error {
v := "0"
@@ -166,12 +208,62 @@ func (c *Client) ensureConnected() error {
}
c.conn = conn
c.reader = bufio.NewReader(conn)
// Discard the version banner the device sends on connect.
// Banner: "V…" (LAN) or "V… AUTH" (remote → authentication required, exactly
// like the Tuner Genius / Antenna Genius). Send the remote code when the amp
// demands it, otherwise every command comes back "Unauthorized".
_ = conn.SetReadDeadline(time.Now().Add(ioTimeout))
_, _ = c.reader.ReadString('\n')
banner, _ := c.reader.ReadString('\n')
banner = strings.TrimSpace(banner)
applog.Printf("pgxl: connected %s → %s, banner=%q", conn.LocalAddr(), conn.RemoteAddr(), banner)
if strings.Contains(banner, "AUTH") {
if c.password == "" {
applog.Printf("pgxl: device requires AUTH but no remote code set (Settings → Amplifier)")
} else if err := c.authLocked(); err != nil {
c.conn.Close()
c.conn, c.reader = nil, nil
return err
}
}
return nil
}
// authLocked authenticates the remote link. Must be called with c.mu held
// (during ensureConnected). 4O3A boxes want "auth code=<pw>" and reply
// "R<seq>|<hex>|" with an EMPTY message — hex "0" means accepted, so the response
// CODE decides, not the text. The device also rejects the FIRST attempt (R|FF)
// and accepts a retry, so resend a few times before giving up.
func (c *Client) authLocked() error {
var lastHex string
for try := 1; try <= 4; try++ {
id := c.cmdID.Add(1)
_ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout))
if _, err := fmt.Fprintf(c.conn, "C%d|auth code=%s\n", id, c.password); err != nil {
return err
}
_ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout))
line, err := c.reader.ReadString('\n')
if err != nil {
return err
}
line = strings.TrimSpace(line)
applog.Printf("pgxl: auth reply=%q (try %d)", line, try)
hex, msg := "", ""
if p := strings.SplitN(line, "|", 3); len(p) >= 2 {
hex = strings.TrimSpace(p[1])
if len(p) == 3 {
msg = strings.TrimSpace(p[2])
}
}
// hex "0" is the standard accept; also treat an explicit OK message as success.
if hex == "0" || (msg != "" && strings.Contains(strings.ToLower(msg), "ok")) {
applog.Printf("pgxl: authenticated")
return nil
}
lastHex = hex
}
return fmt.Errorf("powergenius: authentication failed after 4 tries (R|%s|) — check the remote code", lastHex)
}
func (c *Client) dropConn() {
c.mu.Lock()
if c.conn != nil {
@@ -226,9 +318,10 @@ func (c *Client) parse(resp string) {
c.statusMu.Lock()
c.status.Connected = true
c.status.LastError = ""
// Log each DISTINCT status payload once: the PGXL's field set isn't fully
// documented, so this is how we learn the real key for e.g. operate/standby.
if data != c.lastRaw {
// Log the first REAL status frame (one that carries "key=value" fields) so the
// field set is visible — even if an earlier reply was junk like "Unauthorized"
// (which would otherwise latch lastRaw and hide the real frame).
if strings.Contains(data, "=") && !strings.Contains(c.lastRaw, "=") {
c.lastRaw = data
applog.Printf("pgxl: status raw=%q", data)
}
@@ -252,7 +345,50 @@ func (c *Client) parse(resp string) {
c.status.FanMode = dev
case "temp":
c.status.Temperature, _ = strconv.ParseFloat(kv[1], 64)
case "fwd":
if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
c.status.FwdW = dbmToWatts(v)
}
case "peakfwd":
if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
c.status.PeakW = dbmToWatts(v)
}
case "swr":
if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
c.status.Vswr = returnLossToVswr(v)
}
case "id":
c.status.Id, _ = strconv.ParseFloat(kv[1], 64)
case "peakid":
c.status.PeakId, _ = strconv.ParseFloat(kv[1], 64)
}
}
c.statusMu.Unlock()
}
// dbmToWatts converts a power reading in dBm to watts (0 dBm = 1 mW). The amp
// reports power that way — "fwd=60.5" is 1122 W, not 60 W.
func dbmToWatts(dbm float64) float64 {
if dbm <= 0 {
return 0
}
return math.Pow(10, (dbm-30)/10)
}
// returnLossToVswr converts the amp's "swr" field — a return loss in dB, sent
// negative for a good match — into the VSWR ratio an operator reads.
func returnLossToVswr(swrDb float64) float64 {
rl := math.Abs(swrDb)
if rl <= 0 {
return 0
}
rho := math.Pow(10, -rl/20)
if rho >= 1 {
return 0
}
vswr := (1 + rho) / (1 - rho)
if vswr > 99.9 || math.IsInf(vswr, 0) || math.IsNaN(vswr) {
return 0
}
return vswr
}
+110
View File
@@ -1461,6 +1461,51 @@ func (r *Repo) IterateByIDs(ctx context.Context, ids []int64, fn func(QSO) error
return rows.Err()
}
// BandSlotQSOs returns every contact on one band that belongs to a slot of the
// entry matrix: the exact callsign, or any callsign in the same DXCC entity.
// Mode is NOT filtered here — the class (phone / CW / digital) is a derived
// notion the caller owns, so it filters the (small) band-scoped result itself.
//
// The DXCC arm matches the stored dxcc column only. A QSO logged before the
// entity was resolved has a NULL there and is genuinely unattributed; guessing
// it back from the callsign here would disagree with the matrix above, which is
// built from the same column.
func (r *Repo) BandSlotQSOs(ctx context.Context, callsign string, dxccNum int, band string) ([]QSO, error) {
call := upperTrim(callsign)
b := strings.TrimSpace(band)
if b == "" || (call == "" && dxccNum <= 0) {
return []QSO{}, nil
}
where := "band = ?"
args := []any{b}
switch {
case call != "" && dxccNum > 0:
where += " AND (callsign = ? OR dxcc = ?)"
args = append(args, call, dxccNum)
case call != "":
where += " AND callsign = ?"
args = append(args, call)
default:
where += " AND dxcc = ?"
args = append(args, dxccNum)
}
rows, err := r.db.QueryContext(ctx,
`SELECT `+selectCols+` FROM qso WHERE `+where+` ORDER BY qso_date DESC, id DESC LIMIT 500`, args...)
if err != nil {
return nil, fmt.Errorf("query band slot: %w", err)
}
defer rows.Close()
out := []QSO{}
for rows.Next() {
q, err := scanQSO(rows)
if err != nil {
return nil, err
}
out = append(out, q)
}
return out, rows.Err()
}
// WorkedBefore summarises prior contacts at two granularities:
// - by exact callsign → shown as "this call worked N×"
// - by DXCC entity → drives NEW ONE / NEW BAND / NEW MODE / NEW SLOT
@@ -2061,6 +2106,41 @@ func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty stri
return out, rows.Err()
}
// CountyWorked reports whether one US county has already been worked.
//
// It exists so the entry panel can flag a new county without loading the whole
// worked-county set on every keystroke — the county arrives from a QRZ lookup,
// which is exactly when the operator is deciding whether the contact is worth
// chasing. The comparison goes through keyFn on both sides (rather than a SQL
// equality) because logged county spellings vary — "Los Angeles" against
// "LOS ANGELES, CA" — and the key function is what already resolves that
// everywhere else.
func (r *Repo) CountyWorked(ctx context.Context, state, cnty string, keyFn func(state, cnty string) string) (bool, error) {
want := keyFn(state, cnty)
if want == "" {
return false, nil
}
// Narrowed to the one state: a few dozen rows, not the whole logbook.
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT COALESCE(state,''), COALESCE(cnty,'') FROM qso
WHERE dxcc IN (291,110,6) AND cnty IS NOT NULL AND cnty != ''
AND upper(COALESCE(state,'')) = upper(?)`, strings.TrimSpace(state))
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var st, cn string
if err := rows.Scan(&st, &cn); err != nil {
return false, err
}
if keyFn(st, cn) == want {
return true, nil
}
}
return false, rows.Err()
}
// WorkedCallBandModeKeys returns the set of every worked "CALL|BAND|MODE" key
// (all upper-cased), loaded in one pass. It backs the in-memory worked-index the
// alert engine checks per cluster spot — a DB query per spot cannot keep up with
@@ -2083,6 +2163,36 @@ func (r *Repo) WorkedCallBandModeKeys(ctx context.Context) (map[string]struct{},
return out, rows.Err()
}
// WorkedCallSlotKeys backs the cluster "already worked only on the same slot"
// option. It returns each worked slot keyed BOTH as "CALL|band" (mode-agnostic,
// for a spot whose mode couldn't be inferred) and "CALL|band|MODE" with the mode
// run through normMode — so when digital grouping is on, FT8/FT4/RTTY fold into
// one "DIG" and a call worked on 20m FT8 also matches a 20m FT4 spot. Call is
// upper-cased and band lower-cased to match the spot keys ClusterSpotStatuses
// builds. normMode may be nil (no grouping).
func (r *Repo) WorkedCallSlotKeys(ctx context.Context, normMode func(string) string) (map[string]struct{}, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT upper(callsign), lower(band), upper(mode) FROM qso
WHERE callsign != '' AND band IS NOT NULL AND band != '' AND mode IS NOT NULL AND mode != ''`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]struct{}, 4096)
for rows.Next() {
var c, b, m string
if err := rows.Scan(&c, &b, &m); err != nil {
return nil, err
}
if normMode != nil {
m = normMode(m)
}
out[c+"|"+b] = struct{}{}
out[c+"|"+b+"|"+m] = struct{}{}
}
return out, rows.Err()
}
// WorkedPOTARefs returns the set of POTA park references already worked
// (upper-cased). A QSO's pota_ref may hold several comma-separated parks
// (an n-fer); each is added separately.
+140 -4
View File
@@ -7,6 +7,10 @@
// - KMTronic LAN 8-relay WEB board — 8 relays. Control: GET /FF{rr}{ss}
// (rr = 01..08, ss = 01 on / 00 off); status: GET /status.xml with
// <relay1>..<relay8> (relay0 is reserved). Optional HTTP basic auth.
// - Dingtian IOT relay (DTWONDER) — 2/4/8/16/24/32 relays over its HTTP GET
// CGI. Control: GET /relay_cgi.cgi?type=0&relay=N&on=1&time=0&pwd=P&;
// status: GET /relay_cgi_load.cgi. Both answer &-separated fields.
// (IOT Relay Programming Manual V1.9.8.1, §3.)
//
// A Device presents the same surface to the app regardless of wire protocol.
package relaydev
@@ -57,6 +61,22 @@ func get(ctx context.Context, url, user, pass string) ([]byte, error) {
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 ────────────────────────────────────────────────────
type webswitch struct {
@@ -78,7 +98,7 @@ func (w *webswitch) Set(ctx context.Context, relay int, on bool) error {
if 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
}
@@ -89,7 +109,7 @@ func (w *webswitch) Status(ctx context.Context) ([]bool, error) {
sel.WriteString(strconv.Itoa(i))
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 {
return nil, err
}
@@ -136,7 +156,7 @@ func (k *kmtronic) Set(ctx context.Context, relay int, on bool) error {
state = "01"
}
// 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
}
@@ -150,7 +170,7 @@ type kmStatus struct {
}
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 {
return nil, err
}
@@ -173,3 +193,119 @@ func (k *kmtronic) Status(ctx context.Context) ([]bool, error) {
}
return out, nil
}
// ── Dingtian IOT relay (DTWONDER) ──────────────────────────────────────
//
// The board speaks several protocols (Modbus, MQTT, CoAP, its own binary); we
// use the HTTP GET CGI, which needs no connection state and matches how the
// other network boards here are driven.
//
// Both endpoints answer &-separated fields, first one 0 on success:
//
// /relay_cgi_load.cgi → &0&4&1&0&1&0&
// result, count, r1…rN
// /relay_cgi.cgi?type=0&relay=0&on=1&time=0&pwd=0& → &0&0&0&1&0&
// result, type, relay, on, time
//
// NOTE the relay index in the URL is ZERO-based (relay=0 is relay 1), while the
// Device interface is 1-based like every other board here.
type dingtian struct {
host string
session string // optional: the board can require "Cookie: session=<id>"
pwd string // CGI password, 09999; "0" (or blank) when none is set
count int
}
// NewDingtian builds a Dingtian IOT relay client. session is the HTTP session ID
// when the board has "HTTP Session" enabled (blank otherwise); pwd is its relay
// password (blank or "0" when none).
func NewDingtian(host, session, pwd string, count int) Device {
if count <= 0 {
count = 2
}
if strings.TrimSpace(pwd) == "" {
pwd = "0"
}
return &dingtian{host: host, session: strings.TrimSpace(session), pwd: strings.TrimSpace(pwd), count: count}
}
func (d *dingtian) Count() int { return d.count }
func (d *dingtian) Close() error { return nil } // stateless HTTP, nothing to release
// getCGI issues the GET with the session cookie the board may require.
func (d *dingtian) getCGI(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if d.session != "" {
req.Header.Set("Cookie", "session="+d.session)
}
resp, err := httpClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return body, nil
}
// dtFields splits an &-separated CGI reply into its fields, dropping the empty
// ones the leading and trailing "&" produce.
func dtFields(body []byte) []string {
var out []string
for _, f := range strings.Split(strings.TrimSpace(string(body)), "&") {
if f = strings.TrimSpace(f); f != "" {
out = append(out, f)
}
}
return out
}
func (d *dingtian) Set(ctx context.Context, relay int, on bool) error {
if relay < 1 || relay > d.count {
return fmt.Errorf("relay %d out of range 1..%d", relay, d.count)
}
state := 0
if on {
state = 1
}
// type=0 is plain ON/OFF (1 = jogging, 2 = delay, 3 = flash, 4 = toggle),
// and time is then unused.
body, err := d.getCGI(ctx, fmt.Sprintf("%s/relay_cgi.cgi?type=0&relay=%d&on=%d&time=0&pwd=%s&",
relayBase(d.host), relay-1, state, d.pwd))
if err != nil {
return err
}
// A wrong password or session answers 200 with a non-zero result (e.g.
// "&302&/&"), so the HTTP status alone does not tell us it worked.
if f := dtFields(body); len(f) == 0 || f[0] != "0" {
return fmt.Errorf("dingtian: refused (%s) — check the relay password and the HTTP session ID",
strings.TrimSpace(string(body)))
}
return nil
}
func (d *dingtian) Status(ctx context.Context) ([]bool, error) {
body, err := d.getCGI(ctx, fmt.Sprintf("%s/relay_cgi_load.cgi", relayBase(d.host)))
if err != nil {
return nil, err
}
f := dtFields(body)
if len(f) < 2 || f[0] != "0" {
return nil, fmt.Errorf("dingtian: bad status reply %q", strings.TrimSpace(string(body)))
}
// The board reports its own relay count; trust it over the configured one so
// a mis-set channel count doesn't silently hide relays.
if n, e := strconv.Atoi(f[1]); e == nil && n > 0 && n <= 32 {
d.count = n
}
out := make([]bool, d.count)
for i := 0; i < d.count && i+2 < len(f); i++ {
out[i] = f[i+2] == "1"
}
return out, nil
}
+89
View File
@@ -99,3 +99,92 @@ func TestKMTronicSetURL(t *testing.T) {
}
_ = d.Set(context.Background(), 1, false) // → /FF0100
}
// Dingtian — the wire examples come straight from the IOT Relay Programming
// Manual V1.9.8.1 §3.1/§3.2. The two traps pinned here: the URL relay index is
// ZERO-based while the Device interface is 1-based, and a refusal (bad password
// or session) answers HTTP 200 with a non-zero first field.
func TestDingtianStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/relay_cgi_load.cgi" {
t.Errorf("unexpected status path %q", r.URL.Path)
}
// Manual's own example: ok, 4 relays, 1 on, 2 off, 3 on, 4 off.
_, _ = w.Write([]byte("&0&4&1&0&1&0&"))
}))
defer srv.Close()
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "", "", 2)
st, err := d.Status(context.Background())
if err != nil {
t.Fatal(err)
}
// The board said 4 relays even though 2 were configured — its count wins.
want := []bool{true, false, true, false}
if len(st) != len(want) {
t.Fatalf("got %d relays, want %d", len(st), len(want))
}
for i := range want {
if st[i] != want[i] {
t.Errorf("relay %d = %v, want %v", i+1, st[i], want[i])
}
}
if d.Count() != 4 {
t.Errorf("Count() = %d, want 4 (taken from the board)", d.Count())
}
}
func TestDingtianSetUsesZeroBasedIndexAndSession(t *testing.T) {
var gotQuery, gotCookie string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.RawQuery
gotCookie = r.Header.Get("Cookie")
_, _ = w.Write([]byte("&0&0&2&1&0&"))
}))
defer srv.Close()
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "12345678", "4660", 4)
if err := d.Set(context.Background(), 3, true); err != nil {
t.Fatal(err)
}
if !strings.Contains(gotQuery, "relay=2") {
t.Errorf("relay 3 must go out as relay=2 (zero-based); query was %q", gotQuery)
}
if !strings.Contains(gotQuery, "on=1") || !strings.Contains(gotQuery, "type=0") ||
!strings.Contains(gotQuery, "pwd=4660") {
t.Errorf("unexpected query %q", gotQuery)
}
if gotCookie != "session=12345678" {
t.Errorf("session cookie = %q, want session=12345678", gotCookie)
}
}
func TestDingtianSetRefusalIsAnError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
// Manual §3.4.4: a bad session still answers 200 OK.
_, _ = w.Write([]byte("&302&/&"))
}))
defer srv.Close()
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "", "", 2)
if err := d.Set(context.Background(), 1, true); err == nil {
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)
}
}
}
+15 -4
View File
@@ -36,6 +36,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
@@ -59,6 +60,13 @@ type Server struct {
ln net.Listener
conns map[net.Conn]struct{}
closed bool
// ptt mirrors the last PTT state a client commanded via set_ptt. WSJT-X/JTDX
// poll get_ptt DURING transmit to confirm the rig is keyed; if get_ptt reads
// RX they conclude PTT failed and abort the over after a second or two. We
// don't read PTT back from every backend, so echo what the client last set —
// always consistent with its own command, and enough to satisfy the check.
ptt atomic.Bool
}
func New(port int, rig Rig, logf func(string, ...any)) *Server {
@@ -228,10 +236,12 @@ func (s *Server) handle(line string) (resp string, quit bool) {
return rprt(0), false
case "t", "\\get_ptt":
// We do not read PTT back from every backend, and answering "transmitting"
// wrongly would make a client hold off for ever. Reporting RX is the safe
// direction: the worst case is a client that transmits when we said it
// could, which is what it was going to do anyway.
// Echo the last commanded PTT state. WSJT-X/JTDX poll this WHILE
// transmitting to confirm the rig is keyed; answering a blanket "0" (RX)
// made them decide PTT had failed and abort the over after ~1-2 s.
if s.ptt.Load() {
return "1\n", false
}
return "0\n", false
case "T", "\\set_ptt":
if len(args) < 1 {
@@ -242,6 +252,7 @@ func (s *Server) handle(line string) (resp string, quit bool) {
s.log("rigctld: set_ptt %v failed: %v", on, err)
return rprt(-9), false
}
s.ptt.Store(on)
return rprt(0), false
case "v", "\\get_vfo":
+22
View File
@@ -138,6 +138,28 @@ func TestHandleReportsBackendFailure(t *testing.T) {
}
}
// get_ptt must echo the last commanded PTT state. WSJT-X/JTDX poll get_ptt while
// transmitting to confirm the rig is keyed; a blanket "0" made them decide PTT
// had failed and abort the over after a second or two.
func TestGetPTTEchoesSetPTT(t *testing.T) {
s := New(0, &fakeRig{}, nil)
if got, _ := s.handle("t"); got != "0\n" {
t.Fatalf("initial get_ptt = %q, want 0", got)
}
if got, _ := s.handle("T 1"); got != "RPRT 0\n" {
t.Fatalf("set_ptt 1 = %q, want RPRT 0", got)
}
if got, _ := s.handle("t"); got != "1\n" {
t.Fatalf("get_ptt after T 1 = %q, want 1 — client would abort TX", got)
}
if got, _ := s.handle("T 0"); got != "RPRT 0\n" {
t.Fatalf("set_ptt 0 = %q, want RPRT 0", got)
}
if got, _ := s.handle("t"); got != "0\n" {
t.Fatalf("get_ptt after T 0 = %q, want 0", got)
}
}
// dump_state is parsed POSITIONALLY by Hamlib clients: WSJT-X reads the first
// line as the protocol version and refuses to continue if the block is short or
// misshapen. Pinning its shape is what stops a well-meaning edit from silently
+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
}
+141 -26
View File
@@ -33,13 +33,11 @@ const (
cmdOperate byte = 0x0D // toggles STANDBY ↔ OPERATE (confirmed on hw)
cmdStatus byte = 0x90 // request the status string
// Best-guess keystroke codes reconstructed from the APG command table after
// correcting the PDF's note-wrap misalignment (anchored to the confirmed
// OPERATE=0x0D): OFF=0x0A, POWER=0x0B. The POWER key doubles as power-on (when
// the amp is off) and the output-level cycle L→M→H (when it's on) — same as the
// single physical POWER button. To be verified on hardware.
cmdOff byte = 0x0A // OFF key — switch the amplifier off
cmdPower byte = 0x0B // POWER key — turn on / cycle output power level
// From the official APG rev 1.1 command table. Note POWER does NOT switch a
// sleeping amp on (that is the RTS/DTR wake, see PowerOn) — on a running amp
// it cycles the output level L→M→H, which is all we use it for.
cmdOff byte = 0x0A // SWITCH OFF key — how PowerOff switches the amp off
cmdPower byte = 0x0B // POWER key — cycles the output power level
syncHost = 0x55
syncAmp = 0xAA
@@ -47,11 +45,16 @@ const (
dialTimeout = 5 * time.Second
ioTimeout = 3 * time.Second
pollEvery = 800 * time.Millisecond
// How long a modem line is held LOW before being raised again, to give the
// amp's edge-triggered remote-on input a transition it can see.
wakePulse = time.Second
)
// Status is the decoded amplifier state for the UI.
type Status struct {
Connected bool `json:"connected"`
Transport string `json:"transport"` // "serial" | "tcp" — the UI enables power-ON on serial even when asleep
LastError string `json:"last_error,omitempty"`
Model string `json:"model,omitempty"` // "20K" / "13K"
Operate bool `json:"operate"` // true = OPERATE, false = STANDBY
@@ -91,6 +94,8 @@ type Client struct {
stop chan struct{}
running bool
lastConnErr string // last connect failure logged (log only on change)
}
func New(cfg Config) *Client {
@@ -123,7 +128,13 @@ func (c *Client) Stop() {
func (c *Client) GetStatus() Status {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
return c.status
s := c.status
if c.cfg.Transport == "tcp" {
s.Transport = "tcp"
} else {
s.Transport = "serial"
}
return s
}
func (c *Client) setErr(err error) {
@@ -146,27 +157,91 @@ func (c *Client) Operate(on bool) error {
// ToggleOperate flips STANDBY/OPERATE unconditionally.
func (c *Client) ToggleOperate() error { return c.sendCmd(cmdOperate) }
// PowerOn turns the amplifier on. Per the SPE manual this is NOT a data command
// but a hardware DTR pulse on the serial line (the "Remote_ON" pin), which needs
// the special SPE cable — so it only applies to the serial transport. Over TCP
// there is no DTR line and power-on isn't possible remotely.
// PowerOn switches the amplifier on with a RISING EDGE on RTS, then on DTR.
//
// Confirmed on a live 1.3K-FA. The remote-on input is EDGE-triggered, which is
// why holding the lines high does nothing: a plain port open already leaves both
// high (the serial library's default), so there is no transition left to give.
// Hence low → pause → high on each line, in that order. The keystroke route is a
// dead end — a switched-off amp does not answer its UART at all, and the POWER
// key (0x0B) merely cycles L/M/H on an amp that is already running.
//
// Serial only: over TCP there are no modem lines. The port is (re)opened first,
// since the poll loop has dropped the connection to a sleeping amp long before
// the user clicks ON.
func (c *Client) PowerOn() error {
if c.cfg.Transport == "tcp" {
return fmt.Errorf("power-on needs the serial RTS/DTR lines; not available over a network bridge")
}
// On an amp that is already awake there is nothing to wake.
if c.GetStatus().Connected {
return nil
}
// The poll goroutine may still be inside a blocking read on the old handle;
// Windows keeps the port "busy" until that read times out (ioTimeout). Retry
// the open for a little longer than that.
var err error
deadline := time.Now().Add(ioTimeout + 3*time.Second)
for {
if err = c.ensureConn(); err == nil || time.Now().After(deadline) {
break
}
time.Sleep(200 * time.Millisecond)
}
if err != nil {
applog.Printf("spe: power ON failed — cannot open %s: %v", c.cfg.ComPort, err)
return fmt.Errorf("power-on: %w", err)
}
c.mu.Lock()
defer c.mu.Unlock()
sp, ok := c.conn.(serial.Port)
c.mu.Unlock()
if !ok {
return fmt.Errorf("power-on needs the serial DTR line (special SPE cable); not available over network")
return fmt.Errorf("power-on needs a serial connection")
}
// A single positive pulse (~1.2s) on DTR, as the manual describes.
if err := sp.SetDTR(true); err != nil {
return err
pulse := func(set func(bool) error) error {
if err := set(false); err != nil {
return err
}
time.Sleep(wakePulse)
return set(true)
}
time.Sleep(1200 * time.Millisecond)
return sp.SetDTR(false)
if err = pulse(sp.SetRTS); err == nil {
err = pulse(sp.SetDTR)
}
// Booting, the amp re-enumerates its USB interface, which leaves this handle
// pointing at a device that no longer exists — the amp came up and OpsLog
// still read "offline". Drop it; the poll loop reopens a fresh one as soon as
// the port is back.
c.mu.Lock()
c.dropLocked()
c.mu.Unlock()
applog.Printf("spe: power ON — RTS then DTR pulsed on %s (err=%v)", c.cfg.ComPort, err)
return err
}
// PowerOff presses the OFF key (switches the amp off).
func (c *Client) PowerOff() error { return c.sendCmd(cmdOff) }
// PowerOff presses the SWITCH OFF key (0x0A) — the amp is running, so it hears
// its UART. Dropping DTR then RTS switches it off too (it is the mirror of the
// wake, and does work when done by hand), but back-to-back from one click it
// did not, while this keystroke has never once failed. The amp then stays off:
// the poll loop keeps reopening the port with both lines high and that has
// never woken it — only the deliberate low→high sequence in PowerOn does.
func (c *Client) PowerOff() error {
err := c.sendCmd(cmdOff)
applog.Printf("spe: power OFF — SWITCH OFF key sent (err=%v)", err)
// Drop the link and mark the amp offline at once, exactly as a fresh start
// would see it. The amp stops answering its UART once off, but the connection
// that was already open BEFORE the off keystroke kept reading as "connected"
// (stale/queued frames), so the UI never showed OFF and PowerOn — which only
// wakes a DISCONNECTED amp — did nothing until the operator restarted OpsLog.
// The poll loop reopens the port on its next tick; a plain reopen never wakes a
// switched-off amp (only PowerOn's RTS/DTR edge does), so it stays offline.
time.Sleep(100 * time.Millisecond) // let the 6-byte OFF command flush before we close
c.mu.Lock()
c.dropLocked()
c.mu.Unlock()
c.setErr(fmt.Errorf("switched off"))
return err
}
// SetPowerLevel cycles the output power level (L/M/H) to the requested one by
// tapping the POWER key (0x0B) and WAITING for the amp to actually report the new
@@ -206,9 +281,16 @@ func (c *Client) pollLoop() {
return
case <-t.C:
if err := c.ensureConn(); err != nil {
// Logged once per distinct message: "the amp is on but OpsLog
// says offline" was impossible to diagnose without the reason.
if msg := err.Error(); msg != c.lastConnErr {
c.lastConnErr = msg
applog.Printf("spe: connect %s failed: %s", c.cfg.ComPort, msg)
}
c.setErr(fmt.Errorf("connect: %w", err))
continue
}
c.lastConnErr = ""
// The amp streams status frames faster than one per poll, so a backlog
// builds up and we'd read stale frames (the display lagged ~15s behind
// the real amp). Flush anything pending, THEN request + read exactly one
@@ -239,16 +321,48 @@ func (c *Client) ensureConn() error {
nc, err = net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout)
rwc = nc
} else {
rwc, err = serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
var sp serial.Port
// Plain open: both modem lines come up high (the library's default), which
// is what the interface needs to talk. That is not enough to switch the amp
// on — the remote-on input wants the deliberate low→high sequence PowerOn
// sends — so reconnecting never wakes an amplifier the operator switched
// off, and forcing either line low here would switch a running one off.
sp, err = serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
if err == nil {
// Without this, a read on a sleeping amp blocks forever inside the
// Windows serial driver — the poll goroutine survived the window and
// the process took ~a minute to die.
_ = sp.SetReadTimeout(ioTimeout)
rwc = sp
}
}
if err != nil {
return err
}
c.conn = rwc
c.r = bufio.NewReader(rwc)
if sp, ok := rwc.(serial.Port); ok {
// The driver reports a read timeout as "0 bytes, no error", which bufio
// spins on; surface it as a real error so the poll loop drops and retries.
c.r = bufio.NewReader(serialTimeoutReader{sp})
} else {
c.r = bufio.NewReader(rwc)
}
return nil
}
// serialTimeoutReader converts the serial driver's timeout convention (n=0,
// err=nil once SetReadTimeout expires) into an explicit error, so a bufio
// reader fails fast instead of retrying an amp that is asleep.
type serialTimeoutReader struct{ p serial.Port }
func (s serialTimeoutReader) Read(b []byte) (int, error) {
n, err := s.p.Read(b)
if n == 0 && err == nil {
return 0, fmt.Errorf("spe: read timeout")
}
return n, err
}
func (c *Client) dropLocked() {
if c.conn != nil {
c.conn.Close()
@@ -361,9 +475,10 @@ func (c *Client) decodeCSV(payload string) {
c.statusMu.Lock()
defer c.statusMu.Unlock()
// Log the raw status frame ONCE per change, so field alignment can be verified
// against real hardware (the doc's 19-field layout may differ per firmware).
if payload != c.lastRaw {
// One raw frame per session is enough to verify field alignment against real
// hardware the frame carries live meter values, so logging each change
// meant logging nearly every frame.
if c.lastRaw == "" {
c.lastRaw = payload
applog.Printf("spe: status raw=%q fields=%d", payload, len(f))
}
+11 -1
View File
@@ -61,6 +61,7 @@ type Status struct {
FwdDbm float64 `json:"fwd_dbm"` // forward power [dBm], as reported
FwdW float64 `json:"fwd_w"` // forward power [W], derived from dBm
PeakW float64 `json:"peak_w"` // the device's own peak forward power [W] — what the meters show
SwrDb float64 `json:"swr_db"` // return loss [dB] as reported (negative = good match)
Vswr float64 `json:"vswr"` // VSWR ratio, derived from swr_db (1.0 = perfect)
@@ -405,7 +406,9 @@ func (c *Client) parse(resp string) {
if !strings.HasPrefix(data, "status") {
return
}
if data != c.lastRaw {
// One raw frame per session is enough to verify field alignment — the
// frames carry live meter values, so "log on change" logged every frame.
if c.lastRaw == "" {
c.lastRaw = data
applog.Printf("tunergenius: status raw=%q", data)
}
@@ -439,6 +442,13 @@ func (c *Client) applyStatus(data string) {
c.status.FwdDbm = v
c.status.FwdW = dbmToWatts(v)
}
// The device's OWN peak detector. A poll samples one instant of the envelope,
// so on SSB the plain "fwd" figure lands between syllables as often as on a
// peak — the widget read a few hundred watts on a kilowatt transmission. This
// is the number to display.
if v, ok := parseFloat(kv["peak"]); ok {
c.status.PeakW = dbmToWatts(v)
}
if v, ok := parseFloat(kv["swr"]); ok {
c.status.SwrDb = v
c.status.Vswr = returnLossToVswr(v)
+127 -40
View File
@@ -23,6 +23,12 @@ const (
ubReadTimeout = 4 * time.Second // was 1s — too tight for a remote link
ubKeepAlive = 15 * time.Second // OS-level TCP keepalive
ubMaxPollTimeout = 3 // consecutive read timeouts tolerated before reconnecting
// How long a just-commanded direction is trusted AFTER the motors have stopped
// but before the antenna's status confirms it. The timer is held off entirely
// while the motors are still moving, so this is only the grace period for the
// confirmation poll to arrive once the elements have settled — generous, because
// over a remote link that poll lags by several seconds.
ubPendingDirGrace = 8 * time.Second
)
// Protocol constants
@@ -82,8 +88,22 @@ type Client struct {
// yet (Frequency==0) — otherwise the deadband is bypassed and every small QSY
// re-tunes the motors.
lastSetKHz int
// moveCmdAt is when a move (frequency or direction) was last COMMANDED. The
// status poll runs every couple of seconds, so without this the "moving" flag
// — which drives the UI indicator and the Flex TX-inhibit — appeared up to a
// poll late even though the elements start moving at once. GetStatus reports
// motion during a short window after a command so both react immediately.
moveCmdAt time.Time
}
// ubMoveOptimisticWindow is how long after a commanded move GetStatus reports
// "moving" before the status poll has had a chance to read the real motor state.
// It only needs to bridge one poll interval; once a poll sees real motion, that
// takes over. Bounded, so if the antenna never reports motion the flag still
// clears rather than latching the TX-inhibit on for ever.
const ubMoveOptimisticWindow = 3 * time.Second
// LastSetKHz returns the frequency (kHz) most recently commanded to the antenna,
// or 0 if none yet.
func (c *Client) LastSetKHz() int {
@@ -221,12 +241,30 @@ func (c *Client) pollLoop() {
}
c.statusMu.Lock()
// Keep a just-commanded direction until the antenna reports it.
// Keep a just-commanded direction until the antenna actually reports it.
// Over a remote link the confirmation arrives several seconds after the
// command — the motors flip the elements first — so the old fixed 4 s
// timeout expired WHILE the change was still in flight, and the stale poll
// reverted the UI to the old pattern even though the antenna was on its way
// to the new one. Now: while the motors are still moving the change is in
// progress, so hold the commanded pattern and keep resetting the timer;
// only once the motors have stopped does the short grace window run, giving
// the confirmation poll time to land. The poll only wins if the motors are
// idle AND the antenna still reports a different pattern past that window —
// i.e. the command genuinely did not take.
if c.pendingDirSet {
if time.Since(c.pendingDirAt) > 4*time.Second || status.Direction == c.pendingDir {
c.pendingDirSet = false
} else {
status.Direction = c.pendingDir
if status.MotorsMoving != 0 {
c.pendingDirAt = time.Now() // still repositioning — don't start the grace timer
}
switch {
case status.Direction == c.pendingDir:
c.pendingDirSet = false // confirmed by the antenna
case time.Since(c.pendingDirAt) > ubPendingDirGrace:
c.pendingDirSet = false // motors idle, still unconfirmed → accept the poll
log.Printf("Ultrabeam: antenna never confirmed direction %d (reports %d) — dropping the hold",
c.pendingDir, status.Direction)
default:
status.Direction = c.pendingDir // still changing, or within the grace window
}
}
c.lastStatus = status
@@ -246,7 +284,19 @@ func (c *Client) GetStatus() (*Status, error) {
return &Status{Connected: false}, nil
}
return c.lastStatus, nil
// Copy so the optimistic-motion tweak below never mutates the cached status
// the poll goroutine owns.
st := *c.lastStatus
// Optimistic motion: right after a commanded move, report "moving" until a
// status poll can read the real motor state (~one poll interval). The elements
// start moving the instant the operator clicks a band/pattern, so this makes
// the UI indicator and the Flex TX-inhibit react immediately instead of a poll
// later. Real polled motion takes over once seen; the window is bounded so the
// flag can't latch the inhibit on for ever.
if st.Connected && st.MotorsMoving == 0 && !c.moveCmdAt.IsZero() && time.Since(c.moveCmdAt) < ubMoveOptimisticWindow {
st.MotorsMoving = 1 // sentinel: optimistically moving (read only as != 0)
}
return &st, nil
}
// getNextSeq returns the next sequence number
@@ -277,10 +327,9 @@ func quoteByte(b byte) []byte {
return []byte{b}
}
// buildPacket creates a complete packet with checksum and escaping
func (c *Client) buildPacket(cmd byte, data []byte) []byte {
seq := c.getNextSeq()
// buildPacket creates a complete packet with checksum and escaping. The seq is
// supplied by the caller so sendCommand can match the reply against it.
func (c *Client) buildPacket(seq, cmd byte, data []byte) []byte {
// Calculate checksum on unquoted data
payload := append([]byte{seq, cmd}, data...)
chk := calculateChecksum(payload)
@@ -365,39 +414,27 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
return nil, fmt.Errorf("not connected")
}
// Build and send packet
packet := c.buildPacket(cmd, data)
// Flush anything already sitting in the stream before we send. The antenna
// does NOT echo our sequence number — its replies carry their own counter — so
// a reply cannot be matched to its request. Instead we keep the exchange 1:1:
// a reply left behind by an earlier timed-out command is discarded here, so
// the reply we read next belongs to THIS command. Reading a stale reply as the
// current one crossed STATUS with READ_BANDS/PROGRESS — phantom frequencies (a
// spurious follow-loop re-tune), dropped connections and wrong element lengths.
c.drainStale()
_, err := c.conn.Write(packet)
if err != nil {
seq := c.getNextSeq()
packet := c.buildPacket(seq, cmd, data)
if _, err := c.conn.Write(packet); err != nil {
return nil, fmt.Errorf("failed to write: %w", err)
}
// Read reply with timeout (generous — tolerates remote-link latency).
// Read the reply with a timeout generous enough for a remote link.
c.conn.SetReadDeadline(time.Now().Add(ubReadTimeout))
// Read until we get a complete packet
var buffer []byte
for {
b, err := c.reader.ReadByte()
if err != nil {
return nil, fmt.Errorf("failed to read: %w", err)
}
buffer = append(buffer, b)
// Check if we have a complete packet
if b == ETX && len(buffer) > 0 && buffer[0] == STX {
break
}
// Prevent infinite loop
if len(buffer) > 256 {
return nil, fmt.Errorf("packet too long")
}
buffer, err := c.readPacket()
if err != nil {
return nil, err
}
// Parse reply
_, replyCmd, payload, err := parsePacket(buffer)
if err != nil {
return nil, fmt.Errorf("failed to parse reply: %w", err)
@@ -426,6 +463,45 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
}
}
// drainStale discards any bytes already waiting in the stream — a reply left
// behind by a command that timed out. A short read deadline lets it consume what
// is there and stop quickly when the stream is clean. Caller holds connMu.
func (c *Client) drainStale() {
c.conn.SetReadDeadline(time.Now().Add(5 * time.Millisecond))
buf := make([]byte, 256)
for {
n, err := c.reader.Read(buf)
if n == 0 || err != nil {
return
}
}
}
// readPacket reads one complete STX…ETX frame, skipping any leading bytes until
// an STX so a partial/garbage remnant left in the stream can't derail the parse.
// STX/ETX/DLE are all high-bit (0xF5/0xFA/0xF6) and the escaping clears the MSB,
// so a raw ETX only ever appears as the real terminator. Caller holds connMu and
// has set a read deadline.
func (c *Client) readPacket() ([]byte, error) {
var buffer []byte
for {
b, err := c.reader.ReadByte()
if err != nil {
return nil, fmt.Errorf("failed to read: %w", err)
}
if len(buffer) == 0 && b != STX {
continue // resync to the start of a frame
}
buffer = append(buffer, b)
if b == ETX {
return buffer, nil
}
if len(buffer) > 256 {
return nil, fmt.Errorf("packet too long")
}
}
}
// queryStatus queries general status (command 1)
func (c *Client) queryStatus() (*Status, error) {
reply, err := c.sendCommand(CMD_STATUS, nil)
@@ -433,7 +509,13 @@ func (c *Client) queryStatus() (*Status, error) {
return nil, err
}
if len(reply) < 12 {
// An 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))
}
@@ -446,8 +528,12 @@ func (c *Client) queryStatus() (*Status, error) {
Direction: int(reply[6] & 0x0F),
OffState: (reply[7] & 0x02) != 0,
MotorsMoving: int(reply[9]),
FreqMin: int(reply[10]),
FreqMax: int(reply[11]),
}
if len(reply) > 10 {
status.FreqMin = int(reply[10])
}
if len(reply) > 11 {
status.FreqMax = int(reply[11])
}
return status, nil
@@ -513,6 +599,7 @@ func (c *Client) SetFrequency(freqKhz int, direction int) error {
c.statusMu.Lock()
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
c.lastSetKHz = freqKhz
c.moveCmdAt = time.Now() // start reporting motion at once — see ubMoveOptimisticWindow
if c.lastStatus != nil {
c.lastStatus.Direction = direction // reflect immediately
}
+94
View File
@@ -0,0 +1,94 @@
package ultrabeam
import (
"bufio"
"bytes"
"net"
"testing"
"time"
)
// sendCommand must flush any bytes already in the stream (a reply left by an
// earlier timed-out command) before reading, then return the reply to THIS
// command. The antenna does not echo our sequence number, so keeping the stream
// clean is the only way to stay in sync.
func TestSendCommandFlushesStaleThenReadsReply(t *testing.T) {
srvConn, cliConn := net.Pipe()
defer srvConn.Close()
defer cliConn.Close()
c := &Client{conn: cliConn, reader: bufio.NewReader(cliConn)}
srv := &Client{reader: bufio.NewReader(srvConn)}
errc := make(chan error, 1)
go func() {
// Leftover from a "previous" command still sitting in the stream. The Write
// blocks until drainStale consumes it, so it is guaranteed flushed before
// the real exchange.
if _, err := srvConn.Write(c.buildPacket(9, UB_OK, []byte{0xDE, 0xAD})); err != nil {
errc <- err
return
}
// Now serve the actual request.
req, err := srv.readPacket()
if err != nil {
errc <- err
return
}
seq, _, _, err := parsePacket(req)
if err != nil {
errc <- err
return
}
_, err = srvConn.Write(c.buildPacket(seq, UB_OK, []byte{0x11, 0x22, 0x33}))
errc <- err
}()
payload, err := c.sendCommand(CMD_STATUS, nil)
if err != nil {
t.Fatalf("sendCommand: %v", err)
}
if !bytes.Equal(payload, []byte{0x11, 0x22, 0x33}) {
t.Fatalf("payload = % X, want 11 22 33 — stale reply not flushed", payload)
}
if err := <-errc; err != nil {
t.Fatalf("server: %v", err)
}
}
// readPacket must resynchronise to the next STX, dropping any partial/garbage
// bytes left in the stream, so one corrupt frame can't misalign every frame
// after it.
func TestReadPacketResyncsToSTX(t *testing.T) {
frame := (&Client{}).buildPacket(5, UB_OK, []byte{0x01, 0x02})
stream := append([]byte{0x11, 0x22, 0x33}, frame...) // leading garbage, then a real frame
c := &Client{reader: bufio.NewReader(bytes.NewReader(stream))}
got, err := c.readPacket()
if err != nil {
t.Fatalf("readPacket: %v", err)
}
seq, cmd, payload, err := parsePacket(got)
if err != nil {
t.Fatalf("parsePacket: %v", err)
}
if seq != 5 || cmd != UB_OK || !bytes.Equal(payload, []byte{0x01, 0x02}) {
t.Fatalf("seq=%d cmd=%d payload=% X, want 5 / OK / 01 02", seq, cmd, payload)
}
}
// A commanded move must report motion immediately (before the next status poll),
// then fall back to the real motor state once the window elapses.
func TestOptimisticMotionAfterCommand(t *testing.T) {
c := &Client{lastStatus: &Status{Connected: true, MotorsMoving: 0}}
c.moveCmdAt = time.Now()
if st, _ := c.GetStatus(); st.MotorsMoving == 0 {
t.Fatal("just after a move command, GetStatus should report motion")
}
c.moveCmdAt = time.Now().Add(-ubMoveOptimisticWindow - time.Second)
if st, _ := c.GetStatus(); st.MotorsMoving != 0 {
t.Fatal("past the window with motors idle, GetStatus must report no motion")
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.22.4"
appVersion = "0.23.7"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.
+64 -9
View File
@@ -1,13 +1,47 @@
# Amplifiers and Switches
## PowerGenius XL (4O3A) amplifier
## Amplifiers
Direct TCP connection (Settings → PowerGenius):
Settings → **Amplifier** is a **list**: configure several amps (e.g. two SPEs
run in parallel), each with its own name and connection. Brands:
- **Operate / Standby** and **fault** display.
- **Fan-mode** selector (Standard / Contest / Broadcast).
- When a Flex is connected, the amp controls + its **FWD / ID / TEMP** meters
appear in the FlexRadio panel's Amplifier card. See [[FlexRadio]].
- **SPE Expert** (1.3K / 1.5K / 2K) — USB serial or an RS232-to-Ethernet
bridge. OPERATE/STANDBY, ON/OFF, Low/Mid/High power level, live status.
Switching the amplifier **on and off** uses the remote-on control lines, so
it needs the **USB (or RS-232) connection**, not a network bridge — the ON
button works even while the amplifier is off and reporting nothing.
- **ACOM** (500S / 600S / 700S / 1200S / 2020S) — serial or bridge.
OPERATE/STANDBY/OFF, live power / SWR / PA temperature / band / fan.
Power-ON works over serial when the cable wires the DTR/RTS pins.
- **PowerGenius XL** (4O3A) — direct TCP. Operate/Standby, fault display, and a
**fan-mode** selector (Standard / Contest / Broadcast).
Where the controls appear:
- An **amplifier card** in the FlexRadio panel and in **Station Control**:
OPERATE/STANDBY, ON/OFF, power level, output-power bar and live FWD /
drain-current / temperature meters. Several amps → one card each.
- A **status chip per amp** in the bottom bar: green ON AIR = OPERATE, orange =
STANDBY, red = offline. Clicking it toggles OPERATE/STANDBY.
- A **docked widget** (flame icon, next to the keyers): Operate, power ON/OFF,
the SPE power level and live watts / SWR / temperature, in the narrow column
format of the Tuner Genius widget. With several amplifiers configured, a
dropdown on the icon picks which one it shows — or **All**.
### Band-follow (amp on a second COM port)
An ACOM or SPE that normally polls the radio for its frequency can follow
OpsLog instead: OpsLog answers the amp's polls **as a Kenwood rig** on a second
COM port (Settings → Amplifier → frequency port). Useful when the real radio's
CAT port is already taken by OpsLog itself.
## Tuner Genius XL (4O3A)
Settings → Tuner Genius (IP only; port fixed at 9010). Live **SWR and forward
power** with peak-hold, **Tune / Bypass / Operate-Standby**, and the two
channels A/B (source, frequency, antenna) shown and click-selectable. Available
as a docked widget, a card in the FlexRadio panel and a card in Station
Control. Direct TCP — uses one of the box's connection slots.
## Antenna Genius (4O3A) antenna switch
@@ -16,8 +50,29 @@ Over TCP / GSCP — a docked **A/B antenna-switch** widget:
- One row per configured antenna, with a **Port A** and **Port B** button.
- Colours: green = selected on port A, blue = selected on port B, red (pulsing) =
that port is transmitting. Clicking a selected port deselects it (→ None).
- **Band filter** (funnel icon in the header): shows only the antennas configured
for the current band, like the native app. Toggle it off to see all antennas.
Antennas with an unknown band mask are always shown.
- **Band filter** (funnel icon): shows only the antennas configured for the
current band. Antennas with an unknown band mask are always shown.
Configure the host / password in Settings → Antenna Genius.
## Relay boards (Station Control)
Relay boards for antenna/accessory switching, with named buttons in Station
Control:
- **WebSwitch 1216H** (5 relays, LAN) and **KMTronic** 8-relay WEB (LAN, with
optional HTTP authentication).
- **Dingtian IOT relay** (2 to 32 relays, LAN or WiFi), driven over its HTTP
CGI. Give its IP address and pick the relay count; the **relay password** and
**session ID** fields are only needed if you enabled them on the board's own
web page — a factory board needs neither.
- **Denkovi** USB (4/8 relays, FT245) and generic **CH340 / LCUS** USB-serial
boards.
A Test-connection button and detection feedback live in the setup panel.
## Station Control
The Station Control tab is a dashboard of fixed-width panels that wrap to fill
the window — amplifier(s), tuner, relays, rotator, Ultrabeam… Drag a panel by
the grip on its left edge to reorder; the order is remembered.
+53 -16
View File
@@ -2,35 +2,72 @@
## CW keyer
A CW keyer with **macros** and F-key macros. The keyer **engine** is selectable
(Settings → CW Keyer):
A CW keyer with **macros** on F-keys (F1F9; empty slots are hidden). The keyer
**engine** is selectable (Settings → CW Keyer):
- **WinKeyer** — K1EL WK1/2/3 over a COM port.
- **WinKeyer** — K1EL WK1/2/3 over a COM port. The log names the generation
detected, and a byte-level trace can be enabled for bug reports.
- **Serial port (DTR=CW / RTS=PTT)** — OpsLog keys CW by toggling a serial
control line itself, the hardware method N1MM/WSJT-X use with a Yaesu SCU-17
or a generic keying interface. No WinKeyer needed. Choose the COM port, which
line carries CW, and *Invert keying* for interfaces that key backwards. This
is also the engine for an **FTDX10** (whose CAT refuses `KY`), on the rig's
second COM port.
- **Icom** — the radio's own keyer over CI-V, no extra hardware. Works over the
remote link too. (Requires the Icom CAT backend; set break-in to SEMI/FULL or
the rig keys the sidetone but stays in RX.)
remote link too. (Set break-in to SEMI/FULL or the rig keys the sidetone but
stays in RX.)
- **FlexRadio (CWX)** — keys CW straight over the SmartSDR connection, with
type-ahead and mid-send backspace.
- **Yaesu (rig keyer)** — the radio's internal keyer over CAT (`KY`), for the
FTDX101 / FT-991A / FT-710 family. See [[Yaesu]].
- **TCI** — for TCI backends.
**ESC** stops CW on whichever engine is active; typing a callsign aborts a
running macro. `<LOGQSO>` inside a macro logs the contact at that exact point.
**Auto-call** repeats a CQ with the gap you set measured *after* the message.
### ESM — Enter Sends Message
N1MM-style, for CW (Settings → CW Keyer). With the keyer on, **Enter** fires a
macro by QSO stage instead of logging: empty callsign → F1 (CQ); callsign
entered → F2 (report), focus jumps to RST; Enter in RST → F3 (TU), which logs
if the macro contains `<LOGQSO>`.
## Digital Voice Keyer (DVK)
Record **F1F6** voice messages and transmit them. Set the audio devices, PTT
method and gains in Settings → Audio.
- **PTT** method: CAT, RTS, DTR, or none (VOX).
- **Test PTT** keys the rig briefly. On CAT/OmniRig the key is held ~1.5 s so the
transmit command is actually sent (OmniRig is asynchronous and coalesces rapid
writes).
- **PTT** method: CAT (whichever backend is active — every one supports PTT),
RTS, DTR, or none (VOX).
- **Auto CQ** repeats a CQ-labelled message on a timer until you stop it.
- The DVK refuses to transmit on non-phone modes — no speech on FT8.
- A **message level** control (Settings → Audio) drives the rig at the right
level even from a quietly recorded microphone. If the rig transmits almost
nothing, check its rear-input setting (on an FTDX10: SSB MOD SOURCE = REAR).
## QSO audio recording
Continuous rolling capture. On **Log QSO** the contact is saved to a per-QSO WAV
(`CALL_YYYYMMDD_HHMMSS.wav`), mixing RX + mic. Configure the recordings folder,
format (WAV / MP3), pre-roll and gains in Settings → Audio.
Continuous rolling capture. On **Log QSO** the contact is saved to a per-QSO
WAV/MP3 (`CALL_YYYYMMDD_HHMMSS`), with pre-roll. With automatic recording off, a
**red dot** beside the callsign records the contact by hand.
A recording can be **replayed on the air** to the station being worked, like a
voice-keyer message — with its **own level**, separate from the voice keyer (a
receiver line-out needs far less gain than a microphone). The play button
becomes a **stop** button while it transmits. Hidden on CW.
## CW decoder (RX audio → text)
**Tools → CW decoder** (or the ear button) decodes received CW while the mode
is CW: adaptive envelope with noise squelch, per-character dit/dah
classification, follows 1240 WPM and sloppy fists, rides QSB. Shows WPM /
pitch / level, locks onto the FlexRadio CW pitch automatically, and clicking a
decoded word fills the callsign. Clean-to-moderate signals decode solidly;
weak-signal pileups remain CW Skimmer territory.
## RX audio monitor (USB rigs)
Settings → Audio → **Listen to radio** pipes the rig's received audio (its "USB
Audio CODEC" input) through your speakers, so you hear the radio inside OpsLog.
**Talk to radio** keys PTT and pipes your mic to the rig's audio input. (Network
audio for remote Icom is a work in progress — see
[[Remote Icom over the Internet]].)
Audio CODEC" input) through your speakers — the level applies live as you drag
it. **Talk to radio** keys PTT and pipes your mic to the rig's audio input.
+18 -4
View File
@@ -6,8 +6,15 @@ shared **globally** across profiles.
## Built-in awards
DXCC, WAS / WAZ / WAC, WPX, IOTA / POTA / SOTA / WWFF, **DDFM** (French
departments), and more — tracked **worked / confirmed / validated** by band and
mode.
departments), **DLD** (DARC DOK), **H26** (Helvetia 26 cantons), the US
counties (USA-CA), and more — tracked **worked / confirmed / validated** by
band and mode.
The awards panel has a **mode filter** (All / CW / Phone / Digital) that
applies to the whole award — bands, counts, confirmations and the contacts
behind a cell — and stacks with the worked/confirmed filter, so "worked on CW
but not confirmed on CW" is one click. Reference lists sort by reference or by
description.
## How matching works
@@ -121,13 +128,20 @@ drop off the list. This is how you close the gaps a matcher can't fill on its ow
## Live detection & manual refs
- References are detected **live** as you enter a callsign.
- References are detected **live** as you enter a callsign — from the looked-up
address *and* from the QTH, name, country, comment, note or grid you typed.
- An award can be marked **one reference per QSO** (used by DLD). When several
references then match the same contact, none is assigned silently — the
candidates are offered at log time and you pick. Off by default, so an n-fer
POTA activation still counts every park.
- You can **manually assign** a reference to a QSO (the *Award Refs* tab of the
QSO editor). It's stored in an ADIF extra (`APP_OPSLOG_AWARDREFS`) so it
survives export/import — see [[Import and Export ADIF]] — and is honoured
everywhere (award panel, grid columns, totals). For a list-backed award the
assigned reference must still be a **valid, listed** reference, and the QSO
must be **in scope** (rule 5) for it to count.
must be **in scope** (rule 5) for it to count. A hand-assigned reference
**replaces** what the matcher found, so a correction survives the next
recompute.
## Reference lists & display
+52 -14
View File
@@ -1,28 +1,46 @@
# CAT Control
OpsLog has **four native CAT backends** (Settings → CAT). Each auto-reconnects
and connects *non-blocking*, so a powered-off radio never freezes the app.
OpsLog has **eight CAT backends** (Settings → CAT). Each auto-reconnects and
connects *non-blocking*, so a powered-off radio never freezes the app. Backends
are named by **how the radio is reached**:
| Backend | Use it for |
|---------|-----------|
| **OmniRig** | Any OmniRig-supported rig (Rig 1 / Rig 2, hot-swap). |
| **FlexRadio (SmartSDR)** | FlexRadio 6000/8000 over the TCP API. → [[FlexRadio]] |
| **Icom CI-V** | Any Icom over **USB** *or* over the internet via its **LAN server**. → [[Icom]] · [[Remote Icom over the Internet]] |
| **FlexRadio (API)** | FlexRadio 6000/8000 over the TCP API. → [[FlexRadio]] |
| **Yaesu (USB)** | FTDX10 / FTDX101 family, direct serial — no OmniRig. → [[Yaesu]] |
| **Kenwood (USB, network)** | TS-590 / TS-890 / TS-990 / TS-2000 (Elecraft K3/K4 speak the same dialect), serial or a `host:port` serial bridge. → [[Kenwood and Xiegu]] |
| **Xiegu (USB)** | G90 / X6100 / X6200 / X5105 — CI-V with their reduced command set. → [[Kenwood and Xiegu]] |
| **Icom (CI-V USB)** | Any Icom over USB. → [[Icom]] |
| **Icom (CI-V network)** | An Icom's built-in LAN/internet server, replacing RS-BA1. → [[Remote Icom over the Internet]] |
| **TCI** | SunSDR / ExpertSDR2 and any TCI (WebSocket) server. |
Once connected, frequency / band / mode follow the radio, clicking a cluster spot
tunes the rig, and (for Flex) cluster spots can be pushed to the panadapter.
Once connected, frequency / band / mode follow the radio, clicking a cluster
spot tunes the rig, and (for Flex) cluster spots can be pushed to the panadapter.
## Sharing the rig with WSJT-X / JTDX / MSHV (rigctld)
A native backend holds the radio's serial port **exclusively** — so OpsLog can
serve its rig connection to other programs instead: **Settings → CAT → Share
CAT**. In WSJT-X / JTDX / MSHV / Log4OM pick rig model **Hamlib NET rigctl** at
`127.0.0.1:4532`. Works with every backend and both Hamlib command dialects.
> Never point two programs at the same COM port. If another logger or WSJT-X
> opens the rig's port directly while OpsLog holds it, you get "port busy"
> errors, garbled CI-V frames and erratic rig behaviour. One master — OpsLog —
> and everyone else through the shared rigctl port.
## OmniRig
Pick **Rig 1** or **Rig 2** (the two OmniRig slots). Configure the actual COM
port / baud in OmniRig's own settings. Works with anything OmniRig supports; PTT
keying via CAT is supported where the rig's OmniRig profile exposes it.
port / baud in OmniRig's own settings.
## FlexRadio
Enter the radio's IP (UDP discovery assists). You get a full SmartSDR-style
control tab — see **[[FlexRadio]]**.
- **VFO to read** — some OmniRig rig files declare the wrong active VFO (an
IC-7610 file declared VFO B). If the frequency looks frozen or follows the
wrong VFO, force **A** or **B** here.
- OpsLog and OmniRig must run at the **same privilege level** — one elevated
("as administrator") and not the other cannot see each other; OpsLog detects
and names this case.
## Icom CI-V (USB)
@@ -31,7 +49,7 @@ control tab — see **[[FlexRadio]]**.
- **COM port** + **baud** must match the radio (set *CI-V USB Echo Back* **OFF**
on the rig).
- The model choice also tailors the console (e.g. attenuator steps: 20 dB on an
IC-7300, 6/12/18 dB on an IC-7610).
IC-7300, 6/12/18 dB on an IC-7610/7800).
Full console details: **[[Icom]]**.
@@ -40,10 +58,30 @@ Full console details: **[[Icom]]**.
Enter the TCI host + port (default 40001). Gives freq / mode / PTT / split and,
optionally, panorama spots.
## Serial lines (DTR / RTS)
Windows raises the DTR and RTS lines when a serial port opens, and some
interfaces read one of them as **PTT** — the radio goes into transmit the moment
OpsLog connects. Others need the lines **high** to work at all. So:
- **Xiegu** always lowers both on connect (a G90 behind a DE-19 keyed on open),
and has a setting for which line keys the rig.
- **Yaesu and Kenwood** have an option (off by default) to lower both lines on
connect — turn it on only if your interface transmits as soon as OpsLog opens
the port.
- **Icom CI-V** has always dropped them.
## Protocol trace
Settings → CAT can log the **raw protocol** (every CI-V frame, or every Kenwood
command, as hex/text) to the diagnostic log — invaluable when reporting a radio
that answers oddly. The checkbox shows whether the trace is really running.
## Notes
- The **digital sub-mode** (FT4 vs FT8) is inferred from the frequency.
- A radio reporting LSB or USB selects **SSB** in the entry form.
- **Per-band Flex RX/TX antennas** can be configured and are applied
automatically on band change (Settings → FlexRadio).
automatically on band change (Settings → CAT → FlexRadio).
- Split, RIT/XIT and other rig-specific features are exposed on each backend's
control tab where supported.
+24 -6
View File
@@ -3,27 +3,39 @@
## Servers
Add multiple cluster servers (Settings). They auto-reconnect; one is the
**master** used for sending commands. The Cluster tab and the Main-view cluster
pane share the same live feed.
**master** used for sending commands. A quiet node is not treated as a dead one
— silence keeps its session, login and filters. The Cluster tab and the
Main-view cluster pane share the same live feed.
## Filter sidebar
A show/hide **filter sidebar** (shared by the Cluster tab and the Main pane):
- **Callsign** search, **hide worked**, **group duplicates**.
- Filter by **band / mode / status / source**.
- Filter by **band / mode / status / source**, plus **NEW POTA** and
**NEW COUNTY** chips.
## Per-spot status and tuning
Each spot is coloured by **status** relative to your log:
- **new** (entity never worked), **new-band**, **new-slot**, **worked**.
- **new** (entity never worked), **new-band**, **new-mode**, **new-slot**,
**worked**.
- Settings → General → **Group digital modes as one (DXCC-style)** makes
FT8/FT4/RTTY/PSK count as a single Digital mode for the colouring and the
matrix badges, matching how DXCC counts.
Click a spot to **tune the rig** (fills the callsign into the entry strip). A
multi-band **Band Map** shows panadapter-style strips per band.
multi-band **Band Map** shows panadapter-style strips per band — its CW / data
/ phone sub-bands use their own colour-blind-checked hues, and **Ctrl+↑ /
Ctrl+↓** jumps to the next spot above/below the current frequency and tunes it.
**POTA** spots are tagged with their park reference (via `api.pota.app`).
The entry strip's band matrix can also show the **ClubLog Most Wanted rank**
(Settings → General): an `MW #rank` pill next to the entity name, personalised
to your callsign, refreshed daily.
## Sending a spot
Use the **Send Spot** window to announce a DX spot on the master cluster:
@@ -35,6 +47,12 @@ frequency** (full resolution, including sub-kHz).
**Tools → Alert management** — Log4OM-style alert rules on **call / country /
band / mode / spotter**, with **sound**, **visual** and **e-mail** notification.
Active alerts appear as a discreet bell in the entry strip; click it for the
list. Alerts are kept briefly and only shown when active.
list.
## External spot sources
A **DXHunter** remote-call UDP packet fills the callsign, and when it carries
`<FREQ>` and `<MODE>` it also tunes whichever CAT backend is active. See
[[Settings and Data]] for the UDP configuration.
See also: [[Maps and Antennas]] for turning the beam to a spot.
+8 -6
View File
@@ -1,10 +1,11 @@
# OpsLog
A modern, fast ham-radio logger for **Windows** — Log4OM-style entry, real-time
CAT for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB
*and* remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert
Electronics), a DX cluster with spot alerts, awards tracking, maps, contest
logging, QSL management and a QSL-card designer.
CAT for **OmniRig**, native **FlexRadio/SmartSDR**, **Icom CI-V** (USB *and*
remote-over-internet, replacing RS-BA1), **Yaesu**, **Kenwood**, **Xiegu** and
**TCI** (SunSDR / Expert Electronics) — with **CAT sharing** so WSJT-X & co use
the same rig link — a DX cluster with spot alerts, awards tracking, maps,
contest logging, QSL management and a QSL-card designer.
Built with **Wails v2** (Go backend + React/TypeScript frontend), **pure Go**
(no CGO). SQLite holds the configuration; the logbook can be the local SQLite
@@ -18,14 +19,15 @@ Developed by **F4BPO**.
1. **[[Installation]]** — download / build and first launch.
2. **[[Getting Started]]** — the entry strip, logging your first QSO, choosing a
profile.
3. **[[CAT Control]]** — connect your radio (OmniRig, FlexRadio, Icom, TCI).
3. **[[CAT Control]]** — connect your radio (OmniRig, FlexRadio, Icom, Yaesu,
Kenwood, Xiegu, TCI).
## What OpsLog does
| Area | Pages |
|------|-------|
| Enter and manage QSOs | [[Logging Basics]] · [[Recent QSOs and Filters]] · [[Import and Export ADIF]] |
| Control your radio | [[CAT Control]] · [[FlexRadio]] · [[Icom]] · [[Remote Icom over the Internet]] |
| Control your radio | [[CAT Control]] · [[FlexRadio]] · [[Icom]] · [[Remote Icom over the Internet]] · [[Yaesu]] · [[Kenwood and Xiegu]] |
| Chase DX | [[DX Cluster and Spots]] · [[Maps and Antennas]] |
| Station hardware | [[Amplifiers and Switches]] · [[Audio and Keyers]] |
| Events & contests | [[Contest Logging]] · [[Net Control]] · [[Multi-Operator Live Status]] |
+58 -8
View File
@@ -6,27 +6,77 @@ export modes.
## Import
Import an `.adi` / `.adif` file into the active profile's logbook. On import,
OpsLog parses every field, maps standard fields to columns and keeps unknown but
valid ADIF fields in a per-QSO **extras** store (editable later). Country / zones
are enriched from `cty.dat` where missing.
**File → Import** (`Ctrl+O`) → pick an `.adi` / `.adif` file the **Import ADIF**
dialog opens with the options below. Import runs into the **active profile's**
logbook. A file **without a header** (records pasted from another log) imports
fine. Progress shows live; a summary follows — imported / updated / duplicates /
skipped / total — with expandable **error** and **duplicate** lists.
### Duplicate handling
A **duplicate** is a record with the same **callsign + UTC minute + band + mode**
as a QSO already in the logbook. Pick how they are treated:
| Mode | What it does |
|------|--------------|
| **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
- **Whole log**, **selected rows**, or the **filtered view** (no limit) — see
[[Recent QSOs and Filters]].
- **Standard** mode exports the common promoted fields; **All** mode also exports
every extra field.
- **Standard** mode exports the common promoted fields; **All** mode also
exports every extra field; **Choose fields…** opens a picker that separates
the official ADIF 3.1.7 fields (by category) from the OpsLog / non-standard
tags actually present in your log, with All/None per group. Your selection is
remembered.
- **Cabrillo** export for contest submission.
## Round-trip safety
Award references you assign manually are stored in an ADIF extra field
(`APP_OPSLOG_AWARDREFS`), so exporting your full log and re-importing it does not
lose your award-reference assignments.
(`APP_OPSLOG_AWARDREFS`), so exporting your full log and re-importing it does
not lose your award-reference assignments.
## Tips
- To move a batch of QSOs between logs, export selected → import into the other
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.
+49
View File
@@ -0,0 +1,49 @@
# Kenwood and Xiegu (native CAT)
## Kenwood (USB, network)
A native backend talks straight to a **TS-590 / TS-890 / TS-990 / TS-2000**
over its serial port — frequency, mode, VFO, split and PTT, no OmniRig in
between. **Elecraft K3/K4** speak the same dialect. Pick **Kenwood (USB,
network)** in Settings → CAT.
- **Serial**: COM port + baud.
- **Network**: give a `host:port` instead — for a serial bridge (ser2net, an
Ethernet-serial adapter). *Not* the radio's own RJ45, which speaks Kenwood's
KNS/ARCP protocol.
- Split is detected from FR/FT when the radio omits it from its status frame.
- The backend was tested end-to-end against a TS-2000 emulator and reports
useful errors: a port that opens but stays silent is distinguished from one
sending data that never answers (the error quotes what arrived).
## Xiegu (G90, X6100, X6200, X5105)
These radios speak CI-V but with a **reduced command set** (no scope, no DSP
block, no data mode), so they have their own backend — pick **Xiegu (USB)** in
Settings → CAT. Frequency, mode, split and PTT.
### PTT on a G90 — read this
A **G90 ignores the CI-V PTT command**. That has three consequences:
1. In the Xiegu settings, choose **how the rig is keyed**: CI-V command, RTS or
DTR — pick the hardware line your interface wires to PTT.
2. WSJT-X & co transmit through OpsLog's **shared CAT link** (Hamlib NET rigctl
`127.0.0.1:4532` — see [[CAT Control]]): OpsLog converts the PTT request
into the hardware line. This is the supported path and it works.
3. **WSJT-X + OmniRig will NOT transmit** a G90: OmniRig sends PTT as a CI-V
command, which the radio ignores. Either use the native Xiegu backend +
rigctl sharing, or configure the PTT line (RTS/DTR) inside OmniRig itself.
### Keying on connect
Behind an interface that carries PTT on a modem line (e.g. a DE-19), a G90 used
to go **straight into transmit when OpsLog connected** — Windows raises DTR and
RTS when a serial port opens. The Xiegu backend now drops both lines on
connect, then drives only the line you selected for keying.
## Notes
- Both backends refuse a CI-V/CAT frame read at the wrong offset, so a garbled
answer can no longer turn into a phantom frequency.
- The protocol trace (Settings → CAT) covers both — see [[CAT Control]].
+31 -8
View File
@@ -10,8 +10,24 @@ save. Fields: callsign, RST tx/rx, name, QTH, grid, band, mode, TX/RX frequency
CQ/ITU zones, continent) from `cty.dat` including `/MM` `/AM` `/B` and call-area
handling (`/8`, `/W6`), plus **ClubLog** DXpedition date overrides.
- **Callsign lookup** (QRZ.com / HamQTH) fills name / QTH / grid and shows the
photo and a QRZ.com tab. Configure it in Settings → Lookup.
photo and a QRZ.com tab. Configure it in Settings → Lookup. Portable calls
(`/M`, `/P`) are looked up on the home call too.
- **No lookup account?** The name / QTH / locator are recovered from the **last
time you worked the station**, and for US calls the offline **FCC (ULS)**
database fills state, county and a 6-character grid as you type. A real
QRZ/HamQTH hit still wins.
- A green **NEW** badge appears on the County field (Info / F2) when the US
county has never been worked.
- **ESC** clears the callsign (and stops CW).
- The frequency readout is **scroll-tunable**: roll the wheel over the
hundreds / tens / units-of-kHz digit and the rig follows.
## Super Check Partial / N+1
Enable in Settings → General (downloads the community MASTER.SCP list). As you
type a call, a docked two-column widget shows known calls that **contain** what
you typed (Partial) and calls **one character away** (N+1) — click one to fix a
busted call.
## Editing a QSO
@@ -22,14 +38,18 @@ fields** editor for any ADIF field not promoted to a column.
## Bulk operations
Select rows in Recent QSOs, right-click:
Select rows in Recent QSOs (a **Select all** button takes every displayed row),
right-click:
- **Fix country & zones** from `cty.dat`, or **update from QRZ / ClubLog**.
- **Bulk edit field** — set one field across many QSOs (QSL/upload status, my
station, contest, propagation, contacted-station refs, comment…). Great for
flipping a batch to *Requested* before an upload.
- **Fix country & zones** from `cty.dat`, or **update from the callsign
databases** (QRZ / ClubLog), with a progress bar.
- **Bulk edit field** — set one field across many QSOs: QSL/confirmation
statuses *and dates*, my station, contest, propagation, contacted-station
refs, comment, **gridsquare**, even the **frequency** (band recomputed,
out-of-band values refused).
- **Send to** a QSL service, **export** selected / filtered to ADIF or Cabrillo,
**delete**.
**delete** (optionally withdrawing the QSO from QRZ.com and Club Log —
Settings → External services, off by default).
## Find duplicates
@@ -40,6 +60,9 @@ same day or same minute) and lets you pick which to delete.
- **Worked-before matrix** shows prior contacts with the entity per band/mode
slot as you type.
- Awards references are detected **live** as you enter a call (see [[Awards]]).
- Award references are detected **live** as you enter a call — including from
the QTH, name, comment or grid you typed (see [[Awards]]).
- The callsign in the top bar is the **station switcher**: click it to change
profile.
See also: [[Recent QSOs and Filters]] · [[Import and Export ADIF]]
+27 -15
View File
@@ -3,36 +3,48 @@
## Main view
The Main view is **two configurable panes** (per profile, Settings → General →
*Main view*). Choose from: great-circle map, locator (street) map, the cluster
grid, the worked-before grid, recent QSOs, the **FlexRadio** controls, the
**Icom** console, or the **Net control** panel.
*Main view*), with a **draggable divider** between them (double-click it to
even them out). Choose from: great-circle map, locator (street) map, the
cluster grid, the worked-before grid, recent QSOs, the **FlexRadio** controls,
the **Icom** or **Yaesu** console, or the **Net control** panel.
## Great-circle map
- Short/long-path **distance & azimuth** to the worked station.
- Selectable, **key-free** basemaps: Light / Voyager / Street / Satellite (all
labelled).
- Selectable, **key-free** basemaps: Light / Voyager / Street / Satellite.
- The **antenna beam lobe(s)** are drawn from the rotor azimuth.
- **Day/night terminator** and twilight band (button at the top right).
## Locator (street) map
An OSM street map centred on the contacted grid square — handy for local /
portable contacts. No API key.
## Rotor compass
## Rotators
An azimuthal-equidistant **click-to-turn** compass driven by **PstRotator**. The
current heading and target are shown; click a bearing to turn the rotor.
An azimuthal-equidistant **click-to-turn** compass; the current heading and
target are shown. Three rotator types (Settings → Rotator):
## Ultrabeam
- **PstRotator** — via its UDP interface.
- **microHAM ARCO** — native, over LAN or USB: set the ARCO's CONTROL PROTOCOL
to *Yaesu GS-232A*.
- **GS-232A (generic)** — any GS-232A controller, ERC (Easy Rotor Control)
included; serial speed selectable. Set an ERC to GS-232 emulation, not
Hy-Gain DCU-1.
Ultrabeam antennas are supported with three modes — **Normal**, **180° reverse**,
## Motorized antennas (Ultrabeam / SteppIR)
Both follow the **rig frequency** (band change → retune) and retune immediately
when you click a spot, with three modes — **Normal**, **180° reverse**,
**Bidirectional**:
- The **radiating direction** is shown in green and the **mechanical boom** in
grey, on both the compass and the map, so you always know where the antenna
actually points.
- The antenna **follows the rig frequency** (band change → retune), and retunes
immediately when you click a spot.
grey, on both the compass and the map.
- On a FlexRadio, transmit is **inhibited while the elements move**, and
released as soon as the antenna is tuned.
- **SteppIR — Tunable range** (Settings → Hardware → Antenna, default
1354 MHz): outside it OpsLog leaves the antenna and the TX interlock
completely alone, so tuning to 30 m on a 20 m6 m SteppIR does nothing.
Related hardware: [[Amplifiers and Switches]] (Antenna Genius switch).
Related hardware: [[Amplifiers and Switches]] (Antenna Genius switch, Tuner
Genius, relays).
+28 -10
View File
@@ -6,18 +6,29 @@ Every setting in OpsLog is **per-profile**: station identity, CAT, lookup, QSL
defaults, awards display, antennas, and which logbook to use. Create one profile
per callsign / station / event.
Switch or manage profiles in Settings. The active profile decides which logbook
your QSOs go to.
The **callsign in the top bar is the station switcher** — click it and pick a
profile. Managing profiles is at the bottom of that list (and in Settings).
## Config vs. logbook — two different stores
## Two separate databases
Since 0.21.0 the settings and the QSOs live in **separate files**:
| Store | What | Where |
|-------|------|-------|
| **Config** | settings, profiles, rigs/antennas, cluster nodes, lookup cache, award lists, QSL templates | always the **local SQLite** file under `data/` |
| **Logbook** | your QSOs | where the active profile points — local SQLite **or** shared MySQL |
| **Settings database** (`settings.db`, or `opslog.db` on older installs) | settings, profiles, rigs/antennas, cluster nodes, lookup cache, award lists, QSL templates | always the **local SQLite** file under `data/` |
| **Logbook** (`logbook.db`) | your QSOs | where the active profile points — a local SQLite file **or** shared MySQL |
Keeping config local means the UI is instant even when the logbook is a far-away
MySQL server.
Keeping the settings local means the UI is instant even when the logbook is a
far-away MySQL server. Settings → Database shows the two as separate sections,
with an *Open folder* shortcut; a logbook file can be **renamed or relocated**
(its QSOs move with it), and the status bar names the **logbook** file — that
is the one to back up.
A profile can point at its **own logbook file** — ideal for a visiting
operator, whose contacts stay out of your log without touching your settings.
**Portable**: paths inside the OpsLog folder are stored relative to it, so
moving the whole folder to another drive or PC loses nothing.
## Shared MySQL logbook (multi-operator)
@@ -25,11 +36,18 @@ Point a profile's logbook at a **MySQL** database so several operators run **one
log** — e.g. a multi-op special-event call. Configure the host / database /
credentials in the profile's database settings.
This is also what powers [[Multi-Operator Live Status]] (each instance heartbeats
its activity into a `live_status` table).
- The database and tables are converted to **utf8mb4** on connect, so Cyrillic
or Polish characters from a QRZ lookup store correctly even when the hosting
panel created the database as latin1.
- An FT8/MSHV QSO that cannot reach a laggy shared database is **parked and
re-logged automatically** once it recovers — no lost QSOs.
- Switching to a profile whose MySQL is unreachable shows a clear warning
instead of silently staying on the previous logbook.
This is also what powers [[Multi-Operator Live Status]].
## Backups
Optional **database + ADIF backup at shutdown** (Settings → Backup). Regardless,
copying the `data/` folder backs up your config and local logbook. See
copying the `data/` folder backs up your settings and local logbook. See
[[Settings and Data]].
+22 -8
View File
@@ -4,13 +4,23 @@
The main log table. Double-click a row to edit; right-click for bulk actions
(see [[Logging Basics]]). Columns are configurable — click **Columns** to choose
which are visible; your selection is saved (per profile).
which are visible; widths, order and hidden columns are saved (per profile),
with a separate layout for the narrower Main-tab pane.
- The selected-row count is always visible at the top, and a **Select all /
Unselect all** button takes every displayed row.
- QSL and upload status columns are coloured: **Y** green, **N** red, **R**
blue.
- A **Distance (km)** column (also in Worked-before) is computed from the QSO's
own locator pair.
- There are **no per-column header filters** — they only ever searched the rows
on screen, which made a QSO further back in the log look missing. Use the
advanced filter, which queries the whole logbook.
### Award columns
Each defined award can show a column with the reference the QSO counts for. These
columns are **hidden by default** and opt-in from the Columns picker — turn on
only the awards you're chasing. Your choice persists across restarts.
Each defined award can show a column with the reference the QSO counts for.
These columns are **hidden by default** and opt-in from the Columns picker.
## Advanced filter builder
@@ -19,15 +29,19 @@ Click the filter button to open the **QSO filter builder** (Log4OM-style):
- Add one or more **conditions**: *field · operator · value*.
- Operators: equals, not-equal, contains, starts/ends with, greater/less
(or equal), is empty, is not empty.
- Fields include every confirmation **status and date** (sent / received,
before / after) for paper QSL, LoTW, eQSL, Club Log, HRDLog and QRZ.com.
Field names stay in English — they are ADIF names, a standard vocabulary.
- Join conditions with **ALL (AND)** or **ANY (OR)**.
- Save named **presets** (they travel with the `data/` folder).
- The field dropdown is sorted alphabetically to find things fast.
The filtered view can be exported: **selected rows** or the **whole filtered
view** (no row limit) → ADIF or Cabrillo.
A filter shows **every** match (the on-screen row limit only applies to the
unfiltered log; safety cap 10 000). The filtered view can be exported:
**selected rows** or the **whole filtered view** → ADIF or Cabrillo.
## Worked-before matrix
As you type a callsign, the worked-before grid shows prior contacts with that
**entity**, arranged by band/mode slot, so you instantly see what's new (new
DXCC / new band / new slot) versus already worked.
DXCC / new band / new slot) versus already worked. Its right-click menu offers
the same bulk edit and export as the main grid.
+32 -22
View File
@@ -4,48 +4,58 @@
A `data/` folder is created **next to `OpsLog.exe`**:
- **Config** (SQLite): settings, profiles, rigs/antennas, cluster nodes, lookup
cache, award lists, QSL templates. Always local, so the UI is instant.
- **Local logbook** (SQLite): QSOs, when the profile logs locally.
- **Settings database** (SQLite): settings, profiles, rigs/antennas, cluster
nodes, lookup cache, award lists, QSL templates. Always local.
- **Logbook** (`logbook.db`, SQLite): QSOs, when the profile logs locally.
- `data/qsl/templates/…` — QSL card templates and their photos.
- `data/qsl/outbox/…` — sent QSL card JPEGs.
- Recordings folder (configurable) — per-QSO WAV/MP3.
- Backups.
- Backups, diagnostic logs.
Back up your setup by copying `data/` (and keep it with the exe). See
Paths are stored **relative to the folder**, so the whole OpsLog directory can
move to another drive or PC. Back up your setup by copying `data/`. See
[[Profiles and Databases]] for local-vs-MySQL logbooks.
## Settings map
| Section | What |
|---------|------|
| **General** | language, theme, Main-view panes, live-status publish |
| **Station** | your callsign, grid, name, address (my-station ADIF) |
| **CAT** | radio backend + connection ([[CAT Control]]) |
| **General** | language, theme, date format (US / FR / Standard), Main-view panes, digital-modes grouping, Super Check Partial, ClubLog exceptions & Most Wanted |
| **Station** | your callsign, grid, name, address (my-station ADIF) — hand-corrected CQ/ITU zones stay corrected |
| **CAT** | radio backend + connection, CAT sharing (rigctld), protocol trace ([[CAT Control]]) |
| **Lookup** | QRZ.com / HamQTH credentials |
| **FlexRadio** | per-band RX/TX antennas |
| **Audio** | DVK / recorder devices, PTT, gains, RX monitor |
| **CW Keyer** | WinKeyer / Icom / TCI engine + macros |
| **External services** | LoTW (TQSL), QRZ, eQSL, ClubLog, HRDLog, POTA |
| **Email** | SMTP for eQSL-by-email |
| **PowerGenius / Antenna Genius / Rotator / Ultrabeam** | station hardware |
| **Audio** | DVK / recorder devices, PTT, gains and levels, RX monitor |
| **CW Keyer** | engine (WinKeyer / Serial / Icom / Flex CWX / Yaesu / TCI), macros, ESM |
| **External services** | LoTW (TQSL), QRZ, eQSL, ClubLog, HRDLog, POTA, Cloudlog/Wavelog |
| **Email** | SMTP for eQSL-by-email and *Send log to F4BPO* |
| **Amplifier / Tuner Genius / Antenna Genius / Rotator / Antenna** | station hardware ([[Amplifiers and Switches]] · [[Maps and Antennas]]) |
| **Database** | settings DB + this profile's logbook, rename/relocate |
| **Backup** | database + ADIF backup at shutdown |
## Appearance & language
Four themes (Warm light, Warm dark, Graphite dark, High contrast) plus **Auto**
(follows the OS). Full **English / French** UI with a first-run flag chooser and
a switcher in Settings → General.
(follows the OS); fresh installs start dark. Full **English / French** UI with
a first-run flag chooser and a switcher in Settings → General. **Ctrl + wheel**
zooms the whole UI (remembered; Ctrl+0 resets). The window title bar is drawn
by OpsLog — drag the top bar to move, double-click to maximise — and the window
reopens on the screen it was closed on.
## Integrations (outbound UDP)
## UDP integrations
Push the current frequency to **PstRotator**, radio info in **N1MM `RadioInfo`**
format, or an **ADIF record on each logged QSO** — so external tools stay in sync.
- **Inbound**: WSJT-X / MSHV / JTDX QSO logging and status (unicast or
multicast), and DXHunter remote-call packets (fills the callsign, tunes the
rig when the packet carries `<FREQ>`/`<MODE>`).
- **Outbound**: the current frequency to **PstRotator**, radio info in **N1MM
`RadioInfo`** format, or an **ADIF record on each logged QSO**.
## Other
- **Autostart** external programs (WSJT-X, JTAlert, rotator control…) at launch,
skipping any already running.
- **Update check** at startup (toggleable).
- **Autostart** external programs (WSJT-X, JTAlert, rotator control…) at
launch, skipping any already running.
- **Update check** at startup, when opening Help → About, and every 5 minutes.
A "What's new" summary appears on the first launch after each update.
- **Help → Send log to F4BPO** (shown when SMTP is configured) e-mails the
diagnostic logs straight to the developer.
- **Anonymous usage telemetry** — a once-a-day heartbeat (random install ID +
version + OS; no callsign or QSO data). Opt out in Preferences.
+50 -30
View File
@@ -2,57 +2,77 @@
## Where's the log file?
OpsLog writes a diagnostic log. The path is shown in Settings (and can be opened
from there). When reporting a problem, include the relevant lines — they're
prefixed by subsystem (`Flex:`, `icom net:`, `icom scope`, `antgenius:`,
`cluster:`, `qslmgr:log`, `audio:`…).
OpsLog writes a diagnostic log. The path is shown in Settings (and can be
opened from there). When reporting a problem, include the relevant lines —
they're prefixed by subsystem (`cat:`, `Flex:`, `icom net:`, `antgenius:`,
`cluster:`, `qslmgr:log`, `audio:`…). With an SMTP server configured, **Help →
Send log to F4BPO** e-mails the logs straight to the developer.
A crash no longer leaves a blank window: the error is shown on screen,
selectable, with a copy button.
## CAT
- **Radio not connecting** — check the backend fields (COM port + baud for USB;
IP/credentials for network). OpsLog connects non-blocking, so it won't freeze —
watch the CAT status.
- **Icom: no CAT at all over USB**the CI-V address must match the radio; pick
your **model** from the dropdown (it sets the address) and set *CI-V USB Echo
Back* **OFF** on the rig.
- **Icom scope blank on an IC-7300** — this was a bug where the scope decoder was
chosen from the configured CI-V address; it now uses the model detected from the
CI-V ID, so a single-scope rig run at address `0x98` decodes correctly. Update
to the latest build.
- **Icom attenuator button does nothing** — the dB steps are model-specific; make
sure the right model is selected (IC-7300 = 20 dB, IC-7610 = 6/12/18 dB).
IP/credentials for network). The CAT log line carries the error explaining a
disconnection.
- **"Serial port busy" in the log** — another program holds the COM port
(WSJT-X, MSHV, another logger configured for direct CAT). Only one program
can own the port: let OpsLog hold it and point the others at **Hamlib NET
rigctl 127.0.0.1:4532** (Settings → CAT → Share CAT). Two masters on one
CI-V bus also cause garbled frames and erratic behaviour.
- **The radio transmits as soon as OpsLog connects** — your interface reads DTR
or RTS as PTT. Xiegu drops the lines automatically; on Yaesu and Kenwood
enable the *lower DTR/RTS on connect* option. (It is off by default because
other interfaces stop transmitting with the lines low.)
- **WSJT-X won't key a Xiegu G90 through OmniRig** — a G90 ignores the CI-V PTT
command. Use the native Xiegu backend (which keys RTS/DTR) + rigctl sharing,
or set the PTT line inside OmniRig. See [[Kenwood and Xiegu]].
- **OmniRig frequency frozen / wrong VFO** — set **VFO to read** (A or B) in
the OmniRig backend settings; some rig files declare the wrong VFO.
- **"OmniRig not found" with OmniRig running** — one of the two runs as
administrator and the other doesn't. Start both the same way.
- **Icom: no CAT at all over USB** — the CI-V address must match the radio;
pick your **model** from the dropdown (it sets the address) and set *CI-V USB
Echo Back* **OFF** on the rig.
- **A radio that answers oddly** — enable the **protocol trace** (Settings →
CAT): every frame to and from the rig lands in the log as hex, which turns a
guess into a diagnosis.
## Remote Icom
- **Login rejected** → wrong Network User1 name/password.
- **Can't bind local port** → the Icom **Remote Utility is still running**
close it.
- **Link drops with the scope on** → receive-side retransmit should hold it; if
not, check network quality and the `icom net:` log lines. See
- **Frequency frozen after another program took the CI-V session** — OpsLog
reconnects on its own; if it doesn't, check the `icom net:` log lines. See
[[Remote Icom over the Internet]].
## FlexRadio
- **A control reads 0 / stale at startup** — some SmartSDR status fields use short
names (e.g. the TX filter is reported as `lo`/`hi`, not `filter_low/high`). If a
value never populates, capture the `Flex: … status` log line and report it.
- **Antenna wrong band on a spot click** — fixed: `SetFrequency`/`SetMode` update
the slice cache immediately so the panel and the Ultrabeam follow don't chase a
stale frequency.
- **A control reads 0 / stale at startup** — some SmartSDR status fields use
short names. If a value never populates, capture the `Flex: … status` log
line and report it.
- **SmartSDR CAT keeps disconnecting while OpsLog is open** — fixed: OpsLog now
binds only to the actual SmartSDR/Maestro GUI client. Update.
- **"Interlock is preventing transmission" with a SteppIR** — see the tunable
range setting in [[Maps and Antennas]].
## Multi-op live status PHP
`Connection refused` from the PHP page is almost always MySQL `bind-address =
127.0.0.1` or a firewall/grant issue — see
[[Multi-Operator Live Status]].
127.0.0.1` or a firewall/grant issue — see [[Multi-Operator Live Status]].
## DVK / PTT
- **Test PTT does nothing on CAT/OmniRig** — the key is held ~1.5 s so the async
OmniRig write is actually sent; if it still doesn't key, the rig's OmniRig
profile may not expose CAT TX keying — use RTS/DTR or VOX.
- **Test PTT does nothing on CAT/OmniRig** — the key is held ~1.5 s so the
async OmniRig write is actually sent; if it still doesn't key, the rig's
OmniRig profile may not expose CAT TX keying — use RTS/DTR or VOX.
- **A voice message transmits almost nothing** — raise the message level
(Settings → Audio) and check the rig's rear-input setting (FTDX10: SSB MOD
SOURCE = REAR).
## Still stuck?
Open an issue with: what you did, what happened, your radio/model, and the
relevant log lines.
Open an issue (or *Send log to F4BPO*) with: what you did, what happened, your
radio/model, and the relevant log lines.
+47
View File
@@ -0,0 +1,47 @@
# Yaesu (native CAT)
A native backend talks to an **FTDX10 / FTDX101** (and close relatives) directly
over the rig's USB/serial port — no OmniRig. Pick **Yaesu (USB)** in
Settings → CAT: COM port + baud (FTDX10/101 default 38400).
Frequency, mode, VFO A/B (MAIN/SUB on the 101), split and PTT are covered, and
OpsLog **follows the VFO you are actually on** — the displayed frequency, the
logged frequency and the VFO a cluster spot tunes. Split is worked out from
which VFO transmits, so simplex on the SUB receiver is not shown as split.
## The Yaesu console
With the native backend connected, a console tab (and a Main-view pane) gives:
- **S / PO / SWR meters** — power in watts on a curve measured against the rig's
own display, SWR as the ratio itself, with peak-hold so they stay readable
between CW words or SSB syllables.
- **Band and mode buttons** — CW, RTTY, DIGI and PSK show their sideband; click
the active button again to switch it.
- AF / RF / squelch, AGC, IPOAMP1AMP2, 6/12/18 dB attenuator, NB, DNR, narrow
filter, power, mic gain, VOX, split and **ATU tune**.
- **SPLIT** places the transmit VFO up 1 kHz on CW/data, up 5 kHz on phone.
- In CW the mic gain and VOX give way to a **CW card**: keyer speed, break-in
and ZIN (zero-in).
Sliders only respond to the mouse wheel after you click them, so scrolling the
panel can't change the transmit power by accident.
## CW with the rig's own keyer
A CW engine sends your macros with the radio's internal keyer (CAT `KY`) — no
WinKeyer, no second cable. Pick **Yaesu (rig keyer)** in Settings → CW keyer.
- Works on the **FTDX101 / FT-991A / FT-710** family.
- An **FTDX10 does not accept it** (`KY` is refused, `DAKY` doesn't help) —
OpsLog says so and points to the **serial DTR keyer** on the rig's second COM
port (the *Serial port* CW engine), which works. See [[Audio and Keyers]].
- CW speed is one setting everywhere: the console slider, the CW panel and the
rig's front panel stay in agreement.
## Notes
- A Yaesu that is switched off is reported as such (opening the port is not
"connected").
- If your interface goes into transmit the moment OpsLog connects, enable the
**lower DTR/RTS on connect** option — see [[CAT Control]].
+2
View File
@@ -16,6 +16,8 @@
- [[FlexRadio]]
- [[Icom]]
- [[Remote Icom over the Internet]]
- [[Yaesu]]
- [[Kenwood and Xiegu]]
**Operating**
- [[DX Cluster and Spots]]