Compare commits

..
85 Commits
Author SHA1 Message Date
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
rouggy e6ca7a8bdd chore: release v0.20.11 2026-07-23 19:02:30 +02:00
rouggy 9be3f9147b chore: regenerate wails bindings (serial CW keyer config fields) 2026-07-23 19:02:24 +02:00
rouggy f7f801cdb7 fix: OmniRig spot-click didn't QSY Yaesu/Kenwood rigs (FT-891)
Confirmed from an FT-891 CAT log: OpsLog tuned via OmniRig's SetSimplexMode,
which returned OK on every spot click but never moved the VFO (readback stayed
put), while SetMode on the same .ini worked — so the CAT link was healthy and
only the frequency write was a no-op. SetSimplexMode was chosen because Icoms
accept direct FreqA writes but don't move; Yaesu/Kenwood are the opposite.

Now, for non-Icom rigs (or if SetSimplexMode errors), also write the VFO
frequency property (FreqA/Freq) directly, which does move them. Icom keeps the
SetSimplexMode-only path so the Main/Sub VFO isn't nudged by a direct write.
2026-07-23 18:59:44 +02:00
rouggy 43f15f1a2c feat: serial CW keyer — add invert (active-LOW) polarity option
Some CW interfaces key on the opposite line level (transmit at rest / key
backwards). New "Invert keying (active-LOW)" toggle flips the asserted level for
both the CW and PTT lines, and the idle state drives both lines to their
inactive level accordingly so an active-low rig isn't left keyed at rest.
Default off = N1MM convention (line HIGH = active).
2026-07-23 18:50:01 +02:00
rouggy 2c5416500f feat: serial-line CW keyer (DTR=CW / RTS=PTT) — SCU-17 / N1MM style
Adds a 4th CW keyer engine alongside K1EL WinKeyer, Icom CI-V and Flex CWX:
OpsLog bit-bangs the Morse itself on a serial control line (DTR keys CW, RTS
keys PTT — swappable), the hardware-keying method N1MM/WSJT use with a Yaesu
SCU-17 and generic interfaces. No WinKeyer chip needed.

- internal/winkeyer/serialcw.go: serialKeyer — worker goroutine, morse table,
  WPM + Farnsworth timing (live speed changes), abortable, PTT lead-in/tail/hang.
- winkeyer.Config gains Type ("k1el"|"serial") + CWKeyLine ("dtr"|"rts");
  Manager routes Connect/Send/Stop/SetSpeed/Backspace/Disconnect to it.
- app.go: keyWKCWLine setting; WinkeyerConnect sets Type from the engine.
- Settings -> CW Keyer: new "Serial port (DTR=CW/RTS=PTT)" engine with COM port,
  key-line selector, speed/Farnsworth/lead-in/tail/PTT. Macros, auto-CQ and
  <LOGQSO> reuse the WinKeyer path unchanged.

UNTESTED on hardware (no SCU-17 to hand): polarity assumes N1MM convention
(line HIGH = active). Weight not yet honoured; Farnsworth is.
2026-07-23 17:56:11 +02:00
rouggy fcdc5809e6 fix: IC-7300 spectrum scope never rendered — wrong frame framing
The rig's 0x27 waveform frames carry the same leading main-scope selector byte
as the dual-scope IC-7610/9700 (`27 00 00 <seq> <total> …`), but the code keyed
the layout off the CI-V address and treated the 7300 as selector-less, reading
the sequence from Data[1] (always 0x00) so every frame hit the `seq == 0`
guard and was dropped — blank scope. The waveform parser now detects the
leading selector byte from the frame itself (address-independent), and the
scope config/set commands (mode, span, edges) include the selector the 7300
requires. The IC-7610/9700 path is byte-for-byte unchanged (main → idx 2, sub
frames skipped). Confirmed against a real IC-7300 CI-V capture.
2026-07-23 15:20:56 +02:00
rouggy 7254950162 docs: move the scope display-off fix to changelog 0.20.11
0.20.10 was released before the "closing OpsLog switches off the radio's own
scope" fix landed, so its entry is reattributed to 0.20.11 (the fix code is
already committed in 1668455).
2026-07-23 14:48:45 +02:00
rouggy 1668455ff4 fix: closing OpsLog no longer switches off the Icom's own scope display
SetScope(false) sent both 0x27 0x10 00 (scope DISPLAY off) and 0x27 0x11 00
(CI-V data output off), so quitting OpsLog blanked a local IC-7300's own scope
screen. Only the display-on (0x27 0x10 01) is now sent, and only on enable;
disable stops the CI-V stream alone and never touches the radio's display.
2026-07-23 14:37:39 +02:00
rouggy c07b746d4b chore: release v0.20.10 2026-07-23 14:23:21 +02:00
rouggy d128dabc19 fix: IC-7300 (single-scope Icom) blank spectrum scope in center/VFO mode
The multi-frame waveform header (USB path) read the second frequency field
as an absolute high edge, but in center mode it is a span — so high < low and
the panadapter had no valid range to map the trace onto (blank scope). It now
applies the same center+span vs low+high disambiguation the IC-7610 single-
frame path already used. FIXED mode was unaffected.

