Compare commits

...
490 Commits
Author SHA1 Message Date
rouggy 6497568813 chore: release v0.24.3 2026-08-10 13:56:29 +02:00
rouggy 9f62808392 fix(webpub): the published page could not sort a date, and could not be unsorted
parseFloat accepts a numeric PREFIX. "2026-08-10" therefore became the number
2026, every date in the same year compared equal, and since the sort is stable
nothing moved: the Date column looked as though it were simply not sortable.
The same trap caught every callsign starting with a digit - 8B81SU and 8P9AB
both read as 8 - and the times, where "09:56" became 9. The numeric test is now
anchored to the whole cell, so anything that is not entirely a number is
compared as text, which is exactly right for an ISO date.

Sorting had no way back either. Each row now carries the index it was published
at, and a third click on a header restores that order. Headers also show an
arrow: with no indicator, a column that silently refused to sort was
indistinguishable from one that had sorted into the same order.

Blank cells sink in both directions rather than leading the ascending sort. An
empty field is missing data, not the smallest value.

Tested where it can be: the page ships its own script, so a regression is silent
- the table still renders, it just sorts wrongly.
2026-08-10 13:52:13 +02:00
rouggy 9d8b69d804 feat(cluster): mark the cell that carries the fact, and add the US county
Colour moves from the text to the CELL. A filled Band cell means new band, a
filled Pfx cell means new prefix, a filled County cell means new county. This is
not the pills coming back: a pill is a box inside the cell with its own height
and padding, so it pushed the text off the row baseline. A background has no
geometry - the text does not move a pixel - and it reads from across the room,
which a tinted glyph does not. Worked-call stays text-only: it is not a novelty,
and filling it would wash most of the rows.

The colours still come from the semantic tokens and lib/spotMarkers, so a fact
keeps one colour across the grid, the band map and the filter chips - and the
per-marker colour setting will drive all of it from one table.

US County column. The backend already resolved the county from the offline ULS
store to decide NewCounty and then threw it away; it now returns it, which costs
nothing.

The Locator column is renamed Spotter locator. It always held the SPOTTER's grid
- cluster.go says so - so a column labelled Locator next to a DX callsign was
reading as the DX's grid and was mostly empty besides. The DX grid is not in the
feed at any price worth paying under an RBN firehose.

Both display options move out of Preferences into the cluster filter panel,
beside Hide worked. They are changed while working a run, not set up once, and
a preferences dialog reopened every ten minutes is a filter in the wrong place.
2026-08-10 12:29:57 +02:00
rouggy 6825af135a fix(cluster): NEW CALL is not NEW SLOT
The slot-highlight option reused the new-slot status, which already meant
something else and something narrower: the ENTITY was worked on this band and
on this mode, but never on the two together. What the option surfaces is a fact
about the CALLSIGN - never worked on this band and mode - and the entity may be
long since confirmed there.

Overloading it cost exactly what it always costs. Forty consecutive rows read
NEW SLOT, the genuine new-slot rows drowned in them, and the Status column
stopped carrying information at all.

new-call is its own status now, with its own badge, its own tooltip spelling out
that it is the callsign and not the entity, and its own filter chip. It keeps
the caution colour for the moment: it sits at the same "worth a look, not a new
one" level as new-slot, and the colour rework will separate them.

The status filter had to move onto the displayed status too. It read the raw
backend entry, where new-call by construction never appears, so the chip would
have selected nothing at all.
2026-08-10 12:21:01 +02:00
rouggy ca42fd90f9 fix(cluster): carry worked_slot to the frontend, and react to the option at once
The "colour the stations not worked on this band and mode" option did nothing.
The backend computed WorkedSlot correctly and the generated bindings declared
it, but the status map in App.tsx is assembled field by field from the reply,
and worked_slot was not on that list. The extra key was simply dropped, so the
frontend saw undefined, the === false test never matched, and every spot stayed
muted. Silent by construction: nothing warns that a copied-out struct is missing
a field. All three assembly sites now carry it, and the state type names it so
the compiler catches the next one.

The band map also memoised the transformed status map on spotStatusRaw alone,
so toggling an option changed nothing until a poll happened to hand over a fresh
object. The options are read at render time and are part of the dependencies.
2026-08-10 12:13:16 +02:00
rouggy 18a583901c fix(bandmap): a muted spot must be grey, not brown
Muting emptied the status, which dropped the spot onto statusStyle's default
branch. That branch paints bg-primary/60 - the theme's burnt orange at 60% over
a dark card, i.e. brown - and it was written for the rare unresolved entity,
where a hint of the accent colour is right because the operator should look.

With the mute option on, most of the map is muted. So the whole band map turned
brown and the spots that were meant to stop competing for attention were the
only ones tinted with the app's own "look here" colour.

Muted now lands on QUIET_STYLE: card background, grey border, grey accent bar
and leader. That is the same style the 'worked' status already used, so it is
now one constant rather than two copies - a muted spot and a worked spot are
the same statement and must not drift apart.
2026-08-10 12:06:52 +02:00
rouggy 2cb1add3db fix(cluster): let the two display options compose, and stop claiming a station was worked
Three defects in the options added in the previous commit, all found on air.

Order. Mute returned early, so with both options on the mute swallowed exactly
the spots the slot option existed to surface: the mute test is entity-level, and
an unworked callsign inside a worked entity passes it. Slot promotion now runs
first, and protects itself for free since bringsNothingNew is false on new-slot.
The two were documented as composing - "mute what is done, light up what is
not" - and they did not.

Empty status. Muting emptied the status, but an empty status already meant
"entity not resolved" in the band map, so every muted spot claimed its entity
was unknown with the country printed two words earlier in the same tooltip. A
muted flag now records WHY it went empty; blanking still drives the colour, the
badges and the ranking.

Wording. The muted tooltip said "already worked" of a station never worked -
LZ8NG on a 421st Bulgarian. What is worked is the ENTITY on this band and mode,
so that is what it says now.

new-mode had no case in the band map's statusLabel and fell through to "entity
not resolved" too. Pre-existing, same one-line switch.
2026-08-10 12:01:31 +02:00
rouggy a3815c24a1 feat(cluster): quiet the worked spots, light up the empty slots
Two options that change what the eye is pulled towards, both applying to the
cluster list AND the band map.

Mute worked before: a spot that brings nothing new loses its colour and its
badges. It stays in the list - the operator asked for less noise, not less
information. "Brings nothing new" reuses the dimming rule the cluster list
already had rather than inventing a second notion of done, so it keeps obeying
the same-slot option and the digital-mode grouping for free. A new-band or
new-slot status is NOT muted: having worked that callsign once on another band
says nothing about the band in front of you.

Highlight unworked in this slot: colours any callsign not yet worked on this
band and this mode, whatever the entity says. For an operator filling slots a
common entity on a fresh band+mode is the whole point, and the entity-level
status flatly calls it worked. It reuses the existing new-slot status, so no new
colour, no new legend, no new badge - both panels already knew how to draw it.

WorkedSlot is computed independently of the same-slot preference: it is what
this option reads, and it must not change meaning because a different option was
toggled. The slot index is now built when either option needs it, and the status
cache is keyed on both so a toggle invalidates it.

The rules live in one module used by both panels. Marker colours already taught
us what happens when the two derive the same thing separately.
2026-08-10 11:44:21 +02:00
rouggy f6f5235a8b feat(bandopen): announce sporadic-E openings on 6, 4 and 2 m
Observation, not prediction, and it needs no new data source: the cluster event
worker already enriches every spot with the great-circle distance and bearing
from the operator's grid, which is exactly what a single-hop Es detection rests
on.

The signature is four or more DISTINCT stations at 500-2400 km inside a 90
degree bearing sector within twelve minutes. Each constraint earns its place:
distinct callsigns because one station spotted by six skimmers is six spots and
one station; the lower bound because a 6 m contact under 500 km is ordinary
tropo and says nothing about the ionosphere; the upper bound because past one
hop the bearing test stops meaning anything; and the sector because a real Es
cloud illuminates a direction, which is what separates an opening from a merely
busy evening.

Fed AFTER the Historical guard in the worker. A SH/DX reply replays a hundred
past spots in a second - precisely the shape of a burst - and would announce an
opening that ended hours ago.

Season LABELS, it never gates. Both hemispheres get a summer peak and a lesser
winter one, and an opening outside those is announced with "unusual for the
season" attached: the rare one is the one an operator must not hear about last.

One announcement per band per opening (45 minute quiet period). An opening runs
for hours and produces hundreds of spots; one alert is information, forty is
noise.
2026-08-10 09:49:22 +02:00
rouggy 86a644863a chore(changelog): open an empty 0.24.3 block
Opened as soon as 0.24.2 is tagged, so the next change has somewhere to go and cannot end up described under a version that already shipped - which is exactly what happened to 0.24.1. Invisible until the build reports 0.24.3: GetChangelog drops every entry newer than appVersion.
2026-08-10 09:25:16 +02:00
rouggy dd9fddbc5b chore: release v0.24.2 2026-08-10 09:23:29 +02:00
rouggy 74acf88976 chore(changelog): move the post-release entries to 0.24.2
v0.24.1 was tagged and shipped mid-session; the six commits that followed kept appending to its block, so the released version appeared to describe changes it does not contain. 0.24.1 is restored to exactly the five entries that shipped - verified against git show v0.24.1:changelog.json - and the five later ones open 0.24.2.
2026-08-10 09:23:27 +02:00
rouggy da9c76e161 feat(icom): offer the bands the connected radio actually has
The band row was hardwired to 160-6 m, so an IC-9700 - the one radio in the range with no HF at all - showed ten dead buttons and none of 2 m, 70 cm or 23 cm. bandsFor(model) follows the model, exactly as attOptions already did for the attenuator steps. bandOfHz's labels had to move with it: it returned '70' where the button says '70cm', so the current-band highlight could never have matched, and it knew nothing of 23 cm.
2026-08-10 09:07:08 +02:00
rouggy fb78b1c052 feat(webpublish): publish the log as a web page or CSV, with FTP upload
Settings -> Web publishing. Renders the last N QSOs with the columns the
operator picks, writes the file locally, and optionally uploads it by FTP or
explicit FTPS.

Local write FIRST, upload second, always. A network failure then leaves a good
file on disk that can be published another way, instead of a truncated one on
the server. The local write itself goes to a temp file and renames over the
target, so a reader — or a syncing client — never sees a half-written page.

The HTML page is fully self-contained: inline CSS, inline sort script, no font,
no CDN, no external request at all. It has to work on hosting that blocks
third-party requests, and a page about someone's hobby should not report its
readers to anyone.

Columns are a curated set, not "every ADIF field". This is published to the
public: RST and QSL status belong on it, the operator's home address does not.

Two triggers, both debounced through one path: a QSO is logged, or the optional
timer fires. Fifteen seconds of coalescing means a run of contacts produces one
upload rather than one per QSO, and nobody reading a web page can tell the
difference.

The config is one JSON blob under a single settings key, and that key is marked
sensitive: the FTP password lives inside it, so the whole blob is encrypted at
rest with the others. A locked vault reads back empty, which correctly reads as
"not configured" — publishing must not run with a password it cannot decrypt.
2026-08-10 09:07:08 +02:00
rouggy e51abd262e fix(bandmap): refresh spot colours when the band map is the visible panel
The post-QSO status refresh was gated on the DX-cluster list being on screen; the band map draws the same statuses and was left out. Working a station in the ordinary layout - Recent QSOs left, docked band map right - left its pill NEW until the cluster tab happened to be opened. Reported on an E51 that stayed orange with the QSO already in the log.
2026-08-10 08:51:06 +02:00
rouggy 0ec855d95a fix(map): stop using OpenStreetMap's volunteer tile servers
OSM blocked OpsLog and every user's map filled with "Access blocked / 403"
tiles at once. Their policy is explicit: an application must identify itself
with a proper, unique User-Agent on every tile request. A program that draws
its maps inside a web view cannot do that — the browser sets that header and
Leaflet fetches tiles as plain <img> loads — so this is not something to tune,
it is a service we are not entitled to use.

Two layers still pointed there: the Street basemap and the locator map. Street
moves to Esri World Street Map, the locator map to Carto, both key-free and
both under terms that cover a redistributed application. OpenStreetMap keeps
its credit: Carto's tiles are built from OSM data.

The locator map also gains the tile restraint the world map already had —
updateWhenIdle, no fetching mid-zoom, one ring of buffer. Fetching as fast as
the pointer moves is another item on OSM's block list, and the replacement
providers are under no more obligation to tolerate it than OSM was.

The basemap preference key is untouched, so an operator who had picked "Street"
still gets a street map, from the new source.
2026-08-10 08:51:06 +02:00
rouggy ae06495f91 feat(spots): give a new county its own colour, from a shared marker table
A new county and a new park both drew --success, so the two were the same green
in the cluster list and, since the band map copied it, in the band map too.
County takes violet; POTA keeps green, the association being worth something.

Violet is a chart hue rather than a semantic token because every token was
already spoken for: red, orange and yellow are the entity statuses, blue is
"callsign already worked", green is now POTA. It is defined in both the light
and dark chart groups, so all eleven themes have it.

Changed in BOTH panels at once — separating them in one place would have left
the two views contradicting each other about the same fact, which is worse than
sharing a colour. To make that impossible to get wrong again, the marker
definitions move to lib/spotMarkers: key, colour and label in one table that the
cluster list and the band map both read. That table is what a per-marker colour
setting will drive, which is why it is a table and not three constants.

The band map deliberately shows three of the four markers. A new PREFIX stays a
cluster-list badge: the pill is 22 px tall and a fourth segment on its strip
turns it into a colour code nobody reads at a glance.
2026-08-09 21:12:28 +02:00
rouggy 4dd2c3b997 feat(bandmap): show new POTA, new county and worked-callsign
The band map coloured only the entity status and dropped the other three
markers on the floor — not for want of data: they were already in the status
entry it receives, the local type simply never declared them, so a new park on
an entity you have worked was indistinguishable from any other worked spot.

Colours are the DX-cluster list's, taken from it rather than chosen: --info for
a worked callsign, --success for a new county and a new park. The same fact must
not be blue in one panel and green in the next.

They STACK, they do not replace. These markers are orthogonal to the entity
status — worked entity plus new park is an ordinary combination — and the
cluster list already spells them out side by side rather than letting one win.
So the pill keeps the status colour and the markers take its left strip, split
into one segment each. That strip previously repeated the pill's own colour and
carried no information at all, which is what made it the right place.

Note that a new county and a new park share --success in the cluster list, so
they share it here too. Splitting them means changing both views together, which
is the moment to do it — when the per-marker colour setting arrives.
2026-08-09 21:01:54 +02:00
rouggy ec71dd1661 chore: release v0.24.1 2026-08-09 17:02:13 +02:00
rouggy 31f9bdfc98 fix(qsolist): let the Max box be lowered
The input was bound straight to the number and committed on every keystroke, so
it could not be EMPTIED: clearing it yields "", Number("") is 0, 0 fails the
"> 0" guard, the state never moved, and value={qsoLimit} snapped the old figure
straight back. Going from 200000 down to 100 was a fight against the field — and
read as the setting refusing to persist, which it was not: writeUiPref stores it
and syncPortablePrefs makes the database authoritative at boot. It also wrote
the preference once per keystroke (1, then 10, then 100).

Raw text in local state, committed on blur or Enter, per the controlled-input
note in CLAUDE.md.
2026-08-09 17:00:46 +02:00
rouggy 57e98139ab perf(awards): release the logbook snapshot once it goes cold
Opening the Awards panel pulls every QSO into a cached slice and kept it for the rest of the session: the cache was only ever invalidated by a logbook change, never by disuse. Each cached QSO is a 1896-byte struct plus its strings AND a decoded map of its ADIF extras - one map allocation per QSO. Harmless at 30k rows, several hundred megabytes at 132k, which is where it was reported. A janitor drops it after 15 minutes without a reader and calls FreeOSMemory, because Go hands pages back lazily and the whole point is that the operator sees the memory return. 15 minutes is deliberately generous: an awards session recomputes every few seconds and re-pulling a large remote logbook costs seconds. The heap size is now logged when the snapshot is built and when it is released - a memory report was unanswerable without a number.
2026-08-09 16:57:12 +02:00
rouggy b10a867125 perf(cluster): batch the console lines instead of one render per line
Every line of cluster traffic went straight to state: a spread copy of a 2000-element array, a slice copy, and a React render, per line. An RBN feed sends hundreds a second, so that was tens of MB/s of garbage in the renderer and hundreds of re-renders - and it ran whether the console was open or not, so the cost was paid for a panel nobody was looking at. Reported on an old PC where the UI had stopped responding. Lines are staged in a ref and flushed on a 200 ms timer, the same shape the spot handler already uses. The staging buffer is bounded too, so a burst longer than the console can show is not carried in full just to be sliced away on commit.
2026-08-09 16:48:33 +02:00
rouggy 17819ea673 fix(update): survive an exe that cannot be renamed
Several operators hit "stage current exe: rename …\OpsLog.exe …\OpsLog.exe.old:
Accès refusé" and could not update again. Two separate causes, both ours to
handle.

The staging name was fixed. os.Rename replaces its target, so a single leftover
".old" that could not be deleted — a scanner holding it open is the usual
reason, and the pre-existing os.Remove was best-effort and ignored — made every
later update fail with that error, permanently, recoverable only by deleting the
file by hand. Staging now uses a unique ".old-<nanos>", which no leftover can
block, and the startup cleanup sweeps the pattern instead of one name.

Renaming a running image is legal on Windows, but some endpoint protection
(Bitdefender's ransomware remediation among them) blocks it outright, and no
retry gets past that. So the swap is deferred: the new build is parked beside
the old one and a detached helper moves it into place after this process exits,
when the file is no longer a running image. It keeps trying for ten seconds,
since a scanner tends to let go a beat after the process dies.

A short retry stays in front of both, for the ordinary case of a scanner holding
the file it has just watched being written.

If even the deferred move fails, OpsLog restarts on the CURRENT version rather
than leaving the operator with nothing — someone mid-QSO losing their logger is
worse than an update that waits — and only a successful swap passes
--post-update, so the download survives for the next attempt instead of being
swept by the cleanup.
2026-08-09 15:38:50 +02:00
rouggy a8fac52400 feat(bandmap): drag the width, and remember it
Both band maps were pinned to a hardcoded width — 300px docked beside the
tables, 260px per card in the Band map tab. On a busy band the map could not be
given more room, and on a quiet one the log could not take it back.

The docked map becomes a resizable grid column with the grip in the gap between
the panes, so the handle costs no space; the tab cards share one width with the
grip on their right edge. Side-by-side columns of different widths read as a
mistake rather than a choice, which is why the tab has one width and not one
per card. Double-click either grip to return to the default.

The drag measures from the pointer's START position rather than the container,
so the same helper serves both edges — the docked map sits on the left or the
right depending on the operator's setting, and the grip is on its inner edge
either way. Pointer capture, like the main splitter: without it the map or the
grid under the cursor swallows the moves.

Both widths are persisted through writeUiPref and registered as portable, so
they travel with the data folder like the main splitter and the rest of the
layout.
2026-08-09 13:12:43 +02:00
rouggy 2bf98a9801 chore: release v0.24.0 2026-08-09 13:01:59 +02:00
rouggy 80dac64b56 feat(theme): four themes with an actual hue — Indigo, Ocean, Plum, Nordic
The seven existing themes are warm beige, cool grey, sage grey, slate, warm
dark, graphite and black — every one neutral, six of the seven accented orange.
Picking a theme changed the shade of grey and little else.

These colour the SURFACES, not just the accent, and each takes a different
primary so the picker tells them apart at a glance: violet on deep indigo, cyan
on deep teal, magenta on aubergine, indigo on a crisp cool white.

The semantic colours stay recognisable as themselves. A logger is read for
hours and "red means a problem" cannot become a decorative choice, so the hue
budget went on the surfaces and the primary. Where a theme's primary would have
collided with a meaning, the MEANING kept its identity and the ornament moved:
Ocean's info is blue rather than cyan, Plum's danger is red rather than rose,
and the matrix entity ramp shifts to cyan under Indigo and teal under Nordic so
a "confirmed" cell never reads as a button.

Everything else follows for free: the grids resolve var(--…) at runtime, so
they re-skin with no re-render. The three shared registrations that do NOT
follow automatically are done — the chart palette (light vs dark ramp) and the
date-picker icon inversion, which is keyed on an explicit list of dark themes.
2026-08-09 10:06:05 +02:00
rouggy 79427ccd18 fix(awards): apply the reference Prefix where it can actually help
Prefix exists so a field holding a bare value counts for an award whose codes
carry a letter: a French operator writes "74" in STATE, DDFM's codes are "D74".
Def's own doc says exactly that. But searchOne applied the prefix AFTER looking
the token up in the reference list — after the step that had just failed — so
the bare form matched nothing and the prefix decorated an empty result. The only
way through was a regex, in a mode where the operator had chosen "code" and
explicitly not "pattern".

The token lookup now tries the prefixed form when the bare one is not a known
reference. The list stays the authority: an unknown number still matches
nothing, so the prefix completes references rather than inventing them.

Second bug in the same pass: the blanket prefix also hit codes that came
straight OUT of the list, so a field already holding "D74" produced "DD74" as
soon as a prefix was configured — for both the token lookup and the
description matcher. Branches that yield whole codes are now excluded from it;
the ones that yield a raw capture (regex, whole-field split) still get it.
2026-08-09 09:43:27 +02:00
rouggy 4352b9aec5 fix(udp): read WSJT-X packets that arrive through a relay
A forwarder (W&P, seen in the field in front of MSHV) prepends the origin as
plain text before re-broadcasting:

    "127.0.0.1:2237|" + <the original, untouched WSJT-X packet>

That puts the magic 15 bytes in, so every datagram failed on "bad magic
0x3132372e" — those four bytes being ASCII "127." — and an operator running
MSHV behind the relay saw no decodes, no callsigns and no auto-logged QSOs.

No new service type: what follows the header IS a WSJT-X packet, so the parser
and everything downstream apply unchanged, and a separate type would duplicate
decode, status and logged-ADIF handling to strip 15 bytes. ParseWSJT skips the
header instead, which also covers any other relay that wraps traffic this way.

The match is deliberately narrow — the magic must fall within the first 64
bytes AND every byte before it must be printable ASCII. A corrupt or truncated
packet that merely contains those four bytes somewhere is not resurrected into
a QSO; it fails exactly as it did before.

Test data is the real captured datagram, header included.
2026-08-09 08:06:21 +02:00
rouggy a0f7f2abf0 feat(udp): show the packet behind a parse error, and stop repeating it
"WSJT parse error: bad magic 0x3132372e" named neither the sender nor the
payload, so there was nothing to act on — even though those four bytes are
ASCII "127.", i.e. some program broadcasting an address on a port expecting
WSJT-X binary.

The line now carries the remote address, the size, a printable preview and a
hex dump of the first 96 bytes. Text senders are readable at a glance; a
genuinely binary payload still shows its bytes.

And it stops after five. The reported case wrote that line about 150 times a
second: a permanently misconfigured port would fill the 10 MB rotating log with
one repeated sentence and bury every other piece of evidence — the log's whole
purpose. The fifth line names the two things worth checking, the sender and the
service type.

N1MM's parse error goes through the same path; it had no packet detail either.
2026-08-09 07:59:51 +02:00
rouggy 40f5960c76 feat(awards): implement the QRZ.com and Custom confirmation sources
The award editor offered five confirmation sources and Def's own doc comment
named five, but confirmed() had cases for three. "qrzcom" and "custom" fell
through the switch, so ticking either marked nothing as confirmed — the exact
failure the GrantCodes comment in this struct warns about: a checkbox that does
nothing is worse than no checkbox, because it is trusted.

QRZ.com reads qrzcom_qso_download_status, not the upload one: uploading a QSO
is us telling QRZ about it, which confirms nothing.

Custom names a field. Rather than a checkbox per external source, the Def gains
ConfirmField + ConfirmValue: any QSO field or ADIF extras key, and optionally
the comma-separated values that count. That one shape covers the three cases
asked for — the OpsLog card marker (APP_OPSLOG_QSL_RCVD), an arbitrary ADIF
tag, and a tag stamped by an imported club list — because all three end up as a
field on the QSO.

An empty ConfirmValue means any non-empty content confirms: the OpsLog marker
stores the date the card arrived, not a Y/N flag. A custom source naming NO
field confirms nothing, deliberately — the opposite default would silently mark
a whole logbook confirmed.
2026-08-09 02:11:24 +02:00
rouggy 59135d55ab fix(window): don't save the geometry of a minimised window
Windows parks a minimised window at -32000,-32000 with a stub size and reports
it as not-maximised, so closing OpsLog from the taskbar while minimised stored
exactly that. Seen in a log: "window: saving -32000,-32000 237x39
maximised=false".

The restore side already rejects both the impossible corner and the
below-minimum size, so nothing opened off-screen — but it fell back to the
default placement, and the operator silently lost the size, the position and the
maximised state they had set.

saveWindowState now keeps what was already stored when the window is minimised.
Two tests for that: the Wails flag, and the -32000 corner, because a window that
is mid-close can sit at that corner with the flag already cleared. A poisoned
window.json repairs itself on the next close of an on-screen window.
2026-08-09 01:47:45 +02:00
rouggy 44cf5954fd fix(qsl): print "TNX QSL" rather than "TNX" on the card
The stamp answers "PSE QSL", so it reads as its counterpart or not at all: a
bare "TNX" next to a QSL message says thanks for something unnamed.

Changed at the source of the {qso.pse_tnx} token, so every card picks it up
with no template edit. The live indicator in the QSO editor and the hint beside
it follow.
2026-08-09 01:39:50 +02:00
rouggy f1b7a7e477 fix(qsl): put the OpsLog card in the Manage Confirmation picker too
The previous commit added its row to the status table but left the picker
listing only the column-backed channels, so the one channel you could actually
tick was the one you could not select.

Selecting it swaps the left panel for an OpsLog editor instead of the generic
sent/received/date grid, which has no fields to bind to here: Sent is shown
read-only (OpsLog stamps it when the card goes out), Received is the tick, and
the PSE QSL / TNX indicator sits next to them since that stamp is what the flag
is for.
2026-08-09 01:23:52 +02:00
rouggy 19ae00124f feat(qsl): show the OpsLog card with the other confirmations
The OpsLog QSL marker sat under QSL Msg in "Contact's details", nowhere near
the channel it belongs to, while the QSL Info tab listed every other
confirmation — QSL, LoTW, eQSL, QRZ.com, Club Log, HRDLog — with a Sent and a
Received column.

It now has its own row in that table and the "QSL received" tick moved to the
same tab, keeping the PSE QSL / TNX indicator that is the reason the flag
exists at all: received prints TNX on the card, otherwise PSE QSL.

Written by hand rather than added to CONFIRMATIONS: that table maps QSO
columns, and this channel is backed by ADIF extras (APP_OPSLOG_QSL_RCVD, plus
the older APP_OPSLOG_QSL_CARD_SENT for the sent side). Sent stays read-only —
OpsLog stamps it when a card actually goes out, and a hand tick would record
something that never happened.
2026-08-08 23:28:26 +02:00
rouggy 642ed358c2 feat(worked): fold portable callsigns into the worked-before history
Typing RK3DWA found nothing while RK3DWA/3 found 21 QSOs, so a station's
history was only visible if you happened to type the exact form it had been
logged under — and an operator who worked it as /0, /P or /MM saw none of it.
The other RDA tools and Log4OM fold these together; this does too.

The predicate strips the suffix from what was typed and matches "call = base OR
call LIKE base/%", so it works from either end: the base call finds the portable
QSOs and a portable call finds the plain ones. Deliberately not a bare prefix
LIKE 'RK3DWA%', which would also match RK3DWAB — a different station. The '/' is
what makes it the same operator.

Settings -> General to turn it off. Default ON, hence the inverted storage: an
existing install has no key, and reading that as OFF would leave everyone with
the behaviour we were asked to change.

Contest dupe checking is untouched — it runs through ContestDupe, a separate
binding, and stays an exact match as a contest requires.
2026-08-08 23:24:23 +02:00
rouggy 1d8cd25205 chore: release v0.23.9 2026-08-08 20:44:32 +02:00
rouggy 1f55cfe9fa feat(cluster): self-spot on the master node while logging
Settings -> DX Cluster: a toggle and an interval. When it is on, logging a QSO
announces the station on the master cluster — the QSO's own station callsign as
the DX, on the frequency the contact was made on — so callers find the run
without waiting for someone else to spot it. The node fills the DE field from
the login, so the spotter is us too: a self-spot.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Wired through app.go (normRotorType, default port 4001, GoTo/Heading/Stop/test
switches, park returns the same "PstRotator only" note as ARCO) and exposed in
the rotor settings as "Hy-Gain DCU-1 (RotorCard DXA, Rotor-EZ, Green Heron)"
with the ARCO's serial/TCP transport chooser and a bilingual hint. Protocol is
implemented from the standard DCU-1 spec and still needs field confirmation
against a real RotorCard DXA.
2026-08-06 10:40:36 +02:00
rouggy 20dc3e83a6 ui(stats): prettier slot drill-down + click a callsign to edit the QSO
The pop-up listing the QSOs behind a Stats band/mode square gets a cleaner header
(band chip, hint), banded/hover rows and roomier spacing. Callsigns are now
clickable and open the QSO editor (App.openEdit threaded through DetailsPanel →
BandSlotGrid → SlotQSOModal as onEditQso), matching the double-click-to-edit of
the log grids.
2026-08-06 10:30:34 +02:00
rouggy 62cdabe114 feat(relay): accept a full URL host so relays work behind an HTTPS proxy
Network relay boards (WebSwitch/KMTronic/Dingtian) were always reached at
http://<host>, so a board on the LAN behind a reverse proxy — the common remote
setup where the operator's 80/443 already go to Nginx Proxy Manager — couldn't be
reached from outside. relayBase() now keeps a scheme the operator supplies
(https://relay.example.com, optional sub-path) and only defaults to http:// for a
bare host/host:port. UI hint + test added.
2026-08-06 09:26:16 +02:00
rouggy 9bfb44925b fix(cluster): refresh spot statuses by merge, not by clearing the cache
setSpotStatus({}) blanked EVERY spot's status, so logging one contact wiped all
the other NEW/NEW-BAND badges and they popped back a moment later. Instead,
re-fetch the shown spots and OVERWRITE each status in place (merge) — the just-
worked spot updates while the rest stay put, no flicker. Same visibility gate
and 2 s debounce; backend cost is one status batch (one logbook scan) as before.
2026-08-06 01:36:17 +02:00
rouggy fb1af9b6a2 feat(cluster): refresh spot statuses after a log — visibility-gated & debounced
A just-worked call/entity/POTA/prefix/slot kept its NEW badge because per-spot
status is cached and never expires. Drop the cache on qso:logged so the grid
re-evaluates. Kept cheap for old machines / big logbooks: only fires while the
cluster is on screen (a log while hidden marks it dirty and refreshes once on
next open), and debounced 2 s so a fast run coalesces instead of re-scanning the
logbook per QSO.
2026-08-06 01:13:22 +02:00
rouggy 57a9bed145 perf(cluster): dim spots via cell class, not redrawRows (0.23.6 slowdown)
The "dim spots that represent nothing" feature drove the dimming with getRowStyle
and called api.redrawRows() on EVERY spotStatus update to re-apply it — a full
grid re-render on a firehose of status updates, which pegged the CPU on a busy
cluster (reported as PCs slowing down since 0.23.6). Move the dimming to a
defaultColDef cellClassRules ('opacity-40') that the existing light
refreshCells({force:true}) re-applies; drop redrawRows and getRowStyle.
2026-08-06 00:55:49 +02:00
rouggy fbd323af26 fix(pgxl): fan mode needs the "setup" prefix — "setup fanmode=VALUE"
Confirmed live: "setup fanmode=CONTEST" is accepted (reply code 0) while the
bare "fanmode=CONTEST" we switched to earlier is rejected (0x50000015), so the
fan mode snapped back and never changed on the amp. Restored the "setup " verb
and now check the reply code, returning an error if the amp rejects the set.
2026-08-05 17:32:53 +02:00
rouggy 689d1cc902 feat(qso-edit): QRZ button to open the station's qrz.com profile
Mirrors the main entry form: a QRZ ↗ button by the callsign in the QSO edit
modal opens https://www.qrz.com/db/<call> via OpenExternalURL.
2026-08-05 17:13:34 +02:00
rouggy 67602fd485 docs(changelog): open empty 0.23.7 block 2026-08-05 16:43:32 +02:00
rouggy 2de5bce998 chore: release v0.23.6 2026-08-05 15:22:02 +02:00
rouggy f0959c796d fix(bandmap): call all hooks before the unknown-band early return (React #300)
The Ctrl+↑/↓ keyboard-nav useEffect sat AFTER `if (!range) return`, so on a band
with no range — e.g. a spurious 33 cm frequency briefly reported by an Icom over
CAT — the component returned before that hook and the hook count changed between
renders, crashing the whole window with React #300. Moved the effect above the
early return; lo/hi already fall back to [0,1] when there's no range.
2026-08-05 15:13:47 +02:00
rouggy 16e64fd7b7 fix(ultrabeam): accept an 11-byte status frame from older controllers
An older controller behind an RS232-to-Ethernet bridge returns the status frame
without its trailing FreqMax byte (11 bytes, not 12). The packet checksum is
still valid, so it's a real short frame, not a fragment. queryStatus now accepts
>= 10 bytes and defaults the missing FreqMin/FreqMax rather than rejecting it as
"too short", which reconnect-looped the link. Whether the byte LAYOUT is
otherwise identical across controller versions still needs confirming from the
protocol reference.
2026-08-05 14:49:52 +02:00
rouggy 7e696a72bb feat(cluster): dim spots that represent nothing
A spot whose entity/band/mode is already worked, whose callsign isn't in the log,
and that carries no POTA/county/prefix novelty is dimmed (whole row, opacity) so
the eye skips it. "Nothing" follows the status, which already obeys the "same
slot" option and digital grouping. getRowStyle drives it; the spot-status effect
now redrawRows so the dimming re-applies when a status lands.
2026-08-05 14:38:19 +02:00
rouggy 9af2732314 fix(cluster): WORKED chip filters worked-call only, not the entity status
The WORKED filter also matched the entity status 'worked' (entity/band/mode
already worked, shown as a plain "—"), so selecting WORKED still listed spots of
calls never worked. Match the 'worked' key only via worked_call (the blue
WKD-CALL flag); a worked call still shows under NEW BAND / NEW SLOT.
2026-08-05 14:26:34 +02:00
rouggy f82d92565f feat(cluster): add a WORKED status-filter chip
The filter logic already matched worked_call spots against a 'worked' key; add
the chip to the status row so worked spots can be shown or isolated, not only
dropped via the "Hide worked" checkbox.
2026-08-05 14:17:57 +02:00
rouggy d0f25aea9b feat(cluster): "already worked only on the same slot" option
New DX Cluster setting (Settings → DX Cluster). Off (today's behaviour) a call
worked anywhere reads as already worked; on, the WORKED-call flag needs the same
band AND mode. It folds through the digital-mode grouping (Settings → General):
grouped, a 20m FT8 contact also marks a 20m FT4 spot worked; ungrouped they are
separate slots. Backend: qso.WorkedCallSlotKeys builds CALL|band and
CALL|band|mode(grouped) keys; ClusterSpotStatuses uses them when the option is on
(a spot with no inferable mode falls back to same-band).
2026-08-05 14:11:06 +02:00
rouggy f91b83ee12 fix(awards): upper-case the DXCC main prefix (3D2/c → 3D2/C)
cty.dat spells some sub-entity suffixes in lower case; the awards prefix column
now shows them upper-cased like every other DXCC prefix.
2026-08-05 14:05:09 +02:00
rouggy a554257d5f feat(entry/stats/awards): Alt+W clear, slot drill-down, DXCC prefix column
Five operator-requested items:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

They are not entity statuses — a new park or county is another dimension of the
same spot — so the filter key is now a superset of the status, and matching is
an OR across all of them, as it already was for a previously-worked callsign.
The chips carry the colours their badges use in the grid: a filter that does not
look like what it selects has to be learned twice.
2026-07-31 17:04:01 +02:00
rouggy 5623b860c2 chore: release v0.22.4 2026-07-31 16:56:41 +02:00
rouggy 3e22c4d1a3 feat: filter award references by mode class
All / CW / Phone / Digital, stacked on top of the worked/confirmed filter. The
question an operator actually has — which DXCC entities have I worked on CW but
not confirmed on CW — needs both at once; answering only one of them is what
sends people to a spreadsheet.

References carried bands but not modes, so the award computation now aggregates
mode CLASSES per reference, worked and confirmed separately. Classes, not ADIF
modes: nobody chasing CW cares whether the digital side was FT8 or RTTY, and a
list of twenty mode names would not answer the question. An unrecognised mode
counts as data rather than as nothing, so a new digital mode does not vanish
from the filter the day it appears.

Two rules the filter needs to be honest:

  - Not-confirmed means not confirmed ON THIS MODE. An entity worked on CW and
    confirmed on SSB is still a CW entity to chase, and that is exactly what the
    filter is for.
  - "Not worked" plus a mode is a contradiction — an unworked reference has no
    mode — so the mode filter stands aside there instead of emptying the list.
2026-07-31 16:09:11 +02:00
rouggy d90f953df4 fix: QRZ.com download marked unconfirmed QSOs as confirmed
The operator's own fetch settles it. One record, both fields:

  <app_qrzlog_status:1>N  <qrzcom_qso_download_status:1>Y

QRZ says the QSO is NOT confirmed and sets the download field anyway — it marks
what was handed back, not what was confirmed. OpsLog read it as a confirmation,
so eighteen QSOs came back "UPDATED" and green, none of them confirmed on QRZ.
The site's own logbook page shows them without a star.

Only app_qrzlog_status = C counts now. The test that asserted otherwise is
corrected and carries the real record.

And the download now takes back what it wrongly gave: when QRZ explicitly says
not-confirmed and our log says confirmed, the flag is cleared and reported on
its own line. These QSOs count towards award slots, so leaving them green until
someone noticed was not an option — and nothing else in the app would ever have
undone them. Only on an explicit no: a record with no app_qrzlog_status is QRZ
saying nothing, and silence is not a retraction.
2026-07-31 14:40:50 +02:00
rouggy e4217da010 fix: right-click menu cut off at the bottom of the window
Closes #9.

It was clamped against a GUESSED height — 230 px with the upload submenu, 110
without. The menu is far taller than that with a selection, so right-clicking on
a row near the bottom hid half the entries, delete among them.

It is measured now: placed above the cursor when it does not fit below, pinned
to the top with its own scrollbar when it fits neither way. Its height depends
on which actions the caller passes, so no constant could have stayed right.
2026-07-31 12:25:37 +02:00
rouggy c5ac945de0 fix: digital-mode grouping ignored by the spots already listed
The backend honours the setting: it normalises the log side and the spot's own
mode through the same grouper, so an FT4 spot on a band worked in FT8 reads as
worked. That was not the fault.

Spot statuses are cached per call+band+mode and the cache never expired. Ticking
the box in Settings therefore changed nothing on screen — every spot already
listed kept the answer to the old question, and only new arrivals were judged by
the new rule. The cache is now dropped when settings are saved.

Also: writeUiPref swallowed a failed database write. The interface kept working
from localStorage while the BACKEND — which reads some of these keys, this one
among them — still saw the old value. The two disagreeing in silence is exactly
the shape of this bug, so the failure is now logged.
2026-07-31 12:20:07 +02:00
rouggy eb40991482 fix: the power headings sat one column left of their boxes
The rule between the band-driven columns and the mode-driven ones was an extra
empty cell added to the body rows only. Seven cells per row against six in the
header slid every heading across: Phone stood over the TX antenna, CW over
Phone.

The rule is now drawn on the first power cell itself, in the header and the body
alike, so the two cannot drift apart again.
2026-07-31 12:00:21 +02:00
rouggy 781cf636ea fix: one table per band instead of two
Antennas and power were two tables listing the same bands, one under the other,
so configuring a band meant matching rows by eye between them.

They are now one row per band. That the two settings are stored separately and
applied on different triggers — antennas on band change, power on band or mode
change — is a backend detail, and no reason to split what is a single decision
per band for the person configuring it. A rule in the header keeps the two
groups readable.
2026-07-31 11:52:03 +02:00
rouggy 62b6fe0a3a feat: FlexRadio TX power per band and mode class
Settings → FlexRadio, beside the per-band antennas and applied the same way:
when the band or the mode changes. 1.5 kW that is fine on SSB has no business
going into an FT8 signal on the same band.

Three classes rather than one row per mode, because what decides the power is
duty cycle and what the amplifier will take of it, not the mode's name — FT8 and
RTTY punish an amplifier the same way, SSB and AM do not. The classification is
pinned by a test: a digital mode sorted as phone would deliver exactly the
1.5 kW this exists to prevent.

An empty box means "leave the power alone", never zero watts, and an unknown
mode changes nothing — setting a transmit power from a name we do not recognise
is not a good guess.

Unlike the antennas, a locked band does not skip it: the lock means "do not let
the rig drag my log entry around", not "let the amplifier keep whatever the last
mode left".
2026-07-31 11:30:46 +02:00
rouggy bd2a8524dc fix: CW decoder loses the first characters after a speed change, and splits callsigns
Both faults reported on the air, both reproduced in synthetic signals first —
the existing tests all passed because each starts a fresh decoder and sends
textbook spacing, which is neither of the cases that hurt.

Speed change. A 32 wpm station followed by a 14 wpm reply decoded as
"TTT TTTT TTTTT TT…" for most of the over. Not the element classifier: the
CHARACTER-gap threshold is 2.2 dits, so at the stale fast estimate it stood at
81 ms while the newcomer's element gaps were 86 ms. Every single element was
flushed as its own character, and a lone element with no contrast reads as a
dah. A silence long enough to end an over now drops the estimate back to the
seed, which is exactly the state a freshly started decoder is in — and that case
was always fine. The threshold scales with the current estimate (12 dits, floor
600 ms) because a fixed one cannot serve both 10 and 40 wpm.

Callsign split. The word boundary sits at 4.6 dits, the geometric mean of a
3-dit letter gap and a 7-dit word gap. It assumes textbook spacing; a fist
leaving 5 dits between letters had every letter turned into a word, so a
callsign arrived as "O Y 1 C T". The boundary now also follows the letter gaps
this operator actually sends, whichever is larger. A wide sender's words may run
together — a far smaller price than a callsign in pieces.
2026-07-31 11:15:02 +02:00
rouggy 4afd7dda90 fix: keep the audio captured while waiting for a silent source
The operator asked the right question: why wait 1.5 s at all, when the mode is
known from CAT?

Because the wait is not the problem — what happened during it was. The drift
guard, meant for two sound cards running on their own clocks, saw a starved
source as a one-second skew and trimmed the radio audio to match. So the opening
of every CW recording was thrown away although it had been captured.

The guard now applies only while BOTH sources are actually delivering. Waiting
then costs nothing: everything buffered is written whole the moment the silent
source is written off, which a test now pins with three seconds of audio.

Using the CAT mode to skip the wait would have been a second mechanism for a
case this one already covers — and it would not cover the others: a device
switched off, unplugged, or held by another program.
2026-07-31 00:31:02 +02:00
rouggy ed930667a1 fix: stop warning about a silent mic when the radio is streaming
The log read:

  recorder: the second audio source is silent — recording the radio alone
  recorder: no audio at all from "DAX Mic" after 3 s — nothing is streaming

Both true, and together they read as a fault while the recording was going
perfectly well — it was saved seconds later. On CW the mic channel legitimately
delivers nothing; the mixer already said what it was doing about it.

The watchdog now mentions the mic only when the radio is silent too, which is
the case where the recording really is empty.
2026-07-31 00:22:44 +02:00
rouggy 311479c52f fix: write off an audio source that never delivers anything
The operator said it from the start: in CW there is no sidetone, only reception.
My previous fix did not cover it — it wrote off a source that STOPPED, but a
source that never starts has no last-delivery time, so it stayed forever "not
yet dry" and held the mixer at min(A,B)=0. Three empty recordings while the
radio audio streamed perfectly.

A source with no delivery is now measured from when capture started. Tested both
ways: nothing is written off in the first moments, and a source that has never
spoken is written off once the grace period has passed.
2026-07-31 00:15:11 +02:00
rouggy b25efabab8 fix: check the configured capture device still exists before recording
Third empty recording, same station, and the sequence was as plain as it gets:
type the call, click record, log the QSO. The device opened and never streamed.

Endpoint ids are what gets stored, and a DAX channel that is reconfigured,
disabled or recreated comes back with a DIFFERENT id. The stale one opens
without complaint on some drivers and simply never delivers — indistinguishable
from a quiet band until the file turns out to be empty.

The recorder now checks the id against the current endpoint list when it starts,
which takes milliseconds, and says outright that the configured input is gone —
on screen, not only in the log. If it IS still there, the answer is equally
useful: the device exists and the fault is upstream, in whatever should be
feeding it.
2026-07-31 00:12:51 +02:00
rouggy a93f52d2b9 fix: name the audio device in diagnostics instead of quoting its GUID
The watchdog fired exactly as intended and said:

  no audio at all from "{0.0.1.00000000}.{6a27abfd-f277-496b-b6f1-f92ec284c636}"

which tells the operator nothing they can act on. "DAX RX 1 (FlexRadio DAX)"
points straight at the DAX panel.

Endpoint ids are what gets CONFIGURED and stored, so they are what the recorder
holds; the friendly name is resolved only when something has gone wrong. When
the endpoint cannot be found at all the id is still shown — a device that has
disappeared explains an empty recording too.
2026-07-31 00:11:29 +02:00
rouggy c9f7279a01 fix: log when an in-progress recording is discarded
"No recording was running at log time" says the take had already ended, but not
who ended it. The only path that does is the callsign being cleared — which also
happens when a clicked spot replaces it — and that path was silent.

Now it names itself, so the log shows the moment the recording was lost instead
of only its absence at the end.
2026-07-31 00:01:08 +02:00
rouggy 2560fced87 docs: match the French changelog to the English one 2026-07-30 23:57:17 +02:00
rouggy 1718bf6f33 fix: say when a capture device is streaming nothing
Second empty recording from the same station, and this time NEITHER source had
delivered a sample — so the fix for a silent second source could not apply, and
the log still said only "recording was empty" at the end of the QSO.

Two blind spots, both now closed:

  - captureStream's error was discarded. A device that cannot be opened — renamed,
    unplugged, held by another program — looked exactly like one that is merely
    quiet.
  - A WASAPI device can open cleanly and then produce nothing at all. A DAX
    channel with no stream behind it does precisely that, and it is
    indistinguishable from silence until the file turns out to be empty.

A watchdog now reports both, three seconds in, and the first goes to the OPERATOR
as a toast rather than only to the log: they are mid-QSO, and by the time they
find out at save time the audio is gone for good.
2026-07-30 23:46:27 +02:00
rouggy c4c5db3921 fix: a silent audio source no longer empties the whole recording
From an operator's log: a Flex with From Radio on DAX RX and Recording mic on
DAX Mic, a full CW QSO recorded, and at log time "snapshot failed: recording
was empty".

The mixer took min(len(A), len(B)) samples. In CW the mic path does not run, so
DAX Mic delivered nothing, min stayed 0, and the drift guard then threw the
radio audio away a second at a time. Two devices configured meant the recording
depended on the quieter one being alive.

A source that has delivered nothing for 1.5 s is now treated as absent and the
other is recorded alone, with one line in the log saying so — half a recording
is worth having, silence is not. Both are mixed again the moment the missing one
comes back.

Neither is written off before it has EVER spoken: at startup both timestamps are
zero, and declaring one dead then would clip the opening of every recording.
That case is tested too.
2026-07-30 23:39:54 +02:00
rouggy a1ceea978b fix: no play-on-air button on CW
In CW the transmitter builds its tone from the keyer and ignores the DAX TX
audio path, so the button keyed the rig and sent silence. Confirmed on a Flex:
our side reported the playback as done while nothing went out.

Only the button goes. The row stays, or a CW operator who stopped their
recording would be left with no way to resume it — and the recording itself is
still worth keeping.
2026-07-30 23:34:23 +02:00
rouggy f4956a63bb fix: say why playing a recording on the air did nothing
"I click play and nothing happens" is what an operator sees when a function
returns an error into a promise the interface swallows. Every refusal in the
playback path is now logged as well as returned, and each names its own cause —
audio not initialised, the take still running, the take empty, the device
rejecting the file.

One of those was not even checked: playing back needs an OUTPUT device INTO the
radio, which is a different setting from the recorder's input and is very likely
unset on a station that has only ever recorded. It now refuses with that
sentence instead of failing somewhere inside the player.
2026-07-30 23:27:30 +02:00
rouggy 5a4ad800b3 test: pin which modes produce a recording
CW has moved in and out of this list twice — excluded for the missing sidetone,
restored because the other station is captured regardless. A test says which,
so the next reading of that comment does not undo the decision.
2026-07-30 23:24:58 +02:00
rouggy e152ef0ee0 feat: record CW QSOs again
CW was excluded because SmartSDR does not route the operator's own sidetone
through DAX, so the recording sounds one-sided. The audio path is open all the
same and the other station IS captured — which is the half worth keeping.

The frontend had ONE list serving two purposes: which modes may be recorded, and
which modes the voice keyer may transmit on. Adding CW to it would have let the
DVK key the rig with speech on a CW frequency. They are now two lists: PHONE_MODES
for the keyer, RECORDABLE_MODES (phone + CW) for the recorder.

Digital modes stay out of both: their audio is a modem tone nobody will replay.
2026-07-30 23:13:21 +02:00
rouggy b6465ddc9d docs: trim the Flex split entry to the change itself 2026-07-30 23:07:52 +02:00
rouggy 961357474d feat: Flex stays on the RX slice in split
SmartSDR moves its active flag onto the TX slice as soon as the transmit
frequency is touched, and OpsLog followed it. But the operator is listening to
the DX on the other slice: the S-meter, audio level, filter and DSP they are
adjusting all belong there, so following the transmitter handed them the
controls of a receiver they were not using.

In split the receive slice now wins. Simplex is unchanged, and two same-band
slices in different classes (SSB alongside FT8) are still not a split.

A slice clicked IN OPSLOG is remembered and overrides both the split rule and
the radio's focus — that is the operator speaking. Activating a slice on the
radio or in SmartSDR deliberately does not move OpsLog. The pin is dropped when
that slice stops being in use, so a closed receiver cannot strand the display.

The panel now highlights the slice OpsLog is working with rather than the radio's
focused one; reporting the radio's flag would light up one slice while every
control acted on another.
2026-07-30 23:03:29 +02:00
rouggy 702ca4f0c9 docs: one line per changelog entry
The 0.22.3 block had grown into explanations: why a fault happened, how it was
found, what it does not cover. That belongs in the commits, where it already is.

An operator opens this list to learn what changed for them. Sixteen entries now
say it in a line each.
2026-07-30 23:02:16 +02:00
rouggy 93b4964b3b chore: release v0.22.3 2026-07-30 22:33:01 +02:00
rouggy 14c57035e1 docs: changelog entry for the DARC DOK Award (DLD) 2026-07-30 22:32:53 +02:00
rouggy 7a7fa62af7 feat: choose how dates are displayed (Standard / French / US)
Settings → General, beside the language. An operator reading 2026-07-30 as the
7th of the 30th month is reading their own log wrongly, and that is a display
problem.

Storage stays ISO/ADIF, deliberately and permanently: it is what ADIF
specifies, it sorts correctly as text, and a stored format that followed a UI
preference would make the log unreadable the day the preference changed. Exports
and uploads are untouched.

Two details that decide whether it actually works:

  - The columns are rebuilt when the format changes. The formatters are captured
    inside AG-Grid's column definitions, so nothing else would notice and the
    table would keep the old format until something forced a rebuild.
  - main.tsx re-reads the choice after the portable prefs are pulled from the
    database. The module reads localStorage at import time, which is BEFORE that
    sync — so a copied data/ folder would have shown ISO until the next launch.

UTC is not part of the choice and never will be: a logbook is kept in UTC, and
showing local time would make the display disagree with the QSO's own record
twice a year.
2026-07-30 22:22:33 +02:00
rouggy 6ee4c430c7 feat: colour the QSL and upload status letters
Y green, N red, R blue — colour only, no pill or badge. These columns sit among
callsigns and dates, and a row of coloured boxes would shout louder than the
callsign it belongs to.

Twelve columns: paper QSL, LoTW, eQSL, Club Log, HRDLog, QRZ.com both ways, and
OpsLog's own card and recording flags. All four views the operator named —
recent QSOs, worked before, NET Control, QSL manager — turn out to share this
one grid, so they are all covered by construction rather than by four edits that
could drift.

Semantic tokens rather than fixed colours, so the four themes follow and the
contrast stays right on the light ones. ADIF's I (ignore) is deliberately left
in the default ink: it is neither good news nor bad, and colouring it would put
it in one camp or the other.
2026-07-30 22:12:23 +02:00
rouggy 0df6370c59 docs: the author's wording for the remote-delete hint
Says what the switch does, then names the one limit that changes the decision.
Drops my "no undo" line: deleting a QSO is already understood to be permanent,
and repeating it reads as a warning about the checkbox rather than the QSO.
2026-07-30 22:04:15 +02:00
rouggy fa94657619 docs: cut the remote-delete hint to what must be known before ticking it
Four sentences of API behaviour under a checkbox. Two facts actually change the
operator's decision: it cannot be undone, and QRZ.com cannot reach QSOs uploaded
before OpsLog started keeping their record number.

The rest — how Club Log matches, that the local delete happens regardless, that
the log records each answer — is true, and belongs in the log and the code where
it already is. Documentation in a checkbox hint is documentation nobody reads.
2026-07-30 22:03:02 +02:00
rouggy f5b74856c6 fix: the remote-delete switch sits above the service tabs
It applies to QRZ.com and Club Log both, but it was rendered after the tab
strip — so it read as a QRZ setting when the QRZ tab was open, appeared to
repeat itself on the Club Log tab, and was invisible to anyone opening a third
one.

Above the tabs it belongs to the panel, which is what it actually is.
2026-07-30 22:01:45 +02:00
rouggy d42fdab85c feat: withdraw a deleted QSO from QRZ.com and Club Log
Opt-in, off by default: neither service can undo it, and a local delete is not
always a statement about the world — a duplicate cleared out of a contest log
was never meant to change the DX's log.

The two APIs are not alike, and the difference decides what is possible:

  - Club Log deletes by MATCH (callsign, exact timestamp, band number), so it
    works for every QSO in the log, past or future. The band table is its own
    list and is pinned by a test: a band mapped to the wrong number does not
    fail, it points the delete at a different QSO.
  - QRZ.com deletes ONLY by logid. So markExtUploaded now stores the LOGID it
    already received on every upload — it was being logged and thrown away. A
    QSO uploaded before this cannot be withdrawn from here at all, and the log
    says so by name rather than leaving the operator to assume it worked.

Both run BEFORE the local delete, because both need the QSO's own fields to
find their copy and there is nothing left to ask with once the row is gone.
Neither can block it: the operator asked for the QSO to go, and a refusing
website is not a reason to keep it.
2026-07-30 21:57:40 +02:00
rouggy 83a8708d60 fix: remember the award sort, and the grey line, across restarts
The sort choice is now written through writeUiPref and both keys are registered
as portable, so they travel with a copied data/ folder like every other UI
preference.

Registering them is the part that matters: writeUiPref stores to the DB, but
only keys on the portable list are read BACK at startup. A key written and never
restored looks exactly like one that was never saved — which is what would have
happened here.

The grey-line toggle had the same gap. It called writeUiPref from the day it was
written but was never added to the list, so it came back off on the next launch.
Found while adding the award keys beside it.
2026-07-30 21:49:25 +02:00
rouggy c0671bfe2e feat: sort award references by reference or description
Reference stays the default: that is how award lists are published and how a
paper list is read. Description answers the other question an operator actually
has — "have I worked anything in Alicante" — which reference order scatters
across eight thousand rows.

Sorted where the filtered list is built, so the grid and list views inherit it
rather than each growing its own copy that could disagree.

localeCompare rather than a byte comparison, because these lists are Spanish,
French and Italian place names: "Ávila" belongs beside "Avilés", not after
"Zaragoza". References compare numerically for the same reason in reverse —
08019 must not sort between 0801 and 081.
2026-07-30 21:47:05 +02:00
rouggy ec0cb95de9 style: the play button is green
It was the same red as the recording indicator beside it, so at that size the
two read as one control. Green is also what the theme already uses for a
go-ahead action (the amplifier ON buttons), and it is a semantic token, so it
follows all four themes rather than pinning a colour.
2026-07-30 21:43:41 +02:00
rouggy 9d691343cf fix: say in the log why a recording was not saved
Two silent returns in the save path: no take was running at log time, or the
mode carries no audio worth keeping. Both simply produced no file.

An operator reporting "the sound is not in the folder" is describing an absence,
which is the same absence in either case — but they are different problems with
different fixes, and neither leaves anything behind to inspect. Now each says
which one happened, and for which callsign.

The success line already existed ("qso-rec: saved <path>"), so between the three
the log now accounts for every logged QSO.
2026-07-30 21:41:25 +02:00
rouggy c6088bb2de fix: the stop button overlapped the recording counter
Two absolutely-positioned boxes at guessed offsets: right-14 and right-2. They
collided as soon as the counter passed a minute and the text grew — a layout
that only looked right for the first 60 seconds, and did not even manage that at
this font size.

One flex row with a gap, so spacing is a consequence of the content rather than
an arithmetic guess about it. The callsign field also gains right padding while
the badges are shown, so a long call runs under them instead of being typed over.
2026-07-30 21:39:50 +02:00
rouggy 069728ac68 feat: stop a QSO recording and play it back on the air
The situation, in the operator's words: working VP2MAA, recording, and he asks
to hear his own signal. So: stop, play, and the recording is still saved with
the QSO.

Stopping FREEZES the take rather than ending it — nothing more is captured,
nothing is discarded, and logging writes the file exactly as before. Playback
reuses the voice keyer's path: same output device, same PTT keying and release
with its generation guard, so a replay cannot be cut short by a stale unkey.

Playing is refused while still recording. The transmitted audio would feed back
into the same take on any rig monitoring its own transmission, and stopping is
one click the operator has already made — it is their statement that the take is
finished, not a hoop.

The elapsed clock freezes with the recording and resumes from where it stopped,
because it now shows the LENGTH OF THE FILE rather than the time since the QSO
began. Only a fresh take returns it to zero.
2026-07-30 21:36:42 +02:00
rouggy f221c0ee1e feat: record a contact by hand when automatic recording is off
A red dot in the slot the counter will occupy. Click it and the counter starts,
the dot disappears, and logging the QSO writes the file exactly as an automatic
recording would — the save path already keyed off an active take, not off the
setting.

With automatic recording disabled the capture engine is not idle, it is not
running at all, so a manual take starts it. Two consequences handled here:

  - It is released again when the take ends — logged, cancelled, or dropped for
    being a digital mode. An operator who records one contact does not expect
    their microphone held open for the rest of the session. The release is a
    deferred call at the top of the save path so every exit goes through it,
    rather than one that has to be remembered at each return.
  - There is no pre-roll. An automatic recording opens with the seconds BEFORE
    the callsign was typed; a manual one can only begin now. The button's
    tooltip says so, because otherwise the difference between two files is a
    mystery to the person holding them.

The button only appears where it can work: devices configured, automatic
recording off, and a mode whose audio is worth keeping. That state is asked of
the backend rather than inferred from a preference in the interface, so the two
cannot drift apart, and it is re-read when settings are saved.
2026-07-30 21:24:51 +02:00
rouggy 013492adb0 fix: name CAT backends by how the radio is reached
The list read "Yaesu (native CAT)", "Kenwood (native)", "FlexRadio / SmartSDR
(native)". Native says how OpsLog is built, which is our business; the operator
opening that menu is answering a different question — how is my radio plugged
in.

So: OmniRig, FlexRadio (API), Yaesu (USB), Kenwood (USB, network), Xiegu (USB),
Icom (CI-V USB), Icom (CI-V network), TCI. Shorter, and each entry now tells
someone with a cable in their hand which line to pick.
2026-07-30 21:20:08 +02:00
rouggy 3a87b58ba3 feat: Kenwood CAT over a network serial bridge
The same ASCII stream over a socket instead of a wire: ser2net, an
Ethernet-serial adapter, a Raspberry Pi at the radio. One TCP transport
presented as a serial port, so the backend keeps a single code path.

Explicitly NOT the radio's own RJ45. A TS-890 or TS-990 speaks Kenwood's
KNS/ARCP there — session, authentication, a different protocol — and that needs
one of those radios in hand to write honestly. The setting's help text says so,
because an operator who plugs in their TS-890's Ethernet port and types its
address deserves to learn that from the interface rather than from silence.

Two details that decide whether this is usable or maddening:

  - A read deadline expiring is reported as "no data yet", not as an error. It
    is exactly what a serial read timeout means to the caller; as an error it
    would make ask() abandon a rig that is merely thinking.
  - The log line names what was connected to. "connected on  @ 0 baud" after a
    network connect sends someone hunting a serial fault that does not exist.

The host wins over the COM port when both are filled: it is the more deliberate
setting, and silently preferring the wire leaves someone staring at an address
they typed and a radio that never answers.
2026-07-30 21:13:19 +02:00
rouggy e6b8a17772 fix: a rig reporting LSB/USB left the mode selector empty
From a Kenwood trace: IF answered mode digit 1 on 7.160 MHz, the backend read
LSB correctly, and the entry form showed no mode at all while the frequency
tracked perfectly.

Nothing was wrong in the backend. Radios report the SIDEBAND, and the station's
mode list holds SSB — so the value pushed into the selector was not in its own
list of options and nothing could match. Yaesu and Icom report the same way, so
this was never Kenwood-specific; it only surfaced there.

Normalising at the point where a rig-reported mode enters the form fixes every
backend at once, rather than editing three mode tables and the next backend
someone writes.

It is also the ADIF-correct direction, which matters more than the selector:
LSB and USB are SUBMODEs in ADIF, not modes. MODE=LSB is not a valid value and
is what would have gone to LoTW and eQSL.

An operator who has deliberately put LSB or USB in their own mode list keeps
them — the list is their statement of what they log, not ours.
2026-07-30 20:50:05 +02:00
rouggy 802c6e8c43 docs: changelog records what shipped, not how it was built
The grey line has never been released, so its off-by-one-copy edge was never
something an operator saw. An entry for it would describe a defect that only
existed between two commits of the same unreleased feature — noise in the middle
of the list someone reads to find out what changed for them.

Same case for the bulk-update progress bar: the feature and the fix that made it
non-blocking are both new in 0.22.3, so they are now one entry describing what
it does. The menu rename goes there too rather than standing alone.
2026-07-30 20:37:16 +02:00
rouggy f82631690a fix: grey line cut off at the edge of the world copy
The map repeats the world sideways, and zoomed out several copies are on screen
at once. The rings span −180…+180 once, so the shading ended in a hard vertical
edge wherever the next copy began: it read as a rectangle laid over the planet
rather than as night.

Each ring is now drawn again shifted a full turn either way. Three copies cover
every zoom this map reaches and cost nothing — three more polygons on a canvas
layer.

Zooming the map to fit one world would have hidden it rather than fixed it, and
would have taken away the free pan and zoom the operator has.
2026-07-30 19:35:26 +02:00
rouggy 3b58e39eec feat: grey line on the world map; fix: trace checkboxes lied about their state
Grey line — the day/night terminator and its twilight band, off by default,
redrawn every minute so a map left open all day is not silently wrong by
evening. Its own Leaflet pane below the overlays, non-interactive, so it never
hides the path or beam nor swallows a click.

The solar maths is pure and was checked against known positions before being
wired to anything: both solstices, the March equinox, the subsolar longitude at
12 UTC, and day/night at Paris, Tokyo, New York, Sydney and Reykjavik across
seasons and hemispheres. That last set is what caught the real bug: the equation
has TWO solutions per meridian and the naive branch put New York in daylight at
02:00 in December — the terminator at +63° where it belongs at −63°. A shaded
map that looks entirely plausible and is exactly mirrored. It now anchors on the
classical terminator and takes the branch nearest it, which is also what makes
the twilight band follow the terminator instead of jumping hemisphere.

Separately, from a Xiegu user: the protocol-trace checkbox does not stay ticked.
It is React state initialised to false on every mount, so a trace still running
came back unticked — the operator ticks it to "switch it on", which switches it
OFF, and the log they send contains no trace at all. Worse than a cosmetic bug:
it silently defeats the one tool asked for to diagnose their radio. Both boxes
(CAT and WinKeyer) now read their real state from the backend.
2026-07-30 18:06:12 +02:00
rouggy 45e3f3edb8 fix: bulk update no longer holds the application hostage, and says what it does
Two faults in the progress bar shipped an hour ago.

It was a modal. A hundred QSOs is a minute; a contest log is two thousand and
ten minutes, and for all of it the operator could do nothing else — while their
radio is on. It is now a card in the corner: same bar, same count, same
callsign, none of the imprisonment.

And the menu said "Update from QRZ.com" while the code has always walked EVERY
configured provider, falling through to HamQTH when QRZ answers not-found. The
label is now "Update from the callsign databases", which is what happens.

Noted while reading that code, NOT fixed here because it trades against the
user's API quota and is their call: this path goes through the lookup cache, so
a second run on the same callsigns re-reads the cache rather than reaching the
provider. The cache comment already argues the opposite for deliberate clicks —
"a lookup the operator asked for by clicking is a deliberate act and must reach
the provider" — and a right-click on a selection is exactly that.
2026-07-30 18:00:19 +02:00
rouggy c931d8a762 feat: progress bar for bulk QSO updates from QRZ.com
Selecting the 102 contacts of a freshly imported contest log and updating them
from QRZ.com is 102 network round trips. Nothing moved on screen while it ran,
so the only honest reading was that the application had frozen — and the natural
response to that is to kill it, halfway through.

The backend now emits a progress event per QSO and the frontend shows the same
overlay the ADIF import uses: a bar, the count, and the callsign being queried.

The callsign is reported BEFORE the lookup rather than after. With a slow
provider that is the difference between "waiting on F4BPO" and a name that lags
one QSO behind whatever is actually taking the time — which would be the field
someone stares at while deciding whether it is stuck.

The overlay is cleared by the closing event and again in a finally, so neither
a completed run nor a failed one can leave it on screen.
2026-07-30 17:54:07 +02:00
rouggy eb271e8f20 fix: Kenwood split read from FR/FT when the status frame omits it
Reported from a Flex in Kenwood CAT mode: frequency read perfectly, split never
appeared. Not every rig speaking this dialect fills IF's split bit.

Rather than guess which column that firmware populates, ask the question that
DEFINES split — is the transmit VFO a different VFO from the receive one — which
is exactly what FR and FT answer, and the same rule the Yaesu backend settled on
after several wrong turns. FR is also trusted over IF for which VFO is in use:
they are asked in the same breath, and a rig vague about the split bit may be
just as vague about the VFO field.

Cost is bounded: a rig that rejects FR/FT answers "?;" once and is never asked
again, so this is two short commands per poll only where it works. Both paths
are tested against the emulator — split found through FR/FT with IF silent, and
IF-reported split still working on a rig that refuses FR/FT without re-asking.

Also extends the CAT wire trace to the Kenwood backend, ASCII quoted so an empty
reply is visible as such: "" and ";" look identical unquoted, and telling them
apart is the whole question when a rig half-supports a command. If this fix is
not the whole story on real hardware, the trace is what will say so.
2026-07-30 16:50:06 +02:00
rouggy e2d2485703 test: drive the Kenwood backend against a rig that answers
Nobody here owns a Kenwood, and a backend that has never completed a single
exchange is a guess however carefully it was written.

But this repository already contains the other half of the conversation:
internal/catemu ANSWERS this dialect, pretending to be a TS-2000 so an ACOM
amplifier follows OpsLog. The test's responder replies exactly as catemu does —
same FA/FB widths, same 38-character IF layout, same ID019 — so the two halves
are checked against each other instead of against my reading of a manual.

Connect adds a dialPort seam for this; nothing else changed in the backend.

Covered: model identification, simplex on A, everything following the VFO in
use when on B, tuning writing to that VFO rather than blindly to FA, split with
TX and RX the right way round (ADIF FREQ is the TRANSMIT frequency — swapping
them writes the wrong frequency into every logged QSO), the mode digit, and a
silent port reported as not answering rather than as a connected radio. The last
three are each a fault that actually reached a user through the Yaesu backend.

What this does NOT prove: that a real TS-590 answers on the same timings, or
fills every IF field the way its documentation says. That still needs a radio.
2026-07-30 16:36:49 +02:00
rouggy 712a124081 chore: release v0.22.2 2026-07-30 15:14:32 +02:00
rouggy 08a932c9c1 fix: drop the per-column filters from the recent-QSO grid
They searched only the rows on screen. With the grid holding the most recent
page, filtering a column for RSGB-IOTA across a 28 000-QSO log returned nothing
— which reads as "the log does not contain it", not "it is not on this page".
That is how an import that had worked perfectly came to be reported as broken:
the tool answered a narrower question than the one being asked, and said so
only by being empty.

The advanced filter queries the whole logbook and is the honest instrument. The
headers also get back the width the filter menu icon was taking, which is the
smaller reason but a welcome one on a grid this dense.

No saved filter can be stranded by this: the grid persists widths, sort and
visibility, never a filter model.
2026-07-30 15:13:25 +02:00
rouggy fee2303625 test: pin the whole ADIF import path, not just the conversion step
CONTEST_ID was reported as not imported. It is imported: this drives an
operator's own IOTA-contest record through a real SQLite logbook and reads the
row back, confirming contest_id=RSGB-IOTA and iota=EU005 in the database.

The conversion-level test was already green while the report stood, which is
exactly why it was not enough — a promoted field can be dropped at any of five
places between the file and the grid, and only the round trip says which. The
answer here turned out to be none of them: the Contest columns are simply hidden
by default in the QSO grid.
2026-07-30 15:07:48 +02:00
rouggy 4d5c5c3eb3 feat: move ADIF fields on import, for contest exports that misplace the exchange
An operator worked the RSGB IOTA contest in another logger and got back records
carrying <STATE:5>EU005: N1MM-class software stores the received exchange in the
column its contest module uses, not the one ADIF reserves. Imported verbatim,
every QSO gains a US-state field reading "EU005" and the IOTA award sees
nothing — the reference is in the log, just not where anything looks for it.

The import dialog gains optional source → destination rows, prefilled with
STATE → IOTA since that is the case that prompted it. Applied to the RAW record
before conversion, so it works for promoted columns and Extras alike and the
destination is parsed exactly as if the file had carried it there.

Two rules, both from the same principle that the file outranks our guess:
  - the source is CLEARED, so a reference does not linger in STATE where it
    would show as the contacted station's US state in the grid and every export;
  - a destination the file already filled is kept, judged against the record as
    it ARRIVED — otherwise the result would depend on Go's map iteration order,
    which is deliberately random.

The swap case in the test is what forced that second rule to be stated: "never
overwrite" and "exchange two fields" cannot both hold silently, so a destination
that is itself a mapping source is treated as an explicit swap and nothing else
is overwritten.
2026-07-30 14:57:05 +02:00
rouggy d322ea9a07 feat: report UI crashes instead of showing a blank window
Two white-screen reports are open — one after logging a 10 GHz QSO, one on
starting a DVK auto-call — and neither could be investigated, for the same
reason: a render error emptied the window and left NOTHING. No line in
opslog.log, no dialog, nothing the operator could send but the words "white
screen". A fault the user cannot report is a fault that cannot be fixed.

So before hunting either trigger, the reporting path:

  - An error boundary catches a render throw, writes the error and component
    stack to the app log through the new LogUIError binding, and shows it on
    screen — selectable, with Copy and Reload.
  - Global handlers catch what a boundary cannot: throws from timers, event
    handlers and rejected promises. An auto-call loop lives entirely in
    callbacks, which is exactly where the second report came from, and those
    leave no trace at all today.

It deliberately does not try to resume: React cannot promise sane state after a
render throw, and a half-working logger would be worse than a clear stop.

This does not fix either crash. It makes the next occurrence arrive with its
cause attached, which is the step that has been missing.
2026-07-30 14:36:19 +02:00
rouggy 75c02ea2a0 feat: CI-V byte trace, for a fault the command table cannot explain
Reported on an IC-9100: the MOX button does not transmit, it sets split. The
source sends the PTT command (0x1C 0x00) and nowhere sends the split one (0x0F),
so the discrepancy is between what OpsLog sends and what the rig acts on — and
that link has already been shown, in the same operator's log, to lose frame sync.

Rather than guess from the command table, this adds what settled the WinKeyer
bug in one line: the actual bytes. Settings → CAT → Log the CI-V protocol writes
every frame in both directions as hex. RX is traced BEFORE framing, since the
bytes as they arrived are what reveals a lost boundary — a decoded view would
hide exactly the fault being hunted.

Session-only, like the keyer trace: it is a diagnostic, and a log full of hex
helps nobody who forgot it was on.

I am not claiming a cause yet. The last time I inferred one from a protocol
document rather than from evidence, I inverted a digit and broke the case that
worked.
2026-07-30 14:30:05 +02:00
rouggy ef84b04c99 fix: CI-V frames read at the wrong offset decoded as plausible frequencies
From F4JND's log: a rig sitting on 145.550 MHz FM produced status lines reading
16445550000, 8364550000, 5817408364 and 12640 Hz between good readings — the last
of those even resolved to a 6 cm band.

BCDToFreq treated the nibbles 0x0A..0x0F as the digits 10..15, so a byte stream
that had lost frame sync still decoded into a number. The same log shows why sync
was lost: PTT round trips timing out and a scope stream the rig kept rejecting
(0x27), both competing with the poll on one serial line.

Non-decimal nibbles are now refused. Each caller keeps its last good value rather
than publishing noise: the frequency reader returns an error, the sub-VFO reader
returns not-ok, and the scope keeps its previous edges. Garbage that LOOKS like a
frequency is worse than a refused frame — it reaches the display, the band slots
and eventually the log, and it is the operator who then has to disprove it.

The test builds its fixture with FreqToBCD rather than by hand, because my
hand-written one was byte-reversed and the test caught it.

This does NOT explain the white screen that was also reported, and I have not
claimed it does: it removes one source of absurd values reaching the UI, which is
worth doing on its own evidence.
2026-07-30 14:05:30 +02:00
rouggy fbd6cf400f docs: open 0.22.2 — five entries described work absent from the 0.22.1 build
The 0.22.1 tag was cut at d98bb2e, and six commits landed after it. Five of them
had appended their entries to the 0.22.1 block, so the released changelog
promised things the released binary does not contain: the 4O3A marks, the
status-bar logbook path, the Yaesu per-VFO mode, ERC rotator support and the
Kenwood backend.

Those five move to a new 0.22.2 block, in the order they were written. The
0.22.1 block is restored to exactly the twelve entries the published tag holds —
verified against `git show v0.22.1:changelog.json` rather than by eye.

Worth noting for next time: entries are appended as work lands, so any commit
after the release tag needs its own block opened first. The mistake is silent —
nothing fails, the What's New dialog simply describes a version that never
shipped those changes.
2026-07-30 12:00:45 +02:00
rouggy b16cb973f2 feat: native Kenwood CAT backend
A sixth CAT backend, talking to a TS-590/890/990/2000 over its serial port with
no OmniRig in between — frequency, mode, VFO, split and PTT. Elecraft K3/K4 and
the "Kenwood" setting on other radios speak the same dialect.

The protocol was not researched from scratch: internal/catemu already ANSWERS
these commands, pretending to be a TS-2000 so an ACOM amplifier follows OpsLog.
The IF frame layout here is read from the same format string catemu emits, so
the two halves of the repository agree by construction — and the test caught my
own fixture being one character short, which is exactly the error that layout
invites.

Design notes worth keeping:

  - IF; is the poll. One frame carries frequency, TX state, mode, VFO and split,
    so simplex operation costs a single round trip; the other VFO is only asked
    for when split is actually on.
  - FA/FB take ELEVEN digits here where Yaesu uses nine. That is the likeliest
    place to copy the Yaesu backend and be wrong by a factor of a hundred, so it
    has its own test.
  - Every lesson the Yaesu backend learned the hard way is built in from the
    start: replies matched to the command that asked, "?;" remembered so a poll
    stops paying a timeout for an unsupported command, the serial handle closed
    before reopening, and a rig that answers nothing reported as absent rather
    than "connected".

Untested on hardware — I have no Kenwood here. The frame parsing is covered by
tests against catemu's own format.
2026-07-30 11:56:41 +02:00
rouggy da886de189 feat: support ERC rotator controllers through the existing GS-232A backend
An ERC (Easy Rotor Control, incl. ERC Mini) emulates Yaesu GS-232A/B, which is
exactly what the backend written for the microHAM ARCO already speaks — azimuth
out (Maaa), azimuth in (C), stop (S), which is the whole of what was asked for.
So this is two small gaps rather than a new backend:

  - Serial speed was hardcoded to 9600. An ARCO's virtual COM ignores it, but an
    ERC runs at whatever its own configuration sets, and a mismatch reads as a
    dead rotator. It is now selectable beside the COM port.
  - The entry was called "microHAM ARCO", so an ERC owner would never have found
    it. It is named after the PROTOCOL now: "GS-232A controller (microHAM ARCO,
    ERC…)".

The help text states the condition plainly in both languages, because it is the
one thing that will otherwise waste an evening: an ERC left on Hy-Gain DCU-1
speaks a different command set and simply will not answer.

Untested on hardware — I have no ERC here, and the GS-232 path itself is proven
on a real ARCO.
2026-07-30 11:07:57 +02:00
rouggy 7759e09b4e fix: Yaesu mode commands always addressed the main receiver
Reported, and reasoned out from the half that already worked: a spot click now
tunes the right VFO, but it set the mode with MD0 — main — whatever VFO the
operator was on. Working from SUB, the spot changed the mode of the VFO NOT in
use and left the one in use as it was. The author inferred the sub half from
seeing main change, and was right.

The read had the same fault, so the console displayed main's mode while showing
the sub VFO's frequency.

Mode now addresses the receiver in use — MD0 for main, MD1 for sub — in the poll
read, in SetMode, and in the console's raw-mode setter. A rig without a sub
receiver never sees MD1: curVFO only becomes B where the rig reports its receive
VFO in the first place.
2026-07-30 10:10:43 +02:00
rouggy a8990ff29d fix: the status bar named the settings database, not the logbook
Spotted by the author: on SQLite the database chip showed a.dbPath — the
settings/profile database — under a tooltip reading "Local SQLite logbook".

Those became two separate files when the logbook was split out, so an operator
checking where their QSOs live, or which file to copy before a trip, was pointed
at the wrong one. MySQL was already right.

It now resolves the logbook the same way connectLogbook does: the active
profile's own file if it names one, then the default logbook.db, and the settings
db only in the case where it genuinely doubles as the logbook.

Also moves the 4O3A mark beside its menu label instead of the far edge of the
row, as asked.
2026-07-29 23:53:18 +02:00
rouggy e3b810b4ff feat: draw the actual 4O3A mark in the sidebar, not a text badge
The wordmark I put there was not what the author expected: the brand is "4", a
green waveform of stacked lenses, then "3A".

Only the waveform is SVG. The digits stay HTML text so they keep the sidebar's
own typeface at any theme or zoom — an SVG <text> would pick its own font and
drift from the menu around it. Seven ellipses, tallest in the middle, match the
shape of the mark.

Still drawn rather than fetched: an image off the web has no verifiable
provenance, and this stays one small component to swap if the official SVG is
preferred.
2026-07-29 23:37:57 +02:00
rouggy 337392bb6d feat: mark the 4O3A panels in the settings sidebar
Antenna Genius and Tuner Genius now carry a small 4O3A wordmark, so the two
panels that drive that hardware are recognisable without reading the labels.

Drawn inline rather than shipped as an image file, following the language
picker's flags: it scales with the row, follows the theme through currentColor,
and adds no binary asset to the repository.

It is a plain wordmark, not the manufacturer's logo artwork — I did not pull an
image off the web, since I cannot verify the provenance or licence of what I
would find, and redistributing a company's mark inside the product is the
author's call. The component is one function and takes the official SVG in its
place if that is preferred.

Not applied to the Amplifier panel: it covers both the 4O3A PowerGenius XL and
the SPE Expert range, so a single vendor mark there would be wrong.
2026-07-29 23:28:27 +02:00
rouggy d98bb2e929 chore: release v0.22.1 2026-07-29 23:13:27 +02:00
rouggy 3ab6b95ff8 fix: auto-call CW gap started before the message had finished
Reported: a 6 s auto-call gap gives clearly less than 6 s between the end of one
CQ and the start of the next.

The wait after sending watched only the keyer's busy signal. That signal travels
from the keyer to the window as an event, and the code gave it one second to
appear before giving up — if it had not arrived and cleared by then, the wait
ended immediately and the gap ran from the START of the call. A 6 s gap after a
4 s CQ becomes about 2 s of silence, which is exactly what was heard.

The message's own duration is now a FLOOR: the wait runs at least the estimated
sending time, and the busy signal only extends it (slow link, long buffer),
capped as before. The estimate can only be approximate, but it cannot be skipped
by a late event, and erring long costs a slightly wider gap rather than a call
that steps on the previous one.
2026-07-29 22:28:18 +02:00
rouggy b0da6a3d14 fix: RX and TX both on SUB is simplex, not split
Reported on an FTDX101: with RX and TX moved together to the sub VFO, OpsLog
showed split — and took the MAIN frequency as the transmit one, which would log
the wrong frequency.

ST is a bare flag. It says "split" without saying which VFO transmits, and this
rig raises it whenever the transmit VFO is the sub one, whether or not the
operator is also listening there. FT names the transmit VFO, so where the receive
VFO is known as well (FR), split is derived from the pair: they differ or they do
not. That is a fact about the rig's state rather than a flag whose meaning varies
by model.

The fix is therefore in the probe ORDER, not in the reading: FT is asked first
when FR answered, and ST stays the fallback for rigs that have neither. A test
pins the order and both halves of the trap — that FT gets this case right, and
that ST alone gets it wrong.
2026-07-29 22:10:21 +02:00
rouggy 2d7469a7f7 fix: do not report a Yaesu as connected when it answers nothing
The seven minutes of reconnects in the operator's log turned out to be the radio
switched off — no defect. But the log said otherwise:

  20:14:42 yaesu: ID query failed (timeout) — continuing…
  20:14:44 yaesu: connected on COM7 @ 38400 baud, model="FTDX101D"

Opening the port was treated as connecting, and the model name was left over from
the previous session, so a powered-down rig produced a line claiming it was
connected as a named model. That is what sent me looking for a software fault.

Connect now tracks whether ANY probe answered. If none did, it clears the stale
model, says so in terms an operator can act on — is it powered on, is the CAT
rate right — and returns an error so the reconnect loop keeps trying rather than
believing it succeeded.
2026-07-29 21:53:26 +02:00
rouggy 775f411606 fix: Yaesu Connect leaked the serial handle on every reconnect
Connect assigned a fresh handle to y.port without closing the one it already
held. It is called again on every reconnect, so a rig that fails to answer leaks
one handle per attempt — every few seconds, indefinitely.

Windows opens a serial port EXCLUSIVELY, which makes this visible as "yaesu: open
COM7 @ 9600 baud: Serial port busy" in an operator's log: OpsLog was competing
with itself for the port.

This is a real defect found while reading the reconnect loop, but I want to be
straight about its limits: it does NOT explain the seven minutes of "ID query
failed — timeout" in the same log, where the port opened and the rig stayed
silent. That one is still open, and the operator alternates between two separate
radios (a TCI SDR and the Yaesu) rather than sharing one port, so a conflict
between backends is not the explanation either.
2026-07-29 21:43:05 +02:00
rouggy 6501e97895 fix: read the FIRST digit of the Yaesu VFO reply — I inverted it yesterday
Reported both ways round, which is what settles it. With RX and TX on SUB
everything worked; with them on MAIN the frequency froze and a spot click tuned
the sub VFO. The log shows the rig answering FR01 in the MAIN case.

So on an FTDX101 the state is the FIRST digit — 0 = main — and the second is a
separate parameter. Yesterday I changed this to the LAST digit, which read FR01
as SUB and inverted the whole thing. The fault that change was meant to fix ("SUB
shows MAIN") came from reading VS instead of FR, and probing FR had already fixed
it on its own; the digit change was an unnecessary guess layered on a real fix.

Nothing else needed changing for the operator's two questions: the display and
SetFrequency already follow curVFO, so a spot now tunes whichever VFO holds RX
and TX, and the main frequency is read when on main. A test states that in those
terms — active VFO and which command a spot writes — rather than only as a byte,
because the byte is what I got wrong while believing the logic was right.
2026-07-29 21:39:22 +02:00
rouggy f3807c21ba fix: FTDX101 answers the VFO query with two digits, and only the first was read
The log named the cause in one line: yaesu: receive VFO is read through FR
(answered "FR01;"). An FTDX10 answers "FR0;" — one digit — so reading the digit
straight after the prefix worked there and took the 0 of "01" on an FTDX101.

The rig was on SUB and OpsLog said MAIN, which is the reported "Sub shows the main
frequency". It also explains the second half: split is "TX VFO differs from RX
VFO", so a receive VFO that alternated between right and wrong made the split flag
flip between consecutive polls and swap freq and rx in the log.

The state is now taken from the LAST digit before the terminator, which is correct
on both forms, and the same reading applies to ST/FT/VS rather than leaving the
next two-digit rig to be found in the field.

On the operator's question of whether freq should simply be the active VFO: no —
freq is the TRANSMIT frequency by ADIF convention (FREQ is the QSO frequency,
FREQ_RX the receive one, set only when split). In simplex they are the same, so it
does read as the active VFO; in split it must stay the transmit VFO or the log
records the wrong frequency. What the operator saw was this bug, not that rule.
2026-07-29 18:35:47 +02:00
rouggy d536c39ef5 docs: label the three numbers in the QSO counter
"Showing 10000 of 23683 matches · 28648 total" was read as a filter matching more
than the log holds. The numbers were right — 23 683 matches out of 28 648 QSOs,
capped at 10 000 displayed — but the middle one belonged to "matches" while
sitting where "of N" normally means the total.

Each number is now named where it appears: shown · match the filter · in the log.
Thousands separators too, since 23683 and 28648 are hard to compare at a glance.

No counting change: both counts run over the same table, so matches can never
exceed the total.
2026-07-29 18:10:39 +02:00
rouggy 284a7a18c7 fix: QRZ download marked every paper-QSL QSO as QRZ-confirmed
Reported: after a confirmation download they all turn to Y.

The test accepted qsl_rcvd = Y as a confirmation. That field is the operator's
OWN paper-QSL flag, which they uploaded to QRZ in the first place — so QRZ handed
it back and OpsLog read it as QRZ's answer. On a log full of paper cards that is
most of the log, which is exactly what was seen.

Only QRZ's own statement counts now: app_qrzlog_status = C, or the ADIF field
qrzcom_qso_download_status = Y that QRZ sets for it. LoTW and eQSL confirmations
are explicitly not accepted either — they are other services' answers, and the
column says QRZ.

This status feeds award slots, so a false Y is a QSO counted as confirmed when it
is not. That asymmetry is why the rule is narrow rather than generous, and the
test lists the negatives as deliberately as the positives.

The changelog warns that earlier downloads may already have set the column
wrongly — the fix stops it happening again but does not undo it.
2026-07-29 17:59:04 +02:00
rouggy 482f81fe45 docs: say that the import option also fills the confirmation statuses
Asked for as a missing feature. It is not missing — the "fill my station fields"
option has stamped the default QSL / LoTW / eQSL / Club Log / HRDLog / QRZ.com
statuses on empty fields since v0.14. What was missing is any mention of it: the
option's description talked only about MY_* fields, so the behaviour was
undiscoverable except by comparing a log before and after.

The description now says it, in both languages, and names why it matters — a
WSJT-X log carries almost no QSL fields.

The rule is also pinned by a test now, which meant splitting the fill from the
settings lookup: blanks are filled, existing values are never touched.
Overwriting them would erase confirmations the operator has actually received,
and that is the half a future edit is most likely to get wrong.
2026-07-29 17:56:10 +02:00
rouggy 5d7a9a9562 docs: changelog for the DXHunter tune-on-spot-click (a036120)
Describes what the operator gets: a spot click in DXHunter now moves the radio,
not just the callsign field. Notes explicitly that a packet without <FREQ>/<MODE>
behaves exactly as before — that is the reassurance anyone already using
remote_call needs, and it is the part a feature description usually omits.

Appended at the bottom of the block, per the rule adopted with 0.22.0: a version
is read top to bottom.
2026-07-29 17:51:23 +02:00
rouggy a036120871 feat: remote_call UDP accepts <FREQ>/<MODE> from DXHunter and tunes the active CAT backend 2026-07-29 17:49:29 +02:00
rouggy ccceab9d02 fix: Yaesu showed MAIN when the operator had moved RX and TX to SUB
The operator's description separates the two cases precisely, and that is what
identifies the fault. RX alone on SUB displayed VFO B correctly; RX AND TX on
SUB — entirely on the sub receiver — displayed VFO A.

VS was being read as "which VFO is in use". On an FTDX101 it does not answer that
question: with both RX and TX on sub it still reported the main VFO. FR is the
command that selects the RECEIVE VFO, and it is now asked first, VS remaining the
fallback for models that lack it.

Split falls out of the same correction: split means the TRANSMIT VFO differs from
the RECEIVE one, so a wrong receive VFO made the comparison wrong too — which is
the second half of what was reported. A test covers all four RX/TX combinations,
including the one that is NOT split (both on sub) and reads as split if you take
the transmit VFO alone.
2026-07-29 17:26:00 +02:00
rouggy 35db1440e4 fix: repair a MySQL logbook already left with TEXT columns
Translating the schema correctly does nothing for a database that already
exists. This operator's had been created by an earlier attempt, so qso.state was
already TEXT and every launch died on the same statement — CREATE INDEX … ON
qso(state), error 1170 — with the connection failing at startup and no way
forward from the UI.

Columns that MUST be VARCHAR (the indexed and keyed ones) are now converted
before the migrations run. It reads information_schema, touches only the columns
in varcharColumns, and only when they are actually TEXT — a no-op on a healthy
database, and it preserves each column's NULL/NOT NULL.

A failure to repair IS reported: it is the difference between a logbook that
opens and one that does not. A failure to READ information_schema is not — a
server that will not answer it can still be running a perfectly good schema, and
refusing to open would turn a non-problem into an outage.
2026-07-29 16:35:57 +02:00
rouggy e3aabc06a4 fix: convert EVERY column in a MySQL baseline, not just the first per line
The quoting fix was necessary but not sufficient: "column 'op_name' can't have a
default value" persisted.

The translation worked line by line and rewrote only the FIRST column on each
line. Our own migrations put one column per line, so nothing showed there — but
sqlite_master stores a table's CREATE statement with every ALTER-added column
appended to the SAME line, and the baseline is built from exactly that. On a
logbook that had been through upgrades, most columns therefore kept their TEXT
type and MySQL rejected the schema.

Two consequences fixed together: every match is now rewritten, and each one is
decided from ITS OWN declaration — the text between the surrounding commas —
rather than from the whole line. Deciding per line would have made every column
sharing a line with a default-bearing one into VARCHAR(255), which breaches the
InnoDB row-size limit the TEXT rule exists to respect.

Tests cover the many-columns-on-one-line shape and pin that a neighbour's DEFAULT
does not leak.
2026-07-29 16:30:44 +02:00
rouggy ab9d0bfe0f fix: MySQL schema build failed on quoted column declarations
Two errors from a shared MySQL logbook, both fatal to the schema and neither
naming its cause:

  CREATE INDEX … ON qso(state) → 1170 BLOB/TEXT column used in key
                                 specification without a key length
  op_name TEXT … DEFAULT ''    → 1101 TEXT column can't have a default value

One root cause. TEXT columns are rewritten to VARCHAR(255) when they are indexed
or carry a default — that is exactly what those two MySQL rules require — but the
pattern only matched a BARE column name. The SQLite baseline dump quotes its
identifiers, and mysqlDDL turns those quotes into backticks before the TEXT rules
run, so every column coming from the baseline kept its TEXT type and MySQL
rejected the schema.

The operator saw none of this: just "MySQL is enabled but the connection failed
at startup" and a silent fallback to local SQLite — which is the worst shape for
a shared logbook, because each operator then logs into their own copy.

The pattern now accepts a quoted or bare name and puts the quoting back. A test
covers both forms, plus the case that must NOT change: an unindexed column stays
TEXT, since VARCHAR everywhere would break MySQL's row-size limit. Reverting the
one-line change makes it fail.
2026-07-29 16:26:04 +02:00
rouggy a3fd32ec91 fix: split read (and set) backwards on a Yaesu reporting the TX VFO
Reported on an FTDX101: the panel showed split ON with the radio OFF, and the
reverse — while the same model behaved correctly for another operator, and an
FTDX10 was fine throughout. That pattern is the clue: one operator was on MAIN,
the other on SUB.

The two commands say different things and were read alike:

  ST is a split FLAG   — ST1 means split, whatever VFO is in use.
  FT names the TX VFO  — FT0 = transmit on A, FT1 = transmit on B.

Split is on when the rig transmits on a DIFFERENT VFO from the one it listens to.
Reading FT1 as "split" is therefore correct only on VFO A, and exactly inverted
on SUB. It is now compared with the current VFO.

Writing had the identical fault, and it was worse: sending FT1 for "split on"
while the operator listened on SUB CLEARED the split it was asked to set. Both
directions are pinned by tests over each VFO state.
2026-07-29 16:20:25 +02:00
rouggy c297f91ca8 fix: a cluster spot dragged the rig from SUB back to MAIN
Reported on an FTDX101 (F4NBZ): listening on the SUB VFO, clicking a spot moved
the radio to MAIN.

SetSimplexMode is the first thing SetFrequency tries, and it means "receive and
transmit here, simplex" — OmniRig applies that to the MAIN VFO by definition. It
is there because several Icoms ignore direct FreqA/FreqB writes, so it stays for
the main VFO; on SUB it is now skipped entirely and FreqB carries the change.

The generic "Freq" property is skipped on SUB too: on several rig files it IS the
main VFO, so writing it would move the VFO the operator is not using — or pull
them back to it, which is the bug being fixed.

An operator on SUB put themselves there deliberately. A spot click asks for a
frequency, not for a change of VFO — that distinction is the whole fix, and a
test pins which property each VFO state writes.
2026-07-29 16:07:20 +02:00
rouggy ee1f9ccf35 fix: ADIF import silently dropped a file with no header
Reported with screenshots: a dozen records lifted out of wsjtx_log.adi into a new
.adi imported as "0 imported, 0 ignored, 0 total". The parser only emitted
records after <EOH>, and a hand-assembled file has no header — so the whole file
was read as header text and thrown away. The count says the file was empty; there
is nothing on screen to suggest a missing tag, which is why this was reported as
"import does nothing".

An <eor> is proof that a record ended, header or no header. Fields are now
collected always and DISCARDED at <eoh> — that tag is exactly the statement
"everything before this was the header", so real headers stay out of the data
just as reliably, and a headerless file keeps its first record instead of losing
it to the same rule.

An existing test asserted the old behaviour. It encoded a defensible reading of
the spec, but the file it rejects is one operators really make and the rejection
is silent, so the test is rewritten with the evidence that changed it rather than
deleted.
2026-07-29 15:06:41 +02:00
rouggy 6fb7f06375 docs: read a changelog block top to bottom — order 0.22.0 chronologically
New entries were being prepended, so a version block read backwards: an operator
met "the Yaesu meters are corrected" and "the console follows the mode" several
entries before learning that a Yaesu console existed. The fixes made sense only
to someone who had followed the development.

0.22.0 is reordered so each feature appears before what was fixed on it, and
CLAUDE.md now says to append at the bottom.

Restored the entry that INTRODUCES the Yaesu console: I had overwritten it when
editing the meters entry in place, so the release described corrections to a
panel it never announced.
2026-07-29 15:01:35 +02:00
rouggy 54caee37c4 chore: release v0.22.0 2026-07-29 14:55:17 +02:00
rouggy 15fdbce22a fix: WinKeyer 2 sent the dit/dah ratio to the key-compensation command
The byte trace from a real WK2 settled in one line what no amount of reading
could: "TX 11 32". Command 0x11 is SET KEY COMPENSATION in milliseconds, not the
dit/dah ratio — that is 0x17. So a neutral ratio of 50 asked for 50 ms of extra
key-down on EVERY element. At 25 wpm a dit is 48 ms, so elements more than
doubled and ran into each other: the reported "it sends one element, then a long
pause".

The ratio now goes out as 0x17, and the compensation is explicitly set to 0 —
merely stopping the wrong command would leave an affected keyer misbehaving,
since it keeps the value in EEPROM until something writes over it.

The init sequence is now built by a separate function so the BYTES are testable,
and a test pins each command number. These numbers are the contract with the
hardware, and a wrong one produces a fault that cannot be diagnosed from the UI
at all — this one cost the operator weeks and needed a trace to find.

This is why the fix waited for the trace rather than being guessed: the plausible
guesses (mode register, sidetone, WK1-vs-WK2 differences) were all wrong, and any
of them shipped blind would have broken the keyers that work today.
2026-07-29 14:53:18 +02:00
rouggy c7737aabd6 docs: changelog block becomes 0.22.0, and the duplicated CW entry is merged
Renamed from 0.21.9 at the author's request: this release adds native Yaesu and
Xiegu CAT, CAT sharing over Hamlib NET rigctl, a Yaesu console and a fifth CW
engine, which is a minor version rather than a patch.

The Yaesu keyer had two entries — one written when it was added, one when the
FTDX10 turned out to refuse it. Merged into the single statement an operator
needs: what it does, where to select it, and which rigs accept it.
2026-07-29 14:49:15 +02:00
rouggy 6f5acc2eef docs: name the rig setting that makes the voice keyer inaudible
The weak audio was not a level at all: the radio was still modulating from its
front microphone, so almost nothing of the USB feed reached the air. On an FTDX10
that is MENU → SSB MOD SOURCE = REAR.

Worth putting in the settings hint rather than leaving in a conversation — every
operator wiring a voice keyer for the first time meets it, and no amount of gain
in OpsLog can compensate for a rig listening to the wrong input.
2026-07-29 14:46:50 +02:00
rouggy 67a03c3ddc feat: a level control for the voice keyer, and name every CAT backend in its PTT list
The messages went to the radio exactly as recorded. Nothing in OpsLog could
raise them, so a microphone captured quietly drove the rig quietly and the only
remedies were the radio's own USB input menu or the Windows mixer — which is
where the operator was heading. There is now a level from 10 to 400 %, applied
with clamping (a wrap would turn loud speech into noise on the air), and Play
previews at that same level so the adjustment is made against what will actually
be transmitted.

The PTT method list also named only OmniRig, Flex, Icom and TCI, so choosing a
native Yaesu left "CAT" with no backend beside it — which reads as "there is no
CAT PTT for my radio" and sends the operator to RTS on a COM port that has
nothing to do with the rig. Same list-needing-every-member shape as three
earlier bugs in this feature. The TestPTT log line had the same rot: it said
"CAT via OmniRig" whatever backend was running.
2026-07-29 14:29:47 +02:00
rouggy 8da3a27803 fix: the PWR bar stayed at full scale after transmitting
Receiving cleared the two bar percentages but not the values the bars now
actually draw from: the watts and the SWR ratio, added when the meters were
corrected. So the power bar sat pinned across the panel with the rig plainly
receiving — the dash in the label said one thing and the bar another.

Every transmit value is cleared now, and the peak-hold state with them: a peak
left in the holder would have carried the last transmission's reading into the
start of the next one, which is worse, being wrong while it looks live.

The panel also draws both bars from zero unless the rig reports transmitting, so
a value that has not been refreshed yet cannot show as output.
2026-07-29 14:20:58 +02:00
rouggy e0cefb5c41 fix: the Yaesu power meter is not linear — calibrate it on three points
One point could not reveal the curve. Scaling 207 = 100 W straight down read
30 W where the radio showed 10, and 75 where it showed 50 — wrong everywhere
except at the single point it was fitted to.

Three readings taken against the rig's own display give the shape:

  raw  62 → 10 W
  raw 155 → 50 W
  raw 207 → 100 W

Interpolating between them reproduces the radio exactly at those points and stays
close in between. I did not fit a formula: three samples can be made to support
several curves, and the operator can check a table against their own meter.

Above the top the last segment's slope continues rather than clamping, so a rig
driving an amplifier does not sit pinned at 100 W. A test pins the measured pairs
and the monotonicity, so a later change that breaks them fails against the radio
rather than against taste.
2026-07-29 14:11:25 +02:00
rouggy 113faede14 fix: hold the TX meters through the gaps between words
A quarter-per-poll decay covers the milliseconds between CW elements but not the
gaps that matter on the air: between the words of a CQ, in CW as in SSB, the
meters genuinely read 0 for most of a second and the bars fell with them.

A peak now stands for 1.5 s before it starts to fall, and still rises instantly —
a needle goes up fast and comes down slow. The SWR RATIO is not updated at all
from a zero reading: showing 1.0 during a word gap is worse than showing a stale
figure, because it looks like good news.

A test caught a real defect on the way: the proportional decay stalls on
integers. With the needle at 13 and the truth at 10, a quarter of the gap rounds
to zero and the meter sat three units high for ever. It now always steps down by
at least one, so it converges.
2026-07-29 13:59:47 +02:00
rouggy 8e491544dd fix: power is RM5, not RM4 — the CW keying was the experiment
An FM carrier could not answer it: constant by definition, so nothing to
correlate. CW at 100 W did, because the keying itself varies the output:

  key down: RM4=25 RM5=207 RM6=13
  key up:   RM4=25 RM5=0   RM6=0

RM5 follows the RF envelope exactly — it IS the power meter. RM4 sits near 25
whether the key is down or up, so it is not measuring output at all, and reading
it as power is what showed 8 W on a 100 W transmission. The operator's hunch was
right and my first reading of the ramp was wrong: what I took for a needle
rising was RM4 drifting, not tracking.

Watts are now derived from that meter (207 = 100 W, measured) instead of the
power SETTING scaled by a percentage — the setting says what was asked for, the
meter says what left the radio.

The bars also hold their peak with a gentle decay. In CW the meters genuinely
read zero between elements, so following the raw value made them flash to nothing
several times a second; a needle has inertia, and this only ever holds a value
the radio really reported.
2026-07-29 13:56:59 +02:00
rouggy cbe571a742 chore: log the power setting with the Yaesu meters, to identify the wattmeter
One transmission cannot name it. RM4 rose while RM5 stayed flat — which points at
RM4 — but RM5 differed BETWEEN transmissions, 208 then 105, which points at RM5.
Both readings are consistent with either answer, so picking one now would just be
the FT-991A table mistake again in a new place.

The survey line now carries the rig's power setting, and the sample cap is raised
from 12 to 40 so two transmissions fit. Keying at, say, 10 W and then 100 W makes
the answer a one-line comparison: the wattmeter is whichever index moves with the
setting.

The displayed mapping is left alone until that comparison exists.
2026-07-29 13:52:45 +02:00
rouggy bdda7ad07a fix: read the real SWR on the Yaesu, and show the ratio
A second measurement at a known mismatch settled both the index and the scale.
At SWR 1.1: RM6=0. At SWR 1.5: RM6=52, while RM4 kept tracking the power.

52/255 = 0.204, which is the reflection coefficient of a 1.5 SWR to three
decimals. So the raw value is rho scaled to 255, and the ratio is
(1+rho)/(1-rho) — physics, not a curve fitted through two points, which is why
2.0 and 3.0 fall out of it correctly without ever having been measured.

The panel now shows that ratio, the number the operator reads on the rig, rather
than a percentage of meter travel — and a dash while receiving, since a stale SWR
from the last transmission reads as a live one.

Both measurements are recorded in the code and in a test, so the mapping is
evidence rather than a table borrowed from another model — which is exactly how
it came to read 81 in the first place.
2026-07-29 13:49:09 +02:00
rouggy 3fd6763ff0 fix: the Yaesu SWR bar showed a meter unrelated to the antenna
Measured on the radio, steady carrier for three seconds:

  RM1=0  RM2=unsupported  RM3=0  RM4=9→18→21→22  RM5=208 flat  RM6=0

RM4 is the index that RAMPS with the output, so RM4 is the power meter. RM5 sat
at 208 from the first sample to the last, unmoved by the power — that is not SWR
on an operator reading 1.1, and 208/255 is exactly the 81 that appeared on the
bar. Borrowing the FT-991A's table, which puts SWR on RM5, is what put it there.

SWR now reads RM6. It stayed at 0 throughout, which is CONSISTENT with a 1.1
match but does not prove the index — only a deliberate mismatch would, and I am
not asking for that. A bar at zero on a good antenna is honest; 81 was actively
misleading.

The measurement is written into the code next to the mapping, so the next person
sees the evidence rather than a table copied from another model.
2026-07-29 13:42:25 +02:00
rouggy a89c6e0df8 chore: sample the Yaesu meters throughout a transmission, not at its start
The one-shot survey fired as the transmission began and caught the meters still
at rest — RM4=13 and everything else zero, which identifies nothing. And the
operator's reading points the other way: the bar showing 81 tracks his 100 W,
while the one labelled power sat at 8.

What names a meter is which index FOLLOWS the power over a few seconds of steady
carrier, so the survey now samples on every poll while transmitting, capped at a
dozen lines. Two seconds of tune will settle it.

Still not guessing at the mapping: the numbers will say which index is power and
which is SWR on this radio, and it gets corrected then.
2026-07-29 13:38:09 +02:00
rouggy 48d92ff547 fix: CW speed drove the wrong keyer; survey the meters to find the SWR one
Speed: with DTR/RTS line keying the PC does the timing, so the Yaesu console's
slider — which sets the rig's internal keyer — changed nothing audible and looked
broken. Both entry points now go through one handler that drives the engine
actually sending, and additionally sets the rig's own keyer whenever a Yaesu is
on CAT, so the radio's front panel shows the same figure.

SWR: an operator reads 80 on the bar with a real SWR of 1.1. That is the shape of
reading the WRONG METER — ALC, say — not of a scaling error, and which RM index
carries which meter is not consistent across the family. Rather than guess again
and move the wrong number somewhere else, the first transmission now logs RM1
through RM6 raw, once. The log will say which index is which on this radio, and
the mapping can then be corrected as a fact.
2026-07-29 13:32:29 +02:00
rouggy adadb632fa docs: FTDX10 confirmed — no CAT keying, the serial DTR keyer is the way
Tested on the radio: DAKY does not help, KY is refused whatever PC KEYING is set
to, and the "Serial port (DTR=CW / RTS=PTT)" engine on the second COM port keys
correctly. So my earlier "try DAKY first" was wrong, and the order is now the
other way round: name what works, mention the model that refuses.

The KY engine stays. It is documented for the FTDX101 / FT-991A / FT-710 family
and costs nothing to keep — a rig that refuses it now says so in one clear
sentence instead of transmitting nothing for no stated reason.
2026-07-29 13:27:04 +02:00
rouggy b0b25c7f1f docs: name PC KEYING = DAKY, the setting that should enable CAT keying
The operator found the menu I said to look for: it offers DAKY, RTS and DTR. KY
is the CAT route, so DAKY is the setting that would enable it — which makes my
previous "this rig has no CAT keying command" too strong a claim to leave
standing, since it was drawn from a refusal at the DEFAULT setting.

The error and the settings hint now name DAKY first, and keep the serial line
keyer as the fallback. That order matters: the CAT route costs nothing, the
fallback costs a second COM port.

Marked as untested rather than verified — I have no confirmation that DAKY makes
KY work on this model, only that it is the option that should.
2026-07-29 13:23:22 +02:00
rouggy e120bd4f04 docs: an FTDX10 has no CAT keying command — say what works instead
Confirmed on the radio: it answers "?;" to KY, so this is not a setting to find.
My previous message sent the operator looking for a MENU → CW → PC KEYING entry I
named without checking it exists on that model, and they could not find it.

The message now names the path that does work: the "Serial port (DTR=CW /
RTS=PTT)" keyer on the rig's OTHER COM port — the standard one — while CAT keeps
the enhanced one. Same for the settings hint and the changelog.

The KY engine stays: it is documented for the FTDX101 / FT-991A / FT-710 family.
It now fails loudly and usefully on the models that lack it, which is the
difference between a dead feature and a wrong one.
2026-07-29 13:21:49 +02:00
rouggy 40df4fe22f fix: read the rig's answer to KY — it was killing the CAT link, silently
The log timed it exactly: the CW send at 13:16:17.591, the CAT dropping at
13:16:17.633, forty milliseconds later.

An accepted KY says nothing, but a REJECTED one answers "?;" — and nobody was
reading it. The frame sat in the buffer until the poll loop's next query picked
it up, failed, and the Manager tore the link down. That is the disconnect on
every macro click, and it also explains the silence: the backend was being
rebuilt underneath the send.

Two changes. The KY write is now followed by a short read: silence means
accepted, "?;" means refused — and the operator is told so, naming MENU → CW →
PC KEYING, instead of getting nothing with no reason. And FA; retries once when
it meets a stray rejection, because a "?;" arriving there is almost never about
FA: the rig answers frequency queries perfectly well, it is the previous
command's refusal being attributed to this one.

This does not yet prove the FTDX10 accepts KY at all. It makes the next run say
so plainly either way, which is the point.
2026-07-29 13:18:52 +02:00
rouggy d114a73729 fix: the FTDX10 refuses KY; — stop waiting on a status it will never send
The log settled it: the rig answers "?;" — its "unknown command" — to KY; and to
MG;. So the keyer status query cannot work on this model, and every send spent
four seconds waiting for an answer that was never coming.

"?;" is now recognised as what it is. A command refused once is never asked
again: models implement different subsets, and re-asking costs a 600 ms timeout
on every slow beat for a control that will never answer — which is also why the
panel felt sluggish.

Without a buffer status there is still a real constraint: one KY command carries
24 characters and the rig DROPS the rest silently. So the send is now paced by
how long the text takes to key, from PARIS timing at the rig's own speed. A long
macro goes out complete instead of losing its tail.

That leaves the question the log cannot answer: whether KY <text>; itself is
accepted. If the rig also replies "?;" to it, the CAT menu's PC KEYING setting is
the next suspect — but nothing in the code will be guessing at it.
2026-07-29 13:15:36 +02:00
rouggy d0666581c3 fix: a CW macro knocked the Yaesu CAT link over
Clicking a macro dropped the CAT for a few seconds, keyed nothing, then came
back.

ask() returned the first ';'-terminated frame it saw, whatever command it
belonged to. KY produces NO reply, so the next query — FA; from the poll loop —
collected a leftover frame, failed to parse it as a frequency, and ReadState
reported an error. The Manager reads that as "lost the rig": disconnect, wait,
reconnect. Hence the drop and the automatic recovery a few seconds later.

Replies are now matched to the command that asked for them: anything else is
discarded and logged, so a stray frame costs one log line instead of the link.

This also explains the silence — the send never got a clean run at the port
while the backend was being torn down under it.
2026-07-29 13:03:09 +02:00
rouggy 37dc2a07e5 fix: the Yaesu keyer refused to send — a status reply we read too strictly
"the keyer buffer stayed full for 4s" and nothing keyed, on a rig whose CAT is
working. The buffer was not full: the check demanded the reply be exactly "KY0;"
with the digit at byte 2, so anything the FTDX10 phrases differently — a space
before the digit, another command's reply arriving first — read as "still full"
on every poll until the deadline.

The test is now asymmetric on purpose: only a clear "1" holds the send back.
Anything unrecognised goes ahead. Refusing to transmit because a status line was
phrased unexpectedly is the worse failure — the operator gets silence with no
explanation, where at worst sending early truncates a long message, which the
chunk loop then recovers from.

The reply is also logged once per run, since we have no verified sample of it —
that line is what turns the next surprise into a fact instead of a theory.
2026-07-29 13:00:30 +02:00
rouggy 7424bc6e81 fix: the Yaesu keyer reported "Rig CAT offline" while CAT was working
Three places decide what a rig keyer is, and the Yaesu was only in two of them.

The CW panel's status came from a list naming icom and flex; anything else fell
back to the WinKeyer status, which reports disconnected because no WinKeyer is
attached. So the panel said the rig CAT was offline while the console beside it
was reading the FTDX10 perfectly.

The send loop had the same gap: a rig keyer BUFFERS the whole message, so the
wait before <LOGQSO> is a length estimate, while WinKeyer watches a busy echo
that will never arrive here. Auto-call would have raced the transmission.

This is the third time the same shape has bitten in this feature — a list of
engines that needs every member named, with a silent fallback for the rest.
Grepping for the pair "icom || flex" is what finds them.
2026-07-29 12:58:08 +02:00
rouggy 01c93d979f fix: the Yaesu keyer selection was thrown away when read back
Choosing "Yaesu (rig keyer)" saved correctly and still produced a WinKeyer panel
asking for a COM port. The engine is normalised on load through a chain that
names icom, flex and serial and maps EVERYTHING ELSE to "winkeyer" — so the
value came back as WinKeyer on every read, whatever the settings said.

That is why the panel offered COM3 then COM10: it genuinely believed the engine
was a WinKeyer. The list now names yaesu too.

The same shape of bug is worth watching for: a normaliser with a silent default
turns an unknown value into a plausible one instead of an error, and the symptom
appears far from the cause — here, in a panel three components away from the
setting.
2026-07-29 12:55:26 +02:00
rouggy 0190255762 fix: the Yaesu keyer asked for a COM port it does not use
Selecting the Yaesu keyer fell through to the WinKeyer branch of the settings,
which offers a serial port picker and a Connect button. So the operator went
hunting for the right port — trying the standard COM and the enhanced one in turn
— and neither connected, because this keyer uses NEITHER: it keys over the CAT
link already configured in Settings → CAT.

It now has its own branch, like the Flex one: a speed field, the warning when the
CAT backend is not a Yaesu, and a line saying explicitly that keying rides the
CAT link. Speed changes go to the rig's keyer instead of a WinKeyer that is not
there.
2026-07-29 12:49:19 +02:00
rouggy 0cc806722e fix: Antenna Genius drew both ports on the same antenna
Reported on an 8x2: port A correctly on the 80 m vertical, then a few seconds
later both A and B shown on the same beam.

The display preferred the TX antenna, falling back to RX. On an 8x2 only ONE port
can hold the transmit antenna, so the switch reports the same txant on both — and
every keepalive poll redrew port A as whatever port B transmits through. The RX
antenna is the per-port selection, and the only thing Activate sets, so it is
what a port shows; TX stays the fallback for a port reporting no RX antenna.

A port change is now logged with the raw line — but only on CHANGE, since the
device pushes state every few seconds and logging each one would bury the rest.
Without any trace, "port A jumped to the wrong antenna" cannot be checked.

Two tests pin it: rx wins over a disagreeing tx, and a message about one port
never moves the other — which is the shape that was actually on screen.
2026-07-29 12:46:48 +02:00
rouggy 08f3e54afb feat: CW through the Yaesu keyer — fifth CW engine
The radio has a keyer and a command to feed it (KY), so an FTDX10 needs no
WinKeyer and no second cable, exactly as the Icom CI-V and Flex CWX engines
already do. The rig keys with its own timing, which is why the spacing is right
where a PC keying a line through USB latency drifts.

Text is filtered to what the keyer can actually send: an unsupported byte does
not produce an error on a Yaesu, it can abort the whole buffer, so the rest of a
macro would vanish silently. It is then fed in 24-character pieces, waiting for
room between them — the rig DROPS what does not fit, again with no error, so a
contest CQ would lose its tail.

STOP is the honest gap. Yaesu documents no buffer-clear, so it drops the
transmitter (TX0) instead: nothing queued reaches the air, which is what Escape
means to an operator. It deliberately does NOT send "KY0;" — a plausible-looking
clear that the rig would read as the CHARACTER zero and transmit.

A test caught a real one on the way: tabs and newlines were dropped as
"unsupported", gluing the words either side together, so a macro written on two
lines went out as CQCQ. Whitespace now becomes a word gap before filtering.

Settings warn when the Yaesu keyer is selected without the Yaesu CAT backend —
otherwise it simply never keys, with nothing on screen saying why.

Send path follows the CAT reference and how Hamlib drives these rigs; NOT yet
verified on the air.
2026-07-29 12:42:40 +02:00
rouggy 520d17810c fix: the Yaesu panel showed the TRANSMIT frequency as the main VFO under split
Confirmed on the FTDX10: receiving on 14.018.98 and pressing SPLIT put 14.019.98
in the big display with "TX 14.019.98 (0 kHz)" under it. The radio was right —
RX 14.018.98, TX 14.019.98, up 1 kHz on CW — the panel was not.

RigState follows the ADIF convention where freq_hz is the TRANSMIT frequency, so
under split it is the OTHER VFO. Taking it as the main display showed the
operator the frequency they transmit on, and then an offset of that frequency
against itself: zero.

The header now reads the listening frequency (freq_rx_hz when split, freq_hz
otherwise) and the TX line shows the real transmit frequency and offset.
2026-07-29 12:19:49 +02:00
rouggy c9218310ae feat: the Yaesu console follows the mode — CW gets its own controls
Microphone gain and VOX are meaningless in CW: the rig ignores both, so showing
them is showing dead controls. They are hidden, and a CW card takes their place
with keyer speed (KS), break-in (BI) and ZIN (ZI), the zero-in that retunes so
the station being received lands on the operator's own pitch.

ZIN is a one-shot with no state, so it is a plain button rather than a chip that
would look latched, and no settings read-back follows — the frequency change
arrives through the normal poll like any other.

The keyer values are read on the slow beat whatever the mode, so the card is
already populated the instant the operator switches to CW instead of filling in a
poll cycle later.

Which controls show is decided by the RIG's mode, not the logged one: the logged
mode can be a digital sub-mode the radio knows nothing about.
2026-07-29 12:07:28 +02:00
rouggy 8cc6997eec feat: colour the Yaesu S-meter like a real one — green, amber past S9, red at +20
The bar was one colour for its whole travel, so nothing distinguished a signal
that is merely readable from one that belongs in the log as 59+20.

Green to S9, amber through the S9+ range, red from +20 dB. The thresholds are
computed from the SAME S9 point the label uses rather than hard-coded
percentages: the S9 position is still a hypothesis on this rig, and when it is
corrected the colours have to move with it or the meter would say 59+20 in
amber.
2026-07-29 11:59:58 +02:00
rouggy 6d309cada1 fix: SPLIT placed the transmitter on a stale VFO; share the meter widget
Reported on the FTDX10: listening on 14.244 with VFO B still holding 18.115 from
an earlier session, pressing SPLIT threw the transmitter onto another band. The
button only flipped the rig's split flag, and the other VFO is stale by nature —
the only transmit frequency that makes sense is one derived from where the
operator is listening NOW. SPLIT therefore places the TX VFO too: up 1 kHz on CW
and the data modes, up 5 kHz on phone, the offsets operators actually call. The
+1k / +5k buttons remain for anything else, and a test pins the mapping.

The panel also drew its own flat meters while the Flex and Icom consoles use the
shared LED-segment MeterBar. Two instrument styles in one application is just
inconsistency — it now uses the shared component, and the local one is gone.

And the three consoles are named alike: "Flex Console", "Icom Console", "Yaesu
Console", in the tabs and in the Main-view pane list, in both languages.
2026-07-29 11:55:28 +02:00
rouggy 7153768579 feat: Yaesu panel — sideband on re-click, split readout, one-touch up 1/up 5
Three corrections from the operator's second pass.

The sideband gesture was wrong: I used double-click, which hides the action.
Clicking a button that is ALREADY active now flips its sideband — CW-U → CW-L →
CW-U. One button, one finger, nothing to discover.

A lit SPLIT chip does not tell an operator anything useful: it says split is on,
not where they transmit. The header now shows the TX frequency and the offset in
kHz whenever split is active.

And the offset that matters is set in one action: up 1 kHz on CW, up 5 kHz on
phone. Doing it by hand means swapping VFOs, retuning and swapping back — exactly
the fumbling a panel exists to remove. Both are offered rather than picked from
the mode, because which one is idiomatic is the operator's call, and the button
turns split on at the same time.

The offset is measured from the RECEIVE frequency and written to the VFO we are
not listening on, so it stays correct when the operator works on VFO B, where the
roles are mirrored.
2026-07-29 11:46:54 +02:00
rouggy df4155108f fix: centre the Yaesu panel like the Icom and Flex ones
Capping the width without mx-auto pinned the console to the left edge with an
empty window beside it. It now uses the exact wrapper the other two panels use —
h-full min-h-0 overflow-auto bg-background, then max-w-5xl mx-auto p-3 — rather
than a second layout of my own invention.
2026-07-29 11:42:42 +02:00
rouggy 9bc5a14c69 fix: Yaesu panel — S units, sidebands, three-step ATT, readable sliders, own tab
First look on the FTDX10 turned up six things:

The S meter printed a raw percentage — "57" tells an operator nothing, and it is
the S number that goes into a report. It now reads S1-S9/S9+dB, the same value
the click-to-fill RST already used.

CW, RTTY and the data modes exist on BOTH sidebands and the operator is the one
who knows which they want. The buttons now carry the rig's actual sideband and a
double-click flips it; PSK is added, riding the rig's DATA mode as it does on the
radio itself. This also means the mode row drives the rig directly (MD0 with the
exact mode) instead of going through the ADIF path, which could only pick a
sideband by convention.

The attenuator is a 6/12/18 dB pad on these rigs, not the single step I assumed —
two thirds of the control was unreachable.

Sliders had no visible filled side: --muted is barely lighter than the card it
sits on, so the whole track read as one bar. They also came in three kinds (two
bare range inputs among them). One component now, explicit track colour, and it
takes a min/max so power in watts and DNR 1-15 look like the rest.

The panel stretched across the whole window; it is a column of controls, so it is
now capped and every row stays readable.

And it gets its own Yaesu tab, like FlexRadio and Icom, rather than only being
available as a Main-view pane.
2026-07-29 11:37:18 +02:00
rouggy 842d4708a7 feat: Yaesu control panel (meters, bands, DSP, TX), and drop a stale Flex hint
A console pane for the native Yaesu backend, in the same shape as the Icom and
Flex ones: S/PO/SWR meters, band and mode rows, AF/RF/squelch, AGC, the IPO/AMP1/
AMP2 front-end selector, ATT, NB, DNR + level, narrow filter, power in watts, mic
gain, VOX, split and ATU tune.

Three decisions worth keeping:

Panel reads are STAGGERED and live in their own file, away from ReadState. Meters
poll every cycle; settings only change when someone turns a knob, so they refresh
every 8th cycle and right after any set. Polling all of it every cycle would put
twenty queries a second on the serial link the frequency display shares.

Band buttons use the rig's own band memory (BS) rather than a frequency we pick,
so 20 m lands where the operator last was on 20 m — what the radio's own band
keys do.

A set is followed by a read-back, so the panel shows what the RIG ended up with,
not what we asked for; the two differ whenever a value is out of range or the
mode forbids the control. And a control the model lacks keeps its previous value
instead of dropping to zero, which reads as a setting that reset itself.

The S9 point of the S-meter scale is a hypothesis (the manual does not state it)
and is commented as such — one number to correct if reports come out an S unit
off, rather than a fudge spread through the RST helper.

Also removes the FlexRadio settings blurb, which explained the backend to
someone who had already chosen it.
2026-07-29 11:17:08 +02:00
rouggy e2aba828a9 feat: native Xiegu CAT backend (G90 / X6100 / X6200 / X5105)
Xiegu speaks CI-V with a reduced command set: frames, BCD encoding, addressing
and the opcodes for frequency, mode, PTT and split are Icom's, so this reuses
internal/cat/civ wholesale instead of re-deriving a codec.

It is a SEPARATE backend rather than the Icom one at address 0x70, because what
the two rigs do NOT share is the deciding part. The Icom backend reads the
spectrum scope, the DSP block, data mode via 1A 06 and the model id via 19 —
none of which a Xiegu implements. Pointed at a G90 it would poll every cycle for
answers that never come, and spend its silence tolerance on commands the radio
was never going to support.

Two consequences of the rig's smaller mode table are handled explicitly rather
than left to fail: there is no data mode, so a digital QSO is set to plain
sideband (what the operator does on the radio anyway) instead of being refused;
and the split TX frequency is NOT reported, because reading the unselected VFO
needs 0x25, which the Xiegu table does not list — a split flag carrying a wrong
TX frequency is worse than the flag alone, since the frequency is what gets
logged.

The published command table has rows that slipped during typesetting (0x07 and
0x0F share a block). Where it contradicts itself the Icom meaning is used, the
rest of the table matching Icom exactly, and every unexpected reply is logged
raw so a first on-air run settles it.

NOT yet verified on a radio.
2026-07-29 11:06:27 +02:00
rouggy 753d8d2ffa fix: accept Hamlib's VFO-prefixed commands — JTDX could not set the frequency
MSHV worked immediately, JTDX answered "Hamlib error: Invalid parameter while
setting frequency". The two use different Hamlib dialects: MSHV sends
"F 14074000", JTDX names the target first — "F VFOA 14074000". The VFO name
landed in the slot the frequency was read from, the parse failed, and we returned
RPRT -1, which is exactly the error JTDX reported.

A leading VFO name is now stripped from every command's arguments. It costs
nothing: OpsLog follows the rig's own VFO selection, so the name carries no
information we act on — but refusing it locked out a whole family of clients.
Both dialects are covered by a test, the plain one included, since this is an
addition and must not become a swap.

A rejected frequency is also logged with the raw line now. The client only shows
"Invalid parameter", which says nothing about what it actually sent — that is
why this took a screenshot to diagnose rather than a log.
2026-07-29 10:58:16 +02:00
rouggy 38b480a985 feat: share the CAT link with other programs (Hamlib NET rigctl server)
A native CAT backend owns the rig's serial port, and Windows gives a COM port to
one process — so choosing native CAT locked WSJT-X, MSHV and JTDX out of the
radio entirely. That is the cost of dropping OmniRig, which was itself a sharing
layer, and it has to be paid back.

OpsLog now becomes the server, as wfview does. It speaks the Hamlib net rigctl
protocol, which every one of those programs supports natively (rig model "Hamlib
NET rigctl", 127.0.0.1:4532) with no driver to install. It sits in front of the
MANAGER, not a backend, so an operator on OmniRig, Flex, Icom or TCI gets the
same server.

Two details that decide whether a client works at all rather than degrading:
dump_state is parsed positionally and WSJT-X refuses to proceed without a
well-formed block, so it is written out in full and its shape is pinned by a
test; and set_vfo / set_split_vfo answer RPRT 0 rather than an error, because
OpsLog follows the rig's own VFO and a refusal makes WSJT-X abandon the
connection. Unknown commands answer RPRT -11 — never silence, which hangs a
client instead.

The whole protocol is tested against a fake rig, plus one end-to-end exchange
over a real socket, since the framing is as much the contract as the text.

Also: the Yaesu backend is confirmed working on a real FTDX10 (frequency, mode,
VFO, split), so its "not yet verified" note is now wrong and is corrected.
2026-07-29 10:49:14 +02:00
rouggy 67005a8d50 feat: native Yaesu CAT backend (FTDX10 / FTDX101), no OmniRig
Every Yaesu fault reported so far came from OmniRig's interpretation layer, not
from the radio: a rig file that never exposes the VFO, a Freq property meaning A
on one model and B on another, a split flag that alternates between polls. This
talks to the rig directly, so what the radio answers is what is shown.

Modern Yaesu CAT is plain ASCII with a ';' terminator — FA/FB for the VFOs, MD0
for the mode, VS for the selected VFO, TX to key. Frequency is written to the VFO
the operator is actually on, not always to A, which is the failure that made a
display disagree with the radio.

Two things are genuinely uncertain across the family and are treated as such
rather than guessed. SPLIT is read through ST, then FT if the rig ignores ST —
whichever answers wins and the choice is remembered, because the two commands say
DIFFERENT things (a split flag vs which VFO transmits). If neither answers, split
is reported OFF and the fact is logged, rather than invented. Unknown model ids
and mode bytes are logged raw for the same reason.

Split resolution, frequency parsing and the mode mapping are pure functions with
a table test — the OmniRig equivalent is where every Yaesu bug lived, and it had
no test until late.

Written from the CAT reference; NOT yet verified on a radio.
2026-07-29 10:33:26 +02:00
rouggy 9cfa7a4dd1 fix: band change from OpsLog left the frequency on the old band
The log settled it: the backend was right all along. Every set produced
"readback +1.5s FreqA=21140000 Freq=21140000 -> shown 21140000" and published
cat:state with the new frequency and band. The display did not follow.

The fault is in the frontend. Changing band does two things at once: it opens the
1.5 s freeze that protects what the operator is typing, and it commands the rig.
The rig's answer comes back in ~170 ms — inside that freeze — and the handler
DROPPED any snapshot arriving during it. The backend only emits on change, so
nothing came afterwards, and the strip kept the old frequency until the VFO was
nudged. Changing band from the radio always worked because no freeze was open.

A dropped snapshot is now kept and replayed when the freeze closes, rather than
discarded. Further typing simply defers the replay again.
2026-07-29 10:23:52 +02:00
rouggy a1c4305f20 feat: bulk-edit the contacted station's gridsquare
An import that drops the locator leaves it missing on a whole batch, and fixing
that one QSO at a time is exactly what bulk edit exists to avoid.

Added on all three sides in lockstep — the dialog field, the field-to-column map,
and the repository whitelist — because they are separate lists and offering a
field the repository refuses fails only at Apply, after the operator has selected
the QSOs. The existing contract test covers it.

The callsign and RST stay excluded, as before: bulk-setting those corrupts a log.
The locator does not carry that risk — it identifies a place, not a station, and
a wrong value is simply overwritten again.
2026-07-29 09:00:35 +02:00
rouggy 3b296b19ab test: prove the status filter filters; fix a regex escape in the filter builder
An operator reports that "QRZ.com received status = N" returns rows showing both
N and Y (issue #5 follow-up). The SQL is a plain col = ?, but reading it cannot
distinguish a wrong query from a UI that kept the previous rows after an error —
so this runs it: five QSOs inserted, filtered, and both the list and the count
asserted. The backend filters correctly, list and count agree. The fault is not
in the query.

Found while looking: the filter builder tested an ADIF date with /^d{8}$/ instead
of /^\d{8}$/. The escape was missing, so the branch never matched and the
calendar input was handed "20260728", which type=date rejects — an empty box
over a value that was really stored.
2026-07-29 08:19:18 +02:00
rouggy 8a0d76fa0c feat: IC-7300MKII, and two Icom addresses that were plain wrong
Adds the IC-7300MKII at CI-V 0xB6.

Doing so exposed a drift between the two hand-kept copies of the model table: the
settings offered the IC-7700 at 0x88 and the IC-7800 at 0x80, which are the
IC-7100's and the IC-7410's factory addresses. Picking either set an address the
rig never answers on — the symptom is a radio that simply stays silent — and the
backend then named it as the other model. Corrected to 0x74 and 0x6A, and the
four models the backend already knew (IC-7100, IC-7410, IC-7600, IC-7851) are now
offered too instead of forcing a manual address.

A test reads the model list out of the .tsx and asserts civ.ModelName agrees, so
the next model added on one side alone fails the build rather than someone's
radio.
2026-07-28 22:38:02 +02:00
rouggy bd5f9c0746 fix: Icom over LAN froze on the last frequency after WSJT-X released the rig
The network backend treated "control link alive but no CI-V reply" as the rig
being in standby, and tolerated it WITHOUT BOUND. When another program takes the
CI-V session — WSJT-X through OmniRig, or the Remote Utility — the rig goes on
answering pings on the control stream while sending us nothing at all. Alive()
stayed true, so ReadState returned the cached frequency with err == nil, the
Manager never saw a failure, never reconnected, and re-published a frozen number
with a fresh timestamp on every poll. Only restarting OpsLog cleared it.

Bounded now, on the last SUCCESSFUL read. Past the grace the error is reported so
the Manager tears the session down and reconnects, which re-takes the CI-V
stream. Also fatal immediately: the CI-V reader goroutine having exited — no read
can ever succeed after that, however healthy the control link looks.

The grace backs off to minutes when the silence persists, because the two cases
pull opposite ways: a stolen session recovers on the first attempt, while a rig
switched OFF is silent for hours and re-tearing its session every 30 s would
blink the panel — and its ON button — away continuously. A good read resets it.

The decision table is pinned by a test; the standby case (never answered since
connect) keeps the old tolerate-for-ever behaviour.
2026-07-28 22:24:32 +02:00
rouggy 29591c5f0c feat: the offline FCC database answers during entry, not only at save
ULS was wired into AddQSO alone, so an operator who downloaded it and has no
QRZ.com/HamQTH account saw nothing while typing a US call: cty.dat gives the
country, the zones, and a 4-character grid that is the ENTITY centroid — a
thousand kilometres from the station. The county and the real square were stamped
after logging, too late to point an antenna with.

The enrichment runs last in the lookup wrapper and only fills blanks, so a
provider always wins. ULS carries no name and no address, so it completes a QRZ
record and can never replace one.

The grid needed a rule of its own. refineGrid keeps what is there unless the new
value extends it — correct for a QRZ square, wrong against an entity centroid,
which is simply a different square. A per-callsign FCC square therefore beats any
4-character grid, while a 6-character one already present is left untouched.
Lat/lon are recomputed only when we actually moved the grid.

Portable calls are skipped: W1AW/4 is not what the FCC licensed.
2026-07-28 22:10:22 +02:00
rouggy 01bcf256e2 fix: hand-corrected CQ/ITU zones came back wrong at every restart
The profile's MY_* metadata is derived from the callsign through cty.dat, and the
effect that derives it ran on every load — so opening the settings counted as
"the source changed" and overwrote whatever the operator had typed. Reported by
an operator in CQ 4 / ITU 4 handed 5 and 9: he corrected them, and each restart
put 5 and 9 back.

cty.dat gives the zones of the ENTITY, and a large country spans several, so the
automatic value cannot be treated as authoritative — it is a starting point. A
load now fills only fields that are EMPTY; a recompute that overwrites happens
only when the callsign or grid itself changes, which is the case the derivation
exists for.

The load-vs-edit distinction is a ref holding the profile id the values were last
derived from — first sight of a profile is a load.
2026-07-28 20:53:10 +02:00
rouggy c9d1fc1cc0 chore: release v0.21.8 2026-07-28 19:10:14 +02:00
rouggy f43d41892e docs: changelog — the QRZ date window covers both cases 2026-07-28 15:56:37 +02:00
rouggy 1f2e86c04d feat: a typed QRZ start date fetches that period, not what QRZ edited in it
"Last download" and an operator-chosen date are different questions, and they
now ask QRZ different ones. The automatic run wants everything TOUCHED since it
last ran (MODSINCE) — that is what returns a 2015 QSO confirmed yesterday. A date
typed by the operator names a period of OPERATING, so it maps to BETWEEN date and
today, on the QSO date.

The client-side date filter is skipped only under MODSINCE, where it would throw
away the older-but-newly-confirmed records the query exists to fetch. Under
BETWEEN it agrees with the server and stays on as a backstop.
2026-07-28 15:48:00 +02:00
rouggy 305da583e1 fix: QRZ download used an option QRZ does not accept
The date window was sent as OPTION=AFTER:<date>. That was a guess, and QRZ
rejects the request outright — RESULT=FAIL with no reason — so the confirmation
download failed completely for everyone, whatever the account.

The documented option is MODSINCE, and it is the better window anyway: it selects
records MODIFIED since the date, so a QSO from years ago that was confirmed
yesterday comes back. A QSO-date window structurally cannot return those, and
confirmations are the whole point of this download — which is why the client-side
date filter is now skipped when the server did the windowing, instead of throwing
those same records away on arrival.

A refusal now falls back to ALL rather than leaving the operator with nothing.
2026-07-28 15:43:47 +02:00
rouggy 71bde30e24 chore: log the window geometry that is saved and restored
Reported as a regression — reopens on the wrong screen — but the window code is
untouched since it was verified working: the only change to main.go since is the
background colour. So the fault is in the DATA, and there was no way to see it.

Both ends now log their coordinates, plus where the window actually landed after
the un-maximise / move / re-maximise dance. That separates the cases, which need
opposite fixes: nothing was captured, something wrong was captured, or the right
corner was captured and Windows moved the window anyway.
2026-07-28 15:35:57 +02:00
rouggy 31b13cfbc0 Revert "fix: no right-click Cut/Copy/Paste in release builds"
Reverts 30d8827. Ctrl+C / Ctrl+V already work in the app, so the WebView's
default menu only added Refresh / Save as on the neutral areas — noise in an
application window, for nothing gained. Changelog entry dropped with it.
2026-07-28 15:33:30 +02:00
rouggy 30d88276f3 fix: no right-click Cut/Copy/Paste in release builds
Wails disables the WebView context menu in production, so an operator could not
paste a cluster address, an API key or a callsign anywhere in the app. Ctrl+V
did work — nothing intercepts it — but the menu is where people look, and a key
copied off a web page is exactly what you paste rather than retype.

EnableDefaultContextMenu turns it back on.
2026-07-28 15:28:51 +02:00
rouggy b18db7acfc fix: a port field could not be cleared
Every port box was parseInt(e.target.value) || <default>. Delete the contents
and parseInt gives NaN, so the default is written straight back: the digits
reappear under the cursor and the only way to enter a different port is to
overwrite it character by character. Reported on the cluster editor; the same
line existed in eight other places.

One PortInput component now holds the raw TEXT as its state and only tells the
parent about a value that is actually a port. An empty box stays empty while you
type; on blur an invalid one falls back to the default, so nothing is ever saved
as 0 or NaN. It adopts a value arriving from the backend only while the operator
has not started typing — re-syncing mid-edit is the behaviour being fixed.

This is the controlled-input trap the project notes already warn about: keep the
raw text in local state and derive the stored value from it.
2026-07-28 15:25:21 +02:00
rouggy d303dee768 chore: microwave-band entry belongs to 0.21.8, not the published 0.21.7
Same slip as before the 0.21.6 release: I appended to a block that had already
shipped. 0.21.7 is back to its 12 published entries; the microwave-band fix
opens 0.21.8.
2026-07-28 15:15:37 +02:00
rouggy ad02583ef7 fix: recognise the microwave bands — 10 GHz resolved to no band at all
Reported on 3 cm. cat.BandFromHz stopped at 23 cm, so a 10 GHz frequency came
back EMPTY — and an empty band is not cosmetic: the QSO is logged without one,
counts in no award slot, and exports with no BAND field. The low end was short
too (2190m / 630m / 560m).

Four band tables had to be extended because four exist: the CAT one, the award
plan in app.go, the cluster spot classifier, and the frontend's. Plus the places
that LIST bands — the QSO editor and award-definition pickers (you could not set
3 cm by hand either), the statistics axis, and the award band ORDER, where a
missing band sorts to the end instead of in frequency order.

Ranges and names are ADIF 3.1.7, which is what an export has to carry.

A test now pins one frequency per band across the Go tables and asserts they
agree — that is what was missing, four hand-maintained copies with nothing
checking them. It also pins that an out-of-band frequency stays empty rather
than snapping to the nearest band, which would file a QSO under a band the
operator never used.
2026-07-28 14:50:17 +02:00
rouggy add4b175fc chore: release v0.21.7 2026-07-28 14:43:26 +02:00
rouggy 11940ae843 chore: changelog entry for the two diagnostic-log commits
Left out as "internal", but the log is something operators read and send —
and the Flex meter removal, which is the same nature, was listed. Consistent
now.
2026-07-28 14:42:34 +02:00
rouggy 91f046444a chore: log a DELAYED frequency readback after an OmniRig set
An FTDX10 operator reports that changing band from OpsLog moves the rig but the
displayed frequency stays on the old band until they nudge the VFO. The existing
readback is taken immediately after the write, so it always shows the old value
and proves nothing: OmniRig queues the CAT command and sends it over serial.

A second readback is now logged ~1.5 s later, from ReadState (the goroutine that
owns the COM apartment), together with what OpsLog concluded. That separates the
two candidates, which look identical to the operator: the rig ignored the write,
or the rig moved and its rig file never re-reads the frequency.
2026-07-28 14:39:04 +02:00
rouggy 386fb7f030 feat: ATU controls in the FlexRadio panel
The backend already parsed the atu object and exposed ATUStart / ATUBypass /
SetATUMemories through the Flex controller and the App bindings — only the UI
was missing, so the radio's own tuner was the one thing on the panel you still
had to reach SmartSDR for.

Placed under TUNE, which is the order of operations: the tuner needs a carrier
to measure against.

The status is decoded into words rather than shown raw. That is the point of the
block: TUNE_FAIL and "never tuned" leave the radio looking identical on its own
front panel, and the operator transmits into a bad match either way. Failed is
red, tuning amber, tuned green.
2026-07-28 14:35:56 +02:00
rouggy 5a6db2b656 feat: the header callsign is the station switcher; clock moves to the status bar
Switching station is the frequent act and editing a profile the rare one, but
the callsign opened the settings — so changing station took four clicks and a
scroll. It is now a dropdown of every profile: pick one and it activates. The
active one is ticked and disabled, and "Manage profiles…" stays at the bottom
for the rare case.

The list reloads on every profile change, so a profile added or renamed in the
settings appears without a restart.

The UTC clock leaves the header for the status bar, next to the database: in the
header it sat beside the frequency in the same weight and competed with it for
the eye, while being something you consult rather than watch.
2026-07-28 14:25:06 +02:00
rouggy c3958be0b2 chore: move the post-release entries out of 0.21.6 into 0.21.7
0.21.6 was published before these landed, so its notes must stay exactly as they
shipped — a released block is a record of what an operator actually got, not a
place to keep appending. Restored it to the 11 entries in the release commit and
opened 0.21.7 for the eight that followed.

The bulk-edit line was the one subtlety: it was published in 0.21.6 and then
REWRITTEN in place to cover the filter builder. 0.21.6 keeps its published
wording; the filter half is new and gets its own 0.21.7 entry.
2026-07-28 11:15:50 +02:00
rouggy a42a3d5713 chore: name the database in every migration log line
An operator reported migrations being very slow and sent a log with three
interleaved runs — and no way to tell one database migrated three times from
three databases migrated once. Each line now carries its target: the SQLite file
name, mysql:<database>, or baseline:memory for the in-memory pass that derives
the schema for a FRESH MySQL database (that one is fast and is not a real
database — in the report it sat between two slow runs and looked like a third).

A pass also announces how many migrations it will apply, so a run that applies
nothing is visibly distinct from one that does.
2026-07-28 10:40:04 +02:00
rouggy 0a1569dbbd fix: QRZ confirmation download read a third of a large logbook, then skipped the rest for good
Three faults compounding, all visible in one operator's report (117k QSOs):

1. We asked for OPTION=ALL every time, so QRZ serialised the entire logbook —
   56 MB — and cut it short. The parser stopped at record 27832 with "unexpected
   EOF" and everything past that was never read. The window is now sent to the
   server (OPTION=AFTER:date); the client-side date filter stays as a backstop.

2. The last-download date was stored RIGHT AFTER the fetch, before knowing
   whether the records had been processed. So the truncated run still advanced
   the window past data it had never read — and every later run skipped it. It
   is now written at the end, and only when the ADIF parsed cleanly. A window
   that moves past unseen data is worse than one that re-reads.

3. Which is why the run in the report ended "Confirmed 0, added 0 (of 0
   returned)" despite parsing 27832 records: an earlier truncated run had
   already pushed the window to yesterday, so every record was filtered out
   client-side. The operator could not recover without clearing the key by hand.

A truncated download now says so in plain words, and says the window was not
moved.
2026-07-28 09:52:20 +02:00
rouggy 843dccf0c4 fix: bulk edit refused the fields it offered (#3)
My mistake yesterday: the new confirmation columns went into uploadStatusCols —
the QSL Manager's FILTER whitelist — instead of bulkEditableCols. The dialog
listed QRZ.com received status and the ten dates, and the repository refused
them at Apply, after the operator had selected 54 QSOs.

Moved to the right map. And two fields have been broken this way since long
before: State and County were offered under "Contacted station" (whose IOTA /
POTA / SOTA / SIG siblings are all allowed) but were missing from the whitelist,
so choosing one always failed. Added — they are exactly what an import loses and
what a whole run shares.

The real fix is the test. Two lists in two languages had to agree by hand and
nothing checked it, so the failure only ever surfaced as an error message at the
last click. The new tests read the ACTUAL field ids out of BulkEditModal.tsx and
FilterBuilder.tsx and assert the repository accepts every one — a field added to
the UI alone now fails the build instead of the operator.
2026-07-28 09:45:22 +02:00
rouggy d49126d45c chore: stop logging FlexRadio meter values
They arrive many times a second and were dumped every 5 s, drowning the rest of
the log. They only ever existed to confirm the meter NAMES during development,
which the subscription line already does — and that one is now a single summary
instead of one line per meter, a Flex announcing dozens of them at each connect.
2026-07-27 22:20:47 +02:00
rouggy cc1ad06a9d fix: keep the precise locator on digital QSOs; make the manual lookup real
Grid on the UDP path — WSJT-X and MSHV can only send a FOUR-character grid,
that is all the FT8 protocol carries. The enrichment rule there is "fill only
what is empty", so the coarse JN05 always won and QRZ's JN05JG was thrown away:
roughly 100 km of accuracy discarded on every digital QSO. The lookup grid is
now taken when it EXTENDS the received square. A lookup that disagrees outright
(JN18 against JN05) is not a refinement — the station may be portable and what
came over the air is then the better record. Both rules are pinned by tests.

Manual fetch in the QSO editor — it read the CACHE, valid for 30 days. An
operator who upgraded their QRZ subscription went on getting the thin
free-account record and clearing the cached row by hand was the only way out.
The button now forces a lookup that bypasses the cache, reaches the provider and
REFRESHES the stored row, with a 15 s budget instead of 2 s: a deliberate click
must reach the network, where giving up early would fall back to cty.dat and
look like nothing happened. The automatic type-ahead lookup keeps the cache,
which is where it earns its keep.

Same fetch: it used `??`, which only guards against null. Go marshals an unset
string as "", so a QRZ record with no grid BLANKED the grid already in the QSO.
The lookup still wins — that is the point of asking for it — but an empty
result no longer erases a good value.
2026-07-27 22:10:00 +02:00
rouggy 4dd15b09e9 chore: release v0.21.6 2026-07-27 21:26:31 +02:00
rouggy 4eec272c0b fix: bulk edit — name the confirmation fields properly, add the missing ones (#3)
"Upload" was wrong for half of them: a paper QSL is not uploaded. Every entry
now says whether it sets a STATUS or a DATE, and in which direction — "QRZ.com
sent status", "LoTW received date" — which is also how the ADIF fields read.

Added: the QRZ.com received status (the column existed, it was simply never
offered) and the ten confirmation DATES — QSL / LoTW / eQSL / QRZ.com in both
directions, Club Log and HRDLog outbound only, those two having no return
channel. Dates use the same picker as the QSL Info tab, with a Today (UTC)
button; clearing the field writes an empty value, which is how a wrong date is
removed from a batch.

Fields are grouped by CHANNEL now, each status followed by its date, in the same
order as the QSL Info tab — a longer list, but nothing to hunt for.

The repository whitelist is extended to match: without it the new columns would
have been refused at write time. The old ids (qrz_upload, clublog_upload,
hrdlog_upload) still resolve, in case anything still holds one.
2026-07-27 21:21:09 +02:00
rouggy 816a727e88 feat: stats timeline, band-map colours, frameless window, and four fixes
Statistics — the activity chart ignored the period selector: picking a year up
top left it showing the last 7 days, two controls contradicting each other. The
buttons were four fixed rolling windows anchored on the newest QSO, not
granularities. They now choose the BUCKET SIZE over the selected period, with an
Auto default and a step-up when a bucket would be too fine (the backend drops a
series past 750 buckets rather than ship 6000 unreadable bars). The hour-of-day
histogram covered a single day, too little to read anything from; it moves to
its own card with a weekday view, over the whole period.

Band map — the CW/data/phone sub-bands were shaded with the STATUS tokens, the
same amber that means "new band" on the pills drawn on top of them. A sub-band
is an identity, not a state: categorical hues now, validated in both themes
(violet failed on the dark steps — 1.9 ΔE from blue for a protan reader) and
added to the legend, since identity must never be colour-alone.

Frameless window — the OS title bar was a dead 32px band above a window that
already has its own. Minimise/maximise/close move into the app header, which
carries the drag region and double-click-to-maximise. The drag opt-out is by
ROLE in CSS, so a future button in that bar keeps working without anyone
remembering the rule.

Fixes:
- Saving settings froze the window while a device was slow: restarting a link
  waits for its poll goroutine, which can sit 45 s inside an uninterruptible COM
  Connect when another program holds the rig. The restart is now off the UI
  path; a slow one is logged.
- Multi-screen: a MAXIMISED window was never repositioned, so it came back on
  the primary screen every launch. The corner is now recorded even when
  maximised (it names the monitor) and re-applied while still hidden.
- The Callsign field is marked out at rest — operators were typing the call into
  Name, which sat beside it and looked identical.
- QSL dates get a real picker (native, for the OS calendar and locale order); a
  malformed old value stays in a text box rather than vanishing behind an empty
  one.

Also: a Support OpsLog entry in Help, and a WinKeyer protocol trace. The WK2
report (one element then a ten-second pause, sidetone that will not switch off)
is NOT fixed here: the WK1/WK2/WK3 command sets differ and a guessed fix would
break the operators it works for today. The trace logs every byte with the
command name and spells out the firmware family, which is what will settle it.
2026-07-27 20:50:44 +02:00
rouggy 2b5c195ab4 chore: release v0.21.5 2026-07-27 13:49:19 +02:00
rouggy 139b4675e3 fix: portable folder — stop storing absolute database paths
Moving the folder (C:\OpsLog → D:\OpsLog, or onto a stick) broke everything:
config.json and each profile's logbook path were absolute, so on the new
machine the pointer named a drive that no longer applied.

The logbook case was the dangerous one. db.Open CREATES what is missing, so if
the stale C: path happened to be creatable, the operator silently got a NEW
EMPTY logbook instead of an error — with their QSOs sitting untouched in the
folder they had just copied.

A path inside the application folder is now stored relative to it and resolved
against the current location at read time. A path OUTSIDE it (a synced folder,
a chosen drive) stays absolute and untouched — that is a deliberate choice; it
is only re-rooted when it has gone missing AND a file of the same name exists in
this install's data folder, i.e. the copied-folder case. With no such twin the
path is left alone so the failure is reported rather than papered over with an
unrelated database. Both rules are pinned by tests.
2026-07-27 13:48:11 +02:00
rouggy 2ad72b19fb feat: separate column layout for Recent QSOs in the Main tab
The Main-tab pane and the full Recent QSOs tab are the same component sharing
one storage key, but the pane is about half as wide — so it wants fewer and
narrower columns. Whichever was opened last rewrote the other's widths.

The pane now stores under "mainpane", which also scopes its award-column
visibility and widths. The full tab keeps the historical key, so its layout is
untouched; the pane starts from defaults once.
2026-07-27 11:58:24 +02:00
rouggy 678c8821c2 fix: column layouts would not stick — five separate faults
1. A language change rebuilt the column defs, which sets the anti-clobber
   guard, but the effect that clears it listened to only two of the rebuild's
   causes. Guard stuck on → every column change was silently discarded for the
   rest of the session. Now keyed on the rebuilt defs themselves, so any future
   cause is covered.
2. The cluster grid had no guard at all: the same rebuild wrote its defaults
   over the saved layout, in the cache AND the database. Unrecoverable.
3. Worked-before and cluster read their state before the active profile was
   known, so they looked up the unscoped cache key (always a miss) and then
   saved under the scoped one. They now wait for the scope and remount on a
   profile switch, like the main log.
4. GetUIPref returns an ERROR, not "", while the settings store is not yet
   scoped — deliberately distinct from "unset". The frontend read it as "no
   preference", rendered defaults, and the first column event overwrote the
   good copy. It now retries instead of giving up.
5. The QSL manager had no storageKey, so it shared the main log's layout and
   each rewrote the other.

Also: the DB write is debounced (a resize drag fired dozens of writes) with a
flush on close, and a rejected write is retried rather than dropped.
2026-07-27 11:56:43 +02:00
rouggy 5394b55bb7 feat: amplifier band-follow — answer the amp's frequency polls
On its CAT/AUX connector an ACOM is the MASTER: it polls a transceiver and
changes band from the reply, so nothing can be pushed to it. internal/catemu
answers those polls on a second serial port, in ACOM command set 5 (Kenwood /
Elecraft): FA;, FB;, IF; and ID;, and nothing else — a wrong-length reply is
worse than none, it desynchronises the amp's parser for the following poll.

Also offered for SPE. Some amps do not poll at all but read the radio↔PC CAT
line in parallel; those never hear a responder, so an optional unprompted send
(500/1000 ms) covers them.

The TX frequency is what is sent: in split the amp must be tuned where we
transmit. Frame lengths are pinned by tests — the failure they guard against is
silent and only shows up as an amp that mistunes.

Untested on hardware.
2026-07-27 11:56:30 +02:00
rouggy 05d64024ef fix: version string was 021.4, not 0.21.4
The release prompt was answered with a missing dot and the script propagated
it verbatim into both constants, so the shipped exe reported "021.4".

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

GetUIPref now returns an error when the settings store isn't wired yet, and the
theme self-heal treats that as "not ready → keep retrying" (up to ~36s) vs a
real empty answer ("unset → keep default"). A dark theme survives the relaunch.
2026-07-23 19:13:00 +02:00
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
rouggy 24a5a0480d chore: release v0.20.7 2026-07-21 18:26:34 +02:00
rouggy 828f99b8ac docs: add Discord server badge to the top of the README (EN/FR) 2026-07-21 17:15:18 +02:00
rouggy 9e5868b839 fix: raise MySQL pool 8->16 — 8 starved the instance's own live-sync polling (revision + grid refresh + live_status + chat), so the grid stopped showing other operators' QSOs; 16 avoids the stall and still fits ~15 ops under max_connections=300 2026-07-21 16:50:48 +02:00
rouggy 40d0ca57c3 diag: log slow QSO inserts (>2s) with call/band/mode; rotate the app log to .1 at 10MB instead of deleting it, so the previous session's diagnostics survive 2026-07-21 16:24:50 +02:00
rouggy aa537f9eea fix: log returns as soon as the QSO row is inserted; award_refs/recording/uploads/eQSL now run off the critical path (both manual and UDP) — a busy multi-op MySQL no longer freezes the entry form ~40s waiting on the award_refs UPDATE 2026-07-21 16:20:49 +02:00
rouggy ea1bd2a66d feat: sync the host CW keyer speed (wkWpm) to the FlexRadio's actual CW speed — including changes made on the radio/SmartSDR, not just the panel slider — so CW-length estimates (auto-call gap) match reality when using Flex CWX 2026-07-21 16:10:15 +02:00
rouggy 1f54656256 fix: <LOGQSO> logs immediately at its position instead of waiting the full CW-duration estimate first (buffered keyers keep sending; logging never interrupts them) — was delaying the log up to ~10s; only the final segment still waits, for auto-call timing 2026-07-21 16:01:15 +02:00
rouggy 54dd109288 docs: changelog entry for 0.20.7 (multi-op MySQL connection fix, By-Continent stats total) 2026-07-21 15:12:44 +02:00
rouggy 2c1d7235f0 fix: shrink MySQL connection pool (50->8) so a multi-op event doesn't exhaust the shared server's max_connections (Error 1040: Too many connections), which was silently failing the on-air widget/award_refs/grid reads and making the UI look frozen 2026-07-21 12:23:38 +02:00
rouggy adf9844d1b fix: stats By Continent donut totals now match the QSO count (bucket blank/unknown continents as 'Unknown' instead of dropping them; keep the Continents metric counting real continents only) 2026-07-21 12:10:00 +02:00
rouggy 7afe40a48e chore: release v0.20.6 2026-07-21 11:57:20 +02:00
rouggy 311ea8341f docs: changelog entry for 0.20.6 (amp card in Flex panel, faster CAT+MySQL startup, no lost QSOs, instant stations-on-air, LOGQSO position, relay fixes) 2026-07-21 11:51:51 +02:00
rouggy a2958d458e fix: 'stations on air' widget updates instantly after a QSO (UDP/FT8 path now emits qso:logged; publishLiveStatus emits livestatus:updated so the widget re-reads exactly when the shared row changes) — was lagging ~15-45s behind the ON-AIR badge 2026-07-21 11:32:16 +02:00
rouggy d9b7e48e83 fix: UDP-logged QSOs fall back to the offline outbox on DB failure (were silently lost); speed up MySQL connect (batch migration checks into 1 query, connect direct-to-DB first) — was ~40s on a high-latency link 2026-07-21 11:18:36 +02:00
rouggy 3e9ebdb89a fix: serialise UDP QSO logging (lookup+dedup+insert) so multi-stream MSHV (4 QSOs at once) queues instead of firing concurrent lookups/DB writes that got 'too many requests' 2026-07-21 11:09:43 +02:00
rouggy be66ac1e19 fix: auto-call gap respected again (wkSend already waits for the send; runAutoCall no longer double-counts it, so a 4s gap is 4s not ~13s) 2026-07-21 10:26:28 +02:00
rouggy 6aab4a6989 fix: start CAT before the MySQL logbook connect — Flex/CAT no longer waits ~30s behind a slow remote-MySQL dial at launch 2026-07-21 10:26:27 +02:00
rouggy 9c62bf0152 fix: cleanly stop CAT (and release relay handles) on shutdown — FlexRadio kept our API client registered, making the next launch take ~30s to reconnect 2026-07-21 10:15:02 +02:00
rouggy 1630c8fdf4 feat: include band in the self-spot toast (Spotted by X on 20m with ...) 2026-07-21 10:08:11 +02:00
rouggy 408791ddf5 fix: <LOGQSO> now logs at its position in the macro (splits the macro there), and ESC/Stop before it completes cancels the pending log 2026-07-21 09:54:54 +02:00
rouggy 3de47a8825 fix: pin bottom status bar — root no longer shows a horizontal scrollbar that overlapped it in a small window 2026-07-21 09:49:49 +02:00
rouggy 880ecdbbb5 fix: cache relay-board drivers so Denkovi/USB-serial boards stay connected
The station-control code rebuilt a fresh driver on every status poll and every
relay set. Stateful boards (Denkovi FTDI D2XX, USB-serial) hold an OS handle only
one opener can own, so the first poll opened the board and leaked the handle, and
every poll after failed with 'device in use' — the relays greyed out a second
after Save and auto-control never switched. Drivers are now cached per device and
reused, closed on config change. Adds a Test-connection button + detect feedback
in the device editor (reported by VK4MA).
2026-07-21 09:31:08 +02:00
rouggy 615df0dc10 docs: update README (EN/FR) — SPE Expert amp, Station Control relays, Flex CWX keyer, statistics dashboard, What's new dialog 2026-07-21 09:24:28 +02:00
rouggy 2194279602 fix: drain SPE status backlog so display is current (was ~15s stale); ON now pulses DTR (0x0B is level-only); power-level cycle waits for real status change 2026-07-21 01:38:39 +02:00
rouggy 51c4bda71a fix: SPE card shows 'SPE offline' instead of 'PowerGenius offline' 2026-07-21 01:31:38 +02:00
rouggy 7d7d175ede feat: SPE amp ON/OFF buttons and Low/Mid/High power-level control (keystroke codes best-guess from APG, pending hw verification) 2026-07-21 01:25:55 +02:00
rouggy be1ae76eb3 fix: correct SPE band-code map (00=160m,01=80m,02=60m...confirmed on hw); add output-power bar to SPE amp card 2026-07-21 01:19:24 +02:00
rouggy 50157a25d3 feat: show band on SPE amp card, spell out power level (Low/Mid/High), larger status text 2026-07-21 01:13:02 +02:00
rouggy fe1a77a54d fix: use official SPE band-code table from the manual (00=60m,01=160m,...) 2026-07-21 01:10:12 +02:00
rouggy b818a2d947 fix: correct SPE status CSV field alignment (leading empty field shifts indices +1); decode band code; OPERATE/STANDBY now reflects real amp state 2026-07-21 01:09:13 +02:00
rouggy 5c4ae0cfd7 fix: put Mode Split By Band and By Mode side by side on the same row 2026-07-21 01:02:34 +02:00
rouggy 69635a15bc refactor: remove redundant By Band chart, keep Mode Split By Band in its place 2026-07-21 00:59:25 +02:00
rouggy 78220e700f fix: hide the entire Flex-reported amp card when PowerGenius is not the selected amp type 2026-07-21 00:57:16 +02:00
rouggy 42a6b9c76a debug: log raw SPE status frame on change (to verify CSV field alignment on real hw) 2026-07-21 00:54:13 +02:00
rouggy 56affa4bed feat: SPE Expert amplifier card in the Flex panel (OPERATE/STANDBY + live status)
When the configured amplifier is an SPE Expert (not the PowerGenius), the Flex
panel now shows an SPE control card — the Flex doesn't report SPE amps, so it's
driven by OpsLog's own SPE link. The PowerGenius fan-mode control is hidden when
the amp type isn't PowerGenius.
2026-07-21 00:49:48 +02:00
rouggy aa5af4fc75 ui: EN/FR toggle in the What's-new dialog (defaults to UI language) 2026-07-21 00:36:39 +02:00
rouggy 46772e54fe chore: check for updates every 5 minutes (was 10) 2026-07-21 00:32:19 +02:00
rouggy a0cea352ff feat: re-check for updates when opening Help - About (no more stale 'up to date')
Opening About now fires a fresh CheckForUpdate with a 'Checking…' state, and
clears the update banner when the latest check finds nothing — so it reflects
reality instead of the last periodic check.
2026-07-21 00:26:54 +02:00
rouggy 190b86eb1c chore: release v0.20.5 2026-07-21 00:21:55 +02:00
rouggy cfc3d00ea1 feat: Help - What's new button to reopen the changelog anytime
Adds GetChangelog (all entries up to the running version, no seen-marker change)
and a Help menu 'What's new' item that reopens the changelog dialog on demand.
2026-07-21 00:17:11 +02:00
rouggy 9033e8518c feat: What's-new changelog dialog on first launch after an update (EN/FR)
Embeds changelog.json (per-version EN + FR notes, curated from the release's
commits). GetWhatsNew compares the stored last-seen version with the running
build and returns the notes for every newer version, once, then advances the
marker. A dialog shows them in the UI language on the first launch after update.
Seeded with the 0.20.5 notes.
2026-07-20 22:58:05 +02:00
rouggy bfbd9fa61a fix: activity chart uses rolling chronological windows (real dates), not cyclical
Day/Week/Month/Year now show the anchor day hour-by-hour, the last 7 days, the
last 30 days, and the last 12 months — anchored to the period end (else now), in
real date order. Replaces the cyclical hour-of-day/weekday/day-of-month/month
buckets, which left future days empty and ordered the weekend after Monday.
2026-07-20 21:39:24 +02:00
rouggy f0c4f22942 feat: stats activity-over-time granularity selector (timeline / day / week / month / year)
Backend adds cyclical distributions ByHour (hour-of-day), ByDOW (weekday), ByDOM
(day-of-month) and ByMonthYr (calendar month), all UTC. The Activity-over-time
card gains a selector: Timeline keeps the chronological month trend; Day/Week/
Month/Year show the hour/weekday/day/month distribution as bars.
2026-07-20 21:25:33 +02:00
rouggy 19c91f32a0 feat: stats — per-band mode split (CW / phone / data) stacked bar chart
Adds Stats.ByBandCategory (per band: CW/phone/data counts, band-plan order) via a
modeCategory() bucketing in stats.go, and a StackedBandBars chart in the Stats
panel showing the split per band with a legend.
2026-07-20 21:20:29 +02:00
rouggy 3ec23bc613 ui: compact Stations-on-air rows to single line so 5+ fit without scrolling 2026-07-20 19:36:01 +02:00
rouggy 2fbb922bd2 Revert "fix: UDP-logged QSOs adopt the active profile's station callsign (Club Log upload blocked)"
This reverts commit f0afdcc498.
2026-07-20 19:34:33 +02:00
rouggy f0afdcc498 fix: UDP-logged QSOs adopt the active profile's station callsign (Club Log upload blocked)
MSHV/WSJT-X often stamp a personal STATION_CALLSIGN (e.g. F4BPO) that differs
from the special-event/contest profile you're running in OpsLog (e.g. TM74TFR).
The QSO then failed the extsvc station-callsign guard and was never uploaded to
the (matching) Club Log logbook. Live UDP logging now stamps the active profile's
callsign when the base call genuinely differs (portable variants left alone).
2026-07-20 19:32:06 +02:00
rouggy 22352c5748 fix: who's-on-air freq/appearance lag — prompt publish on activity + 5s widget poll
publishLiveStatus only ran on QSO log or the 15s heartbeat, and the widget polled
every 15s, so an operator's band/freq (and their appearance) could trail by up to
a minute. Now ReportLiveActivity debounce-publishes ~1.5s after a freq/band/mode
change, and the widget polls every 5s (and 1.5s after qso:logged).
2026-07-20 19:17:13 +02:00
rouggy 6356d60a66 fix: Stations-on-air widget lagged minutes (timer reset by dbConn object churn)
The 15s poll effect depended on the dbConn OBJECT, which gets a fresh reference
on several events — each reset the interval before it fired, so the widget could
stay stale for over a minute. Depend on the is-mysql boolean instead, and also
refresh ~2s after qso:logged so our own row appears promptly like the ON AIR
badge.
2026-07-20 19:02:57 +02:00
rouggy cafade0dbb remove CW decoder (audio->text) entirely; fix CWX macro not stopping on call typing
- Removes the RX-audio CW decoder: deletes app_cw.go + internal/cwdecode, the
  cwDecoder/cwPitchHz/cwMu/cwStop App fields, and all frontend state/effects, the
  header Ear button, the Tools menu item and the decoded-text strip. The CW
  KEYER (WinKeyer/Icom/Flex) is untouched.
- Fix: typing a callsign while a macro is keying now aborts the CURRENT engine
  (Flex CWX / Icom / WinKeyer) on the first character, not just WinKeyer during
  an auto-call loop — via a shared stopKeyerTx() helper.
2026-07-20 19:00:09 +02:00
rouggy 2e39615554 feat: SPE Expert amplifier control (serial/TCP) — OPERATE toggle + live status
Implements the SPE Application Programmer's Guide protocol: packets
0x55 0x55 0x55|CNT|DATA|CHK (sum%256), OPERATE key 0x0D (toggles STANDBY/OPERATE)
and STATUS request 0x90 whose 0xAA-framed reply is a 19-field CSV (mode, RX/TX,
band, power level, output W, SWR, V/I, temp, warnings, alarms). internal/spe
polls status ~1/s over USB serial (go.bug.st/serial, 8N1) or TCP (RS232-to-
Ethernet bridge) — same codec, different transport.

Wired via startPGXL (starts the SPE client for spe* types), bindings GetSPEStatus
/ SPESetOperate, and a live status card + OPERATE/STANDBY toggle in the Amplifier
settings panel. Only the two example-anchored commands are sent (safe); other
keystroke codes were ambiguous in the guide's table. Untested on hardware.
2026-07-20 17:59:24 +02:00
rouggy 666b933114 feat: generalize Settings 'Power Genius' into 'Amplifier' (type + serial/IP transport) for SPE Expert
Renames the Hardware section to Amplifier and adds an amplifier-type selector
(4O3A PowerGenius XL, SPE Expert 1.3K-FA / 1.5K-FA / 2K-FA) plus a transport
choice for the SPE amps: USB (serial COM + baud) or Network (RS232-to-Ethernet
IP:port). PGXLSettings gains Type/Transport/ComPort/Baud (keys keep the pgxl.*
prefix for back-compat). PowerGenius still drives over TCP; SPE settings are
stored but control is not wired yet (needs the SPE serial protocol).
2026-07-20 17:44:56 +02:00
rouggy def59da748 ui: label both USB relay backends as 'Denkovi USB' (FT245/D2XX vs serial/COM) 2026-07-20 17:33:02 +02:00
rouggy fe69bc308c feat: Denkovi USB 4/8 (selectable) + generic USB-serial relay board (CH340/LCUS)
- Denkovi (FT245 D2XX) now selectable as 4 or 8 relays (a 4-relay board just uses
  the low nibble); NewDenkovi takes a count, driven by the device's Channels.
- New 'usbrelay' backend: cheap CH340/LCUS USB-serial boards over a COM port
  using the common A0 protocol ([0xA0][relay][state][checksum]); write-only, so
  Status is a shadow. Channel count configurable (1/2/4/8/16).
- StationDevice gains Channels; deviceRelayCount() drives label/relay counts for
  the variable-size types. Device editor: channel selector + COM-port picker
  (usbrelay) / FTDI-serial (denkovi).

Both untested on hardware; the A0 command set / Denkovi bit order may need a tweak
after a live test.
2026-07-20 17:23:38 +02:00
rouggy c07a17dc47 feat: Denkovi USB 8-relay board (FT245 D2XX bit-bang)
Third relay device alongside WebSwitch and KMTronic. Despite enumerating as a
COM port, this board is driven via FTDI D2XX synchronous bit-bang (one byte = 8
relays, bit 0 = relay 1), not serial ASCII — so we load ftd2xx.dll at runtime
(no CGO) and call FT_OpenEx/FT_SetBitMode/FT_Write, addressing the board by its
FTDI serial (e.g. DAE0006K), exactly like the vendor tool. Windows-only (stub
elsewhere); ListDenkoviDevices enumerates connected serials for the picker.

Wired into relaydev (Count/Status/Set), app.go (deviceDriver/relayCountFor/type
whitelist + ListDenkoviDevices binding), and the Station device editor (new type
+ FTDI-serial field with Detect). Untested on hardware; bit order / on-polarity
may need a flip after a live test.
2026-07-20 16:50:04 +02:00
rouggy 3cef885934 feat: Flex CWX type-ahead backspace (cwx erase) — phase 2
Completes the CWX type-ahead loop: with send-on-type the keyer-panel CW text
already streams each typed char to the radio's CWX buffer (which keys in order,
so you can keep typing while it sends); this adds the matching un-send. Route
wkBackspace to FlexBackspaceCW -> cwx erase N, so backspacing a mistyped char in
send-on-type removes it from the buffer before the radio keys it.
2026-07-20 16:12:24 +02:00
rouggy b8db653981 fix: ESC now stops the active CW engine (Icom/Flex), not just WinKeyer
The ESC abort keyed WinkeyerStop unconditionally, so it never aborted the Icom
CI-V or Flex CWX keyer. Route the stop through cwSource like the panel Stop
button does.
2026-07-20 15:51:32 +02:00
rouggy 9729ef62ba feat: FlexRadio CWX CW keyer (send/stop/speed) — no WinKeyer/SmartCAT needed
Adds a third CW engine alongside WinKeyer and Icom CI-V: FlexRadio's CWX keyer
over the existing SmartSDR CAT connection.

- cat.FlexController: SendCW (cwx send), StopCW (cwx clear); flex.go implements
  them, escaping the quoted text form. Speed reuses SetCWSpeed (cw wpm).
- App bindings FlexSendCW/FlexStopCW/FlexSetKeySpeed via FlexDo.
- Frontend: cwSource gains 'flex'; macros/auto-call/<LOGQSO>/send-on-type route to
  Flex when the CAT backend is a Flex (estimate-based timing like Icom, no busy
  echo). Keyer panel shows 'Flex CWX' + ready/offline; Settings adds the engine
  option with a CAT-backend guard.

Core send/stop/speed first; type-ahead buffer + mid-send backspace (cwx delete)
to follow.
2026-07-20 15:13:24 +02:00
rouggy cc6411a618 fix: Antenna Genius buttons ignored clicks while the CW decoder ran
PortBtn was defined inside AntGeniusPanel, so every render produced a new
component type and React unmounted/remounted every port button. Normally
re-renders are rare so it's invisible, but the CW decoder floods the app with
cw:text/cw:status events (re-render many times a second), tearing the buttons
down between mousedown and mouseup — the click never completed and the antenna
never switched. Move PortBtn to module scope (stable identity) and pass
onActivate/t as props.
2026-07-20 14:45:29 +02:00
rouggy 991831bdec chore: DDFM catalog file updated from Publish-to-catalog (address+QTH postal OR-rules, full department reference list) 2026-07-20 14:39:15 +02:00
rouggy 1b2da95ad4 ui: widen the Award management modal (max-w-6xl, 95vw) so the fuller footer fits 2026-07-20 14:35:18 +02:00
rouggy 10d86db50a feat: rename the database from Settings, keeping all config
Adds a Rename button to Settings -> Database: renames the current SQLite database
(e.g. opslog.db -> F4BPO.db) while preserving every setting/profile, unlike New
database which starts empty. Implemented as a VACUUM INTO copy + pointer switch;
the old file (open and locked now) is scheduled via config.json delete_pending
and removed, with its -wal/-shm sidecars, on the next launch.
2026-07-20 14:30:45 +02:00
rouggy 8538f48259 feat: Publish-to-catalog export for awards (edit in UI, version, ship to team)
Adds ExportAwardForCatalog(code, version): writes the selected award as a
catalog-format JSON ({"def":…,"references":…}) stamped with a version and with
user_edited cleared, ready to paste over internal/award/catalog/<code>.json. A
new release then propagates the change to the whole team via mergeCatalog —
unedited copies auto-upgrade, edited ones are offered the update.

UI: a version input + "Publish to catalog…" button in the award editor footer,
defaulting to one past the award's current version.
2026-07-20 14:20:30 +02:00
rouggy 0fa91c3d5f feat: ship DDFM v2 (postal-code OR-rule) via the award catalog so it reaches the team
DDFM now derives the French department from the address postal code, not only
from a note typed by hand — the catalog def gains the address OR-rule (\d{2}\d{3}
-> Dxx), France+Corsica scope, and version 2. On next launch every operator whose
DDFM is unedited auto-upgrades to it (user-edited copies are offered it); the
materialised award_refs then recompute so ~500+ French QSOs gain their department.

Also: a catalog definition update now clears the award_refs backfill flag, so any
future catalog bump self-heals the stored columns without a manual flag bump.
2026-07-20 13:57:50 +02:00
rouggy c86d331bd9 fix: re-materialize award_refs so new matching rules reach existing QSOs
The one-shot award_refs backfill never revisited rows after it ran, so a QSO
materialized before an award gained a rule kept stale values — e.g. DDFM's
postal-code OR-rule matched nothing on the 500+ French QSOs whose department
comes from the address, only the handful with it typed in notes. Two fixes:
bump the backfill flag (awards.materialized.v2) so every operator re-materializes
once on next launch with their current definitions, and make RescanAwards (the
Awards tab Rescan button) also recompute award_refs for an on-demand refresh.
2026-07-20 13:41:14 +02:00
rouggy 77a752efe3 chore: release v0.20.4 2026-07-20 12:06:32 +02:00
rouggy 781fbfaa30 fix: wire up File - Exit (was disabled and had no handler)
The File menu's Exit item was hard-disabled and its action had no case in the
menu dispatcher, so it did nothing. Enable it and route file.exit to QuitApp,
which runs the same graceful beforeClose shutdown as the window close button.
2026-07-20 12:05:12 +02:00
rouggy 61c11c0fe3 fix: clear entry-form award refs when the callsign is wiped
Live award detection merges pickable refs into award_refs per call, but nothing
dropped them when the call was cleared, so a previous call's ref lingered onto
the next (IT9AOT's ref still showing after typing F4BPO, both listed in the F3
Awards tab). Clear award_refs the moment the callsign field is emptied.
2026-07-20 11:57:04 +02:00
rouggy 64b746f007 feat: materialize award references into the QSO row (award_refs column)
Award refs are now computed once and stored on the QSO as a JSON column
(award_refs, e.g. {"DDFM":"74","WAJA":"12"}) instead of recomputing the whole
award engine on every grid page load. Written on log/edit/UDP-import and
bulk-recomputed when an award definition or reference list changes; a one-time
per-logbook backfill materializes pre-existing QSOs. The grid reads the column
directly like any other field, so it's fast and available everywhere (including
the shared MySQL logbook).

Also fix per-profile grid column layout: the DB copy was already per-profile,
but the localStorage cache used one global namespace and shadowed it, so a
profile switch kept the previous profile's columns/widths. gridPrefs now scopes
the cache by active profile id and the grids remount on profile:changed.
2026-07-20 11:51:49 +02:00
rouggy 9cc72c7575 fix: OmniRig reads VFO A first (FreqA→Freq fallback), fixing IC-7610 CAT
The 7610-only FreqA gate broke CAT on the 7610 when FreqA was momentarily 0.
Match DXHunter/WSJT-X: read FreqA first for all rigs, fall back to the generic
Freq (IC-9100 etc.), then FreqB. OmniRig's generic Freq maps to VFO B on the
7610, which is why keying off FreqA is correct.
2026-07-20 10:11:10 +02:00
rouggy 9e4f43f648 fix: offline operators removed from live_status + award-column visibility
Live status: when an operator goes offline (no QSO in the window) OpsLog now
DELETEs its live_status row instead of just flipping online=0, so a status page
that lists present/recent rows shows them as gone without having to read the
online column — the whole point of the feature. The row reappears on the next
QSO.

Recent QSOs columns: toggling an award column (e.g. DDFM) rebuilt columnDefs and
made AG Grid re-apply every colDef hide default, so hidden non-award columns
(QTH/Grid) came back and restoringRef stayed stuck true (saving off). The
restore-saved-state effect now also runs on awardShown changes, so the other
columns keep the user's visibility.
2026-07-20 09:55:14 +02:00
rouggy 5f044b959e chore: release v0.20.3 2026-07-20 01:25:28 +02:00
rouggy 68a49be8c1 feat: rate meter OP/Team display, on-air-only station widget, column-filter count
Rate meter: on a shared MySQL logbook it now shows two lines — OP (the active
operator, accent) and Team (all operators, foreground) — each with the 10/60-min
QSOs/hour; single-op keeps the one-line display.

Live-stations widget: only stations currently on air are listed (offline ones
are hidden rather than greyed).

Recent QSOs footer: 'Showing X of Y' now reflects the AG-Grid COLUMN filters
(the funnel icons) — the grid reports its displayed row count, so filtering a
column shows how many QSOs remain instead of always the full total.
2026-07-20 01:08:17 +02:00
rouggy 8eb82d6cdb feat: QSO rate meter — per-operator AND team totals
GetQSORate now returns both the active operator's rate (their own performance)
and the whole station's rate (all operators combined). RecentRateBreakdown
computes both in one scan of the recent rows (per-operator + all-operators),
replacing RecentRate.
2026-07-20 01:08:17 +02:00
rouggy d327db3f57 fix: auto-update relaunch — clear Mark-of-the-Web + wait for exit
The new build downloaded and OpsLog quit, but never came back. Two Windows
causes: the freshly written exe carried the internet Zone.Identifier mark, so
SmartScreen wanted to prompt "are you sure you want to open this?" — invisibly,
since we launch it programmatically — and silently blocked the launch; and
starting the new exe while the old one was still exiting raced the single-
instance mutex.

Now the swapped exe's Zone.Identifier stream is removed, and the relaunch is
done by a detached, hidden PowerShell that Wait-Process's on our PID (so we're
fully gone and the mutex is free) before Start-Process'ing the new exe.
2026-07-19 19:53:19 +02:00
rouggy 59e6570f17 chore: release v0.20.2 2026-07-19 19:11:06 +02:00
rouggy 82a2c6cb7f fix: relay auto-control no longer toggles on every launch/close
Closing OpsLog dropped the CAT, whose frequency went to 0; the auto-control
read that as out of range and switched the relay OFF, then ON again at the next
launch. Now a rule is skipped entirely when the frequency/band is unknown (CAT
off), so relays keep their state across a close. And on the first evaluation
after launch/save it reads the boards' LIVE state and only switches a relay
that isn't already in the wanted position — no more clunk when it's already
correct.
2026-07-19 18:35:18 +02:00
rouggy 24eaf597fd fix: ON AIR badge correct at launch + faster to appear
At launch nothing is in memory yet, so the badge showed offline even with a
recent QSO. liveLastQSOTime is now authoritative: it takes the most recent of
the in-memory stamp AND the DB (this operator's last QSO — covers a contact
from the shared logbook or one logged before launch), used by both the
published status and the badge. The badge polls the backend every 5 s (down
from 15) and on each qso:logged, so it shows on air within a few seconds and
flips online instantly on a new contact.
2026-07-19 18:35:18 +02:00
rouggy 14a22ddb66 fix: SetExtra used the wrong column name (extras -> extras_json)
The targeted extras write used SET extras, but the column is extras_json, so
sending an OpsLog QSL card (and the recording stamps) failed with an unknown-
column error. Use the real column name.
2026-07-19 18:35:18 +02:00
rouggy 9156acea5f feat: ON AIR status-bar badge + auto-updater UI (10-min check, progress)
ON AIR badge (bottom status bar, shown when live-status publishing is on): a
blinking red light + "On air" when a QSO was logged in the last 5 min, dim
"Offline" otherwise. Seeded at launch from LiveLastQSOAgeSec so it's right
immediately, flips online on qso:logged, expires via a 10s tick.

Auto-updater UI: the update check now also runs every 10 minutes, and the
update notification downloads and installs in-app — a live progress bar
(update:progress) then OpsLog restarts on the new build. Falls back to opening
the release page when there's no auto-download asset or on error (retry).
Regenerated bindings for DownloadAndApplyUpdate / LiveLastQSOAgeSec.
2026-07-19 18:15:07 +02:00
rouggy 5d0906f00e feat: in-app auto-updater — download, swap and relaunch
CheckForUpdate now also resolves the release's downloadable asset (portable
.exe, or a .zip to unpack). DownloadAndApplyUpdate streams it with progress
events (update:progress 0-100), swaps it in for the running exe (rename the
current one to .old — allowed for a running image on Windows — move the new one
into place, roll back on failure), then relaunches with --post-update and quits.

main: a --post-update relaunch retries the single-instance mutex for ~20 s so
the fresh process waits for the old one to exit and free it, then clears the
.old exe left behind. Falls back to opening the release page when a release has
no auto-download asset.
2026-07-19 18:14:56 +02:00
rouggy 901e967b53 feat: live operator status goes offline after 5 min idle, online on log
The multi-op live status heartbeated every 15 s, so an operator who left the
log open but stopped working still showed on air. Now it publishes an explicit
online flag: online when a new contact was logged within the last 5 minutes,
offline otherwise. live_status gains `online` + `last_qso_at` columns (added to
existing tables via ALTER). Logging a QSO (manual, UDP, ADIF monitor) stamps the
time and republishes at once, so they flip back online instantly.

At launch the last-QSO time is seeded from the DB (Repo.LastQSOTime), so an
operator who worked someone just before starting OpsLog is shown online right
away rather than offline until their next contact. LiveLastQSOAgeSec exposes it
to the UI badge.
2026-07-19 18:14:43 +02:00
rouggy 4ab4f70349 fix: no duplicate logs on repeat Enter + ESC stops the callsign lookup
Duplicate logs: `saving` is React state (async), so a burst of Enter presses /
clicks fired before the re-render each ran a full AddQSO and logged the same
QSO several times when a slow lookup made logging take a moment. A synchronous
savingRef guard now ignores the repeat immediately.

ESC: clearing the entry (ESC) left the "looking up" spinner turning and let a
still in-flight lookup re-populate the field afterwards. resetEntry now cancels
the pending lookup debounce, hides the spinner at once, and bumps a generation
counter so a late lookup result is discarded instead of applied.
2026-07-19 17:42:27 +02:00
rouggy 64e80986ea fix: QSL/recording no longer revert a QSO's upload status + faster logging
Status clobber: sending an OpsLog QSL card (or stamping a recording) read the
QSO, did slow work (e-mail send), then wrote the WHOLE row back from that now-
stale copy — silently reverting clublog/qrz_qso_upload_status from Y to R that a
concurrent auto-upload had just set. That's why QSOs "showed R again, but only
with QSL sending on", intermittently. New Repo.SetExtra does a targeted
`UPDATE qso SET extras = ?` that touches only the extras column, so an app-field
stamp can never clobber another column. Used by SendEQSL, markRecordingSent and
the recording-path stamp.

Logging speed: the log path no longer runs a callsign lookup at all — the e-mail
already arrives on the QSO from the entry lookup, so the second one was
redundant and stalled logging on a slow/not-found QRZ. And the entry lookup
(LookupCallsign) is now bounded to ~2 s: providers get 2 s, then it falls
through to cty.dat instead of spinning 10 s+ on a call that isn't in QRZ.
2026-07-19 17:42:14 +02:00
rouggy 816c6ffcf1 fix: revert upload serialisation — it stranded QSOs at R (regression)
The single-worker upload queue introduced head-of-line blocking: one slow
upload (LoTW signing via TQSL, or a service on a 30 s timeout) stalled the
whole queue, so every following QSO's Club Log upload waited and the rows sat
at "R". Operators on the older, concurrent build had no such problem — the
tell that this was a fresh regression.

Back to one goroutine per upload (never serialised), so a slow upload never
holds up the rest, while keeping the transient-failure retry with back-off
(concurrent, so it can't block anything).
2026-07-19 17:41:48 +02:00
rouggy 2166d1aa4b fix: IC-7610 VFO A over OmniRig + faster Icom LAN reconnect
OmniRig: on the IC-7610 the generic Freq property reports the wrong VFO (its
Main/Sub model confuses the stock ini), so OpsLog showed VFO B. Detect the rig
by RigType and, in simplex, read VFO A explicitly — matching Log4OM. Only the
7610 is affected; other rigs keep using the generic Freq.

Icom network: when the rig tears the session down (control/CI-V 0x05) OpsLog
only logged it and kept the half-dead link until the 6 s liveness timeout
expired. Mark the link dead on 0x05 so Alive() fails on the next poll and the
manager reconnects cleanly right away.
2026-07-19 17:00:11 +02:00
233 changed files with 89850 additions and 3910 deletions
+1
View File
@@ -46,3 +46,4 @@ cat.log
.env.local
*.pem
*.key
*.syso
+111
View File
@@ -0,0 +1,111 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
OpsLog is a Windows ham-radio logger built with **Wails v2** — Go backend, React/TypeScript frontend, compiled into a single `.exe`. Author F4BPO. See `README.md` for the user-facing feature list and `wiki/` for end-user documentation.
**Pure Go, no CGO** — SQLite is `modernc.org/sqlite`, serial is `go.bug.st/serial`. Any dependency requiring cgo breaks the build; check before adding one.
## Commands
```bash
wails dev # hot-reload dev (Go methods also reachable at http://localhost:34115)
wails build # full build → build/bin/OpsLog.exe (~25 s)
wails generate module # REQUIRED after changing exported App methods (regenerates TS bindings)
go build ./... # fast Go-only check
go test ./... # all Go tests
go test ./internal/steppir/ -run TestApplyPendingDirHold -v # one test
gofmt -w <file>
cd frontend && npx tsc --noEmit # frontend typecheck alone (wails build does this too)
```
Release: `.vscode/release.ps1` (Ctrl+Shift+P → *Tasks: Run Task**Release OpsLog*) — bumps the version, pushes to Gitea, builds and publishes to Gitea + GitHub.
Prefer `wails build` for final validation: it is the only command that exercises Go, the TS typecheck, the bindings and the asset embed together.
## Navigating app.go
`app.go` is ~14 000 lines and holds nearly every Wails binding. **Do not read it linearly** — it is organised into banner-delimited sections:
```bash
grep -n "^// ──\|^// ---" app.go # table of contents
```
Sections map to features (`── Motorized antenna (Ultrabeam / SteppIR) ──`, `── NET Control ──`, `── DX-cluster spot alerts ──`, …). Find the banner, then read that range. The `App` struct (~line 448) is the other useful landmark: its fields document every subsystem and carry substantial explanatory comments.
Smaller root files split off self-contained areas: `app_cw.go`, `app_qsl_designer.go`, `app_secret.go`, `chat.go`, `offline.go`, `relayauto.go`, `adifwatch.go`, `livestatus.go`, `update.go`, `telemetry.go`.
## Architecture
### Wails binding boundary
Exported methods on `*App` are the entire frontend API. Adding or changing a signature requires `wails generate module`, which writes `frontend/wailsjs/go/main/App.d.ts` and `models.ts`. Parameters and return types must be Wails-serializable — the generator prints `Not found: time.Time` noise for unsupported types (that particular message is long-standing and harmless).
Backend→frontend push uses Wails events (`runtime.EventsEmit` / `EventsOn`), e.g. `qso:logged`, `update:progress`. Most hardware status is **polled** by the frontend on an interval rather than pushed.
### Two separate databases
This split is easy to get wrong and matters:
- **`a.db`** — settings database (`settings.db` / `opslog.db`). Always SQLite. Holds settings and profiles. Its location is chosen by the user and recorded in `data/config.json`.
- **`a.logDb`** — the logbook (QSOs). Either a per-profile SQLite file, the default `logbook.db`, or a **shared MySQL** so several operators log into one database. `a.dbBackend` says which.
QSOs never go to `a.db`. `internal/db` holds the connection helpers and the MySQL specifics; `internal/qso` is the repository.
Remote MySQL is the slow path: startup deliberately brings CAT/rig links up **before** connecting the logbook, and hot paths (cluster spot enrichment, alert matching) avoid per-row queries — hence the in-memory `wcbm` worked-index and the `clusterEvents` queue that keeps the telnet socket draining.
### Per-profile settings scoping
`internal/settings.Store` is a key/value store over the settings DB. Every key is transparently prefixed with the active profile (`p3.`), so each station profile has a complete independent set. Two consequences:
- `App.settingsScoped` (atomic) gates reads until the active profile is known. Anything reading settings early must respect it or it reads the wrong scope.
- Settings keys are declared as `key<Thing>` string constants near the top of `app.go` (~180 of them) — grep `key[A-Z].*= "` to find one.
Password-type keys are encrypted at rest when the secret vault is unlocked (`internal/secret`); a locked vault returns `""` rather than ciphertext, and callers treat that as "not configured".
### Hardware device pattern
The codebase talks to ~20 devices (rigs, amplifiers, antenna controllers, switches, keyers, rotators). They all follow the same shape — match it when adding one:
1. **`internal/<device>/`** — a self-contained package exposing a `Client` with `New(...)`, `Start()`, `Stop()`, `GetStatus()`. The client owns its own goroutine: a reconnecting poll loop, a mutex serialising the shared connection, and a cached last-known `Status`. Transports are usually TCP *or* serial behind one `io.ReadWriteCloser`.
2. **Settings**`key<Thing>*` constants plus `Get<Thing>Settings()` / `Save<Thing>Settings()` bindings in `app.go`.
3. **Lifecycle** — a `start<Thing>()` method that tears down any existing client and rebuilds it from settings; called at startup and again whenever settings are saved.
4. **Status binding**`Get<Thing>Status()`, polled by the frontend.
5. **UI** — a settings panel in `frontend/src/components/SettingsModal.tsx` and a live widget in `frontend/src/components/`.
`internal/steppir` and `internal/antgenius` are compact, well-commented references. Wire-protocol packages carry the byte layout in the package doc comment and pin it with table tests against **real captured frames** — keep that up, since a wrong byte silently mistunes an antenna.
Where two devices are interchangeable (Ultrabeam / SteppIR), `app.go` defines a small interface (`motorAntenna`) plus thin per-device adapters rather than branching everywhere.
### CAT
`internal/cat` (~7 000 lines, the largest package) is the rig abstraction: OmniRig, native FlexRadio/SmartSDR, native Icom CI-V (USB **and** remote-over-internet), and TCI (SunSDR / Expert Electronics). `cat.Manager` exposes a backend-agnostic `State()`; backend-specific features are reached through typed escapes such as `FlexDo(func(cat.FlexController) error)`. Check `State().Backend` before using one.
### Logging
`internal/applog` — Wails builds with the Windows GUI subsystem, so `fmt.Println` is discarded. Use `applog.Printf` (or plain `log.Printf` inside `internal/` packages) so output reaches the rotating log file in the user data dir. That file is usually the only evidence available when diagnosing a user's hardware problem.
## Conventions
**Changelog is mandatory.** Every user-visible change gets an entry in `changelog.json`, **in both `en` and `fr`**, in the same session as the change. Keep entries to one or two sentences — rationale belongs in the commit message. New work goes under the next version number; the release script bumps the version constants.
**Append new entries at the BOTTOM of a version block**, never at the top. A block is read top to bottom, so a feature must appear before the fixes made to it — prepending meant an operator read "the Yaesu meters are corrected" several entries before learning a Yaesu console existed at all.
**Version lives in two places** and must stay in lockstep: `appVersion` in `telemetry.go` and `APP_VERSION` in `frontend/src/version.ts`.
**Bilingual UI.** Every user-visible string goes through `t()` from `frontend/src/lib/i18n.tsx` (~700 keys), with both English and French provided. No hardcoded display strings.
**Commit messages must not include a `Co-Authored-By` line or any mention of the model.**
**Comments explain *why*, not *what*.** The existing code documents the reasoning behind non-obvious decisions — which protocol source was trusted, what field failure a workaround addresses, why an ordering matters. Match that: a comment that restates the code is noise, a comment recording the hard-won reason is why this codebase is navigable.
## Gotchas
- **Adding a promoted ADIF field touches five places in lockstep.** `internal/adif` promotes ~30 ADIF fields to real QSO columns; miss one and imports silently drop data. All of: the struct field, column list, insert args and scan in `internal/qso/qso.go`; the dictionary entry (`Promoted: true`) in `internal/adif/fields.go`; the `adifPromoted` list *and* the assignment in `internal/adif/import.go`; the writer in `internal/adif/export.go`; then the frontend column in `RecentQSOsGrid.tsx` with its EN+FR i18n keys. Trace an existing field (e.g. `ant_path`) across the repo as a template.
- **Generated files — don't hand-edit.** `internal/dxcc/dxcc_names_gen.go` (cty.dat joined to the ARRL/ADIF entity list) and `internal/awardref/uscounties_gen.go` (emitted by `cmd/cntygen` from the FIPS county CSV; a one-shot generator, not part of the build).
- **Single-instance guard** (`main.go`): a second process would open its own CAT and antenna-follow loops and the two would fight over the rig frequency.
- **Frontend controlled inputs**: several editors normalise a value on every keystroke (trim, split, filter). Binding an input directly to the normalised form makes Enter/Space appear dead — keep raw text in local state and derive the stored value from it.
+80 -20
View File
@@ -1,7 +1,11 @@
# OpsLog
Un logiciel de log radioamateur moderne et rapide pour Windows — saisie façon
Log4OM, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif,
<a href="https://discord.gg/FYM8yw5pT" target="_blank">
<img src="https://img.shields.io/badge/Discord-Rejoindre%20le%20serveur-5865F2?logo=discord&logoColor=white" alt="Rejoindre notre Discord" />
</a>
Un logiciel de log radioamateur moderne et rapide pour Windows — saisie en
bandeau unique, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif,
**Icom CI-V** natif (USB **et** à distance par internet, en remplacement de
RS-BA1) et **TCI** (SunSDR / Expert Electronics), cluster DX avec alertes de
spots, suivi des diplômes, cartes, log de concours, gestion des QSL et un
@@ -28,7 +32,7 @@ Développé par **F4BPO**.
## Journalisation
- **Bandeau de saisie façon Log4OM :** indicatif, RST émis/reçu, nom/QTH/locator,
- **Bandeau de saisie unique :** indicatif, RST émis/reçu, nom/QTH/locator,
bande/mode, fréquence TX/RX (split), heure de début/fin, commentaire/note. Le
**drapeau** de l'entité contactée est affiché en grand à côté des champs RST.
- **Recherche d'indicatif** (QRZ.com / HamQTH) avec photo, pré-remplissage du
@@ -38,7 +42,10 @@ Développé par **F4BPO**.
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),
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,
préréglages enregistrés) avec **export ADIF** des lignes filtrées ou
sélectionnées.
@@ -63,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
rotor.
- **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
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.
@@ -77,10 +86,12 @@ Développé par **F4BPO**.
Cluster et le volet cluster de la vue principale, avec bascule affichage/masquage.
- **Statut** par spot (nouveau / nouvelle bande / nouveau créneau / contacté),
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
`api.pota.app`).
- **Alertes de spots** (façon Log4OM) : règles sur indicatif / pays / bande /
- **Alertes de spots :** règles sur indicatif / pays / bande /
mode / spotter, avec notification sonore, visuelle et e-mail (Outils →
*Gestion des alertes*).
@@ -93,7 +104,8 @@ connexion rapide non bloquante — une radio éteinte ne fige jamais l'applicati
supportée par OmniRig.
- **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
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
**serveur LAN intégré** de la radio (voir *Icom à distance* ci-dessous). Ni
RS-BA1 ni Remote Utility nécessaires.
@@ -110,9 +122,17 @@ Affiché uniquement quand le backend CAT est une FlexRadio :
- **Émission :** puissance RF, puissance d'accord, TUNE, MOX, processeur de
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.
- **Amplificateur :** PowerGenius XL operate/standby + défaut.
- **Amplificateur :** la carte de commande suit l'ampli configuré, avec une liste
déroulante pour le choisir quand **plusieurs amplificateurs** sont configurés
(p. ex. deux SPE en parallèle). Voir *Amplis & commutateurs* ci-dessous.
- **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.
@@ -159,9 +179,10 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
## Keyers & audio
- **Keyer CW** avec macros et macros sur touches F. Le moteur du keyer est
sélectionnable : **WinKeyer** (K1EL WK1/2/3 sur port COM), **Icom** (le keyer
intégré de la radio via CI-V — sans matériel supplémentaire, fonctionne aussi à
distance) ou **TCI**.
sélectionnable : **WinKeyer** (K1EL WK1/2/3 sur port COM), **FlexRadio CWX**
(le keyer intégré de la radio via l'API SmartSDR — type-ahead et retour arrière,
sans WinKeyer ni SmartCAT), **Icom** (le keyer intégré de la radio via CI-V —
sans matériel supplémentaire, fonctionne aussi à distance) ou **TCI**.
- **Keyer vocal numérique** (DVK) : enregistrer les messages vocaux F1F6 et les
émettre.
- **Enregistrement audio des QSO :** capture continue en tampon glissant ; au
@@ -170,10 +191,33 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
## Amplis & commutateurs
- Amplificateur **PowerGenius XL** (4O3A) — TCP direct : operate/standby,
sélecteur de mode ventilateur et affichage des défauts.
- **Amplificateurs** — configurez **un ou plusieurs** amplis (Réglages →
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
ventilateur et affichage des défauts.
- **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
sortie Low / Mid / High, une barre de puissance et le statut en direct (bande,
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
commutation A/B ancré.
- Panneau **Station Control** (ancrable, widgets réordonnables par glisser) : le
**rotor**, la commande d'éléments **Ultrabeam** et des **cartes relais**
WebSwitch 1216H, KMTronic, **Denkovi** USB (bit-bang FT245 D2XX, 4 ou 8 relais)
et USB-série générique (CH340 / LCUS, protocole A0) — pour l'alimentation, les
antennes et accessoires de la station.
- **Relais automatiques** (Réglages) : bascule les relais de Station Control
automatiquement selon la fréquence / bande de la radio (comme PstRotator) — par
relais, une fenêtre de fréquence ou un ensemble de bandes.
## QSL & diplômes
@@ -201,13 +245,23 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
un début/fin de fenêtre, signale les doublons et tient un tableau de score en
direct.
## Statistiques
- **Tableau de bord des statistiques :** tuiles de synthèse (QSO, indicatifs
uniques, entités DXCC, continents, % confirmés) plus des graphiques — QSO **par
mode**, un split **CW / phonie / data** par bande, l'**activité dans le temps**
(vues glissantes jour / 7 jours / 30 jours / 12 mois), par opérateur et par
continent. Filtres par plage de dates, par opérateur et par concours, et une vue
**Table** qui reprend chaque graphique.
## 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.
**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
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
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
@@ -217,8 +271,11 @@ ce n'est pas un serveur web.
## Net control
- **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
net d'un coup en utilisant la fréquence CAT.
session active en mémoire — pointez les stations présentes, puis loguez-les une
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
@@ -247,7 +304,10 @@ ce n'est pas un serveur web.
- **Démarrage automatique :** lancer des programmes externes (WSJT-X, JTAlert,
contrôle de rotor…) au démarrage d'OpsLog, en sautant ceux déjà lancés.
- **Sauvegarde :** sauvegarde optionnelle base + ADIF à la fermeture.
- **Vérification de mise à jour** au démarrage avec un toast (désactivable).
- **Vérification de mise à jour** au démarrage et toutes les 5 minutes (et à
l'ouverture d'Aide → À propos), avec un toast (désactivable), plus une fenêtre
**Nouveautés** qui affiche le changelog (anglais / français) au premier
lancement après une mise à jour — réouvrable à tout moment depuis le menu Aide.
- **Télémétrie d'usage anonyme** (un battement de cœur quotidien : ID
d'installation aléatoire + version + OS — aucune donnée d'indicatif ou de QSO ;
désactivable dans les Préférences).
+76 -19
View File
@@ -1,6 +1,10 @@
# OpsLog
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
<a href="https://discord.gg/FYM8yw5pT" target="_blank">
<img src="https://img.shields.io/badge/Discord-Join%20the%20server-5865F2?logo=discord&logoColor=white" alt="Join our Discord" />
</a>
A modern, fast ham-radio logger for Windows — single-strip entry, real-time CAT
for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB **and**
remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert Electronics),
DX cluster with spot alerts, awards tracking, maps, contest logging, QSL
@@ -27,7 +31,7 @@ Developed by **F4BPO**.
## Logging
- **Log4OM-style entry strip:** callsign, RST tx/rx, name/QTH/grid, band/mode,
- **Single-strip entry:** callsign, RST tx/rx, name/QTH/grid, band/mode,
TX/RX frequency (split), start/end time, comment/note. The contacted entity's
**flag** is shown large next to the RST fields.
- **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid
@@ -36,7 +40,10 @@ Developed by **F4BPO**.
with `/MM` `/AM` `/B` (beacon) and call-area (`/8`, `/W6`) handling, plus
ClubLog DXpedition date overrides.
- **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
presets) with filtered- and selected-row **ADIF export**.
- **Find duplicates** (Tools) — groups QSOs by same call + band + mode (optionally
@@ -55,7 +62,10 @@ Developed by **F4BPO**.
- **Great-circle map** with short/long-path distance & azimuth, selectable
basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and
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
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.
@@ -67,9 +77,11 @@ Developed by **F4BPO**.
mode / status / source) shared by the Cluster tab and the Main-view cluster
pane, with a show/hide toggle.
- 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`).
- **Spot alerts** (Log4OM-style): rules on call / country / band / mode /
- **Spot alerts:** rules on call / country / band / mode /
spotter, with sound, visual and e-mail notification (Tools → *Alert
management*).
@@ -81,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.
- **FlexRadio (SmartSDR)** over the radio's TCP API — real-time slice freq /
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
via the radio's **built-in LAN server** (see *Remote Icom* below). No RS-BA1 or
Remote Utility needed.
@@ -98,9 +111,16 @@ Shown only when the CAT backend is a FlexRadio:
- **Transmit:** RF power, tune power, TUNE, MOX, speech processor (NOR/DX/DX+),
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.
- **Amplifier:** PowerGenius XL operate/standby + fault.
- **Amplifier:** the amp card follows whichever amplifier is configured, with a
dropdown to pick it when **several amplifiers** are set up (e.g. two SPEs run
in parallel). See *Amplifiers & switches* below.
- **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.
@@ -141,18 +161,40 @@ as Mumble.)
## Keyers & audio
- **CW keyer** with macros and F-key macros. The keyer engine is selectable:
**WinKeyer** (K1EL WK1/2/3 over a COM port), **Icom** (the radio's own keyer
over CI-V — no extra hardware, works over the remote link too) or **TCI**.
**WinKeyer** (K1EL WK1/2/3 over a COM port), **FlexRadio CWX** (the radio's
built-in keyer over the SmartSDR API — type-ahead and backspace, no WinKeyer or
SmartCAT needed), **Icom** (the radio's own keyer over CI-V — no extra hardware,
works over the remote link too) or **TCI**.
- **Digital Voice Keyer** (DVK): record F1F6 voice messages and transmit them.
- **QSO audio recording:** continuous rolling capture; on *Log QSO* the contact
is saved to a per-QSO WAV (`CALL_YYYYMMDD_HHMMSS.wav`); mixes RX + mic.
## Amplifiers & switches
- **PowerGenius XL** (4O3A) amplifier — direct TCP: operate/standby, fan-mode
selector and fault display.
- **Amplifiers** — configure **one or several** amps (Settings → Amplifier is a
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
selector and fault display.
- **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,
Low / Mid / High output level, an output-power bar and live status (band,
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-switch widget.
- **Station Control** panel (dockable, drag-to-reorder widgets): the **rotator**,
**Ultrabeam** element control and **relay boards** — WebSwitch 1216H, KMTronic,
**Denkovi** USB (FT245 D2XX bit-bang, 4 or 8 relays) and generic USB-serial
(CH340 / LCUS, A0 protocol) — for station power, antennas and accessories.
- **Relay auto-control** (Settings): switch Station-Control relays automatically
from the rig frequency / band (like PstRotator) — per relay, a frequency window
or a set of bands.
## QSL & awards
@@ -176,12 +218,21 @@ as Mumble.)
and the sent/received serials (`STX` / `SRX`), enforces a window start/end,
flags dupes and keeps a live scoreboard.
## Statistics
- **Logbook statistics dashboard:** headline tiles (QSOs, unique callsigns, DXCC
entities, continents, % confirmed) plus charts — QSOs **by mode**, a per-band
**CW / phone / data** split, **activity over time** (rolling day / 7-day /
30-day / 12-month views), by operator and by continent. Date-range, per-operator
and per-contest filters, and a **Table** view that mirrors every chart.
## Multi-operator live status (special events)
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**):
Settings → General → *Publish live operator status*. Each OpsLog instance
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**),
publishing is **automatic** — no setting to turn on. Each OpsLog instance
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
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
@@ -190,8 +241,11 @@ only writes to the DB — it is not a web server.
## Net control
- **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
using the CAT frequency.
in-memory active session — check stations in, then log them individually or the
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
@@ -218,7 +272,10 @@ only writes to the DB — it is not a web server.
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
OpsLog startup, skipping any already running.
- **Backup:** optional database + ADIF backup at shutdown.
- **Update check** at startup with a toast (toggleable).
- **Update check** at startup and every 5 minutes (and on opening Help → About),
with a toast (toggleable), plus a **What's new** dialog that shows the changelog
(English / French) on the first launch after an update — reopenable any time
from the Help menu.
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
version + OS — no callsign or QSO data; opt-out in Preferences).
+5115 -651
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
package main
import (
"encoding/json"
"fmt"
"strings"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// keyAwardsTracked holds the PER-PROFILE list of award codes the operator wants
// to follow (JSON array of award.Def.Code). Award definitions themselves are
// global (keyAwardDefs, shared across profiles), but WHICH awards a station
// tracks is a per-profile choice — a DX profile follows DXCC/WPX, a POTA profile
// follows POTA/WWFF. An empty/unset list means "track them all" so the Awards
// tab is never blank before the operator has picked anything.
const keyAwardsTracked = "awards.tracked"
// GetTrackedAwards returns the active profile's followed award codes. An empty
// slice means the operator has not narrowed the list — the Awards tab then shows
// every award.
func (a *App) GetTrackedAwards() ([]string, error) {
out := []string{}
if a.settings == nil {
return out, nil
}
s, _ := a.settings.Get(a.ctx, keyAwardsTracked)
if strings.TrimSpace(s) == "" {
return out, nil
}
if err := json.Unmarshal([]byte(s), &out); err != nil {
return []string{}, nil
}
return out, nil
}
// SaveTrackedAwards persists the followed award codes for the active profile and
// notifies the Awards tab to re-filter its list. Codes are stored verbatim; the
// Awards tab intersects them with the live award definitions, so a code that no
// longer exists is simply ignored (not an error).
func (a *App) SaveTrackedAwards(codes []string) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if codes == nil {
codes = []string{}
}
b, err := json.Marshal(codes)
if err != nil {
return err
}
if err := a.settings.Set(a.ctx, keyAwardsTracked, string(b)); err != nil {
return err
}
wruntime.EventsEmit(a.ctx, "awards:tracked-changed")
return nil
}
+60
View File
@@ -0,0 +1,60 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"os"
"strings"
)
// deniedCallHashes lists station callsigns not permitted to run this build,
// stored as SHA-256 hex of the base call so the calls themselves appear nowhere
// in the source or the compiled binary. Enforcement is best-effort by design —
// the call is entered by the operator and can be changed — so this only turns
// away straightforward use, not a determined one.
var deniedCallHashes = map[string]struct{}{
"2282a88b3e5e4eebc6b8174d005bb46d32025758b7741bdcf34f2b3621c02205": {},
"0ee09fe60817a2a4982f5e5b14a60b8dbb11cff55b3d36661213c4cbdd0933ea": {},
"46fb61e71afb40627cb6493a6a59c9d4d0231ee3cad1a2bf3fcc4cdfce36fabc": {},
}
// callDenied reports whether a callsign is on deniedCallHashes. The call is
// reduced to its base form (upper-cased, portable prefix/suffix dropped) the
// same way extsvc does, so F4XYZ/P matches F4XYZ.
func callDenied(call string) bool {
base := denyBaseCall(call)
if base == "" {
return false
}
sum := sha256.Sum256([]byte(base))
_, bad := deniedCallHashes[hex.EncodeToString(sum[:])]
return bad
}
// denyBaseCall mirrors extsvc.baseCall: for a slashed form it returns the
// longest token (the real call), otherwise the call itself, upper-cased.
func denyBaseCall(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
if !strings.Contains(s, "/") {
return s
}
best := ""
for _, part := range strings.Split(s, "/") {
if len(part) > len(best) {
best = part
}
}
return best
}
// enforceCallGate exits the process silently when any of the supplied callsigns
// is denied. Called once at startup, after the profiles are loaded: the first
// launch is where the operator enters and saves the call, so a denied build
// simply does not come back up on the next launch. No window, no message.
func enforceCallGate(calls ...string) {
for _, c := range calls {
if callDenied(c) {
os.Exit(0)
}
}
}
+68 -19
View File
@@ -31,13 +31,20 @@ import (
const (
keyQSLEmailSubject = "qsl.email_subject"
keyQSLEmailBody = "qsl.email_body"
keyQSLAutoSend = "qsl.auto_send" // "1" → render+send an eQSL on log when an e-mail and default template exist
keyQSLAutoSend = "qsl.auto_send" // "1" → render+send an eQSL on log when an e-mail and default template exist
keyQSLDefaultMsg = "qsl.default_message" // fallback QSL message printed on the card when the QSO's own QSLMSG is empty
)
// appQSLCardSentField is the ADIF APP_ field stamping when OpsLog e-mailed its
// own QSL card. Deliberately NOT eqsl_sent (that's eQSL.cc's, kept independent).
const appQSLCardSentField = "APP_OPSLOG_QSL_SENT"
// appQSLCardRcvdField marks that a QSL was RECEIVED for this QSO (set by the
// operator, e.g. when a card arrives by e-mail). It drives the PSE/TNX stamp on
// the card: received → "TNX QSL" (thanks for your card), not received → "PSE QSL"
// (please send one). Independent of ADIF qsl_rcvd, like the sent field.
const appQSLCardRcvdField = "APP_OPSLOG_QSL_RCVD"
const (
defaultQSLEmailSubject = "eQSL — {CALL} de {MYCALL}"
defaultQSLEmailBody = "Hi,\n\nThank you for our QSO! Please find attached your eQSL card.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}"
@@ -437,28 +444,53 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
applog.Printf("qsl: send eQSL to %s (%s) failed: %v", to, q.Callsign, err)
return err
}
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field —
// NOT the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay
// independent. q came straight from GetByID, so a full Update rewrites the
// row unchanged apart from this field.
if q.Extras == nil {
q.Extras = map[string]string{}
}
q.Extras[appQSLCardSentField] = time.Now().UTC().Format(time.RFC3339)
if err := a.qso.Update(a.ctx, q); err != nil {
applog.Printf("qsl: eQSL sent to %s but marking failed: %v", q.Callsign, err)
return fmt.Errorf("eQSL sent but status not saved: %w", err)
// Record WHEN OpsLog e-mailed its own QSL card, in a dedicated app field — NOT
// the ADIF eqsl_sent flag, which belongs to eQSL.cc and must stay independent.
//
// Stamp ONLY this extras key (targeted UPDATE), never a full-row write. The `q`
// read up top is now stale after the slow e-mail send, and rewriting the whole
// row would revert any column an auto-upload changed meanwhile — that's how
// sending a QSL card was flipping clublog_qso_upload_status back from Y to R.
if err := a.qso.SetExtra(a.ctx, qsoID, appQSLCardSentField, time.Now().UTC().Format(time.RFC3339)); err != nil {
applog.Printf("qsl: card sent to %s but marking failed: %v", q.Callsign, err)
return fmt.Errorf("QSL card sent but status not saved: %w", err)
}
applog.Printf("qsl: eQSL sent to %s (%s)", to, q.Callsign)
wruntime.EventsEmit(a.ctx, "qsl:sent", qsoID)
return nil
}
// QSLEmailTemplates is the eQSL e-mail subject/body plus the auto-send toggle.
// qslDefaultMessage returns the operator's default QSL message (Settings), used
// on the card when a QSO has no QSLMSG of its own.
func (a *App) qslDefaultMessage() string {
if a.settings == nil {
return ""
}
s, _ := a.settings.Get(a.ctx, keyQSLDefaultMsg)
return s
}
// SetOpsLogQSLReceived marks (or clears) that a QSL was received for a QSO. This
// flips the card's PSE/TNX stamp. Stored as a timestamp in the QSO extras (a
// single-key UPDATE, like the sent marker) so it survives concurrent uploads.
func (a *App) SetOpsLogQSLReceived(qsoID int64, on bool) error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
v := ""
if on {
v = time.Now().UTC().Format(time.RFC3339)
}
return a.qso.SetExtra(a.ctx, qsoID, appQSLCardRcvdField, v)
}
// QSLEmailTemplates is the eQSL e-mail subject/body, the auto-send toggle, and
// the default QSL message printed on the card when a QSO has none of its own.
type QSLEmailTemplates struct {
Subject string `json:"subject"`
Body string `json:"body"`
AutoSend bool `json:"auto_send"`
Subject string `json:"subject"`
Body string `json:"body"`
AutoSend bool `json:"auto_send"`
DefaultMessage string `json:"default_message"`
}
// QSLGetEmailTemplates returns the eQSL e-mail templates (with defaults).
@@ -467,7 +499,7 @@ func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
if a.settings == nil {
return out, nil
}
m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody, keyQSLAutoSend)
m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody, keyQSLAutoSend, keyQSLDefaultMsg)
if err != nil {
return out, err
}
@@ -478,6 +510,7 @@ func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
out.Body = b
}
out.AutoSend = m[keyQSLAutoSend] == "1"
out.DefaultMessage = m[keyQSLDefaultMsg]
return out, nil
}
@@ -496,7 +529,10 @@ func (a *App) QSLSaveEmailTemplates(t QSLEmailTemplates) error {
if t.AutoSend {
v = "1"
}
return a.settings.Set(a.ctx, keyQSLAutoSend, v)
if err := a.settings.Set(a.ctx, keyQSLAutoSend, v); err != nil {
return err
}
return a.settings.Set(a.ctx, keyQSLDefaultMsg, t.DefaultMessage)
}
// maybeAutoSendEQSL fires an eQSL render+send for a freshly-logged QSO when the
@@ -635,6 +671,18 @@ func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error)
}
return strconv.Itoa(z)
}
// The QSL message on the card: the QSO's own QSLMSG wins; when it's empty the
// operator's default message (Settings) is used instead.
msg := q.QSLMsg
if strings.TrimSpace(msg) == "" {
msg = a.qslDefaultMessage()
}
// PSE/TNX stamp, chosen automatically by whether a QSL was received for this
// QSO (appQSLCardRcvdField): received → thank them, otherwise ask for a card.
pseTnx := "PSE QSL"
if q.Extras != nil && strings.TrimSpace(q.Extras[appQSLCardRcvdField]) != "" {
pseTnx = "TNX QSL"
}
vars := map[string]string{
"profile.callsign": info.Callsign,
"profile.operator_name": info.Operator,
@@ -651,7 +699,8 @@ func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error)
"qso.mode": q.Mode,
"qso.submode": q.Submode,
"qso.rst_sent": q.RSTSent,
"qso.qsl_msg": q.QSLMsg,
"qso.qsl_msg": msg,
"qso.pse_tnx": pseTnx,
"qso.name": q.Name,
}
vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q)
+14 -8
View File
@@ -18,14 +18,20 @@ const (
// sensitiveSettingKeys are the password fields encrypted at rest when the user
// sets a passphrase. Everything else stays plaintext.
var sensitiveSettingKeys = map[string]bool{
keyQRZPassword: true,
keyHQPassword: true,
keyEmailPassword: true,
keyExtClublogPassword: true,
keyExtLoTWKeyPassword: true,
keyExtLoTWWebPassword: true,
keyExtHRDLogCode: true,
keyExtEQSLPassword: true,
keyQRZPassword: true,
keyHQPassword: true,
keyEmailPassword: true,
keyExtClublogPassword: true,
keyExtLoTWKeyPassword: true,
keyExtLoTWWebPassword: true,
keyExtHRDLogCode: true,
keyExtEQSLPassword: true,
keyExtCloudlogAPIKey: true,
// The web-publish config is one JSON blob and the FTP password lives inside
// it, so the whole blob is encrypted. That costs nothing — it is read and
// written as a unit anyway — and beats storing a server password in clear
// next to the rest.
keyWebPublish: true,
}
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"testing"
"hamlog/internal/cat"
)
// Three band tables exist in Go — cat.BandFromHz (the rig/CAT one), bandForHz
// (awards) and the cluster's own — plus one in App.tsx. They must agree, and
// nothing checked it: the CAT one stopped at 23 cm, so a 10 GHz QSO came back
// with an EMPTY band. An empty band is not a cosmetic problem — it is not
// logged, not counted in any award slot, and exports without a BAND field.
//
// One frequency per band, taken inside the range, plus the edges that used to be
// missing entirely.
func TestBandPlansAgree(t *testing.T) {
cases := []struct {
hz int64
want string
}{
{137000, "2190m"},
{475000, "630m"},
{1840000, "160m"},
{3650000, "80m"},
{7100000, "40m"},
{10120000, "30m"},
{14200000, "20m"},
{18100000, "17m"},
{21200000, "15m"},
{24940000, "12m"},
{28400000, "10m"},
{50150000, "6m"},
{70200000, "4m"},
{144300000, "2m"},
{223500000, "1.25m"},
{432000000, "70cm"},
{1296000000, "23cm"},
// The microwave bands that were missing. 3 cm is the one an operator
// reported: 10 GHz is worked and spotted, and resolved to nothing.
{2320000000, "13cm"},
{3400000000, "9cm"},
{5760000000, "6cm"},
{10368000000, "3cm"},
{24048000000, "1.25cm"},
{47088000000, "6mm"},
{76032000000, "4mm"},
{122250000000, "2.5mm"},
{134928000000, "2mm"},
{241920000000, "1mm"},
}
for _, c := range cases {
if got := cat.BandFromHz(c.hz); got != c.want {
t.Errorf("cat.BandFromHz(%d) = %q, want %q", c.hz, got, c.want)
}
if got := bandForHz(c.hz); got != c.want {
t.Errorf("bandForHz(%d) = %q, want %q — the award band plan disagrees with the CAT one", c.hz, got, c.want)
}
}
}
// A frequency in no amateur band must resolve to "" everywhere, not to the
// nearest band: guessing here would put a QSO in a band the operator never used.
func TestBandPlanRejectsOutOfBand(t *testing.T) {
for _, hz := range []int64{100_000_000, 1_000_000_000, 15_000_000_000} {
if got := cat.BandFromHz(hz); got != "" {
t.Errorf("cat.BandFromHz(%d) = %q, want empty", hz, got)
}
if got := bandForHz(hz); got != "" {
t.Errorf("bandForHz(%d) = %q, want empty", hz, got)
}
}
}
+82
View File
@@ -0,0 +1,82 @@
package main
// Band-opening announcements — the app-side glue for internal/bandopen.
//
// The detector needs nothing OpsLog does not already compute: the cluster event
// worker enriches every spot with the great-circle distance and bearing from
// the operator's grid before this is called. So watching for sporadic E costs
// one function call per spot and no new data source.
import (
"fmt"
"strings"
"sync"
"hamlog/internal/applog"
"hamlog/internal/bandopen"
"hamlog/internal/cluster"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
type bandOpenState struct {
mu sync.Mutex
det *bandopen.Detector
last []bandopen.Opening // most recent first, for the UI
}
const maxRememberedOpenings = 20
// detectBandOpening feeds one spot to the detector and announces a hit.
func (a *App) detectBandOpening(s cluster.Spot) {
// No operator grid = no distance and no bearing on the spot, and the whole
// detection rests on those two. Say nothing rather than guess.
if !a.opSet || s.DistanceKm <= 0 || !bandopen.Watched(s.Band) {
return
}
a.bandOpen.mu.Lock()
if a.bandOpen.det == nil {
a.bandOpen.det = bandopen.New(bandopen.DefaultConfig())
}
op := a.bandOpen.det.Add(bandopen.Spot{
Call: s.DXCall, Band: s.Band, DistKm: s.DistanceKm,
Bearing: s.ShortPath, At: s.ReceivedAt,
}, a.opLat)
if op != nil {
a.bandOpen.last = append([]bandopen.Opening{*op}, a.bandOpen.last...)
if len(a.bandOpen.last) > maxRememberedOpenings {
a.bandOpen.last = a.bandOpen.last[:maxRememberedOpenings]
}
}
a.bandOpen.mu.Unlock()
if op == nil {
return
}
applog.Printf("bandopen: %s opening — %d stations, ~%d km, %s%s (%s)",
op.Band, op.Calls, op.MedianKm, op.Sector(),
map[bool]string{true: "", false: " — UNUSUAL for the season"}[op.InSeason],
strings.Join(op.Examples, " "))
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "bandopen:detected", op)
}
}
// GetBandOpenings returns the openings seen this session, newest first. The UI
// polls this so a detection is still visible after its toast has gone.
func (a *App) GetBandOpenings() []bandopen.Opening {
a.bandOpen.mu.Lock()
defer a.bandOpen.mu.Unlock()
out := make([]bandopen.Opening, len(a.bandOpen.last))
copy(out, a.bandOpen.last)
return out
}
// BandOpeningSummary is the one-line form used in the toast and the log.
func BandOpeningSummary(o bandopen.Opening) string {
s := fmt.Sprintf("%s open — %d stations ~%d km, %s", strings.ToUpper(o.Band), o.Calls, o.MedianKm, o.Sector())
if !o.InSeason {
s += " (unusual for the season)"
}
return s
}
+18902
View File
File diff suppressed because it is too large Load Diff
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"os"
"regexp"
"strings"
"testing"
"hamlog/internal/qso"
)
// Every field the bulk-edit dialog OFFERS must be one the repository accepts.
//
// These two lists live in different packages — a TypeScript field table on one
// side, a Go whitelist on the other — and they drifted apart the day the
// confirmation dates were added: the new columns went into the QSL Manager's
// filter whitelist instead of the bulk-edit one, so the dialog listed fields it
// could not write and the operator got "field … is not bulk-editable" only after
// selecting 54 QSOs and clicking Apply. Nothing in the build caught it.
//
// The test reads the ACTUAL field ids out of the .tsx rather than a copy, so a
// field added to the UI alone fails here immediately.
func TestBulkEditFieldsAreWritable(t *testing.T) {
src, err := os.ReadFile("frontend/src/components/BulkEditModal.tsx")
if err != nil {
t.Skipf("frontend source not available: %v", err)
}
ids := regexp.MustCompile(`\{ id: '([a-z0-9_]+)'`).FindAllStringSubmatch(string(src), -1)
if len(ids) < 10 {
t.Fatalf("only %d field ids found — the parse is wrong, not the data", len(ids))
}
for _, m := range ids {
id := m[1]
// Handled before the column map: freq takes a numeric path (freq_hz +
// band together) and the extras live in extras_json.
if id == "freq" || qso.IsBulkEditableExtra(id) {
continue
}
col, ok := bulkFieldColumns[id]
if !ok {
t.Errorf("bulk-edit UI offers %q, which maps to no column (bulkFieldColumns)", id)
continue
}
// Extras live outside the qso table (owner_callsign and friends): they are
// written into extras_json, not a column, so the column whitelist does not
// apply to them.
if strings.HasPrefix(col, "extras.") {
continue
}
if !qso.IsBulkEditable(col) && !qso.IsBulkEditableExtra(col) {
t.Errorf("bulk-edit UI offers %q → column %q, which the repository refuses", id, col)
}
}
}
// Same contract for the filter builder: every field it offers must be one the
// query layer will accept.
func TestFilterFieldsAreQueryable(t *testing.T) {
src, err := os.ReadFile("frontend/src/components/FilterBuilder.tsx")
if err != nil {
t.Skipf("frontend source not available: %v", err)
}
// Anchored on `type:` — the FIELDS entries carry one, the OPERATOR list does
// not, and without it every operator (eq, contains, …) was read as a field.
ids := regexp.MustCompile(`\{ value: '([a-z0-9_]+)', label: '[^']*', type:`).FindAllStringSubmatch(string(src), -1)
if len(ids) < 10 {
t.Fatalf("only %d field ids found — the parse is wrong, not the data", len(ids))
}
for _, m := range ids {
col := m[1]
if !qso.IsFilterable(col) && !qso.IsFilterableExtra(col) {
t.Errorf("filter builder offers %q, which the query layer refuses", col)
}
}
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
_ "embed"
"encoding/json"
"strings"
"hamlog/internal/applog"
)
//go:embed changelog.json
var changelogJSON []byte
// ChangelogEntry is one release's user-facing notes, in English and French. It's
// shown once on the first launch after an update (the "What's new" dialog),
// derived from the release's commit history.
type ChangelogEntry struct {
Version string `json:"version"`
Date string `json:"date"`
EN []string `json:"en"`
FR []string `json:"fr"`
}
const keyChangelogSeen = "changelog.last_seen_version"
func loadChangelog() []ChangelogEntry {
var out []ChangelogEntry
if err := json.Unmarshal(changelogJSON, &out); err != nil {
applog.Printf("changelog: parse failed: %v", err)
}
return out
}
// GetChangelog returns every changelog entry up to the running version (newest
// first), without touching the "seen" marker — for a "What's new" button that
// reopens the notes on demand.
func (a *App) GetChangelog() []ChangelogEntry {
out := []ChangelogEntry{}
for _, e := range loadChangelog() {
if strings.TrimSpace(e.Version) == "" || versionLess(appVersion, e.Version) {
continue
}
out = append(out, e)
}
return out
}
// GetWhatsNew returns the changelog entries the operator hasn't seen yet — the
// notes for every version newer than the last one they ran, up to the current
// build — and records the current version as seen so it pops exactly once per
// update. Empty when there's nothing new.
func (a *App) GetWhatsNew() []ChangelogEntry {
if a.settings == nil {
return nil
}
lastSeen, _ := a.settings.GetGlobal(a.ctx, keyChangelogSeen)
cur := appVersion
a.setSettingGlobal(keyChangelogSeen, cur) // advance the marker now
out := []ChangelogEntry{}
for _, e := range loadChangelog() {
if strings.TrimSpace(e.Version) == "" {
continue
}
if versionLess(cur, e.Version) {
continue // don't preview notes for a version newer than we run
}
if lastSeen == "" {
// First run of the feature (fresh install, or upgrade from a build that
// predates it): show only the CURRENT version's notes, once.
if e.Version == cur {
out = append(out, e)
}
continue
}
if versionLess(lastSeen, e.Version) {
out = append(out, e)
}
}
return out
}
+1032
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
package main
import "testing"
// Which class of power a mode falls into.
//
// This decides how much power leaves the amplifier, so an error here is not a
// display bug: a digital mode sorted as phone gets the SSB setting, which is
// exactly the 1.5 kW into a 100% duty cycle this feature exists to prevent.
func TestFlexPowerClass(t *testing.T) {
for _, c := range []struct{ mode, want string }{
{"SSB", "phone"}, {"USB", "phone"}, {"LSB", "phone"}, {"AM", "phone"}, {"FM", "phone"},
{"CW", "cw"}, {"cw", "cw"}, {" CW ", "cw"},
{"FT8", "digi"}, {"FT4", "digi"}, {"RTTY", "digi"}, {"PSK31", "digi"},
{"JS8", "digi"}, {"Q65", "digi"}, {"MSK144", "digi"}, {"DATA", "digi"},
{"DIGU", "digi"}, {"DIGL", "digi"}, {"WSPR", "digi"},
// Unknown or empty: no class, and the caller changes nothing. Setting a
// transmit power from a name we do not recognise is not a good guess.
{"", ""}, {"SSTV", ""}, {"BANANA", ""},
} {
if got := flexPowerClass(c.mode); got != c.want {
t.Errorf("flexPowerClass(%q) = %q, want %q", c.mode, got, c.want)
}
}
}
+2447 -381
View File
File diff suppressed because it is too large Load Diff
+23 -7
View File
@@ -70,6 +70,13 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
const { t } = useI18n();
const [rules, setRules] = useState<Rule[]>([]);
const [draft, setDraft] = useState<Rule | null>(null);
// Raw text of the callsigns box, kept separately from draft.calls. The list is
// normalised (trim + drop empties) on the way into the rule, so binding the
// textarea straight to calls.join('\n') round-trips every keystroke through
// that normalisation — a trailing newline or space is stripped before React
// re-renders, and Enter/Space appear to do nothing. Editing the raw text and
// deriving the list from it keeps typing intact.
const [callsText, setCallsText] = useState('');
const [tab, setTab] = useState('def'); // active editor tab (reset to Definition on new/select)
const [emailTo, setEmailTo] = useState('');
const [err, setErr] = useState('');
@@ -80,6 +87,12 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
}, []);
useEffect(() => { refresh(); GetAlertEmailTo().then((v) => setEmailTo(v || '')).catch(() => {}); }, [refresh]);
// loadDraft opens a rule in the editor — always through here, so the raw
// callsigns text is re-seeded from whatever rule is now being edited.
const loadDraft = (r: Rule | null) => {
setDraft(r);
setCallsText((r?.calls ?? []).join('\n'));
};
const patch = (p: Partial<Rule>) => setDraft((d) => (d ? alerts.Rule.createFrom({ ...d, ...p }) : d));
const toggleIn = (key: keyof Rule, v: string) => setDraft((d) => {
if (!d) return d;
@@ -92,14 +105,14 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
async function save() {
if (!draft) return;
if (!draft.name.trim()) { setErr(t('altm.giveName')); return; }
try { const saved = await SaveAlertRule(draft); await refresh(); setDraft(saved as Rule); setErr(''); }
try { const saved = await SaveAlertRule(draft); await refresh(); loadDraft(saved as Rule); setErr(''); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function del() {
if (!draft) return;
if (!draft.id) { setDraft(null); return; }
if (!draft.id) { loadDraft(null); return; }
if (!window.confirm(t('altm.deleteConfirm', { name: draft.name }))) return;
try { await DeleteAlertRule(draft.id); setDraft(null); await refresh(); }
try { await DeleteAlertRule(draft.id); loadDraft(null); await refresh(); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
@@ -116,12 +129,12 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
<div className="w-56 shrink-0 flex flex-col border border-border rounded-md">
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground flex-1">{t('altm.rules')}</span>
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { setDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => { loadDraft(emptyRule()); setTab('def'); }}><Plus className="size-3.5" /></Button>
</div>
<div className="flex-1 overflow-y-auto p-1">
{rules.length === 0 && <div className="text-[11px] text-muted-foreground px-2 py-4 text-center">{t('altm.noRules')}</div>}
{rules.map((r) => (
<button key={r.id} onClick={() => { setDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
<button key={r.id} onClick={() => { loadDraft(alerts.Rule.createFrom(r)); setTab('def'); }}
className={cn('w-full text-left px-2 py-1.5 rounded text-xs flex items-center gap-1.5',
draft?.id === r.id ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60')}>
<span className={cn('size-1.5 rounded-full shrink-0', r.enabled ? 'bg-success' : 'bg-muted-foreground/40')} />
@@ -187,8 +200,11 @@ export function AlertsModal({ onClose, bands, modes, countries }: {
<Label className="text-xs">{t('altm.callsigns')}</Label>
<textarea className="w-full h-52 rounded-md border border-border bg-background p-2 text-xs font-mono resize-none"
placeholder={'DL1ABC\nIW3*\n*/P'}
value={(draft.calls ?? []).join('\n')}
onChange={(e) => patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) })} />
value={callsText}
onChange={(e) => {
setCallsText(e.target.value);
patch({ calls: e.target.value.split('\n').map((x) => x.trim()).filter(Boolean) });
}} />
</div>
<div className="space-y-1">
<Label className="text-xs">{t('altm.countries')}</Label>
+238
View File
@@ -0,0 +1,238 @@
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).
// One second: the same window as the docked widget, so the two never show
// different figures for the same amplifier, and neither trails the end of a
// transmission long enough to look like lag.
const peak = useRef<Record<string, { v: number; t: number }>>({});
const peakHold = (key: string, val: number) => {
const now = Date.now();
const p = peak.current[key];
if (!p || val >= p.v || now - p.t > 1000) { 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>
{/* Power ON pulses RTS then DTR — it must stay clickable while the amp
is off (no status = not "connected"), and serial only. */}
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
<button type="button" disabled={!(spe.connected || spe.transport === 'serial')}
onClick={() => AmpPower(amp.id, true).catch(() => {})}
title={spe.transport === 'serial' ? 'Power on (RTS then DTR pulse)' : 'Power-on needs the serial RTS/DTR lines — not available over a network bridge'}
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
<button type="button" disabled={!spe.connected}
onClick={() => AmpPower(amp.id, false).catch(() => {})}
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>
{(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;
// Power comes from the radio's meter stream and nothing else. The
// amplifier also reports a "peakfwd", and using it was a mistake twice
// over: it is a latched maximum that is never reset, and it survives in
// the last-known status after the amp disconnects — so it claimed
// 1350 W from an old transmission while the radio was putting out 10.
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
{amps.map((m) => {
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>
);
}
+288
View File
@@ -0,0 +1,288 @@
import { useRef } from 'react';
import { Flame, X, Power } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { MeterBar } from '@/components/MeterBar';
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
// AmpWidget — the docked amplifier widget, the compact counterpart of AmpCard
// (which is the full-size card shown in the FlexRadio panel and Station
// Control). It covers all three families, since an operator running an SPE and
// a PowerGenius wants one widget, not two:
// • SPE Expert — OPERATE, power ON/OFF, L/M/H, live power / SWR / temp
// • ACOM — OPERATE, power ON/OFF, live power / SWR / temp
// • PowerGenius — OPERATE and meters ride on the FlexRadio, fan mode on GSCP
// With several amplifiers configured the caller passes a selection ("all" or an
// amp id) chosen from the toolbar icon's dropdown.
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
// Full-scale watts for the output bar, from the model string.
function maxW(model?: string, fallback = 1300): number {
const m = (model || '').toUpperCase();
if (m.includes('20K') || m.includes('2K')) return 2000;
if (m.includes('15K')) return 1500;
if (m.includes('13K')) return 1300;
return fallback;
}
function swrColour(swr?: number): string {
if (!swr || swr <= 0) return 'text-muted-foreground';
if (swr <= 1.5) return 'text-success';
if (swr <= 2.0) return 'text-warning';
return 'text-danger';
}
// PowerMeter — the output-power LED bar that closes each column, straight under
// the controls (no filler above it: pushed to the bottom it left a band of dead
// space in the middle of the column). Uses the app-wide MeterBar, compact size,
// so it reads exactly like the meters on the rig panels.
function PowerMeter({ label, watts, maxWatts }: { label: string; watts: number; maxWatts: number }) {
return (
<MeterBar compact label={label} value={watts} unit="W" lo={0} hi={maxWatts}
display={`${Math.round(watts)} W`}
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
);
}
// usePeakHold keeps the highest reading seen for a short window, so a reading
// sampled between two syllables doesn't collapse the meter. `live` is the key
// part: pass false the moment the carrier drops and the held value is thrown
// away at once — holding it there made releasing the PTT look like a five
// second lag, which read as the whole app being slow.
function usePeakHold(windowMs = 1000) {
const peak = useRef<Record<string, { v: number; t: number }>>({});
return (key: string, val: number, live = true) => {
if (!live) { delete peak.current[key]; return val; }
const now = Date.now();
const p = peak.current[key];
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
return p.v;
};
}
// Stat — one small labelled figure (power, SWR, temperature).
function Stat({ label, value, className }: { label: string; value: string; className?: string }) {
return (
<div className="rounded-lg border border-border bg-card/70 px-1.5 py-1 text-center">
<div className="text-[9px] uppercase tracking-wider text-muted-foreground leading-tight">{label}</div>
<div className={cn('text-sm font-bold font-mono leading-tight', className ?? 'text-foreground/80')}>{value}</div>
</div>
);
}
// OperateButton — the STANDBY/OPERATE toggle, identical in behaviour across the
// three families (only where the state comes from differs).
function OperateButton({ operate, disabled, onClick, t }: {
operate: boolean; disabled: boolean; onClick: () => void; t: (k: string, v?: any) => string;
}) {
return (
<button type="button" disabled={disabled} onClick={onClick}
className={cn('w-full inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-[0.98] disabled:opacity-40',
operate
? 'bg-gradient-to-b from-amber-400 to-amber-600 text-white border-amber-300/60 shadow-[0_0_9px_rgba(245,158,11,0.45)]'
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground')}>
<Power className="size-3.5" />
{operate ? t('ampw.operate') : t('ampw.standby')}
</button>
);
}
// AmpBlock — one amplifier inside the widget.
function AmpBlock({ amp, flex, showName, t }: {
amp: Amp; flex: any; showName: boolean; t: (k: string, v?: any) => string;
}) {
const spe = amp.spe, acom = amp.acom;
const hold = usePeakHold();
if (spe || acom) {
const s = spe || acom;
// The amp reports zero watts on receive, so its TX flag clears the meter as
// soon as the operator lets go — and the radio's own flag, when we have one,
// gets there first (the amp is polled on its own slower cycle).
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : s.tx !== false;
const w = hold('w', Number(spe ? s.output_w : s.fwd_w) || 0, txing);
const swr = Number(spe ? s.swr_ant : s.swr) || 0;
const hi = spe ? maxW(s.model) : (Number(s.max_w) || 800);
// Power ON drives the remote-on control lines, so it stays available while
// the amplifier is off and reporting nothing — but only over a serial link.
const canPowerOn = spe ? (s.connected || s.transport === 'serial') : (s.port_open && s.transport === 'serial');
return (
<div className="h-full flex flex-col gap-1.5">
{showName && (
<div className="flex items-center gap-1.5">
<span className={cn('size-1.5 rounded-full shrink-0', s.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || (spe ? 'SPE' : 'ACOM')}</span>
{s.connected && s.band && <span className="ml-auto text-[9px] text-muted-foreground shrink-0">{s.band}</span>}
</div>
)}
<OperateButton operate={!!s.operate} disabled={!s.connected} t={t}
onClick={() => AmpOperate(amp.id, !s.operate).catch(() => {})} />
<div className="flex gap-1.5">
<button type="button" disabled={!canPowerOn}
onClick={() => AmpPower(amp.id, true).catch(() => {})}
className="flex-1 rounded-lg border border-success/60 bg-card py-1 text-[11px] font-bold text-success hover:bg-success/15 disabled:opacity-30">
{t('ampw.on')}
</button>
<button type="button" disabled={!s.connected}
onClick={() => AmpPower(amp.id, false).catch(() => {})}
className="flex-1 rounded-lg border border-danger/60 bg-card py-1 text-[11px] font-bold text-danger hover:bg-danger/15 disabled:opacity-30">
{t('ampw.off')}
</button>
</div>
{/* Output level — SPE only; an ACOM has no L/M/H over CAT. */}
{spe && (
<div className="grid grid-cols-3 gap-1">
{(['L', 'M', 'H'] as const).map((lvl) => {
const active = (s.power_level || '').trim().toUpperCase() === lvl;
return (
<button key={lvl} type="button" disabled={!s.connected}
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
className={cn('rounded-md border py-0.5 text-[10px] font-bold transition-colors disabled:opacity-30',
active ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
{t(`ampw.lvl${lvl}`)}
</button>
);
})}
</div>
)}
{s.connected ? (
<div className="grid grid-cols-3 gap-1">
<Stat label={t('ampw.pwr')} value={`${Math.round(w)}`} />
<Stat label={t('ampw.swr')} value={swr > 0 ? swr.toFixed(1) : '—'} className={swrColour(swr)} />
<Stat label={t('ampw.temp')} value={`${Number(s.temp_c) || 0}°`} />
</div>
) : (
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
)}
{(s.warnings || s.alarms || s.err_text) && (
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
{s.err_text || `${s.warnings || ''} ${s.alarms || ''}`.trim()}
</div>
)}
<PowerMeter label={t('flxp.outputPower')} watts={s.connected ? w : 0} maxWatts={hi} />
</div>
);
}
// PowerGenius XL: OPERATE and the meters come from the radio when the Flex
// reports the amp; the fan mode rides on our own GSCP link.
const pg = amp.pgxl || {};
const viaFlex = !!flex?.amp_available;
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
const connected = !!pg.connected || viaFlex;
const fault = flex?.amp_fault;
const meters = ((flex?.meters as any[]) || []).filter((m) => (m.src || '').toUpperCase().includes('AMP'));
const meter = (re: RegExp) => meters.find((m) => re.test((m.name || '').trim()));
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
const fwd = meter(/^fwd|pwr/i);
const flexW = fwd ? (/dbm/i.test(fwd.unit || '') ? dbmToW(fwd.value) : fwd.value) : 0;
// Whether there is power is the RADIO's transmit flag: it flips the instant
// the PTT does, where the amplifier's own status poll answers a second and a
// half late and holds TRANSMIT past the carrier.
//
// How MUCH is the live reading — the radio's meter stream, or the amp's own
// forward figure when there is no Flex. NOT the amp's "peakfwd": that is a
// latched maximum which is never reset AND survives in the last-known status
// after the amp disconnects, so it reported 1350 W from an old transmission
// while the radio was putting out 10 W into a disconnected amplifier.
const txing = typeof flex?.transmitting === 'boolean'
? flex.transmitting
: fwd ? flexW >= 1 : /TRANSMIT/i.test(pg.state || '');
// Prefer the radio's VITA meter stream when it is actually flowing (local or
// over a VPN) — it is fast and matches SmartSDR. Fall back to the amp's OWN
// link only when the radio isn't streaming its meters (a remote link without
// the VITA path), so the reading survives but is naturally slower.
const liveW = flexW > 0 ? flexW : (pg.connected ? Number(pg.fwd_w) || 0 : 0);
const fwdW = hold('fwd', txing ? liveW : 0, txing);
const id = meter(/^ID$|current/i);
// Live drain current, same source preference. peak_id latches like peak_w, so
// use the instantaneous 'id' and gate it on the radio's transmit flag.
const idA = id ? Number(id.value) : (txing ? Number(pg.id) || undefined : 0);
const temp = meter(/temp/i);
// VITA temp matches SmartSDR (the amp's direct link exposes a different sensor
// that can read ~10°C higher); fall back to the direct temp when no VITA.
const tempC = temp ? temp.value : (pg.temperature > 0 ? pg.temperature : undefined);
return (
<div className="h-full flex flex-col gap-1.5">
{showName && (
<div className="flex items-center gap-1.5">
<span className={cn('size-1.5 rounded-full shrink-0', connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || 'PGXL'}</span>
</div>
)}
<OperateButton operate={operate} disabled={!connected} t={t}
onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})} />
{connected ? (
<div className="grid grid-cols-3 gap-1">
<Stat label={t('ampw.pwr')} value={`${Math.round(fwdW)}`} />
<Stat label={t('ampw.id')} value={idA != null ? idA.toFixed(1) : '—'} />
<Stat label={t('ampw.temp')} value={tempC != null ? `${Math.round(tempC)}°` : '—'} />
</div>
) : (
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
)}
{(pg.host || pg.connected) && (
<select
disabled={!pg.connected}
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
onChange={(e) => AmpFanMode(amp.id, e.target.value).catch(() => {})}
title={t('ampw.fan')}
className="w-full h-7 rounded-md border border-border bg-card px-1.5 text-[10px] font-semibold outline-none focus:border-warning disabled:opacity-40">
<option value="STANDARD">{t('flxp.fanStandard')}</option>
<option value="CONTEST">{t('flxp.fanContest')}</option>
<option value="BROADCAST">{t('flxp.fanBroadcast')}</option>
</select>
)}
{fault && fault !== 'NONE' && (
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
{t('flxp.fault')}: {fault}
</div>
)}
<PowerMeter label={t('flxp.outputPower')} watts={connected ? fwdW : 0} maxWatts={2000} />
</div>
);
}
export function AmpWidget({ amps, flex, sel, onClose }: {
amps: Amp[];
flex: any;
sel: string; // "all" or an amp id
onClose: () => void;
}) {
const { t } = useI18n();
// An id that no longer matches (the amp was removed in Settings) falls back to
// showing everything rather than an empty widget.
const shown = sel === 'all' ? amps : (amps.filter((a) => a.id === sel).length ? amps.filter((a) => a.id === sel) : amps);
const anyOnline = shown.some((a) => a.spe?.connected || a.acom?.connected || a.pgxl?.connected || flex?.amp_available);
const title = shown.length === 1 ? (shown[0].name || t('ampw.title')) : t('ampw.title');
return (
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
<Flame className={cn('size-4', anyOnline ? 'text-warning drop-shadow-[0_0_3px_rgba(245,158,11,0.55)]' : 'text-muted-foreground')} />
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80 truncate">{title}</span>
<span className="flex-1" />
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('ampw.close')}>
<X className="size-3.5" />
</button>
</div>
{/* Several amplifiers sit SIDE BY SIDE, one column each: stacked, two amps
made a widget taller than the dock row and both ended up half hidden. */}
<div className="flex-1 min-h-0 overflow-y-auto p-2.5">
{shown.length === 0 ? (
<div className="text-[11px] text-center text-muted-foreground italic py-2">{t('ampw.none')}</div>
) : (
<div className="grid gap-2.5 h-full" style={{ gridTemplateColumns: `repeat(${shown.length}, minmax(0, 1fr))` }}>
{shown.map((a, i) => (
<div key={a.id} className={cn(i > 0 && 'pl-2.5 border-l border-border/50')}>
<AmpBlock amp={a} flex={flex} showName={shown.length > 1} t={t} />
</div>
))}
</div>
)}
</div>
</div>
);
}
+33 -23
View File
@@ -33,6 +33,37 @@ function pretty(name: string): string {
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
}
// PortBtn is defined at MODULE scope on purpose. Defined inside AntGeniusPanel it
// would be a new component *type* on every render, so React would unmount and
// remount every port button each time the panel re-renders — harmless when that's
// rare, but with the CW decoder running the parent re-renders many times a second
// and the buttons were being torn down mid-click (mousedown and mouseup landing on
// different element instances), so antenna changes silently did nothing.
function PortBtn({ port, index, active, tx, onActivate, t }: {
port: 1 | 2; index: number; active: boolean; tx: boolean;
onActivate: (port: number, antenna: number) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
}) {
const letter = port === 1 ? 'A' : 'B';
const cls = tx
? '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)] animate-pulse'
: active
? (port === 1
? '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-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';
return (
<button
type="button"
onClick={() => onActivate(port, active ? 0 : index)}
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
>
{letter}
</button>
);
}
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
// match the app's light theme with soft gradients + glows. Each antenna row has
// a port-A button (left) and port-B button (right). Colours: green = selected on
@@ -61,27 +92,6 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
if (filtered.length > 0) list = filtered;
}
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
const letter = port === 1 ? 'A' : 'B';
const cls = tx
? '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)] animate-pulse'
: active
? (port === 1
? '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-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';
return (
<button
type="button"
onClick={() => onActivate(port, active ? 0 : index)}
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
>
{letter}
</button>
);
};
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">
@@ -124,11 +134,11 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
return (
<div key={a.index} className="flex items-center gap-1.5">
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} />
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} onActivate={onActivate} t={t} />
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
{pretty(a.name)}
</div>
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} />
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} onActivate={onActivate} t={t} />
</div>
);
})}
+88 -12
View File
@@ -19,6 +19,7 @@ import {
ListCountries, DXCCForCountry, DXCCName,
PopulateBuiltinReferences, HasBuiltinReferences,
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
ExportAwardForCatalog,
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
} from '../../wailsjs/go/main/App';
@@ -32,12 +33,15 @@ export type AwardDef = {
code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
ref_display?: string; // grid column shows: ref | name | both
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
leading_str?: string; trailing_str?: string; dynamic?: boolean;
type?: string; field: string; match_by?: string; exact_match?: boolean; one_ref_per_qso?: boolean; pattern: string;
leading_str?: string; trailing_str?: string; prefix?: string; dynamic?: boolean;
or_rules?: AwardOrRule[];
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
total: number; builtin?: boolean;
// The "custom" confirmation source: the field it reads and, optionally, the
// value(s) that count. Empty value = any non-empty value confirms.
confirm_field?: string; confirm_value?: string;
total: number; builtin?: boolean; version?: number;
};
type AwardOrRule = {
@@ -62,7 +66,7 @@ const CONFIRM_SRC = [
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
];
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','23cm','13cm'];
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
const EMISSIONS = ['CW', 'PHONE', 'DIGITAL'];
@@ -155,6 +159,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
const [search, setSearch] = useState('');
const [updating, setUpdating] = useState<string | null>(null);
const [err, setErr] = useState('');
// Version to stamp into a "publish for catalog" export — defaults to one past
// the selected award's current version whenever the selection changes.
const [catVer, setCatVer] = useState('1');
// The err banner doubles as a success/notice area (export path, import counts,
// "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay
@@ -212,6 +219,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
const cur = defs[sel];
const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null;
useEffect(() => { setCatVer(String((cur?.version ?? 0) + 1)); }, [cur?.code]);
// ── Award tester: run the award's rules against a real QSO and show every step.
type Rejected = { candidate: string; reason: string };
@@ -222,7 +230,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
};
type Explanation = {
code: string; in_scope: boolean; scope_error?: string; predefined: boolean;
ref_count: number; steps: Step[]; manual?: string[]; result: string[];
ref_count: number; steps: Step[]; manual?: string[]; ambiguous?: string[]; result: string[];
};
type TestRow = { qso: any; explanation: Explanation };
const [testCall, setTestCall] = useState('');
@@ -299,6 +307,19 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
if (p) setErr(t('awed.exportedTo', { path: p }));
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
// paste over internal/award/catalog/<code>.json. A new release then ships it to
// the whole team (unedited copies auto-upgrade; edited ones are offered it).
async function exportForCatalog() {
setErr('');
if (!cur) return;
const v = Math.trunc(Number(catVer));
if (!Number.isFinite(v) || v < 1) { setErr(t('awed.catalogBadVersion')); return; }
try {
const p = await ExportAwardForCatalog(cur.code.trim().toUpperCase(), v);
if (p) setErr(t('awed.catalogExportedTo', { path: p }));
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
// Import: LOOK FIRST, then ask.
//
// This used to merge by code with "imported wins", silently — import a WAPC
@@ -352,14 +373,14 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogContent className="max-w-6xl w-[95vw] max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogHeader className="px-5 py-3 border-b">
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-[220px_1fr] min-h-0 overflow-hidden">
{/* Left: award list */}
<div className="border-r flex flex-col min-h-0">
<div className="border-r flex flex-col min-w-0 min-h-0">
<div className="p-2 border-b">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
@@ -404,8 +425,14 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
</Button>
</div>
{/* Right: tabbed editor for selected award */}
<div className="flex flex-col min-h-0 overflow-hidden">
{/* Right: tabbed editor for selected award.
min-w-0 is load-bearing: this sits in a `1fr` grid track, and a 1fr
track has min-width:auto — it refuses to shrink below its content's
intrinsic width, so a wide row (the band/mode chip lists, a long
translated label) grew the track and pushed the whole editor out
past the dialog instead of scrolling inside it. Same reason on the
Tabs below: a flex item defaults to min-width:auto too. */}
<div className="flex flex-col min-w-0 min-h-0 overflow-hidden">
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
{/* A fix shipped for an award this operator has customised. We did NOT
apply it — that would destroy their work — so we offer it, and say
@@ -441,7 +468,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
{!cur ? (
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
) : (
<Tabs defaultValue="info" className="flex flex-col min-h-0 overflow-hidden">
<Tabs defaultValue="info" className="flex flex-col min-w-0 min-h-0 overflow-hidden">
<TabsList className="px-3 justify-start">
<TabsTrigger value="info">{t('awed.tabInfo')}</TabsTrigger>
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
@@ -530,10 +557,12 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
</div>
</Field2>
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]"><Checkbox checked={!!cur.exact_match} onCheckedChange={(c) => patch({ exact_match: !!c })} /> {t('awed.exactMatch')}</label>
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]" title={t('awed.oneRefHint')}><Checkbox checked={!!cur.one_ref_per_qso} onCheckedChange={(c) => patch({ one_ref_per_qso: !!c })} /> {t('awed.oneRef')}</label>
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder={t('awed.patternPlaceholder')} /></Field2>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-3 gap-3">
<Field2 label={t('awed.leadingString')}><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
<Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
<Field2 label={t('awed.prefix')}><Input className="h-8 font-mono text-xs" value={cur.prefix ?? ''} onChange={(e) => patch({ prefix: e.target.value })} placeholder={t('awed.prefixPlaceholder')} title={t('awed.prefixTitle')} /></Field2>
</div>
{/* Fallback searches: tried in order, only while nothing
@@ -596,6 +625,30 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
))}
</div>
</div>
{/* "Custom" is only meaningful once it names a field, so the
inputs appear as soon as it is ticked in either column —
and a custom source with no field confirms nothing. */}
{((cur.confirm ?? []).includes('custom') || (cur.validate ?? []).includes('custom')) && (
<div className="space-y-2 border-t border-border/60 pt-3">
<Label className="text-xs font-semibold">{t('awed.customLabel')}</Label>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">{t('awed.customField')}</Label>
<Input className="h-8 font-mono text-xs" placeholder="APP_OPSLOG_QSL_RCVD"
value={(cur as any).confirm_field ?? ''}
onChange={(e) => patch({ confirm_field: e.target.value.trim().toUpperCase() } as any)} />
</div>
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">{t('awed.customValue')}</Label>
<Input className="h-8 font-mono text-xs" placeholder="Y,V"
value={(cur as any).confirm_value ?? ''}
onChange={(e) => patch({ confirm_value: e.target.value } as any)} />
</div>
</div>
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('awed.customHint')}</p>
</div>
)}
{/* "Grant codes" and "export credit_granted" used to live here. No
ADIF export has ever written CREDIT_GRANTED, so both controls
did nothing at all. The stored values are kept (see award.Def);
@@ -684,6 +737,13 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
)}
</div>
))}
{(ex.ambiguous ?? []).length > 0 && (
<div className="px-3 py-2 text-xs">
<span className="font-semibold uppercase text-[10px] tracking-wide text-warning">{t('awed.testAmbiguous')}</span>{' '}
<span className="font-mono text-warning">{(ex.ambiguous ?? []).join(', ')}</span>{' '}
<span className="text-muted-foreground">{t('awed.testAmbiguousHint')}</span>
</div>
)}
{(ex.manual ?? []).length > 0 && (
<div className="px-3 py-2 text-xs">
<span className="font-semibold uppercase text-[10px] tracking-wide">{t('awed.testManual')}</span>{' '}
@@ -711,7 +771,11 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
</div>
</div>
<DialogFooter className="px-5 py-3 border-t !flex-row">
{/* Eight buttons on one unwrappable row fitted in English and overflowed
in French, where the labels are half again as long. Wrap instead of
widening the dialog; gap-2 replaces the base sm:space-x-2, whose
margin-left approach leaves no vertical gap once a row wraps. */}
<DialogFooter className="px-5 py-3 border-t !flex-row flex-wrap sm:space-x-0 gap-2">
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> {t('awed.resetDefaults')}</Button>
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
<Download className="size-3.5 mr-1" /> {t('awed.export')}
@@ -726,6 +790,18 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
title={t('awed.awardsFolderTip')}>
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
</Button>
{/* Publish the selected award to the catalog: stamp a version and write a
file to paste over internal/award/catalog/<code>.json, so a release
ships your change to the whole team. */}
{cur && (
<div className="flex items-center gap-1" title={t('awed.catalogPublishTip')}>
<input type="number" min={1} value={catVer} onChange={(e) => setCatVer(e.target.value)}
className="h-8 w-14 rounded border border-input bg-background px-1.5 text-xs font-mono" />
<Button variant="outline" onClick={exportForCatalog}>
<Download className="size-3.5 mr-1" /> {t('awed.catalogPublish')}
</Button>
</div>
)}
<div className="flex-1" />
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
+2 -6
View File
@@ -216,8 +216,8 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
<span className="font-mono truncate text-[11px]">{selectedRef?.subgrp || '—'}</span>
</div>
{/* Selected ref chip */}
{selectedRef ? (
{/* Selected ref chip (nothing shown until one is picked) */}
{selectedRef && (
<div className="flex items-center gap-1.5 h-6 px-2 rounded border border-success-border bg-success-muted text-success-muted-foreground text-xs min-w-0">
<span className="font-mono font-semibold shrink-0">{selectedRef.code}</span>
<span className="truncate text-[10px] text-success-muted-foreground">{selectedRef.name}</span>
@@ -225,10 +225,6 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
<X className="size-3" />
</button>
</div>
) : (
<div className="h-6 flex items-center px-2 text-[11px] text-muted-foreground italic border border-dashed border-border rounded">
{t('awrs.pickReference')}
</div>
)}
{/* Add — references are always scoped to the contacted DXCC */}
+157 -23
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react';
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs, RescanAwards } from '../../wailsjs/go/main/App';
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs, RescanAwards, GetTrackedAwards } from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
@@ -8,12 +9,15 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@
import { cn } from '@/lib/utils';
import { AwardEditor } from '@/components/AwardEditor';
import { useI18n } from '@/lib/i18n';
import { writeUiPref } from '@/lib/uiPref';
type BandCount = { band: string; worked: number; confirmed: number };
type AwardRef = {
ref: string; name?: string; group?: string; subgrp?: string;
worked: boolean; confirmed: boolean; validated: boolean;
bands: string[]; confirmed_bands: string[]; validated_bands: string[];
// Mode CLASSES — "CW" | "PHONE" | "DIGI" — not ADIF modes.
modes?: string[]; confirmed_modes?: string[];
};
type AwardResult = {
code: string; name: string; dimension: string;
@@ -66,6 +70,9 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
// Computed results are cached per award code — each award is scanned only the
// first time it's selected (or when explicitly rescanned).
// Keyed by "CODE|MODECLASS": the same award computed for CW and for all modes
// are different results, and caching them under one key showed the previous
// modes numbers after switching.
const [byCode, setByCode] = useState<Record<string, AwardResult>>({});
const [loading, setLoading] = useState(false);
const [err, setErr] = useState('');
@@ -74,6 +81,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
const [editing, setEditing] = useState(false);
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
// Mode filter, stacked ON TOP of the status one. "Worked on CW but not
// confirmed" is two questions at once, and answering only one of them is what
// sends an operator to a spreadsheet.
const [modeFilter, setModeFilter] = useState<'all' | 'CW' | 'PHONE' | 'DIGI'>('all');
const [cell, setCell] = useState<{ ref: string; band: string; name?: string } | null>(null);
const [showMissing, setShowMissing] = useState(false);
const [stats, setStats] = useState<AwardStats | null>(null);
@@ -112,13 +123,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
// Compute one award (cached). force=true bypasses the cache (Rescan).
async function compute(code: string, force = false) {
if (!code) return;
if (!force && byCode[code]) { setSelected(code); return; }
const key = `${code}|${modeFilter}`;
if (!force && byCode[key]) { setSelected(code); return; }
setSelected(code);
setLoading(true);
setErr('');
try {
const r = (await GetAward(code)) as any as AwardResult;
setByCode((m) => ({ ...m, [code]: r }));
const r = (await GetAward(code, modeFilter === 'all' ? '' : modeFilter)) as any as AwardResult;
setByCode((m) => ({ ...m, [key]: r }));
} catch (e: any) {
setErr(String(e?.message ?? e));
} finally {
@@ -126,13 +138,20 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
}
}
// Load the award list (no QSO scan), then compute only the first award.
// Load the award list (no QSO scan), then compute only the first award. The
// list is narrowed to the awards the operator follows (Settings → Awards); an
// empty follow-set means "show them all" so the tab is never blank.
async function loadList() {
try {
const defs = ((await GetAwardDefs()) ?? []) as any[];
const list: AwardListItem[] = defs
const [defs, tracked] = await Promise.all([
GetAwardDefs().then((d) => (d ?? []) as any[]),
GetTrackedAwards().then((t) => (t ?? []) as string[]).catch(() => [] as string[]),
]);
const follow = new Set(tracked);
let list: AwardListItem[] = defs
.map((d) => ({ code: d.code, name: d.name, valid: d.valid, bands: d.valid_bands ?? [], emission: d.emission ?? [] }))
.sort((a, b) => a.code.localeCompare(b.code));
if (follow.size > 0) list = list.filter((a) => follow.has(a.code));
setAwardList(list);
const first = list.find((a) => a.code === selected) ?? list[0];
if (first) compute(first.code);
@@ -141,8 +160,17 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
}
}
useEffect(() => { loadList(); }, []);
// Re-filter when the operator changes their followed awards in Settings.
useEffect(() => {
const off = EventsOn('awards:tracked-changed', () => { loadList(); });
return () => { off(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const current = byCode[selected];
const current = byCode[`${selected}|${modeFilter}`];
// Recompute when the mode class changes: the bands, counts and confirmations
// all describe the selected mode, so they cannot be filtered client-side.
useEffect(() => { if (selected) compute(selected); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [modeFilter]);
// Bands relevant to the selected award, used by BOTH the grid and the stats
// matrix so neither is padded with bands the award doesn't use. Rule: the
@@ -191,17 +219,81 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
return idx.length ? idx : all;
}, [stats, awardBands]);
// Sort order for the reference table. Reference is the default because that
// is how award lists are published and how an operator reads a paper list;
// description answers the other question — "have I worked anything in
// Alicante" — which reference order scatters across the whole table.
// Remembered through writeUiPref rather than localStorage, so the choice
// travels with a copied data/ folder like every other UI preference here.
const [refSort, setRefSort] = useState<'ref' | 'name' | 'group'>(
() => {
const v = localStorage.getItem('opslog.awardRefSort');
return v === 'name' || v === 'group' ? v : 'ref';
});
const [refSortDir, setRefSortDir] = useState<'asc' | 'desc'>(
() => (localStorage.getItem('opslog.awardRefSortDir') === 'desc' ? 'desc' : 'asc'));
const toggleRefSort = (k: 'ref' | 'name' | 'group') => {
if (k === refSort) {
const d = refSortDir === 'asc' ? 'desc' : 'asc';
setRefSortDir(d);
writeUiPref('opslog.awardRefSortDir', d);
} else {
setRefSort(k);
setRefSortDir('asc');
writeUiPref('opslog.awardRefSort', k);
writeUiPref('opslog.awardRefSortDir', 'asc');
}
};
const filteredRefs = useMemo(() => {
if (!current) return [];
const q = refSearch.trim().toUpperCase();
return (current.refs ?? []).filter((r) => {
const rows = (current.refs ?? []).filter((r) => {
if (refFilter === 'worked' && !r.worked) return false;
if (refFilter === 'notworked' && r.worked) return false;
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
if (modeFilter !== 'all' && refFilter !== 'notworked') {
// A reference never worked has no mode, so "not worked" plus a mode is
// a contradiction: the mode filter stands aside rather than emptying
// the list.
if (!(r.modes ?? []).includes(modeFilter)) return false;
// Not-confirmed must mean not confirmed ON THIS MODE. An entity worked
// on CW and confirmed on SSB is still a CW entity to chase, and the
// whole point of the filter is to find those.
if (refFilter === 'worked_notconf' && (r.confirmed_modes ?? []).includes(modeFilter)) return false;
}
if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
return true;
});
}, [current, refSearch, refFilter]);
const dir = refSortDir === 'asc' ? 1 : -1;
return rows.sort((a, b) => {
if (refSort === 'name') {
// localeCompare, not a byte comparison: these lists are Spanish, French
// and Italian place names, where "Ávila" belongs beside "Avilés" rather
// than after "Zaragoza".
const c = (a.name ?? '').localeCompare(b.name ?? '', undefined, { sensitivity: 'base' });
if (c !== 0) return c * dir;
return a.ref.localeCompare(b.ref) * dir; // stable tie-break
}
if (refSort === 'group') {
// Prefix order (XE, YV, ZL…) — how a DX operator actually thinks about
// entities. Blank prefixes sink to the bottom regardless of direction,
// so an unresolved entity never heads the list.
const ag = a.group ?? '', bg = b.group ?? '';
if (!ag !== !bg) return ag ? -1 : 1;
const c = ag.localeCompare(bg, undefined, { sensitivity: 'base' });
if (c !== 0) return c * dir;
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
}
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
});
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]);
// The group column earns its width only when the list actually carries one
// (DXCC prefixes, POTA locations); most custom lists have none. For DXCC the
// value IS the primary prefix, so it is labelled as such.
const hasGroup = useMemo(() => filteredRefs.some((r) => !!r.group), [filteredRefs]);
const groupColLabel = current && current.code.toUpperCase() === 'DXCC' ? t('awp.prefixCol') : t('awp.groupCol');
return (
<div className="flex h-full min-h-0">
@@ -240,7 +332,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
<div className="flex-1 overflow-auto">
{err && <div className="p-3 text-xs text-destructive">{err}</div>}
{awardList.map((a) => {
const r = byCode[a.code];
const r = byCode[`${a.code}|${modeFilter}`];
return (
<button
key={a.code}
@@ -325,6 +417,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
</button>
))}
</div>
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
{([['all', t('awp.filterAll')], ['CW', 'CW'], ['PHONE', t('awp.modePhone')], ['DIGI', t('awp.modeDigital')]] as const).map(([k, label]) => (
<button key={k} onClick={() => setModeFilter(k)}
className={cn('px-2 py-1', modeFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
{label}
</button>
))}
</div>
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
<button
onClick={() => setShowMissing(true)}
@@ -386,8 +486,26 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
<thead className="sticky top-0 z-10">
<tr className="bg-card">
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">{t('awp.ref')}</th>
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.description')}</th>
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">
<button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
{t('awp.ref')}
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
{hasGroup && (
<th className="text-left py-1.5 pr-3 font-medium border-b border-border w-20">
<button type="button" onClick={() => toggleRefSort('group')} className="inline-flex items-center gap-1 hover:text-foreground">
{groupColLabel}
{refSort === 'group' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
)}
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
{t('awp.description')}
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
{gridBands.map((b) => (
<th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
))}
@@ -397,9 +515,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
{filteredRefs.map((r) => (
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
<td className="sticky left-0 bg-card hover:bg-accent/20 py-1 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">
{r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''}
</td>
{hasGroup && (
<td className="py-1 pr-3 font-mono font-semibold border-b border-border/30">{r.group}</td>
)}
<td className="py-1 pr-3 text-muted-foreground truncate max-w-[360px] border-b border-border/30">{r.name}</td>
{gridBands.map((b) => {
const s = cellStatus(r, b);
return (
@@ -424,9 +543,24 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
<table className="w-full text-sm">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.ref')}</th>
<th className="py-1.5 pr-2 font-medium">{t('awp.name')}</th>
<th className="py-1.5 pr-2 font-medium w-40">{t('awp.groupCol')}</th>
<th className="py-1.5 pr-2 font-medium w-24">
<button type="button" onClick={() => toggleRefSort('ref')} className="inline-flex items-center gap-1 hover:text-foreground">
{t('awp.ref')}
{refSort === 'ref' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
<th className="py-1.5 pr-2 font-medium">
<button type="button" onClick={() => toggleRefSort('name')} className="inline-flex items-center gap-1 hover:text-foreground">
{t('awp.name')}
{refSort === 'name' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
<th className="py-1.5 pr-2 font-medium w-40">
<button type="button" onClick={() => toggleRefSort('group')} className="inline-flex items-center gap-1 hover:text-foreground">
{groupColLabel}
{refSort === 'group' && (refSortDir === 'asc' ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />)}
</button>
</th>
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
</tr>
@@ -459,7 +593,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
</div>
{cell && current && (
<CellQSOModal code={current.code} cell={cell} onClose={() => setCell(null)} />
<CellQSOModal code={current.code} cell={cell} modeClass={modeFilter === 'all' ? '' : modeFilter} onClose={() => setCell(null)} />
)}
{showMissing && current && (
<MissingQSOModal code={current.code} name={current.name} onClose={() => setShowMissing(false)} onEditQSO={onEditQSO} />
@@ -644,17 +778,17 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
}
// CellQSOModal lists the QSOs behind one award-grid cell (reference × band).
function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; onClose: () => void }) {
function CellQSOModal({ code, cell, modeClass, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; modeClass: string; onClose: () => void }) {
const { t } = useI18n();
const [qsos, setQsos] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
AwardCellQSOs(code, cell.ref, cell.band)
AwardCellQSOs(code, cell.ref, cell.band, modeClass)
.then((r) => setQsos((r ?? []) as any))
.catch(() => setQsos([]))
.finally(() => setLoading(false));
}, [code, cell.ref, cell.band]);
}, [code, cell.ref, cell.band, modeClass]);
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
+194 -32
View File
@@ -3,6 +3,8 @@ import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
// BandMap — vertical spectrum panel inspired by Log4OM.
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
@@ -24,7 +26,45 @@ interface Spot {
spotter?: string;
}
type SpotStatusEntry = { status: string; country?: string };
// The FULL status entry, not the two fields the map used to declare. The extra
// markers were already arriving in this object — the local type simply never
// mentioned them, so they could not be drawn.
type SpotStatusEntry = {
status?: string;
country?: string;
worked_call?: boolean;
// worked_slot: this exact callsign already worked on THIS band and mode.
worked_slot?: boolean;
new_county?: boolean;
new_pota?: boolean;
new_pfx?: boolean;
// muted: the status was emptied on purpose by the "no colour on worked
// stations" option, so an empty status here is a choice, not an unknown.
muted?: boolean;
};
// The extra markers are ORTHOGONAL to the entity status: a spot can be a worked
// entity AND a new park. The cluster list stacks them as separate badges instead
// of letting one replace another, so the map stacks them too — as segments of
// the pill's left accent bar, which until now only repeated the pill's own
// colour and carried no information of its own.
//
// Their colours come from lib/spotMarkers, shared with the cluster list: the
// same fact must not be violet in one panel and green in the next.
// The map shows three of the four. A new PREFIX is left to the cluster list: the
// pill is 22 px tall, and a fourth segment turns the strip into a colour code
// nobody can read at a glance. Add it here the day the strip earns more room.
const BMP_MARKERS = SPOT_MARKERS.filter((m) => m.key !== 'new_pfx');
const markersFor = (e: SpotStatusEntry | undefined) =>
activeMarkers(e).filter((m) => m.key !== 'new_pfx');
// The legend spells the markers out; the cluster list's badges are abbreviated
// ("NEW CTY") because they sit in a narrow cell, and there is room here.
const BMP_MARKER_LABEL: Record<string, string> = {
new_pota: 'bmp.legendNewPota',
new_county: 'bmp.legendNewCounty',
worked_call: 'bmp.legendWorkedCall',
};
interface Props {
band: string;
@@ -41,6 +81,10 @@ interface Props {
// globally from the band-map tab toolbar.
hideDigital?: boolean;
fitToBand?: boolean;
// keyNav enables Ctrl+↑ / Ctrl+↓ to hop to the next spot above / below the rig
// frequency (and tune to it). Only the docked Main-view band map sets this, so
// the multi-band Band Map tab (several maps) doesn't fight over the shortcut.
keyNav?: boolean;
}
const BAND_RANGES: Record<string, [number, number]> = {
@@ -60,25 +104,47 @@ const BAND_RANGES: Record<string, [number, number]> = {
'70cm': [430000, 440000],
};
// Sub-band shading: CW / digital / phone.
//
// These are IDENTITIES (which mode the segment is for), not states, so they take
// categorical hues — never the status tokens. They used to use success / info /
// warning, which collided head-on with the spot pills drawn ON TOP of them: amber
// is "new band" in this very component's legend, so the whole SSB portion read as
// a giant "new band" wash. Status colours are reserved for status.
//
// All three are drawn from the COOL end of the categorical order and laid down at
// low opacity, so the band plan stays background context while the warm spot pills
// keep the foreground to themselves.
// Checked with the palette validator in BOTH themes. Violet was the first pick
// for CW and failed on the dark steps — violet and blue land 1.9 ΔE apart for a
// protan reader there, i.e. the same colour. Magenta clears every check on both
// surfaces (worst adjacent pair 13.0 light / 15.9 dark).
const SEG_CW = 'var(--chart-7)'; // magenta
const SEG_DIGI = 'var(--chart-1)'; // blue
const SEG_PHONE = 'var(--chart-2)'; // aqua
// A band-plan wash is CONTEXT, not data: it must stay under the spot pills that
// are read on top of it.
const SEG_OPACITY = 0.13;
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
'160m': [[1800, 1838, 'fill-success-muted'], [1838, 1840, 'fill-info-muted'], [1840, 2000, 'fill-warning-muted']],
'80m': [[3500, 3580, 'fill-success-muted'], [3580, 3600, 'fill-info-muted'], [3600, 3800, 'fill-warning-muted']],
'60m': [[5350, 5450, 'fill-warning-muted']],
'40m': [[7000, 7040, 'fill-success-muted'], [7040, 7100, 'fill-info-muted'], [7100, 7200, 'fill-warning-muted']],
'30m': [[10100, 10130, 'fill-success-muted'], [10130, 10150, 'fill-info-muted']],
'20m': [[14000, 14070, 'fill-success-muted'], [14070, 14100, 'fill-info-muted'], [14100, 14350, 'fill-warning-muted']],
'17m': [[18068, 18095, 'fill-success-muted'], [18095, 18110, 'fill-info-muted'], [18110, 18168, 'fill-warning-muted']],
'15m': [[21000, 21070, 'fill-success-muted'], [21070, 21150, 'fill-info-muted'], [21150, 21450, 'fill-warning-muted']],
'12m': [[24890, 24915, 'fill-success-muted'], [24915, 24940, 'fill-info-muted'], [24940, 24990, 'fill-warning-muted']],
'10m': [[28000, 28070, 'fill-success-muted'], [28070, 28300, 'fill-info-muted'], [28300, 29700, 'fill-warning-muted']],
'6m': [[50000, 50100, 'fill-success-muted'], [50100, 50500, 'fill-warning-muted']],
'160m': [[1800, 1838, SEG_CW], [1838, 1840, SEG_DIGI], [1840, 2000, SEG_PHONE]],
'80m': [[3500, 3580, SEG_CW], [3580, 3600, SEG_DIGI], [3600, 3800, SEG_PHONE]],
'60m': [[5350, 5450, SEG_PHONE]],
'40m': [[7000, 7040, SEG_CW], [7040, 7100, SEG_DIGI], [7100, 7200, SEG_PHONE]],
'30m': [[10100, 10130, SEG_CW], [10130, 10150, SEG_DIGI]],
'20m': [[14000, 14070, SEG_CW], [14070, 14100, SEG_DIGI], [14100, 14350, SEG_PHONE]],
'17m': [[18068, 18095, SEG_CW], [18095, 18110, SEG_DIGI], [18110, 18168, SEG_PHONE]],
'15m': [[21000, 21070, SEG_CW], [21070, 21150, SEG_DIGI], [21150, 21450, SEG_PHONE]],
'12m': [[24890, 24915, SEG_CW], [24915, 24940, SEG_DIGI], [24940, 24990, SEG_PHONE]],
'10m': [[28000, 28070, SEG_CW], [28070, 28300, SEG_DIGI], [28300, 29700, SEG_PHONE]],
'6m': [[50000, 50100, SEG_CW], [50100, 50500, SEG_PHONE]],
};
// Small coloured dot + label used in the band-map legend strip.
function LegendDot({ cls, label }: { cls: string; label: string }) {
function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
return (
<span className="inline-flex items-center gap-1">
<span className={cn('size-2 rounded-full', cls)} />
<span className={cn("size-2 rounded-full", cls)} style={colour ? { background: colour } : undefined} />
{label}
</span>
);
@@ -86,17 +152,39 @@ function LegendDot({ cls, label }: { cls: string; label: string }) {
// Human-readable label for a spot status — used in the pill hover tooltip
// so the operator can see WHY a spot is coloured the way it is.
function statusLabel(s: string, t: (k: string) => string): string {
function statusLabel(s: string, t: (k: string) => string, muted = false): string {
switch (s) {
case 'new': return t('bmp.statusNew');
case 'new-band': return t('bmp.statusNewBand');
case 'new-mode': return t('bmp.statusNewMode');
case 'new-slot': return t('bmp.statusNewSlot');
case 'new-call': return t('bmp.statusNewCall');
case 'worked': return t('bmp.statusWorked');
default: return t('bmp.statusUnresolved');
// An empty status means the entity could not be resolved — EXCEPT when the
// "no colour on worked stations" option emptied it on purpose. Saying
// "entity not resolved" there was a flat contradiction of the country
// printed two words earlier in the same tooltip.
default: return muted ? t('bmp.statusMuted') : t('bmp.statusUnresolved');
}
}
function statusStyle(s: string): { pill: string; bar: string; line: string; dot: string } {
// QUIET: no status colour at all. The pill keeps the card background and the
// accent drops to a plain border grey, so the spot is present but says nothing.
const QUIET_STYLE = {
pill: 'bg-card text-muted-foreground border-border/60 hover:bg-muted/50',
bar: 'bg-muted-foreground/30',
line: 'stroke-border',
dot: 'fill-border',
};
function statusStyle(s: string, muted = false): { pill: string; bar: string; line: string; dot: string } {
// A muted spot must land on QUIET, never on the default branch below. That
// default paints bg-primary/60 — the theme's burnt orange at 60 % over a dark
// card, i.e. brown — because it was written for the rare unresolved entity and
// orange is this app's "look here" colour. With the mute option on, most of
// the map is muted, so the whole band map turned brown and the quietest spots
// shouted the loudest.
if (muted) return QUIET_STYLE;
// pill = full pill background+text+border
// bar = thick left accent inside the pill
// line = SVG leader stroke (visible on hover)
@@ -114,18 +202,14 @@ function statusStyle(s: string): { pill: string; bar: string; line: string; dot:
line: 'stroke-warning',
dot: 'fill-warning',
};
case 'new-call':
case 'new-slot': return {
pill: 'bg-caution-muted text-caution-muted-foreground border-caution-border hover:bg-caution-muted',
bar: 'bg-caution',
line: 'stroke-caution',
dot: 'fill-caution',
};
case 'worked': return {
pill: 'bg-card text-muted-foreground border-border/60 hover:bg-muted/50',
bar: 'bg-muted-foreground/30',
line: 'stroke-border',
dot: 'fill-border',
};
case 'worked': return QUIET_STYLE;
default: return {
pill: 'bg-card text-foreground border-border hover:bg-accent/40',
bar: 'bg-primary/60',
@@ -153,8 +237,24 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
// last; ties broken by closeness to the rig freq).
const MAX_VISIBLE_SPOTS = 30;
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false }: Props) {
export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, keyNav = false }: Props) {
const { t } = useI18n();
// The two display options are applied ONCE here, on the whole map, so the
// leader lines, the dots, the pills and the badges can never disagree — and so
// the map and the cluster list share the single rule set in lib/spotDisplay.
// Re-derived whenever the poll delivers a new status map, which is also when a
// just-changed option takes effect.
// Read at render time, not inside the memo: the options must be part of the
// dependencies. Keyed only on spotStatusRaw, toggling an option changed
// nothing until the next poll happened to hand over a fresh object.
const dispOpts = readSpotDisplayOptions();
const spotStatus = useMemo(() => {
if (!dispOpts.muteWorked && !dispOpts.slotHighlight) return spotStatusRaw;
const out: Record<string, SpotStatusEntry> = {};
for (const k of Object.keys(spotStatusRaw)) out[k] = applySpotDisplay(spotStatusRaw[k], dispOpts) as SpotStatusEntry;
return out;
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight]);
const range = BAND_RANGES[band];
const segments = SEGMENT_COLORS[band] ?? [];
const [zoomIdx, setZoomIdx] = useState(2); // default 32 px/kHz
@@ -222,6 +322,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
case 'new': return 0;
case 'new-band': return 1;
case 'new-slot': return 2;
case 'new-call': return 2;
case 'worked': return 4;
default: return 3;
}
@@ -332,6 +433,43 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
return () => el.removeEventListener('wheel', onWheel);
}, [range]);
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
// Only active on the docked Main-view map (keyNav) and ignored while typing.
// MUST stay ABOVE the `if (!range)` early return below: on an unknown band
// (e.g. a spurious 33 cm CAT reading from an Icom) that return skipped this
// hook, so the hook count changed between renders and React crashed with #300
// ("rendered fewer hooks than expected"). lo/hi are always defined (they fall
// back to [0,1] when there's no range), so it's safe to run here.
useEffect(() => {
if (!keyNav) return;
const onKey = (e: KeyboardEvent) => {
if (!e.ctrlKey || e.altKey || e.metaKey) return;
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
const ae = document.activeElement as HTMLElement | null;
const tag = (ae?.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select' || ae?.isContentEditable) return;
const list = spots
.filter((s) => (s.band ?? '') === band && s.freq_hz > 0)
.slice()
.sort((a, b) => a.freq_hz - b.freq_hz);
if (!list.length) return;
const cur = currentFreqHz || (lo + hi) * 500; // mid-band kHz→Hz when no rig freq
const EPS = 50; // Hz, so we don't re-pick the spot we're already sitting on
let target: Spot | undefined;
if (e.key === 'ArrowUp') {
target = list.find((s) => s.freq_hz > cur + EPS);
} else {
for (let i = list.length - 1; i >= 0; i--) { if (list[i].freq_hz < cur - EPS) { target = list[i]; break; } }
}
if (!target) return;
e.preventDefault();
onSpotClick(target);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [keyNav, spots, band, currentFreqHz, lo, hi, onSpotClick]);
if (!range) {
return (
<div className="h-full w-full flex flex-col items-center justify-center text-xs text-muted-foreground p-3 bg-muted/20">
@@ -411,10 +549,13 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
height={totalH}
preserveAspectRatio="none"
>
{segments.map(([s, e, cls], i) => {
{segments.map(([s, e, colour], i) => {
const y1 = freqToY(Math.min(e, hi));
const y2 = freqToY(Math.max(s, lo));
return <rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)} className={cls} />;
return (
<rect key={i} x={0} y={y1} width={SCALE_W} height={Math.max(0, y2 - y1)}
fill={colour} fillOpacity={SEG_OPACITY} />
);
})}
<line x1={SCALE_W - 0.5} y1={0} x2={SCALE_W - 0.5} y2={totalH} className="stroke-border" strokeWidth={1} />
</svg>
@@ -440,7 +581,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
{placed.map((p, i) => {
const k = spotStatusKey(p.spot.dx_call, p.spot.band ?? '', p.spot.comment ?? '', p.spot.freq_hz);
const st = spotStatus[k]?.status ?? '';
const style = statusStyle(st);
const style = statusStyle(st, spotStatus[k]?.muted);
const labelMidY = p.labelY + PILL_H / 2;
const bumped = Math.abs(p.freqY - labelMidY) > 0.5;
return (
@@ -488,7 +629,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
const k = spotStatusKey(p.spot.dx_call, p.spot.band ?? '', p.spot.comment ?? '', p.spot.freq_hz);
const entry = spotStatus[k];
const st = entry?.status ?? '';
const style = statusStyle(st);
const style = statusStyle(st, entry?.muted);
const mode = inferSpotMode(p.spot.comment ?? '', p.spot.freq_hz);
return (
<button
@@ -501,10 +642,21 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
'hover:translate-x-0.5 hover:shadow',
style.pill,
)}
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st, t)}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
title={`${p.spot.dx_call}${entry?.country ? ' · ' + entry.country : ''} · ${p.spot.freq_khz.toFixed(1)} kHz · ${statusLabel(st, t, entry?.muted)}${markersFor(entry).map((m) => ' · ' + t(BMP_MARKER_LABEL[m.key])).join('')}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
>
{/* Status accent strip on the left */}
<span className={cn('w-1 shrink-0', style.bar)} aria-hidden />
{/* Left accent strip. With no extra marker it repeats the status
colour, exactly as before; otherwise it splits into one
segment per marker, so "worked entity + new park" shows both
instead of one hiding the other. */}
{(() => {
const marks = markersFor(entry);
if (marks.length === 0) return <span className={cn('w-1 shrink-0', style.bar)} aria-hidden />;
return (
<span className="w-1 shrink-0 flex flex-col" aria-hidden>
{marks.map((m) => <span key={m.key} className="flex-1" style={{ background: m.colour }} />)}
</span>
);
})()}
<span className="flex items-center gap-1.5 px-2 font-mono text-[11px] font-bold leading-none">
<span>{p.spot.dx_call}</span>
{mode && (
@@ -523,7 +675,17 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
<LegendDot cls="bg-caution" label={t('bmp.legendNewSlot')} />
<LegendDot cls="bg-muted-foreground/30" label={t('bmp.legendWorked')} />
<LegendDot cls="bg-muted-foreground/30" label={t("bmp.legendWorked")} />
{/* The stacked markers, straight from the shared table so the legend
cannot drift from what the pills actually draw. */}
{BMP_MARKERS.map((m) => (
<LegendDot key={m.key} colour={m.colour} label={t(BMP_MARKER_LABEL[m.key])} />
))}
{/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
<span className="mx-0.5 opacity-40">|</span>
<LegendDot colour={SEG_CW} label={t("bmp.legendCW")} />
<LegendDot colour={SEG_DIGI} label={t("bmp.legendData")} />
<LegendDot colour={SEG_PHONE} label={t("bmp.legendPhone")} />
</div>
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
{t('bmp.footerHint')}
+205 -11
View File
@@ -1,7 +1,9 @@
import { useMemo } from 'react';
import { Star, Radio } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Star, Radio, Sunrise, Sunset, X, Loader2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { sunTimes } from '@/lib/sun';
import { BandSlotQSOs } from '../../wailsjs/go/main/App';
import type { WorkedBeforeView } from '@/types';
type WorkedBefore = WorkedBeforeView;
@@ -13,6 +15,16 @@ interface Props {
currentMode: string;
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
// DX station coordinates, for its sunrise/sunset. Optional: many spots resolve
// to an entity with no position at all, and the block simply does not appear.
lat?: number;
lon?: number;
// Set when the matrix is showing a QSO picked in the log grid rather than the
// entry form — labelled so the two can never be confused.
forCall?: string;
// Open the QSO editor for a contact — makes callsigns in the cell drill-down
// clickable. Threaded down from App.openEdit.
onEditQso?: (id: number) => void;
}
// Compact column label for a band tag: keep the classic V/U for 2m/70cm,
@@ -84,7 +96,9 @@ function cellTitle(band: string, cls: string, status: string, current: boolean):
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
}
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true }: Props) {
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
// Cell drill-down: which band+class the operator clicked, or null.
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
// Columns from the operator's configured bands (so the matrix shows only the
// bands they actually use), falling back to the built-in default set.
const cols = useMemo(
@@ -94,6 +108,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
const dxcc = wb?.dxcc ?? 0;
const dxccName = wb?.dxcc_name ?? '';
const dxccCount = wb?.dxcc_count ?? 0;
const mwRank = wb?.mw_rank ?? 0; // ClubLog Most Wanted rank (1 = most wanted; 0 = off/unknown)
const callCount = wb?.count ?? 0; // QSOs with this exact callsign
const hasDxcc = dxcc > 0;
const newOne = hasDxcc && dxccCount === 0;
@@ -109,14 +124,21 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
// "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
// worked on — not just the operator's configured column list).
// Newness uses the ACTUAL mode (FT8 / FT4 / RTTY…), not the PH/CW/DIG class:
// DIG is a group, so FT4 after FT8 is genuinely a new mode. dxcc_band_modes
// lists every real (band, mode) the entity was worked on.
// By default newness uses the ACTUAL mode (FT8 / FT4 / RTTY…): DIG is a
// group, so FT4 after FT8 is genuinely a new mode. The operator can opt into
// 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 curMode = (currentMode || '').toUpperCase().trim();
const curMode = normMode(currentMode);
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 slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && (bm.mode || '').toUpperCase() === curMode);
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 && normMode(bm.mode) === curMode);
// Mutually-exclusive badges, shown only when the entity is worked but this
// exact band+mode is NOT yet:
// New Band & Mode = both the band AND the mode are new for this entity.
@@ -129,6 +151,50 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
const newMode = slotNew && bandWorked && !modeWorked;
const newSlot = slotNew && bandWorked && modeWorked;
// ClubLog Most Wanted rank pill (shown next to the entity name when the feature
// is on). Hotter colour the more wanted the entity is.
const mwBadge = mwRank > 0 ? (
<Badge
title={`ClubLog Most Wanted #${mwRank}`}
className={cn('px-1.5 py-0 text-[10px] font-bold shrink-0',
mwRank <= 100 ? 'bg-danger text-danger-foreground'
: mwRank <= 250 ? 'bg-warning text-warning-foreground'
: 'bg-muted text-muted-foreground')}
>
MW #{mwRank}
</Badge>
) : null;
// Sunrise / sunset AT THE DX, in UTC. A glance tells you whether the path is
// about to open or close on their side, which is the reason to look at a DX
// station's grey line at all. Recomputed only when the position changes.
const sun = useMemo(
() => (lat == null || lon == null ? null : sunTimes(new Date(), lat, lon)),
[lat, lon],
);
const sunBlock = sun ? (
<div className="ml-auto flex items-center gap-3 text-xs shrink-0"
title="Sunrise / sunset at the DX station (UTC)">
{sun.polarDay ? (
<span className="font-semibold text-warning">midnight sun</span>
) : sun.polarNight ? (
<span className="font-semibold text-info">polar night</span>
) : (
<>
<span className="flex items-center gap-1">
<Sunrise className="size-3.5 text-warning" />
<span className="font-mono tabular-nums">{sun.rise || '—'}</span>
</span>
<span className="flex items-center gap-1">
<Sunset className="size-3.5 text-info" />
<span className="font-mono tabular-nums">{sun.set || '—'}</span>
</span>
<span className="text-muted-foreground">UTC</span>
</>
)}
</div>
) : null;
return (
<section
className={cn(
@@ -136,7 +202,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg',
)}
>
<div className="flex items-center gap-2 min-w-[220px]">
<div className="flex items-center gap-2 min-w-[220px] flex-1">
{newOne ? (
<>
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
@@ -147,12 +213,22 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
<strong className="text-warning-muted-foreground font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
{' '}· never worked this entity
</span>
{mwBadge}
</>
) : hasDxcc ? (
<>
{/* Says WHOSE stats these are when they come from a row picked in
the log rather than from what's being typed. */}
{forCall && (
<Badge variant="outline" className="px-2 py-0.5 text-[10px] font-mono shrink-0"
title="Stats for the QSO selected in the log">
{forCall}
</Badge>
)}
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
{dxccName || `DXCC #${dxcc}`}
</Badge>
{mwBadge}
<span className="text-xs text-muted-foreground">
<strong className="text-foreground font-semibold">{dxccCount}</strong>{' '}
QSO{dxccCount > 1 ? 's' : ''} with this entity
@@ -183,6 +259,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
Type a callsign to see entity stats
</span>
)}
{sunBlock}
</div>
<div className="flex flex-col gap-2">
@@ -222,10 +299,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
return (
<td
key={b.tag}
title={cellTitle(b.tag, cls, st, isCurrent)}
title={cellTitle(b.tag, cls, st, isCurrent) + (st ? ' — click to list the QSOs' : '')}
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
className={cn(
'w-[28px] h-[24px] rounded transition-colors p-0',
st ? STATUS_CLASSES[st] : 'bg-mx-none',
// Only a filled cell has anything to show — an empty one
// stays inert rather than opening a "no QSOs" dialog.
st && 'cursor-pointer hover:brightness-110',
isCurrent && 'ring-2 ring-warning ring-inset',
)}
/>
@@ -253,6 +334,119 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
))}
</div>
</div>
{slot && (
<SlotQSOModal
call={wb?.callsign ?? ''}
dxcc={dxcc}
entity={dxccName}
band={slot.band}
cls={slot.cls}
onClose={() => setSlot(null)}
onEdit={onEditQso}
/>
)}
</section>
);
}
// ── Cell drill-down ──────────────────────────────────────────────────────────
// The contacts behind one band+class cell: this exact callsign AND anyone else
// in the entity, because that is the pair of facts the cell's colour encodes.
// The call's own QSOs are marked so the two never blur together.
function SlotQSOModal({ call, dxcc, entity, band, cls, onClose, onEdit }: {
call: string; dxcc: number; entity: string; band: string; cls: string; onClose: () => void; onEdit?: (id: number) => void;
}) {
const [rows, setRows] = useState<any[] | null>(null);
const [err, setErr] = useState('');
useEffect(() => {
let dead = false;
BandSlotQSOs(call, dxcc, band, cls)
.then((r: any) => { if (!dead) setRows((r ?? []) as any[]); })
.catch((e: any) => { if (!dead) { setErr(String(e?.message ?? e)); setRows([]); } });
return () => { dead = true; };
}, [call, dxcc, band, cls]);
useEffect(() => {
// Capture phase: the app's global ESC handler resets the entry form, and
// closing a dialog must not also wipe what the operator was typing.
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') { e.stopImmediatePropagation(); e.preventDefault(); onClose(); }
}
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-[2px] p-4" onClick={onClose}>
<div className="bg-card border border-border rounded-xl shadow-2xl w-[760px] max-w-full max-h-[72vh] flex flex-col overflow-hidden ring-1 ring-black/5"
onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2.5 px-4 py-2.5 border-b border-border bg-muted/30">
<span className="inline-flex items-center justify-center h-6 min-w-6 px-1.5 rounded-md bg-primary/15 text-primary text-[11px] font-bold shrink-0 tabular-nums">{band}</span>
<div className="min-w-0">
<div className="font-semibold text-sm leading-tight truncate">
{cls}{entity && <span className="text-muted-foreground font-normal"> · {entity}</span>}
</div>
{onEdit && <div className="text-[10px] text-muted-foreground leading-tight">Click a callsign to edit the QSO</div>}
</div>
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">{rows ? `${rows.length} QSO${rows.length > 1 ? 's' : ''}` : ''}</span>
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground shrink-0 rounded p-0.5 hover:bg-muted transition-colors">
<X className="size-4" />
</button>
</div>
<div className="flex-1 overflow-auto">
{err && <p className="p-3 text-xs text-destructive">{err}</p>}
{!rows ? (
<p className="p-4 text-xs text-muted-foreground flex items-center gap-2">
<Loader2 className="size-3.5 animate-spin" /> Loading
</p>
) : rows.length === 0 ? (
<p className="p-4 text-xs text-muted-foreground italic">No QSOs.</p>
) : (
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 z-10 bg-muted/80 backdrop-blur text-[10px] uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left font-medium px-3 py-1.5">Date UTC</th>
<th className="text-left font-medium px-3 py-1.5">Callsign</th>
<th className="text-left font-medium px-3 py-1.5">Mode</th>
<th className="text-left font-medium px-3 py-1.5">Freq</th>
<th className="text-left font-medium px-3 py-1.5">Name</th>
<th className="text-center font-medium px-3 py-1.5">Cfm</th>
</tr>
</thead>
<tbody>
{rows.map((q, i) => {
const cfm = q.lotw_rcvd === 'Y' || q.eqsl_rcvd === 'Y' || q.qsl_rcvd === 'Y';
const mine = q.callsign === call;
return (
<tr key={q.id ?? i} className="border-t border-border/40 even:bg-muted/[0.06] hover:bg-primary/[0.06] transition-colors">
<td className="px-3 py-1.5 tabular-nums text-muted-foreground whitespace-nowrap">
{String(q.qso_date ?? '').slice(0, 16).replace('T', ' ')}
</td>
<td className="px-3 py-1.5">
{onEdit ? (
<button type="button" onClick={() => { onEdit(q.id as number); onClose(); }} title="Edit this QSO"
className={cn('font-mono cursor-pointer text-left hover:underline underline-offset-2', mine ? 'font-bold text-primary' : 'text-foreground hover:text-primary')}>
{q.callsign}
</button>
) : (
<span className={cn('font-mono', mine && 'font-bold text-primary')}>{q.callsign}</span>
)}
</td>
<td className="px-3 py-1.5">{q.mode}</td>
<td className="px-3 py-1.5 tabular-nums text-muted-foreground">{q.freq_hz ? (q.freq_hz / 1e6).toFixed(3) : ''}</td>
<td className="px-3 py-1.5 text-muted-foreground truncate max-w-[180px]">{q.name}</td>
<td className="px-3 py-1.5 text-center">{cfm ? <span className="text-success"></span> : <span className="text-muted-foreground/30">·</span>}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
</div>
);
}
+59 -14
View File
@@ -12,7 +12,7 @@ import {
} from '@/components/ui/select';
import { useI18n } from '@/lib/i18n';
type FieldKind = 'status' | 'text';
type FieldKind = 'status' | 'text' | 'freq' | 'date';
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.
@@ -21,19 +21,36 @@ type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upp
// label holds an i18n key (resolved with t() at render time).
const FIELDS: FieldDef[] = [
// QSL / upload status
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_sent', label: 'bulk.fQslSent', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'qrz_upload', label: 'bulk.fQrzUpload', group: 'QSL / upload', kind: 'status' },
{ id: 'clublog_upload', label: 'bulk.fClublogUpload', group: 'QSL / upload', kind: 'status' },
{ id: 'hrdlog_upload', label: 'bulk.fHrdlogUpload', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
// One channel at a time, each with its status and its date, in the same order
// as the QSL Info tab. "Upload" was the wrong word for half of them — a paper
// QSL is not uploaded — so every entry now reads "<channel> sent/received
// status" or "… date", which is also what the ADIF field is called.
{ id: 'qsl_sent', label: 'bulk.fQslSent', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_sent_date', label: 'bulk.fQslSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_rcvd_date', label: 'bulk.fQslRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
{ id: 'lotw_sent_date', label: 'bulk.fLotwSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'lotw_rcvd_date', label: 'bulk.fLotwRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
{ id: 'eqsl_sent_date', label: 'bulk.fEqslSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'eqsl_rcvd_date', label: 'bulk.fEqslRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'qrz_sent', label: 'bulk.fQrzSent', group: 'QSL / upload', kind: 'status' },
{ id: 'qrz_sent_date', label: 'bulk.fQrzSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'qrz_rcvd', label: 'bulk.fQrzRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'qrz_rcvd_date', label: 'bulk.fQrzRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'clublog_sent', label: 'bulk.fClublogSent', group: 'QSL / upload', kind: 'status' },
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'hrdlog_sent', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
// My station / operator
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
// No promoted column: written into extras_json (see qso.bulkEditableExtras).
{ id: 'owner_callsign', label: 'bulk.fOwnerCallsign', group: 'My station', kind: 'text', upper: true },
{ id: 'my_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true },
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
@@ -65,6 +82,7 @@ const FIELDS: FieldDef[] = [
{ id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
// Contacted station (location / activation refs / SIG)
{ id: 'grid', label: 'bulk.fGrid', group: 'Contacted station', kind: 'text', upper: true },
{ id: 'state', label: 'bulk.fState', group: 'Contacted station', kind: 'text', upper: true },
{ id: 'cnty', label: 'bulk.fCnty', group: 'Contacted station', kind: 'text' },
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
@@ -74,6 +92,9 @@ const FIELDS: FieldDef[] = [
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
// 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: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' },
{ id: 'rig', label: 'bulk.fRig', group: 'Misc', kind: 'text' },
@@ -112,7 +133,7 @@ type Props = {
// so they become eligible for upload.
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
const { t } = useI18n();
const [field, setField] = useState('hrdlog_upload');
const [field, setField] = useState('qsl_sent');
const [statusValue, setStatusValue] = useState('R');
const [textValue, setTextValue] = useState('');
const [busy, setBusy] = useState(false);
@@ -176,11 +197,35 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{t(v.label)}</SelectItem>)}
</SelectContent>
</Select>
) : def.kind === 'date' ? (
// ADIF stores YYYYMMDD; the picker speaks YYYY-MM-DD. Clearing it
// writes an empty value — which is how a wrong date is removed
// from a batch, the same meaning as leaving a text field blank.
<div className="flex gap-1">
<Input
type="date"
className="h-8 text-xs font-mono"
value={/^\d{8}$/.test(textValue) ? `${textValue.slice(0, 4)}-${textValue.slice(4, 6)}-${textValue.slice(6, 8)}` : ''}
onChange={(e) => setTextValue(e.target.value ? e.target.value.replace(/-/g, '') : '')}
/>
<Button
type="button" variant="outline" size="sm" className="h-8 shrink-0 px-2"
title={t('bulk.todayUtc')}
onClick={() => {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
// UTC: the whole log is UTC, and near midnight the local day
// is the wrong one.
setTextValue(`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`);
}}
>{t('bulk.today')}</Button>
</div>
) : (
<Input
className="h-8 text-xs"
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)}
/>
)}
@@ -188,7 +233,7 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
<div className="text-[11px] text-muted-foreground">
{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>
{error && <div className="text-xs text-danger">{error}</div>}
</div>
+207 -81
View File
@@ -12,7 +12,9 @@ import {
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
import { markerColour } from '@/lib/spotMarkers';
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
import { useI18n } from '@/lib/i18n';
type TFn = (key: string, vars?: Record<string, string | number>) => string;
@@ -51,8 +53,17 @@ export type SpotStatusEntry = {
country?: string;
continent?: string;
worked_call?: boolean;
// worked_slot: this exact callsign already worked on THIS band and mode.
// Always slot-scoped, unlike worked_call which follows the "same slot" option.
worked_slot?: boolean;
new_county?: boolean;
// The resolved US county and state, so the grid can SHOW them and not merely
// flag them. Filled from the offline ULS store, US callsigns only.
county?: string;
state?: string;
new_pota?: boolean;
new_pfx?: boolean;
pfx?: string;
};
type Props = {
@@ -94,61 +105,108 @@ type ColEntry = ColDef<ClusterSpot> & { group: string; label: string; defaultVis
// statusFor resolves the precomputed spot status (new / new-band / new-slot /
// worked-call) for an ag-Grid cell's row.
function statusFor(p: any): SpotStatusEntry | undefined {
return p?.context?.spotStatus?.[
const s = p?.context?.spotStatus?.[
spotStatusKey(p.data?.dx_call, p.data?.band ?? '', p.data?.comment ?? '', p.data?.freq_hz)
];
// One chokepoint: the colour, the badges and the Status text all read through
// here, so the two display options are applied once rather than in each
// renderer — and the band map applies the very same function.
return applySpotDisplay(s, readSpotDisplayOptions());
}
// statusBadge maps a resolved spot status to a short labelled badge for the
// Status column, using the same colours as the per-cell fills (NEW DXCC =
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
// there's nothing notable to show.
type Badge = { text: string; fg: string; bg: string; bd: string };
// Spot status is shown on the CELL that carries the fact — filled background for
// a novelty, tinted text otherwise — never by a pill or a badge.
//
// The pills were dropped and stay dropped: a rounded box has its own height and
// padding, so it sat off the row's baseline and the callsign inside it no longer
// lined up with the plain callsigns above and below. A whole column of them read
// as chrome rather than as data. Same reasoning — and the same semantic tokens —
// as the Y/N/R QSL columns in lib/qslStatus.ts.
//
// Which cell is marked IS the message:
//
// filled call → new DXCC filled band → new band filled mode → new mode
// filled pfx → new prefix filled POTA → new park filled county → new county
// blue call → already worked (not a novelty, so text only)
const NEW = 'var(--warning)'; // yellow: something here is new
const WKD = 'var(--info)'; // blue: this callsign is already in the log
// tok builds a badge from a semantic status token (success/warning/caution/
// danger/info/neutral) so the colours adapt to every theme via CSS variables,
// instead of the hard-coded light-theme pastels that looked wrong in dark mode.
function tok(name: string, text: string): Badge {
if (name === 'neutral') return { text, fg: 'var(--muted-foreground)', bg: 'var(--muted)', bd: 'var(--border)' };
return { text, fg: `var(--${name}-muted-foreground)`, bg: `var(--${name}-muted)`, bd: `var(--${name}-border)` };
}
// FILLING the cell that carries the fact, rather than only tinting its text.
//
// This is not a return to the pills that were dropped. A pill is a box INSIDE
// the cell: it has its own height and padding, so it sat off the row baseline
// and the callsign inside it stopped lining up with the plain callsigns above
// and below. A cell background has no geometry of its own — the text does not
// move by a single pixel — and it reads from across the room, which a tinted
// glyph does not.
//
// Which cell is filled is still the whole message: filled Band means new band,
// filled Pfx means new prefix, filled County means new county.
//
// The colours come from the same places they already came from — the semantic
// status tokens and lib/spotMarkers — so a fact keeps one colour across the
// grid, the band map and the filter chips. 22 % is the wash that survived both
// themes: enough to see at a glance, not enough to fight the text on it.
const fillStyle = (colour: string) => ({
background: `color-mix(in srgb, ${colour} 22%, transparent)`,
color: colour,
fontWeight: 700,
});
// cellChip wraps a Band/Mode cell value in a small rounded pill (same look as the
// Status badges) when the slot is notable, instead of flooding the whole cell with
// a heavy muted fill that turned into an ugly olive block on dark themes. `name` is
// null → plain text, no pill.
function cellChip(value: any, name: string | null): any {
// cellText renders a cell value in an optional colour. An empty value keeps the
// muted dash the grid used before, so blank cells still read as "nothing here"
// rather than as a gap.
function cellText(value: any, color: string | null): any {
const txt = value === undefined || value === null || value === '' ? '' : String(value);
if (!name) return txt || <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
// Inherit the column's font size (no fixed 9px / height) so a pill around a
// callsign stays the same size as the plain callsigns next to it — just tinted
// and rounded, not shrunk.
return (
<span style={{
display: 'inline-flex', alignItems: 'center', lineHeight: 1.1,
backgroundColor: `var(--${name}-muted)`, color: `var(--${name}-muted-foreground)`,
border: `1px solid var(--${name}-border)`, fontWeight: 700,
padding: '1px 6px', borderRadius: 999, whiteSpace: 'nowrap',
}}>{txt}</span>
);
if (!txt) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
return <span style={color ? { color, fontWeight: 700 } : undefined}>{txt}</span>;
}
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): Badge | null {
// statusColor is the colour for a resolved status in the Status column. County,
// POTA and prefix keep their own tokens: they are orthogonal to the band/mode/
// DXCC story and an operator filters on them separately.
function statusColor(s: SpotStatusEntry | undefined): string | null {
switch (s?.status) {
case 'new': return tok('danger', t('clg2.newDxcc'));
case 'new-band': return tok('warning', t('clg2.newBand'));
case 'new-mode': return tok('caution', t('clg2.newMode'));
case 'new-slot': return tok('caution', t('clg2.newSlot'));
default: return s?.worked_call ? tok('neutral', t('clg2.wkdCall')) : null;
case 'new':
case 'new-band':
case 'new-mode':
case 'new-slot':
case 'new-call':
return NEW;
default:
return s?.worked_call ? WKD : null;
}
}
// isDull reports a spot that "represents nothing" under the current worked/slot
// rules: the entity/band/mode is already worked (status resolved, not new in any
// dimension), the callsign itself isn't in the log, and there's no POTA / county
// / prefix novelty. Such rows are dimmed so the eye skips them. Because the
// status obeys the DX-cluster "same slot" option and the digital-mode grouping,
// what counts as dull follows those settings automatically. Unresolved statuses
// (still loading, or entity unknown) are NOT dimmed — that would flicker.
function isDull(s: SpotStatusEntry | undefined): boolean {
if (!s || !s.status) return false;
if (s.status === 'new' || s.status === 'new-band' || s.status === 'new-mode' || s.status === 'new-slot' || s.status === 'new-call') return false;
return !(s.worked_call || s.new_pota || s.new_county || s.new_pfx);
}
const makeColCatalog = (t: TFn): ColEntry[] => [
{
group: 'Spot', label: t('clg2.c.time'), colId: 'time',
headerName: t('clg2.c.time'), field: 'time_utc' as any, width: 80, cellClass: 'font-mono',
defaultVisible: true,
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)' },
},
{
@@ -156,15 +214,13 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
defaultVisible: true,
cellClass: 'font-mono',
// NEW DXCC → a danger pill around the call, consistent with the NEW BAND /
// NEW MODE pills (same look, same tokens). Worked-call stays blue bold text;
// a plain spot inherits the theme's normal text colour so callsigns blend in
// with the rest of the row instead of always shouting a colour.
// NEW DXCC fills the call cell. Already worked only tints the text blue:
// 'worked' is not a novelty, and filling it would wash most of the rows.
cellStyle: (p: any) => (statusFor(p)?.status === 'new' ? fillStyle('var(--danger)') : null) as any,
cellRenderer: (p: any) => {
const s = statusFor(p);
if (s?.status === 'new') return cellChip(p.value, 'danger');
const color = s?.worked_call ? 'var(--info)' : undefined;
return <span style={{ color, fontWeight: 700 }}>{p.value ?? ''}</span>;
const color = s?.status === 'new' ? null : s?.worked_call ? WKD : null;
return <span style={{ color: color ?? undefined, fontWeight: 700 }}>{p.value ?? ''}</span>;
},
tooltipValueGetter: (p: any) => {
const s = statusFor(p);
@@ -175,9 +231,10 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
headerName: t('clg2.c.status'), width: 120, sortable: true,
defaultVisible: true,
// Spells out the slot status as a text badge so NEW SLOT (and the others)
// is obvious at the row level, not just a single coloured cell. NEW COUNTY
// and NEW POTA are orthogonal, so they stack as extra badges.
// Spells the status out in words so NEW SLOT (and the others) is obvious at
// the row level, not just a single coloured cell NEW SLOT in particular
// colours no cell at all, since neither the band nor the mode is new on its
// own. NEW COUNTY and NEW POTA are orthogonal, so they stack after it.
valueGetter: (p: any) => {
const s = statusFor(p);
const parts: string[] = [];
@@ -185,28 +242,38 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
else if (s?.status === 'new-band') parts.push(t('clg2.newBand'));
else if (s?.status === 'new-mode') parts.push(t('clg2.newMode'));
else if (s?.status === 'new-slot') parts.push(t('clg2.newSlot'));
else if (s?.status === 'new-call') parts.push(t('clg2.newCall'));
else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
if (s?.new_county) parts.push(t('clg2.newCounty'));
if (s?.new_pota) parts.push(t('clg2.newPota'));
if (s?.new_pfx) parts.push(t('clg2.newPfx'));
return parts.join(' ');
},
cellRenderer: (p: any) => {
const s = statusFor(p);
const badges: Badge[] = [];
const b = statusBadge(t, s);
if (b) badges.push(b);
if (s?.new_county) badges.push(tok('info', t('clg2.newCounty')));
if (s?.new_pota) badges.push(tok('success', t('clg2.newPota')));
if (badges.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
const parts: { text: string; color: string }[] = [];
const main = statusColor(s);
if (main) {
const label = s?.status === 'new' ? t('clg2.newDxcc')
: s?.status === 'new-band' ? t('clg2.newBand')
: s?.status === 'new-mode' ? t('clg2.newMode')
: s?.status === 'new-slot' ? t('clg2.newSlot')
: s?.status === 'new-call' ? t('clg2.newCall')
: t('clg2.wkdCall');
parts.push({ text: label, color: main });
}
// Colours from lib/spotMarkers — shared with the band map so a marker is
// never one colour here and another there.
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county') });
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: markerColour('new_pota') });
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx') });
if (parts.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
return (
<span style={{ display: 'flex', height: '100%', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
{badges.map((bd, i) => (
<span key={i} style={{
display: 'inline-flex', alignItems: 'center', height: 15, lineHeight: 1,
backgroundColor: bd.bg, color: bd.fg, border: `1px solid ${bd.bd}`,
fontWeight: 700, fontSize: 9,
padding: '0 5px', borderRadius: 999, letterSpacing: 0.3, whiteSpace: 'nowrap',
}}>{bd.text}</span>
<span style={{ whiteSpace: 'nowrap' }}>
{parts.map((pt, i) => (
<span key={i} style={{ color: pt.color, fontWeight: 700 }}>
{i > 0 ? ' · ' : ''}{pt.text}
</span>
))}
</span>
);
@@ -216,6 +283,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
if (s?.status === 'new') return t('clg2.tipNewDxcc', { country: s?.country ?? '' });
if (s?.status === 'new-band') return t('clg2.tipNewBand');
if (s?.status === 'new-slot') return t('clg2.tipNewSlotBand');
if (s?.status === 'new-call') return t('clg2.tipNewCall');
if (s?.worked_call) return t('clg2.tipWorkedCall');
return undefined;
},
@@ -224,7 +292,9 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
defaultVisible: true,
cellStyle: { color: 'var(--success)' },
cellStyle: (p: any) => (statusFor(p)?.new_pota
? fillStyle(markerColour('new_pota'))
: { color: 'var(--success)' }) as any,
tooltipValueGetter: (p: any) => (p.data?.pota_name ? t('clg2.tipPota', { name: p.data.pota_name }) : undefined),
},
{
@@ -239,8 +309,9 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
defaultVisible: true,
cellClass: 'font-mono',
// NEW BAND for this entity → small warning pill around the band text.
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-band' ? 'warning' : null),
// NEW BAND for this entity → the band cell is filled.
cellStyle: (p: any) => (statusFor(p)?.status === 'new-band' ? fillStyle(NEW) : null) as any,
cellRenderer: (p: any) => cellText(p.value, null),
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
},
{
@@ -253,7 +324,8 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
// for the entity. NEW SLOT means band AND mode were each worked before (just
// not together), so highlighting the mode cell would wrongly imply "CW is new";
// that case is signalled by the Status badge alone.
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-mode' ? 'caution' : null),
cellStyle: (p: any) => (statusFor(p)?.status === 'new-mode' ? fillStyle('var(--caution)') : null) as any,
cellRenderer: (p: any) => cellText(p.value, null),
tooltipValueGetter: (p: any) => {
const st = statusFor(p)?.status;
if (st === 'new-mode') return t('clg2.tipNewMode');
@@ -265,7 +337,25 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
group: 'Spot', label: t('clg2.c.pfx'), colId: 'pfx',
headerName: t('clg2.c.pfx'), width: 60, cellClass: 'font-mono',
valueGetter: (p: any) => fmtPfx(p.data?.dx_call ?? ''),
cellStyle: { color: 'var(--muted-foreground)' },
// NEW PFX fills the prefix cell, in the prefix marker's own colour.
cellStyle: (p: any) => (statusFor(p)?.new_pfx
? fillStyle(markerColour('new_pfx'))
: { color: 'var(--muted-foreground)' }) as any,
tooltipValueGetter: (p: any) => (statusFor(p)?.new_pfx ? t('clg2.tipNewPfx') : undefined),
},
{
group: 'Geo', label: t('clg2.c.county'), colId: 'county',
headerName: t('clg2.c.county'), width: 130, cellClass: 'font-mono',
// Not on the spot: resolved per callsign from the offline ULS store, so it
// stays empty for non-US calls and until that database is downloaded.
valueGetter: (p: any) => {
const s = statusFor(p);
if (!s?.county) return '';
return s.state ? s.county + ', ' + s.state : s.county;
},
cellStyle: (p: any) => (statusFor(p)?.new_county ? fillStyle(markerColour('new_county')) : null) as any,
cellRenderer: (p: any) => cellText(p.value, null),
tooltipValueGetter: (p: any) => (statusFor(p)?.new_county ? t('clg2.tipNewCounty') : undefined),
},
{
group: 'Geo', label: t('clg2.c.cqz'), colId: 'cqz',
@@ -361,13 +451,36 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
// Localized column catalog — rebuilt when the language changes.
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
}), [COL_CATALOG]);
// A rebuild makes AG Grid re-apply every colDef hide/width DEFAULT and fire the
// matching column events. Without this guard those events were persisted, so a
// single language change overwrote the saved cluster layout — in the cache AND
// in the database, with nothing to restore from.
const restoringRef = useRef(true);
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => {
restoringRef.current = true;
return COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
});
}, [COL_CATALOG]);
// Re-apply the saved state after every rebuild, then re-enable saving.
useEffect(() => {
const api = gridRef.current?.api;
const local = loadLocal(COL_STATE_KEY);
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
const tm = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(tm);
}, [columnDefs]);
const defaultColDef = useMemo<ColDef>(() => ({
sortable: true, resizable: true, filter: true, suppressMovable: false,
// Dim "represents nothing" spots at the CELL level, via a class that
// refreshCells re-applies. Doing it with a row STYLE needed redrawRows() on
// every status update, which re-rendered the whole grid continuously and
// pegged the CPU on a busy cluster (the 0.23.6 slowdown).
cellClassRules: { 'opacity-40': (p: any) => isDull(statusFor(p)) },
}), []);
// Pass spotStatus through AG Grid's context so cell renderers can look up
@@ -377,24 +490,37 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
// Spot statuses arrive asynchronously (~after the rows render). The Call/Band/
// Mode cellStyles depend on them but their cell VALUE doesn't change, so ag-grid
// won't re-render those cells on its own — force a refresh so e.g. a worked call
// turns blue once its status loads.
// Mode cellStyles and the dimmed "represents nothing" class depend on them but
// the cell VALUE doesn't change, so ag-grid won't re-render on its own — force a
// refresh so e.g. a worked call turns blue once its status loads.
//
// THROTTLED: under an RBN firehose spotStatus updates ~20×/second, and firing a
// full refreshCells that often is pure churn on a slow PC. Coalesce the bursts
// into one refresh every 200 ms (still imperceptible) instead of one per update.
const refreshPending = useRef<number | undefined>(undefined);
useEffect(() => {
gridRef.current?.api?.refreshCells({ force: true });
if (refreshPending.current !== undefined) return; // a refresh is already queued
refreshPending.current = window.setTimeout(() => {
refreshPending.current = undefined;
gridRef.current?.api?.refreshCells({ force: true });
}, 200);
}, [spotStatus]);
useEffect(() => () => { if (refreshPending.current !== undefined) window.clearTimeout(refreshPending.current); }, []);
function onGridReady(e: GridReadyEvent) {
// Restore AFTER the profile scope is known — this grid has no key= remount to
// save it from reading the wrong (unscoped) cache key at first paint.
async function onGridReady(e: GridReadyEvent) {
await whenGridPrefsReady();
const local = loadLocal(COL_STATE_KEY);
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
loadRemote(COL_STATE_KEY).then((remote) => {
if (remote && !local) {
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
seedLocal(COL_STATE_KEY, remote);
}
});
const remote = await loadRemote(COL_STATE_KEY);
if (remote && !local) {
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
seedLocal(COL_STATE_KEY, remote);
}
}
const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState();
if (state) saveState(COL_STATE_KEY, state);
}, []);
+172 -59
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
import { ComputeQSOAwardRefs, IsNewUSCounty } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
@@ -53,6 +53,14 @@ interface Props {
prefix: string;
operatorGrid: string; // station.my_grid — origin for bearing/distance
remoteGrid: string; // entry-strip Grid value — destination
// Entry-strip text fields. Live award detection matches on these too — a DARC
// DOK keys off QTH, not the address — and sending only the address meant such
// an award stayed silent until the QSO was logged.
qth?: string;
name?: string;
country?: string;
comment?: string;
note?: string;
details: DetailsState;
onChange: (patch: Partial<DetailsState>) => void;
// Stats (F1) tab content: the worked-before matrix + optional QRZ image.
@@ -61,6 +69,14 @@ interface Props {
band: string;
mode: string;
bands?: string[]; // configured bands for the worked-before matrix columns
// When the entry form is empty and a QSO is selected in the log grid, the
// Stats (F1) matrix shows THAT contact's entity instead of sitting blank.
// Only the matrix is redirected — every other tab still edits the live entry.
slotCall?: string;
slotBand?: string;
slotMode?: string;
slotWb?: any;
slotWbBusy?: boolean;
imageUrl?: string;
onOpenImage?: () => void;
// Optional controlled active tab (so the app can switch it via keyboard).
@@ -69,6 +85,8 @@ interface Props {
// When the WinKeyer is active, F1-F12 fire macros, so the tab shortcut is
// shown as Ctrl+F1…F5 instead of F1…F5.
keyerActive?: boolean;
// Open the QSO editor — makes callsigns clickable in the band-slot drill-down.
onEditQso?: (id: number) => void;
}
export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
@@ -113,28 +131,57 @@ function numOrUndef(v: string): number | undefined {
return isNaN(n) ? undefined : n;
}
// Column spans written out rather than built as `col-span-${n}`: Tailwind scans
// for literal class names, so an interpolated one is never emitted.
const SPAN_CLASS: Record<number, string> = {
1: '', 2: 'col-span-2', 3: 'col-span-3', 4: 'col-span-4', 5: 'col-span-5',
6: 'col-span-6', 7: 'col-span-7', 8: 'col-span-8', 9: 'col-span-9',
10: 'col-span-10', 11: 'col-span-11', 12: 'col-span-12',
};
// Compact field helper to keep the JSX dense.
function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 4 | 6; children: React.ReactNode }) {
function Field({ label, span = 1, className, children }: { label: string; span?: number; className?: string; children: React.ReactNode }) {
return (
<div className={cn('flex flex-col min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 4 && 'col-span-4', span === 6 && 'col-span-6')}>
<div className={cn('flex flex-col min-w-0', SPAN_CLASS[span], className)}>
<Label className="mb-1">{label}</Label>
{children}
</div>
);
}
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) {
const { t } = useI18n();
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
const open = tab ?? internalOpen; // controlled when `tab` is provided
// Live award detection: run the SAME engine used at log time over the current
// contact (callsign + looked-up address/state/zones) so award references the
// QSO will earn — e.g. WAPC matching "Beijing" inside the address — are
// surfaced and auto-added the moment you enter a call or click a spot, instead
// of only appearing after logging. Pickable matches are merged into award_refs
// contact callsign, the looked-up address/state/zones AND the entry-strip
// text (QTH, name, country, comment, note, grid). Sending only the address was
// a silent hole: a DARC DOK matches on QTH, so "Aachen" produced nothing here
// while the award editor's Test tab, which passes the whole QSO, matched it.
// References the QSO will earn — WAPC matching "Beijing" inside the address,
// a DOK matching the town — are surfaced and auto-added the moment you enter a
// call or click a spot, instead of only appearing after logging. Pickable
// matches are merged into award_refs
// (idempotent; award_refs is NOT a dependency, so removing one by hand sticks).
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean }>>([]);
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean; ambiguous?: boolean }>>([]);
// NEW badge on the county: the same question the cluster's NEW CTY badge
// answers, for the station being worked right now. Debounced because the
// county field is typed into as well as filled by the lookup.
const [newCounty, setNewCounty] = useState(false);
useEffect(() => {
const cnty = (details.cnty ?? '').trim();
if (!cnty) { setNewCounty(false); return; }
let alive = true;
const id = window.setTimeout(async () => {
try {
const isNew = await IsNewUSCounty(details.state ?? '', cnty);
if (alive) setNewCounty(!!isNew);
} catch { if (alive) setNewCounty(false); }
}, 300);
return () => { alive = false; window.clearTimeout(id); };
}, [details.state, details.cnty]);
useEffect(() => {
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
const t = window.setTimeout(async () => {
@@ -142,6 +189,8 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
const q: any = {
callsign, band, mode,
address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '',
qth: qth ?? '', name: name ?? '', country: country ?? '',
comment: comment ?? '', notes: note ?? '', grid: remoteGrid ?? '',
cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz,
qso_date: new Date().toISOString(),
};
@@ -151,7 +200,9 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
const have = new Set(cur.split(';').filter(Boolean));
let next = cur;
for (const r of all) {
if (!r.pickable) continue;
// An ambiguous candidate is offered, never auto-added: adding one would
// be the guess the award's "one reference per QSO" setting exists to refuse.
if (!r.pickable || r.ambiguous) continue;
const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`;
if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); }
}
@@ -160,7 +211,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
}, 400);
return () => window.clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, band, mode]);
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, qth, name, country, comment, note, remoteGrid, band, mode]);
// Bearing/distance from operator's home grid to the remote station.
// Recomputed only when either grid actually changes.
const path = useMemo(() => {
@@ -174,6 +225,15 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
}
return null;
}, [operatorGrid, remoteGrid, details.lat, details.lon]);
// Where the DX actually is, for its sunrise/sunset. The locator wins when we
// have one — it is what the operator can see and check — and the provider's
// raw coordinates cover entities that resolve through cty.dat with no grid.
const dxLL = useMemo(() => {
const byGrid = gridToLatLon(remoteGrid);
if (byGrid) return byGrid;
if (details.lat != null && details.lon != null) return { lat: details.lat, lon: details.lon };
return null;
}, [remoteGrid, details.lat, details.lon]);
const fmtDeg = (n: number) => `${Math.round(n)}°`;
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
@@ -222,19 +282,40 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
{open === 'stats' && (
<div className="px-3 py-2.5">
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
<BandSlotGrid
wb={slotCall ? slotWb : wb}
busy={slotCall ? !!slotWbBusy : !!wbBusy}
currentBand={slotCall ? (slotBand ?? '') : band}
currentMode={slotCall ? (slotMode ?? '') : mode}
bands={bands}
hasCall={slotCall ? true : callsign.trim() !== ''}
forCall={slotCall}
onEditQso={onEditQso}
lat={dxLL?.lat} lon={dxLL?.lon} />
</div>
)}
{open === 'info' && (
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
<Field label={t('detp.statePref')}>
<div className="grid grid-cols-12 gap-2 px-3 py-2.5">
{/* Twelve columns, not six: the fields hold very different things. A US
county name is long, a CQ zone is two digits — on an even six-column
grid they were the same width, so the county truncated while the
zones sat mostly empty. */}
<Field label={t('detp.statePref')} span={2}>
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
</Field>
<Field label={t('detp.county')}>
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} />
<Field label={t('detp.county')} span={4} className="relative">
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} className={cn(newCounty && 'pr-14')} />
{newCounty && (
<span
title={t('detp.newCountyTip')}
className="absolute right-1.5 bottom-1.5 rounded px-1.5 py-0.5 text-[10px] font-bold tracking-wide bg-success text-success-foreground pointer-events-none"
>
{t('detp.newCounty')}
</span>
)}
</Field>
<Field label={t('detp.prefix')}>
<Field label={t('detp.prefix')} span={2}>
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
</Field>
<Field label={t('detp.cqZone')}>
@@ -248,7 +329,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
{/* DXCC # closes the top row (next to the zones); Continent and
Azimuth SP live in the main entry strip / bandeau. The long-path
bearing and distances move to the row below. */}
<Field label={t('detp.dxcc')}>
<Field label={t('detp.dxcc')} span={2}>
<Input
readOnly
tabIndex={-1}
@@ -257,40 +338,45 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
placeholder="—"
/>
</Field>
<Field label={t('detp.azimuthLp')}>
<Input
readOnly
tabIndex={-1}
className="font-mono bg-muted/40 cursor-default"
value={path ? fmtDeg(path.bearingLong) : ''}
placeholder="—"
/>
</Field>
<Field label={t('detp.distanceSp')}>
<Input
readOnly
tabIndex={-1}
className="font-mono bg-muted/40 cursor-default"
value={path ? fmtKm(path.distanceShort) : ''}
placeholder="—"
/>
</Field>
<Field label={t('detp.distanceLp')}>
<Input
readOnly
tabIndex={-1}
className="font-mono bg-muted/40 cursor-default"
value={path ? fmtKm(path.distanceLong) : ''}
placeholder="—"
/>
</Field>
<Field label={t('detp.address')} span={3}>
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
</Field>
<Field label={t('detp.qslMessage')} span={3}>
{/* The bearing and the two distances are read-only and hold at most
"12345 km", so they take a fixed width and the address — the one
field here that is never wide enough — gets everything left over. */}
<div className="col-span-12 flex gap-2">
<Field label={t('detp.azimuthLp')} className="w-20 shrink-0">
<Input
readOnly
tabIndex={-1}
className="font-mono bg-muted/40 cursor-default"
value={path ? fmtDeg(path.bearingLong) : ''}
placeholder="—"
/>
</Field>
<Field label={t('detp.distanceSp')} className="w-24 shrink-0">
<Input
readOnly
tabIndex={-1}
className="font-mono bg-muted/40 cursor-default"
value={path ? fmtKm(path.distanceShort) : ''}
placeholder="—"
/>
</Field>
<Field label={t('detp.distanceLp')} className="w-24 shrink-0">
<Input
readOnly
tabIndex={-1}
className="font-mono bg-muted/40 cursor-default"
value={path ? fmtKm(path.distanceLong) : ''}
placeholder="—"
/>
</Field>
<Field label={t('detp.address')} className="flex-1 min-w-0">
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
</Field>
</div>
<Field label={t('detp.qslMessage')} span={7}>
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
</Field>
<Field label={t('detp.qslVia')} span={2}>
<Field label={t('detp.qslVia')} span={5}>
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
</Field>
</div>
@@ -305,12 +391,39 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
heightClass="flex-1 min-h-0"
/>
{detected.length > 0 && (
<div className="mt-2 text-[11px] text-muted-foreground shrink-0">
{/* Ambiguous candidates: the award allows one reference per QSO and
several matched, so none was kept. Click one to settle it while the
contact is still in front of you. */}
{detected.some((r) => r.ambiguous) && (
<div className="mt-2 text-[11px] shrink-0 leading-snug border-t border-warning/40 pt-1.5">
<span className="font-medium text-warning">{t('detp.ambiguous')}</span>{' '}
{detected.filter((r) => r.ambiguous).map((r) => (
<button
key={`amb-${r.code}@${r.ref}`}
type="button"
title={r.name ?? ''}
onClick={() => {
const entry = `${r.code.toUpperCase()}@${r.ref.toUpperCase()}`;
const cur = details.award_refs ?? '';
// Only one of the candidates can be right — replace any
// sibling already picked for this award rather than stacking.
const kept = cur.split(';').filter((e) => e && e.split('@')[0].toUpperCase() !== r.code.toUpperCase());
onChange({ award_refs: [...kept, entry].join(';') });
setDetected((d) => d.filter((x) => !x.ambiguous || x.code.toUpperCase() !== r.code.toUpperCase()));
}}
className="inline-block mr-1.5 px-1.5 py-0.5 rounded font-mono whitespace-nowrap border border-warning/50 text-warning hover:bg-warning/10"
>
{r.code}@{r.ref}{r.name ? `${r.name}` : ''}
</button>
))}
</div>
)}
{detected.some((r) => !r.ambiguous) && (
<div className="mt-2 text-[11px] text-muted-foreground shrink-0 max-h-14 overflow-y-auto leading-snug border-t border-border/50 pt-1.5">
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
{detected.map((r) => (
<span key={`${r.code}@${r.ref}`} className="inline-block mr-2 font-mono">
{r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? <span className="text-muted-foreground/70"> {r.name}</span> : null}
{detected.filter((r) => !r.ambiguous).map((r) => (
<span key={`${r.code}@${r.ref}`} className="inline-block mr-1.5 font-mono whitespace-nowrap" title={r.name ?? ''}>
<span className="text-foreground/80">{r.code}{r.ref ? `@${r.ref}` : ''}</span>
</span>
))}
</div>
@@ -352,18 +465,18 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
</Select>
</Field>
<Field label={t('detp.rig')} span={3}>
<Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} />
<Input value={details.my_rig} onChange={(e) => onChange({ my_rig: e.target.value })} />
</Field>
<Field label={t('detp.antenna')} span={3}>
<Input value={details.my_antenna} placeholder="UB640" onChange={(e) => onChange({ my_antenna: e.target.value })} />
<Input value={details.my_antenna} onChange={(e) => onChange({ my_antenna: e.target.value })} />
</Field>
{satelliteMode && (
<>
<Field label={t('detp.satName')} span={3}>
<Input value={details.sat_name} placeholder="AO-91" onChange={(e) => onChange({ sat_name: e.target.value })} />
<Input value={details.sat_name} onChange={(e) => onChange({ sat_name: e.target.value })} />
</Field>
<Field label={t('detp.satelliteMode')} span={3}>
<Input value={details.sat_mode} placeholder="U/V" onChange={(e) => onChange({ sat_mode: e.target.value })} />
<Input value={details.sat_mode} onChange={(e) => onChange({ sat_mode: e.target.value })} />
</Field>
</>
)}
+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 { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
@@ -12,14 +12,20 @@ type Props = {
onPlay: (slot: number) => void;
onStop: () => 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
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
// 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 anyAudio = messages.some((m) => m.has_audio);
const recorded = messages.filter((m) => m.has_audio);
const anyAudio = recorded.length > 0;
return (
<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">
@@ -43,29 +49,54 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
{t('dvkp.noMsgPre')} <strong>{t('dvkp.settingsPath')}</strong> {t('dvkp.noMsgPost')}
</div>
) : (
<div className="grid grid-cols-1 gap-1">
{messages.map((m) => (
// Only the recorded slots are shown. Two columns filling top-to-bottom
// (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
key={m.slot}
type="button"
disabled={!m.has_audio}
disabled={!phoneOk}
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(
'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-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="text-xs truncate flex-1">{m.label || (m.has_audio ? t('dvkp.message') : '—')}</span>
{m.has_audio && <span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>}
<span className="text-xs truncate flex-1">{m.label || t('dvkp.message')}</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>
))}
</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>
);
}
+112
View File
@@ -0,0 +1,112 @@
import React from 'react';
import { LogUIError } from '../../wailsjs/go/main/App';
// A render error used to empty the window and leave nothing behind: no line in
// opslog.log, no dialog, no way for the operator to say more than "white screen".
// Two separate reports — one after logging a 10 GHz QSO, one on starting a DVK
// auto-call — stayed undiagnosed for weeks for exactly that reason.
//
// So this does two things, in order of importance:
//
// 1. Writes the error and its stack to the app log through LogUIError, so the
// next report arrives WITH the cause instead of a description of a blank
// screen.
// 2. Shows it, with the text selectable and a button to copy it, because the
// person who can act on it is the one looking at the screen.
//
// It deliberately does NOT try to recover the tree. React cannot guarantee the
// state is sane after a render throw, and a half-working logger is worse than
// one that says plainly what happened and offers a reload.
type Props = { children: React.ReactNode };
type State = { error: Error | null; info: string };
export class ErrorBoundary extends React.Component<Props, State> {
state: State = { error: null, info: '' };
static getDerivedStateFromError(error: Error): Partial<State> {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
const stack = (info?.componentStack || error.stack || '').trim();
this.setState({ info: stack });
// Never let the reporting path throw: it runs while the app is already broken.
try {
LogUIError('render crash', `${error.name}: ${error.message}`, stack);
} catch {
/* the backend may be gone too — the on-screen copy still works */
}
}
render() {
const { error, info } = this.state;
if (!error) return this.props.children;
const report = `${error.name}: ${error.message}\n\n${info}`;
return (
<div style={{
position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24, background: '#0b1220', color: '#e5e7eb', fontFamily: 'ui-sans-serif, system-ui, sans-serif',
overflow: 'auto', zIndex: 9999,
}}>
<div style={{ maxWidth: 820, width: '100%' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, margin: '0 0 6px' }}>
OpsLog hit an error and stopped drawing
</h1>
<p style={{ fontSize: 13, opacity: 0.8, margin: '0 0 14px' }}>
Your log is safe this is the window, not the database. The details below
are already in the app log; send them with your report.
</p>
<pre style={{
whiteSpace: 'pre-wrap', wordBreak: 'break-word', userSelect: 'text',
fontSize: 11.5, lineHeight: 1.5, background: '#111827', border: '1px solid #1f2937',
borderRadius: 8, padding: 12, maxHeight: '48vh', overflow: 'auto', margin: 0,
}}>{report}</pre>
<div style={{ display: 'flex', gap: 8, marginTop: 14 }}>
<button
onClick={() => { void navigator.clipboard?.writeText(report); }}
style={{
padding: '7px 14px', borderRadius: 6, border: '1px solid #374151',
background: '#1f2937', color: '#e5e7eb', fontSize: 13, cursor: 'pointer',
}}>
Copy the details
</button>
<button
onClick={() => window.location.reload()}
style={{
padding: '7px 14px', borderRadius: 6, border: '1px solid #1d4ed8',
background: '#2563eb', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
}}>
Reload the window
</button>
</div>
</div>
</div>
);
}
}
// installGlobalErrorLogging catches what a boundary cannot: an error thrown from
// a timer, an event handler or a rejected promise. Those do not unmount the tree,
// so they leave no trace at all — and an auto-call loop lives entirely in
// callbacks, which is precisely where a white screen was reported.
export function installGlobalErrorLogging() {
const report = (kind: string, message: string, stack?: string) => {
try {
LogUIError(kind, message, stack || '');
} catch {
/* nothing more we can do from here */
}
};
window.addEventListener('error', (e: ErrorEvent) => {
const src = e.filename ? ` (${e.filename}:${e.lineno}:${e.colno})` : '';
report('uncaught error', (e.message || 'unknown error') + src, e.error?.stack);
});
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
const r: any = e.reason;
report('unhandled rejection', String(r?.message ?? r ?? 'unknown'), r?.stack);
});
}
@@ -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>
);
}
+44 -8
View File
@@ -26,7 +26,9 @@ export interface QueryFilter {
// Curated field catalog. `value` MUST match a column in the backend whitelist
// (qso.FilterableFields); `type` only drives which operators/value input we show.
type FieldType = 'text' | 'number' | 'date';
// 'date' is an ISO timestamp column (qso_date); 'adifdate' is an ADIF YYYYMMDD
// string (the confirmation dates) — picked with a calendar, stored 8-digit.
type FieldType = 'text' | 'number' | 'date' | 'adifdate';
const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'callsign', label: 'fltb.fCallsign', type: 'text' },
{ value: 'qso_date', label: 'fltb.fDate', type: 'date' },
@@ -57,16 +59,30 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'wwff_ref', label: 'fltb.fWwff', type: 'text' },
{ value: 'rig', label: 'fltb.fRig', type: 'text' },
{ value: 'ant', label: 'fltb.fAntenna', type: 'text' },
// Same naming as the bulk editor: each entry says whether it is a STATUS or a
// DATE and in which direction, grouped by channel. "Upload" was wrong for half
// of them — a paper QSL is not uploaded.
{ value: 'qsl_sent', label: 'fltb.fQslSent', type: 'text' },
{ value: 'qsl_sent_date', label: 'fltb.fQslSentDate', type: 'adifdate' },
{ value: 'qsl_rcvd', label: 'fltb.fQslRcvd', type: 'text' },
{ value: 'qsl_rcvd_date', label: 'fltb.fQslRcvdDate', type: 'adifdate' },
{ value: 'qsl_via', label: 'fltb.fQslVia', type: 'text' },
{ value: 'lotw_sent', label: 'fltb.fLotwSent', type: 'text' },
{ value: 'lotw_sent_date', label: 'fltb.fLotwSentDate', type: 'adifdate' },
{ value: 'lotw_rcvd', label: 'fltb.fLotwRcvd', type: 'text' },
{ value: 'lotw_rcvd_date', label: 'fltb.fLotwRcvdDate', type: 'adifdate' },
{ value: 'eqsl_sent', label: 'fltb.fEqslSent', type: 'text' },
{ value: 'eqsl_sent_date', label: 'fltb.fEqslSentDate', type: 'adifdate' },
{ value: 'eqsl_rcvd', label: 'fltb.fEqslRcvd', type: 'text' },
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzUpload', type: 'text' },
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogUpload', type: 'text' },
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogUpload', type: 'text' },
{ value: 'eqsl_rcvd_date', label: 'fltb.fEqslRcvdDate', type: 'adifdate' },
{ value: 'qrzcom_qso_upload_status', label: 'fltb.fQrzSent', type: 'text' },
{ value: 'qrzcom_qso_upload_date', label: 'fltb.fQrzSentDate', type: 'adifdate' },
{ value: 'qrzcom_qso_download_status', label: 'fltb.fQrzRcvd', type: 'text' },
{ value: 'qrzcom_qso_download_date', label: 'fltb.fQrzRcvdDate', type: 'adifdate' },
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' },
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' },
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
@@ -112,6 +128,8 @@ const NUM_OPS: FilterOp[] = ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'empty', 'notem
function opsFor(field: string): { value: FilterOp; label: string }[] {
const t = FIELDS.find((f) => f.value === field)?.type ?? 'text';
// 'adifdate' is stored as a string but sorts chronologically, so it takes the
// comparison operators (before/after), not the text ones.
const allow = t === 'text' ? TEXT_OPS : NUM_OPS;
return OPS.filter((o) => allow.includes(o.value));
}
@@ -216,7 +234,14 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
<SelectItem key={n} value={n}>
<span className="inline-flex items-center gap-2">
{n}
<Trash2 className="size-3 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation(); deletePreset(n); }} />
{/* Radix SelectItem selects on pointer-up, so an onClick here
fires too late — the menu has already loaded the preset and
closed. Intercept pointer-down (preventDefault stops Radix
claiming it) and delete there instead. */}
<Trash2
className="size-3 text-muted-foreground hover:text-destructive"
onPointerDown={(e) => { e.preventDefault(); e.stopPropagation(); deletePreset(n); }}
/>
</span>
</SelectItem>
))}
@@ -253,12 +278,23 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
</SelectContent>
</Select>
<Input
type={fieldType === 'date' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
// An ADIF date is picked with a calendar but STORED as the
// 8-digit form the column holds, so the comparison stays a
// plain string one on both sides.
type={fieldType === 'date' || fieldType === 'adifdate' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
className="h-8 flex-1 text-xs"
disabled={!needsValue}
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : t('fltb.valuePh')) : '—'}
value={c.value}
onChange={(e) => setCond(i, { value: e.target.value })}
// \d, not d: the escape was missing, so the test never matched
// an 8-digit ADIF date and the calendar input was handed
// "20260728" — which type=date rejects, showing an empty box
// over a value that was really there.
value={fieldType === 'adifdate' && /^\d{8}$/.test(c.value)
? `${c.value.slice(0, 4)}-${c.value.slice(4, 6)}-${c.value.slice(6, 8)}`
: c.value}
onChange={(e) => setCond(i, {
value: fieldType === 'adifdate' ? e.target.value.replace(/-/g, '') : e.target.value,
})}
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
/>
<button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title={t('fltb.remove')}>
+443 -96
View File
@@ -1,13 +1,17 @@
import { useEffect, useRef, useState } from 'react';
import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX } from 'lucide-react';
import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX, ChevronDown, SlidersHorizontal } from 'lucide-react';
import {
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
GetFlexState, FlexATUStart, FlexATUBypass, FlexSetATUMemories, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexAmpOperate,
GetPGXLStatus, PGXLSetFanMode,
GetTunerGeniusStatus, GetTunerGeniusSettings,
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
FlexSetLMSNR, FlexSetLMSNRLevel, FlexSetLMSANF, FlexSetLMSANFLevel,
FlexSetSpeexNR, FlexSetSpeexNRLevel, FlexSetRNN, FlexSetANFT, FlexSetNRF, FlexSetNRFLevel, FlexSetTXDAX,
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
@@ -16,6 +20,9 @@ import { EventsOn } from '../../wailsjs/runtime/runtime';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { sMeterRST } from '@/lib/rst';
import { TunerCard } from '@/components/TunerCard';
import { MeterBar } from '@/components/MeterBar';
import type { TGStatus } from '@/components/TunerGeniusPanel';
type FlexState = {
available: boolean; model?: string;
@@ -30,6 +37,11 @@ type FlexState = {
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;
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[];
mode?: string;
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
@@ -113,8 +125,8 @@ function Segmented({ value, options, onChange, disabled }: {
}
// Chip — a compact on/off pill (NB/NR/ANF/VOX/MON…).
function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber';
function Chip({ on, onClick, label, disabled, accent = 'emerald', title }: {
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; title?: string;
}) {
const onCls = {
emerald: 'bg-success border-success text-success-foreground',
@@ -123,7 +135,7 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
amber: 'bg-warning border-warning text-warning-foreground',
}[accent];
return (
<button type="button" onClick={onClick} disabled={disabled}
<button type="button" onClick={onClick} disabled={disabled} title={title}
className={cn(
// min width (not fixed) so a longer label like STONE keeps symmetric
// padding instead of overflowing; short labels (NB/NR/APF) stay aligned.
@@ -134,6 +146,37 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
);
}
// SmartSDR reports the tuner as an enum. It is worth decoding rather than
// showing raw: TUNE_FAIL and "never tuned" both leave the radio looking normal,
// and the operator transmits into a bad match either way.
function atuLabel(status: string | undefined, t: (k: string) => string): string {
switch ((status || '').toUpperCase()) {
case 'TUNE_IN_PROGRESS': return t('flxp.atuTuning');
case 'TUNE_SUCCESSFUL':
case 'TUNE_OK': return t('flxp.atuOk');
case 'TUNE_FAIL':
case 'TUNE_FAIL_BYPASS': return t('flxp.atuFail');
case 'TUNE_BYPASS':
case 'TUNE_MANUAL_BYPASS': return t('flxp.atuBypassed');
case 'TUNE_ABORTED': return t('flxp.atuAborted');
case 'TUNE_NOT_STARTED':
case 'NONE':
case '': return t('flxp.atuIdle');
default: return status || '';
}
}
function atuTone(status: string | undefined): string {
switch ((status || '').toUpperCase()) {
case 'TUNE_SUCCESSFUL':
case 'TUNE_OK': return 'text-success';
case 'TUNE_FAIL':
case 'TUNE_FAIL_BYPASS': return 'text-danger';
case 'TUNE_IN_PROGRESS': return 'text-warning';
default: return 'text-muted-foreground';
}
}
function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, sliderAccent }: {
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; sliderAccent?: string;
@@ -211,56 +254,50 @@ function OffsetRow({ label, on, onToggle, hz, onHz, disabled, title }: {
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
// `display` overrides the numeric readout; `segColor` colours segments by their
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
const METER_SEGMENTS = 26;
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: {
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
function Card({ icon: Icon, title, accent, children, ckey, open: openProp, onToggle }: {
icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string;
open?: boolean; onToggle?: () => void; // controlled mode — lets sibling cards share one collapse state
}) {
const span = hi - lo;
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
const lit = Math.round((pct / 100) * METER_SEGMENTS);
// Collapsible: a chevron in the header hides the body. Uncontrolled by default,
// persisting per card (keyed by ckey, falling back to the title); when `open`/
// `onToggle` are supplied the parent owns the state (e.g. linked TX/RX cards).
const storeKey = 'opslog.cardOpen.' + (ckey || title);
const [openState, setOpenState] = useState(() => localStorage.getItem(storeKey) !== '0');
const controlled = openProp !== undefined;
const open = controlled ? openProp : openState;
const toggle = controlled
? (onToggle ?? (() => {}))
: () => setOpenState((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
return (
<div 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 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>
);
}
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
</div>
<div className="p-3 space-y-3">{children}</div>
</div>
);
// speMaxW is the amp's rated output, used as the power-bar full scale. Derived from
// the model string the amp reports (13K→1.3kW, 15K→1.5kW, 2K/20K→2kW).
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; // 1.3K-FA default
}
// powerLevelLabel spells out the SPE power-level code (L/M/H) in full.
function powerLevelLabel(pl?: string): string {
switch ((pl || '').trim().toUpperCase()) {
case 'L': return 'Low';
case 'M': return 'Mid';
case 'H': return 'High';
default: return pl || '';
}
}
// onCWSpeed (optional): notified when the operator changes CW speed here, so the
@@ -268,14 +305,36 @@ function Card({ icon: Icon, title, accent, children }: { icon: any; title: strin
export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) {
const { t } = useI18n();
const [st, setSt] = useState<FlexState>(ZERO);
// Extra/"advanced" DSP rows (WNB + the SmartSDR v4 NRL/NRS/NRF/ANFL/AI-FFT
// block) collapse behind a button so the RECEIVE card doesn't grow tall — only
// NB/NR/ANF stay visible by default. Remembered across sessions.
const [dspOpen, setDspOpen] = useState<boolean>(() => localStorage.getItem('opslog.flexDspOpen') === '1');
const toggleDsp = () => setDspOpen((o) => { const n = !o; localStorage.setItem('opslog.flexDspOpen', n ? '1' : '0'); return n; });
const hold = useRef<Record<string, number>>({});
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
// read steadily instead of jumping every poll.
// 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
// radio / in SmartSDR, not just via the slider below. Notify only on a real change.
const lastCwSpeed = useRef<number | null>(null);
useEffect(() => {
const w = st.cw_speed;
if (typeof w === 'number' && w > 0 && w !== lastCwSpeed.current) {
lastCwSpeed.current = w;
onCWSpeed?.(w);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [st.cw_speed]);
// Peak-hold: keep the highest reading for a second so the jittery VITA-49
// meters read steadily instead of jumping every poll. One second, not two —
// longer and the meters visibly trail the end of a transmission, which reads
// as the app lagging rather than as a peak being held.
const peak = useRef<Record<string, { v: number; t: number }>>({});
const peakHold = (key: string, val: number) => {
const peakHold = (key: string, val: number, windowMs = 1000, live = true) => {
// live=false throws the held value away at once (e.g. the carrier dropped),
// so the meter falls immediately instead of coasting for the window.
if (!live) { delete peak.current[key]; return val; }
const now = Date.now();
const p = peak.current[key];
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
return p.v;
};
@@ -301,15 +360,60 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
}, []);
// PowerGenius XL direct connection (fan mode), independent of the Flex link.
const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string }>({ connected: false });
const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string; state?: string; peak_w?: number }>({ connected: false });
useEffect(() => {
let alive = true;
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
tick();
const id = window.setInterval(tick, 2000);
const id = window.setInterval(tick, 250); // match the amp's 250 ms poll so the card catches every envelope peak
return () => { alive = false; window.clearInterval(id); };
}, []);
// TRANSMIT + RECEIVE share ONE collapse state (they sit side by side, so folding
// one folds the other and keeps the row tidy). Persisted like the other cards.
const [txrxOpen, setTxrxOpen] = useState(() => localStorage.getItem('opslog.cardOpen.txrx') !== '0');
const toggleTxrx = () => setTxrxOpen((o) => { const n = !o; localStorage.setItem('opslog.cardOpen.txrx', n ? '1' : '0'); return n; });
// Tuner Genius XL direct connection — its own card in the Flex panel (like PGXL).
const [tg, setTg] = useState<TGStatus>({ connected: false });
const [tgEnabled, setTgEnabled] = useState(false);
useEffect(() => {
let alive = true;
const tick = async () => {
try { const en: any = await GetTunerGeniusSettings(); if (alive) setTgEnabled(!!en?.enabled); } catch {}
try { const s: any = await GetTunerGeniusStatus(); if (alive && s) setTg(s as TGStatus); } catch {}
};
tick();
const id = window.setInterval(tick, 500); // fast so meters track TX (see App.tsx)
return () => { alive = false; window.clearInterval(id); };
}, []);
// Configured amplifiers (Settings → Amplifier) — possibly SEVERAL (some ops
// run two SPEs in parallel). The card shows ONE at a time; the dropdown picks
// which, and the choice is remembered per panel.
const [ampList, setAmpList] = useState<any[]>([]);
const [ampSel, setAmpSel] = useState<string>(() => localStorage.getItem('opslog.ampSel.flex') || '');
useEffect(() => {
let alive = true;
const tick = () => GetAmpStatuses().then((l: any) => alive && setAmpList((l ?? []) as any[])).catch(() => {});
tick();
const id = window.setInterval(tick, 1500);
return () => { alive = false; window.clearInterval(id); };
}, []);
const selAmp = ampList.find((a: any) => a.id === ampSel) ?? ampList[0];
const isSPE = !!selAmp?.spe;
const isACOM = !!selAmp?.acom;
const spe = selAmp?.spe ?? { connected: false };
const acom = selAmp?.acom ?? { connected: false };
const ampPicker = ampList.length > 1 && selAmp ? (
<select value={selAmp.id}
onChange={(e) => { setAmpSel(e.target.value); localStorage.setItem('opslog.ampSel.flex', e.target.value); }}
title={t('flxp.ampPick')}
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>) => {
hold.current[key] = Date.now() + 900;
setSt((p) => ({ ...p, [key]: val }));
@@ -352,6 +456,9 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
return () => off();
}, [st.rit, st.rit_freq]);
const isCW = (st.mode || '').toUpperCase().includes('CW');
// Phone (voice) modes — MIC / COMP meters only make sense here, so they're
// hidden in CW and digital.
const isPhone = /\b(SSB|USB|LSB|AM|FM|DFM|NFM)\b/i.test(st.mode || '');
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
const CW_BW = [100, 200, 300, 400, 500];
@@ -480,21 +587,22 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
};
const cur = [
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
// dBm sits inline next to the S-value (no separate line below) to save height.
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={`${s.display} | ${dbm.toFixed(0)} dBm`}
title={onReportRST ? t('rst.clickToFill') : undefined}
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
); })(),
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626" value={w} />
); })(),
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
// Mic input level: fixed -40..+10 dB scale, RED from 0 dB up (overdrive).
mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={10} accent="#16a34a"
// Mic input level in dBFS — SmartSDR's scale is -40…0 dB. Phone modes only.
isPhone && mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={0} accent="#16a34a"
segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />,
// Speech compression (dB of gain reduction).
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" />,
// Speech compression — original working meter, only the top of the
// 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);
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
@@ -511,7 +619,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
{/* TX + RX columns */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* TRANSMIT */}
<Card icon={Zap} title={t('flxp.transmit')} accent="#dc2626">
<Card icon={Zap} title={t('flxp.transmit')} accent="#dc2626" open={txrxOpen} onToggle={toggleTxrx}>
<div className="flex items-center gap-3">
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">{t('flxp.rfPower')}</span>
<Slider value={st.rf_power} disabled={off} accent="#dc2626" onChange={(v) => change('rf_power', v, () => FlexSetPower(v))} />
@@ -552,6 +660,33 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
)}
</div>
{/* ATU — the radio's built-in tuner. Sits under TUNE because that is
the order of operations: the tuner needs a carrier to measure
against, so TUNE first, then start a tuning cycle. */}
<div className="flex items-center gap-2 border-t border-border/60 pt-3">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">ATU</span>
<button type="button" disabled={off}
title={t('flxp.atuTuneHint')}
onClick={() => FlexATUStart().catch(() => {})}
className="px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 border-warning text-warning bg-card hover:bg-warning-muted transition-all disabled:opacity-30">
{t('flxp.atuTune')}
</button>
<button type="button" disabled={off}
title={t('flxp.atuBypassHint')}
onClick={() => FlexATUBypass().catch(() => {})}
className="px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 border-border text-muted-foreground bg-card hover:bg-muted transition-all disabled:opacity-30">
{t('flxp.atuBypass')}
</button>
<Chip on={st.atu_memories} disabled={off} label={t('flxp.atuMem')} accent="cyan"
title={t('flxp.atuMemHint')}
onClick={() => change('atu_memories', !st.atu_memories, () => FlexSetATUMemories(!st.atu_memories))} />
{/* The status is the whole point: a tune that FAILED and one that
never ran look identical on the radio's own front panel. */}
<span className={cn('ml-auto text-[11px] font-mono font-semibold whitespace-nowrap', atuTone(st.atu_status))}>
{atuLabel(st.atu_status, t)}
</span>
</div>
{!isCW ? (
<div className="border-t border-border/60 pt-3 space-y-3">
@@ -636,12 +771,24 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
onLevel={(v) => change('cw_mon_level', v, () => FlexSetSidetoneLevel(v))} />
</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>
{/* RECEIVE */}
<Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2">
<Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2" open={txrxOpen} onToggle={toggleTxrx}>
{/* Antenna selection sits at the very top of the RX column. */}
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
<div className="flex items-center gap-2">
<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>
<div className="flex items-center gap-1.5 flex-1 min-w-0">
<span className="text-[10px] text-muted-foreground">RX</span>
@@ -656,21 +803,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">
{((st.tx_ant_list?.length ? st.tx_ant_list : st.ant_list) ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
</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>
)}
{/* 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">
<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}
@@ -694,13 +837,28 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
</div>
<div className="border-t border-border/60 pt-3 space-y-3">
{/* Core noise controls stay visible; WNB + the SmartSDR v4 DSP block
hide behind the 'DSP' toggle so the card doesn't tower. When any
hidden control is ON we badge the toggle so it's not forgotten. */}
{(() => {
const hiddenOn = st.wnb || (st.dsp_v4 && (!!st.lms_nr || !!st.speex_nr || !!st.nrf || (!isCW && !!st.lms_anf) || !!st.rnn || (!isCW && !!st.anft)));
return (
<div className="flex items-center justify-between">
<span className="text-[11px] font-bold text-muted-foreground">{t('flxp.dspNoise')}</span>
<button type="button" onClick={toggleDsp} title={t('flxp.dspMore')}
className={cn('flex items-center gap-1 h-6 px-2 rounded-md border text-[10px] font-bold uppercase tracking-wide transition-colors',
hiddenOn && !dspOpen ? 'bg-primary/15 text-primary border-primary/40' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
<SlidersHorizontal className="size-3" />
DSP
{hiddenOn && !dspOpen && <span className="ml-0.5 size-1.5 rounded-full bg-primary" />}
<ChevronDown className={cn('size-3 transition-transform', dspOpen && 'rotate-180')} />
</button>
</div>
);
})()}
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))}
onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} />
{/* WNB — wideband noise blanker (the extra hardware NR the Flex has). */}
<LevelRow label="WNB" on={st.wnb} disabled={rxOff} value={st.wnb_level} accent="amber" sliderAccent="#f59e0b"
onToggle={() => change('wnb', !st.wnb, () => FlexSetWNB(!st.wnb))}
onLevel={(v) => change('wnb_level', v, () => FlexSetWNBLevel(v))} />
<LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
@@ -710,6 +868,58 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
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>
{isCW && (
<div className="border-t border-border/60 pt-3 space-y-3">
@@ -760,23 +970,130 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
</Card>
</div>
{/* External amplifier (PowerGenius XL) — only when detected. */}
{st.amp_available && (
<Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
{/* SPE Expert amplifier (serial/TCP) — shown when it's the configured amp.
The Flex doesn't report SPE amps, so this card is driven by OpsLog's own
SPE link rather than the Flex amplifier object. */}
{isSPE && (
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${selAmp?.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
<div className="flex items-center gap-3 flex-wrap">
{ampPicker}
<button type="button" disabled={!spe.connected}
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',
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{spe.operate ? 'OPERATE' : 'STANDBY'}
</button>
{/* Power ON pulses RTS then DTR — it must stay clickable while the amp
is off (no status = not "connected"), and serial only. */}
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
<button type="button" disabled={!(spe.connected || spe.transport === 'serial')}
onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
title={spe.transport === 'serial' ? 'Power on (RTS then DTR pulse)' : 'Power-on needs the serial RTS/DTR lines — not available over a network bridge'}
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
<button type="button" disabled={!spe.connected}
onClick={() => AmpPower(selAmp.id, false).catch(() => {})}
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>
{/* Output power level: Low / Mid / High. */}
<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(selAmp.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>
)}
{/* ACOM amplifier (serial/TCP) — shown when it's the configured amp. Driven
by OpsLog's own ACOM link (the Flex doesn't report ACOM amps). */}
{isACOM && (
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${selAmp?.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
<div className="flex items-center gap-3 flex-wrap">
{ampPicker}
<button type="button" disabled={!acom.connected}
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',
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>
{/* Power ON is a DTR/RTS pulse — serial only, and it works while the amp
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">
<button type="button" disabled={!(acom.port_open && acom.transport === 'serial')}
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'}
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(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>
</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>
)}
{/* External amplifier (PowerGenius XL) — only when the Flex reports one AND
PowerGenius is the selected amp type. Running an SPE Expert or ACOM hides
this Flex-reported card so two amps don't both show. */}
{st.amp_available && !isSPE && !isACOM && (
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
<div className="flex items-center gap-3">
{ampPicker}
<button type="button" disabled={off}
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',
st.amp_operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{st.amp_operate ? 'OPERATE' : 'STANDBY'}
</button>
<span className="text-xs text-muted-foreground">
{st.amp_operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
</span>
{/* Fan mode — shown when the PowerGenius is configured (Settings →
PowerGenius). The dot shows the direct-connection state; the
selector is disabled until connected (hover it for the error). */}
{(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'))}>
<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>
@@ -800,29 +1117,55 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
{/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
and shown compact so they sit with the amp controls. */}
{(() => {
// Prefer the radio's VITA amp meters when they are flowing (local or
// over a VPN) — fast, and the same values SmartSDR shows (incl. the
// right temperature sensor). Fall back to the amp's OWN link only when
// the radio isn't streaming them (a remote link without VITA).
const meters = st.meters || [];
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
if (off || amp.length === 0) return null;
if (off || amp.length === 0) {
const P = pg as any;
if (!P.connected) return null;
// Direct-link fallback: a 500 ms poll samples the SSB envelope at
// random points, so peak-hold it (live=txing drops it the instant
// PTT releases). The LDMOS pair can pull well past 25 A → 50 A scale.
const txing = typeof st.transmitting === 'boolean' ? st.transmitting : /TRANSMIT/i.test(P.state || '');
const w = txing ? Math.round(Number(P.fwd_w) || 0) : 0;
const idA = txing ? Number(P.id) || 0 : 0;
const temp = Number(P.temperature) || 0;
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
<MeterBar label={t('ampw.pwr')} value={peakHold('pgw', w, 2500, txing)} unit="W" lo={0} hi={2000} accent="#dc2626" />
<MeterBar label={t('ampw.id')} value={peakHold('pgid', idA, 2500, txing)} unit="A" lo={0} hi={50} accent="#16a34a" />
<MeterBar label={t('ampw.temp')} value={temp} unit="°C" lo={0} hi={80} accent="#ea580c" />
</div>
);
}
// Power comes from the radio's meter stream and nothing else. The
// amplifier also reports a "peakfwd", and using it was a mistake
// twice over: it is a latched maximum that is never reset, and it
// survives in the last-known status after the amp disconnects — so
// it claimed 1350 W from an old transmission while the radio was
// putting out 10.
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
{amp.map((m) => {
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
}
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
// Drain current (ID): the PGXL reports a full-scale far too small
// for this meter (~3 A), so 1.5 A pinned the bar near half. The
// value itself is fine — only the bar's range was wrong. Give it a
// fixed 25 A scale (the PGXL's LDMOS pair tops out around 20 A), and
// only override an unusable reported range, not a sane one.
// for this meter, so it pinned the bar. Give it a fixed 50 A scale
// (the LDMOS pair can pull ~45-50 A near full output), overriding an
// unusable reported range but keeping a sane one.
let lo = m.lo, hi = m.hi;
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
lo = 0;
hi = m.hi >= 25 ? m.hi : 25;
hi = m.hi >= 50 ? m.hi : 50;
}
return <MeterBar key={m.id} 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>
);
@@ -830,6 +1173,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
</Card>
)}
{/* Tuner Genius XL — 4O3A ATU, its own card when enabled (Settings →
Tuner Genius). Same card shown in Station Control. */}
{tgEnabled && <TunerCard status={tg} t={t} />}
</div>
</div>
);
+57 -8
View File
@@ -47,12 +47,33 @@ const ZERO: IcomState = {
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
// the plain SetFrequency command — no band-stacking codes needed. Hz values.
const BANDS: { l: string; hz: number }[] = [
type Band = { l: string; hz: number };
const HF_BANDS: Band[] = [
{ l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 }, { l: '40', hz: 7_100_000 },
{ l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 },
{ l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 },
{ l: '6', hz: 50_150_000 },
];
const B6 = { l: '6', hz: 50_150_000 };
const B2 = { l: '2', hz: 144_300_000 }; // SSB calling
const B70 = { l: '70cm', hz: 432_200_000 }; // SSB calling
const B23 = { l: '23cm', hz: 1_296_200_000 }; // SSB calling
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
// the plain SetFrequency command — no band-stacking codes needed.
//
// Which buttons to OFFER depends on the radio, exactly as the attenuator steps
// do below. An IC-9700 has no HF at all, yet the console was showing it 160
// through 6 and none of the bands it actually covers: ten dead buttons, and no
// way to change band from here on the only rig where you would want to.
function bandsFor(model?: string): Band[] {
const m = (model ?? '').toUpperCase();
if (m.includes('9700')) return [B2, B70, B23]; // VHF/UHF/SHF only
if (m.includes('705')) return [...HF_BANDS, B6, B2, B70];
if (m.includes('9100')) return [...HF_BANDS, B6, B2, B70, B23];
// 7300 / 7610 / 7100 / unknown: HF + 6 m, the historical list.
return [...HF_BANDS, B6];
}
// Mode buttons for the console (like RS-BA1's row). SetCATMode picks USB/LSB for
// SSB by frequency and the rig's data variant for digital modes.
@@ -73,6 +94,27 @@ function attOptions(model?: string): { v: string; l: string }[] {
return [OFF, { v: '20', l: '20dB' }]; // IC-7300 / IC-705 / IC-7100 / default
}
// bandOfHz names the amateur band a frequency falls in, so the band row can show
// where the rig actually is. The buttons only ever SENT a frequency and carried
// no active state at all, so nothing was highlighted whatever the rig reported.
// Edges are the ITU/IARU band limits, wide enough to cover regional differences —
// out-of-band (transverter IF, general coverage RX) matches nothing, as it should.
function bandOfHz(hz?: number): string {
if (!hz || hz <= 0) return '';
const mhz = hz / 1_000_000;
const bands: [string, number, number][] = [
['160', 1.8, 2.0], ['80', 3.5, 4.0], ['60', 5.25, 5.45], ['40', 7.0, 7.3],
['30', 10.1, 10.15], ['20', 14.0, 14.35], ['17', 18.068, 18.168],
['15', 21.0, 21.45], ['12', 24.89, 24.99], ['10', 28.0, 29.7],
// Labels must match the band buttons' exactly — this is only used to light
// the button for the band the rig is on, and '70' never matched '70cm'.
['6', 50.0, 54.0], ['4', 70.0, 70.5], ['2', 144.0, 148.0],
['70cm', 430.0, 450.0], ['23cm', 1240.0, 1300.0],
];
for (const [name, lo, hi] of bands) if (mhz >= lo && mhz <= hi) return name;
return '';
}
// fmtVFO renders a Hz frequency the way an Icom front panel does:
// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00".
function fmtVFO(hz?: number): string {
@@ -731,12 +773,19 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
{/* Band buttons + antenna selection. */}
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
<div className="grid grid-cols-5 gap-1.5">
{BANDS.map((b) => (
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
className="px-1 py-1.5 rounded-md text-[11px] font-bold border border-border bg-card text-foreground hover:bg-muted transition-colors">
{b.l}
</button>
))}
{bandsFor(st.model).map((b) => {
const here = bandOfHz(mainHz) === b.l;
return (
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
title={here ? t('icmp.bandCurrent', { b: b.l }) : undefined}
className={cn('px-1 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
here
? 'border-primary bg-primary text-primary-foreground shadow-[0_0_8px] shadow-primary/40'
: 'border-border bg-card text-foreground hover:bg-muted')}>
{b.l}
</button>
);
})}
</div>
<Row label={t('icmp.antenna')}>
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
+143
View File
@@ -0,0 +1,143 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { useI18n } from '@/lib/i18n';
import { TailLogFile, GetLogFilePath } from '../../wailsjs/go/main/App';
// LogViewer shows the tail of opslog.log and keeps it current while open.
//
// This exists because diagnosing a problem used to mean asking the operator to
// find a file in their data directory, open it in a text editor, and re-open it
// after every attempt. Half the round-trips in a bug report were spent on that.
//
// Three behaviours matter more than they look:
// • Auto-scroll is a checkbox the operator owns, AND it releases itself the
// moment they scroll up — otherwise the line they are reading is yanked away
// a second later, which defeats the one thing this window is for. Scrolling
// back to the bottom re-arms it, so following is never a dead end.
// • The filter keeps only matching LINES rather than highlighting inside a
// 256 KB blob: with "cluster" or "acom" typed in, the window becomes exactly
// the subsystem trace you would have grepped for.
// • It polls only while open. A closed dialog costs nothing.
// 512 KB ≈ 3200 lines. The window is a sliding tail: once new lines push past it
// the oldest fall out of the fetched text entirely, so a small buffer dropped
// lines the operator was still reading during a busy trace (CAT/cluster/antenna
// polling). This is double the old 256 KB — 1 MB was tried but the per-second
// fetch + split + re-render of ~6500 lines made the window lag. 512 KB keeps a
// useful backlog while staying smooth. The backend caps this at 4 MB.
const TAIL_BYTES = 512 * 1024;
const POLL_MS = 1000;
export function LogViewer({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
const { t } = useI18n();
const [text, setText] = useState('');
const [path, setPath] = useState('');
const [query, setQuery] = useState('');
const [autoScroll, setAutoScroll] = useState(true);
// Shown next to the checkbox so a QUIET log still proves it is being polled —
// without it there is no way to tell 'nothing new' from 'window is dead'.
const [lastPull, setLastPull] = useState('');
const boxRef = useRef<HTMLPreElement>(null);
const autoRef = useRef(true);
useEffect(() => { autoRef.current = autoScroll; }, [autoScroll]);
// stick pins the view to the bottom on the NEXT frame. Setting scrollTop
// straight away is not enough while the dialog is still animating in: the box
// has no height yet, scrollHeight is wrong, and the scroll silently does
// nothing — leaving the operator staring at the top of a 256 KB buffer, i.e.
// at lines from hours ago, which looks exactly like a frozen window.
const stick = useCallback(() => {
if (!autoRef.current) return;
requestAnimationFrame(() => {
const el = boxRef.current;
if (el) el.scrollTop = el.scrollHeight;
});
}, []);
const pull = useCallback(() => {
TailLogFile(TAIL_BYTES).then((s: any) => {
setText(String(s ?? ''));
setLastPull(new Date().toLocaleTimeString());
}).catch(() => {});
}, []);
useEffect(() => {
if (!open) return;
setAutoScroll(true);
autoRef.current = true;
GetLogFilePath().then((p: any) => setPath(String(p ?? ''))).catch(() => {});
pull();
// Re-assert the bottom after the open animation has settled. A quiet log
// never changes, so the content-driven scroll below would never fire again
// and the first attempt would be the only one.
const t1 = window.setTimeout(stick, 120);
const t2 = window.setTimeout(stick, 450);
const id = window.setInterval(pull, POLL_MS);
return () => { window.clearInterval(id); window.clearTimeout(t1); window.clearTimeout(t2); };
}, [open, pull, stick]);
const all = useMemo(() => text.split('\n'), [text]);
const shown = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return all;
return all.filter((l) => l.toLowerCase().includes(q));
}, [all, query]);
// Stick to the bottom after each refresh, but only while auto-scroll is on.
useEffect(() => { if (open) stick(); }, [shown, open, stick]);
// Scrolling away from the bottom releases auto-scroll; coming back re-arms it.
function onScroll() {
const el = boxRef.current;
if (!el) return;
setAutoScroll(el.scrollHeight - el.scrollTop - el.clientHeight < 24);
}
const body = shown.join('\n');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle>{t('logview.title')}</DialogTitle>
<DialogDescription className="font-mono text-[11px] break-all">{path}</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
<Input className="h-8 flex-1 font-mono text-xs" value={query} placeholder={t('logview.searchPh')}
onChange={(e) => setQuery(e.target.value)} />
{query.trim() !== '' && (
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
{t('logview.matches', { n: shown.length, total: all.length })}
</span>
)}
</div>
<pre ref={boxRef} onScroll={onScroll}
className="h-[60vh] overflow-auto rounded-md border border-border bg-muted/30 p-2 text-[11px] leading-snug font-mono whitespace-pre-wrap break-all">
{body.trim() === '' ? t('logview.empty') : body}
</pre>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox checked={autoScroll} onCheckedChange={(c) => {
const on = !!c;
setAutoScroll(on);
if (on) { const el = boxRef.current; if (el) el.scrollTop = el.scrollHeight; }
}} />
{t('logview.autoScroll')}
</label>
{lastPull && <span className="text-xs text-muted-foreground tabular-nums">{t('logview.updated', { time: lastPull })}</span>}
<div className="flex-1" />
<Button variant="outline" size="sm" onClick={() => navigator.clipboard?.writeText(body)}>
{t('logview.copy')}
</Button>
<Button variant="outline" size="sm" onClick={() => { setQuery(''); setAutoScroll(true); pull(); }}>
{t('logview.toEnd')}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+118 -4
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { nightPolygon } from '../lib/greyline';
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
import { writeUiPref } from '@/lib/uiPref';
@@ -42,9 +43,20 @@ function unwrapLon(ring: [number, number][]): [number, number][] {
}
const CARTO_LIGHT = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
const OSM = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
const CARTO_ATTR = '&copy; OpenStreetMap &copy; CARTO';
const OSM_ATTR = '&copy; OpenStreetMap contributors';
// NOT tile.openstreetmap.org. Those are OpenStreetMap's VOLUNTEER-run servers,
// and their usage policy does not cover a desktop application handed to an
// unbounded number of operators — they blocked OpsLog for it, and every user's
// map filled with "Access blocked / 403" tiles at once.
//
// The replacements are tile services whose terms do cover redistributed apps,
// with no API key: Carto (whose tiles are OSM-derived, hence the attribution
// still credits OpenStreetMap) and Esri. Anything added here later must be
// checked the same way — a free tile URL is not the same thing as a tile URL we
// are allowed to ship.
const ESRI_STREET = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}';
const ESRI_STREET_ATTR = 'Tiles &copy; Esri — Source: Esri, HERE, Garmin, &copy; OpenStreetMap contributors';
// Selectable basemaps for the world (great-circle) map. All key-free and all
// LABELLED (country/continent names). `labelsUrl` adds a transparent place-name
@@ -54,7 +66,7 @@ const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: string; s
light: { label: 'Light', url: CARTO_LIGHT, attr: CARTO_ATTR, subdomains: 'abcd' },
voyager: { label: 'Voyager', url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
attr: CARTO_ATTR, subdomains: 'abcd' },
street: { label: 'Street', url: OSM, attr: OSM_ATTR },
street: { label: 'Street', url: ESRI_STREET, attr: ESRI_STREET_ATTR },
satellite: { label: 'Satellite', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attr: 'Tiles &copy; Esri — Source: Esri, Maxar, Earthstar Geographics',
labelsUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}' },
@@ -115,6 +127,12 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
// pans/zooms freely (e.g. a whole-world view) and the view is remembered
// across restarts. Default on.
const [autoZoom, setAutoZoom] = useState(() => localStorage.getItem('opslog.mapAutoZoomDX') !== '0');
// Grey line — the day/night terminator with its twilight band. Off by
// default: it is a working tool for the operator who wants it, not decoration
// for the one who does not.
const [greyline, setGreyline] = useState(() => localStorage.getItem('opslog.mapGreyline') === '1');
const greylineLayer = useRef<L.LayerGroup | null>(null);
const autoZoomRef = useRef(autoZoom);
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
@@ -152,6 +170,80 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
}, [basemap]);
// Draw the grey line, and keep it moving.
//
// Redrawn every minute: the terminator travels a quarter of a degree in that
// time, which is invisible, but a map left on all day would otherwise be
// silently wrong by evening — and the whole point of the display is knowing
// where the line is NOW.
//
// It lives in its own pane below the overlay pane so the path and beam are
// never hidden behind the shading, and is non-interactive so it can never
// swallow a click meant for the map.
useEffect(() => {
const m = worldMap.current;
if (!m) return;
if (!greyline) {
greylineLayer.current?.remove();
greylineLayer.current = null;
return;
}
if (!m.getPane('greyline')) {
const pane = m.createPane('greyline');
pane.style.zIndex = '350'; // above tiles (200), below overlays (400)
pane.style.pointerEvents = 'none';
}
const draw = () => {
const now = new Date();
greylineLayer.current?.remove();
const g = L.layerGroup([], { pane: 'greyline' });
// The rings span 180…+180 once, but this map repeats the world sideways
// (worldCopyJump, and zoomed out several copies are on screen at once). A
// single ring therefore ends in a hard vertical edge wherever a copy
// begins — the shading looked like a rectangle laid over the planet.
//
// So each ring is drawn again shifted a full turn either way. Three copies
// cover every zoom level this map reaches; they cost nothing, being three
// polygons on a canvas layer.
const COPIES = [-360, 0, 360];
const shifted = (ring: [number, number][], by: number): [number, number][] =>
by === 0 ? ring : ring.map(([lat, lon]) => [lat, lon + by] as [number, number]);
// Two bands, drawn dark-to-light so they read as one gradient into night:
// full darkness (Sun below the horizon) and civil twilight (down to 6°),
// which is the band operators actually mean by "grey line".
const nightRing = nightPolygon(now, 0);
const twilightRing = nightPolygon(now, -6);
// The terminator itself, so the line is readable at a glance rather than
// being inferred from where the shading fades. Dropping the last two
// points leaves the terminator alone, without the segment that closes the
// polygon along the dark pole.
const lineRing = nightRing.slice(0, -2);
for (const by of COPIES) {
L.polygon(shifted(twilightRing, by), {
pane: 'greyline', stroke: false, fillColor: '#0b1220', fillOpacity: 0.22, interactive: false,
}).addTo(g);
L.polygon(shifted(nightRing, by), {
pane: 'greyline', stroke: false, fillColor: '#0b1220', fillOpacity: 0.32, interactive: false,
}).addTo(g);
L.polyline(shifted(lineRing, by), {
pane: 'greyline', color: '#f59e0b', weight: 1, opacity: 0.75, interactive: false,
}).addTo(g);
}
g.addTo(m);
greylineLayer.current = g;
};
draw();
const id = window.setInterval(draw, 60_000);
return () => { window.clearInterval(id); };
}, [greyline]);
// Redraw overlays whenever the operator/DX grids (or beam) change.
useEffect(() => {
const wm = worldMap.current, wo = worldOverlay.current;
@@ -293,6 +385,21 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
>
Zoom DX
</button>
{/* Grey line toggle, under the zoom button. */}
<button
type="button"
onClick={() => {
const v = !greyline;
setGreyline(v);
writeUiPref('opslog.mapGreyline', v ? '1' : '0');
}}
title={greyline ? 'Grey line is ON — day/night terminator and twilight band' : 'Show the grey line (day/night terminator)'}
className={`absolute top-9 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
greyline ? 'bg-primary text-primary-foreground border-primary' : 'bg-card/90 text-muted-foreground border-border hover:bg-card'
}`}
>
Grey line
</button>
{path && (
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
@@ -320,7 +427,14 @@ export function LocatorMap({ toGrid, toLabel }: LocatorProps) {
if (locatorRef.current && !locatorMap.current) {
const m = L.map(locatorRef.current, { zoomControl: true, attributionControl: true })
.setView([20, 0], 2);
L.tileLayer(OSM, { attribution: OSM_ATTR, maxZoom: 19 }).addTo(m);
// Same restraint as the world map: fetch on idle, not mid-pan, and keep a
// single ring of buffer tiles. Requesting tiles as fast as the pointer
// moves is what gets an app blocked, and the next provider is under no
// more obligation to tolerate it than the last one was.
L.tileLayer(CARTO_LIGHT, {
attribution: CARTO_ATTR, subdomains: 'abcd', maxZoom: 19,
updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1,
}).addTo(m);
locatorOverlay.current = L.layerGroup().addTo(m);
locatorMap.current = m;
}
+5 -1
View File
@@ -6,7 +6,10 @@ import {
import { cn } from '@/lib/utils';
export type MenuItem =
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean }
// accent highlights an item that is an OFFER rather than a command (Donate).
// Generic on purpose: a hard-coded label test in the renderer would break the
// moment the menu is translated.
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean; accent?: boolean }
| { type: 'separator' };
export interface Menu {
@@ -77,6 +80,7 @@ export function Menubar({ menus, onAction }: Props) {
<DropdownMenuItem
key={i}
disabled={item.disabled}
className={item.accent ? 'text-warning focus:text-warning font-medium' : undefined}
onSelect={() => onAction(item.action)}
>
<span>{item.label}</span>
+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';
import { hamlogGridTheme } from '@/lib/gridTheme';
import { AgGridReact } from 'ag-grid-react';
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus, History } from 'lucide-react';
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus, History, ChevronUp, ChevronDown } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
@@ -42,14 +42,28 @@ function fmtTimeOn(s: any): string {
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 = {
onLogged?: () => void;
countries?: string[];
bands?: 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 [nets, setNets] = useState<Net[]>([]);
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).
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
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.
const [contactOpen, setContactOpen] = useState(false);
@@ -68,6 +84,34 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
const [looking, setLooking] = useState(false);
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).
const [wbCall, setWbCall] = useState('');
@@ -122,6 +166,53 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
catch { /* ignore */ }
}, []);
// ---- Mic pass order ------------------------------------------------------
// The on-air grid shows the stations in the order the mic goes around — NOT
// by log time. The order is owned here as a list of draft ids: new check-ins
// join at the END of the queue, logged/cancelled ones drop out, and the ⬆⬇
// buttons move the selected station. Persisted per net so a panel remount
// (tab switch) keeps the round.
const [orderIds, setOrderIds] = useState<number[]>([]);
const orderKey = openId ? `opslog.netOrder.${openId}` : '';
// Load the saved order when a net opens.
useEffect(() => {
if (!orderKey) { setOrderIds([]); return; }
try { setOrderIds(JSON.parse(localStorage.getItem(orderKey) || '[]')); }
catch { setOrderIds([]); }
}, [orderKey]);
// Reconcile with the live list: keep known ids in their order, append new
// ones, drop the gone. Persist.
useEffect(() => {
setOrderIds((cur) => {
const liveIds = active.map((a) => a.id as number).filter((id) => id != null);
const liveSet = new Set(liveIds);
const kept = cur.filter((id) => liveSet.has(id));
const keptSet = new Set(kept);
const next = [...kept, ...liveIds.filter((id) => !keptSet.has(id))];
if (next.length === cur.length && next.every((v, i) => v === cur[i])) return cur;
if (orderKey) { try { localStorage.setItem(orderKey, JSON.stringify(next)); } catch { /* quota */ } }
return next;
});
}, [active, orderKey]);
const activeOrdered = useMemo(() => {
const byId = new Map(active.map((a) => [a.id as number, a]));
return orderIds.map((id) => byId.get(id)).filter(Boolean) as QSOForm[];
}, [active, orderIds]);
// Move the selected on-air station up/down the queue.
const moveSelected = useCallback((dir: -1 | 1) => {
const id = selectedActiveIds[0];
if (id == null) return;
setOrderIds((cur) => {
const i = cur.indexOf(id);
const j = i + dir;
if (i < 0 || j < 0 || j >= cur.length) return cur;
const next = [...cur];
[next[i], next[j]] = [next[j], next[i]];
if (orderKey) { try { localStorage.setItem(orderKey, JSON.stringify(next)); } catch { /* quota */ } }
return next;
});
}, [selectedActiveIds, orderKey]);
useEffect(() => { refreshNets(); }, [refreshNets]);
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
@@ -177,10 +268,38 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
catch (e: any) { setError(String(e?.message ?? e)); }
}
// 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) {
if (id == null) return;
try { await NetDeactivate(id); await refreshActive(); onLogged?.(); }
catch (e: any) { setError(String(e?.message ?? e)); }
const next = activeOrdered.find((a) => a.id !== id); // computed BEFORE the list shrinks
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).
@@ -232,7 +351,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
}
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: 'QTH', field: 'qth', flex: 1 },
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 },
@@ -281,11 +401,15 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
{t('ncp.onAirActive')}
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
</div>
<div className="flex flex-col min-h-0 flex-1">
<div ref={onAirWrap} className="flex flex-col min-h-0 flex-1">
<RecentQSOsGrid
rows={active}
total={active.length}
rows={activeOrdered}
total={activeOrdered.length}
storageKey="net.onair"
selectRowSignal={selectRow}
rowDragCall
passOrder
onGridApi={(api) => { onAirApi.current = api; wireDropZones(); }}
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
onRowDoubleClicked={(q) => setEditingDraft(q)}
onRowSelected={setSelectedActiveIds}
@@ -297,6 +421,22 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
</Button>
{/* Mic pass order: move the selected station up/down the round. */}
<div className="flex items-center gap-0.5 pl-1 border-l border-border/50">
<Button variant="ghost" size="icon" className="size-7" title={t('ncp.moveUp')}
disabled={selectedActiveIds[0] == null || orderIds.indexOf(selectedActiveIds[0]) <= 0}
onClick={() => moveSelected(-1)}>
<ChevronUp className="size-4" />
</Button>
<Button variant="ghost" size="icon" className="size-7" title={t('ncp.moveDown')}
disabled={selectedActiveIds[0] == null || orderIds.indexOf(selectedActiveIds[0]) >= orderIds.length - 1}
onClick={() => moveSelected(1)}>
<ChevronDown className="size-4" />
</Button>
</div>
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto" onClick={logAll}>
<MinusCircle className="size-3.5" /> {t('ncp.logAll', { n: active.length })}
</Button>
</div>
)}
@@ -330,6 +470,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
rows={(wb.entries ?? []) as any}
total={wb.count}
storageKey="net.wb"
{...qsoMenuHandlers}
/>
)}
</div>
@@ -343,7 +484,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
{t('ncp.netUsersRoster')}
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
</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 }}>
<AgGridReact<Station>
ref={rosterGrid}
@@ -352,6 +493,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
columnDefs={rosterCols}
defaultColDef={defaultColDef}
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)}
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
animateRows={false}
+10 -1
View File
@@ -8,7 +8,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { EventsOn } from '../../wailsjs/runtime/runtime';
@@ -120,6 +120,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
}, [service]);
// Closing the QSL Manager (the tab's ×) unmounts this panel — cancel any download
// still running so a slow QRZ/LoTW sync doesn't keep going in the background and
// bleed its log into the next one.
useEffect(() => () => { CancelConfirmations().catch(() => {}); }, []);
const [potaSyncing, setPotaSyncing] = useState(false);
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
const [potaErr, setPotaErr] = useState('');
@@ -427,6 +431,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
// drives which QSOs the apply-form below updates; a search selects all.
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
<RecentQSOsGrid
// Its OWN column layout. Without a storageKey this fell back to
// the main log's key, so every resize or hidden column here
// silently rewrote the Recent QSOs layout, and vice versa.
storageKey="qslmgr.paper"
rows={paperRows as any}
total={paperRows.length}
selectAllSignal={paperSelAllSig}
@@ -532,6 +540,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
) : (
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
<RecentQSOsGrid
storageKey="qslmgr.upload"
rows={rows as any}
total={rows.length}
selectAllSignal={uploadSelAllSig}
+47 -7
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
import { useI18n } from '@/lib/i18n';
@@ -15,6 +15,7 @@ type Props = {
onSendEQSL?: (ids: number[]) => void;
onBulkEdit?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void;
onExportSelectedFields?: (ids: number[]) => void;
onExportFiltered?: () => void;
onExportCabrilloSelected?: (ids: number[]) => void;
onExportCabrilloFiltered?: () => void;
@@ -35,8 +36,14 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
// which used to dismiss the menu the instant it appeared.)
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
const { t } = useI18n();
const boxRef = useRef<HTMLDivElement>(null);
// Starts at the cursor; the layout effect corrects it once the real size is
// known. Rendering at the cursor first avoids a visible jump for the common
// case where it already fits.
const [pos, setPos] = useState({ x: 0, y: 0 });
useEffect(() => { if (menu) setPos({ x: menu.x, y: menu.y }); }, [menu]);
useEffect(() => {
if (!menu) return;
const close = () => onClose();
@@ -49,16 +56,40 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
};
}, [menu, onClose]);
// Position AFTER measuring.
//
// The menu used to be clamped against a guessed height — 230 px when the
// upload submenu was present, 110 otherwise. It is far taller than that with
// a selection, so right-clicking near the bottom of the window cut half the
// entries off. Its height also depends on which actions the caller passes, so
// no constant can be right for long.
//
// Measured instead: if it does not fit below the cursor it is placed above,
// and failing that pinned to the top with its own scrollbar.
useLayoutEffect(() => {
if (!menu || !boxRef.current) return;
const r = boxRef.current.getBoundingClientRect();
const pad = 6;
let x = menu.x;
let y = menu.y;
if (x + r.width > window.innerWidth - pad) {
x = Math.max(pad, window.innerWidth - r.width - pad);
}
if (y + r.height > window.innerHeight - pad) {
// Above the cursor, if there is room there.
y = menu.y - r.height >= pad ? menu.y - r.height : Math.max(pad, window.innerHeight - r.height - pad);
}
setPos({ x, y });
}, [menu, onSendTo]);
if (!menu) return null;
const n = menu.ids.length;
// Keep the menu on-screen near the cursor.
const x = Math.min(menu.x, window.innerWidth - 248);
const y = Math.min(menu.y, window.innerHeight - (onSendTo ? 230 : 110));
return (
<div
className="fixed z-[200] min-w-[240px] rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
style={{ left: x, top: y }}
ref={boxRef}
className="fixed z-[200] min-w-[240px] max-h-[85vh] overflow-y-auto rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
style={{ left: pos.x, top: pos.y }}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
@@ -137,6 +168,15 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
<span>{t('qctx.exportSelectedAdif', { n })}</span>
</button>
)}
{onExportSelectedFields && (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onExportSelectedFields(menu.ids); onClose(); }}
>
<FileDown className="size-4 text-info" />
<span>{t('qctx.exportSelectedFields', { n })}</span>
</button>
)}
{onExportFiltered && (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
+201 -36
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Trash2, Search, Loader2 } from 'lucide-react';
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings } from '../../wailsjs/go/main/App';
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings, OpenExternalURL, SetOpsLogQSLReceived } from '../../wailsjs/go/main/App';
import { rstOptions, type RSTLists } from '@/lib/rst';
import { AwardRefSelector } from '@/components/AwardRefSelector';
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
@@ -22,6 +22,7 @@ import { Combobox } from '@/components/ui/combobox';
import { cn } from '@/lib/utils';
import { flagURL } from '@/lib/flags';
import { useI18n } from '@/lib/i18n';
import { titleCase, sentenceCase } from '@/lib/textCase';
import type { QSOForm } from '@/types';
type QSO = QSOForm;
@@ -37,7 +38,7 @@ function pfxOf(call: string): string {
return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
}
const BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm'];
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
// label holds an i18n key (resolved with t() at render time).
const QSL_STATUSES = [
@@ -72,6 +73,11 @@ const CONF_LABEL_KEYS: Record<string, string> = {
QSL: 'qedit.confQslPaper',
};
// OpsLog's own card. Kept out of CONFIRMATIONS on purpose — that list maps QSO
// columns and this channel is backed by ADIF extras — but it still belongs in
// the channel picker and the status table alongside the rest.
const OPSLOG_CONF = 'OPSLOG';
// Colour-coded status cell for the confirmation grid.
function StatusCell({ value }: { value?: string }) {
const { t } = useI18n();
@@ -148,6 +154,56 @@ function F({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6;
);
}
// AdifDateInput — a real date picker over an ADIF date.
//
// ADIF stores YYYYMMDD; the browser's date input speaks YYYY-MM-DD, so the two
// are converted at the edge and the log keeps its ADIF form. The native control
// is used on purpose: it brings the OS calendar, the locale's day/month order
// and keyboard entry for free, which a hand-rolled popover would have to
// reimplement and get wrong.
//
// A value that is NOT a valid 8-digit date (an old hand-typed entry) is shown in
// a plain text box instead, so it stays visible and correctable rather than
// silently disappearing behind an empty picker.
function AdifDateInput({ value, onChange, disabled }: { value?: string; onChange: (v: string) => void; disabled?: boolean }) {
const raw = (value ?? '').trim();
const valid = /^\d{8}$/.test(raw);
const iso = valid ? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}` : '';
const today = () => {
const d = new Date();
const p = (n: number) => String(n).padStart(2, '0');
// UTC: every date in the log is UTC, and near midnight the local day is the
// wrong one.
onChange(`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}`);
};
if (raw !== '' && !valid) {
return (
<div className="flex gap-1">
<Input value={raw} onChange={(e) => onChange(e.target.value)} disabled={disabled} className="font-mono" />
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" onClick={() => onChange('')} title="Clear">×</Button>
</div>
);
}
return (
<div className="flex gap-1">
<Input
type="date"
value={iso}
disabled={disabled}
onChange={(e) => {
const v = e.target.value; // "" when cleared
onChange(v ? v.replace(/-/g, '') : '');
}}
className="font-mono"
/>
<Button type="button" variant="outline" size="sm" className="shrink-0 px-2" disabled={disabled}
onClick={today} title="Today (UTC)">
<CalendarDays className="size-3.5" />
</Button>
</div>
);
}
function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
const { t } = useI18n();
return (
@@ -280,19 +336,28 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
setLooking(true);
setLocalErr('');
try {
const r: any = await LookupCallsign(call);
// FRESH: this fetch is a deliberate click, so it bypasses the cache and
// refreshes it. A cached answer from a thinner QRZ subscription (or any
// stale row) otherwise stayed for its whole 30-day life and the button
// appeared to do nothing.
const r: any = await LookupCallsignFresh(call);
// The lookup WINS over what is in the record — that is the point of asking
// for it. But an EMPTY result must never blank a good value: `??` only
// guards against null, and Go marshals an unset string as "", so a QRZ
// record with no grid used to wipe the grid that was already there.
const keep = (found: any, cur: any) => (found === undefined || found === null || found === '' ? cur : found);
setDraft((d) => ({
...d,
name: r.name ?? d.name,
qth: r.qth ?? d.qth,
address: r.address ?? (d as any).address,
email: r.email ?? (d as any).email,
country: r.country ?? d.country,
grid: r.grid ?? d.grid,
state: r.state ?? d.state,
cnty: r.cnty ?? d.cnty,
cont: r.cont ?? d.cont,
qsl_via: r.qsl_via ?? d.qsl_via,
name: keep(r.name, d.name),
qth: keep(r.qth, d.qth),
address: keep(r.address, (d as any).address),
email: keep(r.email, (d as any).email),
country: keep(r.country, d.country),
grid: keep(r.grid, d.grid),
state: keep(r.state, d.state),
cnty: keep(r.cnty, d.cnty),
cont: keep(r.cont, d.cont),
qsl_via: keep(r.qsl_via, d.qsl_via),
dxcc: r.dxcc || d.dxcc,
cqz: r.cqz || d.cqz,
ituz: r.ituz || d.ituz,
@@ -369,6 +434,26 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
[draft.extras],
);
// OpsLog QSL "received" marker (ADIF extra). Drives the card's PSE/TNX stamp:
// received → TNX QSL (thanks for your card), otherwise PSE QSL (please send one).
// Saved with the normal Save via draft.extras.
const OPSLOG_QSL_RCVD = 'APP_OPSLOG_QSL_RCVD';
const qslReceived = !!String(draft.extras?.[OPSLOG_QSL_RCVD] ?? '').trim();
// Sent side, for the confirmations table. Two key names because the marker was
// renamed once and old QSOs still carry the first one — same test the Recent
// QSOs "OpsLog QSL" column makes.
const opslogQslSent = !!(draft.extras?.['APP_OPSLOG_QSL_SENT'] || draft.extras?.['APP_OPSLOG_QSL_CARD_SENT']);
const toggleQslReceived = (on: boolean) => {
// Reflect immediately in the draft (for the PSE/TNX indicator)…
const next = { ...(draft.extras ?? {}) };
if (on) next[OPSLOG_QSL_RCVD] = new Date().toISOString();
else delete next[OPSLOG_QSL_RCVD];
set('extras', next as any);
// …and persist right away with a targeted write that reliably sets OR clears
// the key (the modal's extras-merge on Save wouldn't clear a removed key).
if ((draft as any).id) SetOpsLogQSLReceived((draft as any).id, on).catch(() => {});
};
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
@@ -407,7 +492,22 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
{/* Top: Callsign + RST + Fetch */}
<div className="flex items-end gap-2 mb-3">
<div className="flex flex-col flex-1 min-w-0">
<Label>{t('qedit.callsign')}</Label>
<div className="flex items-center">
<Label>{t('qedit.callsign')}</Label>
{(draft.callsign ?? '').trim() && (
<button
type="button"
title={t('qrz.openTitle', { call: (draft.callsign ?? '').trim().toUpperCase() })}
onClick={() => {
const c = (draft.callsign ?? '').trim().toUpperCase().split('/').map(encodeURIComponent).join('/');
if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch((e: any) => setLocalErr(String(e?.message ?? e)));
}}
className="ml-auto shrink-0 inline-flex items-center gap-0.5 text-[9px] font-semibold normal-case tracking-wider text-primary hover:underline"
>
QRZ
</button>
)}
</div>
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-10"
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
</div>
@@ -424,7 +524,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
{/* ── Left column ── */}
<div className="flex flex-col gap-2.5">
<div><Label>{t('qedit.name')}</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></div>
<div><Label>{t('qedit.name')}</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)}
onBlur={() => set('name', titleCase(draft.name ?? '') as any)} /></div>
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">{t('qedit.band')}</Label>
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
@@ -490,8 +591,10 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<div className="flex flex-col flex-1"><Label>{t('qedit.grid')}</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
<div className="flex flex-col w-24"><Label>PFX</Label><Input readOnly value={pfxOf(draft.callsign ?? '')} className="font-mono bg-muted/40" /></div>
</div>
<div><Label>{t('qedit.comment')}</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></div>
<div><Label>{t('qedit.note')}</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></div>
<div><Label>{t('qedit.comment')}</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)}
onBlur={() => set('comment', sentenceCase(draft.comment ?? '') as any)} /></div>
<div><Label>{t('qedit.note')}</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)}
onBlur={() => set('notes', sentenceCase(draft.notes ?? '') as any)} /></div>
</div>
</div>
</TabsContent>
@@ -512,7 +615,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
</SelectContent>
</Select>
</div>
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></div>
<div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)}
onBlur={() => set('qth', titleCase(draft.qth ?? '') as any)} /></div>
<div><Label>{t('qedit.address')}</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
</div>
{/* Right column */}
@@ -522,7 +626,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<div className="flex flex-col flex-1"><Label>Lat</Label><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
<div className="flex flex-col flex-1"><Label>Lon</Label><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
</div>
<div><Label>{t('qedit.qslMsg')}</Label><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></div>
{/* The OpsLog QSL marker used to sit here, under QSL Msg. It
belongs with the other confirmation channels — see the QSL
Info tab. */}
<div>
<Label>{t('qedit.qslMsg')}</Label>
<Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} />
</div>
<div><Label>{t('qedit.qslVia')}</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></div>
</div>
</div>
@@ -570,25 +680,69 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<Label>{t('qedit.manageConf')}</Label>
<Select value={confSel} onValueChange={setConfSel}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</SelectItem>)}</SelectContent>
<SelectContent>
{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</SelectItem>)}
{/* Listed here but NOT in CONFIRMATIONS: that table maps
QSO columns, and this channel lives in the ADIF
extras. It gets its own editor below rather than the
generic sent/received/date grid, which has no field
to bind to. */}
<SelectItem value={OPSLOG_CONF}>{t('qedit.confOpsLog')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div><Label>{t('qedit.sent')}</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
<div><Label>{t('qedit.received')}</Label>
{def.rcvd
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
: <Input disabled value="—" />}
{confSel === OPSLOG_CONF ? (
/* OpsLog's own card. "Sent" is stamped when the card
actually goes out, so it is shown, not offered: ticking
it by hand would record something that never happened.
"Received" drives the PSE/TNX stamp printed on the card,
which is the whole reason the flag exists — hence the
live indicator next to it. It writes immediately rather
than on Save, because clearing an extras key would not
survive the merge Save does. */
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label>{t('qedit.sent')}</Label>
<Input disabled value={opslogQslSent ? t('qedit.qslYes') : t('qedit.qslNo')} />
</div>
<div>
<Label>{t('qedit.received')}</Label>
<label className="flex h-9 items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={qslReceived} onCheckedChange={(c) => toggleQslReceived(!!c)} />
{t('qedit.qslReceived')}
</label>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{qslReceived ? 'TNX QSL' : 'PSE QSL'}
</span>
<span className="text-[11px] text-muted-foreground">{t('qedit.pseTnxHint')}</span>
</div>
<p className="text-[11px] text-muted-foreground">{t('qedit.opslogSentHint')}</p>
</div>
<div><Label>{t('qedit.dateSent')}</Label><Input value={val(def.sentDate)} placeholder="YYYYMMDD" onChange={(e) => put(def.sentDate, e.target.value)} className="font-mono" /></div>
<div><Label>{t('qedit.dateReceived')}</Label><Input value={val(def.rcvdDate)} placeholder="YYYYMMDD" disabled={!def.rcvdDate} onChange={(e) => put(def.rcvdDate, e.target.value)} className="font-mono" /></div>
{def.via && (
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div>
)}
</div>
<p className="text-[11px] text-muted-foreground">
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
</p>
) : (
<>
<div className="grid grid-cols-2 gap-3">
<div><Label>{t('qedit.sent')}</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
<div><Label>{t('qedit.received')}</Label>
{def.rcvd
? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
: <Input disabled value="—" />}
</div>
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={val(def.sentDate)} onChange={(v) => put(def.sentDate, v)} /></div>
<div><Label>{t('qedit.dateReceived')}</Label><AdifDateInput value={val(def.rcvdDate)} onChange={(v) => put(def.rcvdDate, v)} disabled={!def.rcvdDate} /></div>
{def.via && (
<div className="col-span-2"><Label>{t('qedit.via')}</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder={t('qedit.viaPlaceholder')} /></div>
)}
</div>
<p className="text-[11px] text-muted-foreground">
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
</p>
</>
)}
</div>
{/* Right: live status grid for every channel.
@@ -613,6 +767,17 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground"></span>}</td>
</tr>
))}
{/* OpsLog's own card, read from the ADIF extras rather
than a QSO column — hence a hand-written row instead
of a CONFIRMATIONS entry. "Sent" is stamped by OpsLog
when the card actually goes out, so it stays
read-only here: an operator ticking it by hand would
be recording something that never happened. */}
<tr className="text-xs">
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{t('qedit.confOpsLog')}</td>
<td className="w-24"><StatusCell value={opslogQslSent ? 'Y' : 'N'} /></td>
<td className="w-24"><StatusCell value={qslReceived ? 'Y' : 'N'} /></td>
</tr>
</tbody>
</table>
</div>
+271 -54
View File
@@ -4,8 +4,10 @@ import {
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
} from 'ag-grid-community';
import { hamlogGridTheme } from '@/lib/gridTheme';
import { qslStatusCellClass } from '@/lib/qslStatus';
import { formatDateTimeUTC, formatDateOnly, getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
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 { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
import {
@@ -15,6 +17,7 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
import { useI18n } from '@/lib/i18n';
import { gridToLatLon, pathBetweenLatLon } from '@/lib/maidenhead';
// Register every Community feature once. v32+ requires explicit registration;
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
@@ -27,9 +30,25 @@ const hamlogTheme = hamlogGridTheme.withParams({ rowHeight: 32, headerHeight: 34
type Props = {
rows: QSOForm[];
total: number;
// Operator's CURRENT locator — fallback for the distance column on older QSOs
// that carry no my_grid of their own.
myGrid?: string;
// Bump this number to programmatically select every row (e.g. after a search
// in the QSL Manager, where the default is "all selected").
selectAllSignal?: number;
// 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
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
// reuse this grid elsewhere with its OWN independent column config.
@@ -37,6 +56,10 @@ type Props = {
onRowDoubleClicked?: (q: QSOForm) => void;
onRowClicked?: (q: QSOForm) => void;
onRowSelected?: (ids: number[]) => void;
// The full first selected row — the Stats (F1) panel shows its entity when the
// entry form is empty, so browsing the log answers "what have I got with this
// one?" without retyping the call.
onRowSelectedQso?: (row: QSOForm | null) => void;
onUpdateFromCty?: (ids: number[]) => void;
onUpdateFromQRZ?: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
@@ -45,10 +68,15 @@ type Props = {
onSendEQSL?: (ids: number[]) => void;
onBulkEdit?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void;
onExportSelectedFields?: (ids: number[]) => void;
onExportFiltered?: () => void;
onExportCabrilloSelected?: (ids: number[]) => void;
onExportCabrilloFiltered?: () => void;
onDelete?: (ids: number[]) => void;
// Reports how many rows the grid shows after its COLUMN filters (the funnel
// icons), or null when no column filter is active — so the parent's "Showing X
// of Y" can reflect them. Fired on filter change and when the data updates.
onFilteredCountChange?: (count: number | null) => void;
// One column per defined award; the cell shows the reference this QSO counts
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
awardCols?: { code: string; name: string }[];
@@ -63,23 +91,13 @@ function fmtMhzDots(hz?: number): string {
return `${i}.${f.slice(0, 3)}.${f.slice(3, 6)}`;
}
// Both formatters follow the display preference (Settings → General). What is
// STORED never changes — see lib/dateFormat.
function fmtDateUTC(s: any): string {
if (!s) return '';
const d = new Date(s);
if (isNaN(d.getTime())) return s;
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
return formatDateTimeUTC(s);
}
function fmtDateOnly(s: any): string {
if (!s) return '';
const t = String(s).trim();
// QSL/LoTW/eQSL/ClubLog dates are ADIF YYYYMMDD; upload dates may be ISO.
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
const d = new Date(t);
if (isNaN(d.getTime())) return t;
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
return formatDateOnly(s);
}
// Full catalog of selectable columns, grouped for the picker. `defaultVisible`
@@ -93,7 +111,26 @@ export type ColEntry = ColDef<QSOForm> & { group: string; label: string; default
// hooks can't run at module level, so the component calls this with its own t.
type TFn = (key: string, vars?: Record<string, string | number>) => string;
export const makeColCatalog = (t: TFn): ColEntry[] => [
// qsoDistanceKm returns the great-circle distance for one row, in km.
//
// The QSO's OWN my_grid / my_lat / my_lon come first: a log spans years and
// portable outings, so the station the QSO was made from is not necessarily the
// one configured today. `myGrid` (the current profile) is only the fallback for
// the many older records that carry no my_* fields at all.
function qsoDistanceKm(d: any, myGrid?: string): number | undefined {
if (!d) return undefined;
const here =
(d.my_grid && gridToLatLon(d.my_grid)) ||
(d.my_lat || d.my_lon ? { lat: d.my_lat || 0, lon: d.my_lon || 0 } : null) ||
(myGrid ? gridToLatLon(myGrid) : null);
const there =
(d.grid && gridToLatLon(d.grid)) ||
(d.lat || d.lon ? { lat: d.lat || 0, lon: d.lon || 0 } : null);
if (!here || !there) return undefined;
return Math.round(pathBetweenLatLon(here, there).distanceShort);
}
export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
// ── QSO basics ──
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
@@ -128,12 +165,17 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
{ group: 'Contacted', label: t('rqg.c.age'), colId: 'age', headerName: t('rqg.c.age'), field: 'age' as any, width: 60, type: 'rightAligned' },
{ group: 'Contacted', label: t('rqg.c.lat'), colId: 'lat', headerName: t('rqg.c.lat'), field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
{ group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
// Derived, not stored: computed from the two locations at display time, like
// the cluster grid's own distance column.
{ group: 'Contacted', label: t('rqg.c.distance_km'), colId: 'distance_km', headerName: t('rqg.h.distance_km'), width: 90, type: 'rightAligned', cellClass: 'font-mono',
valueGetter: (p) => qsoDistanceKm(p.data, myGrid),
comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
{ group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 },
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
// ── QSL ──
{ group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 },
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 },
{ group: 'QSL', label: t('rqg.c.qsl_sent'), colId: 'qsl_sent', headerName: t('rqg.c.qsl_sent'), field: 'qsl_sent' as any, width: 80 , cellClass: qslStatusCellClass },
{ group: 'QSL', label: t('rqg.c.qsl_rcvd'), colId: 'qsl_rcvd', headerName: t('rqg.c.qsl_rcvd'), field: 'qsl_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
{ group: 'QSL', label: t('rqg.c.qsl_sent_date'),colId: 'qsl_sent_date', headerName: t('rqg.h.qsl_sent_date'), field: 'qsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'QSL', label: t('rqg.c.qsl_rcvd_date'),colId: 'qsl_rcvd_date', headerName: t('rqg.h.qsl_rcvd_date'), field: 'qsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'QSL', label: t('rqg.c.qsl_via'), colId: 'qsl_via', headerName: t('rqg.c.qsl_via'), field: 'qsl_via' as any, width: 130 },
@@ -141,32 +183,34 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
{ group: 'QSL', label: t('rqg.c.qslmsg_rcvd'), colId: 'qslmsg_rcvd', headerName: t('rqg.c.qslmsg_rcvd'), field: 'qslmsg_rcvd' as any, width: 200 },
// ── LoTW ──
{ group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 },
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 },
{ group: 'LoTW', label: t('rqg.c.lotw_sent'), colId: 'lotw_sent', headerName: t('rqg.c.lotw_sent'), field: 'lotw_sent' as any, width: 80 , cellClass: qslStatusCellClass },
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd'), colId: 'lotw_rcvd', headerName: t('rqg.c.lotw_rcvd'), field: 'lotw_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
{ group: 'LoTW', label: t('rqg.c.lotw_sent_date'), colId: 'lotw_sent_date', headerName: t('rqg.h.lotw_sent_date'), field: 'lotw_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'LoTW', label: t('rqg.c.lotw_rcvd_date'), colId: 'lotw_rcvd_date', headerName: t('rqg.h.lotw_rcvd_date'), field: 'lotw_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
// ── eQSL ──
{ group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 },
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 },
{ group: 'eQSL', label: t('rqg.c.eqsl_sent'), colId: 'eqsl_sent', headerName: t('rqg.c.eqsl_sent'), field: 'eqsl_sent' as any, width: 80 , cellClass: qslStatusCellClass },
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd'), colId: 'eqsl_rcvd', headerName: t('rqg.c.eqsl_rcvd'), field: 'eqsl_rcvd' as any, width: 80 , cellClass: qslStatusCellClass },
{ group: 'eQSL', label: t('rqg.c.eqsl_sent_date'), colId: 'eqsl_sent_date', headerName: t('rqg.h.eqsl_sent_date'), field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'eQSL', label: t('rqg.c.eqsl_rcvd_date'), colId: 'eqsl_rcvd_date', headerName: t('rqg.h.eqsl_rcvd_date'), field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_sent'), colId: 'opslog_qsl_card_sent', headerName: t('rqg.c.opslog_qsl_card_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
// App-specific: operator marked a QSL as RECEIVED for this QSO (drives PSE/TNX).
{ group: 'QSL', label: t('rqg.c.opslog_qsl_card_rcvd'), colId: 'opslog_qsl_card_rcvd', headerName: t('rqg.c.opslog_qsl_card_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_QSL_RCVD'] ? 'Y' : 'N'; }, defaultVisible: false },
// App-specific: when the QSO's audio recording was e-mailed to the station.
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
// ── Uploads (online logbooks) ──
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
// for QRZ only, a "download status/date" (= it came back confirmed). We
// relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have
// NO rcvd field in ADIF — they're upload-only, so only "sent" is shown.
{ group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 },
{ group: 'Uploads', label: t('rqg.c.clublog_sent'), colId: 'clublog_qso_upload_status', headerName: t('rqg.c.clublog_sent'), field: 'clublog_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
{ group: 'Uploads', label: t('rqg.c.clublog_sent_date'), colId: 'clublog_qso_upload_date', headerName: t('rqg.h.clublog_sent_date'), field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 },
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent'), colId: 'hrdlog_qso_upload_status', headerName: t('rqg.c.hrdlog_sent'), field: 'hrdlog_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
{ group: 'Uploads', label: t('rqg.c.hrdlog_sent_date'), colId: 'hrdlog_qso_upload_date', headerName: t('rqg.h.hrdlog_sent_date'), field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 },
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 },
{ group: 'Uploads', label: t('rqg.c.qrz_sent'), colId: 'qrzcom_qso_upload_status', headerName: t('rqg.c.qrz_sent'), field: 'qrzcom_qso_upload_status' as any, width: 100 , cellClass: qslStatusCellClass },
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
@@ -238,14 +282,24 @@ const GRP_KEYS: Record<string, string> = {
};
export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
// Award columns are governed SOLELY by the awardShown code-set, never by AG
// Grid's saved column state. Stripping them here (on both save and restore)
// stops a stale saved state from re-hiding a shown award column on every
// awardCols rebuild — the desync that made award columns vanish mid-session.
const stripAwardCols = (st: any[] | null | undefined): any[] =>
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
// Award-column VISIBILITY is governed SOLELY by the awardShown code-set, never
// by AG Grid's saved column state: dropping `hide` here (on both save and
// restore) stops a stale saved state from re-hiding a shown award column on
// every awardCols rebuild — the desync that made award columns vanish
// mid-session.
//
// Their POSITION, however, has to stay in the state. Filtering the entries out
// entirely left applyColumnState({applyOrder:true}) with no place for them, and
// AG Grid appends what the state doesn't mention — so a column the operator
// dragged to sit AFTER an award column jumped back in front of it on reload.
const sanitizeAwardCols = (st: any[] | null | undefined): any[] =>
(st ?? []).map((s) => {
if (!String(s?.colId ?? '').startsWith('award_')) return s;
const { hide: _hide, ...rest } = s as any;
return rest;
});
export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) {
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
@@ -253,9 +307,15 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
// (e.g. the Net panel) keeps its own layout independent of the main log.
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
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.
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
// Rebuild the columns when the format changes: the formatters are captured
// inside the column definitions, so nothing else would notice.
const [dateFmt, setDateFmt] = useState(getDateFormat);
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
// Right-click: if the clicked row isn't already part of the selection,
// select just it; then open the bulk-action menu on the whole selection.
@@ -308,17 +368,66 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
saveState(AWARD_SHOWN_KEY, [...next]);
}, []);
// Award-column WIDTHS are persisted separately too, for the same reason as
// visibility: award columns are stripped from AG Grid's saved column-state
// round-trip, so their width would otherwise reset to the default on reopen.
// Stored as an array of { code, width } (code upper-cased); mirrored to the DB.
const AWARD_WIDTH_KEY = storageKey ? `hamlog.awardColWidths.${storageKey}` : 'hamlog.awardColWidths';
const awardWidthsRef = useRef<Record<string, number>>({});
const awardWidthsInit = useRef(false);
if (!awardWidthsInit.current) {
awardWidthsInit.current = true;
for (const it of (loadLocal(AWARD_WIDTH_KEY) ?? []) as any[]) {
if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width);
}
}
// Fresh machine: hydrate widths from the portable DB copy, seed the cache, and
// apply them to any award columns already on screen.
useEffect(() => {
if (loadLocal(AWARD_WIDTH_KEY)) return;
loadRemote(AWARD_WIDTH_KEY).then((remote) => {
if (!remote || !remote.length) return;
for (const it of remote as any[]) {
if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width);
}
seedLocal(AWARD_WIDTH_KEY, remote);
const api = gridRef.current?.api;
if (api && awardCols?.length) {
const ups = awardCols
.map((a) => ({ key: `award_${a.code}`, newWidth: awardWidthsRef.current[a.code.toUpperCase()] }))
.filter((u) => !!u.newWidth);
if (ups.length) api.setColumnWidths(ups);
}
});
}, []);
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
restoringRef.current = true;
const base = COL_CATALOG.map((c) => {
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) => ({
colId: `award_${a.code}`,
headerName: a.code,
headerTooltip: t('rqg.awardTip', { name: a.name }),
width: 110,
width: awardWidthsRef.current[a.code.toUpperCase()] ?? 110,
cellClass: 'text-[11px]',
// Visibility comes from the persisted award-code set, so a column the user
// showed reappears on reopen and one they didn't stays hidden.
@@ -326,7 +435,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
}));
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 —
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
@@ -334,58 +443,122 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
useEffect(() => {
const api = gridRef.current?.api;
if (!api || !awardCols?.length) return;
const widthUps: { key: string; newWidth: number }[] = [];
for (const a of awardCols) {
const want = awardShown.has(a.code.toUpperCase());
const col = api.getColumn(`award_${a.code}`);
if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want);
// Re-apply the saved width — AG Grid keeps an existing column's width across
// a columnDefs rebuild instead of re-reading colDef.width (same quirk as hide).
const w = awardWidthsRef.current[a.code.toUpperCase()];
if (col && w && Math.round(col.getActualWidth()) !== w) widthUps.push({ key: `award_${a.code}`, newWidth: w });
}
if (widthUps.length) api.setColumnWidths(widthUps);
}, [awardCols, awardShown]);
// No column filters here, deliberately. This grid holds only the most recent
// page of QSOs, so a column filter searches THOSE rows and nothing else — and
// an empty result then reads as "the log does not contain it" rather than "it
// is not on this page". An operator hunting an imported CONTEST_ID across
// 28 000 QSOs found nothing and reasonably concluded the import had dropped it.
//
// The advanced filter queries the whole logbook and is the honest tool for it.
// Removing the filter also gives each header back the width of its menu icon.
const defaultColDef = useMemo<ColDef>(() => ({
sortable: true,
sortable: !passOrder,
resizable: true,
filter: true,
filter: 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) {
onGridApi?.(e.api);
const local = loadLocal(colStateKey);
if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
if (local) e.api.applyColumnState({ state: sanitizeState(sanitizeAwardCols(local)) as ColumnState[], applyOrder: true });
// Fall back to the portable DB copy when the local cache is empty
// (fresh machine / after a reinstall), then re-seed the cache.
loadRemote(colStateKey).then((remote) => {
if (remote && !local) {
e.api.applyColumnState({ state: stripAwardCols(remote) as ColumnState[], applyOrder: true });
e.api.applyColumnState({ state: sanitizeState(sanitizeAwardCols(remote)) as ColumnState[], applyOrder: true });
seedLocal(colStateKey, remote);
}
});
}
// Report the post-column-filter row count (funnel filters) to the parent, or
// null when no column filter is active, so "Showing X of Y" reflects them.
const reportFilteredCount = useCallback((e: { api?: any }) => {
const api = e?.api ?? gridRef.current?.api;
if (api?.getDisplayedRowCount) setDispCount(api.getDisplayedRowCount());
// Pass-order "#" is a rowIndex-based valueGetter; AG-Grid keeps the same
// row node across a reorder (getRowId by id), so the number is cached and
// stale until we force it. Refresh it on every model update (a reorder
// fires one). force:true bypasses the value cache.
if (passOrder && api?.refreshCells) {
api.refreshCells({ columns: ['__order'], force: true });
}
if (!api || !onFilteredCountChange) return;
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
}, [onFilteredCountChange, passOrder]);
const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState();
if (state) saveState(colStateKey, stripAwardCols(state));
}, []);
if (!state) return;
saveState(colStateKey, sanitizeAwardCols(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]);
// The award columns load asynchronously; when they arrive (or change) the
// columnDefs memo is rebuilt and AG Grid re-applies each colDef's `hide`
// default — wiping the user's saved visibility (award columns reappear,
// manually-shown ones like LoTW sent vanish). Re-apply the saved state after
// every rebuild so the user's choices win. No-op before the grid is ready.
// 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
// rebuild makes AG Grid re-apply every colDef's `hide` default, wiping the user's
// saved visibility of the NON-award columns (QTH/Grid reappear, a manually-shown
// LoTW-sent vanishes). Re-apply the saved (award-stripped) state after EVERY such
// rebuild — hence awardShown in the deps, not just awardCols; without it, toggling
// an award reset the other columns AND left restoringRef stuck true (saving off).
useEffect(() => {
const api = gridRef.current?.api;
const local = loadLocal(colStateKey);
if (api && local) api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
if (api && local) api.applyColumnState({ state: sanitizeState(sanitizeAwardCols(local)) as ColumnState[], applyOrder: true });
// Re-enable saving once AG Grid has settled the column events from the rebuild.
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t);
}, [awardCols]);
// Keyed on columnDefs ITSELF, not on the reasons it was rebuilt. Listing the
// causes here (awardCols/awardShown) missed the others — switching the UI
// language rebuilds the memo through `t`, which set restoringRef and left it
// set, so every column change for the rest of the session was silently
// discarded and the layout reverted on reload. Any future dependency of the
// memo is now covered for free.
}, [columnDefs]);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
}
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));
onRowSelectedQso?.(sel[0] ?? null);
}
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
useEffect(() => {
@@ -393,6 +566,21 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
gridRef.current?.api?.selectAll();
}, [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) ──
// 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
@@ -449,6 +637,32 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
return (
<>
<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)}
title={t('rqg.clearFiltersTitle')}>
<FilterX className="size-3.5" /> {t('rqg.clearFilters')}
@@ -467,6 +681,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
defaultColDef={defaultColDef}
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
onGridReady={onGridReady}
onFilterChanged={reportFilteredCount}
onModelUpdated={reportFilteredCount}
onColumnResized={saveColumnState}
onColumnMoved={saveColumnState}
onColumnPinned={saveColumnState}
@@ -495,6 +711,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
onSendEQSL={onSendEQSL}
onBulkEdit={onBulkEdit}
onExportSelected={onExportSelected}
onExportSelectedFields={onExportSelectedFields}
onExportFiltered={onExportFiltered}
onExportCabrilloSelected={onExportCabrilloSelected}
onExportCabrilloFiltered={onExportCabrilloFiltered}
+29 -1
View File
@@ -24,6 +24,9 @@ interface Props {
centerLat?: number | null; // operator latitude (projection centre)
centerLon?: number | null; // operator longitude
rotorEnabled?: boolean;
rotors?: string[]; // logical rotor names; >1 → show a selector
activeRotor?: number; // index of the selected rotor (0-based)
onSelectRotor?: (i: number) => void; // switch the active rotor
onGoto?: (az: number) => void; // click-to-turn
onClose?: () => void;
}
@@ -38,7 +41,7 @@ function pt(az: number, radius: number): [number, number] {
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
}
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, onGoto, onClose }: Props) {
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose }: Props) {
const cardinals = useMemo(
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
@@ -100,6 +103,31 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
)}
</div>
{/* Multiple rotors — pick which one the dial shows and turns. */}
{rotors && rotors.length > 1 && (
<div className="flex flex-wrap gap-1 px-2 pt-1.5">
{rotors.map((nm, i) => {
const active = (activeRotor ?? 0) === i;
const label = nm?.trim() || `Rotor ${i + 1}`;
return (
<button
key={i}
onClick={() => onSelectRotor?.(i)}
className={cn(
'flex-1 min-w-[48px] px-1.5 py-0.5 rounded text-[10px] font-semibold truncate transition-colors',
active
? 'bg-success text-success-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/70',
)}
title={label}
>
{label}
</button>
);
})}
</div>
)}
<div className="flex items-center justify-center p-2 min-h-0">
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
+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
+274 -44
View File
@@ -1,5 +1,5 @@
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 { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -8,26 +8,46 @@ import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { writeUiPref } from '@/lib/uiPref';
import { RotorCompass } from '@/components/RotorCompass';
import { AmpCard } from '@/components/AmpCard';
import { TunerCard } from '@/components/TunerCard';
import type { TGStatus } from '@/components/TunerGeniusPanel';
import {
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
GetRotatorHeading, RotatorGoTo, RotatorStop,
GetRotatorHeading, RotatorGoTo, RotatorStop, SetActiveRotor,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
GetAmpStatuses, GetFlexState,
GetTunerGeniusStatus, GetTunerGeniusSettings,
} from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; labels: string[] };
type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; channels?: number; labels: string[] };
type Relay = { number: number; label: string; on: boolean };
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8 };
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay' };
// Dashboard geometry: a grid of EQUAL columns whose cards also share a common
// HEIGHT per row — every card stretches to the tallest one beside it. Masonry
// packed tighter but left the row edges ragged, which read as untidy on a wide
// screen; aligned rows are what an operator expects from a dashboard.
const CARD_MIN = 430; // narrowest a card may get before "Auto" drops a column
const GRID_GAP = 16; // px between cards, both axes
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8, dingtian: 2 };
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)', dingtian: 'Dingtian IOT relay' };
// Relay count for a configured device: fixed by type, except Denkovi (4/8) and
// the generic USB-serial board, whose channel count the user picks.
const chanCount = (d: Device): number =>
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8)
: d.type === 'dingtian' ? (d.channels && d.channels >= 1 ? d.channels : 2)
: (RELAY_COUNT[d.type] ?? 5);
function blankDevice(): Device {
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
}
type Heading = { enabled: boolean; ok: boolean; azimuth: number };
type Heading = { enabled: boolean; ok: boolean; azimuth: number; rotors?: string[]; active?: number };
// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
// Station Control tab: a live compass you can click to turn, a heading readout, a
@@ -62,6 +82,9 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
centerLat={centerLat ?? null}
centerLon={centerLon ?? null}
rotorEnabled={hd.ok}
rotors={hd.rotors}
activeRotor={hd.active}
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
onGoto={(az) => turn(az)}
/>
<div className="flex-1 min-w-0 space-y-2">
@@ -199,9 +222,11 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
) : (
<div className="space-y-1.5">
{/* Hide 0-length elements an Ultrabeam reports 6 slots but a
3-element beam only uses the first few. */}
{lengths.map((mm, i) => ({ mm, i })).filter((e) => e.mm > 0).map(({ mm, i }) => (
{/* The READ_BANDS reply is undocumented and its 16-bit parse picks
up structural bytes past the real data (varying by band), which
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">
<span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
<button type="button" disabled={!ant.connected || busyEl !== null}
@@ -253,8 +278,59 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
});
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');
// How many columns the dashboard shows. "Auto" fits as many CARD_MIN-wide
// cards as the window allows; an explicit count is honoured as asked, even
// past that width — the operator can see his own screen.
const gridRef = useRef<HTMLDivElement | null>(null);
const [colCount, setColCount] = useState(1);
useLayoutEffect(() => {
if (cols !== 'auto') { setColCount(Number(cols) || 1); return; }
const el = gridRef.current;
if (!el) return;
const measure = () => setColCount(Math.max(1, Math.floor((el.clientWidth + GRID_GAP) / (CARD_MIN + GRID_GAP))));
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [cols]);
// Amplifiers (Settings → Amplifier): shown here so operators without a
// FlexRadio panel still get the controls. EVERY configured amp gets its own
// card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the
// 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 () => {
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
}, []);
@@ -340,24 +416,25 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
onClick={() => removeDevice(dev.id)}><Trash2 className="size-3.5" /></button>
</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) => {
const key = `${dev.id}:${r.number}`;
const label = r.label || `${t('station.relay')} ${r.number}`;
return (
<button key={r.number} type="button" disabled={!st?.connected}
title={label}
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')}>
<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')}>
{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 className="min-w-0">
<span className="block text-xs font-medium truncate">{label}</span>
<span className={cn('block text-[10px] font-semibold', r.on ? 'text-success' : 'text-muted-foreground')}>
{r.on ? t('station.on') : t('station.off')}
</span>
<span className="flex-1 min-w-0 text-xs font-medium truncate">{label}</span>
<span className={cn('text-[9px] font-bold shrink-0', r.on ? 'text-success' : 'text-muted-foreground/50')}>
{r.on ? t('station.on') : t('station.off')}
</span>
</button>
);
@@ -367,36 +444,38 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
);
};
const widgets: { id: string; node: React.ReactNode }[] = [];
// `wide` cards take two grid columns instead of one. The amplifier and tuner
// carry meter rows and a channel selector that are unreadable squeezed into a
// single ~430px column — they are the same cards the FlexRadio panel shows
// full-width, and they need that room here too.
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
if (rot.enabled) {
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
}
if (ant.enabled) {
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} />, wide: true });
// Tuner Genius XL card (identical to the Flex panel's).
if (tgEnabled) widgets.push({ id: 'tuner', node: <TunerCard status={tg} t={t} />, wide: true });
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
const widgetIds = ordered.map((w) => w.id);
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled;
// 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',
};
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && amps.length === 0 && !tgEnabled;
return (
<div className="flex-1 min-h-0 overflow-auto p-4">
<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>
<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]">
{(['auto', '2', '3', '4'] as const).map((c) => (
{(['auto', '1', '2', '3', '4', '5', '6'] as const).map((c) => (
<button key={c} type="button"
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
@@ -416,14 +495,36 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
</div>
)}
<div className={cn('grid gap-3 items-start', gridCols[cols] ?? gridCols.auto)}>
{/* Dashboard grid: equal columns, and every card stretches to the height of
the tallest one on its row, so the rows line up instead of ending
ragged. One column each, two for the amplifier/tuner whose meter rows
need the room. Each card has a grip handle (left rail) as the drag
initiator, since the body is full of buttons. */}
<div ref={gridRef} style={{
display: 'grid',
gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))`,
gap: `${GRID_GAP}px`,
}}>
{ordered.map((w) => (
<div key={w.id} draggable
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
onDrop={(e) => { e.preventDefault(); onDrop(w.id); }}
className={cn('cursor-grab active:cursor-grabbing', dragId.current === w.id && 'opacity-60')}>
{w.node}
<div key={w.id}
// A wide card takes two columns — but never more than there are, or
// the grid would grow a phantom column on a narrow window.
style={{ gridColumn: w.wide ? `span ${Math.min(2, colCount)}` : undefined }}
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
{/* h-full down the chain is what makes the card fill the row height
rather than sit at its natural size in a taller cell. */}
<div className="flex items-stretch h-full">
<div draggable
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
onDragEnd={() => { dragId.current = null; }}
title={t('station.dragMove')}
className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors',
dragId.current === w.id && 'opacity-60')}>
<GripVertical className="size-4" />
</div>
<div className="flex-1 min-w-0 [&>*]:h-full">{w.node}</div>
</div>
</div>
))}
</div>
@@ -445,11 +546,57 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []);
const setType = (type: string) => {
const n = RELAY_COUNT[type] ?? 5;
const channels = (type === 'denkovi' || type === 'usbrelay') ? (device.channels || 8) : undefined;
const n = chanCount({ ...device, type, channels });
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
onChange({ ...device, type, labels });
onChange({ ...device, type, channels, labels });
};
const setChannels = (channels: number) => {
const n = chanCount({ ...device, channels });
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
onChange({ ...device, channels, labels });
};
const isKM = device.type === 'kmtronic';
const isDingtian = device.type === 'dingtian';
const isDenkovi = device.type === 'denkovi';
const isUsbRelay = device.type === 'usbrelay';
// COM ports for the generic USB-serial relay picker.
const [serialPorts, setSerialPorts] = useState<string[]>([]);
useEffect(() => {
if (!isUsbRelay) return;
ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => setSerialPorts([]));
}, [isUsbRelay]);
// Detected FTDI serials for the Denkovi picker.
const [denkoviSerials, setDenkoviSerials] = useState<string[]>([]);
const [detecting, setDetecting] = useState(false);
const [detectMsg, setDetectMsg] = useState('');
const detectDenkovi = async () => {
setDetecting(true);
setDetectMsg('');
try {
const list = ((await ListDenkoviDevices()) ?? []) as string[];
setDenkoviSerials(list);
// Auto-fill when there's exactly one and nothing chosen yet.
if (list.length >= 1 && !device.host.trim()) onChange({ ...device, host: list[0] });
setDetectMsg(list.length === 0 ? t('station.detectNone') : t('station.detectFound', { n: list.length }));
} catch (e: any) { setDenkoviSerials([]); setDetectMsg(String(e?.message || e || t('station.detectNone'))); }
finally { setDetecting(false); }
};
// Connection test result for the current device config.
const [testing, setTesting] = useState(false);
const [testMsg, setTestMsg] = useState<{ ok: boolean; text: string } | null>(null);
const testDevice = async () => {
setTesting(true);
setTestMsg(null);
try {
const r: any = await TestStationDevice(device as any);
setTestMsg(r?.ok
? { ok: true, text: t('station.testOk', { n: r.relays }) }
: { ok: false, text: r?.error || t('station.testFail') });
} catch (e: any) { setTestMsg({ ok: false, text: String(e?.message || e || t('station.testFail')) }); }
finally { setTesting(false); }
};
useEffect(() => { if (isDenkovi) void detectDenkovi(); /* eslint-disable-next-line */ }, [isDenkovi]);
return (
<div ref={ref} className="mt-4 max-w-4xl rounded-xl border border-primary/40 bg-card p-4 space-y-3">
<div className="text-sm font-semibold">{device.id ? t('station.editDevice') : t('station.addDevice')}</div>
@@ -461,6 +608,9 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
<SelectContent>
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
<SelectItem value="dingtian">Dingtian IOT relay (LAN / WiFi)</SelectItem>
<SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
<SelectItem value="usbrelay">Denkovi USB (serial / COM)</SelectItem>
</SelectContent>
</Select>
</div>
@@ -469,11 +619,78 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
</div>
</div>
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
{(isDenkovi || isUsbRelay || isDingtian) && (
<div className="space-y-1 max-w-[10rem]">
<Label>{t('station.channels')}</Label>
<Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
{(isDenkovi ? [4, 8] : isDingtian ? [2, 4, 8, 16, 24, 32] : [1, 2, 4, 8, 16]).map((n) => (
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{isDenkovi ? (
<div className="space-y-1 max-w-md">
<Label>{t('station.ftdiSerial')}</Label>
<div className="flex items-center gap-2">
<Input className="font-mono flex-1" value={device.host} placeholder="DAE0006K"
list="denkovi-serials"
onChange={(e) => onChange({ ...device, host: e.target.value })} />
<datalist id="denkovi-serials">
{denkoviSerials.map((s) => <option key={s} value={s} />)}
</datalist>
<Button size="sm" variant="outline" onClick={detectDenkovi} disabled={detecting}>
{detecting ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
{t('station.detect')}
</Button>
</div>
<p className="text-[10px] text-muted-foreground">{t('station.ftdiHint')}</p>
{detectMsg && <p className="text-[11px] font-medium text-muted-foreground">{detectMsg}</p>}
</div>
) : isUsbRelay ? (
<div className="space-y-1 max-w-md">
<Label>{t('station.comPort')}</Label>
<div className="flex items-center gap-2">
<Select value={device.host || '_'} onValueChange={(v) => onChange({ ...device, host: v === '_' ? '' : v })}>
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
<SelectContent>
{serialPorts.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
{serialPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => {})}>
<RefreshCw className="size-3.5" />
</Button>
</div>
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
</div>
) : (
<div className={cn('grid gap-3', (isKM || isDingtian) ? 'grid-cols-3' : 'grid-cols-1')}>
<div className={cn('space-y-1', (isKM || isDingtian) ? '' : 'max-w-xs')}>
<Label>{t('station.host')}</Label>
<Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
<span className="text-[10px] text-muted-foreground">{t('station.hostHint')}</span>
</div>
{/* Dingtian: both are OFF on a factory board the session ID only when
"HTTP Session" is enabled in its web page, the password only when a
relay password is set. */}
{isDingtian && (
<>
<div className="space-y-1">
<Label>{t('station.dtSession')}</Label>
<Input className="font-mono" value={device.user ?? ''} placeholder={t('station.optional')}
onChange={(e) => onChange({ ...device, user: e.target.value })} />
</div>
<div className="space-y-1">
<Label>{t('station.dtPwd')}</Label>
<Input className="font-mono" value={device.pass ?? ''} placeholder="0"
onChange={(e) => onChange({ ...device, pass: e.target.value.replace(/[^0-9]/g, '') })} />
</div>
</>
)}
{isKM && (
<>
<div className="space-y-1">
@@ -487,6 +704,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
</>
)}
</div>
)}
<div className="space-y-1">
<Label>{t('station.labels')}</Label>
<div className="grid grid-cols-4 gap-2">
@@ -496,9 +714,21 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
))}
</div>
</div>
<div className="flex justify-end gap-2">
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
<div className="flex items-center gap-2">
{testMsg && (
<span className={cn('inline-flex items-center gap-1.5 text-xs font-medium', testMsg.ok ? 'text-success' : 'text-danger')}>
<span className={cn('size-2 rounded-full', testMsg.ok ? 'bg-success' : 'bg-danger')} />
{testMsg.text}
</span>
)}
<div className="ml-auto flex gap-2">
<Button size="sm" variant="outline" onClick={testDevice} disabled={testing || !device.host.trim()}>
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <PlugZap className="size-3.5 mr-1" />}
{t('station.test')}
</Button>
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
</div>
</div>
</div>
);
+138 -7
View File
@@ -23,14 +23,20 @@ import { cn } from '@/lib/utils';
// ─────────────────────────────────────────────────────────────────────────────
type Bucket = { key: string; count: number };
type BandCat = { band: string; cw: number; phone: number; data: number; total: number };
type Gap = { start: string; end: string; minutes: number };
type ContestRun = { id: string; year: number; count: number; start: string; end: string };
type Stats = {
total: number; unique_calls: number; entities: number; continents: number;
first_qso: string; last_qso: string;
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
by_mode: Bucket[]; by_band: Bucket[]; by_operator: Bucket[]; by_station: Bucket[];
by_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[];
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
by_hour: Bucket[]; by_day7: Bucket[]; by_day30: Bucket[]; by_month12: Bucket[];
// Chronological series across the SELECTED period, one per bucket size; empty
// when that bucket would be too fine for the period (see the Go side).
by_day_win: Bucket[]; by_week_win: Bucket[]; by_month_win: Bucket[]; by_year_win: Bucket[];
by_hour_of_day: Bucket[]; by_weekday: Bucket[];
// Period / contest metrics.
window_start: string; window_end: string; window_hours: number;
avg_per_hour: number; avg_per_active: number;
@@ -157,6 +163,47 @@ function VBars({ data, empty, height = 150, showValues, colorful }: { data: Buck
);
}
// Per-band mode split (CW / phone / data) as stacked vertical bars. The three
// categories are the subject → categorical colour in a fixed order, matching the
// mode colours used elsewhere (CW gold, phone green, data blue).
const BAND_SPLIT = [
{ key: 'cw', label: 'CW', color: 'var(--chart-3)' },
{ key: 'phone', label: 'Phone', color: 'var(--chart-2)' },
{ key: 'data', label: 'Data', color: 'var(--chart-1)' },
] as const;
function StackedBandBars({ data, empty, height = 150 }: { data: BandCat[]; empty: string; height?: number }) {
if (!data || data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
const peak = Math.max(1, ...data.map((d) => d.total));
return (
<div>
<div className="flex items-end gap-[3px] min-w-0" style={{ height }}>
{data.map((d) => (
<div key={d.band} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
title={`${d.band} — CW ${nf(d.cw)} · Phone ${nf(d.phone)} · Data ${nf(d.data)} (${nf(d.total)})`}>
<span className="text-[9px] font-semibold mb-0.5 tabular-nums text-foreground">{nf(d.total)}</span>
<div className="w-full flex flex-col justify-end rounded-t-[4px] overflow-hidden"
style={{ height: `${Math.max(2, (d.total / peak) * 100)}%` }}>
{BAND_SPLIT.map((s) => {
const v = d[s.key];
if (!v) return null;
return <div key={s.key} style={{ flexGrow: v, flexBasis: 0, minHeight: 1, background: s.color }} />;
})}
</div>
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap">{d.band}</span>
</div>
))}
</div>
<div className="mt-2 flex items-center justify-center gap-3 text-[10px] text-muted-foreground">
{BAND_SPLIT.map((s) => (
<span key={s.key} className="inline-flex items-center gap-1">
<span className="size-2.5 rounded-[3px]" style={{ background: s.color }} /> {s.label}
</span>
))}
</div>
</div>
);
}
// ── Contest rate, stacked by operator ────────────────────────────────────────
// The categories (the operators) ARE the subject, so this is categorical colour,
// in the FIXED order the backend sends (busiest first). An operator therefore
@@ -462,6 +509,88 @@ function periodRange(p: Period, from: string, to: string): [string, string] {
}
}
// ActivityCard — the "activity over time" chart with a granularity selector. Its
// own state, module-scoped so a parent re-render doesn't reset the choice. The
// timeline is the chronological month series; day/week/month/year are the cyclical
// distributions (hour-of-day, day-of-week, day-of-month, month-of-year).
// The selector is a BUCKET SIZE applied to the period chosen at the top of the
// panel — not a window of its own. It used to be four fixed rolling windows
// anchored on the newest QSO, which ignored the period filter entirely: picking
// a year up top left this chart showing the last 7 days.
const ACT_GRAN = [
{ key: 'day', tkey: 'stats.granDay' },
{ key: 'week', tkey: 'stats.granWeek' },
{ key: 'month', tkey: 'stats.granMonth' },
{ key: 'year', tkey: 'stats.granYear' },
] as const;
type Gran = typeof ACT_GRAN[number]['key'];
const COARSER: Record<Gran, Gran | null> = { day: 'week', week: 'month', month: 'year', year: null };
function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
const [g, setG] = useState<Gran | 'auto'>('auto');
const seriesFor = (k: Gran): Bucket[] => (
k === 'day' ? stats.by_day_win : k === 'week' ? stats.by_week_win : k === 'month' ? stats.by_month_win : stats.by_year_win
);
// Auto picks the finest bucket that stays readable: the backend leaves a
// series empty when it would be too fine for the period, so "the first
// non-empty one" is exactly the right rule and needs no thresholds here.
const auto: Gran = (['day', 'week', 'month', 'year'] as Gran[]).find((k) => seriesFor(k).length > 0 && seriesFor(k).length <= 120) ?? 'year';
// A bucket the user forces but that is too fine for the period falls back to
// the next coarser one rather than showing an empty chart.
let shown: Gran = g === 'auto' ? auto : g;
while (seriesFor(shown).length === 0 && COARSER[shown]) shown = COARSER[shown]!;
const series = seriesFor(shown);
const steppedUp = g !== 'auto' && shown !== g;
return (
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
<div className="mb-2 flex flex-wrap gap-1 items-center">
<button type="button" onClick={() => setG('auto')}
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
g === 'auto' ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
{t('stats.granAuto')}
</button>
{ACT_GRAN.map((o) => (
<button key={o.key} type="button" onClick={() => setG(o.key)}
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
g === o.key ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
{t(o.tkey)}
</button>
))}
{steppedUp && (
<span className="text-[10px] text-muted-foreground">{t('stats.granTooFine')}</span>
)}
</div>
{/* Many buckets read better as a filled trend than as a forest of bars. */}
{series.length > 60
? <AreaTrend data={series} empty={empty} />
: <VBars data={series} empty={empty} showValues height={160} />}
</Card>
);
}
// RhythmCard — WHEN the operator is on the air over the selected period, which is
// a different question from "how much lately" and so gets its own card. The hour
// histogram used to cover a single day, too little to read anything from.
function RhythmCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
const [g, setG] = useState<'hour' | 'weekday'>('hour');
return (
<Card title={t('stats.rhythm')} sub={t('stats.rhythmSub')} className="lg:col-span-2" accent="var(--chart-2)">
<div className="mb-2 flex flex-wrap gap-1">
{([['hour', 'stats.byHourOfDay'], ['weekday', 'stats.byWeekday']] as const).map(([k, tk]) => (
<button key={k} type="button" onClick={() => setG(k)}
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
g === k ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
{t(tk)}
</button>
))}
</div>
<VBars data={g === 'hour' ? stats.by_hour_of_day : stats.by_weekday} empty={empty} showValues height={160} />
</Card>
);
}
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
@@ -524,9 +653,12 @@ export function StatsPanel() {
const arr = (v: any) => (Array.isArray(v) ? v : []);
setStats({
...raw,
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_operator: arr(raw.by_operator),
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_band_category: arr(raw.by_band_category), by_operator: arr(raw.by_operator),
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
by_hour: arr(raw.by_hour), by_day7: arr(raw.by_day7), by_day30: arr(raw.by_day30), by_month12: arr(raw.by_month12),
by_day_win: arr(raw.by_day_win), by_week_win: arr(raw.by_week_win), by_month_win: arr(raw.by_month_win), by_year_win: arr(raw.by_year_win),
by_hour_of_day: arr(raw.by_hour_of_day), by_weekday: arr(raw.by_weekday),
rate: arr(raw.rate), gaps: arr(raw.gaps),
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
} as Stats);
@@ -734,16 +866,15 @@ export function StatsPanel() {
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
<Card title={t('stats.byBand')} sub={t('stats.byBandSub')} accent="var(--chart-3)">
<VBars data={stats.by_band} empty={empty} showValues colorful />
<Card title={t('stats.bandSplit')} sub={t('stats.bandSplitSub')} accent="var(--chart-3)">
<StackedBandBars data={stats.by_band_category} empty={empty} />
</Card>
<Card title={t('stats.byMode')} accent="var(--chart-2)">
<HBars data={stats.by_mode} max={8} empty={empty} colorful />
</Card>
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
<AreaTrend data={stats.by_month} empty={empty} />
</Card>
<ActivityCard stats={stats} t={t} empty={empty} />
<RhythmCard stats={stats} t={t} empty={empty} />
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
worked what, and a cap would quietly delete the 9th operator. Scrolls
+171
View File
@@ -0,0 +1,171 @@
import { useRef, useState } from 'react';
import { Gauge, Radio, ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MeterBar } from '@/components/MeterBar';
import type { TGStatus, TGChannel } from '@/components/TunerGeniusPanel';
import { TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate } from '../../wailsjs/go/main/App';
// TunerCard renders the 4O3A Tuner Genius XL exactly like the amplifier card
// (AmpCard) so Station Control and the FlexRadio panel show the SAME card. It
// mirrors the native app's two channels (A / B) with their source, frequency and
// antenna, a live PWR / SWR pair, and the Tune / Bypass / Operate actions. It
// drives the backend directly (no local state) — the caller's ~1.5s poll
// reconciles the display, just like AmpCard. Meters come from the shared MeterBar
// so they're the exact same size as the Flex/amp meters.
function Card({ icon: Icon, title, accent, children, ckey }: { icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string }) {
// Collapsible with persisted state — same behaviour as the FlexRadio panel's Card.
const storeKey = 'opslog.cardOpen.' + (ckey || title);
const [open, setOpen] = useState(() => localStorage.getItem(storeKey) !== '0');
const toggle = () => setOpen((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<button type="button" onClick={toggle}
className={cn('w-full flex items-center gap-2 px-3 py-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left', open && 'border-b border-border/60')}>
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
<ChevronDown className={cn('ml-auto size-4 text-muted-foreground transition-transform', !open && '-rotate-90')} />
</button>
{open && <div className="p-3 space-y-3">{children}</div>}
</div>
);
}
// ChannelButton — one of the two RF channels (A / B). Clicking it makes that
// channel active. Shows the source (RF Sense / Flex / CAT…), frequency and
// antenna, matching the two rows of the native 4O3A app.
function ChannelButton({ letter, ch, active, ptt, threeWay, onSelect, t }: {
letter: 'A' | 'B'; ch: TGChannel; active: boolean; ptt: boolean; threeWay: boolean;
onSelect: () => void; t: (k: string, v?: any) => string;
}) {
const cls = ptt
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)]'
: active
? 'bg-gradient-to-b from-emerald-500 to-emerald-600 text-white border-emerald-400/50 shadow-[0_0_9px_rgba(16,185,129,0.4)]'
: 'bg-card text-foreground/80 border-border hover:bg-muted';
const src = ch.mode_str || '—';
const freq = ch.freq_mhz && ch.freq_mhz > 0 ? `${ch.freq_mhz.toFixed(3)} MHz` : '—';
return (
<button type="button" onClick={onSelect}
title={active ? t('tgp.chActive', { letter }) : t('tgp.chSelect', { letter })}
className={cn('flex-1 min-w-0 rounded-lg border px-2 py-1.5 text-left transition-all active:scale-[0.98]', cls)}>
<div className="flex items-center gap-1.5">
<span className="font-extrabold text-sm">{letter}</span>
<span className="text-[11px] font-semibold truncate opacity-90">{src}</span>
{ptt && <span className="ml-auto text-[9px] font-bold uppercase">TX</span>}
{!ptt && active && <span className="ml-auto text-[9px] font-bold uppercase opacity-90">{t('tgp.chActiveTag')}</span>}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="font-mono text-[11px] tabular-nums truncate">{freq}</span>
{ch.antenna != null && ch.antenna > 0 && (threeWay || ch.antenna > 0) && (
<span className="ml-auto text-[10px] font-semibold whitespace-nowrap opacity-90">{t('tgp.ant')} {ch.antenna}</span>
)}
</div>
</button>
);
}
export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?: any) => string }) {
// Peak-hold, exactly as AmpCard does it. Both cards read the same transmitter,
// but the tuner is sampled by a 400 ms TCP poll: on SSB that lands in the gaps
// between syllables as often as on a peak, so the raw reading collapses to zero
// several times a second mid-transmission and reads as a dropped link. The
// amplifier looked steady next to it only because it already smoothed this way.
// Hold the highest value seen, decaying after 2 s so it follows a real drop.
const peak = useRef<Record<string, { v: number; t: number }>>({});
const peakHold = (key: string, val: number) => {
const now = Date.now();
const p = peak.current[key];
if (!p || val >= p.v || now - p.t > 1000) { peak.current[key] = { v: val, t: now }; return val; }
return p.v;
};
const connected = !!status.connected;
const rawVswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
// Prefer the device's own peak detector over the sampled reading; the JS hold
// below still smooths what is left between polls.
const rawPeak = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
const rawFwdW = rawPeak >= 1 ? rawPeak : 0;
const fwdW = peakHold('fwd', rawFwdW);
// SWR needs RF to mean anything: sampled on receive the device reports a
// meaningless 1.00, which would wipe the figure the operator actually wants —
// the one measured while transmitting. So show the live value during TX, keep it
// briefly afterwards so it can be read, then let it go.
//
// That expiry is the point: the first version of this held the last TX value
// with no timeout at all, so the meter sat frozen on the previous transmission
// indefinitely — reading 1.39:1 with the radio plainly in RX and 0 W forward.
// Same one-second window as the power peak above, so the two meters clear
// together.
const swrHold = useRef<{ v: number; t: number } | null>(null);
if (rawFwdW >= 1 && rawVswr) swrHold.current = { v: rawVswr, t: Date.now() };
const heldSwr = swrHold.current;
const vswr = rawFwdW >= 1
? rawVswr
: heldSwr && Date.now() - heldSwr.t < 1000
? heldSwr.v
: undefined;
const active = status.active ?? 1;
const a: TGChannel = status.a ?? {};
const b: TGChannel = status.b ?? {};
const title = `${t('tgp.title')}${status.host ? ' · ' + status.host : ''}`;
return (
<Card icon={Gauge} title={title} accent="#f59e0b">
<div className="flex items-center gap-3 flex-wrap">
<button type="button" disabled={!connected}
onClick={() => TunerGeniusSetOperate(!status.operate).catch(() => {})}
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
status.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{status.operate ? 'OPERATE' : 'STANDBY'}
</button>
<button type="button" disabled={!connected}
onClick={() => TunerGeniusAutotune().catch(() => {})}
className={cn('inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
status.tuning ? 'bg-amber-500 text-white border-amber-500 shadow-[0_0_14px] shadow-amber-500/50 animate-pulse' : 'bg-card text-amber-600 border-amber-500 hover:bg-amber-500/10')}>
<Radio className="size-4" />{status.tuning ? t('tgp.tuning') : t('tgp.tune')}
</button>
<button type="button" disabled={!connected}
onClick={() => TunerGeniusSetBypass(!status.bypass).catch(() => {})}
className={cn('px-4 py-2 rounded-lg text-sm font-bold tracking-wide border-2 transition-all disabled:opacity-30',
status.bypass ? 'bg-sky-500 text-white border-sky-500 shadow-[0_0_12px] shadow-sky-500/40' : 'bg-card text-sky-600 border-sky-500/70 hover:bg-sky-500/10')}>
{t('tgp.bypass')}
</button>
<span className={cn('inline-flex items-center gap-1.5 text-sm', connected ? 'text-muted-foreground' : 'text-danger')}>
<span className={cn('size-2 rounded-full', connected ? 'bg-success' : 'bg-danger')} />
{connected ? (status.bypass ? t('tgp.bypassed') : t('tgp.inLine')) : t('tgp.offline')}
</span>
<div className="flex-1" />
{status.message && (
<span className="px-2 py-1 rounded bg-warning-muted text-warning-muted-foreground text-xs font-bold"> {status.message}</span>
)}
</div>
{connected && (
<>
{/* Channel A / B selector — click to make active (activate ch=1/2). */}
<div className="flex items-stretch gap-2">
<ChannelButton letter="A" ch={a} active={active === 1} ptt={!!a.ptt} threeWay={!!status.three_way}
onSelect={() => TunerGeniusActivate(1).catch(() => {})} t={t} />
<ChannelButton letter="B" ch={b} active={active === 2} ptt={!!b.ptt} threeWay={!!status.three_way}
onSelect={() => TunerGeniusActivate(2).catch(() => {})} t={t} />
</div>
{/* PWR + SWR meters, each taking half the card. The amp card's grid is
3-wide because it shows three meters; copying it here left the tuner's
two meters in the first two of three columns, with a third of the card
empty to the right of SWR. */}
<div className="grid grid-cols-2 gap-2">
<MeterBar label={t('tgp.power')} value={fwdW} unit="W" lo={0} hi={2000}
display={fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
<MeterBar label={t('tgp.swr')} value={vswr ?? 1} lo={1} hi={3}
display={vswr ? `${vswr.toFixed(2)}:1` : '—'}
segColor={(f) => (f > 0.5 ? '#dc2626' : f > 0.25 ? '#f59e0b' : '#16a34a')} />
</div>
</>
)}
</Card>
);
}
@@ -0,0 +1,180 @@
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; peak_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number;
operate?: boolean; bypass?: boolean; tuning?: boolean; active?: number;
antenna?: number; three_way?: boolean; message?: string;
a?: TGChannel; b?: TGChannel;
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;
// The device's own peak, not the instantaneous reading: a 400 ms poll lands
// between syllables as often as on a peak, so the plain figure showed a few
// hundred watts on a kilowatt transmission.
const fwdW = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
return (
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
<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">
{fwdW >= 1 ? `${Math.round(fwdW)} 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>
);
}
+211
View File
@@ -0,0 +1,211 @@
import { useEffect, useState } from 'react';
import { Upload, FolderOpen, Loader2 } from 'lucide-react';
import {
GetWebPublishConfig, SaveWebPublishConfig, WebPublishColumns,
TestWebPublishFTP, PublishLogNow, GetWebPublishStatus, PickBackupFolder,
} from '../../wailsjs/go/main/App';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
type Cfg = {
enabled: boolean; format: string; folder: string; file_name: string; title: string;
count: number; interval_min: number; columns: string[];
ftp_enabled: boolean; ftp_host: string; ftp_port: number; ftp_user: string;
ftp_password: string; ftp_tls: boolean; ftp_folder: string; ftp_file_name: string;
};
type Col = { key: string; header: string };
export function WebPublishPanel() {
const { t } = useI18n();
const [cfg, setCfg] = useState<Cfg | null>(null);
const [cols, setCols] = useState<Col[]>([]);
const [busy, setBusy] = useState<'' | 'test' | 'publish'>('');
const [msg, setMsg] = useState('');
const [err, setErr] = useState('');
// Raw text for the two numeric boxes: bound straight to the number they could
// not be cleared, the same trap as the Recent-QSOs Max field.
const [countText, setCountText] = useState('');
const [everyText, setEveryText] = useState('');
useEffect(() => {
(async () => {
try {
const [c, k, st] = await Promise.all([GetWebPublishConfig(), WebPublishColumns(), GetWebPublishStatus()]);
setCfg(c as any);
setCols((k ?? []) as Col[]);
setCountText(String((c as any).count ?? 100));
setEveryText(String((c as any).interval_min ?? 0));
const s: any = st;
if (s?.last_err) setErr(s.last_err);
else if (s?.last_run) setMsg(t('wpub.lastRun') + ' ' + s.last_run);
} catch (e: any) { setErr(String(e?.message ?? e)); }
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!cfg) return <p className="text-xs text-muted-foreground"></p>;
const set = (p: Partial<Cfg>) => setCfg((c) => (c ? { ...c, ...p } : c));
// Every control saves immediately: this panel has no Save button of its own,
// matching the other "saved instantly" panels.
const save = async (next: Cfg) => {
setCfg(next);
try { await SaveWebPublishConfig(next as any); setErr(''); }
catch (e: any) { setErr(String(e?.message ?? e)); }
};
const patch = (p: Partial<Cfg>) => save({ ...cfg, ...p });
const toggleCol = (key: string) => {
const has = cfg.columns?.includes(key);
patch({ columns: has ? cfg.columns.filter((c) => c !== key) : [...(cfg.columns ?? []), key] });
};
const run = async (what: 'test' | 'publish') => {
setBusy(what); setMsg(''); setErr('');
try {
const r = what === 'test' ? await TestWebPublishFTP(cfg as any) : await PublishLogNow();
setMsg(String(r));
} catch (e: any) { setErr(String(e?.message ?? e)); }
finally { setBusy(''); }
};
return (
<div className="space-y-4 max-w-3xl">
<p className="text-xs text-muted-foreground leading-relaxed">{t('wpub.hint')}</p>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={cfg.enabled} onCheckedChange={(c) => patch({ enabled: !!c })} />
{t('wpub.enable')}
</label>
{cfg.enabled && (<>
{/* ── The file ── */}
<div className="space-y-2 border-t border-border/60 pt-3">
<Label className="text-xs font-semibold">{t('wpub.fileSection')}</Label>
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
<Label className="text-sm">{t('wpub.format')}</Label>
<Select value={cfg.format} onValueChange={(v) => patch({ format: v })}>
<SelectTrigger className="h-8 w-56"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="html">{t('wpub.formatHtml')}</SelectItem>
<SelectItem value="csv">{t('wpub.formatCsv')}</SelectItem>
</SelectContent>
</Select>
<Label className="text-sm">{t('wpub.folder')}</Label>
<div className="flex gap-2">
<Input className="h-8 font-mono text-xs flex-1" value={cfg.folder}
onChange={(e) => set({ folder: e.target.value })} onBlur={() => patch({ folder: cfg.folder })} />
<Button variant="outline" size="sm" className="h-8" onClick={async () => {
try { const p = await PickBackupFolder(); if (p) patch({ folder: p }); } catch { /* cancelled */ }
}}><FolderOpen className="size-3.5" /> {t('wpub.browse')}</Button>
</div>
<Label className="text-sm">{t('wpub.fileName')}</Label>
<Input className="h-8 w-56 font-mono text-xs" value={cfg.file_name}
onChange={(e) => set({ file_name: e.target.value })} onBlur={() => patch({ file_name: cfg.file_name })} />
<Label className="text-sm">{t('wpub.title')}</Label>
<Input className="h-8" placeholder={t('wpub.titlePh')} value={cfg.title}
onChange={(e) => set({ title: e.target.value })} onBlur={() => patch({ title: cfg.title })} />
<Label className="text-sm">{t('wpub.count')}</Label>
<Input type="number" min={1} max={100000} className="h-8 w-28 font-mono text-xs" value={countText}
onChange={(e) => setCountText(e.target.value)}
onBlur={() => {
const n = Math.floor(Number(countText));
if (Number.isFinite(n) && n > 0) patch({ count: n }); else setCountText(String(cfg.count));
}} />
<Label className="text-sm">{t('wpub.every')}</Label>
<div className="flex items-center gap-2">
<Input type="number" min={0} max={1440} className="h-8 w-28 font-mono text-xs" value={everyText}
onChange={(e) => setEveryText(e.target.value)}
onBlur={() => {
const n = Math.floor(Number(everyText));
if (Number.isFinite(n) && n >= 0) patch({ interval_min: n }); else setEveryText(String(cfg.interval_min));
}} />
<span className="text-xs text-muted-foreground">{t('wpub.everyHint')}</span>
</div>
</div>
</div>
{/* ── Columns ── */}
<div className="space-y-2 border-t border-border/60 pt-3">
<Label className="text-xs font-semibold">{t('wpub.columns')}</Label>
<div className="flex flex-wrap gap-1.5">
{cols.map((c) => {
const on = cfg.columns?.includes(c.key);
return (
<button key={c.key} type="button" onClick={() => toggleCol(c.key)}
className={cn('px-2 py-0.5 rounded-full border text-[11px] font-medium transition-colors',
on ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
{c.header}
</button>
);
})}
</div>
<p className="text-[11px] text-muted-foreground">{t('wpub.columnsHint')}</p>
</div>
{/* ── Upload ── */}
<div className="space-y-2 border-t border-border/60 pt-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={cfg.ftp_enabled} onCheckedChange={(c) => patch({ ftp_enabled: !!c })} />
{t('wpub.ftpEnable')}
</label>
{cfg.ftp_enabled && (
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
<Label className="text-sm">{t('wpub.ftpHost')}</Label>
<div className="flex gap-2">
<Input className="h-8 flex-1" placeholder="ftp.example.com" value={cfg.ftp_host}
onChange={(e) => set({ ftp_host: e.target.value })} onBlur={() => patch({ ftp_host: cfg.ftp_host })} />
<Input type="number" className="h-8 w-24 font-mono text-xs" value={cfg.ftp_port}
onChange={(e) => set({ ftp_port: Number(e.target.value) || 21 })}
onBlur={() => patch({ ftp_port: cfg.ftp_port })} />
</div>
<Label className="text-sm">{t('wpub.ftpUser')}</Label>
<Input className="h-8" value={cfg.ftp_user}
onChange={(e) => set({ ftp_user: e.target.value })} onBlur={() => patch({ ftp_user: cfg.ftp_user })} />
<Label className="text-sm">{t('wpub.ftpPassword')}</Label>
<Input type="password" className="h-8" value={cfg.ftp_password}
onChange={(e) => set({ ftp_password: e.target.value })} onBlur={() => patch({ ftp_password: cfg.ftp_password })} />
<Label className="text-sm">{t('wpub.ftpFolder')}</Label>
<Input className="h-8 font-mono text-xs" placeholder="/www/log" value={cfg.ftp_folder}
onChange={(e) => set({ ftp_folder: e.target.value })} onBlur={() => patch({ ftp_folder: cfg.ftp_folder })} />
<Label className="text-sm">{t('wpub.ftpFileName')}</Label>
<Input className="h-8 w-56 font-mono text-xs" value={cfg.ftp_file_name}
onChange={(e) => set({ ftp_file_name: e.target.value })} onBlur={() => patch({ ftp_file_name: cfg.ftp_file_name })} />
<span />
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={cfg.ftp_tls} onCheckedChange={(c) => patch({ ftp_tls: !!c })} />
{t('wpub.ftpTls')}
</label>
</div>
)}
</div>
<div className="flex items-center gap-3 border-t border-border/60 pt-3 flex-wrap">
<Button size="sm" onClick={() => run('publish')} disabled={busy !== ''}>
{busy === 'publish' ? <Loader2 className="size-3.5 animate-spin" /> : <Upload className="size-3.5" />}
{t('wpub.publishNow')}
</Button>
{cfg.ftp_enabled && (
<Button variant="outline" size="sm" onClick={() => run('test')} disabled={busy !== ''}>
{busy === 'test' ? <Loader2 className="size-3.5 animate-spin" /> : null}
{t('wpub.testFtp')}
</Button>
)}
</div>
</>)}
{msg && <p className="text-xs text-success break-all">{msg}</p>}
{err && <p className="text-xs text-danger break-all">{err}</p>}
</div>
);
}
+16 -7
View File
@@ -26,7 +26,7 @@ interface Props {
wpm: number;
macros: WKMacro[];
sent: string; // text echoed back by the keyer as it transmits
source: 'winkeyer' | 'icom'; // CW output engine (chosen in Settings → CW Keyer)
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | 'kenwood'; // CW output engine (chosen in Settings → CW Keyer)
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
onSetBreakIn?: (mode: number) => void;
onSelectPort: (p: string) => void;
@@ -101,14 +101,18 @@ export function WinkeyerPanel({
<Radio className="size-4 text-primary shrink-0" />
{/* CW output engine (chosen in Settings → CW Keyer). */}
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
{source === 'icom' ? 'Icom CW' : 'WinKeyer'}
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : source === 'kenwood' ? 'Kenwood CW' : 'WinKeyer'}
</span>
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
<div className="flex-1" />
{source === 'icom' ? (
{source === 'icom' || source === 'flex' || source === 'yaesu' || source === 'kenwood' ? (
<span className="text-[11px] font-medium text-muted-foreground">
{connected ? t('wkp.civReady') : t('wkp.civOffline')}
{source === 'flex'
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
: source === 'yaesu' || source === 'kenwood'
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
</span>
) : !connected ? (
<>
@@ -183,7 +187,7 @@ export function WinkeyerPanel({
<div className="flex flex-col flex-1 min-w-0">
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
{t('wkp.cwText')}
{source === 'winkeyer' && (
{(source === 'winkeyer' || source === 'flex') && (
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
title={t('wkp.sendOnTypeHint')}>
<input type="checkbox" className="accent-primary" checked={sendOnType}
@@ -228,9 +232,14 @@ export function WinkeyerPanel({
{autoCall && <span className="text-[10px] text-warning/80">{t('wkp.loopHint')}</span>}
</div>
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
{/* Macro buttons F1 single-line (F-key + label) to keep the panel short.
Empty macros (no label AND no text) are hidden, like the voice keyer;
the F-number stays tied to the macro's real index so shortcuts match. */}
<div className="grid grid-cols-3 gap-1">
{macros.map((m, i) => (
{macros
.map((m, i) => ({ m, i }))
.filter(({ m }) => `${m.label ?? ''}${m.text ?? ''}`.trim() !== '')
.map(({ m, i }) => (
<button
key={i}
type="button"
+34 -11
View File
@@ -14,8 +14,9 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import type { WorkedBeforeView, QSOForm } from '@/types';
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
import { getDateFormat, subscribeDateFormat } from '@/lib/dateFormat';
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
import { useI18n } from '@/lib/i18n';
ModuleRegistry.registerModules([AllCommunityModule]);
@@ -25,6 +26,8 @@ const hamlogTheme = hamlogGridTheme;
type WorkedEntry = QSOForm; // entries are now full QSO records
type Props = {
// Operator's CURRENT locator — fallback for the distance column (see catalog).
myGrid?: string;
wb: WorkedBeforeView | null;
busy: boolean;
currentCall: string;
@@ -35,6 +38,10 @@ type Props = {
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void;
onBulkEdit?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void;
onExportSelectedFields?: (ids: number[]) => void;
onExportCabrilloSelected?: (ids: number[]) => void;
onDelete?: (ids: number[]) => void;
// One column per defined award (cell = the reference this QSO counts for).
awardCols?: { code: string; name: string }[];
@@ -50,14 +57,16 @@ function fmtDate(s: any): string {
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
}
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onDelete, awardCols }: Props) {
export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null);
// Localized column catalog (shared with the Recent QSOs grid).
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
const [dateFmt, setDateFmt] = useState(getDateFormat);
useEffect(() => subscribeDateFormat(() => setDateFmt(getDateFormat())), []);
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt]);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
@@ -107,15 +116,18 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
sortable: true, resizable: true, filter: true, suppressMovable: false,
}), []);
function onGridReady(e: GridReadyEvent) {
// Restore AFTER the profile scope is known: this grid has no key= remount to
// save it from reading the wrong (unscoped) cache key at first paint, so it
// used to miss the cache every time and then save under the scoped key.
async function onGridReady(e: GridReadyEvent) {
await whenGridPrefsReady();
const local = loadLocal(COL_STATE_KEY);
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
loadRemote(COL_STATE_KEY).then((remote) => {
if (remote && !local) {
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
seedLocal(COL_STATE_KEY, remote);
}
});
const remote = await loadRemote(COL_STATE_KEY);
if (remote && !local) {
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
seedLocal(COL_STATE_KEY, remote);
}
}
const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore events fired by a column rebuild
@@ -131,7 +143,9 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t);
}, [awardCols]);
// columnDefs itself, not the reasons it was rebuilt — see the same note in
// RecentQSOsGrid: a language change rebuilt the memo and left saving off.
}, [columnDefs]);
function isColVisible(colId: string): boolean {
const col = gridRef.current?.api?.getColumn(colId);
@@ -247,6 +261,11 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
</div>
</div>
{/* Same menu as Recent QSOs, minus the two "filtered" exports: there they
mean "the whole logbook under the active column filters", but this grid
is a per-callsign view rather than a filter over the log, so the entry
would quietly export everything not what it would appear to do here.
The selection-based exports and bulk edit apply unchanged. */}
<QSOContextMenu
menu={menu}
onClose={() => setMenu(null)}
@@ -256,6 +275,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
onSendTo={onSendTo}
onSendRecording={onSendRecording}
onSendEQSL={onSendEQSL}
onBulkEdit={onBulkEdit}
onExportSelected={onExportSelected}
onExportSelectedFields={onExportSelectedFields}
onExportCabrilloSelected={onExportCabrilloSelected}
onDelete={onDelete}
/>
+501
View File
@@ -0,0 +1,501 @@
import { useEffect, useRef, useState } from 'react';
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna } from 'lucide-react';
import {
GetYaesuState, RefreshYaesuPanel,
SetYaesuPower, SetYaesuMicGain, SetYaesuAFGain, SetYaesuRFGain, SetYaesuSquelch,
SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel,
SetYaesuNarrow, SetYaesuVOX, SetYaesuSplit, SetYaesuBand, TuneYaesuATU,
SetYaesuModeRaw, SetYaesuSplitOffset, SetYaesuKeySpeed, SetYaesuBreakIn, YaesuZeroIn, GetCATState,
} from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { sMeterRST } from '@/lib/rst';
import { MeterBar } from '@/components/MeterBar';
type YaesuState = {
available: boolean; model?: string; mode?: string; raw_mode?: string;
transmitting: boolean; split: boolean;
s_meter: number; power_meter: number; swr_meter: number;
rf_power: number; mic_gain: number; af_gain: number; rf_gain: number; squelch: number;
agc?: string; preamp: number; att: number;
nb: boolean; nr: boolean; nr_level: number; narrow: boolean; vox: boolean;
split_tx_hz?: number; key_speed?: number; break_in?: boolean; swr?: number; power_w?: number;
};
const ZERO: YaesuState = {
available: false, transmitting: false, split: false,
s_meter: 0, power_meter: 0, swr_meter: 0,
rf_power: 0, mic_gain: 0, af_gain: 0, rf_gain: 0, squelch: 0,
preamp: 0, att: 0, nb: false, nr: false, nr_level: 0, narrow: false, vox: false,
};
// Band buttons use the rig's OWN band memory (CAT "BS"), not a frequency we
// choose: pressing 20 m lands where the operator last was on 20 m, which is what
// the radio's own band keys do. That is why these are band names, not Hz.
const BANDS = ['160m', '80m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'];
// Mode buttons. CW, RTTY, DIGI and PSK exist on BOTH sidebands on a Yaesu and
// the operator is the one who knows which they want, so each shows its sideband
// and CLICKING AN ACTIVE BUTTON AGAIN flips it: CW-U → CW-L → CW-U. One button,
// one finger, no hidden gesture. SSB takes its sideband from the frequency, as
// the band plan dictates, and AM/FM have none.
//
// PSK rides on the rig's DATA mode, like the other digital modes — the button
// exists because the operator thinks in modes, not in what the radio calls them.
type ModeBtn = { id: string; label: string; sideband: boolean; rig: (side: 'U' | 'L') => string };
const MODES: ModeBtn[] = [
{ id: 'SSB', label: 'SSB', sideband: false, rig: () => 'SSB' },
{ id: 'CW', label: 'CW', sideband: true, rig: (s) => 'CW-' + s },
{ id: 'RTTY', label: 'RTTY', sideband: true, rig: (s) => 'RTTY-' + s },
{ id: 'DIGI', label: 'DIGI', sideband: true, rig: (s) => 'DATA-' + s },
{ id: 'PSK', label: 'PSK', sideband: true, rig: (s) => 'DATA-' + s },
{ id: 'AM', label: 'AM', sideband: false, rig: () => 'AM' },
{ id: 'FM', label: 'FM', sideband: false, rig: () => 'FM' },
];
// Which button the rig's current raw mode belongs to, and on which sideband.
function activeMode(raw?: string): { id: string; side: 'U' | 'L' } | null {
switch ((raw || '').toUpperCase()) {
case 'USB': return { id: 'SSB', side: 'U' };
case 'LSB': return { id: 'SSB', side: 'L' };
case 'CW-U': return { id: 'CW', side: 'U' };
case 'CW-L': return { id: 'CW', side: 'L' };
case 'RTTY-U': return { id: 'RTTY', side: 'U' };
case 'RTTY-L': return { id: 'RTTY', side: 'L' };
case 'DATA-U': return { id: 'DIGI', side: 'U' };
case 'DATA-L': return { id: 'DIGI', side: 'L' };
case 'AM': return { id: 'AM', side: 'U' };
case 'FM': return { id: 'FM', side: 'U' };
}
return null;
}
// The FTDX10/FTDX101 preamp is a three-way front-end selector, not an on/off:
// IPO bypasses the preamp entirely (best on a quiet, high-signal band), AMP1 and
// AMP2 add gain. Presenting it as a toggle would hide the middle position.
const PREAMPS = [{ v: '0', l: 'IPO' }, { v: '1', l: 'AMP1' }, { v: '2', l: 'AMP2' }];
const AGCS = [{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }, { v: 'AUTO', l: 'AUTO' }];
// The attenuator is a three-step pad on these rigs (6/12/18 dB), not a toggle.
const ATTS = [{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
function fmtVFO(hz?: number): string {
if (!hz || hz <= 0) return '––.–––.––';
const mhz = Math.floor(hz / 1_000_000);
const khz = Math.floor((hz % 1_000_000) / 1000);
const h2 = Math.floor((hz % 1000) / 10);
return `${mhz}.${String(khz).padStart(3, '0')}.${String(h2).padStart(2, '0')}`;
}
function bandOfHz(hz?: number): string {
if (!hz || hz <= 0) return '';
const mhz = hz / 1_000_000;
const bands: [string, number, number][] = [
['160m', 1.8, 2.0], ['80m', 3.5, 4.0], ['60m', 5.25, 5.45], ['40m', 7.0, 7.3],
['30m', 10.1, 10.15], ['20m', 14.0, 14.35], ['17m', 18.068, 18.168],
['15m', 21.0, 21.45], ['12m', 24.89, 24.99], ['10m', 28.0, 29.7], ['6m', 50.0, 54.0],
];
for (const [name, lo, hi] of bands) if (mhz >= lo && mhz <= hi) return name;
return '';
}
// Split the 0-100 S-meter reading into S units + dB over S9.
//
// The FTDX10 answers SM0 on a 0-255 scale and its manual does not say where S9
// falls; the front panel puts it at roughly half travel, which is the 50 used
// here. That figure is a HYPOTHESIS — if reports come out consistently one S
// unit off on a radio, this is the number to correct, not the RST helper.
const S9_PCT = 50; // where S9 falls on the 0-100 reading (see above)
const DB_PER_PCT = 60 / 50; // above S9 the scale runs to roughly +60 dB
function sParts(v: number): { s: number; over: number; label: string } {
if (v >= S9_PCT) {
const over = Math.max(0, Math.round((v - S9_PCT) * DB_PER_PCT));
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
}
const s = Math.max(0, Math.min(9, Math.round(v / (S9_PCT / 9))));
return { s, over: 0, label: `S${s}` };
}
// Segment colour, the way a radio's own meter is printed: green up to S9, amber
// through the S9+ range, red once the signal is strong enough to be reported as
// 59+20 or more. Derived from the SAME S9 point as the label, so the colour
// change always lands exactly where the numbers say it should — if the S9 point
// is ever corrected, the colours follow on their own.
const RED_OVER_DB = 20;
function sSegColor(frac: number): string {
const pct = frac * 100;
if (pct < S9_PCT) return '#16a34a';
if ((pct - S9_PCT) * DB_PER_PCT < RED_OVER_DB) return '#f59e0b';
return '#dc2626';
}
function Slider({ value, onChange, disabled, accent = 'var(--primary)', min = 0, max = 100 }: {
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; min?: number; max?: number;
}) {
const v = Math.max(min, Math.min(max, value));
const pct = max > min ? ((v - min) / (max - min)) * 100 : 0;
const ref = useRef<HTMLInputElement>(null);
// React's onWheel is passive, so preventDefault is ignored there — attach a
// native non-passive listener, and read live values through refs so the
// handler never closes over a stale value.
const valRef = useRef(value); valRef.current = value;
const cbRef = useRef(onChange); cbRef.current = onChange;
const disRef = useRef(disabled); disRef.current = disabled;
const minRef = useRef(min); minRef.current = min;
const maxRef = useRef(max); maxRef.current = max;
useEffect(() => {
const el = ref.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (disRef.current) return;
e.preventDefault();
const nv = Math.max(minRef.current, Math.min(maxRef.current, valRef.current + (e.deltaY < 0 ? 1 : -1)));
if (nv !== valRef.current) cbRef.current(nv);
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
return (
<input
ref={ref}
type="range" min={min} max={max} value={v} disabled={disabled}
onChange={(e) => onChange(parseInt(e.target.value, 10))}
className={cn('flex-1 h-2 rounded-full appearance-none cursor-pointer disabled:opacity-40 disabled:cursor-default',
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full',
'[&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow',
'[&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing')}
// The filled side was invisible against the dark theme: --muted is barely
// lighter than the card it sits on, so the whole track read as one bar.
// An explicit translucent track keeps both halves distinct in either theme.
style={{
background: `linear-gradient(to right, ${accent} 0%, ${accent} ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) 100%)`,
borderColor: accent,
}}
/>
);
}
function Segmented({ value, options, onChange }: {
value: string; options: { v: string; l: string }[]; onChange: (v: string) => void;
}) {
return (
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
{options.map((o) => (
<button key={o.v} type="button" onClick={() => onChange(o.v)}
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors border-l border-border first:border-l-0',
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{o.l}
</button>
))}
</div>
);
}
function Chip({ on, onClick, label, title }: { on: boolean; onClick: () => void; label: string; title?: string }) {
return (
<button type="button" onClick={onClick} title={title}
className={cn('shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
on ? 'bg-success border-success text-success-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
{label}
</button>
);
}
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
</div>
<div className="p-3 space-y-3">{children}</div>
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
{children}
</div>
);
}
export function YaesuPanel({ onReportRST, onKeySpeed }: {
onReportRST?: (rst: string) => void;
// Told whenever the operator moves the CW speed here, so the app can keep the
// keyer that is ACTUALLY sending in step. With DTR/RTS line keying the PC does
// the timing and the rig's internal keyer speed changes nothing audible — the
// slider looked broken because it was driving the wrong keyer.
onKeySpeed?: (wpm: number) => void;
}) {
const { t } = useI18n();
const [st, setSt] = useState<YaesuState>(ZERO);
// The frequency being LISTENED to. RigState follows ADIF, where freq_hz is the
// TRANSMIT frequency — under split that is the other VFO, so taking it as the
// main display showed the operator the frequency they transmit on and an
// offset of 0 kHz against itself.
const [freqHz, setFreqHz] = useState(0);
const [txHz, setTxHz] = useState(0);
const [err, setErr] = useState('');
// Optimistic local values for the sliders. Without them a drag fights the
// poll: the rig's older reading arrives mid-gesture and yanks the thumb back.
const [local, setLocal] = useState<Partial<YaesuState>>({});
const localAtRef = useRef(0);
useEffect(() => {
let alive = true;
const tick = async () => {
try {
const s = (await GetYaesuState()) as YaesuState;
const c = await GetCATState();
if (!alive) return;
setSt(s);
const cs = c as any;
const tx = cs?.freq_hz ?? 0;
const rx = cs?.split && cs?.freq_rx_hz > 0 ? cs.freq_rx_hz : tx;
setFreqHz(rx);
setTxHz(tx);
// Drop the optimistic overlay once the rig has had time to answer with
// the new value — 1.2 s covers the slow-beat settings read.
if (Date.now() - localAtRef.current > 1200) setLocal({});
setErr('');
} catch (e: any) {
if (alive) setErr(String(e?.message ?? e));
}
};
tick();
const id = window.setInterval(tick, 400);
return () => { alive = false; window.clearInterval(id); };
}, []);
const view = { ...st, ...local };
// Every setter follows the same shape: show the value at once, remember when,
// and let the poll take over. A rejected command surfaces as an error rather
// than as a control that silently springs back.
function push<K extends keyof YaesuState>(key: K, value: YaesuState[K], fn: () => Promise<void>) {
setLocal((l) => ({ ...l, [key]: value }));
localAtRef.current = Date.now();
fn().catch((e) => setErr(String(e?.message ?? e)));
}
const band = bandOfHz(freqHz);
// CW changes what belongs on the panel: no microphone, no VOX, but a keyer
// speed, break-in and ZIN. Driven by the RIG's mode, not the logged one.
const isCW = (view.raw_mode || '').toUpperCase().startsWith('CW');
if (!st.available) {
return (
<div className="h-full w-full flex items-center justify-center p-6 text-center">
<div className="space-y-1">
<Radio className="size-8 mx-auto text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">{t('yaesu.notConnected')}</p>
{err && <p className="text-xs text-destructive">{err}</p>}
</div>
</div>
);
}
return (
<div className="h-full min-h-0 overflow-auto bg-background">
{/* Same wrapper as the Icom and Flex panels: capped width, CENTRED. Capping
it without mx-auto left the console pinned to the left edge with a
window of empty space beside it. */}
<div className="max-w-5xl mx-auto p-3 space-y-3">
{/* VFO + status */}
<div className="rounded-xl border border-border bg-card shadow-sm px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
<div>
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{st.model || 'Yaesu'}</div>
<div className="text-2xl font-mono tabular-nums font-bold">{fmtVFO(freqHz)}</div>
{view.split && txHz > 0 && (
<div className="text-[11px] font-mono tabular-nums text-warning">
{t('yaesu.txOn')} {fmtVFO(txHz)}
{freqHz > 0 ? ' (' + (txHz > freqHz ? '+' : '') + Math.round((txHz - freqHz) / 100) / 10 + ' kHz)' : ''}
</div>
)}
</div>
<div className="flex items-center gap-2">
{st.transmitting && (
<span className="px-2 py-1 rounded-md text-[11px] font-bold bg-destructive text-destructive-foreground">TX</span>
)}
<Chip on={view.split} onClick={() => push('split', !view.split, () => SetYaesuSplit(!view.split))} label="SPLIT" />
{/* The usual pile-up offsets. Which one is idiomatic depends on the
mode up 5 on phone, up 1 on CW so both are offered rather than
guessed, and each turns split on in the same action. */}
<button type="button" onClick={() => SetYaesuSplitOffset(1000).catch((e) => setErr(String(e?.message ?? e)))}
title={t('yaesu.splitUpHint')}
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">+1k</button>
<button type="button" onClick={() => SetYaesuSplitOffset(5000).catch((e) => setErr(String(e?.message ?? e)))}
title={t('yaesu.splitUpHint')}
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">+5k</button>
<button type="button" onClick={() => TuneYaesuATU().catch((e) => setErr(String(e?.message ?? e)))}
title={t('yaesu.tuneHint')}
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
TUNE
</button>
</div>
</div>
{err && <p className="text-xs text-destructive px-1">{err}</p>}
{/* Meters the SHARED MeterBar the Flex and Icom panels use, so the three
consoles read alike instead of each having its own instrument style. */}
<Card icon={Activity} title={t('yaesu.meters')}>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<MeterBar label="S-METER" value={view.s_meter} lo={0} hi={100} accent="#16a34a" segColor={sSegColor}
display={sParts(view.s_meter).label}
onClick={onReportRST ? () => { const sp = sParts(view.s_meter); onReportRST(sMeterRST(sp.s, sp.over, view.mode)); } : undefined}
title={onReportRST ? t('yaesu.sToRst') : undefined} />
{/* Watts as MEASURED, not the power setting scaled by a percentage:
the setting says what was asked for, the meter says what left. */}
<MeterBar label="PWR" value={view.transmitting ? (view.power_w ?? 0) : 0} unit="W" lo={0} hi={Math.max(100, view.rf_power || 100)} accent="#0ea5e9"
display={view.transmitting ? Math.round(view.power_w ?? 0) + 'W' : '—'} />
{/* The RATIO, as the rig shows it a percentage of meter travel is
not something an operator can act on. The bar keeps the travel. */}
<MeterBar label="SWR" value={view.transmitting ? view.swr_meter : 0} lo={0} hi={100} accent="#f59e0b"
display={view.transmitting ? (view.swr && view.swr >= 1 ? view.swr.toFixed(1) : '1.0') : '—'} />
</div>
</Card>
{/* Bands + modes */}
<Card icon={Antenna} title={t('yaesu.bandMode')}>
<div className="flex flex-wrap gap-1">
{BANDS.map((b) => (
<button key={b} type="button"
onClick={() => SetYaesuBand(b).catch((e) => setErr(String(e?.message ?? e)))}
className={cn('px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
band === b ? 'bg-primary border-primary text-primary-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
{b.replace('m', '')}
</button>
))}
</div>
<div className="flex flex-wrap gap-1">
{MODES.map((m) => {
const act = activeMode(view.raw_mode);
const on = act?.id === m.id;
// The sideband shown is the rig's when this mode is active, else the
// one the band plan implies — so a button says what pressing it will
// actually do rather than a stale letter.
const side: 'U' | 'L' = on && act ? act.side : (freqHz > 0 && freqHz < 10_000_000 ? 'L' : 'U');
const flip: 'U' | 'L' = side === 'U' ? 'L' : 'U';
return (
<button key={m.id} type="button"
onClick={() => {
// Already on this mode → the click means "the other sideband".
const target = m.id === 'SSB'
? (freqHz > 0 && freqHz < 10_000_000 ? 'LSB' : 'USB')
: m.rig(on && m.sideband ? flip : side);
SetYaesuModeRaw(target).catch((e) => setErr(String(e?.message ?? e)));
}}
title={m.sideband ? t('yaesu.sidebandHint') : undefined}
className={cn('px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
on ? 'bg-primary border-primary text-primary-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
{m.sideband ? m.label + '-' + side : m.label}
</button>
);
})}
</div>
</Card>
{/* Receive */}
<Card icon={AudioLines} title={t('yaesu.receive')}>
<Row label="AF">
<Slider value={view.af_gain} onChange={(v) => push('af_gain', v, () => SetYaesuAFGain(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.af_gain}</span>
</Row>
<Row label="RF">
<Slider value={view.rf_gain} onChange={(v) => push('rf_gain', v, () => SetYaesuRFGain(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_gain}</span>
</Row>
<Row label="SQL">
<Slider value={view.squelch} onChange={(v) => push('squelch', v, () => SetYaesuSquelch(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.squelch}</span>
</Row>
<Row label="AGC">
<Segmented value={view.agc ?? 'AUTO'} options={AGCS} onChange={(v) => push('agc', v, () => SetYaesuAGC(v))} />
</Row>
<Row label="FRONT">
<Segmented value={String(view.preamp)} options={PREAMPS} onChange={(v) => push('preamp', parseInt(v, 10), () => SetYaesuPreamp(parseInt(v, 10)))} />
</Row>
<Row label="ATT">
<Segmented value={String(view.att)} options={ATTS} onChange={(v) => push('att', parseInt(v, 10), () => SetYaesuAtt(parseInt(v, 10)))} />
</Row>
</Card>
{/* Noise + filter */}
<Card icon={SlidersHorizontal} title={t('yaesu.noiseFilter')}>
<div className="flex items-center gap-2 flex-wrap">
<Chip on={view.nb} onClick={() => push('nb', !view.nb, () => SetYaesuNB(!view.nb))} label="NB" />
<Chip on={view.nr} onClick={() => push('nr', !view.nr, () => SetYaesuNR(!view.nr))} label="DNR" />
<Chip on={view.narrow} onClick={() => push('narrow', !view.narrow, () => SetYaesuNarrow(!view.narrow))} label="NAR" />
</div>
<Row label="DNR">
{/* 1-15 on the rig, shown as-is rather than rescaled to a percentage:
the radio's own display counts 1-15, and matching it is what makes
the panel readable next to the front panel. */}
<Slider value={view.nr_level || 1} min={1} max={15} disabled={!view.nr}
onChange={(v) => push('nr_level', v, () => SetYaesuNRLevel(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.nr_level || 1}</span>
</Row>
</Card>
{/* Transmit */}
<Card icon={Mic} title={t('yaesu.transmit')}>
<Row label="PWR">
{/* Watts, not a percentage: the rig reports and takes watts, and a
percentage would be a second unit to reconcile every time. */}
<Slider value={view.rf_power || 5} min={5} max={100} accent="var(--destructive)"
onChange={(v) => push('rf_power', v, () => SetYaesuPower(v))} />
<span className="w-10 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_power}W</span>
</Row>
{/* Microphone gain and VOX are meaningless in CW the rig ignores both
so they are hidden rather than shown as dead controls. */}
{!isCW && (
<>
<Row label="MIC">
<Slider value={view.mic_gain} onChange={(v) => push('mic_gain', v, () => SetYaesuMicGain(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.mic_gain}</span>
</Row>
<Row label="VOX">
<Chip on={view.vox} onClick={() => push('vox', !view.vox, () => SetYaesuVOX(!view.vox))} label="VOX" />
</Row>
</>
)}
<Row label="">
<button type="button" onClick={() => RefreshYaesuPanel().catch((e) => setErr(String(e?.message ?? e)))}
className="ml-auto px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
{t('yaesu.refresh')}
</button>
</Row>
</Card>
{/* CW — only in CW, where these replace the phone controls above. */}
{isCW && (
<Card icon={Radio} title={t('yaesu.cw')}>
<Row label="SPEED">
<Slider value={view.key_speed || 20} min={4} max={60} accent="var(--primary)"
onChange={(v) => { push('key_speed', v, () => SetYaesuKeySpeed(v)); onKeySpeed?.(v); }} />
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.key_speed || 20} wpm</span>
</Row>
<div className="flex items-center gap-2 flex-wrap">
<Chip on={!!view.break_in} onClick={() => push('break_in', !view.break_in, () => SetYaesuBreakIn(!view.break_in))}
label="BK-IN" title={t('yaesu.breakInHint')} />
{/* ZIN is a one-shot: the rig retunes so the station being received
lands on the operator's own CW pitch. Not a toggle, so it is a
plain button rather than a chip that would look latched. */}
<button type="button" onClick={() => YaesuZeroIn().catch((e) => setErr(String(e?.message ?? e)))}
title={t('yaesu.zinHint')}
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
ZIN
</button>
</div>
</Card>
)}
</div>
</div>
);
}
+5 -1
View File
@@ -37,7 +37,11 @@ const TabsContent = React.forwardRef<
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn('mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', className)}
// No focus ring on the panel itself. Radix makes the content focusable and
// moves focus into it when a tab is selected, so clicking anywhere in the
// lower half drew a full-width orange rule under the tab strip. The panel is
// a container — what it holds carries its own focus styling.
className={cn('mt-2 focus-visible:outline-none', className)}
{...props}
/>
));
+91
View File
@@ -0,0 +1,91 @@
// How dates are DISPLAYED. Nothing here touches what is stored.
//
// The log keeps ADIF/ISO throughout — the database, ADIF export, LoTW, every
// upload. This is the reading layer only, because an operator reading
// "2026-07-30" as the 7th of the 30th month is reading their own log wrongly,
// and that is a display problem, not a data one.
//
// Storage stays ISO deliberately and permanently: it sorts correctly as text,
// it is what ADIF specifies, and a log whose stored format followed a UI
// preference would become unreadable the day the preference changed.
import { writeUiPref } from '@/lib/uiPref';
export type DateFormat = 'iso' | 'fr' | 'us';
const KEY = 'opslog.dateFormat';
function read(): DateFormat {
const v = localStorage.getItem(KEY);
return v === 'fr' || v === 'us' ? v : 'iso';
}
let current: DateFormat = read();
// A minimal store so every grid and panel re-renders the moment the format
// changes, rather than showing the old one until the next restart.
const listeners = new Set<() => void>();
export function getDateFormat(): DateFormat {
return current;
}
export function setDateFormat(f: DateFormat): void {
current = f;
writeUiPref(KEY, f);
listeners.forEach((l) => l());
}
export function subscribeDateFormat(fn: () => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
// reloadDateFormat re-reads the stored value — for after the portable prefs
// have been synced from the database at startup, which happens AFTER this
// module's first read.
export function reloadDateFormat(): void {
const v = read();
if (v !== current) {
current = v;
listeners.forEach((l) => l());
}
}
const pad = (n: number) => String(n).padStart(2, '0');
// order arranges the three parts. Separators stay '-' in every format: a slash
// in the French one would be conventional but it also invites the reader to
// take it for a US date, which is exactly the confusion being fixed.
function order(y: number, m: number, d: number): string {
switch (current) {
case 'fr':
return `${pad(d)}-${pad(m)}-${y}`;
case 'us':
return `${pad(m)}-${pad(d)}-${y}`;
}
return `${y}-${pad(m)}-${pad(d)}`;
}
// formatDateTimeUTC renders a timestamp with the time, in UTC.
//
// UTC is not negotiable and is not part of the preference: a logbook is kept in
// UTC, and a date shown in local time would disagree with the QSO's own record
// twice a year.
export function formatDateTimeUTC(s: unknown): string {
if (!s) return '';
const d = new Date(String(s));
if (isNaN(d.getTime())) return String(s);
return `${order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
}
// formatDateOnly renders a date with no time. It accepts both the ADIF form
// (YYYYMMDD, used by every QSL and upload date) and anything Date can parse.
export function formatDateOnly(s: unknown): string {
if (!s) return '';
const t = String(s).trim();
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
if (m) return order(Number(m[1]), Number(m[2]), Number(m[3]));
const d = new Date(t);
if (isNaN(d.getTime())) return t;
return order(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
}
+130
View File
@@ -0,0 +1,130 @@
// Grey line — the day/night terminator, and the twilight band around it where
// HF propagation briefly does things it does at no other time.
//
// The maths is the standard low-precision solar position (Meeus, chapter 25, as
// used by NOAA's solar calculator): good to well under a degree, which is far
// finer than the band being drawn. No ephemeris, no network, no dependency —
// this has to work in a field shack with no internet.
//
// Everything here is pure so it can be tested against known positions; the
// drawing lives in the map component.
const DEG = Math.PI / 180;
// julianDay for a date, including the fraction of the day.
export function julianDay(d: Date): number {
return d.getTime() / 86400000 + 2440587.5;
}
// Sun's declination and Greenwich hour angle, both in degrees.
//
// Returned together because they are computed from the same intermediates and
// every caller needs both — splitting them into two exported functions would
// double the work at each call for the sake of a tidier signature.
export function sunPosition(date: Date): { dec: number; gha: number } {
const jd = julianDay(date);
const n = jd - 2451545.0; // days from J2000.0
// Mean longitude and mean anomaly of the Sun.
const L = (280.460 + 0.9856474 * n) % 360;
const g = ((357.528 + 0.9856003 * n) % 360) * DEG;
// Ecliptic longitude: the equation of centre applied to the mean longitude.
const lambda = (L + 1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g)) * DEG;
// Obliquity of the ecliptic, slowly decreasing.
const eps = (23.439 - 0.0000004 * n) * DEG;
const dec = Math.asin(Math.sin(eps) * Math.sin(lambda)) / DEG;
// Right ascension, kept in the same quadrant as the ecliptic longitude —
// atan2 alone lands 180° out for half the year, which mirrors the whole
// terminator about the prime meridian. Six months of a plausible-looking
// wrong answer is exactly the kind of bug that survives review.
let ra = Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda)) / DEG;
if (ra < 0) ra += 360;
// Greenwich mean sidereal time, in degrees.
const gmst = (280.46061837 + 360.98564736629 * n) % 360;
let gha = gmst - ra;
gha = ((gha % 360) + 360) % 360;
return { dec, gha };
}
// terminatorLatitude returns the latitude, at a given longitude, where the Sun
// sits at `altitude` degrees above (or below) the horizon.
//
// altitude 0 is the geometric terminator; -6 is civil twilight, the outer edge
// of the grey line as operators use the term.
//
// Returns null where no such latitude exists at that longitude — inside the
// polar day or polar night, where the Sun never crosses that altitude at all.
export function terminatorLatitude(lonDeg: number, dec: number, gha: number, altitude = 0): number | null {
const ha = (gha + lonDeg) * DEG; // local hour angle
const d = dec * DEG;
const h = altitude * DEG;
// From sin(alt) = sin(dec)sin(lat) + cos(dec)cos(lat)cos(ha), solved for lat:
// A·sin(lat) + B·cos(lat) = sin(alt), A = sin(dec), B = cos(dec)cos(ha)
//
// That has TWO solutions per meridian, and picking the wrong one is not a
// small error — it mirrors the answer about the equator. New York at 02:00
// local in December came out as daylight because the branch chosen put the
// terminator at +63° where it belongs at 63°: a shaded map that looks
// entirely plausible and is exactly wrong.
//
// So anchor on the geometric terminator, where the answer is unambiguous and
// classical, and take whichever branch is nearest it. At altitude 0 that
// reproduces it exactly; for the twilight band it follows it by continuity,
// which is the physical requirement — the twilight curve is a small offset
// from the terminator, never on the other side of the planet.
const A = Math.sin(d);
const B = Math.cos(d) * Math.cos(ha);
const R = Math.hypot(A, B);
if (R === 0) return null;
const s = Math.sin(h) / R;
if (s < -1 || s > 1) return null; // the Sun never reaches this altitude here
// The classical terminator. Division by zero is deliberate and correct here:
// at an equinox tan(dec) → 0 and the terminator becomes a meridian, which is
// exactly what atan(±Infinity) = ±90° expresses.
const t = Math.tan(d);
if (t === 0 && Math.cos(ha) === 0) return null; // degenerate: no crossing to name
const lat0 = Math.atan(-Math.cos(ha) / t) / DEG;
const phi = Math.atan2(B, A); // phase of the A·sin(lat) + B·cos(lat) sum
const fold = (x: number) => {
let v = ((x + 180) % 360 + 360) % 360 - 180;
if (v > 90) v = 180 - v;
if (v < -90) v = -180 - v;
return v;
};
const c1 = fold((Math.asin(s) - phi) / DEG);
const c2 = fold((Math.PI - Math.asin(s) - phi) / DEG);
return Math.abs(c1 - lat0) <= Math.abs(c2 - lat0) ? c1 : c2;
}
// nightPolygon returns a ring covering the part of the world where the Sun is
// below `altitude`, ready for L.polygon (as [lat, lon] pairs).
//
// The ring runs along the terminator from west to east and closes along
// whichever pole is in darkness — which pole that is flips with the season, and
// getting it wrong shades the lit half of the planet.
export function nightPolygon(date: Date, altitude = 0, stepDeg = 2): [number, number][] {
const { dec, gha } = sunPosition(date);
const ring: [number, number][] = [];
for (let lon = -180; lon <= 180; lon += stepDeg) {
const lat = terminatorLatitude(lon, dec, gha, altitude);
// Inside a polar day/night there is no crossing; clamp to the pole so the
// ring stays closed rather than tearing open across the map.
ring.push([lat === null ? (dec > 0 ? -90 : 90) : lat, lon]);
}
// Northern summer (positive declination) → the SOUTH pole is dark.
const pole = dec > 0 ? -90 : 90;
ring.push([pole, 180]);
ring.push([pole, -180]);
return ring;
}
+98 -12
View File
@@ -6,11 +6,52 @@
// back to the DB copy and re-seed the cache.
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
// The DB copy is ALREADY per-profile (the backend prefixes every ui.* key with
// the active profile id). The localStorage cache, however, is one namespace for
// the whole WebView, so without scoping it too a profile switch would keep
// serving the previous profile's cached layout and the correct per-profile DB
// value would never win. lsScope makes the cache per-profile as well; it's set
// once the active profile is known and updated on every profile switch.
let lsScope = '';
// setGridPrefsProfile scopes the localStorage cache to a profile. Call it before
// the grids read their state (at startup) and again whenever the active profile
// changes so each profile keeps its own column layout / widths.
export function setGridPrefsProfile(id: number | string | null | undefined): void {
lsScope = id == null || id === '' ? '' : `p${id}.`;
markReady();
}
// The active profile is only known after an async call, but the grids mount and
// read their state on the first paint. A grid that read before the scope was set
// looked up the UNSCOPED cache key (always a miss) and then saved under the
// scoped one — so its layout could never be restored, only rewritten. Grids now
// wait for this instead of racing it.
//
// The 5 s fallback matters: if the profile lookup fails outright, the grids must
// still come up with whatever the unscoped cache holds rather than hang with no
// columns restored.
let markReady: () => void = () => {};
const gridPrefsReady = new Promise<void>((resolve) => {
markReady = resolve;
setTimeout(resolve, 5000);
});
export function whenGridPrefsReady(): Promise<void> {
return gridPrefsReady;
}
// lsKey scopes ONLY the localStorage cache key. The DB key passed to
// GetUIPref/SetUIPref is left untouched — the backend already scopes it.
function lsKey(key: string): string {
return lsScope + key;
}
// loadLocal reads the cached column state synchronously (used in onGridReady
// to apply instantly, before the async DB round-trip).
export function loadLocal(key: string): any[] | null {
try {
const raw = localStorage.getItem(key);
const raw = localStorage.getItem(lsKey(key));
const v = raw ? JSON.parse(raw) : null;
return Array.isArray(v) ? v : null;
} catch {
@@ -19,25 +60,70 @@ export function loadLocal(key: string): any[] | null {
}
// loadRemote pulls the portable copy from the DB (null if none / unset).
export async function loadRemote(key: string): Promise<any[] | null> {
try {
const v = await GetUIPref(key);
const parsed = v ? JSON.parse(v) : null;
return Array.isArray(parsed) ? parsed : null;
} catch {
return null;
//
// GetUIPref returns an ERROR — not "" — while the settings store is still
// coming up and not yet scoped to the active profile. Treating that as "no
// preference" was the silent data-loss path: the grid rendered defaults and the
// first column event then wrote those defaults over the good saved copy. So
// retry for a few seconds instead, and only give up on a real absence.
export async function loadRemote(key: string, attempts = 10): Promise<any[] | null> {
for (let i = 0; i < attempts; i++) {
try {
const v = await GetUIPref(key);
const parsed = v ? JSON.parse(v) : null;
return Array.isArray(parsed) ? parsed : null;
} catch {
// Not ready yet (or a parse failure on a corrupt value — one more read
// costs nothing). 300 ms × 10 covers a slow startup without hanging.
await new Promise((r) => setTimeout(r, 300));
}
}
return null;
}
// saveState write-throughs to both the cache and the DB (fire-and-forget).
// saveState write-throughs to both the cache and the DB (fire-and-forget). Only
// the cache key is profile-scoped; the DB key is scoped by the backend.
// The DB write is DEBOUNCED. AG-Grid emits a column-resized event per drag
// frame, so dragging one border used to fire dozens of settings writes; the
// cache write stays synchronous so nothing is lost if the window closes.
const pendingDB = new Map<string, string>();
const dbTimers = new Map<string, number>();
export function saveState(key: string, state: any[]) {
const json = JSON.stringify(state);
try { localStorage.setItem(key, json); } catch { /* quota / private mode */ }
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
try { localStorage.setItem(lsKey(key), json); } catch { /* quota / private mode */ }
pendingDB.set(key, json);
const prev = dbTimers.get(key);
if (prev) clearTimeout(prev);
dbTimers.set(key, window.setTimeout(() => flushOne(key), 400));
}
function flushOne(key: string) {
const json = pendingDB.get(key);
dbTimers.delete(key);
if (json == null) return;
pendingDB.delete(key);
SetUIPref(key, json).catch(() => {
// The store may not be scoped yet at startup. Keep the value and try once
// more shortly — dropping it here is how a fresh profile ended up with no
// portable copy at all.
pendingDB.set(key, json);
if (!dbTimers.has(key)) dbTimers.set(key, window.setTimeout(() => flushOne(key), 2000));
});
}
// flushGridPrefs writes any debounced state immediately. Call it when the app is
// about to close so a resize made in the last moments still reaches the DB.
export function flushGridPrefs() {
for (const key of Array.from(pendingDB.keys())) {
const t = dbTimers.get(key);
if (t) clearTimeout(t);
flushOne(key);
}
}
// seedLocal writes a value into the cache without touching the DB (used after
// hydrating the cache from the DB on a fresh machine).
export function seedLocal(key: string, state: any[]) {
try { localStorage.setItem(key, JSON.stringify(state)); } catch { /* ignore */ }
try { localStorage.setItem(lsKey(key), JSON.stringify(state)); } catch { /* ignore */ }
}
+336 -88
View File
File diff suppressed because one or more lines are too long
+41
View File
@@ -0,0 +1,41 @@
// Colouring for QSL / confirmation status letters.
//
// These columns are read by COLOUR before they are read as letters: an operator
// scanning a hundred rows wants to see where the gaps are, not to read "Y" a
// hundred times.
//
// Colour ONLY — no pill, no badge. These values sit among callsigns and dates,
// and a column of coloured boxes would shout louder than the callsign it
// belongs to.
//
// The classes are semantic tokens, not fixed colours, so all four themes follow
// and the contrast stays right on the light ones.
// ADIF confirmation values, and what they mean to the operator:
// Y confirmed / sent
// N not sent, not received
// R requested — queued, waiting on the other station or the service
// I ignore (ADIF's "invalid/ignore"), left in default ink: it is neither
// good news nor bad, and colouring it would put it in one camp or the
// other.
//
// Anything else — a blank, or a status some other logger wrote — is left alone
// rather than guessed at.
export function qslStatusClass(value: unknown): string {
switch (String(value ?? '').trim().toUpperCase()) {
case 'Y':
return 'text-success';
case 'N':
return 'text-destructive';
case 'R':
return 'text-info';
}
return '';
}
// qslStatusCellClass is the AG-Grid form: same rule, plus the monospace the
// status columns already used so the letters stay in a straight column.
export function qslStatusCellClass(p: { value?: unknown }): string {
const c = qslStatusClass(p?.value);
return c ? 'font-mono ' + c : 'font-mono';
}
+85
View File
@@ -0,0 +1,85 @@
// Two operator options that change how a spot LOOKS, shared by the DX-cluster
// list and the band map so the two panels can never disagree about the same
// spot — the lesson already learnt with the marker colours.
//
// muteWorked — a station already worked gets no colour and no badge. It
// stays in the list, it simply stops competing for attention.
// slotHighlight — a callsign not yet worked on THIS band and mode is coloured,
// whatever the entity says. For an operator filling slots, a
// common entity on a new band+mode is the whole point, and the
// entity-level status calls it "worked".
//
// They compose deliberately: mute what is done, light up what is not.
export type SpotDisplayOptions = { muteWorked: boolean; slotHighlight: boolean };
export function readSpotDisplayOptions(): SpotDisplayOptions {
try {
return {
muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1',
slotHighlight: localStorage.getItem('opslog.clusterSlotHighlight') === '1',
};
} catch {
return { muteWorked: false, slotHighlight: false };
}
}
type Entry = {
status?: string;
worked_call?: boolean;
worked_slot?: boolean;
new_county?: boolean;
new_pota?: boolean;
new_pfx?: boolean;
muted?: boolean;
} | undefined;
// bringsNothingNew: the ENTITY is resolved and worked, and no other dimension
// (county, park, prefix) is new. Same test the cluster list already used to dim
// a row — muting reuses it rather than inventing a second notion of "done".
//
// Entity-level, NOT callsign-level: an operator can be on their 421st Bulgarian
// and still have never worked that particular station. That spot is muted here
// by design — it brings nothing to an award — and turning on the slot option is
// what brings it back, which is why the promotion above runs first.
//
// Note what is NOT muted: a status of new-band / new-slot survives, because
// having worked that callsign once on another band says nothing about the band
// in front of you.
function bringsNothingNew(s: Entry): boolean {
if (!s || !s.status) return false; // unresolved — never hide, it would flicker
if (s.status === 'new' || s.status === 'new-band' || s.status === 'new-mode' || s.status === 'new-slot' || s.status === 'new-call') return false;
return !(s.new_pota || s.new_county || s.new_pfx);
}
// applySpotDisplay rewrites a status entry per the options, so every consumer —
// colour, badge, status text — follows from one decision instead of each panel
// re-deriving it.
export function applySpotDisplay<T extends Entry>(s: T, o: SpotDisplayOptions): T {
if (!s) return s;
let e = s;
// ORDER MATTERS, and it is the whole difference between the two options
// composing and one cancelling the other.
//
// Slot promotion runs FIRST. A callsign not yet worked on this band and mode
// is not "done", so it must never be swallowed by the mute below — yet the
// mute test only looks at the entity, and an unworked callsign inside a worked
// entity is precisely the spot the second option exists to surface. Promoting
// first protects it for free: bringsNothingNew() returns false on new-slot.
if (o.slotHighlight && e.worked_slot === false && (!e.status || e.status === 'worked')) {
e = { ...e, status: 'new-call' } as NonNullable<T>;
}
if (o.muteWorked && bringsNothingNew(e)) {
// Strip everything that paints: the row keeps its data, loses its emphasis.
//
// muted says WHY the status went empty. An empty status already meant
// "entity not resolved" in the band map, so without this flag every muted
// spot claimed its entity was unknown — with the country printed right next
// to it. Blanking is still what drives the colour, the badges and the
// ranking; muted only lets the tooltip stay honest.
return { ...e, status: '', worked_call: false, muted: true } as T;
}
return e;
}
+41
View File
@@ -0,0 +1,41 @@
// The "extra" spot markers — the ones ORTHOGONAL to the entity status.
//
// A spot's entity status (new DXCC / new band / new slot / worked) answers one
// question. These answer others that can be true at the same time: the park is
// new even if the entity is worked, the county is new even if the band is not.
// Both the DX-cluster list and the band map show them, and they have to agree —
// the same fact must not be green in one panel and violet in the next, so the
// colours live HERE and nowhere else. This is also the table a per-marker colour
// setting will drive, which is why it is a table rather than three constants.
//
// Colour choices:
// POTA green — parks; the association is worth keeping
// county violet — a chart hue, because every semantic token was already
// spoken for: red, orange and yellow are the entity
// statuses, blue is "callsign worked", green is POTA
// worked blue — matches the WKD-CALL badge the cluster list has always used
// prefix yellow — unchanged
export type SpotMarkerKey = 'new_pota' | 'new_county' | 'new_pfx' | 'worked_call';
export type SpotMarker = {
key: SpotMarkerKey;
colour: string; // a CSS colour — a var() reference, so it follows the theme
labelKey: string; // i18n key, short form (badge / legend)
};
// Order is the display order, in both panels.
export const SPOT_MARKERS: SpotMarker[] = [
{ key: 'new_pota', colour: 'var(--success)', labelKey: 'clg2.newPota' },
{ key: 'new_county', colour: 'var(--chart-5)', labelKey: 'clg2.newCounty' },
{ key: 'new_pfx', colour: 'var(--caution)', labelKey: 'clg2.newPfx' },
{ key: 'worked_call', colour: 'var(--info)', labelKey: 'clg2.wkdCall' },
];
export const markerColour = (key: SpotMarkerKey): string =>
SPOT_MARKERS.find((m) => m.key === key)?.colour ?? 'var(--muted-foreground)';
// activeMarkers returns the markers set on a status entry, in display order.
export function activeMarkers(e: Record<string, unknown> | undefined): SpotMarker[] {
if (!e) return [];
return SPOT_MARKERS.filter((m) => !!e[m.key]);
}
+83
View File
@@ -0,0 +1,83 @@
// Sunrise / sunset for a station, in UTC.
//
// Built on greyline.ts's sunPosition() rather than on a second implementation of
// the solar equations: the map's terminator and the entry strip's sunrise must
// never disagree, and there is a whole comment block over there about a sign
// error that produced six months of plausible-looking wrong answers. One source
// of astronomy, one place to be wrong.
//
// Everything is UTC. Amateur radio runs on UTC, the log is in UTC, and a local
// time would raise the question "local to whom — me or the DX?".
import { sunPosition } from './greyline';
const DEG = Math.PI / 180;
// Standard altitude of the Sun's centre at rise/set: half a solar diameter below
// the horizon plus atmospheric refraction.
const H0 = -0.833;
// solarAltitude returns the Sun's elevation in degrees at a place and instant.
function solarAltitude(date: Date, latDeg: number, lonDeg: number): number {
const { dec, gha } = sunPosition(date);
const ha = (gha + lonDeg) * DEG; // local hour angle
const d = dec * DEG;
const lat = latDeg * DEG;
return Math.asin(Math.sin(lat) * Math.sin(d) + Math.cos(lat) * Math.cos(d) * Math.cos(ha)) / DEG;
}
export type SunTimes = {
rise: string; // "HH:MM" UTC, "" when the Sun does not rise that day
set: string;
polarDay: boolean; // Sun stays up all day
polarNight: boolean; // Sun never comes up
};
// sunTimes scans the UTC day a minute at a time and reports where the Sun
// crosses the rise/set altitude.
//
// A scan, not the closed-form hour-angle formula, because the closed form needs
// special-casing for the polar cases and for days where the declination shifts
// enough to matter, and it silently returns NaN rather than saying "no sunrise
// here today". 1440 evaluations is nothing, and the failure mode is honest: no
// crossing found means no sunrise, which is a real answer above the Arctic
// circle. The crossing minute is then refined by linear interpolation, so the
// result is accurate to well under a minute despite the coarse scan.
export function sunTimes(date: Date, latDeg: number, lonDeg: number): SunTimes {
const out: SunTimes = { rise: '', set: '', polarDay: false, polarNight: false };
if (!isFinite(latDeg) || !isFinite(lonDeg)) return out;
const start = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
const minute = 60000;
let prev = solarAltitude(new Date(start), latDeg, lonDeg);
const anyUp = prev > H0;
let anyDown = prev <= H0;
let sawUp = anyUp;
for (let i = 1; i <= 1440; i++) {
const alt = solarAltitude(new Date(start + i * minute), latDeg, lonDeg);
if (alt > H0) sawUp = true; else anyDown = true;
if (prev <= H0 && alt > H0 && !out.rise) {
out.rise = fmt(start + (i - 1 + frac(prev, alt)) * minute);
} else if (prev > H0 && alt <= H0 && !out.set) {
out.set = fmt(start + (i - 1 + frac(prev, alt)) * minute);
}
prev = alt;
}
if (!out.rise && !out.set) {
out.polarDay = sawUp && !anyDown;
out.polarNight = !sawUp;
}
return out;
}
// frac is where between two samples the altitude crosses H0.
function frac(a: number, b: number): number {
const d = b - a;
return d === 0 ? 0 : (H0 - a) / d;
}
function fmt(ms: number): string {
const d = new Date(ms);
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
}
+28
View File
@@ -0,0 +1,28 @@
// Capitalisation helpers for the free-text QSO fields (Name / QTH / Comment /
// Note).
//
// The same station reaches the log SHOUTED by QRZ, lower-cased by a hurried
// operator, and in whatever case the other logger stored it in an imported
// ADIF — so one callsign ends up as "JEAN", "jean" and "Jean" across a log,
// and sorting or reading a QTH column becomes a mess.
//
// Both helpers are meant to run on BLUR, never per keystroke: rewriting the
// value while the operator is still typing a word fights them mid-word (see
// the controlled-input note in CLAUDE.md).
// titleCase upper-cases the first letter of every word and lower-cases the
// rest. Separators are kept, so "SAINT-JULIEN" → "Saint-Julien" and
// "o'brien" → "O'Brien".
export function titleCase(s: string): string {
return s
.toLowerCase()
.replace(/(^|[\s\-'/.])(\p{L})/gu, (_m, sep: string, ch: string) => sep + ch.toUpperCase());
}
// sentenceCase upper-cases the first letter and leaves everything after it as
// typed. A comment routinely carries callsigns, modes and abbreviations
// ("TNX QSO F5ABC, FT8 59") that lower-casing would quietly destroy — which is
// why this is NOT titleCase.
export function sentenceCase(s: string): string {
return s.replace(/^(\s*)(\p{L})/u, (_m, sp: string, ch: string) => sp + ch.toUpperCase());
}
+24 -7
View File
@@ -6,15 +6,24 @@ import { GetUIPref } from '../../wailsjs/go/main/App';
// CSS variables in style.css key off of. 'auto' follows the OS light/dark
// preference. The choice is persisted (localStorage + portable UI pref, so it
// travels with the data/ folder like the language).
export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'high-contrast';
export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'light-nordic'
| 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'dark-indigo' | 'dark-teal' | 'dark-plum' | 'high-contrast';
// Selectable, concrete themes (excludes 'auto') in display order.
// Selectable, concrete themes (excludes 'auto') in display order: lights first,
// then darks, with high-contrast last — it is an accessibility choice, not a
// taste one, and listing it among the moods buries it.
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
'light-warm', 'light-cool', 'light-sage', 'dim-slate', 'dark-warm', 'dark-graphite', 'high-contrast',
'light-warm', 'light-cool', 'light-sage', 'light-nordic',
'dim-slate', 'dark-warm', 'dark-graphite', 'dark-indigo', 'dark-teal', 'dark-plum',
'high-contrast',
];
export const LS_KEY = 'opslog.theme';
const DEFAULT: ThemeChoice = 'light-warm';
// A fresh install starts DARK. A shack is usually a dim room and the screen is
// looked at for hours; every other logger defaults the same way. Graphite
// specifically, because that is what 'auto' already resolves to for a dark
// system — so the two paths agree instead of landing on different darks.
const DEFAULT: ThemeChoice = 'dark-graphite';
const ALL: ThemeChoice[] = ['auto', ...CONCRETE_THEMES];
function systemDark(): boolean {
@@ -73,16 +82,24 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const load = () => {
tries += 1;
GetUIPref(LS_KEY).then((raw) => {
// Backend ANSWERED (settings store ready). Apply a valid stored value; an
// empty/invalid one means the theme is genuinely unset → keep the default.
// Either way we're done — do NOT retry (retrying only matters while the
// backend is still starting, which now surfaces as a rejected promise).
if (cancelled || userPicked.current) return;
const v = raw as ThemeChoice;
if (v && ALL.includes(v)) {
try { localStorage.setItem(LS_KEY, v); } catch { /* quota */ }
applyThemeToDom(v); // idempotent — safe to call unconditionally
setThemeState(v);
return; // restored
}
if (tries < 8) window.setTimeout(load, 300); // empty (unset or backend not ready yet) → retry
}).catch(() => { if (!cancelled && tries < 8) window.setTimeout(load, 300); });
}).catch(() => {
// Settings store not ready yet — a brief startup window before the backend
// has opened the local DB and built the store (the frontend can query it
// first). Keep retrying well past the old 2.4s cap so a dark theme isn't
// lost to the light default after an update cleared localStorage.
if (!cancelled && !userPicked.current && tries < 120) window.setTimeout(load, 300);
});
};
load();
return () => { cancelled = true; };
+23 -3
View File
@@ -5,7 +5,7 @@
// folder (data/) to another machine. These helpers mirror them into the DB
// settings table (ui.* keys, like the grid columns) so the whole setup is
// identical after a copy.
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
import { GetUIPref, SetUIPref, LogUIError } from '../../wailsjs/go/main/App';
// Keys that must travel with data/ (DB is the portable source of truth; the
// localStorage copy is just a fast, synchronous cache).
@@ -17,6 +17,8 @@ const PORTABLE_KEYS = [
'opslog.autofocusWB', // auto-focus Worked-before
'hamlog.filterPresets', // Filter Builder saved presets
'opslog.showRotor', // rotor compass shown next to the keyers
'opslog.showAmpWidget', // amplifier widget shown next to the keyers
'opslog.ampSel.widget', // which amplifier that widget shows ("all" or an amp id)
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
'opslog.showQsoRate', // QSO-rate meter (10/60 min) shown in the header
@@ -25,14 +27,26 @@ const PORTABLE_KEYS = [
'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.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.mapBasemap', // world map basemap (light / street / satellite)
'opslog.dateFormat', // how dates are DISPLAYED (iso / fr / us); storage stays ISO
'opslog.mapGreyline', // world map: grey line (day/night terminator) shown
'opslog.awardRefSort', 'opslog.awardRefSortDir', // award reference table: sort column and direction
// Cluster filter selections — restored on reopen.
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
'opslog.activeTab', // last selected tab
'hamlog.awardColsShown', // which award columns are shown in the QSO grid
'opslog.mainSplit', // Main tab: width share of the left pane (percent)
'opslog.clusterMuteWorked', // cluster/band map: no colour or badge on worked spots
'opslog.clusterSlotHighlight', // cluster/band map: colour calls not worked on this band+mode
'opslog.bandMapWidth', // docked band map: column width (px)
'opslog.bandMapTabWidth', // Band map tab: shared card width (px)
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
// through this global path would fight that per-profile scoping.
];
// syncPortablePrefs reconciles the DB with the local cache at startup:
@@ -58,5 +72,11 @@ export async function syncPortablePrefs(): Promise<void> {
// Use it everywhere these keys are written instead of localStorage.setItem.
export function writeUiPref(key: string, value: string): void {
try { localStorage.setItem(key, value); } catch { /* quota / private mode */ }
SetUIPref(key, value).catch(() => { /* DB unavailable — the cache still holds it */ });
SetUIPref(key, value).catch((e: any) => {
// The cache still holds it, so the interface behaves — but the BACKEND
// reads some of these keys too (digital-mode grouping decides how the
// cluster judges a slot). A write that reaches localStorage and not the
// database makes the two disagree, and used to do so in silence.
try { LogUIError("ui pref", "could not store " + key + ": " + String(e?.message ?? e), ""); } catch {}
});
}
+17 -5
View File
@@ -5,6 +5,8 @@ import App from './App'
import { syncPortablePrefs } from './lib/uiPref'
import { I18nProvider } from './lib/i18n'
import { ThemeProvider, initTheme } from './lib/theme'
import { ErrorBoundary, installGlobalErrorLogging } from './components/ErrorBoundary'
import { reloadDateFormat } from './lib/dateFormat'
const container = document.getElementById('root')
@@ -17,13 +19,23 @@ syncPortablePrefs().finally(() => {
// Stamp the persisted theme onto <html> before render so the correct
// palette is applied immediately (portable pref hydrated just above).
initTheme()
// The date format module read localStorage at import time, which is BEFORE
// the portable prefs were pulled from the database. Re-read it now, or a
// copied data/ folder would show ISO until the next launch.
reloadDateFormat()
// Catch what a boundary cannot: errors thrown from timers, event handlers and
// rejected promises. Installed before the first render so a fault during
// startup is reported too.
installGlobalErrorLogging()
root.render(
<React.StrictMode>
<I18nProvider>
<ThemeProvider>
<App/>
</ThemeProvider>
</I18nProvider>
<ErrorBoundary>
<I18nProvider>
<ThemeProvider>
<App/>
</ThemeProvider>
</I18nProvider>
</ErrorBoundary>
</React.StrictMode>
)
})
+321 -2
View File
@@ -495,6 +495,286 @@
color-scheme: dark;
}
/* ---- Theme 5: Indigo deep blue-violet, electric accent ----------------
The first seven themes are all neutral (beige, slate, graphite, black) with an
orange accent, so "another dark theme" only ever meant another grey. These
four carry a real hue in the SURFACES, not just in the accent, and each takes
a different primary violet, cyan, pink, indigo so they are told apart at a
glance in the picker.
Every one keeps the same contract: the semantic colours (success / warning /
caution / danger / info) must stay recognisable as themselves. A logger is
read for hours and "red = a problem" cannot become a decorative choice, so the
hue budget is spent on the surfaces and the primary, never on the meanings. */
[data-theme="dark-indigo"] {
--background: #0e0f1f;
--foreground: #e4e5f2;
--card: #181a30;
--card-foreground: #e4e5f2;
--popover: #181a30;
--popover-foreground: #e4e5f2;
--primary: #7c6cff; /* electric violet */
--primary-foreground: #0b0a1a;
--secondary: #222542;
--secondary-foreground: #e4e5f2;
--muted: #1e2038;
--muted-foreground: #9d9fbe;
--accent: #2a2d51;
--accent-foreground: #c7c9ee;
--destructive: #f2555a;
--destructive-foreground: #12030a;
--destructive-muted: #34131c;
--destructive-muted-foreground: #fca5a5;
--border: #2a2d4c;
--input: #2a2d4c;
--ring: #9b8dff;
--success: #34d399;
--success-foreground: #052018;
--success-muted: #0e2f27;
--success-muted-foreground: #6ee7b7;
--success-border: #165241;
--warning: #fbbf24;
--warning-foreground: #221803;
--warning-muted: #33280f;
--warning-muted-foreground: #fcd34d;
--warning-border: #4f3e15;
--caution: #facc15;
--caution-foreground: #221e04;
--caution-muted: #322d0e;
--caution-muted-foreground: #fde047;
--caution-border: #4b4315;
--danger: #fb7185;
--danger-foreground: #250912;
--danger-muted: #37151f;
--danger-muted-foreground: #fda4af;
--danger-border: #562533;
--info: #38bdf8;
--info-foreground: #051d29;
--info-muted: #0b2a3a;
--info-muted-foreground: #7dd3fc;
--info-border: #12475c;
/* The entity ramp moves to CYAN here: violet is the primary, and a violet
"entity confirmed" cell would read as a button. */
--mx-call-conf: #22c55e;
--mx-call-work: #2c7a52;
--mx-dx-conf: #22d3ee;
--mx-dx-work: #1b6b7c;
--mx-none: #2c2f4d;
--scrollbar-thumb: #383c63;
--scrollbar-thumb-hover: #4c5182;
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(228, 229, 242, 0.05);
color-scheme: dark;
}
/* ---- Theme 6: Ocean — deep teal, cyan accent --------------------------- */
[data-theme="dark-teal"] {
--background: #061c21;
--foreground: #dcecee;
--card: #0d2c33;
--card-foreground: #dcecee;
--popover: #0d2c33;
--popover-foreground: #dcecee;
--primary: #22d3ee; /* cyan */
--primary-foreground: #041d24;
--secondary: #143b44;
--secondary-foreground: #dcecee;
--muted: #10333b;
--muted-foreground: #92b4ba;
--accent: #15424c;
--accent-foreground: #a5d8e0;
--destructive: #f2555a;
--destructive-foreground: #12030a;
--destructive-muted: #35161a;
--destructive-muted-foreground: #fca5a5;
--border: #1a4652;
--input: #1a4652;
--ring: #5fe3f5;
--success: #4ade80;
--success-foreground: #04220f;
--success-muted: #0e3020;
--success-muted-foreground: #86efac;
--success-border: #17593a;
--warning: #fbbf24;
--warning-foreground: #221803;
--warning-muted: #33280f;
--warning-muted-foreground: #fcd34d;
--warning-border: #4f3e15;
--caution: #facc15;
--caution-foreground: #221e04;
--caution-muted: #312d0e;
--caution-muted-foreground: #fde047;
--caution-border: #4a4315;
--danger: #fb7185;
--danger-foreground: #250912;
--danger-muted: #38161f;
--danger-muted-foreground: #fda4af;
--danger-border: #572634;
/* Info moves to a BLUE: a cyan "info" would be the primary colour again. */
--info: #7aa2f7;
--info-foreground: #04122e;
--info-muted: #16233f;
--info-muted-foreground: #a9c2fb;
--info-border: #2b3d66;
--mx-call-conf: #4ade80;
--mx-call-work: #2b7a4d;
--mx-dx-conf: #a78bfa;
--mx-dx-work: #5b4a9c;
--mx-none: #1c4550;
--scrollbar-thumb: #235a68;
--scrollbar-thumb-hover: #31788a;
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(220, 236, 238, 0.05);
color-scheme: dark;
}
/* ---- Theme 7: Plum — aubergine, magenta accent ------------------------- */
[data-theme="dark-plum"] {
--background: #180f1e;
--foreground: #eee0f0;
--card: #251830;
--card-foreground: #eee0f0;
--popover: #251830;
--popover-foreground: #eee0f0;
--primary: #f472b6; /* magenta-pink */
--primary-foreground: #250518;
--secondary: #33203f;
--secondary-foreground: #eee0f0;
--muted: #2b1b36;
--muted-foreground: #b39dbd;
--accent: #3b2648;
--accent-foreground: #dcc2e6;
--destructive: #f2555a;
--destructive-foreground: #12030a;
--destructive-muted: #3a1620;
--destructive-muted-foreground: #fca5a5;
--border: #3a2547;
--input: #3a2547;
--ring: #f9a8d4;
--success: #34d399;
--success-foreground: #052018;
--success-muted: #10302a;
--success-muted-foreground: #6ee7b7;
--success-border: #1a5745;
--warning: #fbbf24;
--warning-foreground: #221803;
--warning-muted: #352a12;
--warning-muted-foreground: #fcd34d;
--warning-border: #524019;
--caution: #facc15;
--caution-foreground: #221e04;
--caution-muted: #342e12;
--caution-muted-foreground: #fde047;
--caution-border: #4e4519;
/* Danger goes RED here, not rose: a rose "danger" beside a pink primary is
indistinguishable, and this is the one colour that must never be missed. */
--danger: #f43f3f;
--danger-foreground: #2a0606;
--danger-muted: #3d1418;
--danger-muted-foreground: #fca5a5;
--danger-border: #5c2128;
--info: #38bdf8;
--info-foreground: #051d29;
--info-muted: #0f2b3a;
--info-muted-foreground: #7dd3fc;
--info-border: #16485d;
--mx-call-conf: #22c55e;
--mx-call-work: #2f7d55;
--mx-dx-conf: #60a5fa;
--mx-dx-work: #35538c;
--mx-none: #3a2647;
--scrollbar-thumb: #4a3159;
--scrollbar-thumb-hover: #644176;
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(238, 224, 240, 0.05);
color-scheme: dark;
}
/* ---- Theme 8: Nordic light — crisp cool white, indigo accent ----------- */
[data-theme="light-nordic"] {
--background: #eef1f7;
--foreground: #1b2130;
--card: #ffffff;
--card-foreground: #1b2130;
--popover: #ffffff;
--popover-foreground: #1b2130;
--primary: #4f46e5; /* indigo */
--primary-foreground: #ffffff;
--secondary: #dfe4ee;
--secondary-foreground: #1b2130;
--muted: #e2e7f0;
--muted-foreground: #4a5468;
--accent: #dcdcfb;
--accent-foreground: #312a91;
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--destructive-muted: #fef2f2;
--destructive-muted-foreground: #b91c1c;
--border: #c9d1e0;
--input: #c9d1e0;
--ring: #6366f1;
--success: #15803d;
--success-foreground: #ffffff;
--success-muted: #dcfce7;
--success-muted-foreground: #14532d;
--success-border: #86efac;
--warning: #b45309;
--warning-foreground: #ffffff;
--warning-muted: #fef3c7;
--warning-muted-foreground: #92400e;
--warning-border: #fcd34d;
--caution: #a16207;
--caution-foreground: #ffffff;
--caution-muted: #fef9c3;
--caution-muted-foreground: #854d0e;
--caution-border: #fde047;
--danger: #dc2626;
--danger-foreground: #ffffff;
--danger-muted: #fee2e2;
--danger-muted-foreground: #991b1b;
--danger-border: #fca5a5;
--info: #0369a1;
--info-foreground: #ffffff;
--info-muted: #e0f2fe;
--info-muted-foreground: #075985;
--info-border: #7dd3fc;
/* Entity ramp shifts to TEAL: indigo is the primary here. */
--mx-call-conf: #15803d;
--mx-call-work: #86efac;
--mx-dx-conf: #0f766e;
--mx-dx-work: #99f6e4;
--mx-none: #dde3ec;
--scrollbar-thumb: #b6c0d2;
--scrollbar-thumb-hover: #93a0b8;
--card-shadow: 0 1px 2px rgba(27, 33, 48, 0.06), 0 0 0 1px rgba(27, 33, 48, 0.03);
color-scheme: light;
}
/* Data-viz palette (Statistics dashboard)
A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes
it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never
@@ -512,7 +792,8 @@
:root,
[data-theme="light-warm"],
[data-theme="light-cool"],
[data-theme="light-sage"] {
[data-theme="light-sage"],
[data-theme="light-nordic"] {
--chart-1: #2a78d6; /* blue — default single-series hue */
--chart-2: #1baf7a; /* aqua */
--chart-3: #eda100; /* yellow */
@@ -533,7 +814,10 @@
[data-theme="dim-slate"],
[data-theme="dark-warm"],
[data-theme="dark-graphite"],
[data-theme="high-contrast"] {
[data-theme="high-contrast"],
[data-theme="dark-indigo"],
[data-theme="dark-teal"],
[data-theme="dark-plum"] {
--chart-1: #3987e5;
--chart-2: #199e70;
--chart-3: #c98500;
@@ -652,3 +936,38 @@
border: 2px solid var(--background);
}
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
/* Frameless window: drag region
The OS title bar is gone (main.go, Frameless), so the app header IS the title
bar and must be draggable. Everything interactive inside it opts OUT: a drag
region swallows clicks, and without this the menus and toolbar buttons would
move the window instead of doing their job. Opting out by ROLE rather than
listing each child keeps a future button working without anyone remembering
this rule. */
.app-dragregion {
--wails-draggable: drag;
}
.app-dragregion button,
.app-dragregion a,
.app-dragregion input,
.app-dragregion select,
.app-dragregion textarea,
.app-dragregion nav,
.app-dragregion [role='button'],
.app-dragregion [role='menu'],
.app-dragregion [data-no-drag] {
--wails-draggable: no-drag;
}
/* Native date inputs: the WebView draws its calendar glyph in near-black, which
disappears on the dark themes. Invert it there the control itself is worth
keeping (OS calendar, locale date order, keyboard entry), only its icon needs
help. */
Every temporal input carries the same glyph, so the rule names them all:
datetime-local was left out at first and its icon stayed invisible. */
:is([data-theme='dim-slate'], [data-theme='dark-warm'], [data-theme='dark-graphite'], [data-theme='high-contrast'],
[data-theme='dark-indigo'], [data-theme='dark-teal'], [data-theme='dark-plum'])
:is(input[type='date'], input[type='datetime-local'], input[type='time'], input[type='month'], input[type='week'])::-webkit-calendar-picker-indicator {
filter: invert(1) brightness(1.8);
opacity: 0.9;
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.20.1';
export const APP_VERSION = '0.24.3';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+268 -11
View File
@@ -5,13 +5,19 @@ import {qso} from '../models';
import {main} from '../models';
import {cat} from '../models';
import {profile} from '../models';
import {acom} from '../models';
import {catemu} from '../models';
import {antgenius} from '../models';
import {award} from '../models';
import {awardref} from '../models';
import {bandopen} from '../models';
import {cluster} from '../models';
import {extsvc} from '../models';
import {powergenius} from '../models';
import {spe} from '../models';
import {solar} from '../models';
import {tunergenius} from '../models';
import {webpub} from '../models';
import {winkeyer} from '../models';
import {alerts} from '../models';
import {audio} from '../models';
@@ -21,6 +27,13 @@ import {udp} from '../models';
import {lotwusers} from '../models';
import {lookup} from '../models';
import {netctl} from '../models';
import {scp} from '../models';
export function ACOMSetOperate(arg1:boolean):Promise<void>;
export function ACOMSetPower(arg1:boolean):Promise<void>;
export function ADIFExtraFields():Promise<Array<string>>;
export function ADIFFields():Promise<Array<adif.FieldDef>>;
@@ -30,6 +43,14 @@ export function ActivateProfile(arg1:number):Promise<void>;
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 AntGeniusDeselect(arg1:number):Promise<void>;
@@ -42,6 +63,8 @@ export function ApplyAwardUpdate(arg1:string):Promise<void>;
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
export function AudioApplyLevels(arg1:number,arg2:number):Promise<void>;
export function AudioMonitorActive():Promise<boolean>;
export function AudioStartMonitor():Promise<void>;
@@ -54,7 +77,7 @@ export function AudioStopTX():Promise<void>;
export function AudioTXActive():Promise<boolean>;
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
export function AwardFields():Promise<Array<string>>;
@@ -66,14 +89,20 @@ export function AwardsFolder():Promise<string>;
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
export function BandSlotQSOs(arg1:string,arg2:number,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
export function BrowseExecutable():Promise<string>;
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
export function CIVTraceEnabled():Promise<boolean>;
export function CWDecoderRunning():Promise<boolean>;
export function CancelConfirmations():Promise<void>;
export function ChatAvailable():Promise<boolean>;
export function CheckForUpdate():Promise<main.UpdateInfo>;
@@ -146,26 +175,34 @@ export function DismissAwardUpdate(arg1:string):Promise<void>;
export function DownloadAllReferenceLists():Promise<string>;
export function DownloadAndApplyUpdate(arg1:string):Promise<void>;
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
export function DownloadClublogMostWanted():Promise<main.ClublogMostWantedInfo>;
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
export function DownloadLoTWUsers():Promise<number>;
export function DownloadScp():Promise<number>;
export function DownloadULSCounties():Promise<void>;
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
export function ExportADIF(arg1:string,arg2:boolean,arg3:Array<string>):Promise<adif.ExportResult>;
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>;
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter,arg4:Array<string>):Promise<adif.ExportResult>;
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>;
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>,arg4:Array<string>):Promise<adif.ExportResult>;
export function ExportAward(arg1:string):Promise<string>;
export function ExportAwardForCatalog(arg1:string,arg2:number):Promise<string>;
export function ExportAwards():Promise<string>;
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
@@ -188,8 +225,14 @@ export function FlexAmpOperate(arg1:boolean):Promise<void>;
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
export function FlexApplyBandPower(arg1:string,arg2:string):Promise<void>;
export function FlexBackspaceCW(arg1:number):Promise<void>;
export function FlexMox(arg1:boolean):Promise<void>;
export function FlexSendCW(arg1:string):Promise<void>;
export function FlexSetAGCMode(arg1:string):Promise<void>;
export function FlexSetAGCThreshold(arg1:number):Promise<void>;
@@ -198,6 +241,8 @@ export function FlexSetANF(arg1:boolean):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 FlexSetAPFLevel(arg1:number):Promise<void>;
@@ -218,8 +263,20 @@ export function FlexSetCWSidetone(arg1:boolean):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 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 FlexSetMicProfile(arg1:string):Promise<void>;
@@ -236,6 +293,10 @@ export function FlexSetNBLevel(arg1:number):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 FlexSetPower(arg1:number):Promise<void>;
@@ -248,14 +309,22 @@ export function FlexSetRIT(arg1:boolean):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 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 FlexSetTXAntenna(arg1:string):Promise<void>;
export function FlexSetTXDAX(arg1:boolean):Promise<void>;
export function FlexSetTXFilter(arg1:number,arg2:number):Promise<void>;
export function FlexSetTXSlice(arg1:number):Promise<void>;
@@ -276,14 +345,24 @@ export function FlexSetXIT(arg1:boolean):Promise<void>;
export function FlexSetXITFreq(arg1:number):Promise<void>;
export function FlexStopCW():Promise<void>;
export function FlexTune(arg1:boolean):Promise<void>;
export function GetACOMStatus():Promise<acom.Status>;
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
export function GetActiveProfile():Promise<profile.Profile>;
export function GetAlertEmailTo():Promise<string>;
export function GetAmpBandFollowStatus():Promise<Record<string, catemu.Status>>;
export function GetAmpStatuses():Promise<Array<main.AmpStatus>>;
export function GetAmplifiers():Promise<Array<main.AmpConfig>>;
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
export function GetAntGeniusStatus():Promise<antgenius.Status>;
@@ -292,7 +371,7 @@ export function GetAudioSettings():Promise<main.AudioSettings>;
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
export function GetAward(arg1:string):Promise<award.Result>;
export function GetAward(arg1:string,arg2:string):Promise<award.Result>;
export function GetAwardDefs():Promise<Array<award.Def>>;
@@ -308,6 +387,8 @@ export function GetAwards():Promise<Array<award.Result>>;
export function GetBackupSettings():Promise<main.BackupSettings>;
export function GetBandOpenings():Promise<Array<bandopen.Opening>>;
export function GetCATSettings():Promise<main.CATSettings>;
export function GetCATState():Promise<cat.RigState>;
@@ -316,10 +397,14 @@ export function GetCWDecoderPitch():Promise<number>;
export function GetCatalogCodes():Promise<Array<string>>;
export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
export function GetClublogMostWantedInfo():Promise<main.ClublogMostWantedInfo>;
export function GetClusterAutoConnect():Promise<boolean>;
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
@@ -346,13 +431,15 @@ export function GetExternalServices():Promise<extsvc.ExternalServices>;
export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
export function GetFlexBandPower():Promise<Record<string, main.FlexBandPower>>;
export function GetFlexState():Promise<cat.FlexTXState>;
export function GetIcomState():Promise<cat.IcomTXState>;
export function GetListsSettings():Promise<main.ListsSettings>;
export function GetLiveStatusEnabled():Promise<boolean>;
export function GetLiveStations():Promise<Array<main.LiveStation>>;
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
@@ -390,10 +477,16 @@ export function GetRelayAuto():Promise<main.RelayAutoConfig>;
export function GetRotatorHeading():Promise<main.RotatorHeading>;
export function GetRotatorSettings():Promise<main.RotatorSettings>;
export function GetRotators():Promise<Array<main.RotatorDevice>>;
export function GetSPEStatus():Promise<spe.Status>;
export function GetScpStatus():Promise<main.ScpStatus>;
export function GetSecretStatus():Promise<main.SecretStatus>;
export function GetSelfSpotSettings():Promise<main.SelfSpotSettings>;
export function GetSlotStats():Promise<qso.SlotStats>;
export function GetSolarData():Promise<solar.Data>;
@@ -408,16 +501,32 @@ export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
export function GetTelemetryEnabled():Promise<boolean>;
export function GetTrackedAwards():Promise<Array<string>>;
export function GetTunerGeniusSettings():Promise<main.TunerGeniusSettings>;
export function GetTunerGeniusStatus():Promise<tunergenius.Status>;
export function GetUIPref(arg1:string):Promise<string>;
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
export function GetWebPublishConfig():Promise<webpub.Config>;
export function GetWebPublishStatus():Promise<main.WebPublishStatus>;
export function GetWhatsNew():Promise<Array<main.ChangelogEntry>>;
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
export function GetWorkedCallVariants():Promise<boolean>;
export function GetYaesuState():Promise<cat.YaesuTXState>;
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
export function IcomRefresh():Promise<void>;
@@ -506,7 +615,7 @@ export function IcomStopCW():Promise<void>;
export function IcomTune():Promise<void>;
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean,arg5:Record<string, string>):Promise<adif.ImportResult>;
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
@@ -514,6 +623,12 @@ export function ImportAwards():Promise<main.AwardImportResult>;
export function InspectAwardImport():Promise<main.AwardImportPreview>;
export function IsNewUSCounty(arg1:string,arg2:string):Promise<boolean>;
export function KenwoodSendCW(arg1:string):Promise<void>;
export function KenwoodStopCW():Promise<void>;
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
@@ -532,6 +647,8 @@ export function ListContests():Promise<Array<contest.Def>>;
export function ListCountries():Promise<Array<string>>;
export function ListDenkoviDevices():Promise<Array<string>>;
export function ListOperatingTree():Promise<Array<operating.Station>>;
export function ListProfiles():Promise<Array<profile.Profile>>;
@@ -546,12 +663,18 @@ export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
export function LiveLastQSOAgeSec():Promise<number>;
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
export function LogUIError(arg1:string,arg2:string,arg3:string):Promise<void>;
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
export function LookupCallsignFresh(arg1:string):Promise<lookup.Result>;
export function MotorReadElements():Promise<Array<number>>;
export function MotorSetElement(arg1:number,arg2:number):Promise<void>;
@@ -604,6 +727,12 @@ export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefau
export function PGXLSetFanMode(arg1:string):Promise<void>;
export function PGXLSetOperate(arg1:boolean):Promise<void>;
export function PTTHotkeyDown():Promise<void>;
export function PTTHotkeyUp():Promise<void>;
export function PickADIFMonitorFile():Promise<string>;
export function PickAudioFolder():Promise<string>;
@@ -616,6 +745,8 @@ export function PickSaveDatabase():Promise<string>;
export function PopulateBuiltinReferences(arg1:string):Promise<number>;
export function PublishLogNow():Promise<string>;
export function QSLDefaultTemplateID():Promise<number>;
export function QSLDeleteTemplate(arg1:number):Promise<void>;
@@ -654,20 +785,38 @@ export function QSOAudioBegin():Promise<boolean>;
export function QSOAudioCancel():Promise<void>;
export function QSOAudioManualReady():Promise<boolean>;
export function QSOAudioManualStart():Promise<boolean>;
export function QSOAudioPlayOnAir():Promise<void>;
export function QSOAudioResetClock():Promise<boolean>;
export function QSOAudioRestart():Promise<boolean>;
export function QSOAudioResume():Promise<boolean>;
export function QSOAudioStop():Promise<boolean>;
export function QuitApp():Promise<void>;
export function RecomputeAllAwardRefs():Promise<number>;
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function RefreshSolar():Promise<void>;
export function RefreshYaesuPanel():Promise<void>;
export function ReloadUDPIntegrations():Promise<Array<string>>;
export function RemovePassphrase(arg1:string):Promise<void>;
export function RenameDatabase(arg1:string):Promise<void>;
export function RenameLogbook(arg1:string):Promise<void>;
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
@@ -686,6 +835,8 @@ export function RestartQSORecorder():Promise<void>;
export function RetryOfflineSync():Promise<number>;
export function RevealDataFolder():Promise<void>;
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
export function RotatorPark():Promise<void>;
@@ -694,12 +845,22 @@ export function RotatorStop():Promise<void>;
export function RunBackupNow():Promise<string>;
export function SMTPConfigured():Promise<boolean>;
export function SPESetOperate(arg1:boolean):Promise<void>;
export function SPESetPower(arg1:boolean):Promise<void>;
export function SPESetPowerLevel(arg1:string):Promise<void>;
export function SaveADIFFile():Promise<string>;
export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>;
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 SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
@@ -724,6 +885,8 @@ export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>
export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Promise<void>;
export function SaveFlexBandPower(arg1:Record<string, main.FlexBandPower>):Promise<void>;
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
@@ -744,18 +907,28 @@ export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
export function SaveTrackedAwards(arg1:Array<string>):Promise<void>;
export function SaveTunerGeniusSettings(arg1:main.TunerGeniusSettings):Promise<void>;
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):Promise<void>;
export function SaveWebPublishConfig(arg1:webpub.Config):Promise<void>;
export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
export function ScpLookup(arg1:string):Promise<scp.Result>;
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
export function SendChatMessage(arg1:string):Promise<main.ChatMessage>;
@@ -766,34 +939,88 @@ export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<voi
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 SetActiveRotor(arg1:number):Promise<void>;
export function SetAlertEmailTo(arg1:string):Promise<void>;
export function SetCATFrequency(arg1:number):Promise<void>;
export function SetCATMode(arg1:string):Promise<void>;
export function SetCIVTrace(arg1:boolean):Promise<void>;
export function SetCWDecoderPitch(arg1:number):Promise<void>;
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
export function SetClublogMostWantedEnabled(arg1:boolean):Promise<void>;
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
export function SetCompactMode(arg1:boolean):Promise<void>;
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
export function SetLiveStatusEnabled(arg1:boolean):Promise<void>;
export function SetKenwoodKeySpeed(arg1:number):Promise<void>;
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
export function SetPassphrase(arg1:string):Promise<void>;
export function SetScpEnabled(arg1:boolean):Promise<void>;
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
export function SetUltrabeamDirection(arg1:number):Promise<void>;
export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
export function SetWorkedCallVariants(arg1:boolean):Promise<void>;
export function SetYaesuAFGain(arg1:number):Promise<void>;
export function SetYaesuAGC(arg1:string):Promise<void>;
export function SetYaesuAtt(arg1:number):Promise<void>;
export function SetYaesuBand(arg1:string):Promise<void>;
export function SetYaesuBreakIn(arg1:boolean):Promise<void>;
export function SetYaesuKeySpeed(arg1:number):Promise<void>;
export function SetYaesuMicGain(arg1:number):Promise<void>;
export function SetYaesuModeRaw(arg1:string):Promise<void>;
export function SetYaesuNB(arg1:boolean):Promise<void>;
export function SetYaesuNR(arg1:boolean):Promise<void>;
export function SetYaesuNRLevel(arg1:number):Promise<void>;
export function SetYaesuNarrow(arg1:boolean):Promise<void>;
export function SetYaesuPower(arg1:number):Promise<void>;
export function SetYaesuPreamp(arg1:number):Promise<void>;
export function SetYaesuRFGain(arg1:number):Promise<void>;
export function SetYaesuSplit(arg1:boolean):Promise<void>;
export function SetYaesuSplitOffset(arg1:number):Promise<void>;
export function SetYaesuSquelch(arg1:number):Promise<void>;
export function SetYaesuVOX(arg1:boolean):Promise<void>;
export function StartCWDecoder():Promise<void>;
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
@@ -804,6 +1031,10 @@ export function SwitchCATRig(arg1:number):Promise<void>;
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
export function TailLogFile(arg1:number):Promise<string>;
export function TestCloudlogUpload():Promise<string>;
export function TestClublogUpload():Promise<string>;
export function TestEQSLUpload():Promise<string>;
@@ -822,10 +1053,26 @@ export function TestPTT(arg1:main.AudioSettings):Promise<void>;
export function TestQRZUpload():Promise<string>;
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
export function TestRotatorDevice(arg1:main.RotatorDevice,arg2:number):Promise<void>;
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
export function TestWebPublishFTP(arg1:webpub.Config):Promise<string>;
export function TuneYaesuATU():Promise<void>;
export function TunerGeniusActivate(arg1:number):Promise<void>;
export function TunerGeniusAutotune():Promise<void>;
export function TunerGeniusSetBypass(arg1:boolean):Promise<void>;
export function TunerGeniusSetOperate(arg1:boolean):Promise<void>;
export function UILog(arg1:string):Promise<void>;
export function ULSStatus():Promise<main.ULSStatusResult>;
export function UltrabeamRetract():Promise<void>;
@@ -846,6 +1093,8 @@ export function UploadCallsign(arg1:string):Promise<string>;
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
export function WebPublishColumns():Promise<Array<Record<string, string>>>;
export function WinkeyerBackspace():Promise<void>;
export function WinkeyerConnect():Promise<void>;
@@ -858,4 +1107,12 @@ export function WinkeyerSetSpeed(arg1:number):Promise<void>;
export function WinkeyerStop():Promise<void>;
export function WinkeyerTraceEnabled():Promise<boolean>;
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
export function YaesuSendCW(arg1:string):Promise<void>;
export function YaesuStopCW():Promise<void>;
export function YaesuZeroIn():Promise<void>;
+522 -22
View File
@@ -2,6 +2,18 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function ACOMSetOperate(arg1) {
return window['go']['main']['App']['ACOMSetOperate'](arg1);
}
export function ACOMSetPower(arg1) {
return window['go']['main']['App']['ACOMSetPower'](arg1);
}
export function ADIFExtraFields() {
return window['go']['main']['App']['ADIFExtraFields']();
}
export function ADIFFields() {
return window['go']['main']['App']['ADIFFields']();
}
@@ -18,6 +30,22 @@ export function 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) {
return window['go']['main']['App']['AntGeniusActivate'](arg1, arg2);
}
@@ -42,6 +70,10 @@ export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
}
export function AudioApplyLevels(arg1, arg2) {
return window['go']['main']['App']['AudioApplyLevels'](arg1, arg2);
}
export function AudioMonitorActive() {
return window['go']['main']['App']['AudioMonitorActive']();
}
@@ -66,8 +98,8 @@ export function AudioTXActive() {
return window['go']['main']['App']['AudioTXActive']();
}
export function AwardCellQSOs(arg1, arg2, arg3) {
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
export function AwardCellQSOs(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3, arg4);
}
export function AwardFields() {
@@ -90,6 +122,10 @@ export function BackfillUSCounties() {
return window['go']['main']['App']['BackfillUSCounties']();
}
export function BandSlotQSOs(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['BandSlotQSOs'](arg1, arg2, arg3, arg4);
}
export function BrowseExecutable() {
return window['go']['main']['App']['BrowseExecutable']();
}
@@ -102,10 +138,18 @@ export function BulkUpdateQSL(arg1, arg2) {
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
}
export function CIVTraceEnabled() {
return window['go']['main']['App']['CIVTraceEnabled']();
}
export function CWDecoderRunning() {
return window['go']['main']['App']['CWDecoderRunning']();
}
export function CancelConfirmations() {
return window['go']['main']['App']['CancelConfirmations']();
}
export function ChatAvailable() {
return window['go']['main']['App']['ChatAvailable']();
}
@@ -250,10 +294,18 @@ export function DownloadAllReferenceLists() {
return window['go']['main']['App']['DownloadAllReferenceLists']();
}
export function DownloadAndApplyUpdate(arg1) {
return window['go']['main']['App']['DownloadAndApplyUpdate'](arg1);
}
export function DownloadClublogCty() {
return window['go']['main']['App']['DownloadClublogCty']();
}
export function DownloadClublogMostWanted() {
return window['go']['main']['App']['DownloadClublogMostWanted']();
}
export function DownloadConfirmations(arg1, arg2, arg3) {
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3);
}
@@ -262,6 +314,10 @@ export function DownloadLoTWUsers() {
return window['go']['main']['App']['DownloadLoTWUsers']();
}
export function DownloadScp() {
return window['go']['main']['App']['DownloadScp']();
}
export function DownloadULSCounties() {
return window['go']['main']['App']['DownloadULSCounties']();
}
@@ -274,22 +330,26 @@ export function ExplainAward(arg1, arg2) {
return window['go']['main']['App']['ExplainAward'](arg1, arg2);
}
export function ExportADIF(arg1, arg2) {
return window['go']['main']['App']['ExportADIF'](arg1, arg2);
export function ExportADIF(arg1, arg2, arg3) {
return window['go']['main']['App']['ExportADIF'](arg1, arg2, arg3);
}
export function ExportADIFFiltered(arg1, arg2, arg3) {
return window['go']['main']['App']['ExportADIFFiltered'](arg1, arg2, arg3);
export function ExportADIFFiltered(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['ExportADIFFiltered'](arg1, arg2, arg3, arg4);
}
export function ExportADIFSelected(arg1, arg2, arg3) {
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3);
export function ExportADIFSelected(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3, arg4);
}
export function ExportAward(arg1) {
return window['go']['main']['App']['ExportAward'](arg1);
}
export function ExportAwardForCatalog(arg1, arg2) {
return window['go']['main']['App']['ExportAwardForCatalog'](arg1, arg2);
}
export function ExportAwards() {
return window['go']['main']['App']['ExportAwards']();
}
@@ -334,10 +394,22 @@ export function FlexApplyBandAntenna(arg1) {
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
}
export function FlexApplyBandPower(arg1, arg2) {
return window['go']['main']['App']['FlexApplyBandPower'](arg1, arg2);
}
export function FlexBackspaceCW(arg1) {
return window['go']['main']['App']['FlexBackspaceCW'](arg1);
}
export function FlexMox(arg1) {
return window['go']['main']['App']['FlexMox'](arg1);
}
export function FlexSendCW(arg1) {
return window['go']['main']['App']['FlexSendCW'](arg1);
}
export function FlexSetAGCMode(arg1) {
return window['go']['main']['App']['FlexSetAGCMode'](arg1);
}
@@ -354,6 +426,10 @@ export function FlexSetANFLevel(arg1) {
return window['go']['main']['App']['FlexSetANFLevel'](arg1);
}
export function FlexSetANFT(arg1) {
return window['go']['main']['App']['FlexSetANFT'](arg1);
}
export function FlexSetAPF(arg1) {
return window['go']['main']['App']['FlexSetAPF'](arg1);
}
@@ -394,10 +470,34 @@ export function 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) {
return window['go']['main']['App']['FlexSetFilter'](arg1, arg2);
}
export function 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) {
return window['go']['main']['App']['FlexSetMic'](arg1);
}
@@ -430,6 +530,14 @@ export function 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) {
return window['go']['main']['App']['FlexSetNRLevel'](arg1);
}
@@ -454,6 +562,10 @@ export function FlexSetRITFreq(arg1) {
return window['go']['main']['App']['FlexSetRITFreq'](arg1);
}
export function FlexSetRNN(arg1) {
return window['go']['main']['App']['FlexSetRNN'](arg1);
}
export function FlexSetRXAntenna(arg1) {
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
}
@@ -462,6 +574,14 @@ export function 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) {
return window['go']['main']['App']['FlexSetSplit'](arg1);
}
@@ -470,6 +590,10 @@ export function 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) {
return window['go']['main']['App']['FlexSetTXFilter'](arg1, arg2);
}
@@ -510,10 +634,18 @@ export function FlexSetXITFreq(arg1) {
return window['go']['main']['App']['FlexSetXITFreq'](arg1);
}
export function FlexStopCW() {
return window['go']['main']['App']['FlexStopCW']();
}
export function FlexTune(arg1) {
return window['go']['main']['App']['FlexTune'](arg1);
}
export function GetACOMStatus() {
return window['go']['main']['App']['GetACOMStatus']();
}
export function GetADIFMonitor() {
return window['go']['main']['App']['GetADIFMonitor']();
}
@@ -526,6 +658,18 @@ export function GetAlertEmailTo() {
return window['go']['main']['App']['GetAlertEmailTo']();
}
export function GetAmpBandFollowStatus() {
return window['go']['main']['App']['GetAmpBandFollowStatus']();
}
export function GetAmpStatuses() {
return window['go']['main']['App']['GetAmpStatuses']();
}
export function GetAmplifiers() {
return window['go']['main']['App']['GetAmplifiers']();
}
export function GetAntGeniusSettings() {
return window['go']['main']['App']['GetAntGeniusSettings']();
}
@@ -542,8 +686,8 @@ export function GetAutostartPrograms() {
return window['go']['main']['App']['GetAutostartPrograms']();
}
export function GetAward(arg1) {
return window['go']['main']['App']['GetAward'](arg1);
export function GetAward(arg1, arg2) {
return window['go']['main']['App']['GetAward'](arg1, arg2);
}
export function GetAwardDefs() {
@@ -574,6 +718,10 @@ export function GetBackupSettings() {
return window['go']['main']['App']['GetBackupSettings']();
}
export function GetBandOpenings() {
return window['go']['main']['App']['GetBandOpenings']();
}
export function GetCATSettings() {
return window['go']['main']['App']['GetCATSettings']();
}
@@ -590,6 +738,10 @@ export function GetCatalogCodes() {
return window['go']['main']['App']['GetCatalogCodes']();
}
export function GetChangelog() {
return window['go']['main']['App']['GetChangelog']();
}
export function GetChatHistory(arg1) {
return window['go']['main']['App']['GetChatHistory'](arg1);
}
@@ -598,6 +750,10 @@ export function GetClublogCtyInfo() {
return window['go']['main']['App']['GetClublogCtyInfo']();
}
export function GetClublogMostWantedInfo() {
return window['go']['main']['App']['GetClublogMostWantedInfo']();
}
export function GetClusterAutoConnect() {
return window['go']['main']['App']['GetClusterAutoConnect']();
}
@@ -650,6 +806,10 @@ export function GetFlexBandAntennas() {
return window['go']['main']['App']['GetFlexBandAntennas']();
}
export function GetFlexBandPower() {
return window['go']['main']['App']['GetFlexBandPower']();
}
export function GetFlexState() {
return window['go']['main']['App']['GetFlexState']();
}
@@ -662,8 +822,8 @@ export function GetListsSettings() {
return window['go']['main']['App']['GetListsSettings']();
}
export function GetLiveStatusEnabled() {
return window['go']['main']['App']['GetLiveStatusEnabled']();
export function GetLiveStations() {
return window['go']['main']['App']['GetLiveStations']();
}
export function GetLoTWUsersStatus() {
@@ -738,14 +898,26 @@ export function GetRotatorHeading() {
return window['go']['main']['App']['GetRotatorHeading']();
}
export function GetRotatorSettings() {
return window['go']['main']['App']['GetRotatorSettings']();
export function GetRotators() {
return window['go']['main']['App']['GetRotators']();
}
export function GetSPEStatus() {
return window['go']['main']['App']['GetSPEStatus']();
}
export function GetScpStatus() {
return window['go']['main']['App']['GetScpStatus']();
}
export function GetSecretStatus() {
return window['go']['main']['App']['GetSecretStatus']();
}
export function GetSelfSpotSettings() {
return window['go']['main']['App']['GetSelfSpotSettings']();
}
export function GetSlotStats() {
return window['go']['main']['App']['GetSlotStats']();
}
@@ -774,6 +946,18 @@ export function GetTelemetryEnabled() {
return window['go']['main']['App']['GetTelemetryEnabled']();
}
export function GetTrackedAwards() {
return window['go']['main']['App']['GetTrackedAwards']();
}
export function GetTunerGeniusSettings() {
return window['go']['main']['App']['GetTunerGeniusSettings']();
}
export function GetTunerGeniusStatus() {
return window['go']['main']['App']['GetTunerGeniusStatus']();
}
export function GetUIPref(arg1) {
return window['go']['main']['App']['GetUIPref'](arg1);
}
@@ -786,6 +970,18 @@ export function GetUltrabeamStatus() {
return window['go']['main']['App']['GetUltrabeamStatus']();
}
export function GetWebPublishConfig() {
return window['go']['main']['App']['GetWebPublishConfig']();
}
export function GetWebPublishStatus() {
return window['go']['main']['App']['GetWebPublishStatus']();
}
export function GetWhatsNew() {
return window['go']['main']['App']['GetWhatsNew']();
}
export function GetWinkeyerSettings() {
return window['go']['main']['App']['GetWinkeyerSettings']();
}
@@ -794,6 +990,14 @@ export function GetWinkeyerStatus() {
return window['go']['main']['App']['GetWinkeyerStatus']();
}
export function GetWorkedCallVariants() {
return window['go']['main']['App']['GetWorkedCallVariants']();
}
export function GetYaesuState() {
return window['go']['main']['App']['GetYaesuState']();
}
export function HasBuiltinReferences(arg1) {
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
}
@@ -970,8 +1174,8 @@ export function IcomTune() {
return window['go']['main']['App']['IcomTune']();
}
export function ImportADIF(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4);
export function ImportADIF(arg1, arg2, arg3, arg4, arg5) {
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4, arg5);
}
export function ImportAwardReferencesText(arg1, arg2) {
@@ -986,6 +1190,18 @@ export function InspectAwardImport() {
return window['go']['main']['App']['InspectAwardImport']();
}
export function IsNewUSCounty(arg1, arg2) {
return window['go']['main']['App']['IsNewUSCounty'](arg1, arg2);
}
export function KenwoodSendCW(arg1) {
return window['go']['main']['App']['KenwoodSendCW'](arg1);
}
export function KenwoodStopCW() {
return window['go']['main']['App']['KenwoodStopCW']();
}
export function LaunchAutostartProgram(arg1) {
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
}
@@ -1022,6 +1238,10 @@ export function ListCountries() {
return window['go']['main']['App']['ListCountries']();
}
export function ListDenkoviDevices() {
return window['go']['main']['App']['ListDenkoviDevices']();
}
export function ListOperatingTree() {
return window['go']['main']['App']['ListOperatingTree']();
}
@@ -1050,6 +1270,10 @@ export function ListUDPIntegrations() {
return window['go']['main']['App']['ListUDPIntegrations']();
}
export function LiveLastQSOAgeSec() {
return window['go']['main']['App']['LiveLastQSOAgeSec']();
}
export function LoTWUserInfo(arg1) {
return window['go']['main']['App']['LoTWUserInfo'](arg1);
}
@@ -1058,10 +1282,18 @@ export function LogUDPLoggedADIF(arg1) {
return window['go']['main']['App']['LogUDPLoggedADIF'](arg1);
}
export function LogUIError(arg1, arg2, arg3) {
return window['go']['main']['App']['LogUIError'](arg1, arg2, arg3);
}
export function LookupCallsign(arg1) {
return window['go']['main']['App']['LookupCallsign'](arg1);
}
export function LookupCallsignFresh(arg1) {
return window['go']['main']['App']['LookupCallsignFresh'](arg1);
}
export function MotorReadElements() {
return window['go']['main']['App']['MotorReadElements']();
}
@@ -1166,6 +1398,18 @@ export function PGXLSetFanMode(arg1) {
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
}
export function PGXLSetOperate(arg1) {
return window['go']['main']['App']['PGXLSetOperate'](arg1);
}
export function PTTHotkeyDown() {
return window['go']['main']['App']['PTTHotkeyDown']();
}
export function PTTHotkeyUp() {
return window['go']['main']['App']['PTTHotkeyUp']();
}
export function PickADIFMonitorFile() {
return window['go']['main']['App']['PickADIFMonitorFile']();
}
@@ -1190,6 +1434,10 @@ export function PopulateBuiltinReferences(arg1) {
return window['go']['main']['App']['PopulateBuiltinReferences'](arg1);
}
export function PublishLogNow() {
return window['go']['main']['App']['PublishLogNow']();
}
export function QSLDefaultTemplateID() {
return window['go']['main']['App']['QSLDefaultTemplateID']();
}
@@ -1266,6 +1514,18 @@ export function QSOAudioCancel() {
return window['go']['main']['App']['QSOAudioCancel']();
}
export function QSOAudioManualReady() {
return window['go']['main']['App']['QSOAudioManualReady']();
}
export function QSOAudioManualStart() {
return window['go']['main']['App']['QSOAudioManualStart']();
}
export function QSOAudioPlayOnAir() {
return window['go']['main']['App']['QSOAudioPlayOnAir']();
}
export function QSOAudioResetClock() {
return window['go']['main']['App']['QSOAudioResetClock']();
}
@@ -1274,10 +1534,22 @@ export function QSOAudioRestart() {
return window['go']['main']['App']['QSOAudioRestart']();
}
export function QSOAudioResume() {
return window['go']['main']['App']['QSOAudioResume']();
}
export function QSOAudioStop() {
return window['go']['main']['App']['QSOAudioStop']();
}
export function QuitApp() {
return window['go']['main']['App']['QuitApp']();
}
export function RecomputeAllAwardRefs() {
return window['go']['main']['App']['RecomputeAllAwardRefs']();
}
export function RefreshCtyDat() {
return window['go']['main']['App']['RefreshCtyDat']();
}
@@ -1286,6 +1558,10 @@ export function RefreshSolar() {
return window['go']['main']['App']['RefreshSolar']();
}
export function RefreshYaesuPanel() {
return window['go']['main']['App']['RefreshYaesuPanel']();
}
export function ReloadUDPIntegrations() {
return window['go']['main']['App']['ReloadUDPIntegrations']();
}
@@ -1294,6 +1570,14 @@ export function RemovePassphrase(arg1) {
return window['go']['main']['App']['RemovePassphrase'](arg1);
}
export function RenameDatabase(arg1) {
return window['go']['main']['App']['RenameDatabase'](arg1);
}
export function RenameLogbook(arg1) {
return window['go']['main']['App']['RenameLogbook'](arg1);
}
export function RenderEQSL(arg1, arg2) {
return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
}
@@ -1330,6 +1614,10 @@ export function RetryOfflineSync() {
return window['go']['main']['App']['RetryOfflineSync']();
}
export function RevealDataFolder() {
return window['go']['main']['App']['RevealDataFolder']();
}
export function RotatorGoTo(arg1, arg2) {
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
}
@@ -1346,6 +1634,22 @@ export function RunBackupNow() {
return window['go']['main']['App']['RunBackupNow']();
}
export function SMTPConfigured() {
return window['go']['main']['App']['SMTPConfigured']();
}
export function SPESetOperate(arg1) {
return window['go']['main']['App']['SPESetOperate'](arg1);
}
export function SPESetPower(arg1) {
return window['go']['main']['App']['SPESetPower'](arg1);
}
export function SPESetPowerLevel(arg1) {
return window['go']['main']['App']['SPESetPowerLevel'](arg1);
}
export function SaveADIFFile() {
return window['go']['main']['App']['SaveADIFFile']();
}
@@ -1358,6 +1662,10 @@ export function SaveAlertRule(arg1) {
return window['go']['main']['App']['SaveAlertRule'](arg1);
}
export function SaveAmplifiers(arg1) {
return window['go']['main']['App']['SaveAmplifiers'](arg1);
}
export function SaveAntGeniusSettings(arg1) {
return window['go']['main']['App']['SaveAntGeniusSettings'](arg1);
}
@@ -1406,6 +1714,10 @@ export function SaveFlexBandAntennas(arg1) {
return window['go']['main']['App']['SaveFlexBandAntennas'](arg1);
}
export function SaveFlexBandPower(arg1) {
return window['go']['main']['App']['SaveFlexBandPower'](arg1);
}
export function SaveListsSettings(arg1) {
return window['go']['main']['App']['SaveListsSettings'](arg1);
}
@@ -1446,8 +1758,12 @@ export function SaveRelayAuto(arg1) {
return window['go']['main']['App']['SaveRelayAuto'](arg1);
}
export function SaveRotatorSettings(arg1) {
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
export function SaveRotators(arg1) {
return window['go']['main']['App']['SaveRotators'](arg1);
}
export function SaveSelfSpotSettings(arg1) {
return window['go']['main']['App']['SaveSelfSpotSettings'](arg1);
}
export function SaveStationDevices(arg1) {
@@ -1458,6 +1774,14 @@ export function SaveStationSettings(arg1) {
return window['go']['main']['App']['SaveStationSettings'](arg1);
}
export function SaveTrackedAwards(arg1) {
return window['go']['main']['App']['SaveTrackedAwards'](arg1);
}
export function SaveTunerGeniusSettings(arg1) {
return window['go']['main']['App']['SaveTunerGeniusSettings'](arg1);
}
export function SaveUDPIntegration(arg1) {
return window['go']['main']['App']['SaveUDPIntegration'](arg1);
}
@@ -1466,10 +1790,18 @@ export function SaveUltrabeamSettings(arg1) {
return window['go']['main']['App']['SaveUltrabeamSettings'](arg1);
}
export function SaveWebPublishConfig(arg1) {
return window['go']['main']['App']['SaveWebPublishConfig'](arg1);
}
export function SaveWinkeyerSettings(arg1) {
return window['go']['main']['App']['SaveWinkeyerSettings'](arg1);
}
export function ScpLookup(arg1) {
return window['go']['main']['App']['ScpLookup'](arg1);
}
export function SearchAwardReferences(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4);
}
@@ -1490,10 +1822,18 @@ export function 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) {
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
}
export function SetActiveRotor(arg1) {
return window['go']['main']['App']['SetActiveRotor'](arg1);
}
export function SetAlertEmailTo(arg1) {
return window['go']['main']['App']['SetAlertEmailTo'](arg1);
}
@@ -1506,6 +1846,10 @@ export function SetCATMode(arg1) {
return window['go']['main']['App']['SetCATMode'](arg1);
}
export function SetCIVTrace(arg1) {
return window['go']['main']['App']['SetCIVTrace'](arg1);
}
export function SetCWDecoderPitch(arg1) {
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
}
@@ -1514,6 +1858,10 @@ export function SetClublogCtyEnabled(arg1) {
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
}
export function SetClublogMostWantedEnabled(arg1) {
return window['go']['main']['App']['SetClublogMostWantedEnabled'](arg1);
}
export function SetClusterAutoConnect(arg1) {
return window['go']['main']['App']['SetClusterAutoConnect'](arg1);
}
@@ -1526,14 +1874,22 @@ export function SetDVKLabel(arg1, arg2) {
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
}
export function SetLiveStatusEnabled(arg1) {
return window['go']['main']['App']['SetLiveStatusEnabled'](arg1);
export function SetKenwoodKeySpeed(arg1) {
return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1);
}
export function SetOpsLogQSLReceived(arg1, arg2) {
return window['go']['main']['App']['SetOpsLogQSLReceived'](arg1, arg2);
}
export function SetPassphrase(arg1) {
return window['go']['main']['App']['SetPassphrase'](arg1);
}
export function SetScpEnabled(arg1) {
return window['go']['main']['App']['SetScpEnabled'](arg1);
}
export function SetTelemetryEnabled(arg1) {
return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
}
@@ -1546,6 +1902,90 @@ export function SetUltrabeamDirection(arg1) {
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
}
export function SetWinkeyerTrace(arg1) {
return window['go']['main']['App']['SetWinkeyerTrace'](arg1);
}
export function SetWorkedCallVariants(arg1) {
return window['go']['main']['App']['SetWorkedCallVariants'](arg1);
}
export function SetYaesuAFGain(arg1) {
return window['go']['main']['App']['SetYaesuAFGain'](arg1);
}
export function SetYaesuAGC(arg1) {
return window['go']['main']['App']['SetYaesuAGC'](arg1);
}
export function SetYaesuAtt(arg1) {
return window['go']['main']['App']['SetYaesuAtt'](arg1);
}
export function SetYaesuBand(arg1) {
return window['go']['main']['App']['SetYaesuBand'](arg1);
}
export function SetYaesuBreakIn(arg1) {
return window['go']['main']['App']['SetYaesuBreakIn'](arg1);
}
export function SetYaesuKeySpeed(arg1) {
return window['go']['main']['App']['SetYaesuKeySpeed'](arg1);
}
export function SetYaesuMicGain(arg1) {
return window['go']['main']['App']['SetYaesuMicGain'](arg1);
}
export function SetYaesuModeRaw(arg1) {
return window['go']['main']['App']['SetYaesuModeRaw'](arg1);
}
export function SetYaesuNB(arg1) {
return window['go']['main']['App']['SetYaesuNB'](arg1);
}
export function SetYaesuNR(arg1) {
return window['go']['main']['App']['SetYaesuNR'](arg1);
}
export function SetYaesuNRLevel(arg1) {
return window['go']['main']['App']['SetYaesuNRLevel'](arg1);
}
export function SetYaesuNarrow(arg1) {
return window['go']['main']['App']['SetYaesuNarrow'](arg1);
}
export function SetYaesuPower(arg1) {
return window['go']['main']['App']['SetYaesuPower'](arg1);
}
export function SetYaesuPreamp(arg1) {
return window['go']['main']['App']['SetYaesuPreamp'](arg1);
}
export function SetYaesuRFGain(arg1) {
return window['go']['main']['App']['SetYaesuRFGain'](arg1);
}
export function SetYaesuSplit(arg1) {
return window['go']['main']['App']['SetYaesuSplit'](arg1);
}
export function SetYaesuSplitOffset(arg1) {
return window['go']['main']['App']['SetYaesuSplitOffset'](arg1);
}
export function SetYaesuSquelch(arg1) {
return window['go']['main']['App']['SetYaesuSquelch'](arg1);
}
export function SetYaesuVOX(arg1) {
return window['go']['main']['App']['SetYaesuVOX'](arg1);
}
export function StartCWDecoder() {
return window['go']['main']['App']['StartCWDecoder']();
}
@@ -1566,6 +2006,14 @@ export function SyncPOTAHunterLog(arg1, arg2) {
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
}
export function TailLogFile(arg1) {
return window['go']['main']['App']['TailLogFile'](arg1);
}
export function TestCloudlogUpload() {
return window['go']['main']['App']['TestCloudlogUpload']();
}
export function TestClublogUpload() {
return window['go']['main']['App']['TestClublogUpload']();
}
@@ -1602,14 +2050,46 @@ export function TestQRZUpload() {
return window['go']['main']['App']['TestQRZUpload']();
}
export function TestRotator(arg1) {
return window['go']['main']['App']['TestRotator'](arg1);
export function TestRotatorDevice(arg1, arg2) {
return window['go']['main']['App']['TestRotatorDevice'](arg1, arg2);
}
export function TestStationDevice(arg1) {
return window['go']['main']['App']['TestStationDevice'](arg1);
}
export function TestUltrabeam(arg1) {
return window['go']['main']['App']['TestUltrabeam'](arg1);
}
export function TestWebPublishFTP(arg1) {
return window['go']['main']['App']['TestWebPublishFTP'](arg1);
}
export function TuneYaesuATU() {
return window['go']['main']['App']['TuneYaesuATU']();
}
export function TunerGeniusActivate(arg1) {
return window['go']['main']['App']['TunerGeniusActivate'](arg1);
}
export function TunerGeniusAutotune() {
return window['go']['main']['App']['TunerGeniusAutotune']();
}
export function TunerGeniusSetBypass(arg1) {
return window['go']['main']['App']['TunerGeniusSetBypass'](arg1);
}
export function TunerGeniusSetOperate(arg1) {
return window['go']['main']['App']['TunerGeniusSetOperate'](arg1);
}
export function UILog(arg1) {
return window['go']['main']['App']['UILog'](arg1);
}
export function ULSStatus() {
return window['go']['main']['App']['ULSStatus']();
}
@@ -1650,6 +2130,10 @@ export function UploadQSOsManual(arg1, arg2) {
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
}
export function WebPublishColumns() {
return window['go']['main']['App']['WebPublishColumns']();
}
export function WinkeyerBackspace() {
return window['go']['main']['App']['WinkeyerBackspace']();
}
@@ -1674,6 +2158,22 @@ export function WinkeyerStop() {
return window['go']['main']['App']['WinkeyerStop']();
}
export function WinkeyerTraceEnabled() {
return window['go']['main']['App']['WinkeyerTraceEnabled']();
}
export function WorkedBefore(arg1, arg2) {
return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
}
export function YaesuSendCW(arg1) {
return window['go']['main']['App']['YaesuSendCW'](arg1);
}
export function YaesuStopCW() {
return window['go']['main']['App']['YaesuStopCW']();
}
export function YaesuZeroIn() {
return window['go']['main']['App']['YaesuZeroIn']();
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -7,6 +7,7 @@ require (
github.com/go-ole/go-ole v1.3.0
github.com/go-sql-driver/mysql v1.10.0
github.com/gorilla/websocket v1.5.3
github.com/jlaffaye/ftp v0.2.2
github.com/moutend/go-wca v0.3.0
github.com/wailsapp/wails/v2 v2.11.0
github.com/wneessen/go-mail v0.7.3
+4 -2
View File
@@ -25,6 +25,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/jlaffaye/ftp v0.2.2 h1:JwjrXCAIjN9ZYrF1/8qlmHFXDteh9MHYaiEIh/Oqtd8=
github.com/jlaffaye/ftp v0.2.2/go.mod h1:zuLAKdqFqFvNgkCrH0SC7K1XyUiydS7BFCmmoHUWWg0=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
@@ -64,8 +66,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"os"
"regexp"
"strconv"
"testing"
"hamlog/internal/cat/civ"
)
// The Icom model picker in the settings and civ.ModelName are two hand-kept
// copies of the same table, and they had drifted: the UI offered the IC-7700 at
// 0x88 and the IC-7800 at 0x80, which are the IC-7100's and the IC-7410's
// factory addresses. Picking either set an address the rig never answers on —
// the symptom is a rig that simply never replies — and the backend then named it
// as the other model.
//
// The list is read out of the .tsx itself, so a model added on one side alone
// fails here rather than on someone's radio.
func TestIcomModelAddressesMatch(t *testing.T) {
src, err := os.ReadFile("frontend/src/components/SettingsModal.tsx")
if err != nil {
t.Skipf("frontend source not available: %v", err)
}
re := regexp.MustCompile(`\{ name: '(IC-[A-Za-z0-9-]+)', addr: 0x([0-9A-Fa-f]{2}) \}`)
ms := re.FindAllStringSubmatch(string(src), -1)
if len(ms) < 5 {
t.Fatalf("only %d models found — the parse is wrong, not the data", len(ms))
}
for _, m := range ms {
name := m[1]
addr, err := strconv.ParseUint(m[2], 16, 8)
if err != nil {
t.Fatalf("bad address %q for %s", m[2], name)
}
if got := civ.ModelName(byte(addr)); got != name {
t.Errorf("settings offer %s at 0x%02X, but civ.ModelName(0x%02X) = %q",
name, addr, addr, got)
}
}
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"testing"
"hamlog/internal/qso"
)
// The confirmation statuses an import fills in, and the ones it must not touch.
//
// An operator asked for this feature believing it missing — it has been there
// since v0.14, but the option's description never mentioned it, so nobody could
// know. The behaviour is pinned here so a future edit cannot quietly drop what
// the description now promises.
//
// The rule is fill-if-empty: a WSJT-X log carries almost no QSL fields, while a
// log exported from another program carries real ones that must survive.
func TestQSLDefaultsFillOnlyEmptyFields(t *testing.T) {
defaults := QSLDefaults{
QSLSent: "N", QSLRcvd: "N",
LOTWSent: "N", LOTWRcvd: "N",
EQSLSent: "N", EQSLRcvd: "N",
ClublogStatus: "N", HRDLogStatus: "N",
QRZComStatus: "N", QRZComCfm: "N",
}
// A record as WSJT-X writes it: no confirmation fields at all.
bare := &qso.QSO{Callsign: "F4XYZ"}
applyQSLDefaultsTo(bare, defaults)
for name, got := range map[string]string{
"qsl_sent": bare.QSLSent, "qsl_rcvd": bare.QSLRcvd,
"lotw_sent": bare.LOTWSent, "lotw_rcvd": bare.LOTWRcvd,
"eqsl_sent": bare.EQSLSent, "eqsl_rcvd": bare.EQSLRcvd,
"clublog": bare.ClublogUploadStatus, "hrdlog": bare.HRDLogUploadStatus,
"qrz_sent": bare.QRZComUploadStatus, "qrz_rcvd": bare.QRZComDownloadStatus,
} {
if got != "N" {
t.Errorf("%s = %q on an empty record, want the default \"N\"", name, got)
}
}
// A record that already carries confirmations — from a real logbook export.
// Overwriting these would erase QSLs the operator has actually received.
kept := &qso.QSO{
Callsign: "F4XYZ",
QSLRcvd: "Y", LOTWRcvd: "Y", EQSLSent: "Y",
ClublogUploadStatus: "Y", QRZComDownloadStatus: "Y",
}
applyQSLDefaultsTo(kept, defaults)
for name, got := range map[string]string{
"qsl_rcvd": kept.QSLRcvd, "lotw_rcvd": kept.LOTWRcvd, "eqsl_sent": kept.EQSLSent,
"clublog": kept.ClublogUploadStatus, "qrz_rcvd": kept.QRZComDownloadStatus,
} {
if got != "Y" {
t.Errorf("%s = %q — an existing confirmation was overwritten", name, got)
}
}
// …while its empty ones still get the default.
if kept.QSLSent != "N" || kept.LOTWSent != "N" {
t.Errorf("empty fields on a partly-filled record were not defaulted: qsl_sent=%q lotw_sent=%q",
kept.QSLSent, kept.LOTWSent)
}
}
+86
View File
@@ -0,0 +1,86 @@
# ACOM built-in ATU — what we know, and why OpsLog does not drive it
Session of 2026-08-02 on F4BPO's friend's **ACOM 500S**, serial link, telemetry
trace. Written down so nobody repeats the work. **Conclusion first: the tuner
cannot be started over the serial link, and the attempt to work around that was
abandoned.**
## The tuner is PA state 8
Frame byte 3's high nibble is the PA status. ACOM's own list (recovered by the
ACOM-Controller project) has no entry for 8; it is the antenna tuner. Confirmed
twice, front-panel TUNE, with the byte-level trace running:
```
14:47:20.745 state 8 step 0 ← TUNE pressed. err=FF
14:47:22.588 operator keys a carrier (FT-891, FM, 31 W, 3.6358 MHz)
14:47:24.006 state 8 step 2
14:47:25.201 state 8 step 4
14:47:27.457 state 8 step 5
14:47:30.092 state 5 STANDBY ← accord finished
```
Byte 3's **low** nibble is a step counter through the accord: `0 → 2 → 4 → 5`.
Step 0 means *armed, waiting for RF* — in the first of the two runs the
amplifier sat there **30 seconds** untouched before the operator transmitted.
`paStatusNames[8] = "TUNE"` in acom.go is the only thing kept from all of this.
## The command does not exist in the state-command family
Every action byte of `55 81 08 02 00 XX 00 CHK` was tried, each one from a
**verified** STANDBY (an earlier sweep was worthless because its second code
parked the amp in SERVICE, where it silently refuses everything — 17 false
negatives). Result:
| byte | effect |
|------|--------|
| 0x01, 0x02 | **reboot the amplifier** — it prints `AMPLIFIER: ACOM 500S / HELLO ME…` |
| 0x04 | → SERVICE (state 4) |
| 0x05 | → STANDBY (state 5) |
| 0x06 | → OPERATE (state 6) |
| 0x0A | → OFF (state 10) |
| 0x03, 0x07..0x09, 0x0B..0x18 | nothing, state unchanged |
The action byte **is** the target PA state — four independent confirmations.
Which makes 0x08 the obvious candidate for TUNE. **It was tried, from a verified
STANDBY, and does nothing.** State 8 is reachable from the front panel only.
Going further would mean varying the other bytes of the frame. That is a large
space, and 0x01/0x02 prove that bad values reboot a 500 W amplifier. Not worth
it.
## The workaround that was built and removed
Since the amp waits, armed, for a carrier, OpsLog could watch for state 8 and
supply one: memorise the mode, drop the power, switch to FM, key the PTT, wait
for state 8 to clear, unkey, restore. It was implemented (`acomtune.go`, opt-in,
Settings → Amplifier) and **removed** after the first hardware test:
- the power change did not take effect, and
- **the PTT was not released when the tune finished.**
A logger that can leave a transceiver keyed is not acceptable, and the whole
thing was scaffolding around a protocol we do not actually control. Removed at
the operator's request: *"je veux qu'OpsLog soit solide et ça c'est de la
bidouille."*
If it is ever revisited, the unexplained part is why the tune-finished detection
did not fire — `anyAcomTuning()` polled `StateRaw == 8` every 200 ms and should
have seen the drop to STANDBY. Suspect the status snapshot, not the amplifier.
## Loose ends worth knowing
- **Frame byte 6 is a multiplexer.** The tail of the frame changes meaning with
it: `0x93` = bytes 48/49 carry the TX frequency in kHz (`62 1B` = 7010 while
the rig was on 7.010 MHz), `0x9D` = something else. Byte 70 looked like a
tuner flag for a while purely because of this — it toggles on its own several
times a minute. Do not read tail bytes without checking byte 6.
- **Byte 5 bit 0x80 alternates frame to frame.** It is a sequence/parity bit,
not a command acknowledgement.
- **Byte 66 (the error code) shows 0x14 / 0x8E / 0x69 / 0x0F during a normal
tune and during transmission.** `errText` renders those as "ERROR — see
display" and "Remove drive power", so an ACOM operator probably sees a false
alarm. This was never confirmed with the amplifier in OPERATE (it was in
STANDBY throughout, where drive genuinely is an error) — worth a look with a
clean log before touching the table.
+509
View File
@@ -0,0 +1,509 @@
// Package acom drives the ACOM solid-state amplifiers (500S / 600S / 700S /
// 1200S / 2020S) over their (unpublished) RS-232 protocol, reverse-engineered by
// the ACOM-Controller project (bjornekelund, C#). The amp is reached either
// directly over a serial COM port (9600 8N1) or over TCP via an RS232-to-Ethernet
// bridge — both are just an io.ReadWriteCloser to this code, same as the SPE
// backend.
//
// Wire format (host → amp): fixed raw byte strings, validated by the same rule as
// telemetry (sum of ALL frame bytes ≡ 0 mod 256):
//
// enable telemetry: 55 92 04 15
// disable telemetry: 55 91 04 16
// OPERATE: 55 81 08 02 00 06 00 1A
// STANDBY: 55 81 08 02 00 05 00 1B
// OFF (power down): 55 81 08 02 00 0A 00 16
//
// Power ON is NOT a data command: it is a hardware pulse on the serial DTR/RTS
// lines (the amp's remote power-on pins) — serial transport only, and only if the
// cable wires those pins (a telemetry-only cable uses just RX/TX/GND).
//
// IMPORTANT: DTR and RTS must be held LOW at all times otherwise — asserting them
// permanently blocks the amplifier's front-panel power button.
//
// Telemetry (amp → host): once enabled, the amp streams 72-byte frames starting
// 0x55 0x2F, valid when the sum of all 72 bytes ≡ 0 (mod 256). Field offsets are
// decoded in decodeFrame below.
package acom
import (
"fmt"
"io"
"net"
"strconv"
"strings"
"sync"
"time"
"go.bug.st/serial"
"hamlog/internal/applog"
)
var (
cmdEnableTelemetry = []byte{0x55, 0x92, 0x04, 0x15}
cmdDisableTelemetry = []byte{0x55, 0x91, 0x04, 0x16}
cmdOperate = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x06, 0x00, 0x1A}
cmdStandby = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x05, 0x00, 0x1B}
cmdOff = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x0A, 0x00, 0x16}
)
const (
frameLen = 72
sync0 = 0x55
sync1 = 0x2F
dialTimeout = 5 * time.Second
ioTimeout = 3 * time.Second
// The amp streams roughly 4-5 frames/s once telemetry is on; if nothing valid
// arrives for this long the link (or the amp) is considered down.
staleAfter = 5 * time.Second
)
// model carries the per-model constants: the temperature word offset and the
// nominal/max forward power (for UI bar scaling).
type model struct {
Name string
TempOffset int
NominalW int
MaxW int
}
var models = map[string]model{
"500S": {"500S", 282, 500, 600},
"600S": {"600S", 273, 600, 700},
"700S": {"700S", 282, 700, 800},
"1200S": {"1200S", 281, 1200, 1400},
"2020S": {"2020S", 282, 1800, 2000},
}
// paStatusNames maps the PAstatus nibble to a display string.
// 8 is absent from the state list the ACOM-Controller project recovered; it is
// the antenna tuner, read off F4BPO's 500S on 2026-08-02. Pressing TUNE on the
// front panel took byte 3 from 0x51 to 0x80, the amp then WAITED 19 s doing
// nothing until the operator keyed a carrier, ran the tune over ~8 s while the
// low nibble stepped 0→2→4→5, and dropped back to 0x51 (STANDBY).
var paStatusNames = map[int]string{
1: "RESET", 2: "INIT", 3: "DEBUG", 4: "SERVICE",
5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 8: "TUNE", 9: "SYSTEM", 10: "OFF",
}
// acomBands maps the band nibble to a band label.
var acomBands = []string{"?", "160m", "80m", "40/60m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m"}
// errText translates the error code (frame byte 66) shown on the amp's display.
// 0xFF means NO error — everything else, including 0x00, is a fault/warning.
func errText(code int) string {
switch code {
case 0xFF:
return ""
case 0x00, 0x08:
return "Hot switching"
case 0x03:
return "Drive power at wrong time"
case 0x04, 0x05:
return "Reflected power warning"
case 0x06, 0x07:
return "Drive power too high"
case 0x0C:
return "RF power at wrong time"
case 0x0E:
return "Stop transmission first"
case 0x0F:
return "Remove drive power"
case 0x24, 0x25, 0x39, 0x44, 0x45, 0x59:
return "Excessive PAM current"
case 0x70:
return "CAT error"
default:
return "ERROR — see display"
}
}
// Status is the decoded amplifier state for the UI.
type Status struct {
Connected bool `json:"connected"` // valid telemetry is flowing
PortOpen bool `json:"port_open"` // transport is open (serial port / TCP socket) — power-on possible even when the amp itself is off
Transport string `json:"transport"` // "serial" | "tcp" — the UI disables power-ON over tcp (no DTR line)
LastError string `json:"last_error,omitempty"`
Model string `json:"model,omitempty"`
State string `json:"state,omitempty"` // STANDBY / RECEIVE / TRANSMIT / OFF / …
Operate bool `json:"operate"` // RECEIVE or TRANSMIT (vs STANDBY)
TX bool `json:"tx"`
FwdW int `json:"fwd_w"` // forward/output power
ReflW int `json:"refl_w"` // reflected power
SWR float64 `json:"swr"`
DriveW int `json:"drive_w"`
DCPowerW int `json:"dc_w"`
TempC int `json:"temp_c"` // PA temperature
Band string `json:"band,omitempty"`
FanLevel int `json:"fan"` // 1..4
ErrCode int `json:"err_code"` // raw code from the frame; 0xFF = none
ErrText string `json:"err_text,omitempty"` // human message; empty = no error
NominalW int `json:"nominal_w"`
MaxW int `json:"max_w"`
}
// Config selects the transport and amplifier model.
type Config struct {
Model string // "500S" | "600S" | "700S" | "1200S" | "2020S"
Transport string // "serial" | "tcp"
ComPort string // serial
Baud int // serial (the amp is fixed 9600 8N1)
Host string // tcp (RS232-to-Ethernet bridge)
Port int // tcp
}
type Client struct {
cfg Config
mdl model
mu sync.Mutex // serialises access to the connection
conn io.ReadWriteCloser
statusMu sync.RWMutex
status Status
// Diagnostics: raw byte count + first-bytes capture, so a hardware session log
// tells apart "nothing on the wire" (cable/COM/amp) from "bytes but no valid
// frame" (framing/checksum) without a serial sniffer.
rawSeen int64
dbgBytes []byte
dbgLogged bool
ckFails int
frameLogged bool
stop chan struct{}
running bool
}
func New(cfg Config) *Client {
if cfg.Baud <= 0 {
cfg.Baud = 9600
}
mdl, ok := models[strings.ToUpper(strings.TrimSpace(cfg.Model))]
if !ok {
mdl = models["700S"]
}
c := &Client{cfg: cfg, mdl: mdl, stop: make(chan struct{})}
c.status.Model = mdl.Name
c.status.Transport = cfg.Transport
c.status.NominalW = mdl.NominalW
c.status.MaxW = mdl.MaxW
return c
}
func (c *Client) Start() error {
if c.running {
return nil
}
c.running = true
go c.readLoop()
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.Write(cmdDisableTelemetry) // best effort: stop the stream
}
c.dropLocked()
c.mu.Unlock()
}
func (c *Client) GetStatus() Status {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
return c.status
}
func (c *Client) setErr(msg string) {
c.statusMu.Lock()
c.status.Connected = false
c.status.LastError = msg
c.statusMu.Unlock()
}
// Operate puts the amp in OPERATE (true) or STANDBY (false). Unlike the SPE's
// single toggle key, the ACOM protocol has explicit commands for each state.
func (c *Client) Operate(on bool) error {
if on {
return c.send(cmdOperate)
}
return c.send(cmdStandby)
}
// PowerOff sends the power-down command (same as pressing OFF on the amp).
func (c *Client) PowerOff() error { return c.send(cmdOff) }
// PowerOn pulses the serial DTR/RTS lines — the amp's remote power-on pins, wired
// like a press of the front-panel power button. Hardware line ⇒ serial transport
// only (an RS232-to-Ethernet bridge doesn't forward DTR), and the cable must have
// those pins connected. Pulse length to be confirmed on real hardware.
func (c *Client) PowerOn() error {
c.mu.Lock()
defer c.mu.Unlock()
sp, ok := c.conn.(serial.Port)
if !ok {
return fmt.Errorf("power-on needs the serial DTR/RTS lines — not available over a network bridge")
}
if err := sp.SetDTR(true); err != nil {
return err
}
if err := sp.SetRTS(true); err != nil {
_ = sp.SetDTR(false)
return err
}
time.Sleep(2500 * time.Millisecond)
err1 := sp.SetDTR(false)
err2 := sp.SetRTS(false)
if err1 != nil {
return err1
}
return err2
}
func (c *Client) send(cmd []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return fmt.Errorf("not connected")
}
if nc, ok := c.conn.(net.Conn); ok {
_ = nc.SetWriteDeadline(time.Now().Add(ioTimeout))
}
_, err := c.conn.Write(cmd)
return err
}
// readLoop keeps the link up and consumes the telemetry stream. When no valid
// frame arrives for a while it re-arms telemetry (the amp forgets the enable
// across a power cycle) and, on TCP, reconnects. A serial port is kept open even
// with the amp off, so the DTR power-on pulse stays available.
func (c *Client) readLoop() {
var lastFrame time.Time
var lastEnable time.Time
for {
select {
case <-c.stop:
return
default:
}
if err := c.ensureConn(); err != nil {
c.setErr("connect: " + err.Error())
select {
case <-c.stop:
return
case <-time.After(2 * time.Second):
}
continue
}
frame, err := c.readFrame()
now := time.Now()
if err == nil {
lastFrame = now
if !c.frameLogged {
c.frameLogged = true
applog.Printf("acom: telemetry up — first valid frame received")
}
c.decodeFrame(frame)
continue
}
// No valid frame. Re-send the telemetry enable briskly — there is no way to
// know whether the amp has it on (the reference controller re-sends every
// 200ms until frames flow), and it revives the stream after a power cycle.
if now.Sub(lastEnable) >= 500*time.Millisecond {
lastEnable = now
_ = c.send(cmdEnableTelemetry)
}
if lastFrame.IsZero() || now.Sub(lastFrame) > staleAfter {
if c.cfg.Transport == "tcp" {
// The bridge socket may be dead — force a reconnect.
c.mu.Lock()
c.dropLocked()
c.mu.Unlock()
}
if prev := c.GetStatus(); prev.Connected || prev.LastError == "" {
applog.Printf("acom: no telemetry (raw bytes seen so far: %d, checksum fails: %d)", c.rawSeen, c.ckFails)
}
c.setErr("no telemetry — amplifier off or cable/bridge issue")
}
}
}
func (c *Client) ensureConn() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
return nil
}
if c.cfg.Transport == "tcp" {
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout)
if err != nil {
return err
}
c.conn = nc
} else {
sp, err := serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
if err != nil {
return err
}
// CRITICAL: hold DTR/RTS LOW. Windows may assert them on open, and while
// asserted the amp's front-panel power button is blocked (they are its
// remote power-on lines).
_ = sp.SetDTR(false)
_ = sp.SetRTS(false)
// Short read timeout so the frame reader can poll the stop channel and
// detect staleness rather than blocking forever.
_ = sp.SetReadTimeout(200 * time.Millisecond)
c.conn = sp
}
c.statusMu.Lock()
c.status.PortOpen = true
c.statusMu.Unlock()
applog.Printf("acom: %s link open (%s)", c.mdl.Name, c.cfg.Transport)
_, _ = c.conn.Write(cmdEnableTelemetry)
return nil
}
func (c *Client) dropLocked() {
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.statusMu.Lock()
c.status.PortOpen = false
c.statusMu.Unlock()
}
// readByte reads a single byte, honouring the transport's short timeout. A serial
// read that times out returns (0, nil) with go.bug.st/serial — mapped to an error
// here so callers can distinguish "no data yet".
var errNoData = fmt.Errorf("no data")
func (c *Client) readByte(deadline time.Time) (byte, error) {
c.mu.Lock()
conn := c.conn
c.mu.Unlock()
if conn == nil {
return 0, fmt.Errorf("not connected")
}
buf := make([]byte, 1)
for {
if nc, ok := conn.(net.Conn); ok {
_ = nc.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
}
n, err := conn.Read(buf)
if n == 1 {
c.rawSeen++
// Capture the first 144 raw bytes (~2 frames) once, so a session log shows
// what the wire actually carries when frames won't validate.
if !c.dbgLogged {
c.dbgBytes = append(c.dbgBytes, buf[0])
if len(c.dbgBytes) >= 144 {
c.dbgLogged = true
applog.Printf("acom: first raw bytes: % X", c.dbgBytes)
c.dbgBytes = nil
}
}
return buf[0], nil
}
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Timeout() {
err = nil // treat like the serial short-timeout: just no data yet
} else {
return 0, err
}
}
select {
case <-c.stop:
return 0, fmt.Errorf("stopped")
default:
}
if time.Now().After(deadline) {
return 0, errNoData
}
}
}
// readFrame syncs on 0x55 0x2F, reads the rest of the 72-byte frame and verifies
// the mod-256 checksum (sum of ALL 72 bytes ≡ 0).
func (c *Client) readFrame() ([]byte, error) {
deadline := time.Now().Add(ioTimeout)
// Sync: hunt for 0x55 followed by 0x2F.
for {
b, err := c.readByte(deadline)
if err != nil {
return nil, err
}
if b != sync0 {
continue
}
b2, err := c.readByte(deadline)
if err != nil {
return nil, err
}
if b2 == sync1 {
break
}
}
frame := make([]byte, frameLen)
frame[0], frame[1] = sync0, sync1
for i := 2; i < frameLen; i++ {
b, err := c.readByte(deadline)
if err != nil {
return nil, err
}
frame[i] = b
}
var sum int
for _, b := range frame {
sum += int(b)
}
if sum%256 != 0 {
c.ckFails++
if c.ckFails <= 3 {
applog.Printf("acom: bad checksum (fail #%d): % X", c.ckFails, frame)
}
return nil, fmt.Errorf("bad checksum")
}
return frame, nil
}
// decodeFrame extracts the telemetry fields (see the package comment for the
// reverse-engineered layout; 16-bit values are little-endian lo + hi*256).
func (c *Client) decodeFrame(f []byte) {
u16 := func(i int) int { return int(f[i]) + int(f[i+1])*256 }
paStatus := int(f[3]&0xF0) >> 4
state := paStatusNames[paStatus]
if state == "" {
state = fmt.Sprintf("?%d", paStatus)
}
bandIdx := int(f[69] & 0x0F)
band := ""
if bandIdx > 0 && bandIdx < len(acomBands) {
band = acomBands[bandIdx]
}
c.statusMu.Lock()
defer c.statusMu.Unlock()
c.status.Connected = true
c.status.LastError = ""
c.status.State = state
c.status.Operate = paStatus == 6 || paStatus == 7
c.status.TX = paStatus == 7
c.status.DCPowerW = u16(8) / 10
c.status.TempC = u16(16) - c.mdl.TempOffset
c.status.DriveW = u16(20)
c.status.FwdW = u16(22)
c.status.ReflW = u16(24)
c.status.SWR = float64(u16(26)) / 100
c.status.ErrCode = int(f[66])
c.status.ErrText = errText(int(f[66]))
c.status.Band = band
c.status.FanLevel = int(f[69]&0xF0) >> 4
}
+162 -131
View File
@@ -34,6 +34,12 @@ type Exporter struct {
// export destined for another logger; set true for a full OpsLog→OpsLog
// round-trip that preserves everything.
IncludeAppFields bool
// Fields, when non-nil, restricts the export to exactly these ADIF tags
// (uppercase) — the "choose which fields to export" mode. nil = write every
// field (the standard/full behaviour above). When set it overrides
// IncludeAppFields: an APP_/vendor tag is written iff it's in the set.
Fields map[string]bool
}
// iterator streams QSOs through fn. The three concrete sources (all, filtered,
@@ -100,7 +106,7 @@ func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (in
count := 0
err := iter(ctx, func(q qso.QSO) error {
writeRecord(bw, q, e.IncludeAppFields)
writeRecord(bw, q, e.IncludeAppFields, e.Fields)
count++
return nil
})
@@ -115,7 +121,7 @@ func SingleRecordADIF(q qso.QSO) string {
var b strings.Builder
bw := bufio.NewWriter(&b)
// Uploads target other services — keep it standard (no app-specific tags).
writeRecord(bw, q, false)
writeRecord(bw, q, false, nil)
bw.Flush()
return b.String()
}
@@ -127,7 +133,7 @@ func SingleRecordADIF(q qso.QSO) string {
func FullRecordADIF(q qso.QSO) string {
var b strings.Builder
bw := bufio.NewWriter(&b)
writeRecord(bw, q, true)
writeRecord(bw, q, true, nil)
bw.Flush()
return b.String()
}
@@ -155,161 +161,181 @@ func BatchRecordsADIF(records []string) string {
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
// mode (e.g. FT4 stored without a parent) is exported as the canonical
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool) {
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]bool) {
// allow == nil → write every promoted field (standard/full behaviour).
// Otherwise a promoted tag is written only when it's in the chosen set.
// w/wi/wf wrap the raw writers with that gate so the ~150 field lines below
// stay one-liners.
ok := func(tag string) bool { return allow == nil || allow[tag] }
w := func(tag, v string) {
if ok(tag) {
writeField(bw, tag, v)
}
}
wi := func(tag string, p *int) {
if ok(tag) {
writeIntPtr(bw, tag, p)
}
}
wf := func(tag string, p *float64, decimals int) {
if ok(tag) {
writeFloatPtr(bw, tag, p, decimals)
}
}
// --- Core ---
writeField(bw, "CALL", q.Callsign)
w("CALL", q.Callsign)
if !q.QSODate.IsZero() {
writeField(bw, "QSO_DATE", q.QSODate.UTC().Format("20060102"))
writeField(bw, "TIME_ON", q.QSODate.UTC().Format("150405"))
w("QSO_DATE", q.QSODate.UTC().Format("20060102"))
w("TIME_ON", q.QSODate.UTC().Format("150405"))
}
if !q.QSODateOff.IsZero() {
writeField(bw, "QSO_DATE_OFF", q.QSODateOff.UTC().Format("20060102"))
writeField(bw, "TIME_OFF", q.QSODateOff.UTC().Format("150405"))
w("QSO_DATE_OFF", q.QSODateOff.UTC().Format("20060102"))
w("TIME_OFF", q.QSODateOff.UTC().Format("150405"))
}
writeField(bw, "BAND", q.Band)
writeField(bw, "BAND_RX", q.BandRX)
w("BAND", q.Band)
w("BAND_RX", q.BandRX)
mode, submode := modeForExport(q.Mode, q.Submode)
writeField(bw, "MODE", mode)
writeField(bw, "SUBMODE", submode)
w("MODE", mode)
w("SUBMODE", submode)
if q.FreqHz != nil && *q.FreqHz > 0 {
writeField(bw, "FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
w("FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
}
if q.FreqRXHz != nil && *q.FreqRXHz > 0 {
writeField(bw, "FREQ_RX", strconv.FormatFloat(float64(*q.FreqRXHz)/1_000_000, 'f', 6, 64))
w("FREQ_RX", strconv.FormatFloat(float64(*q.FreqRXHz)/1_000_000, 'f', 6, 64))
}
writeField(bw, "RST_SENT", q.RSTSent)
writeField(bw, "RST_RCVD", q.RSTRcvd)
w("RST_SENT", q.RSTSent)
w("RST_RCVD", q.RSTRcvd)
// --- Contacted ---
writeField(bw, "NAME", q.Name)
writeField(bw, "QTH", q.QTH)
writeField(bw, "ADDRESS", q.Address)
writeField(bw, "EMAIL", q.Email)
writeField(bw, "WEB", q.Web)
writeField(bw, "GRIDSQUARE", q.Grid)
writeField(bw, "GRIDSQUARE_EXT", q.GridExt)
writeField(bw, "VUCC_GRIDS", q.VUCCGrids)
writeField(bw, "COUNTRY", q.Country)
writeField(bw, "STATE", q.State)
writeField(bw, "CNTY", q.County)
writeIntPtr(bw, "DXCC", q.DXCC)
writeField(bw, "CONT", q.Continent)
writeIntPtr(bw, "CQZ", q.CQZ)
writeIntPtr(bw, "ITUZ", q.ITUZ)
writeField(bw, "IOTA", q.IOTA)
writeField(bw, "SOTA_REF", q.SOTARef)
writeField(bw, "POTA_REF", q.POTARef)
writeIntPtr(bw, "AGE", q.Age)
writeFloatPtr(bw, "LAT", q.Lat, 6)
writeFloatPtr(bw, "LON", q.Lon, 6)
writeField(bw, "RIG", q.Rig)
writeField(bw, "ANT", q.Ant)
w("NAME", q.Name)
w("QTH", q.QTH)
w("ADDRESS", q.Address)
w("EMAIL", q.Email)
w("WEB", q.Web)
w("GRIDSQUARE", q.Grid)
w("GRIDSQUARE_EXT", q.GridExt)
w("VUCC_GRIDS", q.VUCCGrids)
w("COUNTRY", q.Country)
w("STATE", q.State)
w("CNTY", q.County)
wi("DXCC", q.DXCC)
w("CONT", q.Continent)
wi("CQZ", q.CQZ)
wi("ITUZ", q.ITUZ)
w("IOTA", q.IOTA)
w("SOTA_REF", q.SOTARef)
w("POTA_REF", q.POTARef)
wi("AGE", q.Age)
wf("LAT", q.Lat, 6)
wf("LON", q.Lon, 6)
w("RIG", q.Rig)
w("ANT", q.Ant)
// --- QSL / LoTW / eQSL / Clublog / HRDLog ---
writeField(bw, "QSL_SENT", q.QSLSent)
writeField(bw, "QSL_RCVD", q.QSLRcvd)
writeField(bw, "QSLSDATE", q.QSLSentDate)
writeField(bw, "QSLRDATE", q.QSLRcvdDate)
writeField(bw, "QSL_VIA", q.QSLVia)
writeField(bw, "QSLMSG", q.QSLMsg)
writeField(bw, "QSLMSG_RCVD", q.QSLMsgRcvd)
writeField(bw, "LOTW_QSL_SENT", q.LOTWSent)
writeField(bw, "LOTW_QSL_RCVD", q.LOTWRcvd)
writeField(bw, "LOTW_QSLSDATE", q.LOTWSentDate)
writeField(bw, "LOTW_QSLRDATE", q.LOTWRcvdDate)
writeField(bw, "EQSL_QSL_SENT", q.EQSLSent)
writeField(bw, "EQSL_QSL_RCVD", q.EQSLRcvd)
writeField(bw, "EQSL_QSLSDATE", q.EQSLSentDate)
writeField(bw, "EQSL_QSLRDATE", q.EQSLRcvdDate)
writeField(bw, "CLUBLOG_QSO_UPLOAD_DATE", q.ClublogUploadDate)
writeField(bw, "CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus)
writeField(bw, "HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate)
writeField(bw, "HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
writeField(bw, "QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
writeField(bw, "QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
writeField(bw, "QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
writeField(bw, "QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
w("QSL_SENT", q.QSLSent)
w("QSL_RCVD", q.QSLRcvd)
w("QSLSDATE", q.QSLSentDate)
w("QSLRDATE", q.QSLRcvdDate)
w("QSL_VIA", q.QSLVia)
w("QSLMSG", q.QSLMsg)
w("QSLMSG_RCVD", q.QSLMsgRcvd)
w("LOTW_QSL_SENT", q.LOTWSent)
w("LOTW_QSL_RCVD", q.LOTWRcvd)
w("LOTW_QSLSDATE", q.LOTWSentDate)
w("LOTW_QSLRDATE", q.LOTWRcvdDate)
w("EQSL_QSL_SENT", q.EQSLSent)
w("EQSL_QSL_RCVD", q.EQSLRcvd)
w("EQSL_QSLSDATE", q.EQSLSentDate)
w("EQSL_QSLRDATE", q.EQSLRcvdDate)
w("CLUBLOG_QSO_UPLOAD_DATE", q.ClublogUploadDate)
w("CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus)
w("HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate)
w("HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
w("QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
w("QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
// --- Contest ---
writeField(bw, "CONTEST_ID", q.ContestID)
writeIntPtr(bw, "SRX", q.SRX)
writeIntPtr(bw, "STX", q.STX)
writeField(bw, "SRX_STRING", q.SRXString)
writeField(bw, "STX_STRING", q.STXString)
writeField(bw, "CHECK", q.Check)
writeField(bw, "PRECEDENCE", q.Precedence)
writeField(bw, "ARRL_SECT", q.ARRLSect)
w("CONTEST_ID", q.ContestID)
wi("SRX", q.SRX)
wi("STX", q.STX)
w("SRX_STRING", q.SRXString)
w("STX_STRING", q.STXString)
w("CHECK", q.Check)
w("PRECEDENCE", q.Precedence)
w("ARRL_SECT", q.ARRLSect)
// --- Satellite / propagation ---
writeField(bw, "PROP_MODE", q.PropMode)
writeField(bw, "SAT_NAME", q.SatName)
writeField(bw, "SAT_MODE", q.SatMode)
writeFloatPtr(bw, "ANT_AZ", q.AntAz, 1)
writeFloatPtr(bw, "ANT_EL", q.AntEl, 1)
writeField(bw, "ANT_PATH", q.AntPath)
w("PROP_MODE", q.PropMode)
w("SAT_NAME", q.SatName)
w("SAT_MODE", q.SatMode)
wf("ANT_AZ", q.AntAz, 1)
wf("ANT_EL", q.AntEl, 1)
w("ANT_PATH", q.AntPath)
// --- My station / operator ---
writeField(bw, "STATION_CALLSIGN", q.StationCallsign)
writeField(bw, "OPERATOR", q.Operator)
writeField(bw, "MY_GRIDSQUARE", q.MyGrid)
writeField(bw, "MY_GRIDSQUARE_EXT", q.MyGridExt)
writeField(bw, "MY_COUNTRY", q.MyCountry)
writeField(bw, "MY_STATE", q.MyState)
writeField(bw, "MY_CNTY", q.MyCounty)
writeField(bw, "MY_IOTA", q.MyIOTA)
writeField(bw, "MY_SOTA_REF", q.MySOTARef)
writeField(bw, "MY_POTA_REF", q.MyPOTARef)
writeIntPtr(bw, "MY_DXCC", q.MyDXCC)
writeIntPtr(bw, "MY_CQ_ZONE", q.MyCQZone)
writeIntPtr(bw, "MY_ITU_ZONE", q.MyITUZone)
writeFloatPtr(bw, "MY_LAT", q.MyLat, 6)
writeFloatPtr(bw, "MY_LON", q.MyLon, 6)
writeField(bw, "MY_STREET", q.MyStreet)
writeField(bw, "MY_CITY", q.MyCity)
writeField(bw, "MY_POSTAL_CODE", q.MyPostalCode)
writeField(bw, "MY_RIG", q.MyRig)
writeField(bw, "MY_ANTENNA", q.MyAntenna)
w("STATION_CALLSIGN", q.StationCallsign)
w("OPERATOR", q.Operator)
w("MY_GRIDSQUARE", q.MyGrid)
w("MY_GRIDSQUARE_EXT", q.MyGridExt)
w("MY_COUNTRY", q.MyCountry)
w("MY_STATE", q.MyState)
w("MY_CNTY", q.MyCounty)
w("MY_IOTA", q.MyIOTA)
w("MY_SOTA_REF", q.MySOTARef)
w("MY_POTA_REF", q.MyPOTARef)
wi("MY_DXCC", q.MyDXCC)
wi("MY_CQ_ZONE", q.MyCQZone)
wi("MY_ITU_ZONE", q.MyITUZone)
wf("MY_LAT", q.MyLat, 6)
wf("MY_LON", q.MyLon, 6)
w("MY_STREET", q.MyStreet)
w("MY_CITY", q.MyCity)
w("MY_POSTAL_CODE", q.MyPostalCode)
w("MY_RIG", q.MyRig)
w("MY_ANTENNA", q.MyAntenna)
// --- Misc ---
writeFloatPtr(bw, "TX_PWR", q.TXPower, 1)
writeField(bw, "COMMENT", q.Comment)
writeField(bw, "NOTES", q.Notes)
wf("TX_PWR", q.TXPower, 1)
w("COMMENT", q.Comment)
w("NOTES", q.Notes)
// --- ADIF 3.1.7 additional promoted fields ---
writeField(bw, "SIG", q.SIG)
writeField(bw, "SIG_INFO", q.SIGInfo)
writeField(bw, "MY_SIG", q.MySIG)
writeField(bw, "MY_SIG_INFO", q.MySIGInfo)
writeField(bw, "WWFF_REF", q.WWFFRef)
writeField(bw, "MY_WWFF_REF", q.MyWWFFRef)
writeFloatPtr(bw, "DISTANCE", q.Distance, 1)
writeFloatPtr(bw, "RX_PWR", q.RXPower, 1)
writeFloatPtr(bw, "A_INDEX", q.AIndex, 0)
writeFloatPtr(bw, "K_INDEX", q.KIndex, 0)
writeFloatPtr(bw, "SFI", q.SFI, 0)
writeField(bw, "SKCC", q.SKCC)
writeField(bw, "FISTS", q.FISTS)
writeField(bw, "TEN_TEN", q.TenTen)
writeField(bw, "CONTACTED_OP", q.ContactedOp)
writeField(bw, "EQ_CALL", q.EqCall)
writeField(bw, "PFX", q.PFX)
writeField(bw, "MY_NAME", q.MyName)
writeField(bw, "CLASS", q.Class)
writeField(bw, "DARC_DOK", q.DarcDOK)
writeField(bw, "MY_DARC_DOK", q.MyDarcDOK)
writeField(bw, "REGION", q.Region)
writeField(bw, "SILENT_KEY", q.SilentKey)
writeField(bw, "SWL", q.SWL)
writeField(bw, "QSO_COMPLETE", q.QSOComplete)
writeField(bw, "QSO_RANDOM", q.QSORandom)
writeField(bw, "CREDIT_GRANTED", q.CreditGranted)
writeField(bw, "CREDIT_SUBMITTED", q.CreditSubmitted)
writeField(bw, "MY_ARRL_SECT", q.MyARRLSect)
writeField(bw, "MY_VUCC_GRIDS", q.MyVUCCGrids)
w("SIG", q.SIG)
w("SIG_INFO", q.SIGInfo)
w("MY_SIG", q.MySIG)
w("MY_SIG_INFO", q.MySIGInfo)
w("WWFF_REF", q.WWFFRef)
w("MY_WWFF_REF", q.MyWWFFRef)
wf("DISTANCE", q.Distance, 1)
wf("RX_PWR", q.RXPower, 1)
wf("A_INDEX", q.AIndex, 0)
wf("K_INDEX", q.KIndex, 0)
wf("SFI", q.SFI, 0)
w("SKCC", q.SKCC)
w("FISTS", q.FISTS)
w("TEN_TEN", q.TenTen)
w("CONTACTED_OP", q.ContactedOp)
w("EQ_CALL", q.EqCall)
w("PFX", q.PFX)
w("MY_NAME", q.MyName)
w("CLASS", q.Class)
w("DARC_DOK", q.DarcDOK)
w("MY_DARC_DOK", q.MyDarcDOK)
w("REGION", q.Region)
w("SILENT_KEY", q.SilentKey)
w("SWL", q.SWL)
w("QSO_COMPLETE", q.QSOComplete)
w("QSO_RANDOM", q.QSORandom)
w("CREDIT_GRANTED", q.CreditGranted)
w("CREDIT_SUBMITTED", q.CreditSubmitted)
w("MY_ARRL_SECT", q.MyARRLSect)
w("MY_VUCC_GRIDS", q.MyVUCCGrids)
// --- Extras (unpromoted ADIF fields preserved verbatim) ---
// Standard mode emits ONLY valid ADIF-spec fields, so it drops APP_*
@@ -318,7 +344,12 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool) {
// extra for a lossless OpsLog round-trip.
for k, v := range q.Extras {
tag := strings.ToUpper(k)
if !includeApp && (strings.HasPrefix(tag, "APP_") || !IsStandardField(tag)) {
if allow != nil {
// Chosen-fields mode: the set alone decides (incl. APP_/vendor tags).
if !allow[tag] {
continue
}
} else if !includeApp && (strings.HasPrefix(tag, "APP_") || !IsStandardField(tag)) {
continue
}
writeField(bw, tag, v)
+59
View File
@@ -46,6 +46,21 @@ type Importer struct {
// Used to recompute country / zones from cty.dat so a bad COUNTRY in the
// source file (common with contest loggers) is corrected on the way in.
Enrich func(*qso.QSO)
// FieldMap moves ADIF fields around before a record is converted, keyed
// lowercase source → lowercase destination.
//
// Contest software puts things where the operator, not the standard, expects
// them. The RSGB IOTA contest is the case that prompted this: N1MM and
// friends export the island reference in STATE, because that is the column
// their contest module uses for the received exchange. Imported verbatim,
// every QSO gets a US-state field holding "EU005" and the IOTA award sees
// nothing at all.
//
// Applied to the RAW record, so it works for promoted columns and Extras
// alike, and so the destination is parsed exactly as if the file had carried
// it there in the first place. A destination that already has a value is
// never overwritten — the file's own IOTA tag outranks a guess about STATE.
FieldMap map[string]string
// OnProgress, when set, is called periodically with (processed, total)
// record counts so the UI can show a progress bar. total is an estimate
// from counting <EOR> tags up front.
@@ -161,6 +176,7 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error {
res.Total++
reportProgress(false)
applyFieldMap(rec, im.FieldMap)
q, ok := recordToQSO(rec)
if !ok {
res.Skipped++
@@ -303,6 +319,49 @@ func stringSet(items ...string) map[string]struct{} {
// RecordToQSO is the exported alias used by the UDP auto-log path so it
// can convert a freshly received ADIF record into a QSO and then enrich
// it with lookup + operating data before inserting.
// applyFieldMap moves values between tags of a record, in place.
//
// The source is CLEARED after the move: a reference that belongs in IOTA should
// not also be left behind in STATE, where it would show up as the contacted
// station's US state in the grid and in every export.
func applyFieldMap(rec Record, m map[string]string) {
if len(m) == 0 || rec == nil {
return
}
// Read every source first. Applied one at a time, a swap (a→b, b→a) would
// read back what the previous move had just written.
orig := make(map[string]string, len(rec))
for k, v := range rec {
orig[k] = v
}
vals := make(map[string]string, len(m))
for from := range m {
if v := strings.TrimSpace(rec[from]); v != "" {
vals[from] = v
}
}
for from := range m {
delete(rec, from)
}
for from, to := range m {
v, ok := vals[from]
if !ok || from == to || to == "" {
continue
}
// Whether the destination was already filled is judged against the record
// as it ARRIVED, not as we are rewriting it — otherwise the outcome would
// depend on Go's map iteration order, which is deliberately random.
//
// A destination that is itself a mapping source is an explicit swap, and
// overwriting it is the whole point; anything else keeps what the file
// carried, because the file's own tag outranks a guess about where a
// reference might have been put.
if _, swap := m[to]; swap || strings.TrimSpace(orig[to]) == "" {
rec[to] = v
}
}
}
func RecordToQSO(rec Record) (qso.QSO, bool) { return recordToQSO(rec) }
// recordToQSO maps an ADIF record onto a QSO. Returns false if required
+74
View File
@@ -0,0 +1,74 @@
package adif_test
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"hamlog/internal/adif"
"hamlog/internal/db"
"hamlog/internal/qso"
)
// End to end: an operator's own record, through a real SQLite logbook and back.
//
// Written because CONTEST_ID was reported as "not imported". It is imported, and
// this pins the whole path — parse, remap, insert, read back — rather than the
// conversion step alone, which was already green while the report stood. A field
// can be lost at five places between the file and the grid; only the round trip
// says which.
func TestContestIDSurvivesImport(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "logbook.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer conn.Close()
const file = "<CALL:6>2E0CVN <QSO_DATE:8>20260725 <TIME_ON:6>120039 <STATE:5>EU005 <BAND:3>20M <FREQ:6>14.014 <MODE:2>CW <CONTEST_ID:9>RSGB-IOTA <RST_RCVD:3>599 <RST_SENT:3>599 <SRX_STRING:8>001EU005 <EOR>\n"
path := filepath.Join(dir, "in.adi")
if err := os.WriteFile(path, []byte(file), 0o644); err != nil {
t.Fatal(err)
}
repo := qso.NewRepo(conn)
im := &adif.Importer{Repo: repo, FieldMap: map[string]string{"state": "iota"}}
res, err := im.ImportFile(context.Background(), path)
if err != nil {
t.Fatalf("import: %v", err)
}
t.Logf("result: %+v", res)
rows, err := conn.Query("SELECT callsign, contest_id, iota, state FROM qso")
if err != nil {
t.Fatal(err)
}
defer rows.Close()
n := 0
for rows.Next() {
var call, contest, iota, state any
if err := rows.Scan(&call, &contest, &iota, &state); err != nil {
t.Fatal(err)
}
n++
t.Logf("stored: call=%v contest_id=%v iota=%v state=%v", call, contest, iota, state)
if s := strings.TrimSpace(toS(contest)); s != "RSGB-IOTA" {
t.Errorf("contest_id in the DATABASE = %q, want RSGB-IOTA", s)
}
}
if n == 0 {
t.Fatal("nothing was stored")
}
}
func toS(v any) string {
switch x := v.(type) {
case string:
return x
case []byte:
return string(x)
}
return ""
}
+55
View File
@@ -0,0 +1,55 @@
package adif
import "testing"
// Field mapping, from the RSGB IOTA contest case.
//
// N1MM and similar export the island reference in STATE, because that is the
// column their contest module uses for the received exchange. Imported
// verbatim, every QSO gets a US-state field reading "EU005" and the IOTA award
// sees nothing — the reference is in the log but not where anything looks.
func TestApplyFieldMap(t *testing.T) {
// The real record, from an operator's TM2Q log.
rec := Record{
"call": "2E0CVN", "band": "20m", "mode": "CW",
"state": "EU005", "srx_string": "001EU005",
}
applyFieldMap(rec, map[string]string{"state": "iota"})
if rec["iota"] != "EU005" {
t.Errorf("iota = %q, want EU005", rec["iota"])
}
// Cleared, not copied: a reference left behind in STATE would show up as the
// contacted station's US state in the grid and in every export.
if _, ok := rec["state"]; ok {
t.Errorf("state survived the move as %q", rec["state"])
}
// A destination the file already filled is never overwritten — the file's own
// tag outranks a guess about where the reference might be.
rec2 := Record{"call": "UT0RM", "state": "EU005", "iota": "EU030"}
applyFieldMap(rec2, map[string]string{"state": "iota"})
if rec2["iota"] != "EU030" {
t.Errorf("iota = %q — the file's own value must win", rec2["iota"])
}
// An empty source moves nothing (the second QSO of that log has no STATE).
rec3 := Record{"call": "UT0RM", "state": " "}
applyFieldMap(rec3, map[string]string{"state": "iota"})
if v, ok := rec3["iota"]; ok {
t.Errorf("an empty source created iota=%q", v)
}
// A swap reads both sources before writing either.
rec4 := Record{"state": "EU005", "iota": "NA001"}
applyFieldMap(rec4, map[string]string{"state": "iota", "iota": "state"})
if rec4["iota"] != "EU005" || rec4["state"] != "NA001" {
t.Errorf("swap gave iota=%q state=%q, want EU005 / NA001", rec4["iota"], rec4["state"])
}
// No mapping, no change.
rec5 := Record{"call": "F4BPO", "state": "EU005"}
applyFieldMap(rec5, nil)
if rec5["state"] != "EU005" {
t.Error("a nil mapping must leave the record alone")
}
}
+23 -7
View File
@@ -22,9 +22,11 @@ import (
// Record is a single ADIF record. Keys are lowercased field names.
type Record map[string]string
// Parse reads an ADI stream and invokes fn for each record (after <EOH>).
// Parse reads an ADI stream and invokes fn for each record.
// Returning a non-nil error from fn stops parsing and is propagated.
// The header (text before <EOH>) is silently discarded.
//
// The header (text before <EOH>) is silently discarded. A file with NO header is
// read as records from the first tag — hand-assembled files often have none.
func Parse(r io.Reader, fn func(Record) error) error {
return parseWith(r, nil, fn)
}
@@ -44,7 +46,6 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
br := bufio.NewReaderSize(r, 64*1024)
rec := Record{}
headerDone := false
for {
// Seek next '<'. Bytes before it are either header text or
@@ -65,11 +66,19 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
name, length := parseSpec(spec)
switch name {
case "eoh":
headerDone = true
rec = Record{}
rec = Record{} // everything collected so far was the header
continue
case "eor":
if headerDone && len(rec) > 0 {
// An <eor> proves we are in the record section, whether or not an <eoh>
// came first. Files pasted together by hand — a few lines lifted out of
// wsjtx_log.adi into a new file — have no header at all, and requiring
// one made every record invisible: the import reported 0 imported,
// 0 ignored, 0 total, which reads as "the file is empty" rather than
// "there was no header".
//
// A real header cannot contain <eor>, so accepting it costs nothing for
// well-formed files: theirs ends at <eoh> long before any record.
if len(rec) > 0 {
if err := fn(rec); err != nil {
return err
}
@@ -95,7 +104,14 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
if decodeValue == nil && !utf8.Valid(val) {
val = extendToRunes(br, val, length)
}
if headerDone && name != "" {
// Fields are collected ALWAYS, and thrown away at <eoh>.
//
// Collecting them only after the header meant a headerless file lost its
// FIRST record whole: its fields were discarded as header text, and the
// <eor> that followed found nothing to emit. Discarding at <eoh> instead
// keeps real headers out of the data just as well — that tag is exactly
// what says "everything before this was the header".
if name != "" {
if decodeValue != nil {
rec[name] = decodeValue(val)
} else {
+69 -6
View File
@@ -51,19 +51,28 @@ func TestParseValueWithAngleBracket(t *testing.T) {
}
func TestParseNoHeader(t *testing.T) {
// Some loggers omit the header entirely — records before <EOH> are
// discarded by design. Verify nothing is emitted in that case.
// A file without a header is IMPORTED, not discarded.
//
// This test asserted the opposite until 2026-07-29, when an operator pasted a
// dozen lines out of wsjtx_log.adi into a new .adi and got "0 imported,
// 0 ignored, 0 total". Discarding records before <EOH> was a defensible
// reading of the spec, but the file it rejects is one people really make, and
// the rejection is silent: the count says the file was empty, not that a tag
// was missing. An <eor> is proof enough that a record ended.
src := `<CALL:5>F4XYZ<EOR>`
var got int
var got []Record
err := Parse(strings.NewReader(src), func(r Record) error {
got++
got = append(got, r)
return nil
})
if err != nil {
t.Fatalf("parse: %v", err)
}
if got != 0 {
t.Errorf("expected 0 records without <EOH>, got %d", got)
if len(got) != 1 {
t.Fatalf("expected the record to be imported without <EOH>, got %d", len(got))
}
if got[0]["call"] != "F4XYZ" {
t.Errorf("call = %q, want F4XYZ", got[0]["call"])
}
}
@@ -82,3 +91,57 @@ func TestParseTypedField(t *testing.T) {
t.Errorf("freq mismatch: %q", got["freq"])
}
}
// A file with NO header must still import.
//
// Reported from the field: an operator lifted a dozen lines out of wsjtx_log.adi
// into a new .adi and got "0 imported, 0 ignored, 0 total" — which reads as an
// empty file rather than as a missing header. Records were only emitted after
// <EOH>, so a hand-assembled file was invisible in its entirety.
func TestParseHeaderlessFile(t *testing.T) {
// The operator's exact shape: records straight from a WSJT-X log, no header.
in := "<call:5>PY2VE <gridsquare:4>GG67 <mode:3>FT8 <rst_sent:3>-18 <rst_rcvd:3>-15 " +
"<qso_date:8>20260728 <time_on:6>194200 <band:3>15m <eor>\n" +
"<call:5>W8OBX <gridsquare:4>FM25 <mode:3>FT8 <rst_sent:3>-19 <rst_rcvd:3>-12 " +
"<qso_date:8>20260728 <time_on:6>194500 <band:3>15m <eor>\n"
var got []Record
if err := Parse(strings.NewReader(in), func(r Record) error {
got = append(got, r)
return nil
}); err != nil {
t.Fatalf("Parse: %v", err)
}
if len(got) != 2 {
t.Fatalf("parsed %d records from a headerless file, want 2", len(got))
}
if got[0]["call"] != "PY2VE" || got[1]["call"] != "W8OBX" {
t.Errorf("callsigns = %q, %q — want PY2VE, W8OBX", got[0]["call"], got[1]["call"])
}
if got[0]["gridsquare"] != "GG67" {
t.Errorf("gridsquare = %q, want GG67", got[0]["gridsquare"])
}
}
// And a normal file must be unaffected: its header is still discarded, so header
// fields never leak into the first record.
func TestParseHeaderStillDiscarded(t *testing.T) {
in := "Generated by SomeLogger\n<adif_ver:5>3.1.4 <programid:6>OpsLog <eoh>\n" +
"<call:4>F4AA <mode:2>CW <eor>\n"
var got []Record
if err := Parse(strings.NewReader(in), func(r Record) error {
got = append(got, r)
return nil
}); err != nil {
t.Fatalf("Parse: %v", err)
}
if len(got) != 1 {
t.Fatalf("parsed %d records, want 1", len(got))
}
if _, leaked := got[0]["programid"]; leaked {
t.Error("a header field leaked into the record")
}
if got[0]["call"] != "F4AA" {
t.Errorf("call = %q, want F4AA", got[0]["call"])
}
}
+2 -2
View File
@@ -34,7 +34,7 @@ func TestPromotedFieldsRoundTrip(t *testing.T) {
var buf bytes.Buffer
bw := bufio.NewWriter(&buf)
bw.WriteString("<EOH>\n")
writeRecord(bw, in, true)
writeRecord(bw, in, true, nil)
bw.Flush()
var rec Record
@@ -115,7 +115,7 @@ func TestStandardExportDropsNonStandard(t *testing.T) {
func renderRecord(q qso.QSO, includeApp bool) string {
var buf bytes.Buffer
bw := bufio.NewWriter(&buf)
writeRecord(bw, q, includeApp)
writeRecord(bw, q, includeApp, nil)
bw.Flush()
return buf.String()
}
+34 -14
View File
@@ -66,11 +66,12 @@ type Client struct {
authTries int
ready atomic.Bool // init commands sent → keepalive may run
statusMu sync.RWMutex
status Status
antennas map[int]string // index → name (rebuilt into status.Antennas)
antBands map[int]int // index → band bitmask (which bands the antenna covers)
antRawN int // one-shot: how many raw antenna lines we've logged
statusMu sync.RWMutex
status Status
antennas map[int]string // index → name (rebuilt into status.Antennas)
antBands map[int]int // index → band bitmask (which bands the antenna covers)
antRawN int // one-shot: how many raw antenna lines we've logged
lastShown map[int]int // port id → antenna last reported, so only CHANGES are logged
stop chan struct{}
running bool
@@ -81,13 +82,14 @@ func New(host string, port int, password string) *Client {
port = defaultPort
}
return &Client{
host: host,
port: port,
password: strings.TrimSpace(password),
stop: make(chan struct{}),
antennas: map[int]string{},
antBands: map[int]int{},
status: Status{Host: host},
host: host,
port: port,
password: strings.TrimSpace(password),
stop: make(chan struct{}),
antennas: map[int]string{},
antBands: map[int]int{},
lastShown: map[int]int{},
status: Status{Host: host},
}
}
@@ -398,9 +400,27 @@ func (c *Client) parsePort(msg string) {
}
tx := kvInt(msg, "txant")
rx := kvInt(msg, "rxant")
active := tx
// The RX antenna is what identifies a port's selection, and it is the only
// thing Activate sets. Preferring the TX antenna made both ports converge on
// the same row a few seconds after a change: on an 8x2 only ONE port can hold
// the transmit antenna, so the device reports the same txant on both, and a
// port A genuinely on the 80 m vertical was redrawn as the beam that port B
// transmits through.
//
// TX remains the fallback for a port that reports no RX antenna at all.
active := rx
if active == 0 {
active = rx
active = tx
}
// Logged only when the shown antenna CHANGES: the device pushes port state
// every few seconds, and logging each one would bury everything else — but
// without any trace, "port A jumped to the wrong antenna" is unfalsifiable.
c.statusMu.Lock()
prev, seen := c.lastShown[id]
c.lastShown[id] = active
c.statusMu.Unlock()
if !seen || prev != active {
applog.Printf("antgenius: port %d → antenna %d (rxant=%d txant=%d) raw=%q", id, active, rx, tx, msg)
}
txOn := kvInt(msg, "tx") != 0 // the standalone "tx=0|1" transmit flag
c.setStatus(func(s *Status) {
+73
View File
@@ -0,0 +1,73 @@
package antgenius
import "testing"
// Which antenna a port SHOWS.
//
// Reported on an 8x2: port A was correctly on the 80 m vertical, then a few
// seconds later both A and B were drawn on the same beam. The cause is that only
// ONE port can hold the transmit antenna, so the device reports the same txant
// on both — and the display preferred txant. The RX antenna is the per-port
// selection, and the only thing Activate sets, so it has to win.
func TestPortShowsRXAntenna(t *testing.T) {
cases := []struct {
name string
msg string
wantPort int
wantAnt int
}{
{
// The reported case: port A listening on antenna 2 while the shared TX
// antenna is 1. Before the fix this drew port A on antenna 1.
name: "rx and tx disagree — rx wins",
msg: "port 1 rxant=2 txant=1 tx=0",
wantPort: 1, wantAnt: 2,
},
{
name: "they agree",
msg: "port 2 rxant=3 txant=3 tx=0",
wantPort: 2, wantAnt: 3,
},
{
// A port with no RX antenna still shows something meaningful.
name: "no rx antenna — fall back to tx",
msg: "port 1 rxant=0 txant=4 tx=1",
wantPort: 1, wantAnt: 4,
},
{
name: "nothing selected",
msg: "port 2 rxant=0 txant=0 tx=0",
wantPort: 2, wantAnt: 0,
},
}
for _, c := range cases {
cl := New("host", 0, "")
cl.parsePort(c.msg)
st := cl.GetStatus()
got := st.PortA
if c.wantPort == 2 {
got = st.PortB
}
if got != c.wantAnt {
t.Errorf("%s: port %d shows antenna %d, want %d (%q)", c.name, c.wantPort, got, c.wantAnt, c.msg)
}
}
}
// The two ports must stay independent: a message about one must never move the
// other, which is the shape the operator actually saw on screen.
func TestPortsAreIndependent(t *testing.T) {
c := New("host", 0, "")
c.parsePort("port 1 rxant=2 txant=0 tx=0")
c.parsePort("port 2 rxant=1 txant=1 tx=1")
st := c.GetStatus()
if st.PortA != 2 {
t.Errorf("port A = %d, want 2 — port B's message moved it", st.PortA)
}
if st.PortB != 1 {
t.Errorf("port B = %d, want 1", st.PortB)
}
if !st.TxB || st.TxA {
t.Errorf("transmit flags crossed: TxA=%v TxB=%v", st.TxA, st.TxB)
}
}
+9 -4
View File
@@ -47,10 +47,15 @@ func Init(dataDir string) (string, error) {
_ = os.Rename(oldLog, logPath)
}
}
// Truncate if the file grew past ~5MB so we don't accumulate logs
// forever. We keep one file — simple and adequate for diagnostics.
if fi, err := os.Stat(logPath); err == nil && fi.Size() > 5*1024*1024 {
_ = os.Remove(logPath)
// Rotate (don't delete) once the file grows past ~10MB: rename it to
// opslog.log.1 so the PREVIOUS session's log survives. Deleting it outright used
// to erase exactly the diagnostics we needed when a user reported an issue from
// the session that just ended. One generation kept — enough, without unbounded growth.
if fi, err := os.Stat(logPath); err == nil && fi.Size() > 10*1024*1024 {
_ = os.Remove(logPath + ".1") // drop the older generation
if err := os.Rename(logPath, logPath+".1"); err != nil {
_ = os.Remove(logPath) // rename failed (locked?) → fall back to the old behaviour
}
}
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
+27
View File
@@ -101,3 +101,30 @@ func endpointName(dev *wca.IMMDevice, fallback string) string {
}
return fallback
}
// DeviceName resolves an endpoint id to its friendly name.
//
// Diagnostics quote the id that was CONFIGURED, which is a GUID — an operator
// told "no audio at all from {0.0.1.00000000}.{6a27abfd…}" learns nothing they
// can act on, while "no audio at all from DAX RX 1 (FlexRadio DAX)" points
// straight at the DAX panel.
//
// Falls back to the id when the endpoint cannot be found, which is itself worth
// seeing: a device that has disappeared explains an empty recording too.
func DeviceName(id string) string {
if id == "" {
return "(none)"
}
for _, list := range []func() ([]Device, error){ListInputDevices, ListOutputDevices} {
devs, err := list()
if err != nil {
continue
}
for _, d := range devs {
if d.ID == id {
return d.Name
}
}
}
return id
}
+11 -1
View File
@@ -80,7 +80,17 @@ func pcmFormat() *wca.WAVEFORMATEX {
}
}
const autoConvert = wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | wca.AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY
// autoConvert lets WASAPI resample between the device format and ours.
//
// SRC_DEFAULT_QUALITY used to be set alongside it, and that name is misleading:
// it selects the CHEAP converter, meant for cases where quality does not matter.
// Going from a rig 48 kHz stream down to our 16 kHz, it folds everything above
// 8 kHz back into the audio band — and a receiver hiss is mostly high
// frequencies. The result was a recording where the noise sat ON TOP of the
// voice, while the same audio heard live had the voice well clear of it.
//
// Without the flag Windows uses its normal converter, which filters first.
const autoConvert = wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM
// recordPCM captures from a device into 16 kHz mono 16-bit PCM bytes until the
// stop channel is closed.
+119 -11
View File
@@ -5,16 +5,28 @@ package audio
import (
"fmt"
"sync"
"time"
)
// Manager owns the DVK record/playback lifecycle: at most one recording and
// one playback at a time. Device ids are passed per call so the host can route
// recording to the mic and playback to the rig (or the preview speakers).
type Manager struct {
mu sync.Mutex
recStop chan struct{}
recDone chan recResult
playStop chan struct{}
mu sync.Mutex
recStop chan struct{}
recDone chan recResult
// monGainPct scales what the RX monitor plays, 100 = as captured.
//
// The "From radio" slider used to reach only the QSO recorder, so an
// operator listening through OpsLog heard no difference between 10% and
// 150% — the setting looked broken because it was, for the thing they were
// listening to.
monGainPct int
playStop chan struct{}
// playDone closes when the playback goroutine has returned and the audio
// device is free again. Without it, the next Play raced the old one for the
// device and lost.
playDone chan struct{}
monStop chan struct{} // RX monitor passthrough (capture → render)
monRing *pcmRing // live audio hand-off, also fed by the network stream
txStop chan struct{} // TX audio passthrough (mic → rig)
@@ -104,23 +116,59 @@ func (m *Manager) IsPlaying() bool {
// Play renders a WAV file to deviceID. Any current playback is stopped first.
// Returns immediately; playback runs in the background.
func (m *Manager) Play(deviceID, path string) error {
// Play sends a recorded message to a device, amplified by gainPct (100 = as
// recorded).
//
// The gain exists because nothing else could raise the level: the message went
// out exactly as captured, so a mic recorded quietly drove the rig quietly and
// the operator had no control anywhere in OpsLog — only the radio's own USB
// input level, buried in its menus, and the Windows mixer.
func (m *Manager) Play(deviceID, path string, gainPct int) error {
pcm, rate, ch, bits, err := readWAV(path)
if err != nil {
return err
}
if gainPct > 0 && gainPct != 100 && bits == 16 {
g := float64(gainPct) / 100
// In place: the buffer is this call's own copy of the file.
for i := 0; i+1 < len(pcm); i += 2 {
v := int16(uint16(pcm[i]) | uint16(pcm[i+1])<<8)
v = scalePCM(v, g)
pcm[i], pcm[i+1] = byte(uint16(v)), byte(uint16(v)>>8)
}
}
// Waits for any previous playback to have RELEASED THE DEVICE.
//
// It used to only signal the old one to stop and start a new one at once.
// The old goroutine still held the WASAPI render client for a moment, so the
// new client could not start: it returned immediately, the PTT was keyed and
// released a tenth of a second later, and nothing came out. On the air that
// looked like "press play again and you must wait the whole length of the
// message before it will play at all".
m.StopPlayback()
stop := make(chan struct{})
done := make(chan struct{})
m.mu.Lock()
m.playStop = stop
m.playDone = done
m.mu.Unlock()
go func() {
_ = playPCM(deviceID, pcm, rate, ch, bits, stop)
// The error was discarded. A device that refuses to start returns here
// instantly, the PTT is released 120 ms later, and NOTHING says why —
// which is exactly what a station heard as "it plays once, then never
// again": the call succeeded, the sound did not.
if err := playPCM(deviceID, pcm, rate, ch, bits, stop); err != nil {
LogSink("audio: playback on %q failed: %v", DeviceName(deviceID), err)
}
m.mu.Lock()
if m.playStop == stop {
m.playStop = nil
}
if m.playDone == done {
m.playDone = nil
}
m.mu.Unlock()
close(done) // the device is free from here
m.notify()
}()
m.notify()
@@ -131,12 +179,24 @@ func (m *Manager) Play(deviceID, path string) error {
func (m *Manager) StopPlayback() {
m.mu.Lock()
stop := m.playStop
done := m.playDone
m.playStop = nil
m.mu.Unlock()
if stop != nil {
close(stop)
m.notify()
if stop == nil {
return
}
close(stop)
// Wait for the goroutine to release the device — that is the whole point of
// stopping before starting again. Bounded: a wedged WASAPI call must not
// freeze the caller, which here is the operator clicking a button.
if done != nil {
select {
case <-done:
case <-time.After(1500 * time.Millisecond):
LogSink("audio: previous playback did not release the device within 1.5 s")
}
}
m.notify()
}
// ---- RX audio monitor (Phase 2: USB codec passthrough) --------------------
@@ -172,9 +232,12 @@ func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
m.mu.Unlock()
if capture {
// Producer: capture the rig's USB audio into the ring.
// Producer: capture the rig's USB audio into the ring, at the level the
// operator set. Applied HERE rather than on the render side so the
// network-fed path (PushMonitorAudio) keeps its own untouched levels —
// that stream is already scaled by the radio.
go func() {
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(chunk) })
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(m.scaleMonitor(chunk)) })
}()
}
// Consumer: render the ring to the output device at the internal 16 kHz mono.
@@ -185,6 +248,38 @@ func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
return nil
}
// SetMonitorGain sets the RX monitor level in percent (100 = as captured).
// Takes effect on the next captured chunk — no need to restart the monitor.
func (m *Manager) SetMonitorGain(pct int) {
if pct <= 0 {
pct = 100
}
m.mu.Lock()
m.monGainPct = pct
m.mu.Unlock()
}
// scaleMonitor applies the monitor level to one captured chunk, returning a
// buffer the ring may keep. At unity it hands the chunk straight back: the
// common case must not pay for a copy 30 times a second.
func (m *Manager) scaleMonitor(chunk []byte) []byte {
m.mu.Lock()
pct := m.monGainPct
m.mu.Unlock()
if pct == 0 || pct == 100 {
return chunk
}
g := float64(pct) / 100
out := make([]byte, len(chunk))
copy(out, chunk)
for i := 0; i+1 < len(out); i += 2 {
v := int16(uint16(out[i]) | uint16(out[i+1])<<8)
v = scalePCM(v, g)
out[i], out[i+1] = byte(uint16(v)), byte(uint16(v)>>8)
}
return out
}
// StopMonitor stops the RX monitor passthrough.
func (m *Manager) StopMonitor() {
m.mu.Lock()
@@ -265,3 +360,16 @@ func (m *Manager) TXAudioActive() bool {
defer m.mu.Unlock()
return m.txStop != nil
}
// scalePCM applies a gain to one sample, clamping rather than wrapping — an
// overflow that wraps turns loud speech into a burst of noise on the air.
func scalePCM(s int16, g float64) int16 {
v := float64(s) * g
if v > 32767 {
return 32767
}
if v < -32768 {
return -32768
}
return int16(v)
}
+70 -7
View File
@@ -3,6 +3,7 @@
package audio
import (
"math"
"os"
"github.com/braheezy/shine-mp3/pkg/mp3"
@@ -50,19 +51,81 @@ func writeMP3(path string, pcm []byte) error {
return enc.Write(f, stereo)
}
// upsample2 doubles the sample rate with linear interpolation (16 kHz → 32 kHz).
// upsample2 doubles the sample rate, 16 kHz → 32 kHz, WITH the interpolation
// filter that makes the operation legitimate.
//
// It used to interpolate linearly — each new sample the average of its
// neighbours. That is an upsampler in name only: every component at f is
// mirrored to 16 kHz f, measured just 7 dB down (see the test). On speech it
// adds brightness; on receiver noise, which is broadband, it lays a second
// noise floor across the top of the band. Operators heard it as a hiss louder
// than the voice, present in the MP3 and absent from the WAV of the very same
// audio.
//
// Zero-stuffing followed by a windowed-sinc low-pass at the old Nyquist is the
// textbook answer, and it puts the image below 40 dB. 63 taps is a few hundred
// microseconds of work on an eight-second recording — nothing, next to the MP3
// encode that follows.
func upsample2(in []int16) []int16 {
if len(in) == 0 {
return in
}
out := make([]int16, len(in)*2)
for i := range in {
out[2*i] = in[i]
if i+1 < len(in) {
out[2*i+1] = int16((int32(in[i]) + int32(in[i+1])) / 2)
} else {
out[2*i+1] = in[i]
half := len(upsampleFIR) / 2
for i := range out {
var acc float64
// The zero-stuffed signal is non-zero only at even indices, so only
// every other tap contributes — the loop skips the zeros rather than
// multiplying by them.
start := (i - half + 1) &^ 1 // first even index in the window
for j := start; j <= i+half; j += 2 {
k := i - j + half
if k < 0 || k >= len(upsampleFIR) {
continue
}
src := j / 2
if src < 0 || src >= len(in) {
continue
}
acc += float64(in[src]) * upsampleFIR[k]
}
// ×2 for the energy lost to zero-stuffing, then clamp.
v := acc * 2
if v > 32767 {
v = 32767
} else if v < -32768 {
v = -32768
}
out[i] = int16(v)
}
return out
}
// upsampleFIR is a 63-tap Hamming-windowed sinc, cut off at a quarter of the
// NEW rate (8 kHz at 32 kHz) — exactly the old Nyquist, which is where the
// images begin.
var upsampleFIR = func() []float64 {
const n = 63
const fc = 0.25 // cycles/sample at the new rate
h := make([]float64, n)
mid := (n - 1) / 2
var sum float64
for i := 0; i < n; i++ {
m := float64(i - mid)
var v float64
if m == 0 {
v = 2 * fc
} else {
v = math.Sin(2*math.Pi*fc*m) / (math.Pi * m)
}
// Hamming window: a rectangular one would ring and put the stopband
// only ~21 dB down, which is not enough to be worth the filter.
v *= 0.54 - 0.46*math.Cos(2*math.Pi*float64(i)/float64(n-1))
h[i] = v
sum += v
}
for i := range h {
h[i] /= sum // unity gain at DC
}
return h
}()
+56
View File
@@ -0,0 +1,56 @@
package audio
import (
"testing"
"time"
)
// Playing a stopped take must not consume it.
//
// Reported on the air: the recording plays once, then nothing — "as if it threw
// the sound away after playing it". PeekQSO is what the playback reads, and it
// is meant to COPY; TakeQSO is the one that clears.
func TestPeekDoesNotConsumeTheTake(t *testing.T) {
r := &Recorder{running: true, active: true, paused: true, prerollSamples: sampleRate}
r.acc = make([]int16, 4000)
for i := range r.acc {
r.acc[i] = int16(i % 500)
}
first, err := r.PeekQSO()
if err != nil {
t.Fatalf("first peek: %v", err)
}
second, err := r.PeekQSO()
if err != nil {
t.Fatalf("second peek: %v — the take was consumed by the first", err)
}
if len(first) != len(second) {
t.Errorf("second peek returned %d bytes, first %d", len(second), len(first))
}
// And the copy must be independent: the caller holds these bytes while the
// recorder may still be appending to its own slice.
if len(first) > 0 {
first[0] = ^first[0]
again, _ := r.PeekQSO()
if len(again) > 0 && again[0] == first[0] {
t.Error("PeekQSO handed out its internal buffer, not a copy")
}
}
// Resuming and stopping again keeps everything captured so far.
r.ResumeQSO()
r.mu.Lock()
r.acc = append(r.acc, make([]int16, 1000)...)
r.mu.Unlock()
r.PauseQSO()
third, err := r.PeekQSO()
if err != nil {
t.Fatalf("peek after resume: %v", err)
}
if len(third) <= len(second) {
t.Errorf("after resuming, the take is %d bytes — it should have grown past %d", len(third), len(second))
}
_ = time.Now
}
+254 -23
View File
@@ -15,6 +15,14 @@ import (
// Defaults to a no-op so the package is usable without wiring.
var LogSink = func(string, ...any) {}
// AlertSink receives the few audio problems an operator must see WHILE they are
// operating, not afterwards in a log file — a capture device that opens but
// never streams being the one that matters: the recording is silently empty and
// nothing says so until the QSO is logged and gone.
//
// Set to a toast emitter at startup; a no-op keeps the package standalone.
var AlertSink = func(string, ...any) {}
// recoverGoroutine turns a panic in a long-running audio goroutine into a logged
// event with a stack trace instead of a silent process-killing crash. (It can't
// catch a hard Windows access violation from the WASAPI layer — those are fatal
@@ -42,16 +50,31 @@ type Recorder struct {
prerollSamples int
// Per-source sample queues (guarded by srcMu), drained by the mixer.
srcMu sync.Mutex
bufA []int16 // From Radio
bufB []int16 // mic
twoSrc bool
gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu
gainB float64 // mic gain
srcMu sync.Mutex
bufA []int16 // From Radio
bufB []int16 // mic
// When each source last delivered samples. A configured device that never
// produces anything is not an error anywhere — the capture call just sits
// there — so the only way to notice is to watch the clock.
lastA, lastB time.Time
// startedAt is when capture began — the reference for a source that has not
// delivered anything at all yet.
startedAt time.Time
// deadB (deadA) latches once a source has been declared silent, so the
// warning is logged once rather than 25 times a second.
deadA, deadB bool
twoSrc bool
gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu
gainB float64 // mic gain
// Mixed output state (guarded by mu).
ring []int16 // last prerollSamples of mixed audio
active bool
// paused freezes an ACTIVE take: nothing more is accumulated, but everything
// captured so far is kept and the take still ends normally when the QSO is
// logged. It is what lets an operator stop, play the recording back on the
// air to the station they are working, and still have it saved with the QSO.
paused bool
acc []int16 // active QSO accumulation (seeded from ring on BeginQSO)
}
@@ -110,11 +133,37 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
if prerollSec < 0 {
prerollSec = 0
}
// Does the configured endpoint still EXIST?
//
// Endpoint ids are stored, and a DAX channel that is reconfigured, disabled
// or removed comes back with a different id. The old one then opens without
// complaint on some drivers and simply never streams — which is
// indistinguishable from a quiet band until the recording turns out empty.
// Checking the list takes milliseconds and answers it outright.
if devs, derr := ListInputDevices(); derr == nil {
known := func(id string) bool {
for _, d := range devs {
if d.ID == id {
return true
}
}
return false
}
if fromDev != "" && !known(fromDev) {
LogSink("recorder: the configured radio input no longer exists (%s) — re-select it in Settings → Audio", fromDev)
AlertSink("The configured radio audio input no longer exists — re-select it in Settings")
}
if micDev != "" && micDev != fromDev && !known(micDev) {
LogSink("recorder: the configured microphone no longer exists (%s) — re-select it in Settings → Audio", micDev)
}
}
r.prerollSamples = prerollSec * sampleRate
r.twoSrc = micDev != "" && micDev != fromDev
r.stopCh = make(chan struct{})
r.running = true
r.ring, r.acc, r.active, r.bufA, r.bufB = nil, nil, false, nil, nil
r.startedAt = time.Now()
r.ring, r.acc, r.active, r.paused, r.bufA, r.bufB = nil, nil, false, false, nil, nil
r.lastA, r.lastB, r.deadA, r.deadB = time.Time{}, time.Time{}, false, false
stop := r.stopCh
twoSrc := r.twoSrc
r.mu.Unlock()
@@ -124,27 +173,79 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
go func() {
defer r.wg.Done()
defer recoverGoroutine("recorder capture (radio)")
_ = captureStream(fromDev, stop, func(chunk []byte) {
// The error was discarded here. A device that cannot be opened — renamed,
// unplugged, held by another program — then looked exactly like a device
// that is merely quiet, and the recording came out empty with nothing in
// the log to say why.
if err := captureStream(fromDev, stop, func(chunk []byte) {
s := bytesToInt16(chunk)
r.srcMu.Lock()
r.bufA = append(r.bufA, s...)
r.lastA = time.Now()
r.srcMu.Unlock()
})
}); err != nil {
LogSink("recorder: capture from %q failed: %v", DeviceName(fromDev), err)
}
}()
if twoSrc {
r.wg.Add(1)
go func() {
defer r.wg.Done()
defer recoverGoroutine("recorder capture (mic)")
_ = captureStream(micDev, stop, func(chunk []byte) {
if err := captureStream(micDev, stop, func(chunk []byte) {
s := bytesToInt16(chunk)
r.srcMu.Lock()
r.bufB = append(r.bufB, s...)
r.lastB = time.Now()
r.srcMu.Unlock()
})
}); err != nil {
LogSink("recorder: capture from %q failed: %v", DeviceName(micDev), err)
}
}()
}
// Name the devices being recorded FROM, once, at the start.
//
// A station with two radios has two sets of endpoints, and the recorder will
// happily capture the one that is not being listened to: the result is a
// file full of hiss with no relation to what the operator hears, and nothing
// anywhere said which receiver it came from.
if twoSrc {
LogSink("recorder: capturing %q + %q", DeviceName(fromDev), DeviceName(micDev))
} else {
LogSink("recorder: capturing %q", DeviceName(fromDev))
}
// Watchdog. A WASAPI device can open cleanly and then produce nothing at
// all — a DAX channel with no stream behind it does exactly that, and it is
// indistinguishable from silence until the recording turns out to be empty
// at the end of a QSO. Say it once, three seconds in, while there is still
// time to fix the setup.
r.wg.Add(1)
go func() {
defer r.wg.Done()
defer recoverGoroutine("recorder watchdog")
select {
case <-stop:
return
case <-time.After(3 * time.Second):
}
r.srcMu.Lock()
aQuiet, bQuiet := r.lastA.IsZero(), r.lastB.IsZero()
r.srcMu.Unlock()
if aQuiet {
LogSink("recorder: no audio at all from %q after 3 s — the device opened but nothing is streaming", DeviceName(fromDev))
AlertSink("No audio from %s — nothing is being recorded", DeviceName(fromDev))
}
// The mic is only worth a warning when the RADIO is silent too. On CW the
// mic channel legitimately delivers nothing, and the mixer has already
// said "recording the radio alone" — repeating it as an alarm made the log
// read as if something were broken while the recording was going fine.
if twoSrc && bQuiet && aQuiet {
LogSink("recorder: no audio at all from %q either", DeviceName(micDev))
}
}()
// Mixer goroutine.
r.wg.Add(1)
go func() {
@@ -166,15 +267,79 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
// mixTick drains the source queues, mixes what's available, and appends to the
// ring + active accumulation.
// deadSourceAfter is how long a source may deliver nothing before the recorder
// carries on without it. Long enough not to trip on a scheduling hiccup, short
// enough that almost nothing is lost from the source that IS working.
const deadSourceAfter = 1500 * time.Millisecond
func (r *Recorder) mixTick() {
r.srcMu.Lock()
var mixed []int16
if r.twoSrc {
// A source that has been silent for a while is treated as ABSENT and the
// other one is recorded alone.
//
// Two sources used to mean min(len(A), len(B)) samples: if one device
// delivered nothing, NOTHING was recorded, and the drift guard below
// then threw the live source away a second at a time. That is exactly
// what happened on a Flex in CW — DAX Mic delivers nothing when the mic
// path is not running — and the operator got "recording was empty" after
// a whole QSO. Half a recording is worth having; silence is not.
now := time.Now()
// A source is dry when it has been quiet for too long — and a source that
// has NEVER delivered is measured from when capture started, because it
// has no last-delivery time of its own.
//
// The first version required a source to have spoken at least once. That
// covers a device that stops, but not the one that actually happens: a
// DAX Mic channel that is simply switched off never delivers a single
// sample, so it stayed "not yet dry" forever and took the whole recording
// down with it. Three empty CW recordings, with the radio audio streaming
// perfectly the entire time.
dry := func(last time.Time) bool {
if last.IsZero() {
return now.Sub(r.startedAt) > deadSourceAfter
}
return now.Sub(last) > deadSourceAfter
}
aDry, bDry := dry(r.lastA), dry(r.lastB)
if bDry && !aDry && len(r.bufA) > 0 {
if !r.deadB {
r.deadB = true
LogSink("recorder: the second audio source is silent — recording the radio alone")
}
mixed = make([]int16, len(r.bufA))
for i, v := range r.bufA {
mixed[i] = scaleSample(v, r.gainA)
}
r.bufA = r.bufA[:0]
r.bufB = r.bufB[:0]
r.srcMu.Unlock()
r.store(mixed)
return
}
if aDry && !bDry && len(r.bufB) > 0 {
if !r.deadA {
r.deadA = true
LogSink("recorder: the radio audio source is silent — recording the microphone alone")
}
mixed = make([]int16, len(r.bufB))
for i, v := range r.bufB {
mixed[i] = scaleSample(v, r.gainB)
}
r.bufA = r.bufA[:0]
r.bufB = r.bufB[:0]
r.srcMu.Unlock()
r.store(mixed)
return
}
n := len(r.bufA)
if len(r.bufB) < n {
n = len(r.bufB)
}
if n > 0 {
// Both alive again after one was written off.
r.deadA, r.deadB = false, false
mixed = make([]int16, n)
for i := 0; i < n; i++ {
mixed[i] = clampSum(scaleSample(r.bufA[i], r.gainA), scaleSample(r.bufB[i], r.gainB))
@@ -182,12 +347,20 @@ func (r *Recorder) mixTick() {
r.bufA = append(r.bufA[:0], r.bufA[n:]...)
r.bufB = append(r.bufB[:0], r.bufB[n:]...)
}
// Drift guard: if the clocks diverge, drop the excess so the two
// sources stay roughly aligned (≤1 s skew).
if d := len(r.bufA) - len(r.bufB); d > sampleRate {
r.bufA = append(r.bufA[:0], r.bufA[d:]...)
} else if d < -sampleRate {
r.bufB = append(r.bufB[:0], r.bufB[-d:]...)
// Drift guard: two sound cards run on their own clocks, so drop the
// excess to keep them within a second of each other.
//
// ONLY while both are actually running. A starved source is not drift: it
// made this guard throw away the radio audio a second at a time during
// the grace period, so the opening of every recording was lost even
// though it had been captured. Waiting costs nothing now — whatever is
// buffered is written whole the moment the silent source is written off.
if len(r.bufA) > 0 && len(r.bufB) > 0 {
if d := len(r.bufA) - len(r.bufB); d > sampleRate {
r.bufA = append(r.bufA[:0], r.bufA[d:]...)
} else if d < -sampleRate {
r.bufB = append(r.bufB[:0], r.bufB[-d:]...)
}
}
} else if len(r.bufA) > 0 {
mixed = make([]int16, len(r.bufA))
@@ -198,6 +371,12 @@ func (r *Recorder) mixTick() {
}
r.srcMu.Unlock()
r.store(mixed)
}
// store appends mixed samples to the pre-roll ring and, when a take is running,
// to the take itself.
func (r *Recorder) store(mixed []int16) {
if len(mixed) == 0 {
return
}
@@ -206,7 +385,7 @@ func (r *Recorder) mixTick() {
if len(r.ring) > r.prerollSamples {
r.ring = append(r.ring[:0], r.ring[len(r.ring)-r.prerollSamples:]...)
}
if r.active {
if r.active && !r.paused {
r.acc = append(r.acc, mixed...)
}
r.mu.Unlock()
@@ -221,7 +400,7 @@ func (r *Recorder) BeginQSO() {
return
}
r.acc = append([]int16(nil), r.ring...)
r.active = true
r.active, r.paused = true, false
}
// RestartQSO begins a fresh accumulation even if one is already active —
@@ -236,7 +415,7 @@ func (r *Recorder) RestartQSO() {
return
}
r.acc = append([]int16(nil), r.ring...)
r.active = true
r.active, r.paused = true, false
}
// ResetQSOClock restarts the active accumulation from ZERO — discarding
@@ -266,7 +445,7 @@ func (r *Recorder) TakeQSO() ([]byte, error) {
return nil, fmt.Errorf("no active recording")
}
samples := r.acc
r.acc, r.active = nil, false
r.acc, r.active, r.paused = nil, false, false
r.mu.Unlock()
if len(samples) == 0 {
return nil, fmt.Errorf("recording was empty")
@@ -295,7 +474,7 @@ func (r *Recorder) SaveQSO(path string) error {
// DiscardQSO drops the active accumulation without saving (callsign cleared).
func (r *Recorder) DiscardQSO() {
r.mu.Lock()
r.acc, r.active = nil, false
r.acc, r.active, r.paused = nil, false, false
r.mu.Unlock()
}
@@ -313,7 +492,7 @@ func (r *Recorder) Stop() {
close(stop)
r.wg.Wait()
r.mu.Lock()
r.ring, r.acc, r.active = nil, nil, false
r.ring, r.acc, r.active, r.paused = nil, nil, false, false
r.mu.Unlock()
r.srcMu.Lock()
r.bufA, r.bufB = nil, nil
@@ -346,3 +525,55 @@ func int16sToBytes(s []int16) []byte {
}
return b
}
// PauseQSO freezes the active take without ending it: nothing more is recorded,
// nothing is thrown away, and logging the QSO still writes the file.
//
// This exists for one situation, which is common enough on the bands to be
// worth the state: you are working a station, you have been recording, and they
// ask to hear it. You stop, you play it back to them on the air, and the
// recording is still saved with the QSO afterwards.
func (r *Recorder) PauseQSO() bool {
r.mu.Lock()
defer r.mu.Unlock()
if !r.active {
return false
}
r.paused = true
return true
}
// ResumeQSO continues an interrupted take, appending to what is already there.
func (r *Recorder) ResumeQSO() bool {
r.mu.Lock()
defer r.mu.Unlock()
if !r.active {
return false
}
r.paused = false
return true
}
// Paused reports whether the active take is frozen.
func (r *Recorder) Paused() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.active && r.paused
}
// PeekQSO returns what has been captured so far WITHOUT ending the take.
//
// Unlike TakeQSO this keeps the audio, because the take is going to be played
// back and then still saved with the QSO. The copy is deliberate: the caller
// gets bytes it can hold while the recorder keeps appending to its own slice.
func (r *Recorder) PeekQSO() ([]byte, error) {
r.mu.Lock()
defer r.mu.Unlock()
if !r.active {
return nil, fmt.Errorf("no active recording")
}
if len(r.acc) == 0 {
return nil, fmt.Errorf("recording is empty")
}
return int16sToBytes(append([]int16(nil), r.acc...)), nil
}
+123
View File
@@ -0,0 +1,123 @@
package audio
import (
"testing"
"time"
)
// A silent second source must not silence the recording.
//
// From a real session: a Flex with "From Radio" on DAX RX and "Recording mic"
// on DAX Mic. In CW the mic path does not run, so DAX Mic delivered nothing —
// and because the mixer took min(len(A), len(B)) samples, nothing at all was
// recorded. The whole QSO ended in "recording was empty".
func TestMixerSurvivesASilentSource(t *testing.T) {
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
// The radio has been talking; the mic has not spoken for well over the
// grace period.
r.bufA = make([]int16, 800)
for i := range r.bufA {
r.bufA[i] = 1000
}
r.lastA = time.Now()
r.lastB = time.Now().Add(-5 * time.Second)
r.mixTick()
if len(r.acc) == 0 {
t.Fatal("nothing was recorded — a silent mic must not stop the radio being captured")
}
if r.acc[0] != 1000 {
t.Errorf("sample = %d, want 1000 — the live source must pass through unchanged", r.acc[0])
}
}
// While BOTH sources are alive the two are mixed, as before.
func TestMixerStillMixesBothSources(t *testing.T) {
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
r.bufA = []int16{100, 100, 100}
r.bufB = []int16{50, 50, 50}
now := time.Now()
r.lastA, r.lastB = now, now
r.mixTick()
if len(r.acc) != 3 {
t.Fatalf("recorded %d samples, want 3", len(r.acc))
}
if r.acc[0] != 150 {
t.Errorf("sample = %d, want 150 — both sources should be summed", r.acc[0])
}
}
// Just after capture starts, a source that has not delivered yet is NOT written
// off: the first samples take a moment to arrive, and declaring the mic dead at
// once would drop the opening of every recording.
func TestMixerWaitsBrieflyAtStartup(t *testing.T) {
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
r.startedAt = time.Now()
r.bufA = []int16{100, 100}
r.mixTick()
if len(r.acc) != 0 {
t.Errorf("recorded %d samples immediately — the second source deserves a moment to start", len(r.acc))
}
}
// A source that has NEVER delivered is written off once enough time has passed.
//
// This is the case that actually happens, and the one the first version missed:
// a DAX Mic channel switched off delivers not one sample, so it had no
// last-delivery time and stayed forever "not yet dry" — taking the whole
// recording down with it while the radio audio streamed perfectly.
func TestMixerWritesOffASourceThatNeverSpoke(t *testing.T) {
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
r.startedAt = time.Now().Add(-5 * time.Second)
r.bufA = []int16{700, 700, 700}
r.lastA = time.Now()
// lastB stays zero: the mic has never produced anything at all.
r.mixTick()
if len(r.acc) != 3 {
t.Fatalf("recorded %d samples, want 3 — the radio was streaming the whole time", len(r.acc))
}
if r.acc[0] != 700 {
t.Errorf("sample = %d, want 700", r.acc[0])
}
}
// Nothing captured during the grace period is thrown away.
//
// The drift guard exists for two sound cards running on their own clocks. A
// STARVED source is not drift, and treating it as such discarded the radio
// audio a second at a time while the mixer was still waiting for the mic — so
// the opening of every CW recording was lost although it had been captured.
func TestGracePeriodKeepsWhatWasCaptured(t *testing.T) {
r := &Recorder{twoSrc: true, gainA: 1, gainB: 1, running: true, active: true, prerollSamples: sampleRate}
r.startedAt = time.Now()
// Three seconds of radio audio arrive while the mic says nothing at all.
r.bufA = make([]int16, 3*sampleRate)
for i := range r.bufA {
r.bufA[i] = 500
}
r.lastA = time.Now()
r.mixTick() // still inside the grace period: nothing is written yet…
if len(r.acc) != 0 {
t.Fatalf("wrote %d samples before the mic was written off", len(r.acc))
}
if len(r.bufA) != 3*sampleRate {
t.Fatalf("buffered audio was trimmed to %d samples — it must be kept, not dropped", len(r.bufA))
}
// …and once the mic is written off, everything captured comes through.
r.startedAt = time.Now().Add(-5 * time.Second)
r.mixTick()
if len(r.acc) != 3*sampleRate {
t.Errorf("recorded %d samples, want the full %d captured during the wait", len(r.acc), 3*sampleRate)
}
}
+79
View File
@@ -0,0 +1,79 @@
package audio
import (
"math"
"testing"
)
// goertzel returns the magnitude of one frequency bin, so a test can ask "how
// much energy is at 10 kHz" without pulling in an FFT.
func goertzel(x []int16, fs, freq float64) float64 {
w := 2 * math.Pi * freq / fs
c := 2 * math.Cos(w)
var s1, s2 float64
for _, v := range x {
s := float64(v) + c*s1 - s2
s2, s1 = s1, s
}
return math.Hypot(s1-s2*math.Cos(w), s2*math.Sin(w))
}
// Upsampling 16 kHz → 32 kHz must not create an audible mirror image.
//
// MP3 recordings came back with a hiss that is not on the air and is not in the
// WAV of the same audio — "louder than the voice", from a station that hears the
// voice well clear of the noise. The MP3 path is the only one that resamples,
// and it did so by linear interpolation: every component at f is mirrored to
// 16 kHz f, which for broadband receiver noise means a second noise floor
// spread across the top of the band.
func TestUpsampleSuppressesTheImage(t *testing.T) {
const fs = 16000.0
const tone = 6000.0 // its image lands at 16000 6000 = 10 kHz
in := make([]int16, 4096)
for i := range in {
in[i] = int16(12000 * math.Sin(2*math.Pi*tone*float64(i)/fs))
}
out := upsample2(in)
if len(out) != 2*len(in) {
t.Fatalf("upsample2 returned %d samples for %d", len(out), len(in))
}
wanted := goertzel(out, 2*fs, tone)
image := goertzel(out, 2*fs, 2*fs-tone-16000) // = 10 kHz
if wanted == 0 {
t.Fatal("the tone itself did not survive upsampling")
}
db := 20 * math.Log10(image/wanted)
t.Logf("image at 10 kHz is %.1f dB below the 6 kHz tone", db)
if db > -35 {
t.Errorf("image only %.1f dB down — it is audible as added hiss; want at least 35 dB", db)
}
}
// The filter must not change the level, or every existing recording would come
// back quieter (or clipped) after the fix — a second surprise on top of the one
// being repaired.
func TestUpsampleKeepsTheLevel(t *testing.T) {
const fs = 16000.0
in := make([]int16, 4096)
for i := range in {
in[i] = int16(10000 * math.Sin(2*math.Pi*1000*float64(i)/fs))
}
rms := func(x []int16) float64 {
var acc float64
for _, v := range x {
acc += float64(v) * float64(v)
}
return math.Sqrt(acc / float64(len(x)))
}
// Skip the filter's warm-up and tail, where the window is only partly fed.
out := upsample2(in)
got, want := rms(out[200:len(out)-200]), rms(in[100:len(in)-100])
ratio := got / want
t.Logf("level after upsampling: ×%.3f", ratio)
if ratio < 0.9 || ratio > 1.1 {
t.Errorf("level changed by ×%.3f — want within 10%%", ratio)
}
}
+7 -7
View File
@@ -33,13 +33,13 @@ func writeWAV(path string, pcm []byte) error {
put(uint32(36 + dataLen))
f.WriteString("WAVE")
f.WriteString("fmt ")
put(uint32(16)) // PCM fmt chunk size
put(uint16(1)) // WAVE_FORMAT_PCM
put(uint16(channels)) //
put(uint32(sampleRate)) //
put(uint32(bytesPerSec)) // byte rate
put(uint16(blockAlign)) //
put(uint16(bitsPerSample)) //
put(uint32(16)) // PCM fmt chunk size
put(uint16(1)) // WAVE_FORMAT_PCM
put(uint16(channels)) //
put(uint32(sampleRate)) //
put(uint32(bytesPerSec)) // byte rate
put(uint16(blockAlign)) //
put(uint16(bitsPerSample)) //
f.WriteString("data")
put(uint32(dataLen))
_, err = f.Write(pcm)
+214 -38
View File
@@ -6,10 +6,11 @@
// QSO may yield several references (e.g. a Note holding "D74 D73").
//
// Examples:
// DXCC : field "dxcc" (no pattern) → entity number
// WAS : field "state", DXCCFilter [291,110,6] → US state
// DDFM : field "note", pattern "D(\d{1,2}[AB]?)" → French department from notes
// WPX : field "prefix" (computed from callsign)
//
// DXCC : field "dxcc" (no pattern) → entity number
// WAS : field "state", DXCCFilter [291,110,6] → US state
// DDFM : field "note", pattern "D(\d{1,2}[AB]?)" → French department from notes
// WPX : field "prefix" (computed from callsign)
package award
import (
@@ -48,16 +49,16 @@ const (
// behaves as before.
type Def struct {
// --- Identity ---
Code string `json:"code"` // unique key, e.g. "DXCC"
Name string `json:"name"` // friendly name
Code string `json:"code"` // unique key, e.g. "DXCC"
Name string `json:"name"` // friendly name
Description string `json:"description,omitempty"` // free text
Valid bool `json:"valid"` // award enabled
Protected bool `json:"protected,omitempty"` // shipped/locked award
URL string `json:"url,omitempty"` // award home page
Valid bool `json:"valid"` // award enabled
Protected bool `json:"protected,omitempty"` // shipped/locked award
URL string `json:"url,omitempty"` // award home page
DownloadURL string `json:"download_url,omitempty"` // reference-list source
RefURL string `json:"ref_url,omitempty"` // per-ref link, <REF> placeholder
ValidFrom string `json:"valid_from,omitempty"` // ISO date (QSOs before don't count)
ValidTo string `json:"valid_to,omitempty"` // ISO date (QSOs after don't count)
RefURL string `json:"ref_url,omitempty"` // per-ref link, <REF> placeholder
ValidFrom string `json:"valid_from,omitempty"` // ISO date (QSOs before don't count)
ValidTo string `json:"valid_to,omitempty"` // ISO date (QSOs after don't count)
Alias string `json:"alias,omitempty"`
// RefDisplay picks what the grid's award column shows for a match: "" or "ref"
// = the reference (e.g. WAJA "36"), "name" = the reference's description (e.g.
@@ -66,18 +67,31 @@ type Def struct {
RefDisplay string `json:"ref_display,omitempty"`
// --- Type & matching ---
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
Field string `json:"field"` // QSO field to scan (see fieldRaw)
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
Prefix string `json:"prefix,omitempty"` // prepended to each found reference (e.g. captured "01" + "D" → "D01"), same as OrRule.Prefix
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
// NOTE: there is no "one reference per QSO" switch, and there was never any
// point in one. A QSO ALWAYS yields every reference its field holds — an n-fer
// POTA activation ("US-6544,US-0680"), a VUCC contact on a grid line. The old
// `Multi` flag was read by nothing; its checkbox changed nothing.
// OneRefPerQSO refuses an AMBIGUOUS match rather than guessing.
//
// This is not the old `Multi` flag, which was removed for good reason: a QSO
// always yields every reference its FIELD holds — an n-fer POTA activation
// ("US-6544,US-0680"), a VUCC contact on a grid line. That is several
// references, correctly.
//
// The case here is the opposite. The field holds ONE value and two entries in
// the reference list carry it: two German DOKs are both called "Gießen",
// because two clubs share the town. The matcher is not wrong, the data is
// ambiguous — and picking one would write a reference into the log that may
// well be the other. So on an award where a QSO can only ever count for one
// reference, an ambiguous match assigns NONE and the contact appears under
// "missing references", where the operator picks. Their choice then sticks:
// a manual reference overrides the matcher.
OneRefPerQSO bool `json:"one_ref_per_qso,omitempty"`
// OrRules are ordered FALLBACK searches for the primary one above: they are
// tried IN ORDER and only while nothing has matched yet — the first rule that
@@ -89,14 +103,26 @@ type Def struct {
OrRules []OrRule `json:"or_rules,omitempty"`
// --- Scope ---
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands
ValidModes []string `json:"valid_modes,omitempty"` // empty = all modes
Emission []string `json:"emission,omitempty"` // CW | DIGITAL | PHONE (empty = all)
// --- Confirmation ---
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
Validate []string `json:"validate,omitempty"` // validated/granted sources
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
Validate []string `json:"validate,omitempty"` // validated/granted sources
// The "custom" source, for confirmations OpsLog has no dedicated column for:
// ConfirmField names a QSO field or an ADIF extras key, ConfirmValue the
// value(s) that count (comma-separated, case-insensitive).
//
// An EMPTY ConfirmValue means "any non-empty value confirms" — which is what
// the OpsLog card marker needs: APP_OPSLOG_QSL_RCVD stores the timestamp of
// the day the card arrived, not a Y/N flag. The same shape serves a key
// stamped by an outside source (a club's CSV imported into an extras field),
// so one mechanism covers every "confirmed somewhere else" case instead of a
// new checkbox per site.
ConfirmField string `json:"confirm_field,omitempty"`
ConfirmValue string `json:"confirm_value,omitempty"`
// NOT IMPLEMENTED. Kept so the values operators already typed are not lost, but
// nothing reads them: no ADIF export has ever written CREDIT_GRANTED. Their
// controls have been removed from the editor — a checkbox that quietly does
@@ -333,13 +359,19 @@ type BandCount struct {
// Ref is one reference's status within an award.
type Ref struct {
Ref string `json:"ref"`
Name string `json:"name,omitempty"`
Group string `json:"group,omitempty"`
SubGrp string `json:"subgrp,omitempty"`
Worked bool `json:"worked"`
Confirmed bool `json:"confirmed"`
Validated bool `json:"validated"`
Ref string `json:"ref"`
Name string `json:"name,omitempty"`
Group string `json:"group,omitempty"`
SubGrp string `json:"subgrp,omitempty"`
Worked bool `json:"worked"`
Confirmed bool `json:"confirmed"`
Validated bool `json:"validated"`
// Modes / ConfirmedModes are CLASSES, not ADIF modes: "CW", "PHONE",
// "DIGI". An operator asking "which entities have I worked on CW but not
// confirmed" does not care whether it was FT8 or RTTY on the digital side,
// and a list of twenty mode names would not answer the question they asked.
Modes []string `json:"modes"`
ConfirmedModes []string `json:"confirmed_modes"`
Bands []string `json:"bands"`
ConfirmedBands []string `json:"confirmed_bands"`
ValidatedBands []string `json:"validated_bands"`
@@ -359,11 +391,33 @@ type Result struct {
Error string `json:"error,omitempty"` // e.g. bad regexp pattern
}
// ModeClass sorts an ADIF mode into the three classes an operator thinks in:
// CW, PHONE, DIGI. Anything unrecognised returns "" and is simply not counted
// under any class — better than inventing one, since these classes drive a
// filter that decides what the operator is shown.
func ModeClass(mode string) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "CW", "CWR":
return "CW"
case "SSB", "USB", "LSB", "AM", "FM", "DV", "PHONE", "DIGITALVOICE":
return "PHONE"
case "":
return ""
}
// Everything else that a logbook actually carries is a data mode: FT8, FT4,
// RTTY, PSK31, JS8, Q65, MSK144, OLIVIA, VARA… Listing them exhaustively
// would mean a new mode silently vanishing from the filter the day it
// appears, which is worse than treating an unknown data mode as data.
return "DIGI"
}
// NameResolver optionally maps a (field, ref) pair to a human name. May be nil.
type NameResolver func(field, ref string) string
type refAgg struct {
bands map[string]struct{}
modes map[string]struct{}
confirmedModes map[string]struct{}
confirmedBands map[string]struct{}
validatedBands map[string]struct{}
anyConfirmed bool
@@ -480,22 +534,33 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
continue
}
band := strings.ToLower(strings.TrimSpace(q.Band))
isConf := confirmed(q, d.Confirm)
isVal := confirmed(q, d.Validate)
modeClass := ModeClass(q.Mode)
isConf := confirmed(q, d.Confirm, d)
isVal := confirmed(q, d.Validate, d)
for _, ref := range refs {
a := agg[i][ref]
if a == nil {
a = &refAgg{bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{}, validatedBands: map[string]struct{}{}}
a = &refAgg{
bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{},
validatedBands: map[string]struct{}{},
modes: map[string]struct{}{}, confirmedModes: map[string]struct{}{},
}
agg[i][ref] = a
}
if band != "" {
a.bands[band] = struct{}{}
}
if modeClass != "" {
a.modes[modeClass] = struct{}{}
}
if isConf {
a.anyConfirmed = true
if band != "" {
a.confirmedBands[band] = struct{}{}
}
if modeClass != "" {
a.confirmedModes[modeClass] = struct{}{}
}
}
if isVal {
a.anyValidated = true
@@ -524,6 +589,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
r.Validated++
}
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Validated: a.anyValidated,
Modes: setToSorted(a.modes), ConfirmedModes: setToSorted(a.confirmedModes),
Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands), ValidatedBands: setToSorted(a.validatedBands)}
labelRef(&rf, d, ref, rl, hasList, nameOf)
r.Refs = append(r.Refs, rf)
@@ -631,8 +697,9 @@ func manualRefs(q *qso.QSO, code string) []string {
}
// Confirmed reports whether a QSO satisfies any of the given confirmation
// sources (lotw|qsl|eqsl). Exported for the statistics view.
func Confirmed(q *qso.QSO, sources []string) bool { return confirmed(q, sources) }
// sources (lotw|qsl|eqsl|qrzcom|custom). Exported for the statistics view.
// The Def is needed for the "custom" source, which reads the field IT names.
func Confirmed(q *qso.QSO, d Def, sources []string) bool { return confirmed(q, sources, &d) }
// InScope reports whether a QSO falls within an award's scope (DXCC entity,
// bands, modes, emission, dates) — independent of whether a reference was
@@ -726,6 +793,10 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr
byDesc := predefined && strings.EqualFold(strings.TrimSpace(matchBy), "description")
var found []string
// codesAreFinal: this branch produced references straight from the award's
// LIST, so they are already whole codes. The blanket prefix at the end must
// leave them alone — prefixing a code that is already "D74" yields "DD74".
codesAreFinal := false
switch {
case re != nil:
// Award-level regex: capture group 1 (or whole match) for each hit.
@@ -734,6 +805,7 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr
// Match references by their DESCRIPTION/name appearing in the field
// (e.g. WAJA finds the prefecture name inside the QTH). ExactMatch means
// the field equals the name; otherwise the name is a substring of it.
codesAreFinal = true
up := strings.ToUpper(raw)
for _, nc := range rl.names {
if exact {
@@ -756,9 +828,23 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr
// "Search reference inside the field": look up each token of the field in
// the list — O(tokens), not O(all references) — plus test the few
// references that declare a regex.
codesAreFinal = true
for _, tok := range tokenize(raw) {
if _, ok := rl.byCode[tok]; ok {
found = append(found, tok)
continue
}
// The field may carry the reference WITHOUT the award's letter: a French
// operator writes "74" in STATE while the DDFM codes are "D74". Prefix is
// exactly what that case is for, so try the prefixed form HERE — applying
// it after the lookup (as the blanket pass below used to) could never
// help, because the lookup is the step that failed. Without this, the
// only way to match a bare department was to write a regex, in a mode
// where the operator had chosen "code" and not "pattern".
if prefix != "" {
if _, ok := rl.byCode[prefix+tok]; ok {
found = append(found, prefix+tok)
}
}
}
for _, code := range rl.withPattern {
@@ -772,7 +858,7 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr
// counts each reference separately.
found = splitRefs(raw)
}
if prefix != "" {
if prefix != "" && !codesAreFinal {
for i := range found {
found[i] = prefix + found[i]
}
@@ -818,7 +904,15 @@ type Explanation struct {
RefCount int `json:"ref_count"` // size of that list
Steps []Step `json:"steps"`
Manual []string `json:"manual,omitempty"` // references the operator assigned by hand
Result []string `json:"result"` // what the QSO finally counts for
// Superseded is what the matcher had found before the operator's choice
// replaced it — shown in the trace so a correction is explainable rather
// than mysterious.
Superseded []string `json:"superseded,omitempty"`
// Ambiguous lists the references that matched when the award allows only one
// — none of them was kept. Shown so the operator sees WHICH ones to choose
// between rather than an unexplained blank.
Ambiguous []string `json:"ambiguous,omitempty"`
Result []string `json:"result"` // what the QSO finally counts for
}
// Explain runs the matcher on a single QSO and reports what it did. It goes
@@ -899,7 +993,7 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
// Testing the raw candidate would call that a hit, skip every fallback, and
// only then drop it as unlisted — leaving the QSO unmatched even though the
// next rule ("find the code inside the QTH") would have found BG.
found := run("primary", d.Field, d.MatchBy, d.Pattern, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "")
found := run("primary", d.Field, d.MatchBy, d.Pattern, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, d.Prefix)
for i := range d.OrRules {
r := &d.OrRules[i]
label := fmt.Sprintf("OR %d", i+1)
@@ -936,8 +1030,31 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
if ex != nil {
ex.Manual = manual
}
found = append(found, manual...)
// An operator's choice REPLACES what the matcher found. It does not join it.
//
// Adding to it was the behaviour, and it made a correction impossible: a
// German address matching two or three DOKs kept them all, so deleting the
// wrong ones and assigning the right one lasted exactly until the next
// recompute, which put them straight back. The operator was arguing with
// the matcher and could not win.
//
// One manual entry is stored per award (setOverrideRef drops the previous
// one), so this is an override in the storage as well as in the name.
if len(manual) > 0 {
if ex != nil {
ex.Superseded = dedupe(found)
}
found = manual
}
out := dedupe(found)
// Ambiguity is not a result. See OneRefPerQSO.
if d.OneRefPerQSO && len(manual) == 0 && len(out) > 1 {
if ex != nil {
ex.Ambiguous = out
ex.Result = []string{}
}
return nil
}
if ex != nil {
ex.Result = out
}
@@ -1289,7 +1406,7 @@ func dxccAllowed(dxcc *int, filter []int) bool {
// confirmed reports whether the QSO satisfies any accepted confirmation source.
// ADIF *_QSL_RCVD values Y (confirmed) and V (verified) both count.
func confirmed(q *qso.QSO, sources []string) bool {
func confirmed(q *qso.QSO, sources []string, d *Def) bool {
for _, s := range sources {
switch s {
case "lotw":
@@ -1304,11 +1421,66 @@ func confirmed(q *qso.QSO, sources []string) bool {
if isYes(q.EQSLRcvd) {
return true
}
case "qrzcom":
// The DOWNLOAD status, not the upload one: uploading a QSO to QRZ is
// us telling them, not them confirming it back.
if isYes(q.QRZComDownloadStatus) {
return true
}
case "custom":
if customConfirmed(q, d) {
return true
}
}
}
return false
}
// customConfirmed answers the operator-defined confirmation source: the field
// named by ConfirmField, optionally required to hold one of ConfirmValue's
// comma-separated values.
//
// A source that names no field confirms NOTHING. That is deliberate: ticking
// "custom" without saying what it means used to mark every QSO as unconfirmed
// anyway, and silently marking them all CONFIRMED instead would be far worse.
func customConfirmed(q *qso.QSO, d *Def) bool {
if d == nil {
return false
}
field := strings.TrimSpace(d.ConfirmField)
if field == "" {
return false
}
got := strings.TrimSpace(customFieldValue(q, field))
if got == "" {
return false
}
want := strings.TrimSpace(d.ConfirmValue)
if want == "" {
return true // any value at all counts — see ConfirmField's doc
}
for _, w := range strings.Split(want, ",") {
if w = strings.TrimSpace(w); w != "" && strings.EqualFold(w, got) {
return true
}
}
return false
}
// customFieldValue resolves the field a custom confirmation names: one of the
// engine's known QSO fields first, then the ADIF extras — which is where the
// OpsLog card marker (APP_OPSLOG_QSL_RCVD) and anything stamped by an outside
// import actually live.
func customFieldValue(q *qso.QSO, field string) string {
if v := strings.TrimSpace(fieldRaw(field, q)); v != "" {
return v
}
if q.Extras == nil {
return ""
}
return q.Extras[strings.ToUpper(strings.TrimSpace(field))]
}
func isYes(v string) bool {
switch strings.ToUpper(strings.TrimSpace(v)) {
case "Y", "V":
@@ -1326,7 +1498,11 @@ func setToSorted(m map[string]struct{}) []string {
return out
}
var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm"}
var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m",
"6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm",
// Microwave. A band missing from this order sorts to the end of an award's
// band columns rather than in frequency order — and 3 cm was missing.
"9cm", "6cm", "3cm", "1.25cm", "6mm", "4mm", "2.5mm", "2mm", "1mm"}
func sortedBands(m map[string]int) []string {
idx := map[string]int{}
+791 -4
View File
@@ -4,11 +4,27 @@
"name": "Départements Français Métropolitains",
"valid": true,
"protected": true,
"ref_display": "name",
"type": "QSOFIELDS",
"field": "note",
"pattern": "(?i)\\b(D\\d{1,2}[AB]?)\\b",
"or_rules": [
{
"field": "address",
"match_by": "code",
"pattern": "\\b(\\d{2})\\d{3}\\b",
"prefix": "D"
},
{
"field": "qth",
"match_by": "code",
"pattern": "\\b(\\d{2})\\d{3}\\b",
"prefix": "D"
}
],
"dxcc_filter": [
227
227,
214
],
"confirm": [
"lotw",
@@ -18,6 +34,777 @@
"lotw"
],
"total": 96,
"builtin": true
}
}
"builtin": true,
"version": 2
},
"references": [
{
"code": "D01",
"name": "Ain",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D02",
"name": "Aisne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D03",
"name": "Allier",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D04",
"name": "Alpes-de-Haute-Provence",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D05",
"name": "Hautes-Alpes",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D06",
"name": "Alpes-Maritimes",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D07",
"name": "Ardèche",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D08",
"name": "Ardennes",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D09",
"name": "Ariège",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D10",
"name": "Aube",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D11",
"name": "Aude",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D12",
"name": "Aveyron",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D13",
"name": "Bouches-du-Rhône",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D14",
"name": "Calvados",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D15",
"name": "Cantal",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D16",
"name": "Charente",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D17",
"name": "Charente-Maritime",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D18",
"name": "Cher",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D19",
"name": "Corrèze",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D21",
"name": "Côte-d'Or",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D22",
"name": "Côtes-d'Armor",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D23",
"name": "Creuse",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D24",
"name": "Dordogne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D25",
"name": "Doubs",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D26",
"name": "Drôme",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D27",
"name": "Eure",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D28",
"name": "Eure-et-Loir",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D29",
"name": "Finistère",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D2A",
"name": "Corse-du-Sud",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D2B",
"name": "Haute-Corse",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D30",
"name": "Gard",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D31",
"name": "Haute-Garonne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D32",
"name": "Gers",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D33",
"name": "Gironde",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D34",
"name": "Hérault",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D35",
"name": "Ille-et-Vilaine",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D36",
"name": "Indre",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D37",
"name": "Indre-et-Loire",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D38",
"name": "Isère",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D39",
"name": "Jura",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D40",
"name": "Landes",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D41",
"name": "Loir-et-Cher",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D42",
"name": "Loire",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D43",
"name": "Haute-Loire",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D44",
"name": "Loire-Atlantique",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D45",
"name": "Loiret",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D46",
"name": "Lot",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D47",
"name": "Lot-et-Garonne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D48",
"name": "Lozère",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D49",
"name": "Maine-et-Loire",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D50",
"name": "Manche",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D51",
"name": "Marne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D52",
"name": "Haute-Marne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D53",
"name": "Mayenne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D54",
"name": "Meurthe-et-Moselle",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D55",
"name": "Meuse",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D56",
"name": "Morbihan",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D57",
"name": "Moselle",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D58",
"name": "Nièvre",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D59",
"name": "Nord",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D60",
"name": "Oise",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D61",
"name": "Orne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D62",
"name": "Pas-de-Calais",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D63",
"name": "Puy-de-Dôme",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D64",
"name": "Pyrénées-Atlantiques",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D65",
"name": "Hautes-Pyrénées",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D66",
"name": "Pyrénées-Orientales",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D67",
"name": "Bas-Rhin",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D68",
"name": "Haut-Rhin",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D69",
"name": "Rhône",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D70",
"name": "Haute-Saône",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D71",
"name": "Saône-et-Loire",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D72",
"name": "Sarthe",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D73",
"name": "Savoie",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D74",
"name": "Haute-Savoie",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D75",
"name": "Paris",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D76",
"name": "Seine-Maritime",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D77",
"name": "Seine-et-Marne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D78",
"name": "Yvelines",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D79",
"name": "Deux-Sèvres",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D80",
"name": "Somme",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D81",
"name": "Tarn",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D82",
"name": "Tarn-et-Garonne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D83",
"name": "Var",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D84",
"name": "Vaucluse",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D85",
"name": "Vendée",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D86",
"name": "Vienne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D87",
"name": "Haute-Vienne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D88",
"name": "Vosges",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D89",
"name": "Yonne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D90",
"name": "Territoire de Belfort",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D91",
"name": "Essonne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D92",
"name": "Hauts-de-Seine",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D93",
"name": "Seine-Saint-Denis",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D94",
"name": "Val-de-Marne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D95",
"name": "Val-d'Oise",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
}
]
}

Some files were not shown because too many files have changed in this diff Show More