Compare commits

...
64 Commits
Author SHA1 Message Date
rouggy 05d64024ef fix: version string was 021.4, not 0.21.4
The release prompt was answered with a missing dot and the script propagated
it verbatim into both constants, so the shipped exe reported "021.4".

That is not cosmetic: versionLess splits on dots, so "021.4" parses as [21,4]
and compares GREATER than "0.21.5" — a client on that build would be told it
is up to date forever and never offered another update. It also broke the
What's new dialog on a fresh install, where the changelog entry ("0.21.4") is
matched against appVersion by string equality.

Same code as the release otherwise; only the version string changes.
2026-07-27 10:03:57 +02:00
rouggy cb27aa5ebf chore: release v021.4 2026-07-27 00:16:17 +02:00
rouggy aefb984974 feat: follow the SUB VFO over OmniRig; distance column in the QSO grids
OmniRig reports the VFO pair (AA/AB/BA/BB) on the whole Yaesu range and never
the single-letter form. Only the latter was honoured, so every rig in that
family stayed pinned to VFO A: pressing SUB moved the radio but not OpsLog,
and a QSO worked on SUB was logged on the main VFO's frequency. The first
letter of the pair is the VFO being listened on — Log4OM reads it and gets the
right frequency on the same rigs, which is what showed the data was there.
The enum now wins over the Yaesu Freq==FreqB inference, which is only a
fallback for a rig file that names no VFO at all (the stock FTDX10 one answers
neither VS; nor FR; usably — verified on the air).

Split: the ON flag is still latched to survive a rig file that flips it on its
own, but the latch is now ARMED only after 8 flips in 30 s. A first cut at
3-in-15s was armed by the operator toggling split while testing, imposing the
6 s clearing delay on a radio that did not need it; a misreading file flips a
dozen times in that window untouched, so the two cases separate cleanly.

Distance (km) column added to Recent QSOs and Worked before (shared catalog).
Computed from the QSO's OWN my_grid/my_lat/lon first, falling back to the
current profile's locator: a log spans years and portable outings, so the
station a QSO was made from is not necessarily today's.

Locator: a precise QRZ/HamQTH grid is no longer overwritten by the cty.dat
entity centroid. The lookup runs several times per QSO and the provider gets
2 s; a slow second answer fell back to cty.dat and downgraded JN05JG to JN16
while name and QTH survived (they are only written when non-empty).

The OmniRig diagnostic line now logs what OpsLog concluded, not just what
OmniRig reported. icomnet.go: gofmt alignment only.
2026-07-26 23:36:56 +02:00
rouggy 91b5af1c7b chore: release v0.21.3 2026-07-26 16:57:19 +02:00
rouggy 4fd70f6a9d chore: release v0.21.2 2026-07-25 20:05:50 +02:00
rouggy 51e279887d feat: Super Check Partial + N+1 helper; fix Flex binding to SmartSDR CAT; telemetry callsign wait
- SCP/N+1: new internal/scp downloads the community MASTER.SCP master list and a
  docked two-column widget shows, as you type a call, the known calls containing it
  (Partial) and the calls one edit away (N+1) — click to fix a busted call. Opt-in
  in Settings → General; top-bar toggle; queried debounced on callsign input.
- Flex: OpsLog's GUI-client detection was too loose and could bind to "SmartSDR CAT"
  (or DAX) — both carry "smartsdr" in the program name — instead of the real GUI
  client, making SmartSDR CAT drop and reconnect in a loop while OpsLog was open.
  Now it binds only to a real SmartSDR/Maestro GUI client (e.g. a FLEX-8600M's
  integrated screen) and excludes cat/dax; dropped the risky empty-program fallback.
- Telemetry: on a fresh install the callsign isn't set at launch, so the once-a-day
  heartbeat recorded the machine UUID. Now it waits (~10 min) for the operator to
  enter their callsign before sending, falling back to the UUID only if none appears.
2026-07-25 20:05:33 +02:00
rouggy 7e08553e6e feat: prefer the last QSO's precise locator over the cty.dat centroid; changelog → 0.21.2
For an entity resolved only via cty.dat (e.g. a French call not on QRZ/HamQTH),
the provider block sets a coarse country-centroid grid. The worked-before backfill
now overrides that with the precise locator from the last QSO with this call, and
takes that QSO's lat/lon (derived from the grid when the record stored none) so
the map and saved record stay consistent. A real provider grid still wins.

Moved the (unreleased) worked-before backfill entry into a new 0.21.2 changelog
block, since 0.21.1 is already out.
2026-07-25 15:54:25 +02:00
rouggy 3564eecc36 feat: backfill name/QTH/grid/address from the last QSO when lookup finds nothing
When a callsign resolves only to cty.dat (not on QRZ/HamQTH, or no lookup service
configured) — or the lookup errors — enrich the entry from the most recent QSO
already in the log with that call. Fills ONLY the fields the provider left empty
and the operator hasn't edited (name, QTH, grid, country, address, state, county,
lat/lon, zones, continent, email, QSL-via), so a real QRZ/HamQTH hit is never
overridden. Uses the worked-before entries (qso_date DESC, [0] = latest) via a
live ref so it works regardless of the lookup/worked-before debounce ordering.
2026-07-25 15:49:23 +02:00
rouggy 3e7f3832e0 chore: release v0.21.1 2026-07-25 13:54:58 +02:00
rouggy 34b60f9f20 fix(zoom): use transform:scale instead of CSS zoom to kill map tile seams
CSS `zoom` re-lays-out and pixel-rounds each element, which opened ~1px seams
between map tiles (a faint grid over the map when zoomed). Switch the persistent
UI zoom to `transform: scale(z)` from the app root's top-left, with the root
counter-sized to (100/z)vw × (100/z)vh so it reflows to fill and scales back to
the window. transform scales the subtree as one composited layer, so tiles stay
seamless. Removed the now-unneeded tile-overlap CSS hack.
2026-07-25 13:34:53 +02:00
rouggy b7bfd39652 fix(zoom): hide Leaflet tile seams that appear when the map is CSS-zoomed
Under our CSS zoom the map tiles scale by a fractional factor, opening ~1px gaps
between adjacent tiles that read as a faint grid over the map. Tag the document
with data-uizoom and, only while zoomed, enlarge each 256px tile by 1px so
neighbours overlap and cover the seams. The 100% view is untouched.
2026-07-25 13:30:43 +02:00
rouggy 6a1103bf5f fix(zoom): keep the window filled when zoomed out
CSS `zoom` doesn't rescale viewport units, so the 100vh app root left an empty
strip below the UI when zoomed under 100%. Counter-size the app root to
(100/z)vw × (100/z)vh so that, after the zoom scales it back down, it exactly
fills the window again. Zoom still applies to the whole document so portalled
menus/modals scale with it.
2026-07-25 13:25:05 +02:00
rouggy 91653bca57 feat: persistent Ctrl+wheel zoom, saved award-column widths, F9 + hide-empty CW macros
- Zoom: our own Ctrl+wheel zoom (CSS zoom on the root, 50–250%), persisted in
  localStorage and restored at startup; Ctrl+0 resets. Replaces the non-persistent
  native WebView2 zoom. The freq-digit wheel now ignores Ctrl so it passes through
  to zoom.
- Award column widths: they were stripped from AG Grid's saved column state (that
  strip fixes a visibility desync) which also dropped their width. Now persisted
  separately per award code (localStorage + portable DB copy) and re-applied on
  rebuild/reopen.
- CW keyer widget: macros padded to 9 (F1–F9 slot) and empty macros hidden like
  the voice keyer, with the F-number kept tied to the real macro index so the
  F-key shortcuts still line up. New QRZ? default for F9.
2026-07-25 13:14:05 +02:00
rouggy 6be0f43dd0 style(settings): drop the verbose CW Keyer explanations (Flex/Icom notes, ESM hint)
They took vertical space in the CW Keyer panel for text operators already know.
Kept the actionable CAT-backend mismatch warnings and the checkbox labels.
2026-07-25 13:05:42 +02:00
rouggy 370fde42f7 feat: ESM (Enter Sends Message) for CW keyers, N1MM-style
With ESM enabled (Settings → CW Keyer), the keyer active and mode CW, Enter in
the entry strip fires a macro by QSO stage instead of logging:
- callsign field empty            → F1 (CQ)
- callsign entered (in call field) → F2 (report), then focus jumps to RST-sent
- Enter in RST-sent / RST-rcvd     → F3 (TU), which logs via its own <LOGQSO>

Works for every keyer engine (WinKeyer / serial DTR-RTS / Flex CWX / Icom CI-V)
since it routes through the shared macro sender. Enter in other fields still logs
normally, and ESM off keeps the classic Enter-to-log behaviour.

Backend: new `esm` flag on WinkeyerSettings (winkeyer.esm). Frontend: esmHandleEnter
state machine keyed off data-esm markers on the call/RST blocks, a Settings
checkbox + hint, and i18n EN/FR.
2026-07-25 12:38:09 +02:00
rouggy e5ff30823d feat: scroll-tune the top frequency readout (100/10/1 kHz per digit)
Rolling the mouse wheel over the hundreds / tens / units-of-kHz digit of the
header (and compact top-bar) frequency steps the frequency by 100 / 10 / 1 kHz
(wheel up = higher). The display updates optimistically on every notch and the
rig QSYs over CAT, debounced so a fast scroll doesn't flood the link. Only the
kHz digits are wheel-sensitive; MHz and Hz stay static.

New FreqWheelDisplay component (replaces the plain fmtFreqDots span in both the
full header and the compact top bar) + nudgeFreqHz handler.
2026-07-25 12:22:17 +02:00
rouggy 9e86d57dac style(flex): pipe separator between S-value and dBm on the S-meter 2026-07-25 11:48:19 +02:00
rouggy 00bfee4ed2 refactor(flex): single shared MeterBar so Flex/amp/tuner meters are identical
The FlexRadio panel, AmpCard and TunerCard each carried their own copy of the
MeterBar (LED-bar) component. Even small drift between the copies made the tuner
meters look slightly off in height/LED size vs the others. Extracted one shared
components/MeterBar.tsx (segments, bar height, padding) and imported it in all
three, so every meter renders at exactly the same size. No behaviour change.
2026-07-25 11:44:23 +02:00
rouggy f4bc55cd41 fix(flex): link TX/RX collapse, equal-size meters, phone-only MIC/COMP, inline S-meter dBm
FlexRadio panel refinements from feedback:
- TRANSMIT and RECEIVE now share one collapse state — folding either folds both
  (Card gained an optional controlled open/onToggle mode; the parent owns the
  shared txrx state, persisted).
- Meter sizes: the Tuner Genius PWR/SWR meters used a 2-column grid (wider than
  the Flex/amp meters). Switched to the same grid-cols-2 sm:grid-cols-3 so every
  meter across the METERS, AMPLIFIER and TUNER cards is the same width.
- MIC and COMP meters are hidden outside phone modes (shown for SSB/AM/FM only).
- S-meter dBm moved inline next to the S-value (was a second line under the bar),
  and the redundant dBm line under PWR removed — keeps only the watts. Saves height.
2026-07-25 11:31:26 +02:00
rouggy 3a9dda13c4 feat: collapsible Flex/amp/tuner cards, matched meter sizes, faster + distinct-icon TGXL
Addresses three points of feedback on the Tuner Genius work:

- Meter sizes: the amplifier meters were rendered `compact` (smaller than the
  FlexRadio meters). Dropped compact so the amp, tuner and Flex meters are all
  the same size.
- Collapsible cards: the FlexRadio panel Card, AmpCard and TunerCard now fold
  from a chevron in the header, state persisted per card (opslog.cardOpen.*).
  The amplifier cards share the "amplifier" collapse key across their SPE/ACOM/
  PGXL variants so folding sticks regardless of the shown model.
- TGXL responsiveness: the tuner's device poll dropped 1500ms→400ms and the
  three UI pollers 1500ms→500ms, so the SWR/power meters track TX without the
  2–3s lag behind the amplifier the user saw.
- Icon: the Tuner Genius top-bar toggle used Zap, same as the CW keyer — changed
  the tuner's icon (top bar + widget + card) to Gauge so the two are distinct.
2026-07-25 11:22:56 +02:00
rouggy 9b677c6b35 feat: Tuner Genius XL — A/B channels, plus FlexRadio panel + Station Control cards
Push the tuner control further so it mirrors the native 4O3A app and the way the
PowerGenius XL is surfaced.

Backend (internal/tunergenius):
- Status now carries both RF channels A and B (source/mode, band, frequency,
  bound Flex nickname, per-channel bypass, antenna, PTT), the active channel,
  the C1/L/C2 relay-network positions, and the 3-way-vs-SO2R hardware variant
  (learned once from the `info` reply). Flat freq/antenna still mirror the active
  channel for the compact widget.
- New TunerGeniusActivate(ch) binding → `activate ch=N` (or `ant=N` on 3-way).

Frontend:
- New shared TunerCard, styled exactly like AmpCard (PWR/SWR meter bars,
  A/B channel selector with source+freq+antenna, Tune/Bypass/Operate). Used in
  BOTH the FlexRadio panel (its own card, like the PGXL) and Station Control.
- Docked TunerGeniusPanel widget now shows the two channels A/B with their
  source/frequency/antenna and lets you click to make one active — the missing
  A/B state the user flagged.
- i18n EN/FR for the new labels (channels, antenna, in-line/bypassed, title).

Still UNTESTED on hardware — verify the per-channel field names/units and the
activate behaviour on the real box.
2026-07-25 10:47:11 +02:00
rouggy 933d601c03 feat: control the 4O3A Tuner Genius XL directly over TCP (port 9010)
New internal/tunergenius client speaking the same "Genius Series" text API
as the Antenna Genius / PowerGenius XL: banner on connect (with optional
"AUTH" for remote access), "C<seq>|<cmd>\n" commands, "R<seq>|<code>|<msg>"
replies and the "S<seq>|status k=v …" snapshot; async "M|<msg>" info lines
are consumed inline. Polls status ~1.5s and exposes SWR (return-loss dB
converted to a VSWR ratio), forward power (dBm→W), and the operate/bypass/
tuning/active-channel state. Actions: autotune, global bypass, operate/standby.

Controlled directly (not via the radio) so OpsLog uses only one of the box's
four connection slots, per the device's protocol doc.

Wiring:
- app.go: tunergenius.* settings keys, Get/Save/start, GetTunerGeniusStatus,
  TunerGeniusAutotune/SetBypass/SetOperate; started in the background at launch.
- Settings → Tuner Genius panel (enable + IP + optional remote code).
- Docked TunerGeniusPanel widget (SWR/power readouts + Tune/Bypass/Operate),
  top-bar toggle, shown when enabled — mirrors the Antenna Genius widget.
- i18n EN/FR (sec.tunergenius, tg2.*, tgp.*).

Command verbs (operate/bypass/autotune/status/auth) come straight from the
4O3A "Tuner Genius XL — Protocol Description"; UNTESTED on hardware.
2026-07-25 10:32:00 +02:00
rouggy b4f0e0bc29 fix: clear entry-strip award refs when the callsign changes (not only on wipe)
award_refs was only reset when the callsign was emptied, so swapping from one
call to another (e.g. clicking successive spots) kept the previous station's
references in the F3 Awards tab. Move the reset to just past the same-call guard
so any change clears them; the new call's spot POTA + live detection re-populate
right after. Changelog 0.21.1.
2026-07-25 09:24:09 +02:00
rouggy 9f384402fa docs: Getting Started — fill station info + operating conditions before importing
Note that entering address/city/state and per-band rig & antenna, then ticking
"Fill my station fields from my profile" on ADIF import, backfills the MY_* fields
automatically for a fully-described log.
2026-07-25 09:15:44 +02:00
rouggy 18b69ee8b4 ui: drop the "← pick a reference" placeholder in the award ref selector
Empty-state dashed box added clutter; show nothing until a reference is picked.
2026-07-25 02:48:06 +02:00
rouggy 8c1b7af5b3 ui: compact the "will count for" detected-awards list in QSO details
In the QSO details Awards tab, the detected-refs line grew tall (DXCC/WAS/WAZ/
WAC/WPX/USA-CA… each with its full name), squishing the AwardRefSelector above so
you couldn't see the selected awards. Show just CODE@REF chips (full name on
hover), cap the block height with overflow scroll, and add a separator. Changelog 0.21.1.
2026-07-25 02:32:42 +02:00
rouggy 6e953ab1f4 feat: Ctrl+Up/Down hops spot-to-spot on the Main-view band map
Add a keyNav prop to BandMap: when set (only the docked Main-view map, not the
multi-band Band Map tab), Ctrl+ArrowUp / Ctrl+ArrowDown selects the next in-band
spot above / below the rig frequency and tunes to it via the existing spot-click
handler. Higher freq is up on the map (freqToY), so Up = next higher spot.
Ignored while typing in an input. Changelog 0.21.1.
2026-07-25 02:05:51 +02:00
rouggy 88202efddb ui: Station Control masonry layout — pack cards into balanced columns
flex-wrap with fixed-width cards started each new row below the TALLEST card of
the previous row, leaving big gaps under short panels (e.g. the amplifier alone
on a second line). Switch the dashboard to balanced CSS columns (column-width
430px, break-inside-avoid): cards flow top-to-bottom and pack tightly by height.
"Auto" fits as many columns as the window allows; 1-4 cap the column count via
the container max-width. Grip-drag reorder unchanged. Changelog 0.21.1.
2026-07-25 01:49:18 +02:00
rouggy 3f15608c59 fix: ADIF export field picker All/None buttons dead — hoist GroupCard
GroupCard was defined inside ExportFieldsDialog, so it got a new component
identity every render and React remounted the whole grid on each sel change,
making the per-group All/None buttons (and checkboxes) feel unresponsive. Hoist
GroupCard to module scope with sel + handlers passed as props. Changelog 0.21.1.
2026-07-25 00:51:32 +02:00
rouggy e6a6f04ccf docs: move theme fix to a new 0.21.1 (0.21.0 released) + shorten the entry 2026-07-24 19:21:32 +02:00
rouggy 3b1a8ef01a fix: theme sometimes reverts on reopen — gate per-profile UI prefs on scope
The theme (and other per-profile UI prefs) are read via a.settings.Get, which is
scoped to the active profile. GetUIPref only guarded on a.settings==nil, so in
the startup window AFTER NewStore but BEFORE SetProfile(active) it resolved with
the wrong (empty) scope. The frontend theme self-heal treats a resolved ""  as
"answered, unset → keep default, stop retrying", so a race landed on the light
default and stayed. Add a settingsScoped atomic flag set right after
SetProfile; GetUIPref/SetUIPref return "not ready" until then, so the frontend
keeps retrying and restores the real theme. Also prevents seeding prefs into the
wrong profile scope. Changelog entry added to 0.21.0 (EN+FR).
2026-07-24 19:14:26 +02:00
rouggy fd097a647f docs: un-mix 0.21.0 and 0.20.12 — 0.21.0 holds only the architecture change
0.20.12 was already released; restore its 11 entries under 0.20.12 and keep only
the two settings/logbook-split entries under 0.21.0.
2026-07-24 18:40:39 +02:00
rouggy b2bd818ac4 chore: release v0.21.0 2026-07-24 18:32:27 +02:00
rouggy d71d09cbb6 docs: bump upcoming release to 0.21.0 + prominent architecture-change warning
Big architecture change (settings/logbook DB split) warrants a minor bump. Rename
the unreleased 0.20.12 changelog block to 0.21.0 and prepend a clear ⚠️ warning
(EN+FR) explaining the automatic non-destructive split, that nothing is lost, and
advising a data-folder backup before updating.
2026-07-24 18:31:59 +02:00
rouggy 557fb162c3 ui: tidy Database panel — settings shows only Open-folder; DB actions on logbook
Per feedback: New/Open/Rename/Save-copy/Reset made no sense under the settings
database — moved the meaningful DB actions to the logbook (New database / Open
existing / Rename-relocate). The settings section now shows just its path + Open
folder. Also fixed the "Logbook switched…" confirmation rendering twice (dropped
the duplicate render in the SQLite block; kept the shared one). The unused
settings-db handlers/bindings stay for a later re-add.
2026-07-24 18:30:05 +02:00
rouggy 77a2350240 feat: rename/relocate a profile's SQLite logbook (QSOs carried across)
"Choose a dedicated file" points at a fresh/empty file; there was no way to
rename or move an existing logbook WITH its data. RenameLogbook VACUUM INTOs the
active profile's SQLite logbook to a new path, repoints the profile and switches
the live logbook (no restart). Deletes the old file only when it was a dedicated
per-profile file; the shared default logbook.db is left for other profiles.
Errors on a MySQL logbook. UI: "Rename / relocate…" button in the logbook section.
2026-07-24 18:22:17 +02:00
rouggy 2b3d118d84 feat: split settings DB from the QSO logbook (own file per profile)
Design flaw: opslog.db held BOTH the config (settings + profiles) AND the SQLite
logbook, so the only way to a separate SQLite logbook — the whole-app "change
database location" pointer — swapped the config too, wiping the operator's setup
(reported: creating a new SQLite for a visiting op lost all profiles/settings).

Now the two are separate files:
- Settings database: settings + profiles. Fresh installs name it settings.db;
  existing installs keep opslog.db.
- Logbook (QSOs): a dedicated logbook.db next to the settings db, or a
  per-profile file (profile.ProfileDB.Path), or MySQL. connectLogbook opens the
  logbook file (db.Open creates+migrates on first use); the settings db is used
  as the logbook only if the split ever fails.

One-time, non-destructive migration at startup: if there's no logbook file yet
but the settings db already holds QSOs (legacy combined opslog.db), seed
logbook.db with a clean copy via VACUUM INTO — contacts move to the logbook, the
originals stay in the settings db as an untouched backup.

UI: the Database panel now shows the settings database and this profile's logbook
as two clearly labelled sections (+ "Open folder"), and the logbook selector is
SQLite (default logbook.db, or a dedicated file via "Choose a dedicated file…")
vs MySQL — no more confusing shared/separate choice. New binding RevealDataFolder.
2026-07-24 18:13:08 +02:00
rouggy 638ffcb326 feat: per-profile separate SQLite logbook file (config never swapped)
Design flaw: the SQLite backend conflated the app/config database (opslog.db —
settings + profiles) with the QSO logbook. connectLogbook returned a.db for any
non-MySQL profile, so the ONLY way to get a separate SQLite logbook was the
whole-app "change database location" pointer — which swapped config + profiles
too, wiping the operator's setup (reported: creating Jerem.db lost all configs).

profile.ProfileDB gains a Path field: a non-empty path routes that profile's
logbook to its own SQLite file (db.Open creates + migrates it on first use),
while opslog.db keeps settings/profiles. connectLogbook opens the file; empty
path = shared db (unchanged default, backward compatible). MySQLSettings carries
sqlite_path; Get/Save wire it through.

UI: the Database panel gains a third backend option "SQLite — separate file"
with a file picker, and now clearly labels the shared application database
(settings + profiles) vs this profile's logbook, so the two are never confused.
2026-07-24 17:12:31 +02:00
rouggy bf4fba484a feat: H26 award recognises each canton by its main cities (regex per reference)
Operators rarely write the canton in their address/QTH but do write the city.
Add a per-reference city regex to every one of the 26 cantons (same mechanism as
WAPC): the description match now fires on the canton NAME or any of its main
cities (Genève→GE, Lausanne→VD, Zürich→ZH, Bellinzona→TI, …), with accented and
ASCII/localised spellings. Scoped to DXCC 287 so cross-border city-name clashes
can't misfire. Catalog version bumped 1→2 so the update reaches anyone already
running H26 v1 (unless they've edited it).
2026-07-24 16:29:59 +02:00
rouggy 3ea7f44fd1 telemetry: use station callsign as the PostHog distinct_id (dedupe users)
The random per-install UUID lives in the LOCAL SQLite, so one operator running
OpsLog on several machines (laptop / desktop / Maestro) or after a reinstall
counted as several distinct "users" — making the user total unreliable. Use the
station callsign as distinct_id when set (public in ham radio, stable across all
of an op's machines); the install UUID stays as a fallback and rides along as an
install_id property so per-machine detail isn't lost.
2026-07-24 16:20:23 +02:00
rouggy 384925f24a chore: release v0.20.12 2026-07-24 16:10:50 +02:00
rouggy 898323c49f docs: set 0.20.12 changelog date to release day (2026-07-24) 2026-07-24 16:10:41 +02:00
rouggy 48345a22fc feat: configurable SteppIR tunable range — skip follow/inhibit off-band
A SteppIR only covers part of the spectrum (a standard one is 20 m–6 m) but
can't report its own range, so the follow loop's existing out-of-range guard
(FreqMin/FreqMax) never engaged for it. Tuning the rig to a band the antenna
can't reach (e.g. 30 m) made the loop keep trying — the immediate cause of the
stuck TX interlock addressed in the previous commit.

Add a Tunable range (MHz) setting to the Antenna panel, wired into the SteppIR
adapter's Status().FreqMin/FreqMax so the follow loop skips out-of-range
frequencies entirely: no tune command, no TX inhibit. Defaults to 13–54 MHz
(20 m–6 m, the common model) when unset; widen the low edge for a 40 m-equipped
SteppIR. Ultrabeam is unaffected (it reports its own per-band coverage).

Keeps the last-commanded-freq deadband from the previous commit as a universal
safety net for any flaky-status case within the valid range.
2026-07-24 16:05:36 +02:00
rouggy b83d55cc32 fix: SteppIR flaky status pinned FlexRadio TX interlock — operator couldn't transmit
The motorized-antenna follow loop keyed its deadband off the antenna's reported
frequency. A SteppIR reports an intermittent/stale status frequency (flips
between the commanded freq and its home/6 m value), so the deadband tripped on
almost every 1.5 s poll and re-issued a tune command. Each command refreshed
noteMotorMoveCommanded(), keeping motorTXInhibitLoop's recentCmd window alive, so
the Flex transmit-inhibit ("Interlock is preventing transmission") never
released — confirmed in a real log (moving=0 throughout, recentCmd was the
driver).

Follow loop now keys the deadband off the LAST COMMANDED rig frequency, only
re-tuning when the radio actually QSYs beyond the step — immune to a flaky
antenna status. Falls back to the antenna's freq only until the first command.

Also dropped SetTXInhibit's `interlock set reason=` sends: `reason` is a
read-only field on the Flex interlock object, so writing it is rejected
(cmd error 5000002D on V1.4.0.0) and did nothing; `transmit set inhibit=` is the
real mechanism and is unchanged.
2026-07-24 15:53:40 +02:00
rouggy 76e4288b9e feat: ship the Helvetia 26 (H26) award — 26 Swiss cantons, via the catalog
New built-in award file internal/award/catalog/h26.json (USKA "The Helvetia 26
Award HF"): 26-canton reference list, matches the canton from the QSO address
(OR-rule: QTH) by description, scoped to DXCC 287 (HB) on HF. Fixed a typo in
the description ("Swityerland"). Changelog entry added (EN+FR).
2026-07-24 15:48:06 +02:00
rouggy 246ecf23ba Revert "feat: remove PostHog usage telemetry entirely"
This reverts commit 6a28344. Restore the once-a-day anonymous PostHog heartbeat
and its opt-out toggle: telemetry.go (sendTelemetryHeartbeat, GetTelemetryEnabled/
SetTelemetryEnabled, PostHog host + API key), the startup goroutine, the Settings
→ General toggle + i18n keys, and the README/wiki mentions. version.go is dropped
again (appVersion moves back into telemetry.go). release.ps1 (untracked) pointed
back at telemetry.go by hand.
2026-07-24 15:24:22 +02:00
rouggy 1717677056 docs: expand Awards wiki — Test/Explain, Missing refs, n-fer, catalog updates
The matching "five rules" were already documented; add the parts that were only
in code: the per-QSO Explain trace (Test a callsign in the award editor), the
Missing-refs gap finder (in-scope-but-no-reference + bulk assign), how one QSO
credits several references (n-fer POTA / VUCC grid lines, comma/semicolon split),
code-vs-exact matching, and how built-in award updates skip awards you've edited.
2026-07-24 14:48:40 +02:00
rouggy 15159f38a5 diag: log SteppIR status frames so a stuck "moving" (TX interlock) is visible
The SteppIR client logged commands it sent but never the status frames it read
back, so a misread motor-status byte — which drives the app's "block TX while
the antenna is moving" interlock on a FlexRadio — was invisible. Log the raw
11-byte frame plus the decoded freq/dir/moving and the motor (buf[6]) and
direction (buf[7]) bytes, but only when the frame changes, to keep the 2 s poll
from flooding the log. Purely diagnostic; no behaviour change.
2026-07-24 13:38:37 +02:00
rouggy 6a28344e93 feat: remove PostHog usage telemetry entirely
Drop the once-a-day anonymous "app_opened" heartbeat to PostHog and everything
around it: telemetry.go (sendTelemetryHeartbeat, GetTelemetryEnabled/
SetTelemetryEnabled, install-ID/last-sent settings keys, PostHog host + API key)
and the startup goroutine that fired it. The 'Send anonymous usage statistics'
toggle is removed from Settings → General (TelemetryToggle + i18n keys), and the
telemetry mentions are stripped from the README and wiki.

appVersion (still used by changelog/update/livestatus/log-email) moves to a new
version.go; release.ps1 now rewrites it there instead of telemetry.go.
2026-07-24 11:52:52 +02:00
rouggy a14ade9277 test: fix adif roundtrip_test writeRecord calls for new allow-set param
writeRecord gained a fourth `allow map[string]bool` argument in the export
field-picker change; the round-trip test still called it with three. Pass nil
(no field filtering — the historical behaviour).
2026-07-24 11:47:15 +02:00
rouggy 513ab178ec feat: collapse advanced FlexRadio DSP behind a 'DSP' toggle
The RECEIVE card had grown tall after adding WNB and the SmartSDR v4 DSP rows
(NRL/NRS/NRF/ANFL/RNN/ANFT). Keep the everyday NB/NR/ANF visible and tuck the
supplementary controls behind a collapsible 'DSP' button. The button badges +
highlights when any hidden control is ON so an active filter is never hidden,
and its open/closed state persists in localStorage (opslog.flexDspOpen).
2026-07-24 11:12:32 +02:00
rouggy e3aeeae616 feat: ADIF export field picker — choose exactly which fields to write
Global "Export to ADIF" gains a third option "Choose fields…" next to the
standard-only and full-OpsLog choices, and right-clicking selected QSOs adds
"Export selected — choose fields…". The picker separates official ADIF 3.1.7
fields (grouped by category) from OpsLog/non-standard tags actually present in
the log (discovered via DistinctExtraKeys over extras_json), with All/None per
group and reset-to-defaults; the selection is persisted per user.

Backend: adif.Exporter gains a Fields allow-set that gates every promoted and
extras field in writeRecord; app.go ExportADIF/ExportADIFFiltered/
ExportADIFSelected take a fields []string param, plus new ADIFExtraFields.
2026-07-24 10:56:07 +02:00
rouggy a5cfecbf76 i18n: translate the External services settings tabs
The External-services panel (HRDLOG, eQSL, LoTW, POTA) and the many input
placeholders across all tabs (QRZ/ClubLog included) were still hardcoded
English. Added ~40 es.* keys (EN + FR) and wired every label, placeholder,
hint, checkbox, button and the "coming soon" fallback through t(). Renamed the
tab-map param off `t` so it no longer shadows the i18n function.
2026-07-24 09:45:20 +02:00
rouggy 7d644d05cc i18n: translate the rest of Settings → General (encryption, ClubLog)
The Password-encryption, ClubLog-exceptions and ClubLog-Most-Wanted blocks in
the General settings panel were still hardcoded English. Added ~30 gen.* keys
(EN + FR) and wired every heading, checkbox label, description, button and
status line through t(), plus the passphrase-mismatch error.
2026-07-24 09:36:20 +02:00
rouggy 75510a1161 fix: convert MySQL logbook to utf8mb4 (fixes 1366 on Cyrillic/Polish)
A QSO with a non-Latin character (Cyrillic Я = \xD0\xAF, Polish ł, …) failed to
save on the shared MySQL backend with "Error 1366: Incorrect string value …
for column 'qso'.'address'". Cause: the database had been pre-created by a
hosting panel (o2switch/cPanel) as latin1, so OpsLog's CREATE DATABASE IF NOT
EXISTS … utf8mb4 was a no-op and every table inherited latin1 — which can't
store those letters, even over a utf8mb4 connection.

OpenMySQL now runs ensureMySQLUTF8MB4 after migrations: ALTER DATABASE to
utf8mb4 (for future tables) + CONVERT any table with a non-utf8mb4 text column
via an information_schema probe. Idempotent and cheap on a healthy DB (the probe
returns nothing → no ALTERs); best-effort so a charset issue never blocks
logbook access. SQLite is unaffected.
2026-07-23 23:59:47 +02:00
rouggy 1a0a349fb3 fix: NET Control "#" column now renumbers on reorder
The pass-order "#" is a rowIndex valueGetter, but AG-Grid keeps the same row
node across a reorder (getRowId by id) and caches the value, so a moved station
kept its old number. Force-refresh the __order column on every model update in
passOrder mode.
2026-07-23 23:11:58 +02:00
rouggy 5e09b6a796 feat: NET Control mic-pass order for the on-air list
The on-air list was ordered by log time, but a net controller needs the order
the mic goes around. The on-air grid now renders in a controller-owned order:

- A pinned "#" column numbers the round; header sorting is disabled on this
  grid (new passOrder prop on RecentQSOsGrid) so a stray click can't reshuffle
  it, and any previously-saved sort is stripped on restore.
- ⬆⬇ buttons move the selected station up/down the queue.
- New check-ins append to the end; logged/cancelled stations drop out; the
  order is reconciled against the live session list and persisted per net
  (localStorage opslog.netOrder.<id>), so a tab switch keeps the round.
- "Log & end" now auto-advances to the NEXT station in pass order.
2026-07-23 23:02:03 +02:00
rouggy d6a2e84eed feat: CW decoder v2 — rebuilt decode engine, back in the UI
The first RX-audio CW decoder decoded poorly on real signals (vs SDC) and was
removed in cafade0. This rebuilds the DSP core from scratch in
internal/cwdecode, keeping v1's good ideas (pitch lock, Flex cw_pitch
targeting, tiered acquisition) and fixing its proven failures:

- Two-way DEBOUNCE (pending-commit ~0.3 dit): v1 rejected short marks but not
  short spaces, so a one-hop fade inside a dah shattered it into dits — the
  single worst real-signal failure.
- Per-character BATCH dit/dah classification at flush time, using the batch's
  own bimodal boundary — the first letter of an over decodes at any speed; the
  timing clusters (muDit/muDah) are updated with the same attribution, killing
  the classify-then-learn feedback spiral ("all dits at 60 WPM").
- dB-domain envelope with separate floor/peak trackers, hysteresis slicer,
  span CAP (30 dB — else quiet backgrounds put the off-threshold in the
  analysis-window skirts and letters merge) and window de-bias on durations.
- Double squelch: span >= 6 dB AND peak >= bank-median + 9.5 dB (span alone
  cannot reject pure noise).
- Hamming-windowed Goertzel, 16 ms window / 5 ms hop (a 20 ms window left
  40 WPM inter-element gaps with no envelope dip).
- Hardened acquisition (overlapping windows made "3 stable hops" meaningless)
  plus SUPERVISED RE-LOCK: a clearly stronger tone on another pitch sustained
  ~1 s takes over — heals noise locks and follows a QSY.
- Gap-fed dit-length tracking (inter-element gaps are timing evidence too).

Proven by a synthetic-signal test suite (all passing): clean 12-40 WPM, three
pitches, added noise, QSB fading 0.35-1.0, 10 ms dropouts inside dahs, QRM in
auto and targeted modes, noise-only squelch, mid-over speed change 25->15 WPM,
and jittered hand keying (+/-20-25%, 3 seeds).

Wiring and UI restored from git (app_cw.go, Ear header button, decoded-text
strip with WPM/pitch/level + pitch lock + click-a-word-to-fill-callsign, Tools
menu entry) — strip strings translated (cwd.* i18n keys, EN/FR) this time.
2026-07-23 22:50:02 +02:00
rouggy 7b0ef0ba97 i18n: translate the CW Keyer settings panel + drop the serial blurb
The whole "Manipulateur CW" settings panel was hardcoded in English, so it
stayed English even with the app in French. Added ~35 wk.* keys (EN + FR) and
wired every label/checkbox/select/placeholder through t(). Also removed the long
serial-engine explanation paragraph as requested.
2026-07-23 22:14:06 +02:00
rouggy aa871a07b7 fix: serial CW keyer PTT dropping between words on CP210x (SCU-17)
Confirmed from HB9HBY's log: ptt=true, yet the FT-891 dropped to RX between
words during a CQ macro — while the same SCU-17 works in N1MM. Root cause:
go.bug.st/serial drives DTR/RTS through GetCommState/SetCommState, which on the
SCU-17's Silicon Labs CP210x drops the asserted RTS (PTT) the moment DTR (CW)
is toggled. N1MM/WSJT use EscapeCommFunction, which sets one line without
disturbing the other.

OpsLog now drives the lines the same way: a new lineCtl abstraction with a
Windows implementation (linectl_windows.go) that opens the COM port via
CreateFile and toggles DTR/RTS with windows.EscapeCommFunction (SETDTR/CLRDTR/
SETRTS/CLRRTS). linectl_other.go keeps go.bug.st/serial for non-Windows builds.
The serial keyer holds a lineCtl instead of a serial.Port. RTS (PTT) now stays
asserted for the whole macro, so the rig holds TX between words.

Promotes golang.org/x/sys to a direct dependency.
2026-07-23 21:35:59 +02:00
rouggy 89584f173d fix: serial CW keyer PTT — default on, live-apply, and diagnostic log
Reported: on a CQ macro the rig drops to RX between words instead of holding TX
for the whole macro. Root cause is PTT not being held (usePTT off, or the RTS
PTT line not wired/honoured). Improvements:

- "Key PTT line" now defaults ON when the Serial engine is selected — without it
  the rig runs on semi-break-in and drops between words.
- The serial keyer's line options (PTT, key line, invert, lead/tail, speed) are
  now atomic and live-updatable: SaveWinkeyerSettings -> ApplySerialConfig applies
  them from the next character, no reconnect needed.
- Each transmission logs its PTT state / key line / lead/tail, so a rig that
  still won't hold TX can be diagnosed from the app log.

The macro itself is sent as one string (not fragmented), so PTT is genuinely
held across word gaps when usePTT is on — pending on-air confirmation via the log.
2026-07-23 20:27:05 +02:00
rouggy a71e48f811 feat: ClubLog Most Wanted rank in the entry band matrix
Opt-in (Settings → General). Shows a "MW #rank" pill next to the DXCC entity
name in the entry-strip band/slot matrix — ClubLog's Most Wanted ranking
(1 = most wanted), personalised to the operator's callsign and refreshed daily.

- internal/clublog/mostwanted.go: fetch mostwanted.php?api=1&callsign=<CALL>
  (rank→DXCC JSON, real User-Agent), invert to dxcc→rank, cache to
  <dataDir>/clublog_mostwanted.json; NeedsRefresh invalidates on callsign change.
- app.go: keyClublogMostWanted setting, a.clublogMW, startup refresh goroutine,
  activeCallsign() helper, and Get/Set/Download bindings mirroring the cty ones.
  WorkedBefore now carries MWRank (qso.WorkedBefore.MWRank) when enabled.
- BandSlotGrid.tsx: MW pill next to the entity name, colour-tiered by rank.
- SettingsModal General: toggle + download button + status, mirroring the
  ClubLog cty-exceptions block.

Also reorganises the changelog: the theme-persistence fix (landed after the
v0.20.11 tag) plus this feature go under a new 0.20.12 entry; 0.20.11 keeps only
what shipped in its binary.
2026-07-23 20:01:28 +02:00
rouggy 5c4f101402 docs: correct theme-fix rationale — local SQLite, not remote MySQL
The settings store is backed by the LOCAL SQLite config DB (wired before the
slow remote-MySQL logbook connect), so the "not ready" window is the brief
startup gap before OnStartup builds the store, not a MySQL delay. Fix behaviour
is unchanged; only the comments and changelog wording are corrected.
2026-07-23 19:15:50 +02:00
rouggy ede9461010 fix: theme reverts to light after an update when the logbook DB is slow
An update can clear the WebView's localStorage; the theme is then restored from
the portable DB pref, but GetUIPref returned ("", nil) while the settings store
was still starting (indistinguishable from a genuinely-unset pref), so the
self-heal gave up after ~2.4s and fell back to the light default.

GetUIPref now returns an error when the settings store isn't wired yet, and the
theme self-heal treats that as "not ready → keep retrying" (up to ~36s) vs a
real empty answer ("unset → keep default"). A dark theme survives the relaunch.
2026-07-23 19:13:00 +02:00
84 changed files with 9156 additions and 1063 deletions
+109
View File
@@ -0,0 +1,109 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
OpsLog is a Windows ham-radio logger built with **Wails v2** — Go backend, React/TypeScript frontend, compiled into a single `.exe`. Author F4BPO. See `README.md` for the user-facing feature list and `wiki/` for end-user documentation.
**Pure Go, no CGO** — SQLite is `modernc.org/sqlite`, serial is `go.bug.st/serial`. Any dependency requiring cgo breaks the build; check before adding one.
## Commands
```bash
wails dev # hot-reload dev (Go methods also reachable at http://localhost:34115)
wails build # full build → build/bin/OpsLog.exe (~25 s)
wails generate module # REQUIRED after changing exported App methods (regenerates TS bindings)
go build ./... # fast Go-only check
go test ./... # all Go tests
go test ./internal/steppir/ -run TestApplyPendingDirHold -v # one test
gofmt -w <file>
cd frontend && npx tsc --noEmit # frontend typecheck alone (wails build does this too)
```
Release: `.vscode/release.ps1` (Ctrl+Shift+P → *Tasks: Run Task**Release OpsLog*) — bumps the version, pushes to Gitea, builds and publishes to Gitea + GitHub.
Prefer `wails build` for final validation: it is the only command that exercises Go, the TS typecheck, the bindings and the asset embed together.
## Navigating app.go
`app.go` is ~14 000 lines and holds nearly every Wails binding. **Do not read it linearly** — it is organised into banner-delimited sections:
```bash
grep -n "^// ──\|^// ---" app.go # table of contents
```
Sections map to features (`── Motorized antenna (Ultrabeam / SteppIR) ──`, `── NET Control ──`, `── DX-cluster spot alerts ──`, …). Find the banner, then read that range. The `App` struct (~line 448) is the other useful landmark: its fields document every subsystem and carry substantial explanatory comments.
Smaller root files split off self-contained areas: `app_cw.go`, `app_qsl_designer.go`, `app_secret.go`, `chat.go`, `offline.go`, `relayauto.go`, `adifwatch.go`, `livestatus.go`, `update.go`, `telemetry.go`.
## Architecture
### Wails binding boundary
Exported methods on `*App` are the entire frontend API. Adding or changing a signature requires `wails generate module`, which writes `frontend/wailsjs/go/main/App.d.ts` and `models.ts`. Parameters and return types must be Wails-serializable — the generator prints `Not found: time.Time` noise for unsupported types (that particular message is long-standing and harmless).
Backend→frontend push uses Wails events (`runtime.EventsEmit` / `EventsOn`), e.g. `qso:logged`, `update:progress`. Most hardware status is **polled** by the frontend on an interval rather than pushed.
### Two separate databases
This split is easy to get wrong and matters:
- **`a.db`** — settings database (`settings.db` / `opslog.db`). Always SQLite. Holds settings and profiles. Its location is chosen by the user and recorded in `data/config.json`.
- **`a.logDb`** — the logbook (QSOs). Either a per-profile SQLite file, the default `logbook.db`, or a **shared MySQL** so several operators log into one database. `a.dbBackend` says which.
QSOs never go to `a.db`. `internal/db` holds the connection helpers and the MySQL specifics; `internal/qso` is the repository.
Remote MySQL is the slow path: startup deliberately brings CAT/rig links up **before** connecting the logbook, and hot paths (cluster spot enrichment, alert matching) avoid per-row queries — hence the in-memory `wcbm` worked-index and the `clusterEvents` queue that keeps the telnet socket draining.
### Per-profile settings scoping
`internal/settings.Store` is a key/value store over the settings DB. Every key is transparently prefixed with the active profile (`p3.`), so each station profile has a complete independent set. Two consequences:
- `App.settingsScoped` (atomic) gates reads until the active profile is known. Anything reading settings early must respect it or it reads the wrong scope.
- Settings keys are declared as `key<Thing>` string constants near the top of `app.go` (~180 of them) — grep `key[A-Z].*= "` to find one.
Password-type keys are encrypted at rest when the secret vault is unlocked (`internal/secret`); a locked vault returns `""` rather than ciphertext, and callers treat that as "not configured".
### Hardware device pattern
The codebase talks to ~20 devices (rigs, amplifiers, antenna controllers, switches, keyers, rotators). They all follow the same shape — match it when adding one:
1. **`internal/<device>/`** — a self-contained package exposing a `Client` with `New(...)`, `Start()`, `Stop()`, `GetStatus()`. The client owns its own goroutine: a reconnecting poll loop, a mutex serialising the shared connection, and a cached last-known `Status`. Transports are usually TCP *or* serial behind one `io.ReadWriteCloser`.
2. **Settings**`key<Thing>*` constants plus `Get<Thing>Settings()` / `Save<Thing>Settings()` bindings in `app.go`.
3. **Lifecycle** — a `start<Thing>()` method that tears down any existing client and rebuilds it from settings; called at startup and again whenever settings are saved.
4. **Status binding**`Get<Thing>Status()`, polled by the frontend.
5. **UI** — a settings panel in `frontend/src/components/SettingsModal.tsx` and a live widget in `frontend/src/components/`.
`internal/steppir` and `internal/antgenius` are compact, well-commented references. Wire-protocol packages carry the byte layout in the package doc comment and pin it with table tests against **real captured frames** — keep that up, since a wrong byte silently mistunes an antenna.
Where two devices are interchangeable (Ultrabeam / SteppIR), `app.go` defines a small interface (`motorAntenna`) plus thin per-device adapters rather than branching everywhere.
### CAT
`internal/cat` (~7 000 lines, the largest package) is the rig abstraction: OmniRig, native FlexRadio/SmartSDR, native Icom CI-V (USB **and** remote-over-internet), and TCI (SunSDR / Expert Electronics). `cat.Manager` exposes a backend-agnostic `State()`; backend-specific features are reached through typed escapes such as `FlexDo(func(cat.FlexController) error)`. Check `State().Backend` before using one.
### Logging
`internal/applog` — Wails builds with the Windows GUI subsystem, so `fmt.Println` is discarded. Use `applog.Printf` (or plain `log.Printf` inside `internal/` packages) so output reaches the rotating log file in the user data dir. That file is usually the only evidence available when diagnosing a user's hardware problem.
## Conventions
**Changelog is mandatory.** Every user-visible change gets an entry in `changelog.json`, **in both `en` and `fr`**, in the same session as the change. Keep entries to one or two sentences — rationale belongs in the commit message. New work goes under the next version number; the release script bumps the version constants.
**Version lives in two places** and must stay in lockstep: `appVersion` in `telemetry.go` and `APP_VERSION` in `frontend/src/version.ts`.
**Bilingual UI.** Every user-visible string goes through `t()` from `frontend/src/lib/i18n.tsx` (~700 keys), with both English and French provided. No hardcoded display strings.
**Commit messages must not include a `Co-Authored-By` line or any mention of the model.**
**Comments explain *why*, not *what*.** The existing code documents the reasoning behind non-obvious decisions — which protocol source was trusted, what field failure a workaround addresses, why an ordering matters. Match that: a comment that restates the code is noise, a comment recording the hard-won reason is why this codebase is navigable.
## Gotchas
- **Adding a promoted ADIF field touches five places in lockstep.** `internal/adif` promotes ~30 ADIF fields to real QSO columns; miss one and imports silently drop data. All of: the struct field, column list, insert args and scan in `internal/qso/qso.go`; the dictionary entry (`Promoted: true`) in `internal/adif/fields.go`; the `adifPromoted` list *and* the assignment in `internal/adif/import.go`; the writer in `internal/adif/export.go`; then the frontend column in `RecentQSOsGrid.tsx` with its EN+FR i18n keys. Trace an existing field (e.g. `ant_path`) across the repo as a template.
- **Generated files — don't hand-edit.** `internal/dxcc/dxcc_names_gen.go` (cty.dat joined to the ARRL/ADIF entity list) and `internal/awardref/uscounties_gen.go` (emitted by `cmd/cntygen` from the FIPS county CSV; a one-shot generator, not part of the build).
- **Single-instance guard** (`main.go`): a second process would open its own CAT and antenna-follow loops and the two would fight over the rig frequency.
- **Frontend controlled inputs**: several editors normalise a value on every keystroke (trim, split, filter). Binding an input directly to the normalised form makes Enter/Space appear dead — keep raw text in local state and derive the stored value from it.
+4 -4
View File
@@ -4,8 +4,8 @@
<img src="https://img.shields.io/badge/Discord-Rejoindre%20le%20serveur-5865F2?logo=discord&logoColor=white" alt="Rejoindre notre Discord" />
</a>
Un logiciel de log radioamateur moderne et rapide pour Windows — saisie façon
Log4OM, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif,
Un logiciel de log radioamateur moderne et rapide pour Windows — saisie en
bandeau unique, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif,
**Icom CI-V** natif (USB **et** à distance par internet, en remplacement de
RS-BA1) et **TCI** (SunSDR / Expert Electronics), cluster DX avec alertes de
spots, suivi des diplômes, cartes, log de concours, gestion des QSL et un
@@ -32,7 +32,7 @@ Développé par **F4BPO**.
## Journalisation
- **Bandeau de saisie façon Log4OM :** indicatif, RST émis/reçu, nom/QTH/locator,
- **Bandeau de saisie unique :** indicatif, RST émis/reçu, nom/QTH/locator,
bande/mode, fréquence TX/RX (split), heure de début/fin, commentaire/note. Le
**drapeau** de l'entité contactée est affiché en grand à côté des champs RST.
- **Recherche d'indicatif** (QRZ.com / HamQTH) avec photo, pré-remplissage du
@@ -91,7 +91,7 @@ Développé par **F4BPO**.
matrice des déjà-contactés (Réglages → Général).
- Les spots **POTA** sont étiquetés avec leur référence de parc (via
`api.pota.app`).
- **Alertes de spots** (façon Log4OM) : règles sur indicatif / pays / bande /
- **Alertes de spots :** règles sur indicatif / pays / bande /
mode / spotter, avec notification sonore, visuelle et e-mail (Outils →
*Gestion des alertes*).
+3 -3
View File
@@ -4,7 +4,7 @@
<img src="https://img.shields.io/badge/Discord-Join%20the%20server-5865F2?logo=discord&logoColor=white" alt="Join our Discord" />
</a>
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
A modern, fast ham-radio logger for Windows — single-strip 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),
DX cluster with spot alerts, awards tracking, maps, contest logging, QSL
@@ -31,7 +31,7 @@ Developed by **F4BPO**.
## Logging
- **Log4OM-style entry strip:** callsign, RST tx/rx, name/QTH/grid, band/mode,
- **Single-strip entry:** callsign, RST tx/rx, name/QTH/grid, band/mode,
TX/RX frequency (split), start/end time, comment/note. The contacted entity's
**flag** is shown large next to the RST fields.
- **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid
@@ -81,7 +81,7 @@ Developed by **F4BPO**.
**digital modes count as one** (DXCC-style) for the new/new-slot colouring and
the worked-before matrix badges (Settings → General).
- **POTA** spots are tagged with their park reference (via `api.pota.app`).
- **Spot alerts** (Log4OM-style): rules on call / country / band / mode /
- **Spot alerts:** rules on call / country / band / mode /
spotter, with sound, visual and e-mail notification (Tools → *Alert
management*).
+732 -38
View File
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
package main
import (
"fmt"
"time"
"hamlog/internal/applog"
"hamlog/internal/audio"
"hamlog/internal/cwdecode"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// CW decoder: taps the RX audio device (the same "From radio" capture the DVK
// and QSO recorder use) and streams decoded Morse text to the UI. It is started
// only by the frontend, and only while the entry mode is CW.
//
// Pitch targeting: the single-channel decoder is far more reliable when it locks
// to a KNOWN pitch (a narrow filter at the signal frequency, like a skimmer)
// instead of auto-searching for the loudest tone. So we follow the radio's CW
// pitch (FlexRadio cw_pitch) when available — or a manual override — and fall
// back to auto-search otherwise.
// cwTargetPitch returns the pitch (Hz) the decoder should lock to: the manual
// override if set, else the FlexRadio's CW pitch when it's in CW, else 0 (auto).
func (a *App) cwTargetPitch() int {
if a.cwPitchHz > 0 {
return a.cwPitchHz
}
if a.cat != nil {
if st, ok := a.cat.FlexState(); ok && st.Available {
// Only trust the radio's pitch when it's actually in CW.
if st.Mode == "CW" || st.Mode == "CWL" || st.Mode == "CWU" {
if st.CWPitch > 0 {
return st.CWPitch
}
}
}
}
return 0
}
// StartCWDecoder begins decoding CW from the configured RX audio device. The
// frontend calls this when the decoder toggle is on AND the mode is CW. Safe to
// call repeatedly; a second call is a no-op while already running.
func (a *App) StartCWDecoder() error {
a.cwMu.Lock()
defer a.cwMu.Unlock()
if a.cwStop != nil {
return nil // already running
}
dev := ""
if a.settings != nil {
dev, _ = a.settings.Get(a.ctx, keyAudioFromRadio)
}
if dev == "" {
return fmt.Errorf("no RX audio device configured (set \"From radio\" in Audio settings)")
}
dec := cwdecode.New(audio.SampleRate,
func(text string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cw:text", text)
}
},
func(st cwdecode.Status) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cw:status", st)
}
},
)
dec.SetTarget(a.cwTargetPitch())
a.cwDecoder = dec
stop := make(chan struct{})
a.cwStop = stop
go func() {
if err := audio.StreamCapture(dev, stop, dec.Process); err != nil {
applog.Printf("cw: capture failed: %v", err)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cw:error", err.Error())
}
}
a.cwMu.Lock()
if a.cwStop == stop {
a.cwStop = nil
a.cwDecoder = nil
}
a.cwMu.Unlock()
}()
// Follow the radio's CW pitch live (every second) while this run is active.
go a.cwFollowPitch(stop, dec)
return nil
}
// cwFollowPitch keeps the decoder locked to the current target pitch until stop.
func (a *App) cwFollowPitch(stop <-chan struct{}, dec *cwdecode.Decoder) {
t := time.NewTicker(time.Second)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
dec.SetTarget(a.cwTargetPitch())
}
}
}
// StopCWDecoder halts the CW decoder if running.
func (a *App) StopCWDecoder() {
a.cwMu.Lock()
stop := a.cwStop
a.cwStop = nil
a.cwDecoder = nil
a.cwMu.Unlock()
if stop != nil {
close(stop)
}
}
// CWDecoderRunning reports whether the decoder is currently capturing.
func (a *App) CWDecoderRunning() bool {
a.cwMu.Lock()
defer a.cwMu.Unlock()
return a.cwStop != nil
}
// SetCWDecoderPitch sets a manual decode pitch (Hz); 0 returns to auto (follow
// the Flex CW pitch, or search). Applies live to a running decoder.
func (a *App) SetCWDecoderPitch(hz int) {
if hz < 0 {
hz = 0
}
a.cwMu.Lock()
a.cwPitchHz = hz
dec := a.cwDecoder
a.cwMu.Unlock()
if dec != nil {
dec.SetTarget(a.cwTargetPitch())
}
}
// GetCWDecoderPitch returns the manual override (0 = auto / follow Flex).
func (a *App) GetCWDecoderPitch() int {
a.cwMu.Lock()
defer a.cwMu.Unlock()
return a.cwPitchHz
}
+9 -8
View File
@@ -18,14 +18,15 @@ const (
// sensitiveSettingKeys are the password fields encrypted at rest when the user
// sets a passphrase. Everything else stays plaintext.
var sensitiveSettingKeys = map[string]bool{
keyQRZPassword: true,
keyHQPassword: true,
keyEmailPassword: true,
keyExtClublogPassword: true,
keyExtLoTWKeyPassword: true,
keyExtLoTWWebPassword: true,
keyExtHRDLogCode: true,
keyExtEQSLPassword: true,
keyQRZPassword: true,
keyHQPassword: true,
keyEmailPassword: true,
keyExtClublogPassword: true,
keyExtLoTWKeyPassword: true,
keyExtLoTWWebPassword: true,
keyExtHRDLogCode: true,
keyExtEQSLPassword: true,
keyExtCloudlogAPIKey: true,
}
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
+174
View File
@@ -1,4 +1,178 @@
[
{
"version": "0.21.4",
"date": "2026-07-26",
"en": [
"OmniRig on an FTDX101D: the frequency follows the SUB VFO again, and split is detected. The stock rig file never names a single VFO — it reports only the A/B pair and alternates between the two values, split included, from one poll to the next. OpsLog now takes the displayed frequency as the reference on Yaesu and keeps a split reported once for a few seconds, so the contradicting reading no longer cancels it.",
"The SMTP password can no longer be revealed in the settings — the eye button is gone. The settings window is often open while the screen is being shared or shown to someone in the shack.",
"Cloudlog and Wavelog: each QSO can now be uploaded to your own instance as it is logged. Settings → External services → CLOUDLOG: the address of your instance, a read/write API key, the station ID, and a Test connection button. Sending is immediate or delayed by 12 minutes so a mistake can still be corrected.",
"Recent QSOs and Worked before: new Distance (km) column, like the cluster's. It is computed from the QSO's own locator pair, so a contact made portable keeps the distance from where you actually were; QSOs with no locator on either side stay blank.",
"OmniRig: the frequency follows the SUB VFO on radios that report the two VFOs as a pair (AA / AB / BA / BB) — the whole Yaesu range. Only the single-VFO form was recognised, so pressing SUB left OpsLog on the main VFO, and a QSO worked on SUB was logged on the wrong frequency.",
"OmniRig split is held briefly ONLY for a radio whose rig file reports it erratically. On a radio that reports it correctly, split now clears the instant you cancel it on the front panel instead of a few seconds later.",
"The diagnostic log now records what OpsLog concluded from OmniRig (VFO, TX/RX frequencies, split), not just what OmniRig reported.",
"Locator: a precise grid from QRZ/HamQTH is no longer replaced by the country centroid (JN05JG becoming JN16 for a French station). The lookup runs more than once per QSO and the provider only gets two seconds to answer; on a slower connection the second answer fell back to cty.dat and downgraded the locator, while the name and QTH stayed — which made it look like QRZ had returned a wrong grid."
],
"fr": [
"OmniRig sur FTDX101D : la fréquence suit de nouveau le VFO SUB, et le split est détecté. Le fichier radio d'origine ne nomme jamais un VFO seul — il ne rapporte que la paire A/B, et alterne entre les deux valeurs, split compris, d'une interrogation à l'autre. OpsLog prend désormais la fréquence affichée comme référence sur Yaesu et conserve quelques secondes un split annoncé une fois, pour que la lecture contradictoire ne l'annule plus.",
"Le mot de passe SMTP ne peut plus être affiché en clair dans les réglages — le bouton en forme d'œil a été retiré. La fenêtre des réglages est souvent ouverte pendant un partage d'écran ou devant quelqu'un au shack.",
"Cloudlog et Wavelog : chaque QSO peut désormais être envoyé vers votre propre instance au moment où il est enregistré. Réglages → Services externes → CLOUDLOG : l'adresse de votre instance, une clé API lecture/écriture, l'ID de station, et un bouton de test de connexion. L'envoi est immédiat ou différé de 1 à 2 minutes pour laisser le temps de corriger une erreur.",
"QSO récents et Déjà contacté : nouvelle colonne Distance (km), comme celle du cluster. Elle est calculée à partir des locators propres au QSO, si bien qu'un contact fait en portable garde la distance depuis l'endroit où vous étiez réellement ; les QSO sans locator d'un côté ou de l'autre restent vides.",
"OmniRig : la fréquence suit le VFO SUB sur les radios qui rapportent les deux VFO sous forme de paire (AA / AB / BA / BB) — toute la gamme Yaesu. Seule la forme à un seul VFO était reconnue, si bien qu'un passage sur SUB laissait OpsLog sur le VFO principal, et qu'un QSO fait sur SUB était enregistré sur la mauvaise fréquence.",
"Le split OmniRig n'est maintenu brièvement que pour une radio dont le fichier de définition le rapporte de façon erratique. Sur une radio qui le rapporte correctement, le split disparaît désormais dès que vous le coupez en façade, au lieu de quelques secondes plus tard.",
"Le journal de diagnostic enregistre maintenant ce qu'OpsLog a déduit d'OmniRig (VFO, fréquences TX/RX, split), et pas seulement ce qu'OmniRig a rapporté.",
"Locator : un locator précis venu de QRZ/HamQTH n'est plus remplacé par le centre du pays (JN05JG devenant JN16 pour une station française). La recherche est lancée plusieurs fois par QSO et le fournisseur ne dispose que de deux secondes ; sur une connexion plus lente, la seconde réponse retombait sur cty.dat et dégradait le locator, alors que le nom et le QTH restaient — d'où l'impression que QRZ renvoyait un mauvais locator."
]
},
{
"version": "0.21.3",
"date": "2026-07-25",
"en": [
"SteppIR: the direction button no longer snaps back to Normal a few seconds after you select 180° or Bidirectional. OpsLog was reading status frames the controller had queued up minutes earlier; it now flushes them before each poll and holds the direction you chose until the controller confirms it.",
"Alert rules: Enter and Space now work in the callsigns box — you can type a list one call per line again.",
"Fixed the US county database (USA-CA) download, broken since the FCC moved its weekly files on 24 July 2026. OpsLog now locates the file instead of assuming a fixed address, so it keeps working when the FCC reshuffles its download directories.",
"Tuner Genius XL: the power and SWR meters now fill the card instead of leaving a third of it empty. In Station Control the amplifier and tuner cards span the dashboard, so their meters are readable.",
"The Audio devices settings panel is now translated — it was still entirely in English.",
"Award management: the editor no longer spills outside its window. Wide rows now scroll inside the panel, and the button bar wraps instead of stretching the dialog (it overflowed in French, where the labels are longer).",
"Tuner Genius XL: the power and SWR meters now peak-hold like the amplifier's. They were showing raw 400 ms samples, so on SSB they fell to zero between syllables and looked like a dropped connection.",
"Fixed recovering name/QTH/locator from the last QSO when you don't use QRZ/HamQTH: the callsign resolved from cty.dat faster than the logbook could answer, so the recovery found no history and never retried. It could also, on a slow logbook, fill in the PREVIOUS station's details — it is now tied to the callsign it belongs to.",
"Big logbooks: finding a callsign in your history no longer scans the whole log. The query could not use the callsign index — on a 190 000-QSO log that made it ~70x slower, on every keystroke, slow enough that the entry strip gave up before the history arrived.",
"One-off with this update: OpsLog has stored every callsign in your logbook in UPPER CASE — that is what lets the lookup above use the index. It changes letter case and nothing else, and runs once. Because the auto-updater migrates before you get to read this, a copy of your logbook was saved next to it first (logbook.db.pre-0024_normalise_callsign.bak) — delete it once you are happy.",
"The diagnostic log now also records what the entry strip does (auto-fill decisions), so a problem that only happens on your station can be reported from the log file instead of guessed at.",
"Worked-before grid: the right-click menu now offers bulk edit and the ADIF/Cabrillo export of the selected rows, like the Recent QSOs grid — no need to go looking for the same QSOs in the main log to change them.",
"Tuner Genius XL: the SWR meter no longer stays frozen on the last transmission once you stop transmitting.",
"Portable callsigns (F4LYI/M, .../P) are looked up on QRZ/HamQTH again. They need two requests — the full form, then the home call — and the time limit only allowed one, so they always fell back to cty.dat even though the operator was listed.",
"LoTW download: a rejected login now says so, instead of pasting a fragment of the ARRL web page into the error.",
"French UI: field names in the filter builder and bulk edit are back in English. They are ADIF field names — a standard vocabulary — and translating them made it harder to relate a filter to an export or to another logger. The operators and buttons around them stay translated.",
"Bulk edit: added Owner callsign. It was filterable but not bulk-editable, because unlike the other fields it has no dedicated column and lives among the ADIF extras; editing it now merges into those and leaves the rest of them alone.",
"Some installations opened with the window invisible: the saved size was sanity-checked but the saved position never was, so a window last closed on a monitor that is no longer attached reopened off-screen every time, with no way back. The position is now checked against the monitors actually present.",
"Icom console: it now follows the radio, not just drives it. AGC, attenuator, preamp and filter were read once when connecting and never again, so switching AGC from FAST to MID on the rig left the panel showing FAST for good. They are re-read continuously now, one per poll cycle so the CAT link stays free for your own commands.",
"Icom: fixed the CI-V model table. The IC-7800 (6Ah) and IC-7700 (74h) were missing, and their addresses were assigned to the wrong radios — 80h is the IC-7410 and 88h the IC-7100. On an IC-7800 that also meant a 20 dB attenuator button the radio does not have, instead of its real 6/12/18 dB steps.",
"Icom console: the band row now highlights the band the radio is actually on. The buttons only ever sent a frequency and never showed where you were.",
"Voice keyer PTT: the CAT option was labelled \"CAT (OmniRig)\", so operators on an Icom CI-V, FlexRadio or TCI rig assumed it was not for them and fell back to RTS/DTR or VOX. It has always driven whichever CAT backend is active — every one of them supports PTT. It now reads \"CAT — Icom CI-V (USB serial)\" and so on, naming the link you actually configured.",
"OmniRig: the frequency now follows the VFO you are actually on. It always read VFO A when the rig reported one, so pressing SUB VFO on an FTDX101D left OpsLog showing the main VFO — and the band and the logged frequency with it.",
"OmniRig split: the PM_SPLITON flag is now read as a bit rather than compared for exact equality, so a rig that reports it alongside another flag is no longer seen as simplex. Split still requires two distinct VFOs in the same band, which keeps a stale VFO B from faking one.",
"CAT connection failures are now written to the diagnostic log. The status pill condenses everything to a few words (\"OmniRig not found\"), and the real reason — the COM error code, the serial or TCP error — previously existed only in a tooltip, so it never reached a bug report. Logged once per distinct message.",
"OmniRig running as administrator while OpsLog is not (or the reverse) is now detected and named. Windows keeps the two privilege levels apart, so OpsLog could not reach OmniRig even with its window open on screen — and reported \"OmniRig not found\", which sent people hunting for a driver or COM-port fault. It now says to start both the same way, and stops repeating the failure in the log every five seconds."
],
"fr": [
"SteppIR : le bouton de direction ne repasse plus sur Normal quelques secondes après avoir choisi 180° ou Bidirectionnel. OpsLog lisait des trames d'état que le contrôleur avait empilées plusieurs minutes plus tôt ; elles sont désormais purgées avant chaque interrogation, et la direction choisie est conservée jusqu'à confirmation du contrôleur.",
"Règles d'alerte : Entrée et Espace fonctionnent de nouveau dans la zone des indicatifs — on peut ressaisir une liste, un indicatif par ligne.",
"Correction du téléchargement de la base des comtés américains (USA-CA), cassé depuis le déplacement des fichiers hebdomadaires par la FCC le 24 juillet 2026. OpsLog localise désormais le fichier au lieu de supposer une adresse fixe, et continuera donc de fonctionner lors des prochains remaniements de leurs répertoires.",
"Tuner Genius XL : les jauges de puissance et de ROS occupent toute la carte au lieu d'en laisser un tiers vide. Dans Station Control, les cartes ampli et tuner s'étendent sur toute la largeur du tableau de bord pour que leurs jauges soient lisibles.",
"Le panneau de réglages « Périphériques audio » est désormais traduit — il était resté entièrement en anglais.",
"Gestion des diplômes : l'éditeur ne déborde plus de sa fenêtre. Les lignes trop larges défilent à l'intérieur du panneau et la barre de boutons passe à la ligne au lieu d'élargir la boîte de dialogue (elle débordait en français, où les libellés sont plus longs).",
"Tuner Genius XL : les jauges de puissance et de ROS maintiennent la crête, comme celles de l'ampli. Elles affichaient l'échantillon brut toutes les 400 ms et retombaient donc à zéro entre les syllabes en BLU, ce qui ressemblait à une perte de connexion.",
"Correction de la récupération du nom/QTH/locator depuis le dernier QSO quand on n'utilise pas QRZ/HamQTH : l'indicatif était résolu par cty.dat plus vite que le log ne répondait, la récupération ne trouvait donc aucun historique et ne réessayait jamais. Sur un log lent, elle pouvait aussi reprendre les données de la station PRÉCÉDENTE — elle est désormais liée à l'indicatif concerné.",
"Gros logs : retrouver un indicatif dans l'historique ne parcourt plus tout le log. La requête ne pouvait pas utiliser l'index sur l'indicatif — sur un log de 190 000 QSO, ~70× plus lent que nécessaire, à chaque frappe, et assez lentement pour que la saisie renonce avant l'arrivée de l'historique.",
"Une seule fois, avec cette mise à jour : OpsLog a mis tous les indicatifs de votre log en MAJUSCULES — c'est ce qui permet à la recherche ci-dessus d'utiliser l'index. Seule la casse des lettres change, et l'opération ne se produit qu'une fois. Comme la mise à jour automatique s'exécute avant que vous puissiez lire ceci, une copie de votre log a été enregistrée à côté au préalable (logbook.db.pre-0024_normalise_callsign.bak) — supprimez-la quand vous serez rassuré.",
"Le journal de diagnostic enregistre désormais aussi ce que fait le bandeau de saisie (décisions de remplissage automatique), pour qu'un problème qui n'arrive que chez vous puisse être rapporté depuis le fichier de log au lieu d'être deviné.",
"Grille « Déjà contacté » : le menu du clic droit propose maintenant la modification en masse et l'export ADIF/Cabrillo des lignes sélectionnées, comme la grille des QSO récents — plus besoin d'aller rechercher les mêmes QSO dans le log principal pour les modifier.",
"Tuner Genius XL : la jauge de ROS ne reste plus figée sur la dernière émission une fois que vous cessez d'émettre.",
"Les indicatifs portables (F4LYI/M, .../P) sont de nouveau trouvés sur QRZ/HamQTH. Ils nécessitent deux requêtes — la forme complète, puis l'indicatif de base — et le délai n'en autorisait qu'une : on retombait donc toujours sur cty.dat alors que l'opérateur y était bien référencé.",
"Téléchargement LoTW : un login refusé est désormais annoncé comme tel, au lieu de recopier un fragment de la page web de l'ARRL dans l'erreur.",
"Interface française : les noms de champs du constructeur de filtres et de la modification en masse repassent en anglais. Ce sont des noms de champs ADIF — un vocabulaire standard — et les traduire compliquait le rapprochement avec un export ou un autre logiciel. Les opérateurs et les boutons autour restent traduits.",
"Modification en masse : ajout de « Owner callsign ». Il était filtrable mais pas modifiable en masse car, contrairement aux autres champs, il n'a pas de colonne dédiée et vit parmi les champs ADIF supplémentaires ; sa modification s'y intègre désormais sans toucher aux autres.",
"Chez certains, la fenêtre s'ouvrait invisible : la taille enregistrée était contrôlée mais jamais la position, si bien qu'une fenêtre fermée sur un écran depuis débranché se réouvrait hors champ à chaque fois, sans retour possible. La position est désormais vérifiée contre les écrans réellement présents.",
"Console Icom : elle suit désormais la radio et ne fait plus que la piloter. AGC, atténuateur, préampli et filtre étaient lus une seule fois à la connexion et plus jamais ensuite : passer l'AGC de FAST à MID sur le poste laissait le panneau sur FAST définitivement. Ils sont maintenant relus en continu, un par cycle d'interrogation pour laisser la liaison CAT libre pour vos propres commandes.",
"Icom : correction de la table des modèles CI-V. L'IC-7800 (6Ah) et l'IC-7700 (74h) étaient absents, et leurs adresses attribuées aux mauvais postes — 80h est l'IC-7410 et 88h l'IC-7100. Sur un IC-7800, cela donnait aussi un bouton d'atténuateur 20 dB que la radio ne possède pas, au lieu de ses vrais crans 6/12/18 dB.",
"Console Icom : la rangée des bandes met en évidence celle sur laquelle le poste se trouve réellement. Les boutons ne faisaient qu'envoyer une fréquence, sans jamais indiquer où l'on était.",
"PTT du manipulateur vocal : l'option CAT était libellée « CAT (OmniRig) », si bien que les opérateurs en Icom CI-V, FlexRadio ou TCI la croyaient hors de portée et se rabattaient sur RTS/DTR ou le VOX. Elle a toujours piloté le backend CAT actif, quel qu'il soit — tous gèrent le PTT. Elle affiche désormais « CAT — Icom CI-V (USB série) » et ainsi de suite, en nommant la liaison réellement configurée.",
"OmniRig : la fréquence suit désormais le VFO réellement actif. Le VFO A était toujours lu dès que le poste en rapportait un, si bien qu'appuyer sur SUB VFO sur un FTDX101D laissait OpsLog sur le VFO principal — et avec lui la bande et la fréquence enregistrée.",
"Split OmniRig : le drapeau PM_SPLITON est lu comme un bit au lieu d'être comparé par égalité exacte, donc un poste qui le rapporte accompagné d'un autre drapeau n'est plus vu comme simplex. Le split exige toujours deux VFO distincts sur la même bande, ce qui évite qu'un VFO B périmé en simule un.",
"Les échecs de connexion CAT sont désormais écrits dans le journal de diagnostic. La pastille d'état condense tout en quelques mots (« OmniRig not found ») et la vraie raison — le code d'erreur COM, l'erreur série ou TCP — n'existait que dans une infobulle : elle n'arrivait donc jamais jusqu'à un rapport de bug. Journalisé une fois par message distinct.",
"OmniRig lancé en administrateur alors qu'OpsLog ne l'est pas (ou l'inverse) est désormais détecté et nommé. Windows sépare les deux niveaux de privilège : OpsLog ne pouvait donc pas atteindre OmniRig, fenêtre ouverte à l'écran, et annonçait « OmniRig not found » — de quoi partir chercher un problème de pilote ou de port COM. Il indique maintenant de lancer les deux de la même façon, et cesse de répéter l'échec dans le journal toutes les cinq secondes."
]
},
{
"version": "0.21.2",
"date": "2026-07-25",
"en": [
"Fixed OpsLog disrupting another Flex client: it could bind to 'SmartSDR CAT' (or DAX) instead of the real GUI client, which made SmartSDR CAT keep disconnecting/reconnecting from the radio while OpsLog was open. OpsLog now only binds to the actual SmartSDR/Maestro GUI client (e.g. a FlexRadio 'M' integrated screen).",
"New: Super Check Partial + N+1 callsign helper (like N1MM/DXLog). Enable it in Settings → General to download the community MASTER.SCP list; a docked two-column widget then shows, as you type a call, the known calls that contain it (Partial) and the calls one character away (N+1) — click one to fix a busted call.",
"When a callsign isn't found on QRZ/HamQTH (or you don't use a lookup service), the name, QTH, locator and address are recovered from the last time you worked that station — and the precise locator from that QSO is used instead of the coarse cty.dat country centroid. A real QRZ/HamQTH hit still wins."
],
"fr": [
"Correction : OpsLog pouvait perturber un autre client Flex — il pouvait se « binder » sur « SmartSDR CAT » (ou DAX) au lieu du vrai client GUI, ce qui faisait décrocher/reconnecter SmartSDR CAT de la radio en boucle tant qu'OpsLog était ouvert. OpsLog ne se binde désormais qu'au vrai client GUI SmartSDR/Maestro (ex. l'écran intégré d'un FlexRadio « M »).",
"Nouveau : assistant indicatifs Super Check Partial + N+1 (façon N1MM/DXLog). Active-le dans Réglages → Général pour télécharger la liste communautaire MASTER.SCP ; un widget ancré en 2 colonnes affiche alors, pendant que tu tapes, les indicatifs connus qui contiennent ta saisie (Partiel) et ceux à une lettre près (N+1) — clique pour corriger un call busté.",
"Quand un indicatif est introuvable sur QRZ/HamQTH (ou si tu n'utilises pas de service de lookup), le nom, le QTH, le locator et l'adresse sont récupérés du dernier QSO avec cette station — et c'est le locator précis de ce QSO qui est utilisé, pas le centroïde du pays de cty.dat. Un vrai résultat QRZ/HamQTH reste prioritaire."
]
},
{
"version": "0.21.1",
"date": "2026-07-24",
"en": [
"Ctrl + mouse wheel zoom is now remembered across restarts (Ctrl+0 resets to 100%).",
"Log grid: award column widths are now saved like the other columns, so a width you set survives a restart.",
"CW keyer widget: added an F9 macro slot, and empty macros are now hidden (like the voice keyer) — fill them in Settings → CW Keyer.",
"New: ESM (Enter Sends Message) for CW, N1MM-style. Enable it in Settings → CW Keyer. With the keyer on in CW, Enter fires a macro by QSO stage instead of logging: empty callsign → F1 (CQ); callsign entered → F2 (report) and focus jumps to RST; Enter in RST → F3 (TU), which logs the QSO if the macro contains <LOGQSO>.",
"The top frequency readout is now scroll-tunable: roll the mouse wheel over the hundreds / tens / units-of-kHz digit to step the frequency by 100 / 10 / 1 kHz, and the rig follows over CAT.",
"New: 4O3A Tuner Genius XL control. Enable it in Settings → Tuner Genius (IP only; port is fixed at 9010). Live SWR and forward power with Tune, Bypass and Operate/Standby, the two channels A/B (source, frequency and antenna) shown and click-selectable. Available as a docked widget, a card in the FlexRadio panel (like the PowerGenius) and a card in Station Control. Controlled directly over TCP, so it uses just one of the box's connection slots.",
"FlexRadio panel tidy-up: cards collapse from the chevron in their header (Transmit and Receive fold together), all meters are the same size, the MIC and COMP meters only show in phone modes, and the S-meter dBm now sits next to the S-value instead of on a second line.",
"Fixed the colour theme sometimes reverting to the default when reopening OpsLog — it's now restored reliably.",
"ADIF export field picker: the per-group All / None buttons work again.",
"Station Control: cards now pack tightly into balanced columns instead of leaving big gaps under shorter panels — a cleaner, more even dashboard.",
"Main-view band map: Ctrl+↑ / Ctrl+↓ jumps to the next spot above / below the current frequency and tunes to it.",
"QSO details → Awards: the 'this contact will count for' list is now compact (CODE@REF chips, full name on hover) with a capped height, so it no longer hides the awards you've selected.",
"Fixed award references (in the entry strip's Awards tab) not clearing when the callsign changes — clicking one spot then another no longer keeps the previous station's references."
],
"fr": [
"Le zoom Ctrl + molette est maintenant conservé après un redémarrage (Ctrl+0 remet à 100 %).",
"Grille du log : les largeurs des colonnes de diplômes sont désormais sauvegardées comme les autres colonnes, une largeur réglée survit au redémarrage.",
"Widget keyer CW : ajout d'un emplacement de macro F9, et les macros vides sont maintenant masquées (comme le keyer vocal) — remplis-les dans Réglages → Keyer CW.",
"Nouveau : ESM (Entrée envoie le message) en CW, façon N1MM. À activer dans Réglages → Keyer CW. Avec le keyer actif en CW, Entrée envoie un macro selon l'étape du QSO au lieu de loguer : indicatif vide → F1 (CQ) ; indicatif saisi → F2 (report) et le focus passe au RST ; Entrée dans le RST → F3 (TU), qui logue le QSO si le macro contient <LOGQSO>.",
"L'affichage de fréquence en haut est maintenant accordable à la molette : roule la molette sur le chiffre des centaines / dizaines / unités de kHz pour changer la fréquence par pas de 100 / 10 / 1 kHz, et la radio suit en CAT.",
"Nouveau : contrôle du 4O3A Tuner Genius XL. Active-le dans Réglages → Tuner Genius (IP seulement ; port fixé à 9010). ROS et puissance directe en direct avec Accord, Bypass et Operate/Standby, et les deux canaux A/B (source, fréquence et antenne) affichés et sélectionnables d'un clic. Disponible en widget ancré, en carte dans le panneau FlexRadio (comme le PowerGenius) et en carte dans Station Control. Piloté directement en TCP, il n'utilise qu'une des connexions de la boîte.",
"Nettoyage du panneau FlexRadio : les cartes se replient via le chevron de leur en-tête (Transmit et Receive se replient ensemble), tous les meters ont la même taille, les meters MIC et COMP ne s'affichent qu'en phonie, et le dBm du S-mètre est maintenant à côté de la valeur S plutôt que sur une deuxième ligne.",
"Correction du thème qui revenait parfois au défaut à la réouverture d'OpsLog — il est maintenant restauré de façon fiable.",
"Sélecteur de champs à l'export ADIF : les boutons Tout / Aucun par groupe refonctionnent.",
"Station Control : les cartes se rangent en colonnes équilibrées et se tassent au lieu de laisser de gros trous sous les panneaux plus courts — tableau de bord plus propre et régulier.",
"Band map de l'écran principal : Ctrl+↑ / Ctrl+↓ saute au spot suivant au-dessus / en dessous de la fréquence courante et s'y accorde.",
"Détails du QSO → Diplômes : la liste « ce contact comptera pour » est maintenant compacte (pastilles CODE@REF, nom complet au survol) et de hauteur limitée, elle ne masque plus les diplômes sélectionnés.",
"Correction des références de diplômes (onglet Diplômes de la bande de saisie) qui ne se vidaient pas au changement d'indicatif — cliquer un spot puis un autre ne garde plus les références de la station précédente."
]
},
{
"version": "0.21.0",
"date": "2026-07-24",
"en": [
"⚠️ IMPORTANT — ARCHITECTURE CHANGE. OpsLog now keeps your settings/profiles and your QSO logbook in SEPARATE database files (before, everything was in one file). On the first launch after this update, your existing database is split AUTOMATICALLY and non-destructively: your contacts are copied into a new logbook file (logbook.db) while the originals stay untouched in the settings database as a backup — nothing is deleted, no QSO is lost. Existing installs keep their opslog.db as the settings database; fresh installs name it settings.db. As a precaution, back up your OpsLog data folder before updating. Afterwards, Settings → Database shows the settings database and this profile's logbook as two separate sections.",
"Your QSOs and your settings now live in separate files: settings + profiles stay in the settings database, while contacts go to a dedicated logbook file (existing logs are migrated automatically, originals kept as a backup). A profile can also point at its own logbook file — ideal for a visiting operator, whose contacts stay out of your log without ever touching your settings or profiles. The Database panel now clearly shows the two, with an 'Open folder' shortcut, and a logbook file can be renamed/relocated (its QSOs move with it)."
],
"fr": [
"⚠️ IMPORTANT — CHANGEMENT D'ARCHITECTURE. OpsLog stocke désormais tes réglages/profils et ton journal de QSO dans des fichiers de base de données SÉPARÉS (avant, tout était dans un seul fichier). Au premier lancement après cette mise à jour, ta base existante est scindée AUTOMATIQUEMENT et sans destruction : tes contacts sont copiés dans un nouveau fichier journal (logbook.db) tandis que les originaux restent intacts dans la base de réglages en sauvegarde — rien n'est supprimé, aucun QSO n'est perdu. Les installs existantes gardent leur opslog.db comme base de réglages ; les nouvelles installs la nomment settings.db. Par précaution, sauvegarde ton dossier de données OpsLog avant de mettre à jour. Ensuite, Réglages → Base de données affiche la base de réglages et le journal de ce profil en deux sections distinctes.",
"Tes QSO et tes réglages sont désormais dans des fichiers séparés : réglages + profils dans la base de réglages, contacts dans un fichier journal dédié (les journaux existants sont migrés automatiquement, les originaux gardés en sauvegarde). Un profil peut aussi pointer vers son propre fichier journal — idéal pour un opérateur de passage, dont les contacts restent hors de ton journal sans jamais toucher tes réglages ni profils. Le panneau Base de données montre maintenant clairement les deux, avec un raccourci « Ouvrir le dossier », et un fichier journal peut être renommé/déplacé (ses QSO le suivent)."
]
},
{
"version": "0.20.12",
"date": "2026-07-24",
"en": [
"Fixed a motorized-antenna bug that could leave a FlexRadio permanently unable to transmit (Interlock is preventing transmission). With a SteppIR whose status frequency reads intermittently (it flipped between the commanded frequency and its home value), the follow the rig loop re-sent a tune command on almost every poll, and each command re-armed the block TX while the antenna moves window — so the interlock never released. The follow loop now keys its deadband off the rigs frequency (only re-tuning when the RADIO actually QSYs), immune to a flaky antenna status. Also dropped an interlock set reason= command that SmartSDR rejects (its read-only) and only produced a harmless error line — the transmit-inhibit itself is unchanged and still holds TX safely while the elements move. New: a SteppIR Tunable range setting (Settings → Hardware → Antenna, default 1354 MHz = 20 m6 m) — on a band outside it OpsLog leaves the antenna and TX completely alone, so tuning to 30 m on a 20 m6 m SteppIR no longer tries to move it or touches the interlock.",
"New built-in award: The Helvetia 26 Award (H26) — the 26 cantons of Switzerland (USKA), matched from the QSO's address or QTH on HF. Each canton also recognises its main cities (Genève, Lausanne, Zürich, Bellinzona…), since operators rarely write the canton itself.",
"FlexRadio panel: the RECEIVE card is shorter again. All the noise controls added recently made it tower, so only the everyday ones — NB, NR, ANF — now stay visible; WNB and the SmartSDR v4 DSP block (NRL/NRS/NRF/ANFL and the AI/FFT RNN & ANFT) tuck behind a 'DSP' button you expand when you need them. The button carries a dot and highlights when one of the hidden controls is switched on, so nothing active is ever out of sight, and its open/closed state is remembered.",
"ADIF export can now pick exactly which fields to write. The global 'Export to ADIF' dialog gains a third choice, 'Choose fields…', alongside 'Standard ADIF fields' and 'All OpsLog fields'; and right-clicking selected QSOs adds 'Export selected — choose fields…'. The picker separates the official ADIF 3.1.7 fields (grouped by category) from OpsLog / non-standard tags actually present in your log, with All/None per group and a one-click reset to defaults. Your selection is remembered for next time.",
"Fixed 'Incorrect string value' (MySQL error 1366) when a QSO contained non-Latin characters — Cyrillic (Я), Polish ł, etc. — e.g. updating from QRZ. It happened when the shared MySQL database had been pre-created by the hosting panel as latin1, so OpsLog's tables couldn't store those letters. OpsLog now converts the database and its tables to utf8mb4 on connect (once, automatically), and everything stores correctly. Local SQLite logbooks were never affected.",
"NET Control: the on-air list now follows a mic-pass order you control instead of log time. A pinned '#' column numbers the round, ⬆⬇ buttons move the selected station up/down the queue, new check-ins join the end, and 'Log & end' advances to the next in line. The order is remembered per net. Header sorting is disabled on this list so a click can't shuffle the round.",
"The CW decoder (RX audio → text) is back, with a rebuilt decoding engine. The first version struggled on real signals; the new one adds a windowed tone detector, an adaptive dB envelope with noise squelch, two-way debouncing (a brief fade inside a dash no longer shatters it into dots), per-character dit/dah classification (even the first letter of an over decodes at any speed), and gap-fed speed tracking. It rides QSB, ignores QRM on other pitches, follows 1240 WPM and sloppy hand keying — all proven by a synthetic-signal test suite. Tools → CW decoder (or the ear button): decodes while the mode is CW, with WPM/pitch/level display, pitch lock (follows the FlexRadio CW pitch automatically), and click-a-word to fill the callsign. Weak signals in heavy QRM remain hard — that's CW Skimmer territory — but clean-to-moderate signals now decode solidly.",
"Finished translating the Settings dialog: several panels that had stayed in English are now French too — the CW Keyer panel (and its long serial-engine paragraph removed for clarity), Password encryption, the ClubLog exceptions / Most Wanted blocks in General, and the External services tabs (HRDLOG, eQSL, LoTW, POTA).",
"Serial CW keyer: fixed the rig dropping to RX between words during a macro (PTT not held). On USB-serial interfaces like the Yaesu SCU-17 (CP210x), the previous method of driving the DTR/RTS lines would drop the held RTS (PTT) as soon as DTR (CW) toggled. OpsLog now drives the lines with the direct Win32 method N1MM/WSJT use (EscapeCommFunction), so RTS stays asserted for the whole transmission. 'Key PTT line' also defaults on for the Serial engine, and PTT / key-line / speed changes apply immediately without reconnecting the keyer.",
"New (Settings → General): show the ClubLog 'Most Wanted' rank in the entry-strip band matrix. When on, a 'MW #rank' pill appears next to the DXCC entity name (1 = the most wanted entity), coloured hotter the more wanted it is. The list is fetched from ClubLog and personalised to your callsign, refreshed daily.",
"Fixed the colour theme sometimes resetting to light after an update/relaunch: an update can clear the WebView's localStorage, and the fallback that restores the theme from the local settings database gave up after ~2.4s — occasionally too soon during the brief startup window before that store is ready. It now keeps retrying until the store actually answers, so a dark theme is reliably restored."
],
"fr": [
"Correction d'un bug d'antenne motorisée qui pouvait laisser un FlexRadio définitivement incapable d'émettre (« Interlock is preventing transmission »). Avec une SteppIR dont la fréquence de statut se lit par intermittence (elle alternait entre la fréquence commandée et sa valeur de repos), la boucle de suivi renvoyait un ordre d'accord à presque chaque cycle, et chaque ordre réarmait la fenêtre « bloquer l'émission pendant que l'antenne bouge » — l'interlock ne se relâchait donc jamais. La boucle de suivi se base maintenant sur la fréquence de la RADIO (elle ne réaccorde que quand le poste change réellement de fréquence), insensible à un statut d'antenne erratique. Retrait aussi d'une commande « interlock set reason= » que SmartSDR refuse (champ en lecture seule) et qui ne produisait qu'une ligne d'erreur sans effet — l'inhibition d'émission elle-même est inchangée et protège toujours pendant le mouvement des éléments. Nouveau : un réglage « Plage accordable » pour la SteppIR (Réglages → Matériel → Antenne, défaut 13-54 MHz = 20 m-6 m) — sur une bande hors de cette plage, OpsLog laisse totalement l'antenne et l'émission tranquilles, donc passer sur 30 m avec une SteppIR 20 m-6 m ne tente plus de la bouger ni ne touche à l'interlock.",
"Nouveau diplôme intégré : The Helvetia 26 Award (H26) — les 26 cantons de Suisse (USKA), reconnus depuis l'adresse ou le QTH du QSO en HF. Chaque canton reconnaît aussi ses principales villes (Genève, Lausanne, Zürich, Bellinzone…), car les opérateurs écrivent rarement le canton lui-même.",
"Panneau FlexRadio : la carte RÉCEPTION est de nouveau plus compacte. Tous les contrôles de bruit ajoutés récemment la faisaient s'allonger, donc seuls ceux du quotidien — NB, NR, ANF — restent visibles ; le WNB et le bloc DSP SmartSDR v4 (NRL/NRS/NRF/ANFL ainsi que RNN & ANFT IA/FFT) se replient derrière un bouton « DSP » que tu déplies au besoin. Le bouton porte un point et s'illumine quand l'un des contrôles masqués est activé, pour ne jamais perdre de vue quelque chose d'actif, et son état ouvert/fermé est mémorisé.",
"L'export ADIF permet maintenant de choisir précisément les champs à écrire. La fenêtre globale « Exporter en ADIF » gagne un troisième choix, « Choisir les champs… », à côté de « Champs ADIF standard » et « Tous les champs OpsLog » ; et le clic droit sur des QSO sélectionnés ajoute « Exporter la sélection — choisir les champs… ». Le sélecteur sépare les champs ADIF 3.1.7 officiels (regroupés par catégorie) des balises OpsLog / non standard réellement présentes dans ton log, avec Tout/Aucun par groupe et un retour aux valeurs par défaut en un clic. Ta sélection est mémorisée pour la prochaine fois.",
"Correction de « Incorrect string value » (erreur MySQL 1366) quand un QSO contenait des caractères non latins — cyrillique (Я), polonais ł, etc. — p. ex. lors d'une mise à jour depuis QRZ. Ça arrivait quand la base MySQL partagée avait été pré-créée en latin1 par le panel d'hébergement, empêchant les tables d'OpsLog de stocker ces lettres. OpsLog convertit désormais la base et ses tables en utf8mb4 à la connexion (une seule fois, automatiquement), et tout s'enregistre correctement. Les logbooks SQLite locaux n'étaient pas concernés.",
"NET Control : la liste on-air suit maintenant un ordre de passage du micro que tu contrôles, au lieu de l'heure de log. Une colonne « # » épinglée numérote le tour, les boutons ⬆⬇ montent/descendent la station sélectionnée dans la file, les nouveaux arrivants rejoignent la fin, et « Logger & terminer » passe au suivant. L'ordre est mémorisé par NET. Le tri par en-tête est désactivé sur cette liste pour qu'un clic ne bouscule pas le tour.",
"Le décodeur CW (audio RX → texte) est de retour, avec un moteur de décodage reconstruit. La première version peinait sur les vrais signaux ; la nouvelle ajoute un détecteur de tonalité fenêtré, une enveloppe dB adaptative avec squelch de bruit, un anti-rebond bilatéral (un bref fading dans un trait ne le brise plus en points), une classification point/trait par caractère (même la première lettre d'un over se décode à n'importe quelle vitesse) et un suivi de vitesse nourri par les espaces. Il tient le QSB, ignore le QRM sur d'autres tonalités, suit de 12 à 40 WPM et la manipulation humaine approximative — le tout prouvé par une suite de tests sur signaux synthétiques. Outils → Décodeur CW (ou le bouton oreille) : décode quand le mode est CW, avec affichage WPM/tonalité/niveau, verrouillage de tonalité (suit automatiquement le pitch CW du FlexRadio) et clic-sur-un-mot pour remplir l'indicatif. Les signaux faibles dans un fort QRM restent difficiles — c'est le territoire de CW Skimmer — mais les signaux propres à moyens se décodent maintenant solidement.",
"Fin de la traduction de la fenêtre Réglages : plusieurs panneaux restés en anglais sont maintenant en français aussi — le Manipulateur CW (et son long paragraphe moteur série retiré pour l'alléger), le Chiffrement des mots de passe, les blocs Exceptions ClubLog / Most Wanted dans Général, et les onglets Services externes (HRDLOG, eQSL, LoTW, POTA).",
"Keyer CW série : correction de la radio qui retombait en RX entre les mots pendant une macro (PTT non tenu). Sur les interfaces USB-série comme le Yaesu SCU-17 (CP210x), l'ancienne méthode de pilotage des lignes DTR/RTS faisait retomber le RTS (PTT) dès que DTR (CW) basculait. OpsLog pilote maintenant les lignes avec la méthode Win32 directe qu'utilisent N1MM/WSJT (EscapeCommFunction), donc RTS reste asserté toute l'émission. « Key PTT line » est aussi activé par défaut pour le moteur Série, et les changements PTT / ligne / vitesse s'appliquent immédiatement sans reconnecter le keyer.",
"Nouveau (Réglages → Général) : afficher le rang « Most Wanted » de ClubLog dans la matrice de bandes de la barre de saisie. Activé, une pastille « MW #rang » apparaît à côté du nom de l'entité DXCC (1 = l'entité la plus recherchée), d'autant plus colorée qu'elle est recherchée. La liste est récupérée depuis ClubLog et personnalisée selon ton indicatif, rafraîchie quotidiennement.",
"Correction du thème de couleur qui revenait parfois au clair après une mise à jour/relancement : une mise à jour peut vider le localStorage de la WebView, et le repli qui restaure le thème depuis la base de réglages locale abandonnait après ~2,4s — parfois trop tôt pendant le court instant de démarrage avant que ce store soit prêt. Il réessaie maintenant jusqu'à ce que le store réponde vraiment, donc un thème sombre est restauré de façon fiable."
]
},
{
"version": "0.20.11",
"date": "2026-07-23",
+568 -48
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Eraser, Hash, Loader2, Lock,
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Gauge, Hash, Loader2, Lock,
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
} from 'lucide-react';
import {
@@ -22,8 +22,10 @@ import {
RefreshCtyDat, DownloadAllReferenceLists,
RotatorGoTo, RotatorStop, GetRotatorHeading,
GetDBConnectionInfo, GetLogbookRevision,
GetUltrabeamStatus, SetUltrabeamDirection,
GetUltrabeamStatus, SetUltrabeamDirection, UILog,
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate,
GetScpStatus, ScpLookup,
OpenExternalURL,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
@@ -39,6 +41,7 @@ import {
IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
GetAwardDefs,
@@ -68,11 +71,14 @@ import { WorldMap, LocatorMap } from '@/components/MainMap';
import { FlexPanel } from '@/components/FlexPanel';
import { IcomPanel } from '@/components/IcomPanel';
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel';
import { ScpPanel, type ScpResult } from '@/components/ScpPanel';
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
import { AwardsPanel } from '@/components/AwardsPanel';
import { StatsPanel } from '@/components/StatsPanel';
import { StationControlPanel } from '@/components/StationControlPanel';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { ExportFieldsDialog } from '@/components/ExportFieldsDialog';
import { ShutdownProgress } from '@/components/ShutdownProgress';
import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
@@ -207,12 +213,49 @@ function fmtFreqDots(mhzStr: string): string {
const frac = (fracRaw + '000000').slice(0, 6);
return `${intPart}.${frac.slice(0, 3)}.${frac.slice(3, 6)}`;
}
// FreqWheelDisplay renders a frequency (MHz string) like fmtFreqDots — MHz.kHz.Hz
// — but makes the three kHz digits scroll-sensitive: rolling the mouse wheel over
// the hundreds / tens / units-of-kHz digit steps the frequency by 100 / 10 / 1 kHz
// (up = wheel up). onNudge receives the delta in Hz. The MHz and Hz digits are
// static (only kHz stepping was requested). Used in the header + compact top bar.
function FreqWheelDisplay({ mhz, onNudge, className, placeholder = '—.———.———' }: {
mhz: string; onNudge: (deltaHz: number) => void; className?: string; placeholder?: string;
}) {
if (!mhz) return <span className={className}>{placeholder}</span>;
const [intPart, fracRaw = ''] = mhz.split('.');
const frac = (fracRaw + '000000').slice(0, 6);
const khz = frac.slice(0, 3); // [hundreds, tens, units] of kHz
const hz = frac.slice(3, 6);
const stepHz = [100_000, 10_000, 1_000]; // per kHz digit: 100 / 10 / 1 kHz
return (
<span className={className}>
{intPart}.
{khz.split('').map((d, i) => (
<span key={i}
onWheel={(e) => { if (e.ctrlKey || e.metaKey) return; e.preventDefault(); e.stopPropagation(); onNudge(e.deltaY < 0 ? stepHz[i] : -stepHz[i]); }}
className="cursor-ns-resize rounded-[2px] hover:bg-primary/25 transition-colors"
title="Scroll to change frequency">
{d}
</span>
))}
.{hz}
</span>
);
}
// shortCatError condenses a backend error into a few words for the topbar
// pill. The full message stays in the tooltip. Recognises the common cases
// (OmniRig not installed, not registered) and otherwise truncates.
function shortCatError(err?: string): string {
if (!err) return '';
const e = err.toLowerCase();
// Checked BEFORE the not-found cases: a privilege mismatch used to be condensed
// into "OmniRig not found", which is actively misleading — OmniRig is running,
// visibly, and the operator goes looking for a driver or COM-port fault. The
// full explanation is in the tooltip and the log.
if (e.includes('privilege level') || e.includes('elevation') || e.includes('élévation')) {
return 'OmniRig: run as admin?';
}
if (e.includes('not registered') || e.includes('not available')) return 'OmniRig not found';
if (e.includes('not connected')) return 'not connected';
if (e.includes('coinitialize')) return 'COM error';
@@ -437,6 +480,12 @@ export default function App() {
const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false });
const [agStatus, setAgStatus] = useState<AGStatus>({ connected: false, port_a: 0, port_b: 0, antennas: [] });
const [agEnabled, setAgEnabled] = useState(false);
const [tgStatus, setTgStatus] = useState<TGStatus>({ connected: false });
const [tgEnabled, setTgEnabled] = useState(false);
// Super Check Partial / N+1
const [scpEnabled, setScpEnabled] = useState(false);
const [scpCount, setScpCount] = useState(0);
const [scpResult, setScpResult] = useState<ScpResult>({});
// Per-port optimistic selection that the status poll must not revert until the
// device confirms it (or it expires) — otherwise a stale poll right after a
// click reverts the UI and the click looks like it did nothing.
@@ -563,6 +612,10 @@ export default function App() {
const userEditedRef = useRef<Set<string>>(new Set());
const lastLookedUpRef = useRef<string>('');
// Callsign for which a PROVIDER (QRZ/HamQTH) grid is currently in the field.
// A later cty.dat-only answer for the same call must not replace that precise
// locator with the entity centroid — see the apply block in doLookup.
const providerGridCallRef = useRef<string>('');
// Tracks the call we last auto-switched to the Worked-before tab for, so we
// don't keep yanking the tab on every wb refresh of the same callsign.
const lastWbFocusRef = useRef<string>('');
@@ -804,6 +857,49 @@ export default function App() {
const [wkSent, setWkSent] = useState(''); // rolling text the keyer echoes as it transmits
const [wkEscClears, setWkEscClears] = useState(true); // ESC also clears the callsign
const [wkSendOnType, setWkSendOnType] = useState(false); // key chars live as typed
const [wkEsm, setWkEsm] = useState(false); // Enter-Sends-Message (N1MM-style CW flow)
const wkEsmRef = useRef(false);
useEffect(() => { wkEsmRef.current = wkEsm; }, [wkEsm]);
// Persistent Ctrl+wheel zoom. The native WebView2 Ctrl+wheel zoom isn't saved,
// so we run our own, factor stored in localStorage and restored at startup;
// Ctrl+0 resets to 100%.
//
// We use `transform: scale` (NOT CSS `zoom`): `zoom` re-lays-out and rounds each
// element to the pixel grid, which opens ~1px seams between map tiles. `transform`
// scales the whole subtree as one composited layer, so tiles stay seamless. The
// app root is counter-sized to (100/z)vw × (100/z)vh and scaled from its top-left
// by z, so it reflows to fill that larger box and then scales back to exactly the
// window (more content visible when zoomed out, like a real browser zoom).
const appRootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const KEY = 'opslog.uiZoom';
let z = parseFloat(localStorage.getItem(KEY) || '1');
if (!Number.isFinite(z) || z <= 0) z = 1;
const apply = () => {
const el = appRootRef.current;
if (!el) return;
el.style.transformOrigin = '0 0';
el.style.transform = z === 1 ? '' : `scale(${z})`;
el.style.width = `${100 / z}vw`;
el.style.height = `${100 / z}vh`;
document.documentElement.setAttribute('data-uizoom', String(z));
};
apply();
const onWheel = (e: WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return; // plain wheel is left alone (scroll / freq nudge)
e.preventDefault();
z = Math.min(2.5, Math.max(0.5, Math.round((z + (e.deltaY < 0 ? 0.1 : -0.1)) * 10) / 10));
localStorage.setItem(KEY, String(z));
apply();
};
const onKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && (e.key === '0')) { e.preventDefault(); z = 1; localStorage.setItem(KEY, '1'); apply(); }
};
window.addEventListener('wheel', onWheel, { passive: false });
window.addEventListener('keydown', onKey);
return () => { window.removeEventListener('wheel', onWheel); window.removeEventListener('keydown', onKey); };
}, []);
// CW keyer output engine (persisted in the WinKeyer settings, chosen in
// Settings → CW Keyer): the WinKeyer hardware, or the Icom rig's own keyer via
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
@@ -852,6 +948,37 @@ export default function App() {
// === Digital Voice Keyer (DVK) ===
// CW decoder: taps RX audio and decodes Morse. Runs only when enabled AND the
// mode is CW. The decoded text appears in a strip above the tabs.
const [cwEnabled, setCwEnabled] = useState(() => localStorage.getItem('opslog.cwDecoder') === '1');
const [cwText, setCwText] = useState('');
const [cwStatus, setCwStatus] = useState<{ wpm: number; pitch: number; level: number; active: boolean }>({ wpm: 0, pitch: 0, level: 0, active: false });
const cwOn = cwEnabled && mode === 'CW';
// Keep the decoded line scrolled to the newest text (left-aligned, no scrollbar).
const cwScrollRef = useRef<HTMLDivElement>(null);
useEffect(() => { const el = cwScrollRef.current; if (el) el.scrollLeft = el.scrollWidth; }, [cwText]);
// Manual pitch override ('' = Auto: follow the radio's CW pitch / search).
const [cwPitch, setCwPitch] = useState(() => localStorage.getItem('opslog.cwPitch') || '');
useEffect(() => {
const hz = parseInt(cwPitch, 10);
SetCWDecoderPitch(Number.isFinite(hz) ? hz : 0).catch(() => {});
localStorage.setItem('opslog.cwPitch', cwPitch);
}, [cwPitch, cwOn]);
useEffect(() => {
const offT = EventsOn('cw:text', (txt: string) => setCwText((s) => (s + txt).slice(-200)));
const offS = EventsOn('cw:status', (st: any) => setCwStatus(st));
const offE = EventsOn('cw:error', (e: string) => { setError(String(e)); setCwEnabled(false); });
return () => { offT?.(); offS?.(); offE?.(); };
}, []);
// Start/stop the backend decoder as the (enabled, mode) combination changes.
useEffect(() => {
if (cwOn) { StartCWDecoder().catch((e: any) => { setError(String(e?.message ?? e)); setCwEnabled(false); }); }
else { StopCWDecoder().catch(() => {}); }
}, [cwOn]);
function toggleCwDecoder() {
setCwEnabled((v) => { const n = !v; localStorage.setItem('opslog.cwDecoder', n ? '1' : '0'); return n; });
}
// === Multi-op chat (shared MySQL logbook) — docked panel like rotor/DVK ===
const [chatAvailable, setChatAvailable] = useState(false);
const [chatOpen, setChatOpen] = useState(false);
@@ -1296,6 +1423,9 @@ export default function App() {
const [exporting, setExporting] = useState(false);
// Export mode chooser: standard ADIF (other loggers) vs full (OpsLog round-trip).
const [showExportChoice, setShowExportChoice] = useState(false);
// Field-picker export: null when closed; ids present = export those selected
// rows, absent = export the whole logbook (global "choose fields" flow).
const [fieldPicker, setFieldPicker] = useState<{ ids?: number[]; count: number } | null>(null);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [importErrorsOpen, setImportErrorsOpen] = useState(false);
const [importDupsOpen, setImportDupsOpen] = useState(false);
@@ -1329,6 +1459,21 @@ export default function App() {
// re-populating a field the operator just cleared.
const lookupGenRef = useRef(0);
const [wb, setWb] = useState<WB | null>(null);
// Live mirror of `wb` so the lookup fallback can read the latest worked-before
// entries synchronously (the two run on separate debounce timers).
const wbRef = useRef<WB | null>(null);
useEffect(() => { wbRef.current = wb; }, [wb]);
// Which callsign wbRef.current actually belongs to. Worked-before is replaced
// only when its query RESOLVES, so between two calls the ref still holds the
// previous station — and the backfill below would happily copy that station's
// name, QTH and grid onto the new one.
const wbCallRef = useRef('');
// A backfill the lookup asked for before the history had arrived. Parked here
// and replayed by runWorkedBefore: with no QRZ/HamQTH configured the lookup
// answers from local cty.dat almost instantly, while the history query — a
// round trip to a possibly remote MySQL logbook — is still in flight, so the
// backfill found nothing and nothing ever ran it again.
const pendingBackfillRef = useRef<{ call: string; r?: any } | null>(null);
const [wbBusy, setWbBusy] = useState(false);
// Per-award columns for the Recent QSOs / Worked-before grids: load the award
@@ -1437,6 +1582,8 @@ export default function App() {
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
const [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0');
const [showTuner, setShowTuner] = useState(() => localStorage.getItem('opslog.showTuner') !== '0');
const [showScp, setShowScp] = useState(() => localStorage.getItem('opslog.showScp') !== '0');
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
@@ -1632,6 +1779,67 @@ export default function App() {
AntGeniusActivate(port, antenna).catch((e) => setError(String(e?.message ?? e)));
};
// Poll the Tuner Genius XL for SWR / power / operating state. Re-read the
// enabled flag each tick so toggling it in Settings shows/hides the widget
// without an app restart.
useEffect(() => {
let alive = true;
const tick = async () => {
try { const en: any = await GetTunerGeniusSettings(); if (alive) setTgEnabled(!!en?.enabled); } catch {}
try {
const s = (await GetTunerGeniusStatus()) as TGStatus;
if (!alive || !s) return;
setTgStatus((prev) => (JSON.stringify(prev) === JSON.stringify(s) ? prev : s));
} catch {}
};
tick();
// Fast poll so the SWR/power meters track TX responsively (backend polls the
// device at ~400ms; 500ms here keeps the UI close behind).
const id = window.setInterval(tick, 500);
return () => { alive = false; window.clearInterval(id); };
}, []);
const tgTune = () => {
setTgStatus((s) => ({ ...s, tuning: true })); // optimistic
TunerGeniusAutotune().catch((e) => setError(String(e?.message ?? e)));
};
const tgBypass = (on: boolean) => {
setTgStatus((s) => ({ ...s, bypass: on }));
TunerGeniusSetBypass(on).catch((e) => setError(String(e?.message ?? e)));
};
const tgOperate = (on: boolean) => {
setTgStatus((s) => ({ ...s, operate: on }));
TunerGeniusSetOperate(on).catch((e) => setError(String(e?.message ?? e)));
};
const tgActivate = (ch: number) => {
setTgStatus((s) => ({ ...s, active: ch })); // optimistic
TunerGeniusActivate(ch).catch((e) => setError(String(e?.message ?? e)));
};
// Super Check Partial: poll the enabled flag + list size so the widget shows up
// once the operator turns SCP on and the master list has downloaded.
useEffect(() => {
let alive = true;
const load = async () => {
try { const s: any = await GetScpStatus(); if (alive && s) { setScpEnabled(!!s.enabled); setScpCount(s.count || 0); } } catch {}
};
load();
const id = window.setInterval(load, 3000);
const off = EventsOn('scp:updated', load);
return () => { alive = false; window.clearInterval(id); off(); };
}, []);
// Query SCP/N+1 as the callsign changes (debounced). Skipped when disabled or
// the widget is hidden, so we don't hit the backend for nothing.
useEffect(() => {
if (!scpEnabled || !showScp) { setScpResult({}); return; }
const c = callsign.trim();
if (c.length < 2) { setScpResult({}); return; }
let alive = true;
const id = window.setTimeout(async () => {
try { const r: any = await ScpLookup(c); if (alive && r) setScpResult(r as ScpResult); } catch {}
}, 120);
return () => { alive = false; window.clearTimeout(id); };
}, [callsign, scpEnabled, showScp]);
// RX band auto-follows the TX band (only differs for cross-band work).
useEffect(() => { setBandRx(band); }, [band]);
@@ -2127,9 +2335,12 @@ export default function App() {
setWkEnabled(!!s.enabled);
setWkPort(s.port ?? '');
setWkWpm(s.wpm ?? 25);
setWkMacros((s.macros ?? []) as WKMacro[]);
// Pad to 9 slots (F1F9) so an F9 macro always exists to fill; empty ones
// are hidden in the widget.
{ const mac = ((s.macros ?? []) as WKMacro[]).slice(); while (mac.length < 9) mac.push({ label: '', text: '' }); setWkMacros(mac); }
setWkEscClears(s.esc_clears_call !== false);
setWkSendOnType(!!s.send_on_type);
setWkEsm(!!s.esm);
setWkEngine(s.engine === 'icom' ? 'icom' : s.engine === 'flex' ? 'flex' : s.engine === 'serial' ? 'serial' : 'winkeyer');
} catch { /* keyer not configured */ }
}, []);
@@ -2336,6 +2547,32 @@ export default function App() {
WinkeyerBackspace().catch(() => {});
}
function wkToggleSendOnType(on: boolean) { setWkSendOnType(on); saveWk({ send_on_type: on }); }
function wkToggleEsm(on: boolean) { setWkEsm(on); saveWk({ esm: on }); }
// ESM (Enter Sends Message): N1MM-style CW flow. When ESM is on, the CW keyer is
// active and we're in CW, Enter fires a macro by QSO stage instead of logging:
// • callsign empty → F1 (CQ)
// • callsign entered → F2 (report), then focus jumps to RST-sent
// • focus in RST-sent/rcvd → F3 (TU) — which logs IF the macro has <LOGQSO>
// Returns true when it handled the Enter (so the caller skips the plain log).
function esmHandleEnter(target: HTMLElement): boolean {
if (!(wkEsmRef.current && wkActiveRef.current && (mode || '').toUpperCase().includes('CW'))) return false;
const field = target.closest('[data-esm]')?.getAttribute('data-esm');
if (field === 'rsttx' || field === 'rstrx') {
wkSendMacro(2); // F3 (TU) — logs via its own <LOGQSO> if present
return true;
}
if (field === 'call') {
if (callsignValRef.current.trim() === '') {
wkSendMacro(0); // F1 (CQ)
return true;
}
wkSendMacro(1); // F2 (report)
// Move focus to RST-sent so the next Enter fires F3.
(document.querySelector('[data-esm="rsttx"] input') as HTMLInputElement | null)?.focus();
return true;
}
return false; // any other field → normal behaviour (log on Enter)
}
// Resolve slot status for any spot we haven't seen yet — debounced so we
// don't hammer the backend at firehose rate. The mode passed to the
@@ -2490,6 +2727,7 @@ export default function App() {
function resetAutoFill() {
setName(''); setQth(''); setCountry(''); setGrid('');
providerGridCallRef.current = ''; // the grid field is empty again
// NOTE: don't clear `wb` here. It's owned by runWorkedBefore (fast 150 ms
// pass) and the short-callsign guard in scheduleLookup. Clearing it inside
// runLookup blanked the Worked-before table for the whole (possibly slow,
@@ -2582,25 +2820,32 @@ export default function App() {
}
// Right-click "Export filtered to ADIF (no limit)": exports every QSO that
// matches the current filter, bypassing the on-screen row threshold.
async function exportFilteredADIF() {
// fields (optional): a chosen ADIF-tag list — the "choose which fields" export.
// Empty = the standard/full behaviour (all fields, includeAppFields=false here).
async function exportFilteredADIF(fields: string[] = []) {
try {
const path = await SaveADIFFile();
if (!path) return;
const f = buildActiveFilter();
const r = await ExportADIFFiltered(path, false, { ...f, limit: 0, offset: 0 } as any);
const r = await ExportADIFFiltered(path, false, { ...f, limit: 0, offset: 0 } as any, fields);
showToast(`Exported ${r.count} QSO${r.count > 1 ? 's' : ''}${r.path}`);
} catch (e: any) { setError(String(e?.message ?? e)); }
}
// Right-click "Export selected to ADIF": only the highlighted rows.
async function exportSelectedADIF(ids: number[]) {
async function exportSelectedADIF(ids: number[], fields: string[] = []) {
if (ids.length === 0) return;
try {
const path = await SaveADIFFile();
if (!path) return;
const r = await ExportADIFSelected(path, false, ids as any);
const r = await ExportADIFSelected(path, false, ids as any, fields);
showToast(`Exported ${r.count} selected QSO${r.count > 1 ? 's' : ''}${r.path}`);
} catch (e: any) { setError(String(e?.message ?? e)); }
}
// Open the field-picker to export the highlighted rows with chosen fields.
function exportSelectedFields(ids: number[]) {
if (ids.length === 0) return;
setFieldPicker({ ids, count: ids.length });
}
// Cabrillo (contest log) export → a .log file. The contest name comes from the
// QSOs' ADIF CONTEST_ID (no prompt). All / filtered / selected mirror the ADIF ones.
async function exportCabrillo() {
@@ -2664,9 +2909,93 @@ export default function App() {
async function runWorkedBefore(call: string, dxccHint: number = 0) {
setWbBusy(true);
try { setWb(await WorkedBefore(call, dxccHint)); }
catch { setWb(null); }
finally { setWbBusy(false); }
try {
const w = await WorkedBefore(call, dxccHint);
setWb(w);
// Mirrored synchronously rather than through the effect above: a backfill
// parked by the lookup has to read this on the very next line, not a
// render later.
wbRef.current = w;
wbCallRef.current = call;
// The lookup finished before this history did and parked its backfill —
// run it now that we can answer "who did we work last?".
const p = pendingBackfillRef.current;
if (p && p.call === call) {
pendingBackfillRef.current = null;
fillFromLastQso(p.r, call);
}
} catch {
setWb(null);
wbRef.current = null;
wbCallRef.current = '';
} finally { setWbBusy(false); }
}
// fillFromLastQso enriches the entry from the LAST QSO we logged with this call
// when the live lookup came up short — the callsign isn't on QRZ/HamQTH, or no
// lookup service is configured (cty.dat then gives country/zones only). It fills
// ONLY the fields the provider left empty and the operator hasn't edited, so a
// real QRZ/HamQTH hit is never overridden. `r` is the provider result (omitted
// on a lookup error, where every provider field counts as empty).
function fillFromLastQso(r: any, call: string) {
// Only ever fill from THIS callsign's history. If worked-before hasn't
// resolved for it yet, park the request instead of reading whatever the ref
// happens to hold — that would be the previously entered station.
if (wbCallRef.current !== call) {
pendingBackfillRef.current = { call, r };
UILog(`backfill ${call}: history not in yet (have "${wbCallRef.current}") — parked`).catch(() => {});
return;
}
// Parked backfills can land late; drop it if the operator has moved on.
if (call !== callsignValRef.current.trim().toUpperCase()) {
UILog(`backfill ${call}: abandoned, entry now holds "${callsignValRef.current}"`).catch(() => {});
return;
}
const last: any = wbRef.current?.entries?.[0]; // entries are qso_date DESC → most recent
if (!last) {
UILog(`backfill ${call}: no prior QSO in the log (count=${wbRef.current?.count ?? 0}, entries=${wbRef.current?.entries?.length ?? 0})`).catch(() => {});
return;
}
const ue = userEditedRef.current;
const empty = (v: any) => (v ?? '') === '';
// One line saying what we found and what blocked each field, so this can be
// diagnosed from another operator's log instead of guessed at.
UILog(`backfill ${call}: last QSO ${last.qso_date ?? '?'} name="${last.name ?? ''}" qth="${last.qth ?? ''}" grid="${last.grid ?? ''}"`
+ ` | provider name="${r?.name ?? ''}" grid="${r?.grid ?? ''}" src=${r?.source ?? 'none'}`
+ ` | edited=[${[...ue].join(',')}]`).catch(() => {});
if (!ue.has('name') && empty(r?.name) && last.name) setName(last.name);
if (!ue.has('qth') && empty(r?.qth) && last.qth) setQth(last.qth);
if (!ue.has('country') && empty(r?.country) && last.country) setCountry(last.country);
// Grid: a REAL provider grid always wins. Otherwise the last QSO's precise
// locator beats the coarse cty.dat entity centroid the provider block set — so
// a French call resolves to its real JNxx, not the country's JN16 centroid.
const adoptLastGrid = !ue.has('grid') && empty(r?.grid) && !!last.grid;
if (adoptLastGrid) setGrid(last.grid);
setDetails((d) => {
// When we adopt the last QSO's locator, take its coordinates too (derive them
// from the grid if that QSO didn't store any) so the map + saved record match.
let lat = d.lat, lon = d.lon;
if (adoptLastGrid) {
if (last.lat != null && last.lon != null) { lat = last.lat; lon = last.lon; }
else { const ll = gridToLatLon(last.grid); if (ll) { lat = ll.lat; lon = ll.lon; } }
} else {
lat = d.lat ?? (last.lat ?? undefined);
lon = d.lon ?? (last.lon ?? undefined);
}
return {
...d,
address: d.address || last.address || '',
state: d.state || last.state || '',
cnty: d.cnty || last.cnty || '',
lat, lon,
dxcc: d.dxcc ?? (last.dxcc || undefined),
cqz: d.cqz ?? (last.cqz || undefined),
ituz: d.ituz ?? (last.ituz || undefined),
cont: d.cont || last.cont || '',
email: d.email || last.email || '',
qsl_via: d.qsl_via || last.qsl_via || '',
};
});
if (adoptLastGrid || last.grid || last.lat) setMapZoomSignal((n) => n + 1);
}
async function runLookup(call: string) {
if (call !== lastLookedUpRef.current) resetAutoFill();
@@ -2691,12 +3020,24 @@ export default function App() {
if (!ue.has('name') && (r.name ?? '') !== '') setName(r.name ?? '');
if (!ue.has('qth') && (r.qth ?? '') !== '') setQth(r.qth ?? '');
if (!ue.has('grid')) {
if ((r.grid ?? '') !== '') setGrid(r.grid ?? '');
if ((r.grid ?? '') !== '') {
setGrid(r.grid ?? '');
providerGridCallRef.current = call;
}
// No provider grid (cty.dat-only / portable): derive a 4-char grid from
// the entity centroid (e.g. Svalbard → JQ88) so the field — and the
// bearing/map — aren't empty. 4 chars signals it's entity-level, not a
// precise QTH (matches how Log4OM shows it).
else if (r.lat || r.lon) setGrid(latLonToGrid(r.lat || 0, r.lon || 0, 4));
//
// Skipped when QRZ already gave a precise grid for THIS call: lookups run
// more than once per QSO (debounced typing, then blur/Enter), and the
// provider only has a 2-second budget — one slow answer fell back to
// cty.dat and downgraded JN05JG to the France centroid JN16, while Name
// and QTH stayed (they are only written when non-empty). Same rule as
// those fields: a cty.dat answer never overwrites a richer one.
else if ((r.lat || r.lon) && providerGridCallRef.current !== call) {
setGrid(latLonToGrid(r.lat || 0, r.lon || 0, 4));
}
}
// Country/zones are exactly what cty.dat IS authoritative for — set them
// (only skipped if empty, so we never blank a known country).
@@ -2715,6 +3056,9 @@ export default function App() {
email: d.email || (r.email ?? ''),
qsl_via: d.qsl_via || (r.qsl_via ?? ''),
}));
// Backfill anything the provider didn't supply from the last time we worked
// this call (call not found on QRZ/HamQTH, or lookup off → cty.dat only).
fillFromLastQso(r, call);
if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc);
// The DX location is now known (grid set above) — force the world map to
// auto-zoom right away, so it doesn't lag behind the resolved QSO.
@@ -2734,6 +3078,8 @@ export default function App() {
if (gen === lookupGenRef.current && call === callsignValRef.current.trim().toUpperCase()) {
setLookupResult(null);
setLookupError(String(e?.message ?? e));
// Lookup failed outright — still borrow from the last logged QSO.
fillFromLastQso(undefined, call);
}
} finally {
// Only clear the spinner if we're still the current lookup — a newer one
@@ -2748,6 +3094,9 @@ export default function App() {
const call = value.trim().toUpperCase();
if (call.length < 3) {
setLookupResult(null); setWb(null);
// Drop the history and any backfill waiting on it, so clearing the field
// can't let a late one repopulate the next callsign typed.
wbRef.current = null; wbCallRef.current = ''; pendingBackfillRef.current = null;
if (lastLookedUpRef.current !== '') resetAutoFill();
return;
}
@@ -2793,17 +3142,19 @@ export default function App() {
// reload worked-before + the band matrix, making them flicker. Compared
// via the ref so it's correct even from the stale UDP closure.
if (v.trim().toUpperCase() === callsignValRef.current.trim().toUpperCase()) return;
// The callsign CHANGED (past the same-call guard) → drop the previous
// contact's award references. They're auto-added per call (live detection
// merges pickable refs into award_refs) or picked by hand, so without clearing
// here they carry over to the NEXT call — e.g. clicking one Italian spot
// (WAIP@RG), then another (WAIP@PG), then EJ7IRB still showing both. The new
// call's lookup re-detects its own refs right after. Covers wipe AND swap.
updateDetails({ award_refs: '' });
// QSO recorder: a non-empty callsign marks the QSO start (the recorder
// keeps the pre-roll from before this); clearing it discards the take.
// Recording START happens on blur (leaving the callsign field), NOT here —
// you may type a call and work it minutes later. Clearing it cancels.
if (v.trim() === '') {
QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
// Callsign wiped → drop this contact's award references. They are auto-added
// per call (live detection merges pickable refs into award_refs), so without
// this they'd carry over to the NEXT call — e.g. IT9AOT's ref lingering when
// you then type F4BPO, showing both in the F3 Awards tab.
updateDetails({ award_refs: '' });
}
const isEmpty = v.trim() === '';
if (!isEmpty && !locks.start) {
@@ -2840,7 +3191,10 @@ export default function App() {
if (exporting) return;
setShowExportChoice(true); // pick standard vs full first
}
async function runExport(includeAppFields: boolean) {
// includeAppFields is ignored when fields is a non-empty chosen set (the set
// decides everything). Used by both the standard/full buttons and the
// "choose fields" flow (global export of the whole logbook).
async function runExport(includeAppFields: boolean, fields: string[] = []) {
setShowExportChoice(false);
if (exporting) return;
setError('');
@@ -2848,9 +3202,10 @@ export default function App() {
const path = await SaveADIFFile();
if (!path) return;
setExporting(true);
const res = await ExportADIF(path, includeAppFields);
const res = await ExportADIF(path, includeAppFields, fields);
const kind = fields.length ? ` (${fields.length} fields)` : includeAppFields ? ' (full)' : ' (standard)';
// Green success toast (auto-dismiss) — not the red error banner.
showToast(`ADIF exported${includeAppFields ? ' (full)' : ' (standard)'}: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`);
showToast(`ADIF exported${kind}: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`);
} catch (e: any) {
setError(`ADIF export failed: ${String(e?.message ?? e)}`);
} finally {
@@ -2908,6 +3263,7 @@ export default function App() {
{ type: 'separator' },
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
{ type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' },
{ type: 'item', label: (cwEnabled ? '✓ ' : '') + t('tools.cwDecoder'), action: 'tools.cwdecoder' },
{ type: 'separator' },
{ type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' },
{ type: 'item', label: (contestTabEnabled ? '✓ ' : '') + t('tools.contest'), action: 'tools.contest' },
@@ -2930,7 +3286,7 @@ export default function App() {
{ type: 'separator' },
{ type: 'item', label: t('help.about'), action: 'help.about' },
]},
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);
function handleMenu(action: string) {
switch (action) {
@@ -2951,6 +3307,7 @@ export default function App() {
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
case 'tools.dvk': setDvkEnabled((v) => !v); break;
case 'tools.cwdecoder': toggleCwDecoder(); break;
case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
case 'tools.contest': setContestTabEnabled((v) => { const nv = !v; if (nv) setActiveTab('contest'); else setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); return nv; }); break;
case 'tools.alerts': setAlertsOpen(true); break;
@@ -3089,7 +3446,7 @@ export default function App() {
// them as shared consts avoids duplicating the (large) per-field JSX +
// handlers across the two layouts.
const callsignBlock = (
<div className="flex flex-col w-44">
<div className="flex flex-col w-44" data-esm="call">
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
{t('field.callsign')}
{lookupBusy && (
@@ -3162,12 +3519,12 @@ export default function App() {
</div>
);
const rstTxBlock = (
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
<div className="flex flex-col w-20" data-esm="rsttx"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
</div>
);
const rstRxBlock = (
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
<div className="flex flex-col w-20" data-esm="rstrx"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
</div>
);
@@ -3451,6 +3808,30 @@ export default function App() {
noteManualEdit();
SetCATFrequency(Math.round(mhz * 1_000_000)).catch(() => {});
};
// Mouse-wheel over a kHz digit of the top frequency readout: step the frequency
// and (if CAT is connected) QSY the rig. The display updates optimistically on
// every notch; the actual radio tune is debounced so a fast scroll doesn't flood
// the CAT link. nudgeAccumRef holds the live value across a burst (setFreqMhz is
// async, so we can't re-read it between notches).
const nudgeAccumRef = useRef<number | null>(null);
const nudgeCatTimer = useRef<number | null>(null);
const nudgeFreqHz = (deltaHz: number) => {
let cur = nudgeAccumRef.current;
if (cur == null) cur = freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0;
if (!cur) return;
const newHz = Math.max(0, cur + deltaHz);
nudgeAccumRef.current = newHz;
setFreqMhz((newHz / 1_000_000).toFixed(6));
noteManualEdit();
const b = bandForMHz(newHz / 1_000_000); if (b) setBand(b);
if (nudgeCatTimer.current) window.clearTimeout(nudgeCatTimer.current);
nudgeCatTimer.current = window.setTimeout(() => {
const hz = nudgeAccumRef.current;
nudgeAccumRef.current = null;
nudgeCatTimer.current = null;
if (hz && catState.enabled && catState.connected) SetCATFrequency(hz).catch(() => {});
}, 150);
};
const freqBlock = (
<div className="flex flex-col w-32">
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockBtn k="freq" title="frequency" /></Label>
@@ -3775,9 +4156,11 @@ export default function App() {
case 'worked':
return (
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
<WorkedBeforeGrid wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog}
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} />
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
onExportCabrilloSelected={exportSelectedCabrillo} onDelete={(ids) => setDeletingIds(ids)} />
</div>
);
case 'flex':
@@ -3810,6 +4193,7 @@ export default function App() {
<RecentQSOsGrid
key={`rqg-${activeProfileId ?? 'x'}`}
rows={qsosWithAwards as any}
myGrid={station.my_grid}
total={total}
awardCols={awardCols}
onRowDoubleClicked={(q) => openEdit(q.id as number)}
@@ -3821,6 +4205,7 @@ export default function App() {
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
onBulkEdit={openBulkEdit}
onExportSelected={exportSelectedADIF}
onExportSelectedFields={exportSelectedFields}
onExportFiltered={exportFilteredADIF}
onDelete={(ids) => setDeletingIds(ids)}
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
@@ -3831,7 +4216,7 @@ export default function App() {
};
return (
<div className="flex flex-col h-screen overflow-hidden bg-background">
<div ref={appRootRef} className="flex flex-col h-screen overflow-hidden bg-background">
<ShutdownProgress />
{/* ===== TOPBAR ===== */}
{compact ? (
@@ -3843,7 +4228,7 @@ export default function App() {
<span className="font-bold text-xs tracking-tight">OpsLog</span>
</div>
<div className="flex items-baseline gap-1.5 font-mono ml-2">
<span className="text-sm font-semibold text-primary">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
<FreqWheelDisplay mhz={freqMhz} onNudge={nudgeFreqHz} className="text-sm font-semibold text-primary" />
<span className="text-[9px] text-muted-foreground">MHz</span>
<Badge variant="accent" className="font-mono ml-2 text-[9px] py-0">{band}</Badge>
<Badge className="bg-success-muted text-success-muted-foreground hover:bg-success-muted font-mono text-[9px] py-0" variant="outline">{mode}</Badge>
@@ -3873,7 +4258,7 @@ export default function App() {
{/* Toasts and errors live in the STATUS BAR at the bottom now the
header band was too narrow and long messages were cut off. */}
<div className="flex flex-col items-end leading-none">
<span className="text-2xl font-semibold text-primary tracking-wide">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
<FreqWheelDisplay mhz={freqMhz} onNudge={nudgeFreqHz} className="text-2xl font-semibold text-primary tracking-wide" />
{catState.split && rxFreqMhz && (
<span className="text-[10px] text-muted-foreground mt-0.5">
<span className="text-danger font-semibold mr-1">RX</span>
@@ -4000,6 +4385,20 @@ export default function App() {
<Zap className="size-4" />
{wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-warning animate-pulse" />}
</button>
<button
type="button"
onClick={toggleCwDecoder}
title={cwEnabled ? (mode === 'CW' ? t('cwd.tipOnCw') : t('cwd.tipOnIdle')) : t('cwd.tipOff')}
className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
cwOn && cwStatus.active ? 'border-success bg-success-muted text-success-muted-foreground'
: cwEnabled ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
: 'border-border text-muted-foreground hover:bg-muted',
)}
>
<Ear className="size-4" />
{cwOn && cwStatus.active && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success animate-pulse" />}
</button>
<button
type="button"
onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
@@ -4027,6 +4426,35 @@ export default function App() {
{showAntGenius && agStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
</button>
)}
{tgEnabled && (
<button
type="button"
onClick={() => { const v = !showTuner; setShowTuner(v); writeUiPref('opslog.showTuner', v ? '1' : '0'); }}
title={showTuner ? 'Tuner Genius — shown · click to hide' : 'Tuner Genius · click to show'}
className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
showTuner ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
: 'border-border text-muted-foreground hover:bg-muted',
)}
>
<Gauge className="size-4" />
{showTuner && tgStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
</button>
)}
{scpEnabled && (
<button
type="button"
onClick={() => { const v = !showScp; setShowScp(v); writeUiPref('opslog.showScp', v ? '1' : '0'); }}
title={showScp ? 'Super Check Partial — shown · click to hide' : 'Super Check Partial · click to show'}
className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
showScp ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
: 'border-border text-muted-foreground hover:bg-muted',
)}
>
<SpellCheck className="size-4" />
</button>
)}
{chatAvailable && (
<button
type="button"
@@ -4385,6 +4813,9 @@ export default function App() {
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') {
e.preventDefault();
// ESM (Enter Sends Message): fire the stage-appropriate CW macro instead
// of logging. Falls through to the normal log when ESM isn't active.
if (esmHandleEnter(e.target as HTMLElement)) return;
save();
}
// ESC is handled globally (stop CW + optional callsign reset).
@@ -4495,7 +4926,7 @@ export default function App() {
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
Digital Voice Keyer take this slot when enabled (Log4OM-style);
otherwise it shows the QRZ profile photo. */}
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showScp && scpEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
// relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
// the DVK with Auto CQ) can't grow the row — the row height stays set by
// the entry strip and each widget fills that height, scrolling inside.
@@ -4582,6 +5013,29 @@ export default function App() {
/>
</div>
)}
{showTuner && tgEnabled && (
<div className="w-[230px] shrink-0 min-h-0">
<TunerGeniusPanel
status={tgStatus}
onTune={tgTune}
onBypass={tgBypass}
onOperate={tgOperate}
onActivate={tgActivate}
onClose={() => { setShowTuner(false); writeUiPref('opslog.showTuner', '0'); }}
/>
</div>
)}
{showScp && scpEnabled && (
<div className="w-[240px] shrink-0 min-h-0">
<ScpPanel
result={scpResult}
currentCall={callsign}
count={scpCount}
onPick={(c) => onCallsignInput(c, { force: true })}
onClose={() => { setShowScp(false); writeUiPref('opslog.showScp', '0'); }}
/>
</div>
)}
{dvkEnabled && (
<div className="w-[320px] shrink-0 min-h-0">
<DvkPanel
@@ -4664,6 +5118,53 @@ export default function App() {
</div>{/* /entry + aside row */}
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
{cwOn && (
<div className="ml-2.5 mt-1.5 -mb-1 w-[45%] flex items-center gap-2 rounded-md border border-success-border/70 bg-success-muted/60 px-2 py-1.5 text-xs">
<Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-success' : 'text-muted-foreground')} />
{/* Input-level meter if this stays flat with a strong signal, the RX
audio device is wrong/silent rather than a decode problem. */}
<div className="shrink-0 w-12 h-1.5 rounded bg-muted overflow-hidden" title={t('cwd.levelTip', { pct: Math.round(cwStatus.level * 100) })}>
<div className="h-full bg-success transition-[width] duration-100" style={{ width: `${Math.min(100, Math.round(cwStatus.level * 100))}%` }} />
</div>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums">
{cwStatus.wpm > 0 ? `${cwStatus.wpm} WPM` : '— WPM'} · {cwStatus.pitch > 0 ? `${cwStatus.pitch} Hz` : '— Hz'}
</span>
{/* Lock pitch: blank = Auto (follow the Flex CW pitch / search). */}
<input
type="number"
value={cwPitch}
onChange={(e) => setCwPitch(e.target.value)}
placeholder="auto"
title={t('cwd.pitchTip')}
className="shrink-0 w-14 h-5 rounded border border-success-border/70 bg-card/60 px-1 text-[10px] font-mono text-center outline-none"
/>
{/* Left-aligned single line, no scrollbar; auto-scrolled to the newest
text (see cwScrollRef effect) so the latest stays in view. */}
<div ref={cwScrollRef} className="flex-1 min-w-0 overflow-hidden font-mono leading-none flex items-center">
{cwText.trim() === '' ? (
<span className="text-muted-foreground italic">{t('cwd.listening')}</span>
) : (
<div className="inline-flex items-center whitespace-nowrap">
{cwText.trim().split(/\s+/).map((tok, i) => (
<button
key={i}
type="button"
className="mr-1 shrink-0 rounded px-1 leading-none hover:bg-success-muted/70"
title={t('cwd.useCall')}
onClick={() => onCallsignInput(tok, { force: true })}
>
{tok}
</button>
))}
</div>
)}
</div>
<button type="button" className="shrink-0 text-muted-foreground hover:text-foreground" title={t('cwd.clear')} onClick={() => setCwText('')}>
<Eraser className="size-3.5" />
</button>
</div>
)}
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
{compact ? null : <>
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
@@ -4826,6 +5327,7 @@ export default function App() {
<RecentQSOsGrid
key={`rqg2-${activeProfileId ?? 'x'}`}
rows={qsosWithAwards as any}
myGrid={station.my_grid}
total={total}
awardCols={awardCols}
onFilteredCountChange={setGridFilteredCount}
@@ -4838,6 +5340,7 @@ export default function App() {
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
onBulkEdit={openBulkEdit}
onExportSelected={exportSelectedADIF}
onExportSelectedFields={exportSelectedFields}
onExportFiltered={exportFilteredADIF}
onExportCabrilloSelected={exportSelectedCabrillo}
onExportCabrilloFiltered={exportFilteredCabrillo}
@@ -5066,9 +5569,11 @@ export default function App() {
</TabsContent>
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
<WorkedBeforeGrid wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} />
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
onExportCabrilloSelected={exportSelectedCabrillo} onDelete={(ids) => setDeletingIds(ids)} />
</TabsContent>
{/* Opened on demand from Tools QSL Manager; closable via the
@@ -5208,6 +5713,7 @@ export default function App() {
currentFreqHz={band && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
onSpotClick={handleSpotClick}
onClose={() => setBandMapShown(false)}
keyNav
/>
</div>
)}
@@ -5471,10 +5977,8 @@ export default function App() {
<Dialog open onOpenChange={(o) => { if (!o) setShowExportChoice(false); }}>
<DialogContent className="max-w-lg px-6">
<DialogHeader className="px-2">
<DialogTitle>Export ADIF</DialogTitle>
<DialogDescription>
Choose which fields to include. OpsLog writes ADIF 3.1.7.
</DialogDescription>
<DialogTitle>{t('exp.title')}</DialogTitle>
<DialogDescription>{t('exp.desc')}</DialogDescription>
</DialogHeader>
<div className="px-2 py-1 space-y-2.5">
<button
@@ -5482,30 +5986,46 @@ export default function App() {
onClick={() => runExport(false)}
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
>
<div className="font-semibold text-sm">Standard ADIF only</div>
<div className="text-xs text-muted-foreground mt-0.5">
Only fields defined in the ADIF 3.1.7 spec portable to other loggers (Log4OM, N1MM, LoTW).
Application-specific <span className="font-mono">APP_*</span> and any non-standard / vendor tags are stripped.
</div>
<div className="font-semibold text-sm">{t('exp.stdTitle')}</div>
<div className="text-xs text-muted-foreground mt-0.5">{t('exp.stdDesc')}</div>
</button>
<button
type="button"
onClick={() => runExport(true)}
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
>
<div className="font-semibold text-sm">All fields (OpsLog round-trip)</div>
<div className="text-xs text-muted-foreground mt-0.5">
Every field including application-specific <span className="font-mono">APP_*</span> and vendor tags
a lossless backup you'll re-import into OpsLog.
</div>
<div className="font-semibold text-sm">{t('exp.allTitle')}</div>
<div className="text-xs text-muted-foreground mt-0.5">{t('exp.allDesc')}</div>
</button>
<button
type="button"
onClick={() => { setShowExportChoice(false); setFieldPicker({ count: total }); }}
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
>
<div className="font-semibold text-sm">{t('exp.chooseTitle')}</div>
<div className="text-xs text-muted-foreground mt-0.5">{t('exp.chooseDesc')}</div>
</button>
</div>
<DialogFooter className="px-2 bg-transparent border-t-0">
<Button variant="outline" onClick={() => setShowExportChoice(false)}>Cancel</Button>
<Button variant="outline" onClick={() => setShowExportChoice(false)}>{t('exf.cancel')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{fieldPicker && (
<ExportFieldsDialog
open
count={fieldPicker.count}
onClose={() => setFieldPicker(null)}
onExport={(fields) => {
const fp = fieldPicker;
setFieldPicker(null);
if (!fp) return;
if (fp.ids) exportSelectedADIF(fp.ids, fields);
else runExport(false, fields);
}}
/>
)}
{pendingImportPath && (
<Dialog open onOpenChange={(o) => { if (!o) setPendingImportPath(null); }}>
<DialogContent className="max-w-lg px-6">
+23 -7
View File
@@ -70,6 +70,13 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
const { t } = useI18n();
const [rules, setRules] = useState<Rule[]>([]);
const [draft, setDraft] = useState<Rule | null>(null);
// Raw text of the callsigns box, kept separately from draft.calls. The list is
// normalised (trim + drop empties) on the way into the rule, so binding the
// textarea straight to calls.join('\n') round-trips every keystroke through
// that normalisation — a trailing newline or space is stripped before React
// re-renders, and Enter/Space appear to do nothing. Editing the raw text and
// deriving the list from it keeps typing intact.
const [callsText, setCallsText] = useState('');
const [tab, setTab] = useState('def'); // active editor tab (reset to Definition on new/select)
const [emailTo, setEmailTo] = useState('');
const [err, setErr] = useState('');
@@ -80,6 +87,12 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
}, []);
useEffect(() => { refresh(); GetAlertEmailTo().then((v) => setEmailTo(v || '')).catch(() => {}); }, [refresh]);
// loadDraft opens a rule in the editor — always through here, so the raw
// callsigns text is re-seeded from whatever rule is now being edited.
const loadDraft = (r: Rule | null) => {
setDraft(r);
setCallsText((r?.calls ?? []).join('\n'));
};
const patch = (p: Partial<Rule>) => setDraft((d) => (d ? alerts.Rule.createFrom({ ...d, ...p }) : d));
const toggleIn = (key: keyof Rule, v: string) => setDraft((d) => {
if (!d) return d;
@@ -92,14 +105,14 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
async function save() {
if (!draft) return;
if (!draft.name.trim()) { setErr(t('altm.giveName')); return; }
try { const saved = await SaveAlertRule(draft); await refresh(); setDraft(saved as Rule); setErr(''); }
try { const saved = await SaveAlertRule(draft); await refresh(); loadDraft(saved as Rule); setErr(''); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function del() {
if (!draft) return;
if (!draft.id) { setDraft(null); return; }
if (!draft.id) { loadDraft(null); return; }
if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return;
try { await DeleteAlertRule(draft.id); setDraft(null); await refresh(); }
try { await DeleteAlertRule(draft.id); loadDraft(null); await refresh(); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
@@ -116,12 +129,12 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
<div className="w-56 shrink-0 flex flex-col border border-border rounded-md">
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground flex-1">{t('altm.rules')}</span>
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { setDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { loadDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
</div>
<div className="flex-1 overflow-y-auto p-1">
{rules.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-4 text-center">{t('altm.noRules')}</div>}
{rules.map((r) => (
<button key={r.id} onClick={() => { setDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
<button key={r.id} onClick={() => { loadDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
className={cn('w-full text-left px-2 py-1.5 rounded text-xs flex items-center gap-1.5',
draft?.id === r.id ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60')}>
<span className={cn('size-1.5 rounded-full shrink-0', r.enabled ? 'bg-success' : 'bg-muted-foreground/40')} />
@@ -187,8 +200,11 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
<Label className="text-xs">{t('altm.callsigns')}</Label>
<textarea className="w-full h-52 rounded-md border border-border bg-background p-2 text-xs font-mono resize-none"
placeholder={'DL1ABC\nIW3*\n*/P'}
value={(draft.calls ?? []).join('\n')}
onChange={(e) => patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) })} />
value={callsText}
onChange={(e) => {
setCallsText(e.target.value);
patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) });
}} />
</div>
<div className="space-y-1">
<Label className="text-xs">{t('altm.countries')}</Label>
+18 -48
View File
@@ -1,6 +1,7 @@
import { useRef } from 'react';
import { Flame } from 'lucide-react';
import { useRef, useState } from 'react';
import { Flame, ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MeterBar } from '@/components/MeterBar';
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
// AmpCard renders the amplifier card exactly like the one in the FlexRadio panel,
@@ -12,51 +13,20 @@ import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from
// Controls use the multi-amp API (AmpOperate/AmpPower/… by amp id) so several amps
// can each get their own card.
const METER_SEGMENTS = 26;
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', display, segColor, compact }: {
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; display?: string;
segColor?: (frac: number) => string; compact?: boolean;
}) {
const span = hi - lo;
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
const lit = Math.round((pct / 100) * METER_SEGMENTS);
return (
<div className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
compact ? 'px-2 py-1' : 'px-2.5 py-2')}>
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
{display !== undefined ? display : (
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
)}
</span>
</div>
<div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
const on = i < lit;
const frac = i / METER_SEGMENTS;
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
return (
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
style={on
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
: { background: '#cfc6ad', opacity: 0.35 }} />
);
})}
</div>
</div>
);
}
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
function Card({ icon: Icon, title, accent, children, ckey }: { icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string }) {
// Collapsible with persisted state — same behaviour as the FlexRadio panel's Card.
const storeKey = 'opslog.cardOpen.' + (ckey || title);
const [open, setOpen] = useState(() => localStorage.getItem(storeKey) !== '0');
const toggle = () => setOpen((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<button type="button" onClick={toggle}
className={cn('w-full flex items-center gap-2 px-3 py-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left', open && 'border-b border-border/60')}>
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
</div>
<div className="p-3 space-y-3">{children}</div>
<ChevronDown className={cn('ml-auto size-4 text-muted-foreground transition-transform', !open && '-rotate-90')} />
</button>
{open && <div className="p-3 space-y-3">{children}</div>}
</div>
);
}
@@ -95,7 +65,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
if (isSPE) {
const spe = amp.spe;
return (
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${amp.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${amp.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
<div className="flex items-center gap-3 flex-wrap">
<button type="button" disabled={!spe.connected}
onClick={() => AmpOperate(amp.id, !spe.operate).catch(() => {})}
@@ -151,7 +121,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
if (isACOM) {
const acom = amp.acom;
return (
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${amp.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${amp.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
<div className="flex items-center gap-3 flex-wrap">
<button type="button" disabled={!acom.connected}
onClick={() => AmpOperate(amp.id, !acom.operate).catch(() => {})}
@@ -199,7 +169,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
const connected = !!pg.connected || viaFlex;
const fault = flex?.amp_fault;
return (
<Card icon={Flame} title={`${t('flxp.amplifier')}${flex?.amp_model ? ' · ' + flex.amp_model : (pg.model ? ' · ' + pg.model : '')} · ${amp.name}`} accent="#ea580c">
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')}${flex?.amp_model ? ' · ' + flex.amp_model : (pg.model ? ' · ' + pg.model : '')} · ${amp.name}`} accent="#ea580c">
<div className="flex items-center gap-3 flex-wrap">
<button type="button" disabled={!connected}
onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})}
@@ -242,7 +212,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
{amps.map((m) => {
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
}
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
let lo = m.lo, hi = m.hi;
@@ -250,7 +220,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
lo = 0;
hi = m.hi >= 25 ? m.hi : 25;
}
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
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} />;
})}
</div>
);
+15 -5
View File
@@ -377,7 +377,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<div className="grid grid-cols-[220px_1fr] min-h-0 overflow-hidden">
{/* Left: award list */}
<div className="border-r flex flex-col min-h-0">
<div className="border-r flex flex-col min-w-0 min-h-0">
<div className="p-2 border-b">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
@@ -422,8 +422,14 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
</Button>
</div>
{/* Right: tabbed editor for selected award */}
<div className="flex flex-col min-h-0 overflow-hidden">
{/* Right: tabbed editor for selected award.
min-w-0 is load-bearing: this sits in a `1fr` grid track, and a 1fr
track has min-width:auto — it refuses to shrink below its content's
intrinsic width, so a wide row (the band/mode chip lists, a long
translated label) grew the track and pushed the whole editor out
past the dialog instead of scrolling inside it. Same reason on the
Tabs below: a flex item defaults to min-width:auto too. */}
<div className="flex flex-col min-w-0 min-h-0 overflow-hidden">
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
{/* A fix shipped for an award this operator has customised. We did NOT
apply it — that would destroy their work — so we offer it, and say
@@ -459,7 +465,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
{!cur ? (
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
) : (
<Tabs defaultValue="info" className="flex flex-col min-h-0 overflow-hidden">
<Tabs defaultValue="info" className="flex flex-col min-w-0 min-h-0 overflow-hidden">
<TabsList className="px-3 justify-start">
<TabsTrigger value="info">{t('awed.tabInfo')}</TabsTrigger>
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
@@ -729,7 +735,11 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
</div>
</div>
<DialogFooter className="px-5 py-3 border-t !flex-row">
{/* Eight buttons on one unwrappable row fitted in English and overflowed
in French, where the labels are half again as long. Wrap instead of
widening the dialog; gap-2 replaces the base sm:space-x-2, whose
margin-left approach leaves no vertical gap once a row wraps. */}
<DialogFooter className="px-5 py-3 border-t !flex-row flex-wrap sm:space-x-0 gap-2">
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> {t('awed.resetDefaults')}</Button>
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
<Download className="size-3.5 mr-1" /> {t('awed.export')}
+2 -6
View File
@@ -216,8 +216,8 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
<span className="font-mono truncate text-[11px]">{selectedRef?.subgrp || '—'}</span>
</div>
{/* Selected ref chip */}
{selectedRef ? (
{/* Selected ref chip (nothing shown until one is picked) */}
{selectedRef && (
<div className="flex items-center gap-1.5 h-6 px-2 rounded border border-success-border bg-success-muted text-success-muted-foreground text-xs min-w-0">
<span className="font-mono font-semibold shrink-0">{selectedRef.code}</span>
<span className="truncate text-[10px] text-success-muted-foreground">{selectedRef.name}</span>
@@ -225,10 +225,6 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
<X className="size-3" />
</button>
</div>
) : (
<div className="h-6 flex items-center px-2 text-[11px] text-muted-foreground italic border border-dashed border-border rounded">
{t('awrs.pickReference')}
</div>
)}
{/* Add — references are always scoped to the contacted DXCC */}
+37 -1
View File
@@ -41,6 +41,10 @@ interface Props {
// globally from the band-map tab toolbar.
hideDigital?: boolean;
fitToBand?: boolean;
// keyNav enables Ctrl+↑ / Ctrl+↓ to hop to the next spot above / below the rig
// frequency (and tune to it). Only the docked Main-view band map sets this, so
// the multi-band Band Map tab (several maps) doesn't fight over the shortcut.
keyNav?: boolean;
}
const BAND_RANGES: Record<string, [number, number]> = {
@@ -153,7 +157,7 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
// last; ties broken by closeness to the rig freq).
const MAX_VISIBLE_SPOTS = 30;
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false }: Props) {
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, keyNav = false }: Props) {
const { t } = useI18n();
const range = BAND_RANGES[band];
const segments = SEGMENT_COLORS[band] ?? [];
@@ -362,6 +366,38 @@ 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);
+17
View File
@@ -94,6 +94,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
const dxcc = wb?.dxcc ?? 0;
const dxccName = wb?.dxcc_name ?? '';
const dxccCount = wb?.dxcc_count ?? 0;
const mwRank = wb?.mw_rank ?? 0; // ClubLog Most Wanted rank (1 = most wanted; 0 = off/unknown)
const callCount = wb?.count ?? 0; // QSOs with this exact callsign
const hasDxcc = dxcc > 0;
const newOne = hasDxcc && dxccCount === 0;
@@ -136,6 +137,20 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
const newMode = slotNew && bandWorked && !modeWorked;
const newSlot = slotNew && bandWorked && modeWorked;
// ClubLog Most Wanted rank pill (shown next to the entity name when the feature
// is on). Hotter colour the more wanted the entity is.
const mwBadge = mwRank > 0 ? (
<Badge
title={`ClubLog Most Wanted #${mwRank}`}
className={cn('px-1.5 py-0 text-[10px] font-bold shrink-0',
mwRank <= 100 ? 'bg-danger text-danger-foreground'
: mwRank <= 250 ? 'bg-warning text-warning-foreground'
: 'bg-muted text-muted-foreground')}
>
MW #{mwRank}
</Badge>
) : null;
return (
<section
className={cn(
@@ -154,12 +169,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
<strong className="text-warning-muted-foreground font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
{' '}· never worked this entity
</span>
{mwBadge}
</>
) : hasDxcc ? (
<>
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
{dxccName || `DXCC #${dxcc}`}
</Badge>
{mwBadge}
<span className="text-xs text-muted-foreground">
<strong className="text-foreground font-semibold">{dxccCount}</strong>{' '}
QSO{dxccCount > 1 ? 's' : ''} with this entity
@@ -34,6 +34,8 @@ const FIELDS: FieldDef[] = [
// My station / operator
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
// No promoted column: written into extras_json (see qso.bulkEditableExtras).
{ id: 'owner_callsign', label: 'bulk.fOwnerCallsign', group: 'My station', kind: 'text', upper: true },
{ id: 'my_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true },
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
+3 -3
View File
@@ -306,11 +306,11 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
heightClass="flex-1 min-h-0"
/>
{detected.length > 0 && (
<div className="mt-2 text-[11px] text-muted-foreground shrink-0">
<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) => (
<span key={`${r.code}@${r.ref}`} className="inline-block mr-2 font-mono">
{r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? <span className="text-muted-foreground/70"> {r.name}</span> : null}
<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>
))}
</div>
@@ -0,0 +1,139 @@
import { useEffect, useMemo, useState } from 'react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { useI18n } from '@/lib/i18n';
import { writeUiPref } from '@/lib/uiPref';
import { ADIFFields, ADIFExtraFields } from '@/../wailsjs/go/main/App';
import { adif } from '@/../wailsjs/go/models';
type FieldDef = adif.FieldDef;
const PREF_KEY = 'opslog.exportFields';
// One category card with its checkboxes + All/None. Defined at MODULE scope (not
// inside ExportFieldsDialog) so its component identity is stable across renders —
// an inner component is re-created every render, remounting the whole subtree and
// making the All/None buttons and checkboxes feel dead.
function GroupCard({ title, tags, warn, sel, allLabel, noneLabel, onAll, onNone, onToggle }: {
title: string; tags: string[]; warn?: boolean; sel: Set<string>;
allLabel: string; noneLabel: string;
onAll: (tags: string[]) => void; onNone: (tags: string[]) => void; onToggle: (name: string, on: boolean) => void;
}) {
return (
<div className={`rounded-md border p-2 ${warn ? 'border-warning-border/50 bg-warning-muted/20' : 'border-border/60'}`}>
<div className="flex items-center justify-between mb-1 gap-2">
<span className={`text-[11px] font-semibold uppercase tracking-wide ${warn ? 'text-warning-muted-foreground' : 'text-muted-foreground'}`}>{title}</span>
<span className="flex gap-1.5 shrink-0">
<button type="button" className="text-[10px] text-primary hover:underline" onClick={() => onAll(tags)}>{allLabel}</button>
<button type="button" className="text-[10px] text-muted-foreground hover:underline" onClick={() => onNone(tags)}>{noneLabel}</button>
</span>
</div>
{tags.map((name) => (
<label key={name} className="flex items-center gap-1.5 text-[11px] cursor-pointer py-0.5">
<Checkbox checked={sel.has(name)} onCheckedChange={(c) => onToggle(name, !!c)} />
<span className="font-mono break-all">{name}</span>
</label>
))}
</div>
);
}
// ExportFieldsDialog lets the operator pick exactly which ADIF fields an export
// writes. Two groups: the official ADIF 3.1.7 dictionary (grouped by category)
// and the OpsLog / non-standard tags actually present in the log's extras. The
// chosen set is returned to the parent (which runs the selected/global export)
// and persisted so it's the default next time.
export function ExportFieldsDialog({ open, count, onExport, onClose }: {
open: boolean;
count: number; // how many QSOs the pending export covers
onExport: (fields: string[]) => void;
onClose: () => void;
}) {
const { t } = useI18n();
const [official, setOfficial] = useState<FieldDef[]>([]);
const [extras, setExtras] = useState<string[]>([]);
const [sel, setSel] = useState<Set<string>>(new Set());
const defaultSet = (flds: FieldDef[]) =>
new Set(flds.filter((d) => d.promoted && !d.deprecated).map((d) => d.name));
useEffect(() => {
if (!open) return;
let cancelled = false;
(async () => {
const [f, e] = await Promise.all([ADIFFields(), ADIFExtraFields()]);
if (cancelled) return;
const flds = (f ?? []) as FieldDef[];
setOfficial(flds);
setExtras((e ?? []) as string[]);
let saved: string[] | null = null;
try { const raw = localStorage.getItem(PREF_KEY); if (raw) saved = JSON.parse(raw); } catch { /* ignore */ }
setSel(saved && saved.length ? new Set(saved.map((x) => x.toUpperCase())) : defaultSet(flds));
})().catch(() => {});
return () => { cancelled = true; };
}, [open]);
// Official fields grouped by category (deprecated ones are import-only → hidden).
const groups = useMemo(() => {
const m = new Map<string, FieldDef[]>();
for (const d of official) {
if (d.deprecated) continue;
const g = d.category || 'Misc';
(m.get(g) ?? m.set(g, []).get(g)!).push(d);
}
return [...m.entries()];
}, [official]);
const toggle = (name: string, on: boolean) =>
setSel((s) => { const n = new Set(s); if (on) n.add(name); else n.delete(name); return n; });
const setMany = (names: string[], on: boolean) =>
setSel((s) => { const n = new Set(s); for (const x of names) { if (on) n.add(x); else n.delete(x); } return n; });
const doExport = () => {
const fields = [...sel];
writeUiPref(PREF_KEY, JSON.stringify(fields));
onExport(fields);
};
const allLabel = t('exf.all');
const noneLabel = t('exf.none');
const cardProps = {
sel, allLabel, noneLabel,
onAll: (tags: string[]) => setMany(tags, true),
onNone: (tags: string[]) => setMany(tags, false),
onToggle: toggle,
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>{t('exf.title')}</DialogTitle>
<DialogDescription>{t('exf.desc')}</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2 px-1 text-[11px] text-muted-foreground">
<span>{t('exf.chosen', { n: sel.size })}</span>
<Button variant="ghost" size="sm" className="ml-auto h-6 text-[11px]" onClick={() => setSel(defaultSet(official))}>
{t('exf.defaults')}
</Button>
</div>
<div className="grid grid-cols-3 gap-3 max-h-[56vh] overflow-y-auto pr-1">
{/* OpsLog / non-standard group first (most relevant to keep or drop). */}
{extras.length > 0 && <GroupCard title={t('exf.opslogGroup')} tags={extras} warn {...cardProps} />}
{groups.map(([g, list]) => (
<GroupCard key={g} title={g} tags={list.map((d) => d.name)} {...cardProps} />
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>{t('exf.cancel')}</Button>
<Button disabled={sel.size === 0} onClick={doExport}>{t('exf.export', { n: count })}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+137 -103
View File
@@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX } from 'lucide-react';
import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX, ChevronDown, SlidersHorizontal } from 'lucide-react';
import {
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexAmpOperate,
GetPGXLStatus, PGXLSetFanMode,
GetTunerGeniusStatus, GetTunerGeniusSettings,
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
@@ -19,6 +20,9 @@ import { EventsOn } from '../../wailsjs/runtime/runtime';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { sMeterRST } from '@/lib/rst';
import { TunerCard } from '@/components/TunerCard';
import { MeterBar } from '@/components/MeterBar';
import type { TGStatus } from '@/components/TunerGeniusPanel';
type FlexState = {
available: boolean; model?: string;
@@ -219,54 +223,29 @@ function OffsetRow({ label, on, onToggle, hz, onHz, disabled, title }: {
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
// `display` overrides the numeric readout; `segColor` colours segments by their
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
const METER_SEGMENTS = 26;
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: {
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
function Card({ icon: Icon, title, accent, children, ckey, open: openProp, onToggle }: {
icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string;
open?: boolean; onToggle?: () => void; // controlled mode — lets sibling cards share one collapse state
}) {
const span = hi - lo;
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
const lit = Math.round((pct / 100) * METER_SEGMENTS);
return (
<div onClick={onClick} title={title}
className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
compact ? 'px-2 py-1' : 'px-2.5 py-2',
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
{display !== undefined ? display : (
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
)}
</span>
</div>
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
<div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
const on = i < lit;
const frac = i / METER_SEGMENTS;
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
return (
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
style={on
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
: { background: '#cfc6ad', opacity: 0.35 }} />
);
})}
</div>
{extra && !compact && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
</div>
);
}
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
// Collapsible: a chevron in the header hides the body. Uncontrolled by default,
// persisting per card (keyed by ckey, falling back to the title); when `open`/
// `onToggle` are supplied the parent owns the state (e.g. linked TX/RX cards).
const storeKey = 'opslog.cardOpen.' + (ckey || title);
const [openState, setOpenState] = useState(() => localStorage.getItem(storeKey) !== '0');
const controlled = openProp !== undefined;
const open = controlled ? openProp : openState;
const toggle = controlled
? (onToggle ?? (() => {}))
: () => setOpenState((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<button type="button" onClick={toggle}
className={cn('w-full flex items-center gap-2 px-3 py-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left', open && 'border-b border-border/60')}>
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
</div>
<div className="p-3 space-y-3">{children}</div>
<ChevronDown className={cn('ml-auto size-4 text-muted-foreground transition-transform', !open && '-rotate-90')} />
</button>
{open && <div className="p-3 space-y-3">{children}</div>}
</div>
);
}
@@ -295,6 +274,11 @@ function powerLevelLabel(pl?: string): string {
export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) {
const { t } = useI18n();
const [st, setSt] = useState<FlexState>(ZERO);
// Extra/"advanced" DSP rows (WNB + the SmartSDR v4 NRL/NRS/NRF/ANFL/AI-FFT
// block) collapse behind a button so the RECEIVE card doesn't grow tall — only
// NB/NR/ANF stay visible by default. Remembered across sessions.
const [dspOpen, setDspOpen] = useState<boolean>(() => localStorage.getItem('opslog.flexDspOpen') === '1');
const toggleDsp = () => setDspOpen((o) => { const n = !o; localStorage.setItem('opslog.flexDspOpen', n ? '1' : '0'); return n; });
const hold = useRef<Record<string, number>>({});
// Keep the host's keyer speed (used for CW-length estimates and the keyer panel)
// in step with the radio's ACTUAL CW speed — including when it's changed on the
@@ -349,6 +333,25 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
return () => { alive = false; window.clearInterval(id); };
}, []);
// TRANSMIT + RECEIVE share ONE collapse state (they sit side by side, so folding
// one folds the other and keeps the row tidy). Persisted like the other cards.
const [txrxOpen, setTxrxOpen] = useState(() => localStorage.getItem('opslog.cardOpen.txrx') !== '0');
const toggleTxrx = () => setTxrxOpen((o) => { const n = !o; localStorage.setItem('opslog.cardOpen.txrx', n ? '1' : '0'); return n; });
// Tuner Genius XL direct connection — its own card in the Flex panel (like PGXL).
const [tg, setTg] = useState<TGStatus>({ connected: false });
const [tgEnabled, setTgEnabled] = useState(false);
useEffect(() => {
let alive = true;
const tick = async () => {
try { const en: any = await GetTunerGeniusSettings(); if (alive) setTgEnabled(!!en?.enabled); } catch {}
try { const s: any = await GetTunerGeniusStatus(); if (alive && s) setTg(s as TGStatus); } catch {}
};
tick();
const id = window.setInterval(tick, 500); // fast so meters track TX (see App.tsx)
return () => { alive = false; window.clearInterval(id); };
}, []);
// Configured amplifiers (Settings → Amplifier) — possibly SEVERAL (some ops
// run two SPEs in parallel). The card shows ONE at a time; the dropdown picks
// which, and the choice is remembered per panel.
@@ -417,6 +420,9 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
return () => off();
}, [st.rit, st.rit_freq]);
const isCW = (st.mode || '').toUpperCase().includes('CW');
// Phone (voice) modes — MIC / COMP meters only make sense here, so they're
// hidden in CW and digital.
const isPhone = /\b(SSB|USB|LSB|AM|FM|DFM|NFM)\b/i.test(st.mode || '');
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
const CW_BW = [100, 200, 300, 400, 500];
@@ -545,22 +551,22 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
};
const cur = [
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
// dBm sits inline next to the S-value (no separate line below) to save height.
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={`${s.display} | ${dbm.toFixed(0)} dBm`}
title={onReportRST ? t('rst.clickToFill') : undefined}
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
); })(),
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626" value={w} />
); })(),
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
// Mic input level in dBFS — SmartSDR's scale is -40…0 dB.
mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={0} accent="#16a34a"
// Mic input level in dBFS — SmartSDR's scale is -40…0 dB. Phone modes only.
isPhone && mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={0} accent="#16a34a"
segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />,
// Speech compression — original working meter, only the top of the
// scale changed from the radio-reported 20 to 25 (SmartSDR's -25 max).
comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={25} accent="#0891b2" />,
isPhone && comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={25} accent="#0891b2" />,
].filter(Boolean);
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
@@ -577,7 +583,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
{/* TX + RX columns */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* TRANSMIT */}
<Card icon={Zap} title={t('flxp.transmit')} accent="#dc2626">
<Card icon={Zap} title={t('flxp.transmit')} accent="#dc2626" open={txrxOpen} onToggle={toggleTxrx}>
<div className="flex items-center gap-3">
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">{t('flxp.rfPower')}</span>
<Slider value={st.rf_power} disabled={off} accent="#dc2626" onChange={(v) => change('rf_power', v, () => FlexSetPower(v))} />
@@ -716,7 +722,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
</Card>
{/* RECEIVE */}
<Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2">
<Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2" open={txrxOpen} onToggle={toggleTxrx}>
{/* Antenna selection sits at the very top of the RX column. */}
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
<div className="flex items-center gap-2 pb-3 border-b border-border/60">
@@ -768,13 +774,28 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
</div>
<div className="border-t border-border/60 pt-3 space-y-3">
{/* Core noise controls stay visible; WNB + the SmartSDR v4 DSP block
hide behind the 'DSP' toggle so the card doesn't tower. When any
hidden control is ON we badge the toggle so it's not forgotten. */}
{(() => {
const hiddenOn = st.wnb || (st.dsp_v4 && (!!st.lms_nr || !!st.speex_nr || !!st.nrf || (!isCW && !!st.lms_anf) || !!st.rnn || (!isCW && !!st.anft)));
return (
<div className="flex items-center justify-between">
<span className="text-[11px] font-bold text-muted-foreground">{t('flxp.dspNoise')}</span>
<button type="button" onClick={toggleDsp} title={t('flxp.dspMore')}
className={cn('flex items-center gap-1 h-6 px-2 rounded-md border text-[10px] font-bold uppercase tracking-wide transition-colors',
hiddenOn && !dspOpen ? 'bg-primary/15 text-primary border-primary/40' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
<SlidersHorizontal className="size-3" />
DSP
{hiddenOn && !dspOpen && <span className="ml-0.5 size-1.5 rounded-full bg-primary" />}
<ChevronDown className={cn('size-3 transition-transform', dspOpen && 'rotate-180')} />
</button>
</div>
);
})()}
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))}
onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} />
{/* WNB — wideband noise blanker (the extra hardware NR the Flex has). */}
<LevelRow label="WNB" on={st.wnb} disabled={rxOff} value={st.wnb_level} accent="amber" sliderAccent="#f59e0b"
onToggle={() => change('wnb', !st.wnb, () => FlexSetWNB(!st.wnb))}
onLevel={(v) => change('wnb_level', v, () => FlexSetWNBLevel(v))} />
<LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
@@ -784,50 +805,59 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
)}
</div>
{/* SmartSDR v4 DSP (8000/Aurora series) — NRL/ANFL (legacy LMS), NRS
(spectral subtraction), NRF (NR w/ filter), RNN (AI NR), ANFT (FFT
notch). Only shown when the radio actually reports these slice keys
(older 6000s never do). API keys per the FlexLib slice docs. */}
{st.dsp_v4 && (
<div className="border-t border-border/60 pt-3 space-y-3">
<LevelRow label="NRL" on={!!st.lms_nr} disabled={rxOff} value={st.lms_nr_level ?? 0} accent="cyan" sliderAccent="#0e7490"
onToggle={() => change('lms_nr', !st.lms_nr, () => FlexSetLMSNR(!st.lms_nr))}
onLevel={(v) => change('lms_nr_level', v, () => FlexSetLMSNRLevel(v))} />
<LevelRow label="NRS" on={!!st.speex_nr} disabled={rxOff} value={st.speex_nr_level ?? 0} accent="cyan" sliderAccent="#06b6d4"
onToggle={() => change('speex_nr', !st.speex_nr, () => FlexSetSpeexNR(!st.speex_nr))}
onLevel={(v) => change('speex_nr_level', v, () => FlexSetSpeexNRLevel(v))} />
<LevelRow label="NRF" on={!!st.nrf} disabled={rxOff} value={st.nrf_level ?? 0} accent="cyan" sliderAccent="#0369a1"
onToggle={() => change('nrf', !st.nrf, () => FlexSetNRF(!st.nrf))}
onLevel={(v) => change('nrf_level', v, () => FlexSetNRFLevel(v))} />
{/* Notch filters target carriers in voice — hidden in CW like ANF. */}
{!isCW && (
<LevelRow label="ANFL" on={!!st.lms_anf} disabled={rxOff} value={st.lms_anf_level ?? 0} accent="violet" sliderAccent="#8b5cf6"
onToggle={() => change('lms_anf', !st.lms_anf, () => FlexSetLMSANF(!st.lms_anf))}
onLevel={(v) => change('lms_anf_level', v, () => FlexSetLMSANFLevel(v))} />
)}
{/* RNN and ANFT are on/off only — no level in the API. */}
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground" title={t('flxp.dspV4Hint')}>AI/FFT</span>
<button type="button" disabled={rxOff}
title={t('flxp.rnnHint')}
onClick={() => change('rnn', !st.rnn, () => FlexSetRNN(!st.rnn))}
className={cn('px-2.5 py-1 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-30',
st.rnn ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
RNN
</button>
{!isCW && (
<button type="button" disabled={rxOff}
title={t('flxp.anftHint')}
onClick={() => change('anft', !st.anft, () => FlexSetANFT(!st.anft))}
className={cn('px-2.5 py-1 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-30',
st.anft ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
ANFT
</button>
{/* Advanced / supplementary DSP — collapsed by default. WNB (wideband
noise blanker) plus the SmartSDR v4 block (8000/Aurora): NRL/ANFL
(legacy LMS), NRS (spectral subtraction), NRF (NR w/ filter), RNN
(AI NR), ANFT (FFT notch). The v4 rows only exist when the radio
reports those slice keys (older 6000s never do). */}
{dspOpen && (
<div className="space-y-3 border-t border-dashed border-border/50 pt-3">
{/* WNB — wideband noise blanker (the extra hardware NR the Flex has). */}
<LevelRow label="WNB" on={st.wnb} disabled={rxOff} value={st.wnb_level} accent="amber" sliderAccent="#f59e0b"
onToggle={() => change('wnb', !st.wnb, () => FlexSetWNB(!st.wnb))}
onLevel={(v) => change('wnb_level', v, () => FlexSetWNBLevel(v))} />
{st.dsp_v4 && (
<>
<LevelRow label="NRL" on={!!st.lms_nr} disabled={rxOff} value={st.lms_nr_level ?? 0} accent="cyan" sliderAccent="#0e7490"
onToggle={() => change('lms_nr', !st.lms_nr, () => FlexSetLMSNR(!st.lms_nr))}
onLevel={(v) => change('lms_nr_level', v, () => FlexSetLMSNRLevel(v))} />
<LevelRow label="NRS" on={!!st.speex_nr} disabled={rxOff} value={st.speex_nr_level ?? 0} accent="cyan" sliderAccent="#06b6d4"
onToggle={() => change('speex_nr', !st.speex_nr, () => FlexSetSpeexNR(!st.speex_nr))}
onLevel={(v) => change('speex_nr_level', v, () => FlexSetSpeexNRLevel(v))} />
<LevelRow label="NRF" on={!!st.nrf} disabled={rxOff} value={st.nrf_level ?? 0} accent="cyan" sliderAccent="#0369a1"
onToggle={() => change('nrf', !st.nrf, () => FlexSetNRF(!st.nrf))}
onLevel={(v) => change('nrf_level', v, () => FlexSetNRFLevel(v))} />
{/* Notch filters target carriers in voice — hidden in CW like ANF. */}
{!isCW && (
<LevelRow label="ANFL" on={!!st.lms_anf} disabled={rxOff} value={st.lms_anf_level ?? 0} accent="violet" sliderAccent="#8b5cf6"
onToggle={() => change('lms_anf', !st.lms_anf, () => FlexSetLMSANF(!st.lms_anf))}
onLevel={(v) => change('lms_anf_level', v, () => FlexSetLMSANFLevel(v))} />
)}
{/* RNN and ANFT are on/off only — no level in the API. */}
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground" title={t('flxp.dspV4Hint')}>AI/FFT</span>
<button type="button" disabled={rxOff}
title={t('flxp.rnnHint')}
onClick={() => change('rnn', !st.rnn, () => FlexSetRNN(!st.rnn))}
className={cn('px-2.5 py-1 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-30',
st.rnn ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
RNN
</button>
{!isCW && (
<button type="button" disabled={rxOff}
title={t('flxp.anftHint')}
onClick={() => change('anft', !st.anft, () => FlexSetANFT(!st.anft))}
className={cn('px-2.5 py-1 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-30',
st.anft ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
ANFT
</button>
)}
</div>
</>
)}
</div>
</div>
)}
)}
</div>
{isCW && (
<div className="border-t border-border/60 pt-3 space-y-3">
<LevelRow label="APF" on={st.apf} disabled={rxOff} value={st.apf_level} accent="emerald" sliderAccent="#16a34a"
@@ -881,7 +911,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
The Flex doesn't report SPE amps, so this card is driven by OpsLog's own
SPE link rather than the Flex amplifier object. */}
{isSPE && (
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${selAmp?.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${selAmp?.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
<div className="flex items-center gap-3 flex-wrap">
{ampPicker}
<button type="button" disabled={!spe.connected}
@@ -939,7 +969,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
{/* ACOM amplifier (serial/TCP) — shown when it's the configured amp. Driven
by OpsLog's own ACOM link (the Flex doesn't report ACOM amps). */}
{isACOM && (
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${selAmp?.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${selAmp?.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
<div className="flex items-center gap-3 flex-wrap">
{ampPicker}
<button type="button" disabled={!acom.connected}
@@ -986,7 +1016,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
PowerGenius is the selected amp type. Running an SPE Expert or ACOM hides
this Flex-reported card so two amps don't both show. */}
{st.amp_available && !isSPE && !isACOM && (
<Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
<div className="flex items-center gap-3">
{ampPicker}
<button type="button" disabled={off}
@@ -1034,7 +1064,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
{amp.map((m) => {
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
}
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
@@ -1047,7 +1077,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
lo = 0;
hi = m.hi >= 25 ? m.hi : 25;
}
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
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} />;
})}
</div>
);
@@ -1055,6 +1085,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
</Card>
)}
{/* Tuner Genius XL — 4O3A ATU, its own card when enabled (Settings →
Tuner Genius). Same card shown in Station Control. */}
{tgEnabled && <TunerCard status={tg} t={t} />}
</div>
</div>
);
+31 -6
View File
@@ -73,6 +73,24 @@ function attOptions(model?: string): { v: string; l: string }[] {
return [OFF, { v: '20', l: '20dB' }]; // IC-7300 / IC-705 / IC-7100 / default
}
// bandOfHz names the amateur band a frequency falls in, so the band row can show
// where the rig actually is. The buttons only ever SENT a frequency and carried
// no active state at all, so nothing was highlighted whatever the rig reported.
// Edges are the ITU/IARU band limits, wide enough to cover regional differences —
// out-of-band (transverter IF, general coverage RX) matches nothing, as it should.
function bandOfHz(hz?: number): string {
if (!hz || hz <= 0) return '';
const mhz = hz / 1_000_000;
const bands: [string, number, number][] = [
['160', 1.8, 2.0], ['80', 3.5, 4.0], ['60', 5.25, 5.45], ['40', 7.0, 7.3],
['30', 10.1, 10.15], ['20', 14.0, 14.35], ['17', 18.068, 18.168],
['15', 21.0, 21.45], ['12', 24.89, 24.99], ['10', 28.0, 29.7],
['6', 50.0, 54.0], ['4', 70.0, 70.5], ['2', 144.0, 148.0], ['70', 430.0, 450.0],
];
for (const [name, lo, hi] of bands) if (mhz >= lo && mhz <= hi) return name;
return '';
}
// fmtVFO renders a Hz frequency the way an Icom front panel does:
// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00".
function fmtVFO(hz?: number): string {
@@ -731,12 +749,19 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
{/* Band buttons + antenna selection. */}
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
<div className="grid grid-cols-5 gap-1.5">
{BANDS.map((b) => (
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
className="px-1 py-1.5 rounded-md text-[11px] font-bold border border-border bg-card text-foreground hover:bg-muted transition-colors">
{b.l}
</button>
))}
{BANDS.map((b) => {
const here = bandOfHz(mainHz) === b.l;
return (
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
title={here ? t('icmp.bandCurrent', { b: b.l }) : undefined}
className={cn('px-1 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
here
? 'border-primary bg-primary text-primary-foreground shadow-[0_0_8px] shadow-primary/40'
: 'border-border bg-card text-foreground hover:bg-muted')}>
{b.l}
</button>
);
})}
</div>
<Row label={t('icmp.antenna')}>
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
+46
View File
@@ -0,0 +1,46 @@
import { cn } from '@/lib/utils';
// MeterBar is the ONE LED-bar meter used across the FlexRadio panel, the amplifier
// cards and the Tuner Genius card, so every meter renders at exactly the same size
// (segment count, bar height, padding). Keep it the single source of truth — don't
// re-declare a local copy in a panel, or the meters drift out of sync.
export const METER_SEGMENTS = 26;
export function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: {
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
}) {
const span = hi - lo;
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
const lit = Math.round((pct / 100) * METER_SEGMENTS);
return (
<div onClick={onClick} title={title}
className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
compact ? 'px-2 py-1' : 'px-2.5 py-2',
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
{display !== undefined ? display : (
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
)}
</span>
</div>
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
<div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
const on = i < lit;
const frac = i / METER_SEGMENTS;
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
return (
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
style={on
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
: { background: '#cfc6ad', opacity: 0.35 }} />
);
})}
</div>
{extra && !compact && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
</div>
);
}
+67 -6
View File
@@ -5,7 +5,7 @@ import {
} from 'ag-grid-community';
import { hamlogGridTheme } from '@/lib/gridTheme';
import { AgGridReact } from 'ag-grid-react';
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus, History } from 'lucide-react';
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus, History, ChevronUp, ChevronDown } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
@@ -166,6 +166,53 @@ export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHand
catch { /* ignore */ }
}, []);
// ---- Mic pass order ------------------------------------------------------
// The on-air grid shows the stations in the order the mic goes around — NOT
// by log time. The order is owned here as a list of draft ids: new check-ins
// join at the END of the queue, logged/cancelled ones drop out, and the ⬆⬇
// buttons move the selected station. Persisted per net so a panel remount
// (tab switch) keeps the round.
const [orderIds, setOrderIds] = useState<number[]>([]);
const orderKey = openId ? `opslog.netOrder.${openId}` : '';
// Load the saved order when a net opens.
useEffect(() => {
if (!orderKey) { setOrderIds([]); return; }
try { setOrderIds(JSON.parse(localStorage.getItem(orderKey) || '[]')); }
catch { setOrderIds([]); }
}, [orderKey]);
// Reconcile with the live list: keep known ids in their order, append new
// ones, drop the gone. Persist.
useEffect(() => {
setOrderIds((cur) => {
const liveIds = active.map((a) => a.id as number).filter((id) => id != null);
const liveSet = new Set(liveIds);
const kept = cur.filter((id) => liveSet.has(id));
const keptSet = new Set(kept);
const next = [...kept, ...liveIds.filter((id) => !keptSet.has(id))];
if (next.length === cur.length && next.every((v, i) => v === cur[i])) return cur;
if (orderKey) { try { localStorage.setItem(orderKey, JSON.stringify(next)); } catch { /* quota */ } }
return next;
});
}, [active, orderKey]);
const activeOrdered = useMemo(() => {
const byId = new Map(active.map((a) => [a.id as number, a]));
return orderIds.map((id) => byId.get(id)).filter(Boolean) as QSOForm[];
}, [active, orderIds]);
// Move the selected on-air station up/down the queue.
const moveSelected = useCallback((dir: -1 | 1) => {
const id = selectedActiveIds[0];
if (id == null) return;
setOrderIds((cur) => {
const i = cur.indexOf(id);
const j = i + dir;
if (i < 0 || j < 0 || j >= cur.length) return cur;
const next = [...cur];
[next[i], next[j]] = [next[j], next[i]];
if (orderKey) { try { localStorage.setItem(orderKey, JSON.stringify(next)); } catch { /* quota */ } }
return next;
});
}, [selectedActiveIds, orderKey]);
useEffect(() => { refreshNets(); }, [refreshNets]);
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
@@ -221,11 +268,11 @@ export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHand
catch (e: any) { setError(String(e?.message ?? e)); }
}
// Active → logged (end QSO, removed from session, written to the logbook).
// Then auto-select the FIRST remaining on-air station, so the operator can
// chain "log → log → log" without re-clicking a row each time.
// Then auto-select the NEXT station in the mic-pass order, so the operator
// can chain "log → log → log" without re-clicking a row each time.
async function deactivate(id?: number) {
if (id == null) return;
const next = active.find((a) => a.id !== id); // computed BEFORE the list shrinks
const next = activeOrdered.find((a) => a.id !== id); // computed BEFORE the list shrinks
try {
await NetDeactivate(id);
await refreshActive();
@@ -356,11 +403,12 @@ export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHand
</div>
<div ref={onAirWrap} className="flex flex-col min-h-0 flex-1">
<RecentQSOsGrid
rows={active}
total={active.length}
rows={activeOrdered}
total={activeOrdered.length}
storageKey="net.onair"
selectRowSignal={selectRow}
rowDragCall
passOrder
onGridApi={(api) => { onAirApi.current = api; wireDropZones(); }}
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
onRowDoubleClicked={(q) => setEditingDraft(q)}
@@ -373,6 +421,19 @@ export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHand
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
</Button>
{/* Mic pass order: move the selected station up/down the round. */}
<div className="flex items-center gap-0.5 pl-1 border-l border-border/50">
<Button variant="ghost" size="icon" className="size-7" title={t('ncp.moveUp')}
disabled={selectedActiveIds[0] == null || orderIds.indexOf(selectedActiveIds[0]) <= 0}
onClick={() => moveSelected(-1)}>
<ChevronUp className="size-4" />
</Button>
<Button variant="ghost" size="icon" className="size-7" title={t('ncp.moveDown')}
disabled={selectedActiveIds[0] == null || orderIds.indexOf(selectedActiveIds[0]) >= orderIds.length - 1}
onClick={() => moveSelected(1)}>
<ChevronDown className="size-4" />
</Button>
</div>
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto" onClick={logAll}>
<MinusCircle className="size-3.5" /> {t('ncp.logAll', { n: active.length })}
</Button>
+11 -1
View File
@@ -15,6 +15,7 @@ type Props = {
onSendEQSL?: (ids: number[]) => void;
onBulkEdit?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void;
onExportSelectedFields?: (ids: number[]) => void;
onExportFiltered?: () => void;
onExportCabrilloSelected?: (ids: number[]) => void;
onExportCabrilloFiltered?: () => void;
@@ -35,7 +36,7 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
// which used to dismiss the menu the instant it appeared.)
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
const { t } = useI18n();
useEffect(() => {
if (!menu) return;
@@ -137,6 +138,15 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
<span>{t('qctx.exportSelectedAdif', { n })}</span>
</button>
)}
{onExportSelectedFields && (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onExportSelectedFields(menu.ids); onClose(); }}
>
<FileDown className="size-4 text-info" />
<span>{t('qctx.exportSelectedFields', { n })}</span>
</button>
)}
{onExportFiltered && (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
+128 -13
View File
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
import { useI18n } from '@/lib/i18n';
import { gridToLatLon, pathBetweenLatLon } from '@/lib/maidenhead';
// Register every Community feature once. v32+ requires explicit registration;
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
@@ -27,6 +28,9 @@ const hamlogTheme = hamlogGridTheme.withParams({ rowHeight: 32, headerHeight: 34
type Props = {
rows: QSOForm[];
total: number;
// Operator's CURRENT locator — fallback for the distance column on older QSOs
// that carry no my_grid of their own.
myGrid?: string;
// Bump this number to programmatically select every row (e.g. after a search
// in the QSL Manager, where the default is "all selected").
selectAllSignal?: number;
@@ -37,6 +41,11 @@ type Props = {
// row onto the roster to log it). Pair with onGridApi so the parent can
// register external drop zones via api.addRowDropZone.
rowDragCall?: boolean;
// Pass-order mode (NET Control on-air queue): the ROW ORDER GIVEN IN `rows`
// is the display order — a pinned "#" column numbers it, and all column
// sorting is disabled (including saved sorts) so the queue can't be shuffled
// by a header click. The parent owns/reorders the array.
passOrder?: boolean;
onGridApi?: (api: any) => void;
// storageKey scopes the persisted column layout/visibility. Omit for the main
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
@@ -53,6 +62,7 @@ type Props = {
onSendEQSL?: (ids: number[]) => void;
onBulkEdit?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void;
onExportSelectedFields?: (ids: number[]) => void;
onExportFiltered?: () => void;
onExportCabrilloSelected?: (ids: number[]) => void;
onExportCabrilloFiltered?: () => void;
@@ -105,7 +115,26 @@ export type ColEntry = ColDef<QSOForm> & { group: string; label: string; default
// hooks can't run at module level, so the component calls this with its own t.
type TFn = (key: string, vars?: Record<string, string | number>) => string;
export const makeColCatalog = (t: TFn): ColEntry[] => [
// qsoDistanceKm returns the great-circle distance for one row, in km.
//
// The QSO's OWN my_grid / my_lat / my_lon come first: a log spans years and
// portable outings, so the station the QSO was made from is not necessarily the
// one configured today. `myGrid` (the current profile) is only the fallback for
// the many older records that carry no my_* fields at all.
function qsoDistanceKm(d: any, myGrid?: string): number | undefined {
if (!d) return undefined;
const here =
(d.my_grid && gridToLatLon(d.my_grid)) ||
(d.my_lat || d.my_lon ? { lat: d.my_lat || 0, lon: d.my_lon || 0 } : null) ||
(myGrid ? gridToLatLon(myGrid) : null);
const there =
(d.grid && gridToLatLon(d.grid)) ||
(d.lat || d.lon ? { lat: d.lat || 0, lon: d.lon || 0 } : null);
if (!here || !there) return undefined;
return Math.round(pathBetweenLatLon(here, there).distanceShort);
}
export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
// ── QSO basics ──
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
@@ -140,6 +169,11 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
{ group: 'Contacted', label: t('rqg.c.age'), colId: 'age', headerName: t('rqg.c.age'), field: 'age' as any, width: 60, type: 'rightAligned' },
{ group: 'Contacted', label: t('rqg.c.lat'), colId: 'lat', headerName: t('rqg.c.lat'), field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
{ group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
// Derived, not stored: computed from the two locations at display time, like
// the cluster grid's own distance column.
{ group: 'Contacted', label: t('rqg.c.distance_km'), colId: 'distance_km', headerName: t('rqg.h.distance_km'), width: 90, type: 'rightAligned', cellClass: 'font-mono',
valueGetter: (p) => qsoDistanceKm(p.data, myGrid),
comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
{ group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 },
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
@@ -257,7 +291,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, selectAllSignal, selectRowSignal, rowDragCall, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
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) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
@@ -269,7 +303,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
// Localized column catalog — rebuilt when the language changes.
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
// Right-click: if the clicked row isn't already part of the selection,
// select just it; then open the bulk-action menu on the whole selection.
@@ -322,6 +356,39 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
saveState(AWARD_SHOWN_KEY, [...next]);
}, []);
// Award-column WIDTHS are persisted separately too, for the same reason as
// visibility: award columns are stripped from AG Grid's saved column-state
// round-trip, so their width would otherwise reset to the default on reopen.
// Stored as an array of { code, width } (code upper-cased); mirrored to the DB.
const AWARD_WIDTH_KEY = storageKey ? `hamlog.awardColWidths.${storageKey}` : 'hamlog.awardColWidths';
const awardWidthsRef = useRef<Record<string, number>>({});
const awardWidthsInit = useRef(false);
if (!awardWidthsInit.current) {
awardWidthsInit.current = true;
for (const it of (loadLocal(AWARD_WIDTH_KEY) ?? []) as any[]) {
if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width);
}
}
// Fresh machine: hydrate widths from the portable DB copy, seed the cache, and
// apply them to any award columns already on screen.
useEffect(() => {
if (loadLocal(AWARD_WIDTH_KEY)) return;
loadRemote(AWARD_WIDTH_KEY).then((remote) => {
if (!remote || !remote.length) return;
for (const it of remote as any[]) {
if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width);
}
seedLocal(AWARD_WIDTH_KEY, remote);
const api = gridRef.current?.api;
if (api && awardCols?.length) {
const ups = awardCols
.map((a) => ({ key: `award_${a.code}`, newWidth: awardWidthsRef.current[a.code.toUpperCase()] }))
.filter((u) => !!u.newWidth);
if (ups.length) api.setColumnWidths(ups);
}
});
}, []);
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
restoringRef.current = true;
const base = COL_CATALOG.map((c) => {
@@ -330,13 +397,25 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
if (rowDragCall && ((rest as any).colId === 'callsign' || (rest as any).field === 'callsign')) {
col.rowDrag = true;
}
if (passOrder) {
delete (col as any).sort; // e.g. the date column's default 'desc'
col.sortable = false;
}
return col;
});
if (passOrder) {
base.unshift({
colId: '__order', headerName: '#', width: 46, pinned: 'left',
sortable: false, resizable: false, suppressMovable: true, filter: false,
cellClass: 'font-mono text-muted-foreground tabular-nums',
valueGetter: (p) => (p.node?.rowIndex ?? 0) + 1,
} as ColDef<QSOForm>);
}
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
colId: `award_${a.code}`,
headerName: a.code,
headerTooltip: t('rqg.awardTip', { name: a.name }),
width: 110,
width: awardWidthsRef.current[a.code.toUpperCase()] ?? 110,
cellClass: 'text-[11px]',
// Visibility comes from the persisted award-code set, so a column the user
// showed reappears on reopen and one they didn't stays hidden.
@@ -344,7 +423,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
}));
return [...base, ...awards];
}, [awardCols, COL_CATALOG, t, awardShown, rowDragCall]);
}, [awardCols, COL_CATALOG, t, awardShown, rowDragCall, passOrder]);
// Enforce award-column visibility via the API after the columns (re)appear —
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
@@ -352,29 +431,41 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
useEffect(() => {
const api = gridRef.current?.api;
if (!api || !awardCols?.length) return;
const widthUps: { key: string; newWidth: number }[] = [];
for (const a of awardCols) {
const want = awardShown.has(a.code.toUpperCase());
const col = api.getColumn(`award_${a.code}`);
if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want);
// Re-apply the saved width — AG Grid keeps an existing column's width across
// a columnDefs rebuild instead of re-reading colDef.width (same quirk as hide).
const w = awardWidthsRef.current[a.code.toUpperCase()];
if (col && w && Math.round(col.getActualWidth()) !== w) widthUps.push({ key: `award_${a.code}`, newWidth: w });
}
if (widthUps.length) api.setColumnWidths(widthUps);
}, [awardCols, awardShown]);
const defaultColDef = useMemo<ColDef>(() => ({
sortable: true,
sortable: !passOrder,
resizable: true,
filter: true,
suppressMovable: false,
}), []);
}), [passOrder]);
// In pass-order mode a saved sort (from before the mode existed, or synced
// from elsewhere) must not shuffle the queue — strip sorts when restoring.
const sanitizeState = useCallback((state: any[]) => (
passOrder ? state.map((s) => ({ ...s, sort: null })) : state
), [passOrder]);
function onGridReady(e: GridReadyEvent) {
onGridApi?.(e.api);
const local = loadLocal(colStateKey);
if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
if (local) e.api.applyColumnState({ state: sanitizeState(stripAwardCols(local)) as ColumnState[], applyOrder: true });
// Fall back to the portable DB copy when the local cache is empty
// (fresh machine / after a reinstall), then re-seed the cache.
loadRemote(colStateKey).then((remote) => {
if (remote && !local) {
e.api.applyColumnState({ state: stripAwardCols(remote) as ColumnState[], applyOrder: true });
e.api.applyColumnState({ state: sanitizeState(stripAwardCols(remote)) as ColumnState[], applyOrder: true });
seedLocal(colStateKey, remote);
}
});
@@ -384,14 +475,37 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
const reportFilteredCount = useCallback((e: { api?: any }) => {
const api = e?.api ?? gridRef.current?.api;
if (api?.getDisplayedRowCount) setDispCount(api.getDisplayedRowCount());
// Pass-order "#" is a rowIndex-based valueGetter; AG-Grid keeps the same
// row node across a reorder (getRowId by id), so the number is cached and
// stale until we force it. Refresh it on every model update (a reorder
// fires one). force:true bypasses the value cache.
if (passOrder && api?.refreshCells) {
api.refreshCells({ columns: ['__order'], force: true });
}
if (!api || !onFilteredCountChange) return;
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
}, [onFilteredCountChange]);
}, [onFilteredCountChange, passOrder]);
const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState();
if (state) saveState(colStateKey, stripAwardCols(state));
}, []);
if (!state) return;
saveState(colStateKey, stripAwardCols(state));
// Award columns are stripped above, so persist their widths on the side.
let changed = false;
for (const s of state) {
const id = String((s as any)?.colId ?? '');
if (id.startsWith('award_') && (s as any).width) {
const code = id.slice('award_'.length).toUpperCase();
if (awardWidthsRef.current[code] !== (s as any).width) {
awardWidthsRef.current[code] = (s as any).width;
changed = true;
}
}
}
if (changed) {
saveState(AWARD_WIDTH_KEY, Object.entries(awardWidthsRef.current).map(([code, width]) => ({ code, width })));
}
}, [colStateKey, AWARD_WIDTH_KEY]);
// columnDefs is rebuilt whenever the award columns load OR the user toggles an
// award column (both change the memo → restoringRef flips true at line 316). Each
@@ -403,7 +517,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
useEffect(() => {
const api = gridRef.current?.api;
const local = loadLocal(colStateKey);
if (api && local) api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
if (api && local) api.applyColumnState({ state: sanitizeState(stripAwardCols(local)) as ColumnState[], applyOrder: true });
// Re-enable saving once AG Grid has settled the column events from the rebuild.
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t);
@@ -570,6 +684,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
onSendEQSL={onSendEQSL}
onBulkEdit={onBulkEdit}
onExportSelected={onExportSelected}
onExportSelectedFields={onExportSelectedFields}
onExportFiltered={onExportFiltered}
onExportCabrilloSelected={onExportCabrilloSelected}
onExportCabrilloFiltered={onExportCabrilloFiltered}
+63
View File
@@ -0,0 +1,63 @@
import { SpellCheck, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
export type ScpResult = { partial?: string[]; nplus1?: string[] };
// ScpPanel — Super Check Partial + N+1 callsign helper, split in two columns.
// Left: master calls that CONTAIN what you've typed (spot/correct a call). Right:
// calls one edit away (busted-call check). Clicking a suggestion fills the entry.
export function ScpPanel({ result, currentCall, count, onPick, onClose }: {
result: ScpResult;
currentCall: string;
count: number; // master-list size (0 = list not downloaded yet)
onPick: (call: string) => void;
onClose: () => void;
}) {
const { t } = useI18n();
const cur = (currentCall || '').trim().toUpperCase();
const partial = result.partial ?? [];
const nplus1 = result.nplus1 ?? [];
const Col = ({ title, tone, calls, empty }: { title: string; tone: string; calls: string[]; empty: string }) => (
<div className="flex-1 min-w-0 flex flex-col">
<div className={cn('text-[10px] font-bold uppercase tracking-wider px-1.5 py-1 border-b border-border/50', tone)}>{title}</div>
<div className="flex-1 min-h-0 overflow-y-auto p-1 space-y-0.5">
{calls.length === 0 ? (
<div className="text-[10px] text-muted-foreground/70 italic px-1 py-1">{empty}</div>
) : calls.map((c) => (
<button key={c} type="button" onClick={() => onPick(c)}
title={t('scp.fill', { call: c })}
className={cn('w-full text-left rounded px-1.5 py-0.5 font-mono text-xs transition-colors',
c === cur ? 'bg-success/20 text-success font-bold'
: 'hover:bg-primary/15 text-foreground/90')}>
{c}
</button>
))}
</div>
</div>
);
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">
<SpellCheck className={cn('size-4', count > 0 ? 'text-primary' : 'text-muted-foreground')} />
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">{t('scp.title')}</span>
<span className="flex-1" />
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors" title={t('scp.close')}>
<X className="size-3.5" />
</button>
</div>
{count === 0 ? (
<div className="flex-1 min-h-0 flex items-center justify-center text-[11px] text-muted-foreground italic text-center px-3">
{t('scp.noList')}
</div>
) : (
<div className="flex-1 min-h-0 flex divide-x divide-border/60">
<Col title={t('scp.partial')} tone="text-primary" calls={partial} empty={t('scp.typeMore')} />
<Col title="N+1" tone="text-warning" calls={nplus1} empty={t('scp.none')} />
</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
+40 -10
View File
@@ -9,12 +9,15 @@ import { useI18n } from '@/lib/i18n';
import { writeUiPref } from '@/lib/uiPref';
import { RotorCompass } from '@/components/RotorCompass';
import { AmpCard } from '@/components/AmpCard';
import { TunerCard } from '@/components/TunerCard';
import type { TGStatus } from '@/components/TunerGeniusPanel';
import {
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
GetRotatorHeading, RotatorGoTo, RotatorStop,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
GetAmpStatuses, GetFlexState,
GetTunerGeniusStatus, GetTunerGeniusSettings,
} from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
@@ -283,6 +286,21 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
const id = window.setInterval(load, 1500);
return () => { alive = false; window.clearInterval(id); };
}, []);
// Tuner Genius XL (4O3A): a card here too, so operators without a FlexRadio
// panel still get the controls. Re-read the enabled flag so it appears/hides
// without a restart.
const [tg, setTg] = useState<TGStatus>({ connected: false });
const [tgEnabled, setTgEnabled] = useState(false);
useEffect(() => {
let alive = true;
const load = async () => {
try { const en: any = await GetTunerGeniusSettings(); if (alive) setTgEnabled(!!en?.enabled); } catch {}
try { const s: any = await GetTunerGeniusStatus(); if (alive && s) setTg(s as TGStatus); } catch {}
};
load();
const id = window.setInterval(load, 500); // fast so meters track TX (see App.tsx)
return () => { alive = false; window.clearInterval(id); };
}, []);
const loadDevices = useCallback(async () => {
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
@@ -397,7 +415,11 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
);
};
const widgets: { id: string; node: React.ReactNode }[] = [];
// `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.
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} /> });
}
@@ -405,14 +427,16 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
}
// One card per configured amplifier (identical to the Flex panel's card).
for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} /> });
for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} />, wide: true });
// Tuner Genius XL card (identical to the Flex panel's).
if (tgEnabled) widgets.push({ id: 'tuner', node: <TunerCard status={tg} t={t} />, wide: true });
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
const widgetIds = ordered.map((w) => w.id);
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && amps.length === 0;
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && amps.length === 0 && !tgEnabled;
return (
<div className="flex-1 min-h-0 overflow-auto p-4">
@@ -442,14 +466,20 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
</div>
)}
{/* Dashboard of FIXED-WIDTH cards that wrap. "Auto" fills the window; a fixed
column count caps the container width so cards wrap onto more lines. Each
card has a grip handle (left rail) as the drag initiator (the card body is
full of buttons, so dragging the whole card was unreliable). */}
<div className="flex flex-wrap gap-4 items-start"
style={cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : undefined}>
{/* 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` } : {}) }}>
{ordered.map((w) => (
<div key={w.id} className="flex items-stretch w-[430px]"
// 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}
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
<div draggable
+167
View File
@@ -0,0 +1,167 @@
import { useRef, useState } from 'react';
import { Gauge, Radio, ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MeterBar } from '@/components/MeterBar';
import type { TGStatus, TGChannel } from '@/components/TunerGeniusPanel';
import { TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate } from '../../wailsjs/go/main/App';
// TunerCard renders the 4O3A Tuner Genius XL exactly like the amplifier card
// (AmpCard) so Station Control and the FlexRadio panel show the SAME card. It
// mirrors the native app's two channels (A / B) with their source, frequency and
// antenna, a live PWR / SWR pair, and the Tune / Bypass / Operate actions. It
// drives the backend directly (no local state) — the caller's ~1.5s poll
// reconciles the display, just like AmpCard. Meters come from the shared MeterBar
// so they're the exact same size as the Flex/amp meters.
function Card({ icon: Icon, title, accent, children, ckey }: { icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string }) {
// Collapsible with persisted state — same behaviour as the FlexRadio panel's Card.
const storeKey = 'opslog.cardOpen.' + (ckey || title);
const [open, setOpen] = useState(() => localStorage.getItem(storeKey) !== '0');
const toggle = () => setOpen((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<button type="button" onClick={toggle}
className={cn('w-full flex items-center gap-2 px-3 py-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left', open && 'border-b border-border/60')}>
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
<ChevronDown className={cn('ml-auto size-4 text-muted-foreground transition-transform', !open && '-rotate-90')} />
</button>
{open && <div className="p-3 space-y-3">{children}</div>}
</div>
);
}
// ChannelButton — one of the two RF channels (A / B). Clicking it makes that
// channel active. Shows the source (RF Sense / Flex / CAT…), frequency and
// antenna, matching the two rows of the native 4O3A app.
function ChannelButton({ letter, ch, active, ptt, threeWay, onSelect, t }: {
letter: 'A' | 'B'; ch: TGChannel; active: boolean; ptt: boolean; threeWay: boolean;
onSelect: () => void; t: (k: string, v?: any) => string;
}) {
const cls = ptt
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)]'
: active
? 'bg-gradient-to-b from-emerald-500 to-emerald-600 text-white border-emerald-400/50 shadow-[0_0_9px_rgba(16,185,129,0.4)]'
: 'bg-card text-foreground/80 border-border hover:bg-muted';
const src = ch.mode_str || '—';
const freq = ch.freq_mhz && ch.freq_mhz > 0 ? `${ch.freq_mhz.toFixed(3)} MHz` : '—';
return (
<button type="button" onClick={onSelect}
title={active ? t('tgp.chActive', { letter }) : t('tgp.chSelect', { letter })}
className={cn('flex-1 min-w-0 rounded-lg border px-2 py-1.5 text-left transition-all active:scale-[0.98]', cls)}>
<div className="flex items-center gap-1.5">
<span className="font-extrabold text-sm">{letter}</span>
<span className="text-[11px] font-semibold truncate opacity-90">{src}</span>
{ptt && <span className="ml-auto text-[9px] font-bold uppercase">TX</span>}
{!ptt && active && <span className="ml-auto text-[9px] font-bold uppercase opacity-90">{t('tgp.chActiveTag')}</span>}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="font-mono text-[11px] tabular-nums truncate">{freq}</span>
{ch.antenna != null && ch.antenna > 0 && (threeWay || ch.antenna > 0) && (
<span className="ml-auto text-[10px] font-semibold whitespace-nowrap opacity-90">{t('tgp.ant')} {ch.antenna}</span>
)}
</div>
</button>
);
}
export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?: any) => string }) {
// Peak-hold, exactly as AmpCard does it. Both cards read the same transmitter,
// but the tuner is sampled by a 400 ms TCP poll: on SSB that lands in the gaps
// between syllables as often as on a peak, so the raw reading collapses to zero
// several times a second mid-transmission and reads as a dropped link. The
// amplifier looked steady next to it only because it already smoothed this way.
// Hold the highest value seen, decaying after 2 s so it follows a real drop.
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; }
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;
const fwdW = peakHold('fwd', rawFwdW);
// SWR needs RF to mean anything: sampled on receive the device reports a
// meaningless 1.00, which would wipe the figure the operator actually wants —
// the one measured while transmitting. So show the live value during TX, keep it
// briefly afterwards so it can be read, then let it go.
//
// 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.
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.v
: undefined;
const active = status.active ?? 1;
const a: TGChannel = status.a ?? {};
const b: TGChannel = status.b ?? {};
const title = `${t('tgp.title')}${status.host ? ' · ' + status.host : ''}`;
return (
<Card icon={Gauge} title={title} accent="#f59e0b">
<div className="flex items-center gap-3 flex-wrap">
<button type="button" disabled={!connected}
onClick={() => TunerGeniusSetOperate(!status.operate).catch(() => {})}
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
status.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')}>
{status.operate ? 'OPERATE' : 'STANDBY'}
</button>
<button type="button" disabled={!connected}
onClick={() => TunerGeniusAutotune().catch(() => {})}
className={cn('inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
status.tuning ? 'bg-amber-500 text-white border-amber-500 shadow-[0_0_14px] shadow-amber-500/50 animate-pulse' : 'bg-card text-amber-600 border-amber-500 hover:bg-amber-500/10')}>
<Radio className="size-4" />{status.tuning ? t('tgp.tuning') : t('tgp.tune')}
</button>
<button type="button" disabled={!connected}
onClick={() => TunerGeniusSetBypass(!status.bypass).catch(() => {})}
className={cn('px-4 py-2 rounded-lg text-sm font-bold tracking-wide border-2 transition-all disabled:opacity-30',
status.bypass ? 'bg-sky-500 text-white border-sky-500 shadow-[0_0_12px] shadow-sky-500/40' : 'bg-card text-sky-600 border-sky-500/70 hover:bg-sky-500/10')}>
{t('tgp.bypass')}
</button>
<span className={cn('inline-flex items-center gap-1.5 text-sm', connected ? 'text-muted-foreground' : 'text-danger')}>
<span className={cn('size-2 rounded-full', connected ? 'bg-success' : 'bg-danger')} />
{connected ? (status.bypass ? t('tgp.bypassed') : t('tgp.inLine')) : t('tgp.offline')}
</span>
<div className="flex-1" />
{status.message && (
<span className="px-2 py-1 rounded bg-warning-muted text-warning-muted-foreground text-xs font-bold"> {status.message}</span>
)}
</div>
{connected && (
<>
{/* Channel A / B selector — click to make active (activate ch=1/2). */}
<div className="flex items-stretch gap-2">
<ChannelButton letter="A" ch={a} active={active === 1} ptt={!!a.ptt} threeWay={!!status.three_way}
onSelect={() => TunerGeniusActivate(1).catch(() => {})} t={t} />
<ChannelButton letter="B" ch={b} active={active === 2} ptt={!!b.ptt} threeWay={!!status.three_way}
onSelect={() => TunerGeniusActivate(2).catch(() => {})} t={t} />
</div>
{/* PWR + SWR meters, each taking half the card. The amp card's grid is
3-wide because it shows three meters; copying it here left the tuner's
two meters in the first two of three columns, with a third of the card
empty to the right of SWR. */}
<div className="grid grid-cols-2 gap-2">
<MeterBar label={t('tgp.power')} value={fwdW} unit="W" lo={0} hi={2000}
display={fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
<MeterBar label={t('tgp.swr')} value={vswr ?? 1} lo={1} hi={3}
display={vswr ? `${vswr.toFixed(2)}:1` : '—'}
segColor={(f) => (f > 0.5 ? '#dc2626' : f > 0.25 ? '#f59e0b' : '#16a34a')} />
</div>
</>
)}
</Card>
);
}
@@ -0,0 +1,176 @@
import { Gauge, X, Power, Radio } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
export type TGChannel = {
ptt?: boolean; band?: number; mode?: number; mode_str?: string; flex?: string;
freq_mhz?: number; bypass?: boolean; antenna?: number;
};
export type TGStatus = {
connected: boolean; host?: string; last_error?: string;
fwd_dbm?: number; fwd_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;
relay_c1?: number; relay_l?: number; relay_c2?: number;
};
// swrColour picks a status colour for the VSWR readout: green ≤1.5, amber ≤2.0,
// red above (the usual "safe / caution / high" ATU thresholds).
function swrColour(vswr?: number): string {
if (!vswr || vswr <= 0) return 'text-muted-foreground';
if (vswr <= 1.5) return 'text-success';
if (vswr <= 2.0) return 'text-warning';
return 'text-danger';
}
// ChanRow — a compact A/B channel line in the docked widget. Highlights the
// active channel (green) or TX (red), shows the source + frequency + antenna,
// and clicking it makes that channel active.
function ChanRow({ letter, ch, active, onSelect, t }: {
letter: 'A' | 'B'; ch: TGChannel; active: boolean; onSelect: () => void;
t: (k: string, v?: any) => string;
}) {
const ptt = !!ch.ptt;
const cls = ptt
? 'bg-gradient-to-r from-red-500 to-rose-600 text-white border-red-400/40'
: active
? 'bg-gradient-to-r from-emerald-500 to-emerald-600 text-white border-emerald-400/40'
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
const src = ch.mode_str || '—';
const freq = ch.freq_mhz && ch.freq_mhz > 0 ? `${ch.freq_mhz.toFixed(3)}` : '—';
return (
<button type="button" onClick={onSelect}
title={active ? t('tgp.chActive', { letter }) : t('tgp.chSelect', { letter })}
className={cn('w-full flex items-center gap-1.5 rounded-lg border px-2 py-1 text-left transition-all active:scale-[0.98]', cls)}>
<span className="font-extrabold text-xs w-3 shrink-0">{letter}</span>
<span className="text-[10px] font-semibold truncate opacity-90 w-12 shrink-0">{src}</span>
<span className="font-mono text-[10px] tabular-nums truncate flex-1">{freq}</span>
{ch.antenna != null && ch.antenna > 0 && (
<span className="text-[9px] font-semibold whitespace-nowrap opacity-90 shrink-0">{t('tgp.ant')}{ch.antenna}</span>
)}
{ptt && <span className="text-[9px] font-bold uppercase shrink-0">TX</span>}
</button>
);
}
// TunerGeniusPanel — compact docked widget for a 4O3A Tuner Genius XL ATU. Shows
// the live SWR / forward power, the two RF channels (A / B, click to activate),
// and Tune / Bypass / Operate. A fuller card (TunerCard) is shown in the FlexRadio
// panel and Station Control.
export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActivate, onClose }: {
status: TGStatus;
onTune: () => void;
onBypass: (on: boolean) => void;
onOperate: (on: boolean) => void;
onActivate: (ch: number) => void;
onClose: () => void;
}) {
const { t } = useI18n();
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
const active = status.active ?? 1;
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">
<Gauge className={cn('size-4', status.connected ? 'text-success drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Tuner Genius</span>
<span className="flex-1" />
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-danger')} />
<span className={status.connected ? 'text-success' : 'text-danger'}>{status.connected ? t('tgp.online') : t('tgp.offline')}</span>
</span>
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('tgp.close')}>
<X className="size-3.5" />
</button>
</div>
{!status.connected ? (
<div className="flex-1 min-h-0 flex flex-col items-center justify-center text-xs gap-2 p-3">
<div className="text-muted-foreground italic animate-pulse">{t('tgp.connecting')}</div>
{status.last_error && <div className="text-danger font-mono text-[10px] break-words text-center px-2">{status.last_error}</div>}
</div>
) : (
<div className="flex-1 min-h-0 overflow-y-auto p-2.5 space-y-2.5">
{/* SWR + forward power readouts */}
<div className="grid grid-cols-2 gap-2">
<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.swr')}</div>
<div className={cn('text-lg font-bold font-mono leading-tight', swrColour(vswr))}>
{vswr ? `${vswr.toFixed(2)}:1` : '—'}
</div>
</div>
<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` : '—'}
</div>
</div>
</div>
{/* Channel A / B — click to activate */}
<div className="space-y-1">
<ChanRow letter="A" ch={status.a ?? {}} active={active === 1} onSelect={() => onActivate(1)} t={t} />
<ChanRow letter="B" ch={status.b ?? {}} active={active === 2} onSelect={() => onActivate(2)} t={t} />
</div>
{status.message && (
<div className="rounded-lg border border-warning-border bg-warning-muted text-warning-muted-foreground text-[10px] px-2 py-1 text-center break-words">
{status.message}
</div>
)}
{/* Tune */}
<button
type="button"
onClick={onTune}
disabled={status.tuning}
title={t('tgp.tuneHint')}
className={cn(
'w-full rounded-lg text-sm font-bold py-2 border transition-all active:scale-[0.98]',
status.tuning
? 'bg-gradient-to-b from-amber-400 to-amber-600 text-white border-amber-300/60 shadow-[0_0_10px_rgba(245,158,11,0.5)] animate-pulse'
: 'bg-gradient-to-b from-primary/90 to-primary text-primary-foreground border-primary/40 hover:brightness-110',
)}
>
<span className="inline-flex items-center justify-center gap-2">
<Radio className="size-4" />
{status.tuning ? t('tgp.tuning') : t('tgp.tune')}
</span>
</button>
{/* Bypass + Operate toggles */}
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => onBypass(!status.bypass)}
title={t('tgp.bypassHint')}
className={cn(
'rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95',
status.bypass
? 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]'
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground',
)}
>
{t('tgp.bypass')}
</button>
<button
type="button"
onClick={() => onOperate(!status.operate)}
title={t('tgp.operateHint')}
className={cn(
'inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95',
status.operate
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground',
)}
>
<Power className="size-3.5" />
{status.operate ? t('tgp.operate') : t('tgp.standby')}
</button>
</div>
</div>
)}
</div>
);
}
+7 -2
View File
@@ -230,9 +230,14 @@ export function WinkeyerPanel({
{autoCall && <span className="text-[10px] text-warning/80">{t('wkp.loopHint')}</span>}
</div>
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
{/* Macro buttons F1 single-line (F-key + label) to keep the panel short.
Empty macros (no label AND no text) are hidden, like the voice keyer;
the F-number stays tied to the macro's real index so shortcuts match. */}
<div className="grid grid-cols-3 gap-1">
{macros.map((m, i) => (
{macros
.map((m, i) => ({ m, i }))
.filter(({ m }) => `${m.label ?? ''}${m.text ?? ''}`.trim() !== '')
.map(({ m, i }) => (
<button
key={i}
type="button"
+17 -2
View File
@@ -25,6 +25,8 @@ const hamlogTheme = hamlogGridTheme;
type WorkedEntry = QSOForm; // entries are now full QSO records
type Props = {
// Operator's CURRENT locator — fallback for the distance column (see catalog).
myGrid?: string;
wb: WorkedBeforeView | null;
busy: boolean;
currentCall: string;
@@ -35,6 +37,10 @@ type Props = {
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void;
onBulkEdit?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void;
onExportSelectedFields?: (ids: number[]) => void;
onExportCabrilloSelected?: (ids: number[]) => void;
onDelete?: (ids: number[]) => void;
// One column per defined award (cell = the reference this QSO counts for).
awardCols?: { code: string; name: string }[];
@@ -50,14 +56,14 @@ function fmtDate(s: any): string {
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
}
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onDelete, awardCols }: Props) {
export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null);
// Localized column catalog (shared with the Recent QSOs grid).
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
@@ -247,6 +253,11 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
</div>
</div>
{/* Same menu as Recent QSOs, minus the two "filtered" exports: there they
mean "the whole logbook under the active column filters", but this grid
is a per-callsign view rather than a filter over the log, so the entry
would quietly export everything not what it would appear to do here.
The selection-based exports and bulk edit apply unchanged. */}
<QSOContextMenu
menu={menu}
onClose={() => setMenu(null)}
@@ -256,6 +267,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
onSendTo={onSendTo}
onSendRecording={onSendRecording}
onSendEQSL={onSendEQSL}
onBulkEdit={onBulkEdit}
onExportSelected={onExportSelected}
onExportSelectedFields={onExportSelectedFields}
onExportCabrilloSelected={onExportCabrilloSelected}
onDelete={onDelete}
/>
+178 -20
View File
File diff suppressed because one or more lines are too long
+11 -3
View File
@@ -73,16 +73,24 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const load = () => {
tries += 1;
GetUIPref(LS_KEY).then((raw) => {
// Backend ANSWERED (settings store ready). Apply a valid stored value; an
// empty/invalid one means the theme is genuinely unset → keep the default.
// Either way we're done — do NOT retry (retrying only matters while the
// backend is still starting, which now surfaces as a rejected promise).
if (cancelled || userPicked.current) return;
const v = raw as ThemeChoice;
if (v && ALL.includes(v)) {
try { localStorage.setItem(LS_KEY, v); } catch { /* quota */ }
applyThemeToDom(v); // idempotent — safe to call unconditionally
setThemeState(v);
return; // restored
}
if (tries < 8) window.setTimeout(load, 300); // empty (unset or backend not ready yet) → retry
}).catch(() => { if (!cancelled && tries < 8) window.setTimeout(load, 300); });
}).catch(() => {
// Settings store not ready yet — a brief startup window before the backend
// has opened the local DB and built the store (the frontend can query it
// first). Keep retrying well past the old 2.4s cap so a dark theme isn't
// lost to the light default after an update cleared localStorage.
if (!cancelled && !userPicked.current && tries < 120) window.setTimeout(load, 300);
});
};
load();
return () => { cancelled = true; };
+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.20.11';
export const APP_VERSION = '0.21.4';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+53 -3
View File
@@ -14,6 +14,7 @@ import {extsvc} from '../models';
import {powergenius} from '../models';
import {spe} from '../models';
import {solar} from '../models';
import {tunergenius} from '../models';
import {winkeyer} from '../models';
import {alerts} from '../models';
import {audio} from '../models';
@@ -23,11 +24,14 @@ import {udp} from '../models';
import {lotwusers} from '../models';
import {lookup} from '../models';
import {netctl} from '../models';
import {scp} from '../models';
export function ACOMSetOperate(arg1:boolean):Promise<void>;
export function ACOMSetPower(arg1:boolean):Promise<void>;
export function ADIFExtraFields():Promise<Array<string>>;
export function ADIFFields():Promise<Array<adif.FieldDef>>;
export function ADIFVersion():Promise<string>;
@@ -86,6 +90,8 @@ export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Prom
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
export function CWDecoderRunning():Promise<boolean>;
export function ChatAvailable():Promise<boolean>;
export function CheckForUpdate():Promise<main.UpdateInfo>;
@@ -162,21 +168,25 @@ export function DownloadAndApplyUpdate(arg1:string):Promise<void>;
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
export function DownloadClublogMostWanted():Promise<main.ClublogMostWantedInfo>;
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
export function DownloadLoTWUsers():Promise<number>;
export function DownloadScp():Promise<number>;
export function DownloadULSCounties():Promise<void>;
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
export function ExportADIF(arg1:string,arg2:boolean,arg3:Array<string>):Promise<adif.ExportResult>;
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>;
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter,arg4:Array<string>):Promise<adif.ExportResult>;
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>;
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>,arg4:Array<string>):Promise<adif.ExportResult>;
export function ExportAward(arg1:string):Promise<string>;
@@ -366,6 +376,8 @@ export function GetCATSettings():Promise<main.CATSettings>;
export function GetCATState():Promise<cat.RigState>;
export function GetCWDecoderPitch():Promise<number>;
export function GetCatalogCodes():Promise<Array<string>>;
export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
@@ -374,6 +386,8 @@ export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
export function GetClublogMostWantedInfo():Promise<main.ClublogMostWantedInfo>;
export function GetClusterAutoConnect():Promise<boolean>;
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
@@ -448,6 +462,8 @@ export function GetRotatorSettings():Promise<main.RotatorSettings>;
export function GetSPEStatus():Promise<spe.Status>;
export function GetScpStatus():Promise<main.ScpStatus>;
export function GetSecretStatus():Promise<main.SecretStatus>;
export function GetSlotStats():Promise<qso.SlotStats>;
@@ -464,6 +480,10 @@ export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
export function GetTelemetryEnabled():Promise<boolean>;
export function GetTunerGeniusSettings():Promise<main.TunerGeniusSettings>;
export function GetTunerGeniusStatus():Promise<tunergenius.Status>;
export function GetUIPref(arg1:string):Promise<string>;
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
@@ -736,6 +756,8 @@ export function RemovePassphrase(arg1:string):Promise<void>;
export function RenameDatabase(arg1:string):Promise<void>;
export function RenameLogbook(arg1:string):Promise<void>;
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
@@ -754,6 +776,8 @@ export function RestartQSORecorder():Promise<void>;
export function RetryOfflineSync():Promise<number>;
export function RevealDataFolder():Promise<void>;
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
export function RotatorPark():Promise<void>;
@@ -828,12 +852,16 @@ export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
export function SaveTunerGeniusSettings(arg1:main.TunerGeniusSettings):Promise<void>;
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):Promise<void>;
export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
export function ScpLookup(arg1:string):Promise<scp.Result>;
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
export function SendChatMessage(arg1:string):Promise<main.ChatMessage>;
@@ -854,8 +882,12 @@ export function SetCATFrequency(arg1:number):Promise<void>;
export function SetCATMode(arg1:string):Promise<void>;
export function SetCWDecoderPitch(arg1:number):Promise<void>;
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
export function SetClublogMostWantedEnabled(arg1:boolean):Promise<void>;
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
export function SetCompactMode(arg1:boolean):Promise<void>;
@@ -864,18 +896,26 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
export function SetPassphrase(arg1:string):Promise<void>;
export function SetScpEnabled(arg1:boolean):Promise<void>;
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
export function SetUltrabeamDirection(arg1:number):Promise<void>;
export function StartCWDecoder():Promise<void>;
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
export function StopCWDecoder():Promise<void>;
export function SwitchCATRig(arg1:number):Promise<void>;
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
export function TestCloudlogUpload():Promise<string>;
export function TestClublogUpload():Promise<string>;
export function TestEQSLUpload():Promise<string>;
@@ -900,6 +940,16 @@ export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationT
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
export function TunerGeniusActivate(arg1:number):Promise<void>;
export function TunerGeniusAutotune():Promise<void>;
export function TunerGeniusSetBypass(arg1:boolean):Promise<void>;
export function TunerGeniusSetOperate(arg1:boolean):Promise<void>;
export function UILog(arg1:string):Promise<void>;
export function ULSStatus():Promise<main.ULSStatusResult>;
export function UltrabeamRetract():Promise<void>;
+102 -6
View File
@@ -10,6 +10,10 @@ export function ACOMSetPower(arg1) {
return window['go']['main']['App']['ACOMSetPower'](arg1);
}
export function ADIFExtraFields() {
return window['go']['main']['App']['ADIFExtraFields']();
}
export function ADIFFields() {
return window['go']['main']['App']['ADIFFields']();
}
@@ -126,6 +130,10 @@ export function BulkUpdateQSL(arg1, arg2) {
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
}
export function CWDecoderRunning() {
return window['go']['main']['App']['CWDecoderRunning']();
}
export function ChatAvailable() {
return window['go']['main']['App']['ChatAvailable']();
}
@@ -278,6 +286,10 @@ export function DownloadClublogCty() {
return window['go']['main']['App']['DownloadClublogCty']();
}
export function DownloadClublogMostWanted() {
return window['go']['main']['App']['DownloadClublogMostWanted']();
}
export function DownloadConfirmations(arg1, arg2, arg3) {
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3);
}
@@ -286,6 +298,10 @@ export function DownloadLoTWUsers() {
return window['go']['main']['App']['DownloadLoTWUsers']();
}
export function DownloadScp() {
return window['go']['main']['App']['DownloadScp']();
}
export function DownloadULSCounties() {
return window['go']['main']['App']['DownloadULSCounties']();
}
@@ -298,16 +314,16 @@ export function ExplainAward(arg1, arg2) {
return window['go']['main']['App']['ExplainAward'](arg1, arg2);
}
export function ExportADIF(arg1, arg2) {
return window['go']['main']['App']['ExportADIF'](arg1, arg2);
export function ExportADIF(arg1, arg2, arg3) {
return window['go']['main']['App']['ExportADIF'](arg1, arg2, arg3);
}
export function ExportADIFFiltered(arg1, arg2, arg3) {
return window['go']['main']['App']['ExportADIFFiltered'](arg1, arg2, arg3);
export function ExportADIFFiltered(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['ExportADIFFiltered'](arg1, arg2, arg3, arg4);
}
export function ExportADIFSelected(arg1, arg2, arg3) {
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3);
export function ExportADIFSelected(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3, arg4);
}
export function ExportAward(arg1) {
@@ -686,6 +702,10 @@ export function GetCATState() {
return window['go']['main']['App']['GetCATState']();
}
export function GetCWDecoderPitch() {
return window['go']['main']['App']['GetCWDecoderPitch']();
}
export function GetCatalogCodes() {
return window['go']['main']['App']['GetCatalogCodes']();
}
@@ -702,6 +722,10 @@ export function GetClublogCtyInfo() {
return window['go']['main']['App']['GetClublogCtyInfo']();
}
export function GetClublogMostWantedInfo() {
return window['go']['main']['App']['GetClublogMostWantedInfo']();
}
export function GetClusterAutoConnect() {
return window['go']['main']['App']['GetClusterAutoConnect']();
}
@@ -850,6 +874,10 @@ export function GetSPEStatus() {
return window['go']['main']['App']['GetSPEStatus']();
}
export function GetScpStatus() {
return window['go']['main']['App']['GetScpStatus']();
}
export function GetSecretStatus() {
return window['go']['main']['App']['GetSecretStatus']();
}
@@ -882,6 +910,14 @@ export function GetTelemetryEnabled() {
return window['go']['main']['App']['GetTelemetryEnabled']();
}
export function GetTunerGeniusSettings() {
return window['go']['main']['App']['GetTunerGeniusSettings']();
}
export function GetTunerGeniusStatus() {
return window['go']['main']['App']['GetTunerGeniusStatus']();
}
export function GetUIPref(arg1) {
return window['go']['main']['App']['GetUIPref'](arg1);
}
@@ -1426,6 +1462,10 @@ export function RenameDatabase(arg1) {
return window['go']['main']['App']['RenameDatabase'](arg1);
}
export function RenameLogbook(arg1) {
return window['go']['main']['App']['RenameLogbook'](arg1);
}
export function RenderEQSL(arg1, arg2) {
return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
}
@@ -1462,6 +1502,10 @@ export function RetryOfflineSync() {
return window['go']['main']['App']['RetryOfflineSync']();
}
export function RevealDataFolder() {
return window['go']['main']['App']['RevealDataFolder']();
}
export function RotatorGoTo(arg1, arg2) {
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
}
@@ -1610,6 +1654,10 @@ export function SaveStationSettings(arg1) {
return window['go']['main']['App']['SaveStationSettings'](arg1);
}
export function SaveTunerGeniusSettings(arg1) {
return window['go']['main']['App']['SaveTunerGeniusSettings'](arg1);
}
export function SaveUDPIntegration(arg1) {
return window['go']['main']['App']['SaveUDPIntegration'](arg1);
}
@@ -1622,6 +1670,10 @@ export function SaveWinkeyerSettings(arg1) {
return window['go']['main']['App']['SaveWinkeyerSettings'](arg1);
}
export function ScpLookup(arg1) {
return window['go']['main']['App']['ScpLookup'](arg1);
}
export function SearchAwardReferences(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4);
}
@@ -1662,10 +1714,18 @@ export function SetCATMode(arg1) {
return window['go']['main']['App']['SetCATMode'](arg1);
}
export function SetCWDecoderPitch(arg1) {
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
}
export function SetClublogCtyEnabled(arg1) {
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
}
export function SetClublogMostWantedEnabled(arg1) {
return window['go']['main']['App']['SetClublogMostWantedEnabled'](arg1);
}
export function SetClusterAutoConnect(arg1) {
return window['go']['main']['App']['SetClusterAutoConnect'](arg1);
}
@@ -1682,6 +1742,10 @@ export function SetPassphrase(arg1) {
return window['go']['main']['App']['SetPassphrase'](arg1);
}
export function SetScpEnabled(arg1) {
return window['go']['main']['App']['SetScpEnabled'](arg1);
}
export function SetTelemetryEnabled(arg1) {
return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
}
@@ -1694,10 +1758,18 @@ export function SetUltrabeamDirection(arg1) {
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
}
export function StartCWDecoder() {
return window['go']['main']['App']['StartCWDecoder']();
}
export function StationSetRelay(arg1, arg2, arg3) {
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
}
export function StopCWDecoder() {
return window['go']['main']['App']['StopCWDecoder']();
}
export function SwitchCATRig(arg1) {
return window['go']['main']['App']['SwitchCATRig'](arg1);
}
@@ -1706,6 +1778,10 @@ export function SyncPOTAHunterLog(arg1, arg2) {
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
}
export function TestCloudlogUpload() {
return window['go']['main']['App']['TestCloudlogUpload']();
}
export function TestClublogUpload() {
return window['go']['main']['App']['TestClublogUpload']();
}
@@ -1754,6 +1830,26 @@ export function TestUltrabeam(arg1) {
return window['go']['main']['App']['TestUltrabeam'](arg1);
}
export function TunerGeniusActivate(arg1) {
return window['go']['main']['App']['TunerGeniusActivate'](arg1);
}
export function TunerGeniusAutotune() {
return window['go']['main']['App']['TunerGeniusAutotune']();
}
export function TunerGeniusSetBypass(arg1) {
return window['go']['main']['App']['TunerGeniusSetBypass'](arg1);
}
export function TunerGeniusSetOperate(arg1) {
return window['go']['main']['App']['TunerGeniusSetOperate'](arg1);
}
export function UILog(arg1) {
return window['go']['main']['App']['UILog'](arg1);
}
export function ULSStatus() {
return window['go']['main']['App']['ULSStatus']();
}
+190
View File
@@ -1184,6 +1184,8 @@ export namespace extsvc {
export class ServiceConfig {
api_key: string;
url: string;
station_id: string;
email: string;
username: string;
password: string;
@@ -1206,6 +1208,8 @@ export namespace extsvc {
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.api_key = source["api_key"];
this.url = source["url"];
this.station_id = source["station_id"];
this.email = source["email"];
this.username = source["username"];
this.password = source["password"];
@@ -1228,6 +1232,7 @@ export namespace extsvc {
lotw: ServiceConfig;
hrdlog: ServiceConfig;
eqsl: ServiceConfig;
cloudlog: ServiceConfig;
static createFrom(source: any = {}) {
return new ExternalServices(source);
@@ -1240,6 +1245,7 @@ export namespace extsvc {
this.lotw = this.convertValues(source["lotw"], ServiceConfig);
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -1949,6 +1955,26 @@ export namespace main {
this.count = source["count"];
}
}
export class ClublogMostWantedInfo {
enabled: boolean;
loaded: boolean;
callsign: string;
date: string;
count: number;
static createFrom(source: any = {}) {
return new ClublogMostWantedInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.loaded = source["loaded"];
this.callsign = source["callsign"];
this.date = source["date"];
this.count = source["count"];
}
}
export class ContestBandRow {
band: string;
count: number;
@@ -2089,6 +2115,7 @@ export namespace main {
path: string;
default_path: string;
is_custom: boolean;
logbook_default_path: string;
static createFrom(source: any = {}) {
return new DatabaseSettings(source);
@@ -2099,6 +2126,7 @@ export namespace main {
this.path = source["path"];
this.default_path = source["default_path"];
this.is_custom = source["is_custom"];
this.logbook_default_path = source["logbook_default_path"];
}
}
export class DuplicateGroup {
@@ -2295,6 +2323,7 @@ export namespace main {
user: string;
password: string;
database: string;
sqlite_path?: string;
static createFrom(source: any = {}) {
return new MySQLSettings(source);
@@ -2308,6 +2337,7 @@ export namespace main {
this.user = source["user"];
this.password = source["password"];
this.database = source["database"];
this.sqlite_path = source["sqlite_path"];
}
}
export class OfflineStatus {
@@ -2696,6 +2726,22 @@ export namespace main {
this.com_port = source["com_port"];
}
}
export class ScpStatus {
enabled: boolean;
count: number;
updated?: string;
static createFrom(source: any = {}) {
return new ScpStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.count = source["count"];
this.updated = source["updated"];
}
}
export class SecretStatus {
has_passphrase: boolean;
unlocked: boolean;
@@ -2915,6 +2961,22 @@ export namespace main {
this.error = source["error"];
}
}
export class TunerGeniusSettings {
enabled: boolean;
host: string;
password: string;
static createFrom(source: any = {}) {
return new TunerGeniusSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.host = source["host"];
this.password = source["password"];
}
}
export class ULSStatusResult {
count: number;
updated_at: string;
@@ -2940,6 +3002,8 @@ export namespace main {
follow: boolean;
step_khz: number;
tx_inhibit: boolean;
freq_min_mhz: number;
freq_max_mhz: number;
static createFrom(source: any = {}) {
return new UltrabeamSettings(source);
@@ -2957,6 +3021,8 @@ export namespace main {
this.follow = source["follow"];
this.step_khz = source["step_khz"];
this.tx_inhibit = source["tx_inhibit"];
this.freq_min_mhz = source["freq_min_mhz"];
this.freq_max_mhz = source["freq_max_mhz"];
}
}
export class UltrabeamStatusInfo {
@@ -3041,6 +3107,7 @@ export namespace main {
engine: string;
esc_clears_call: boolean;
send_on_type: boolean;
esm: boolean;
macros: WKMacro[];
static createFrom(source: any = {}) {
@@ -3070,6 +3137,7 @@ export namespace main {
this.engine = source["engine"];
this.esc_clears_call = source["esc_clears_call"];
this.send_on_type = source["send_on_type"];
this.esm = source["esm"];
this.macros = this.convertValues(source["macros"], WKMacro);
}
@@ -3341,6 +3409,7 @@ export namespace profile {
user: string;
password: string;
database: string;
path?: string;
static createFrom(source: any = {}) {
return new ProfileDB(source);
@@ -3354,6 +3423,7 @@ export namespace profile {
this.user = source["user"];
this.password = source["password"];
this.database = source["database"];
this.path = source["path"];
}
}
export class Profile {
@@ -4225,6 +4295,7 @@ export namespace qso {
dxcc_bands: string[];
dxcc_modes: string[];
dxcc_band_modes: BandMode[];
mw_rank?: number;
band_status: BandStatus[];
static createFrom(source: any = {}) {
@@ -4249,6 +4320,7 @@ export namespace qso {
this.dxcc_bands = source["dxcc_bands"];
this.dxcc_modes = source["dxcc_modes"];
this.dxcc_band_modes = this.convertValues(source["dxcc_band_modes"], BandMode);
this.mw_rank = source["mw_rank"];
this.band_status = this.convertValues(source["band_status"], BandStatus);
}
@@ -4273,6 +4345,25 @@ export namespace qso {
}
export namespace scp {
export class Result {
partial: string[];
nplus1: string[];
static createFrom(source: any = {}) {
return new Result(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.partial = source["partial"];
this.nplus1 = source["nplus1"];
}
}
}
export namespace solar {
export class Data {
@@ -4380,6 +4471,105 @@ export namespace spe {
}
export namespace tunergenius {
export class Channel {
ptt: boolean;
band: number;
mode: number;
mode_str: string;
flex: string;
freq_mhz: number;
bypass: boolean;
antenna: number;
static createFrom(source: any = {}) {
return new Channel(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.ptt = source["ptt"];
this.band = source["band"];
this.mode = source["mode"];
this.mode_str = source["mode_str"];
this.flex = source["flex"];
this.freq_mhz = source["freq_mhz"];
this.bypass = source["bypass"];
this.antenna = source["antenna"];
}
}
export class Status {
connected: boolean;
host?: string;
last_error?: string;
fwd_dbm: number;
fwd_w: number;
swr_db: number;
vswr: number;
operate: boolean;
bypass: boolean;
tuning: boolean;
active: number;
three_way: boolean;
a: Channel;
b: Channel;
relay_c1: number;
relay_l: number;
relay_c2: number;
freq_mhz: number;
antenna: number;
message?: string;
static createFrom(source: any = {}) {
return new Status(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.connected = source["connected"];
this.host = source["host"];
this.last_error = source["last_error"];
this.fwd_dbm = source["fwd_dbm"];
this.fwd_w = source["fwd_w"];
this.swr_db = source["swr_db"];
this.vswr = source["vswr"];
this.operate = source["operate"];
this.bypass = source["bypass"];
this.tuning = source["tuning"];
this.active = source["active"];
this.three_way = source["three_way"];
this.a = this.convertValues(source["a"], Channel);
this.b = this.convertValues(source["b"], Channel);
this.relay_c1 = source["relay_c1"];
this.relay_l = source["relay_l"];
this.relay_c2 = source["relay_c2"];
this.freq_mhz = source["freq_mhz"];
this.antenna = source["antenna"];
this.message = source["message"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace udp {
export class Config {
+162 -131
View File
@@ -34,6 +34,12 @@ type Exporter struct {
// export destined for another logger; set true for a full OpsLog→OpsLog
// round-trip that preserves everything.
IncludeAppFields bool
// Fields, when non-nil, restricts the export to exactly these ADIF tags
// (uppercase) — the "choose which fields to export" mode. nil = write every
// field (the standard/full behaviour above). When set it overrides
// IncludeAppFields: an APP_/vendor tag is written iff it's in the set.
Fields map[string]bool
}
// iterator streams QSOs through fn. The three concrete sources (all, filtered,
@@ -100,7 +106,7 @@ func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (in
count := 0
err := iter(ctx, func(q qso.QSO) error {
writeRecord(bw, q, e.IncludeAppFields)
writeRecord(bw, q, e.IncludeAppFields, e.Fields)
count++
return nil
})
@@ -115,7 +121,7 @@ func SingleRecordADIF(q qso.QSO) string {
var b strings.Builder
bw := bufio.NewWriter(&b)
// Uploads target other services — keep it standard (no app-specific tags).
writeRecord(bw, q, false)
writeRecord(bw, q, false, nil)
bw.Flush()
return b.String()
}
@@ -127,7 +133,7 @@ func SingleRecordADIF(q qso.QSO) string {
func FullRecordADIF(q qso.QSO) string {
var b strings.Builder
bw := bufio.NewWriter(&b)
writeRecord(bw, q, true)
writeRecord(bw, q, true, nil)
bw.Flush()
return b.String()
}
@@ -155,161 +161,181 @@ func BatchRecordsADIF(records []string) string {
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
// mode (e.g. FT4 stored without a parent) is exported as the canonical
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool) {
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]bool) {
// allow == nil → write every promoted field (standard/full behaviour).
// Otherwise a promoted tag is written only when it's in the chosen set.
// w/wi/wf wrap the raw writers with that gate so the ~150 field lines below
// stay one-liners.
ok := func(tag string) bool { return allow == nil || allow[tag] }
w := func(tag, v string) {
if ok(tag) {
writeField(bw, tag, v)
}
}
wi := func(tag string, p *int) {
if ok(tag) {
writeIntPtr(bw, tag, p)
}
}
wf := func(tag string, p *float64, decimals int) {
if ok(tag) {
writeFloatPtr(bw, tag, p, decimals)
}
}
// --- Core ---
writeField(bw, "CALL", q.Callsign)
w("CALL", q.Callsign)
if !q.QSODate.IsZero() {
writeField(bw, "QSO_DATE", q.QSODate.UTC().Format("20060102"))
writeField(bw, "TIME_ON", q.QSODate.UTC().Format("150405"))
w("QSO_DATE", q.QSODate.UTC().Format("20060102"))
w("TIME_ON", q.QSODate.UTC().Format("150405"))
}
if !q.QSODateOff.IsZero() {
writeField(bw, "QSO_DATE_OFF", q.QSODateOff.UTC().Format("20060102"))
writeField(bw, "TIME_OFF", q.QSODateOff.UTC().Format("150405"))
w("QSO_DATE_OFF", q.QSODateOff.UTC().Format("20060102"))
w("TIME_OFF", q.QSODateOff.UTC().Format("150405"))
}
writeField(bw, "BAND", q.Band)
writeField(bw, "BAND_RX", q.BandRX)
w("BAND", q.Band)
w("BAND_RX", q.BandRX)
mode, submode := modeForExport(q.Mode, q.Submode)
writeField(bw, "MODE", mode)
writeField(bw, "SUBMODE", submode)
w("MODE", mode)
w("SUBMODE", submode)
if q.FreqHz != nil && *q.FreqHz > 0 {
writeField(bw, "FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
w("FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
}
if q.FreqRXHz != nil && *q.FreqRXHz > 0 {
writeField(bw, "FREQ_RX", strconv.FormatFloat(float64(*q.FreqRXHz)/1_000_000, 'f', 6, 64))
w("FREQ_RX", strconv.FormatFloat(float64(*q.FreqRXHz)/1_000_000, 'f', 6, 64))
}
writeField(bw, "RST_SENT", q.RSTSent)
writeField(bw, "RST_RCVD", q.RSTRcvd)
w("RST_SENT", q.RSTSent)
w("RST_RCVD", q.RSTRcvd)
// --- Contacted ---
writeField(bw, "NAME", q.Name)
writeField(bw, "QTH", q.QTH)
writeField(bw, "ADDRESS", q.Address)
writeField(bw, "EMAIL", q.Email)
writeField(bw, "WEB", q.Web)
writeField(bw, "GRIDSQUARE", q.Grid)
writeField(bw, "GRIDSQUARE_EXT", q.GridExt)
writeField(bw, "VUCC_GRIDS", q.VUCCGrids)
writeField(bw, "COUNTRY", q.Country)
writeField(bw, "STATE", q.State)
writeField(bw, "CNTY", q.County)
writeIntPtr(bw, "DXCC", q.DXCC)
writeField(bw, "CONT", q.Continent)
writeIntPtr(bw, "CQZ", q.CQZ)
writeIntPtr(bw, "ITUZ", q.ITUZ)
writeField(bw, "IOTA", q.IOTA)
writeField(bw, "SOTA_REF", q.SOTARef)
writeField(bw, "POTA_REF", q.POTARef)
writeIntPtr(bw, "AGE", q.Age)
writeFloatPtr(bw, "LAT", q.Lat, 6)
writeFloatPtr(bw, "LON", q.Lon, 6)
writeField(bw, "RIG", q.Rig)
writeField(bw, "ANT", q.Ant)
w("NAME", q.Name)
w("QTH", q.QTH)
w("ADDRESS", q.Address)
w("EMAIL", q.Email)
w("WEB", q.Web)
w("GRIDSQUARE", q.Grid)
w("GRIDSQUARE_EXT", q.GridExt)
w("VUCC_GRIDS", q.VUCCGrids)
w("COUNTRY", q.Country)
w("STATE", q.State)
w("CNTY", q.County)
wi("DXCC", q.DXCC)
w("CONT", q.Continent)
wi("CQZ", q.CQZ)
wi("ITUZ", q.ITUZ)
w("IOTA", q.IOTA)
w("SOTA_REF", q.SOTARef)
w("POTA_REF", q.POTARef)
wi("AGE", q.Age)
wf("LAT", q.Lat, 6)
wf("LON", q.Lon, 6)
w("RIG", q.Rig)
w("ANT", q.Ant)
// --- QSL / LoTW / eQSL / Clublog / HRDLog ---
writeField(bw, "QSL_SENT", q.QSLSent)
writeField(bw, "QSL_RCVD", q.QSLRcvd)
writeField(bw, "QSLSDATE", q.QSLSentDate)
writeField(bw, "QSLRDATE", q.QSLRcvdDate)
writeField(bw, "QSL_VIA", q.QSLVia)
writeField(bw, "QSLMSG", q.QSLMsg)
writeField(bw, "QSLMSG_RCVD", q.QSLMsgRcvd)
writeField(bw, "LOTW_QSL_SENT", q.LOTWSent)
writeField(bw, "LOTW_QSL_RCVD", q.LOTWRcvd)
writeField(bw, "LOTW_QSLSDATE", q.LOTWSentDate)
writeField(bw, "LOTW_QSLRDATE", q.LOTWRcvdDate)
writeField(bw, "EQSL_QSL_SENT", q.EQSLSent)
writeField(bw, "EQSL_QSL_RCVD", q.EQSLRcvd)
writeField(bw, "EQSL_QSLSDATE", q.EQSLSentDate)
writeField(bw, "EQSL_QSLRDATE", q.EQSLRcvdDate)
writeField(bw, "CLUBLOG_QSO_UPLOAD_DATE", q.ClublogUploadDate)
writeField(bw, "CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus)
writeField(bw, "HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate)
writeField(bw, "HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
writeField(bw, "QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
writeField(bw, "QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
writeField(bw, "QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
writeField(bw, "QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
w("QSL_SENT", q.QSLSent)
w("QSL_RCVD", q.QSLRcvd)
w("QSLSDATE", q.QSLSentDate)
w("QSLRDATE", q.QSLRcvdDate)
w("QSL_VIA", q.QSLVia)
w("QSLMSG", q.QSLMsg)
w("QSLMSG_RCVD", q.QSLMsgRcvd)
w("LOTW_QSL_SENT", q.LOTWSent)
w("LOTW_QSL_RCVD", q.LOTWRcvd)
w("LOTW_QSLSDATE", q.LOTWSentDate)
w("LOTW_QSLRDATE", q.LOTWRcvdDate)
w("EQSL_QSL_SENT", q.EQSLSent)
w("EQSL_QSL_RCVD", q.EQSLRcvd)
w("EQSL_QSLSDATE", q.EQSLSentDate)
w("EQSL_QSLRDATE", q.EQSLRcvdDate)
w("CLUBLOG_QSO_UPLOAD_DATE", q.ClublogUploadDate)
w("CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus)
w("HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate)
w("HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
w("QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
w("QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
// --- Contest ---
writeField(bw, "CONTEST_ID", q.ContestID)
writeIntPtr(bw, "SRX", q.SRX)
writeIntPtr(bw, "STX", q.STX)
writeField(bw, "SRX_STRING", q.SRXString)
writeField(bw, "STX_STRING", q.STXString)
writeField(bw, "CHECK", q.Check)
writeField(bw, "PRECEDENCE", q.Precedence)
writeField(bw, "ARRL_SECT", q.ARRLSect)
w("CONTEST_ID", q.ContestID)
wi("SRX", q.SRX)
wi("STX", q.STX)
w("SRX_STRING", q.SRXString)
w("STX_STRING", q.STXString)
w("CHECK", q.Check)
w("PRECEDENCE", q.Precedence)
w("ARRL_SECT", q.ARRLSect)
// --- Satellite / propagation ---
writeField(bw, "PROP_MODE", q.PropMode)
writeField(bw, "SAT_NAME", q.SatName)
writeField(bw, "SAT_MODE", q.SatMode)
writeFloatPtr(bw, "ANT_AZ", q.AntAz, 1)
writeFloatPtr(bw, "ANT_EL", q.AntEl, 1)
writeField(bw, "ANT_PATH", q.AntPath)
w("PROP_MODE", q.PropMode)
w("SAT_NAME", q.SatName)
w("SAT_MODE", q.SatMode)
wf("ANT_AZ", q.AntAz, 1)
wf("ANT_EL", q.AntEl, 1)
w("ANT_PATH", q.AntPath)
// --- My station / operator ---
writeField(bw, "STATION_CALLSIGN", q.StationCallsign)
writeField(bw, "OPERATOR", q.Operator)
writeField(bw, "MY_GRIDSQUARE", q.MyGrid)
writeField(bw, "MY_GRIDSQUARE_EXT", q.MyGridExt)
writeField(bw, "MY_COUNTRY", q.MyCountry)
writeField(bw, "MY_STATE", q.MyState)
writeField(bw, "MY_CNTY", q.MyCounty)
writeField(bw, "MY_IOTA", q.MyIOTA)
writeField(bw, "MY_SOTA_REF", q.MySOTARef)
writeField(bw, "MY_POTA_REF", q.MyPOTARef)
writeIntPtr(bw, "MY_DXCC", q.MyDXCC)
writeIntPtr(bw, "MY_CQ_ZONE", q.MyCQZone)
writeIntPtr(bw, "MY_ITU_ZONE", q.MyITUZone)
writeFloatPtr(bw, "MY_LAT", q.MyLat, 6)
writeFloatPtr(bw, "MY_LON", q.MyLon, 6)
writeField(bw, "MY_STREET", q.MyStreet)
writeField(bw, "MY_CITY", q.MyCity)
writeField(bw, "MY_POSTAL_CODE", q.MyPostalCode)
writeField(bw, "MY_RIG", q.MyRig)
writeField(bw, "MY_ANTENNA", q.MyAntenna)
w("STATION_CALLSIGN", q.StationCallsign)
w("OPERATOR", q.Operator)
w("MY_GRIDSQUARE", q.MyGrid)
w("MY_GRIDSQUARE_EXT", q.MyGridExt)
w("MY_COUNTRY", q.MyCountry)
w("MY_STATE", q.MyState)
w("MY_CNTY", q.MyCounty)
w("MY_IOTA", q.MyIOTA)
w("MY_SOTA_REF", q.MySOTARef)
w("MY_POTA_REF", q.MyPOTARef)
wi("MY_DXCC", q.MyDXCC)
wi("MY_CQ_ZONE", q.MyCQZone)
wi("MY_ITU_ZONE", q.MyITUZone)
wf("MY_LAT", q.MyLat, 6)
wf("MY_LON", q.MyLon, 6)
w("MY_STREET", q.MyStreet)
w("MY_CITY", q.MyCity)
w("MY_POSTAL_CODE", q.MyPostalCode)
w("MY_RIG", q.MyRig)
w("MY_ANTENNA", q.MyAntenna)
// --- Misc ---
writeFloatPtr(bw, "TX_PWR", q.TXPower, 1)
writeField(bw, "COMMENT", q.Comment)
writeField(bw, "NOTES", q.Notes)
wf("TX_PWR", q.TXPower, 1)
w("COMMENT", q.Comment)
w("NOTES", q.Notes)
// --- ADIF 3.1.7 additional promoted fields ---
writeField(bw, "SIG", q.SIG)
writeField(bw, "SIG_INFO", q.SIGInfo)
writeField(bw, "MY_SIG", q.MySIG)
writeField(bw, "MY_SIG_INFO", q.MySIGInfo)
writeField(bw, "WWFF_REF", q.WWFFRef)
writeField(bw, "MY_WWFF_REF", q.MyWWFFRef)
writeFloatPtr(bw, "DISTANCE", q.Distance, 1)
writeFloatPtr(bw, "RX_PWR", q.RXPower, 1)
writeFloatPtr(bw, "A_INDEX", q.AIndex, 0)
writeFloatPtr(bw, "K_INDEX", q.KIndex, 0)
writeFloatPtr(bw, "SFI", q.SFI, 0)
writeField(bw, "SKCC", q.SKCC)
writeField(bw, "FISTS", q.FISTS)
writeField(bw, "TEN_TEN", q.TenTen)
writeField(bw, "CONTACTED_OP", q.ContactedOp)
writeField(bw, "EQ_CALL", q.EqCall)
writeField(bw, "PFX", q.PFX)
writeField(bw, "MY_NAME", q.MyName)
writeField(bw, "CLASS", q.Class)
writeField(bw, "DARC_DOK", q.DarcDOK)
writeField(bw, "MY_DARC_DOK", q.MyDarcDOK)
writeField(bw, "REGION", q.Region)
writeField(bw, "SILENT_KEY", q.SilentKey)
writeField(bw, "SWL", q.SWL)
writeField(bw, "QSO_COMPLETE", q.QSOComplete)
writeField(bw, "QSO_RANDOM", q.QSORandom)
writeField(bw, "CREDIT_GRANTED", q.CreditGranted)
writeField(bw, "CREDIT_SUBMITTED", q.CreditSubmitted)
writeField(bw, "MY_ARRL_SECT", q.MyARRLSect)
writeField(bw, "MY_VUCC_GRIDS", q.MyVUCCGrids)
w("SIG", q.SIG)
w("SIG_INFO", q.SIGInfo)
w("MY_SIG", q.MySIG)
w("MY_SIG_INFO", q.MySIGInfo)
w("WWFF_REF", q.WWFFRef)
w("MY_WWFF_REF", q.MyWWFFRef)
wf("DISTANCE", q.Distance, 1)
wf("RX_PWR", q.RXPower, 1)
wf("A_INDEX", q.AIndex, 0)
wf("K_INDEX", q.KIndex, 0)
wf("SFI", q.SFI, 0)
w("SKCC", q.SKCC)
w("FISTS", q.FISTS)
w("TEN_TEN", q.TenTen)
w("CONTACTED_OP", q.ContactedOp)
w("EQ_CALL", q.EqCall)
w("PFX", q.PFX)
w("MY_NAME", q.MyName)
w("CLASS", q.Class)
w("DARC_DOK", q.DarcDOK)
w("MY_DARC_DOK", q.MyDarcDOK)
w("REGION", q.Region)
w("SILENT_KEY", q.SilentKey)
w("SWL", q.SWL)
w("QSO_COMPLETE", q.QSOComplete)
w("QSO_RANDOM", q.QSORandom)
w("CREDIT_GRANTED", q.CreditGranted)
w("CREDIT_SUBMITTED", q.CreditSubmitted)
w("MY_ARRL_SECT", q.MyARRLSect)
w("MY_VUCC_GRIDS", q.MyVUCCGrids)
// --- Extras (unpromoted ADIF fields preserved verbatim) ---
// Standard mode emits ONLY valid ADIF-spec fields, so it drops APP_*
@@ -318,7 +344,12 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool) {
// extra for a lossless OpsLog round-trip.
for k, v := range q.Extras {
tag := strings.ToUpper(k)
if !includeApp && (strings.HasPrefix(tag, "APP_") || !IsStandardField(tag)) {
if allow != nil {
// Chosen-fields mode: the set alone decides (incl. APP_/vendor tags).
if !allow[tag] {
continue
}
} else if !includeApp && (strings.HasPrefix(tag, "APP_") || !IsStandardField(tag)) {
continue
}
writeField(bw, tag, v)
+2 -2
View File
@@ -34,7 +34,7 @@ func TestPromotedFieldsRoundTrip(t *testing.T) {
var buf bytes.Buffer
bw := bufio.NewWriter(&buf)
bw.WriteString("<EOH>\n")
writeRecord(bw, in, true)
writeRecord(bw, in, true, nil)
bw.Flush()
var rec Record
@@ -115,7 +115,7 @@ func TestStandardExportDropsNonStandard(t *testing.T) {
func renderRecord(q qso.QSO, includeApp bool) string {
var buf bytes.Buffer
bw := bufio.NewWriter(&buf)
writeRecord(bw, q, includeApp)
writeRecord(bw, q, includeApp, nil)
bw.Flush()
return buf.String()
}
+286
View File
@@ -0,0 +1,286 @@
{
"def": {
"code": "H26",
"name": "The Helvetia 26 Award HF",
"description": "26 Cantons of Switzerland",
"valid": true,
"url": "https://uska.ch/en/contest/uska-diplome/",
"ref_url": "https://uska.ch/en/contest/uska-diplome/",
"valid_from": "1980-01-01",
"valid_to": "9999-12-01",
"ref_display": "name",
"type": "QSOFIELDS",
"field": "address",
"match_by": "description",
"pattern": "",
"or_rules": [
{
"field": "qth",
"match_by": "description"
}
],
"dxcc_filter": [
287
],
"valid_bands": [
"2190m",
"630m",
"160m",
"80m",
"40m",
"30m",
"20m",
"17m",
"15m",
"12m",
"10m"
],
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw",
"qsl"
],
"total": 0,
"builtin": true,
"version": 3
},
"references": [
{
"code": "AG",
"name": "Aargau",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Aarau|Baden|Wettingen|Wohlen|Rheinfelden|Zofingen|Lenzburg|Brugg|Oftringen|Suhr|Spreitenbach)\\b",
"valid": true
},
{
"code": "AI",
"name": "Appenzell Innerrhoden",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Appenzell|Oberegg)\\b",
"valid": true
},
{
"code": "AR",
"name": "Appenzell Ausserrhoden",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Herisau|Teufen|Speicher|Heiden|Gais|Urnäsch|Waldstatt)\\b",
"valid": true
},
{
"code": "BE",
"name": "Bern",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Bern|Berne|Thun|Biel|Bienne|Köniz|Burgdorf|Langenthal|Steffisburg|Münsingen|Spiez|Interlaken|Ostermundigen|Lyss|Zollikofen)\\b",
"valid": true
},
{
"code": "BL",
"name": "Basel-Landschaft",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Liestal|Allschwil|Reinach|Muttenz|Pratteln|Binningen|Münchenstein|Birsfelden|Aesch|Sissach|Oberwil)\\b",
"valid": true
},
{
"code": "BS",
"name": "Basel-Stadt",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Basel|Bâle|Bale|Riehen|Bettingen)\\b",
"valid": true
},
{
"code": "FR",
"name": "Fribourg",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Fribourg|Freiburg|Bulle|Marly|Düdingen|Estavayer|Murten|Morat|Villars-sur-Glâne|Guin)\\b",
"valid": true
},
{
"code": "GE",
"name": "Geneva",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Genève|Geneve|Sezenove|Chene-Bourg|Chene Bourg|Geneva|Genf|Carouge|Vernier|Lancy|Meyrin|Onex|Thônex|Thonex|Versoix|Bernex|Plan-les-Ouates)\\b",
"valid": true
},
{
"code": "GL",
"name": "Glarus",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Glarus|Glaris|Näfels|Netstal|Ennenda|Mollis)\\b",
"valid": true
},
{
"code": "GR",
"name": "Graubünden (Grisons)",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Chur|Coire|Kueblis|Davos|Sankt Moritz|St\\.? Moritz|Landquart|Arosa|Klosters|Ilanz|Thusis|Poschiavo|Domat|Ems)\\b",
"valid": true
},
{
"code": "JU",
"name": "Jura",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Delémont|Delemont|Porrentruy|Bassecourt|Courroux|Saignelégier|Alle)\\b",
"valid": true
},
{
"code": "LU",
"name": "Lucerne",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Luzern|Lucerne|Emmen|Kriens|Horw|Ebikon|Sursee|Hochdorf|Willisau|Rothenburg)\\b",
"valid": true
},
{
"code": "NE",
"name": "Neuchâtel",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Neuchâtel|Neuchatel|Neuenburg|La Chaux-de-Fonds|Chaux-de-Fonds|Le Locle|Peseux|Boudry|Colombier|Marin)\\b",
"valid": true
},
{
"code": "NW",
"name": "Nidwalden",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Stans|Hergiswil|Buochs|Stansstad|Beckenried|Ennetbürgen)\\b",
"valid": true
},
{
"code": "OW",
"name": "Obwalden",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Sarnen|Kerns|Alpnach|Engelberg|Sachseln|Giswil)\\b",
"valid": true
},
{
"code": "SG",
"name": "St. Gallen",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Sankt Gallen|Saint-Gall|Rapperswil|Wil|Gossau|Rorschach|Uzwil|Flawil|Altstätten|Jona)\\b",
"valid": true
},
{
"code": "SH",
"name": "Schaffhausen",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Schaffhausen|Schaffhouse|Neuhausen|Stein am Rhein|Thayngen)\\b",
"valid": true
},
{
"code": "SO",
"name": "Solothurn",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Solothurn|Soleure|Olten|Grenchen|Zuchwil|Dornach|Oensingen|Balsthal|Biberist)\\b",
"valid": true
},
{
"code": "SZ",
"name": "Schwyz",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Schwyz|Einsiedeln|Freienbach|Küssnacht|Arth|Lachen|Wollerau|Brunnen|Goldau)\\b",
"valid": true
},
{
"code": "TG",
"name": "Thurgau",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Frauenfeld|Kreuzlingen|Arbon|Amriswil|Weinfelden|Romanshorn|Sirnach|Aadorf|Münchwilen)\\b",
"valid": true
},
{
"code": "TI",
"name": "Ticino",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Lugano|Bellinzona|Locarno|Mendrisio|Chiasso|Biasca|Ascona|Losone|Minusio|Giubiasco|Massagno)\\b",
"valid": true
},
{
"code": "UR",
"name": "Uri",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Altdorf|Schattdorf|Erstfeld|Andermatt|Flüelen|Bürglen)\\b",
"valid": true
},
{
"code": "VD",
"name": "Vaud",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Lausanne|Chavornay|Blonay|Saint-Cergue|St-Cergue|St Cergue|Yverdon|Gingins|Montreux|Nyon|Vevey|Renens|Morges|Gland|Pully|Prilly|Ecublens|Aigle|Payerne|Rolle|Lutry|Bussigny)\\b",
"valid": true
},
{
"code": "VS",
"name": "Valais",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Sion|Sitten|Martigny|Monthey|Sierre|Brig|Brigue|Naters|Visp|Viège|Conthey|Fully|Verbier|Zermatt|Crans-Montana|Saas-Fee|Savièse|Bagnes)\\b",
"valid": true
},
{
"code": "ZG",
"name": "Zug",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Zug|Zoug|Baar|Cham|Steinhausen|Risch|Rotkreuz|Hünenberg|Unterägeri|Oberägeri)\\b",
"valid": true
},
{
"code": "ZH",
"name": "Zurich",
"dxcc": 287,
"group": "",
"subgrp": "",
"pattern": "\\b(Zürich|Zurich|Winterthur|Uster|Dübendorf|Dietikon|Wetzikon|Kloten|Wädenswil|Horgen|Thalwil|Opfikon|Küsnacht|Meilen|Bülach|Regensdorf|Schlieren|Adliswil|Volketswil)\\b",
"valid": true
}
]
}
+14
View File
@@ -649,15 +649,29 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
const reconnectEvery = 5 * time.Second
connected := false
var lastAttempt time.Time
var lastConnErr string // last connect failure logged, so the retry loop says it once
tryConnect := func() {
if connected || time.Since(lastAttempt) < reconnectEvery {
return
}
lastAttempt = time.Now()
if err := b.Connect(); err != nil {
// Log it — the message used to live only in RigState.Error, i.e. in a
// tooltip. The status pill condenses everything to "OmniRig not found",
// so a user reporting that had no way to tell us WHY: the COM HRESULT,
// the serial error, the refused TCP connect, all invisible. Logged once
// per distinct message so the retry loop doesn't flood the file.
if msg := err.Error(); msg != lastConnErr {
lastConnErr = msg
debugLog.Printf("%s connect failed: %s", b.Name(), msg)
}
m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
return
}
if lastConnErr != "" {
debugLog.Printf("%s connected (after: %s)", b.Name(), lastConnErr)
lastConnErr = ""
}
connected = true
}
tryConnect()
+44 -23
View File
@@ -39,10 +39,10 @@ const (
CmdReadID = 0x19 // sub 0x00 = rig's own CI-V address (identifies model)
CmdPower = 0x18 // power on/off (sub 0x01 = on, 0x00 = off; on needs an FE wake preamble)
CmdAnt = 0x12 // antenna selector (sub 0x00 = ANT1, 0x01 = ANT2; read = no sub)
CmdAtt = 0x11 // attenuator (1 BCD byte of dB; 0x00 = off)
CmdLevel = 0x14 // analogue levels (sub + 2 BCD bytes, 0000-0255)
CmdMeter = 0x15 // meters (sub + 2 BCD bytes, 0000-0255): S-meter/Po/SWR
CmdAnt = 0x12 // antenna selector (sub 0x00 = ANT1, 0x01 = ANT2; read = no sub)
CmdAtt = 0x11 // attenuator (1 BCD byte of dB; 0x00 = off)
CmdLevel = 0x14 // analogue levels (sub + 2 BCD bytes, 0000-0255)
CmdMeter = 0x15 // meters (sub + 2 BCD bytes, 0000-0255): S-meter/Po/SWR
CmdSwitch = 0x16 // on/off + multi-state DSP settings (sub + 1 byte)
CmdATU = 0x1C // sub 0x01 = antenna tuner (0x00 off, 0x01 through, 0x02 tune)
CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off)
@@ -91,26 +91,26 @@ const (
SubATU = 0x01 // antenna tuner (data 0x02 = start tune)
// CmdScope sub-commands.
SubScopeData = 0x00 // waveform data frame (divided across several frames)
SubScopeOnOff = 0x10 // turn the scope display itself on/off (00/01)
SubScopeOn = 0x11 // enable/disable waveform data output over CI-V (00/01)
SubScopeData = 0x00 // waveform data frame (divided across several frames)
SubScopeOnOff = 0x10 // turn the scope display itself on/off (00/01)
SubScopeOn = 0x11 // enable/disable waveform data output over CI-V (00/01)
SubScopeMode = 0x14 // center/fixed mode (0=center, 1=fixed)
SubScopeSpan = 0x15 // span in center mode (±span/2 as 5 LE-BCD)
SubScopeEdge = 0x16 // fixed-mode ACTIVE edge set 1-4 (vfo + set#)
SubScopeFixEdge = 0x1e // fixed-mode edge FREQUENCIES: [range][set#][low 5-BCD][high 5-BCD]
// CmdSwitch sub-commands.
SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2
SubSwAGC = 0x12 // 1=FAST, 2=MID, 3=SLOW
SubSwNB = 0x22 // noise blanker on/off
SubSwNR = 0x40 // noise reduction on/off
SubSwANF = 0x41 // auto-notch on/off
SubSwComp = 0x44 // speech compressor on/off
SubSwMon = 0x45 // monitor on/off
SubSwVOX = 0x46 // VOX on/off
SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2
SubSwAGC = 0x12 // 1=FAST, 2=MID, 3=SLOW
SubSwNB = 0x22 // noise blanker on/off
SubSwNR = 0x40 // noise reduction on/off
SubSwANF = 0x41 // auto-notch on/off
SubSwComp = 0x44 // speech compressor on/off
SubSwMon = 0x45 // monitor on/off
SubSwVOX = 0x46 // VOX on/off
SubSwBreakIn = 0x47 // CW break-in: 0=OFF, 1=SEMI, 2=FULL (needed so 0x17 CW keys TX)
SubSwMN = 0x48 // manual notch on/off
SubSwAPF = 0x32 // audio peak filter on/off (CW only)
SubSwMN = 0x48 // manual notch on/off
SubSwAPF = 0x32 // audio peak filter on/off (CW only)
)
// CW break-in modes (CmdSwitch 0x47).
@@ -307,22 +307,43 @@ func ModeToADIF(m byte, data bool) string {
// ModelName maps a rig's default CI-V address (from CmdReadID) to a readable
// model. Unknown addresses fall back to a hex label.
//
// The name is not cosmetic: the UI derives model-dependent behaviour from it —
// notably the attenuator steps, which are 6/12/18 dB on the big rigs and a single
// 20 dB on the small ones. An address missing here therefore shows the WRONG
// attenuator buttons, and the rig NAKs them.
//
// Two entries here used to be wrong in a way that pointed at each other: 0x80 was
// labelled IC-7800 (it is the IC-7410) and 0x88 IC-7700 (it is the IC-7100), while
// the real IC-7800 (0x6A) and IC-7700 (0x74) were absent — so a 7800 came up as
// "Icom (0x6A)" with a 20 dB attenuator it does not have.
//
// Addresses cross-checked against the TR4W CI-V table and an independent
// published list; both agree on every value below.
func ModelName(addr byte) string {
switch addr {
case 0x6A:
return "IC-7800"
case 0x74:
return "IC-7700"
case 0x7A:
return "IC-7600"
case 0x7C:
return "IC-9100"
case 0x80:
return "IC-7410"
case 0x88:
return "IC-7100"
case 0x8E:
return "IC-7851" // shared with the IC-7850
case 0x94:
return "IC-7300"
case 0x98:
return "IC-7610"
case 0x7C:
return "IC-9100"
case 0xA2:
return "IC-9700"
case 0xA4:
return "IC-705"
case 0x88:
return "IC-7700"
case 0x80:
return "IC-7800"
}
return fmt.Sprintf("Icom (0x%02X)", addr)
}
+34 -2
View File
@@ -55,8 +55,8 @@ func TestScanSingleFreqResponse(t *testing.T) {
}
func TestScanSkipsEchoAndKeepsPartial(t *testing.T) {
echo := Frame(0x98, AddrController, CmdReadFreq) // our outgoing (echoed back)
resp := Frame(AddrController, 0x98, CmdReadMode, ModeCW, 0x01) // a real response
echo := Frame(0x98, AddrController, CmdReadFreq) // our outgoing (echoed back)
resp := Frame(AddrController, 0x98, CmdReadMode, ModeCW, 0x01) // a real response
buf := append(append([]byte{}, echo...), resp...)
buf = append(buf, 0xFE, 0xFE, 0x98) // a partial third frame (no FD yet)
@@ -130,3 +130,35 @@ func TestModelName(t *testing.T) {
t.Errorf("ModelName(0x12) = %q, want fallback", got)
}
}
// CI-V addresses are hardware constants: a wrong one means the console shows the
// wrong model, and with it the wrong attenuator steps (6/12/18 dB on the big
// rigs, a single 20 dB on the small ones) — buttons the rig then NAKs.
//
// Two entries here were previously wrong in a way that pointed at each other:
// 0x80 was labelled IC-7800 (it is the IC-7410) and 0x88 IC-7700 (it is the
// IC-7100), while the real IC-7800 (0x6A) and IC-7700 (0x74) were missing — so an
// IC-7800 came up as "Icom (0x6A)" with a 20 dB attenuator it does not have.
func TestModelNameAddresses(t *testing.T) {
for addr, want := range map[byte]string{
0x6A: "IC-7800",
0x74: "IC-7700",
0x7A: "IC-7600",
0x7C: "IC-9100",
0x80: "IC-7410",
0x88: "IC-7100",
0x8E: "IC-7851",
0x94: "IC-7300",
0x98: "IC-7610",
0xA2: "IC-9700",
0xA4: "IC-705",
} {
if got := ModelName(addr); got != want {
t.Errorf("ModelName(0x%02X) = %q, want %q", addr, got, want)
}
}
// An unknown address must stay identifiable rather than masquerade as a model.
if got := ModelName(0x42); got != "Icom (0x42)" {
t.Errorf("ModelName(0x42) = %q, want the hex fallback", got)
}
}
+12 -9
View File
@@ -518,7 +518,14 @@ func (f *Flex) handleStatus(payload string) {
alreadyBound := f.boundClientID != ""
f.mu.Unlock()
lp := strings.ToLower(program)
isGUI := program == "" || strings.Contains(lp, "smartsdr") || strings.Contains(lp, "maestro")
// The real GUI client is SmartSDR (Windows) or Maestro. Its non-GUI
// helpers "SmartSDR CAT" and "SmartSDR DAX" also carry "smartsdr" in the
// name, so exclude them — and require an explicit GUI name (dropping the
// old program=="" fallback that could match CAT before its program field
// arrived). Binding to CAT/DAX is invalid and the radio was seen to drop
// SmartSDR CAT (connect/disconnect loop) when a logger did this.
isGUI := (strings.Contains(lp, "smartsdr") || strings.Contains(lp, "maestro")) &&
!strings.Contains(lp, "cat") && !strings.Contains(lp, "dax")
if !disconnected && clientID != "" && !alreadyBound && isGUI {
f.mu.Lock()
f.boundClientID = clientID
@@ -1889,15 +1896,11 @@ func (f *Flex) SetTune(on bool) error {
// SmartSDR refuses to transmit while inhibit is set, so it holds even against a
// footswitch or an external keyer, which a software PTT block could not.
//
// It also labels the interlock reason "OpsLog" so SmartSDR shows WHY TX is
// blocked. The reason is best-effort (a separate interlock object); the inhibit
// is what actually blocks TX, so a rig that ignores the reason still stays safe.
// `transmit set inhibit=` is the whole mechanism. We used to also send
// `interlock set reason=OpsLog` to label WHY TX was blocked, but `reason` is a
// read-only status field on the interlock object — writing it is rejected
// (cmd error 5000002D on SmartSDR v1.4/v3) and does nothing, so it's dropped.
func (f *Flex) SetTXInhibit(on bool) error {
if on {
f.send("interlock set reason=OpsLog")
} else {
f.send("interlock set reason=")
}
return f.txSet("transmit set inhibit="+boolFlex(on), "inhibit", func(t *flexTX) { t.inhibit = on })
}
+9 -9
View File
@@ -98,8 +98,8 @@ type icomNet struct {
vTracked uint16
vCivSeq uint16
rx chan []byte // CI-V byte chunks from civPump → Read (control replies)
scopeRx chan []byte // scope (0x27) frames, kept off rx so the panadapter
rx chan []byte // CI-V byte chunks from civPump → Read (control replies)
scopeRx chan []byte // scope (0x27) frames, kept off rx so the panadapter
// stream can't crowd control replies out (→ ScopeChan)
leftover []byte // partial chunk not yet returned by Read (Read-only)
readTO time.Duration // Read timeout (SetReadTimeout)
@@ -114,11 +114,11 @@ type icomNet struct {
// login token every ~45 s. The rig invalidates the session ~2 min after login
// without renewal (this was the "loses control after 2 min" drop — RS-BA1/the
// Remote Utility renew too). Owned solely by ctrlPump after dial → no lock.
cTracked uint16 // control-stream tracked seq (continues after dial)
cAuthSeq uint16 // token-packet innerseq
cToken uint32 // login token (opaque, echoed back verbatim)
cTokReq uint16 // token-request id (echoed)
cSentBuf map[uint16][]byte // control-stream retransmit buffer (token renewals)
cTracked uint16 // control-stream tracked seq (continues after dial)
cAuthSeq uint16 // token-packet innerseq
cToken uint32 // login token (opaque, echoed back verbatim)
cTokReq uint16 // token-request id (echoed)
cSentBuf map[uint16][]byte // control-stream retransmit buffer (token renewals)
// Receive-side retransmit (CI-V stream): track the rig's data-packet send seq
// and ask it to resend any gap. Under the scope stream, UDP drops are common;
@@ -844,8 +844,8 @@ func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, use
copy(b[0x60:0x70], icnPasscode(user))
b[0x70] = rxEnable // rxenable: 1 opens the 50003 RX audio stream, 0 = CI-V only
b[0x71] = 0x00 // txenable (Phase 5)
b[0x72] = 0x10 // rxcodec
b[0x73] = 0x04 // txcodec
b[0x72] = 0x10 // rxcodec
b[0x73] = 0x04 // txcodec
icnBE.PutUint32(b[0x74:], 16000)
icnBE.PutUint32(b[0x78:], 8000)
icnBE.PutUint32(b[0x7c:], uint32(civPort))
+62 -18
View File
@@ -79,23 +79,23 @@ type IcomSerial struct {
// leading main/sub selector byte (IC-7610/9700). scopeAmp is the latest
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read
// via ScopeData from the binding goroutine).
dualScope bool
scopeMu sync.Mutex
scopeAmp []byte
scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame)
scopeHigh int64 // spectrum right-edge frequency
scopeSeq int
scopeOn bool
dualScope bool
scopeMu sync.Mutex
scopeAmp []byte
scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame)
scopeHigh int64 // spectrum right-edge frequency
scopeSeq int
scopeOn bool
scopeFixed bool // true = fixed-span mode (tracked optimistically)
scopeSeen bool // logged the first sweep's structure once (on-rig verification)
scopeSeen bool // logged the first sweep's structure once (on-rig verification)
curFreq int64 // last frequency read (for sideband choice)
curModeByte byte // last raw Icom mode byte (for filter re-send)
pollN int // ReadState cycle counter (staggers slow reads)
splitOn bool // last read split state (refreshed every few cycles)
splitTXFreq int64 // last read unselected/TX VFO freq while in split
readFails int // consecutive ReadState freq-read failures (transient tolerance)
dspLoaded bool // readDSP has run since the rig became responsive (loads all
curFreq int64 // last frequency read (for sideband choice)
curModeByte byte // last raw Icom mode byte (for filter re-send)
pollN int // ReadState cycle counter (staggers slow reads)
splitOn bool // last read split state (refreshed every few cycles)
splitTXFreq int64 // last read unselected/TX VFO freq while in split
readFails int // consecutive ReadState freq-read failures (transient tolerance)
dspLoaded bool // readDSP has run since the rig became responsive (loads all
// the panel's set-once controls once the rig actually answers)
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
lastSetFreqAt time.Time
@@ -341,7 +341,7 @@ func (b *IcomSerial) ReadState() (RigState, error) {
}
if b.splitOn && b.splitTXFreq > 0 && b.splitTXFreq != s.FreqHz {
s.Split = true
s.RxFreqHz = s.FreqHz // selected VFO = RX
s.RxFreqHz = s.FreqHz // selected VFO = RX
s.FreqHz = b.splitTXFreq // unselected VFO = TX
}
@@ -374,10 +374,54 @@ func (b *IcomSerial) ReadState() (RigState, error) {
if !b.dspLoaded {
b.readDSP()
b.dspLoaded = true
} else {
b.refreshFrontPanel()
}
return s, nil
}
// refreshFrontPanel re-reads the few controls an operator actually reaches for on
// the rig itself, so the console follows the radio instead of only driving it.
//
// readDSP loads everything but runs ONCE per connection (dspLoaded), which left
// the panel showing whatever was set at connect: switch AGC from FAST to MID on
// the front panel and OpsLog still said FAST, indefinitely. Commands worked, so
// the link was plainly fine — only this direction was missing.
//
// ONE read per poll cycle, in rotation. The full snapshot is ~30 CI-V round trips
// and refreshing it wholesale would hog the CAT thread for seconds at a time —
// including the operator's own Set* commands, which is far worse than a stale
// label. The rotation completes in 8 cycles; anything not covered here is still a
// connect-time or ↻ Refresh read.
func (b *IcomSerial) refreshFrontPanel() {
switch b.pollN % 8 {
case 1:
if v, ok := b.readSwitch(civ.SubSwAGC); ok {
b.dspMu.Lock()
b.dsp.AGC = agcName(v)
b.dspMu.Unlock()
}
case 3:
if v, ok := b.readAtt(); ok {
b.dspMu.Lock()
b.dsp.Att = v
b.dspMu.Unlock()
}
case 5:
if v, ok := b.readSwitch(civ.SubSwPreamp); ok {
b.dspMu.Lock()
b.dsp.Preamp = int(v)
b.dspMu.Unlock()
}
case 7:
if _, f, ok := b.readModeFilter(); ok {
b.dspMu.Lock()
b.dsp.Filter = int(f)
b.dspMu.Unlock()
}
}
}
func (b *IcomSerial) SetFrequency(hz int64) error {
if hz <= 0 {
return fmt.Errorf("invalid frequency")
@@ -583,8 +627,8 @@ func (b *IcomSerial) drainResp() {
func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
regions := make(map[byte][]byte)
var total byte
rawN := 0 // diagnostic: dump the first few raw 0x27 frames
loggedCfg := map[byte]bool{} // one-shot dump of each config read response
rawN := 0 // diagnostic: dump the first few raw 0x27 frames
loggedCfg := map[byte]bool{} // one-shot dump of each config read response
for {
select {
case <-done:
+19 -4
View File
@@ -4,6 +4,7 @@ import (
"log"
"os"
"path/filepath"
"sync"
)
// LogSink, when set by the host app at startup, receives every CAT debug
@@ -15,13 +16,21 @@ var LogSink func(format string, args ...any)
// catLogger forwards Printf either to the host LogSink (preferred) or to a
// local file/stderr fallback. Keeps the call sites (debugLog.Printf(...))
// unchanged.
type catLogger struct{ fallback *log.Logger }
type catLogger struct {
once sync.Once
fallback *log.Logger
}
func (c *catLogger) Printf(format string, args ...any) {
if LogSink != nil {
LogSink("cat: "+format, args...)
return
}
// Only now, on a line that genuinely has nowhere else to go, is the fallback
// file opened. It used to be created at package init, so every installation
// grew an %APPDATA%\OpsLog\cat.log that nothing ever wrote to once the app
// wired LogSink — a decoy for anyone told to "check the CAT log".
c.once.Do(func() { c.fallback = openFallbackLog() })
if c.fallback != nil {
c.fallback.Printf(format, args...)
}
@@ -30,7 +39,7 @@ func (c *catLogger) Printf(format string, args ...any) {
// debugLog writes CAT debug events so users can diagnose mode/freq mismatches
// without rebuilding with a console. Once LogSink is set, lines flow into the
// main opslog.log.
var debugLog = &catLogger{fallback: openFallbackLog()}
var debugLog = &catLogger{}
func openFallbackLog() *log.Logger {
base, err := os.UserConfigDir()
@@ -49,9 +58,15 @@ func openFallbackLog() *log.Logger {
return log.New(f, "", log.LstdFlags|log.Lmicroseconds)
}
// DebugLogPath returns where the fallback cat.log lives, for surfacing in the
// UI / docs. When LogSink is wired, CAT lines are in the main app log instead.
// DebugLogPath returns where the fallback cat.log lives, or "" when CAT lines are
// going to the app log instead — which is the normal case, and the answer callers
// actually need. It previously returned the path unconditionally, so the one place
// that displayed it sent operators to an empty file in %APPDATA% while their CAT
// diagnostics sat in data\opslog.log.
func DebugLogPath() string {
if LogSink != nil {
return "" // lines go to the unified app log; there is no separate cat.log
}
base, err := os.UserConfigDir()
if err != nil {
return ""
+265 -64
View File
@@ -34,6 +34,11 @@ type OmniRig struct {
lastSig string // last logged Split/VFO signature — only log on change
rigType string // OmniRig's RigType string (the .ini title), e.g. "IC-7610"
// connLogged holds the connect failure already written to the log, so the
// 5-second reconnect loop reports a persistent problem once instead of
// forever. Cleared on success.
connLogged string
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
// async Freq property, which still reports the OLD band for a poll or two
@@ -41,6 +46,19 @@ type OmniRig struct {
// the sideband (freq moved, but mode read the old band → wrong sideband).
lastSetFreq int64
lastSetFreqAt time.Time
// lastSplitOnAt is when OmniRig last reported PM_SPLITON cleanly. See the
// FTDX101D note in ReadState — some .ini files alternate between ON and OFF
// on consecutive polls, so the flag has to be latched to be usable.
lastSplitOnAt time.Time
// splitFlaky records that THIS rig's .ini flips the split flag on its own,
// which is what arms the latch. lastSplitFlag / splitFlips / splitFlipWindow
// count the flips inside a rolling window to detect it.
lastSplitFlag bool
splitFlaky bool
splitFlips int
splitFlipWindow time.Time
}
// NewOmniRig creates a non-connected backend. Call Connect before use.
@@ -53,8 +71,55 @@ func NewOmniRig(rigNum int) *OmniRig {
func (o *OmniRig) Name() string { return "omnirig" }
// elevationHint recognises the COM refusal that happens when OmniRig runs
// elevated (as administrator) and OpsLog does not — or the reverse. Windows keeps
// the two integrity levels apart, so the client cannot bind to the running
// server's object and COM falls back to launching a fresh one, which then needs
// elevation the client cannot grant.
//
// It is worth naming explicitly: the operator SEES OmniRig running, with its
// settings window open, so "OmniRig not found" reads as nonsense and sends them
// hunting for a driver or COM-port problem that does not exist. The fix is thirty
// seconds of work once you know what to look for.
func elevationHint(err error) string {
if err == nil {
return ""
}
msg := strings.ToLower(err.Error())
// Matched on the HRESULT text in whatever language Windows is running in, so
// the code is checked too: 0x800702E4 = ERROR_ELEVATION_REQUIRED.
if strings.Contains(msg, "elevation") || strings.Contains(msg, "élévation") ||
strings.Contains(msg, "0x800702e4") || strings.Contains(msg, "access denied") ||
strings.Contains(msg, "accès refusé") {
return "OmniRig and OpsLog are running at different privilege levels — Windows keeps them apart, " +
"so OpsLog cannot reach OmniRig even though it is running. Start BOTH the same way: either " +
"un-tick \"Run as administrator\" on the OmniRig shortcut (and its Compatibility tab), or run " +
"OpsLog as administrator too"
}
return ""
}
// logConnFailure writes a connect failure once per distinct cause. The reconnect
// loop retries every 5 seconds forever, and a station whose OmniRig was simply
// elevated had this filling its log at roughly 1500 lines an hour — which buries
// the very diagnostics someone would go looking for.
func (o *OmniRig) logConnFailure(msg string) {
if o.connLogged == msg {
return
}
o.connLogged = msg
debugLog.Printf("OmniRig Rig%d: %s", o.RigNum, msg)
}
func (o *OmniRig) Connect() error {
debugLog.Printf("OmniRig.Connect Rig%d — log path: %s", o.RigNum, DebugLogPath())
// This used to announce DebugLogPath() on every attempt — the path of the
// FALLBACK cat.log, which nothing writes to once the app has wired LogSink and
// everything goes to data\opslog.log. It pointed operators at an empty file in
// %APPDATA% while the lines they wanted were somewhere else entirely. Dropped;
// and logged once per failure run rather than every 5-second retry.
if o.connLogged == "" {
debugLog.Printf("OmniRig.Connect Rig%d", o.RigNum)
}
if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); err != nil {
// 0x1 = S_FALSE → COM already initialised on this thread, fine.
if oerr, ok := err.(*ole.OleError); !ok || oerr.Code() != 0x00000001 {
@@ -62,15 +127,43 @@ func (o *OmniRig) Connect() error {
}
}
unk, err := oleutil.CreateObject("Omnirig.OmnirigX")
if err != nil {
return fmt.Errorf("Omnirig.OmnirigX not available — is OmniRig installed and running?: %w", err)
}
omnirig, err := unk.QueryInterface(ole.IID_IDispatch)
unk.Release()
if err != nil {
return fmt.Errorf("query interface: %w", err)
const progID = "Omnirig.OmnirigX"
var omnirig *ole.IDispatch
unk, err := oleutil.CreateObject(progID)
if err == nil {
omnirig, err = unk.QueryInterface(ole.IID_IDispatch)
unk.Release()
if err != nil {
return fmt.Errorf("query interface: %w", err)
}
} else {
// A privilege mismatch is final — retrying, or trying the 32-bit server,
// cannot cross an integrity boundary. Say what to do instead of dressing it
// up as "not installed", which is what sends operators looking in the wrong
// place entirely.
if hint := elevationHint(err); hint != "" {
o.logConnFailure(hint)
return fmt.Errorf("%s (Windows said: %v)", hint, err)
}
// Otherwise it may be a partial registration; try activating the 32-bit
// server explicitly before giving up (see omnirig_activate32.go).
disp, err32 := createOmniRig32(progID)
if err32 != nil {
o.logConnFailure(fmt.Sprintf("CreateObject(%s) failed: %v; 32-bit activation also failed: %v", progID, err, err32))
// Name the version requirement. HB9RYZ's OmniRig v2.1 is a different
// product that its own author states is not compatible with v1, and it
// does not provide v1's IOmniRigX interface — so an operator who has
// only v2 installed sees OmniRig running and OpsLog failing, with
// nothing to connect the two facts.
return fmt.Errorf("OmniRig (v1) not reachable: %w — OpsLog needs OmniRig v1.19/v1.20 "+
"(VE3NEA/Alex), the interface every logger uses. HB9RYZ's OmniRig v2.1 is a separate, "+
"incompatible product and cannot serve OpsLog; the two may be installed side by side, "+
"but v1 must be present and running", err)
}
debugLog.Printf("OmniRig: reached via explicit 32-bit activation after CreateObject failed (%v)", err)
omnirig = disp
}
o.connLogged = "" // connected: re-arm the one-shot failure logging
rigVar, err := oleutil.GetProperty(omnirig, fmt.Sprintf("Rig%d", o.RigNum))
if err != nil {
@@ -80,10 +173,26 @@ func (o *OmniRig) Connect() error {
o.omnirig = omnirig
o.rig = rigVar.ToIDispatch()
// Log WHICH OmniRig answered. There are two incompatible products called
// OmniRig: v1.19/1.20 (Alex, VE3NEA), whose IOmniRigX interface every logger
// including OpsLog uses, and HB9RYZ's v2.1, which its own author states is "not
// compatible" with v1 and works only with programs written for it. They can
// coexist, and OmniRig's own window looks much the same either way — so an
// operator running only v2 sees OmniRig on screen, sees OpsLog fail, and has no
// way to know the two were never going to talk. These two version numbers
// settle it in one line of a bug report.
var iv, sv int64 = -1, -1
if v, err := oleutil.GetProperty(o.omnirig, "InterfaceVersion"); err == nil {
iv = v.Val
}
if v, err := oleutil.GetProperty(o.omnirig, "SoftwareVersion"); err == nil {
sv = v.Val
}
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
o.rigType = rt.ToString()
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, o.rigType)
}
debugLog.Printf("OmniRig connected: Rig%d type=%q (OmniRig interface=%d software=%d)",
o.RigNum, o.rigType, iv, sv)
return nil
}
@@ -155,66 +264,151 @@ func (o *OmniRig) ReadState() (RigState, error) {
splitRaw = v.Val
}
// Diagnostic logged ONLY when Split or VFO changes (not on a timer), so
// normal operation stays quiet but toggling split on the radio is captured —
// needed to pin down this rig's PM_SPLITON value.
// FTDX101D field capture: OmniRig alternates between two contradictory
// readings on consecutive polls — "Vfo=AB Split=0x10000(OFF)" then
// "Vfo=BA Split=0x8000(ON)", ~1.5 s apart, with the rig untouched. The stock
// .ini evidently has two status commands that each write these params. A
// sample-by-sample test therefore reports split for half the polls and no
// split for the other half, which the UI shows as no split at all. Latch the
// ON flag briefly so one truthful sample survives the contradicting one; the
// latch expires on its own once the rig stops reporting ON, so cancelling
// split on the radio still clears within a few seconds.
//
// The latch is ARMED ONLY for a rig that actually oscillates, because it costs
// ~6 s before split clears on screen. A correct .ini (FTDX10 with VS; and FT;
// read as separate frames, confirmed on the air 2026-07-26) never flips
// unprompted, and there the latch would be a pure delay on a reading that was
// already right.
//
// 8 flips in 30 s: a first cut at 3-in-15s was armed by the OPERATOR toggling
// split three times while testing, which then imposed the 6 s delay on a rig
// that did not need it. A misreading .ini flips every 1.53 s without being
// touched — a dozen in the same window — so the gap is wide. The arming also
// expires after 30 s without a flip, so a rig that behaves is never stuck with
// the delay because of one burst.
const flipWindow, flipsToArm = 30 * time.Second, 8
now := time.Now()
flagOn := splitRaw&pmSplitOn != 0 && splitRaw&pmSplitOff == 0
if flagOn {
o.lastSplitOnAt = now
}
if flagOn != o.lastSplitFlag {
o.lastSplitFlag = flagOn
if now.Sub(o.splitFlipWindow) > flipWindow {
o.splitFlipWindow, o.splitFlips, o.splitFlaky = now, 0, false
}
o.splitFlips++
if o.splitFlips >= flipsToArm {
o.splitFlaky = true
}
} else if o.splitFlaky && now.Sub(o.splitFlipWindow) > flipWindow {
o.splitFlaky, o.splitFlips = false, 0 // stopped oscillating — drop the delay
}
splitRecentOn := o.splitFlaky && !o.lastSplitOnAt.IsZero() && now.Sub(o.lastSplitOnAt) < 6*time.Second
s.FreqHz, s.RxFreqHz, s.Split = resolveOmniRigVFOs(o.rigType, freqMain, freqA, freqB, s.Vfo, splitRaw, splitRecentOn)
// Diagnostic logged ONLY when Split or VFO changes (not on a timer), so normal
// operation stays quiet but toggling split or SUB VFO on the radio is
// captured. It logs the RESOLVED tx/rx/split too: with the raw values alone a
// user's log showed what OmniRig said but not what OpsLog concluded, which is
// the half that was wrong.
if sig := fmt.Sprintf("%x:%x", splitRaw, rawVfo); sig != o.lastSig {
o.lastSig = sig
debugLog.Printf("OmniRig Rig%d raw: Freq=%d FreqA=%d FreqB=%d Vfo=%q(raw=0x%X) Split=0x%X status=%d",
o.RigNum, freqMain, freqA, freqB, s.Vfo, rawVfo, splitRaw, func() int64 {
if v, e := oleutil.GetProperty(o.rig, "Status"); e == nil {
return v.Val
}
return -1
}())
}
// A genuine split: the rig explicitly flags PM_SPLITON, the two VFOs are
// distinct and non-zero, AND they're in the same band. The same-band test
// kills the common false positive where VFO B just holds a leftover from
// another band (a "28 MHz / 7 MHz split" is nonsensical), which on the
// FT-710 / TS-570 otherwise froze the main/TX freq on the wrong VFO.
genuineSplit := splitRaw == pmSplitOn &&
freqA != 0 && freqB != 0 && freqA != freqB &&
BandFromHz(freqA) == BandFromHz(freqB)
if genuineSplit {
// ADIF: FreqHz = TX, RxFreqHz = RX. Determine which VFO is RX from the
// ACTIVE frequency (OmniRig's generic Freq — the VFO you're listening on):
// RX = the active VFO, TX = the other one. This is far more reliable than
// trusting OmniRig's Vfo AB/BA enum, which several rigs (e.g. Yaesu FTDX10)
// report inverted — the split then showed TX/RX swapped.
s.Split = true
switch {
case freqMain != 0 && freqMain == freqA:
s.RxFreqHz, s.FreqHz = freqA, freqB // listening on A → TX on B
case freqMain != 0 && freqMain == freqB:
s.RxFreqHz, s.FreqHz = freqB, freqA // listening on B → TX on A
case s.Vfo == "BA":
s.FreqHz, s.RxFreqHz = freqA, freqB // fall back to the Vfo enum
default:
s.FreqHz, s.RxFreqHz = freqB, freqA
}
} else {
// Simplex: read VFO A first, fall back to the generic Freq — exactly like
// DXHunter/WSJT-X. PM_FREQA rigs (Yaesu, Kenwood) populate FreqA; some
// Icoms (IC-9100 etc.) only populate the generic Freq. On the IC-7610
// OmniRig's generic Freq reports VFO B (its Main/Sub model confuses the
// stock ini), so keying off FreqA gives the operator the VFO they expect.
s.Split = false
s.RxFreqHz = 0
switch {
case freqA != 0:
s.FreqHz = freqA
case freqMain != 0:
s.FreqHz = freqMain
default:
s.FreqHz = freqB
}
debugLog.Printf("OmniRig Rig%d raw: rig=%q Freq=%d FreqA=%d FreqB=%d Vfo=%q(raw=0x%X) Split=0x%X sticky=%v → tx=%d rx=%d split=%v",
o.RigNum, o.rigType, freqMain, freqA, freqB, s.Vfo, rawVfo, splitRaw, splitRecentOn,
s.FreqHz, s.RxFreqHz, s.Split)
}
return s, nil
}
// resolveOmniRigVFOs turns OmniRig's four readings into the ADIF pair
// (FreqHz = TX, RxFreqHz = RX) plus a split flag.
//
// Pure and separate from ReadState because it encodes rig-specific rules that
// contradict each other — what fixes a Yaesu can break an Icom — and the only way
// to change it safely is with every known rig's behaviour pinned in a test. COM
// cannot be exercised from a test; this can.
func resolveOmniRigVFOs(rigType string, freqMain, freqA, freqB int64, vfo string, splitRaw int64, splitRecentOn bool) (txHz, rxHz int64, split bool) {
// PM_SPLITON is tested as a BIT, not by equality. OmniRig's Split is a flag
// word: an exact `== 0x8000` holds only for a rig whose ini sets that bit and
// nothing else, and silently reports "no split" for any rig reporting the bit
// alongside another. Requiring ON set and OFF clear keeps the two states apart
// (both flags are non-zero, so a bare `!= 0` would read OFF as split) while
// tolerating extra bits.
splitFlagged := (splitRaw&pmSplitOn != 0 && splitRaw&pmSplitOff == 0) || splitRecentOn
// A genuine split also needs two distinct, non-zero VFOs in the SAME band. The
// band test kills the common false positive where VFO B merely holds a
// leftover from another band (a "28 MHz / 7 MHz split" is nonsensical), which
// on the FT-710 / TS-570 otherwise froze the TX freq on the wrong VFO.
if splitFlagged && freqA != 0 && freqB != 0 && freqA != freqB &&
BandFromHz(freqA) == BandFromHz(freqB) {
// RX is the VFO being listened on — identified from the generic Freq rather
// than from the Vfo AB/BA enum, which several rigs (Yaesu FTDX10) report
// inverted, showing TX and RX swapped.
switch {
case freqMain != 0 && freqMain == freqA:
return freqB, freqA, true // listening on A → TX on B
case freqMain != 0 && freqMain == freqB:
return freqA, freqB, true // listening on B → TX on A
case vfo == "BA":
return freqA, freqB, true // fall back to the Vfo enum
default:
return freqB, freqA, true
}
}
// Simplex. The VFO the rig says is ACTIVE comes first: preferring freqA
// unconditionally (as this did) meant the displayed frequency never left VFO
// A — press SUB VFO on an FTDX101D and the radio receives on B while OpsLog
// went on showing A, taking the band and the logged frequency with it.
//
// Only a VFO OmniRig explicitly names is honoured, so a rig that does not
// report the enum keeps exactly the previous fallback order. That matters for
// the IC-7610, whose stock ini reports the generic Freq as VFO B; and for the
// PM_FREQA rigs (Yaesu, Kenwood) versus the Icoms (IC-9100) that populate only
// the generic Freq.
// The PAIR enums name BOTH VFOs at once — first letter = the one being
// listened on, second = the one that transmits. Only the single-letter forms
// were honoured here, so a rig that reports nothing but pairs (the whole Yaesu
// family) always fell through to freqA and never followed the operator to SUB.
// Log4OM reads that first letter and logs the right frequency on the same
// rigs, which is what showed this was readable data and not a dead end.
//
// Checked BEFORE the Yaesu fallback below: an enum that names a VFO is the
// rig speaking, the fallback is only an inference.
switch {
case (vfo == "BA" || vfo == "BB") && freqB != 0:
return freqB, 0, false
case (vfo == "AA" || vfo == "AB") && freqA != 0:
return freqA, 0, false
}
// Yaesu fallback, for a rig file that names no VFO at all (the stock FTDX10
// one: it answers neither VS; nor FR; usably, verified on the air 2026-07-26).
// There the generic Freq is the last clue — matching FreqB and not FreqA means
// the operator is on SUB. Limited to Yaesu: the IC-7610's stock ini reports
// the generic Freq as VFO B permanently, where this would name the wrong VFO —
// that case is pinned in the test table.
if isYaesuRig(rigType) && freqMain != 0 && freqMain == freqB && freqB != freqA {
return freqB, 0, false
}
switch {
case (vfo == "B" || vfo == "BB") && freqB != 0:
return freqB, 0, false
case (vfo == "A" || vfo == "AA") && freqA != 0:
return freqA, 0, false
case freqA != 0:
return freqA, 0, false
case freqMain != 0:
return freqMain, 0, false
default:
return freqB, 0, false
}
}
func (o *OmniRig) SetFrequency(hz int64) error {
if o.rig == nil {
debugLog.Printf("OmniRig.SetFrequency(%d): NOT CONNECTED", hz)
@@ -486,6 +680,13 @@ func omniRigMode(m int64) string {
// omniRigVfo maps the OmniRig Vfo RigParamX enum to a short label, using the
// documented PM_VFO* constants.
// isYaesuRig recognises a Yaesu from OmniRig's RigType (the .ini title, e.g.
// "FTDX101D", "FT-891"). Only used to gate rules that are true for Yaesu and
// false for Icom, so a mis-titled ini simply keeps the generic behaviour.
func isYaesuRig(rigType string) bool {
return strings.HasPrefix(strings.ToUpper(strings.TrimSpace(rigType)), "FT")
}
func omniRigVfo(v int64) string {
switch {
case v&0x40 != 0: // PM_VFOAA
+83
View File
@@ -0,0 +1,83 @@
package cat
import (
"fmt"
"syscall"
"unsafe"
ole "github.com/go-ole/go-ole"
"golang.org/x/sys/windows/registry"
)
// Last-resort activation path for OmniRig, used only when the normal
// CreateObject fails.
//
// OmniRig is a 32-bit program and its installer splits its COM identity across
// registry views — on a working machine the ProgID sits in the 64-bit view while
// the CLSID and its LocalServer32 exist only under WOW6432Node. That LOOKS like
// it should break a 64-bit client, and it was my first theory for "OmniRig not
// found"; measuring it on a machine with exactly that layout disproved it. COM
// resolves an out-of-process server across views by itself, and the plain
// CreateObject succeeds.
//
// What this still covers is the case where the ProgID is not visible to us at all
// (a partial or 32-bit-only registration), where CreateObject has nothing to
// resolve. Here we read the CLSID from BOTH views ourselves and activate the
// 32-bit local server explicitly. CLSCTX_ACTIVATE_32_BIT_SERVER is the documented
// flag for that; go-ole hard-codes CLSCTX_SERVER and keeps CoCreateInstance
// unexported, hence the direct call.
//
// It costs nothing when the normal path works, and the reason it ran at all is
// logged — so if it ever rescues a real installation we will see it.
const clsctxActivate32BitServer = 0x40000
var (
modole32 = syscall.NewLazyDLL("ole32.dll")
procCoCreateInst32 = modole32.NewProc("CoCreateInstance")
)
// omnirigCLSIDFromRegistry reads OmniRig's CLSID from the ProgID key, looking in
// the 64-bit view first and then the 32-bit one. Returned as a GUID ready for
// CoCreateInstance.
func omnirigCLSIDFromRegistry(progID string) (*ole.GUID, error) {
for _, access := range []uint32{registry.QUERY_VALUE, registry.QUERY_VALUE | registry.WOW64_32KEY} {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Classes\`+progID+`\CLSID`, access)
if err != nil {
continue
}
s, _, err := k.GetStringValue("")
k.Close()
if err != nil || s == "" {
continue
}
if g := ole.NewGUID(s); g != nil {
return g, nil
}
}
return nil, fmt.Errorf("no CLSID registered for %s in either registry view", progID)
}
// createOmniRig32 activates OmniRig's 32-bit out-of-process server explicitly,
// bypassing the 64-bit registry lookup that CoCreateInstance would otherwise do.
func createOmniRig32(progID string) (*ole.IDispatch, error) {
clsid, err := omnirigCLSIDFromRegistry(progID)
if err != nil {
return nil, err
}
var unk *ole.IUnknown
hr, _, _ := procCoCreateInst32.Call(
uintptr(unsafe.Pointer(clsid)),
0, // no aggregation
uintptr(ole.CLSCTX_LOCAL_SERVER|clsctxActivate32BitServer),
uintptr(unsafe.Pointer(ole.IID_IUnknown)),
uintptr(unsafe.Pointer(&unk)))
if hr != 0 {
return nil, fmt.Errorf("CoCreateInstance(32-bit local server) failed: %w", ole.NewError(hr))
}
disp, err := unk.QueryInterface(ole.IID_IDispatch)
unk.Release()
if err != nil {
return nil, fmt.Errorf("query IDispatch: %w", err)
}
return disp, nil
}
+73
View File
@@ -0,0 +1,73 @@
package cat
import (
"errors"
"testing"
)
// The COM refusal when OmniRig runs elevated and OpsLog does not (or the reverse)
// must be recognised and named. It reached a user as "OmniRig not found" while
// OmniRig sat visibly on screen with its settings window open — so the report was
// a driver hunt that could never succeed.
//
// Windows returns this text localised, so the match cannot rely on English alone;
// the HRESULT (0x800702E4 = ERROR_ELEVATION_REQUIRED) is accepted too.
func TestElevationHint(t *testing.T) {
recognised := []string{
"Lopération demandée nécessite une élévation.", // as logged, fr-FR
"The requested operation requires elevation.",
"Access denied",
"Accès refusé",
"CoCreateInstance failed: 0x800702E4",
}
for _, msg := range recognised {
if elevationHint(errors.New(msg)) == "" {
t.Errorf("not recognised as a privilege problem: %q", msg)
}
}
// Unrelated failures must NOT be blamed on elevation, or the advice sends the
// operator to the wrong place just as surely.
for _, msg := range []string{
"Classe non enregistrée",
"REGDB_E_CLASSNOTREG",
"no CLSID registered for Omnirig.OmnirigX in either registry view",
"open COM3: Access is den", // truncated word must not match "access denied"
} {
if h := elevationHint(errors.New(msg)); h != "" {
t.Errorf("%q wrongly reported as a privilege problem: %s", msg, h)
}
}
if elevationHint(nil) != "" {
t.Error("nil error must yield no hint")
}
}
// The reconnect loop runs every 5 s forever; a persistent failure must be logged
// once, not 1500 times an hour.
func TestLogConnFailureOnlyOncePerCause(t *testing.T) {
var lines []string
prev := LogSink
LogSink = func(format string, args ...any) { lines = append(lines, format) }
defer func() { LogSink = prev }()
o := &OmniRig{RigNum: 1}
for i := 0; i < 20; i++ {
o.logConnFailure("requires elevation")
}
if len(lines) != 1 {
t.Errorf("logged %d times, want 1", len(lines))
}
// A DIFFERENT cause must still be reported.
o.logConnFailure("class not registered")
if len(lines) != 2 {
t.Errorf("a new cause was not logged: %d lines", len(lines))
}
// And after reconnecting, the same cause may be reported again.
o.connLogged = ""
o.logConnFailure("requires elevation")
if len(lines) != 3 {
t.Errorf("after a reconnect the cause should log again: %d lines", len(lines))
}
}
+77
View File
@@ -0,0 +1,77 @@
package cat
import "testing"
// Every known rig's OmniRig behaviour, pinned. These rules genuinely contradict
// each other between models, so a change that fixes one rig must be shown not to
// break another — that is what this table is for.
func TestResolveOmniRigVFOs(t *testing.T) {
const (
a14200 = 14200000
b14205 = 14205000
b21000 = 21000000
)
cases := []struct {
name string
rig string
main, fa, fb int64
vfo string
split int64
sticky bool
wantTX, wantRX int64
wantSplit bool
}{
// The reported failure: FTDX101D, SUB VFO pressed. OmniRig names VFO B and
// reports it as the generic Freq; freqA still holds the main VFO. Preferring
// freqA meant the display never followed the operator to B.
{"FTDX101D on SUB VFO", "FTDX101D", b14205, a14200, b14205, "B", pmSplitOff, false, b14205, 0, false},
{"FTDX101D on MAIN VFO", "FTDX101D", a14200, a14200, b14205, "A", pmSplitOff, false, a14200, 0, false},
// Yaesu fallback for a rig file that names NO VFO at all (the stock FTDX10
// one, confirmed 2026-07-26): the generic Freq matching FreqB is then the
// only sign that the operator is on SUB. It applies ONLY when the enum is
// silent — when the enum names a VFO, the enum wins (pair cases below).
{"FTDX10, no enum, generic Freq is B", "FTDX10", b14205, a14200, b14205, "", pmSplitOff, false, b14205, 0, false},
// Same alternating ini: one poll says OFF while the rig IS in split. The
// latched ON flag has to survive the contradicting sample.
{"FTDX101D split, contradicting OFF sample", "FTDX101D", a14200, a14200, b14205, "AB", pmSplitOff, true, b14205, a14200, true},
// The latch must not manufacture a split out of a stale cross-band VFO B.
{"FTDX101D latch, VFOs on different bands", "FTDX101D", a14200, a14200, b21000, "AB", pmSplitOff, true, a14200, 0, false},
// Pair enums: first letter is the VFO being listened on. Reported by the
// whole Yaesu family; ignoring them pinned the display to VFO A (F4NBZ,
// 2026-07-26 — Log4OM follows SUB on the same rig through OmniRig).
{"pair enum BA, simplex → listening on B", "", b14205, a14200, b14205, "BA", pmSplitOff, false, b14205, 0, false},
{"pair enum BB, simplex → listening on B", "", b14205, a14200, b14205, "BB", pmSplitOff, false, b14205, 0, false},
{"pair enum AB, simplex → listening on A", "", a14200, a14200, b14205, "AB", pmSplitOff, false, a14200, 0, false},
{"pair enum AA, simplex → listening on A", "", a14200, a14200, b14205, "AA", pmSplitOff, false, a14200, 0, false},
// Non-regression: a rig that does not report the VFO enum keeps the old
// order — freqA, then the generic Freq, then freqB.
{"no VFO enum, freqA populated (Yaesu/Kenwood)", "FT-891", a14200, a14200, 0, "", pmSplitOff, false, a14200, 0, false},
{"no VFO enum, only generic Freq (IC-9100)", "IC-9100", a14200, 0, 0, "", pmSplitOff, false, a14200, 0, false},
{"IC-7610: generic Freq reports B, enum says A", "IC-7610", b14205, a14200, b14205, "A", pmSplitOff, false, a14200, 0, false},
// Split: PM_SPLITON must be read as a BIT. An exact == 0x8000 reported "no
// split" for any rig that sets the flag alongside another bit.
{"split, ON flag alone", "", a14200, a14200, b14205, "AB", pmSplitOn, false, b14205, a14200, true},
{"split, ON flag with extra bits set", "", a14200, a14200, b14205, "AB", pmSplitOn | 0x40, false, b14205, a14200, true},
{"listening on B → TX on A", "", b14205, a14200, b14205, "BA", pmSplitOn, false, a14200, b14205, true},
// Split must NOT be inferred when the rig says OFF, nor from a stale VFO B
// left on another band (the FT-710 / TS-570 false positive).
{"OFF flag, two distinct VFOs", "", a14200, a14200, b14205, "A", pmSplitOff, false, a14200, 0, false},
{"ON flag but VFOs on different bands", "", a14200, a14200, b21000, "AB", pmSplitOn, false, a14200, 0, false},
{"ON flag but both VFOs identical", "", a14200, a14200, a14200, "AB", pmSplitOn, false, a14200, 0, false},
{"ON and OFF both set — ambiguous, treat as no split", "", a14200, a14200, b14205, "A", pmSplitOn | pmSplitOff, false, a14200, 0, false},
}
for _, c := range cases {
tx, rx, split := resolveOmniRigVFOs(c.rig, c.main, c.fa, c.fb, c.vfo, c.split, c.sticky)
if tx != c.wantTX || rx != c.wantRX || split != c.wantSplit {
t.Errorf("%s:\n got TX=%d RX=%d split=%v\n want TX=%d RX=%d split=%v",
c.name, tx, rx, split, c.wantTX, c.wantRX, c.wantSplit)
}
}
}
+194
View File
@@ -0,0 +1,194 @@
package clublog
// mostwanted.go — the ClubLog "Most Wanted" DXCC ranking. ClubLog publishes, per
// callsign, how wanted each DXCC entity is (rank 1 = the single most wanted, e.g.
// P5 North Korea). We fetch it, invert it to a DXCC-number → rank map, cache it on
// disk (personalised to the operator's callsign) and refresh daily. The rank is
// surfaced next to the entity in the entry-strip band/slot matrix.
//
// Endpoint (same one DXHunter uses): mostwanted.php?api=1 returns JSON
// { "1": "344", "2": "123", ... } rank → ADIF (DXCC) number
// Passing &callsign=<CALL> personalises the ranking to that operator's log.
// api=1 is just the "return JSON" flag — no API key is needed here.
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
const mwURL = "https://clublog.org/mostwanted.php?api=1"
const mwFile = "clublog_mostwanted.json"
// mwUserAgent — ClubLog's nginx 403s the default Go user-agent, so set a real one.
const mwUserAgent = "OpsLog (amateur radio logger)"
// mwCache is the on-disk shape (ranks keyed by DXCC number as a string for JSON).
type mwCache struct {
Callsign string `json:"callsign"`
FetchedAt time.Time `json:"fetched_at"`
Ranks map[string]int `json:"ranks"`
}
// MostWanted owns the on-disk cache and the parsed DXCC-number → rank map.
type MostWanted struct {
dir string
mu sync.RWMutex
ranks map[int]int // dxcc number → rank (1 = most wanted)
call string // callsign the current ranks were fetched for
fetched time.Time
}
func NewMostWanted(dataDir string) *MostWanted {
return &MostWanted{dir: dataDir, ranks: map[int]int{}}
}
func (m *MostWanted) path() string { return filepath.Join(m.dir, mwFile) }
// Rank returns the most-wanted rank for a DXCC number, or 0 if unknown/unloaded.
func (m *MostWanted) Rank(dxcc int) int {
if dxcc <= 0 {
return 0
}
m.mu.RLock()
defer m.mu.RUnlock()
return m.ranks[dxcc]
}
// Ranks returns a copy of the whole DXCC-number → rank map.
func (m *MostWanted) Ranks() map[int]int {
m.mu.RLock()
defer m.mu.RUnlock()
out := make(map[int]int, len(m.ranks))
for k, v := range m.ranks {
out[k] = v
}
return out
}
// Info reports the callsign the list was fetched for, its date, and entity count.
func (m *MostWanted) Info() (call, date string, count int) {
m.mu.RLock()
defer m.mu.RUnlock()
d := ""
if !m.fetched.IsZero() {
d = m.fetched.Format("2006-01-02")
}
return m.call, d, len(m.ranks)
}
func (m *MostWanted) Loaded() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.ranks) > 0
}
// LoadFromDisk reads the cached JSON into memory. Does NOT download.
func (m *MostWanted) LoadFromDisk() error {
b, err := os.ReadFile(m.path())
if err != nil {
return err
}
var c mwCache
if err := json.Unmarshal(b, &c); err != nil {
return err
}
ranks := make(map[int]int, len(c.Ranks))
for k, v := range c.Ranks {
if n, err := strconv.Atoi(k); err == nil && n > 0 && v > 0 {
ranks[n] = v
}
}
m.mu.Lock()
m.ranks = ranks
m.call = c.Callsign
m.fetched = c.FetchedAt
m.mu.Unlock()
return nil
}
// NeedsRefresh reports whether the list is empty, older than maxAge, or was
// fetched for a DIFFERENT callsign (the ranking is personalised, so a profile
// switch invalidates it).
func (m *MostWanted) NeedsRefresh(callsign string, maxAge time.Duration) bool {
m.mu.RLock()
defer m.mu.RUnlock()
if len(m.ranks) == 0 {
return true
}
if !strings.EqualFold(strings.TrimSpace(callsign), m.call) {
return true
}
return time.Since(m.fetched) > maxAge
}
// Download fetches the most-wanted list for callsign, writes it atomically, and
// swaps it into memory.
func (m *MostWanted) Download(ctx context.Context, callsign string) error {
if err := os.MkdirAll(m.dir, 0o755); err != nil {
return err
}
call := strings.ToUpper(strings.TrimSpace(callsign))
url := mwURL
if call != "" {
url += "&callsign=" + call
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", mwUserAgent)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("clublog mostwanted HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return err
}
// { "1": "344", "2": "123", ... } — rank → ADIF (DXCC) number.
var raw map[string]string
if err := json.Unmarshal(body, &raw); err != nil {
return fmt.Errorf("clublog mostwanted parse: %w", err)
}
ranks := make(map[int]int, len(raw))
strRanks := make(map[string]int, len(raw))
for rankStr, adif := range raw {
rank, e1 := strconv.Atoi(strings.TrimSpace(rankStr))
dxcc, e2 := strconv.Atoi(strings.TrimSpace(adif))
if e1 == nil && e2 == nil && rank > 0 && dxcc > 0 {
ranks[dxcc] = rank
strRanks[adif] = rank
}
}
if len(ranks) == 0 {
return fmt.Errorf("clublog mostwanted: empty/invalid response")
}
now := time.Now()
if b, err := json.Marshal(mwCache{Callsign: call, FetchedAt: now, Ranks: strRanks}); err == nil {
tmp := m.path() + ".tmp"
if os.WriteFile(tmp, b, 0o644) == nil {
_ = os.Rename(tmp, m.path())
}
}
m.mu.Lock()
m.ranks = ranks
m.call = call
m.fetched = now
m.mu.Unlock()
return nil
}
+697
View File
@@ -0,0 +1,697 @@
// Package cwdecode is a real-time CW (Morse) decoder: it turns a stream of
// mono PCM samples into decoded text.
//
// This is the second generation of the decoder. The first one worked on clean
// machine keying but fell apart on real signals; every stage below exists to
// fix a specific failure of that version (and of naïve Goertzel decoders in
// general):
//
// audio ─ Hamming-windowed Goertzel bank ─ pitch lock ─ dB envelope with
// separate noise-floor / peak trackers ─ hysteresis slicer + SNR squelch ─
// DEBOUNCED mark/space stream ─ two-cluster dit/dah length tracking ─
// element / character / word segmentation ─ Morse table ─ text
//
// The fixes that matter, in order of impact:
//
// 1. Debounced transitions BOTH ways. The old decoder rejected too-short
// marks but not too-short SPACES, so a one-hop fade inside a dah (QSB,
// static crash) split it into two dits — the single biggest source of
// garbage on real signals. Here a state flip must persist for a glitch
// time (~0.3 dit) before it is committed; shorter flips are folded back
// into the surrounding element.
//
// 2. Two-cluster timing. Dit and dah lengths are tracked as two separate
// moving centres with the decision boundary at their geometric mean,
// instead of one EMA "dot length" that both classifies marks and is
// updated by that same classification (a feedback loop that spiralled to
// "all dits at 60 WPM" the moment it started mis-classifying). The
// inter-element gaps also feed the dit centre — spaces are timing
// evidence too, and hand keying is often more regular in its gaps than
// in its dits.
//
// 3. dB-domain envelope. Peak and noise floor are tracked in dB with
// asymmetric attack/decay, the slicer runs at 55%/38% of the span with
// hysteresis, and a minimum-span squelch (6 dB) keeps pure noise from
// keying at all. In the old linear-magnitude scheme weak signals lived
// in the bottom few percent of the scale and QSB swallowed them.
//
// 4. Windowed Goertzel. A Hamming window tames spectral leakage so a strong
// tone doesn't bleed across the whole bank and corrupt both the noise
// estimate and the lock choice.
//
// Kept from the first version because they were right: the pitch LOCK (decode
// one tone, ignore QRM at other pitches), pitch targeting (follow the radio's
// known CW pitch instead of searching — SetTarget), tiered acquisition
// (strong signals lock on the first hop so their opening dit isn't eaten),
// the floor frozen during key-down (a rising floor mid-dah fragments it), and
// the end-of-over flush when the lock releases.
//
// Deliberately dependency-free and fed by plain []int16 so the whole pipeline
// is unit-tested with synthetic signals (see cwdecode_test.go: clean keying at
// several speeds, added noise, QSB fading, QRM on a nearby pitch, and a
// noise-only squelch test).
//
// Honest expectations: on clean or moderately noisy signals this decodes
// solidly; very weak signals in heavy QRM remain hard for any envelope
// decoder — the tools that shine there (CW Skimmer, SDC) use probabilistic
// sequence estimation on top. This stage is designed so such a layer could be
// added later without touching the DSP.
package cwdecode
import (
"math"
"sort"
"sync/atomic"
)
// Status is a periodic snapshot for the UI (pitch lock, speed, signal).
type Status struct {
WPM int `json:"wpm"`
Pitch int `json:"pitch"` // Hz of the locked tone (0 = not locked)
Level float64 `json:"level"` // 0..1 input audio level (RMS) for the meter
Active bool `json:"active"` // a tone is currently keyed down
}
// Decoder consumes PCM and emits decoded characters via onChar (one or more
// characters at a time, including " " for word gaps) and periodic onStatus.
// Process must be called from a single goroutine; SetTarget is safe to call
// concurrently.
type Decoder struct {
fs int
hop int // samples between analyses (~6 ms)
win int // Goertzel window length (~20 ms)
hopMs float64 // hop duration in ms
biasMs float64 // envelope widening caused by the analysis window (see below)
window []float64 // Hamming window, len win
ring []float64 // circular raw-sample buffer, len win
rpos int // next write position in ring
filled int // samples written so far (until >= win)
acc int // samples since last analysis
ws []float64 // scratch: windowed samples for this hop
// Search bank (only run while unlocked / untargeted).
freqs []float64
coeffs []float64
mags []float64 // dB per bin
nbuf []float64 // scratch for the median
// Fixed-pitch target (Hz). 0 = auto-search; >0 = decode exactly this pitch
// and ignore everything else (e.g. follow the radio's CW pitch). Set live
// from another goroutine, so it's atomic.
targetHz atomic.Int32
targetFor float64 // freq the cached target coeff was computed for
targetCoeff float64
// Pitch lock.
lockIdx int // bin index while auto-locked; -1 = unlocked
candIdx int
candHops int
quietHops int // consecutive key-up hops while locked (drives release)
bankTick int // hops since the bank last ran while locked
betterHops int // evidence that a different bin is the real signal
// Envelope (dB domain) on the locked/target tone.
floorDB, peakDB float64
bankNoiseDB float64 // broadband reference: EMA of the bank median
haveBankNoise bool
envSeeded bool
rawKey bool // slicer output this hop
// Debounced mark/space state machine.
key bool // committed state (true = mark)
stableHops int // hops in the committed state
pendHops int // consecutive hops the raw state has disagreed
// Two-cluster element timing (ms).
muDit, muDah float64
marksSeen int
// Character assembly. Element DURATIONS are stored and only classified
// into dits/dahs when the character is flushed: by then the character's
// own marks are all known, and a bimodal batch carries its own dit/dah
// boundary — so even the very first character of an over decodes
// correctly at any speed, before the global clusters have converged.
elemMs []float64
charEmitted bool
wordEmitted bool
textSince bool // something was decoded since lock (guards leading spaces)
lastPitch float64
lastRMS float64
statusEvery int
sinceStatus int
onChar func(string)
onStatus func(Status)
}
var morse = map[string]byte{
".-": 'A', "-...": 'B', "-.-.": 'C', "-..": 'D', ".": 'E', "..-.": 'F',
"--.": 'G', "....": 'H', "..": 'I', ".---": 'J', "-.-": 'K', ".-..": 'L',
"--": 'M', "-.": 'N', "---": 'O', ".--.": 'P', "--.-": 'Q', ".-.": 'R',
"...": 'S', "-": 'T', "..-": 'U', "...-": 'V', ".--": 'W', "-..-": 'X',
"-.--": 'Y', "--..": 'Z',
"-----": '0', ".----": '1', "..---": '2', "...--": '3', "....-": '4',
".....": '5', "-....": '6', "--...": '7', "---..": '8', "----.": '9',
".-.-.-": '.', "--..--": ',', "..--..": '?', "-..-.": '/', "-...-": '=',
".-.-.": '+', "-.-.--": '!', "---...": ':', "-....-": '-', ".--.-.": '@',
}
// Tunables (hops are ~6 ms).
const (
minDitMs = 20.0 // 60 WPM ceiling
maxDitMs = 240.0 // 5 WPM floor
seedDit = 60.0 // 20 WPM seed before any marks are seen
acqStrongDB = 12.0 // lock on the FIRST hop above this SNR (don't eat the opening dit)
acqWeakDB = 8.5 // lock after sustained hops above this SNR
// Successive analysis windows overlap ~70%, so consecutive hops are highly
// correlated — a noise spike easily "persists" 23 hops. Requiring ~2 full
// window lengths of persistence makes a false noise lock genuinely rare.
acqWeakHops = 6
squelchDB = 6.0 // minimum peak-floor span to key at all
// The span alone can't reject pure noise: a noise bin's dB level swings
// ±56 dB, which the peak/floor trackers happily turn into a keyable
// span. The second squelch is ABSOLUTE: the envelope peak must stand well
// above the broadband noise reference (the bank median) — a keyed tone
// does, noise never sustainably does.
snrSquelchDB = 9.5
// The slicer's span is CAPPED: with a very quiet background (high SNR, or
// digitally silent test signals) the raw floor sits so far below the peak
// that the off-threshold lands in the analysis window's skirts — every
// mark then stretches by almost a full window and every gap shrinks,
// until character gaps fall below the segmentation threshold and letters
// merge. Capping the usable span keeps the slicer crossing near the
// signal edges regardless of how quiet the background is.
spanCapDB = 30.0
onFrac = 0.55 // slicer thresholds as a fraction of the (capped) span…
offFrac = 0.38 // …with hysteresis
charGapDits = 2.2 // gap > this ⇒ character boundary (geom. mean of 1 & 3 ≈ 1.7, plus margin for sloppy fists)
wordGapDits = 4.6 // gap > this ⇒ word boundary (geom. mean of 3 & 7)
)
// New builds a decoder for the given sample rate. onChar receives decoded text
// incrementally; onStatus receives ~10 snapshots/second. Either may be nil.
func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
if sampleRate <= 0 {
sampleRate = 16000
}
d := &Decoder{
fs: sampleRate,
hop: sampleRate * 5 / 1000, // 5 ms — resolves dits up to ~50 WPM
win: sampleRate * 16 / 1000, // 16 ms window (≈80 Hz bandwidth with Hamming; a 20 ms window left 40 WPM inter-element gaps with almost no envelope dip)
lockIdx: -1,
candIdx: -1,
muDit: seedDit,
muDah: 3 * seedDit,
onChar: onChar,
onStatus: onStatus,
}
if d.hop < 1 {
d.hop = 1
}
if d.win < 4*d.hop {
d.win = 4 * d.hop
}
d.hopMs = float64(d.hop) / float64(d.fs) * 1000
// Even with the span cap, the analysis window widens every mark and
// narrows every gap by roughly half a window on each edge (the tone leaks
// into windows that straddle an edge). Durations are de-biased by this
// constant so the timing clusters and gap thresholds see true lengths.
d.biasMs = 0.6 * float64(d.win) / float64(d.fs) * 1000
d.statusEvery = int(math.Max(1, 100/d.hopMs)) // ~10 Hz
d.ring = make([]float64, d.win)
d.ws = make([]float64, d.win)
// Hamming window: 43 dB sidelobes keep a strong tone from bleeding across
// the bank (rectangular Goertzel leaks at 13 dB, enough to fool the lock).
d.window = make([]float64, d.win)
for i := range d.window {
d.window[i] = 0.54 - 0.46*math.Cos(2*math.Pi*float64(i)/float64(d.win-1))
}
// Candidate CW tones: 4001000 Hz every 30 Hz. Deliberately NOT lower:
// low-frequency hum/rumble rises toward DC and would win the argmax.
for f := 400.0; f <= 1000.0; f += 30 {
d.freqs = append(d.freqs, f)
d.coeffs = append(d.coeffs, 2*math.Cos(2*math.Pi*f/float64(d.fs)))
}
d.mags = make([]float64, len(d.freqs))
d.nbuf = make([]float64, len(d.freqs))
return d
}
// SetTarget fixes the decode pitch to hz (an exact-frequency detector, not the
// nearest search bin), or returns to auto-search when hz <= 0. Safe to call
// concurrently.
func (d *Decoder) SetTarget(hz int) { d.targetHz.Store(int32(hz)) }
// Reset clears decode state (e.g. when the user re-arms the decoder).
func (d *Decoder) Reset() {
d.rpos, d.filled, d.acc = 0, 0, 0
d.lockIdx, d.candIdx, d.candHops, d.quietHops = -1, -1, 0, 0
d.envSeeded, d.rawKey, d.key = false, false, false
d.haveBankNoise = false
d.stableHops, d.pendHops = 0, 0
d.bankTick, d.betterHops = 0, 0
d.muDit, d.muDah, d.marksSeen = seedDit, 3*seedDit, 0
d.elemMs = d.elemMs[:0]
d.charEmitted, d.wordEmitted, d.textSince = true, true, false
}
// Process feeds a block of mono samples through the decoder.
func (d *Decoder) Process(samples []int16) {
for _, s := range samples {
d.ring[d.rpos] = float64(s)
d.rpos++
if d.rpos == d.win {
d.rpos = 0
}
if d.filled < d.win {
d.filled++
}
d.acc++
if d.acc >= d.hop && d.filled >= d.win {
d.acc = 0
d.hopStep()
}
}
}
// goertzelDB returns the tone power (dB, arbitrary reference) of the windowed
// scratch buffer at the detector coefficient c.
func (d *Decoder) goertzelDB(c float64) float64 {
var s1, s2 float64
for _, x := range d.ws {
s0 := x + c*s1 - s2
s2 = s1
s1 = s0
}
p := s1*s1 + s2*s2 - c*s1*s2
if p < 1e-12 {
p = 1e-12
}
return 10 * math.Log10(p)
}
// hopStep runs one analysis hop: tone detection, envelope, slicer, timing.
func (d *Decoder) hopStep() {
// Materialize the window (oldest→newest; order is irrelevant for power)
// and the RMS level for the UI meter.
var sumSq float64
for i := 0; i < d.win; i++ {
x := d.ring[(d.rpos+i)%d.win]
d.ws[i] = x * d.window[i]
sumSq += x * x
}
d.lastRMS = math.Min(1, math.Sqrt(sumSq/float64(d.win))/32768*4)
toneDB, haveTone := 0.0, false
if th := float64(d.targetHz.Load()); th > 0 {
// Fixed pitch: one exact-frequency detector, like a skimmer channel.
if th != d.targetFor {
d.targetFor = th
d.targetCoeff = 2 * math.Cos(2*math.Pi*th/float64(d.fs))
d.envSeeded = false // re-seed the envelope for the new channel
}
toneDB = d.goertzelDB(d.targetCoeff)
d.lastPitch = th
d.lockIdx = -1 // targeting supersedes the auto lock
haveTone = true
// Refresh the broadband noise reference for the absolute squelch.
d.bankTick++
if d.bankTick >= 8 {
d.bankTick = 0
for i, c := range d.coeffs {
d.mags[i] = d.goertzelDB(c)
}
copy(d.nbuf, d.mags)
sort.Float64s(d.nbuf)
d.updateBankNoise(d.nbuf[len(d.nbuf)/2])
}
} else if d.lockIdx >= 0 {
// Auto-locked: only the locked bin is needed per hop.
toneDB = d.goertzelDB(d.coeffs[d.lockIdx])
d.lastPitch = d.freqs[d.lockIdx]
haveTone = true
// Supervised re-lock: periodically sweep the whole bank anyway. If a
// clearly stronger tone lives on a DIFFERENT pitch and keeps doing so,
// the current lock is wrong (locked onto noise, or the operator moved)
// — jump to the real signal instead of decoding garbage until the
// quiet-release finally fires.
d.bankTick++
if d.bankTick >= 8 {
d.bankTick = 0
bestIdx, bestDB := -1, math.Inf(-1)
for i, c := range d.coeffs {
m := d.goertzelDB(c)
d.mags[i] = m
if i >= d.lockIdx-1 && i <= d.lockIdx+1 {
continue
}
if m > bestDB {
bestDB, bestIdx = m, i
}
}
copy(d.nbuf, d.mags)
sort.Float64s(d.nbuf)
d.updateBankNoise(d.nbuf[len(d.nbuf)/2])
if bestIdx >= 0 && bestDB > toneDB+6 {
d.betterHops += 2
} else if d.betterHops > 0 {
d.betterHops--
}
if d.betterHops >= 24 && bestIdx >= 0 { // ~12 confirmations over ~1 s
d.flushPending()
copy(d.nbuf, d.mags)
sort.Float64s(d.nbuf)
d.lockIdx = bestIdx
// Seed the envelope honestly: floor from the bank median, NOT
// peakcap — fabricating a full span would let a noise re-lock
// key freely until the trackers converged.
d.floorDB, d.peakDB = d.nbuf[len(d.nbuf)/2], bestDB
d.quietHops, d.bankTick, d.betterHops = 0, 0, 0
d.marksSeen = 0
d.elemMs = d.elemMs[:0]
d.charEmitted, d.wordEmitted, d.textSince = true, true, false
d.key, d.stableHops, d.pendHops = false, 0, 0
toneDB = d.goertzelDB(d.coeffs[d.lockIdx])
d.lastPitch = d.freqs[d.lockIdx]
}
}
} else {
// Unlocked: run the whole bank, estimate noise, hunt for a tone.
maxIdx, maxDB := 0, math.Inf(-1)
for i, c := range d.coeffs {
m := d.goertzelDB(c)
d.mags[i] = m
if m > maxDB {
maxDB, maxIdx = m, i
}
}
copy(d.nbuf, d.mags)
sort.Float64s(d.nbuf)
noise := d.nbuf[len(d.nbuf)/2] // median: robust to a few strong tones
d.updateBankNoise(noise)
snr := maxDB - noise
near := d.candIdx >= 0 && maxIdx >= d.candIdx-1 && maxIdx <= d.candIdx+1
if near {
d.candHops++
} else {
d.candIdx, d.candHops = maxIdx, 1
}
// Tiered acquisition: a clearly strong tone locks on the FIRST hop (so
// its opening dit isn't eaten), a marginal one must persist a few hops
// (so we don't lock onto a noise spike).
if snr > acqStrongDB || (d.candHops >= acqWeakHops && snr > acqWeakDB) {
d.lockIdx = maxIdx
d.floorDB, d.peakDB = noise, maxDB
d.envSeeded = true
d.quietHops = 0
d.bankTick, d.betterHops = 0, 0
d.marksSeen = 0 // relearn speed quickly for this signal (keep muDit as seed)
d.elemMs = d.elemMs[:0]
d.charEmitted, d.wordEmitted, d.textSince = true, true, false
d.key, d.stableHops, d.pendHops = false, 0, 0
toneDB = maxDB
d.lastPitch = d.freqs[maxIdx]
haveTone = true
} else {
d.lastPitch = 0
}
}
if !haveTone {
d.rawKey = false
d.emitStatus()
return
}
if !d.envSeeded {
// First hop on a targeted channel: seed from the current reading.
d.floorDB, d.peakDB = toneDB, toneDB+squelchDB
d.envSeeded = true
}
// ---- Envelope: separate floor / peak trackers, dB domain. ----
// Floor drops fast, but only RISES while keyed up: a floor creeping up
// under a long dah shrinks the span until the dah fragments into dits.
if toneDB < d.floorDB {
d.floorDB += (toneDB - d.floorDB) * 0.3
} else if !d.rawKey {
d.floorDB += (toneDB - d.floorDB) * 0.015
}
// Peak attacks fast and decays slowly toward current conditions (~1.5 s),
// so QSB is followed without collapsing across ordinary word gaps.
if toneDB > d.peakDB {
d.peakDB += (toneDB - d.peakDB) * 0.4
} else {
d.peakDB += (toneDB - d.peakDB) * 0.004
}
if d.peakDB < d.floorDB {
d.peakDB = d.floorDB
}
// ---- Slicer with hysteresis + SNR squelch. ----
if d.peakDB-d.floorDB < squelchDB ||
(d.haveBankNoise && d.peakDB < d.bankNoiseDB+snrSquelchDB) {
d.rawKey = false
} else {
// Cap the usable span so the slicer crossings stay near the keying
// edges even over a dead-quiet background (see spanCapDB).
effFloor := math.Max(d.floorDB, d.peakDB-spanCapDB)
span := d.peakDB - effFloor
if d.rawKey {
d.rawKey = toneDB > effFloor+offFrac*span
} else {
d.rawKey = toneDB > effFloor+onFrac*span
}
}
// ---- Auto-lock release after a long quiet spell. ----
if d.lockIdx >= 0 {
if d.rawKey {
d.quietHops = 0
} else {
d.quietHops++
// Long enough to survive slow-speed word gaps (7 dits), short
// enough to retune to a new signal within a few seconds.
release := int(math.Max(2000, 12*d.muDit) / d.hopMs)
if d.quietHops > release {
d.flushPending()
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
d.rawKey = false
d.lastPitch = 0
}
}
}
d.timing()
d.emitStatus()
}
// updateBankNoise folds a fresh bank-median reading into the broadband noise
// reference used by the absolute squelch.
func (d *Decoder) updateBankNoise(medianDB float64) {
if !d.haveBankNoise {
d.bankNoiseDB, d.haveBankNoise = medianDB, true
return
}
d.bankNoiseDB += (medianDB - d.bankNoiseDB) * 0.2
}
// glitchHops is the debounce time in hops: ~0.3 dit, clamped to 1..3 hops
// (619 ms) so it neither swallows fast dits nor passes static crashes.
func (d *Decoder) glitchHops() int {
g := int(math.Round(0.3 * d.muDit / d.hopMs))
if g < 1 {
g = 1
}
if g > 3 {
g = 3
}
return g
}
// timing runs the debounced mark/space state machine for one hop.
func (d *Decoder) timing() {
if d.rawKey == d.key {
// Agreement folds any pending flip back into the committed state:
// a sub-glitch dropout inside a dah (or spike inside a gap) vanishes.
d.stableHops += 1 + d.pendHops
d.pendHops = 0
} else {
d.pendHops++
if d.pendHops > d.glitchHops() {
seg := d.stableHops
wasMark := d.key
d.key = d.rawKey
d.stableHops = d.pendHops
d.pendHops = 0
if wasMark {
d.endMark(seg)
} else {
d.endSpace(seg)
}
if d.key {
d.charEmitted, d.wordEmitted = false, false
}
}
}
if !d.key {
d.spaceProgress()
}
}
// endMark stores a finished key-down run for the character batch. Cluster
// updates deliberately do NOT happen here: attributing a mark to the dit or
// dah centre with the still-converging global boundary poisons the clusters
// (a dah-led character at an unexpected speed lands its dahs in the dit
// centre, which then oscillates). Both classification AND cluster updates
// happen per character in flushChar, where the batch's own contrast makes
// the attribution reliable.
func (d *Decoder) endMark(hops int) {
ms := float64(hops)*d.hopMs - d.biasMs // de-bias the window widening
if ms < 0.5*minDitMs {
return // debounce residue — not a credible element
}
d.elemMs = append(d.elemMs, ms)
// Bootstrap: a first mark SHORTER than the seeded dit can only be a dit of
// a faster sender — jump the estimate straight there instead of easing in.
if d.marksSeen == 0 && ms < d.muDit {
d.muDit = math.Max(ms, minDitMs)
}
d.marksSeen++
if len(d.elemMs) > 8 {
d.elemMs = d.elemMs[:0] // no Morse character is that long — noise run
}
}
// endSpace runs when a mark begins: the finished gap, if it was clearly an
// inter-element one, is extra evidence for the dit length (gaps are often
// steadier than dits in hand keying).
func (d *Decoder) endSpace(hops int) {
ms := float64(hops)*d.hopMs + d.biasMs // gaps shrink by what marks gained
if d.marksSeen < 1 || ms < 0.35*d.muDit || ms > 1.7*d.muDit {
return
}
// Early on, gaps are the FASTEST way to find the true dit length (the very
// first inter-element gap is one, whatever the first mark was); once the
// clusters have settled they are just a gentle refinement.
alpha := 0.08
if d.marksSeen < 8 {
alpha = 0.35
}
d.muDit += (ms - d.muDit) * alpha
d.muDit = math.Min(math.Max(d.muDit, minDitMs), maxDitMs)
}
// spaceProgress emits the pending character / word space LIVE once the current
// gap crosses each boundary (instead of waiting for the next mark), so text
// appears as it is sent.
func (d *Decoder) spaceProgress() {
gapMs := float64(d.stableHops)*d.hopMs + d.biasMs
if !d.charEmitted && gapMs > charGapDits*d.muDit {
d.flushChar()
d.charEmitted = true
}
if !d.wordEmitted && gapMs > wordGapDits*d.muDit {
if d.textSince && d.onChar != nil {
d.onChar(" ")
}
d.wordEmitted = true
}
}
// flushChar classifies the accumulated mark durations into dits/dahs and
// emits the character. Classification happens HERE, not as marks arrive: a
// character whose own marks are clearly bimodal carries its own dit/dah
// boundary (their geometric mean), which decodes correctly even before the
// global clusters have converged — the first character of an over included.
// Unimodal characters (EEE, TTT, 555…) fall back to the global boundary.
func (d *Decoder) flushChar() {
if len(d.elemMs) == 0 {
return
}
lo, hi := d.elemMs[0], d.elemMs[0]
for _, v := range d.elemMs[1:] {
lo = math.Min(lo, v)
hi = math.Max(hi, v)
}
b := math.Sqrt(d.muDit * d.muDah)
if hi >= 2*lo {
b = math.Sqrt(lo * hi) // the batch is bimodal: trust its own contrast
}
alpha := 0.18
if d.marksSeen <= 8 {
alpha = 0.45 // converge fast on a new sender
}
pat := make([]byte, len(d.elemMs))
for i, v := range d.elemMs {
if v < b {
pat[i] = '.'
d.muDit += (v - d.muDit) * alpha
} else {
pat[i] = '-'
d.muDah += (v - d.muDah) * alpha
}
}
// Ratio guards: dah stays 24.8 dits; dit stays within speed limits.
d.muDit = math.Min(math.Max(d.muDit, minDitMs), maxDitMs)
if d.muDah < 2.0*d.muDit {
d.muDah = 2.0 * d.muDit
}
if d.muDah > 4.8*d.muDit {
d.muDah = 4.8 * d.muDit
}
d.elemMs = d.elemMs[:0]
if c, ok := morse[string(pat)]; ok {
if d.onChar != nil {
d.onChar(string(c))
}
d.textSince = true
} else if len(pat) <= 7 {
// Morse-shaped but unknown → flag it; longer runs are noise, drop.
if d.onChar != nil {
d.onChar("?")
}
d.textSince = true
}
}
// flushPending finishes the in-progress character and word at end-of-over
// (lock release), so the last word isn't left hanging until the next signal.
func (d *Decoder) flushPending() {
if !d.charEmitted {
d.flushChar()
d.charEmitted = true
}
if !d.wordEmitted && d.textSince && d.onChar != nil {
d.onChar(" ")
d.wordEmitted = true
}
}
func (d *Decoder) emitStatus() {
d.sinceStatus++
if d.sinceStatus < d.statusEvery || d.onStatus == nil {
return
}
d.sinceStatus = 0
wpm := 0
if d.muDit > 0 && (d.lockIdx >= 0 || d.targetHz.Load() > 0) {
wpm = int(math.Round(1200 / d.muDit))
}
d.onStatus(Status{
WPM: wpm,
Pitch: int(math.Round(d.lastPitch)),
Level: d.lastRMS,
Active: d.rawKey,
})
}
+326
View File
@@ -0,0 +1,326 @@
package cwdecode
import (
"math"
"math/rand"
"strings"
"testing"
)
// ---- Synthesizer -----------------------------------------------------------
func charToMorse() map[byte]string {
m := map[byte]string{}
for code, ch := range morse {
m[ch] = code
}
return m
}
// keyMessage synthesizes keyed CW for msg with raised-cosine edges (5 ms), so
// the signal has realistic click-free envelopes rather than hard steps.
func keyMessage(msg string, fs, wpm int, pitch, amp float64) []int16 {
dot := fs * 1200 / (wpm * 1000) // samples per dit
edge := fs * 5 / 1000 // 5 ms shaping
c2m := charToMorse()
var out []float64
phase := 0.0
dphi := 2 * math.Pi * pitch / float64(fs)
tone := func(n int) {
for i := 0; i < n; i++ {
g := 1.0
if i < edge {
g = 0.5 - 0.5*math.Cos(math.Pi*float64(i)/float64(edge))
} else if n-1-i < edge {
g = 0.5 - 0.5*math.Cos(math.Pi*float64(n-1-i)/float64(edge))
}
out = append(out, amp*g*math.Sin(phase))
phase += dphi
}
}
silence := func(n int) {
for i := 0; i < n; i++ {
out = append(out, 0)
}
}
silence(fs / 4) // lead-in for envelope warm-up
for i := 0; i < len(msg); i++ {
ch := msg[i]
if ch == ' ' {
silence(4 * dot) // + trailing 3 from the previous char = 7 total
continue
}
code := c2m[ch]
for j := 0; j < len(code); j++ {
if code[j] == '.' {
tone(dot)
} else {
tone(3 * dot)
}
silence(dot)
}
silence(2 * dot) // + trailing element gap = 3 total
}
silence(fs / 2)
return toInt16(out)
}
func toInt16(x []float64) []int16 {
out := make([]int16, len(x))
for i, v := range x {
if v > 32767 {
v = 32767
} else if v < -32768 {
v = -32768
}
out[i] = int16(v)
}
return out
}
func addNoise(s []int16, sigma float64, seed int64) []int16 {
r := rand.New(rand.NewSource(seed))
out := make([]int16, len(s))
for i, v := range s {
out[i] = int16(math.Max(-32768, math.Min(32767, float64(v)+r.NormFloat64()*sigma)))
}
return out
}
// applyQSB modulates the amplitude between lo..1.0 at rate Hz (slow fading).
func applyQSB(s []int16, fs int, rate, lo float64) []int16 {
out := make([]int16, len(s))
for i, v := range s {
g := lo + (1-lo)*(0.5+0.5*math.Sin(2*math.Pi*rate*float64(i)/float64(fs)))
out[i] = int16(float64(v) * g)
}
return out
}
// applyDropouts blanks brief windows (ms long) every period ms — static-crash
// style holes that land inside dahs and gaps alike.
func applyDropouts(s []int16, fs int, everyMs, holeMs int) []int16 {
out := make([]int16, len(s))
copy(out, s)
every := fs * everyMs / 1000
hole := fs * holeMs / 1000
for start := every; start+hole < len(out); start += every {
for i := start; i < start+hole; i++ {
out[i] = 0
}
}
return out
}
func mix(a, b []int16) []int16 {
n := len(a)
if len(b) > n {
n = len(b)
}
out := make([]int16, n)
for i := 0; i < n; i++ {
var v int
if i < len(a) {
v += int(a[i])
}
if i < len(b) {
v += int(b[i])
}
if v > 32767 {
v = 32767
} else if v < -32768 {
v = -32768
}
out[i] = int16(v)
}
return out
}
// decode runs samples through a fresh decoder in live-sized chunks.
func decode(t *testing.T, samples []int16, targetHz int) string {
t.Helper()
var sb strings.Builder
d := New(16000, func(s string) { sb.WriteString(s) }, nil)
if targetHz > 0 {
d.SetTarget(targetHz)
}
for i := 0; i < len(samples); i += 256 {
end := i + 256
if end > len(samples) {
end = len(samples)
}
d.Process(samples[i:end])
}
return strings.ToUpper(sb.String())
}
func wantContains(t *testing.T, got, want, label string) {
t.Helper()
if !strings.Contains(got, want) {
t.Fatalf("%s: decoded %q, want it to contain %q", label, got, want)
}
}
// ---- Tests -----------------------------------------------------------------
func TestCleanSignalSpeeds(t *testing.T) {
const fs = 16000
for _, wpm := range []int{12, 18, 25, 32, 40} {
got := decode(t, keyMessage("CQ TEST DE F4BPO K", fs, wpm, 700, 9000), 0)
wantContains(t, got, "CQ TEST DE F4BPO K", "clean @"+itoa(wpm)+"wpm")
}
}
func TestOtherPitches(t *testing.T) {
const fs = 16000
for _, pitch := range []float64{450, 600, 850} {
got := decode(t, keyMessage("PARIS PARIS", fs, 22, pitch, 9000), 0)
wantContains(t, got, "PARIS PARIS", "pitch")
}
}
func TestWithNoise(t *testing.T) {
const fs = 16000
clean := keyMessage("CQ CQ DE HB9HBY", fs, 22, 700, 9000)
noisy := addNoise(clean, 2000, 1) // ≈13 dB tone/noise in the audio band
got := decode(t, noisy, 0)
wantContains(t, got, "CQ CQ DE HB9HBY", "noise")
}
// QSB fading between 35% and 100% amplitude — the adaptive dB envelope must
// ride it. The old linear envelope lost the faded halves entirely.
func TestQSBFading(t *testing.T) {
const fs = 16000
clean := keyMessage("CQ CQ CQ DE F4BPO F4BPO", fs, 20, 700, 12000)
faded := applyQSB(clean, fs, 0.4, 0.35)
got := decode(t, faded, 0)
wantContains(t, got, "DE F4BPO", "qsb")
}
// Brief 10 ms holes punched every 150 ms — they land inside dahs. Without the
// two-sided debounce every hit dah shatters into dits (the old decoder's
// single worst failure on real signals).
func TestDropoutsInsideDahs(t *testing.T) {
const fs = 16000
clean := keyMessage("TEST TEST TEST", fs, 18, 700, 9000)
holed := applyDropouts(clean, fs, 150, 10)
got := decode(t, holed, 0)
wantContains(t, got, "TEST TEST", "dropouts")
}
// QRM: a second, slightly weaker keyed signal at 950 Hz. The pitch lock must
// hold the 700 Hz target and ignore the interferer.
func TestQRMAutoLock(t *testing.T) {
const fs = 16000
target := keyMessage("PARIS PARIS PARIS", fs, 20, 700, 9000)
qrm := keyMessage("QRZ QRZ QRZ QRZ QRZ", fs, 26, 950, 5000)
got := decode(t, mix(target, qrm), 0)
wantContains(t, got, "PARIS", "qrm-auto")
}
// Targeted mode: with two comparable signals, SetTarget must decode the chosen
// one even though the other is as strong.
func TestQRMTargeted(t *testing.T) {
const fs = 16000
want := keyMessage("SOS SOS SOS", fs, 20, 600, 8000)
other := keyMessage("QRL QRL QRL QRL", fs, 24, 900, 8000)
got := decode(t, mix(want, other), 600)
wantContains(t, got, "SOS SOS", "qrm-target")
}
// Pure noise must stay silent: the squelch keys nothing, so no text at all.
func TestNoiseOnlySquelch(t *testing.T) {
const fs = 16000
noise := addNoise(make([]int16, fs*6), 3000, 7)
got := strings.TrimSpace(decode(t, noise, 0))
if len(got) > 2 { // tolerate at most a stray flagged char
t.Fatalf("squelch: decoded %q from pure noise, want (almost) nothing", got)
}
}
// keyMessageJitter synthesizes hand-sent CW: every element and gap duration
// is jittered (elements ±je, gaps ±jg, uniform), like a human fist.
func keyMessageJitter(msg string, fs, wpm int, pitch, amp, je, jg float64, seed int64) []int16 {
r := rand.New(rand.NewSource(seed))
dot := float64(fs) * 1200 / (float64(wpm) * 1000)
edge := fs * 5 / 1000
c2m := charToMorse()
var out []float64
phase := 0.0
dphi := 2 * math.Pi * pitch / float64(fs)
jit := func(n float64, j float64) int { return int(n * (1 + (r.Float64()*2-1)*j)) }
tone := func(n int) {
for i := 0; i < n; i++ {
g := 1.0
if i < edge {
g = 0.5 - 0.5*math.Cos(math.Pi*float64(i)/float64(edge))
} else if n-1-i < edge {
g = 0.5 - 0.5*math.Cos(math.Pi*float64(n-1-i)/float64(edge))
}
out = append(out, amp*g*math.Sin(phase))
phase += dphi
}
}
silence := func(n int) {
for i := 0; i < n; i++ {
out = append(out, 0)
}
}
silence(fs / 4)
for i := 0; i < len(msg); i++ {
ch := msg[i]
if ch == ' ' {
silence(jit(4*dot, jg))
continue
}
code := c2m[ch]
for j := 0; j < len(code); j++ {
if code[j] == '.' {
tone(jit(dot, je))
} else {
tone(jit(3*dot, je))
}
silence(jit(dot, jg))
}
silence(jit(2*dot, jg))
}
silence(fs / 2)
return toInt16(out)
}
// Hand keying: ±20% element jitter, ±25% gap jitter — a sloppy but readable
// human fist. The batch classifier and the gap-fed dit tracking must ride it.
func TestHandKeying(t *testing.T) {
const fs = 16000
for seed := int64(1); seed <= 3; seed++ {
s := keyMessageJitter("CQ CQ DE HB9HBY HB9HBY K", fs, 22, 700, 9000, 0.20, 0.25, seed)
got := decode(t, s, 0)
wantContains(t, got, "HB9HBY", "hand-keying")
}
}
// Speed change mid-over: the cluster tracker must follow 25 → 15 WPM.
func TestSpeedChange(t *testing.T) {
const fs = 16000
fast := keyMessage("CQ CQ CQ DE F4BPO", fs, 25, 700, 9000)
slow := keyMessage("UR RST 599 599", fs, 15, 700, 9000)
got := decode(t, append(fast, slow...), 0)
wantContains(t, got, "F4BPO", "speed-fast-part")
wantContains(t, got, "599", "speed-slow-part")
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [8]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
+30
View File
@@ -0,0 +1,30 @@
package cwdecode
import (
"fmt"
"testing"
)
// TestDebugTrace prints the element stream for a chosen case — a development
// aid, not an assertion test. Run with: go test -run TestDebugTrace -v
func TestDebugTrace(t *testing.T) {
if testing.Short() {
t.Skip("debug aid")
}
const fs = 16000
samples := keyMessage("CQ TEST DE F4BPO K", fs, 40, 700, 9000)
var d *Decoder
d = New(fs, func(s string) { fmt.Printf("EMIT %q (muDit=%.0f muDah=%.0f)\n", s, d.muDit, d.muDah) }, nil)
lastMarks := 0
for i := 0; i < len(samples); i += 256 {
end := i + 256
if end > len(samples) {
end = len(samples)
}
d.Process(samples[i:end])
if d.marksSeen != lastMarks {
lastMarks = d.marksSeen
fmt.Printf("mark#%d elems=%v muDit=%.0f muDah=%.0f\n", d.marksSeen, d.elemMs, d.muDit, d.muDah)
}
}
}
+74 -3
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"embed"
"fmt"
"os"
"sort"
"strings"
"time"
@@ -143,7 +144,6 @@ func SetDialect(d string) {
// same INSERT/UPDATE works on both backends.
func NowISO() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z") }
// Open opens (and creates if needed) the SQLite database at the given path,
// enables performance PRAGMAs, and applies embedded migrations.
func Open(path string) (*sql.DB, error) {
@@ -161,18 +161,77 @@ func Open(path string) (*sql.DB, error) {
return nil, fmt.Errorf("ping sqlite: %w", err)
}
Dialect = "sqlite"
if err := migrate(conn, nil); err != nil {
if err := migrate(conn, nil, path); err != nil {
_ = conn.Close()
return nil, err
}
return conn, nil
}
// LogSink receives this package's diagnostic lines. The app points it at
// applog.Printf at startup (same pattern as cat / audio / extsvc); left nil in
// tests and in the CLI tools under cmd/, where it is simply discarded.
var LogSink func(format string, args ...any)
// logMigration records a migration that has just been applied, and how long it
// took — the only trace an operator has that a data-rewriting migration ran.
func logMigration(name string, start time.Time) {
logf("db: migration %s applied in %s", name, time.Since(start).Round(time.Millisecond))
}
func logf(format string, args ...any) {
if LogSink != nil {
LogSink(format, args...)
}
}
// dataRewriteMarker flags a migration that rewrites existing user rows rather
// than only altering the schema. Put it on its own line in the .sql file, and a
// safety copy of the logbook is taken before it runs.
const dataRewriteMarker = "-- opslog:rewrites-data"
// backupBeforeRewrite takes a one-off copy of the logbook before a migration
// that rewrites user rows.
//
// It exists because the auto-updater gives the operator no say: the new build
// relaunches and migrates before the changelog explaining it is ever shown, so
// "back up first" is advice nobody can act on. The app takes the copy instead.
//
// Skipped when there is nothing to protect — a shared MySQL (no file to copy;
// that server is the admin's to back up), the settings database, and a
// freshly-created empty logbook all have no QSOs at stake.
func backupBeforeRewrite(conn *sql.DB, dbPath, migration string) {
if dbPath == "" {
return
}
var n int
if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil || n == 0 {
return
}
dest := dbPath + ".pre-" + strings.TrimSuffix(migration, ".sql") + ".bak"
if _, err := os.Stat(dest); err == nil {
return // a copy from an earlier attempt is already there — never overwrite it
}
// VACUUM INTO rather than copying the file: it writes a consistent,
// self-contained snapshot even with WAL pages still outstanding, which a
// plain file copy would silently miss. It cannot run inside a transaction,
// so it happens here, before the migration opens one. The destination is
// spliced (VACUUM INTO takes no bound parameter), with quotes doubled.
start := time.Now()
if _, err := conn.Exec(`VACUUM INTO '` + strings.ReplaceAll(dest, "'", "''") + `'`); err != nil {
// Not fatal: the migration itself is a single atomic transaction, so
// failing to take a belt-and-braces copy is no reason to block the update.
logf("db: could not back up before %s: %v — continuing (the migration is atomic)", migration, err)
return
}
logf("db: backed up %d QSO(s) to %s in %s before %s", n, dest, time.Since(start).Round(time.Millisecond), migration)
}
// migrate applies all embedded *.sql migrations in alphabetical order,
// skipping those already applied. Intentionally minimal in-house system
// (no external dependency). translate, when non-nil, rewrites each statement
// for a non-SQLite backend (see mysqlDDL); nil means run the SQLite DDL as-is.
func migrate(conn *sql.DB, translate func(string) string) error {
func migrate(conn *sql.DB, translate func(string) string, dbPath string) error {
// A non-nil translator means this is the MySQL connection (use the
// per-statement, FK-aware path); nil means a SQLite connection. This is
// determined by the caller's argument, NOT the global Dialect, so the
@@ -219,12 +278,22 @@ func migrate(conn *sql.DB, translate func(string) string) error {
if applied[name] {
continue // already applied
}
// Timed, and logged only once it has actually succeeded (below). Most
// migrations are instant DDL, but some rewrite user rows — 0024 upper-cases
// every callsign — and on a large logbook that is exactly what an operator
// wants confirmed afterwards: that it ran, once, and what it cost.
start := time.Now()
content, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
sqlText := translate(string(content))
// A migration that rewrites user rows gets a safety copy taken first.
if strings.Contains(string(content), dataRewriteMarker) {
backupBeforeRewrite(conn, dbPath, name)
}
// MySQL implicitly commits each DDL statement, so a wrapping transaction
// gives no atomicity — a mid-file failure would leave columns/tables
// behind, unrecorded, and every restart would re-run and choke on
@@ -238,6 +307,7 @@ func migrate(conn *sql.DB, translate func(string) string) error {
if _, err := conn.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil {
return fmt.Errorf("record migration %s: %w", name, err)
}
logMigration(name, start)
continue
}
@@ -257,6 +327,7 @@ func migrate(conn *sql.DB, translate func(string) string) error {
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
logMigration(name, start)
}
return nil
}
+161
View File
@@ -0,0 +1,161 @@
package db
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
const mig0024 = "0024_normalise_callsign.sql"
// openWithUnappliedRewrite builds a logbook that already holds QSOs and has not
// yet had the callsign-normalising migration applied — i.e. exactly what an
// existing installation looks like the moment the auto-updater relaunches it.
func openWithUnappliedRewrite(t *testing.T, rows [][2]string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "logbook.db")
conn, err := Open(p)
if err != nil {
t.Fatal(err)
}
for _, r := range rows {
if _, err := conn.Exec(
`INSERT INTO qso (callsign, qso_date, band, mode) VALUES (?, ?, '20m', 'SSB')`, r[0], r[1]); err != nil {
t.Fatal(err)
}
}
// Rewind so the migration runs against real data on the next Open.
if _, err := conn.Exec(`DELETE FROM schema_migrations WHERE name = ?`, mig0024); err != nil {
t.Fatal(err)
}
if err := conn.Close(); err != nil {
t.Fatal(err)
}
return p
}
func callsigns(t *testing.T, path string) []string {
t.Helper()
conn, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
rows, err := conn.Query(`SELECT callsign FROM qso ORDER BY id`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out []string
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
t.Fatal(err)
}
out = append(out, c)
}
return out
}
// The auto-updater migrates before the operator ever sees the changelog, so the
// app has to take the safety copy itself. The copy must hold the data as it was
// BEFORE the rewrite — a copy of the already-migrated rows would be worthless.
func TestRewriteMigrationBacksUpOriginalData(t *testing.T) {
p := openWithUnappliedRewrite(t, [][2]string{
{"f5lit", "2026-07-01"},
{" Pa3Eyf ", "2026-07-02"},
{"F4BPO", "2026-07-03"},
})
var logged []string
LogSink = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) }
defer func() { LogSink = nil }()
conn, err := Open(p)
if err != nil {
t.Fatal(err)
}
conn.Close()
// The live logbook is normalised.
if got, want := callsigns(t, p), []string{"F5LIT", "PA3EYF", "F4BPO"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("logbook callsigns = %v, want %v", got, want)
}
// The backup exists, next to the logbook, named after the migration.
backup := p + ".pre-0024_normalise_callsign.bak"
if _, err := os.Stat(backup); err != nil {
t.Fatalf("no safety copy at %s: %v\nlogged:\n%s", backup, err, strings.Join(logged, "\n"))
}
// …and it holds the ORIGINAL rows, untouched.
if got, want := callsigns(t, backup), []string{"f5lit", " Pa3Eyf ", "F4BPO"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("backup callsigns = %v, want the pre-migration values %v", got, want)
}
var sawBackup bool
for _, l := range logged {
if strings.Contains(l, "backed up 3 QSO(s)") {
sawBackup = true
}
}
if !sawBackup {
t.Errorf("the backup was not logged; got:\n%s", strings.Join(logged, "\n"))
}
}
// No QSOs, nothing to protect: the settings database and a freshly created
// logbook must not litter the folder with pointless copies.
func TestRewriteMigrationSkipsBackupWhenNoQSOs(t *testing.T) {
p := openWithUnappliedRewrite(t, nil)
conn, err := Open(p)
if err != nil {
t.Fatal(err)
}
conn.Close()
if _, err := os.Stat(p + ".pre-0024_normalise_callsign.bak"); err == nil {
t.Error("an empty database should not be backed up")
}
}
// schema_migrations is what makes "runs once" a guarantee rather than a promise:
// a second launch must neither re-run the rewrite nor take a second copy.
func TestRewriteMigrationRunsOnce(t *testing.T) {
p := openWithUnappliedRewrite(t, [][2]string{{"f5lit", "2026-07-01"}})
conn, err := Open(p)
if err != nil {
t.Fatal(err)
}
conn.Close()
backup := p + ".pre-0024_normalise_callsign.bak"
first, err := os.Stat(backup)
if err != nil {
t.Fatal(err)
}
var logged []string
LogSink = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) }
defer func() { LogSink = nil }()
conn2, err := Open(p) // second launch
if err != nil {
t.Fatal(err)
}
conn2.Close()
for _, l := range logged {
if strings.Contains(l, mig0024) {
t.Errorf("migration ran again on the second launch: %s", l)
}
}
second, err := os.Stat(backup)
if err != nil {
t.Fatal(err)
}
if !first.ModTime().Equal(second.ModTime()) {
t.Error("the existing safety copy was overwritten on the second launch")
}
}
@@ -0,0 +1,22 @@
-- opslog:rewrites-data
-- Normalise stored callsigns so lookups can use idx_qso_callsign.
--
-- WorkedBefore matched rows with `upper(trim(callsign)) = ?`. Wrapping the
-- column in functions makes the predicate non-sargable: SQLite cannot use the
-- index and falls back to scanning. Measured on a 190 000-row logbook, the
-- COUNT went from 0.3 ms (SEARCH ... USING INDEX) to 20.8 ms (SCAN), and the
-- entries query — which needs every column, so not even a covering index helps
-- — scans the whole table. That runs on every keystroke of a callsign, and it
-- made the entry strip's history arrive too late to auto-fill the name and
-- locator from the previous QSO. Small logbooks never showed it.
--
-- Add, bulk insert and Update have always upper-cased and trimmed the callsign,
-- so this only rewrites rows left by older versions or foreign imports, and the
-- queries can then compare the column directly.
--
-- SQLite compares case-sensitively, so the WHERE finds exactly the rows that
-- need it. MySQL's default collation is case- and trailing-space-insensitive:
-- there the UPDATE is largely a no-op and equally unnecessary, because `=`
-- already matches those rows through the index.
UPDATE qso SET callsign = upper(trim(callsign))
WHERE callsign <> upper(trim(callsign));
+51 -2
View File
@@ -200,16 +200,65 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
err = applyMySQLBaseline(conn)
} else {
// Existing database: apply only the migrations it's missing.
err = migrate(conn, mysqlDDL)
err = migrate(conn, mysqlDDL, "")
}
if err != nil {
_ = conn.Close()
Dialect = "sqlite" // migration failed; we'll fall back to SQLite
return nil, err
}
// Guarantee the schema can store non-Latin text (Cyrillic, Polish ł, …).
// Best-effort: never block access to the logbook over the charset probe.
if err := ensureMySQLUTF8MB4(conn, name); err != nil {
fmt.Printf("OpsLog: utf8mb4 conversion: %v\n", err)
}
return conn, nil
}
// ensureMySQLUTF8MB4 makes the database default and every OpsLog table utf8mb4,
// converting any that isn't. It repairs databases pre-created as latin1 or
// utf8mb3 by a hosting panel (cPanel/o2switch default), where OpsLog's
// "CREATE DATABASE IF NOT EXISTS … utf8mb4" was a no-op and the tables inherited
// a charset that can't store non-Latin callsigns/names/QTHs — the MySQL 1366
// "Incorrect string value: '\xD0\xAF'" (Cyrillic Я) seen updating a QSO from QRZ.
//
// Idempotent and cheap on a healthy database: one information_schema probe finds
// the tables with a non-utf8mb4 text column, and only those are converted (none
// on an already-correct DB). Conversion is safe for the wide qso table because
// the bulk of its columns are off-page TEXT (see the row-size note above); only
// the ~20 indexed VARCHARs sit in-row, well under InnoDB's 65535-byte limit even
// at 4 bytes/char.
func ensureMySQLUTF8MB4(conn *sql.DB, dbName string) error {
// Fix the database default so any FUTURE table is utf8mb4 too.
_, _ = conn.Exec("ALTER DATABASE `" + dbName + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
rows, err := conn.Query(`
SELECT DISTINCT TABLE_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = ?
AND CHARACTER_SET_NAME IS NOT NULL
AND CHARACTER_SET_NAME <> 'utf8mb4'`, dbName)
if err != nil {
return fmt.Errorf("probe charsets: %w", err)
}
var tables []string
for rows.Next() {
var t string
if err := rows.Scan(&t); err == nil && validDBIdent(t) {
tables = append(tables, t)
}
}
rows.Close()
var firstErr error
for _, t := range tables {
if _, err := conn.Exec("ALTER TABLE `" + t + "` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil && firstErr == nil {
firstErr = fmt.Errorf("convert %s: %w", t, err)
}
}
return firstErr
}
// isFreshMySQL reports whether the database has no OpsLog schema yet (no qso
// table), so the baseline fast-path applies. A partially-migrated database is
// NOT fresh and goes through the incremental migrator.
@@ -238,7 +287,7 @@ func applyMySQLBaseline(conn *sql.DB) error {
return fmt.Errorf("open baseline sqlite: %w", err)
}
defer mem.Close()
if err := migrate(mem, nil); err != nil {
if err := migrate(mem, nil, ""); err != nil {
return fmt.Errorf("build baseline schema: %w", err)
}
+195
View File
@@ -0,0 +1,195 @@
package extsvc
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Cloudlog (and its fork Wavelog) are self-hosted logbooks, so there is no
// fixed endpoint: the user gives the base URL of their own instance and we
// append the API path. Both expose the SAME contract — an ADIF record wrapped
// in JSON — which is why one uploader serves both.
//
// POST <base>/index.php/api/qso
// {"key":"…","station_profile_id":"1","type":"adif","string":"<call:4>… <eor>"}
//
// The station profile id is NOT optional: Cloudlog files the QSO under one of
// the account's station locations, and a wrong id silently lands the contact in
// someone else's log slot.
const cloudlogAPIPath = "index.php/api/qso"
// cloudlogEndpoint builds the API URL from whatever the user pasted. People
// paste the dashboard URL, the API URL, with or without a trailing slash or
// index.php, so normalise all of it rather than make them guess the exact form.
func cloudlogEndpoint(base string) (string, error) {
u := strings.TrimSpace(base)
if u == "" {
return "", fmt.Errorf("cloudlog: URL not set")
}
// A bare host or IP is almost always meant as http:// on a LAN instance;
// requiring the scheme just produces a confusing "unsupported protocol".
if !strings.HasPrefix(strings.ToLower(u), "http://") && !strings.HasPrefix(strings.ToLower(u), "https://") {
u = "http://" + u
}
u = strings.TrimRight(u, "/")
// Trim anything the user copied past the site root, so both
// "https://log.f4bpo.fr" and "https://log.f4bpo.fr/index.php/api/qso" work.
for _, suffix := range []string{"/index.php/api/qso", "/api/qso", "/index.php"} {
if strings.HasSuffix(strings.ToLower(u), suffix) {
u = u[:len(u)-len(suffix)]
u = strings.TrimRight(u, "/")
}
}
return u + "/" + cloudlogAPIPath, nil
}
// cloudlogRequest is the JSON body both Cloudlog and Wavelog expect.
type cloudlogRequest struct {
Key string `json:"key"`
StationID string `json:"station_profile_id"`
Type string `json:"type"`
String string `json:"string"`
}
// cloudlogReply covers the documented failure shape
// ({"status":"failed","reason":"missing api key"}); success replies vary
// between versions, so success is judged on the HTTP status plus the ABSENCE
// of a failure marker rather than on a field that may not be there.
type cloudlogReply struct {
Status string `json:"status"`
Reason string `json:"reason"`
Type string `json:"type"`
String string `json:"string"`
}
// cloudlogPost sends one JSON body and returns the trimmed response.
func cloudlogPost(ctx context.Context, client *http.Client, endpoint string, body cloudlogRequest) (string, int, error) {
buf, err := json.Marshal(body)
if err != nil {
return "", 0, fmt.Errorf("cloudlog: encode request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(buf))
if err != nil {
return "", 0, fmt.Errorf("cloudlog: build request: %w", err)
}
// Content-Type is what most "wrong JSON" reports come down to: without it
// Cloudlog falls back to form-decoding and never sees the fields.
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return "", 0, fmt.Errorf("cloudlog: request failed: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
return strings.TrimSpace(string(raw)), resp.StatusCode, nil
}
// cloudlogReason turns a reply into a human-readable failure reason, or ""
// when the reply looks like a success.
func cloudlogReason(body string, status int) string {
var r cloudlogReply
if json.Unmarshal([]byte(body), &r) == nil && strings.EqualFold(r.Status, "failed") {
if r.Reason != "" {
return r.Reason
}
return "rejected"
}
switch {
case status == http.StatusUnauthorized || status == http.StatusForbidden:
// The documented 401 body is {"status":"failed","reason":"missing api key"},
// but a reverse proxy in front of the instance can swallow it.
return "API key refused"
case status == http.StatusNotFound:
return "API not found at this URL — check the address of your Cloudlog/Wavelog instance"
case status >= 400:
msg := body
if len(msg) > 200 {
msg = msg[:200]
}
if msg == "" {
msg = fmt.Sprintf("HTTP %d", status)
}
return msg
}
return ""
}
// UploadCloudlog pushes one ADIF record to a Cloudlog or Wavelog instance.
//
// Duplicates are handled by the server (Cloudlog dedupes on the fly), so a
// retry of an already-accepted QSO is harmless — the upload stays idempotent
// without OpsLog having to track it.
func UploadCloudlog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
endpoint, err := cloudlogEndpoint(cfg.URL)
if err != nil {
return UploadResult{}, err
}
key := strings.TrimSpace(cfg.APIKey)
station := strings.TrimSpace(cfg.StationID)
if key == "" {
return UploadResult{}, fmt.Errorf("cloudlog: API key not set")
}
if station == "" {
return UploadResult{}, fmt.Errorf("cloudlog: station ID not set")
}
if strings.TrimSpace(adifRecord) == "" {
return UploadResult{}, fmt.Errorf("cloudlog: empty adif record")
}
body, status, err := cloudlogPost(ctx, client, endpoint, cloudlogRequest{
Key: key, StationID: station, Type: "adif", String: adifRecord,
})
if err != nil {
return UploadResult{OK: false, Message: body}, err
}
// The endpoint is echoed in both outcomes: a self-hosted instance means the
// URL itself is a prime suspect, and a log line naming what was actually
// called settles it without the operator having to guess how we normalised
// what they typed.
if reason := cloudlogReason(body, status); reason != "" {
return UploadResult{OK: false, Message: reason},
fmt.Errorf("cloudlog: POST %s → HTTP %d: %s", endpoint, status, reason)
}
return UploadResult{OK: true, Message: fmt.Sprintf("uploaded to %s (HTTP %d)", endpoint, status)}, nil
}
// TestCloudlog validates URL, API key and station ID with a REAL request.
//
// It posts a well-formed body with an EMPTY ADIF string: the credentials are
// checked by the server before the record is parsed, so a bad key or id fails
// exactly as it would for a real QSO, while nothing is inserted.
func TestCloudlog(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
endpoint, err := cloudlogEndpoint(cfg.URL)
if err != nil {
return "", err
}
key := strings.TrimSpace(cfg.APIKey)
station := strings.TrimSpace(cfg.StationID)
if key == "" {
return "", fmt.Errorf("cloudlog: API key not set")
}
if station == "" {
return "", fmt.Errorf("cloudlog: station ID not set")
}
body, status, err := cloudlogPost(ctx, client, endpoint, cloudlogRequest{
Key: key, StationID: station, Type: "adif", String: "",
})
if err != nil {
return "", err
}
if reason := cloudlogReason(body, status); reason != "" {
return "", fmt.Errorf("cloudlog: %s", reason)
}
return fmt.Sprintf("Connected — station profile %s", station), nil
}
+13 -5
View File
@@ -33,6 +33,9 @@ const (
ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL)
ServiceHRDLog Service = "hrdlog" // HRDLog.net real-time upload
ServiceEQSL Service = "eqsl" // eQSL.cc ADIF upload
// ServiceCloudlog covers Cloudlog AND its fork Wavelog: same API contract,
// only the instance URL differs, so one service handles both.
ServiceCloudlog Service = "cloudlog"
)
// UploadMode selects when an auto-upload fires after a QSO is saved.
@@ -63,6 +66,8 @@ const (
// user can run e.g. Club Log immediate and QRZ delayed).
type ServiceConfig struct {
APIKey string `json:"api_key"`
URL string `json:"url"` // Cloudlog/Wavelog: base URL of the user's own instance
StationID string `json:"station_id"` // Cloudlog/Wavelog: station profile (location) id
Email string `json:"email"` // Club Log account email
Username string `json:"username"` // LoTW website login (for confirmation download)
Password string `json:"password"` // Club Log account / LoTW website password
@@ -83,6 +88,8 @@ type ServiceConfig struct {
// mode (defaults to immediate).
func (c ServiceConfig) normalised() ServiceConfig {
c.APIKey = strings.TrimSpace(c.APIKey)
c.URL = strings.TrimSpace(c.URL)
c.StationID = strings.TrimSpace(c.StationID)
c.Email = strings.TrimSpace(c.Email)
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
c.Code = strings.TrimSpace(c.Code)
@@ -115,11 +122,12 @@ func (c ServiceConfig) normalised() ServiceConfig {
// ExternalServices bundles every service's config for the settings UI.
type ExternalServices struct {
QRZ ServiceConfig `json:"qrz"`
Clublog ServiceConfig `json:"clublog"`
LoTW ServiceConfig `json:"lotw"`
HRDLog ServiceConfig `json:"hrdlog"`
EQSL ServiceConfig `json:"eqsl"`
QRZ ServiceConfig `json:"qrz"`
Clublog ServiceConfig `json:"clublog"`
LoTW ServiceConfig `json:"lotw"`
HRDLog ServiceConfig `json:"hrdlog"`
EQSL ServiceConfig `json:"eqsl"`
Cloudlog ServiceConfig `json:"cloudlog"`
}
// UploadResult is the outcome of a single upload attempt.
+28 -7
View File
@@ -36,8 +36,8 @@ func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg Ser
q.Set("login", user)
q.Set("password", cfg.Password)
q.Set("qso_query", "1")
q.Set("qso_qsl", "yes") // only QSLed (confirmed) records
q.Set("qso_qsldetail", "yes") // include QSL_RCVD / QSLRDATE detail
q.Set("qso_qsl", "yes") // only QSLed (confirmed) records
q.Set("qso_qsldetail", "yes") // include QSL_RCVD / QSLRDATE detail
if c := strings.TrimSpace(ownCall); c != "" {
q.Set("qso_owncall", c) // restrict to this station callsign
}
@@ -65,11 +65,32 @@ func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg Ser
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("lotw: http %d", resp.StatusCode)
}
// LoTW returns a plain-text error (not ADIF) on bad login.
// Not ADIF. Two very different failures land here, and telling them apart is
// the difference between a fixable message and a wall of markup.
if !strings.Contains(strings.ToUpper(text), "<EOH>") && !strings.Contains(strings.ToLower(text), "<eor>") {
msg := strings.TrimSpace(text)
trimmed := strings.TrimSpace(text)
// Keep the whole thing in the log — that is where a real diagnosis happens,
// and a 200-character excerpt of an HTML page tells nobody anything.
snippet := trimmed
if len(snippet) > 2000 {
snippet = snippet[:2000]
}
LogSink("lotw: expected ADIF, got %d bytes of non-ADIF; first 2000: %s", len(text), snippet)
// LoTW answers a REJECTED LOGIN with its ordinary web page rather than an
// error string, so an HTML body here means the credentials were not
// accepted — not that the download is broken.
lower := strings.ToLower(trimmed)
if strings.HasPrefix(lower, "<!doctype html") || strings.HasPrefix(lower, "<html") || strings.Contains(lower, "logbook of the world</title>") {
return "", fmt.Errorf("LoTW returned its web page instead of a log, which is how it answers a login it did not accept. " +
"Check the username and password in Settings → External services: LoTW wants your lotw.arrl.org WEBSITE login, " +
"not your callsign certificate or your ARRL member number")
}
// Anything else: a plain-text complaint from LoTW, or a maintenance notice.
msg := trimmed
if len(msg) > 200 {
msg = msg[:200]
msg = msg[:200] + "…"
}
return "", fmt.Errorf("lotw: unexpected response: %s", msg)
}
@@ -220,8 +241,8 @@ func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord stri
// AppCompat RUNASADMIN entry), but OpsLog isn't elevated so Windows
// refuses to launch it. Actionable message instead of the raw error.
return UploadResult{}, fmt.Errorf(
"lotw: Windows won't launch tqsl.exe because it's marked \"Run as administrator\". " +
"Fix: right-click %q → Properties → Compatibility → UNTICK \"Run this program as an administrator\" (Apply). " +
"lotw: Windows won't launch tqsl.exe because it's marked \"Run as administrator\". "+
"Fix: right-click %q → Properties → Compatibility → UNTICK \"Run this program as an administrator\" (Apply). "+
"Or run OpsLog itself as administrator.", tqsl)
} else {
return UploadResult{}, fmt.Errorf("lotw: run tqsl: %w", runErr)
+69 -1
View File
@@ -138,7 +138,30 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
cfg.LoTW = cfg.LoTW.normalised()
cfg.HRDLog = cfg.HRDLog.normalised()
cfg.EQSL = cfg.EQSL.normalised()
cfg.Cloudlog = cfg.Cloudlog.normalised()
m.cfg = cfg
// Summary of what is armed, written at startup and on every settings save.
// It answers "is the service even switched on for this profile?" — the
// settings are per-profile, so a service configured under another profile
// looks enabled in the UI of the one and silent in the other.
var on []string
for _, s := range []struct {
name string
cfg ServiceConfig
}{
{"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW},
{"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog},
} {
if s.cfg.AutoUpload {
on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode))
}
}
if len(on) == 0 {
m.logf("extsvc: auto-upload disabled for every service")
} else {
m.logf("extsvc: auto-upload armed for %s", strings.Join(on, " "))
}
}
// Config returns the current snapshot.
@@ -182,6 +205,28 @@ func (m *Manager) OnQSOLogged(id int64) {
if e := cfg.EQSL; e.AutoUpload && e.Username != "" && e.Password != "" {
m.route(ServiceEQSL, id, e)
}
// Cloudlog / Wavelog — the instance URL, an API key and the station id.
if c := cfg.Cloudlog; c.AutoUpload {
// Say WHY nothing happens when the toggle is on but a field is missing.
// Without this the whole path was silent — the operator saw no upload and
// no log line, with no way to tell "disabled" from "broken".
var missing []string
if c.URL == "" {
missing = append(missing, "URL")
}
if c.APIKey == "" {
missing = append(missing, "API key")
}
if c.StationID == "" {
missing = append(missing, "station ID")
}
if len(missing) > 0 {
m.logf("extsvc: cloudlog auto-upload is ON but not configured — missing %s (QSO %d not sent)",
strings.Join(missing, ", "), id)
} else {
m.route(ServiceCloudlog, id, c)
}
}
}
// route sends a logged QSO down the configured timing path: queue it for the
@@ -229,6 +274,9 @@ func (m *Manager) onCloseServices() []Service {
if e := cfg.EQSL; e.AutoUpload && e.UploadMode == ModeOnClose && e.Username != "" && e.Password != "" {
out = append(out, ServiceEQSL)
}
if c := cfg.Cloudlog; c.AutoUpload && c.UploadMode == ModeOnClose && c.URL != "" && c.APIKey != "" && c.StationID != "" {
out = append(out, ServiceCloudlog)
}
return out
}
@@ -288,6 +336,12 @@ func (m *Manager) FlushOnClose() int {
uploaded++
}
}
case ServiceCloudlog:
for _, id := range ids {
if ok, _ := m.upload(svc, id, cfg.Cloudlog); ok {
uploaded++
}
}
}
}
return uploaded
@@ -376,6 +430,11 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
}
}
// One line per attempt, BEFORE the request: a failure that never returns
// (hung TCP connect to a self-hosted instance) otherwise leaves no trace at
// all, and "did it even try?" is the first question when an upload is missing.
m.logf("extsvc: %s uploading QSO %d…", svc, id)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -426,6 +485,15 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
return false, false
}
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
case ServiceCloudlog:
// Cloudlog/Wavelog file the QSO under a station profile chosen by id,
// so the ADIF keeps the QSO's own station call (no override).
record, ok := m.deps.BuildADIF(id, "")
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false, false
}
res, err = UploadCloudlog(ctx, m.deps.Client, cfg, record)
default:
return false, false
}
@@ -441,7 +509,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
return false, true // transient (rate-limit / network) → worth a retry
}
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID)
m.logf("extsvc: %s upload of QSO %d OK (logid=%q) %s", svc, id, res.LogID, res.Message)
if m.deps.MarkUploaded != nil {
m.deps.MarkUploaded(svc, id, res.LogID)
}
+41
View File
@@ -19,3 +19,44 @@ func TestHomeCall(t *testing.T) {
}
}
}
// Mobile and maritime-mobile suffixes: /M is the case that surfaced in the field
// (F4LYI/M resolved only to cty.dat while F4LYI resolved on QRZ), so pin the
// whole suffix family — the home call is what the provider record is filed under.
func TestHomeCallSuffixes(t *testing.T) {
for call, want := range map[string]string{
"F4LYI/M": "F4LYI",
"F4LYI/MM": "F4LYI",
"F4LYI/AM": "F4LYI",
"F4LYI/QRP": "F4LYI",
"F4LYI/A": "F4LYI",
"F4LYI/B": "F4LYI",
} {
if got := homeCall(call); got != want {
t.Errorf("homeCall(%q) = %q, want %q", call, got, want)
}
}
}
// Operational suffixes must resolve to the bare call WITHOUT a provider query on
// the slashed form; entity- and area-changing forms must not be stripped.
func TestStripOpSuffix(t *testing.T) {
strip := map[string]string{
"F4LYI/M": "F4LYI", "F4LYI/MM": "F4LYI", "F4LYI/AM": "F4LYI",
"F4LYI/P": "F4LYI", "F4LYI/QRP": "F4LYI", "F4LYI/p": "F4LYI",
"F4BPO/M/P": "F4BPO",
}
for call, want := range strip {
got, ok := stripOpSuffix(call)
if !ok || got != want {
t.Errorf("stripOpSuffix(%q) = %q,%v — want %q,true", call, got, ok, want)
}
}
// These change the entity or the call area: they are real, separately
// registered forms and must be queried exactly as entered.
for _, call := range []string{"JW/OR1A", "VP8/F4BPO", "F4BPO/8", "DL/F4NIE", "OH2BH", "F4BPO/W6"} {
if got, ok := stripOpSuffix(call); ok {
t.Errorf("stripOpSuffix(%q) stripped to %q — it changes entity/area and must be kept", call, got)
}
}
}
+102 -29
View File
@@ -109,22 +109,30 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
}
var lastErr error
for _, p := range providers {
r, err := p.Lookup(ctx, call)
if err == nil {
r.Callsign = call
r.Source = p.Name()
r.FetchedAt = time.Now().UTC()
fillFromDXCC(&r, dxcc)
normalizeNames(&r)
_ = m.cache.Put(ctx, r)
return r, nil
// An operational suffix (/M, /P, …) is never registered as such: skip the
// futile query on the slashed form and let the home-call pass below do the one
// request that can actually answer.
_, opOnly := stripOpSuffix(call)
if opOnly {
LogSink("lookup: %s carries only an operational suffix — querying the bare call", call)
} else {
for _, p := range providers {
r, err := p.Lookup(ctx, call)
if err == nil {
r.Callsign = call
r.Source = p.Name()
r.FetchedAt = time.Now().UTC()
fillFromDXCC(&r, dxcc)
normalizeNames(&r)
_ = m.cache.Put(ctx, r)
return r, nil
}
if errors.Is(err, ErrNotFound) {
lastErr = err
continue
}
lastErr = fmt.Errorf("%s: %w", p.Name(), err)
}
if errors.Is(err, ErrNotFound) {
lastErr = err
continue
}
lastErr = fmt.Errorf("%s: %w", p.Name(), err)
}
// Portable / slashed call not found under its full form: the operator's
@@ -135,6 +143,10 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
for _, p := range providers {
r, err := p.Lookup(ctx, home)
if err != nil {
// Logged, because this is where a portable lookup silently dies: the
// error is swallowed to try the next provider, and the operator only
// ever sees the cty.dat fallback with no clue why.
LogSink("lookup: %s → home call %s failed on %s: %v", call, home, p.Name(), err)
continue
}
r.Callsign = call
@@ -177,6 +189,46 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
return Result{}, lastErr
}
// LogSink receives this package's diagnostic lines (which call was actually
// queried, and why a lookup fell back). Set to applog.Printf by the app.
var LogSink = func(string, ...any) {}
// opSuffixes are OPERATIONAL suffixes: they describe how the operator is working
// — mobile, maritime, aeronautical, portable, low power — not who they are or
// where. No provider has a record filed under "F4LYI/M", so querying that form
// is a round trip that cannot succeed.
//
// It was worse than merely wasted: it spent the lookup's time budget, so the
// home-call retry that followed ran out of time and the entry fell back to
// cty.dat for every /M and /P call, even though the operator was on QRZ. These
// go straight to the bare callsign instead.
//
// Everything else after a slash is NOT this: JW/, VP8/ change the DXCC entity,
// and /8 or /W6 change the call area. Those forms can be registered in their own
// right and must be looked up exactly as entered.
var opSuffixes = map[string]bool{"M": true, "MM": true, "AM": true, "P": true, "QRP": true}
// stripOpSuffix returns the bare callsign when call carries nothing but
// operational suffixes ("F4LYI/M" → "F4LYI", true). Reports false for anything
// that changes entity or area ("JW/OR1A", "F4BPO/8"), and for a call whose base
// part isn't callsign-shaped.
func stripOpSuffix(call string) (string, bool) {
if !strings.ContainsRune(call, '/') {
return call, false
}
parts := strings.Split(call, "/")
base := strings.TrimSpace(parts[0])
if len(base) < 3 || !strings.ContainsAny(base, "0123456789") {
return call, false // "JW/OR1A": the first part is a prefix, not the callsign
}
for _, p := range parts[1:] {
if !opSuffixes[strings.ToUpper(strings.TrimSpace(p))] {
return call, false
}
}
return base, true
}
// homeCall extracts the operator's home callsign from a slashed/portable call
// so its provider record (name/QTH/QSL) can be fetched when the full form isn't
// registered: JW/OR1A → OR1A, DL/F4NIE → F4NIE, F4BPO/P → F4BPO, VP8/F4BPO →
@@ -266,12 +318,30 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
return false
}
filled := false
if country != "" { r.Country = country; filled = true }
if cont != "" { r.Continent = cont; filled = true }
if cqz != 0 { r.CQZ = cqz; filled = true }
if ituz != 0 { r.ITUZ = ituz; filled = true }
if lat != 0 && r.Lat == 0 { r.Lat = lat; filled = true }
if lon != 0 && r.Lon == 0 { r.Lon = lon; filled = true }
if country != "" {
r.Country = country
filled = true
}
if cont != "" {
r.Continent = cont
filled = true
}
if cqz != 0 {
r.CQZ = cqz
filled = true
}
if ituz != 0 {
r.ITUZ = ituz
filled = true
}
if lat != 0 && r.Lat == 0 {
r.Lat = lat
filled = true
}
if lon != 0 && r.Lon == 0 {
r.Lon = lon
filled = true
}
// cty.dat is authoritative for the *operating* entity: it strips benign
// suffixes (/P /M /MM /QRP /A …) and honours real prefixes (DL/F4NIE).
// Use its DXCC# when known — this overrides the provider's home-call
@@ -279,7 +349,10 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
// France's 227). Only when cty.dat can't map a slashed call do we drop
// the provider's number rather than mislabel.
if dxccNum != 0 {
if r.DXCC != dxccNum { r.DXCC = dxccNum; filled = true }
if r.DXCC != dxccNum {
r.DXCC = dxccNum
filled = true
}
} else if strings.ContainsRune(r.Callsign, '/') && r.DXCC != 0 {
r.DXCC = 0
filled = true
@@ -317,13 +390,13 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
source, fetched_at
FROM callsign_cache WHERE callsign = ?`, callsign)
var (
r Result
name, qth, addr, state, cnty sql.NullString
country, grid, cont, email, qslVia, image sql.NullString
src string
dxcc, cqz, ituz sql.NullInt64
lat, lon sql.NullFloat64
fetched string
r Result
name, qth, addr, state, cnty sql.NullString
country, grid, cont, email, qslVia, image sql.NullString
src string
dxcc, cqz, ituz sql.NullInt64
lat, lon sql.NullFloat64
fetched string
)
if err := row.Scan(&r.Callsign, &name, &qth, &addr, &state, &cnty,
&country, &grid, &lat, &lon,
+7
View File
@@ -26,6 +26,13 @@ type ProfileDB struct {
User string `json:"user"`
Password string `json:"password"`
Database string `json:"database"`
// Path is a per-profile SQLite logbook FILE, distinct from the shared app
// database (opslog.db, which always holds settings + profiles). Empty =
// the shared database is used as the logbook (the historical default). Set =
// this profile's QSOs live in their own .db, so a visiting operator's contacts
// don't mix into yours and switching it never touches your config. Only used
// when Backend != "mysql".
Path string `json:"path,omitempty"`
}
// Profile is one operating configuration. A user typically keeps a few:
+114
View File
@@ -0,0 +1,114 @@
package qso
import (
"context"
"database/sql"
"encoding/json"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
)
// openBulkTestDB builds the minimum of the qso table this needs. It does not go
// through db.Open (that would pull the whole migration set and an import cycle);
// the columns BulkSetExtra touches are extras_json and updated_at.
func openBulkTestDB(t *testing.T) *sql.DB {
t.Helper()
conn, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "t.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { conn.Close() })
if _, err := conn.Exec(`CREATE TABLE qso (
id INTEGER PRIMARY KEY AUTOINCREMENT,
callsign TEXT NOT NULL,
extras_json TEXT,
updated_at TEXT
)`); err != nil {
t.Fatal(err)
}
return conn
}
func extras(t *testing.T, conn *sql.DB, id int64) map[string]any {
t.Helper()
var raw sql.NullString
if err := conn.QueryRow(`SELECT extras_json FROM qso WHERE id = ?`, id).Scan(&raw); err != nil {
t.Fatal(err)
}
if !raw.Valid || raw.String == "" {
return map[string]any{}
}
var m map[string]any
if err := json.Unmarshal([]byte(raw.String), &m); err != nil {
t.Fatalf("extras_json is not valid JSON (%q): %v", raw.String, err)
}
return m
}
// OWNER_CALLSIGN has no promoted column, so bulk-editing it means merging a key
// into extras_json. The thing that must not happen is collateral damage: the
// other ADIF extras on the same QSO have to survive.
func TestBulkSetExtraPreservesOtherExtras(t *testing.T) {
conn := openBulkTestDB(t)
r := &Repo{db: conn}
ctx := context.Background()
// Two QSOs with existing extras, one with none at all (NULL column).
conn.Exec(`INSERT INTO qso (callsign, extras_json) VALUES ('F5LIT', '{"SILENT_KEY":"Y","ANT_PATH":"S"}')`)
conn.Exec(`INSERT INTO qso (callsign, extras_json) VALUES ('PA3EYF', '{"ANT_PATH":"L"}')`)
conn.Exec(`INSERT INTO qso (callsign, extras_json) VALUES ('F4BPO', NULL)`)
n, err := r.BulkSetExtra(ctx, []int64{1, 2, 3}, "OWNER_CALLSIGN", "TM2Q")
if err != nil {
t.Fatal(err)
}
if n != 3 {
t.Errorf("updated %d rows, want 3", n)
}
e1 := extras(t, conn, 1)
if e1["OWNER_CALLSIGN"] != "TM2Q" {
t.Errorf("row 1 OWNER_CALLSIGN = %v, want TM2Q", e1["OWNER_CALLSIGN"])
}
if e1["SILENT_KEY"] != "Y" || e1["ANT_PATH"] != "S" {
t.Errorf("row 1 lost its other extras: %v", e1)
}
// A NULL extras_json must become a valid object, not stay null or hold "null".
if e3 := extras(t, conn, 3); e3["OWNER_CALLSIGN"] != "TM2Q" {
t.Errorf("row 3 (extras_json was NULL) = %v, want OWNER_CALLSIGN=TM2Q", e3)
}
}
// Clearing the field must REMOVE the key: a blank extra would otherwise be
// carried into every ADIF export from then on.
func TestBulkSetExtraEmptyRemovesKey(t *testing.T) {
conn := openBulkTestDB(t)
r := &Repo{db: conn}
conn.Exec(`INSERT INTO qso (callsign, extras_json) VALUES ('F5LIT', '{"OWNER_CALLSIGN":"TM2Q","ANT_PATH":"S"}')`)
if _, err := r.BulkSetExtra(context.Background(), []int64{1}, "OWNER_CALLSIGN", ""); err != nil {
t.Fatal(err)
}
e := extras(t, conn, 1)
if _, present := e["OWNER_CALLSIGN"]; present {
t.Errorf("OWNER_CALLSIGN should be gone, got %v", e)
}
if e["ANT_PATH"] != "S" {
t.Errorf("clearing one extra removed another: %v", e)
}
}
// The frontend field id must resolve to the ADIF key, and nothing else must slip
// through — this map is the whitelist guarding a spliced JSON path.
func TestBulkExtraKeyWhitelist(t *testing.T) {
if got := BulkExtraKey("owner_callsign"); got != "OWNER_CALLSIGN" {
t.Errorf(`BulkExtraKey("owner_callsign") = %q, want "OWNER_CALLSIGN"`, got)
}
for _, bad := range []string{"", "callsign", "notes", "OWNER_CALLSIGN", "owner_callsign'"} {
if got := BulkExtraKey(bad); got != "" {
t.Errorf("BulkExtraKey(%q) = %q, want empty", bad, got)
}
}
}
+179 -89
View File
@@ -62,29 +62,29 @@ type QSO struct {
RSTRcvd string `json:"rst_rcvd,omitempty"`
// --- Contacted station ---
Name string `json:"name,omitempty"`
QTH string `json:"qth,omitempty"`
Address string `json:"address,omitempty"`
Email string `json:"email,omitempty"`
Web string `json:"web,omitempty"`
Grid string `json:"grid,omitempty"`
GridExt string `json:"gridsquare_ext,omitempty"`
VUCCGrids string `json:"vucc_grids,omitempty"`
Country string `json:"country,omitempty"`
State string `json:"state,omitempty"`
County string `json:"cnty,omitempty"`
DXCC *int `json:"dxcc,omitempty"`
Continent string `json:"cont,omitempty"`
CQZ *int `json:"cqz,omitempty"`
ITUZ *int `json:"ituz,omitempty"`
IOTA string `json:"iota,omitempty"`
SOTARef string `json:"sota_ref,omitempty"`
POTARef string `json:"pota_ref,omitempty"`
Age *int `json:"age,omitempty"`
Lat *float64 `json:"lat,omitempty"`
Lon *float64 `json:"lon,omitempty"`
Rig string `json:"rig,omitempty"`
Ant string `json:"ant,omitempty"`
Name string `json:"name,omitempty"`
QTH string `json:"qth,omitempty"`
Address string `json:"address,omitempty"`
Email string `json:"email,omitempty"`
Web string `json:"web,omitempty"`
Grid string `json:"grid,omitempty"`
GridExt string `json:"gridsquare_ext,omitempty"`
VUCCGrids string `json:"vucc_grids,omitempty"`
Country string `json:"country,omitempty"`
State string `json:"state,omitempty"`
County string `json:"cnty,omitempty"`
DXCC *int `json:"dxcc,omitempty"`
Continent string `json:"cont,omitempty"`
CQZ *int `json:"cqz,omitempty"`
ITUZ *int `json:"ituz,omitempty"`
IOTA string `json:"iota,omitempty"`
SOTARef string `json:"sota_ref,omitempty"`
POTARef string `json:"pota_ref,omitempty"`
Age *int `json:"age,omitempty"`
Lat *float64 `json:"lat,omitempty"`
Lon *float64 `json:"lon,omitempty"`
Rig string `json:"rig,omitempty"`
Ant string `json:"ant,omitempty"`
// --- QSL / LoTW / eQSL / Clublog / HRDLog ---
QSLSent string `json:"qsl_sent,omitempty"`
@@ -105,12 +105,12 @@ type QSO struct {
EQSLSentDate string `json:"eqsl_sent_date,omitempty"`
EQSLRcvdDate string `json:"eqsl_rcvd_date,omitempty"`
ClublogUploadDate string `json:"clublog_qso_upload_date,omitempty"`
ClublogUploadStatus string `json:"clublog_qso_upload_status,omitempty"`
HRDLogUploadDate string `json:"hrdlog_qso_upload_date,omitempty"`
HRDLogUploadStatus string `json:"hrdlog_qso_upload_status,omitempty"`
QRZComUploadDate string `json:"qrzcom_qso_upload_date,omitempty"`
QRZComUploadStatus string `json:"qrzcom_qso_upload_status,omitempty"`
ClublogUploadDate string `json:"clublog_qso_upload_date,omitempty"`
ClublogUploadStatus string `json:"clublog_qso_upload_status,omitempty"`
HRDLogUploadDate string `json:"hrdlog_qso_upload_date,omitempty"`
HRDLogUploadStatus string `json:"hrdlog_qso_upload_status,omitempty"`
QRZComUploadDate string `json:"qrzcom_qso_upload_date,omitempty"`
QRZComUploadStatus string `json:"qrzcom_qso_upload_status,omitempty"`
QRZComDownloadDate string `json:"qrzcom_qso_download_date,omitempty"`
QRZComDownloadStatus string `json:"qrzcom_qso_download_status,omitempty"`
@@ -366,6 +366,42 @@ func decodeExtras(s string) map[string]string {
return m
}
// DistinctExtraKeys returns the set of keys present across stored QSO extras
// (the non-promoted ADIF tags kept verbatim), scanning the most recent `sample`
// rows that have any extras. Capped so it stays fast on a large remote logbook —
// the extras vocabulary is small and stable, so a recent sample finds it all.
func (r *Repo) DistinctExtraKeys(ctx context.Context, sample int) ([]string, error) {
if sample <= 0 {
sample = 5000
}
rows, err := r.db.QueryContext(ctx,
`SELECT extras_json FROM qso
WHERE extras_json IS NOT NULL AND extras_json <> '' AND extras_json <> '{}'
ORDER BY id DESC LIMIT ?`, sample)
if err != nil {
return nil, err
}
defer rows.Close()
seen := map[string]bool{}
for rows.Next() {
var s sql.NullString
if err := rows.Scan(&s); err != nil {
return nil, err
}
for k := range decodeExtras(s.String) {
seen[k] = true
}
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]string, 0, len(seen))
for k := range seen {
out = append(out, k)
}
return out, nil
}
// Add inserts a QSO and returns its ID.
func (r *Repo) Add(ctx context.Context, q QSO) (int64, error) {
q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign))
@@ -563,7 +599,7 @@ func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]Uploa
// active logbook's callsign (a mixed-call DB — F4BPO, F4BPO/P, TM2Q — must not
// all be signed under one cert).
type UploadCandidate struct {
ID int64
ID int64
StationCallsign string
}
@@ -792,6 +828,56 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
return n, nil
}
// bulkEditableExtras whitelists ADIF fields that are bulk-editable but live in
// extras_json rather than in a promoted column. Key = the frontend's field id,
// value = the uppercase ADIF key inside the JSON object.
//
// OWNER_CALLSIGN is the case that prompted this: it was already filterable (see
// filterableExtras) but could not be bulk-edited, because BulkSetField writes a
// column and there is no owner_callsign column.
var bulkEditableExtras = map[string]string{
"owner_callsign": "OWNER_CALLSIGN",
}
// BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "".
func BulkExtraKey(field string) string { return bulkEditableExtras[field] }
// BulkSetExtra sets one whitelisted extras_json field on every listed QSO,
// leaving the other extras untouched. An empty value REMOVES the key rather than
// storing a blank — an empty extra would otherwise be carried into every export.
//
// json_set / json_remove exist under those names in both SQLite and MySQL and
// take the same '$.KEY' path syntax, so one statement serves both backends.
func (r *Repo) BulkSetExtra(ctx context.Context, ids []int64, adifKey, value string) (int64, error) {
if adifKey == "" {
return 0, fmt.Errorf("empty extras key")
}
if len(ids) == 0 {
return 0, nil
}
ph := make([]string, len(ids))
args := make([]any, 0, len(ids)+2)
expr := `json_set(COALESCE(extras_json, '{}'), '$.` + adifKey + `', ?)`
if value == "" {
expr = `json_remove(COALESCE(extras_json, '{}'), '$.` + adifKey + `')`
} else {
args = append(args, value)
}
args = append(args, db.NowISO())
for i, id := range ids {
ph[i] = "?"
args = append(args, id)
}
res, err := r.db.ExecContext(ctx,
`UPDATE qso SET extras_json = `+expr+`, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
args...)
if err != nil {
return 0, fmt.Errorf("bulk set extra %s: %w", adifKey, err)
}
n, _ := res.RowsAffected()
return n, nil
}
// BulkSetFrequency sets freq_hz AND band together on every listed QSO. Kept
// separate from BulkSetField because frequency is numeric and must keep the band
// consistent — the main use is fixing a batch that was logged on a stale/default
@@ -1352,13 +1438,13 @@ type WorkedBefore struct {
Callsign string `json:"callsign"`
// --- Per-callsign ---
Count int `json:"count"` // total prior QSOs with this call
First time.Time `json:"first,omitempty"` // oldest call QSO date
Last time.Time `json:"last,omitempty"` // most recent call QSO date
Bands []string `json:"bands"` // distinct bands for this call
Modes []string `json:"modes"` // distinct modes for this call
BandModes []BandMode `json:"band_modes"` // distinct (band, mode) pairs
Entries []QSO `json:"entries"` // up to maxWorkedEntries most recent (full records)
Count int `json:"count"` // total prior QSOs with this call
First time.Time `json:"first,omitempty"` // oldest call QSO date
Last time.Time `json:"last,omitempty"` // most recent call QSO date
Bands []string `json:"bands"` // distinct bands for this call
Modes []string `json:"modes"` // distinct modes for this call
BandModes []BandMode `json:"band_modes"` // distinct (band, mode) pairs
Entries []QSO `json:"entries"` // up to maxWorkedEntries most recent (full records)
// --- Per-DXCC entity (populated when DXCC is known) ---
DXCC int `json:"dxcc,omitempty"`
@@ -1370,6 +1456,10 @@ type WorkedBefore struct {
DXCCModes []string `json:"dxcc_modes"`
DXCCBandModes []BandMode `json:"dxcc_band_modes"`
// MWRank is the ClubLog "Most Wanted" rank for this entity (1 = most wanted),
// 0 when unknown or the feature is off. Populated by the app layer, not here.
MWRank int `json:"mw_rank,omitempty"`
// Status grid driving the band×class matrix in the UI. One entry per
// (band, class) where ANY QSO exists in this DXCC. Only the highest
// status for that cell is kept (call_c > call_w > dxcc_c > dxcc_w).
@@ -1438,14 +1528,14 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
// ---- Per-callsign stats ----
if err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM qso WHERE upper(trim(callsign)) = ?`, wb.Callsign).Scan(&wb.Count); err != nil {
`SELECT COUNT(*) FROM qso WHERE callsign = ?`, wb.Callsign).Scan(&wb.Count); err != nil {
return wb, fmt.Errorf("count worked: %w", err)
}
if wb.Count > 0 {
// Pull the full QSO records (same columns as the Recent QSOs list) so
// the Worked-before grid can offer the same rich column picker.
rows, err := r.db.QueryContext(ctx, `SELECT `+selectCols+`
FROM qso WHERE upper(trim(callsign)) = ?
FROM qso WHERE callsign = ?
ORDER BY qso_date DESC, id DESC
LIMIT ?`, wb.Callsign, maxWorkedEntries)
if err != nil {
@@ -1480,7 +1570,7 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
if wb.Count > maxWorkedEntries {
var firstStr sql.NullString
_ = r.db.QueryRowContext(ctx,
`SELECT MIN(qso_date) FROM qso WHERE upper(trim(callsign)) = ?`, wb.Callsign).Scan(&firstStr)
`SELECT MIN(qso_date) FROM qso WHERE callsign = ?`, wb.Callsign).Scan(&firstStr)
if firstStr.Valid {
wb.First = parseTimeLoose(firstStr.String)
}
@@ -1505,7 +1595,7 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
var d sql.NullInt64
_ = r.db.QueryRowContext(ctx, `
SELECT dxcc FROM qso
WHERE upper(trim(callsign)) = ? AND dxcc IS NOT NULL
WHERE callsign = ? AND dxcc IS NOT NULL
ORDER BY qso_date DESC LIMIT 1`, wb.Callsign).Scan(&d)
if d.Valid {
dxcc = int(d.Int64)
@@ -1574,8 +1664,8 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
// WorkedBefore call, blanking the matrix in the UI.
statusRows, err := r.db.QueryContext(ctx, `
SELECT band, mode,
MAX(CASE WHEN upper(trim(callsign)) = ? THEN 1 ELSE 0 END),
MAX(CASE WHEN upper(trim(callsign)) = ?
MAX(CASE WHEN callsign = ? THEN 1 ELSE 0 END),
MAX(CASE WHEN callsign = ?
AND (lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y')
THEN 1 ELSE 0 END),
MAX(CASE WHEN lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y'
@@ -2419,58 +2509,58 @@ type scanner interface {
func scanQSO(s scanner) (QSO, error) {
var q QSO
var (
qsoDateStr string
qsoDateOffStr sql.NullString
bandRx, submode sql.NullString
freqHz, freqRX sql.NullInt64
rstS, rstR sql.NullString
name, qth, addr, email, web sql.NullString
grid, gridExt, vucc sql.NullString
country, state, cnty sql.NullString
dxcc, cqz, ituz sql.NullInt64
cont, iota, sota, pota sql.NullString
age sql.NullInt64
lat, lon sql.NullFloat64
rig, ant sql.NullString
qslSent, qslRcvd sql.NullString
qslSentDate, qslRcvdDate sql.NullString
qslVia, qslMsg, qslMsgRcvd sql.NullString
lotwSent, lotwRcvd sql.NullString
lotwSentDate, lotwRcvdDate sql.NullString
eqslSent, eqslRcvd sql.NullString
eqslSentDate, eqslRcvdDate sql.NullString
clublogDate, clublogStatus sql.NullString
hrdlogDate, hrdlogStatus sql.NullString
qrzcomDate, qrzcomStatus sql.NullString
qrzcomDlDate, qrzcomDlStatus sql.NullString
contestID sql.NullString
srx, stx sql.NullInt64
srxStr, stxStr sql.NullString
checkField, precedence, arrlSect sql.NullString
propMode, satName, satMode sql.NullString
antAz, antEl sql.NullFloat64
antPath sql.NullString
stCall, op, myGrid, myGridExt sql.NullString
myCountry, myState, myCnty, myIOTA sql.NullString
mySOTA, myPOTA sql.NullString
myDXCC, myCQZ, myITUZ sql.NullInt64
myLat, myLon sql.NullFloat64
myStreet, myCity, myPostal sql.NullString
myRig, myAntenna sql.NullString
txp sql.NullFloat64
comment, notes sql.NullString
sig, sigInfo, mySig, mySigInfo sql.NullString
qsoDateStr string
qsoDateOffStr sql.NullString
bandRx, submode sql.NullString
freqHz, freqRX sql.NullInt64
rstS, rstR sql.NullString
name, qth, addr, email, web sql.NullString
grid, gridExt, vucc sql.NullString
country, state, cnty sql.NullString
dxcc, cqz, ituz sql.NullInt64
cont, iota, sota, pota sql.NullString
age sql.NullInt64
lat, lon sql.NullFloat64
rig, ant sql.NullString
qslSent, qslRcvd sql.NullString
qslSentDate, qslRcvdDate sql.NullString
qslVia, qslMsg, qslMsgRcvd sql.NullString
lotwSent, lotwRcvd sql.NullString
lotwSentDate, lotwRcvdDate sql.NullString
eqslSent, eqslRcvd sql.NullString
eqslSentDate, eqslRcvdDate sql.NullString
clublogDate, clublogStatus sql.NullString
hrdlogDate, hrdlogStatus sql.NullString
qrzcomDate, qrzcomStatus sql.NullString
qrzcomDlDate, qrzcomDlStatus sql.NullString
contestID sql.NullString
srx, stx sql.NullInt64
srxStr, stxStr sql.NullString
checkField, precedence, arrlSect sql.NullString
propMode, satName, satMode sql.NullString
antAz, antEl sql.NullFloat64
antPath sql.NullString
stCall, op, myGrid, myGridExt sql.NullString
myCountry, myState, myCnty, myIOTA sql.NullString
mySOTA, myPOTA sql.NullString
myDXCC, myCQZ, myITUZ sql.NullInt64
myLat, myLon sql.NullFloat64
myStreet, myCity, myPostal sql.NullString
myRig, myAntenna sql.NullString
txp sql.NullFloat64
comment, notes sql.NullString
sig, sigInfo, mySig, mySigInfo sql.NullString
wwffRef, myWWFFRef sql.NullString
distance, rxPwr, aIndex, kIndex, sfi sql.NullFloat64
distance, rxPwr, aIndex, kIndex, sfi sql.NullFloat64
skcc, fists, tenTen sql.NullString
contactedOp, eqCall, pfx, myName sql.NullString
class, darcDOK, myDarcDOK, region sql.NullString
silentKey, swl, qsoComplete, qsoRandom sql.NullString
creditGranted, creditSubmitted sql.NullString
myARRLSect, myVUCCGrids sql.NullString
extrasJSON sql.NullString
awardRefs sql.NullString
createdStr, updatedStr string
myARRLSect, myVUCCGrids sql.NullString
extrasJSON sql.NullString
awardRefs sql.NullString
createdStr, updatedStr string
)
if err := s.Scan(
&q.ID, &q.Callsign, &qsoDateStr, &qsoDateOffStr, &q.Band, &bandRx, &q.Mode, &submode, &freqHz, &freqRX,
+270
View File
@@ -0,0 +1,270 @@
// Package scp provides Super Check Partial (SCP) and N+1 callsign suggestions
// from the community MASTER.SCP master file (supercheckpartial.com) — the same
// aid contest loggers (N1MM, DXLog, Log4OM) show to catch/correct a busted call.
//
// - Partial (SCP): every master call that CONTAINS what you've typed, so a
// mistyped or half-copied call surfaces the real ones.
// - N+1: master calls exactly one edit away (one char substituted, added or
// removed) from the full call you typed — the classic "did I bust it?" check.
//
// The list is downloaded once and cached on disk so it survives restarts.
package scp
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// masterURL is the community Super Check Partial master file: one callsign per
// line, '#'-prefixed header lines. ~50k+ active contest/DX calls.
const masterURL = "https://www.supercheckpartial.com/MASTER.SCP"
const cacheFile = "MASTER.SCP"
// Result is the two suggestion lists for a typed fragment.
type Result struct {
Partial []string `json:"partial"` // master calls containing the fragment
NPlus1 []string `json:"nplus1"` // master calls one edit away from the full call
}
// Manager holds the parsed call list + cache location.
type Manager struct {
mu sync.RWMutex
calls []string // UPPER, de-duplicated, sorted
updated time.Time // when the cache was last refreshed
dir string
client *http.Client
}
// NewManager loads any on-disk cache and returns a ready manager.
func NewManager(dataDir string) *Manager {
m := &Manager{
dir: dataDir,
client: &http.Client{Timeout: 60 * time.Second},
}
m.loadCache()
return m
}
func (m *Manager) path() string { return filepath.Join(m.dir, cacheFile) }
func (m *Manager) loadCache() {
data, err := os.ReadFile(m.path())
if err != nil {
return
}
m.parse(data)
if fi, e := os.Stat(m.path()); e == nil {
m.mu.Lock()
m.updated = fi.ModTime()
m.mu.Unlock()
}
}
// Download fetches the latest MASTER.SCP, caches it and replaces the in-memory
// list. Returns the number of callsigns loaded.
func (m *Manager) Download(ctx context.Context) (int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, masterURL, nil)
if err != nil {
return 0, err
}
req.Header.Set("User-Agent", "OpsLog")
resp, err := m.client.Do(req)
if err != nil {
return 0, fmt.Errorf("scp: request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("scp: http %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024))
if err != nil {
return 0, fmt.Errorf("scp: read: %w", err)
}
n := m.parse(body)
if n == 0 {
return 0, fmt.Errorf("scp: file parsed to 0 callsigns")
}
_ = os.WriteFile(m.path(), body, 0o644) // best-effort cache
m.mu.Lock()
m.updated = time.Now()
m.mu.Unlock()
return n, nil
}
// parse loads the SCP bytes into the sorted call slice and returns the count.
func (m *Manager) parse(data []byte) int {
seen := make(map[string]struct{}, 1<<17)
sc := bufio.NewScanner(bytes.NewReader(data))
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
for sc.Scan() {
line := strings.ToUpper(strings.TrimSpace(sc.Text()))
if line == "" || strings.HasPrefix(line, "#") {
continue // blank / header comment
}
// A call token only (the master file is one call per line, but guard
// against stray trailing fields).
if i := strings.IndexAny(line, " \t,;"); i >= 0 {
line = line[:i]
}
if !plausibleCall(line) {
continue
}
seen[line] = struct{}{}
}
if len(seen) == 0 {
return 0
}
calls := make([]string, 0, len(seen))
for c := range seen {
calls = append(calls, c)
}
sort.Strings(calls)
m.mu.Lock()
m.calls = calls
m.mu.Unlock()
return len(calls)
}
// plausibleCall keeps a token that looks like a callsign: length 312, at least
// one digit and one letter, only AZ/09//.
func plausibleCall(s string) bool {
if len(s) < 3 || len(s) > 12 {
return false
}
hasDigit, hasAlpha := false, false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= '0' && c <= '9':
hasDigit = true
case c >= 'A' && c <= 'Z':
hasAlpha = true
case c == '/':
default:
return false
}
}
return hasDigit && hasAlpha
}
// Lookup returns the Partial (substring) and N+1 (one-edit) suggestions for the
// typed fragment. limit caps EACH list. Partial needs ≥2 chars; N+1 needs ≥3
// (a plausible whole call) and is skipped otherwise.
func (m *Manager) Lookup(fragment string, limit int) Result {
q := strings.ToUpper(strings.TrimSpace(fragment))
if len(q) < 2 {
return Result{}
}
if limit <= 0 {
limit = 50
}
m.mu.RLock()
calls := m.calls
m.mu.RUnlock()
wantN1 := len(q) >= 3
type pm struct {
call string
prefix bool
}
var partial []pm
var nplus1 []string
for _, c := range calls {
if c == q {
partial = append(partial, pm{c, true}) // exact = strongest match
continue
}
if idx := strings.Index(c, q); idx >= 0 {
partial = append(partial, pm{c, idx == 0})
}
if wantN1 && len(nplus1) < limit*2 && editDistanceOne(q, c) {
nplus1 = append(nplus1, c)
}
}
// Prefix matches first, then the rest (both already alphabetical since `calls`
// is sorted and we scanned in order).
sort.SliceStable(partial, func(i, j int) bool {
if partial[i].prefix != partial[j].prefix {
return partial[i].prefix
}
return false
})
out := Result{Partial: []string{}, NPlus1: []string{}}
for _, p := range partial {
if len(out.Partial) >= limit {
break
}
out.Partial = append(out.Partial, p.call)
}
if len(nplus1) > limit {
nplus1 = nplus1[:limit]
}
out.NPlus1 = append(out.NPlus1, nplus1...)
return out
}
// editDistanceOne reports whether a and b are exactly one edit apart (one
// substitution, insertion or deletion) — never equal (distance 0 returns false).
func editDistanceOne(a, b string) bool {
la, lb := len(a), len(b)
if la == lb {
diff := 0
for i := 0; i < la; i++ {
if a[i] != b[i] {
diff++
if diff > 1 {
return false
}
}
}
return diff == 1
}
// Ensure a is the shorter one; lengths must differ by exactly 1.
if la > lb {
a, b = b, a
la, lb = lb, la
}
if lb-la != 1 {
return false
}
// b is a with one extra char: walk both, allowing a single skip in b.
i, j, skipped := 0, 0, false
for i < la && j < lb {
if a[i] == b[j] {
i++
j++
continue
}
if skipped {
return false
}
skipped = true
j++ // skip the extra char in the longer string
}
return true
}
// Count returns how many callsigns are loaded.
func (m *Manager) Count() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.calls)
}
// Updated returns when the list was last refreshed (zero if never).
func (m *Manager) Updated() time.Time {
m.mu.RLock()
defer m.mu.RUnlock()
return m.updated
}
+127 -9
View File
@@ -23,7 +23,9 @@
package steppir
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"log"
@@ -34,6 +36,10 @@ import (
"go.bug.st/serial"
)
// errBadFrame marks a reply that isn't a well-formed status frame. It means
// "ignore this poll", not "the link is down".
var errBadFrame = errors.New("steppir: malformed status frame")
// Direction values, matching the app-wide convention (also used by Ultrabeam):
// 0 normal, 1 reverse (180°), 2 bidirectional.
const (
@@ -49,6 +55,15 @@ const (
wireBi = 0x80
)
// pendingDirTTL is how long a commanded direction is trusted over the
// controller's own report. The elements physically re-tune to swap director and
// reflector, and the SDA only reports the new pattern once it starts that move,
// so a few seconds is not enough — 4 s (the original value) had the UI snapping
// back to "normal" while the antenna was on its way to 180°. Long enough to
// cover a real move, short enough that a command the controller never received
// self-corrects instead of lying forever.
const pendingDirTTL = 45 * time.Second
// Transport says how to reach the controller.
type Transport struct {
Mode string // "tcp" | "serial"
@@ -84,9 +99,20 @@ type Client struct {
lastStatus *Status
lastSetKHz int
// lastRaw holds the previous raw status frame so we only log a status line
// when the controller's reply actually changes — enough to diagnose a stuck
// "motors moving" read (which drives the app's TX-inhibit interlock) without
// spamming the log every 2 s poll.
lastRaw []byte
// A just-commanded direction is held until the controller's poll reports it —
// the motors take a second or two, and a stale poll would otherwise snap the
// UI back. Same trick as the Ultrabeam client.
//
// The hold is deliberately long (pendingDirTTL). It is not just a UI nicety:
// the follow loop re-tunes with the direction it reads back from this status,
// so a single stale poll reading "normal" would make OpsLog command the
// antenna out of 180° all by itself.
pendingDir int
pendingDirAt time.Time
pendingDirSet bool
@@ -182,6 +208,12 @@ func (c *Client) pollLoop() {
c.connMu.Unlock()
st, err := c.queryStatus()
if errors.Is(err, errBadFrame) {
// Framing glitch, not a dead link: skip this tick and keep the
// previous status. Dropping the connection here would blink the
// UI to "disconnected" over one garbled reply.
continue
}
if err != nil {
log.Printf("steppir: status query failed, reconnecting: %v", err)
c.closeConn()
@@ -190,19 +222,32 @@ func (c *Client) pollLoop() {
}
st.Connected = true
c.statusMu.Lock()
if c.pendingDirSet {
if time.Since(c.pendingDirAt) > 4*time.Second || st.Direction == c.pendingDir {
c.pendingDirSet = false
} else {
st.Direction = c.pendingDir
}
}
c.applyPendingDir(st)
c.lastStatus = st
c.statusMu.Unlock()
}
}
}
// applyPendingDir replaces a freshly polled direction with the one the operator
// last commanded, until the controller confirms it (or the hold expires). The
// caller holds statusMu.
func (c *Client) applyPendingDir(st *Status) {
if !c.pendingDirSet {
return
}
switch {
case st.Direction == c.pendingDir:
c.pendingDirSet = false // confirmed — trust the controller's reports again
case time.Since(c.pendingDirAt) > pendingDirTTL:
c.pendingDirSet = false
log.Printf("steppir: controller never confirmed direction %d (still reports %d) — dropping the hold",
c.pendingDir, st.Direction)
default:
st.Direction = c.pendingDir
}
}
func (c *Client) setDisconnected() {
c.statusMu.Lock()
c.lastStatus = &Status{Connected: false}
@@ -225,6 +270,56 @@ func setDeadline(conn io.ReadWriteCloser, d time.Duration) {
}
}
// setReadTimeout bounds a single read on either transport, so a drain can tell
// "nothing more queued" from "still arriving" without blocking.
func setReadTimeout(conn io.ReadWriteCloser, d time.Duration) {
switch t := conn.(type) {
case net.Conn:
_ = t.SetReadDeadline(time.Now().Add(d))
case serial.Port:
_ = t.SetReadTimeout(d)
}
}
// restoreTimeouts puts the normal exchange timeouts back after a drain shortened
// them.
func restoreTimeouts(conn io.ReadWriteCloser) {
setDeadline(conn, 3*time.Second) // TCP: read + write
setReadTimeout(conn, 2*time.Second)
}
// drain throws away everything already sitting in the input buffer and returns
// how many bytes it discarded.
//
// This is the fix for the antenna's state appearing tens of seconds out of date.
// The SDA controller does not only answer "?A" — it also pushes status frames on
// its own (front-panel changes, autotrack moves, each command it processes). We
// consume exactly one frame per poll, so every unsolicited frame adds one to a
// backlog that only ever grows: reading 11 bytes then returns a frame from
// minutes ago. The field log showed it plainly — two consecutive polls 4 s apart
// reporting 28280 kHz then 14200 kHz, a frequency last used hours earlier, and a
// 180° command not showing up in the status for ~40 s (long after the UI had
// given up waiting and snapped the button back to "normal"). Emptying the buffer
// immediately before each query means the frame we then read is the answer to
// THIS query.
func drain(conn io.ReadWriteCloser) int {
buf := make([]byte, 512)
total := 0
// Bounded so a controller that streams continuously can't hold the poll
// goroutine here forever. 32 × 512 B is ~1500 frames — far more backlog than
// any real link builds up, and it only costs one 30 ms timeout when the
// buffer is already empty (reads return immediately while data is queued).
for i := 0; i < 32; i++ {
setReadTimeout(conn, 30*time.Millisecond)
n, err := conn.Read(buf)
total += n
if err != nil || n == 0 { // timeout / nothing left
break
}
}
return total
}
func (c *Client) queryStatus() (*Status, error) {
c.connMu.Lock()
conn := c.conn
@@ -234,7 +329,12 @@ func (c *Client) queryStatus() (*Status, error) {
}
c.ioMu.Lock()
defer c.ioMu.Unlock()
setDeadline(conn, 3*time.Second)
// Discard any frame the controller pushed on its own since the last poll, so
// what we read below is this query's answer and not a stale backlog entry.
if n := drain(conn); n > 0 {
log.Printf("steppir: discarded %d stale byte(s) queued by the controller before polling", n)
}
restoreTimeouts(conn)
if _, err := conn.Write([]byte("?A\r")); err != nil {
return nil, fmt.Errorf("write status cmd: %w", err)
}
@@ -242,7 +342,25 @@ func (c *Client) queryStatus() (*Status, error) {
if _, err := io.ReadFull(conn, buf); err != nil {
return nil, fmt.Errorf("read status: %w", err)
}
return parseStatus(buf)
// Reject anything that isn't a framed reply rather than decoding garbage into
// a frequency and a direction the app would then act on.
if buf[0] != '@' || buf[1] != 'A' || buf[10] != 0x0D {
log.Printf("steppir: ignoring malformed status frame % X", buf)
drain(conn) // resync: drop the rest of whatever we landed mid-way through
restoreTimeouts(conn)
return nil, errBadFrame
}
st, err := parseStatus(buf)
// Log the raw frame + decode whenever it changes. The motor byte (buf[6]) is
// what decides st.MotorsMoving, and that in turn drives the app's "block TX
// while moving" interlock — so if a controller (e.g. with its INHIBIT engaged)
// reports a byte we misread as perpetual motion, this line makes it visible.
if err == nil && !bytes.Equal(buf, c.lastRaw) {
c.lastRaw = append(c.lastRaw[:0], buf...)
log.Printf("steppir: status ← % X (freq=%d kHz dir=%d moving=%d motorByte=0x%02X dirByte=0x%02X)",
buf, st.Frequency, st.Direction, st.MotorsMoving, buf[6], buf[7])
}
return st, err
}
// parseStatus decodes an 11-byte status frame.
+121
View File
@@ -1,8 +1,12 @@
package steppir
import (
"bytes"
"encoding/binary"
"errors"
"sync"
"testing"
"time"
)
// The exact bytes are the correctness checksum. If buildSet ever drifts from the
@@ -104,3 +108,120 @@ func TestParseStatus(t *testing.T) {
t.Error("active-motors 0xFF (command received) must not read as moving")
}
}
// fakeConn stands in for the controller link. rx holds bytes the "controller"
// has already sent (what the client will read), tx collects what the client
// wrote, and a "?A" query queues `reply` into rx the way the SDA answers.
//
// Reads never block: an empty rx returns (0, nil), which is exactly how
// go.bug.st/serial reports a read timeout, so drain() sees the same
// nothing-left signal it gets from real hardware.
type fakeConn struct {
mu sync.Mutex
rx bytes.Buffer
tx bytes.Buffer
reply []byte
}
func (f *fakeConn) Read(p []byte) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.rx.Len() == 0 {
return 0, nil
}
return f.rx.Read(p)
}
func (f *fakeConn) Write(p []byte) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.tx.Write(p)
if bytes.Contains(p, []byte("?A")) {
f.rx.Write(f.reply)
}
return len(p), nil
}
func (f *fakeConn) Close() error { return nil }
var (
// 50.150 MHz, normal — the "stuck at 6 m" frame that kept turning up in
// F4BPO's friend's log long after the rig had left the band.
frame6mNormal = []byte{0x40, 0x41, 0x00, 0x4C, 0x85, 0xD8, 0x00, 0x07, 0x30, 0x38, 0x0D}
// 14.250 MHz, 180° — what the controller actually reports right now.
frame20m180 = []byte{0x40, 0x41, 0x00, 0x15, 0xBE, 0x68, 0x00, 0x47, 0x30, 0x38, 0x0D}
)
// The controller pushes status frames unsolicited, so they pile up between polls.
// Reading one frame per poll then returns state from minutes ago — which is how a
// 180° command could take ~40 s to show up in the UI, and how two polls 4 s apart
// reported 28 MHz then 14 MHz. queryStatus must empty the backlog first.
func TestQueryStatusDiscardsQueuedFrames(t *testing.T) {
fc := &fakeConn{reply: frame20m180}
fc.rx.Write(frame6mNormal) // two frames the controller pushed on its own
fc.rx.Write(frame6mNormal)
c := &Client{conn: fc}
st, err := c.queryStatus()
if err != nil {
t.Fatal(err)
}
if st.Frequency != 14250 {
t.Errorf("freq = %d kHz, want 14250 — a stale queued frame was read instead of this poll's reply", st.Frequency)
}
if st.Direction != Dir180 {
t.Errorf("direction = %d, want %d (180°)", st.Direction, Dir180)
}
}
// A reply we land on mid-frame must be rejected, not decoded into a bogus
// frequency and direction the follow loop would then act on — and it must not
// look like a dead link either (errBadFrame keeps the connection).
func TestQueryStatusRejectsMalformedFrame(t *testing.T) {
shifted := append(append([]byte{}, frame20m180[3:]...), 0x40, 0x41, 0x00) // 11 bytes, wrong header
c := &Client{conn: &fakeConn{reply: shifted}}
if _, err := c.queryStatus(); !errors.Is(err, errBadFrame) {
t.Fatalf("err = %v, want errBadFrame", err)
}
}
// The direction the operator just commanded is shown until the controller
// confirms it. The hold used to be 4 s — two polls — so the button snapped back
// to Normal while the elements were still swapping over to 180°.
func TestApplyPendingDirHold(t *testing.T) {
c := &Client{pendingDir: Dir180, pendingDirAt: time.Now(), pendingDirSet: true}
// Controller still reports the old pattern: keep showing what was commanded.
st := &Status{Direction: DirNormal}
c.applyPendingDir(st)
if st.Direction != Dir180 {
t.Fatalf("direction = %d, want %d while the move is pending", st.Direction, Dir180)
}
if !c.pendingDirSet {
t.Fatal("hold released before the controller confirmed")
}
// Still holding well past the old 4 s window — a SteppIR takes longer than
// that to report a pattern change.
c.pendingDirAt = time.Now().Add(-10 * time.Second)
st = &Status{Direction: DirNormal}
c.applyPendingDir(st)
if st.Direction != Dir180 {
t.Fatalf("direction = %d after 10 s, want %d — the hold expired too early", st.Direction, Dir180)
}
// Controller confirms: release the hold and trust its reports again.
st = &Status{Direction: Dir180}
c.applyPendingDir(st)
if c.pendingDirSet {
t.Fatal("hold should be released once the controller reports the commanded direction")
}
// A command the controller never acted on must not lie forever.
c.pendingDir, c.pendingDirAt, c.pendingDirSet = DirBi, time.Now().Add(-pendingDirTTL-time.Second), true
st = &Status{Direction: DirNormal}
c.applyPendingDir(st)
if st.Direction != DirNormal || c.pendingDirSet {
t.Fatalf("expired hold should fall back to the controller: direction = %d, pending = %v", st.Direction, c.pendingDirSet)
}
}
+100
View File
@@ -0,0 +1,100 @@
package tunergenius
import (
"bufio"
"net"
"os"
"strings"
"testing"
"time"
)
// captureLog redirects the stream applog.Printf always writes to, and returns a
// stop function yielding what was logged. Reading only after stop keeps the
// collector goroutine and the test off the same slice.
func captureLog() func() []string {
orig := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
var lines []string
done := make(chan struct{})
go func() {
sc := bufio.NewScanner(r)
for sc.Scan() {
lines = append(lines, sc.Text())
}
close(done)
}()
return func() []string {
os.Stderr = orig
w.Close()
<-done
r.Close()
return lines
}
}
// A tuner that answers, then vanishes mid-session. A drop used to leave no trace
// at all, so "did the tuner disconnect, or is the meter just reading a gap
// between syllables?" was unanswerable from the log. It must now log — and log
// ONCE, not on every 400 ms retry.
func TestLinkDownLoggedOnceNotPerRetry(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := ln.Addr().(*net.TCPAddr).Port
die := make(chan struct{})
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
conn.Write([]byte("V1.2.11\n"))
buf := make([]byte, 256)
for {
n, err := conn.Read(buf)
if err != nil {
return
}
id := strings.TrimPrefix(strings.SplitN(string(buf[:n]), "|", 2)[0], "C")
conn.Write([]byte("R" + id + "|0|status state=1 fwd=60.7 swr=-25 active=1\n"))
select {
case <-die:
conn.Close()
ln.Close()
return
default:
}
}
}()
stop := captureLog()
c := New("127.0.0.1", port, "")
if err := c.Start(); err != nil {
stop()
t.Fatal(err)
}
time.Sleep(1200 * time.Millisecond)
connected := c.GetStatus().Connected
close(die)
time.Sleep(2 * time.Second) // ~5 poll cycles with the tuner gone
c.Stop()
lines := stop()
if !connected {
t.Fatal("the client never reached the fake tuner — the rest of the test is meaningless")
}
down := 0
for _, l := range lines {
if strings.Contains(l, "link DOWN") {
down++
}
}
t.Logf("logged:\n%s", strings.Join(lines, "\n"))
if down != 1 {
t.Errorf("got %d 'link DOWN' lines, want exactly 1 — the poll retries many times in a 3 s outage and must not repeat itself", down)
}
}
+518
View File
@@ -0,0 +1,518 @@
// Package tunergenius drives a 4O3A Tuner Genius XL over its TCP text API
// (fixed port 9010 — the same port the device also uses for UDP discovery
// broadcasts). It's the same "Genius Series" line protocol as the PowerGenius
// XL / Antenna Genius: on connect the device sends a banner ("V1.1.8" or
// "V1.1.8 AUTH" when reached from outside the LAN); commands are
// "C<seq>|<command>\n" and replies are "R<seq>|<code>|<message>" (code 0 = OK).
// The status reply is pushed back as "S<seq>|status <k=v …>", and unsolicited
// "M|<message>" info/warning lines can arrive at any time.
//
// Protocol reference: 4O3A "TUNER GENIUS XL — PROTOCOL DESCRIPTION".
package tunergenius
import (
"bufio"
"fmt"
"math"
"net"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"hamlog/internal/applog"
)
const (
// DefaultPort is fixed on the device (both the TCP control channel and the
// UDP discovery broadcast use 9010).
DefaultPort = 9010
dialTimeout = 5 * time.Second
ioTimeout = 3 * time.Second
// Poll fast so the meters track TX like the amplifier does (the amp's numbers
// ride the real-time Flex UDP stream; the tuner is a synchronous TCP poll, so
// a slow interval made its SWR/power lag noticeably behind).
pollEvery = 400 * time.Millisecond
)
// Channel is the live state of one of the tuner's two RF channels (A / B). The
// Tuner Genius XL is a dual (SO2R) coupler, so each channel tracks its own
// source, band, frequency and antenna — mirroring the two rows the native 4O3A
// app shows.
type Channel struct {
PTT bool `json:"ptt"` // this channel is keyed
Band int `json:"band"` // band as reported by the device (0 = unknown)
Mode int `json:"mode"` // 0=RF Sense 1=FLEX 2=CAT 3=P2B 4=BCD
ModeStr string `json:"mode_str"` // human-readable mode/source
Flex string `json:"flex"` // bound Flex radio nickname (FLEX mode)
FreqMHz float64 `json:"freq_mhz"` // current frequency
Bypass bool `json:"bypass"` // this channel bypassed
Antenna int `json:"antenna"` // antenna in use (3WAY / SO2R-with-AG; 0 = n/a)
}
// Status is the snapshot the UI renders. Power/SWR come from the device's
// "status" reply; the booleans mirror the tuner's operating state.
type Status struct {
Connected bool `json:"connected"`
Host string `json:"host,omitempty"`
LastError string `json:"last_error,omitempty"`
FwdDbm float64 `json:"fwd_dbm"` // forward power [dBm], as reported
FwdW float64 `json:"fwd_w"` // forward power [W], derived from dBm
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)
Operate bool `json:"operate"` // state == OPERATE (1) vs STANDBY (0)
Bypass bool `json:"bypass"` // device global bypass engaged
Tuning bool `json:"tuning"` // autotune in progress
Active int `json:"active"` // active channel (1 = A, 2 = B)
ThreeWay bool `json:"three_way"` // 3-way (vs SO2R) hardware variant
A Channel `json:"a"` // channel A
B Channel `json:"b"` // channel B
RelayC1 int `json:"relay_c1"` // tuner network position (0255)
RelayL int `json:"relay_l"`
RelayC2 int `json:"relay_c2"`
// FreqMHz / Antenna mirror the ACTIVE channel, kept for the compact docked
// widget that shows a single readout.
FreqMHz float64 `json:"freq_mhz"`
Antenna int `json:"antenna"`
Message string `json:"message,omitempty"` // last M| warning/info (empty = cleared)
}
// modeName maps the device's numeric mode/source to a label.
func modeName(m int) string {
switch m {
case 0:
return "RF Sense"
case 1:
return "Flex"
case 2:
return "CAT"
case 3:
return "P2B"
case 4:
return "BCD"
default:
return ""
}
}
type Client struct {
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
reader *bufio.Reader
statusMu sync.RWMutex
status Status
lastRaw string // last raw status payload — logged on change to map fields against real hardware
cmdID atomic.Int64
stop chan struct{}
running bool
}
func New(host string, port int, password string) *Client {
if port <= 0 || port > 65535 {
port = DefaultPort
}
return &Client{
host: host,
port: port,
password: strings.TrimSpace(password),
stop: make(chan struct{}),
status: Status{Host: host},
}
}
func (c *Client) Start() error {
c.running = true
go c.pollLoop()
return nil
}
func (c *Client) Stop() {
if !c.running {
return
}
c.running = false
close(c.stop)
c.mu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
c.reader = nil
}
c.mu.Unlock()
}
func (c *Client) GetStatus() Status {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
return c.status
}
func (c *Client) setStatus(fn func(*Status)) {
c.statusMu.Lock()
fn(&c.status)
c.statusMu.Unlock()
}
// SetOperate puts the tuner in OPERATE (1) or STANDBY (0).
func (c *Client) SetOperate(on bool) error {
if _, err := c.command("operate set=" + boolNum(on)); err != nil {
return err
}
c.setStatus(func(s *Status) { s.Operate = on }) // optimistic; the poll confirms
return nil
}
// SetBypass engages (1) or clears (0) the device's global bypass (antenna
// connected straight through, tuner out of line).
func (c *Client) SetBypass(on bool) error {
if _, err := c.command("bypass set=" + boolNum(on)); err != nil {
return err
}
c.setStatus(func(s *Status) { s.Bypass = on })
return nil
}
// Autotune starts an automatic tuning cycle on the active channel. The rig must
// be keyed into a carrier for the tuner to measure SWR — the device asserts its
// own PTT OUT if "tune PTT" is enabled in its setup.
func (c *Client) Autotune() error {
if _, err := c.command("autotune"); err != nil {
return err
}
c.setStatus(func(s *Status) { s.Tuning = true }) // optimistic until the poll clears it
return nil
}
// Activate selects the active channel. On SO2R hardware ch is 1 (A) or 2 (B);
// on the 3-way variant it selects the antenna (1/2/3).
func (c *Client) Activate(ch int) error {
if ch < 1 {
return fmt.Errorf("tunergenius: invalid channel %d", ch)
}
key := "ch"
if c.GetStatus().ThreeWay {
key = "ant"
}
_, err := c.command(fmt.Sprintf("activate %s=%d", key, ch))
return err
}
func (c *Client) pollLoop() {
t := time.NewTicker(pollEvery)
defer t.Stop()
// Outage bookkeeping. A dropped link used to be entirely silent: the poll just
// set Connected=false and retried, so "did the tuner disconnect, or is the
// meter simply reading a gap between syllables?" could not be answered from
// the log. Now an outage logs once when it starts and once when it ends, with
// how long it lasted.
//
// Once, not every retry: the poll comes round every 400 ms, and a tuner that
// is switched off would otherwise bury the log. These are local to the one
// goroutine that polls — ensureConnected has no other caller — so they need no
// locking.
var downSince time.Time
for {
select {
case <-c.stop:
return
case <-t.C:
fresh := false
if c.needConnect() {
if err := c.ensureConnected(); err != nil {
if downSince.IsZero() {
downSince = time.Now()
applog.Printf("tunergenius: link DOWN — cannot reach %s:%d: %v (retrying)", c.host, c.port, err)
}
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() })
continue
}
fresh = true
if !downSince.IsZero() {
applog.Printf("tunergenius: link RESTORED after %s down", time.Since(downSince).Round(time.Second))
downSince = time.Time{}
}
}
// One-shot on a fresh link: learn the hardware variant (3-way vs SO2R).
if fresh {
_, _ = c.command("info")
}
if _, err := c.command("status"); err != nil {
if downSince.IsZero() {
downSince = time.Now()
applog.Printf("tunergenius: link DOWN — poll failed: %v", err)
}
c.dropConn()
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = err.Error() })
}
}
}
}
// needConnect reports whether the TCP link is currently down (so the poll loop
// knows a fresh connect + one-shot info query is needed).
func (c *Client) needConnect() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.conn == nil
}
func (c *Client) ensureConnected() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
return nil
}
conn, err := net.DialTimeout("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)), dialTimeout)
if err != nil {
return err
}
c.conn = conn
c.reader = bufio.NewReader(conn)
// Banner: "V1.1.8" (LAN) or "V1.1.8 AUTH" (remote → authentication required).
_ = conn.SetReadDeadline(time.Now().Add(ioTimeout))
banner, _ := c.reader.ReadString('\n')
banner = strings.TrimSpace(banner)
applog.Printf("tunergenius: connected %s → %s, banner=%q", conn.LocalAddr(), conn.RemoteAddr(), banner)
if strings.Contains(banner, "AUTH") {
if c.password == "" {
applog.Printf("tunergenius: device requires AUTH but no remote code set (Settings → Tuner Genius)")
} else if err := c.authLocked(); err != nil {
c.conn.Close()
c.conn, c.reader = nil, nil
return err
}
}
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = ""; s.Host = c.host })
return nil
}
// authLocked sends "auth <code>" and checks the reply. Must be called with c.mu
// held (during ensureConnected). Note the device replies R<seq>|0|... for BOTH
// success ("auth OK") and failure ("Unauthorized"), so the message text — not
// the response code — decides.
func (c *Client) authLocked() error {
id := c.cmdID.Add(1)
_ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout))
if _, err := fmt.Fprintf(c.conn, "C%d|auth %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)
if strings.Contains(strings.ToLower(line), "unauthorized") {
return fmt.Errorf("tunergenius: authentication failed — check the remote code")
}
applog.Printf("tunergenius: authenticated")
return nil
}
// command sends "C<id>|<cmd>\n" and returns the matching reply line, updating
// the status snapshot from whatever status/message lines arrive. Unsolicited
// "M|" info lines that precede the reply are consumed (they update Message).
func (c *Client) command(cmd string) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil || c.reader == nil {
return "", fmt.Errorf("tunergenius: not connected")
}
id := c.cmdID.Add(1)
_ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout))
if _, err := fmt.Fprintf(c.conn, "C%d|%s\n", id, cmd); err != nil {
return "", err
}
// Read until the command's reply (R…/S…); consume async M| lines along the way.
for i := 0; i < 8; i++ {
_ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout))
line, err := c.reader.ReadString('\n')
if err != nil {
return "", err
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
c.parse(line)
if line[0] == 'R' || line[0] == 'S' {
return line, nil
}
}
return "", fmt.Errorf("tunergenius: no reply to %q", cmd)
}
func (c *Client) dropConn() {
c.mu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
c.reader = nil
}
c.mu.Unlock()
}
// parse handles the three line shapes: "R<id>|<code>|<msg>", "S<id>|status …"
// and "M|<message>".
func (c *Client) parse(resp string) {
// Async info/warning: "M|<message>" (empty message = cleared).
if strings.HasPrefix(resp, "M|") {
msg := strings.TrimSpace(strings.TrimPrefix(resp, "M|"))
c.setStatus(func(s *Status) { s.Message = msg })
return
}
var data string
switch {
case strings.HasPrefix(resp, "R"):
p := strings.SplitN(resp, "|", 3)
if len(p) < 3 {
return
}
data = p[2]
case strings.HasPrefix(resp, "S"):
p := strings.SplitN(resp, "|", 2)
if len(p) < 2 {
return
}
data = p[1]
default:
return
}
// "info …" carries the hardware variant (3way=1 on the 3-way model, absent on
// SO2R) — parsed once so the UI knows whether channels are A/B or antennas.
if strings.HasPrefix(data, "info") {
tw := strings.Contains(data, "3way=1")
c.statusMu.Lock()
c.status.ThreeWay = tw
c.statusMu.Unlock()
return
}
// Only the "status …" payload carries the live state we render.
if !strings.HasPrefix(data, "status") {
return
}
if data != c.lastRaw {
c.lastRaw = data
applog.Printf("tunergenius: status raw=%q", data)
}
c.applyStatus(data)
}
// applyStatus maps the "status k=v …" fields onto the snapshot, filling both
// channels (A/B) plus the global power/SWR and operating state.
func (c *Client) applyStatus(data string) {
kv := map[string]string{}
for _, tok := range strings.Fields(data) {
if p := strings.SplitN(tok, "=", 2); len(p) == 2 {
kv[p[0]] = p[1]
}
}
active := atoiDefault(kv["active"], 1)
c.statusMu.Lock()
defer c.statusMu.Unlock()
c.status.Connected = true
c.status.LastError = ""
c.status.Active = active
c.status.Operate = kv["state"] == "1"
c.status.Bypass = kv["bypass"] == "1"
c.status.Tuning = kv["tuning"] == "1"
c.status.RelayC1 = atoiDefault(kv["relayC1"], 0)
c.status.RelayL = atoiDefault(kv["relayL"], 0)
c.status.RelayC2 = atoiDefault(kv["relayC2"], 0)
if v, ok := parseFloat(kv["fwd"]); ok {
c.status.FwdDbm = v
c.status.FwdW = dbmToWatts(v)
}
if v, ok := parseFloat(kv["swr"]); ok {
c.status.SwrDb = v
c.status.Vswr = returnLossToVswr(v)
}
c.status.A = channelFrom(kv, "A")
c.status.B = channelFrom(kv, "B")
// Mirror the active channel into the flat fields the compact widget uses.
act := c.status.A
if active == 2 {
act = c.status.B
}
c.status.FreqMHz = act.FreqMHz
c.status.Antenna = act.Antenna
}
// channelFrom extracts one channel's fields (suffix "A" or "B") from the parsed
// status map.
func channelFrom(kv map[string]string, suffix string) Channel {
mode := atoiDefault(kv["mode"+suffix], 0)
freq, _ := parseFloat(kv["freq"+suffix])
return Channel{
PTT: kv["ptt"+suffix] == "1",
Band: atoiDefault(kv["band"+suffix], 0),
Mode: mode,
ModeStr: modeName(mode),
Flex: strings.TrimSpace(kv["flex"+suffix]),
FreqMHz: freq,
Bypass: kv["bypass"+suffix] == "1",
Antenna: atoiDefault(kv["ant"+suffix], 0),
}
}
func boolNum(on bool) string {
if on {
return "1"
}
return "0"
}
func atoiDefault(s string, def int) int {
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
return n
}
return def
}
func parseFloat(s string) (float64, bool) {
if s == "" {
return 0, false
}
v, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
return v, err == nil
}
// dbmToWatts converts a power reading in dBm to watts (0 dBm = 1 mW).
func dbmToWatts(dbm float64) float64 {
return math.Pow(10, (dbm-30)/10)
}
// returnLossToVswr converts the device's "swr" field — a return loss in dB,
// reported as a negative number (e.g. -60 = an excellent 60 dB match) — into a
// conventional VSWR ratio. A near-zero return loss (bad match) yields a large
// VSWR; a large negative one yields ~1.0.
func returnLossToVswr(swrDb float64) float64 {
rl := math.Abs(swrDb)
rho := math.Pow(10, -rl/20) // reflection coefficient magnitude
if rho >= 1 {
return 99.9
}
vswr := (1 + rho) / (1 - rho)
if vswr > 99.9 || math.IsInf(vswr, 0) || math.IsNaN(vswr) {
return 99.9
}
return vswr
}
+150 -7
View File
@@ -27,10 +27,13 @@ import (
"errors"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
@@ -46,6 +49,14 @@ var errFCCMaintenance = errors.New("fcc uls under maintenance")
const (
fccAmateurURL = "https://data.fcc.gov/download/pub/uls/complete/l_amat.zip"
geoNamesURL = "https://download.geonames.org/export/zip/US.zip"
// fccULSDir is the parent listing the weekly files live under. It is browsed
// to recover from the FCC moving the file — see resolveFCCAmateurURL.
fccULSDir = "https://data.fcc.gov/download/pub/uls/"
// fccAmateurFile is the weekly full-database filename, constant across the
// FCC's directory reshuffles.
fccAmateurFile = "l_amat.zip"
)
// Location is a resolved callsign's home county + grid.
@@ -170,10 +181,19 @@ func (s *Store) Import(ctx context.Context, tmpDir string, prog Progress) error
return fmt.Errorf("GeoNames crosswalk is empty")
}
// 2) FCC ULS full amateur database (large).
// 2) FCC ULS full amateur database (large). The address is resolved rather
// than assumed — the FCC moves this file between directories.
prog("Locating FCC ULS database", 0)
amatURL, err := resolveFCCAmateurURL(ctx)
if err != nil {
return err // already a user-facing explanation
}
if amatURL != fccAmateurURL {
log.Printf("uls: FCC weekly file not at its usual address, using %s", amatURL)
}
prog("Downloading FCC ULS database", 0)
amatPath := filepath.Join(tmpDir, "opslog_l_amat.zip")
if err := download(ctx, fccAmateurURL, amatPath, func(pct int) { prog("Downloading FCC ULS database", pct) }); err != nil {
if err := download(ctx, amatURL, amatPath, func(pct int) { prog("Downloading FCC ULS database", pct) }); err != nil {
return fmt.Errorf("download FCC ULS: %w", err)
}
defer os.Remove(amatPath)
@@ -323,6 +343,129 @@ func parseGeoNames(zipPath string) (map[string]zipRow, error) {
return out, sc.Err()
}
// resolveFCCAmateurURL returns a URL that actually serves the weekly amateur
// database, working around the FCC relocating it.
//
// The canonical path is .../uls/complete/l_amat.zip and it is tried first. On
// 2026-07-24 the FCC renamed that whole directory to "complete.07242026" and
// left an empty "complete" behind, so every weekly file for every radio service
// (not just amateur) started redirecting to a generic fcc.gov help page — the
// county database became un-downloadable for everyone. The file itself was
// intact the whole time, one directory across.
//
// Rather than hard-code that dated directory — it looks like a pre-migration
// snapshot, and pinning it would break again the moment the FCC restores or
// re-snapshots — we browse the parent listing and pick the most recent
// "complete*" directory that actually holds the file. That survives the
// canonical path coming back (tried first, so it wins), a differently-dated
// snapshot next time, and anything else short of the file being withdrawn.
func resolveFCCAmateurURL(ctx context.Context) (string, error) {
if ok, _ := servesFile(ctx, fccAmateurURL); ok {
return fccAmateurURL, nil
}
dirs, err := listFCCCompleteDirs(ctx)
if err != nil {
return "", fmt.Errorf("the FCC weekly download moved and the directory listing could not be read (%w) — try again later, or check %s", err, fccULSDir)
}
// Newest first: the listing is alphabetical, and the dated names sort in an
// arbitrary order (MMDDYYYY), so try them all rather than trusting the order.
for _, d := range dirs {
u := fccULSDir + d + fccAmateurFile
if ok, _ := servesFile(ctx, u); ok {
return u, nil
}
}
return "", fmt.Errorf("the FCC no longer serves %s at its usual address, and no alternate directory under %s has it either — the FCC has changed its downloads; please report this", fccAmateurFile, fccULSDir)
}
// servesFile reports whether url returns a real file rather than a redirect to
// an HTML error page. The FCC answers a missing file with 302 → a help page, so
// a plain status check on the final response is not enough: we must refuse to
// follow the bounce and insist on a non-HTML body.
func servesFile(ctx context.Context, url string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
if err != nil {
return false, err
}
client := &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
resp, err := client.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("HTTP %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); strings.Contains(strings.ToLower(ct), "html") {
return false, fmt.Errorf("served HTML, not a file")
}
return true, nil
}
// listFCCCompleteDirs returns the "complete*/" subdirectory names in the ULS
// download area (e.g. "complete/", "complete.07242026/"), newest-looking last.
func listFCCCompleteDirs(ctx context.Context) ([]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fccULSDir, nil)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
return parseCompleteDirs(string(body)), nil
}
// completeDirRe matches an Apache-style listing link to a "complete…" directory.
var completeDirRe = regexp.MustCompile(`(?i)href="(complete[^"/]*/)"`)
// parseCompleteDirs pulls the candidate directory names out of a listing page.
// Split out from the fetch so it can be tested against a captured listing.
func parseCompleteDirs(html string) []string {
seen := map[string]bool{}
var out []string
for _, m := range completeDirRe.FindAllStringSubmatch(html, -1) {
d := m[1]
if d == "complete/" || seen[d] { // canonical path was already tried
continue
}
seen[d] = true
out = append(out, d)
}
// Dated snapshots (complete.MMDDYYYY) — prefer the most recent by date, so a
// stale older snapshot is never picked over a fresh one.
sort.Slice(out, func(i, j int) bool { return snapshotDate(out[i]).After(snapshotDate(out[j])) })
return out
}
var snapshotDateRe = regexp.MustCompile(`(\d{8})`)
// snapshotDate extracts the MMDDYYYY stamp from "complete.07242026/"; a name
// without one sorts as the zero time (tried last).
func snapshotDate(dir string) time.Time {
m := snapshotDateRe.FindStringSubmatch(dir)
if m == nil {
return time.Time{}
}
t, err := time.Parse("01022006", m[1])
if err != nil {
return time.Time{}
}
return t
}
// download streams url to dest, reporting percent when the content length is
// known and prog is non-nil.
func download(ctx context.Context, url, dest string, prog func(pct int)) error {
@@ -371,11 +514,11 @@ func download(ctx context.Context, url, dest string, prog func(pct int)) error {
}
type progReader struct {
r io.Reader
total int64
read int64
last int
prog func(pct int)
r io.Reader
total int64
read int64
last int
prog func(pct int)
}
func (p *progReader) Read(b []byte) (int, error) {
+57 -3
View File
@@ -10,9 +10,9 @@ func TestGrid6(t *testing.T) {
lat, lon float64
want string
}{
{38.90, -77.03, "FM18lw"}, // Washington DC
{40.71, -74.00, "FN30xr"}, // New York
{34.05, -118.24, "DM04vd"},// Los Angeles
{38.90, -77.03, "FM18lw"}, // Washington DC
{40.71, -74.00, "FN30xr"}, // New York
{34.05, -118.24, "DM04vd"}, // Los Angeles
}
for _, c := range cases {
if got := grid6(c.lat, c.lon); got[:4] != c.want[:4] {
@@ -40,3 +40,57 @@ func TestParseGeoNames(t *testing.T) {
t.Errorf("ZIP 20500 = %+v (ok=%v)", r, ok)
}
}
// The real listing captured from data.fcc.gov on 2026-07-25, the day after the
// FCC renamed complete/ to complete.07242026/ and left an empty complete/
// behind — which broke the county-database download for every user.
const fccListing2026 = `<html><head><title>Index of /download/pub/uls</title></head><body>
<h1>Index of /download/pub/uls</h1>
<table><tr><th>Name</th><th>Last modified</th><th>Size</th></tr>
<tr><td><a href="/download/pub/">Parent Directory</a></td><td>&nbsp;</td><td>-</td></tr>
<tr><td><a href="UAT/">UAT/</a></td><td>2025-04-08 19:30</td><td>-</td></tr>
<tr><td><a href="complete.07242026/">complete.07242026/</a></td><td>2026-07-24 20:15</td><td>-</td></tr>
<tr><td><a href="complete/">complete/</a></td><td>2026-07-25 21:15</td><td>-</td></tr>
<tr><td><a href="daily/">daily/</a></td><td>2026-07-25 21:15</td><td>-</td></tr>
</table></body></html>`
func TestParseCompleteDirs(t *testing.T) {
got := parseCompleteDirs(fccListing2026)
if len(got) != 1 || got[0] != "complete.07242026/" {
t.Fatalf("got %v, want [complete.07242026/]", got)
}
// "complete/" is deliberately absent: it is the canonical URL, already tried
// before the listing is consulted, and on this date it was empty.
for _, d := range got {
if d == "complete/" {
t.Error("canonical complete/ should not be offered as a fallback")
}
}
}
// Several snapshots must be tried newest-first, so a stale one is never
// preferred over a fresh one.
func TestParseCompleteDirsPrefersNewestSnapshot(t *testing.T) {
html := `<a href="complete/">x</a><a href="complete.01052026/">x</a>` +
`<a href="complete.07242026/">x</a><a href="complete.11302025/">x</a>`
got := parseCompleteDirs(html)
want := []string{"complete.07242026/", "complete.01052026/", "complete.11302025/"}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got %v, want %v", got, want)
}
}
}
func TestSnapshotDate(t *testing.T) {
if d := snapshotDate("complete.07242026/"); d.Format("2006-01-02") != "2026-07-24" {
t.Errorf("complete.07242026/ → %s, want 2026-07-24", d.Format("2006-01-02"))
}
// An undated name must sort last rather than crash.
if !snapshotDate("complete.backup/").IsZero() {
t.Error("an undated directory should yield the zero time")
}
}
+37
View File
@@ -0,0 +1,37 @@
//go:build !windows
package winkeyer
// linectl_other.go — non-Windows DTR/RTS line control via go.bug.st/serial. On
// Linux/macOS the SetDTR/SetRTS ioctls control the lines independently, so the
// CP210x-on-Windows problem that motivated the native path (see linectl_windows.go)
// doesn't apply. OpsLog ships on Windows; this keeps the package building elsewhere.
import (
"fmt"
"strings"
"go.bug.st/serial"
)
type serialLineCtl struct {
port serial.Port
}
func openLineCtl(port string) (lineCtl, error) {
if strings.TrimSpace(port) == "" {
return nil, fmt.Errorf("no serial port")
}
p, err := serial.Open(port, &serial.Mode{BaudRate: 9600, DataBits: 8, Parity: serial.NoParity, StopBits: serial.OneStopBit})
if err != nil {
return nil, fmt.Errorf("open %s: %w", port, err)
}
c := &serialLineCtl{port: p}
_ = c.setDTR(false)
_ = c.setRTS(false)
return c, nil
}
func (c *serialLineCtl) setDTR(on bool) error { return c.port.SetDTR(on) }
func (c *serialLineCtl) setRTS(on bool) error { return c.port.SetRTS(on) }
func (c *serialLineCtl) close() error { return c.port.Close() }
+81
View File
@@ -0,0 +1,81 @@
//go:build windows
package winkeyer
// linectl_windows.go — DTR/RTS line control via EscapeCommFunction, the direct
// Win32 method N1MM/WSJT/fldigi use. It sets ONE line at a time without a
// GetCommState/SetCommState round-trip, so a held RTS (PTT) is NOT disturbed when
// DTR (CW) is toggled. go.bug.st/serial drives the lines via SetCommState, which
// on USB-serial adapters (the SCU-17's CP210x, FTDI) drops the held RTS the moment
// DTR changes — the "rig drops to RX between words" bug this fixes.
import (
"fmt"
"strings"
"golang.org/x/sys/windows"
)
type winLineCtl struct {
h windows.Handle
}
// openLineCtl opens a COM port for line control only (no data I/O).
func openLineCtl(port string) (lineCtl, error) {
name := strings.TrimSpace(port)
if name == "" {
return nil, fmt.Errorf("no serial port")
}
// The \\.\ prefix is required for COM10+ and harmless for COM1-9.
if !strings.HasPrefix(name, `\\.\`) {
name = `\\.\` + name
}
p, err := windows.UTF16PtrFromString(name)
if err != nil {
return nil, err
}
h, err := windows.CreateFile(p,
windows.GENERIC_READ|windows.GENERIC_WRITE, 0, nil,
windows.OPEN_EXISTING, 0, 0)
if err != nil {
return nil, fmt.Errorf("open %s: %w", port, err)
}
// A minimal DCB so the driver is in a sane state. Baud is irrelevant for pure
// line control, but leaving fDtr/fRtsControl at DISABLE means only our explicit
// EscapeCommFunction calls drive the lines.
var dcb windows.DCB
if windows.GetCommState(h, &dcb) == nil {
dcb.BaudRate = 9600
dcb.ByteSize = 8
dcb.Parity = 0 // NOPARITY
dcb.StopBits = 0 // ONESTOPBIT
// Clear DTR (bits 4-5) and RTS (bits 12-13) control fields → DISABLE, so the
// lines idle low until we assert them, and no hardware flow control fights us.
dcb.Flags &^= 0x00000030
dcb.Flags &^= 0x00003000
_ = windows.SetCommState(h, &dcb)
}
c := &winLineCtl{h: h}
// Start both lines low (inactive) — the keyer's idle() will do this too.
_ = c.setDTR(false)
_ = c.setRTS(false)
return c, nil
}
func (c *winLineCtl) setDTR(on bool) error {
f := uint32(windows.CLRDTR)
if on {
f = windows.SETDTR
}
return windows.EscapeCommFunction(c.h, f)
}
func (c *winLineCtl) setRTS(on bool) error {
f := uint32(windows.CLRRTS)
if on {
f = windows.SETRTS
}
return windows.EscapeCommFunction(c.h, f)
}
func (c *winLineCtl) close() error { return windows.CloseHandle(c.h) }
+65 -27
View File
@@ -14,11 +14,24 @@ package winkeyer
import (
"strings"
"sync"
"sync/atomic"
"time"
"go.bug.st/serial"
"hamlog/internal/applog"
)
// lineCtl drives a serial port's DTR and RTS control lines. On Windows it uses
// EscapeCommFunction (SETDTR/CLRDTR/SETRTS/CLRRTS) — the direct method N1MM/WSJT
// use, reliable on USB-serial adapters (CP210x/FTDI). go.bug.st/serial drives the
// lines via SetCommState instead, which on a CP210x drops the held RTS (PTT) as
// soon as DTR (CW) is toggled — the "rig drops to RX between words" bug. See
// linectl_windows.go / linectl_other.go.
type lineCtl interface {
setDTR(on bool) error
setRTS(on bool) error
close() error
}
// morseTable maps a keyable character to its Morse element string (. = dit,
// - = dah). Mirrors winkeyer.allowedCW.
var morseTable = map[rune]string{
@@ -35,14 +48,16 @@ var morseTable = map[rune]string{
}
// serialKeyer bit-bangs CW on a serial control line. Owned by a winkeyer.Manager
// while Type == "serial"; all keying happens in run().
// while Type == "serial"; all keying happens in run(). The line-control options
// (key line, invert, PTT, lead/tail) are atomic so ApplyConfig can update them
// LIVE — e.g. ticking "Key PTT line" takes effect without reconnecting the keyer.
type serialKeyer struct {
port serial.Port
keyDTR bool // true: CW on DTR, PTT on RTS (N1MM default). false: swapped.
invert bool // true: active-LOW — asserting a line means driving it LOW
usePTT bool
leadMs int
tailMs int
line lineCtl
keyDTR atomic.Bool // true: CW on DTR, PTT on RTS (N1MM default). false: swapped.
invert atomic.Bool // true: active-LOW — asserting a line means driving it LOW
usePTT atomic.Bool // hold the PTT line for the whole transmission
leadMs atomic.Int32 // PTT lead-in
tailMs atomic.Int32 // PTT hold (hang) after the last element
onBusy func(bool)
mu sync.Mutex
@@ -55,14 +70,9 @@ type serialKeyer struct {
done chan struct{}
}
func newSerialKeyer(port serial.Port, cfg Config, onBusy func(bool)) *serialKeyer {
func newSerialKeyer(line lineCtl, cfg Config, onBusy func(bool)) *serialKeyer {
k := &serialKeyer{
port: port,
keyDTR: !strings.EqualFold(strings.TrimSpace(cfg.CWKeyLine), "rts"), // default DTR=CW
invert: cfg.CWInvert,
usePTT: cfg.UsePTT,
leadMs: cfg.LeadInMs,
tailMs: cfg.TailMs,
line: line,
onBusy: onBusy,
wpm: cfg.WPM,
farns: cfg.Farnsworth,
@@ -71,39 +81,60 @@ func newSerialKeyer(port serial.Port, cfg Config, onBusy func(bool)) *serialKeye
stop: make(chan struct{}),
done: make(chan struct{}),
}
k.keyDTR.Store(!strings.EqualFold(strings.TrimSpace(cfg.CWKeyLine), "rts")) // default DTR=CW
k.invert.Store(cfg.CWInvert)
k.usePTT.Store(cfg.UsePTT)
k.leadMs.Store(int32(cfg.LeadInMs))
k.tailMs.Store(int32(cfg.TailMs))
k.idle() // start inactive: key up, PTT off
go k.run()
return k
}
// ApplyConfig live-updates the line-control options (no port reopen), so a
// settings change is felt from the next character without a reconnect. Speed and
// Farnsworth are updated too. keyText re-reads these per element.
func (k *serialKeyer) ApplyConfig(cfg Config) {
k.keyDTR.Store(!strings.EqualFold(strings.TrimSpace(cfg.CWKeyLine), "rts"))
k.invert.Store(cfg.CWInvert)
k.usePTT.Store(cfg.UsePTT)
k.leadMs.Store(int32(cfg.LeadInMs))
k.tailMs.Store(int32(cfg.TailMs))
k.mu.Lock()
k.wpm = cfg.WPM
k.farns = cfg.Farnsworth
k.mu.Unlock()
k.idle() // re-assert idle lines with any new polarity/key-line choice
}
// level maps a logical "active" state to the physical line level, honouring the
// invert (active-LOW) option: normally active = HIGH, inverted active = LOW.
func (k *serialKeyer) level(active bool) bool { return active != k.invert }
func (k *serialKeyer) level(active bool) bool { return active != k.invert.Load() }
// setKey raises/lowers the CW key line; setPTT the PTT line (only when enabled).
func (k *serialKeyer) setKey(down bool) {
if k.keyDTR {
_ = k.port.SetDTR(k.level(down))
if k.keyDTR.Load() {
_ = k.line.setDTR(k.level(down))
} else {
_ = k.port.SetRTS(k.level(down))
_ = k.line.setRTS(k.level(down))
}
}
func (k *serialKeyer) setPTT(on bool) {
if !k.usePTT {
if !k.usePTT.Load() {
return
}
if k.keyDTR {
_ = k.port.SetRTS(k.level(on))
if k.keyDTR.Load() {
_ = k.line.setRTS(k.level(on))
} else {
_ = k.port.SetDTR(k.level(on))
_ = k.line.setDTR(k.level(on))
}
}
// idle drives BOTH lines to their inactive level (key up, PTT off), respecting
// the invert option — so an active-LOW interface isn't left keyed at rest.
func (k *serialKeyer) idle() {
_ = k.port.SetDTR(k.level(false))
_ = k.port.SetRTS(k.level(false))
_ = k.line.setDTR(k.level(false))
_ = k.line.setRTS(k.level(false))
}
// SetSpeed / Send / Stop mirror the WinKeyer manager's control surface.
@@ -139,6 +170,7 @@ func (k *serialKeyer) Stop() {
func (k *serialKeyer) Close() {
close(k.stop)
<-k.done
_ = k.line.close()
}
func (k *serialKeyer) run() {
@@ -150,10 +182,16 @@ func (k *serialKeyer) run() {
return
case s := <-k.in:
k.onBusy(true)
// Diagnostic: confirms whether PTT is actually held for the whole macro
// (a rig dropping to RX between words = usePTT off, or the interface's
// PTT line isn't wired / honoured in CW).
applog.Printf("winkeyer serial: TX %.40q ptt=%v line=%s lead=%dms tail=%dms",
s, k.usePTT.Load(), map[bool]string{true: "DTR-CW/RTS-PTT", false: "RTS-CW/DTR-PTT"}[k.keyDTR.Load()],
k.leadMs.Load(), k.tailMs.Load())
k.setPTT(true)
k.sleep(time.Duration(k.leadMs) * time.Millisecond)
k.sleep(time.Duration(k.leadMs.Load()) * time.Millisecond)
k.keyText(s)
hang := time.Duration(k.tailMs) * time.Millisecond
hang := time.Duration(k.tailMs.Load()) * time.Millisecond
if hang <= 0 {
hang = 5 * time.Millisecond
}
+23 -11
View File
@@ -189,22 +189,34 @@ func (m *Manager) Connect(cfg Config) error {
return nil
}
// ApplySerialConfig live-updates a running serial-line keyer's control options
// (PTT keying, key line, invert, lead/tail, speed) WITHOUT reopening the port —
// so ticking "Key PTT line" (or changing the key line) takes effect from the next
// character, no reconnect needed. No-op unless a serial keyer is running.
func (m *Manager) ApplySerialConfig(cfg Config) {
cfg = cfg.normalised()
m.mu.Lock()
sk := m.serial
m.cfg = cfg
m.mu.Unlock()
if sk != nil {
sk.ApplyConfig(cfg)
}
}
// connectSerial opens the port for line-keying (DTR=CW / RTS=PTT) — no K1EL
// handshake, no read loop. The PC bit-bangs the Morse itself (see serialcw.go).
func (m *Manager) connectSerial(cfg Config) error {
m.Disconnect() // drop any existing link first
p, err := serial.Open(cfg.Port, &serial.Mode{
BaudRate: cfg.Baud,
DataBits: 8,
Parity: serial.NoParity,
StopBits: serial.OneStopBit,
})
// Open for line control only (DTR/RTS). On Windows this uses EscapeCommFunction
// so a held RTS (PTT) survives DTR (CW) toggling on USB adapters — see linectl_*.
line, err := openLineCtl(cfg.Port)
if err != nil {
return fmt.Errorf("winkeyer: open %s: %w", cfg.Port, err)
}
// The keyer updates busy state as it keys; mirror it into status + notify.
sk := newSerialKeyer(p, cfg, func(busy bool) {
sk := newSerialKeyer(line, cfg, func(busy bool) {
m.mu.Lock()
changed := m.status.Busy != busy
m.status.Busy = busy
@@ -215,17 +227,17 @@ func (m *Manager) connectSerial(cfg Config) error {
})
m.mu.Lock()
m.port = p
m.port = nil // serial keyer owns its own (line-only) port, not m.port
m.serial = sk
m.cfg = cfg
m.status = Status{Connected: true, WPM: cfg.WPM, Port: cfg.Port}
m.mu.Unlock()
line := "DTR (CW) / RTS (PTT)"
lineDesc := "DTR (CW) / RTS (PTT)"
if strings.EqualFold(cfg.CWKeyLine, "rts") {
line = "RTS (CW) / DTR (PTT)"
lineDesc = "RTS (CW) / DTR (PTT)"
}
applog.Printf("winkeyer: serial CW keyer on %s — %s, PTT=%v", cfg.Port, line, cfg.UsePTT)
applog.Printf("winkeyer: serial CW keyer on %s — %s, PTT=%v (EscapeCommFunction line ctl)", cfg.Port, lineDesc, cfg.UsePTT)
m.emit()
return nil
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows
package main
// virtualScreenBounds is Windows-only; elsewhere we cannot tell, and the caller
// treats "cannot tell" as "trust the saved position".
func virtualScreenBounds() (x, y, w, h int, ok bool) { return 0, 0, 0, 0, false }
+69
View File
@@ -0,0 +1,69 @@
package main
import "testing"
// A window restored onto a monitor that is no longer attached opens invisibly
// and stays that way — there is no way back short of deleting window.json, which
// no operator knows to do. These cases decide whether the saved position is
// honoured or quietly dropped for the default placement.
func TestOverlapsEnough(t *testing.T) {
// Two 1920x1080 monitors, the SECOND one to the LEFT of the primary: the
// virtual desktop then starts at a negative x. This is the layout that
// produces lost windows, and where a sign error would go unnoticed.
const vx, vy, vw, vh = -1920, 0, 3840, 1080
cases := []struct {
name string
x, y, w, h int
wantRestorabl bool
}{
{"centred on the primary monitor", 300, 200, 1400, 900, true},
{"on the left-hand monitor (negative x)", -1500, 100, 1400, 900, true},
{"just inside the far left edge", -1900, 0, 1400, 900, true},
{"hard against the right edge, title bar still grabbable", 1920 - 200, 100, 1400, 900, true},
// The failures this exists to catch.
{"entirely past the left edge", -3400, 100, 1400, 900, false},
{"below every monitor", 300, 2000, 1400, 900, false},
{"barely clipping the right edge (10 px)", 1910, 100, 1400, 900, false},
{"only a sliver of height on screen (8 px)", 300, 1072, 1400, 900, false},
{"absurd coordinates from a corrupt file", 999999, 999999, 1400, 900, false},
}
for _, c := range cases {
if got := overlapsEnough(c.x, c.y, c.w, c.h, vx, vy, vw, vh); got != c.wantRestorabl {
t.Errorf("%s: overlapsEnough(%d,%d,%dx%d) = %v, want %v",
c.name, c.x, c.y, c.w, c.h, got, c.wantRestorabl)
}
}
}
// The actual field scenario: OpsLog was closed on a second monitor, that monitor
// is gone, and the desktop is now the primary screen alone. The saved position
// must be dropped — this is the case that leaves the window invisible.
func TestOverlapsEnoughAfterMonitorUnplugged(t *testing.T) {
// Single 1920x1080 primary; the left-hand monitor no longer exists.
const vx, vy, vw, vh = 0, 0, 1920, 1080
for _, c := range []struct {
name string
x, y, w, h int
want bool
}{
{"saved on the monitor that is now gone", -1500, 100, 1400, 900, false},
{"saved just off the left edge", -1400, 100, 1400, 900, false},
{"saved on the surviving monitor", 200, 100, 1400, 900, true},
} {
if got := overlapsEnough(c.x, c.y, c.w, c.h, vx, vy, vw, vh); got != c.want {
t.Errorf("%s: got %v, want %v", c.name, got, c.want)
}
}
}
// With the desktop bounds unavailable (non-Windows, or the call failing) the
// saved position must be honoured rather than second-guessed.
func TestOnSomeMonitorTrustsSavedPositionWhenBoundsUnknown(t *testing.T) {
if _, _, _, _, ok := virtualScreenBounds(); !ok {
if !onSomeMonitor(999999, 999999, 1400, 900) {
t.Error("with unknown desktop bounds the saved position must be kept")
}
}
}
+36
View File
@@ -0,0 +1,36 @@
//go:build windows
package main
import "syscall"
// GetSystemMetrics indices for the virtual desktop — the rectangle spanning
// every attached monitor. Wails' ScreenGetAll reports each monitor's size but
// not its offset, so it cannot answer "is this coordinate on any screen?"; the
// Win32 metrics can.
const (
smXVirtualScreen = 76
smYVirtualScreen = 77
smCXVirtualScreen = 78
smCYVirtualScreen = 79
)
var (
user32Dll = syscall.NewLazyDLL("user32.dll")
procGetSystemMetrics = user32Dll.NewProc("GetSystemMetrics")
)
func systemMetric(index int) int {
r, _, _ := procGetSystemMetrics.Call(uintptr(index))
return int(int32(r)) // signed: the virtual desktop origin is negative with a monitor to the left
}
// virtualScreenBounds returns the rectangle covering all monitors, and whether
// it could be determined at all.
func virtualScreenBounds() (x, y, w, h int, ok bool) {
w, h = systemMetric(smCXVirtualScreen), systemMetric(smCYVirtualScreen)
if w <= 0 || h <= 0 {
return 0, 0, 0, 0, false
}
return systemMetric(smXVirtualScreen), systemMetric(smYVirtualScreen), w, h, true
}
+36 -6
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.20.11"
appVersion = "0.21.4"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.
@@ -85,15 +85,45 @@ func (a *App) sendTelemetryHeartbeat() {
return // already counted today
}
// distinct_id identifies the USER. Prefer the station callsign — it's stable
// across every machine/reinstall the same operator runs, so one op counts as
// one user (a callsign is public in amateur radio). The random per-install ID
// is only a fallback; without it the same op on a laptop + desktop + Maestro
// showed up as three "users". install_id rides along as a property so multiple
// machines under one callsign stay visible.
//
// On a FRESH INSTALL the callsign isn't configured yet at launch, so sending
// straight away would record the machine's random UUID (and the once-a-day lock
// would then keep it that way even after the op enters their call). Instead wait
// a while for the callsign to appear (Settings → Station Information) and only
// fall back to the UUID if none shows up within the grace window — so a genuine
// no-callsign install is still counted, but the normal case gets the callsign.
call := a.activeCallsign()
for i := 0; call == "" && i < 60; i++ { // up to ~10 min (60 × 10 s)
time.Sleep(10 * time.Second)
if a.settings == nil || !a.GetTelemetryEnabled() {
return // disabled meanwhile
}
if last, _ := a.settings.GetGlobal(a.ctx, keyTelemetryLastSent); strings.TrimSpace(last) == today {
return // sent by another path in the meantime
}
call = a.activeCallsign()
}
installID := a.telemetryInstallID()
distinctID := installID
if call != "" {
distinctID = call
}
payload := map[string]any{
"api_key": posthogAPIKey,
"event": "app_opened",
"distinct_id": a.telemetryInstallID(),
"distinct_id": distinctID,
"properties": map[string]any{
"version": appVersion,
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"$lib": "opslog",
"version": appVersion,
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"install_id": installID,
"$lib": "opslog",
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
}
+56
View File
@@ -23,6 +23,17 @@ Plus a **leading/trailing** strip, a **prefix** prepended to found references, a
**dynamic** mode (any value counts, like POTA), and the **fallback searches**
described below.
**One QSO can count for several references.** A contact always yields *every*
reference its field holds — the value is split on commas and semicolons, so an
n-fer POTA activation logged as `US-6544,US-0680` credits both parks, and a
grid-line VUCC contact credits each grid. There is no "one reference per QSO"
switch — it was never useful.
**Match by `code`** looks up each token of the field in the reference list (so a
partial or multi-value field still resolves), while **`exact match`** requires
the whole field to equal the reference. Use exact match when a substring would
cause false hits.
### The five rules to remember
1. **Every regex is case-insensitive.** Award and per-reference patterns match
@@ -72,6 +83,42 @@ A custom province award where the log rarely spells the province out (it names a
Now a QSO is resolved by the province **name** if present, else by a **city**
regex — first in the QTH, then in the address. First hit wins.
## Why doesn't a QSO count? — Test & Missing refs
An award that matches nothing is the hardest thing to debug: the column is just
empty, and you can't tell whether the QSO was out of scope, the field was empty,
the rule looked in the wrong place, or the reference isn't on the list. Two tools
answer that.
### Test a callsign (award editor)
In the **award editor** there's a **Test** box: type a callsign and OpsLog
replays that award's rules against every QSO you have with that station and shows
what happened, step by step — the exact same code path the real matcher uses, so
what you see is what actually runs. For each contact it reports:
- whether the QSO is **in scope** (and, if not, *why* — wrong DXCC, band, mode,
emission, or outside the valid-from/to dates);
- each rule in turn (**primary**, then **Fallback 1, 2, …**), the **field value**
it scanned, the **candidates** it produced, which ones were **kept**, and which
were **rejected** (with the reason — e.g. "not on the reference list");
- rules that were **skipped** because an earlier one already matched;
- any **manual** reference you assigned by hand;
- the final **result** — what the QSO counts for.
This is the fastest way to fix a rule: if the primary scanned the wrong field, or
a candidate was rejected as unlisted, the trace says so directly.
### Missing refs (awards panel)
For an award scoped to a DXCC entity (DDFM, WAS, RAC, WAJA…), the **Missing refs**
view lists contacts that ARE in scope (right DXCC / band / mode / dates) **but
where no reference was found** — so they don't count yet. Sort by a column, tick
the matching contacts, and **assign the reference** to all of them at once (or
click a row to open the QSO and fix the field). Recompute and the fixed contacts
drop off the list. This is how you close the gaps a matcher can't fill on its own
(a French QSO logged without its department, say).
## Live detection & manual refs
- References are detected **live** as you enter a callsign.
@@ -89,6 +136,15 @@ regex — first in the QTH, then in the address. First hit wins.
or **both**. Award columns are opt-in per the Columns picker
([[Recent QSOs and Filters]]).
## Built-in updates vs your edits
Built-in awards ship with the app, definition **and** reference list. When a
newer version fixes a shipped award (a better fallback chain, a corrected
reference list), OpsLog offers to apply that update — **unless you've edited that
award yourself**, in which case your version is left alone (your work outranks
ours). Awards you created are never touched by updates. So you can freely tune a
built-in award without fear of a future release overwriting it.
## Rescan
**Rescan** re-pulls the logbook and recomputes — it picks up fresh LoTW / QRZ /
+17 -3
View File
@@ -4,9 +4,23 @@ This walks you from a fresh install to your first logged QSO.
## 1. Set your station
**Settings → Station**: enter your **callsign**, **grid locator** and **name**.
These feed callsign lookups, the map, awards, the QSL card and the "my station"
ADIF fields on every QSO.
**Settings → Station Information**: enter your **callsign**, **grid locator** and
**name**. These feed callsign lookups, the map, awards, the QSL card and the
"my station" ADIF fields on every QSO.
For a **complete ADIF**, also fill your **address / city / state / county** here,
and set your **per-band rig and antenna** in **Settings → Operating conditions**
(tick the default antenna for each band). These populate the `MY_*` fields
(`MY_RIG`, `MY_ANTENNA`, `MY_CITY`, `MY_STREET`, `MY_STATE`…) on your QSOs.
> **Importing an existing log?** Fill the station info **and** your operating
> conditions **before** you import. In the ADIF import dialog, tick **"Fill my
> station fields from my profile"** — OpsLog then backfills the *empty* `MY_*`
> fields (grid, rig, antenna, address, city, state, county, SOTA/POTA…) plus
> **Operator** and **Owner callsign** from your active profile, so even a bare
> ADIF comes in fully described. Existing values are kept; only
> `STATION_CALLSIGN` is left untouched (so a mixed-call log isn't re-routed).
> See [[Import and Export ADIF]].
## 2. (Optional) Connect a radio