Also stamps changelog 0.20.10 with today's date.
2026-07-23 14:21:08 +02:00
rouggy 2823f3e401 feat: Station Control amp parity + all amps, Help→Send log to F4BPO, bulk-edit frequency, fix Cluster midnight sort
- Station Control: the amplifier panel is now the exact same card as the FlexRadio panel (extracted shared AmpCard: OPERATE/STANDBY, ON/OFF, power level, output-power bar, live FWD/ID/temp meters). Every configured amp gets its own card — no dropdown.
- Help → "Send log to F4BPO" (only when SMTP is configured): e-mails opslog.log + previous session + crash log to the developer. New email.SendFiles for multi-attachment.
- Bulk edit: new "Frequency (MHz)" field sets freq_hz AND recomputes band; out-of-band values rejected. Fixes a run logged on a stale/default freq after CAT dropped.
- Cluster: Time column sorted lexically on the HHMMZ string, so spots after 0000Z sorted below 2359Z and looked frozen at the UTC rollover. Now sorts by real arrival time.
- Also folds in the DVK auto-CQ/2-column layout and Station Control dashboard batch (changelog 0.20.10, EN+FR).
2026-07-23 13:22:02 +02:00
rouggy 1070637c40 feat+fix batch: Station Control dashboard (fixed-width panels, grip-handle drag reorder, amp meters from the Flex stream, compact relays), Ultrabeam element list capped to 3 (READ_BANDS over-read), spot-click on Flex panadapter now sets mode, DAX TX toggle, DVK panel state persists across relaunch, duplicate amp meters cleared on power-cycle, QSO audio recording snapshot taken synchronously at log time (was racing the form-clear cancel), Flex COMP meter max -20->-25 and MIC scale to 0; changelog 0.20.10 2026-07-23 01:08:47 +02:00
rouggy 13de53772b docs: update README (EN+FR) — multiple amplifiers + ACOM, SmartSDR v4 DSP filters + DAX, native microHAM ARCO rotator (GS-232A LAN/USB), panadapter spot tunes freq+mode, NET Control drag&drop + Log everyone, automatic live status, digital-mode grouping option, Select all / filter-shows-all 2026-07-22 20:36:29 +02:00
rouggy 6b04072e52 fix: clicking an OpsLog spot on the Flex panadapter now sets the mode too — cluster spots get a mode inferred (comment/band plan) and SendSpot converts it to a real Flex mode (SSB->USB/LSB, FT8->DIGU), so SmartSDR actually switches mode on the click; changelog 0.20.10 2026-07-22 20:36:29 +02:00
rouggy 64b8ba7fdc chore: release v0.20.9 2026-07-22 20:24:34 +02:00
rouggy a9ca50b2dd fix: the Flex panel DAX button now toggles the TRANSMIT-bar DAX (transmit set dax=), not the slice RX DAX channel — this is the button that routes TX audio through DAX (WSJT-X); state follows changes made in SmartSDR too 2026-07-22 20:24:25 +02:00
rouggy 512b5fd35b feat: DAX selector on the Flex panel — routes the active slice's RX audio to a DAX channel (slice dax=0-8, Off..8 dropdown next to the antenna selection, highlighted when active); changelog updated 2026-07-22 17:33:45 +02:00
rouggy 77e3d011a0 ui: rebalance the Flex panel columns — RIT/XIT move to the bottom of the Transmit card, antenna RX/TX selection to the top of the Receive card (the Receive column had grown tall with the v4 DSP rows); changelog wording updated 2026-07-22 17:31:59 +02:00
rouggy b7dd8d4852 feat: multiple amplifiers + SmartSDR v4 DSP filters
Multi-amp: Settings->Amplifier becomes a list (amps.json, legacy single-amp keys auto-migrate to entry #1); one client per enabled amp with per-id bindings (GetAmplifiers/SaveAmplifiers/GetAmpStatuses/AmpOperate/AmpPower/AmpPowerLevel/AmpFanMode); legacy a.pgxl/a.spe/a.acom point at the first enabled amp of each family. Amp cards in FlexPanel and Station Control gain a dropdown to pick the amp; the status bar shows one clickable chip per amp. Use case: two SPEs run in parallel.

Flex v4 DSP (8000/Aurora): NRL/ANFL (lms_nr/lms_anf), NRS (speex_nr), NRF (nrf) with level sliders, RNN (rnnoise) and ANFT on/off — keys per the FlexLib slice docs; the section only shows when the radio reports these keys (dsp_v4 flag), so 6000-series panels are unchanged.
2026-07-22 17:25:10 +02:00
rouggy 4c75680689 feat: day batch — upload results surfaced as toasts (silent LoTW failures from the right-click send looked like nothing was sent); live selection count + Select all/Unselect all toggle in the grid toolbar; active filters bypass the row limit (all matches shown, 10k safety cap); NET Control: same right-click menu on worked-before + drag&drop roster<->on-air (drop on roster logs the QSO); compact mode restores previous window size/position; advanced-filter field renamed Station callsign; optional DXCC-style digital-mode grouping (Settings->General) for matrix badges + cluster colouring; changelog 0.20.9 entries (version NOT bumped) 2026-07-22 15:38:08 +02:00
rouggy 5aac28f564 fix: PGXL status-bar chip and Station Control widget read OPERATE from the Flex when it reports the amp (the direct GSCP status doesn't carry operate state) — toggle then goes through FlexAmpOperate like the Flex panel; direct TCP link stays as fallback; log distinct raw PGXL status payloads to map unknown fields 2026-07-22 09:33:39 +02:00
rouggy c91c8c3b47 feat: v0.20.8 — microHAM ARCO rotator native control (GS-232A over LAN TCP or USB serial, no PstRotator); amplifier OPERATE/STANDBY toggle on the status-bar chip for all three amps (PGXL direct operate command now wired + operate state parsed); PGXL operate button in the Station Control widget; NET Control auto-selects the next on-air station after logging and gains a 'Log everyone' button; changelog + version bump 2026-07-21 21:49:01 +02:00
rouggy 968da5488c chore: release v0.20.8 2026-07-21 21:48:23 +02:00
rouggy 1b5b0c2e90 feat: clicking the status-bar amplifier chip toggles OPERATE <-> STANDBY (SPE/ACOM, optimistic flip reconciled by the poll; disabled offline) — PGXL still opens Settings, it has no standby command 2026-07-21 18:54:48 +02:00
rouggy 0385aed760 feat: amplifier controls without a Flex/Icom panel — Station Control widget (SPE/ACOM full control, PGXL fan) + status-bar chip (green OPERATE / orange STANDBY / red offline, click opens Settings); warn with a toast when activating a profile whose MySQL is unreachable instead of silently staying on the old logbook 2026-07-21 18:47:31 +02:00
61 changed files with 8976 additions and 1025 deletions
+1
View File
@@ -46,3 +46,4 @@ cat.log
.env.local .env.local
*.pem *.pem
*.key *.key
*.syso
+41 -15
View File
@@ -42,7 +42,10 @@ Développé par **F4BPO**.
district (`/8`, `/W6`), plus les dérogations DXpédition de ClubLog par dates. district (`/8`, `/W6`), plus les dérogations DXpédition de ClubLog par dates.
- **QSO récents**, matrice **déjà contactés** (par créneau bande/mode), - **QSO récents**, matrice **déjà contactés** (par créneau bande/mode),
re-résolution en masse depuis cty/QRZ/ClubLog, envoi en masse vers les services re-résolution en masse depuis cty/QRZ/ClubLog, envoi en masse vers les services
QSL. QSL. Un **compteur de sélection** en direct, une bascule **Tout sélectionner /
Tout désélectionner**, et une limite d'affichage qui garde le journal complet
rapide **mais qui saute dès qu'un filtre est actif** (toutes les correspondances
sont montrées, pas seulement la première page).
- **Constructeur de filtres QSO avancé** (champ / opérateur / valeur, ET / OU, - **Constructeur de filtres QSO avancé** (champ / opérateur / valeur, ET / OU,
préréglages enregistrés) avec **export ADIF** des lignes filtrées ou préréglages enregistrés) avec **export ADIF** des lignes filtrées ou
sélectionnées. sélectionnées.
@@ -67,7 +70,9 @@ Développé par **F4BPO**.
légendés) et le(s) **lobe(s) du faisceau d'antenne** tracés depuis l'azimut du légendés) et le(s) **lobe(s) du faisceau d'antenne** tracés depuis l'azimut du
rotor. rotor.
- **Compas de rotor** (azimutal équidistant, clic pour tourner) piloté par - **Compas de rotor** (azimutal équidistant, clic pour tourner) piloté par
PstRotator. **PstRotator** (UDP), un **Rotator Genius 4O3A** (TCP natif) ou un **microHAM
ARCO** contrôlé nativement — sans PstRotator — via le **réseau** ou l'**USB**
avec son protocole Yaesu GS-232A.
- **Support Ultrabeam** (Normal / inversé 180° / bidirectionnel) : la direction - **Support Ultrabeam** (Normal / inversé 180° / bidirectionnel) : la direction
rayonnée est en vert et le **boom mécanique** en gris, à la fois sur le compas rayonnée est en vert et le **boom mécanique** en gris, à la fois sur le compas
et sur la carte, pour toujours savoir où pointe l'antenne. et sur la carte, pour toujours savoir où pointe l'antenne.
@@ -81,7 +86,9 @@ Développé par **F4BPO**.
Cluster et le volet cluster de la vue principale, avec bascule affichage/masquage. Cluster et le volet cluster de la vue principale, avec bascule affichage/masquage.
- **Statut** par spot (nouveau / nouvelle bande / nouveau créneau / contacté), - **Statut** par spot (nouveau / nouvelle bande / nouveau créneau / contacté),
clic pour accorder la radio, et une **bandmap** multi-bandes (bandes façon clic pour accorder la radio, et une **bandmap** multi-bandes (bandes façon
panadapter). panadapter). En option, tous les **modes numériques comptent comme un seul**
(style DXCC) pour le coloriage nouveau/nouveau-créneau et les badges de la
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 - Les spots **POTA** sont étiquetés avec leur référence de parc (via
`api.pota.app`). `api.pota.app`).
- **Alertes de spots** (façon Log4OM) : règles sur indicatif / pays / bande / - **Alertes de spots** (façon Log4OM) : règles sur indicatif / pays / bande /
@@ -97,7 +104,8 @@ connexion rapide non bloquante — une radio éteinte ne fige jamais l'applicati
supportée par OmniRig. supportée par OmniRig.
- **FlexRadio (SmartSDR)** via l'API TCP de la radio — fréquence / mode / split - **FlexRadio (SmartSDR)** via l'API TCP de la radio — fréquence / mode / split
de la slice en temps réel, découverte UDP, et **spots panadapter** (les spots de la slice en temps réel, découverte UDP, et **spots panadapter** (les spots
du cluster poussés sur l'écran Flex, clic remplir l'indicatif). du cluster poussés sur l'écran Flex ; un clic remplit l'indicatif et **accorde
la radio sur la bonne fréquence ET le bon mode**).
- **Icom CI-V** — natif, via le port **USB** de la radio *ou* par internet via le - **Icom CI-V** — natif, via le port **USB** de la radio *ou* par internet via le
**serveur LAN intégré** de la radio (voir *Icom à distance* ci-dessous). Ni **serveur LAN intégré** de la radio (voir *Icom à distance* ci-dessous). Ni
RS-BA1 ni Remote Utility nécessaires. RS-BA1 ni Remote Utility nécessaires.
@@ -114,12 +122,17 @@ Affiché uniquement quand le backend CAT est une FlexRadio :
- **Émission :** puissance RF, puissance d'accord, TUNE, MOX, processeur de - **Émission :** puissance RF, puissance d'accord, TUNE, MOX, processeur de
parole (NOR/DX/DX+), VOX (+ niveau + délai), moniteur (+ niveau), gain micro. parole (NOR/DX/DX+), VOX (+ niveau + délai), moniteur (+ niveau), gain micro.
- **Réception (slice active) :** mode/seuil AGC, niveau audio, NB / NR / ANF. - **Réception (slice active) :** sélecteurs d'**antenne** RX/TX et une bascule
**DAX** (audio d'émission via DAX, pour WSJT-X & co), mode/seuil AGC, niveau
audio, NB / WNB / NR / ANF, et — sur les radios **SmartSDR v4** (séries 8000 /
Aurora) — les filtres DSP supplémentaires **NRL, NRS, NRF** (avec niveau) plus
**RNN** (réduction de bruit par IA) et **ANFT** (notch automatique FFT),
affichés automatiquement si la radio les supporte. **RIT / XIT** avec accord
molette / ±.
- **Coupleur d'antenne (ATU) :** accord / bypass / mémoires. - **Coupleur d'antenne (ATU) :** accord / bypass / mémoires.
- **Amplificateur :** la carte de commande suit l'ampli configuré - **Amplificateur :** la carte de commande suit l'ampli configuré, avec une liste
**PowerGenius XL** (operate/standby, mode ventilateur, défaut) ou déroulante pour le choisir quand **plusieurs amplificateurs** sont configurés
**SPE Expert** (operate/standby, Marche/Arrêt, niveau Low/Mid/High, barre de (p. ex. deux SPE en parallèle). Voir *Amplis & commutateurs* ci-dessous.
puissance de sortie, bande & température).
- **Mesures en direct** via le flux UDP VITA-49 : S-mètre (unités S), puissance - **Mesures en direct** via le flux UDP VITA-49 : S-mètre (unités S), puissance
directe (W), ROS, ALC, température PA, tension, plus les mesures de l'ampli. directe (W), ROS, ALC, température PA, tension, plus les mesures de l'ampli.
@@ -178,14 +191,23 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
## Amplis & commutateurs ## Amplis & commutateurs
- **Amplificateurs** (Réglages → Amplificateur — la carte de commande apparaît - **Amplificateurs** — configurez **un ou plusieurs** amplis (Réglages →
sur l'onglet FlexRadio, ou seule quand ni Flex ni Icom n'est actif) : Amplificateur est une liste ; p. ex. deux SPE en parallèle pour plus de
puissance). La carte de chaque ampli apparaît sur l'onglet FlexRadio et dans
**Station Control**, avec une liste déroulante pour choisir lequel afficher ; la
barre de statut du bas porte **une pastille cliquable par ampli** (vert =
OPERATE, orange = STANDBY, rouge = hors ligne). Supportés :
- **PowerGenius XL** (4O3A) en TCP direct — operate/standby, sélecteur de mode - **PowerGenius XL** (4O3A) en TCP direct — operate/standby, sélecteur de mode
ventilateur et affichage des défauts. ventilateur et affichage des défauts.
- **SPE Expert** (1.3K-FA / 1.5K-FA / 2K-FA) via **USB** (COM virtuel) ou le - **SPE Expert** (1.3K-FA / 1.5K-FA / 2K-FA) via **USB** (COM virtuel) ou le
**réseau** (pont RS232-Ethernet) — operate/standby, Marche/Arrêt, niveau de **réseau** (pont RS232-Ethernet) — operate/standby, Marche/Arrêt, niveau de
sortie Low / Mid / High, une barre de puissance et le statut en direct (bande, sortie Low / Mid / High, une barre de puissance et le statut en direct (bande,
ROS, courant PA, température, avertissements/alarmes). ROS, courant PA, température, avertissements/alarmes).
- **ACOM** (500S / 600S / 700S / 1200S / 2020S) via **USB** ou le **réseau**
(pont RS232-Ethernet) — operate/standby/arrêt et télémétrie en direct
(puissance directe & réfléchie, ROS, température PA, bande, ventilateur,
défauts). La mise en marche fonctionne via un câble série reliant les lignes
DTR/RTS.
- Commutateur d'antenne **Antenna Genius** (4O3A) via TCP/GSCP — un widget de - Commutateur d'antenne **Antenna Genius** (4O3A) via TCP/GSCP — un widget de
commutation A/B ancré. commutation A/B ancré.
- Panneau **Station Control** (ancrable, widgets réordonnables par glisser) : le - Panneau **Station Control** (ancrable, widgets réordonnables par glisser) : le
@@ -235,10 +257,11 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
## Statut opérateur en direct (événements spéciaux) ## Statut opérateur en direct (événements spéciaux)
Pour un indicatif d'événement spécial multi-op sur un journal MySQL partagé (ex. Pour un indicatif d'événement spécial multi-op sur un journal MySQL partagé (ex.
**TM74TFR**) : Réglages → Général → *Publier le statut opérateur en direct*. **TM74TFR**), la publication est **automatique** — aucun réglage à activer.
Chaque instance OpsLog envoie un battement de cœur de son activité (indicatif de Chaque instance OpsLog envoie un battement de cœur de son activité (indicatif de
l'opérateur, bande, fréquence, mode) dans une table `live_status` toutes les l'opérateur, bande, fréquence, mode) dans une table `live_status` toutes les
~15 s. Un petit rendu PHP ~15 s, et repasse *hors antenne* automatiquement 5 min après le dernier QSO
logué. Un petit rendu PHP
([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) sur votre ([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) sur votre
propre serveur web lit cette table et produit une page/image en direct que vous propre serveur web lit cette table et produit une page/image en direct que vous
pouvez intégrer sur la bio **QRZ.com** de la station pouvez intégrer sur la bio **QRZ.com** de la station
@@ -248,8 +271,11 @@ ce n'est pas un serveur web.
## Net control ## Net control
- **Log de net dirigé** (Outils → Net) : un roster global (`nets.json`) plus une - **Log de net dirigé** (Outils → Net) : un roster global (`nets.json`) plus une
session active en mémoire — pointez les stations présentes, puis loguez tout le session active en mémoire — pointez les stations présentes, puis loguez-les une
net d'un coup en utilisant la fréquence CAT. par une ou tout le net d'un coup (**Logger tout le monde**) en utilisant la
fréquence CAT. **Glisser-déposer** entre les deux listes (roster → on air démarre
un QSO, on air → roster l'enregistre), et après chaque log la station on air
suivante est sélectionnée automatiquement pour enchaîner les contacts.
## Apparence & langue ## Apparence & langue
+40 -16
View File
@@ -40,7 +40,10 @@ Developed by **F4BPO**.
with `/MM` `/AM` `/B` (beacon) and call-area (`/8`, `/W6`) handling, plus with `/MM` `/AM` `/B` (beacon) and call-area (`/8`, `/W6`) handling, plus
ClubLog DXpedition date overrides. ClubLog DXpedition date overrides.
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve - **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
from cty/QRZ/ClubLog, bulk send to QSL services. from cty/QRZ/ClubLog, bulk send to QSL services. A live **selection count**, a
**Select all / Unselect all** toggle, and a row limit that keeps the full log
fast **but is lifted while a filter is active** (every match is shown, not just
the first page).
- **Advanced QSO filter builder** (field / operator / value, AND / OR, saved - **Advanced QSO filter builder** (field / operator / value, AND / OR, saved
presets) with filtered- and selected-row **ADIF export**. presets) with filtered- and selected-row **ADIF export**.
- **Find duplicates** (Tools) — groups QSOs by same call + band + mode (optionally - **Find duplicates** (Tools) — groups QSOs by same call + band + mode (optionally
@@ -59,7 +62,10 @@ Developed by **F4BPO**.
- **Great-circle map** with short/long-path distance & azimuth, selectable - **Great-circle map** with short/long-path distance & azimuth, selectable
basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and
the **antenna beam lobe(s)** drawn from the rotor azimuth. the **antenna beam lobe(s)** drawn from the rotor azimuth.
- **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by PstRotator. - **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by
**PstRotator** (UDP), a **4O3A Rotator Genius** (native TCP) or a **microHAM
ARCO** controlled natively — no PstRotator needed — over the **LAN** or **USB**
using its Yaesu GS-232A protocol.
- **Ultrabeam** support (Normal / 180° reverse / Bidirectional): the radiating - **Ultrabeam** support (Normal / 180° reverse / Bidirectional): the radiating
direction is shown in green and the **mechanical boom** in grey, on both the direction is shown in green and the **mechanical boom** in grey, on both the
compass and the map, so you never lose track of where the antenna points. compass and the map, so you never lose track of where the antenna points.
@@ -71,7 +77,9 @@ Developed by **F4BPO**.
mode / status / source) shared by the Cluster tab and the Main-view cluster mode / status / source) shared by the Cluster tab and the Main-view cluster
pane, with a show/hide toggle. pane, with a show/hide toggle.
- Per-spot **status** (new / new-band / new-slot / worked), click-to-tune the - Per-spot **status** (new / new-band / new-slot / worked), click-to-tune the
rig, and a multi-band **Band Map** (panadapter-style strips). rig, and a multi-band **Band Map** (panadapter-style strips). Optionally, all
**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`). - **POTA** spots are tagged with their park reference (via `api.pota.app`).
- **Spot alerts** (Log4OM-style): rules on call / country / band / mode / - **Spot alerts** (Log4OM-style): rules on call / country / band / mode /
spotter, with sound, visual and e-mail notification (Tools → *Alert spotter, with sound, visual and e-mail notification (Tools → *Alert
@@ -85,7 +93,8 @@ non-blocking connect so a powered-off radio never freezes the app:
- **OmniRig** (Rig 1/2, hot-swap) — works with any OmniRig-supported rig. - **OmniRig** (Rig 1/2, hot-swap) — works with any OmniRig-supported rig.
- **FlexRadio (SmartSDR)** over the radio's TCP API — real-time slice freq / - **FlexRadio (SmartSDR)** over the radio's TCP API — real-time slice freq /
mode / split, UDP discovery, and **panadapter spots** (cluster spots pushed to mode / split, UDP discovery, and **panadapter spots** (cluster spots pushed to
the Flex display, click fill the call). the Flex display; a click fills the call and **tunes the rig to the right
frequency AND mode**).
- **Icom CI-V** — native, over the radio's **USB** port *or* over the internet - **Icom CI-V** — native, over the radio's **USB** port *or* over the internet
via the radio's **built-in LAN server** (see *Remote Icom* below). No RS-BA1 or via the radio's **built-in LAN server** (see *Remote Icom* below). No RS-BA1 or
Remote Utility needed. Remote Utility needed.
@@ -102,12 +111,16 @@ Shown only when the CAT backend is a FlexRadio:
- **Transmit:** RF power, tune power, TUNE, MOX, speech processor (NOR/DX/DX+), - **Transmit:** RF power, tune power, TUNE, MOX, speech processor (NOR/DX/DX+),
VOX (+ level + delay), monitor (+ level), mic gain. VOX (+ level + delay), monitor (+ level), mic gain.
- **Receive (active slice):** AGC mode/threshold, audio level, NB / NR / ANF. - **Receive (active slice):** RX/TX **antenna** selectors and a **DAX** toggle
(TX audio through DAX, for WSJT-X & co), AGC mode/threshold, audio level,
NB / WNB / NR / ANF, and — on **SmartSDR v4** radios (8000 / Aurora series) —
the extra DSP tools **NRL, NRS, NRF** (with level) plus **RNN** (AI noise
reduction) and **ANFT** (FFT auto-notch), shown automatically when the radio
supports them. **RIT / XIT** with wheel / ± tuning.
- **Antenna tuner (ATU):** tune / bypass / memories. - **Antenna tuner (ATU):** tune / bypass / memories.
- **Amplifier:** the amp card follows whichever amplifier is configured - **Amplifier:** the amp card follows whichever amplifier is configured, with a
**PowerGenius XL** (operate/standby, fan mode, fault) or **SPE Expert** dropdown to pick it when **several amplifiers** are set up (e.g. two SPEs run
(operate/standby, ON/OFF, Low/Mid/High level, output-power bar, band & in parallel). See *Amplifiers & switches* below.
temperature).
- **Live meters** over the UDP VITA-49 stream: S-meter (S-units), forward power - **Live meters** over the UDP VITA-49 stream: S-meter (S-units), forward power
(W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters. (W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters.
@@ -158,14 +171,21 @@ as Mumble.)
## Amplifiers & switches ## Amplifiers & switches
- **Amplifiers** (Settings → Amplifier — the control card appears on the - **Amplifiers** — configure **one or several** amps (Settings → Amplifier is a
FlexRadio tab, or on its own when neither Flex nor Icom is active): list; e.g. two SPEs run in parallel for more power). Each amp's control card
appears on the FlexRadio tab and in **Station Control**, with a dropdown to
choose which one it shows; the bottom status bar carries **one clickable chip
per amp** (green = OPERATE, orange = STANDBY, red = offline). Supported:
- **PowerGenius XL** (4O3A) over direct TCP — operate/standby, fan-mode - **PowerGenius XL** (4O3A) over direct TCP — operate/standby, fan-mode
selector and fault display. selector and fault display.
- **SPE Expert** (1.3K-FA / 1.5K-FA / 2K-FA) over **USB** (virtual COM) or the - **SPE Expert** (1.3K-FA / 1.5K-FA / 2K-FA) over **USB** (virtual COM) or the
**network** (RS232-to-Ethernet bridge) — operate/standby, ON/OFF, **network** (RS232-to-Ethernet bridge) — operate/standby, ON/OFF,
Low / Mid / High output level, an output-power bar and live status (band, Low / Mid / High output level, an output-power bar and live status (band,
SWR, PA current, temperature, warnings/alarms). SWR, PA current, temperature, warnings/alarms).
- **ACOM** (500S / 600S / 700S / 1200S / 2020S) over **USB** or the **network**
(RS232-to-Ethernet bridge) — operate/standby/off and live telemetry (forward
& reflected power, SWR, PA temperature, band, fan, faults). Power-ON works
over a serial cable that wires the DTR/RTS lines.
- **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B - **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
antenna-switch widget. antenna-switch widget.
- **Station Control** panel (dockable, drag-to-reorder widgets): the **rotator**, - **Station Control** panel (dockable, drag-to-reorder widgets): the **rotator**,
@@ -208,10 +228,11 @@ as Mumble.)
## Multi-operator live status (special events) ## Multi-operator live status (special events)
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**): For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**),
Settings → General → *Publish live operator status*. Each OpsLog instance publishing is **automatic** — no setting to turn on. Each OpsLog instance
heartbeats its current activity (operator call, band, frequency, mode) into a heartbeats its current activity (operator call, band, frequency, mode) into a
`live_status` table every ~15 s. A small PHP renderer `live_status` table every ~15 s, and drops back to *off air* automatically 5 min
after the last logged QSO. A small PHP renderer
([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) on your ([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) on your
own web server reads that table and produces a live page/image you can embed on own web server reads that table and produces a live page/image you can embed on
the station's **QRZ.com** bio (`<img src="…/tm74-status.php?img=1">`). OpsLog the station's **QRZ.com** bio (`<img src="…/tm74-status.php?img=1">`). OpsLog
@@ -220,8 +241,11 @@ only writes to the DB — it is not a web server.
## Net control ## Net control
- **Directed-net logging** (Tools → Net): a global roster (`nets.json`) plus an - **Directed-net logging** (Tools → Net): a global roster (`nets.json`) plus an
in-memory active session — check stations in, then log the whole net at once in-memory active session — check stations in, then log them individually or the
using the CAT frequency. whole net at once (**Log everyone**) using the CAT frequency. **Drag & drop**
between the two lists (roster → on-air starts a QSO, on-air → roster logs it),
and after each log the next on-air station is selected automatically so you can
chain contacts.
## Appearance & language ## Appearance & language
+1215 -88
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
}
+190
View File
@@ -1,4 +1,194 @@
[ [
{
"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",
"en": [
"Fixed clicking a spot / band-map entry not moving the radio on Yaesu (and other non-Icom) rigs controlled through OmniRig — e.g. the FT-891. OpsLog tuned via OmniRig's SetSimplexMode, which on these rigs returns success but silently doesn't move the VFO (mode changes worked, so it looked like a puzzle). It now also writes the VFO frequency property directly, which does move them. Icom rigs are unchanged (there SetSimplexMode is the reliable path).",
"New CW keyer engine: 'Serial port (DTR=CW / RTS=PTT)'. OpsLog can now key CW by toggling a serial control line itself — the hardware-keying method N1MM/WSJT use with a Yaesu SCU-17 and generic keying interfaces — without a K1EL WinKeyer. Settings → CW Keyer → Keyer engine: pick 'Serial port', choose the keying COM port and whether CW is on DTR (PTT on RTS) or swapped. Speed, Farnsworth, lead-in/tail and PTT keying all apply; macros, auto-CQ and <LOGQSO> work exactly as with the WinKeyer. An 'Invert keying (active-LOW)' option is there for interfaces that key backwards / transmit at rest.",
"Fixed the spectrum scope never displaying on the IC-7300: its CI-V waveform frames actually carry the same leading main-scope selector byte as the dual-scope IC-7610/9700, but OpsLog assumed the 7300 omitted it — so it read the frame sequence number from the wrong byte (always 0), discarded every frame, and drew nothing. The frame layout is now detected from the data itself, so the 7300 (and other single-scope Icoms) render correctly, in both center and fixed modes. The IC-7610/9700 are unaffected.",
"IC-7300 scope: the center/fixed mode and edge-frequency commands now include the selector byte the rig requires, so switching the scope between center-on-VFO and fixed span from OpsLog is accepted instead of being rejected.",
"Closing OpsLog no longer switches off the scope display on the radio itself: turning the scope off used to send both 'stop CI-V data' and 'display off', so quitting blanked a local IC-7300's own scope screen. It now only stops the CI-V data stream on disable and never touches the radio's display (the display-on command is sent solely when enabling)."
],
"fr": [
"Correction : cliquer un spot / une entrée de band-map ne changeait pas la fréquence sur les Yaesu (et autres radios non-Icom) pilotées via OmniRig — p. ex. le FT-891. OpsLog accordait via SetSimplexMode d'OmniRig, qui sur ces radios renvoie « OK » mais ne bouge pas le VFO (le changement de mode marchait, d'où l'énigme). Il écrit maintenant aussi directement la propriété fréquence du VFO, ce qui les fait bien QSY. Les Icom sont inchangés (là, SetSimplexMode reste la bonne méthode).",
"Nouveau moteur de keyer CW : « Port série (DTR=CW / RTS=PTT) ». OpsLog peut désormais manipuler la CW en basculant lui-même une ligne de contrôle série — la méthode de keying matériel qu'utilisent N1MM/WSJT avec un Yaesu SCU-17 et les interfaces génériques — sans WinKeyer K1EL. Réglages → Keyer CW → Moteur : choisis « Port série », le port COM de keying et si la CW est sur DTR (PTT sur RTS) ou l'inverse. Vitesse, Farnsworth, lead-in/tail et PTT s'appliquent ; les macros, l'auto-CQ et <LOGQSO> fonctionnent exactement comme avec le WinKeyer. Une option « Inverser le keying (actif-BAS) » est prévue pour les interfaces qui manipulent à l'envers / émettent au repos.",
"Correction du scope spectral qui ne s'affichait jamais sur l'IC-7300 : ses trames de forme d'onde CI-V portent en réalité le même octet sélecteur de scope en tête que les IC-7610/9700 (dual-scope), mais OpsLog supposait que le 7300 ne l'envoyait pas — il lisait donc le numéro de séquence de la trame sur le mauvais octet (toujours 0), jetait toutes les trames et ne dessinait rien. La disposition des trames est maintenant détectée à partir des données elles-mêmes, donc le 7300 (et les autres Icom mono-scope) s'affichent correctement, en mode centre comme en fixe. Les IC-7610/9700 ne sont pas affectés.",
"Scope IC-7300 : les commandes de mode centre/fixe et de fréquences de bords incluent maintenant l'octet sélecteur requis par la radio, donc basculer le scope entre centre-sur-VFO et span fixe depuis OpsLog est accepté au lieu d'être rejeté.",
"Fermer OpsLog n'éteint plus le scope sur la radio elle-même : couper le scope envoyait à la fois « stop données CI-V » et « affichage OFF », donc quitter éteignait l'écran scope d'un IC-7300 local. Désormais seule la diffusion des données CI-V est coupée à la désactivation, l'affichage de la radio n'est jamais touché (la commande d'allumage n'est envoyée qu'à l'activation)."
]
},
{
"version": "0.20.10",
"date": "2026-07-23",
"en": [
"Clicking an OpsLog spot on the FlexRadio panadapter now switches the mode too, not just the frequency: cluster spots (which carry no mode) get one inferred from the comment or the band plan, and every spot's mode is sent to the radio as a real SmartSDR mode (SSB resolves to USB/LSB by frequency), so the click tunes AND sets the mode.",
"Station Control is now a clean dashboard of fixed-width panels that wrap to fill the window. Each panel has a grip handle on its left edge — drag it to move the panel anywhere, and the order is remembered. Relay buttons are compact. The amplifier panel is now the exact same card as the FlexRadio panel's (OPERATE/STANDBY, ON/OFF, power level, output-power bar and live FWD/drain-current/temperature meters), and if you run several amplifiers they all appear — one card each, no dropdown.",
"Station Control: the Ultrabeam element list now shows the right number of elements (3 for a 3-element beam) instead of extra bogus rows that came from over-reading the controller's reply.",
"Fixed the FlexRadio amplifier meters showing twice after the amp was power-cycled — the old (stale) meters are now cleared when the amplifier is torn down, so only the fresh set remains.",
"Fixed QSO audio recordings silently not being saved: the recording snapshot had moved onto the background (post-log) path, where it raced the entry-form clearing that cancels the recorder — so the audio was discarded before it was captured. The snapshot is now taken the instant you log, before the form clears; the file encoding still happens in the background.",
"Digital Voice Keyer: an Auto CQ (like the CW keyer's auto-call) repeats a CQ-labelled voice message on a timer until you stop it, play another slot or press ESC. The DVK now also refuses to transmit on non-phone modes (CW/FT8/RTTY…) — the buttons grey out and it won't key the rig with speech on a data mode.",
"The Digital Voice Keyer (DVK) panel now stays open across a relaunch if you had it open — it used to reset to closed every time.",
"FlexRadio meter scales now match SmartSDR: MIC (level) tops out at 0 dB and COMP (compression) tops out at -25 dB (was -20).",
"New Help → 'Send log to F4BPO' (shown only when you have an SMTP server configured in Settings → E-mail): e-mails OpsLog's diagnostic logs (current session, previous session and any crash log) straight to the developer, so a problem you hit in the field can be looked at without hunting for the log files by hand.",
"Bulk edit can now set the frequency: pick 'Frequency (MHz)', enter e.g. 7.155, and every selected QSO gets that frequency with its band recomputed to match. Handy for fixing a run that was logged on a stale/default frequency (e.g. 7.000) after CAT control dropped. Out-of-band values are rejected so a typo can't corrupt the batch.",
"Fixed the Cluster spot list appearing to 'freeze' at the UTC midnight rollover: the Time column was sorted as plain text, so a spot at 0001Z sorted below 2359Z and new spots after 0000Z dropped to the bottom of the list instead of the top — it looked like the cluster had stopped. The column now sorts by actual arrival time, which crosses midnight correctly.",
"Fixed the spectrum scope being blank on the IC-7300 (and other single-scope Icoms) when the scope was in center / VFO-following mode: the multi-frame waveform header read the second frequency field as an absolute high edge, but in center mode it is a span, so the display got an invalid range and drew nothing. It now interprets center+span vs low+high edges the same way the IC-7610 path already did. FIXED mode was unaffected."
],
"fr": [
"Cliquer sur un spot OpsLog du panadapter FlexRadio change maintenant aussi le mode, plus seulement la fréquence : les spots cluster (qui n'ont pas de mode) en reçoivent un déduit du commentaire ou du plan de bande, et le mode de chaque spot est envoyé à la radio comme un vrai mode SmartSDR (SSB devient USB/LSB selon la fréquence), donc le clic accorde ET règle le mode.",
"Station Control est maintenant un tableau de bord de panneaux à largeur fixe qui s'alignent pour remplir la fenêtre. Chaque panneau a une poignée sur son bord gauche — glisse-la pour déplacer le panneau où tu veux, et l'ordre est mémorisé. Les boutons relais sont compacts. Le panneau amplificateur est désormais exactement la même carte que celle du panneau FlexRadio (OPERATE/STANDBY, ON/OFF, niveau de puissance, barre de puissance de sortie et mesures en direct puissance directe/courant de drain/température), et si tu utilises plusieurs amplis ils apparaissent tous — une carte chacun, sans liste déroulante.",
"Station Control : la liste des éléments Ultrabeam affiche maintenant le bon nombre d'éléments (3 pour une beam 3 éléments) au lieu de lignes fantômes issues d'une sur-lecture de la réponse du contrôleur.",
"Correction des mesures de l'amplificateur FlexRadio affichées en double après un cycle d'alimentation de l'ampli — les anciennes mesures (périmées) sont maintenant effacées quand l'amplificateur est retiré, seul le jeu à jour reste.",
"Correction des enregistrements audio des QSO qui n'étaient plus sauvegardés : la capture de l'enregistrement était passée sur le chemin d'arrière-plan (après le log), où elle entrait en concurrence avec le vidage du formulaire qui annule l'enregistreur — l'audio était donc jeté avant d'être capturé. La capture se fait maintenant à l'instant du log, avant le vidage du formulaire ; l'encodage du fichier reste en arrière-plan.",
"Manipulateur vocal (DVK) : un Auto CQ (comme l'auto-call du keyer CW) répète un message vocal libellé CQ à intervalle régulier jusqu'à l'arrêt, la lecture d'un autre slot ou ÉCHAP. Le DVK refuse aussi désormais d'émettre hors phonie (CW/FT8/RTTY…) — les boutons sont grisés et il ne manipule plus la radio avec de la voix sur un mode data.",
"Le panneau Digital Voice Keyer (DVK) reste maintenant ouvert après un relancement si vous l'aviez ouvert — il se refermait à chaque démarrage.",
"Les échelles des meters FlexRadio collent maintenant à SmartSDR : MIC (niveau) plafonne à 0 dB et COMP (compression) à -25 dB (au lieu de -20).",
"Nouveau Aide → « Envoyer le journal à F4BPO » (affiché seulement si un serveur SMTP est configuré dans Réglages → E-mail) : envoie par e-mail les journaux de diagnostic d'OpsLog (session en cours, session précédente et éventuel journal de crash) directement au développeur, pour qu'un problème rencontré sur le terrain puisse être examiné sans avoir à chercher les fichiers de journal à la main.",
"L'édition groupée permet maintenant de définir la fréquence : choisis « Fréquence (MHz) », saisis p. ex. 7.155, et chaque QSO sélectionné reçoit cette fréquence avec sa bande recalculée en conséquence. Pratique pour corriger une série loguée sur une fréquence par défaut/figée (p. ex. 7.000) après une perte du CAT. Les valeurs hors bande sont refusées pour qu'une faute de frappe ne corrompe pas le lot.",
"Correction de la liste des spots Cluster qui semblait « se figer » au passage de minuit UTC : la colonne Heure était triée comme du texte, donc un spot à 0001Z se retrouvait sous 2359Z et les nouveaux spots après 0000Z tombaient en bas de la liste au lieu du haut — on aurait dit que le cluster s'était arrêté. La colonne trie désormais selon l'heure d'arrivée réelle, qui franchit minuit correctement.",
"Correction du scope spectral vide sur l'IC-7300 (et autres Icom mono-scope) quand le scope était en mode centre / suivi du VFO : l'en-tête de forme d'onde multi-trames lisait le second champ de fréquence comme un bord haut absolu, alors qu'en mode centre c'est un span — l'affichage recevait donc une plage invalide et ne dessinait rien. Il interprète maintenant centre+span vs bords bas+haut comme le faisait déjà le chemin de l'IC-7610. Le mode FIXED n'était pas affecté."
]
},
{
"version": "0.20.9",
"date": "2026-07-22",
"en": [
"The PGXL amplifier chip and Station Control widget now read the OPERATE state from the FlexRadio (like the Flex panel) — the chip no longer shows STANDBY while the amp is operating, and clicking it toggles the amp through the radio.",
"Right-click 'Send to LoTW / QRZ / Club Log…' now reports its result as a notification — a failed upload (e.g. TQSL not configured) was completely silent outside the QSL Manager, which looked like nothing was sent.",
"The number of selected QSOs is now always visible at the top of the log grid — no need to open the right-click menu to see it.",
"NET Control: the worked-before list gets the same right-click menu as Recent QSOs (update from cty/QRZ/Club Log, send to services, recording e-mail, eQSL, delete).",
"Leaving compact mode restores the window's previous size AND position (it used to snap to a fixed size wherever the compact strip had been dragged).",
"The advanced filter's 'My callsign' field is renamed 'Station callsign' so it is findable under its ADIF name (it filters STATION_CALLSIGN).",
"Filtering now shows EVERY match: the on-screen row limit (Max) only applies to the unfiltered log — a filter matching 200 QSOs displays all 200 even with Max at 100 (safety cap 10,000, with a warning to narrow the filter beyond that).",
"New 'Select all' button in the log grid toolbar — selects every displayed row (respecting active column filters) in one click, ready for send-to-LoTW, bulk edit or export; once everything is selected it flips to 'Unselect all'.",
"NET Control: drag & drop between the two lists — drag a roster station onto the on-air list to start its QSO, and drag an on-air station onto the roster to log it (same as the Log & end button).",
"New option (Settings → General): 'Group digital modes as one (DXCC-style)' — when on, the matrix newness badges and the cluster new/new-band/new-mode/new-slot colouring treat FT8/FT4/RTTY/PSK… as a single Digital mode, matching how DXCC counts; when off (default), each digital mode remains its own potential slot.",
"Multiple amplifiers: Settings → Amplifier is now a LIST — configure several amps (e.g. two SPEs run in parallel), each with its own name and connection. The amp cards in the Flex panel and Station Control get a dropdown to pick which amp they show, and the bottom status bar shows one clickable chip per amp. Existing single-amp setups migrate automatically.",
"FlexRadio panel: the SmartSDR v4 DSP filters are now controllable — NRL and ANFL (legacy LMS), NRS (spectral subtraction), NRF (noise reduction with filter) with their level sliders, plus RNN (AI noise reduction) and ANFT (FFT auto-notch) toggles. The section appears automatically on radios that support them (8000/Aurora series). Layout rebalanced: RIT/XIT moved to the Transmit column and the antenna selection now sits at the top of the Receive column, alongside a new DAX button (SmartSDR's transmit-bar DAX toggle — TX audio from DAX, for WSJT-X & co)."
],
"fr": [
"La pastille ampli PGXL et le widget Station Control lisent maintenant l'état OPERATE depuis le FlexRadio (comme le panneau Flex) — la pastille n'affiche plus STANDBY quand l'ampli est en service, et un clic bascule l'ampli via la radio.",
"Le clic droit « Send to LoTW / QRZ / Club Log… » affiche maintenant son résultat en notification — un envoi échoué (p. ex. TQSL non configuré) était totalement silencieux hors du QSL Manager, comme si rien n'était parti.",
"Le nombre de QSO sélectionnés est maintenant toujours visible en haut de la grille du log — plus besoin d'ouvrir le menu clic droit pour le voir.",
"NET Control : la liste worked-before dispose du même menu clic droit que QSO récents (mise à jour cty/QRZ/Club Log, envoi aux services, e-mail d'enregistrement, eQSL, suppression).",
"Quitter le mode compact restaure la taille ET la position précédentes de la fenêtre (avant, elle reprenait une taille fixe là où la barre compacte avait été déplacée).",
"Le champ « Mon indicatif » du filtre avancé est renommé « Station callsign » pour être trouvable sous son nom ADIF (il filtre STATION_CALLSIGN).",
"Le filtrage affiche maintenant TOUTES les correspondances : la limite d'affichage (Max) ne s'applique qu'au log non filtré — un filtre qui matche 200 QSO les affiche tous les 200 même avec Max à 100 (plafond de sécurité 10 000, avec un avertissement pour affiner au-delà).",
"Nouveau bouton « Tout sélectionner » dans la barre d'outils de la grille — sélectionne toutes les lignes affichées (en respectant les filtres de colonnes actifs) en un clic, prêt pour l'envoi LoTW, le bulk edit ou l'export ; une fois tout sélectionné il devient « Tout désélectionner ».",
"NET Control : glisser-déposer entre les deux listes — glisser une station du roster vers la liste on air démarre son QSO, et glisser une station on air vers le roster l'enregistre au log (comme le bouton Logger & terminer).",
"Nouvelle option (Réglages → Général) : « Regrouper les modes digitaux en un seul (style DXCC) » — activée, les badges de nouveauté de la matrice et le coloriage cluster new/new-band/new-mode/new-slot traitent FT8/FT4/RTTY/PSK… comme un seul mode Digital, comme le DXCC ; désactivée (défaut), chaque mode digital reste un slot potentiel distinct.",
"Plusieurs amplificateurs : Réglages → Amplificateur est maintenant une LISTE — configurez plusieurs amplis (p. ex. deux SPE en parallèle), chacun avec son nom et sa connexion. Les cartes ampli du panneau Flex et de Station Control ont une liste déroulante pour choisir lequel afficher, et la barre du bas montre une pastille cliquable par ampli. Les configurations mono-ampli existantes migrent automatiquement.",
"Panneau FlexRadio : les filtres DSP SmartSDR v4 sont maintenant pilotables — NRL et ANFL (LMS legacy), NRS (soustraction spectrale), NRF (réduction de bruit avec filtre) avec leurs curseurs de niveau, plus les interrupteurs RNN (réduction de bruit par IA) et ANFT (notch automatique FFT). La section apparaît automatiquement sur les radios qui les supportent (séries 8000/Aurora). Mise en page rééquilibrée : RIT/XIT passent dans la colonne Transmit et la sélection d'antennes s'affiche en haut de la colonne Receive, avec un nouveau bouton DAX (le toggle DAX du bandeau transmit de SmartSDR — audio d'émission via DAX, pour WSJT-X & co)."
]
},
{
"version": "0.20.8",
"date": "2026-07-21",
"en": [
"New: microHAM ARCO rotator controlled natively — no PstRotator needed. Over the LAN or over USB: set the ARCO's CONTROL PROTOCOL (LAN or USB) to 'Yaesu GS-232A' and pick 'microHAM ARCO' in Settings → Rotator.",
"Amplifier without a FlexRadio: the amplifier controls (OPERATE/STANDBY, ON/OFF, live power/SWR/temperature) now also live in the Station Control tab, and a status chip sits in the bottom bar next to Cluster/CAT/Rotator — green ON AIR = OPERATE, orange = STANDBY, red = offline. Clicking the chip toggles OPERATE/STANDBY on all three amps (PowerGenius XL included, over its direct network link).",
"NET Control: after logging a station, the next on-air station is selected automatically (chain log → log → log), and a new 'Log everyone' button logs every on-air station at once when the net ends.",
"Switching to a profile whose MySQL database is unreachable now shows a clear warning — before, OpsLog silently stayed on the previous logbook and QSOs could land in the wrong log.",
"The ON AIR badge is back for local (SQLite) logbooks — it had disappeared with the removal of the live-status option."
],
"fr": [
"Nouveau : rotator microHAM ARCO contrôlé en natif — sans PstRotator. Par le réseau ou par USB : régler le CONTROL PROTOCOL de l'ARCO (LAN ou USB) sur « Yaesu GS-232A » et choisir « microHAM ARCO » dans Réglages → Rotator.",
"Amplificateur sans FlexRadio : les commandes d'ampli (OPERATE/STANDBY, ON/OFF, puissance/ROS/température en direct) sont maintenant aussi dans l'onglet Station Control, et une pastille de statut s'affiche dans la barre du bas à côté de Cluster/CAT/Rotator — vert = OPERATE, orange = STANDBY, rouge = hors ligne. Un clic sur la pastille bascule OPERATE/STANDBY sur les trois amplis (PowerGenius XL compris, via son lien réseau direct).",
"NET Control : après avoir loggé une station, la station on air suivante est sélectionnée automatiquement (enchaînement log → log → log), et un nouveau bouton « Logger tout le monde » enregistre toutes les stations on air d'un coup à la fin du net.",
"Passer sur un profil dont la base MySQL est injoignable affiche maintenant un avertissement clair — avant, OpsLog restait silencieusement sur l'ancien logbook et les QSO pouvaient partir dans le mauvais log.",
"Le badge ON AIR est de retour pour les logbooks locaux (SQLite) — il avait disparu avec la suppression de l'option de statut en direct."
]
},
{ {
"version": "0.20.7", "version": "0.20.7",
"date": "2026-07-21", "date": "2026-07-21",
+660 -55
View File
File diff suppressed because it is too large Load Diff
+230
View File
@@ -0,0 +1,230 @@
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,
// so Station Control (and anywhere else that needs it) shows the SAME card instead
// of a stripped-down variant. It handles all three amp families:
// • SPE Expert / ACOM — driven by OpsLog's own serial/TCP link (amp.spe/amp.acom)
// • PowerGenius XL — OPERATE + meters come from the Flex (which reports the
// amp), fan mode from the direct GSCP link (amp.pgxl)
// Controls use the multi-amp API (AmpOperate/AmpPower/… by amp id) so several amps
// can each get their own card.
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>
);
}
// speMaxW / powerLevelLabel — same helpers the Flex panel uses.
function speMaxW(model?: string): number {
const m = (model || '').toUpperCase();
if (m.includes('20K') || m.includes('2K')) return 2000;
if (m.includes('15K')) return 1500;
return 1300;
}
function powerLevelLabel(pl?: string): string {
switch ((pl || '').trim().toUpperCase()) {
case 'L': return 'Low';
case 'M': return 'Mid';
case 'H': return 'High';
default: return pl || '';
}
}
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) {
// Peak-hold so the jittery VITA-49 meters read steadily (own ref per card).
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 isSPE = !!amp.spe;
const isACOM = !!amp.acom;
if (isSPE) {
const spe = amp.spe;
return (
<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(() => {})}
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{spe.operate ? 'OPERATE' : 'STANDBY'}
</button>
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
<button type="button" disabled={!spe.connected}
onClick={() => AmpPower(amp.id, true).catch(() => {})}
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
<button type="button" disabled={!spe.connected}
onClick={() => AmpPower(amp.id, false).catch(() => {})}
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
</div>
<div className="inline-flex rounded-lg overflow-hidden border border-border">
{(['L', 'M', 'H'] as const).map((lvl, i) => {
const active = (spe.power_level || '').trim().toUpperCase() === lvl;
return (
<button key={lvl} type="button" disabled={!spe.connected}
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
className={cn('px-3 py-2 text-sm font-bold disabled:opacity-30', i > 0 && 'border-l border-border',
active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{powerLevelLabel(lvl)}
</button>
);
})}
</div>
<span className={cn('inline-flex items-center gap-1.5 text-sm', spe.connected ? 'text-muted-foreground' : 'text-danger')}>
<span className={cn('size-2 rounded-full', spe.connected ? 'bg-success' : 'bg-danger')} />
{spe.connected ? (spe.tx ? 'TX' : 'RX') : t('flxp.speOffline')}
</span>
{spe.connected && (
<span className="text-sm font-mono text-muted-foreground tabular-nums">
{spe.band ? `${spe.band} · ` : ''}{spe.output_w}W · SWR {Number(spe.swr_ant ?? 0).toFixed(1)} · {spe.temp_c}°C · {powerLevelLabel(spe.power_level)}
</span>
)}
<div className="flex-1" />
{(spe.warnings || spe.alarms) && (
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold"> {spe.warnings} {spe.alarms}</span>
)}
</div>
{spe.connected && (
<MeterBar label={t('flxp.outputPower')} value={Number(spe.output_w) || 0} unit="W"
lo={0} hi={speMaxW(spe.model)}
display={`${Number(spe.output_w) || 0} W`}
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
)}
</Card>
);
}
if (isACOM) {
const acom = amp.acom;
return (
<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(() => {})}
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
acom.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')}>
{acom.operate ? 'OPERATE' : 'STANDBY'}
</button>
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
<button type="button" disabled={!(acom.port_open && acom.transport === 'serial')}
onClick={() => AmpPower(amp.id, true).catch(() => {})}
title={acom.transport === 'serial' ? 'Power on (DTR/RTS pulse — needs the power-on pins wired)' : 'Power-on needs the serial DTR/RTS lines — not available over a network bridge'}
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
<button type="button" disabled={!acom.connected}
onClick={() => AmpPower(amp.id, false).catch(() => {})}
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
</div>
<span className={cn('inline-flex items-center gap-1.5 text-sm', acom.connected ? 'text-muted-foreground' : 'text-danger')}>
<span className={cn('size-2 rounded-full', acom.connected ? 'bg-success' : 'bg-danger')} />
{acom.connected ? acom.state : t('flxp.acomOffline')}
</span>
{acom.connected && (
<span className="text-sm font-mono text-muted-foreground tabular-nums">
{acom.band ? `${acom.band} · ` : ''}{acom.fwd_w}W · SWR {Number(acom.swr ?? 0).toFixed(1)} · {acom.temp_c}°C · Fan {acom.fan}
</span>
)}
<div className="flex-1" />
{acom.err_text && (
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold"> {acom.err_text}</span>
)}
</div>
{acom.connected && (
<MeterBar label={t('flxp.outputPower')} value={Number(acom.fwd_w) || 0} unit="W"
lo={0} hi={Number(acom.max_w) || 800}
display={`${Number(acom.fwd_w) || 0} W`}
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
)}
</Card>
);
}
// PowerGenius XL — OPERATE + meters ride on the Flex; fan mode on the GSCP link.
const pg = amp.pgxl || {};
const viaFlex = !!flex?.amp_available;
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
const connected = !!pg.connected || viaFlex;
const fault = flex?.amp_fault;
return (
<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(() => {})}
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{operate ? 'OPERATE' : 'STANDBY'}
</button>
<span className="text-xs text-muted-foreground">
{operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
</span>
{(pg.host || pg.connected) && (
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
<span className="text-muted-foreground">{t('flxp.fan')}</span>
<select
disabled={!pg.connected}
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
onChange={(e) => AmpFanMode(amp.id, e.target.value).catch(() => {})}
className="h-8 rounded-md border border-warning-border bg-card px-2 text-xs font-semibold text-warning outline-none focus:border-warning disabled:opacity-40"
>
<option value="STANDARD">{t('flxp.fanStandard')}</option>
<option value="CONTEST">{t('flxp.fanContest')}</option>
<option value="BROADCAST">{t('flxp.fanBroadcast')}</option>
</select>
</label>
)}
<div className="flex-1" />
{fault && fault !== 'NONE' && (
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {fault}</span>
)}
</div>
{/* Amplifier meters (FWD / ID / TEMP …) from the FlexRadio UDP stream. */}
{viaFlex && (() => {
const meters = (flex?.meters as any[]) || [];
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
if (amps.length === 0) return null;
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
{amps.map((m) => {
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
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;
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
lo = 0;
hi = m.hi >= 25 ? m.hi : 25;
}
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>
);
})()}
</Card>
);
}
+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> <span className="font-mono truncate text-[11px]">{selectedRef?.subgrp || '—'}</span>
</div> </div>
{/* Selected ref chip */} {/* Selected ref chip (nothing shown until one is picked) */}
{selectedRef ? ( {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"> <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="font-mono font-semibold shrink-0">{selectedRef.code}</span>
<span className="truncate text-[10px] text-success-muted-foreground">{selectedRef.name}</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" /> <X className="size-3" />
</button> </button>
</div> </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 */} {/* 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. // globally from the band-map tab toolbar.
hideDigital?: boolean; hideDigital?: boolean;
fitToBand?: 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]> = { 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). // last; ties broken by closeness to the rig freq).
const MAX_VISIBLE_SPOTS = 30; 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 { t } = useI18n();
const range = BAND_RANGES[band]; const range = BAND_RANGES[band];
const segments = SEGMENT_COLORS[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); scrollerRef.current.scrollTop = Math.max(0, y - containerH / 2);
} }
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
// Only active on the docked Main-view map (keyNav) and ignored while typing.
useEffect(() => {
if (!keyNav) return;
const onKey = (e: KeyboardEvent) => {
if (!e.ctrlKey || e.altKey || e.metaKey) return;
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
const ae = document.activeElement as HTMLElement | null;
const tag = (ae?.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select' || ae?.isContentEditable) return;
const list = spots
.filter((s) => (s.band ?? '') === band && s.freq_hz > 0)
.slice()
.sort((a, b) => a.freq_hz - b.freq_hz);
if (!list.length) return;
const cur = currentFreqHz || (lo + hi) * 500; // mid-band kHz→Hz when no rig freq
const EPS = 50; // Hz, so we don't re-pick the spot we're already sitting on
let target: Spot | undefined;
if (e.key === 'ArrowUp') {
target = list.find((s) => s.freq_hz > cur + EPS);
} else {
for (let i = list.length - 1; i >= 0; i--) { if (list[i].freq_hz < cur - EPS) { target = list[i]; break; } }
}
if (!target) return;
e.preventDefault();
onSpotClick(target);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [keyNav, spots, band, currentFreqHz, lo, hi, onSpotClick]);
const currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0; const currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0;
const showRigPointer = currentKHz >= lo && currentKHz <= hi; const showRigPointer = currentKHz >= lo && currentKHz <= hi;
const rigY = freqToY(currentKHz); const rigY = freqToY(currentKHz);
+30 -6
View File
@@ -94,6 +94,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
const dxcc = wb?.dxcc ?? 0; const dxcc = wb?.dxcc ?? 0;
const dxccName = wb?.dxcc_name ?? ''; const dxccName = wb?.dxcc_name ?? '';
const dxccCount = wb?.dxcc_count ?? 0; 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 callCount = wb?.count ?? 0; // QSOs with this exact callsign
const hasDxcc = dxcc > 0; const hasDxcc = dxcc > 0;
const newOne = hasDxcc && dxccCount === 0; const newOne = hasDxcc && dxccCount === 0;
@@ -109,14 +110,21 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
// "Newness" of the current band+mode entry, for the award/DX-chase badges. // "Newness" of the current band+mode entry, for the award/DX-chase badges.
// Derived straight from the entity's real band_status (all bands it was // Derived straight from the entity's real band_status (all bands it was
// worked on — not just the operator's configured column list). // worked on — not just the operator's configured column list).
// Newness uses the ACTUAL mode (FT8 / FT4 / RTTY…), not the PH/CW/DIG class: // By default newness uses the ACTUAL mode (FT8 / FT4 / RTTY…): DIG is a
// DIG is a group, so FT4 after FT8 is genuinely a new mode. dxcc_band_modes // group, so FT4 after FT8 is genuinely a new mode. The operator can opt into
// lists every real (band, mode) the entity was worked on. // DXCC-style grouping instead (Settings → General), where all digital modes
// count as ONE — then FT4 after FT8 is just "worked".
const groupDigital = localStorage.getItem('opslog.groupDigitalSlots') === '1';
const normMode = (m: string): string => {
const u = (m || '').toUpperCase().trim();
if (!groupDigital) return u;
return u === '' || u === 'CW' || PHONE_MODES.has(u) ? u : 'DIG';
};
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[]; const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
const curMode = (currentMode || '').toUpperCase().trim(); const curMode = normMode(currentMode);
const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode) const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode)
const modeWorked = !!curMode && bandModes.some((bm) => (bm.mode || '').toUpperCase() === curMode); // …in this exact mode (any band) const modeWorked = !!curMode && bandModes.some((bm) => normMode(bm.mode) === curMode); // …in this (normalised) mode (any band)
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && (bm.mode || '').toUpperCase() === curMode); const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && normMode(bm.mode) === curMode);
// Mutually-exclusive badges, shown only when the entity is worked but this // Mutually-exclusive badges, shown only when the entity is worked but this
// exact band+mode is NOT yet: // exact band+mode is NOT yet:
// New Band & Mode = both the band AND the mode are new for this entity. // New Band & Mode = both the band AND the mode are new for this entity.
@@ -129,6 +137,20 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
const newMode = slotNew && bandWorked && !modeWorked; const newMode = slotNew && bandWorked && !modeWorked;
const newSlot = 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 ( return (
<section <section
className={cn( className={cn(
@@ -147,12 +169,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
<strong className="text-warning-muted-foreground font-semibold">{dxccName || `DXCC #${dxcc}`}</strong> <strong className="text-warning-muted-foreground font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
{' '}· never worked this entity {' '}· never worked this entity
</span> </span>
{mwBadge}
</> </>
) : hasDxcc ? ( ) : hasDxcc ? (
<> <>
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal"> <Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
{dxccName || `DXCC #${dxcc}`} {dxccName || `DXCC #${dxcc}`}
</Badge> </Badge>
{mwBadge}
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
<strong className="text-foreground font-semibold">{dxccCount}</strong>{' '} <strong className="text-foreground font-semibold">{dxccCount}</strong>{' '}
QSO{dxccCount > 1 ? 's' : ''} with this entity QSO{dxccCount > 1 ? 's' : ''} with this entity
+7 -3
View File
@@ -12,7 +12,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
type FieldKind = 'status' | 'text'; type FieldKind = 'status' | 'text' | 'freq';
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean }; type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input. // Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
@@ -74,6 +74,9 @@ const FIELDS: FieldDef[] = [
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' }, { id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' }, { id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
// Misc // Misc
// Frequency (MHz) — sets freq_hz AND recomputes band. Main use: fixing a batch
// logged on a stale/default frequency after CAT dropped.
{ id: 'freq', label: 'bulk.fFreq', group: 'Misc', kind: 'freq' },
{ id: 'comment', label: 'bulk.fComment', group: 'Misc', kind: 'text' }, { id: 'comment', label: 'bulk.fComment', group: 'Misc', kind: 'text' },
{ id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' }, { id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' },
{ id: 'rig', label: 'bulk.fRig', group: 'Misc', kind: 'text' }, { id: 'rig', label: 'bulk.fRig', group: 'Misc', kind: 'text' },
@@ -180,7 +183,8 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
<Input <Input
className="h-8 text-xs" className="h-8 text-xs"
value={textValue} value={textValue}
placeholder={t('bulk.clearPlaceholder')} inputMode={def.kind === 'freq' ? 'decimal' : undefined}
placeholder={def.kind === 'freq' ? t('bulk.freqPlaceholder') : t('bulk.clearPlaceholder')}
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)} onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
/> />
)} )}
@@ -188,7 +192,7 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
<div className="text-[11px] text-muted-foreground"> <div className="text-[11px] text-muted-foreground">
{t('bulk.willSet')} <span className="font-semibold">{t(def.label)}</span> ={' '} {t('bulk.willSet')} <span className="font-semibold">{t(def.label)}</span> ={' '}
<span className="font-mono">{effectiveValue === '' ? t('bulk.blank') : effectiveValue}</span> {t('bulk.onQsos', { n: ids.length })} <span className="font-mono">{effectiveValue === '' ? t('bulk.blank') : effectiveValue}{def.kind === 'freq' && effectiveValue !== '' ? ' MHz' : ''}</span> {t('bulk.onQsos', { n: ids.length })}
</div> </div>
{error && <div className="text-xs text-danger">{error}</div>} {error && <div className="text-xs text-danger">{error}</div>}
</div> </div>
+10
View File
@@ -149,6 +149,16 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono', headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
defaultVisible: true, defaultVisible: true,
sort: 'desc', sort: 'desc',
// Sort by the real arrival timestamp, NOT the "HHMMZ" display string. A
// lexical sort of time_utc breaks at the UTC day rollover: "0001Z" sorts
// below "2359Z", so spots received just after midnight fell to the bottom
// and looked like the cluster had stopped. received_at is a full datetime
// that keeps ordering correct across 0000Z.
comparator: (_a: any, _b: any, nodeA: any, nodeB: any) => {
const ta = Date.parse(nodeA?.data?.received_at ?? '') || 0;
const tb = Date.parse(nodeB?.data?.received_at ?? '') || 0;
return ta - tb;
},
cellStyle: { color: 'var(--muted-foreground)' }, cellStyle: { color: 'var(--muted-foreground)' },
}, },
{ {
+3 -3
View File
@@ -306,11 +306,11 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
heightClass="flex-1 min-h-0" heightClass="flex-1 min-h-0"
/> />
{detected.length > 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>{' '} <span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
{detected.map((r) => ( {detected.map((r) => (
<span key={`${r.code}@${r.ref}`} className="inline-block mr-2 font-mono"> <span key={`${r.code}@${r.ref}`} className="inline-block mr-1.5 font-mono whitespace-nowrap" title={r.name ?? ''}>
{r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? <span className="text-muted-foreground/70"> {r.name}</span> : null} <span className="text-foreground/80">{r.code}{r.ref ? `@${r.ref}` : ''}</span>
</span> </span>
))} ))}
</div> </div>
+41 -10
View File
@@ -1,4 +1,4 @@
import { Mic, Square, X, Radio } from 'lucide-react'; import { Mic, Square, X, Radio, Repeat } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
@@ -12,14 +12,20 @@ type Props = {
onPlay: (slot: number) => void; onPlay: (slot: number) => void;
onStop: () => void; onStop: () => void;
onClose: () => void; onClose: () => void;
autoCq: boolean;
autoCqSecs: number;
onToggleAutoCq: (on: boolean) => void;
onSetAutoCqSecs: (n: number) => void;
phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked
}; };
// Operating panel for the Digital Voice Keyer — transmits the recorded F1F6 // Operating panel for the Digital Voice Keyer — transmits the recorded F1F6
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in // voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
// the reserved area. Recording/labeling lives in Settings → Audio. // the reserved area. Recording/labeling lives in Settings → Audio.
export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) { export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const anyAudio = messages.some((m) => m.has_audio); const recorded = messages.filter((m) => m.has_audio);
const anyAudio = recorded.length > 0;
return ( return (
<div className="h-full flex flex-col rounded-lg border border-border bg-card shadow-sm overflow-hidden"> <div className="h-full flex flex-col rounded-lg border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0"> <div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0">
@@ -43,29 +49,54 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
{t('dvkp.noMsgPre')} <strong>{t('dvkp.settingsPath')}</strong> {t('dvkp.noMsgPost')} {t('dvkp.noMsgPre')} <strong>{t('dvkp.settingsPath')}</strong> {t('dvkp.noMsgPost')}
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-1"> // Only the recorded slots are shown. Two columns filling top-to-bottom
{messages.map((m) => ( // (rows sized to the count) — use the width instead of scrolling, so the
// panel keeps the same height as the other reserved-area widgets.
<div className="grid grid-flow-col gap-1"
style={{ gridTemplateRows: `repeat(${Math.max(1, Math.ceil(recorded.length / 2))}, minmax(0, auto))` }}>
{recorded.map((m) => (
<button <button
key={m.slot} key={m.slot}
type="button" type="button"
disabled={!m.has_audio} disabled={!phoneOk}
onClick={() => onPlay(m.slot)} onClick={() => onPlay(m.slot)}
title={m.has_audio ? t('dvkp.transmit', { slot: m.slot, label: m.label ? ' — ' + m.label : '', dur: m.duration_sec.toFixed(1) }) : t('dvkp.empty', { slot: m.slot })} title={!phoneOk ? t('dvkp.notPhone') : t('dvkp.transmit', { slot: m.slot, label: m.label ? ' — ' + m.label : '', dur: m.duration_sec.toFixed(1) })}
className={cn( className={cn(
'flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors', 'flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors',
m.has_audio phoneOk
? 'border-border bg-background hover:border-primary/60 hover:bg-accent/30 cursor-pointer' ? 'border-border bg-background hover:border-primary/60 hover:bg-accent/30 cursor-pointer'
: 'border-dashed border-border/60 text-muted-foreground/50 cursor-not-allowed', : 'border-dashed border-border/60 text-muted-foreground/50 cursor-not-allowed',
)} )}
> >
<span className="font-mono text-[11px] font-bold text-primary shrink-0">F{m.slot}</span> <span className="font-mono text-[11px] font-bold text-primary shrink-0">F{m.slot}</span>
<span className="text-xs truncate flex-1">{m.label || (m.has_audio ? t('dvkp.message') : '—')}</span> <span className="text-xs truncate flex-1">{m.label || t('dvkp.message')}</span>
{m.has_audio && <span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>} {(m.label || '').toUpperCase().includes('CQ') && autoCq && <Repeat className="size-3 text-primary shrink-0" />}
<span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>
</button> </button>
))} ))}
</div> </div>
)} )}
</div> </div>
{/* Auto-CQ footer — repeats a CQ-labelled slot on a timer. Greyed out on a
non-phone mode (the DVK won't transmit there). */}
<div className="shrink-0 border-t border-border bg-muted/30 px-2 py-1.5 flex items-center gap-2">
<label className={cn('flex items-center gap-1.5 text-[11px] cursor-pointer', !phoneOk && 'opacity-50')} title={phoneOk ? t('dvkp.autoCqHint') : t('dvkp.notPhone')}>
<input type="checkbox" className="accent-primary" checked={autoCq} disabled={!phoneOk}
onChange={(e) => onToggleAutoCq(e.target.checked)} />
<Repeat className="size-3" /> {t('dvkp.autoCq')}
</label>
<div className="flex-1" />
<span className="text-[10px] text-muted-foreground">{t('dvkp.gap')}</span>
<input type="number" min={0} max={120} value={autoCqSecs}
onChange={(e) => onSetAutoCqSecs(parseInt(e.target.value, 10))}
className="w-12 h-6 rounded border border-input bg-background px-1 text-[11px] font-mono text-center" />
<span className="text-[10px] text-muted-foreground">s</span>
</div>
{!phoneOk && (
<div className="shrink-0 bg-warning-muted text-warning-muted-foreground text-[10px] px-2 py-1 text-center">{t('dvkp.notPhone')}</div>
)}
</div> </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>
);
}
+197 -109
View File
@@ -1,14 +1,17 @@
import { useEffect, useRef, useState } from 'react'; 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 { import {
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay, GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic, FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexAmpOperate, FlexMox, FlexAmpOperate,
GetPGXLStatus, PGXLSetFanMode, GetPGXLSettings, GetSPEStatus, SPESetOperate, SPESetPower, SPESetPowerLevel, GetPGXLStatus, PGXLSetFanMode,
GetACOMStatus, ACOMSetOperate, ACOMSetPower, GetTunerGeniusStatus, GetTunerGeniusSettings,
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice, FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq, FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel, FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
FlexSetLMSNR, FlexSetLMSNRLevel, FlexSetLMSANF, FlexSetLMSANFLevel,
FlexSetSpeexNR, FlexSetSpeexNRLevel, FlexSetRNN, FlexSetANFT, FlexSetNRF, FlexSetNRFLevel, FlexSetTXDAX,
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile, FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay, FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter, FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
@@ -17,6 +20,9 @@ import { EventsOn } from '../../wailsjs/runtime/runtime';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { sMeterRST } from '@/lib/rst'; import { sMeterRST } from '@/lib/rst';
import { TunerCard } from '@/components/TunerCard';
import { MeterBar } from '@/components/MeterBar';
import type { TGStatus } from '@/components/TunerGeniusPanel';
type FlexState = { type FlexState = {
available: boolean; model?: string; available: boolean; model?: string;
@@ -31,6 +37,11 @@ type FlexState = {
rit: boolean; rit_freq: number; xit: boolean; xit_freq: number; rit: boolean; rit_freq: number; xit: boolean; xit_freq: number;
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number; nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
wnb: boolean; wnb_level: number; wnb: boolean; wnb_level: number;
// SmartSDR v4 DSP (8000/Aurora) — dsp_v4 is true once the radio reports them.
lms_nr?: boolean; lms_nr_level?: number; lms_anf?: boolean; lms_anf_level?: number;
speex_nr?: boolean; speex_nr_level?: number; rnn?: boolean; anft?: boolean;
nrf?: boolean; nrf_level?: number; dsp_v4?: boolean;
dax_ch?: number; tx_dax?: boolean;
tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[]; tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[];
mode?: string; mode?: string;
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number; cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
@@ -212,54 +223,29 @@ function OffsetRow({ label, on, onToggle, hz, onHz, disabled, title }: {
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi. // MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
// `display` overrides the numeric readout; `segColor` colours segments by their // `display` overrides the numeric readout; `segColor` colours segments by their
// 0..1 position (zones); the top ~18% light red by default (overload/peak). // 0..1 position (zones); the top ~18% light red by default (overload/peak).
const METER_SEGMENTS = 26; function Card({ icon: Icon, title, accent, children, ckey, open: openProp, onToggle }: {
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: { icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string;
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string; open?: boolean; onToggle?: () => void; // controlled mode — lets sibling cards share one collapse state
segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
}) { }) {
const span = hi - lo; // Collapsible: a chevron in the header hides the body. Uncontrolled by default,
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0; // persisting per card (keyed by ckey, falling back to the title); when `open`/
const lit = Math.round((pct / 100) * METER_SEGMENTS); // `onToggle` are supplied the parent owns the state (e.g. linked TX/RX cards).
return ( const storeKey = 'opslog.cardOpen.' + (ckey || title);
<div onClick={onClick} title={title} const [openState, setOpenState] = useState(() => localStorage.getItem(storeKey) !== '0');
className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0', const controlled = openProp !== undefined;
compact ? 'px-2 py-1' : 'px-2.5 py-2', const open = controlled ? openProp : openState;
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}> const toggle = controlled
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}> ? (onToggle ?? (() => {}))
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span> : () => setOpenState((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
<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 }) {
return ( return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden"> <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)' }} /> <Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span> <span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
</div> <ChevronDown className={cn('ml-auto size-4 text-muted-foreground transition-transform', !open && '-rotate-90')} />
<div className="p-3 space-y-3">{children}</div> </button>
{open && <div className="p-3 space-y-3">{children}</div>}
</div> </div>
); );
} }
@@ -288,6 +274,11 @@ function powerLevelLabel(pl?: string): string {
export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) { export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) {
const { t } = useI18n(); const { t } = useI18n();
const [st, setSt] = useState<FlexState>(ZERO); 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>>({}); const hold = useRef<Record<string, number>>({});
// Keep the host's keyer speed (used for CW-length estimates and the keyer panel) // 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 // in step with the radio's ACTUAL CW speed — including when it's changed on the
@@ -342,39 +333,50 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
return () => { alive = false; window.clearInterval(id); }; return () => { alive = false; window.clearInterval(id); };
}, []); }, []);
// Configured amplifier type (pgxl / spe*), so we show the SPE control when the // TRANSMIT + RECEIVE share ONE collapse state (they sit side by side, so folding
// operator runs an SPE Expert instead of the PowerGenius. // one folds the other and keeps the row tidy). Persisted like the other cards.
const [ampType, setAmpType] = useState<string>('pgxl'); const [txrxOpen, setTxrxOpen] = useState(() => localStorage.getItem('opslog.cardOpen.txrx') !== '0');
const [ampEnabled, setAmpEnabled] = useState(false); 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(() => { useEffect(() => {
let alive = true; let alive = true;
const load = () => GetPGXLSettings().then((s: any) => { if (alive) { setAmpType(s?.type || 'pgxl'); setAmpEnabled(!!s?.enabled); } }).catch(() => {}); const tick = async () => {
load(); try { const en: any = await GetTunerGeniusSettings(); if (alive) setTgEnabled(!!en?.enabled); } catch {}
const id = window.setInterval(load, 5000); 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); }; return () => { alive = false; window.clearInterval(id); };
}, []); }, []);
const isACOM = ampEnabled && ampType.startsWith('acom');
const isSPE = ampEnabled && !isACOM && ampType !== 'pgxl'; // Configured amplifiers (Settings → Amplifier) — possibly SEVERAL (some ops
// SPE Expert live status (only polled when an SPE amp is configured). // run two SPEs in parallel). The card shows ONE at a time; the dropdown picks
const [spe, setSpe] = useState<any>({ connected: false }); // which, and the choice is remembered per panel.
const [ampList, setAmpList] = useState<any[]>([]);
const [ampSel, setAmpSel] = useState<string>(() => localStorage.getItem('opslog.ampSel.flex') || '');
useEffect(() => { useEffect(() => {
if (!isSPE) return;
let alive = true; let alive = true;
const tick = () => GetSPEStatus().then((s: any) => alive && setSpe(s || { connected: false })).catch(() => {}); const tick = () => GetAmpStatuses().then((l: any) => alive && setAmpList((l ?? []) as any[])).catch(() => {});
tick(); tick();
const id = window.setInterval(tick, 1500); const id = window.setInterval(tick, 1500);
return () => { alive = false; window.clearInterval(id); }; return () => { alive = false; window.clearInterval(id); };
}, [isSPE]); }, []);
// ACOM live status (only polled when an ACOM amp is configured). const selAmp = ampList.find((a: any) => a.id === ampSel) ?? ampList[0];
const [acom, setAcom] = useState<any>({ connected: false }); const isSPE = !!selAmp?.spe;
useEffect(() => { const isACOM = !!selAmp?.acom;
if (!isACOM) return; const spe = selAmp?.spe ?? { connected: false };
let alive = true; const acom = selAmp?.acom ?? { connected: false };
const tick = () => GetACOMStatus().then((s: any) => alive && setAcom(s || { connected: false })).catch(() => {}); const ampPicker = ampList.length > 1 && selAmp ? (
tick(); <select value={selAmp.id}
const id = window.setInterval(tick, 1500); onChange={(e) => { setAmpSel(e.target.value); localStorage.setItem('opslog.ampSel.flex', e.target.value); }}
return () => { alive = false; window.clearInterval(id); }; title={t('flxp.ampPick')}
}, [isACOM]); className="h-8 rounded-md border border-border bg-card px-2 text-xs font-semibold outline-none">
{ampList.map((a: any) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
) : null;
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => { const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
hold.current[key] = Date.now() + 900; hold.current[key] = Date.now() + 900;
@@ -418,6 +420,9 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
return () => off(); return () => off();
}, [st.rit, st.rit_freq]); }, [st.rit, st.rit_freq]);
const isCW = (st.mode || '').toUpperCase().includes('CW'); 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 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 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]; const CW_BW = [100, 200, 300, 400, 500];
@@ -546,21 +551,22 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
}; };
const cur = [ const cur = [
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return ( 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} title={onReportRST ? t('rst.clickToFill') : undefined}
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : 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'; }} /> 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 ( 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" <MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626" value={w} />
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
); })(), ); })(),
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />, swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
// Mic input level: fixed -40..+10 dB scale, RED from 0 dB up (overdrive). // Mic input level in dBFS — SmartSDR's scale is -40…0 dB. Phone modes only.
mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={10} accent="#16a34a" 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')} />, segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />,
// Speech compression (dB of gain reduction). // Speech compression — original working meter, only the top of the
comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={(comp.hi && comp.hi > 0) ? comp.hi : 25} accent="#0891b2" />, // scale changed from the radio-reported 20 to 25 (SmartSDR's -25 max).
isPhone && comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={25} accent="#0891b2" />,
].filter(Boolean); ].filter(Boolean);
return ( return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div> <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 */} {/* TX + RX columns */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* TRANSMIT */} {/* 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"> <div className="flex items-center gap-3">
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">{t('flxp.rfPower')}</span> <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))} /> <Slider value={st.rf_power} disabled={off} accent="#dc2626" onChange={(v) => change('rf_power', v, () => FlexSetPower(v))} />
@@ -702,12 +708,24 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
onLevel={(v) => change('cw_mon_level', v, () => FlexSetSidetoneLevel(v))} /> onLevel={(v) => change('cw_mon_level', v, () => FlexSetSidetoneLevel(v))} />
</div> </div>
)} )}
{/* RIT/XIT live on the TRANSMIT side to balance the two columns — the
RECEIVE card grew tall with the v4 DSP rows. */}
<div className="space-y-1.5 border-t border-border/60 pt-3">
<OffsetRow label="RIT" on={st.rit} disabled={rxOff} hz={st.rit_freq} title={t('flxp.ritHint')}
onToggle={() => change('rit', !st.rit, () => FlexSetRIT(!st.rit))}
onHz={(v) => change('rit_freq', v, () => FlexSetRITFreq(v))} />
<OffsetRow label="XIT" on={st.xit} disabled={rxOff} hz={st.xit_freq} title={t('flxp.xitHint')}
onToggle={() => change('xit', !st.xit, () => FlexSetXIT(!st.xit))}
onHz={(v) => change('xit_freq', v, () => FlexSetXITFreq(v))} />
</div>
</Card> </Card>
{/* RECEIVE */} {/* 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) && ( {((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 pb-3 border-b border-border/60">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Ant</span> <span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Ant</span>
<div className="flex items-center gap-1.5 flex-1 min-w-0"> <div className="flex items-center gap-1.5 flex-1 min-w-0">
<span className="text-[10px] text-muted-foreground">RX</span> <span className="text-[10px] text-muted-foreground">RX</span>
@@ -722,21 +740,17 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40"> className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
{((st.tx_ant_list?.length ? st.tx_ant_list : st.ant_list) ?? []).map((a) => <option key={a} value={a}>{a}</option>)} {((st.tx_ant_list?.length ? st.tx_ant_list : st.ant_list) ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
</select> </select>
{/* DAX on/off — SmartSDR's transmit-bar DAX button (TX audio from
DAX, e.g. WSJT-X). "transmit set dax=", not the slice channel. */}
<button type="button" disabled={off} title={t('flxp.daxHint')}
onClick={() => change('tx_dax', !st.tx_dax, () => FlexSetTXDAX(!st.tx_dax))}
className={cn('h-7 px-2.5 shrink-0 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-40',
st.tx_dax ? 'bg-info text-info-foreground border-info' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
DAX
</button>
</div> </div>
</div> </div>
)} )}
{/* RIT / XIT — on the RECEIVE side: RIT is what you reach for while
listening (chasing a station that drifts off your transmit frequency),
so it belongs with the other things you touch mid-QSO. Both act on the
ACTIVE slice and follow slice focus like every control here. */}
<div className="space-y-1.5 pb-3 border-b border-border/60">
<OffsetRow label="RIT" on={st.rit} disabled={rxOff} hz={st.rit_freq} title={t('flxp.ritHint')}
onToggle={() => change('rit', !st.rit, () => FlexSetRIT(!st.rit))}
onHz={(v) => change('rit_freq', v, () => FlexSetRITFreq(v))} />
<OffsetRow label="XIT" on={st.xit} disabled={rxOff} hz={st.xit_freq} title={t('flxp.xitHint')}
onToggle={() => change('xit', !st.xit, () => FlexSetXIT(!st.xit))}
onHz={(v) => change('xit_freq', v, () => FlexSetXITFreq(v))} />
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span> <span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff} <Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
@@ -760,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> <span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
</div> </div>
<div className="border-t border-border/60 pt-3 space-y-3"> <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" <LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))} onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))}
onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} /> 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" <LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))} onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} /> onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
@@ -776,6 +805,58 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))} onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} /> onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
)} )}
{/* 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 && ( {isCW && (
<div className="border-t border-border/60 pt-3 space-y-3"> <div className="border-t border-border/60 pt-3 space-y-3">
@@ -830,10 +911,11 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
The Flex doesn't report SPE amps, so this card is driven by OpsLog's own The Flex doesn't report SPE amps, so this card is driven by OpsLog's own
SPE link rather than the Flex amplifier object. */} SPE link rather than the Flex amplifier object. */}
{isSPE && ( {isSPE && (
<Card icon={Flame} title={`${t('flxp.amplifier')} · 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"> <div className="flex items-center gap-3 flex-wrap">
{ampPicker}
<button type="button" disabled={!spe.connected} <button type="button" disabled={!spe.connected}
onClick={() => SPESetOperate(!spe.operate).catch(() => {})} onClick={() => AmpOperate(selAmp.id, !spe.operate).catch(() => {})}
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30', className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}> spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{spe.operate ? 'OPERATE' : 'STANDBY'} {spe.operate ? 'OPERATE' : 'STANDBY'}
@@ -841,10 +923,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
{/* Power ON / OFF (best-guess keystrokes from the APG — verify on hw). */} {/* Power ON / OFF (best-guess keystrokes from the APG — verify on hw). */}
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70"> <div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
<button type="button" disabled={!spe.connected} <button type="button" disabled={!spe.connected}
onClick={() => SPESetPower(true).catch(() => {})} onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button> className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
<button type="button" disabled={!spe.connected} <button type="button" disabled={!spe.connected}
onClick={() => SPESetPower(false).catch(() => {})} onClick={() => AmpPower(selAmp.id, false).catch(() => {})}
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button> className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
</div> </div>
{/* Output power level: Low / Mid / High. */} {/* Output power level: Low / Mid / High. */}
@@ -853,7 +935,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
const active = (spe.power_level || '').trim().toUpperCase() === lvl; const active = (spe.power_level || '').trim().toUpperCase() === lvl;
return ( return (
<button key={lvl} type="button" disabled={!spe.connected} <button key={lvl} type="button" disabled={!spe.connected}
onClick={() => SPESetPowerLevel(lvl).catch(() => {})} onClick={() => AmpPowerLevel(selAmp.id, lvl).catch(() => {})}
className={cn('px-3 py-2 text-sm font-bold disabled:opacity-30', i > 0 && 'border-l border-border', className={cn('px-3 py-2 text-sm font-bold disabled:opacity-30', i > 0 && 'border-l border-border',
active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}> active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{powerLevelLabel(lvl)} {powerLevelLabel(lvl)}
@@ -887,10 +969,11 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
{/* ACOM amplifier (serial/TCP) — shown when it's the configured amp. Driven {/* 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). */} by OpsLog's own ACOM link (the Flex doesn't report ACOM amps). */}
{isACOM && ( {isACOM && (
<Card icon={Flame} title={`${t('flxp.amplifier')} · 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"> <div className="flex items-center gap-3 flex-wrap">
{ampPicker}
<button type="button" disabled={!acom.connected} <button type="button" disabled={!acom.connected}
onClick={() => ACOMSetOperate(!acom.operate).catch(() => {})} onClick={() => AmpOperate(selAmp.id, !acom.operate).catch(() => {})}
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30', className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
acom.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')}> acom.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')}>
{acom.operate ? 'OPERATE' : 'STANDBY'} {acom.operate ? 'OPERATE' : 'STANDBY'}
@@ -899,11 +982,11 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
is off (the port stays open), so gate on port_open not connected. */} is off (the port stays open), so gate on port_open not connected. */}
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70"> <div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
<button type="button" disabled={!(acom.port_open && acom.transport === 'serial')} <button type="button" disabled={!(acom.port_open && acom.transport === 'serial')}
onClick={() => ACOMSetPower(true).catch(() => {})} onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
title={acom.transport === 'serial' ? 'Power on (DTR/RTS pulse — needs the power-on pins wired)' : 'Power-on needs the serial DTR/RTS lines — not available over a network bridge'} title={acom.transport === 'serial' ? 'Power on (DTR/RTS pulse — needs the power-on pins wired)' : 'Power-on needs the serial DTR/RTS lines — not available over a network bridge'}
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button> className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
<button type="button" disabled={!acom.connected} <button type="button" disabled={!acom.connected}
onClick={() => ACOMSetPower(false).catch(() => {})} onClick={() => AmpPower(selAmp.id, false).catch(() => {})}
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button> className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
</div> </div>
<span className={cn('inline-flex items-center gap-1.5 text-sm', acom.connected ? 'text-muted-foreground' : 'text-danger')}> <span className={cn('inline-flex items-center gap-1.5 text-sm', acom.connected ? 'text-muted-foreground' : 'text-danger')}>
@@ -933,8 +1016,9 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
PowerGenius is the selected amp type. Running an SPE Expert or ACOM hides PowerGenius is the selected amp type. Running an SPE Expert or ACOM hides
this Flex-reported card so two amps don't both show. */} this Flex-reported card so two amps don't both show. */}
{st.amp_available && !isSPE && !isACOM && ( {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"> <div className="flex items-center gap-3">
{ampPicker}
<button type="button" disabled={off} <button type="button" disabled={off}
onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))} onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))}
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30', className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
@@ -947,7 +1031,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
{/* Fan mode — shown when the PowerGenius is configured (Settings → {/* Fan mode — shown when the PowerGenius is configured (Settings →
PowerGenius). The dot shows the direct-connection state; the PowerGenius). The dot shows the direct-connection state; the
selector is disabled until connected (hover it for the error). */} selector is disabled until connected (hover it for the error). */}
{ampType === 'pgxl' && (pg.host || pg.connected) && ( {selAmp?.type === 'pgxl' && (pg.host || pg.connected) && (
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}> <label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} /> <span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
<span className="text-muted-foreground">{t('flxp.fan')}</span> <span className="text-muted-foreground">{t('flxp.fan')}</span>
@@ -980,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"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
{amp.map((m) => { {amp.map((m) => {
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) { 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'; 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 // Drain current (ID): the PGXL reports a full-scale far too small
@@ -993,7 +1077,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
lo = 0; lo = 0;
hi = m.hi >= 25 ? m.hi : 25; 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> </div>
); );
@@ -1001,6 +1085,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
</Card> </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>
</div> </div>
); );
+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>
);
}
+151 -9
View File
@@ -5,7 +5,7 @@ import {
} from 'ag-grid-community'; } from 'ag-grid-community';
import { hamlogGridTheme } from '@/lib/gridTheme'; import { hamlogGridTheme } from '@/lib/gridTheme';
import { AgGridReact } from 'ag-grid-react'; 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 { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
@@ -42,14 +42,28 @@ function fmtTimeOn(s: any): string {
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' }); const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
// The right-click actions of the main Recent QSOs grid (update from cty/QRZ/
// Club Log, send to services, recording e-mail, eQSL, delete) — passed down so
// the worked-before grid here offers the SAME context menu on logged QSOs.
type QSOMenuHandlers = {
onUpdateFromCty?: (ids: number[]) => void;
onUpdateFromQRZ?: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void;
onDelete?: (ids: number[]) => void;
};
type Props = { type Props = {
onLogged?: () => void; onLogged?: () => void;
countries?: string[]; countries?: string[];
bands?: string[]; bands?: string[];
modes?: string[]; modes?: string[];
qsoMenuHandlers?: QSOMenuHandlers;
}; };
export function NetControlPanel({ onLogged, countries, bands, modes }: Props) { export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHandlers }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const [nets, setNets] = useState<Net[]>([]); const [nets, setNets] = useState<Net[]>([]);
const [selId, setSelId] = useState<string>(''); const [selId, setSelId] = useState<string>('');
@@ -61,6 +75,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses). // Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null); const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]); const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]);
// Programmatic row selection in the on-air grid (auto-advance after logging).
const [selectRow, setSelectRow] = useState<{ id: number; seq: number }>({ id: 0, seq: 0 });
// Add/edit-contact dialog. // Add/edit-contact dialog.
const [contactOpen, setContactOpen] = useState(false); const [contactOpen, setContactOpen] = useState(false);
@@ -68,6 +84,34 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
const [looking, setLooking] = useState(false); const [looking, setLooking] = useState(false);
const rosterGrid = useRef<any>(null); const rosterGrid = useRef<any>(null);
// Cross-grid drag & drop: roster row → on-air grid puts the station on air;
// on-air row → roster grid logs it (same as the "Log & end" button). AG-Grid
// external drop zones need each grid's api + the OTHER grid's container div.
const onAirApi = useRef<any>(null);
const onAirWrap = useRef<HTMLDivElement | null>(null);
const rosterWrap = useRef<HTMLDivElement | null>(null);
const dropZonesDone = useRef({ roster: false, onair: false });
// Callbacks live in refs so the drop-zone closures (registered once) always
// call the CURRENT activate/deactivate, not a stale first-render one.
const activateRef = useRef<(call: string) => void>(() => {});
const deactivateRef = useRef<(id?: number) => void>(() => {});
const wireDropZones = useCallback(() => {
const rApi = rosterGrid.current?.api;
if (rApi && onAirWrap.current && !dropZonesDone.current.roster) {
dropZonesDone.current.roster = true;
rApi.addRowDropZone({
getContainer: () => onAirWrap.current!,
onDragStop: (p: any) => { const call = p.node?.data?.callsign; if (call) activateRef.current(call); },
});
}
if (onAirApi.current && rosterWrap.current && !dropZonesDone.current.onair) {
dropZonesDone.current.onair = true;
onAirApi.current.addRowDropZone({
getContainer: () => rosterWrap.current!,
onDragStop: (p: any) => { const id = p.node?.data?.id; if (id != null) deactivateRef.current(id); },
});
}
}, []);
// Worked-before for the clicked station (see if/when we contacted it before). // Worked-before for the clicked station (see if/when we contacted it before).
const [wbCall, setWbCall] = useState(''); const [wbCall, setWbCall] = useState('');
@@ -122,6 +166,53 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
catch { /* ignore */ } 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(() => { refreshNets(); }, [refreshNets]);
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]); useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]); useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
@@ -177,10 +268,38 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
catch (e: any) { setError(String(e?.message ?? e)); } catch (e: any) { setError(String(e?.message ?? e)); }
} }
// Active → logged (end QSO, removed from session, written to the logbook). // Active → logged (end QSO, removed from session, written to the logbook).
// 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) { async function deactivate(id?: number) {
if (id == null) return; if (id == null) return;
try { await NetDeactivate(id); await refreshActive(); onLogged?.(); } const next = activeOrdered.find((a) => a.id !== id); // computed BEFORE the list shrinks
catch (e: any) { setError(String(e?.message ?? e)); } try {
await NetDeactivate(id);
await refreshActive();
onLogged?.();
if (next?.id != null) {
setSelectRow((s) => ({ id: next.id as number, seq: s.seq + 1 }));
setSelectedActiveIds([next.id as number]);
showWorkedBefore(next.callsign, (next as any).dxcc ?? 0);
}
} catch (e: any) { setError(String(e?.message ?? e)); }
}
// Keep the drop-zone closures pointed at the CURRENT handlers.
activateRef.current = activate;
deactivateRef.current = deactivate;
// Log EVERYONE still on air (end of net): each draft is written to the logbook
// exactly as the single-log button would.
async function logAll() {
if (active.length === 0) return;
if (!window.confirm(t('ncp.logAllConfirm', { n: active.length }))) return;
try {
for (const a of [...active]) {
if (a.id != null) await NetDeactivate(a.id as number);
}
await refreshActive();
onLogged?.();
} catch (e: any) { setError(String(e?.message ?? e)); await refreshActive(); }
} }
// Edit-modal handlers (operate on the in-memory draft, not the DB). // Edit-modal handlers (operate on the in-memory draft, not the DB).
@@ -232,7 +351,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
} }
const rosterCols = useMemo<ColDef<Station>[]>(() => [ const rosterCols = useMemo<ColDef<Station>[]>(() => [
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' }, // rowDrag: drag a roster station onto the on-air grid to start its QSO.
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold', rowDrag: true },
{ headerName: t('ncp.colName'), field: 'name', flex: 1 }, { headerName: t('ncp.colName'), field: 'name', flex: 1 },
{ headerName: 'QTH', field: 'qth', flex: 1 }, { headerName: 'QTH', field: 'qth', flex: 1 },
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 }, { headerName: t('ncp.colCountry'), field: 'country', width: 130 },
@@ -281,11 +401,15 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
{t('ncp.onAirActive')} {t('ncp.onAirActive')}
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span> <span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
</div> </div>
<div className="flex flex-col min-h-0 flex-1"> <div ref={onAirWrap} className="flex flex-col min-h-0 flex-1">
<RecentQSOsGrid <RecentQSOsGrid
rows={active} rows={activeOrdered}
total={active.length} total={activeOrdered.length}
storageKey="net.onair" storageKey="net.onair"
selectRowSignal={selectRow}
rowDragCall
passOrder
onGridApi={(api) => { onAirApi.current = api; wireDropZones(); }}
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)} onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
onRowDoubleClicked={(q) => setEditingDraft(q)} onRowDoubleClicked={(q) => setEditingDraft(q)}
onRowSelected={setSelectedActiveIds} onRowSelected={setSelectedActiveIds}
@@ -297,6 +421,22 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}> onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')} <MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
</Button> </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>
</div> </div>
)} )}
@@ -330,6 +470,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
rows={(wb.entries ?? []) as any} rows={(wb.entries ?? []) as any}
total={wb.count} total={wb.count}
storageKey="net.wb" storageKey="net.wb"
{...qsoMenuHandlers}
/> />
)} )}
</div> </div>
@@ -343,7 +484,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
{t('ncp.netUsersRoster')} {t('ncp.netUsersRoster')}
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span> <span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
</div> </div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}> <div ref={rosterWrap} style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0 }}> <div style={{ position: 'absolute', inset: 0 }}>
<AgGridReact<Station> <AgGridReact<Station>
ref={rosterGrid} ref={rosterGrid}
@@ -352,6 +493,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
columnDefs={rosterCols} columnDefs={rosterCols}
defaultColDef={defaultColDef} defaultColDef={defaultColDef}
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }} rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
onGridReady={() => wireDropZones()}
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)} onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)} onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
animateRows={false} animateRows={false}
+11 -1
View File
@@ -15,6 +15,7 @@ type Props = {
onSendEQSL?: (ids: number[]) => void; onSendEQSL?: (ids: number[]) => void;
onBulkEdit?: (ids: number[]) => void; onBulkEdit?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void; onExportSelected?: (ids: number[]) => void;
onExportSelectedFields?: (ids: number[]) => void;
onExportFiltered?: () => void; onExportFiltered?: () => void;
onExportCabrilloSelected?: (ids: number[]) => void; onExportCabrilloSelected?: (ids: number[]) => void;
onExportCabrilloFiltered?: () => 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 // 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, // list auto-refreshes and AG Grid fires internal scroll events on refresh,
// which used to dismiss the menu the instant it appeared.) // 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(); const { t } = useI18n();
useEffect(() => { useEffect(() => {
if (!menu) return; if (!menu) return;
@@ -137,6 +138,15 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
<span>{t('qctx.exportSelectedAdif', { n })}</span> <span>{t('qctx.exportSelectedAdif', { n })}</span>
</button> </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 && ( {onExportFiltered && (
<button <button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50" className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
+161 -14
View File
@@ -5,7 +5,7 @@ import {
} from 'ag-grid-community'; } from 'ag-grid-community';
import { hamlogGridTheme } from '@/lib/gridTheme'; import { hamlogGridTheme } from '@/lib/gridTheme';
import { AgGridReact } from 'ag-grid-react'; import { AgGridReact } from 'ag-grid-react';
import { Columns3, FilterX } from 'lucide-react'; import { Columns3, FilterX, ListChecks } from 'lucide-react';
import type { QSOForm } from '@/types'; import type { QSOForm } from '@/types';
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu'; import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
import { import {
@@ -30,6 +30,19 @@ type Props = {
// Bump this number to programmatically select every row (e.g. after a search // Bump this number to programmatically select every row (e.g. after a search
// in the QSL Manager, where the default is "all selected"). // in the QSL Manager, where the default is "all selected").
selectAllSignal?: number; selectAllSignal?: number;
// Bump `seq` to programmatically select the single row with this id (e.g. NET
// Control auto-advancing to the next on-air station after logging one).
selectRowSignal?: { id: number; seq: number };
// Show a row-drag handle on the callsign column (NET Control: drag an on-air
// 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 // 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 // log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
// reuse this grid elsewhere with its OWN independent column config. // reuse this grid elsewhere with its OWN independent column config.
@@ -45,6 +58,7 @@ type Props = {
onSendEQSL?: (ids: number[]) => void; onSendEQSL?: (ids: number[]) => void;
onBulkEdit?: (ids: number[]) => void; onBulkEdit?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void; onExportSelected?: (ids: number[]) => void;
onExportSelectedFields?: (ids: number[]) => void;
onExportFiltered?: () => void; onExportFiltered?: () => void;
onExportCabrilloSelected?: (ids: number[]) => void; onExportCabrilloSelected?: (ids: number[]) => void;
onExportCabrilloFiltered?: () => void; onExportCabrilloFiltered?: () => void;
@@ -249,7 +263,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
const stripAwardCols = (st: any[] | null | undefined): any[] => const stripAwardCols = (st: any[] | null | undefined): any[] =>
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_')); (st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) { export function RecentQSOsGrid({ rows, 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 { t } = useI18n();
const gridRef = useRef<any>(null); const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
@@ -257,6 +271,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
// (e.g. the Net panel) keeps its own layout independent of the main log. // (e.g. the Net panel) keeps its own layout independent of the main log.
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY; const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
const [menu, setMenu] = useState<QSOMenuState>(null); const [menu, setMenu] = useState<QSOMenuState>(null);
const [selCount, setSelCount] = useState(0); // live selection count shown in the toolbar
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
// Localized column catalog — rebuilt when the language changes. // Localized column catalog — rebuilt when the language changes.
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]); const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
@@ -312,17 +328,66 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
saveState(AWARD_SHOWN_KEY, [...next]); 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>[]>(() => { const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
restoringRef.current = true; restoringRef.current = true;
const base = COL_CATALOG.map((c) => { const base = COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c; const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible }; const col: ColDef<QSOForm> = { ...rest, hide: !defaultVisible };
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) => ({ const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
colId: `award_${a.code}`, colId: `award_${a.code}`,
headerName: a.code, headerName: a.code,
headerTooltip: t('rqg.awardTip', { name: a.name }), headerTooltip: t('rqg.awardTip', { name: a.name }),
width: 110, width: awardWidthsRef.current[a.code.toUpperCase()] ?? 110,
cellClass: 'text-[11px]', cellClass: 'text-[11px]',
// Visibility comes from the persisted award-code set, so a column the user // Visibility comes from the persisted award-code set, so a column the user
// showed reappears on reopen and one they didn't stays hidden. // showed reappears on reopen and one they didn't stays hidden.
@@ -330,7 +395,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '', valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
})); }));
return [...base, ...awards]; return [...base, ...awards];
}, [awardCols, COL_CATALOG, t, awardShown]); }, [awardCols, COL_CATALOG, t, awardShown, rowDragCall, passOrder]);
// Enforce award-column visibility via the API after the columns (re)appear — // 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, // colDef.hide alone isn't honoured by AG Grid for a column that already exists,
@@ -338,28 +403,41 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
useEffect(() => { useEffect(() => {
const api = gridRef.current?.api; const api = gridRef.current?.api;
if (!api || !awardCols?.length) return; if (!api || !awardCols?.length) return;
const widthUps: { key: string; newWidth: number }[] = [];
for (const a of awardCols) { for (const a of awardCols) {
const want = awardShown.has(a.code.toUpperCase()); const want = awardShown.has(a.code.toUpperCase());
const col = api.getColumn(`award_${a.code}`); const col = api.getColumn(`award_${a.code}`);
if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want); 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]); }, [awardCols, awardShown]);
const defaultColDef = useMemo<ColDef>(() => ({ const defaultColDef = useMemo<ColDef>(() => ({
sortable: true, sortable: !passOrder,
resizable: true, resizable: true,
filter: true, filter: true,
suppressMovable: false, 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) { function onGridReady(e: GridReadyEvent) {
onGridApi?.(e.api);
const local = loadLocal(colStateKey); 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 // Fall back to the portable DB copy when the local cache is empty
// (fresh machine / after a reinstall), then re-seed the cache. // (fresh machine / after a reinstall), then re-seed the cache.
loadRemote(colStateKey).then((remote) => { loadRemote(colStateKey).then((remote) => {
if (remote && !local) { if (remote && !local) {
e.api.applyColumnState({ state: stripAwardCols(remote) as ColumnState[], applyOrder: true }); e.api.applyColumnState({ state: sanitizeState(stripAwardCols(remote)) as ColumnState[], applyOrder: true });
seedLocal(colStateKey, remote); seedLocal(colStateKey, remote);
} }
}); });
@@ -368,14 +446,38 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
// null when no column filter is active, so "Showing X of Y" reflects them. // null when no column filter is active, so "Showing X of Y" reflects them.
const reportFilteredCount = useCallback((e: { api?: any }) => { const reportFilteredCount = useCallback((e: { api?: any }) => {
const api = e?.api ?? gridRef.current?.api; 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; if (!api || !onFilteredCountChange) return;
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null); onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
}, [onFilteredCountChange]); }, [onFilteredCountChange, passOrder]);
const saveColumnState = useCallback(() => { const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState(); const state = gridRef.current?.api?.getColumnState();
if (state) 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 // 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 // award column (both change the memo → restoringRef flips true at line 316). Each
@@ -387,7 +489,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
useEffect(() => { useEffect(() => {
const api = gridRef.current?.api; const api = gridRef.current?.api;
const local = loadLocal(colStateKey); const local = loadLocal(colStateKey);
if (api && local) api.applyColumnState({ state: 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. // Re-enable saving once AG Grid has settled the column events from the rebuild.
const t = window.setTimeout(() => { restoringRef.current = false; }, 0); const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t); return () => window.clearTimeout(t);
@@ -397,7 +499,10 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data); if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
} }
function onSelectionChanged() { function onSelectionChanged() {
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? []; const api = gridRef.current?.api;
const sel = (api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
setSelCount(sel.length);
setDispCount(api?.getDisplayedRowCount?.() ?? 0);
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null)); onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
} }
// Select every row when the caller bumps selectAllSignal (QSL Manager search). // Select every row when the caller bumps selectAllSignal (QSL Manager search).
@@ -406,6 +511,21 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
gridRef.current?.api?.selectAll(); gridRef.current?.api?.selectAll();
}, [selectAllSignal]); }, [selectAllSignal]);
// Select ONE row by id when the caller bumps selectRowSignal.seq. Deferred a
// tick so a row-data refresh in the same render settles first.
useEffect(() => {
if (!selectRowSignal || selectRowSignal.seq === 0) return;
const t = window.setTimeout(() => {
const api = gridRef.current?.api;
if (!api) return;
api.deselectAll();
api.forEachNode((n: any) => {
if (n.data?.id === selectRowSignal.id) n.setSelected(true);
});
}, 0);
return () => window.clearTimeout(t);
}, [selectRowSignal?.seq]);
// ── Column picker (visibility) ── // ── Column picker (visibility) ──
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React // Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
// state for "which columns are visible" — AG Grid's column state is the // state for "which columns are visible" — AG Grid's column state is the
@@ -462,6 +582,32 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
return ( return (
<> <>
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20"> <div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
{/* Live selection count — visible without opening the context menu. */}
{selCount > 0 && (
<span className="mr-auto text-[11px] font-semibold text-primary tabular-nums px-1.5">
{t('rqg.selectedCount', { n: selCount })}
</span>
)}
{/* Select every loaded row that passes the active column filters — so a
filtered view can be selected in one click (then send to LoTW, bulk
edit, export…). Once everything is selected the same button flips to
"Unselect all". */}
{(() => {
const allSelected = selCount > 0 && dispCount > 0 && selCount >= dispCount;
return (
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
title={allSelected ? t('rqg.unselectAllTitle') : t('rqg.selectAllTitle')}
onClick={() => {
const api: any = gridRef.current?.api;
if (!api) return;
if (allSelected) api.deselectAll();
else if (typeof api.selectAllFiltered === 'function') api.selectAllFiltered();
else api.selectAll('filtered');
}}>
<ListChecks className="size-3.5" /> {allSelected ? t('rqg.unselectAll') : t('rqg.selectAll')}
</Button>
);
})()}
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)} <Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
title={t('rqg.clearFiltersTitle')}> title={t('rqg.clearFiltersTitle')}>
<FilterX className="size-3.5" /> {t('rqg.clearFilters')} <FilterX className="size-3.5" /> {t('rqg.clearFilters')}
@@ -510,6 +656,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
onSendEQSL={onSendEQSL} onSendEQSL={onSendEQSL}
onBulkEdit={onBulkEdit} onBulkEdit={onBulkEdit}
onExportSelected={onExportSelected} onExportSelected={onExportSelected}
onExportSelectedFields={onExportSelectedFields}
onExportFiltered={onExportFiltered} onExportFiltered={onExportFiltered}
onExportCabrilloSelected={onExportCabrilloSelected} onExportCabrilloSelected={onExportCabrilloSelected}
onExportCabrilloFiltered={onExportCabrilloFiltered} 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
+80 -28
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw } from 'lucide-react'; import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -8,11 +8,16 @@ import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { RotorCompass } from '@/components/RotorCompass'; import { RotorCompass } from '@/components/RotorCompass';
import { AmpCard } from '@/components/AmpCard';
import { TunerCard } from '@/components/TunerCard';
import type { TGStatus } from '@/components/TunerGeniusPanel';
import { import {
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay, GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
GetRotatorHeading, RotatorGoTo, RotatorStop, GetRotatorHeading, RotatorGoTo, RotatorStop,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements, GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
ListDenkoviDevices, ListSerialPorts, TestStationDevice, ListDenkoviDevices, ListSerialPorts, TestStationDevice,
GetAmpStatuses, GetFlexState,
GetTunerGeniusStatus, GetTunerGeniusSettings,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null }; type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
@@ -205,9 +210,11 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p> <p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
) : ( ) : (
<div className="space-y-1.5"> <div className="space-y-1.5">
{/* Hide 0-length elements an Ultrabeam reports 6 slots but a {/* The READ_BANDS reply is undocumented and its 16-bit parse picks
3-element beam only uses the first few. */} up structural bytes past the real data (varying by band), which
{lengths.map((mm, i) => ({ mm, i })).filter((e) => e.mm > 0).map(({ mm, i }) => ( made 6+ bogus "elements" appear. Ultrabeam beams are 3-element,
and ModifyElement addresses elements 0..2 so show just those. */}
{lengths.map((mm, i) => ({ mm, i })).slice(0, 3).map(({ mm, i }) => (
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
<span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span> <span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
<button type="button" disabled={!ant.connected || busyEl !== null} <button type="button" disabled={!ant.connected || busyEl !== null}
@@ -259,7 +266,41 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
}); });
const dragId = useRef<string | null>(null); const dragId = useRef<string | null>(null);
// Max columns per row (persisted). "Auto" fills the window; a number caps the
// row so cards wrap onto further lines even when there's horizontal room.
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto'); const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
// Amplifiers (Settings → Amplifier): shown here so operators without a
// FlexRadio panel still get the controls. EVERY configured amp gets its own
// card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the
// FlexRadio meters read live; the Flex state rides along because a PGXL's
// OPERATE/meters come from the radio, not the direct GSCP link.
const [amps, setAmps] = useState<any[]>([]);
const [flexState, setFlexState] = useState<any>(null);
useEffect(() => {
let alive = true;
const load = () => Promise.all([
GetAmpStatuses().catch(() => []),
GetFlexState().catch(() => null),
]).then(([l, fx]: any[]) => { if (alive) { setAmps((l ?? []) as any[]); setFlexState(fx); } });
load();
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 () => { const loadDevices = useCallback(async () => {
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ } try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
@@ -346,25 +387,26 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')} <button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
onClick={() => removeDevice(dev.id)}><Trash2 className="size-3.5" /></button> onClick={() => removeDevice(dev.id)}><Trash2 className="size-3.5" /></button>
</div> </div>
<div className="p-3 grid grid-cols-2 gap-2"> {/* Compact one-line relay buttons at a FIXED width so they don't stretch
across the whole card they wrap to fill the available width instead. */}
<div className="p-2 flex flex-wrap gap-1.5">
{relays.map((r) => { {relays.map((r) => {
const key = `${dev.id}:${r.number}`; const key = `${dev.id}:${r.number}`;
const label = r.label || `${t('station.relay')} ${r.number}`; const label = r.label || `${t('station.relay')} ${r.number}`;
return ( return (
<button key={r.number} type="button" disabled={!st?.connected} <button key={r.number} type="button" disabled={!st?.connected}
title={label}
onClick={() => toggle(dev, r.number, !r.on)} onClick={() => toggle(dev, r.number, !r.on)}
className={cn('flex items-center gap-2 rounded-lg border px-2.5 py-2 text-left transition-colors disabled:opacity-40', className={cn('w-[150px] flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors disabled:opacity-40',
r.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}> r.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
<span className={cn('flex items-center justify-center size-7 rounded-md shrink-0', <span className={cn('flex items-center justify-center size-5 rounded shrink-0',
r.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}> r.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
{busy[key] ? <Loader2 className="size-3.5 animate-spin" /> : <Power className="size-3.5" />} {busy[key] ? <Loader2 className="size-3 animate-spin" /> : <Power className="size-3" />}
</span> </span>
<span className="min-w-0"> <span className="flex-1 min-w-0 text-xs font-medium truncate">{label}</span>
<span className="block text-xs font-medium truncate">{label}</span> <span className={cn('text-[9px] font-bold shrink-0', r.on ? 'text-success' : 'text-muted-foreground/50')}>
<span className={cn('block text-[10px] font-semibold', r.on ? 'text-success' : 'text-muted-foreground')}>
{r.on ? t('station.on') : t('station.off')} {r.on ? t('station.on') : t('station.off')}
</span> </span>
</span>
</button> </button>
); );
})} })}
@@ -380,29 +422,27 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
if (ant.enabled) { if (ant.enabled) {
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> }); 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} /> });
// Tuner Genius XL card (identical to the Flex panel's).
if (tgEnabled) widgets.push({ id: 'tuner', node: <TunerCard status={tg} t={t} /> });
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) }); 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 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 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 widgetIds = ordered.map((w) => w.id);
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled; const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && amps.length === 0 && !tgEnabled;
// Column count controls the layout: with 4 widgets, choosing "2" lays them out
// 2×2 — which linear drag-reorder alone can't do, since the grid auto-flows to
// fill however many columns are available.
const gridCols: Record<string, string> = {
auto: 'sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4',
'1': 'grid-cols-1', '2': 'grid-cols-2', '3': 'grid-cols-3', '4': 'grid-cols-4',
};
return ( return (
<div className="flex-1 min-h-0 overflow-auto p-4"> <div className="flex-1 min-h-0 overflow-auto p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2> <h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a
card can sit on a further line even with horizontal room to spare. */}
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]"> <div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
{(['auto', '2', '3', '4'] as const).map((c) => ( {(['auto', '1', '2', '3', '4'] as const).map((c) => (
<button key={c} type="button" <button key={c} type="button"
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }} onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}> className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
@@ -422,14 +462,26 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
</div> </div>
)} )}
<div className={cn('grid gap-3 items-start', gridCols[cols] ?? gridCols.auto)}> {/* 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) => ( {ordered.map((w) => (
<div key={w.id} draggable <div key={w.id} className="flex items-stretch break-inside-avoid mb-4"
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
<div draggable
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }} onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }} onDragEnd={() => { dragId.current = null; }}
onDrop={(e) => { e.preventDefault(); onDrop(w.id); }} title={t('station.dragMove')}
className={cn('cursor-grab active:cursor-grabbing', dragId.current === w.id && 'opacity-60')}> className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors',
{w.node} dragId.current === w.id && 'opacity-60')}>
<GripVertical className="size-4" />
</div>
<div className="flex-1 min-w-0">{w.node}</div>
</div> </div>
))} ))}
</div> </div>
+131
View File
@@ -0,0 +1,131 @@
import { 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 }) {
const connected = !!status.connected;
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
const fwdW = status.fwd_w && status.fwd_w >= 1 ? status.fwd_w : 0;
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 — same grid as the Flex/amp meters so they match size. */}
<div className="grid grid-cols-2 sm:grid-cols-3 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>} {autoCall && <span className="text-[10px] text-warning/80">{t('wkp.loopHint')}</span>}
</div> </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"> <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 <button
key={i} key={i}
type="button" type="button"
+155 -25
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 = () => { const load = () => {
tries += 1; tries += 1;
GetUIPref(LS_KEY).then((raw) => { 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; if (cancelled || userPicked.current) return;
const v = raw as ThemeChoice; const v = raw as ThemeChoice;
if (v && ALL.includes(v)) { if (v && ALL.includes(v)) {
try { localStorage.setItem(LS_KEY, v); } catch { /* quota */ } try { localStorage.setItem(LS_KEY, v); } catch { /* quota */ }
applyThemeToDom(v); // idempotent — safe to call unconditionally applyThemeToDom(v); // idempotent — safe to call unconditionally
setThemeState(v); setThemeState(v);
return; // restored
} }
if (tries < 8) window.setTimeout(load, 300); // empty (unset or backend not ready yet) → retry }).catch(() => {
}).catch(() => { if (!cancelled && tries < 8) window.setTimeout(load, 300); }); // 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(); load();
return () => { cancelled = true; }; return () => { cancelled = true; };
+1
View File
@@ -25,6 +25,7 @@ const PORTABLE_KEYS = [
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom) 'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom) 'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing 'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane) 'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
'opslog.mapBasemap', // world map basemap (light / street / satellite) 'opslog.mapBasemap', // world map basemap (light / street / satellite)
// Cluster filter selections — restored on reopen. // Cluster filter selections — restored on reopen.
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.20.7'; export const APP_VERSION = '0.21.2';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+93 -3
View File
@@ -14,6 +14,7 @@ import {extsvc} from '../models';
import {powergenius} from '../models'; import {powergenius} from '../models';
import {spe} from '../models'; import {spe} from '../models';
import {solar} from '../models'; import {solar} from '../models';
import {tunergenius} from '../models';
import {winkeyer} from '../models'; import {winkeyer} from '../models';
import {alerts} from '../models'; import {alerts} from '../models';
import {audio} from '../models'; import {audio} from '../models';
@@ -23,11 +24,14 @@ import {udp} from '../models';
import {lotwusers} from '../models'; import {lotwusers} from '../models';
import {lookup} from '../models'; import {lookup} from '../models';
import {netctl} from '../models'; import {netctl} from '../models';
import {scp} from '../models';
export function ACOMSetOperate(arg1:boolean):Promise<void>; export function ACOMSetOperate(arg1:boolean):Promise<void>;
export function ACOMSetPower(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 ADIFFields():Promise<Array<adif.FieldDef>>;
export function ADIFVersion():Promise<string>; export function ADIFVersion():Promise<string>;
@@ -36,6 +40,14 @@ export function ActivateProfile(arg1:number):Promise<void>;
export function AddQSO(arg1:qso.QSO):Promise<number>; export function AddQSO(arg1:qso.QSO):Promise<number>;
export function AmpFanMode(arg1:string,arg2:string):Promise<void>;
export function AmpOperate(arg1:string,arg2:boolean):Promise<void>;
export function AmpPower(arg1:string,arg2:boolean):Promise<void>;
export function AmpPowerLevel(arg1:string,arg2:string):Promise<void>;
export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>; export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
export function AntGeniusDeselect(arg1:number):Promise<void>; export function AntGeniusDeselect(arg1:number):Promise<void>;
@@ -78,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 BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
export function CWDecoderRunning():Promise<boolean>;
export function ChatAvailable():Promise<boolean>; export function ChatAvailable():Promise<boolean>;
export function CheckForUpdate():Promise<main.UpdateInfo>; export function CheckForUpdate():Promise<main.UpdateInfo>;
@@ -154,21 +168,25 @@ export function DownloadAndApplyUpdate(arg1:string):Promise<void>;
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>; 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 DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
export function DownloadLoTWUsers():Promise<number>; export function DownloadLoTWUsers():Promise<number>;
export function DownloadScp():Promise<number>;
export function DownloadULSCounties():Promise<void>; export function DownloadULSCounties():Promise<void>;
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>; export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>; 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>; export function ExportAward(arg1:string):Promise<string>;
@@ -210,6 +228,8 @@ export function FlexSetANF(arg1:boolean):Promise<void>;
export function FlexSetANFLevel(arg1:number):Promise<void>; export function FlexSetANFLevel(arg1:number):Promise<void>;
export function FlexSetANFT(arg1:boolean):Promise<void>;
export function FlexSetAPF(arg1:boolean):Promise<void>; export function FlexSetAPF(arg1:boolean):Promise<void>;
export function FlexSetAPFLevel(arg1:number):Promise<void>; export function FlexSetAPFLevel(arg1:number):Promise<void>;
@@ -230,10 +250,20 @@ export function FlexSetCWSidetone(arg1:boolean):Promise<void>;
export function FlexSetCWSpeed(arg1:number):Promise<void>; export function FlexSetCWSpeed(arg1:number):Promise<void>;
export function FlexSetDAX(arg1:number):Promise<void>;
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>; export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
export function FlexSetKeySpeed(arg1:number):Promise<void>; export function FlexSetKeySpeed(arg1:number):Promise<void>;
export function FlexSetLMSANF(arg1:boolean):Promise<void>;
export function FlexSetLMSANFLevel(arg1:number):Promise<void>;
export function FlexSetLMSNR(arg1:boolean):Promise<void>;
export function FlexSetLMSNRLevel(arg1:number):Promise<void>;
export function FlexSetMic(arg1:number):Promise<void>; export function FlexSetMic(arg1:number):Promise<void>;
export function FlexSetMicProfile(arg1:string):Promise<void>; export function FlexSetMicProfile(arg1:string):Promise<void>;
@@ -250,6 +280,10 @@ export function FlexSetNBLevel(arg1:number):Promise<void>;
export function FlexSetNR(arg1:boolean):Promise<void>; export function FlexSetNR(arg1:boolean):Promise<void>;
export function FlexSetNRF(arg1:boolean):Promise<void>;
export function FlexSetNRFLevel(arg1:number):Promise<void>;
export function FlexSetNRLevel(arg1:number):Promise<void>; export function FlexSetNRLevel(arg1:number):Promise<void>;
export function FlexSetPower(arg1:number):Promise<void>; export function FlexSetPower(arg1:number):Promise<void>;
@@ -262,14 +296,22 @@ export function FlexSetRIT(arg1:boolean):Promise<void>;
export function FlexSetRITFreq(arg1:number):Promise<void>; export function FlexSetRITFreq(arg1:number):Promise<void>;
export function FlexSetRNN(arg1:boolean):Promise<void>;
export function FlexSetRXAntenna(arg1:string):Promise<void>; export function FlexSetRXAntenna(arg1:string):Promise<void>;
export function FlexSetSidetoneLevel(arg1:number):Promise<void>; export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
export function FlexSetSpeexNR(arg1:boolean):Promise<void>;
export function FlexSetSpeexNRLevel(arg1:number):Promise<void>;
export function FlexSetSplit(arg1:boolean):Promise<void>; export function FlexSetSplit(arg1:boolean):Promise<void>;
export function FlexSetTXAntenna(arg1:string):Promise<void>; export function FlexSetTXAntenna(arg1:string):Promise<void>;
export function FlexSetTXDAX(arg1:boolean):Promise<void>;
export function FlexSetTXFilter(arg1:number,arg2:number):Promise<void>; export function FlexSetTXFilter(arg1:number,arg2:number):Promise<void>;
export function FlexSetTXSlice(arg1:number):Promise<void>; export function FlexSetTXSlice(arg1:number):Promise<void>;
@@ -302,6 +344,10 @@ export function GetActiveProfile():Promise<profile.Profile>;
export function GetAlertEmailTo():Promise<string>; export function GetAlertEmailTo():Promise<string>;
export function GetAmpStatuses():Promise<Array<main.AmpStatus>>;
export function GetAmplifiers():Promise<Array<main.AmpConfig>>;
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>; export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
export function GetAntGeniusStatus():Promise<antgenius.Status>; export function GetAntGeniusStatus():Promise<antgenius.Status>;
@@ -330,6 +376,8 @@ export function GetCATSettings():Promise<main.CATSettings>;
export function GetCATState():Promise<cat.RigState>; export function GetCATState():Promise<cat.RigState>;
export function GetCWDecoderPitch():Promise<number>;
export function GetCatalogCodes():Promise<Array<string>>; export function GetCatalogCodes():Promise<Array<string>>;
export function GetChangelog():Promise<Array<main.ChangelogEntry>>; export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
@@ -338,6 +386,8 @@ export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>; export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
export function GetClublogMostWantedInfo():Promise<main.ClublogMostWantedInfo>;
export function GetClusterAutoConnect():Promise<boolean>; export function GetClusterAutoConnect():Promise<boolean>;
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>; export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
@@ -412,6 +462,8 @@ export function GetRotatorSettings():Promise<main.RotatorSettings>;
export function GetSPEStatus():Promise<spe.Status>; export function GetSPEStatus():Promise<spe.Status>;
export function GetScpStatus():Promise<main.ScpStatus>;
export function GetSecretStatus():Promise<main.SecretStatus>; export function GetSecretStatus():Promise<main.SecretStatus>;
export function GetSlotStats():Promise<qso.SlotStats>; export function GetSlotStats():Promise<qso.SlotStats>;
@@ -428,6 +480,10 @@ export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
export function GetTelemetryEnabled():Promise<boolean>; 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 GetUIPref(arg1:string):Promise<string>;
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>; export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
@@ -630,6 +686,8 @@ export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefau
export function PGXLSetFanMode(arg1:string):Promise<void>; export function PGXLSetFanMode(arg1:string):Promise<void>;
export function PGXLSetOperate(arg1:boolean):Promise<void>;
export function PickADIFMonitorFile():Promise<string>; export function PickADIFMonitorFile():Promise<string>;
export function PickAudioFolder():Promise<string>; export function PickAudioFolder():Promise<string>;
@@ -698,6 +756,8 @@ export function RemovePassphrase(arg1:string):Promise<void>;
export function RenameDatabase(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 RenderEQSL(arg1:number,arg2:number):Promise<string>;
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>; export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
@@ -716,6 +776,8 @@ export function RestartQSORecorder():Promise<void>;
export function RetryOfflineSync():Promise<number>; export function RetryOfflineSync():Promise<number>;
export function RevealDataFolder():Promise<void>;
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>; export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
export function RotatorPark():Promise<void>; export function RotatorPark():Promise<void>;
@@ -724,6 +786,8 @@ export function RotatorStop():Promise<void>;
export function RunBackupNow():Promise<string>; export function RunBackupNow():Promise<string>;
export function SMTPConfigured():Promise<boolean>;
export function SPESetOperate(arg1:boolean):Promise<void>; export function SPESetOperate(arg1:boolean):Promise<void>;
export function SPESetPower(arg1:boolean):Promise<void>; export function SPESetPower(arg1:boolean):Promise<void>;
@@ -736,6 +800,8 @@ export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>;
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>; export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
export function SaveAmplifiers(arg1:Array<main.AmpConfig>):Promise<void>;
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>; export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>; export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
@@ -786,12 +852,16 @@ export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>; export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
export function SaveTunerGeniusSettings(arg1:main.TunerGeniusSettings):Promise<void>;
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>; export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):Promise<void>; export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):Promise<void>;
export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):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 SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
export function SendChatMessage(arg1:string):Promise<main.ChatMessage>; export function SendChatMessage(arg1:string):Promise<main.ChatMessage>;
@@ -802,6 +872,8 @@ export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<voi
export function SendEQSL(arg1:number,arg2:number,arg3:string):Promise<void>; export function SendEQSL(arg1:number,arg2:number,arg3:string):Promise<void>;
export function SendLogToDeveloper():Promise<void>;
export function SendQSORecordingEmail(arg1:number):Promise<void>; export function SendQSORecordingEmail(arg1:number):Promise<void>;
export function SetAlertEmailTo(arg1:string):Promise<void>; export function SetAlertEmailTo(arg1:string):Promise<void>;
@@ -810,8 +882,12 @@ export function SetCATFrequency(arg1:number):Promise<void>;
export function SetCATMode(arg1:string):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 SetClublogCtyEnabled(arg1:boolean):Promise<void>;
export function SetClublogMostWantedEnabled(arg1:boolean):Promise<void>;
export function SetClusterAutoConnect(arg1:boolean):Promise<void>; export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
export function SetCompactMode(arg1:boolean):Promise<void>; export function SetCompactMode(arg1:boolean):Promise<void>;
@@ -820,14 +896,20 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
export function SetPassphrase(arg1:string):Promise<void>; export function SetPassphrase(arg1:string):Promise<void>;
export function SetScpEnabled(arg1:boolean):Promise<void>;
export function SetTelemetryEnabled(arg1:boolean):Promise<void>; export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
export function SetUIPref(arg1:string,arg2:string):Promise<void>; export function SetUIPref(arg1:string,arg2:string):Promise<void>;
export function SetUltrabeamDirection(arg1:number):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 StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
export function StopCWDecoder():Promise<void>;
export function SwitchCATRig(arg1:number):Promise<void>; export function SwitchCATRig(arg1:number):Promise<void>;
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>; export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
@@ -856,6 +938,14 @@ export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationT
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>; 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 ULSStatus():Promise<main.ULSStatusResult>; export function ULSStatus():Promise<main.ULSStatusResult>;
export function UltrabeamRetract():Promise<void>; export function UltrabeamRetract():Promise<void>;
+182 -6
View File
@@ -10,6 +10,10 @@ export function ACOMSetPower(arg1) {
return window['go']['main']['App']['ACOMSetPower'](arg1); return window['go']['main']['App']['ACOMSetPower'](arg1);
} }
export function ADIFExtraFields() {
return window['go']['main']['App']['ADIFExtraFields']();
}
export function ADIFFields() { export function ADIFFields() {
return window['go']['main']['App']['ADIFFields'](); return window['go']['main']['App']['ADIFFields']();
} }
@@ -26,6 +30,22 @@ export function AddQSO(arg1) {
return window['go']['main']['App']['AddQSO'](arg1); return window['go']['main']['App']['AddQSO'](arg1);
} }
export function AmpFanMode(arg1, arg2) {
return window['go']['main']['App']['AmpFanMode'](arg1, arg2);
}
export function AmpOperate(arg1, arg2) {
return window['go']['main']['App']['AmpOperate'](arg1, arg2);
}
export function AmpPower(arg1, arg2) {
return window['go']['main']['App']['AmpPower'](arg1, arg2);
}
export function AmpPowerLevel(arg1, arg2) {
return window['go']['main']['App']['AmpPowerLevel'](arg1, arg2);
}
export function AntGeniusActivate(arg1, arg2) { export function AntGeniusActivate(arg1, arg2) {
return window['go']['main']['App']['AntGeniusActivate'](arg1, arg2); return window['go']['main']['App']['AntGeniusActivate'](arg1, arg2);
} }
@@ -110,6 +130,10 @@ export function BulkUpdateQSL(arg1, arg2) {
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2); return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
} }
export function CWDecoderRunning() {
return window['go']['main']['App']['CWDecoderRunning']();
}
export function ChatAvailable() { export function ChatAvailable() {
return window['go']['main']['App']['ChatAvailable'](); return window['go']['main']['App']['ChatAvailable']();
} }
@@ -262,6 +286,10 @@ export function DownloadClublogCty() {
return window['go']['main']['App']['DownloadClublogCty'](); return window['go']['main']['App']['DownloadClublogCty']();
} }
export function DownloadClublogMostWanted() {
return window['go']['main']['App']['DownloadClublogMostWanted']();
}
export function DownloadConfirmations(arg1, arg2, arg3) { export function DownloadConfirmations(arg1, arg2, arg3) {
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3); return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3);
} }
@@ -270,6 +298,10 @@ export function DownloadLoTWUsers() {
return window['go']['main']['App']['DownloadLoTWUsers'](); return window['go']['main']['App']['DownloadLoTWUsers']();
} }
export function DownloadScp() {
return window['go']['main']['App']['DownloadScp']();
}
export function DownloadULSCounties() { export function DownloadULSCounties() {
return window['go']['main']['App']['DownloadULSCounties'](); return window['go']['main']['App']['DownloadULSCounties']();
} }
@@ -282,16 +314,16 @@ export function ExplainAward(arg1, arg2) {
return window['go']['main']['App']['ExplainAward'](arg1, arg2); return window['go']['main']['App']['ExplainAward'](arg1, arg2);
} }
export function ExportADIF(arg1, arg2) { export function ExportADIF(arg1, arg2, arg3) {
return window['go']['main']['App']['ExportADIF'](arg1, arg2); return window['go']['main']['App']['ExportADIF'](arg1, arg2, arg3);
} }
export function ExportADIFFiltered(arg1, arg2, arg3) { export function ExportADIFFiltered(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['ExportADIFFiltered'](arg1, arg2, arg3); return window['go']['main']['App']['ExportADIFFiltered'](arg1, arg2, arg3, arg4);
} }
export function ExportADIFSelected(arg1, arg2, arg3) { export function ExportADIFSelected(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3); return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3, arg4);
} }
export function ExportAward(arg1) { export function ExportAward(arg1) {
@@ -374,6 +406,10 @@ export function FlexSetANFLevel(arg1) {
return window['go']['main']['App']['FlexSetANFLevel'](arg1); return window['go']['main']['App']['FlexSetANFLevel'](arg1);
} }
export function FlexSetANFT(arg1) {
return window['go']['main']['App']['FlexSetANFT'](arg1);
}
export function FlexSetAPF(arg1) { export function FlexSetAPF(arg1) {
return window['go']['main']['App']['FlexSetAPF'](arg1); return window['go']['main']['App']['FlexSetAPF'](arg1);
} }
@@ -414,6 +450,10 @@ export function FlexSetCWSpeed(arg1) {
return window['go']['main']['App']['FlexSetCWSpeed'](arg1); return window['go']['main']['App']['FlexSetCWSpeed'](arg1);
} }
export function FlexSetDAX(arg1) {
return window['go']['main']['App']['FlexSetDAX'](arg1);
}
export function FlexSetFilter(arg1, arg2) { export function FlexSetFilter(arg1, arg2) {
return window['go']['main']['App']['FlexSetFilter'](arg1, arg2); return window['go']['main']['App']['FlexSetFilter'](arg1, arg2);
} }
@@ -422,6 +462,22 @@ export function FlexSetKeySpeed(arg1) {
return window['go']['main']['App']['FlexSetKeySpeed'](arg1); return window['go']['main']['App']['FlexSetKeySpeed'](arg1);
} }
export function FlexSetLMSANF(arg1) {
return window['go']['main']['App']['FlexSetLMSANF'](arg1);
}
export function FlexSetLMSANFLevel(arg1) {
return window['go']['main']['App']['FlexSetLMSANFLevel'](arg1);
}
export function FlexSetLMSNR(arg1) {
return window['go']['main']['App']['FlexSetLMSNR'](arg1);
}
export function FlexSetLMSNRLevel(arg1) {
return window['go']['main']['App']['FlexSetLMSNRLevel'](arg1);
}
export function FlexSetMic(arg1) { export function FlexSetMic(arg1) {
return window['go']['main']['App']['FlexSetMic'](arg1); return window['go']['main']['App']['FlexSetMic'](arg1);
} }
@@ -454,6 +510,14 @@ export function FlexSetNR(arg1) {
return window['go']['main']['App']['FlexSetNR'](arg1); return window['go']['main']['App']['FlexSetNR'](arg1);
} }
export function FlexSetNRF(arg1) {
return window['go']['main']['App']['FlexSetNRF'](arg1);
}
export function FlexSetNRFLevel(arg1) {
return window['go']['main']['App']['FlexSetNRFLevel'](arg1);
}
export function FlexSetNRLevel(arg1) { export function FlexSetNRLevel(arg1) {
return window['go']['main']['App']['FlexSetNRLevel'](arg1); return window['go']['main']['App']['FlexSetNRLevel'](arg1);
} }
@@ -478,6 +542,10 @@ export function FlexSetRITFreq(arg1) {
return window['go']['main']['App']['FlexSetRITFreq'](arg1); return window['go']['main']['App']['FlexSetRITFreq'](arg1);
} }
export function FlexSetRNN(arg1) {
return window['go']['main']['App']['FlexSetRNN'](arg1);
}
export function FlexSetRXAntenna(arg1) { export function FlexSetRXAntenna(arg1) {
return window['go']['main']['App']['FlexSetRXAntenna'](arg1); return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
} }
@@ -486,6 +554,14 @@ export function FlexSetSidetoneLevel(arg1) {
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1); return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
} }
export function FlexSetSpeexNR(arg1) {
return window['go']['main']['App']['FlexSetSpeexNR'](arg1);
}
export function FlexSetSpeexNRLevel(arg1) {
return window['go']['main']['App']['FlexSetSpeexNRLevel'](arg1);
}
export function FlexSetSplit(arg1) { export function FlexSetSplit(arg1) {
return window['go']['main']['App']['FlexSetSplit'](arg1); return window['go']['main']['App']['FlexSetSplit'](arg1);
} }
@@ -494,6 +570,10 @@ export function FlexSetTXAntenna(arg1) {
return window['go']['main']['App']['FlexSetTXAntenna'](arg1); return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
} }
export function FlexSetTXDAX(arg1) {
return window['go']['main']['App']['FlexSetTXDAX'](arg1);
}
export function FlexSetTXFilter(arg1, arg2) { export function FlexSetTXFilter(arg1, arg2) {
return window['go']['main']['App']['FlexSetTXFilter'](arg1, arg2); return window['go']['main']['App']['FlexSetTXFilter'](arg1, arg2);
} }
@@ -558,6 +638,14 @@ export function GetAlertEmailTo() {
return window['go']['main']['App']['GetAlertEmailTo'](); return window['go']['main']['App']['GetAlertEmailTo']();
} }
export function GetAmpStatuses() {
return window['go']['main']['App']['GetAmpStatuses']();
}
export function GetAmplifiers() {
return window['go']['main']['App']['GetAmplifiers']();
}
export function GetAntGeniusSettings() { export function GetAntGeniusSettings() {
return window['go']['main']['App']['GetAntGeniusSettings'](); return window['go']['main']['App']['GetAntGeniusSettings']();
} }
@@ -614,6 +702,10 @@ export function GetCATState() {
return window['go']['main']['App']['GetCATState'](); return window['go']['main']['App']['GetCATState']();
} }
export function GetCWDecoderPitch() {
return window['go']['main']['App']['GetCWDecoderPitch']();
}
export function GetCatalogCodes() { export function GetCatalogCodes() {
return window['go']['main']['App']['GetCatalogCodes'](); return window['go']['main']['App']['GetCatalogCodes']();
} }
@@ -630,6 +722,10 @@ export function GetClublogCtyInfo() {
return window['go']['main']['App']['GetClublogCtyInfo'](); return window['go']['main']['App']['GetClublogCtyInfo']();
} }
export function GetClublogMostWantedInfo() {
return window['go']['main']['App']['GetClublogMostWantedInfo']();
}
export function GetClusterAutoConnect() { export function GetClusterAutoConnect() {
return window['go']['main']['App']['GetClusterAutoConnect'](); return window['go']['main']['App']['GetClusterAutoConnect']();
} }
@@ -778,6 +874,10 @@ export function GetSPEStatus() {
return window['go']['main']['App']['GetSPEStatus'](); return window['go']['main']['App']['GetSPEStatus']();
} }
export function GetScpStatus() {
return window['go']['main']['App']['GetScpStatus']();
}
export function GetSecretStatus() { export function GetSecretStatus() {
return window['go']['main']['App']['GetSecretStatus'](); return window['go']['main']['App']['GetSecretStatus']();
} }
@@ -810,6 +910,14 @@ export function GetTelemetryEnabled() {
return window['go']['main']['App']['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) { export function GetUIPref(arg1) {
return window['go']['main']['App']['GetUIPref'](arg1); return window['go']['main']['App']['GetUIPref'](arg1);
} }
@@ -1214,6 +1322,10 @@ export function PGXLSetFanMode(arg1) {
return window['go']['main']['App']['PGXLSetFanMode'](arg1); return window['go']['main']['App']['PGXLSetFanMode'](arg1);
} }
export function PGXLSetOperate(arg1) {
return window['go']['main']['App']['PGXLSetOperate'](arg1);
}
export function PickADIFMonitorFile() { export function PickADIFMonitorFile() {
return window['go']['main']['App']['PickADIFMonitorFile'](); return window['go']['main']['App']['PickADIFMonitorFile']();
} }
@@ -1350,6 +1462,10 @@ export function RenameDatabase(arg1) {
return window['go']['main']['App']['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) { export function RenderEQSL(arg1, arg2) {
return window['go']['main']['App']['RenderEQSL'](arg1, arg2); return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
} }
@@ -1386,6 +1502,10 @@ export function RetryOfflineSync() {
return window['go']['main']['App']['RetryOfflineSync'](); return window['go']['main']['App']['RetryOfflineSync']();
} }
export function RevealDataFolder() {
return window['go']['main']['App']['RevealDataFolder']();
}
export function RotatorGoTo(arg1, arg2) { export function RotatorGoTo(arg1, arg2) {
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2); return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
} }
@@ -1402,6 +1522,10 @@ export function RunBackupNow() {
return window['go']['main']['App']['RunBackupNow'](); return window['go']['main']['App']['RunBackupNow']();
} }
export function SMTPConfigured() {
return window['go']['main']['App']['SMTPConfigured']();
}
export function SPESetOperate(arg1) { export function SPESetOperate(arg1) {
return window['go']['main']['App']['SPESetOperate'](arg1); return window['go']['main']['App']['SPESetOperate'](arg1);
} }
@@ -1426,6 +1550,10 @@ export function SaveAlertRule(arg1) {
return window['go']['main']['App']['SaveAlertRule'](arg1); return window['go']['main']['App']['SaveAlertRule'](arg1);
} }
export function SaveAmplifiers(arg1) {
return window['go']['main']['App']['SaveAmplifiers'](arg1);
}
export function SaveAntGeniusSettings(arg1) { export function SaveAntGeniusSettings(arg1) {
return window['go']['main']['App']['SaveAntGeniusSettings'](arg1); return window['go']['main']['App']['SaveAntGeniusSettings'](arg1);
} }
@@ -1526,6 +1654,10 @@ export function SaveStationSettings(arg1) {
return window['go']['main']['App']['SaveStationSettings'](arg1); return window['go']['main']['App']['SaveStationSettings'](arg1);
} }
export function SaveTunerGeniusSettings(arg1) {
return window['go']['main']['App']['SaveTunerGeniusSettings'](arg1);
}
export function SaveUDPIntegration(arg1) { export function SaveUDPIntegration(arg1) {
return window['go']['main']['App']['SaveUDPIntegration'](arg1); return window['go']['main']['App']['SaveUDPIntegration'](arg1);
} }
@@ -1538,6 +1670,10 @@ export function SaveWinkeyerSettings(arg1) {
return window['go']['main']['App']['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) { export function SearchAwardReferences(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4); return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4);
} }
@@ -1558,6 +1694,10 @@ export function SendEQSL(arg1, arg2, arg3) {
return window['go']['main']['App']['SendEQSL'](arg1, arg2, arg3); return window['go']['main']['App']['SendEQSL'](arg1, arg2, arg3);
} }
export function SendLogToDeveloper() {
return window['go']['main']['App']['SendLogToDeveloper']();
}
export function SendQSORecordingEmail(arg1) { export function SendQSORecordingEmail(arg1) {
return window['go']['main']['App']['SendQSORecordingEmail'](arg1); return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
} }
@@ -1574,10 +1714,18 @@ export function SetCATMode(arg1) {
return window['go']['main']['App']['SetCATMode'](arg1); return window['go']['main']['App']['SetCATMode'](arg1);
} }
export function SetCWDecoderPitch(arg1) {
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
}
export function SetClublogCtyEnabled(arg1) { export function SetClublogCtyEnabled(arg1) {
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1); return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
} }
export function SetClublogMostWantedEnabled(arg1) {
return window['go']['main']['App']['SetClublogMostWantedEnabled'](arg1);
}
export function SetClusterAutoConnect(arg1) { export function SetClusterAutoConnect(arg1) {
return window['go']['main']['App']['SetClusterAutoConnect'](arg1); return window['go']['main']['App']['SetClusterAutoConnect'](arg1);
} }
@@ -1594,6 +1742,10 @@ export function SetPassphrase(arg1) {
return window['go']['main']['App']['SetPassphrase'](arg1); return window['go']['main']['App']['SetPassphrase'](arg1);
} }
export function SetScpEnabled(arg1) {
return window['go']['main']['App']['SetScpEnabled'](arg1);
}
export function SetTelemetryEnabled(arg1) { export function SetTelemetryEnabled(arg1) {
return window['go']['main']['App']['SetTelemetryEnabled'](arg1); return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
} }
@@ -1606,10 +1758,18 @@ export function SetUltrabeamDirection(arg1) {
return window['go']['main']['App']['SetUltrabeamDirection'](arg1); return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
} }
export function StartCWDecoder() {
return window['go']['main']['App']['StartCWDecoder']();
}
export function StationSetRelay(arg1, arg2, arg3) { export function StationSetRelay(arg1, arg2, arg3) {
return window['go']['main']['App']['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) { export function SwitchCATRig(arg1) {
return window['go']['main']['App']['SwitchCATRig'](arg1); return window['go']['main']['App']['SwitchCATRig'](arg1);
} }
@@ -1666,6 +1826,22 @@ export function TestUltrabeam(arg1) {
return window['go']['main']['App']['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 ULSStatus() { export function ULSStatus() {
return window['go']['main']['App']['ULSStatus'](); return window['go']['main']['App']['ULSStatus']();
} }
+290
View File
@@ -777,6 +777,19 @@ export namespace cat {
anf_level: number; anf_level: number;
wnb: boolean; wnb: boolean;
wnb_level: number; wnb_level: number;
lms_nr: boolean;
lms_nr_level: number;
lms_anf: boolean;
lms_anf_level: number;
speex_nr: boolean;
speex_nr_level: number;
rnn: boolean;
anft: boolean;
nrf: boolean;
nrf_level: number;
dsp_v4: boolean;
dax_ch: number;
tx_dax: boolean;
rit: boolean; rit: boolean;
rit_freq: number; rit_freq: number;
xit: boolean; xit: boolean;
@@ -844,6 +857,19 @@ export namespace cat {
this.anf_level = source["anf_level"]; this.anf_level = source["anf_level"];
this.wnb = source["wnb"]; this.wnb = source["wnb"];
this.wnb_level = source["wnb_level"]; this.wnb_level = source["wnb_level"];
this.lms_nr = source["lms_nr"];
this.lms_nr_level = source["lms_nr_level"];
this.lms_anf = source["lms_anf"];
this.lms_anf_level = source["lms_anf_level"];
this.speex_nr = source["speex_nr"];
this.speex_nr_level = source["speex_nr_level"];
this.rnn = source["rnn"];
this.anft = source["anft"];
this.nrf = source["nrf"];
this.nrf_level = source["nrf_level"];
this.dsp_v4 = source["dsp_v4"];
this.dax_ch = source["dax_ch"];
this.tx_dax = source["tx_dax"];
this.rit = source["rit"]; this.rit = source["rit"];
this.rit_freq = source["rit_freq"]; this.rit_freq = source["rit_freq"];
this.xit = source["xit"]; this.xit = source["xit"];
@@ -1400,6 +1426,74 @@ export namespace main {
} }
} }
export class AmpConfig {
id: string;
name: string;
enabled: boolean;
type: string;
transport: string;
host: string;
port: number;
com_port: string;
baud: number;
static createFrom(source: any = {}) {
return new AmpConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.enabled = source["enabled"];
this.type = source["type"];
this.transport = source["transport"];
this.host = source["host"];
this.port = source["port"];
this.com_port = source["com_port"];
this.baud = source["baud"];
}
}
export class AmpStatus {
id: string;
name: string;
type: string;
pgxl?: powergenius.Status;
spe?: spe.Status;
acom?: acom.Status;
static createFrom(source: any = {}) {
return new AmpStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.type = source["type"];
this.pgxl = this.convertValues(source["pgxl"], powergenius.Status);
this.spe = this.convertValues(source["spe"], spe.Status);
this.acom = this.convertValues(source["acom"], acom.Status);
}
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 class AntGeniusSettings { export class AntGeniusSettings {
enabled: boolean; enabled: boolean;
host: string; host: string;
@@ -1855,6 +1949,26 @@ export namespace main {
this.count = source["count"]; 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 { export class ContestBandRow {
band: string; band: string;
count: number; count: number;
@@ -1995,6 +2109,7 @@ export namespace main {
path: string; path: string;
default_path: string; default_path: string;
is_custom: boolean; is_custom: boolean;
logbook_default_path: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new DatabaseSettings(source); return new DatabaseSettings(source);
@@ -2005,6 +2120,7 @@ export namespace main {
this.path = source["path"]; this.path = source["path"];
this.default_path = source["default_path"]; this.default_path = source["default_path"];
this.is_custom = source["is_custom"]; this.is_custom = source["is_custom"];
this.logbook_default_path = source["logbook_default_path"];
} }
} }
export class DuplicateGroup { export class DuplicateGroup {
@@ -2201,6 +2317,7 @@ export namespace main {
user: string; user: string;
password: string; password: string;
database: string; database: string;
sqlite_path?: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new MySQLSettings(source); return new MySQLSettings(source);
@@ -2214,6 +2331,7 @@ export namespace main {
this.user = source["user"]; this.user = source["user"];
this.password = source["password"]; this.password = source["password"];
this.database = source["database"]; this.database = source["database"];
this.sqlite_path = source["sqlite_path"];
} }
} }
export class OfflineStatus { export class OfflineStatus {
@@ -2583,6 +2701,8 @@ export namespace main {
port: number; port: number;
has_elevation: boolean; has_elevation: boolean;
rotator_num: number; rotator_num: number;
transport: string;
com_port: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new RotatorSettings(source); return new RotatorSettings(source);
@@ -2596,6 +2716,24 @@ export namespace main {
this.port = source["port"]; this.port = source["port"];
this.has_elevation = source["has_elevation"]; this.has_elevation = source["has_elevation"];
this.rotator_num = source["rotator_num"]; this.rotator_num = source["rotator_num"];
this.transport = source["transport"];
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 { export class SecretStatus {
@@ -2817,6 +2955,22 @@ export namespace main {
this.error = source["error"]; 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 { export class ULSStatusResult {
count: number; count: number;
updated_at: string; updated_at: string;
@@ -2842,6 +2996,8 @@ export namespace main {
follow: boolean; follow: boolean;
step_khz: number; step_khz: number;
tx_inhibit: boolean; tx_inhibit: boolean;
freq_min_mhz: number;
freq_max_mhz: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new UltrabeamSettings(source); return new UltrabeamSettings(source);
@@ -2859,6 +3015,8 @@ export namespace main {
this.follow = source["follow"]; this.follow = source["follow"];
this.step_khz = source["step_khz"]; this.step_khz = source["step_khz"];
this.tx_inhibit = source["tx_inhibit"]; this.tx_inhibit = source["tx_inhibit"];
this.freq_min_mhz = source["freq_min_mhz"];
this.freq_max_mhz = source["freq_max_mhz"];
} }
} }
export class UltrabeamStatusInfo { export class UltrabeamStatusInfo {
@@ -2937,9 +3095,13 @@ export namespace main {
autospace: boolean; autospace: boolean;
use_ptt: boolean; use_ptt: boolean;
serial_echo: boolean; serial_echo: boolean;
type: string;
cw_key_line: string;
cw_invert: boolean;
engine: string; engine: string;
esc_clears_call: boolean; esc_clears_call: boolean;
send_on_type: boolean; send_on_type: boolean;
esm: boolean;
macros: WKMacro[]; macros: WKMacro[];
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
@@ -2963,9 +3125,13 @@ export namespace main {
this.autospace = source["autospace"]; this.autospace = source["autospace"];
this.use_ptt = source["use_ptt"]; this.use_ptt = source["use_ptt"];
this.serial_echo = source["serial_echo"]; this.serial_echo = source["serial_echo"];
this.type = source["type"];
this.cw_key_line = source["cw_key_line"];
this.cw_invert = source["cw_invert"];
this.engine = source["engine"]; this.engine = source["engine"];
this.esc_clears_call = source["esc_clears_call"]; this.esc_clears_call = source["esc_clears_call"];
this.send_on_type = source["send_on_type"]; this.send_on_type = source["send_on_type"];
this.esm = source["esm"];
this.macros = this.convertValues(source["macros"], WKMacro); this.macros = this.convertValues(source["macros"], WKMacro);
} }
@@ -3208,6 +3374,7 @@ export namespace powergenius {
state?: string; state?: string;
fan_mode?: string; fan_mode?: string;
temperature: number; temperature: number;
operate: boolean;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new Status(source); return new Status(source);
@@ -3221,6 +3388,7 @@ export namespace powergenius {
this.state = source["state"]; this.state = source["state"];
this.fan_mode = source["fan_mode"]; this.fan_mode = source["fan_mode"];
this.temperature = source["temperature"]; this.temperature = source["temperature"];
this.operate = source["operate"];
} }
} }
@@ -3235,6 +3403,7 @@ export namespace profile {
user: string; user: string;
password: string; password: string;
database: string; database: string;
path?: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new ProfileDB(source); return new ProfileDB(source);
@@ -3248,6 +3417,7 @@ export namespace profile {
this.user = source["user"]; this.user = source["user"];
this.password = source["password"]; this.password = source["password"];
this.database = source["database"]; this.database = source["database"];
this.path = source["path"];
} }
} }
export class Profile { export class Profile {
@@ -4119,6 +4289,7 @@ export namespace qso {
dxcc_bands: string[]; dxcc_bands: string[];
dxcc_modes: string[]; dxcc_modes: string[];
dxcc_band_modes: BandMode[]; dxcc_band_modes: BandMode[];
mw_rank?: number;
band_status: BandStatus[]; band_status: BandStatus[];
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
@@ -4143,6 +4314,7 @@ export namespace qso {
this.dxcc_bands = source["dxcc_bands"]; this.dxcc_bands = source["dxcc_bands"];
this.dxcc_modes = source["dxcc_modes"]; this.dxcc_modes = source["dxcc_modes"];
this.dxcc_band_modes = this.convertValues(source["dxcc_band_modes"], BandMode); 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); this.band_status = this.convertValues(source["band_status"], BandStatus);
} }
@@ -4167,6 +4339,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 namespace solar {
export class Data { export class Data {
@@ -4274,6 +4465,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 namespace udp {
export class Config { 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 // export destined for another logger; set true for a full OpsLog→OpsLog
// round-trip that preserves everything. // round-trip that preserves everything.
IncludeAppFields bool 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, // 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 count := 0
err := iter(ctx, func(q qso.QSO) error { err := iter(ctx, func(q qso.QSO) error {
writeRecord(bw, q, e.IncludeAppFields) writeRecord(bw, q, e.IncludeAppFields, e.Fields)
count++ count++
return nil return nil
}) })
@@ -115,7 +121,7 @@ func SingleRecordADIF(q qso.QSO) string {
var b strings.Builder var b strings.Builder
bw := bufio.NewWriter(&b) bw := bufio.NewWriter(&b)
// Uploads target other services — keep it standard (no app-specific tags). // Uploads target other services — keep it standard (no app-specific tags).
writeRecord(bw, q, false) writeRecord(bw, q, false, nil)
bw.Flush() bw.Flush()
return b.String() return b.String()
} }
@@ -127,7 +133,7 @@ func SingleRecordADIF(q qso.QSO) string {
func FullRecordADIF(q qso.QSO) string { func FullRecordADIF(q qso.QSO) string {
var b strings.Builder var b strings.Builder
bw := bufio.NewWriter(&b) bw := bufio.NewWriter(&b)
writeRecord(bw, q, true) writeRecord(bw, q, true, nil)
bw.Flush() bw.Flush()
return b.String() return b.String()
} }
@@ -155,161 +161,181 @@ func BatchRecordsADIF(records []string) string {
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted" // Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
// mode (e.g. FT4 stored without a parent) is exported as the canonical // mode (e.g. FT4 stored without a parent) is exported as the canonical
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers. // 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 --- // --- Core ---
writeField(bw, "CALL", q.Callsign) w("CALL", q.Callsign)
if !q.QSODate.IsZero() { if !q.QSODate.IsZero() {
writeField(bw, "QSO_DATE", q.QSODate.UTC().Format("20060102")) w("QSO_DATE", q.QSODate.UTC().Format("20060102"))
writeField(bw, "TIME_ON", q.QSODate.UTC().Format("150405")) w("TIME_ON", q.QSODate.UTC().Format("150405"))
} }
if !q.QSODateOff.IsZero() { if !q.QSODateOff.IsZero() {
writeField(bw, "QSO_DATE_OFF", q.QSODateOff.UTC().Format("20060102")) w("QSO_DATE_OFF", q.QSODateOff.UTC().Format("20060102"))
writeField(bw, "TIME_OFF", q.QSODateOff.UTC().Format("150405")) w("TIME_OFF", q.QSODateOff.UTC().Format("150405"))
} }
writeField(bw, "BAND", q.Band) w("BAND", q.Band)
writeField(bw, "BAND_RX", q.BandRX) w("BAND_RX", q.BandRX)
mode, submode := modeForExport(q.Mode, q.Submode) mode, submode := modeForExport(q.Mode, q.Submode)
writeField(bw, "MODE", mode) w("MODE", mode)
writeField(bw, "SUBMODE", submode) w("SUBMODE", submode)
if q.FreqHz != nil && *q.FreqHz > 0 { 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 { 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) w("RST_SENT", q.RSTSent)
writeField(bw, "RST_RCVD", q.RSTRcvd) w("RST_RCVD", q.RSTRcvd)
// --- Contacted --- // --- Contacted ---
writeField(bw, "NAME", q.Name) w("NAME", q.Name)
writeField(bw, "QTH", q.QTH) w("QTH", q.QTH)
writeField(bw, "ADDRESS", q.Address) w("ADDRESS", q.Address)
writeField(bw, "EMAIL", q.Email) w("EMAIL", q.Email)
writeField(bw, "WEB", q.Web) w("WEB", q.Web)
writeField(bw, "GRIDSQUARE", q.Grid) w("GRIDSQUARE", q.Grid)
writeField(bw, "GRIDSQUARE_EXT", q.GridExt) w("GRIDSQUARE_EXT", q.GridExt)
writeField(bw, "VUCC_GRIDS", q.VUCCGrids) w("VUCC_GRIDS", q.VUCCGrids)
writeField(bw, "COUNTRY", q.Country) w("COUNTRY", q.Country)
writeField(bw, "STATE", q.State) w("STATE", q.State)
writeField(bw, "CNTY", q.County) w("CNTY", q.County)
writeIntPtr(bw, "DXCC", q.DXCC) wi("DXCC", q.DXCC)
writeField(bw, "CONT", q.Continent) w("CONT", q.Continent)
writeIntPtr(bw, "CQZ", q.CQZ) wi("CQZ", q.CQZ)
writeIntPtr(bw, "ITUZ", q.ITUZ) wi("ITUZ", q.ITUZ)
writeField(bw, "IOTA", q.IOTA) w("IOTA", q.IOTA)
writeField(bw, "SOTA_REF", q.SOTARef) w("SOTA_REF", q.SOTARef)
writeField(bw, "POTA_REF", q.POTARef) w("POTA_REF", q.POTARef)
writeIntPtr(bw, "AGE", q.Age) wi("AGE", q.Age)
writeFloatPtr(bw, "LAT", q.Lat, 6) wf("LAT", q.Lat, 6)
writeFloatPtr(bw, "LON", q.Lon, 6) wf("LON", q.Lon, 6)
writeField(bw, "RIG", q.Rig) w("RIG", q.Rig)
writeField(bw, "ANT", q.Ant) w("ANT", q.Ant)
// --- QSL / LoTW / eQSL / Clublog / HRDLog --- // --- QSL / LoTW / eQSL / Clublog / HRDLog ---
writeField(bw, "QSL_SENT", q.QSLSent) w("QSL_SENT", q.QSLSent)
writeField(bw, "QSL_RCVD", q.QSLRcvd) w("QSL_RCVD", q.QSLRcvd)
writeField(bw, "QSLSDATE", q.QSLSentDate) w("QSLSDATE", q.QSLSentDate)
writeField(bw, "QSLRDATE", q.QSLRcvdDate) w("QSLRDATE", q.QSLRcvdDate)
writeField(bw, "QSL_VIA", q.QSLVia) w("QSL_VIA", q.QSLVia)
writeField(bw, "QSLMSG", q.QSLMsg) w("QSLMSG", q.QSLMsg)
writeField(bw, "QSLMSG_RCVD", q.QSLMsgRcvd) w("QSLMSG_RCVD", q.QSLMsgRcvd)
writeField(bw, "LOTW_QSL_SENT", q.LOTWSent) w("LOTW_QSL_SENT", q.LOTWSent)
writeField(bw, "LOTW_QSL_RCVD", q.LOTWRcvd) w("LOTW_QSL_RCVD", q.LOTWRcvd)
writeField(bw, "LOTW_QSLSDATE", q.LOTWSentDate) w("LOTW_QSLSDATE", q.LOTWSentDate)
writeField(bw, "LOTW_QSLRDATE", q.LOTWRcvdDate) w("LOTW_QSLRDATE", q.LOTWRcvdDate)
writeField(bw, "EQSL_QSL_SENT", q.EQSLSent) w("EQSL_QSL_SENT", q.EQSLSent)
writeField(bw, "EQSL_QSL_RCVD", q.EQSLRcvd) w("EQSL_QSL_RCVD", q.EQSLRcvd)
writeField(bw, "EQSL_QSLSDATE", q.EQSLSentDate) w("EQSL_QSLSDATE", q.EQSLSentDate)
writeField(bw, "EQSL_QSLRDATE", q.EQSLRcvdDate) w("EQSL_QSLRDATE", q.EQSLRcvdDate)
writeField(bw, "CLUBLOG_QSO_UPLOAD_DATE", q.ClublogUploadDate) w("CLUBLOG_QSO_UPLOAD_DATE", q.ClublogUploadDate)
writeField(bw, "CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus) w("CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus)
writeField(bw, "HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate) w("HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate)
writeField(bw, "HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus) w("HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
writeField(bw, "QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate) w("QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
writeField(bw, "QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus) w("QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
writeField(bw, "QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate) w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
writeField(bw, "QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus) w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
// --- Contest --- // --- Contest ---
writeField(bw, "CONTEST_ID", q.ContestID) w("CONTEST_ID", q.ContestID)
writeIntPtr(bw, "SRX", q.SRX) wi("SRX", q.SRX)
writeIntPtr(bw, "STX", q.STX) wi("STX", q.STX)
writeField(bw, "SRX_STRING", q.SRXString) w("SRX_STRING", q.SRXString)
writeField(bw, "STX_STRING", q.STXString) w("STX_STRING", q.STXString)
writeField(bw, "CHECK", q.Check) w("CHECK", q.Check)
writeField(bw, "PRECEDENCE", q.Precedence) w("PRECEDENCE", q.Precedence)
writeField(bw, "ARRL_SECT", q.ARRLSect) w("ARRL_SECT", q.ARRLSect)
// --- Satellite / propagation --- // --- Satellite / propagation ---
writeField(bw, "PROP_MODE", q.PropMode) w("PROP_MODE", q.PropMode)
writeField(bw, "SAT_NAME", q.SatName) w("SAT_NAME", q.SatName)
writeField(bw, "SAT_MODE", q.SatMode) w("SAT_MODE", q.SatMode)
writeFloatPtr(bw, "ANT_AZ", q.AntAz, 1) wf("ANT_AZ", q.AntAz, 1)
writeFloatPtr(bw, "ANT_EL", q.AntEl, 1) wf("ANT_EL", q.AntEl, 1)
writeField(bw, "ANT_PATH", q.AntPath) w("ANT_PATH", q.AntPath)
// --- My station / operator --- // --- My station / operator ---
writeField(bw, "STATION_CALLSIGN", q.StationCallsign) w("STATION_CALLSIGN", q.StationCallsign)
writeField(bw, "OPERATOR", q.Operator) w("OPERATOR", q.Operator)
writeField(bw, "MY_GRIDSQUARE", q.MyGrid) w("MY_GRIDSQUARE", q.MyGrid)
writeField(bw, "MY_GRIDSQUARE_EXT", q.MyGridExt) w("MY_GRIDSQUARE_EXT", q.MyGridExt)
writeField(bw, "MY_COUNTRY", q.MyCountry) w("MY_COUNTRY", q.MyCountry)
writeField(bw, "MY_STATE", q.MyState) w("MY_STATE", q.MyState)
writeField(bw, "MY_CNTY", q.MyCounty) w("MY_CNTY", q.MyCounty)
writeField(bw, "MY_IOTA", q.MyIOTA) w("MY_IOTA", q.MyIOTA)
writeField(bw, "MY_SOTA_REF", q.MySOTARef) w("MY_SOTA_REF", q.MySOTARef)
writeField(bw, "MY_POTA_REF", q.MyPOTARef) w("MY_POTA_REF", q.MyPOTARef)
writeIntPtr(bw, "MY_DXCC", q.MyDXCC) wi("MY_DXCC", q.MyDXCC)
writeIntPtr(bw, "MY_CQ_ZONE", q.MyCQZone) wi("MY_CQ_ZONE", q.MyCQZone)
writeIntPtr(bw, "MY_ITU_ZONE", q.MyITUZone) wi("MY_ITU_ZONE", q.MyITUZone)
writeFloatPtr(bw, "MY_LAT", q.MyLat, 6) wf("MY_LAT", q.MyLat, 6)
writeFloatPtr(bw, "MY_LON", q.MyLon, 6) wf("MY_LON", q.MyLon, 6)
writeField(bw, "MY_STREET", q.MyStreet) w("MY_STREET", q.MyStreet)
writeField(bw, "MY_CITY", q.MyCity) w("MY_CITY", q.MyCity)
writeField(bw, "MY_POSTAL_CODE", q.MyPostalCode) w("MY_POSTAL_CODE", q.MyPostalCode)
writeField(bw, "MY_RIG", q.MyRig) w("MY_RIG", q.MyRig)
writeField(bw, "MY_ANTENNA", q.MyAntenna) w("MY_ANTENNA", q.MyAntenna)
// --- Misc --- // --- Misc ---
writeFloatPtr(bw, "TX_PWR", q.TXPower, 1) wf("TX_PWR", q.TXPower, 1)
writeField(bw, "COMMENT", q.Comment) w("COMMENT", q.Comment)
writeField(bw, "NOTES", q.Notes) w("NOTES", q.Notes)
// --- ADIF 3.1.7 additional promoted fields --- // --- ADIF 3.1.7 additional promoted fields ---
writeField(bw, "SIG", q.SIG) w("SIG", q.SIG)
writeField(bw, "SIG_INFO", q.SIGInfo) w("SIG_INFO", q.SIGInfo)
writeField(bw, "MY_SIG", q.MySIG) w("MY_SIG", q.MySIG)
writeField(bw, "MY_SIG_INFO", q.MySIGInfo) w("MY_SIG_INFO", q.MySIGInfo)
writeField(bw, "WWFF_REF", q.WWFFRef) w("WWFF_REF", q.WWFFRef)
writeField(bw, "MY_WWFF_REF", q.MyWWFFRef) w("MY_WWFF_REF", q.MyWWFFRef)
writeFloatPtr(bw, "DISTANCE", q.Distance, 1) wf("DISTANCE", q.Distance, 1)
writeFloatPtr(bw, "RX_PWR", q.RXPower, 1) wf("RX_PWR", q.RXPower, 1)
writeFloatPtr(bw, "A_INDEX", q.AIndex, 0) wf("A_INDEX", q.AIndex, 0)
writeFloatPtr(bw, "K_INDEX", q.KIndex, 0) wf("K_INDEX", q.KIndex, 0)
writeFloatPtr(bw, "SFI", q.SFI, 0) wf("SFI", q.SFI, 0)
writeField(bw, "SKCC", q.SKCC) w("SKCC", q.SKCC)
writeField(bw, "FISTS", q.FISTS) w("FISTS", q.FISTS)
writeField(bw, "TEN_TEN", q.TenTen) w("TEN_TEN", q.TenTen)
writeField(bw, "CONTACTED_OP", q.ContactedOp) w("CONTACTED_OP", q.ContactedOp)
writeField(bw, "EQ_CALL", q.EqCall) w("EQ_CALL", q.EqCall)
writeField(bw, "PFX", q.PFX) w("PFX", q.PFX)
writeField(bw, "MY_NAME", q.MyName) w("MY_NAME", q.MyName)
writeField(bw, "CLASS", q.Class) w("CLASS", q.Class)
writeField(bw, "DARC_DOK", q.DarcDOK) w("DARC_DOK", q.DarcDOK)
writeField(bw, "MY_DARC_DOK", q.MyDarcDOK) w("MY_DARC_DOK", q.MyDarcDOK)
writeField(bw, "REGION", q.Region) w("REGION", q.Region)
writeField(bw, "SILENT_KEY", q.SilentKey) w("SILENT_KEY", q.SilentKey)
writeField(bw, "SWL", q.SWL) w("SWL", q.SWL)
writeField(bw, "QSO_COMPLETE", q.QSOComplete) w("QSO_COMPLETE", q.QSOComplete)
writeField(bw, "QSO_RANDOM", q.QSORandom) w("QSO_RANDOM", q.QSORandom)
writeField(bw, "CREDIT_GRANTED", q.CreditGranted) w("CREDIT_GRANTED", q.CreditGranted)
writeField(bw, "CREDIT_SUBMITTED", q.CreditSubmitted) w("CREDIT_SUBMITTED", q.CreditSubmitted)
writeField(bw, "MY_ARRL_SECT", q.MyARRLSect) w("MY_ARRL_SECT", q.MyARRLSect)
writeField(bw, "MY_VUCC_GRIDS", q.MyVUCCGrids) w("MY_VUCC_GRIDS", q.MyVUCCGrids)
// --- Extras (unpromoted ADIF fields preserved verbatim) --- // --- Extras (unpromoted ADIF fields preserved verbatim) ---
// Standard mode emits ONLY valid ADIF-spec fields, so it drops APP_* // 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. // extra for a lossless OpsLog round-trip.
for k, v := range q.Extras { for k, v := range q.Extras {
tag := strings.ToUpper(k) 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 continue
} }
writeField(bw, tag, v) writeField(bw, tag, v)
+2 -2
View File
@@ -34,7 +34,7 @@ func TestPromotedFieldsRoundTrip(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
bw := bufio.NewWriter(&buf) bw := bufio.NewWriter(&buf)
bw.WriteString("<EOH>\n") bw.WriteString("<EOH>\n")
writeRecord(bw, in, true) writeRecord(bw, in, true, nil)
bw.Flush() bw.Flush()
var rec Record var rec Record
@@ -115,7 +115,7 @@ func TestStandardExportDropsNonStandard(t *testing.T) {
func renderRecord(q qso.QSO, includeApp bool) string { func renderRecord(q qso.QSO, includeApp bool) string {
var buf bytes.Buffer var buf bytes.Buffer
bw := bufio.NewWriter(&buf) bw := bufio.NewWriter(&buf)
writeRecord(bw, q, includeApp) writeRecord(bw, q, includeApp, nil)
bw.Flush() bw.Flush()
return buf.String() 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
}
]
}
+29
View File
@@ -304,6 +304,22 @@ type FlexTXState struct {
ANFLevel int `json:"anf_level"` ANFLevel int `json:"anf_level"`
WNB bool `json:"wnb"` WNB bool `json:"wnb"`
WNBLevel int `json:"wnb_level"` WNBLevel int `json:"wnb_level"`
// SmartSDR v4 DSP (8000/Aurora series): NRL/ANFL (legacy LMS), NRS
// (spectral subtraction), RNN (AI NR), ANFT (FFT notch), NRF (NR w/filter).
// DSPV4 is true once the radio reported any of these keys — gates the UI.
LMSNR bool `json:"lms_nr"`
LMSNRLevel int `json:"lms_nr_level"`
LMSANF bool `json:"lms_anf"`
LMSANFLevel int `json:"lms_anf_level"`
SpeexNR bool `json:"speex_nr"`
SpeexNRLevel int `json:"speex_nr_level"`
RNN bool `json:"rnn"`
ANFT bool `json:"anft"`
NRF bool `json:"nrf"`
NRFLevel int `json:"nrf_level"`
DSPV4 bool `json:"dsp_v4"`
DAXCh int `json:"dax_ch"` // DAX audio channel of the active slice (0 = off, 1-8)
TXDAX bool `json:"tx_dax"` // DAX is the TX audio source (SmartSDR transmit-bar DAX button)
// RIT/XIT — offsets applied to the active slice's RX / TX frequency without // RIT/XIT — offsets applied to the active slice's RX / TX frequency without
// moving the slice. The offset survives the switch being turned off, so // moving the slice. The offset survives the switch being turned off, so
// re-enabling restores it, exactly like the radio's own knob. // re-enabling restores it, exactly like the radio's own knob.
@@ -386,6 +402,19 @@ type FlexController interface {
SetAPFLevel(int) error SetAPFLevel(int) error
SetWNB(bool) error SetWNB(bool) error
SetWNBLevel(int) error SetWNBLevel(int) error
// SmartSDR v4 DSP (8000/Aurora): NRL / ANFL / NRS / RNN / ANFT / NRF.
SetLMSNR(bool) error
SetLMSNRLevel(int) error
SetLMSANF(bool) error
SetLMSANFLevel(int) error
SetSpeexNR(bool) error
SetSpeexNRLevel(int) error
SetRNN(bool) error
SetANFT(bool) error
SetNRF(bool) error
SetNRFLevel(int) error
SetDAX(int) error // slice RX DAX channel 0 (off) - 8
SetTXDAX(bool) error // TX audio from DAX (transmit set dax=)
SetRIT(bool) error SetRIT(bool) error
SetRITFreq(int) error SetRITFreq(int) error
SetXIT(bool) error SetXIT(bool) error
+157 -10
View File
@@ -90,6 +90,22 @@ type flexSlice struct {
apfLevel int apfLevel int
wnb bool // wideband noise blanker wnb bool // wideband noise blanker
wnbLevel int wnbLevel int
// SmartSDR v4 DSP (8000/Aurora series). Key names per the FlexLib slice
// docs: lms_nr(NRL) / lms_anf(ANFL) / speex_nr(NRS) / rnnoise(RNN) /
// anft(ANFT) / nrf(NRF). Older radios never report them — dspV4 flips true
// the first time any of these keys appears, and gates the extra UI rows.
lmsNR bool // NRL — legacy LMS noise reduction
lmsNRLevel int
lmsANF bool // ANFL — legacy LMS auto-notch
lmsANFLevel int
speexNR bool // NRS — spectral subtraction NR
speexNRLevel int
rnnoise bool // RNN — AI noise reduction (on/off only)
anft bool // ANFT — FFT-based auto-notch (on/off only)
nrf bool // NRF — noise reduction w/ filter
nrfLevel int
dspV4 bool
daxCh int // DAX audio channel for this slice (0 = off, 1-8)
rit bool // receive incremental tuning enabled rit bool // receive incremental tuning enabled
ritFreq int // RIT offset in Hz (negative = down) ritFreq int // RIT offset in Hz (negative = down)
xit bool // transmit incremental tuning enabled xit bool // transmit incremental tuning enabled
@@ -120,6 +136,7 @@ type flexTX struct {
micLevel int micLevel int
filterLow int // TX filter low cut (Hz) filterLow int // TX filter low cut (Hz)
filterHigh int // TX filter high cut (Hz) filterHigh int // TX filter high cut (Hz)
dax bool // TX audio comes from DAX (SmartSDR's transmit-bar DAX button)
atuStatus string atuStatus string
atuMemories bool atuMemories bool
// CW keyer params (set via the top-level "cw" commands). // CW keyer params (set via the top-level "cw" commands).
@@ -431,6 +448,8 @@ func (f *Flex) handleStatus(payload string) {
f.tx.tunePower = atoiDefault(val, f.tx.tunePower) f.tx.tunePower = atoiDefault(val, f.tx.tunePower)
case "tune": case "tune":
f.tx.tune = val == "1" f.tx.tune = val == "1"
case "dax":
f.tx.dax = val == "1"
case "vox_enable": case "vox_enable":
f.tx.voxEnable = val == "1" f.tx.voxEnable = val == "1"
case "vox_level": case "vox_level":
@@ -499,7 +518,14 @@ func (f *Flex) handleStatus(payload string) {
alreadyBound := f.boundClientID != "" alreadyBound := f.boundClientID != ""
f.mu.Unlock() f.mu.Unlock()
lp := strings.ToLower(program) 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 { if !disconnected && clientID != "" && !alreadyBound && isGUI {
f.mu.Lock() f.mu.Lock()
f.boundClientID = clientID f.boundClientID = clientID
@@ -630,6 +656,18 @@ func (f *Flex) handleStatus(payload string) {
} }
if removed { if removed {
f.amp = flexAmp{} f.amp = flexAmp{}
// The amp's meters (FWD / ID / TEMP …) don't always get an individual
// "meter removed" push, so power-cycling the amp left the OLD meter
// ids in the maps while the re-registered amp added NEW ones — the
// panel then showed every amp meter twice. Drop every AMP-sourced
// meter here so only the fresh set survives.
for id, mi := range f.meterMeta {
if strings.Contains(strings.ToUpper(mi.src), "AMP") {
delete(f.meterMeta, id)
delete(f.meterVal, id)
delete(f.meterSub, id)
}
}
} }
f.mu.Unlock() f.mu.Unlock()
} }
@@ -641,6 +679,26 @@ func (f *Flex) handleStatus(payload string) {
f.meterRawLogged = true f.meterRawLogged = true
debugLog.Printf("Flex: meter status raw: %s", payload) debugLog.Printf("Flex: meter status raw: %s", payload)
} }
// Meter removal — "meter <id> removed": drop it so a stale value can't
// linger (e.g. after an amp/slice is torn down).
removedMeter := false
for _, tok := range fields[1:] {
if tok == "removed" || tok == "in_use=0" {
removedMeter = true
}
}
if removedMeter {
f.mu.Lock()
for _, tok := range fields[1:] {
if n, err := strconv.Atoi(strings.TrimSpace(tok)); err == nil {
delete(f.meterMeta, n)
delete(f.meterVal, n)
delete(f.meterSub, n)
}
}
f.mu.Unlock()
return
}
var newIDs []int var newIDs []int
f.mu.Lock() f.mu.Lock()
for _, tok := range fields[1:] { for _, tok := range fields[1:] {
@@ -809,6 +867,28 @@ func (f *Flex) handleStatus(payload string) {
s.wnb = val == "1" s.wnb = val == "1"
case "wnb_level": case "wnb_level":
s.wnbLevel = atoiDefault(val, s.wnbLevel) s.wnbLevel = atoiDefault(val, s.wnbLevel)
case "lms_nr":
s.lmsNR, s.dspV4 = val == "1", true
case "lms_nr_level":
s.lmsNRLevel, s.dspV4 = atoiDefault(val, s.lmsNRLevel), true
case "lms_anf":
s.lmsANF, s.dspV4 = val == "1", true
case "lms_anf_level":
s.lmsANFLevel, s.dspV4 = atoiDefault(val, s.lmsANFLevel), true
case "speex_nr":
s.speexNR, s.dspV4 = val == "1", true
case "speex_nr_level":
s.speexNRLevel, s.dspV4 = atoiDefault(val, s.speexNRLevel), true
case "rnnoise":
s.rnnoise, s.dspV4 = val == "1", true
case "anft":
s.anft, s.dspV4 = val == "1", true
case "nrf":
s.nrf, s.dspV4 = val == "1", true
case "nrf_level":
s.nrfLevel, s.dspV4 = atoiDefault(val, s.nrfLevel), true
case "dax":
s.daxCh = atoiDefault(val, s.daxCh)
case "rit_on": case "rit_on":
s.rit = val == "1" s.rit = val == "1"
case "rit_freq": case "rit_freq":
@@ -1121,7 +1201,11 @@ func (f *Flex) SendSpot(s SpotInfo) error {
} }
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d", cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix()) float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
if m := flexEncode(s.Mode); m != "" { // Convert to a real Flex mode (USB/LSB/CW/DIGU/…): SmartSDR only switches the
// slice mode on a spot click when mode= is one of ITS mode names — "SSB" or a
// bare cluster label is ignored, so the mode wouldn't change. adifModeToFlex
// also resolves SSB→USB/LSB from the spot frequency.
if m := flexEncode(adifModeToFlex(s.Mode, s.FreqHz)); m != "" {
cmd += " mode=" + m cmd += " mode=" + m
} }
if c := flexEncode(s.Comment); c != "" { if c := flexEncode(s.Comment); c != "" {
@@ -1274,6 +1358,7 @@ func (f *Flex) FlexState() FlexTXState {
MicLevel: f.tx.micLevel, MicLevel: f.tx.micLevel,
TXFilterLow: f.tx.filterLow, TXFilterLow: f.tx.filterLow,
TXFilterHigh: f.tx.filterHigh, TXFilterHigh: f.tx.filterHigh,
TXDAX: f.tx.dax,
MicProfile: f.micProfile, MicProfile: f.micProfile,
MicProfiles: f.micProfiles, MicProfiles: f.micProfiles,
ATUStatus: f.tx.atuStatus, ATUStatus: f.tx.atuStatus,
@@ -1328,6 +1413,18 @@ func (f *Flex) FlexState() FlexTXState {
st.APFLevel = rx.apfLevel st.APFLevel = rx.apfLevel
st.WNB = rx.wnb st.WNB = rx.wnb
st.WNBLevel = rx.wnbLevel st.WNBLevel = rx.wnbLevel
st.LMSNR = rx.lmsNR
st.LMSNRLevel = rx.lmsNRLevel
st.LMSANF = rx.lmsANF
st.LMSANFLevel = rx.lmsANFLevel
st.SpeexNR = rx.speexNR
st.SpeexNRLevel = rx.speexNRLevel
st.RNN = rx.rnnoise
st.ANFT = rx.anft
st.NRF = rx.nrf
st.NRFLevel = rx.nrfLevel
st.DSPV4 = rx.dspV4
st.DAXCh = rx.daxCh
st.RIT = rx.rit st.RIT = rx.rit
st.RITFreq = rx.ritFreq st.RITFreq = rx.ritFreq
st.XIT = rx.xit st.XIT = rx.xit
@@ -1396,6 +1493,28 @@ func (f *Flex) sendSlice(param string, val any) error {
rx.apf = val == "1" rx.apf = val == "1"
case "apf_level": case "apf_level":
rx.apfLevel = toInt(val) rx.apfLevel = toInt(val)
case "lms_nr":
rx.lmsNR = val == "1"
case "lms_nr_level":
rx.lmsNRLevel = toInt(val)
case "lms_anf":
rx.lmsANF = val == "1"
case "lms_anf_level":
rx.lmsANFLevel = toInt(val)
case "speex_nr":
rx.speexNR = val == "1"
case "speex_nr_level":
rx.speexNRLevel = toInt(val)
case "rnnoise":
rx.rnnoise = val == "1"
case "anft":
rx.anft = val == "1"
case "nrf":
rx.nrf = val == "1"
case "nrf_level":
rx.nrfLevel = toInt(val)
case "dax":
rx.daxCh = toInt(val)
case "rxant": case "rxant":
rx.rxAnt = fmt.Sprint(val) rx.rxAnt = fmt.Sprint(val)
case "txant": case "txant":
@@ -1515,6 +1634,7 @@ func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on))
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) } func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) } func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) } func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
// RIT/XIT — an offset applied to the RX (RIT) or TX (XIT) frequency of the active // RIT/XIT — an offset applied to the RX (RIT) or TX (XIT) frequency of the active
// slice, without moving the slice itself. SmartSDR keeps the offset even while the // slice, without moving the slice itself. SmartSDR keeps the offset even while the
// switch is off, so turning RIT back on restores the last offset — same as the // switch is off, so turning RIT back on restores the last offset — same as the
@@ -1537,6 +1657,37 @@ func clampOffset(hz int) int {
func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) } func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) }
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) } func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }
// SmartSDR v4 DSP (8000/Aurora series) — the extra noise tools SmartSDR ships
// beyond nb/nr/anf/wnb. Key names per the FlexLib slice command docs.
func (f *Flex) SetLMSNR(on bool) error { return f.sendSlice("lms_nr", boolFlex(on)) }
func (f *Flex) SetLMSNRLevel(l int) error { return f.sendSlice("lms_nr_level", clampLevel(l)) }
func (f *Flex) SetLMSANF(on bool) error { return f.sendSlice("lms_anf", boolFlex(on)) }
func (f *Flex) SetLMSANFLevel(l int) error { return f.sendSlice("lms_anf_level", clampLevel(l)) }
func (f *Flex) SetSpeexNR(on bool) error { return f.sendSlice("speex_nr", boolFlex(on)) }
func (f *Flex) SetSpeexNRLevel(l int) error { return f.sendSlice("speex_nr_level", clampLevel(l)) }
func (f *Flex) SetRNN(on bool) error { return f.sendSlice("rnnoise", boolFlex(on)) }
func (f *Flex) SetANFT(on bool) error { return f.sendSlice("anft", boolFlex(on)) }
func (f *Flex) SetNRF(on bool) error { return f.sendSlice("nrf", boolFlex(on)) }
func (f *Flex) SetNRFLevel(l int) error { return f.sendSlice("nrf_level", clampLevel(l)) }
// SetDAX routes the active slice's RX audio to a DAX channel (0 = off, 1-8) —
// the SmartSDR slice "DAX" selector, e.g. to feed WSJT-X via DAX audio.
func (f *Flex) SetDAX(ch int) error {
if ch < 0 {
ch = 0
}
if ch > 8 {
ch = 8
}
return f.sendSlice("dax", ch)
}
// SetTXDAX toggles DAX as the TRANSMIT audio source — SmartSDR's transmit-bar
// DAX button ("transmit set dax="), the one used to send WSJT-X audio.
func (f *Flex) SetTXDAX(on bool) error {
return f.txSet("transmit set dax="+boolFlex(on), "dax", func(t *flexTX) { t.dax = on })
}
func (f *Flex) SetWNB(on bool) error { return f.sendSlice("wnb", boolFlex(on)) } func (f *Flex) SetWNB(on bool) error { return f.sendSlice("wnb", boolFlex(on)) }
func (f *Flex) SetWNBLevel(l int) error { return f.sendSlice("wnb_level", clampLevel(l)) } func (f *Flex) SetWNBLevel(l int) error { return f.sendSlice("wnb_level", clampLevel(l)) }
@@ -1745,15 +1896,11 @@ func (f *Flex) SetTune(on bool) error {
// SmartSDR refuses to transmit while inhibit is set, so it holds even against a // 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. // 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 // `transmit set inhibit=` is the whole mechanism. We used to also send
// blocked. The reason is best-effort (a separate interlock object); the inhibit // `interlock set reason=OpsLog` to label WHY TX was blocked, but `reason` is a
// is what actually blocks TX, so a rig that ignores the reason still stays safe. // 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 { 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 }) return f.txSet("transmit set inhibit="+boolFlex(on), "inhibit", func(t *flexTX) { t.inhibit = on })
} }
+49 -17
View File
@@ -224,11 +224,14 @@ func (b *IcomSerial) Connect() error {
idAddr = f.Data[1] idAddr = f.Data[1]
} }
} }
// Dual-scope rigs (IC-7610/9700) prefix each waveform frame with a main/sub // Whether the rig's scope protocol carries a leading main/sub SELECTOR byte on
// selector byte; single-scope rigs (IC-7300…) do not. Decide from the // every 0x27 frame and command. The IC-7610/9700 (dual scope) do — and the logs
// IDENTIFIED model, NOT the configured address: an IC-7300 run at 0x98 must // prove the IC-7300 does too (frames arrive as `27 00 00 <seq> <total> …` and a
// still parse single-scope frames (this was the "scope blank on the 7300" bug). // mode read answers `27 14 00 <mode>`), even though it has a single scope. The
b.dualScope = idAddr == 0x98 || idAddr == 0xA2 // waveform parser auto-detects this per-frame regardless, so a 7300 at a
// non-default address still RENDERS; this flag only drives the SET/read commands
// (mode, span, edges), which need the 0x00 selector to be accepted on the 7300.
b.dualScope = idAddr == 0x98 || idAddr == 0xA2 || idAddr == 0x94
// Defer the DSP snapshot until the rig actually answers CI-V. Over the network // Defer the DSP snapshot until the rig actually answers CI-V. Over the network
// the rig may still be booting (or off) at Connect, so an immediate readDSP // the rig may still be booting (or off) at Connect, so an immediate readDSP
// would time out and leave every control at 0 / off with no retry. ReadState // would time out and leave every control at 0 / off with no retry. ReadState
@@ -603,12 +606,20 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
rawN++ rawN++
applog.Printf("icom scope raw #%d: len=%d data=[% X]", rawN, len(f.Data), f.Data) applog.Printf("icom scope raw #%d: len=%d data=[% X]", rawN, len(f.Data), f.Data)
} }
// Frame layout after the 0x00 sub-command byte is one of:
// [selector][seq][total][data…] (IC-7610/9700 AND, as the logs prove,
// the IC-7300 — selector 0x00 = main)
// [seq][total][data…] (a rig with no selector byte)
// The sequence starts at 1, so a 0x00 at Data[1] can only be the main-scope
// selector — never a valid sequence. Keying this off the rig ADDRESS was the
// long-standing "blank scope on the IC-7300" bug: the 7300 DOES send the
// selector, so idx stayed 1, seq was read as 0x00, and every frame was
// dropped. Detect it from the byte itself instead — address-independent.
idx := 1 idx := 1
if b.dualScope { if len(f.Data) >= 2 && f.Data[1] == 0x00 {
if len(f.Data) < 2 || f.Data[1] != 0x00 { idx = 2 // leading main-scope selector byte present
continue // only the MAIN scope } else if b.dualScope {
} continue // dual-scope rig SUB frame (selector != 0x00) — main only
idx = 2
} }
if len(f.Data) < idx+2 { if len(f.Data) < idx+2 {
continue continue
@@ -661,9 +672,23 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
if seq == 1 { // header frame — begins a new sweep, no waveform data if seq == 1 { // header frame — begins a new sweep, no waveform data
regions = make(map[byte][]byte) regions = make(map[byte][]byte)
total = tot total = tot
if len(region) >= 11 { // [info][low 5][high 5] if len(region) >= 11 { // [info][freq1 5-BCD][freq2 5-BCD]
low := civ.BCDToFreq(region[1:6]) // Same center/fixed disambiguation as the single-frame (net) path:
high := civ.BCDToFreq(region[6:11]) // FIXED sends [low-edge][high-edge] (both absolute), CENTER sends
// [center][span]. Tell them apart by magnitude — a second value
// BIGGER than the first is a real high edge; a smaller one is a
// span. Without this, an IC-7300 in center (VFO-following) mode set
// high = span (e.g. 100 kHz), so high < low and the panadapter had
// no valid range to map the trace onto → blank scope. FIXED mode
// parsed fine, which is why only center mode was broken.
v1 := civ.BCDToFreq(region[1:6])
v2 := civ.BCDToFreq(region[6:11])
var low, high int64
if v2 > v1 {
low, high = v1, v2 // absolute low/high edges (fixed)
} else {
low, high = v1-v2/2, v1+v2/2 // centre + span (centre-on-VFO)
}
b.scopeMu.Lock() b.scopeMu.Lock()
b.scopeLow, b.scopeHigh = low, high b.scopeLow, b.scopeHigh = low, high
b.scopeMu.Unlock() b.scopeMu.Unlock()
@@ -711,12 +736,19 @@ func (b *IcomSerial) SetScope(on bool) error {
// but nothing shows, compare its `icom scope raw` frames against this. // but nothing shows, compare its `icom scope raw` frames against this.
applog.Printf("icom scope: enable on rig=%q addr=0x%02X dualScope=%v (expect %s frame layout)", applog.Printf("icom scope: enable on rig=%q addr=0x%02X dualScope=%v (expect %s frame layout)",
b.model, b.rigAddr, b.dualScope, map[bool]string{true: "27 00 [MS] [seq] [total] …", false: "27 00 [seq] [total] …"}[b.dualScope]) b.model, b.rigAddr, b.dualScope, map[bool]string{true: "27 00 [MS] [seq] [total] …", false: "27 00 [seq] [total] …"}[b.dualScope])
// Turn the scope DISPLAY on — needed so the rig actually streams (esp. when
// remote and we can't touch the front panel). ONLY on enable: we must never
// switch the display OFF, because that is the operator's own scope on the
// radio, and closing OpsLog (SetScope(false)) blanking a local IC-7300's
// screen is exactly the regression this avoids. Some firmwares don't ack a
// 0x27 set; a timeout isn't fatal, so log and continue.
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, 0x01); err != nil {
applog.Printf("icom scope: display on ack: %v", err)
} }
// Some firmwares don't ack 0x27 sets; a timeout here isn't fatal, so log and
// continue rather than abort the second command.
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, boolByte(on)); err != nil {
applog.Printf("icom scope: display on=%v ack: %v", on, err)
} }
// Waveform data OUTPUT over CI-V: enabled with the scope, and — crucially —
// the ONLY thing we switch off on disable, so the radio's own scope display is
// left exactly as the operator had it.
if err := b.exec(civ.CmdScope, civ.SubScopeOn, boolByte(on)); err != nil { if err := b.exec(civ.CmdScope, civ.SubScopeOn, boolByte(on)); err != nil {
applog.Printf("icom scope: output on=%v ack: %v", on, err) applog.Printf("icom scope: output on=%v ack: %v", on, err)
} }
+15 -4
View File
@@ -267,12 +267,23 @@ func (o *OmniRig) SetFrequency(hz int64) error {
// method (RX=TX=freq, simplex). It works on rigs — notably Icom (IC-9100) — // method (RX=TX=freq, simplex). It works on rigs — notably Icom (IC-9100) —
// where direct FreqA/FreqB writes are accepted but never move the radio. // where direct FreqA/FreqB writes are accepted but never move the radio.
// Clearing split is the right thing when tuning to a spot anyway. // Clearing split is the right thing when tuning to a spot anyway.
simplexOK := false
if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil { if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil {
simplexOK = true
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode(%d) OK", hz32) debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode(%d) OK", hz32)
} else { } else {
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode unavailable (%v) — using property writes", err) debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode unavailable (%v)", err)
// Fallback: write the active VFO's property AND the generic Freq }
// (always — some .ini honour only one, and split here is often misread).
// On Yaesu / Kenwood (and anything non-Icom), SetSimplexMode frequently returns
// OK but is a SILENT NO-OP — the rig never moves. Confirmed on an FT-891 over
// OmniRig: every spot click logged "SetSimplexMode OK" yet FreqA stayed put,
// while SetMode on the same .ini worked fine (so the CAT link is healthy).
// Writing the VFO frequency PROPERTY directly DOES move them, so also do that
// here. It is skipped on Icom, where the direct write is the unreliable one and
// could nudge the wrong Main/Sub VFO — there SetSimplexMode is authoritative.
isIcom := strings.HasPrefix(strings.ToUpper(strings.TrimSpace(rigType)), "IC")
if !isIcom || !simplexOK {
prop := "FreqA" prop := "FreqA"
switch vfo { switch vfo {
case "B", "BB", "BA": case "B", "BB", "BA":
@@ -287,7 +298,7 @@ func (o *OmniRig) SetFrequency(hz int64) error {
okAny = true okAny = true
} }
} }
if !okAny { if !simplexOK && !okAny {
return fmt.Errorf("OmniRig: no writable frequency property for this rig") return fmt.Errorf("OmniRig: no writable frequency property for this rig")
} }
} }
+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)
}
}
}
+49
View File
@@ -207,9 +207,58 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
Dialect = "sqlite" // migration failed; we'll fall back to SQLite Dialect = "sqlite" // migration failed; we'll fall back to SQLite
return nil, err 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 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 // isFreshMySQL reports whether the database has no OpsLog schema yet (no qso
// table), so the baseline fast-path applies. A partially-migrated database is // table), so the baseline fast-path applies. A partially-migrated database is
// NOT fresh and goes through the incremental migrator. // NOT fresh and goes through the incremental migrator.
+14 -2
View File
@@ -41,6 +41,16 @@ func (c Config) opts() []mail.Option {
// Send delivers a plain-text email to `to`, optionally attaching a file. // Send delivers a plain-text email to `to`, optionally attaching a file.
func Send(cfg Config, to, subject, body, attachPath string) error { func Send(cfg Config, to, subject, body, attachPath string) error {
var attach []string
if attachPath != "" {
attach = []string{attachPath}
}
return SendFiles(cfg, to, subject, body, attach)
}
// SendFiles is like Send but attaches any number of files (missing paths are
// skipped by the mail library at send time).
func SendFiles(cfg Config, to, subject, body string, attachPaths []string) error {
if cfg.Host == "" { if cfg.Host == "" {
return fmt.Errorf("SMTP server not configured") return fmt.Errorf("SMTP server not configured")
} }
@@ -65,8 +75,10 @@ func Send(cfg Config, to, subject, body, attachPath string) error {
} }
m.Subject(subject) m.Subject(subject)
m.SetBodyString(mail.TypeTextPlain, body) m.SetBodyString(mail.TypeTextPlain, body)
if attachPath != "" { for _, p := range attachPaths {
m.AttachFile(attachPath) if p != "" {
m.AttachFile(p)
}
} }
client, err := mail.NewClient(cfg.Host, cfg.opts()...) client, err := mail.NewClient(cfg.Host, cfg.opts()...)
if err != nil { if err != nil {
+20 -1
View File
@@ -15,6 +15,8 @@ import (
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
"hamlog/internal/applog"
) )
const ( const (
@@ -33,6 +35,7 @@ type Status struct {
State string `json:"state,omitempty"` // IDLE / TRANSMIT_A … State string `json:"state,omitempty"` // IDLE / TRANSMIT_A …
FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST
Temperature float64 `json:"temperature"` Temperature float64 `json:"temperature"`
Operate bool `json:"operate"` // OPERATE vs STANDBY (optimistic until the amp reports it)
} }
type Client struct { type Client struct {
@@ -45,6 +48,7 @@ type Client struct {
statusMu sync.RWMutex statusMu sync.RWMutex
status Status status Status
lastRaw string // last raw status payload — logged on change so unknown fields (operate?) can be mapped from a real amp
// Optimistic fan mode kept until the amp's status poll confirms it (or it // Optimistic fan mode kept until the amp's status poll confirms it (or it
// ages out) — otherwise a stale poll right after a change reverts the UI. // ages out) — otherwise a stale poll right after a change reverts the UI.
fanPending string fanPending string
@@ -119,8 +123,15 @@ func (c *Client) SetOperate(on bool) error {
if on { if on {
v = "1" v = "1"
} }
_, err := c.command("operate=" + v) if _, err := c.command("operate=" + v); err != nil {
return err return err
}
// Optimistic: the status poll's "operate" field (when the firmware reports
// one) confirms or corrects this.
c.statusMu.Lock()
c.status.Operate = on
c.statusMu.Unlock()
return nil
} }
func (c *Client) pollLoop() { func (c *Client) pollLoop() {
@@ -215,6 +226,12 @@ func (c *Client) parse(resp string) {
c.statusMu.Lock() c.statusMu.Lock()
c.status.Connected = true c.status.Connected = true
c.status.LastError = "" c.status.LastError = ""
// Log each DISTINCT status payload once: the PGXL's field set isn't fully
// documented, so this is how we learn the real key for e.g. operate/standby.
if data != c.lastRaw {
c.lastRaw = data
applog.Printf("pgxl: status raw=%q", data)
}
for _, pair := range strings.Fields(data) { for _, pair := range strings.Fields(data) {
kv := strings.SplitN(pair, "=", 2) kv := strings.SplitN(pair, "=", 2)
if len(kv) != 2 { if len(kv) != 2 {
@@ -223,6 +240,8 @@ func (c *Client) parse(resp string) {
switch kv[0] { switch kv[0] {
case "state": case "state":
c.status.State = kv[1] c.status.State = kv[1]
case "operate":
c.status.Operate = kv[1] == "1"
case "fanmode": case "fanmode":
dev := strings.ToUpper(kv[1]) dev := strings.ToUpper(kv[1])
// Honour a recent optimistic change until the amp confirms it. // Honour a recent optimistic change until the amp confirms it.
+7
View File
@@ -26,6 +26,13 @@ type ProfileDB struct {
User string `json:"user"` User string `json:"user"`
Password string `json:"password"` Password string `json:"password"`
Database string `json:"database"` 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: // Profile is one operating configuration. A user typically keeps a few:
+84 -1
View File
@@ -366,6 +366,42 @@ func decodeExtras(s string) map[string]string {
return m 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. // Add inserts a QSO and returns its ID.
func (r *Repo) Add(ctx context.Context, q QSO) (int64, error) { func (r *Repo) Add(ctx context.Context, q QSO) (int64, error) {
q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign)) q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign))
@@ -792,6 +828,31 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
return n, nil 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
// frequency after CAT dropped. freqHz "" is not allowed (caller validates).
func (r *Repo) BulkSetFrequency(ctx context.Context, ids []int64, freqHz int64, band string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
ph := make([]string, len(ids))
args := make([]any, 0, len(ids)+3)
args = append(args, freqHz, band, db.NowISO())
for i, id := range ids {
ph[i] = "?"
args = append(args, id)
}
res, err := r.db.ExecContext(ctx,
`UPDATE qso SET freq_hz = ?, band = ?, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
args...)
if err != nil {
return 0, fmt.Errorf("bulk set frequency: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
// SetExtra merges ONE key into a QSO's extras JSON without rewriting the rest of // SetExtra merges ONE key into a QSO's extras JSON without rewriting the rest of
// the row. A slow caller that only wants to stamp its own app field (e-mailing a // the row. A slow caller that only wants to stamp its own app field (e-mailing a
// QSL card, saving a recording) must not do a full-row Update: the row it read may // QSL card, saving a recording) must not do a full-row Update: the row it read may
@@ -1345,6 +1406,10 @@ type WorkedBefore struct {
DXCCModes []string `json:"dxcc_modes"` DXCCModes []string `json:"dxcc_modes"`
DXCCBandModes []BandMode `json:"dxcc_band_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 // 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 // (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). // status for that cell is kept (call_c > call_w > dxcc_c > dxcc_w).
@@ -1371,6 +1436,17 @@ func modeClass(mode string) string {
} }
} }
// GroupDigitalMode collapses every digital mode into the single "DIG" bucket
// (FT8, FT4, RTTY, PSK… all become one mode) while leaving phone modes and CW
// untouched. Used as the optional slot normaliser when the operator prefers
// DXCC-style mode classes over per-mode slots (Settings → General).
func GroupDigitalMode(mode string) string {
if modeClass(mode) == "DIG" {
return "DIG"
}
return strings.ToUpper(mode)
}
// BandMode is a (band, mode) pair used for the NEW SLOT check. // BandMode is a (band, mode) pair used for the NEW SLOT check.
type BandMode struct { type BandMode struct {
Band string `json:"band"` Band string `json:"band"`
@@ -1802,7 +1878,11 @@ type EntitySlot struct {
// 0 (unresolvable) skips the QSO. // 0 (unresolvable) skips the QSO.
// //
// One DB scan regardless of input size. Cheap to call per cluster batch. // One DB scan regardless of input size. Cheap to call per cluster batch.
func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int) (map[int]*EntitySlot, error) { //
// normMode (nil = identity) maps each QSO's mode before it is stored as a
// Modes/Slots key — pass GroupDigitalMode to collapse all digital modes into
// one bucket. Callers must normalise their lookup mode the same way.
func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int, normMode func(string) string) (map[int]*EntitySlot, error) {
rows, err := r.db.QueryContext(ctx, rows, err := r.db.QueryContext(ctx,
`SELECT callsign, coalesce(dxcc,0), lower(coalesce(country,'')), lower(band), upper(mode) FROM qso `SELECT callsign, coalesce(dxcc,0), lower(coalesce(country,'')), lower(band), upper(mode) FROM qso
WHERE band IS NOT NULL AND band != '' WHERE band IS NOT NULL AND band != ''
@@ -1827,6 +1907,9 @@ func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, store
if key == 0 { if key == 0 {
continue continue
} }
if normMode != nil {
mode = normMode(mode)
}
e, ok := out[key] e, ok := out[key]
if !ok { if !ok {
e = &EntitySlot{ e = &EntitySlot{
+143
View File
@@ -0,0 +1,143 @@
// Package gs232 drives rotator controllers that speak the Yaesu GS-232A
// protocol, over a raw TCP socket or a serial COM port. The target device is
// the microHAM ARCO: both its LAN "CONTROL PROTOCOL" setting (a TCP port) and
// its USB port ("USB CONTROL PROTOCOL", a virtual COM where the baud rate is
// irrelevant) can be set to speak Yaesu GS-232A — so OpsLog controls it
// directly, no PstRotator in between. ARCO accepts up to four parallel LAN
// connections, and commands are single CR-terminated lines, so short
// per-call connections (same idiom as the other rotator backends) work fine.
//
// GS-232A subset used:
//
// Maaa<CR> move to azimuth aaa (000-450)
// S<CR> stop rotation
// C<CR> query azimuth — replies "+0aaa" (GS-232A) or "AZ=aaa" (GS-232B
// flavour); both are parsed.
package gs232
import (
"fmt"
"io"
"net"
"regexp"
"strconv"
"strings"
"time"
"go.bug.st/serial"
)
const (
dialTimeout = 3 * time.Second
ioTimeout = 2 * time.Second
)
// Client is a stateless per-call sender, mirroring the pst/rotgenius idiom.
// Exactly one of (Host, Port) or ComPort is used, per Transport.
type Client struct {
Host string
Port int
ComPort string // serial transport: "COM5" etc. (ARCO USB virtual COM — any baud)
}
// New returns a TCP Client with sane defaults applied for empty fields. There
// is no standard port: the number is whatever the user typed into the ARCO's
// LAN CONTROL PROTOCOL setting — 4001 is only a placeholder.
func New(host string, port int) *Client {
if host == "" {
host = "127.0.0.1"
}
if port <= 0 || port > 65535 {
port = 4001
}
return &Client{Host: host, Port: port}
}
// NewSerial returns a Client talking over the ARCO's USB virtual COM port. The
// baud rate is irrelevant on USB per the ARCO manual (8N1 framing matters); we
// open at 9600 which also suits a real RS-232 hookup left at its default.
func NewSerial(comPort string) *Client {
return &Client{ComPort: comPort}
}
// roundTrip opens a connection (TCP or serial per the client's config), sends
// one CR-terminated command and (when wantReply) reads one CR/LF-terminated
// reply line.
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
var conn io.ReadWriteCloser
if c.ComPort != "" {
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: 9600})
if err != nil {
return "", fmt.Errorf("open ARCO %s: %w", c.ComPort, err)
}
_ = sp.SetReadTimeout(200 * time.Millisecond)
conn = sp
} else {
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
if err != nil {
return "", fmt.Errorf("connect ARCO %s:%d: %w", c.Host, c.Port, err)
}
_ = nc.SetDeadline(time.Now().Add(ioTimeout))
conn = nc
}
defer conn.Close()
if _, err := conn.Write([]byte(cmd + "\r")); err != nil {
return "", fmt.Errorf("send %q: %w", cmd, err)
}
if !wantReply {
return "", nil
}
buf := make([]byte, 64)
var sb strings.Builder
deadline := time.Now().Add(ioTimeout)
for time.Now().Before(deadline) {
n, err := conn.Read(buf)
if n > 0 {
sb.Write(buf[:n])
if strings.ContainsAny(sb.String(), "\r\n") {
break
}
}
// A serial read that times out returns (0, nil) — keep polling until the
// overall deadline; a real error ends the read.
if err != nil {
break
}
}
line := strings.TrimSpace(sb.String())
if line == "" {
return "", fmt.Errorf("no reply to %q", cmd)
}
return line, nil
}
// GoTo points the antenna at the given azimuth (0-359). GS-232A takes M000-M450
// (overlap rotators accept >360); we normalise to [0,360).
func (c *Client) GoTo(az int) error {
az = ((az % 360) + 360) % 360
_, err := c.roundTrip(fmt.Sprintf("M%03d", az), false)
return err
}
// Stop interrupts any in-progress rotation.
func (c *Client) Stop() error {
_, err := c.roundTrip("S", false)
return err
}
// azRe matches both reply flavours: "+0aaa" (GS-232A) and "AZ=aaa" (GS-232B).
var azRe = regexp.MustCompile(`(?:\+0|AZ=)(\d{3})`)
// Heading queries the current azimuth. Returns the raw reply for diagnostics.
func (c *Client) Heading() (az int, raw string, err error) {
raw, err = c.roundTrip("C", true)
if err != nil {
return 0, raw, err
}
m := azRe.FindStringSubmatch(raw)
if m == nil {
return 0, raw, fmt.Errorf("unrecognised azimuth reply %q", raw)
}
az, _ = strconv.Atoi(m[1])
return az % 360, raw, nil
}
+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
}
+18 -1
View File
@@ -23,6 +23,7 @@
package steppir package steppir
import ( import (
"bytes"
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"io" "io"
@@ -84,6 +85,12 @@ type Client struct {
lastStatus *Status lastStatus *Status
lastSetKHz int 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 — // 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 // the motors take a second or two, and a stale poll would otherwise snap the
// UI back. Same trick as the Ultrabeam client. // UI back. Same trick as the Ultrabeam client.
@@ -242,7 +249,17 @@ func (c *Client) queryStatus() (*Status, error) {
if _, err := io.ReadFull(conn, buf); err != nil { if _, err := io.ReadFull(conn, buf); err != nil {
return nil, fmt.Errorf("read status: %w", err) return nil, fmt.Errorf("read status: %w", err)
} }
return parseStatus(buf) 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. // parseStatus decodes an 11-byte status frame.
+496
View File
@@ -0,0 +1,496 @@
// 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
reconnectDelay = 2 * time.Second
)
// 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()
for {
select {
case <-c.stop:
return
case <-t.C:
fresh := false
if c.needConnect() {
if err := c.ensureConnected(); err != nil {
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() })
continue
}
fresh = true
}
// 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 {
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
}
+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) }
+326
View File
@@ -0,0 +1,326 @@
package winkeyer
// serialcw.go — CW keying by toggling a serial control line, the "hardware CW
// keying" method N1MM / WSJT / fldigi use with a Yaesu SCU-17 and similar
// interfaces (DTR = CW key, RTS = PTT). No K1EL WinKeyer chip involved: the PC
// bit-bangs the Morse itself on the DTR (or RTS) line at the configured WPM.
//
// A single worker goroutine consumes queued text and keys it element by element,
// so Send returns immediately (like the WinKeyer's buffered send) and Stop can
// abort mid-message. Speed changes apply live between characters. PTT (when
// enabled) is asserted on the OTHER line for the whole transmission plus a
// lead-in / tail, and held through a short hang so send-on-type doesn't chatter.
import (
"strings"
"sync"
"sync/atomic"
"time"
"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{
'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': "----.",
'.': ".-.-.-", ',': "--..--", '?': "..--..", '/': "-..-.", '=': "-...-",
'+': ".-.-.", '-': "-....-", ':': "---...", '(': "-.--.", ')': "-.--.-",
';': "-.-.-.", '"': ".-..-.", '\'': ".----.", '@': ".--.-.",
}
// serialKeyer bit-bangs CW on a serial control line. Owned by a winkeyer.Manager
// 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 {
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
wpm int
farns int
abort chan struct{} // recreated by Stop; keying aborts when it closes
in chan string
stop chan struct{}
done chan struct{}
}
func newSerialKeyer(line lineCtl, cfg Config, onBusy func(bool)) *serialKeyer {
k := &serialKeyer{
line: line,
onBusy: onBusy,
wpm: cfg.WPM,
farns: cfg.Farnsworth,
abort: make(chan struct{}),
in: make(chan string, 256),
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.Load() }
// setKey raises/lowers the CW key line; setPTT the PTT line (only when enabled).
func (k *serialKeyer) setKey(down bool) {
if k.keyDTR.Load() {
_ = k.line.setDTR(k.level(down))
} else {
_ = k.line.setRTS(k.level(down))
}
}
func (k *serialKeyer) setPTT(on bool) {
if !k.usePTT.Load() {
return
}
if k.keyDTR.Load() {
_ = k.line.setRTS(k.level(on))
} else {
_ = 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.line.setDTR(k.level(false))
_ = k.line.setRTS(k.level(false))
}
// SetSpeed / Send / Stop mirror the WinKeyer manager's control surface.
func (k *serialKeyer) SetSpeed(wpm int) {
k.mu.Lock()
k.wpm = wpm
k.mu.Unlock()
}
func (k *serialKeyer) Send(text string) {
select {
case k.in <- text:
default: // queue full (pathological) — drop rather than block the app binding
}
}
// Stop aborts the character being keyed and flushes anything queued.
func (k *serialKeyer) Stop() {
k.mu.Lock()
close(k.abort)
k.abort = make(chan struct{})
k.mu.Unlock()
for drained := false; !drained; {
select {
case <-k.in:
default:
drained = true
}
}
k.setKey(false)
}
func (k *serialKeyer) Close() {
close(k.stop)
<-k.done
_ = k.line.close()
}
func (k *serialKeyer) run() {
defer close(k.done)
defer k.idle()
for {
select {
case <-k.stop:
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.Load()) * time.Millisecond)
k.keyText(s)
hang := time.Duration(k.tailMs.Load()) * time.Millisecond
if hang <= 0 {
hang = 5 * time.Millisecond
}
hold: // hold PTT briefly so back-to-back sends (send-on-type) don't chatter
for {
select {
case <-k.stop:
k.idle()
k.onBusy(false)
return
case s2 := <-k.in:
k.keyText(s2)
case <-time.After(hang):
break hold
}
}
k.setPTT(false)
k.onBusy(false)
}
}
}
// keyText keys one string. Element gaps use the character speed (WPM); the
// inter-character (3-unit) and inter-word (7-unit) gaps use the Farnsworth speed
// when one is set, so slow-copy CW keeps full-speed characters with wider spacing.
func (k *serialKeyer) keyText(s string) {
k.mu.Lock()
abort := k.abort
k.mu.Unlock()
for _, r := range strings.ToUpper(s) {
select {
case <-abort:
k.setKey(false)
return
case <-k.stop:
k.setKey(false)
return
default:
}
ditW, ditF := k.dits()
if r == ' ' {
// Word gap: 7 units total. A 3-unit inter-char gap was already emitted
// after the previous character, so add 4 more.
if !k.gap(4*ditF, abort) {
return
}
continue
}
code, ok := morseTable[r]
if !ok {
continue
}
for j, el := range code {
if el == '.' {
if !k.mark(ditW, abort) {
return
}
} else {
if !k.mark(3*ditW, abort) {
return
}
}
if j < len(code)-1 { // intra-character (element) gap: 1 unit
if !k.gap(ditW, abort) {
return
}
}
}
if !k.gap(3*ditF, abort) { // inter-character gap: 3 units
return
}
}
}
// mark holds the key down for d; gap holds it up for d. Both abort cleanly
// (leaving the key UP) when Stop closes abort or the keyer shuts down.
func (k *serialKeyer) mark(d time.Duration, abort chan struct{}) bool {
k.setKey(true)
ok := k.wait(d, abort)
k.setKey(false)
return ok
}
func (k *serialKeyer) gap(d time.Duration, abort chan struct{}) bool { return k.wait(d, abort) }
func (k *serialKeyer) wait(d time.Duration, abort chan struct{}) bool {
if d <= 0 {
return true
}
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
return true
case <-abort:
return false
case <-k.stop:
return false
}
}
// sleep is a non-abortable pause (used for the short PTT lead-in).
func (k *serialKeyer) sleep(d time.Duration) {
if d <= 0 {
return
}
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
case <-k.stop:
}
}
// dits returns the element (character-speed) dit and the spacing (Farnsworth)
// dit as durations, read live so a speed change mid-message takes effect.
func (k *serialKeyer) dits() (elem, space time.Duration) {
k.mu.Lock()
w, f := k.wpm, k.farns
k.mu.Unlock()
if w < 5 {
w = 5
}
if w > 99 {
w = 99
}
elem = time.Duration(float64(time.Millisecond) * 1200.0 / float64(w))
space = elem
if f > 0 && f < w {
space = time.Duration(float64(time.Millisecond) * 1200.0 / float64(f))
}
return elem, space
}
+110 -2
View File
@@ -49,6 +49,16 @@ type Config struct {
AutoSpace bool `json:"autospace"` // auto letter-space AutoSpace bool `json:"autospace"` // auto letter-space
UsePTT bool `json:"use_ptt"` // key PTT (Key/PTT output) UsePTT bool `json:"use_ptt"` // key PTT (Key/PTT output)
SerialEcho bool `json:"serial_echo"` // device echoes sent chars back to host SerialEcho bool `json:"serial_echo"` // device echoes sent chars back to host
// Type selects the keyer engine on this serial port:
// "" / "k1el" → a K1EL WinKeyer chip (the default, everything above applies)
// "serial" → the PC bit-bangs Morse on a control line (no WinKeyer chip):
// the "hardware CW keying" a Yaesu SCU-17 / generic interface
// uses. WPM / Weight / Farnsworth / LeadIn / Tail / UsePTT still
// apply; the paddle/sidetone/ratio fields are WinKeyer-only.
Type string `json:"type"`
CWKeyLine string `json:"cw_key_line"` // serial: "dtr" (CW on DTR, PTT on RTS — default) | "rts"
CWInvert bool `json:"cw_invert"` // serial: invert line polarity (active-LOW) for both key and PTT
} }
func (c Config) normalised() Config { func (c Config) normalised() Config {
@@ -93,6 +103,7 @@ type Manager struct {
status Status status Status
stopRead chan struct{} stopRead chan struct{}
doneRead chan struct{} doneRead chan struct{}
serial *serialKeyer // non-nil when cfg.Type == "serial" (DTR/RTS line keying)
onStatus func(Status) onStatus func(Status)
onEcho func(string) // chars the device echoes back as it keys them onEcho func(string) // chars the device echoes back as it keys them
@@ -130,6 +141,9 @@ func (m *Manager) Connect(cfg Config) error {
if strings.TrimSpace(cfg.Port) == "" { if strings.TrimSpace(cfg.Port) == "" {
return fmt.Errorf("winkeyer: no serial port selected") return fmt.Errorf("winkeyer: no serial port selected")
} }
if cfg.Type == "serial" {
return m.connectSerial(cfg)
}
m.Disconnect() // drop any existing link first m.Disconnect() // drop any existing link first
p, err := serial.Open(cfg.Port, &serial.Mode{ p, err := serial.Open(cfg.Port, &serial.Mode{
@@ -175,6 +189,59 @@ func (m *Manager) Connect(cfg Config) error {
return nil 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
// 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(line, cfg, func(busy bool) {
m.mu.Lock()
changed := m.status.Busy != busy
m.status.Busy = busy
m.mu.Unlock()
if changed {
m.emit()
}
})
m.mu.Lock()
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()
lineDesc := "DTR (CW) / RTS (PTT)"
if strings.EqualFold(cfg.CWKeyLine, "rts") {
lineDesc = "RTS (CW) / DTR (PTT)"
}
applog.Printf("winkeyer: serial CW keyer on %s — %s, PTT=%v (EscapeCommFunction line ctl)", cfg.Port, lineDesc, cfg.UsePTT)
m.emit()
return nil
}
// applyConfig pushes the keying parameters to the device. // applyConfig pushes the keying parameters to the device.
func (m *Manager) applyConfig(c Config) error { func (m *Manager) applyConfig(c Config) error {
cmds := [][]byte{ cmds := [][]byte{
@@ -256,7 +323,12 @@ func (m *Manager) SetSpeed(wpm int) error {
if wpm > 99 { if wpm > 99 {
wpm = 99 wpm = 99
} }
if err := m.write([]byte{0x02, byte(wpm)}); err != nil { m.mu.Lock()
sk := m.serial
m.mu.Unlock()
if sk != nil {
sk.SetSpeed(wpm)
} else if err := m.write([]byte{0x02, byte(wpm)}); err != nil {
return err return err
} }
m.mu.Lock() m.mu.Lock()
@@ -283,18 +355,39 @@ func (m *Manager) Send(text string) error {
if out == "" { if out == "" {
return nil return nil
} }
m.mu.Lock()
sk := m.serial
m.mu.Unlock()
if sk != nil {
sk.Send(out)
return nil
}
return m.write([]byte(out)) return m.write([]byte(out))
} }
// Stop aborts the current message and clears the keyer buffer (command 0x0A). // Stop aborts the current message and clears the keyer buffer (command 0x0A).
func (m *Manager) Stop() error { func (m *Manager) Stop() error {
m.mu.Lock()
sk := m.serial
m.mu.Unlock()
if sk != nil {
sk.Stop()
return nil
}
return m.write([]byte{0x0A}) return m.write([]byte{0x0A})
} }
// Backspace removes the most recent character from the keyer's send buffer, // Backspace removes the most recent character from the keyer's send buffer,
// IF it hasn't been keyed yet (command 0x08). Used by "send on typing" mode // IF it hasn't been keyed yet (command 0x08). Used by "send on typing" mode
// so a fast typo can be corrected before it goes on the air. // so a fast typo can be corrected before it goes on the air. The serial line
// keyer has no buffer to un-key from, so backspace is a no-op there.
func (m *Manager) Backspace() error { func (m *Manager) Backspace() error {
m.mu.Lock()
sk := m.serial
m.mu.Unlock()
if sk != nil {
return nil
}
return m.write([]byte{0x08}) return m.write([]byte{0x08})
} }
@@ -313,14 +406,29 @@ func (m *Manager) write(b []byte) error {
func (m *Manager) Disconnect() { func (m *Manager) Disconnect() {
m.mu.Lock() m.mu.Lock()
p := m.port p := m.port
sk := m.serial
stop, done := m.stopRead, m.doneRead stop, done := m.stopRead, m.doneRead
m.port = nil m.port = nil
m.serial = nil
m.stopRead = nil m.stopRead = nil
m.doneRead = nil m.doneRead = nil
connected := m.status.Connected connected := m.status.Connected
m.status = Status{Connected: false} m.status = Status{Connected: false}
m.mu.Unlock() m.mu.Unlock()
if sk != nil {
// Serial line keyer: stop the worker (drops the key/PTT lines) then close.
sk.Close()
if p != nil {
_ = p.Close()
}
if connected {
applog.Printf("winkeyer: serial CW keyer disconnected")
m.emit()
}
return
}
if p != nil { if p != nil {
_, _ = p.Write([]byte{0x00, 0x03}) // Host Close _, _ = p.Write([]byte{0x00, 0x03}) // Host Close
_ = p.Close() _ = p.Close()
+32 -2
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.20.7" appVersion = "0.21.2"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.
@@ -85,14 +85,44 @@ func (a *App) sendTelemetryHeartbeat() {
return // already counted today 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{ payload := map[string]any{
"api_key": posthogAPIKey, "api_key": posthogAPIKey,
"event": "app_opened", "event": "app_opened",
"distinct_id": a.telemetryInstallID(), "distinct_id": distinctID,
"properties": map[string]any{ "properties": map[string]any{
"version": appVersion, "version": appVersion,
"os": runtime.GOOS, "os": runtime.GOOS,
"arch": runtime.GOARCH, "arch": runtime.GOARCH,
"install_id": installID,
"$lib": "opslog", "$lib": "opslog",
}, },
"timestamp": time.Now().UTC().Format(time.RFC3339), "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** **dynamic** mode (any value counts, like POTA), and the **fallback searches**
described below. 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 ### The five rules to remember
1. **Every regex is case-insensitive.** Award and per-reference patterns match 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** 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. 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 ## Live detection & manual refs
- References are detected **live** as you enter a callsign. - 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 or **both**. Award columns are opt-in per the Columns picker
([[Recent QSOs and Filters]]). ([[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
**Rescan** re-pulls the logbook and recomputes — it picks up fresh LoTW / QRZ / **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 ## 1. Set your station
**Settings → Station**: enter your **callsign**, **grid locator** and **name**. **Settings → Station Information**: enter your **callsign**, **grid locator** and
These feed callsign lookups, the map, awards, the QSL card and the "my station" **name**. These feed callsign lookups, the map, awards, the QSL card and the
ADIF fields on every QSO. "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 ## 2. (Optional) Connect a radio