Compare commits

...
268 Commits
Author SHA1 Message Date
rouggy 0af6cdedcd chore: release v0.27.14 2026-09-06 14:58:42 +02:00
rouggy ab1b45da07 feat(ft map): click a station to take it, double-click to answer it
The map draws a dot per decoded station and they did nothing. They now
answer the same two gestures as the decodes list, because the two are
views of the same list and a click has to mean the same thing on both:
one takes the callsign into the entry (and points the PSK Reporter panel
at it), two answer it.

The handlers were written inline on the list, so the map could not offer
them without a second copy — they are named once now and given to both.
Leaflet leaves the telling apart of click and dblclick to the handler, an
invisible marker three times the dot's radius takes the clicks (a
three-pixel dot is a fine mark and a poor target), and the double click
is stopped rather than merely unpropagated: the map zooms on one, and
answering a station is not a request to zoom in on it.
2026-09-06 14:47:22 +02:00
rouggy ed66e9394b fix(autocall): rest in overs, no phantom miss, and a readable auto readout
The rest between two series was two minutes — four overs on FT8, by
which time the DX has worked four other callers and half the time has
gone. It is now counted in the station's OWN overs and is one by default:
seven calls, one over listened through, and it goes again if the station
is still there. The deadline lands mid-cycle on purpose, so "sit out one
over" means one over rather than depending on a millisecond of clock
skew.

A period the target was DECODED in is never counted as a miss. A period
is judged more than once — decodes arrive in a burst and stragglers
follow — and a later judgement holds a partial view of it, not evidence
of absence: D2ACE answered in the very period the counter then read as a
miss.

The readout moved out of the Auto button and beside it. "Auto D2ACE 1/7
·1/3" read as one number, and the control changed width every period.
The callsign is now the biggest thing on the row, the calls and the
missed periods each carry a label, the miss count appears only once there
is one, and a station being waited for gets its own amber chip.
2026-09-06 14:36:32 +02:00
rouggy f56630d7d6 feat(maps): remembered views for the FT and grid maps, and no wasted first paint
The world map was the only one that remembered anything. Panning and
zooming a map is the operator saying which part of the world they work,
and on the FT decodes map and the grid-square map that was thrown away on
every tab switch. Both now keep centre and zoom, through one shared
helper (lib/mapView) that the world map uses too, and the keys are
portable so a copied data folder brings the views with it.

The world map also waits for the station's square before it paints. It
drew the world at 0 degrees and then moved to the operator's longitude —
a screenful of Esri tiles fetched and discarded on every first run. It is
now built once, knowing where it is looking; a profile with no locator
gets the default view after two seconds rather than a blank panel.
2026-09-06 14:23:43 +02:00
rouggy 50e97a8e0d feat(map): the world map opens on the operator's own longitude
Centred on 0 degrees it left an Australian looking at their own country
in the bottom-right corner, with the paths to everywhere they work
running off both edges. Centred on their own longitude the same map reads
the way their antenna does: the Americas to the east, Europe and Africa
to the west. Leaflet repeats the world horizontally, so this is a choice
of viewpoint and costs nothing — the tiles either side are the same
world, and unwrapLon already draws the arcs across the seam.

The latitude is damped rather than followed (a station at 69 north
centred on itself would spend half the map on the Arctic), the station's
square arrives after the map is built so it re-centres once when it
lands, and a view the operator has panned to themselves still wins.
2026-09-06 14:14:36 +02:00
rouggy e0bba21ecc fix(rst): a change of mode changes the notation
An operator with a callsign in the field, switching FT8 to SSB, kept
"+00" as the report. The edited flag protects a report the operator
chose — 57 rather than 59 — and it was holding across a change of mode as
well, where it protects nothing: a decibel figure is not a weak SSB
report, it is not a report at all. Worse, anything that fills the field
from the rig sets that flag too (the S-meter readouts in the rig
consoles), so the field could be stuck in the wrong notation for the rest
of the QSO.

The flag now holds only while the report still belongs to the mode's
family. When it does not, the operator's own judgement is carried across
where it can be — 57 becomes 579, 599 becomes 59 — and the mode's preset
answers where it cannot.
2026-09-06 14:06:04 +02:00
rouggy 28f784fe88 fix(rst wheel): the S9+ ladder, not the S digit
Below 59+20 comes 59+15, then +10, +5, then a plain 59 and down through
58, 57. Stepping the S digit and leaving the suffix where it was turned
59+20 into 58+20, which nobody has ever said on the air. Five decibels at
a time above S9 — the same increment sMeterRST rounds an S-meter reading
to — and one S-unit below it.
2026-09-06 14:01:58 +02:00
rouggy b421da165a feat: wheel over RST; PSK Reporter keeps the band's window
The mouse wheel steps the RST fields — one S-unit on an RST or RS, one
decibel on a digital report, up for a better one — in the entry strip and
in the QSO editor. The dropdown beside them lists the reports worth
having to hand, not all 41 decibels, so the wheel works on the value
rather than walking the list. The listener is native and non-passive:
React attaches onWheel passively, where preventDefault does nothing and
the panel scrolls away under the field being adjusted.

PSK Reporter, whole-band scope: clicking a decode reset the report count
to zero. The window there is the BAND's — every FTx report on it,
filtered by target only when the analysis is drawn — and it was emptied
on every target change, throwing away an hour of evidence at the moment
the operator asked the question it answers. The narrow scope still clears
it: there the window is one station's, and keeping it would answer the
new question with the old station's evidence.
2026-09-06 13:58:25 +02:00
rouggy 8b59954ce0 fix: a watched callsign is never parked; refuse uploads to unconfigured services
ZD8GB — watch-listed, six streams a period, two of them RR73 — was
refused as "parked" for the rest of the session. Parking answers "it
will not answer, stop wasting the evening on it", which is a fair verdict
about a station the LOG picked out and the wrong one about a station the
OPERATOR named: a DXpedition running a pileup takes more than two series
of calls to get through to, which is exactly why it is on the list. The
rest between series still applies, so it cannot monopolise the
transmitter — it simply never becomes ineligible.

Send to (right-click) now refuses a service with no credentials and says
which are missing. The upload runs on its own goroutine and reports into
the QSL Manager's console, which is not open when the command came from
the QSO list, so an upload to an unconfigured service looked exactly like
one that worked. The toast is also raised only once the backend has
accepted the request, and Cloudlog / Wavelog and HamQTH name themselves
in it.
2026-09-06 13:45:35 +02:00
rouggy 70ada49776 fix: CI-V address 00, simplex uploaded as split, and S/F spots
Three field reports.

Icom CI-V address 00 could not be kept: zero was read as "not
configured" in all three places that validate it, so every save put the
rig back to the IC-7610's 0x98 — and the model dropdown followed, since
it is derived from the address rather than stored. Picking "Other
(custom address)" also had no effect of its own: the list re-derived
itself and snapped back to whatever rig matched. It now stays chosen.

Cloudlog/Wavelog showed "17m/17m" on ordinary FT8 contacts (OE6CLD).
Every QSO is stamped with a receive side equal to the transmit side, and
the uploaded record carried it; Wavelog draws band/band_rx whenever both
are there. In ADIF an absent BAND_RX means "same as transmit", so the
uploaded record now writes the receive side only when it differs. The
copy forwarded to another logger over UDP keeps writing it in full —
that is why it was stamped in the first place (Log4OM reads BAND_RX) —
through its own ForwardRecordADIF.

"S/F" in a spot comment joins superfox / sfox / F-H as FT8.
2026-09-06 13:12:00 +02:00
rouggy e20f32c918 chore(changelog): the late-decode fix opens 0.27.14
It went in after the 0.27.13 release commit, so the shipped build does not
contain it — and the entry had been folded into that block's fixes line,
where it claimed a fix nobody had. The 0.27.13 block is back to what was
released.
2026-09-06 06:56:01 +02:00
rouggy 71adbfd8ff fix(autocall): a late decode belongs to its own period
A decoder sends a period's decodes in a burst, and stragglers follow — a
deep decode a second behind the rest. The sweeper judged the burst and
CLEARED the buffer, so the straggler opened a fresh one under the same
period key and was judged on its own: the ladder applied to a handful of
late arrivals with the other thirty stations of that period nowhere in
sight, and often after the reply to the burst had already put us on the
air, where nothing can act on it at all.

The buffer now outlives the judgement. A period stays open until a decode
stamped with the NEXT slot arrives; a straggler appends to it and the
period is judged again, whole. Judged once per period otherwise — acDirty
says whether anything new has come in — and the same flag now answers the
dead-band case that the cleared buffer used to stand for.
2026-09-06 00:31:16 +02:00
rouggy cccdb354f8 chore: release v0.27.13 2026-09-06 00:11:20 +02:00
rouggy 4321eae647 fix(watchlist widget): one line per spot
Callsign, band, mode, badge, age in reading order, and the widget widened
to 290 px so the badge and the age never squeeze the callsign. Two lines
made the panel twice as tall for the same three facts; the entry that
matched — which only differs when it is a prefix — moved into the row's
tooltip.
2026-09-06 00:10:21 +02:00
rouggy 03e71bfdf2 feat: docked watch-list panel, and auto-call learns the orthogonal markers
The watch list was a tab, and an operator working FT8 lives on the decodes
one: a station they had asked to be told about turned up on a screen they
were not looking at. The same answer is now docked in the widget strip,
above the tabs, reduced to what is worth acting on — on the air and still
needed, one row per band and mode, with the cluster's own NEW DXCC /
NEW BAND / NEW SLOT badge and a click that tunes. Off by default. The
"active and needed" answer costs a debounced query per visible slot, so it
is written once (lib/watchlistSpots) and the tab uses it too.

Auto-call:

- It answers a new prefix, county, state, square or park. Those markers
  are orthogonal to the entity, they ranked as nothing-needed, and the
  engine sat through a never-worked WPX prefix calling CQ. New rung at
  the foot of the ladder, gated by the chase switches the badges use —
  which meant making those switches portable, since the backend cannot
  read localStorage.
- It calls THROUGH a pileup. Giving up the moment the DX answered
  somebody else is precisely how a queue is not worked; the call and miss
  counters already bound the effort, and a station in mid-exchange is
  still never chosen as a new target.

The PSK Reporter panel now follows the station auto-call is waiting for:
the analysis takes a history query and a period or two to fill, so
starting it when the DX comes free is starting it too late.

Callbook lookup: a compound callsign with a page of its OWN keeps that
page's location. QRZ files HP/WE9G under exactly that form, with the
Panama square the station is operating from, and the rule that drops a
home address from a portable call was throwing it away. The record's own
country tells an operation's page from a home page.

Changelog: entries may open with [NEW], drawn as a pill in the What's new
dialog — a release is mostly fixes and the two or three genuinely new
things should not have to be found by reading all of it.
2026-09-06 00:08:55 +02:00
rouggy 23323c91e0 fix(autocall): the QSO in progress outranks the ladder
Six faults from an evening on 60 m, all in the same family: the engine
judging a station by what the log wants from it and forgetting what is
already under way.

- An exchange was abandoned mid-QSO. The reply lands in the same period
  the ladder is re-read, and that period was judged before the reply was
  taken into account, so a better-ranked caller took the slot from a
  station that had just come back to us. The answer is settled first now,
  and our own report counts as being inside the exchange too — which also
  protects a QSO the operator started by hand.
- A station just picked started with misses against it. Its transmit slot
  was unknown until a second decode, and with the parity unknown every
  period counted, including the one spent transmitting to it.
- The freed slot after "it is working somebody else" was thrown away: the
  period's decodes are in hand, so the next station is picked from them
  rather than fifteen seconds later. Never mid-over.
- Auto-call is never armed from a stored setting — not at launch, not on
  a profile switch. It is the one feature that puts the station on the
  air by itself and OpsLog starts with Windows.
- It says what it is waiting for: a wanted station in a QSO with somebody
  else now shows beside the Auto button instead of looking idle.
- Switching profile left the previous logbook's verdicts on screen. The
  worked-index, chase-new and the frontend's cached verdicts are dropped
  when the logbook changes.

FT decodes: distance column, a message addressed to you set whole in
green (the station you are calling keeps a tint — most of what it sends
goes to other people), badge order L / Wkd / WL, list cleared when the
RIG changes band.

Rotor: new world-map compass from EC1KD's design, with the Ultrabeam boom
and second lobe restored and the compact form preserved; the classic dial
is kept and Settings → Rotator chooses between them. Stop no longer
flickers on a rotor standing still — movement was inferred from a degree,
less than the jitter a controller reports at rest.
2026-09-05 23:02:31 +02:00
rouggy 8dbc4b7e62 feat(ui): the FT decodes panel, two DXHunter themes, and the maps
Decodes:
- one click SELECTS, two transmit. A single click handed the decode
  straight to WSJT-X as a reply, so brushing a row while reading the
  band started calling a station.
- the list empties for a receiver that changes band, and a receiver
  column appears when more than one is feeding one merged list.
- the period clock turns red while transmitting: it is the one thing on
  the screen that moves, so it is where the eye already is.
- a WL badge, after the LoTW "L" — one letter, always in the same
  place, so the column does not shift from row to row.
- the auto-call switch, its target and its count, and the chase list:
  naming the station you are waiting for is done while watching the
  band, not in a settings tree.

Themes: DXHunter's slate with its own blue, and the same slate with
OpsLog's orange. Counted across its sources rather than guessed from
one panel — blue is 132 uses to violet's 25, and the violet is the PSK
Reporter panel alone.

Watchlist: drawn as DXHunter draws it — the callsign in the interface
font rather than monospaced, which is the difference that shows with
the two windows side by side.

FT Map: arcs no longer run off the side of the map. The map shows one
world, and a path crossing the antimeridian was drawn past 180° into
the blank space beside it — from VK that is most of them.

Cluster: "superfox", "fox/hound" and "F/H" in a comment are read as
FT8. They are WSJT-X's DXpedition transmit modes, and the comment fell
through to the band plan and came out DATA — which then decided the
band+mode verdict.

A decoder is named by what it IS: Nexus sends its packets as "Tempo",
the engine inside it, and OpsLog showed a program nobody has heard of.
2026-09-05 21:46:06 +02:00
rouggy e4a5d42b85 feat(app): auto-call, PSK Reporter analysis and the decode band change
The Wails boundary for the two features above, plus the pieces that
belong to neither:

- decodes are cut into periods PER RECEIVER, and judged 0.8 s after the
  last one arrives rather than a slot and four seconds after the period
  they belong to. That stamp is the START of the slot, thirteen seconds
  before its decodes exist, so every answer left four seconds into the
  next slot and the decoder began its call late.

- WSJT-X colouring can grey out a station already worked on this band
  AND in this mode. Its own switch, off by default: the other three
  verdicts pick out a handful of decodes, this one can match most of a
  period on a full log.

- the watch list's contest auto-add takes a list of callsigns as well
  as a pattern. A pattern collects a fleet that shares a string; it
  cannot collect the station taking part under a callsign that says
  nothing about the event.
2026-09-05 21:45:51 +02:00
rouggy 470eaf5d80 fix(pskr): a ten-minute window, both directions, and always an answer
Measured against DXHunter on the same station at the same second: 18
decodes here against 27 there.

- The window was five minutes. PSK Reporter's uploaders batch their
  reports, most of them every five, so a five-minute window catches
  about one upload cycle per station. Ten, as DXHunter has always had
  behind a label that says four.

- The history query ran once per target and only on the narrow feed.
  It now runs in both scopes and again every five minutes while a
  station is watched, which is the cadence the uploaders keep.

- It asked only what the target RECEIVED. Both directions now, so
  "who is hearing him" starts full too.

- The suggested call offset looked for a run of empty slots and said
  nothing when there was none — exactly the case it exists for: a
  hundred decodes across a 2800 Hz passband leave no gap. Failing a
  gap it names the quietest slot, ties to the higher offset, never
  above the ceiling.

Chase new: the receiver squares now follow the radius that was asked
for (it was one fixed ring whatever the setting said, so raising it
bought nothing), and the panel says what the feed is doing rather than
leaving an empty list to speak for itself. A change of hunt empties it:
its rows are verdicts reached under the old rule and nothing re-judged
them. Its own band selection, because what a station CAN work and what
is worth watching tonight are different questions.
2026-09-05 21:45:39 +02:00
rouggy a41626955e fix(autocall): the entity's verdict is not the station's
Six faults, all found on the air this evening and all in the same
feature. The decision trace added here is what found the first one:
one line per period, saying what was on the air and why each station
was refused.

- "worked" was read from the ENTITY's status, which means the country
  is in the log on this band and mode. On 10 m, where most countries
  are, twenty decodes out of twenty-one were refused as worked — the
  watched DXpedition among them. The entity decides what is NEEDED;
  the callsign's own slot decides whether calling it is a duplicate.
  candidateOf() is split out and tested because one line was wrong for
  weeks and nothing could catch it.

- A multi-answer line was read only up to its first message. MSHV
  answers two stations in one transmission ("YV5ALI RR73; F4BPO
  <HK0/PY8WW> -08") and the second half was a report to us: the engine
  saw a station working somebody else and dropped the target at the
  moment the DX was answering. The decodes panel already read every
  segment.

- The slot after a QSO belongs to our own 73. Handing straight on to
  the next station took it, switching the DX call mid-sequence, and
  the frame that closes the contact never went out whole.

- A station CALLING US is answered whether or not the log wants
  anything from it. It was refused for having nothing to gain, so a
  QSO would end, two stations would call, and both were ignored.

- Callability was tested when a station was CHOSEN and never again
  while it was held: one picked on its CQ that then answered another
  caller went on being called for the whole seven attempts.

- A watched callsign now outranks every station that is not on the
  list. Lifted one rung at a time it sat at the bottom with nothing
  needed from it, and was never reached on a busy band — the opposite
  of what putting it on the list means.

Halt is now a verdict: the station is set aside for the session rather
than released, and the chase list does not override it. The attempts
cap lets the over finish instead of cutting the call that counted it,
and a rest is a rest. Only what the decodes list is SHOWING can be
called — the panel publishes the callsigns it shows, so there is one
definition of "shown" and not two.
2026-09-05 21:45:25 +02:00
rouggy be889681a9 chore: release v0.27.12 2026-09-05 19:07:21 +02:00
rouggy f93e1c5898 chore: release v0.27.11 2026-09-04 19:53:57 +02:00
rouggy 2d67e3d57f fix(udp): ':' is Q65 to WSJT-X and FT4 to JTDX
Reported from a real shack: JTDX decoding FT4 came through as Q65. A
Decode carries a one-character marker rather than a mode name, and the
forks do not agree on that one — so the character alone cannot answer,
and its wrong answer reached everything behind it: new mode, new slot,
the mode filter, all computed against a mode nobody was using.

The sending program's own Status settles it. It comes from that program,
names the mode in full, and is re-sent whenever it changes, so it knows
what is being decoded in a way one character never can. Only the
ambiguous marker consults it: the ones both forks agree on keep
answering from the table, Status or no Status, and with no Status at all
':' still reads as Q65 — WSJT-X's meaning, the older and commoner.
2026-09-04 18:48:25 +02:00
rouggy 1a32e4a228 feat(chasenew): a row is a spot, and takes the same road
Clicking one tuned the rig and zoomed the panadapter, and stopped there:
no mode on the entry, no RST preset, no park or summit reference, and —
the one that matters — no word to the decoding application. So picking
an FT4 station from Chase new while WSJT-X sat in FT8 left it decoding
FT8, which defeats the only thing the window is for.

It goes through handleSpotClick now rather than repeating a chosen half
of it, so the two can never drift apart again. The mode is handed over
where a cluster line would carry it in its comment, which is where that
handler looks for it.
2026-09-04 14:04:49 +02:00
rouggy b318aa66cc fix(yaesu): the power ceiling is the radio's, not a constant
Reported on an FTDX101MP: the slider sprang back to 100 W. The console
was not wrong about the rig — yaesuMaxPower already answered 200 for
that model, and the slider was drawn to it. The SET path carried its own
hard-coded 100, so asking for 200 W sent PC100, the radio obeyed, and
the next poll read back what had actually been set.

One ceiling now, the one the console draws to, so the two cannot say
different things. Pinned with the model list, including the unknown-rig
case: crediting a radio with power it does not have would be commands it
NAKs, so silence still means 100.
2026-09-04 08:34:28 +02:00
rouggy 6cbe29fef1 fix(hamlog): stop offering an upload that cannot succeed
HAMLOG.online no longer issues API keys, and its upload API takes
nothing else. An operator without a key cannot obtain one, so the
auto-upload switch, the on-close sweep and the 'Send to' entry were all
arming something that could only fail — silently, once per QSO.

Closed at the source rather than hidden in the UI: the upload returns a
sentinel that says why, the manager stops routing to it and says so once
a session, and the manual path refuses with the same words. The settings
page states it plainly instead of showing a switch that does nothing.

Nothing else goes. Their confirmations arrive as an ADIF FILE and never
needed a key, so that import stays; the sent/received state already in
operators' logs stays readable, filterable and bulk-editable; and the
upload itself is kept whole as uploadHamlogLive, still covered by its
request-shape tests, against the day keys come back.
2026-09-03 22:28:32 +02:00
rouggy 96b5f2d91f feat(cloudlog): send a selection from the right-click menu
The one configured service the upload menu did not offer. Not because it
uploads one record at a time — QRZ, HAMLOG.online and HamQTH all do —
but because Cloudlog keeps no per-QSO sent status, on purpose: it
dedupes server-side, so re-sending is harmless and nothing has to be
remembered. The menu had been built around that status.

An explicit selection needs none of it. The operator picked the rows,
and the server refuses what it already has — which is exactly why the
missing column stops mattering the moment a human is choosing.
2026-09-03 22:24:10 +02:00
rouggy ee004c1c62 fix(lookup): a zone is not a property of the country
Reported with real callsigns: every Asiatic Russia contact logged CQ 17
/ ITU 30, whatever the operator's real zone. RU0LL and RA0FF are 19/34,
UA0SDX is 18/32, and QRZ.com had all three right.

Measured before touching anything: cty.dat answers 17/30 for RU0, RA0,
UA0 and UA9 alike — one representative pair for a country eight CQ zones
wide — while ClubLog's prefix table gives 19, 19, 18 and 17. The
reporter's instinct that no UA0 sits in CQ 17 was exactly right.

fillFromDXCC overrode the callbook's zones on purpose, and the reason
holds only for the country: QRZ returns the political nation where
cty.dat returns the DXCC entity. A zone answers a different question —
not what the callsign IS but where the station SITS — and there the
per-station page beats a country default. Zones now FILL rather than
override; the entity is untouched.

The cache made it worse by remembering our conclusion as though the page
had said it, so the wrong zones would have outlived this fix. A lookup
is now cached as the callbook returned it and the country file is
applied on read, which also lets a cty.dat update reach old rows.
2026-09-03 22:16:49 +02:00
rouggy b00552f617 fix(dxcc): a retired prefix is not a wrong one
Reported from a real import: every ZK2 contact came back New Zealand and
Niue vanished from a DXCC that had it confirmed. cty.dat is not wrong,
it is CURRENT — Niue moved to E6, so ZK2 reverted to New Zealand there.
ZK1 loses the Cook Islands the same way, and the reporter was right to
suspect more.

ClubLog's prefix table is date-ranged and still knows both, which is the
whole reason for enabling its country file. We consulted it only for
callsigns that already HAD a per-callsign exception — so a ZK2 with no
exception never reached it. It is now asked whenever no exception covers
the QSO's date.

Two limits keep the blast radius honest. It never overrules an exact
'=CALLSIGN' entry in cty.dat — that is somebody having looked at this
very callsign, and a prefix rule does not overrule it, which is why
Match now says how it matched. And where ClubLog has no answer (E6, TO5A
and their like are absent from its prefix table) cty.dat still decides,
because silence is not an answer. Measured before changing: on a sample
of thirty calls the two files agreed on twenty-eight, and both
disagreements were this bug. Opens 0.27.11.
2026-09-03 22:11:55 +02:00
rouggy 4dbc773343 chore: release v0.27.10 2026-09-03 19:40:48 +02:00
rouggy ec347e1b7a fix(rotator): stop rebooting the controller between commands
A K3NG controller answered PuTTY perfectly and told OpsLog 'no reply to
C'. The reason is not the protocol: the client opened and CLOSED the
serial port for every single command, and an Arduino-based controller
resets when its port is opened — DTR pulses the reset pin. OpsLog was
rebooting it several times a second, and every command it sent landed in
a bootloader.

The port is opened once and held, per COM port, at package level: the
callers build a fresh Client per poll, so the port has to outlive them,
and a serial port is a single-owner resource in any case. A newly opened
port is given two seconds to boot before the first command, bytes left
from a previous exchange are drained rather than read as this command's
answer, and a failed exchange drops the port so the next starts from a
clean open instead of repeating the same silence.

Reply parsing was already right for both flavours and now has the real
strings to prove it, that controller's '+0140' among them.
2026-09-03 19:30:59 +02:00
rouggy b0bbe3e402 style(layout): the whole row is the drag handle
A list whose rows can only be moved by a sixteen-pixel grip is a list
most people conclude cannot be moved at all. The grip stays as the sign
that it can, and the row itself now carries the gesture — plus a line on
the edge the row would take, because the question a dragging hand asks
is 'between which two', which a highlighted target does not answer.
2026-09-03 16:17:02 +02:00
rouggy 85061ab673 feat(layout): drag the widgets into the order you want
A list in Appearance, in the row's own order, dragged to rearrange —
with QSO entry and the F1-F5 panel at its head, locked. They are not in
that row at all, and letting an operator push the thing they type into
behind a rotator dial is not a preference, it is a trap.

Implemented with flexbox ORDER rather than by moving the JSX: in a
component this size, reordering the tree would have moved every
condition, ref and hook with it. Each slot keeps its place in the source
and receives an order property, so a widget switched off still holds its
rank and returns where the operator left it.

An unknown key from a later version joins the end rather than the front,
and a key that no longer exists is dropped — an old preference can
neither reorder a widget it has never heard of nor hide one. Opens
0.27.10.
2026-09-03 09:46:47 +02:00
rouggy ab68e4a84e chore: release v0.27.9 2026-09-02 20:03:08 +02:00
rouggy f6532b2e85 style(dvk): show the DATA-input option only where it does something
Only Kenwood implements it — TX1 is that family's second transmit
command — and every other backend keys the one way it knows. A checkbox
offered to all of them would change nothing on most, which is the same
dead furniture as ANT2 on a radio with one socket. Gated on the Kenwood
backend, and the label says so.
2026-09-02 19:51:38 +02:00
rouggy 8685dbd6cf feat(dvk): key the DATA input, not the microphone
From a TS-590SG report: the voice keyer played through the rig's USB
codec and the radio transmitted silence. Its manual says why — 'TX P1,
0: SEND (normal transmission using the MIC input), 1: DATA SEND
(ACC2/USB input)' — and OpsLog only ever sent the bare TX, so the radio
dutifully opened a front microphone nobody was speaking into.

An option on the audio page, shown for CAT keying, says where the
keyer's audio actually arrives; the manager routes it to a backend that
draws the distinction and falls back to the ordinary key for every rig
where one PTT is all there is. Test PTT goes down the same path, so it
tests what will happen rather than something adjacent.
2026-09-02 16:17:50 +02:00
rouggy b7c87def5b feat(decodes): say when the decoder's band is not the rig's
A decoding application that loses its CAT link keeps announcing the last
dial frequency it knew, and every decode after that carries a stale
band. Nothing downstream can tell: the entity verdicts, the band filter
and the FT map all believe it. Seen for real — MSHV kept saying 80 m,
and Korea read as a NEW BAND because on 80 m it would have been.

Said, not decided. Taking the rig's band instead would be wrong for
anyone decoding a second receiver on another band, and a warning costs
that setup nothing but a line to read past. Shown only while CAT is
actually connected: an empty band means there is nothing to compare
with, never that the rig is on no band. Opens 0.27.9.
2026-09-02 10:25:17 +02:00
rouggy ae8e5b1bc6 chore: release v0.27.8 2026-09-02 00:16:42 +02:00
rouggy ad69371f6a fix(icom): sidebands by name, model-aware controls, address 0x00
From an IC-7300 report, four faults and one addition.

USB and LSB could not be commanded at all: modeCode knew 'SSB' — which
resolves the sideband from the band — and answered 'unsupported mode' to
the sideband names themselves. So an operator wanting USB on 40 m had no
way to say it, from the console or from anywhere else. They are separate
buttons now, and a rig reporting the folded ADIF 'SSB' still lights the
side its frequency implies.

The console offered controls the radio does not have: ANT1/ANT2 on a rig
with one socket, and a PSK button every non-7610-class Icom NAKs. Both
now follow the model, as the band buttons and attenuator steps already
did. Mic gain stops being phone-only — on USB-D it still sets what the
radio transmits at, so an operator who lives in FT8 had none.

CI-V address 0x00 was refused by a 'n > 0' test and silently replaced by
the IC-7610 default; an EMPTY setting is what means unconfigured, so the
parse error decides now, not the value. Plus the 60 m band button that
was missing.
2026-09-02 00:00:21 +02:00
rouggy 3f97084246 style(matrix): pin the label column so RTTY cannot shift it
The width was set for three characters, so the column grew when the
rotation came round to RTTY and every band beneath it moved — which the
eye reads as the matrix sliding rather than the row changing. Sized once
for the longest label it can show and pinned there, header spacer
included; past four characters (PSK31, MSK144) the type gives way
instead of the column.
2026-09-01 23:50:14 +02:00
rouggy 74dfc3a725 feat(matrix): the DIG row cycles through your own digital modes
One row per digital mode would be the honest layout, and there is no
height for it: the matrix sits in a fixed panel beside a dozen widgets.
So the row keeps its place and changes what it answers — DIG, then each
digital mode the operator's own list holds, in the order they put them
in, then back to DIG.

It costs no round trip. The query behind the matrix already grouped by
band AND mode; only the collapse to a class threw that away, so the same
cell is now published under the raw mode name too. Digital only: PH and
CW have nothing to cycle through.

On a specific mode the you-are-here mark follows THAT mode, or every FT4
entry would light whichever digital row the rotation happened to rest
on. A four-letter mode drops to 9px rather than widen a column sized for
three characters and push the whole matrix sideways. Opens 0.27.8.
2026-09-01 23:47:03 +02:00
rouggy 6442325926 chore: release v0.27.7 2026-09-01 23:40:08 +02:00
rouggy 4c4b3b6c2d feat(flex): send SmartSDR's spot priority; fix(chase): the same station on the same slot has nothing left to give
The panadapter has finite room: spots close in frequency are stacked
behind a '+' and only one is drawn, chosen by PRIORITY — a parameter
'spot add' accepts and OpsLog never sent. So a new entity sat invisible
behind three stations already in the log. DXHunter has sent it for
years; the tiers here are the same idea in the operator's own words:
the entity never worked (with my own callsign, for a multi-op), then
band/mode/slot, then the reference hunts, then everything else.

And a callsign already worked on this exact band and mode stops
advertising a need. Working it again cannot turn a missing QSL into a
confirmation — the QSO is already there — so shouting NEW DXCC over a
station worked an hour ago only teaches an operator to distrust the
colour. The need is real and stays on every OTHER station of the entity,
which is where it can be answered. Applied on both paths out of the
verdict, including the early one that leaves the loop first.
2026-09-01 23:33:56 +02:00
rouggy 4d8cb58550 fix(ftmap): tell Leaflet when its box changes size; fix(decodes): all seven continents
The FT map drew a strip of dead space along the bottom after a zoom.
Leaflet measures its container ONCE, at creation — which for this panel
is the instant the tab is selected, before the flex layout has settled —
and then asks for tiles to fit that stale size for ever. A ResizeObserver
hands it the real size, plus one pass after the first paint for the
layout that settles without ever firing a resize.

And the decode list's continent chips are the seven, fixed. Building
them from the feed meant a row that reshuffled itself every period — a
chip appeared when the first Asian station decoded and moved everything
sideways under the pointer — and it could not say what the filter was
capable of until something had been heard.
2026-09-01 23:19:18 +02:00
rouggy a6172c4323 feat(dxhunter): the chase gets a page of its own, and US states a switch
The chase switches were born on the DX Cluster page because the cluster
was the only thing that drew their badges. They now decide what the FT
decode list and Chase new say as well, and settings that govern three
screens should not live under the name of one of them. They move to a
DXHunter page — named for what it is about, and for what is meant to
land beside it as more of DXHunter's ideas are ported over.

US states join the switchable markers, gated the same way as POTA,
counties, prefixes and squares: badge, filter chip and display marker,
all from the one chaseAllows question.
2026-09-01 16:26:31 +02:00
rouggy b70a679c64 feat(chase): the switches follow the operator, not the panel
Chase POTA, US counties, prefixes and grids were written for the cluster
and stayed there, so an operator who does not chase parks still met NEW
POTA in the FT decode list and in Chase new: the same badge withdrawn on
one screen and shouting on the next. The setting is about what is hunted.

One helper answers the question for every panel, keyed by both the status
field names and the shorter category names so a single call serves badges
and filter chips alike. Chase new also stops CATEGORISING a row by a
marker that is switched off, which had left rows listed with no visible
reason for being there. A new US state has no switch of its own, so it is
always allowed.
2026-09-01 16:20:28 +02:00
rouggy f39bda110a fix(chase): the unconfirmed flag survives the next QSO
Reported as 'South Africa shows NEW BAND although I have worked 15m'.
The verdict was right — his hunt counts only LoTW and paper QSL, and no
15m contact is confirmed by either — but the badge drew SOLID, saying
'never worked here' about a band with four contacts in the log.

Proved with his own data: the confirmed ledger holds 10/12/17/20/30/40/80
and not 15, the all-QSO ledger holds 15m FT8 four times, and the backend
sets UnconfStatus correctly for exactly that case (new test, end to end
through ClusterSpotStatuses).

The flag died in the frontend. FOUR hand-written copies of the same
backend-result mapping, and they had drifted: the two that RE-fetch —
after a QSO is logged, and when a pane becomes visible — rebuilt each
entry without the unconf_* flags, so the first refresh after any contact
turned every dimmed badge solid for good. None of the four ever copied
grid_state either, so a known-but-unconfirmed square never dimmed at all.
One mapper now, used by all four.
2026-09-01 11:56:20 +02:00
rouggy ab8ecd65fe test(adif): assert FREQ_RX reaches the forwarded record too
The fix filled both halves of the receive side, but the test only proved
BAND_RX came out the other end. FREQ_RX is the half the question was
really about.
2026-08-31 22:30:32 +02:00
rouggy de0771d797 feat(cat): USB for digital modes, on every backend
A soundcard mode is USB with audio in the mic path, and that is what
most rigs need. Asking for the mode by NAME is the better answer on a
modern transceiver with a DATA position and the wrong one on an older
set, where the CAT layer resolves 'digital' to RTTY/FSK — OmniRig does
exactly that, per rig file, and there is no arguing with it from here.
The operator clicking an FT8 spot landed in FSK, which keys from a
mark/space generator and cannot pass FT8 at all.

Applied in SetCATMode, the one funnel every backend and every caller
goes through, so it holds for a spot click, the band map and the mode
selector alike. The QSO is still logged as FT8: this is what the radio
is put in, not what the contact was.
2026-08-31 22:26:12 +02:00
rouggy 22e4266d38 fix(editor): a corrected frequency corrects its band
Band and frequency have to agree — the log, every award and every upload
are read on the BAND — and an operator fixing a wrong frequency is not
also expecting to fix the band by hand. That is exactly how a QSO ends
up filed on 20m at 7 MHz. Both sides follow, TX and RX.

Only when the number lands in a known allocation, because half a
frequency is typed on the way to all of it and a band must never be
blanked by that. bandForMHz moves out of App.tsx into lib/bandplan,
where the two callers can share one answer instead of drifting.
2026-08-31 22:21:22 +02:00
rouggy e3332d1e27 fix(hamqth): the whole-log upload reports itself
It ran into a panel nobody was showing: the button never set showLog, so
the tab sat on 'Pick a service' while a 25000-QSO upload came and went.
Now it opens the console like every other action here, and the console
has something to say: the callsign it is scoped to and how many of the
logbook's QSOs that leaves (a two-callsign database sends one of them,
which is the whole explanation for a count that looks short), the
exported record count and file size, and HamQTH's own reply.

Plus the two facts that make the site's own number readable: HamQTH
accepts the file and imports it in the BACKGROUND, and it reports what
it made of each record by e-mail — never in the reply we get. An
operator comparing counts a minute later is not looking at a failure.
2026-08-31 22:11:51 +02:00
rouggy 5cebf163c5 feat(hamqth): the sent stamp reaches the columns, the filter and bulk edit
HAMLOG.online was wired into all three when it arrived; HamQTH shipped
with only its uploader and its editor row, so the log could not be asked
'which contacts have never gone there' — the question that precedes
every backlog upload — and a log uploaded to HamQTH by hand could not be
marked, leaving OpsLog offering to send every one of those contacts
again. Sent side only, both whitelists, both panels, two hidden-by-
default columns.
2026-08-31 22:04:42 +02:00
rouggy cfd85ff9c3 fix(adif): a logged QSO carries its receive side
The importer has always read an absent BAND_RX/FREQ_RX as 'not split, so
RX = TX'. The logging paths never did, so what a contact carried
depended on which door it came in through — and it shows outside
OpsLog: the record forwarded over UDP is written from the QSO as logged,
so a receiver that reads BAND_RX (Log4OM does) found nothing there.

Filled at AddQSO, the one funnel every path goes through, so the
database, the export and the forwarded copy agree. A genuine split
contact keeps what it was given — table-tested both ways.
2026-08-31 22:02:24 +02:00
rouggy c6f479750f chore(changelog): open 0.27.7 — two changes landed after the .6 tag
v0.27.6 was tagged at 88f35e2, and the disconnected-pill fix and the
HamQTH whole-log upload came after it: their entries were promising a
released build features it does not carry. They open 0.27.7, and the
.6 pill entry goes back to describing what actually shipped.
2026-08-31 21:57:41 +02:00
rouggy 386a8ad531 feat(hamqth): upload the whole log in one file
The per-QSO API is the only correct way to send a SELECTION, and at the
pace it must be driven a 14k backlog costs the better part of an hour.
HamQTH's other endpoint takes a whole log as one file — and REPLACES
what is on the site with it: its documentation says plainly that partial
uploads do not exist. So it is offered as its own deliberate act behind
a confirmation, never as the batch path behind 'send these', where it
would delete every QSO the operator had not selected.

Scoped to the callsign this profile uploads as, so a database holding
two operators' contacts cannot push one into the other's log; tar.gz
above 12 MB because the ceiling is 20 and a six-figure log passes it as
text; and every QSO not already stamped is marked sent afterwards, in
bulk, so the backlog list agrees with reality.
2026-08-31 20:07:46 +02:00
rouggy bcd7e409ba fix(cluster): a disconnected server keeps its pill
The pills listed live SESSIONS, so disconnecting one made it vanish —
along with the only way back. They now list every enabled server, and a
server with no session shows as disconnected: the state you click to
undo. The address, known only while a session exists, drops out of the
tooltip rather than reading ':0'.
2026-08-31 19:05:56 +02:00
rouggy 88f35e2c20 chore: release v0.27.6 2026-08-31 19:03:58 +02:00
rouggy d2194da28e feat(cluster): the server pills become the per-server switch
The colour already said connected; the word beside it said it again, in
the widest part of a row that has to hold five servers. It goes, and the
pills earn the job nothing else in the app had: reaching ONE cluster
without opening Settings. Click connects or drops that session — a
connecting one included, so a server retrying into a dead host can be
stopped. State, retry count, address and last error move into the
tooltip, where they cost no width.
2026-08-31 19:03:32 +02:00
rouggy 2f1d592497 revert(dxped): no chase badge on the news pane
Tried it, looked at it, took it out. A headline names no band, so the
only honest verdict is entity-level — and a pane where every row reads
NEW DXCC or worked says nothing an operator can act on. Two words
repeated forty times is noise wearing the clothes of information.

The announcements pane keeps its badge, where the announced bands and
modes make the verdict specific enough to be worth a colour. The news
pane keeps what it is good at: what is happening, and a Watch button.
Changelog entry withdrawn with it — it never shipped.
2026-08-31 19:00:44 +02:00
rouggy 2b6f1ba9d7 feat(bandmap,dxped): drop the ctrl+wheel zoom, badge the news
ctrl+wheel is the WINDOW zoom everywhere else in OpsLog, and one gesture
that resizes the whole app over one panel and one band map over another
is a gesture nobody can trust. The + and - buttons keep the zoom, and
the footer hint stops advertising what is gone.

The DX-World headlines now carry a chase badge like the announcements —
but ENTITY-LEVEL only. A headline names no band, and asking the slot
question without one answers 'new band' for every entity ever worked:
the one mistake that costs a QSO. So the news says NEW DXCC or worked,
and the sharper verdicts stay where the bands and modes are known.
2026-08-31 18:57:44 +02:00
rouggy 4a017f6290 feat(bandmap): fold the legend away
A legend is a reference, not a running display: once its colours are
learnt it spends four lines of a short screen saying nothing new. A
chevron in the footer folds it, the same chevron brings it back, and the
choice survives a restart.
2026-08-31 18:52:09 +02:00
rouggy 7bab30aa71 style(qsl): paper and LoTW first, then alphabetical
The channel list had grown in the order channels were added, so finding
one meant remembering when it arrived. Paper QSL and LoTW lead — they
are the two that carry an ARRL award — and the rest sort by name. One
ordering function feeds both the picker and the status table, so the two
cannot drift apart; the three extras-backed channels (OpsLog card,
HAMLOG.online, HamQTH) now sit in that same list instead of being
appended by hand after it.
2026-08-31 18:48:40 +02:00
rouggy 77b95289e7 feat(qsl): HamQTH in the QSO editor's confirmation panel
The channel picker and the status table both list it now, sent only —
HamQTH publishes no confirmation feed, so its received column shows a
dash rather than an N that would read as 'not confirmed yet'. Backed by
the same APP_OPSLOG_HAMQTH_SENT extras the uploader stamps.
2026-08-31 18:44:35 +02:00
rouggy 5b894b9dbc style(dxped): one Watch button for both feeds
The news pane had clickable callsign chips while an announcement had a
Watch button — two gestures for one act. The chips become plain labels
(they still show which calls are already watched) and the news card gets
the announcement's own Watch button and Open link.
2026-08-31 18:42:39 +02:00
rouggy 187ac9aa84 feat(dxped): the announced DX, judged against your own log
A DXpeditions tab holding the two feeds the DX world announces itself
on: NG3K's ADXO (structured — dates, entity, calls, bands, modes, QSL
route) and DX-World's headlines. What a logger can say that a news
reader cannot is whether the operation is worth chasing, so every
announcement is put through the SAME verdict the cluster paints on a
spot — one badge, strongest wins, dimmed when the need is only a missing
QSL — with an 'only what I need' filter. One click watches every
callsign of an operation; the news headlines are mined for callsigns so
they can be watched the same way.

Parsers ported from DXHunter's and pinned with table tests against real
feed text: the 'as PJ2/W2APF' mining, the DXCC-prefix-first
normalisation without which no spot ever matches, both ADXO date forms,
and the '160-6m' span whose unit is written once (DXHunter read that as
6m alone and lost the low end).

Watchlist membership now announces itself app-wide, on the same card as
a new version: the bindings emit watchlist:changed, so a call added from
the cluster or this tab is confirmed where the operator is actually
looking — the panel's own inline message never was.
2026-08-31 17:51:45 +02:00
rouggy 629bd8d84f feat(confirmations): a HamQTH default, and the pending status stops meaning 'sent'
HamQTH joins the Confirmations page with a sent side only — the site
publishes no confirmation feed, so there is nothing to receive — and it
defaults to R, the same 'still to upload' the other online services get.

That default could not have worked as written. HamQTH and HAMLOG.online
keep their sent state in an ADIF extra, and eligibility blocked on the
key being PRESENT — so an operator setting the natural R default would
have stamped every new QSO 'already gone' and silently disabled the very
auto-upload the default arms. Eligibility now reads the VALUE: the ADIF
pending statuses mean pending, anything else means sent. Guard-tested
both ways.
2026-08-31 16:48:11 +02:00
rouggy 7152d11007 feat(cluster): county and prefix get their chase switches, and the grid one tells the whole truth
Chase POTA already withdrew its badge, filter chip and column when
unchecked; US counties and new prefixes now have the same switch, and
unchecking Chase new grids finally withdraws the NEW GRID badge too (the
backend stopped learning squares but the display kept shouting the ones
it knew — the setting is mirrored to localStorage so the display layer
can gate synchronously). All withdrawal happens at the display layer:
the facts keep being computed, so re-ticking a box needs no rescan.
2026-08-31 16:41:13 +02:00
rouggy a03d907128 fix(lotw): MY_CNTY must not sink a non-US station's upload
TQSL validates MY_CNTY against the ADIF secondary-subdivision list,
which is the US county enumeration — 'XX,County' with a two-letter
state. A Canadian profile produced 'ONTARIO,Kawartha' (the export joined
MY_STATE onto the county wholesale) and TQSL refused the whole record.

Two layers: adifCounty only prefixes a two-letter state, so exports stop
manufacturing the invalid shape; and UploadLoTW scrubs any MY_CNTY that
is not the US shape before signing — MY_STATE and MY_GRIDSQUARE already
locate the station for LoTW, and the US form survives for the county
hunters. Table-tested.
2026-08-31 15:56:34 +02:00
rouggy 91569e12f4 feat(hamqth): QSO upload to the HamQTH logbook — the 8th external service
qso_realtime.php with the account's callbook credentials (which the
external-services config falls back to when its own are blank), one ADIF
record per QSO, prg=OpsLog. HTTP status IS the answer: 200 saved, 400
rejected (a duplicate counts as delivered, like HRDLog's insert 0), 403
credentials. Sent-state lives in APP_OPSLOG_HAMQTH_SENT extras like
HAMLOG.online — ADIF names no HamQTH field. Auto-upload on log, on-close
batch, right-click Send to, QSL Manager backlog, and a Test button that
authenticates against the callbook login, which cannot touch the log.

Fixes a real mis-route on the way: manual 'Send to HAMLOG.online' had no
branch in runManualUpload and fell through to QRZ.com — the selection was
uploaded to the wrong service with the QRZ key. Both extras-stamped
services now have their own branch.
2026-08-31 14:34:49 +02:00
rouggy 68f0d68980 fix(restart): the self-relaunch waits for its parent's mutex
A database switch relaunches OpsLog, and the child regularly won the
race against the old instance's teardown — so following the app's own
instructions produced 'OpsLog is already running'. The relaunch now
passes --relaunch, which gets the same 20-second mutex patience the
post-update restart has always had. Opens the 0.27.6 block.
2026-08-31 14:19:30 +02:00
rouggy 8790b98766 chore: release v0.27.5 2026-08-31 13:38:18 +02:00
rouggy 8846cba40c feat(confirmations): a Club Log received default, No out of the box
The Confirmations page's Club Log row gains its received side, applied to
every new QSO the way QRZ.com's already is — so the match download's Y
stands out against a ledger of explicit Ns.
2026-08-31 11:59:14 +02:00
rouggy 0cc6ad686a style(columns): the picker lists each group alphabetically
The catalog's order is the grid's column order, which appends new columns
at the end — so the two ClubLog rows sat at opposite ends of the Uploads
box. Sorted for display only; the grid keeps its own order.
2026-08-31 11:39:54 +02:00
rouggy 612cb67438 fix(kpa): a settings save must not flick the KPA500's power switch — and the maps stay under the menus
startAmps tore down and rebuilt every amplifier client on any settings
save, and closing a COM port makes the Windows driver drop DTR/RTS. On a
KPA500 those lines ARE the power switch — so 'save' meant 'amplifier off
twenty seconds later', exactly F1TPL's report, and the fix that held the
lines while OPEN couldn't survive a close. An amplifier whose config is
unchanged now keeps its running client across saves; the others still
rebuild.

Also: isolate on the FT Map panel — Leaflet's z-1000 panes were painting
over the menu bar and the Preferences dialog, as the grid map's own
comment warned.
2026-08-31 11:39:02 +02:00
rouggy cd5d8b503b fix(db): a migration aimed at a table a database no longer holds must not kill the startup
The 0031 clublog ALTER hit split settings databases (their qso table
moved to the logbook years of QSOs ago) and the whole open failed —
silently, because the error went to a println the GUI subsystem
discards. The app then ran with no settings store: every panel showed
defaults, 'db not initialized' in Preferences, and operators read it as
their database being lost. Nothing was ever touched: the failed
migration rolled back on every attempt.

The SQLite migration path now tolerates what the MySQL path always has —
plus the one case it never meets: ALTER/CREATE INDEX/DROP on a table
this database legitimately does not hold. And a failed open is written
to the rotating log, where the next such morning can actually be
diagnosed.
2026-08-31 11:17:45 +02:00
rouggy aa59431403 fix(changelog): scrub the stash-pop conflict and move the Club Log work to 0.27.5
A stash pop during a test-on-parent check left conflict markers inside
changelog.json and the next commit carried them. The 0.27.4 release had
already shipped with its seven entries, so the two Club Log entries that
landed after it open the 0.27.5 block, per the released-block rule.
2026-08-31 11:13:11 +02:00
rouggy 269701410f fix(profiles): the watchlist's Club Log enrichment follows a profile switch
The guard test said so: startWatchlistClubLog ran at startup only, so
switching profiles kept the previous profile's enrichment settings alive.
2026-08-31 10:16:16 +02:00
rouggy 3cb8096141 feat(clublog): the matches come home, and Club Log lends its call list
Two new QSO columns, clublog_qso_download_status/date — a Club Log MATCH,
the service's own confirmation (both stations uploaded the QSO, paired
within 15 minutes). Full promoted-column lockstep: migration 0031, repo
insert/scan, ADIF dictionary + import + export (app-defined
CLUBLOG_QSO_DOWNLOAD_*), table columns, filter builder, bulk edit and the
QSO editor's Club Log row.

The QSL Manager's Club Log entry now actually downloads: getmatches.php
with the existing account settings and the embedded application key,
incremental via the match-completion date filter, matched call+band+mode
±15 min with a mode-blind fallback because Club Log reports 'false' for
modes it cannot infer. Matches always exist on both sides, so unmatched
ones are listed rather than skeleton-added.

And Super Check Partial can merge Club Log's weekly SCP list (~180k calls
worked on the air in the last 3 years) with MASTER.SCP — an opt-in
checkbox under the SCP setting.
2026-08-31 10:14:52 +02:00
rouggy 1f667e4a4b chore: release v0.27.4 2026-08-30 22:24:05 +02:00
rouggy d5f7e290ab chore(changelog): the FTx menu, the FT Map and the map fixes enter the 0.27.4 block 2026-08-30 22:23:30 +02:00
rouggy f2ba798764 fix(maps): zooming stays centred on the grid and decode maps
The hard maxBounds added for the single-world look fought the zoom: with
the world smaller than the window the clamp dragged every zoom step into
a corner. The noWrap tiles already guarantee one world on their own, so
the bounds go — on both maps.
2026-08-30 22:18:27 +02:00
rouggy d7ee87f22d feat(ftx): the digital corner gets its own menu, and the watchlist learns to line up
An FTx menu between View and Tools gathers FT Decodes, FT Map and the
Grid squares map — three views of the same UDP feed that were buried in
Tools. And the watchlist spot rows swap the elastic country column for
fixed widths, so band, mode and frequency read as columns instead of
starting wherever each country name happened to end.
2026-08-30 22:14:40 +02:00
rouggy 2587e6bcfe style(ftmap): one world, themed ground — here and on every map
The FT map wrapped the planet into a row of copies and showed Leaflet's
hard-coded grey beyond it. noWrap tiles inside hard bounds now, like the
grid-square map already did — and the container background override moves to
style.css so EVERY map's surround follows the theme's card surface instead
of #ddd (the grid map's per-div fix was losing to leaflet.css specificity).
2026-08-30 22:05:25 +02:00
rouggy 755392e0fa feat(ftmap): the live decode feed as a world map — feature branch
A new FT Map tab (Tools menu, beside FT decodes): every station decoded in
the last half hour drawn as a great-circle arc from the operator's own
square, coloured by band the way PSK Reporter taught everyone to read it,
fading with age, one arc per call (its freshest sighting), a tooltip with
call/grid/SNR, the MainMap basemap picker and a legend of only the bands on
screen.

Performance was the open question and it is answered structurally: the panel
mounts only while its tab is ACTIVE (zero cost otherwise), the map renders
canvas (one element, not one node per arc), arcs are capped at 300, and
redraws follow the decode list — every period, not every frame.
2026-08-30 21:54:00 +02:00
rouggy a9fb428a0a fix(chase): band/mode/slot needs finally dim — 'worked' is not ''
spotEntityStatus says 'worked' when every grain is in the log; the
unconfirmed check compared its all-ledger verdict against the empty string,
so no band/mode/slot need ever dimmed however many unconfirmed QSOs stood
behind it — only the entity-level NEW, tested by map presence, behaved. The
check now runs for exactly the four needy statuses and asks for 'worked'.
2026-08-30 21:43:47 +02:00
rouggy bbb485b9cf fix(chase): the unconfirmed entity badge is visible
Dimming the filled chip to a transparent background left its light
foreground text on nothing — an invisible badge in the row. Unconfirmed now
renders in the category's own hue, dashed border and text, like the extras
beside it.
2026-08-30 21:40:26 +02:00
rouggy 3a94a69be3 style(chase): no question marks — the dashed dimmed chip says it alone 2026-08-30 21:38:14 +02:00
rouggy e4028f4bcd fix(chase): switching the rules invalidates every resolved verdict
The frontend caches statuses and only asks about unknown keys, so flipping
the chase mode left half the screen judged under the old rules until a
restart — which read as the mode not working. The backend now broadcasts an
invalidation and the panels re-ask as rows repaint. The entity badges also
say their name with a question mark, dashed, when the need is only a missing
QSL — the 50% opacity was invisible on a filled chip.
2026-08-30 21:37:31 +02:00
rouggy 7c10517bf5 fix(chase): each unconfirmed badge keeps its own name; the block moves up
The unconf label was hard-coded to GRID? for every category — a prefix
worked-but-unconfirmed showed as a second grid badge beside the real one,
which read as impossible. Each badge now says its own name plus a question
mark. And the global Chase block moves above the Chase POTA/SOTA/grid
checkboxes, where its operator expected the master switch.
2026-08-30 21:28:52 +02:00
rouggy cd4e5b1117 i18n: the global chase block's labels, dropped by the failed batch edit 2026-08-30 21:23:17 +02:00
rouggy a13ac7d917 test: the bulk-edit guard learns the integer My-station fields
my_dxcc/my_cq_zone/my_itu_zone go through BulkSetIntField, not the string
column map — the guard flagged them as unwritable ever since they were
added, which is the drift it exists to catch, one package over.
2026-08-30 21:03:03 +02:00
rouggy 4e28098a4e feat(chase): the global hunt — new, or new plus unconfirmed, every category
The gap between worked and confirmed becomes visible everywhere. A Chase
setting on the DX Cluster page picks the mode; in new-plus-unconfirmed the
verdicts for DXCC/band/mode/slot, prefix, county and state are judged against
CONFIRMED-only ledgers built from the operator's chosen sources (LoTW, card,
eQSL, QRZ.com — HRDLog has no confirmation field in ADIF to offer), and a
need that exists only because a QSL never arrived is flagged Unconf: the
cluster and decode badges render it dimmed, the convention the grid hunt
introduced. The grid's own Chase selector folds into the global one; its
square-matching scope stays where it was, being grid-specific. One snapshot
key change rebuilds the cache when any of it flips.
2026-08-30 21:02:34 +02:00
rouggy 52e98a71f4 feat(decodes): NEW STATE — badge and filter
The WAS gap made visible: a worked-states set joins the cluster status cache
(distinct states over the three US entities), the status entries carry a
NewState flag wherever the state resolves (log county first, ULS licence
second), and the decode panel gains the badge in its status column plus the
filter chip alongside NEW/BAND/MODE/GRID. Colour from the shared marker
palette, so the cluster can adopt it later without a second hue.
2026-08-30 20:44:58 +02:00
rouggy 81ff6a9b0e style(decodes): a gap between the state badge and its name 2026-08-30 20:38:50 +02:00
rouggy be52a23868 feat(decodes): a State column — badge plus full name
Between the locator and the country, per its operator: the two-letter badge
anchors the eye and the spelled-out name answers it — 'SD' alone is a quiz
for a European. The status-cell badge from the first pass moves here.
2026-08-30 20:36:45 +02:00
rouggy fc1561804c style(decodes): the US state is a proper badge
Bigger and boxed, per its operator — the plain grey text vanished into the
column it was meant to anchor.
2026-08-30 20:34:02 +02:00
rouggy 1288b3f998 feat(decodes): the two-letter US state in the status column
Plain text, not a badge — it is a fact, not an action — placed first so a
WAS chaser can scan the column edge. Shown whenever the status cache knows
the state (log county data or the licence address).
2026-08-30 20:31:05 +02:00
rouggy e00488fad8 chore: release v0.27.3 2026-08-30 20:19:39 +02:00
rouggy 12c61dc35a feat(dvk): delete a message — recording and label together
A deleted message is a free slot, per its operator; keeping the label made it
look half-deleted.
2026-08-30 20:15:45 +02:00
rouggy 0c0e8b06ba feat(dvk): twelve slots; perf: Settings pauses the heavy streams
The voice keyer grows to F1-F12 — the UI was already data-driven, so the
count constant, the F-key range and the labels are the whole change.

And the preferences stop stuttering on a busy station: every spot batch,
decode batch and CAT snapshot re-rendered the entire App tree behind the
dialog — cluster grid, decodes panel, thousands of nodes — and the pointer
janked over the very panel the operator was trying to use. While Settings is
open the flushes park their batches in the pending refs (bounded) and CAT
repaints at most every two seconds; closing the dialog drains everything.
2026-08-30 20:09:44 +02:00
rouggy 1bd3896ca7 feat(decodes): arrival order within a period, not strongest-first
The panel now mirrors the decoder's own window line for line — the operator
compares the two side by side, and the SNR sort scrambled that
correspondence. The report is still right there in its column.
2026-08-30 19:26:52 +02:00
rouggy 7e6c0b4f7e fix(kpa): stop switching the KPA500 off, and answer its operator instantly
Three faults, one report. The slow poll asks ^TP — the KPA1500's ATU, which
a KPA500 (no ATU) never answers — and ask() dropped the whole connection on
any read timeout: a two-second stall and a reconnect every slow cycle, which
is why buttons lagged and the status read wrong. Worse, every serial reopen
toggled DTR/RTS — and those lines are the KPA500's POWER SWITCH (that is how
the Elecraft utility turns it on), so the amplifier obediently switched off
twenty seconds after its operator pressed nothing but Standby.

Silence is no longer a dead link (write errors still are), ^TP is never asked
again after one silence, the control lines are asserted once and held, and
the baud field becomes a list of the rates these amplifiers actually speak.
2026-08-30 19:14:35 +02:00
rouggy 2cde1a2c27 chore: release v0.27.2 2026-08-30 16:06:51 +02:00
rouggy 53100eb6c8 debug(rigctld): always trace a client's first six commands
A connect that lasts 50 ms leaves nothing to say which answer the client
disliked — WSJT-X refusing to attach to the shared CAT was undiagnosable
without the full wire trace. The handshake is now always logged; steady-state
polling stays behind the trace switch.
2026-08-30 15:55:58 +02:00
rouggy 3b555219d2 style(i18n): trim the FR follow-mode hint, and fix its double-escaped accents 2026-08-30 15:38:14 +02:00
rouggy ba35d4094c style(i18n): trim the follow-mode hint to its first sentence 2026-08-30 15:36:33 +02:00
rouggy 9bd6d988aa feat(udp): spot clicks switch the decoder's mode
Configure (message 15), mode field only, every other field sent as
no-change: an FT4 spot clicked while WSJT-X sits in FT8 lands the operator
ready to decode instead of staring at gibberish. Sent to every instance
heard this session — one already in the right mode treats it as a no-op —
and only for modes the decoder actually speaks (FT8/FT4/JT65/JT9/MSK144/
Q65/FST4); CW and SSB are none of its business. Toggle in the Connections
panel, on by default.
2026-08-30 15:33:07 +02:00
rouggy daabbc63c7 fix(map): Zoom DX clamps its frame to Mercator's edge
The great-circle arc to a polar entity (Franz Josef Land) peaks near 88°N;
fitting the raw arc points framed a band of tile-less white above 85°, where
Web Mercator ends. The camera's bounds are clamped to ±85° — the line still
draws to wherever it goes, only the framing stays where there is a map.
2026-08-30 15:30:30 +02:00
rouggy 997bc81d5e fix(udp): edge-detect the decoder's DX Call stream
WSJT-X restates the same DX Call in every Status packet, and a spot clicked
in OpsLog put that call into the last-UDP memory (deliberately, for another
case) — so the guard let the decoder's next restatement straight through and
the entry snapped back to old news. The decoder's stream now only counts when
its value CHANGES; a clear over there resets the edge so re-selecting the
same station still lands. Explicit picks (N1MM, remote, spot relays) are
untouched.
2026-08-30 15:29:07 +02:00
rouggy 3c59507bc3 feat(udp): Highlight Callsign and Replay — the decoder becomes log-aware
Message 13 paints callsigns in WSJT-X/JTDX's own Band Activity window with
verdicts from the same cluster status cache that colours the spot grid:
watchlist pink, new DXCC green, new band for its entity orange. Deduplicated
per instance+call+verdict, one datagram each; a verdict that lapses (the
operator worked them) clears that call, and turning the option off clears
everything via the protocol's CLEARALL!. Off by default, switch in the
Connections panel.

Message 7 asks a program heard for the first time this session to replay the
decodes already on its screen, so the FT decodes panel starts full instead of
empty until the next period. Replayed lines arrive marked not-new and are
shown but never auto-answered — the auto-caller now checks, on top of its
30-second freshness gate.
2026-08-30 15:25:58 +02:00
rouggy 721c43d569 style(settings): the Connections panel drops its two explainer paragraphs
Same cleanup as the General and DX Cluster panels: the section speaks for
itself, and the per-service hints remain where the choice is made.
2026-08-30 15:09:08 +02:00
rouggy 25eda98612 style(watchlist): a pass toward DXHunter's look
The operator preferred the original's aesthetics. Adopted: the signature pink
callsign (a fixed identity colour, like the cluster palette), counters as
coloured pills, a quieter card surface with a hover, a stronger needed bar on
spot rows, and the lightning bolt back on the DXpedition badge. Everything
else stays on theme tokens.
2026-08-30 13:35:37 +02:00
rouggy 0b909a4d63 fix(elecraft): the K3 power meter converts to real watts
The operator measured the whole table: 10 W showed 83, 12 W showed 100, 13 W
showed 8 — the bargraph is relative to a meter RANGE that flips at 12 W
(0-12 QRP, 0-120 above). A bar percentage was never watts; with the PC
setting choosing the range, it converts, and the console prints the watts
beside the bar.
2026-08-30 13:31:34 +02:00
rouggy 37298afd77 fix(qso): every by-ids statement is chunked — 168k QSOs is a selection too
One placeholder per id in a single IN (…) hits SQLite's bound-variable cap:
bulk field set died at 168 000 QSOs with 'too many SQL variables' (10 000
passed). One chunker now serves bulk set (text/int/extra/frequency), the
delete, the post-upload markers and the export-selection iterator — the last
collecting and sorting once at the end so its chronological contract holds
across chunks. 500 ids a statement keeps every backend far from any limit.
2026-08-30 13:13:22 +02:00
rouggy 5a77fdf68f chore: release v0.27.1 2026-08-30 12:01:03 +02:00
rouggy 31c898ad7f revert(icom): drop Wake-on-LAN — the deck does not honour it
Tried on the real IC-7760: the magic packet wakes nothing; Icom's own
mechanism is the network-standby session plus the CI-V power-on, which
OpsLog already does. The MAC store and the WOL sender go, and the changelog
entry with them. The CI-V pump now says once per session that the client
pings are armed — invisible pings read as absent pings.
2026-08-30 11:59:25 +02:00
rouggy 88c9756edf fix(icom): the client pings too — the dropouts' likely root cause
wfview sends a ping (0x07, 0x15 bytes, its own seq and a monotonic
timestamp) every 500 ms on every stream, and so does RS-BA1. OpsLog only
ever REPLIED to the rig's pings — and the loaner IC-7760 stopped serving
CI-V data about a minute into nearly every session while the transport
stayed alive: the rig had concluded nobody was listening. Scope, TX audio
and idle numbering were each eliminated in turn before wfview's source
settled it. Pings now go out on control, CI-V and audio alike.
2026-08-30 11:42:48 +02:00
rouggy 7b15b534cd fix(wb): latest lookup wins the worked-before panel
Two lookups race when a second callsign is typed before the first answered,
and the slow one landed last: the grid showed the previous call's QSOs under
the new call's name — LU5AVM's contact filed under LW8ETV, screenshot in
hand. A request token now discards any response that is not the newest.
2026-08-30 11:26:47 +02:00
rouggy 8e7ecc3d51 feat(icom): Wake-on-LAN for a deck that sleeps with its server
A 7760 switched fully off takes its LAN server down, and no session means no
console and no ON button — the one thing remote operation cannot tolerate.
The rig's MAC is learned from every login and persisted; when a dial finds
nobody home, the magic packet goes out to it before the next attempt. The
rig wakes into standby, the session opens, and the ON button does the rest.
2026-08-30 11:17:55 +02:00
rouggy 7f9019c7bc fix(omnirig): a Yaesu in split tunes both VFOs to the spot
The generic Freq write lands on the TX VFO when split is engaged: an FT-2000
moved B and left A — the receiver — behind, so a spot click QSYed the
transmitter and nothing audible changed; without split the same write moved A.
With split on (and the operator on MAIN), FreqA and FreqB are both written,
so the radio arrives on the spot whole, split left as the operator had it.
2026-08-30 11:12:08 +02:00
rouggy a1a3bad682 fix(cluster): the FT8/FT4 watering holes exist on every band
The inference tables knew FT8 on some bands and nothing on others: a 10.136
spot with a bare comment read as generic DATA — which is what every
skimmerless spot of a DXpedition's 30 m FT8 slot did. The standard FT8/FT4
windows are added on 30 m, 60 m, 80 m (FT4), 17 m (FT4) and 12 m, in BOTH
tables (backend band plan and frontend spot.ts), ahead of the wide segments
they sit inside — order is the mechanism there.
2026-08-30 03:14:21 +02:00
rouggy 1b746f2452 fix(icom): CI-V idles carry real sequence numbers
Every idle went out with seq 0, seven a second, interleaved with
properly-numbered data — where RS-BA1 and wfview draw idle sequence numbers
from the same counter as everything else. A rig that follows the sequence
tolerates the zeros for a minute or so and then stops serving CI-V data on
the stream: the shape of every dropout the loaner IC-7760 has shown, with the
scope and the TX path both since eliminated as causes. Idles now take the
next tracked seq and sit in the retransmit buffer like any other packet.
2026-08-30 03:08:59 +02:00
rouggy 3db49e1213 debug(icom): say whether the split mode-align was accepted
The 0x26 01 copy was fire-and-forget, and a real 7760's sub VFO kept
yesterday's mode with nothing to show why — refused, or sent with a stale
mode byte. The mode is read first when unknown, and both outcomes are
logged.
2026-08-30 03:02:06 +02:00
rouggy 605c934d33 style(icom): the shared LED meters, and the sideband named
The console's thin home-made bars were the one holdout — every other panel
(Flex, Elecraft, the amps, the tuner) draws the same MeterBar. And the mode
badge said SSB, which on any band leaves the operator guessing which
sideband the rig is on: the panel now shows USB or LSB (the log keeps ADIF's
SSB).
2026-08-30 03:00:58 +02:00
rouggy dad762a0f1 fix(dvk): repair the auto-CQ trace mangled by the previous commit
Shell quoting ate the template literals; the traced loop now compiles.
2026-08-30 02:52:04 +02:00
rouggy 97567d15dd debug(dvk): trace the auto-CQ loop into the app log
It keeps 'not repeating' on a network rig and every guess at which guard was
tripping proved wrong. Each round now logs play / playing-flag / end / gap,
and every exit names its reason.
2026-08-30 02:51:05 +02:00
rouggy fc080f3719 fix(icom): kill the leftover waveform in both command forms, and say so
The blind kill at connect still left a real 7760 streaming — the selector
byte differs by model, and the result was discarded so a quiet failure looked
like a cure. Both forms go out now (a NAK costs one frame) and the outcomes
are logged. The power table also gains its third measured anchor: a real
200 W reads full deflection, so raw 213 is 200 W — not the printed face's
250.
2026-08-30 02:43:43 +02:00
rouggy ce9ea10d68 fix(dvk): a transiently blank mode no longer kills the auto-CQ loop
Over the network the rig's mode read fails now and then and the CAT push
blanks the mode for a poll or two; the auto-CQ loop treated that blank as
'not a phone mode' and stopped after the first call. Only a KNOWN non-phone
mode stops the loop now.
2026-08-30 02:42:20 +02:00
rouggy dc94aa94b6 fix(icom): needle inertia for the TX meters, and room for three digits
The CI-V meters are point samples: between two SSB syllables a poll lands on
0 W and a perfect SWR, so a 150 W transmission read 0-10 W most of the time
and the SWR sat at 1.0. Power now decays like a needle and SWR holds its peak
then snaps to the live value — the same meterPeak the Yaesu and Kenwood
consoles already use. The readout column also widens so '180 W' stays on one
line.
2026-08-30 02:40:07 +02:00
rouggy db7ac771b1 feat(icom): the ATU can be taken out of line
TUNE (1C 01 02) started a cycle; nothing sent the 0 that puts the tuner back
through. An ATU chip beside SPLIT now toggles it, and the tuner state is read
on the slow front-panel beat so the radio's own TUNER button stays in sync.
2026-08-30 02:36:46 +02:00
rouggy 19134ea23c revert(icom): drop the PTT+split strip — MOX and SPLIT already live below
Added on a report that they were missing; they were below the fold, not
absent. The useful parts stay: the MOX button routes the PC microphone on a
network station, split aligns the TX VFO's mode, and SetIcomSplitOffset
remains available for a future use.
2026-08-30 02:34:47 +02:00
rouggy b12011fab7 fix(icom): send the leftover-waveform kill blind, at connect
The first attempt sent it only after CI-V answered — but the waveform flood
is what stops CI-V answering, so a rig wedged streaming never received its
cure. It now goes out at the end of Connect, before anything waits on a
reply: an unanswered set costs one frame, and the rig acts on what it decodes
whether or not we hear the acknowledgement.
2026-08-30 02:33:19 +02:00
rouggy d62cd10478 fix(icom): switch off a leftover waveform stream at first contact
The 0x27 output flag lives in the RADIO and survives our sessions: a scope
enabled by an older build (or another program) kept flooding every new
session with 700-byte frames — and on the IC-7760 that flood is what kills
the CI-V link. One 'waveform output off' as soon as the rig answers, once per
session; the front-panel display is deliberately untouched.
2026-08-30 02:28:41 +02:00
rouggy 7de7cb96c3 fix(icom): one sequence space for everything sent on the audio stream
The voice keyer and the live microphone each numbered their packets from 1.
A message played after a talk session re-used sequence numbers the rig had
already seen and was discarded wholesale: the PTT keyed for the full length
of the message and none of it was modulated. PlayTX now feeds the same framer
and counters as the microphone.
2026-08-30 02:25:37 +02:00
rouggy dbc97ad61b feat(icom): remove the console's spectrum scope
Every Icom streams its waveform differently: the IC-7851 controls a scope it
never streams, and a real IC-7760 stops answering CI-V altogether a few
frames in — taking CAT and the audio down with it, which is this week's whole
dropout saga. Chasing a per-model frame layout for a decoration is not worth
a console that drops the link; the radio's own scope is better anyway. The
backend scope plumbing stays (harmless while nothing enables it); the UI and
its enable path go.
2026-08-30 02:23:31 +02:00
rouggy 7966b01900 fix(icom): the console PTT carries the microphone on a network station
Keying alone is right on USB, where the operator talks into the radio's own
mic; over the LAN it transmitted silence. The console button now goes through
one binding that picks the road: network To-radio → the same key-and-stream
path as the Talk button, USB → key and nothing more.
2026-08-30 02:20:40 +02:00
rouggy f98e95fe9e feat(icom): PTT and offset split at the operator's hand
The MOX and SPLIT controls lived at the bottom of the Transmit card, below
the fold on most layouts — a console whose PTT needs scrolling is not a
console. A strip under the mode row now carries the PTT and Split as
OFF/+1k/+5k/+10k, DXpedition style, on a new SetIcomSplitOffset that takes
the chosen TX offset (0 keeps the old CW/SSB convention). The mode-alignment
from the previous fix rides along on every engage.
2026-08-30 02:15:16 +02:00
rouggy 392b93f089 fix(audio): the Listen button works with the network stream as the source
Its enable condition predated the network audio: with From-radio empty — the
normal network setup — the button was greyed out, and an operator who had
stopped listening had no way to start again from this panel. The stale 'later'
hints on both buttons said the network paths did not exist; they do.
2026-08-30 02:04:51 +02:00
rouggy 9c459c754f fix(icom): standby is not a fault — the quiet recovery needs prior CI-V
A radio in standby is silent on CI-V by design; only the transport chats.
The quiet-recovery added for the mid-session deafness treated that silence
as the fault it usually is and tore the session down every 15 s — taking the
console and its ON button with it, so a radio that was off when OpsLog
started could never be turned on. The recovery now arms only once the
session has heard at least one CI-V payload.
2026-08-30 01:55:35 +02:00
rouggy ec967e29cf fix(icom): the audio stream no longer depends on the speakers
The sink — decode + recorder feed — was built only on the success path of
starting the monitor, so speakers-off (the remembered preference) or an
output device that failed to open silently disabled the WHOLE stream: the
session connected audio=false, and a ticked RX-audio checkbox did nothing.
The sink now exists whenever the stream is on; only the monitor obeys the
speaker choice.
2026-08-30 01:53:06 +02:00
rouggy 58e36667a7 fix(audio): starting the monitor restarts a running one instead of refusing
'Monitor already running' surfaced as the speakers checkbox unticking itself
the instant it was ticked, whenever the UI's idea of the state and the real
monitor had drifted apart. Stop-then-start makes the button mean what it
says.
2026-08-30 01:44:11 +02:00
rouggy 77aba73096 fix(icom): a disconnected radio says so, not 'use the USB sound card'
The TX-audio entry points type-assert the transport; a nil port failed the
assertion and produced the sound-card message, sending an operator whose
radio was simply off — or on a new DHCP address — hunting through audio
devices.
2026-08-30 01:36:37 +02:00
rouggy da8f60a7b3 fix(icom): the SUB dial is always shown, and split aligns the TX mode
The unselected VFO was read only when split was on, so a dual-receiver rig's
second dial sat blank on the console. It is now read on the same slow beat
regardless. And engaging split copies the main's mode (and data flag) onto
the TX VFO with 0x26 01 — a split whose TX VFO still speaks yesterday's mode
transmits FM into a CW pileup; rigs that predate 0x26 NAK it harmlessly.
2026-08-29 21:53:16 +02:00
rouggy e8b5444fc2 feat(icom): live microphone over the network — the last remote brick
The talk button learns the network road the voice keyer already took: when
To-radio is the radio itself, the microphone is captured and re-framed into
the rig's 320-sample packets, the capture callback serving as the clock — the
mic delivers in real time, so no ring and no pacer. Counters and the frame
remainder live on the audio stream, so a talk session spans calls. PTT keys
before and releases after, through the same code as the USB path.

With this, a network Icom is a complete remote station over three UDP ports:
RX audio to the headset, live voice and recorded messages back, CW through
the rig's keyer, CAT for everything else.
2026-08-29 21:51:37 +02:00
rouggy 08bc401681 fix(audio): the network monitor needs no From-radio device
AudioStartMonitor refused before reaching the network branch when From-radio
was empty — which is the NORMAL network setup, so the new speakers checkbox
un-ticked itself the instant it was ticked. The capture-device requirement now
applies only to the USB path that actually captures.
2026-08-29 21:45:18 +02:00
rouggy 7ac342e2d4 feat(icom): the speaker choice appears beside the RX-audio option too
Same remembered preference as the console's speaker button, surfaced where
the stream itself is enabled — tick RX audio and decide in the same breath
whether it reaches the speakers. Applied immediately, no save needed.
2026-08-29 20:02:28 +02:00
rouggy b244575937 feat(icom): a speaker toggle on the console itself
Muting the network RX audio took a trip through Settings → Audio → Stop
listening — two panels away from the console the operator is actually using.
A speaker button now sits beside ON/OFF (network only, like them): one click
to listen or mute, the recorder and the voice keyer unaffected, the choice
remembered across reconnects and launches.
2026-08-29 19:49:09 +02:00
rouggy c878742e50 feat(icom): listening to the network audio is a remembered choice
The RX stream and the speakers were welded together: every reconnect and
every launch restarted the monitor, so an operator sitting in the room with
the radio could not have the stream (which the recorder and the voice keyer
need) without also hearing it twice. Stop listening now persists, the Listen
button turns it back on, and reconnects respect the choice.
2026-08-29 19:42:18 +02:00
rouggy 13434ca36c feat(icom): phase 5 — the voice keyer plays to the radio over the LAN
The audio session opens its TX side alongside RX (txenable in the conninfo,
16 kHz both ways), the stream gains a paced sender — 320 samples every 20 ms,
the frame mirroring what the rig itself sends on this socket, IDs swapped —
and the network Icom takes the same NetworkPlayer slot a TCI radio does: pick
'Radio (network audio)' as the To-radio device and the voice keyer needs no
cable and no virtual sound card. PTT brackets the message through the same
code as every other audio path.
2026-08-29 19:34:05 +02:00
rouggy 6345710de5 fix(icom): wait out the rig's session cleanup before redialling
The capture shows the spiral: a session goes deaf on CI-V (28 unanswered
commands, transport alive), the fast-fail rebuilds it, the new session answers
for one second — one state read got through — and is then strangled when the
rig purges the half-open old one. Twenty seconds a lap, no audio throughout,
and only the third or fourth session survives. When the previous session died
less than 90 s after connecting, the redial now pauses 20 s so the purge
happens BEFORE the new session, not on top of it. A long-lived session still
reconnects immediately.
2026-08-29 19:21:55 +02:00
rouggy 0cab1c1e6b debug(icom): count the questions asked during a CI-V silence
The operator's counter-hypothesis deserves an answer: RS-BA1 shows no such
dropouts, so are we sure the rig stops answering — or do we stop asking? The
quiet log now carries the number of CI-V commands sent since the last answer.
Zero would convict our own poll loop; a healthy count convicts the rig.
2026-08-29 18:43:46 +02:00
rouggy 1c71495446 fix(icom): give up on the quiet CI-V stream after 15 s, not 30
The capture settled it: the 7760 keeps the transport chatting (pings answered,
no socket error, no loss) while CI-V data simply stops, the in-place reopen is
ignored, and only a fresh session brings it back. So: one reopen attempt at
10 s — kept, it is free and the log will say if a firmware ever honours it —
and at 15 s the link is failed deliberately so the manager rebuilds it,
halving the outage. Also: the recovered-acknowledgement now sits on the DATA
packet, not on any packet — pings were toggling the detector, 160 reopens in
thirty seconds all logged as recoveries that never happened.
2026-08-29 18:35:41 +02:00
rouggy 2c0158b75c fix(icom): re-open the CI-V flow when the rig goes quiet mid-session
The IC-7760 recurrently stops answering CI-V a few minutes into a network
session while the transport keeps chatting — pings answered, session alive,
commands unanswered — and the only recovery was the 30 s watchdog's full
teardown. At 10 s of CI-V silence the pump now says 'open' again on the
existing stream, which is all it should take if the rig quietly closed the
data flow; the watchdog remains as the backstop. The send counters gain a
mutex since the pump now transmits too.
2026-08-29 16:56:06 +02:00
rouggy a2b019b280 debug(icom): the quiet detector watches CI-V DATA, not transport chatter
The first detector counted any packet and never fired: pings and idles keep
flowing while the rig stops answering CI-V commands — which is what the 7760's
recurring silence turns out to be. Now it fires on 10 s without a CI-V payload
and reports the ages of the transport, the last scope frame and the last
socket error, so the next occurrence says exactly what the rig was still
sending.
2026-08-29 16:54:37 +02:00
rouggy 1fba7d2d57 fix(icom): create the audio manager before the CAT link comes up
reloadCAT builds the network audio sink gated on audioMgr, and startup created
the manager AFTER the first reloadCAT: every fresh launch connected with audio
silently off however the option was set, and the operator had to
untick/save/retick it to hear anything. The manager is idle-until-used, so
creating it earlier costs nothing.
2026-08-29 16:47:42 +02:00
rouggy 490941f506 fix(icom): only forward-moving audio packets reach the sink
Every audio packet was delivered as if it were the next 20 ms, duplicates and
late retransmits included. The monitor's capped ring quietly threw the surplus
away, so the speakers sounded fine — but the recorder keeps every sample it is
given, and the file came out longer than the QSO, slowed and stuttering, each
lost-then-resent packet heard twice. trackRxSeq now says whether a packet
advances the stream, and only those are handed on.
2026-08-29 16:43:41 +02:00
rouggy 6bf759b6ae fix(cw): Icom CI-V CW works over the network backend too
The CI-V keyer is the same 0x17 command on the same IcomController whatever
carries the link; only the warnings and enable checks insisted on the USB
backend, telling a network operator their working keyer would fail.
2026-08-29 16:39:43 +02:00
rouggy 43e5088e96 fix(icom): the live radio names MY_RIG, and a manual take records it too
Two halves of the same station confusion. The entry form pre-filled MY_RIG
from the per-band default on every band change, so the log-time priority (the
radio that is keying beats the radio that was planned) never ran — the field
was no longer empty by the time the backend looked. The form now asks the
active radio first, through a new ActiveRadioMyRig binding.

And the manual record button started its capture from the From-radio sound
card unconditionally, where the automatic recorder already knew a network
Icom's audio is pushed from the 50003 stream: a manual take on the IC-7760
was a faithful recording of the Flex's DAX. Same source rule for both, and
the button is offered with no sound card at all when the network stream is
the source.
2026-08-29 16:35:25 +02:00
rouggy 582fa561b2 fix(rec): a fresh take starts its clock at zero, not at the last take's total
Two effects, wrong order: the ticking effect ran before the reset effect on a
new take, so its closure captured the PREVIOUS take's elapsed as its starting
point — the counter showed 0 for one second and then jumped back to thirty
minutes. Effects run in declaration order; the reset now comes first.
2026-08-29 16:31:38 +02:00
rouggy 7f328d4eb5 debug(icom): say what the CI-V stream was doing when it goes quiet
A real IC-7760 keeps going silent on the CI-V stream two-three minutes into a
session while the control link stays alive. At 10 s of silence the pump now
logs the last real socket error and the retransmit backlog, and notes when the
stream resumes — the 30 s watchdog that follows cannot tell a dead socket from
a rig that stopped talking, and the next occurrence should.
2026-08-29 16:07:42 +02:00
rouggy b2eb6a4b1e fix(icom): the 7760 power meter interpolates between MEASURED anchors
A known 50 W read raw ~89 where the two-segment curve said 62: the face is
not linear in watts below half scale. Watts now interpolate through the
measured points (50 W, 100 W, full-scale 250 W); refining the curve is adding
a row to the table.
2026-08-29 16:06:05 +02:00
rouggy c2db4c5f7e chore(icom): stop blaming a hijacker for every silent CI-V stream
The 30 s watchdog message asserted another program had taken the session; a
real IC-7760 went silent once with nothing else on the network, and the
message sent its operator hunting software that was not installed. State both
causes.
2026-08-29 16:04:06 +02:00
rouggy b246c4d10a fix(icom): power in watts on the 7760, and Listening restarts clean
The power meter applies Icom's own meter calibration (raw 143 = half
deflection, 213 = full scale) anchored against the real radio: a measured
100 W sits at half deflection of the 7760's 250 W face — the linear guess
showed it as 140. The RF power slider says watts on a 200 W rig instead of a
percentage the operator has to convert.

And the Listening button, pressed while a network Icom is connected, restarts
the monitor render-only: it used to open a USB capture from the From-radio
device as well, and that second producer interleaved with the network pushes
chopped the audio to pieces.
2026-08-29 16:02:15 +02:00
rouggy 6d4a110949 fix(cat): the settings panel opens on the radio that is actually active
The loader looked for an 'active' flag the radio list has never carried, so
reopening the panel always landed on the first entry — Radio 1's name over
whichever radio's settings were live, and every field edit (MY_RIG included)
silently rewriting the wrong entry. The backend is the only one who knows
which radio is on the air; ask it.
2026-08-29 15:52:37 +02:00
rouggy ffbbff80d6 feat(icom): a DATA button, native PSK, real watts — and the audio codec
The console gains a DATA mode button (USB-D, what FT8 and friends ride on);
the PSK button now maps to the rigs' native PSK mode (0x12) instead of erroring
as an unknown mode — rigs without one NAK it, exactly as RS-BA1's button does
there. The power meter shows real watts on the IC-7760, whose meter face runs
to 250 W where a 100 W rig's percentage was the watts. The clickable S-meter
gets a pointer cursor so it looks like what it is.

Network audio: rxcodec 0x04 — the experiments on the real 7760 settle the
codec table (0x10 = 16-bit stereo, 0x02 = 8-bit mono, both heard as garble),
and 0x04 is the 16-bit mono the playback path actually plays.
2026-08-29 15:48:26 +02:00
rouggy bd2edf6624 fix(icom): verify the data flag after a mode set, and correct it with 0x26
The IC-7760 acknowledges the 1A 06 data-mode command and stays in USB-D
anyway, which left the operator unable to get back to plain USB from the
console at all. After every mode set the flag is read back; on a mismatch it
is said again with 0x26 — mode, data flag and filter in one frame, the form
the newer rigs actually honour. Only on a mismatch, so rigs that predate 0x26
never see the command.
2026-08-29 15:36:55 +02:00
rouggy 6321948415 fix(icom): fold the 7760's stereo RX stream down to the mono it plays as
The rig keeps sending two-channel LPCM after rxcodec asks for one — 1280-byte
payloads, 320 stereo frames per 20 ms tick, right channel all zeros in the
capture. Played as mono that interleaving is half-speed metallic garble. A
stereo-sized packet is folded to its left channel; a 640-byte mono packet from
a rig that honours the request passes through untouched.
2026-08-29 15:34:18 +02:00
rouggy 89e239d83f fix(icom): decode the network RX audio as what the rig actually sends
The first real radio on the 50003 stream (an IC-7760) settles the two guesses
the experimental audio path shipped with. The payload starts at 0x18 — the
packet carries a big-endian payload length at 0x14 (0x500 on every packet) —
not at 0x16, which swallowed two header bytes into the PCM and laid a 50 Hz
click track under everything. And rxcodec 0x10 asks for TWO-channel LPCM,
which the mono playback path rendered as double-speed garble; 0x02 asks for
the one channel the monitor plays.
2026-08-29 15:32:44 +02:00
rouggy 0e48ad7bb2 fix(icom): the IC-7760's attenuator is the stepped 6/12/18 dB kind
Confirmed on a real one. It was falling into the single-20 dB default, whose
one button the rig NAKs.
2026-08-29 15:29:14 +02:00
rouggy ad82c21dbc debug(icom): log the raw packets seen during a control handshake
A rig that answers something unrecognised (a newer model, another firmware)
and a rig that answers nothing are different faults; a silent timeout hides
which. Capped at a dozen packets so a working handshake cannot flood the log.
Prompted by the first IC-7760 network attempt — which turned out to be aimed
at the console's IP, where nothing listens; the remote server lives on the RF
deck.
2026-08-29 15:12:12 +02:00
rouggy 6f126802cd feat(icom): the IC-7760 joins the model list
CI-V default address B2h, per the radio's own menu. Both places that know a
model: the settings dropdown and civ.ModelName.
2026-08-29 15:02:23 +02:00
rouggy 704b614c38 fix(elecraft): calibrate the S-meter, and stop the SWR spike lingering
The S-unit mapping was provisional, waiting for a real radio. One arrived:
side by side with a K3's own display, raw 5 reads S7 and raw 9 reads S9+20,
so S9 sits near raw 6.5 — not 9 — and each raw step above it is worth ~8 dB.
The old scale showed everything two S-units low.

The SWR meter borrowed the power meter's needle inertia — hold the peak, then
close a quarter of the gap per poll. The K3 throws a brief SWR spike as an FT8
frame ends, and that decay turned one bad sample into twelve seconds of red on
the next transmission. SWR now holds the peak for the same 1.5 s and then
snaps back to the live reading: it is a warning light, not a needle.
2026-08-29 13:52:40 +02:00
rouggy 6eb6f1b339 chore: release v0.27.0 2026-08-29 02:56:48 +02:00
rouggy dbd2fff121 chore(changelog): the watchlist block becomes 0.27.0
A new tab, a new file format carried over from another program, and a ClubLog
integration are a minor version, not a patch.
2026-08-29 02:56:28 +02:00
rouggy dc6da67cbf chore(changelog): the watchlist headlines 0.26.24
The block's one genuinely new feature opens it — with the ClubLog enrichment,
which had not made it into the entry yet — instead of sitting seventh between
an Elecraft button row and a bulk-edit field.
2026-08-29 02:55:16 +02:00
rouggy ea6fb1c29c fix(flex): send the spot-click zoom after the tune, not alongside it
Double-clicking a cluster spot tuned the slice and zoomed the panadapter in
the same breath. On a cross-band jump SmartSDR moves the panadapter itself to
follow the slice, and that follow lands after — and overrides — a zoom sent in
the same instant, so the width the operator configured never appeared. A spot
clicked ON the panadapter never hit this, because the radio had already tuned
before our zoom arrived; the two paths now behave the same. The Chase new
panel's pick is sequenced identically.
2026-08-29 02:45:07 +02:00
rouggy 29267a865c fix(flex): the Chase new panel's spot click zooms the panadapter too
Every other spot click — cluster grid, band map, watchlist, alert list — goes
through handleSpotClick, which applies the operator's per-mode panadapter
width after the tune. The Chase new panel had its own pick handler that tuned
and stopped there; it now makes the same view change, after the tune for the
same reason.
2026-08-29 02:37:13 +02:00
rouggy e93d2d36a8 feat(watchlist): the ClubLog expedition enrichment — phase 2 lands
clublog.org/watch.php, per entry: the DXpedition flag, OQRS, LiveStream, the
log's QSO total and the last-24h rate — the fields the schema has carried since
phase 1. Refreshed on DXHunter's own cadence (hourly for expeditions,
six-hourly for the rest), two at a time with a breath between requests: the
application API key is shared by every install, so a hundred-entry list must
read as a trickle at ClubLog's end. Nothing to configure — OpsLog's own key,
already used for cty and Most Wanted, serves.

A starred pattern is asked about by its base (ClubLog has no log for VK9*).
The card gains the 24h rate beside the QSO total and the Live link to the
expedition's stream.
2026-08-29 01:49:13 +02:00
rouggy 107c6fccf6 fix(watchlist): remove on a single click, like the bell and trophy beside it
The two-click arm goes: removing an entry is cheap to undo (type it again), so
it does not earn a confirmation its neighbours do not have. The bin reddens on
hover and the green line confirms the removal.
2026-08-29 01:36:06 +02:00
rouggy 9350af9589 fix(watchlist): remove actually removes on the second click
The two-click arm compared against the STATE variable, which each click's
handler captured at render time — a quick double-click read the pre-arm value
twice, so both clicks merely armed: the bin turned red and nothing was ever
deleted. The arm lives in a ref now, compared synchronously, and the window
grows to three seconds.
2026-08-29 01:34:09 +02:00
rouggy a9a6579a9a feat(watchlist): confirm an add, and both messages clear themselves
A green line under the toolbar says what was added (and as what — the contest
variant names the per-UTC-day rule); the already-exists error keeps its red
line. Both fade after four seconds: a stale 'added' from ten minutes ago reads
as a fresh one.
2026-08-29 01:30:10 +02:00
rouggy 7795e13432 fix(watchlist): open the store before the logbook connect
It was created after the (possibly remote) logbook connect, so a 10-30 s MySQL
dial left the tab saying 'Watchlist: 0' until the next refresh tick — twenty
seconds of an empty list for a JSON file that reads in a millisecond. It now
opens right after the data dir is known; only the auto-contest pattern waits
for the settings store.
2026-08-29 01:28:50 +02:00
rouggy c9e98d5fdd feat(watchlist): exact match by default, * for a family
The prefix matching ported from DXHunter swallowed too much: a three-letter
special-event entry N8W lit up NEEDED for N8WCR, a different station entirely.
The implicit becomes explicit — a bare entry matches exactly that call, a
trailing star makes it a family: VK9* catches every VK9…, RI0SP* the
expedition's portable forms. A star anywhere else (or alone) is refused at Add
rather than silently matching nothing. Same rule in the backend Match and the
tab's own matcher, pinned by test.
2026-08-29 01:25:34 +02:00
rouggy 72253f99fa style(watchlist): the toolbar is two atomic groups — no more orphan chip
Add-controls left, filters right, justify-between: when the pane narrows, the
whole filter group drops below as a block instead of shedding its last chip.
Shorter chip labels (Active / Needed) and a slimmer search keep both groups on
one line at the tab's normal width.
2026-08-29 01:16:53 +02:00
rouggy f0d3bed8a2 style(stats): room for a long entity beside its badges
The F1 panel widens 560 → 600 px, the header row drops its outer padding and
wide gaps, and the country badge tightens — so 'Central African Republic' and
the NEW SLOT chip share one line instead of the chip dropping alone underneath.
2026-08-29 01:11:02 +02:00
rouggy 30c9acf0d2 style(watchlist): counters on their own centred line, sane placeholders
The counters are the tab's headline and now sit alone, centred, above the
toolbar; everything one interacts with lives on the second row. The inputs stop
shouting their placeholders — the uppercase belonged to the VALUE (callsigns),
not the hint text — and the auto-contest field says what it is.
2026-08-29 01:08:39 +02:00
rouggy e3175ef6a3 fix(watchlist): never draw a spot before its verdict
A new spot rendered as Needed the instant it arrived and was withdrawn half a
second later when the worked answer landed — the list twitched on every burst.
Unsettled spots are now invisible everywhere (card lines, counters, the
Active/Needed-only filters) until their verdict is in: nobody needs a spot
150 ms early, they need it settled. The answer map is merged rather than
replaced, so a settled line can never fall back to unknown, and the query
debounce drops to 150 ms since there is nothing on screen waiting for it.
2026-08-29 01:06:01 +02:00
rouggy 441eb295c6 feat(watchlist): header counters, mode filter — and the alert checks worked FIRST
DXHunter's header row, ported: Watchlist / Active / Needed counts up front and
the All-Modes select beside the other filters (DIGI matches the digital class,
SSB folds USB/LSB). Counters, card lists and the Active/Needed-only filters all
read the same mode-filtered view, so the numbers add up to what is on screen.

And the notify alert now asks the SAME worked-slot question the tab asks —
before making a sound. It used to fire on the raw spot while the tab's verdict
arrived on a debounce, so the bell rang for a slot that showed Worked a moment
later. Judged in the backend at emit time: exact slot for named modes, digital
class for generic DATA, today-only for contest entries.
2026-08-29 01:03:37 +02:00
rouggy cb2f8aba72 fix(watchlist): the auto-contest FIELD — the backend shipped without its UI
The wiring script died on an app.go anchor mid-run: the backend half was
committed, the frontend half (the pattern field, the changed-event refresh, the
i18n keys) never ran. Replayed; the field sits after Add.
2026-08-29 00:58:46 +02:00
rouggy 7b9cda93bc style(watchlist): drop the lone '!' on needed lines
A needed line already says so twice — the amber left border and the Needed!
badge — and the exclamation mark was noise. The green tick stays on worked
lines.
2026-08-29 00:52:38 +02:00
rouggy 8f64e3fee3 feat(watchlist): the auto-contest pattern — DXHunter's contest_prefix
A field in the tab's toolbar (e.g. WWA): while non-empty, any spotted callsign
CONTAINING it joins the watchlist as a contest entry by itself — a
special-event fleet (HB9WWA, DL0WWA, F4WWA/P…) is collected as it appears
instead of typed in one by one, and each is judged per UTC day like any contest
entry. Contains, not prefix, because the event string sits anywhere in those
calls.

Global setting, cached in an atomic so the spot pipeline never touches the
settings store per spot. Emptying the field stops the collecting but keeps what
was collected — DXHunter deletes non-matching contest entries on a pattern
change, and a setting that silently empties an operator's list is the one
behaviour of the original this port refuses.

The tab also refreshes on backend auto-adds (event) and on a slow tick, so
last-seen and the counters stay honest while it sits open.
2026-08-29 00:50:14 +02:00
rouggy c4de5f2ea0 style(watchlist): a wider column, and the country stops truncating first
max-w-4xl left 'Central African R…' cut mid-name: the column grows to 5xl and
the country cell flexes with the row (capped at 14rem) instead of a hard 8rem.
2026-08-29 00:46:34 +02:00
rouggy 9172f504e5 fix(watchlist): judge generic DATA spots at digital-class grain; tidy the toolbar wrap
7.0589 falls in the band plan's generic [7.04-7.1] DATA segment — an F/H
DXpedition off the standard dials — and 'DATA' is in nobody's log, so the spot
read NEW MODE / Needed while the FT8 contact sat right there. A generic mode
cannot be matched exactly; it is now judged at digital-class grain — worked if
ANY digital mode of that call is logged on that band — against a second
class-folded slot set built beside the exact one (shared when the grouping
option already folds them). DXHunter answers this with a finer frequency table,
which fails on exactly these off-dial expeditions; the class rule cannot.

The toolbar also wraps as a whole with tighter gaps instead of dropping its
last filter chip onto a lone second line.
2026-08-29 00:43:30 +02:00
rouggy 7322e2ab66 feat(watchlist): dockable in the Main tab; sticky filters; Needed-only hides worked lines
The watchlist joins the Main-tab pane list (Settings → Main view), rendered in
a card like the other docked panels.

The three filters persist across tab switches — the component unmounts on every
switch, and filters that reset each time are filters nobody trusts.

Needed-only now hides the worked SPOT LINES as well as the all-worked cards,
which is DXHunter's own Not-Worked behaviour and the reported complaint: a
filter that says needed and still lists five green Worked rows answers a
different question than the one asked.

The add-row checkbox is relabelled 'as contest' — it marks the entry being
ADDED, and read as a filter beside the All/DX/Contest buttons it looked
redundant.
2026-08-29 00:39:07 +02:00
rouggy 29d79fb5d1 fix(watchlist): read the cluster's slot index, and cap the card width
The first version asked the alerts' worked-index — keyed by RAW mode (FT8) —
with a CLASS name (DIGI). Nothing ever matched, and a DXpedition worked on five
bands showed every slot as Needed.

The verdict now reads the cluster's own WorkedCallSlotKeys set, built in the
cached status maps (now unconditionally — it used to exist only when a display
option wanted it). Same index, same mode normalisation as the grid, so the
watchlist can never contradict the cluster about the same spot. The contest
today-set is normalised through the same function.

And the cards column is capped at a reading width instead of stretching
callsign-to-badge lines across the whole window.
2026-08-29 00:33:52 +02:00
rouggy f6b70184a7 chore(qso): drop the redundant SlotsSince free function 2026-08-29 00:26:32 +02:00
rouggy e4abcda94f feat(watchlist): the DXHunter watchlist as an OpsLog tab
The concept, transplanted: a list of callsigns or prefixes being hunted,
matched against the live spot stream, one card per entry with the spots
underneath and the two questions that matter answered on every line — is this
slot still needed, and what is it worth (the cluster's own NEW badges, read
from the same status index).

The file is DXHunter's own watchlist.json, field for field, ClubLog block
included though phase 2 will fill it — a file that round-trips unchanged is the
whole of 'same format', and a test pins it with a real DXHunter entry. Global
(dataDir), not per profile.

CONTEST is per entry, not the global mode DXHunter has: a contest entry is
judged against the current UTC day — the boundary lives in the query's date
bound, so midnight needs no timer and resets nothing. Normal entries read the
same in-memory worked index the alerts use. Prefix matching is why RI0SP
catches RI0SP/MM, pinned by test.

Notify goes through the existing alert:fired event — the frontend already
toasts and sounds it — throttled to one alert per entry per two minutes,
because a DXpedition lights every skimmer on the planet.

Tab wired like NET Control: opt-in from Tools, persisted, closable. Single
click fills the callsign, double click works the spot — the cluster's own
gesture, kept.
2026-08-29 00:25:40 +02:00
rouggy 284ee4ba7c feat(bulk): My DXCC / My CQ zone / My ITU zone
The My-station group covered nineteen fields and not the three numeric ones.
They take their own integer path rather than the generic text setter: the
columns are nullable integers, and while SQLite would coerce '14' quietly, a
shared MySQL logbook would not — and empty must become NULL, never ''. Bounds
checked (DXCC <1000, CQ 1-40, ITU 1-90) so a slip cannot stamp zone 400 across
a thousand rows.
2026-08-29 00:13:47 +02:00
rouggy 10ef984962 feat(k3): align RIT/XIT and the power step with the other consoles
'None of the consoles look alike' is the complaint ShiftRow exists to answer,
and the Elecraft panel was still driving its offset with a row of nudge buttons.
It now uses the shared control — ±, wheel, typed value — plus the same
Ctrl+←/→ 10 Hz clarifier the Icom and TCI panels have. The K3 keeps ONE offset
for RIT and XIT, so both rows show it, exactly like the Icom's.

The backend gains the absolute setter the shared control speaks
(SetKenwoodRITOffset, funnelled through the same RO write as the nudge), and
the power slider moves in 1 W steps — nobody asks a K3 for 'between 10 and 15
watts' in fives.

Also per review: the chase switches are labelled 'Chase POTA' / 'Chase SOTA',
matching 'Chase new grids' beside them.
2026-08-28 23:56:49 +02:00
rouggy bd8719ce99 feat(cluster): chase switches for POTA and SOTA
An operator who does not chase parks does not want NEW POTA shouting from every
activator spot. Two switches (Settings → DX Cluster, on by default): off, the
marker is withdrawn at the display layer — applySpotDisplay, the same chokepoint
the grid, the band map and the filter read through — so the badge, the colour,
the filter chip and the reference column all go quiet together, and a new-band +
new-POTA spot reads NEW BAND alone. The facts keep being computed; only the
telling stops, so ticking the box back on needs no rescan.
2026-08-28 23:48:22 +02:00
rouggy 5e38319379 style(settings): trim the DX Cluster panel's explanations
Per review: the digital-grouping sentence on the same-slot option, the
spot-lifetime explanation, the ring-buffer paragraph on the spots-kept field,
and the free-public-nodes line all go.
2026-08-28 23:44:01 +02:00
rouggy 7e7ad50f60 feat(k3): a mode row on the Elecraft console — CW/USB/LSB/DATA/DATA-RTTY
The report: switching the K3 to a data mode 'does not work for FT8 — the K3
does not go into DATA'. The mode digit is only half the answer on that radio:
MD6 keeps whatever DT submode the previous session left, and with FSK D still
armed the rig keys FT8 with no rear-audio modulation.

The panel buttons say the whole thing: DATA is MD6+DT0 (DATA A, the soundcard
path), DATA RTTY is MD6+DT2 (FSK D). The DT submode is also read on the
settings beat — Elecraft only, a plain Kenwood would '?;' it — so the active
DATA button follows what the rig is actually in, front-panel changes included.
A dedicated SetKenwoodPanelMode rather than the logger's SetMode: a panel
button is the operator saying exactly what the rig should do, not an ADIF mode
to be mapped through preferences.
2026-08-28 23:35:25 +02:00
rouggy 0f4e31853b fix(cluster): slot colour apart from POTA, chips match badges, NEW CALL filter works
Three from review. Aqua for NEW SLOT sat right next to the POTA green — it is
now the sky cyan the panadapter palette has shipped for new-slot all along.
The NEW COUNTY chip was green while its badge is violet, and NEW SLOT's chip
was still caution yellow: chips now carry the badge's own colour (color-mix for
the muted border/背景), because a filter that does not look like what it selects
has to be learned twice.

And the NEW CALL chip matched only the DISPLAYED status, which is manufactured
by the slot-highlight option — with that option off the chip matched nothing
and read as broken. It now filters on the fact itself (worked_slot false), so
it works either way.
2026-08-28 23:29:08 +02:00
rouggy c222468021 style(cluster): give NEW SLOT its own colour
The whole new-* family shared the warning amber while the NEW PFX marker sits in
caution yellow — two different facts, two near-identical colours in one cell.
NEW SLOT now takes aqua (chart-2), the same story the panadapter palette already
tells: its default for new-slot has been cyan all along.
2026-08-28 23:24:06 +02:00
rouggy 2fb4a4d0ce fix(spot): in split, pre-fill the RX frequency — where the station is
The Send-spot dialog took catState.freq_hz, which is the TX frequency. Working
a DXpedition split, that is where WE transmit: the spot went out five up from
the DX, pointing the whole cluster at the pile-up instead of the station. The
RX frequency is where we listen — where the DX is — and is what a spot names.
2026-08-28 23:21:17 +02:00
rouggy e4828482c4 fix(tci): send the sensor subscription in both cases
Two rounds of fixes and the SunSDR's meters are still silent. The one remaining
difference from the protocol document is the case: its examples read
TX_SENSORS_ENABLE:true,200; and ours went out lower-case. Every other command
works lower-case, but nothing proves this newer path does — a case-insensitive
server ignores the duplicate, a sensitive one finally hears it.
2026-08-28 22:54:44 +02:00
rouggy 8acfda4e42 fix(tci): resubscribe to the meters at 'ready', and log what comes back
The report from a real SunSDR: transmit power and SWR still empty after the
sensors fix. The subscription went out once, at connect — while ExpertSDR3 is
still streaming its initial state dump, exactly where a unidirectional control
command can be ignored. It is now renewed every time the server says 'ready'.

The sensor messages also join the logged-on-arrival set, capped like the rest:
the next log will say whether TX_SENSORS ever arrives, which is the question —
a radio that never sends it (AetherSDR may not) and a frame that arrived and
was dropped leave the same blank meters and need opposite fixes.
2026-08-28 22:51:25 +02:00
rouggy db24c38e63 chore: release v0.26.23 2026-08-28 22:45:55 +02:00
rouggy 06868e9ed1 style(settings): three more explanations off the General panel
The SCP paragraph, the digital-grouping parenthetical and the IARU region hint
all move into tooltips on their own controls.
2026-08-28 22:20:31 +02:00
rouggy 3ed643e072 style(settings): tidy the General panel
Per review: the panel-wide 'saved instantly' hint goes; the date-format picker
loses its embedded sample line (kept as the buttons' tooltip) so the row is one
line tall; the IARU region moves up under the date format, before the themes;
and the parentheticals after Distances-in-miles, Check-updates and the
telemetry opt-out go. The telemetry and ClubLog explanations survive as
tooltips — an opt-out about data leaving the machine, and an option changing
how entities resolve, should still say what they do somewhere.
2026-08-28 22:15:53 +02:00
rouggy f0f9898f7c feat(bandmap): draw the band plan of the operator's IARU region
The band maps carried one hard-coded table — Region 1's — so a US operator's
40 m stopped at 7200 with stations spotted at 7250 hanging past the top of the
map, and the SSB wash started 25 kHz early. The region is a station-level fact
stated once (Settings → General, portable pref); lib/bandplan owns the three
tables and publishes changes to the open maps the way the distance unit reaches
the grids.

The tables stay deliberately coarse: a band map's wash is context, not a
regulatory chart, so only the differences an operator notices are split out —
Region 2's 80 m to 4000 and 40 m to 7300 (phone from 7125), Region 3's 80 m to
3900, the wider VHF/UHF allocations. National fine print does not belong in a
background tint.
2026-08-28 21:55:37 +02:00
rouggy 73cae855ed chore(labels): park the label designer and printing on feature/labels
The feature needs more rounds than the next release can wait for, so main goes
back to before it: the packages, bindings, migration, UI and i18n all move to
the feature/labels branch, which holds every commit. The 0.26.23 block keeps
only the Station column.

The 0031 migration may already have run on a machine that launched a dev build;
the two label tables it created are inert and the recorded migration row is
harmless — the runner only applies filenames it has, so re-adding the migration
when the branch merges will skip cleanly there and apply everywhere else.
2026-08-28 21:35:50 +02:00
rouggy de3a115ca6 chore(changelog): move the post-release entries into a 0.26.23 block
The Station column, the Label Designer and the label printing all landed after
v0.26.22 was tagged; the .22 block is byte-identical to the tag again.
2026-08-28 21:31:09 +02:00
rouggy 1d7633484b fix(labels): the manager's address, not the DX's — and one PDF, just opened
Three corrections from the first real session. Routed via a manager, the box
was prefilled with the DX's own address — the one thing that must not go on
that envelope. It now starts empty and the QRZ fetch fills in the MANAGER's;
switching the routing recomputes the box unless the operator has typed in it,
because a hand-checked address is not the app's to replace.

A fetch that came back with no street was overwriting a reviewed address with a
bare country — the cty.dat fallback dressed as an answer. It now refuses to
touch the box and says nothing was found. And the prefill no longer stacks the
same town three times (address + QTH + country all carrying it).

The output is ONE PDF for the whole session, each page at its own label size,
written to the temp dir and opened straight in the viewer — no save dialog: the
file is a print run, not a document to keep.
2026-08-28 20:07:36 +02:00
rouggy 5ee0ade54b feat(labels): print the labels — queue, address review, PDF, log update
The printing session in three steps. The worklist is the paper queue (ADIF
qsl_sent R/Q), grouped by callsign since several QSOs of one station share a
card. Each recipient is reviewed before anything prints: routing first — via
manager when qsl_via says so, direct when an address is known, bureau otherwise
— then the address itself, editable and fetchable from QRZ (the MANAGER's
address when routing says via). What is printed is the reviewed text verbatim,
not a re-resolution that could differ from what was checked.

One PDF per label kind, never one file for all: a roll printer holds one stock
at a time, and a file mixing 29 mm addresses with 62 mm QSO labels could not be
printed at all. Pages are rasterised by the designer's own renderer at the
stock's dpi and carried into the PDF untouched — internal/pdf is a hand-written
image-page writer (DeviceGray + flate: monochrome, lossless, no cgo) because
that is the entire need.

Bureau stations get no address label (no envelope); return labels are printed
one per envelope. Finishing offers the log update: QSL_SENT=Y, the chosen date,
via B or D per routing — through the same BulkUpdateQSL the paper view uses.
2026-08-28 19:58:37 +02:00
rouggy e637f0814d style(labels): fit the preview to the pane
A fixed 720 px cap cut a 98 mm label off at the RST column and wasted half the
window; the scale now follows the centre pane's measured size, with a floor so
small stock never renders unreadably.
2026-08-28 19:46:54 +02:00
rouggy 1a169fdb4f feat(labels): the Label Designer — QSO and address labels for paper QSL
The designer's data model lives in internal/labels: STOCKS are the physical
roll in the printer (geometry in mm, margins, dpi — seeded with the common
Brother DK sizes, the QL family being what prompted the feature), TEMPLATES are
one design each, of two kinds: the QSO label glued on the card, whose repeating
table carries several contacts of the same station, and the address label for
the envelope, whose lines collapse when a variable is empty.

Everything is measured in millimetres — labels are sold in mm, and an operator
lining a design up against a physical sticker thinks in mm; pixels exist only in
the renderer, at the stock's dpi. The canvas renderer is shared between the
editor's preview and the future print path, so there is no second
implementation for the preview to disagree with.

The preview is fed with the log's latest contacts rather than lorem ipsum: real
data shows a too-narrow column immediately.

Printing (PDF, one page per label at exact size) is the next module; nothing
here prints yet.
2026-08-28 19:41:15 +02:00
rouggy 3dc31697cd feat(lotw): name the station callsign on each downloaded confirmation
With 'All my callsigns' the report spans F4BPO, F4BPO/P and TM2Q at once, and a
list mixing them says nothing about which one a confirmation belongs to — which
is the question that option creates.

The column appears only when the rows actually carry a station: the other
services return one, and an empty column for them would be noise.
2026-08-28 17:43:05 +02:00
rouggy 242a68080a chore: release v0.26.22 2026-08-28 17:33:35 +02:00
rouggy 8c7e1c1a3d fix(cluster): group duplicates by call AND band AND mode
The grouping keyed on the callsign alone, so every spot of a station outside the
first one's band and mode was dropped from the list. RI1FJL spotted on 17m CW,
20m, 30m, 12m and 15m FT8 showed as a single row: four slots gone from the one
view an operator uses to find them.

A duplicate is the dozen skimmers that all heard the same CQ — same station,
same band, same mode. The same station on another band is the opposite of a
duplicate; it is what the chaser is scanning for.
2026-08-28 16:25:06 +02:00
rouggy 47ddbed665 fix(tci): send the spot colour as an unsigned ARGB integer
Expert Electronics' own document gives the command whole:

    SPOT:RN6LHF,CW,7100000,16711680,ANY_TEXT;

16711680 is 0x00FF0000 — positive. This backend sent the same field as a SIGNED
32-bit value, from a third-party example: with the alpha byte set to FF for
opacity, 0xFFFFA500 becomes -22336, and a spot whose colour ExpertSDR cannot
read is dropped without a reply or an error.

Reported on ExpertSDR3 1.3, which speaks TCI 2.x — the protocol version was
never the problem, and my first reading of that report was wrong.

The first three spots are now logged verbatim: an ignored spot leaves no trace
whatsoever, so that line is the only evidence of what went out.
2026-08-28 16:23:32 +02:00
rouggy fd7ae77a61 fix(tci): subscribe to the radio's meters; fix the new-spot count; explain an SMTP refusal
TCI meters: the S-meter came only from RX_SMETER and the transmit meters from
TX_POWER / TX_SWR — commands ExpertSDR3 does not send. The protocol's answer is
a subscription (RX_SENSORS_ENABLE / TX_SENSORS_ENABLE, §4.4 of the TCI PDF),
after which the radio pushes RX_CHANNEL_SENSORS and TX_SENSORS. Nobody had
asked, so the console's meters sat empty in RX and in TX while everything else
worked.

Cluster: the held-spot counter looked for the row it froze on. A station spotted
again REPLACES its row, so that row vanishes in the ordinary course of things
and the count fell through to 'everything is new' — 4, 5, then 500. Counted by
timestamp now, which survives both the replacement and the ring buffer.

SMTP: '535 5.7.139 basic authentication is disabled' is a policy, not a typo.
The message now says so, and says what actually helps, without claiming a policy
when the server simply rejected the password.
2026-08-28 15:29:44 +02:00
rouggy 3e794f57e0 perf(cluster): widen the spot batching window with the rate of the feed
Every flush commits state the whole window re-renders on, and the window was a
flat 50 ms — twenty full renders a second under an RBN firehose. That is paid
everywhere else in the interface, and it is what a dropdown highlighting its
entries a beat behind the mouse actually is.

The window now follows the rate: 50 ms on a quiet cluster, where a spot should
appear the moment it arrives; 200 ms past five a second; 500 ms past twenty,
where half a second's delay on a line already scrolling past is invisible.
2026-08-28 15:06:08 +02:00
rouggy 30f74583ff style(cluster): one header row in the docked pane
The pane drew its own title bar above the grid's toolbar, so the cluster spent
two lines on headers where Recent QSOs beside it spent one — in a pane often
only a few spots tall. The grid's toolbar now takes what the caller wants on its
left, and the pane passes its title, live count and Filters button there.
Reported by VK4DX.
2026-08-28 13:15:41 +02:00
rouggy f7b9aa2181 fix(lotw): show TQSL's account of a refused upload, not just the error
UploadLoTW returns both a terse error ('no QSOs processed') and a Message
carrying TQSL's own lines — '17 QSO records were already uploaded', 'N QSO
records are out of date range'. The caller took the error whenever there was
one, which is precisely the half that explains nothing: the operator saw a red
line naming a condition with three unrelated causes and no way to tell which.
2026-08-28 13:10:09 +02:00
rouggy 766b0f95a4 feat(tci): key CW through the radio's own macro keyer
The sixth CW engine, and the one the TCI backend was left without. A SunSDR
keys from the WebSocket that already carries the CAT: no WinKeyer, no second
serial port, nothing to wire.

    CW_MACROS:0,<text>;     send
    CW_MACROS_SPEED:<wpm>;  speed
    CW_MACROS_STOP;         abort

Confirmed against the TCI command table in ars-ka0s/eesdr-tci — the source that
settled SPOT. CW_MACROS rather than TCI 2.0's CW_MSG: it exists from 1.6, and
OpsLog resolves its own variables, so the before/after callsign fields have
nothing to carry.

Commas and semicolons are stripped before sending. They are the protocol's own
separators: a comma would become another argument, a semicolon would end the
command with the message half keyed and the rest parsed as a command.

Only the MACRO speed is set, not the paddle keyer's — an operator who set their
paddle to 28 wpm did not ask for it to change because a macro went out at 25.
And there is no backspace: TCI can stop a message but cannot un-type one, so the
type-ahead correction the Flex engine offers is absent rather than faked.
2026-08-28 13:01:55 +02:00
rouggy 8bc4ed68d4 fix(window): place the window in absolute desktop coordinates
Wails saves and restores in two different coordinate systems. WindowGetPosition
returns GetWindowRect — absolute. WindowSetPosition is winc's ControlBase.SetPos,
which adds the CURRENT monitor's work-area origin:

    w32.SetWindowPos(hwnd, HWND_TOP, int(info.RcWork.Left)+x, ...)

On the primary monitor that origin is 0 and nothing shows. With a monitor to the
LEFT it compounds: a station with its left screen at x=-3840 saved -3844,
reopened there, had -3840 added, and stored -7684 — then -11524, one screen
further out at every launch, until the window was off the desktop entirely.

So the placement is done here, with SetWindowPos and no offset, falling back to
the toolkit when the handle cannot be found. Compact mode had the same pairing
and is fixed with it.
2026-08-28 12:48:03 +02:00
rouggy d534825e92 fix(tci): pick the sideband from the frequency just commanded
Clicking a spot sets the frequency, then the mode 150 ms later, and SetMode
chose USB or LSB from t.freqA — the frequency the radio had ECHOED. From 7 MHz
onto a 14 MHz spot that echo has often not landed, so the sideband was computed
for the band being left: LSB on 20 m. Clicking the same spot again, the echo
having arrived, gave USB. Reported from a SunSDR as 'the frequency is right, the
mode is wrong until I click twice'.

SetFrequency now records what was asked for and SetMode prefers it until the
radio's own echo confirms the move.

The server's 'protocol:' announcement is also kept instead of being filed under
unhandled: it names the ExpertSDR and its TCI version, and panorama spots need
TCI 1.5 — older servers accept the spot commands and silently drop them, which
is indistinguishable from a broken logger. Now it says so.
2026-08-28 12:42:05 +02:00
rouggy 08d6492df1 fix(bandmap): say WHOSE 'Worked' it is — the entity's, not the callsign's
The status comes from spotEntityStatus: it means the country has been worked on
this band and mode. The label said 'Worked (this band + mode already in log)',
which reads as 'I have had this station' — so a spot the cluster called NEW PFX
looked like a contradiction, when the two were answering different questions
about different subjects.

The callsign-level fact has its own marker and its own words ('Callsign already
worked'), so the two are now told apart by reading them.
2026-08-28 12:22:23 +02:00
rouggy a156b6ad10 fix(bandmap): the two tooltip labels the previous commit left out
Without them t() was handed undefined for a new prefix or grid, so the tooltip
would have shown an empty segment — the opposite of naming the fact.
2026-08-28 12:17:59 +02:00
rouggy 3bb92f79b2 fix(bandmap): name the new prefix and new grid in the tooltip
The strip deliberately shows three markers of five — the pill is 22 px and a
fourth segment makes it a colour code nobody reads. But the tooltip has room and
was filtering them out too, so the same spot read NEW PFX in the cluster and
'Worked (this band + mode already in log)' on the map. Both were true: the
entity is worked on that slot and the WPX prefix never has been. Neither view
said so.
2026-08-28 12:17:09 +02:00
rouggy 65cae0d822 perf(lotw): stop asking for the QSL details by default
A tester compared LogHX's two download options on the same account: two minutes
for the confirmations, twenty for the confirmations with QSO details. OpsLog was
always sending qso_qsldetail=yes — LogHX's slow option — which is the whole of
the wait people were reporting.

Marking a confirmation needs call, date, band and mode; the detail adds the QSL
date and the station's grid, state and county. Now a choice, off by default, and
forced on when the download is also ADDING the QSOs it cannot find — the one
case where those fields have no other source.
2026-08-28 08:54:42 +02:00
rouggy b24f880d62 chore: release v0.26.21 2026-08-28 08:48:14 +02:00
rouggy ef71ddb648 docs(changelog): open 0.26.21 with the entries written after the release
Miles and the LoTW download progress landed after v0.26.20 was tagged, so they
belonged to the next block: an entry appended to a shipped one never reaches the
operators who installed it, and misstates what that version contained. The .20
block is byte-identical to the tag again.
2026-08-28 08:47:00 +02:00
rouggy 0143a84cee fix(lotw): stop the heartbeat when the report starts arriving
Deferred, it stopped at the end of the function — so 'still waiting for LoTW to
build the report' kept printing between the megabyte lines, saying the opposite
of what was happening.
2026-08-28 08:43:06 +02:00
rouggy 82721110ed fix(lotw): count out the wait before the first byte
The progress lines only start once bytes arrive, and on a large account nothing
arrives for minutes — LoTW builds the entire report before sending any of it.
That silence WAS the complaint: the window still just said 'working'.

A heartbeat every fifteen seconds until the headers land, then the HTTP status,
then the megabytes — the first at 256 KB rather than 1 MB, so the very first
sign that it is moving comes early.
2026-08-28 08:38:34 +02:00
rouggy aec363b152 fix(lotw): show the download moving, retry a busy server, fail a dead one
Three reports of the same shape: a 503 for anything wider than a few days
before, and since the timeout was raised, a window that sits at 'working'
forever. Both are the same missing thing — nothing said what the transfer was
doing.

The body is now read in chunks and every megabyte is reported. A 503/502/504 is
retried twice, 20 s then 40 s, each attempt announced: LoTW answers 'busy' to a
wide report often enough that other loggers simply ask again. And the deadline
is no longer on the whole exchange, which either cut off a healthy slow download
or hid a dead one for twenty minutes — it is ten minutes to START answering
(LoTW builds the whole report first) and two minutes of silence once it has,
which is the difference between slow and dead.
2026-08-28 08:29:26 +02:00
rouggy 9a21c936b1 feat(units): show distances in miles
Everything is computed in kilometres and converted once at display time, in
lib/units — storing miles anywhere would give the same number two sources of
truth and a rounding error that grows with every hop.

The grids capture the unit inside their column definitions, header and formatter
both, so a change is published to them the way the date format already is;
without that a toggle would only appear after a language change or a restart.
The preference is portable, like the other display ones.
2026-08-28 08:25:02 +02:00
rouggy d8f0fd7b4f chore: release v0.26.20 2026-08-27 23:47:14 +02:00
rouggy 4a942a04c3 docs(changelog): name the SOTA cluster server in the SOTA entry 2026-08-27 23:27:26 +02:00
rouggy ecae68ca79 merge: matrix call mark, Icom scope, KPA chip, SOTA column, awards slots, LoTW download fixes 2026-08-27 23:24:34 +02:00
rouggy 9270b227b0 fix(lotw): send a date even for 'all', and give the report time to arrive
Without qso_qslsince LoTW does not answer 'every confirmation' — it answers
with a few recent ones, as a 200 and a valid ADIF. 'All' therefore downloaded a
nearly empty report and said 'matched 1 of 1' on an account holding twelve
thousand. It now asks from 1945-11-15, older than any QSO LoTW accepts.

And the request had two minutes: LoTW spends several of them building a full
account before the first byte, so asking for everything ended in 'context
deadline exceeded while reading body' — a network-shaped message for a
too-short deadline. Twenty minutes, and the read cap raised past the 18 MB such
a report actually weighs.
2026-08-27 23:15:54 +02:00
rouggy cff590fe8a fix(lotw): keep another station's confirmations out, and show a suspect report
Unscoped, the report carries every station on the account — including the calls
belonging to another profile's logbook. Those match nothing here, and with 'add
the ones not found' ticked they would pour a second log into this one. The
station callsigns this logbook actually holds are now the filter: a portable
worked here is kept, an expedition call never used here is skipped and counted.

A near-empty report is also the failure that reads as success — 'matched 1 of 1'
where the account holds twelve thousand. Under 4 KB, what LoTW answered is shown
verbatim instead.
2026-08-27 23:12:44 +02:00
rouggy 4366083152 feat(lotw): let the download cover every callsign on the account
The download passes qso_owncall so one profile does not pull another's
confirmations. That scope also hides them: a QSO made as F4BPO/P is confirmed at
LoTW and can never come back to an F4BPO profile, so it stays unconfirmed here
for good while the ARRL counts it — found while reconciling a DXCC account
against the panel, where it was the whole of the CW gap (Jersey, worked as
F4BPO/P).

Off by default: the scope is right for anyone whose profiles are separate
stations.
2026-08-27 22:43:46 +02:00
rouggy 8865af35fd feat(qsl): a QRZ button beside the paper-QSL search 2026-08-27 21:53:59 +02:00
rouggy bfad51268b style(awards): fade the confirmed cells while chasing the ones to confirm
The filter keeps a REFERENCE for its unconfirmed slots, so its confirmed ones
come along — and green on grey means the answer is the quietest thing in the
row. Faded to a quarter under that filter only, not hidden: which band is
already done is part of deciding which one to chase.
2026-08-27 21:45:46 +02:00
rouggy 0e77e8d61f feat(awards): find the slots worked but not confirmed
The Challenge line says 1832 worked against 1554 confirmed and stops there —
278 band-slots waiting for a card with nothing pointing at them. Every existing
filter works per REFERENCE, so an entity confirmed on 20 m answers 'confirmed'
and its unconfirmed 15 m contact stays invisible.

The new filter keeps the references holding at least one such slot, and the
count beside the reference total is the gap itself, recomputed over whatever the
other filters left on screen and over the bands actually displayed — so it
always adds up to the columns in front of the operator.
2026-08-27 21:38:25 +02:00
rouggy 08d316b1e0 feat(cluster): a SOTA column, and a spot click that credits the summit
The SOTA feeds put the summit in the spot's own text, so there is nothing to
poll: a regex on the comment gives the reference, and clicking the spot writes
SOTA@<ref> into the award references the way a park already wrote POTA@<ref>.

The regex is narrow on purpose — association/region-NNN, anchored — because
POTA (US-4475), WWFF (DLFF-0001) and portable callsigns (DL/SP9DPM/P) share
that field and none of them is a summit. Pinned by a table test.

Off by default: the reference is only there on the summit feeds, and an empty
column for every operator who does not chase them is worse than a checkbox.
2026-08-27 21:33:17 +02:00
rouggy b8796369ba fix(amp): the KPA was missing from the status-bar chip
The chip read a.spe ?? a.acom ?? a.pgxl, so a KPA fell through to the
not-connected fallback: red on a working amplifier, and a click that opened the
settings instead of switching to STANDBY. Reported from a KPA500 whose panel
showed band, SWR and temperature at the same time.

The card's offline text also borrowed the Acom's string, so a KPA that dropped
its link announced itself as an Acom.
2026-08-27 18:55:55 +02:00
rouggy fa1c41388d fix(icom): say when a radio has no scope stream instead of drawing nothing
The CI-V trace settles it: the IC-7851 answers 27 10 01 with FB and 27 14 00
with its mode, but refuses 27 11 in BOTH shapes — it controls its scope over
CI-V and never streams it. Its last firmware is from 2016, older than the
waveform stream itself; Hamlib's caps claim otherwise, the radio is the
authority.

Rejected both ways is a permanent answer, so it is remembered and reported:
the panadapter says the radio does not send its scope, which is the one thing a
black rectangle could not say.
2026-08-27 16:30:39 +02:00
rouggy d2cfad490a revert(icom): keep 0x27 0x11 as the scope data switch, log what the retry got
Hamlib documents the spectrum scope on the IC-785x, so a rejected 0x27 0x11 is
not proof the command is missing — and taking 0x27 0x10 for the data switch
would have blanked that operator's own scope screen when OpsLog disabled it.

Both shapes were refused within 96 ms, so neither was a timeout, and guessing
the next subcommand is how the last two rounds went. The retry now logs the
second attempt's own error and the failure points at the CI-V trace.
2026-08-27 16:25:29 +02:00
rouggy 69d78ba8c0 fix(icom): fall back to 0x27 0x10 when a rig has no 0x27 0x11
The IC-7851 rejects 0x27 0x11 in BOTH shapes: on that radio 0x27 0x10 is the
waveform-output switch itself, not a display switch. Rejected both ways, take
0x10 as the output command and switch it off on disable too — there it stops a
stream rather than blanking the operator's own scope screen.

A mode/span/edge answer also settles the selector question directly: three bytes
or more means the rig wants the main/sub form. Worth reading, because a
wrong-shaped SET is answered with silence on this firmware, and a timeout is
something the rejection retry cannot act on — which is why the fixed/centre
sets were timing out.
2026-08-27 16:18:55 +02:00
rouggy 9cf1984fb2 fix(icom): ask the radio which shape its scope commands take
The IC-7851 rejects "27 11 01" and wants "27 11 00 01" — the main/sub selector
its two scopes need. The shape was picked from a list of CI-V addresses, so an
address not on it got the wrong one and the scope stayed blank with a rejection
in the log and nothing on screen.

A rejection is an unambiguous answer, so a rejected 0x27 set is now re-sent in
the other shape and the winner remembered. A TIMEOUT deliberately is not
retried: several firmwares never ack a 0x27 set, and treating silence as a
wrong guess would flip a rig that was already working.
2026-08-27 16:14:53 +02:00
rouggy ee8fd32bf7 fix(matrix): dot the portable variants too
The header counts QSOs with the callsign through callMatch — RI1FJL/1 and
ZA/RI1FJL included — while the grid's own per-callsign columns matched the bare
string. A cell could hold one of the QSOs the header had counted and show
nothing.
2026-08-27 15:57:41 +02:00
rouggy f357dad629 feat(appearance): give the worked-with-this-call dot its own two colours
The dot is drawn over all five cell backgrounds, so it cannot borrow one of
their colours; it defaults to the theme foreground with a background ring and
now takes two overrides of its own, worked and confirmed, alongside the rest of
the matrix palette.
2026-08-27 15:29:01 +02:00
rouggy b3547364d4 style(matrix): move the worked-with-this-call dot in from the corner 2026-08-27 09:01:31 +02:00
rouggy 965ed4c792 feat(matrix): mark the slots already worked with the callsign in hand
The worked-before cell answered one question with two facts fused into it.
bandStatusCode takes the highest of them, so a confirmed entity outranks a
worked callsign — right for awards, wrong while chasing: a slot worked with the
DXpedition this morning reads "entity confirmed" and says nothing about it.

BandStatus now carries the callsign's own answer alongside the entity's, and the
cell draws a dot for it. The dot is deliberately identical everywhere — same
shape, same colour, over all five backgrounds — so it is read as one thing
rather than as five variants of the colour underneath it.
2026-08-27 08:55:37 +02:00
rouggy 32dbfbd04e chore(changelog): open 0.26.20 with the per-radio MY_RIG 2026-08-27 01:09:09 +02:00
rouggy a930a4f02d merge: MY_RIG follows the connected radio
Operating conditions describe a plan; a logged QSO asks which radio was
keying. With one rig those were the same answer, with several they are
not — so each saved radio carries its own MY_RIG, ahead of the per-band
station and doing nothing at all when left empty.
2026-08-27 01:08:19 +02:00
rouggy f98a831195 feat(cat): MY_RIG follows the radio that is connected
With one rig, MY_RIG belonged in Operating conditions and nowhere else.
With several, that becomes the wrong place: operating conditions describe
a PLAN — 'on 20 m I use the beam and the 7300' — while the question a
logged QSO asks is which radio was keying.

So each saved radio carries its own MY_RIG, consulted BEFORE the per-band
station. When the two disagree — a second rig borrowed for one evening on
20 m — the one that is transmitting is the true answer.

Left empty it changes nothing at all: the old chain (band default, then
the profile's rig) answers exactly as it did, which is what every
single-radio station will keep doing without touching a setting.
2026-08-27 01:03:27 +02:00
rouggy 9d21af9f7c chore: release v0.26.19 2026-08-27 00:55:38 +02:00
rouggy 0680181016 chore: drop a stray temp file committed by accident
theirs.tmp was scratch from comparing the GitHub readme against ours. It
had no business in the repository and none in a release.
2026-08-27 00:47:37 +02:00
rouggy afd0714301 chore(changelog): open 0.26.19 with the multi-radio switch 2026-08-27 00:47:28 +02:00
rouggy 8855eaf05a merge: several radios, switched from the status bar
The answer to 'I have two rigs' was 'make two profiles', which moves the
whole station — logbook included — to change which radio is connected.
Radios are a list now, like amplifiers, and the CAT chip switches between
them while everything else stays put.

The active radio still lives in the settings keys the rest of OpsLog
reads, so the consoles, the CAT sharing and the band-follow needed no
change at all.
2026-08-27 00:46:18 +02:00
rouggy 2594697e00 fix(cat): the chip says what YOU called the radio
It showed what the radio calls itself over CAT — 'Kenwood (911)' for a K3,
a bare 'CAT' for a Flex that reports nothing useful. That is the answer to
a question nobody asks once the rig has a name in the list, and it made
the two radios look alike in the one place they have to be told apart.

The name the operator typed comes first now, on the chip and in the menu,
and falls back to the radio's own identity when they typed none. The model
identity is still in the tooltip, where it answers 'what is actually
connected' without taking the place of 'which of my rigs is this'.
2026-08-27 00:42:26 +02:00
rouggy cf5efea3e4 fix(cat): the chip opens the radio menu with one radio too
It only became a menu from two radios, on the reasoning that a single
radio has nothing to switch to. That hides the feature from precisely the
operator who has not made a second radio yet — the click lands on a
settings dialog they did not ask for, which is what happened on the first
try. With one radio the menu now shows that radio and 'Add a radio…'.

Right-click still goes straight to the CAT settings.
2026-08-27 00:32:00 +02:00
rouggy 4689505fb9 feat(cat): several radios, switched from the status bar
The answer to 'I have two rigs' was 'make two profiles', which is a heavy
instrument for a light question: a profile carries the whole station —
logbook, callsign, awards, cluster, macros — so switching one to change
which radio is connected takes all of that with it, including a change of
logbook when the profiles point at different databases.

Radios are a list now, the way amplifiers already are. The CAT chip in the
status bar opens it, one click connects the chosen rig, and nothing else
about the station moves.

The storage keeps the ACTIVE radio in the same settings keys everything
already reads (cat.backend, cat.icom_port, …), and switching writes the
chosen entry into them and reloads the link. So the consoles, the CAT
sharing, the band-follow and every panel see exactly what they saw before
and needed no change at all — the list is a second store beside the
configuration, not a replacement for it.

Details that matter more than they look:
  - An operator who has run one rig for a year opens the list and finds it
    there, because a missing list reads as the CURRENT settings rather
    than as nothing. It is written on the first save, not on the first
    read.
  - Editing the CAT panel updates the active entry, or an edit made before
    switching away would be lost on the way back.
  - A new radio starts as a COPY of the current one: a second rig is
    usually the same shape with one port changed, and an empty form is a
    form to fill in twice.
  - Switching saves the panel first, so edits to the radio being left
    behind are kept.
  - The chip only becomes a menu with more than one radio; with one it
    opens the CAT settings exactly as it always did.
2026-08-27 00:22:19 +02:00
rouggy 23f425f95f docs(readme): restore it, and bring it up to date
THE README WAS GONE. A commit of mine last night replaced OpsLog's readme
with the text of the TCI protocol specification — 322 lines of ours for 77
of Expert Electronics' — and nobody noticed because nothing builds from
it. Restored from the commit before, then updated.

What it was missing, all of it shipped since: the Kenwood/Elecraft and
Yaesu CAT backends and their consoles, the SunSDR console, TCI audio in
both directions, CAT sharing over Hamlib rigctl or TCI, the Elecraft
KPA500/KPA1500 and the Tuner Genius XL, Cloudlog and HAMLOG uploads, the
RDA district work, the DXCC Challenge, the editable satellite list, four
main-view panes instead of two, and Esri basemaps in place of Carto.

Also a section on importing a log, which had none — including the rule
that matters most to somebody moving years of contacts across: a deleted
DXCC entity keeps the number the file gives it, because cty.dat only knows
where a callsign is today.
2026-08-26 23:38:35 +02:00
152 changed files with 20610 additions and 2152 deletions
View File
+398 -61
View File
@@ -1,77 +1,414 @@
# Transceiver Control Interface
# OpsLog
## Introduction
<a href="https://discord.gg/tmVPdQ5A8" target="_blank">
<img src="https://img.shields.io/badge/Discord-Join%20the%20server-5865F2?logo=discord&logoColor=white" alt="Join our Discord" />
</a>
TCI (Transceiver Control Interface) is a network interface for control, data transfer and
synchronization between transceiver/receiver, contest loggers, digital mode software, skimmers
and other software, as well as external power amplifiers, bandpass filter units, antenna switches,
radio controllers and other devices.
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
management and a QSL-card designer. Built with **Wails v2** (Go backend +
React/TypeScript frontend), **pure Go** (no CGO): SQLite for configuration,
optional **shared MySQL** for the logbook so several operators can run one log.
Fully themeable and bilingual (English / French).
TCI was created as a modern alternative to the outdated COM port and audio cable
interfaces, it uses a full duplex web socket protocol that runs on top of a TCP connection and
serves for server-client communications, providing cross-platform connectivity. Transceiver works
as a server, all other software and devices as clients. The server and clients can be inside the
same computer (program-server, hardware log, etc.-clients) and/or in separate physical devices
connected through the local network (classical transceiver, power amplifier, antenna switch, FFT
unit, etc.).
Developed by **F4BPO**.
The TCI interface contains basic transceiver control commands (analog of CAT system),
receives CW macros from clients and broadcasts them, outputs transceiver IQ stream to clients,
receives spots from skimmers and Internet clusters, receives/outputs audio signal to work in
digital modes.
---
The TCI uses an extensible architecture and can be supplemented with new functions and
commands, while keeping the old ones operational. Thus, the TCI interface can be extended and
supplemented to meet the specific needs of any software manufacturer and/or device
manufacturer (receivers, transceivers, power amplifiers, switches, etc.). The presence of a device
identifier allows the manufacturers of transceivers and receivers to switch to the TCI interface
while maintaining the device model designation. The extensibility of the TCI interface allows you
to create an individual set of commands and functions for each device model, while maintaining
the basic command set inherent to all transceivers.
## Building / developing
Our company advocates universal unification of data exchange between devices and
software by creating the TCI interface for this purpose. Modern transceivers and software must
communicate using one protocol - the TCI protocol.
- **Dev:** `wails dev` (Vite hot-reload; Go methods reachable at http://localhost:34115).
- **Build:** `wails build` (use the project's wails v2.11 — `~/go/bin/wails.exe`).
- **Regenerate Go↔TS bindings** after changing exported `App` methods:
`wails generate module`.
- **Release:** `.vscode/release.ps1` (Ctrl+Shift+P → *Tasks: Run Task*
*Release OpsLog*) — bumps the version, pushes source to Gitea, builds the exe
and publishes it to Gitea + GitHub releases.
## Interface description
---
Any command represents an ASCII string that contains a command name and a list of
arguments corresponding to this command. There are reserved characters that cannot be
included in the command name and command arguments.
## Logging
List of reserved characters: «:», «,», «;».
- **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
and the QRZ.com tab.
- **Offline DXCC** resolution from `cty.dat` (country, CQ/ITU zones, continent),
with `/MM` `/AM` `/B` (beacon) and call-area (`/8`, `/W6`) handling, plus
ClubLog DXpedition date overrides.
- **Editable lists** for bands, modes (with their default RST) and **satellites**
— the satellite name on the entry form is offered as a dropdown, because
SAT_NAME is compared character for character by the awards and by LoTW.
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
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
same day / minute) and lets you pick which to delete.
- **ADIF 3.1.7 compliant** import/export: a full field dictionary, 30 promoted
columns, a generic "extra fields" editor and standard/all export modes.
- **Profiles:** every setting is per-profile; each profile can point its logbook
at the local SQLite file or a **shared MySQL** database (multi-operator).
Command structure:
1. Name of the command;
2. Separating character between command name and arguments «:»;
3. Separating character between arguments «,»;
4. End of the command character «;».
## Maps & antenna
If a command has no arguments, an end of command symbol is placed after the command
name. If the command is invalid, it is ignored. The case of letters does not matter.
- **Main view = two configurable panes** (per profile, Settings → General →
*Main view*): great-circle map, locator (street) map, the cluster grid, the
worked-before grid, recent QSOs, the **FT decodes**, or any of the radio
consoles — **FlexRadio**, **Icom**, **Yaesu**, **Elecraft K3/K4**, **SunSDR
(TCI)** — and the **Net control** panel. Up to four panes, in columns or a
quadrant.
- **Great-circle map** with short/long-path distance & azimuth, selectable
basemaps (Light / Topo / Street / Satellite — Esri tiles, 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** (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.
The ExpertSDR3 program acts as a server, which can have several client connections at
the same time, they will be synchronized with each other by the server. When connecting to the
ExpertSDR3, the client receives the current status of the ExpertSDR3, first sending initialization
commands, then parameters to set the status, such as frequency, modulation, etc.
## DX Cluster
When a parameter change occurs in the ExpertSDR3 (server) program, the server notifies
all connected clients, i.e., clients do not need to poll the server constantly, any change of state
will be sent in time to all clients. If the client sends a new state, the server will set it to itself, as
well as send it to all clients, that is, the server acts as a synchronizer. All clients connected to the
server will be automatically synchronized. This way of work allows to minimize network load,
reducing traffic.
- Multiple cluster servers with auto-reconnect, a master for commands.
- **Filter sidebar** (callsign search, hide-worked, group duplicates, band /
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). 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:** rules on call / country / band / mode /
spotter, with sound, visual and e-mail notification (Tools → *Alert
management*).
The TCI protocol implements the transmission of receiver IQ stream to clients, which is
necessary for the work of special skimmer software, they automatically find the station and
decode it throughout the band, and it also allows you to record radio signals in the file.
## CAT control
TCI is also used to transmit audio signals of the receiver to clients and to receive audio
signals from clients, i.e., the client can transmit audio signals to ExpertSDR3 for radio
transmission. The audio stream exchange is designed to work with digital modes, where encoding
and decoding is performed by third-party software, as well as in voice modes, where audio macros
can be broadcasted, which is very much in demand in contest loggers.
Six native backends (Settings → CAT), each with auto-reconnect and a fast,
non-blocking connect so a powered-off radio never freezes the app. The settings
ask two questions rather than one — **which radio**, then **how it is
connected** — and only offer the links that radio actually has:
When working in contests, it is important to record all on the air operation, for this purpose
the audio stream from the line-output is sent to all clients. The resulting audio stream can be
recorded to a file or played back with a PC sound card.
- **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; 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.
- **Kenwood / Elecraft** — native ASCII CAT over USB or an RS232-to-Ethernet
bridge, for the K3 / K4 and the Kenwood dialect they share.
- **Yaesu** — native ASCII CAT (FA/FB/MD/VS…), which replaces OmniRig for an
FTDX and gives the Yaesu console below.
- **TCI** (WebSocket) — SunSDR / ExpertSDR3 and any TCI-compatible server:
freq / mode / PTT / split, panorama spots, **audio in both directions** and a
full console. See *TCI audio* below.
**CAT sharing:** OpsLog can hand the radio on to other programs while it holds
the port — a **Hamlib NET rigctl** server (WSJT-X, JTDX, MSHV, fldigi…) or a
**TCI** server. One or the other, on a port you choose.
Mode is taken from the radio; the digital sub-mode (FT4 vs FT8) is inferred from
the frequency. **Per-band Flex RX/TX antennas** can be configured and are applied
automatically on band change.
### FlexRadio control tab (SmartSDR-style)
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):** 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:** 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.
### Elecraft K3 / K4 control tab
Shown for the Elecraft and Kenwood backends: power, AF/RF gain, mic gain,
squelch, preamp/attenuator, NB/NR/AGC, filter widths (200 Hz to 4.0 kHz, the
K3 maximum and the one FT8 wants), antenna selection, MOX and the ATU
(tap to tune, hold for in/out), RIT/XIT with ±10 / ±100 steps, key speed, and
live S / power / SWR meters. The SWR is read from the radio's own `SW;`.
### Yaesu control tab
Shown for the Yaesu backend: power, mic gain, AGC, NB/NR, preamp/attenuator,
filter width, RIT/XIT and the meters — the same shape as the others, driven by
the FTDX's own CAT set.
### SunSDR console (TCI)
Shown for the TCI backend. Drive and tune drive, mic gain, TUNE, power and SWR
while transmitting, volume, mute, squelch and its threshold, NB / NR / ANF,
APF in CW, AGC speed, filter widths **offered per mode**, RIT and XIT, and the
VFO lock. Levels can be dragged, scrolled, stepped or typed. The S-meter reads
real dBm, so its S-units are arithmetic rather than a calibration guess.
It costs nothing to keep open: TCI **pushes** every setting as it changes —
including changes made in the radio's own window, which the console follows
without asking anything.
### Icom control tab
Shown when the CAT backend is Icom (USB or network). A full RS-BA1-style console:
- **Twin VFO readout** (MAIN / SUB) with the big tabular frequency, mode badge,
band and RIT/ΔTX offset, and a **mode-button row** (SSB / CW / RTTY / PSK /
AM / FM).
- **Spectrum scope + waterfall** (panadapter): ON/OFF, CTR/FIX, double-click to
tune, and **◀ ⊙ ▶** buttons to centre the scope on the current frequency
(±50 kHz) and pan left/right.
- **Live meters** always visible: S-meter (click → fill RST), power in watts, SWR.
- **Receive DSP:** AF / RF gain, squelch, AGC, preamp, attenuator, filter
(FIL1/2/3), NB, NR, ANF and — **on CW only** — the **APF** (audio peak filter).
- **Passband / notch:** Twin PBT (inner / outer), manual notch + position.
- **Transmit:** RF power, MOX, TUNE, **split with an automatic offset**
(+5 kHz on SSB, +1 kHz on CW), and monitor. On **voice modes only**: mic gain,
speech compressor, VOX (+ gain + anti-VOX). Controls that don't apply to the
current mode are hidden automatically.
- **Bands & antenna:** one-touch band buttons and ANT1/ANT2 selection.
- **Clarifiers:** RIT and ΔTX with wheel / ± tuning (Ctrl+←/→ nudges RIT).
- **Power ON / OFF** buttons (manual by design — the app never wakes the rig on
connect).
- **CW keying** can run through the radio's own keyer (see *Keyers* below).
### Remote Icom (over the internet, no RS-BA1)
OpsLog speaks the IC-7610's built-in network protocol directly — it **replaces
both the Icom Remote Utility and RS-BA1**. Enter the radio's IP, the Network
User1 name/password and the CI-V address, and the whole Icom console works over
the LAN/internet: login + token (auto-renewed), CI-V tunnel, receive-side
retransmit for a rock-solid link even with the panadapter streaming, and manual
power ON/OFF. (Audio is out of scope — use the radio in USB + a voice link such
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), **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.
### TCI audio — no virtual cable
A SunSDR carries its audio on the same WebSocket as its commands, so OpsLog can
take it directly. Pick **Radio (TCI network audio)** as the *From radio* or
*To radio* device and the QSO recorder and the voice keyer work with no virtual
audio cable, no second sound card and nothing to set in the Windows mixer.
Transmit needs no setting up in ExpertSDR3 either: the audio source is named on
each key-down, so it works in SSB as well as the digital modes, and the
microphone stays the source for every transmission that is not a voice message.
## Amplifiers & switches
- **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.
- **Elecraft KPA500 / KPA1500** over **USB** (serial) or — on the KPA1500 —
over the **network**. Operate/standby, on/off, forward power, SWR,
temperature, supply voltage and current, the band, and faults named in plain
words rather than as a code. The amplifier follows the radio's band by
itself, on the link already open — it has a band command of its own, so no
second serial port is involved.
An amplifier that takes its band from a transceiver instead (Acom, SPE) can be
followed a second way: OpsLog answers its frequency polls as a Kenwood rig on
a **second COM port**, independent of the control link.
- **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
antenna-switch widget, and an option to write the **selected antenna into
MY_ANTENNA** under the name the switch itself carries.
- **Tuner Genius XL** (4O3A) over TCP — SWR and power, TUNE / BYPASS / OPERATE.
- **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
- **Awards engine:** built-in + custom award definitions (shared **globally**
across profiles) — DXCC, WAS / WAZ / WAC, WPX, IOTA / POTA / SOTA / WWFF,
**DDFM**, worked/confirmed/validated by band & mode, OR rules and manual
reference assignment, live reference detection on call entry, **reference-list
import** for totals/names, and a **Rescan** that re-pulls the logbook (picks up
fresh LoTW/QRZ confirmations).
- **Award statistics** by band and by mode class, with the **DXCC Challenge**
worked out where it can be compared to LoTW's own figure — it counts confirmed
band-slots on ten bands, and 60 m is not one of them, which is the whole of the
usual discrepancy. The band columns follow the bands you actually have contacts
on, up to and beyond 23 cm.
- **Russian districts (RDA):** an offline district database that knows where a
callsign was **on the day of the contact**, a bulk fill for existing QSOs, and
a comparison against the district an imported log carries — each disagreement
settled on its own row, with the choice written into the contact.
- **QSL services:** ClubLog (batched ADIF upload), LoTW, QRZ.com, eQSL,
**Cloudlog / Wavelog** and **HAMLOG.online** — upload and **confirmation
download** (which auto-refreshes the award stats).
- **QSL Card Designer** (see below).
- **E-mail eQSL:** right-click a QSO → *Send eQSL by e-mail* via the configured
SMTP account. (Outlook/Hotmail disable basic-auth SMTP — use Gmail with an app
password, or a Microsoft app password.)
## Contest logging
- **Contest tab:** pick a contest (built-in ADIF `CONTEST_ID` list) and an
exchange (running serial or a fixed exchange). OpsLog auto-fills `CONTEST_ID`
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**),
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, 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
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 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
- **Themes:** four complete themes (Warm light, Warm dark, Graphite dark, High
contrast) plus **Auto** (follows the OS light/dark preference), selectable in
Settings → General. Every panel and every AG-Grid table follows the theme.
- **Bilingual:** full **English / French** UI, with a first-run flag chooser and
a switcher in Settings → General.
## Security
- **Secret vault:** opt-in passphrase encryption of the stored passwords
(AES-GCM + PBKDF2). Encrypted values are portable; a single unlock prompt at
launch decrypts them for the session.
## Integrations (outbound)
- **UDP emitters:** push the current frequency to **PstRotator**, radio info in
**N1MM `RadioInfo`** format, or an **ADIF record on each logged QSO** — so
external tools (rotator control, digital apps, other loggers) stay in sync.
- **CAT sharing:** a **Hamlib NET rigctl** or **TCI** server, so WSJT-X, JTDX,
MSHV, fldigi and the rest still reach the radio while OpsLog holds the port.
- **WSJT-X / JTDX / MSHV** decodes over UDP: the heard stations feed the band
map, the alerts and — on a FlexRadio — the panadapter.
## Other
- **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 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).
---
## QSL Card Designer
Tools → *QSL Card Designer…* turns a few photos into a polished eQSL card:
1. Pick 16 photos (jpeg/png). OpsLog analyzes them offline (detail/luminance
grid) and proposes **3 designs** — callsign in the calmest zone of the best
photo, operator name, CQ/ITU zones + locator line, country flag, the other
photos as bordered inserts, and a per-QSO confirmation box.
2. Pick a proposal and fine-tune it: click an element to select, drag to move,
change font / style preset (gel gold, gel silver, classic white outline,
script, flat) and per-preset knobs in the right panel.
3. Save the template (photos are copied into `data/qsl/templates/<id>/`, so the
originals can move). One template can be the default per profile.
Sending: right-click a QSO → *Send eQSL by e-mail*. The card is rendered with
that QSO's data, rasterized to a ≤ 800 KB JPEG, archived in `data/qsl/outbox/`
and sent through the configured SMTP account to the address found by the
QRZ/HamQTH lookup. On success the QSO is stamped `EQSL_SENT=Y` (ADIF). The
e-mail subject/body templates live in the designer
(`{CALL} {DATE} {BAND} {MODE} {MYCALL}` variables).
Fonts: Archivo Black, Lilita One, Baloo 2, Oswald, Great Vibes, Allura (all
OFL, embedded — licenses in `internal/qslcard/assets/fonts/`); Cooper Black is
offered when MS Office installed it. Flags: flag-icons (MIT), embedded for the
commonly-worked DXCC entities.
---
## Importing a log
- **ADIF import** with duplicate handling (skip / update / import anyway) and an
optional field mapping for exports that use their own names.
- Optionally recompute country / continent / DXCC / zones from **cty.dat** and
the **ClubLog date-ranged exceptions** — which know that a callsign belonged to
a different entity on an older date.
- **Deleted DXCC entities are left alone.** cty.dat only knows where a callsign
is *today*: R1MVI resolves to European Russia now, but a 2004 contact was Malyj
Vysotskij — an entity the ARRL deleted in 2012 and which can never be worked
again. Where a record names one of the sixty-odd deleted entities, its own
number is kept.
## Data & storage
- **Config** (settings, profiles, rigs/antennas, cluster nodes, lookup cache,
award lists, QSL templates) always lives in the local SQLite file under
`data/` — instant even when the logbook is on a far-away MySQL.
- **Logbook** (QSOs) lives where the active profile points it: the local SQLite
file or a per-profile shared **MySQL** database.
---
*A French version of this document is available in [README.fr.md](README.fr.md).*
+1368 -95
View File
File diff suppressed because it is too large Load Diff
+153
View File
@@ -0,0 +1,153 @@
package main
import (
"strings"
"hamlog/internal/applog"
"hamlog/internal/dxped"
)
// ── DXpeditions (ADXO announcements + DX-World news) ────────────────────
//
// What a logger can say that a news reader cannot: whether the announced
// operation is worth chasing. Every activation is judged against THIS log —
// the same verdict the cluster paints on a spot — so the list reads as "what I
// still need", not "what is on the air".
// DXpedition is one announced operation plus what it is worth here.
type DXpedition struct {
dxped.Activation
// Status is the strongest verdict across the announced callsigns, bands and
// modes: "new" (entity never worked) beats "new-band-mode", which beats
// "new-band", "new-mode", "new-slot", and finally "worked". Empty when the
// callsign resolves to no entity — a prefix ADXO knows and cty.dat does not.
Status string `json:"status_chase"`
// Unconfirmed marks a need that is only a missing QSL, so the badge can be
// drawn dimmed exactly as it is in the cluster and the decode list.
Unconfirmed bool `json:"unconfirmed"`
}
// chaseRank orders the verdicts from "most worth chasing" down. The DXpedition
// list shows ONE badge, so the ranking is the whole decision.
var chaseRank = map[string]int{
"new": 6,
"new-band-mode": 5,
"new-band": 4,
"new-mode": 3,
"new-slot": 2,
"worked": 1,
}
// GetDXpeditions returns the announced operations, freshest feed permitting,
// each carrying its chase verdict.
func (a *App) GetDXpeditions() ([]DXpedition, error) {
if a.dxped == nil {
a.dxped = dxped.New()
}
acts, err := a.dxped.Activations(a.ctx)
if err != nil {
applog.Printf("dxped: adxo fetch: %v", err)
if len(acts) == 0 {
return nil, err
}
// Stale data with a logged error beats an empty tab.
}
out := make([]DXpedition, 0, len(acts))
for _, act := range acts {
out = append(out, DXpedition{Activation: act, Status: "", Unconfirmed: false})
}
a.judgeDXpeditions(out)
return out, nil
}
// judgeDXpeditions fills in the chase verdict, in place.
//
// One ClusterSpotStatuses call for the whole list rather than one per row: the
// worked-index it builds is the expensive part (a full pass over the log), and
// asking it forty times to answer forty rows was the difference between a tab
// that opens and a tab that stalls a remote MySQL for a second.
func (a *App) judgeDXpeditions(list []DXpedition) {
if a.qso == nil || len(list) == 0 {
return
}
type slot struct{ row int }
var queries []SpotQuery
var owners []slot
for i, d := range list {
calls := d.Calls
if len(calls) == 0 {
if c := strings.ToUpper(strings.TrimSpace(d.Callsign)); c != "" {
calls = []string{c}
}
}
for _, call := range calls {
// No announced band/mode: ask the entity-level question alone.
if len(d.Bands) == 0 && len(d.Modes) == 0 {
queries = append(queries, SpotQuery{Call: call})
owners = append(owners, slot{i})
continue
}
bands := d.Bands
if len(bands) == 0 {
bands = []string{""}
}
modes := d.Modes
if len(modes) == 0 {
modes = []string{""}
}
for _, b := range bands {
for _, m := range modes {
queries = append(queries, SpotQuery{Call: call, Band: b, Mode: m})
owners = append(owners, slot{i})
}
}
}
}
if len(queries) == 0 {
return
}
res := a.ClusterSpotStatuses(queries)
for i, r := range res {
if i >= len(owners) {
break
}
row := owners[i].row
if chaseRank[r.Status] > chaseRank[list[row].Status] {
list[row].Status = r.Status
list[row].Unconfirmed = r.UnconfStatus
}
}
}
// GetDXWorldNews returns the DX-World headlines, with the callsigns mined out
// of each one so the reader can watch them.
//
// Deliberately NOT judged against the log. A headline names no band, so the
// only verdict available is entity-level, and a pane of "NEW DXCC" and
// "worked" badges turned out to say nothing an operator could act on — the
// same two words on every row is noise wearing the clothes of information.
// The announcements pane, which knows the bands and modes, is where a chase
// verdict is worth drawing.
func (a *App) GetDXWorldNews() ([]dxped.News, error) {
if a.dxped == nil {
a.dxped = dxped.New()
}
news, err := a.dxped.News(a.ctx)
if err != nil {
applog.Printf("dxped: dx-world fetch: %v", err)
if len(news) == 0 {
return nil, err
}
}
return news, nil
}
// RefreshDXpeditions drops both caches so the next read goes to the network.
// Wired to the tab's refresh button: an operator who has just read of a landing
// on a cluster should not wait out the cache to see it here.
func (a *App) RefreshDXpeditions() {
if a.dxped == nil {
a.dxped = dxped.New()
}
a.dxped.Invalidate()
}
+1
View File
@@ -20,6 +20,7 @@ var deniedCallHashes = map[string]struct{}{
"0741c9e394b42f43191899105553b47155ddc3026da12b5360701f9c181ff123": {},
"ab4926a3a0ab76d41b5b99cd3ad0683584970c341c29427c1dfa4b3c329ce415": {},
"9d17c9c213a6cc89c12d7520bcf21c86a0cf43d17e82e74f33f3a77cb865d28a": {},
"94ee059335e587e501cc4bf90613e0814f00a7b08bc7c648fd865a2af6a22cc2": {},
}
// callDenied reports whether a callsign is on deniedCallHashes. The call is
+49
View File
@@ -0,0 +1,49 @@
package main
// The voice keyer, through an Icom's network link — the Icom face of what
// app_tci_dvk.go does for a SunSDR: selecting the radio as the "To radio"
// output hands messages to the 50003 audio session instead of a sound card.
// The same PTT before and after, the same gain, the same files.
import (
"fmt"
"hamlog/internal/applog"
"hamlog/internal/audio"
"hamlog/internal/cat"
)
// icomTXPlayer hands one message to the radio. Fetched on the CAT goroutine,
// played off it — a ten-second message must not freeze frequency, mode and
// PTT handling for ten seconds (see tciTXPlayer, which set the pattern).
func (a *App) icomTXPlayer(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
if a.cat == nil {
return fmt.Errorf("CAT not initialized")
}
type txPlayer interface {
PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error
}
var player txPlayer
err := a.cat.IcomDo(func(ic cat.IcomController) error {
p, ok := ic.(txPlayer)
if !ok {
return fmt.Errorf("this radio cannot take transmit audio over its CAT link")
}
player = p
return nil
})
if err != nil {
return err
}
return player.PlayTXAudio(pcm, rate, ch, bits, stop)
}
// installIcomTXPlayer offers the network Icom as an audio output, or withdraws
// it. Withdrawing matters as much as offering — see installTCITXPlayer.
func (a *App) installIcomTXPlayer(on bool) {
if !on {
return // the reloadCAT preamble already cleared the network player
}
audio.SetNetworkPlayer(a.icomTXPlayer)
applog.Printf("icom net: the radio is available as an audio output — the voice keyer can play to it without a cable")
}
+11
View File
@@ -24,6 +24,17 @@ func (a *App) GetKenwoodState() cat.KenwoodTXState {
return st
}
// SetKenwoodPanelMode sets the operating mode from the panel's mode row —
// CW / USB / LSB / DATA (soundcard, DT0) / RTTY (FSK D, DT2).
func (a *App) SetKenwoodPanelMode(mode string) error {
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodPanelMode(mode) })
}
// SetKenwoodRITOffset sets the RIT/XIT offset to an absolute value in Hz.
func (a *App) SetKenwoodRITOffset(hz int) error {
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodRITOffset(hz) })
}
func (a *App) SetKenwoodPower(w int) error {
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodPower(w) })
}
+246
View File
@@ -0,0 +1,246 @@
package main
// Several radios, and the one that is on the air.
//
// Until now the answer to "I have two rigs" was "make two profiles", which is a
// heavy instrument for a light question: a profile carries the whole station —
// logbook, callsign, awards, cluster, macros — and switching one to change
// which radio is connected takes all of that with it, including a logbook
// change if the profiles point at different databases.
//
// So radios are a LIST, the way amplifiers already are, and switching is one
// click on the CAT chip in the status bar. Nothing else about the station
// moves.
//
// The storage deliberately keeps the ACTIVE radio in the settings keys the rest
// of OpsLog already reads (cat.backend, cat.icom_port, …). Switching writes the
// chosen entry into those keys and reloads the link, so every consumer — the
// consoles, the CAT sharing, the band-follow, the panels — sees exactly what it
// saw before and needed no change at all. The list is a second store beside it,
// not a replacement for it.
import (
"encoding/json"
"fmt"
"strings"
"time"
"hamlog/internal/applog"
)
const (
// keyRadiosList holds the saved radios as JSON.
keyRadiosList = "cat.radios.json"
// keyRadioActive is the id of the one currently connected.
keyRadioActive = "cat.radios.active"
)
// RadioConfig is one saved radio: a name and the CAT settings that reach it.
type RadioConfig struct {
ID string `json:"id"`
Name string `json:"name"`
// Settings is the whole CAT configuration for this radio — the same shape
// the CAT panel has always edited, so a saved radio is exactly "what the
// settings said the day it was saved".
Settings CATSettings `json:"settings"`
// MyRig is what goes into MY_RIG on a QSO made with this radio.
//
// It belongs here rather than in Operating conditions once there is more
// than one rig: the operating conditions describe a PLAN — "on 20 m I use
// the beam and the 7300" — while this is the fact of which radio is keying.
// Left empty, nothing changes: the old chain (band default, then the
// profile's rig) answers exactly as it did.
MyRig string `json:"my_rig"`
}
// radioLabel is what the status bar shows when the operator never named one.
func radioLabel(c RadioConfig, i int) string {
if n := strings.TrimSpace(c.Name); n != "" {
return n
}
if b := strings.TrimSpace(c.Settings.Backend); b != "" {
return strings.ToUpper(b)
}
return fmt.Sprintf("Radio %d", i+1)
}
// GetRadios returns the saved radios.
//
// When nothing was ever saved, the CURRENT settings are presented as the single
// entry — so an operator who has been running one rig for a year opens the list
// and finds it there, rather than an empty box suggesting their configuration
// has been lost. It is persisted on the next save, not here: reading a list
// should not write one.
func (a *App) GetRadios() ([]RadioConfig, error) {
if a.settings == nil {
return nil, fmt.Errorf("db not initialized")
}
raw := a.settingOr(keyRadiosList, "")
if strings.TrimSpace(raw) != "" {
var list []RadioConfig
if err := json.Unmarshal([]byte(raw), &list); err == nil && len(list) > 0 {
return list, nil
}
}
cur, err := a.GetCATSettings()
if err != nil {
return nil, err
}
return []RadioConfig{{ID: "radio-1", Name: "", Settings: cur}}, nil
}
// SaveRadios stores the list, giving an id to anything new.
func (a *App) SaveRadios(list []RadioConfig) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
for i := range list {
if strings.TrimSpace(list[i].ID) == "" {
list[i].ID = fmt.Sprintf("radio-%d-%d", time.Now().Unix(), i)
}
}
b, err := json.Marshal(list)
if err != nil {
return err
}
return a.settings.Set(a.ctx, keyRadiosList, string(b))
}
// ActiveRadioID is the id of the radio currently on the air, or the first one
// when nothing was ever chosen.
func (a *App) ActiveRadioID() string {
id := strings.TrimSpace(a.settingOr(keyRadioActive, ""))
list, err := a.GetRadios()
if err != nil || len(list) == 0 {
return id
}
for _, r := range list {
if r.ID == id {
return id
}
}
// The stored id names a radio that has since been deleted. The first one is
// a better answer than an empty selection: the CAT link is up, and it is up
// on SOMETHING.
return list[0].ID
}
// RadioListEntry is what the status-bar menu needs: enough to draw a row.
//
// Name is what the OPERATOR typed, empty when they typed nothing; Label is what
// to draw when there is no better idea. The two are separate because the caller
// has a better idea than we do: the status bar knows what the radio calls
// itself over CAT, and "FTDX10" beats "Radio 2" — but only where the operator
// has not given it a name of their own, which beats both.
type RadioListEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Label string `json:"label"`
Backend string `json:"backend"`
Active bool `json:"active"`
}
// ListRadios is the status bar's view of the list.
func (a *App) ListRadios() []RadioListEntry {
list, err := a.GetRadios()
if err != nil {
return nil
}
active := a.ActiveRadioID()
out := make([]RadioListEntry, 0, len(list))
for i, r := range list {
out = append(out, RadioListEntry{
ID: r.ID, Name: strings.TrimSpace(r.Name), Label: radioLabel(r, i),
Backend: r.Settings.Backend, Active: r.ID == active,
})
}
return out
}
// SetActiveRadio connects the given radio and leaves the rest of the station
// alone.
//
// The chosen entry's settings become THE CAT settings — SaveCATSettings writes
// them and restarts the link — so switching rigs is the same operation as
// editing the CAT panel and pressing Save, which is a path that already works
// everywhere it needs to.
func (a *App) SetActiveRadio(id string) error {
list, err := a.GetRadios()
if err != nil {
return err
}
id = strings.TrimSpace(id)
for i, r := range list {
if r.ID != id {
continue
}
// Persisted BEFORE the link is rebuilt: reloadCAT can take a moment on a
// radio that is switched off, and an operator who closes OpsLog during
// that moment should still come back to the rig they chose.
a.setSetting(keyRadioActive, id)
// The list is written back as well when it was only ever implicit, so
// the first switch is also what makes the list real.
if strings.TrimSpace(a.settingOr(keyRadiosList, "")) == "" {
_ = a.SaveRadios(list)
}
applog.Printf("cat: switching to %q (%s)", radioLabel(r, i), r.Settings.Backend)
return a.SaveCATSettings(r.Settings)
}
return fmt.Errorf("no radio with id %q", id)
}
// syncActiveRadio writes the settings just saved into the active entry of the
// list, so the list and the live configuration never disagree.
//
// Only when a list actually exists: an operator with one radio who has never
// opened the list has nothing to keep in step, and writing one here would
// create a list as a side effect of saving the CAT panel.
func (a *App) syncActiveRadio(s CATSettings) {
if a.settings == nil || strings.TrimSpace(a.settingOr(keyRadiosList, "")) == "" {
return
}
list, err := a.GetRadios()
if err != nil {
return
}
id := a.ActiveRadioID()
for i := range list {
if list[i].ID == id {
list[i].Settings = s
_ = a.SaveRadios(list)
return
}
}
}
// activeRadioMyRig is the MY_RIG of the radio currently connected, or "".
//
// Consulted BEFORE the per-band default, and deliberately: the band default is
// what the operator planned to use on that band, this is which radio is
// actually on the air. When they disagree — a second rig borrowed for one
// evening on 20 m — the one that is transmitting is the true answer.
func (a *App) activeRadioMyRig() string {
if a.settings == nil || strings.TrimSpace(a.settingOr(keyRadiosList, "")) == "" {
return "" // no list was ever made: nothing to say, nothing changes
}
list, err := a.GetRadios()
if err != nil {
return ""
}
id := a.ActiveRadioID()
for _, r := range list {
if r.ID == id {
return strings.TrimSpace(r.MyRig)
}
}
return ""
}
// ActiveRadioMyRig exposes the connected radio's MY_RIG to the frontend, which
// pre-fills the entry form's My-station fields on every band change. Without
// this the per-band default landed in the field FIRST, and the log-time
// priority (the radio that is keying beats the radio that was planned) never
// ran — the field was no longer empty by the time the backend looked.
func (a *App) ActiveRadioMyRig() string {
return a.activeRadioMyRig()
}
+1
View File
@@ -25,6 +25,7 @@ var sensitiveSettingKeys = map[string]bool{
keyExtLoTWKeyPassword: true,
keyExtLoTWWebPassword: true,
keyExtHRDLogCode: true,
keyExtHamqthPassword: true,
keyExtEQSLPassword: true,
keyExtCloudlogAPIKey: true,
// The web-publish config is one JSON blob and the FTP password lives inside
+381
View File
@@ -0,0 +1,381 @@
package main
// Watchlist bindings — the DXHunter watchlist concept as an OpsLog tab.
// The store lives in internal/watchlist (global watchlist.json, DXHunter's own
// schema); this file is the Wails boundary plus the two places the list meets
// the rest of the app: the spot stream (MarkSeen + alert) and the logbook (the
// worked-today answer contest entries are judged by).
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"hamlog/internal/applog"
"hamlog/internal/qso"
"hamlog/internal/watchlist"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// The auto-contest pattern, DXHunter's contest_prefix: while non-empty, any
// spotted callsign CONTAINING it is added to the watchlist as a contest entry
// by itself — a special-event fleet (HB9WWA, DL0WWA, F4WWA…) is collected as
// it appears instead of typed in one by one. Held in an atomic so the spot
// pipeline never touches the settings store per spot.
func (a *App) GetWatchlistContestPattern() string {
if v := a.watchPattern.Load(); v != nil {
return v.(string)
}
return ""
}
// SetWatchlistContestPattern stores the pattern (global, like the list itself).
// Emptying it stops the auto-add; entries already collected stay — deleting an
// operator's list because a setting changed is DXHunter behaviour this port
// deliberately drops.
func (a *App) SetWatchlistContestPattern(p string) {
p = strings.ToUpper(strings.TrimSpace(p))
a.watchPattern.Store(p)
a.setSettingGlobal(keyWatchlistContestPattern, p)
}
// The named contest callsigns: the other half of the auto-add, and the half the
// pattern cannot express.
//
// A special-event fleet is usually a common string in the callsign — WWA in
// TM29WWA, HB9WWA, F4WWA/P — and the pattern collects those on sight. But an
// entry list is not a naming convention: R7W can be part of the same event and
// share nothing with it, and no pattern will ever catch that station without
// catching half the band with it. So the two work together: the pattern for the
// family, this list for everybody else.
//
// Stored GLOBALLY, like the pattern and the list itself — an event is worth
// hunting whichever station profile is on.
func (a *App) GetWatchlistContestCalls() string {
return a.settingOr(keyWatchlistContestCalls, "")
}
// SetWatchlistContestCalls stores the list as typed and caches the parsed set.
//
// The RAW text is what is stored: the operator's line breaks and their order
// are how the list is read back a week later, and rewriting it into a
// normalised single line loses the only structure it has.
func (a *App) SetWatchlistContestCalls(list string) {
a.setSettingGlobal(keyWatchlistContestCalls, list)
a.watchCalls.Store(parseContestCalls(list))
applog.Printf("watchlist: %d named contest callsigns", len(parseContestCalls(list)))
}
// parseContestCalls splits the box into a set. One per line is what is asked
// for, and commas, semicolons and spaces are accepted too: a list pasted from
// an announcement arrives in whatever shape the announcement used.
func parseContestCalls(list string) map[string]struct{} {
out := map[string]struct{}{}
for _, tok := range strings.FieldsFunc(strings.ToUpper(list), func(r rune) bool {
return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
}) {
if tok = strings.TrimSpace(tok); tok != "" {
out[tok] = struct{}{}
}
}
return out
}
// isNamedContestCall answers the hot path from the cached set.
func (a *App) isNamedContestCall(call string) bool {
v := a.watchCalls.Load()
if v == nil {
return false
}
set, ok := v.(map[string]struct{})
if !ok {
return false
}
_, found := set[strings.ToUpper(strings.TrimSpace(call))]
return found
}
// WatchlistEntries returns the list for the tab.
func (a *App) WatchlistEntries() []watchlist.Entry {
if a.watchlist == nil {
return nil
}
return a.watchlist.Entries()
}
// WatchlistAdd adds a callsign or prefix; contest entries are judged per UTC day.
func (a *App) WatchlistAdd(callsign string, contest bool) error {
if a.watchlist == nil {
return fmt.Errorf("watchlist not initialized")
}
if err := a.watchlist.Add(callsign, contest); err != nil {
return err
}
a.notifyWatchlist(callsign, true)
return nil
}
// WatchlistRemove deletes an entry.
func (a *App) WatchlistRemove(callsign string) error {
if a.watchlist == nil {
return fmt.Errorf("watchlist not initialized")
}
if err := a.watchlist.Remove(callsign); err != nil {
return err
}
a.notifyWatchlist(callsign, false)
return nil
}
// notifyWatchlist announces a membership change to the UI.
//
// Emitted from the BINDINGS rather than from the watchlist panel, because a
// call can now be added from three places (the panel, the cluster's menu, the
// DXpeditions tab) and an operator who added one from the cluster saw nothing
// at all — the confirmation lived inside a panel they were not looking at.
func (a *App) notifyWatchlist(callsign string, added bool) {
if a.ctx == nil {
return
}
wruntime.EventsEmit(a.ctx, "watchlist:changed", map[string]any{
"call": strings.ToUpper(strings.TrimSpace(callsign)),
"added": added,
})
}
// WatchlistSetNotify arms the existing alert path (sound + toast) for an entry.
func (a *App) WatchlistSetNotify(callsign string, on bool) error {
if a.watchlist == nil {
return fmt.Errorf("watchlist not initialized")
}
return a.watchlist.SetNotify(callsign, on)
}
// WatchlistSetContest flips the per-entry contest rule.
func (a *App) WatchlistSetContest(callsign string, on bool) error {
if a.watchlist == nil {
return fmt.Errorf("watchlist not initialized")
}
return a.watchlist.SetContest(callsign, on)
}
// WatchlistSlotQuery asks whether one spot's slot is worked — against the whole
// log for a normal entry, against TODAY (UTC) for a contest one.
type WatchlistSlotQuery struct {
Call string `json:"call"`
Band string `json:"band"`
Mode string `json:"mode"`
Contest bool `json:"contest"`
}
// WatchlistWorkedSlots answers a batch of slot questions for the tab.
//
// Normal entries read the CLUSTER's own slot set — the same cached
// WorkedCallSlotKeys index that colours the grid — so the watchlist can never
// contradict the cluster about the same spot. That index normalises the mode
// exactly as the operator configured (digital grouping on → FT4 counts as FT8's
// class; off → exact mode), and the first version of this ignored that: it
// asked the alerts' raw-mode index with a CLASS name, matched nothing, and
// showed a fully-worked DXpedition as all Needed.
//
// Contest entries read TODAY's contacts instead — the midnight-UTC reset is
// the query's date bound, nothing stored, nothing to reset — normalised through
// the same function so the two answers use one grammar.
func (a *App) WatchlistWorkedSlots(queries []WatchlistSlotQuery) []bool {
out := make([]bool, len(queries))
if a.qso == nil || len(queries) == 0 {
return out
}
idx := a.clusterStatusMaps()
norm := func(m string) string {
m = strings.ToUpper(strings.TrimSpace(m))
if idx.normMode != nil {
m = idx.normMode(m)
}
return m
}
slotKey := func(call, band, mode string) string {
up := strings.ToUpper(strings.TrimSpace(call))
b := strings.ToLower(strings.TrimSpace(band))
if m := norm(mode); m != "" {
return up + "|" + b + "|" + m
}
return up + "|" + b
}
// A spot whose mode the band plan could only call "DATA" (an F/H DXpedition
// off the standard dials, a comment with no mode) cannot be matched exactly:
// "DATA" is in nobody's log. Such a spot is judged at digital-CLASS grain —
// worked if ANY digital mode of that call is logged on that band. Claiming
// NEW MODE because the label differs from FT8 was the reported bug.
generic := func(m string) bool {
switch strings.ToUpper(strings.TrimSpace(m)) {
case "", "DATA", "DIG", "DIGI", "DIGITAL":
return true
}
return false
}
digKey := func(call, band string) string {
return strings.ToUpper(strings.TrimSpace(call)) + "|" + strings.ToLower(strings.TrimSpace(band)) + "|DIG"
}
needToday := false
for _, q := range queries {
if q.Contest {
needToday = true
break
}
}
today := map[string]bool{}
if needToday {
midnight := time.Now().UTC().Truncate(24 * time.Hour)
rows, err := a.qso.SlotsSince(a.ctx, midnight)
if err == nil {
for _, r := range rows {
today[slotKey(r.Callsign, r.Band, r.Mode)] = true
today[digKey(r.Callsign, r.Band)] = qso.ModeClass(r.Mode) == "DIG" || today[digKey(r.Callsign, r.Band)]
}
}
}
for i, q := range queries {
if q.Contest {
if generic(q.Mode) {
out[i] = today[digKey(q.Call, q.Band)]
} else {
out[i] = today[slotKey(q.Call, q.Band, q.Mode)]
}
} else if generic(q.Mode) {
if idx.workedCallSlotsDig != nil {
_, out[i] = idx.workedCallSlotsDig[digKey(q.Call, q.Band)]
}
} else if idx.workedCallSlots != nil {
_, out[i] = idx.workedCallSlots[slotKey(q.Call, q.Band, q.Mode)]
}
}
return out
}
// watchSpot runs one live spot through the watchlist: last-seen bookkeeping and
// the alert, through the SAME event the alert rules fire — the frontend already
// knows how to toast and sound it, and a second notification path would be a
// second thing to misconfigure.
func (a *App) watchSpot(dxCall, band, mode, country, comment string, freqHz int64) {
if a.watchlist == nil {
return
}
entry, notify, ok := a.watchlist.MarkSeen(dxCall)
if !ok {
// Not watched yet — the contest pattern or the named list may claim it.
// Contains, not prefix, for the pattern: the event string sits anywhere
// in those calls (HB9WWA, F4WWA/P). The named list is exact, and is what
// catches the entries whose callsign says nothing about the event.
p := a.GetWatchlistContestPattern()
byPattern := p != "" && strings.Contains(strings.ToUpper(dxCall), p)
byName := a.isNamedContestCall(dxCall)
if byPattern || byName {
if err := a.watchlist.Add(dxCall, true); err == nil {
why := fmt.Sprintf("contest pattern %q", p)
if byName {
why = "named in the contest list"
}
applog.Printf("watchlist: auto-added %s (%s)", dxCall, why)
entry, notify, ok = a.watchlist.MarkSeen(dxCall)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "watchlist:changed")
}
}
}
}
if !ok || !notify || a.ctx == nil {
return
}
// Already worked? Then nothing to announce. Judged HERE, before the sound —
// the report was an alert ringing for a slot the tab showed as Worked a
// moment later, because the frontend's verdict arrives on a debounce while
// the alert used to fire on the raw spot. Same verdict as the tab: exact
// slot for named modes, digital class for a generic DATA spot, today-only
// for a contest entry.
if e, found := a.watchlist.Get(entry); found {
if a.WatchlistWorkedSlots([]WatchlistSlotQuery{{Call: dxCall, Band: band, Mode: mode, Contest: e.IsContest}})[0] {
return
}
}
// Throttled per entry: a DXpedition lights up every skimmer on the planet,
// and forty alerts a minute for one station is a alarm nobody keeps on.
a.watchAlertMu.Lock()
last := a.watchAlertAt[entry]
now := time.Now()
if now.Sub(last) < 2*time.Minute {
a.watchAlertMu.Unlock()
return
}
a.watchAlertAt[entry] = now
a.watchAlertMu.Unlock()
wruntime.EventsEmit(a.ctx, "alert:fired", map[string]any{
"rule": "Watchlist " + entry,
"call": strings.ToUpper(strings.TrimSpace(dxCall)),
"band": band,
"mode": mode,
"freq_hz": freqHz,
"country": country,
"comment": comment,
"sound": true,
"visual": true,
})
}
// startWatchlistClubLog begins the expedition enrichment: every entry's
// ClubLog block (expedition flag, OQRS, LiveStream, QSO totals and 24 h rate)
// is refreshed on a rolling schedule — hourly for expeditions, six-hourly for
// the rest, DXHunter's own cadence. OpsLog's application API key is used, so
// there is nothing to configure.
//
// Two at a time with a breath between requests: the key is shared by every
// OpsLog install, and a hundred-entry watchlist must read as a trickle at
// ClubLog's end, not a burst.
func (a *App) startWatchlistClubLog() {
if a.watchlist == nil {
return
}
go func() {
client := &http.Client{Timeout: 30 * time.Second}
// Let the app finish starting before the first network pass.
time.Sleep(15 * time.Second)
for {
stale := a.watchlist.StaleEntries(1*time.Hour, 6*time.Hour)
changed := false
for _, call := range stale {
// The pattern's BASE is what ClubLog knows: VK9* has no log.
base := strings.TrimSuffix(call, "*")
if base == "" {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
d, err := watchlist.FetchWatch(ctx, client, clublogAppAPIKey, base)
cancel()
if err != nil {
applog.Printf("watchlist clublog: %s: %v", base, err)
continue
}
if a.watchlist.ApplyClubLog(call, d) {
changed = true
if d.IsExpedition {
applog.Printf("watchlist clublog: %s is a DXpedition (%d QSOs, %d/24h, OQRS=%v)",
base, func() int {
if d.ClubLogInfo != nil {
return d.ClubLogInfo.TotalQSOs
}
return 0
}(), d.QSOs24h(), d.HasOQRS)
}
}
time.Sleep(400 * time.Millisecond)
}
if changed && a.ctx != nil {
wruntime.EventsEmit(a.ctx, "watchlist:changed")
}
time.Sleep(10 * time.Minute)
}
}()
}
+215
View File
@@ -0,0 +1,215 @@
package main
// Log-aware colours in WSJT-X / JTDX's own Band Activity window (message 13),
// the way JTAlert paints them: a decode of a watchlist member, a new DXCC or a
// new band for its entity is highlighted where the operator is actually
// looking. The verdicts come from the same cluster status cache that colours
// the spot grid, so the two windows can never disagree.
import (
"strings"
"hamlog/internal/applog"
"hamlog/internal/dxcc"
udp "hamlog/internal/integrations/udp"
)
const (
keyWsjtHighlight = "udp.wsjt.highlight"
keyWsjtFollowMode = "udp.wsjt.followmode" // spot clicks switch the decoder's mode
keyWsjtHLWorked = "udp.wsjt.highlight_worked"
)
// wsjtModes are the modes a Configure message can meaningfully ask for — the
// decoder's own vocabulary. Anything else (CW, SSB, RTTY) is none of its
// business and is not sent.
var wsjtModes = map[string]bool{
"FT8": true, "FT4": true, "JT65": true, "JT9": true,
"MSK144": true, "Q65": true, "FST4": true, "JS8": false, // JS8Call speaks another protocol
}
// GetWsjtFollowMode reports whether spot clicks retune the decoder's mode.
func (a *App) GetWsjtFollowMode() bool {
return a.settingOr(keyWsjtFollowMode, "1") == "1"
}
// SetWsjtFollowMode flips it.
func (a *App) SetWsjtFollowMode(on bool) {
v := "0"
if on {
v = "1"
}
a.setSetting(keyWsjtFollowMode, v)
}
// ConfigureDecoderMode asks the connected decoders to switch mode — called by
// the frontend after a spot click has tuned the radio. A no-op for modes the
// decoder does not speak, and when the option is off or nothing is connected.
func (a *App) ConfigureDecoderMode(mode string) {
mode = strings.ToUpper(strings.TrimSpace(mode))
if a.udp == nil || !wsjtModes[mode] || !a.GetWsjtFollowMode() {
return
}
a.udp.SendConfigureMode(mode)
}
// The palette. Fixed colours, not theme tokens — they are painted into another
// application's window, which has no idea what theme OpsLog wears.
var (
hlWatchlist = udp.RGB{R: 244, G: 114, B: 182} // the watchlist pink
hlNewDXCC = udp.RGB{R: 22, G: 130, B: 60} // green
hlNewBand = udp.RGB{R: 226, G: 122, B: 24} // orange
hlWhite = udp.RGB{R: 255, G: 255, B: 255}
hlBlack = udp.RGB{R: 20, G: 20, B: 20}
// Worked already, on this band and in this mode. Grey on purpose, and the
// only DIM colour of the four: the others say "look at this", and this one
// says the opposite — it has to recede, not compete with them.
hlWorked = udp.RGB{R: 75, G: 85, B: 99}
hlWorkedFg = udp.RGB{R: 203, G: 213, B: 225}
)
// GetWsjtHighlightWorked reports whether stations already worked on this band
// and mode are greyed out as well.
//
// Its own switch, and off by default. The other three verdicts pick out a
// handful of decodes in a period; this one can match most of them on a
// well-filled log, and a screen where nearly every line is coloured has stopped
// saying anything. It is worth having only for an operator who wants the dupes
// struck out rather than the catches picked out.
func (a *App) GetWsjtHighlightWorked() bool {
return a.settingOr(keyWsjtHLWorked, "0") == "1"
}
// SetWsjtHighlightWorked flips it, and repaints.
//
// Turning it OFF has to clear what it painted: those callsigns keep their grey
// in the decoder's window otherwise, and nothing would ever say another word
// about them — the de-duplication remembers that they were already told.
func (a *App) SetWsjtHighlightWorked(on bool) {
v := "0"
if on {
v = "1"
}
a.setSetting(keyWsjtHLWorked, v)
a.wsjtHLWorkedOn.Store(on)
a.clearWsjtHighlights()
applog.Printf("wsjt highlight: worked stations %v", on)
}
// clearWsjtHighlights wipes every instruction OpsLog installed, in every
// running decoder, and forgets what it had said.
func (a *App) clearWsjtHighlights() {
if a.udp == nil {
return
}
for _, inst := range a.udp.Instances() {
_ = a.udp.SendClearHighlights(inst)
}
a.wsjtHLMu.Lock()
a.wsjtHLSent = map[string]string{}
a.wsjtHLMu.Unlock()
}
// GetWsjtHighlight reports whether decode highlighting is on.
func (a *App) GetWsjtHighlight() bool {
return a.settingOr(keyWsjtHighlight, "0") == "1"
}
// SetWsjtHighlight turns decode highlighting on or off. Turning it OFF also
// clears every instruction OpsLog installed in the running applications — a
// disabled option that leaves stale colours behind looks broken, not disabled.
func (a *App) SetWsjtHighlight(on bool) {
v := "0"
if on {
v = "1"
}
a.setSetting(keyWsjtHighlight, v)
a.wsjtHighlightOn.Store(on)
if !on {
a.clearWsjtHighlights()
applog.Printf("wsjt highlight: off — cleared in every instance")
}
}
// maybeHighlightDecode paints one decoded callsign in the instance that heard
// it, when the option is on and the verdict is worth a colour. De-duplicated
// per instance+call+verdict: a station CQing all evening is decoded four times
// a minute, and the instruction only needs to be said once.
func (a *App) maybeHighlightDecode(instance, call, band, mode string) {
if !a.wsjtHighlightOn.Load() || a.udp == nil || call == "" || instance == "" {
return
}
bg, fg, verdict := a.decodeHighlightVerdict(call, band, mode)
// The MODE is part of the key: one receiver can be handed FT8 and FT4 in
// the same session, and the verdict on a callsign is not the same in both.
key := instance + "|" + strings.ToUpper(call) + "|" + band + "|" + strings.ToUpper(mode)
a.wsjtHLMu.Lock()
if a.wsjtHLSent == nil {
a.wsjtHLSent = map[string]string{}
}
if len(a.wsjtHLSent) > 4000 { // bounded; a long session just re-says a few
a.wsjtHLSent = map[string]string{}
}
prev, had := a.wsjtHLSent[key]
if had && prev == verdict {
a.wsjtHLMu.Unlock()
return
}
a.wsjtHLSent[key] = verdict
a.wsjtHLMu.Unlock()
if verdict == "" {
// Was highlighted under an earlier verdict and no longer deserves it
// (the operator just worked them): clear that one callsign.
if had && prev != "" {
_ = a.udp.SendHighlight(instance, call, nil, nil, false)
}
return
}
_ = a.udp.SendHighlight(instance, call, bg, fg, false)
}
// decodeHighlightVerdict ranks a callsign: watchlist beats new-DXCC beats
// new-band, and "already worked here" comes last of all — it is the only
// verdict that says do NOT call, so anything worth calling for outranks it.
// Anything else is "no colour", and the empty verdict doubles as the clear
// signal in maybeHighlightDecode.
func (a *App) decodeHighlightVerdict(call, band, mode string) (bg, fg *udp.RGB, verdict string) {
if a.watchlist != nil {
if _, ok := a.watchlist.Match(call); ok {
c := hlWatchlist
f := hlBlack
return &c, &f, "watchlist"
}
}
c := a.clusterStatusMaps()
if a.dxcc != nil {
if m, ok := a.dxcc.Lookup(call); ok && m.Entity != nil {
num := dxcc.EntityDXCC(m.Entity.Name)
ent := c.entities[num]
if ent == nil {
bgc, fgc := hlNewDXCC, hlWhite
return &bgc, &fgc, "new-dxcc"
}
if band != "" {
if _, workedBand := ent.Bands[strings.ToLower(band)]; !workedBand {
bgc, fgc := hlNewBand, hlBlack
return &bgc, &fgc, "new-band"
}
}
}
}
// Worked already, this exact callsign on this band in this mode — a dupe,
// judged by the same ledger and the same digital-mode grouping the cluster
// uses, so the two windows cannot disagree about what "worked" means.
if a.wsjtHLWorkedOn.Load() && band != "" && mode != "" {
m := strings.ToUpper(strings.TrimSpace(mode))
if c.normMode != nil {
m = c.normMode(m)
}
if _, ok := c.workedCallSlots[strings.ToUpper(call)+"|"+strings.ToLower(band)+"|"+m]; ok {
bgc, fgc := hlWorked, hlWorkedFg
return &bgc, &fgc, "worked"
}
}
return nil, nil, ""
}
+7
View File
@@ -174,6 +174,11 @@ type MatrixColors struct {
EntityWorked string `json:"entity_worked"`
NotWorked string `json:"not_worked"`
CurrentEntry string `json:"current_entry"`
// The dot marking a slot already worked with the callsign in hand. It is a
// mark rather than a background, so it needs its own two colours: it is
// drawn on top of all five of the above and must stay legible over each.
MarkWorked string `json:"mark_worked"`
MarkConfirmed string `json:"mark_confirmed"`
}
// normMatrixColors keeps only plain hex values. Anything else becomes "" — i.e.
@@ -196,6 +201,8 @@ func normMatrixColors(c MatrixColors) MatrixColors {
EntityWorked: clean(c.EntityWorked),
NotWorked: clean(c.NotWorked),
CurrentEntry: clean(c.CurrentEntry),
MarkWorked: clean(c.MarkWorked),
MarkConfirmed: clean(c.MarkConfirmed),
}
}
+736
View File
@@ -0,0 +1,736 @@
package main
// Auto-call — the wiring around internal/autocall.
//
// The DECISION is in that package, alone and tested. This file does the three
// things it cannot do for itself: cut the decode stream into periods, tell it
// what the log still needs from each station, and carry out what it decides.
//
// It lives in the backend rather than in the panel because it keys a
// transmitter: it must behave identically whether the FT decodes tab is open,
// behind another tab, or the window is minimised — and because every rule it
// applies is then a Go test rather than something only the air can check.
import (
"fmt"
"strconv"
"strings"
"time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"hamlog/internal/applog"
"hamlog/internal/autocall"
)
const (
keyAutoCallOn = "autocall.enabled"
keyAutoCallOnly = "autocall.only"
keyAutoCallAttempts = "autocall.attempts"
keyAutoCallWatched = "autocall.watched_attempts"
keyAutoCallMisses = "autocall.misses"
keyAutoCallRounds = "autocall.max_rounds"
// Periods, not minutes — see autocall.Settings.RestPeriods. A new key rather
// than a reinterpreted one: the old value was in minutes, and reading "2" as
// two periods or as two minutes are eight periods apart.
keyAutoCallRest = "autocall.rest_periods"
keyAutoCallOnScreen = "autocall.on_screen_only"
keyAutoCallTrace = "autocall.trace"
)
// AutoCallSettings is the panel's shape. Durations are in minutes because that
// is what the operator is asked for.
type AutoCallSettings struct {
Enabled bool `json:"enabled"`
Only string `json:"only"`
// Attempts / WatchedAttempts: how many calls one station gets before it is
// released. The larger allowance is for a callsign on the watch list.
Attempts int `json:"attempts"`
WatchedAttempts int `json:"watched_attempts"`
// Misses: periods in which the station itself transmits, with no decode of
// it, before it is given up on.
Misses int `json:"misses"`
// MaxRounds: how many series of calls one station gets in a session, and
// RestPeriods the pause between two of them, counted in the station's own
// transmit periods.
MaxRounds int `json:"max_rounds"`
RestPeriods int `json:"rest_periods"`
// OnScreenOnly: call only what the decodes panel is showing, so its filters
// steer the transmitter as well as the eye.
OnScreenOnly bool `json:"on_screen_only"`
// Trace writes one line per period to the log: what was on the air, why
// each station was refused, and what was decided. For diagnosing "it is not
// calling anything" — and it is a line every fifteen seconds, so it is off
// unless asked for.
Trace bool `json:"trace"`
}
// intOr reads a stored integer, keeping a stored ZERO — which num() cannot,
// since it treats zero as "nothing set".
func intOr(v string, def int) int {
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil || n < 0 {
return def
}
return n
}
func (a *App) GetAutoCallSettings() AutoCallSettings {
d := autocall.Defaults()
num := func(key string, def int) int {
n, err := strconv.Atoi(strings.TrimSpace(a.settingOr(key, "")))
if err != nil || n <= 0 {
return def
}
return n
}
return AutoCallSettings{
// Never on from a stored value alone — see startAutoCall.
Enabled: a.settingOr(keyAutoCallOn, "0") == "1",
Only: strings.ToUpper(strings.TrimSpace(a.settingOr(keyAutoCallOnly, ""))),
Attempts: num(keyAutoCallAttempts, d.Attempts),
WatchedAttempts: num(keyAutoCallWatched, d.WatchedAttempts),
// On by default: the filters are in front of the operator, and a station
// they have hidden is one they have said they do not want.
OnScreenOnly: a.settingOr(keyAutoCallOnScreen, "1") == "1",
Trace: a.settingOr(keyAutoCallTrace, "0") == "1",
Misses: num(keyAutoCallMisses, d.Misses),
MaxRounds: num(keyAutoCallRounds, d.MaxRounds),
// Zero is meaningful (call it again next period), so it is read directly
// rather than through num(), which treats zero as "unset".
RestPeriods: intOr(a.settingOr(keyAutoCallRest, ""), d.RestPeriods),
}
}
func (a *App) SaveAutoCallSettings(s AutoCallSettings) error {
a.setSetting(keyAutoCallOn, map[bool]string{true: "1", false: "0"}[s.Enabled])
a.setSetting(keyAutoCallOnly, strings.ToUpper(strings.TrimSpace(s.Only)))
a.setSetting(keyAutoCallOnScreen, map[bool]string{true: "1", false: "0"}[s.OnScreenOnly])
a.setSetting(keyAutoCallTrace, map[bool]string{true: "1", false: "0"}[s.Trace])
for key, v := range map[string]int{
keyAutoCallAttempts: s.Attempts, keyAutoCallWatched: s.WatchedAttempts,
keyAutoCallMisses: s.Misses, keyAutoCallRounds: s.MaxRounds,
} {
if v > 0 {
a.setSetting(key, strconv.Itoa(v))
}
}
// Stored even at zero, unlike the counters above: no rest at all is a
// setting, not an empty field.
if s.RestPeriods >= 0 {
a.setSetting(keyAutoCallRest, strconv.Itoa(s.RestPeriods))
}
a.applyAutoCall()
applog.Printf("autocall: %v (only=%q, %d/%d calls, %d misses, %d rounds)",
s.Enabled, s.Only, s.Attempts, s.WatchedAttempts, s.Misses, s.MaxRounds)
return nil
}
// SetAutoCallOnly is the chase-list field in the decodes toolbar.
//
// Its own binding rather than a settings round-trip: the toolbar knows one
// field, and handing back a whole struct it never read is how a Preferences
// window left open somewhere quietly reverts a limit that was just changed.
func (a *App) SetAutoCallOnly(list string) error {
s := a.GetAutoCallSettings()
s.Only = list
return a.SaveAutoCallSettings(s)
}
// SetAutoCall is the toolbar switch above the decodes.
func (a *App) SetAutoCall(on bool) error {
s := a.GetAutoCallSettings()
s.Enabled = on
return a.SaveAutoCallSettings(s)
}
// autoCallEngine returns the engine, built on first use.
func (a *App) autoCallEngine() *autocall.Engine {
a.acMu.Lock()
defer a.acMu.Unlock()
if a.ac == nil {
a.ac = autocall.New(a.autoCallSettings())
}
return a.ac
}
func (a *App) autoCallSettings() autocall.Settings {
s := a.GetAutoCallSettings()
return autocall.Settings{
Enabled: s.Enabled, Only: s.Only, OnScreenOnly: s.OnScreenOnly,
Attempts: s.Attempts, WatchedAttempts: s.WatchedAttempts,
Misses: s.Misses, MaxRounds: s.MaxRounds,
RestPeriods: s.RestPeriods,
}
}
// applyAutoCall pushes the settings into the engine, and clears its state when
// the feature is switched off — an operator turning it off is entitled to have
// it forget the station it was calling, not resume it half an hour later.
func (a *App) applyAutoCall() {
e := a.autoCallEngine()
s := a.autoCallSettings()
e.SetSettings(s)
if a.GetAutoCallSettings().Trace {
e.SetTrace(func(f string, args ...any) { applog.Printf("autocall: "+f, args...) })
} else {
e.SetTrace(nil)
}
if !s.Enabled {
e.Reset()
a.acMu.Lock()
a.acPeriod, a.acBuf = nil, nil
a.acJudged, a.acDirty = nil, nil
a.acMu.Unlock()
}
a.emitAutoCall()
}
// ResetAutoCall is the operator's restart after the engine gave up on an
// explicit target: it clears every verdict, including the grey list.
func (a *App) ResetAutoCall() {
a.autoCallEngine().Reset()
a.emitAutoCall()
}
// HaltAutoCall is the Halt button while a call is in progress.
//
// It does NOT clear the engine's state, which is what Halt used to do: that
// wiped the rests and the rounds along with everything else, so the station the
// operator had just stopped was eligible again in the same second and the next
// period called it straight back.
func (a *App) HaltAutoCall() {
call := a.autoCallEngine().Halt()
if call != "" {
a.acMu.Lock()
a.acReason = fmt.Sprintf("%s stopped by the operator — set aside until auto-call is switched off and on", call)
a.acMu.Unlock()
applog.Printf("autocall: %s", a.acReason)
}
a.emitAutoCall()
}
// AutoCallStatus is what the toolbar shows.
type AutoCallStatus struct {
Enabled bool `json:"enabled"`
// Only is the chase list, carried in the status so the field in the decodes
// toolbar and the one in Preferences are never two versions of the truth:
// whichever is typed into, both show it.
Only string `json:"only"`
Target string `json:"target"`
// Waiting: what it would call if that station were not in a QSO.
Waiting string `json:"waiting"`
Calls int `json:"calls"`
Max int `json:"max"`
Misses int `json:"misses"`
MaxMiss int `json:"max_miss"`
Stopped bool `json:"stopped"`
// Greylisted counts the stations the operator has stopped this session, so
// the toolbar can say why a station on the air is never called.
Greylisted int `json:"greylisted"`
// Reason is the last decision in plain words. An auto-call that is doing
// nothing on purpose looks exactly like one that is broken.
Reason string `json:"reason"`
}
func (a *App) GetAutoCallStatus() AutoCallStatus {
st := a.autoCallEngine().Status()
a.acMu.Lock()
reason := a.acReason
a.acMu.Unlock()
set := a.GetAutoCallSettings()
return AutoCallStatus{
Enabled: set.Enabled, Only: set.Only,
Target: st.Target, Waiting: st.Waiting, Calls: st.Attempts, Max: st.Max,
Misses: st.Misses, MaxMiss: st.MaxMiss, Stopped: st.Stopped,
Greylisted: a.autoCallEngine().Greylisted(),
Reason: reason,
}
}
func (a *App) emitAutoCall() {
if a.ctx == nil {
return
}
wruntime.EventsEmit(a.ctx, "autocall:status", a.GetAutoCallStatus())
}
// TakeAutoCallTarget adopts the station the operator has just clicked, so a
// manual pick gets the same watchdogs as an automatic one — the click is the
// choice of station, not a decision to call it for ever.
func (a *App) TakeAutoCallTarget(call, band, mode string) {
if !a.GetAutoCallSettings().Enabled {
return
}
call = strings.ToUpper(strings.TrimSpace(call))
if call == "" {
return
}
a.autoCallEngine().Take(autocall.Candidate{
Decode: autocall.Decode{Call: call, Band: band, Mode: mode, At: time.Now().UTC(), IsNew: true},
Need: a.autoCallNeed(call, band, mode),
Watched: a.autoCallWatched(call),
})
a.emitAutoCall()
}
// ── The decode stream, cut into periods ───────────────────────────────────
// acDecode is one decode held until its period is complete.
type acDecode struct {
d autocall.Decode
tx bool // the decode is our own transmission echoed back
}
// autoCallFeed takes one decode from the UDP loop.
//
// Decodes arrive one datagram at a time and a decision needs the whole period:
// the best station in it, and whether the target was there at all. They are
// therefore buffered under the period they belong to, and the period is judged
// when the next one starts — or, if the band goes quiet, by the sweeper below,
// which is what makes "not decoded for three of its periods" reachable when the
// answer is that nothing is being decoded at all.
func (a *App) autoCallFeed(d autocall.Decode) {
if !a.GetAutoCallSettings().Enabled {
return
}
inst := d.Instance
key := acPeriodKey(d.At, d.TRPeriod)
a.acMu.Lock()
if a.acPeriod == nil {
a.acPeriod, a.acAt, a.acTR, a.acBuf = map[string]string{}, map[string]time.Time{}, map[string]int{}, map[string][]acDecode{}
a.acFed = map[string]time.Time{}
a.acJudged, a.acDirty = map[string]string{}, map[string]bool{}
}
if prev := a.acPeriod[inst]; prev != "" && prev != key {
prevAt, prevTR, buf := a.acAt[inst], a.acTR[inst], a.acBuf[inst]
// Only if something in it has NOT been judged. The buffer now outlives
// the judgement (see below), so without this the arrival of the next
// period would judge the previous one a second time on the very same
// decodes — and act on them, a slot late.
pending := a.acDirty[inst] || a.acJudged[inst] != prev
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
a.acBuf[inst] = []acDecode{{d: d}}
a.acFed[inst], a.acDirty[inst] = time.Now(), true
a.acMu.Unlock()
// The previous period ends here whatever the sweeper was going to do: a
// decode stamped with the next slot is proof the old one is over.
if pending {
a.autoCallJudge(inst, prev, prevAt, prevTR, buf)
}
return
}
// APPENDED, never restarted. The buffer survives the period being judged,
// so a decode that arrives after the others belongs to the same period and
// is weighed against all of them.
//
// It used to be dropped and then judged on its own: the sweeper cleared the
// buffer, a straggler opened a "new" one under the same key, and the ladder
// was applied to whatever handful had come late — with the other thirty
// stations of that period nowhere in sight. A deep decode arriving a second
// after the burst is exactly the station worth calling.
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
a.acFed[inst], a.acDirty[inst] = time.Now(), true
a.acBuf[inst] = append(a.acBuf[inst], acDecode{d: d})
a.acMu.Unlock()
}
// acQuiet is how long a period is left open after its LAST decode arrives.
//
// This is the whole timing budget of the feature. A decoder finishes a period
// and sends its decodes about a second before the next slot opens, so the
// answer has to be back before that boundary — a reply that arrives after it
// makes the decoder start its call several seconds into the slot, which is what
// an operator sees as "it calls late" and what a station on the other end sees
// as a message it cannot decode.
//
// It was a whole slot plus four seconds, measured from the DECODE'S OWN
// TIMESTAMP — the start of the period, not the moment it arrived — so the
// answer left about four seconds INTO the next slot, every time.
//
// 800 ms: long enough for a busy period's decodes to arrive together (measured
// in bursts of a few hundred milliseconds), short enough to answer inside the
// same second they landed.
const acQuiet = 800 * time.Millisecond
// autoCallSweep closes the periods nothing has closed for us. Called on a timer.
//
// Per receiver, because with two decoders one may fall silent while the other
// is busy — and it is the silent one's period that has to close for a missed
// period to be counted at all.
func (a *App) autoCallSweep() {
if !a.GetAutoCallSettings().Enabled {
return
}
type due struct {
inst, key string
at time.Time
tr int
buf []acDecode
}
var ready []due
a.acMu.Lock()
for inst, key := range a.acPeriod {
if key == "" {
continue
}
tr := a.acTR[inst]
if tr <= 0 {
tr = 15
}
// Measured from when the last decode ARRIVED, not from the period it
// belongs to: a decode is stamped with the start of its own slot, so
// waiting "a slot plus four seconds" from that stamp is waiting until
// the middle of the NEXT slot. See acQuiet.
if time.Since(a.acFed[inst]) < acQuiet {
continue
}
// Judged once per period, and again only when something new has come in
// for it. The period itself is kept open until a decode from the NEXT one
// arrives, so a straggler is judged with the whole period behind it.
if a.acJudged[inst] == key && !a.acDirty[inst] {
continue
}
ready = append(ready, due{inst, key, a.acAt[inst], tr, a.acBuf[inst]})
a.acJudged[inst], a.acDirty[inst] = key, false
}
a.acMu.Unlock()
for _, d := range ready {
a.autoCallJudge(d.inst, d.key, d.at, d.tr, d.buf)
}
}
// autoCallSilence is the empty period. With the band dead, no decode ever
// arrives to close the next one, and the target's absence would never be
// counted — so a period with nothing in it is still a period.
//
// Only for the receiver the target is being called on: an idle second decoder
// has no periods to miss.
func (a *App) autoCallSilence() {
if !a.GetAutoCallSettings().Enabled {
return
}
inst, target := a.autoCallEngine().TargetInstance()
if target == "" {
return
}
a.acMu.Lock()
// Nothing pending for this receiver, and nothing judged for a while: the
// band has gone quiet under it.
quiet := !a.acDirty[inst] && time.Since(a.acLastJudge) > 20*time.Second
tr := a.acTR[inst]
a.acMu.Unlock()
if !quiet {
return
}
now := time.Now().UTC()
a.autoCallJudge(inst, acPeriodKey(now, tr), now, tr, nil)
}
func acPeriodKey(at time.Time, trSec int) string {
if trSec <= 0 {
trSec = 15
}
return fmt.Sprintf("%d", at.UTC().Unix()/int64(trSec))
}
// autoCallJudge resolves what the log needs from each station in the period,
// runs the decision, and carries it out.
func (a *App) autoCallJudge(inst, key string, at time.Time, tr int, buf []acDecode) {
a.acMu.Lock()
a.acLastJudge = time.Now()
a.acMu.Unlock()
// One status call for the whole period. It reads a cached worked-index, but
// it is still per-callsign work and a busy period is thirty of them.
seen := map[string]bool{}
var q []SpotQuery
var uniq []acDecode
for _, dd := range buf {
k := dd.d.Call + "|" + dd.d.Band + "|" + dd.d.Mode
if seen[k] {
// The same station twice in one period is one candidate, judged on
// its most callable line — the engine's bestOf does that, so both
// decodes are kept; only the status lookup is deduplicated.
uniq = append(uniq, dd)
continue
}
seen[k] = true
uniq = append(uniq, dd)
q = append(q, SpotQuery{Call: dd.d.Call, Band: dd.d.Band, Mode: dd.d.Mode})
}
status := map[string]SpotStatus{}
for _, st := range a.ClusterSpotStatuses(q) {
status[st.Call+"|"+st.Band+"|"+st.Mode] = st
}
chase := a.autoCallChase()
cands := make([]autocall.Candidate, 0, len(uniq))
for _, dd := range uniq {
st := status[dd.d.Call+"|"+dd.d.Band+"|"+dd.d.Mode]
c := candidateOf(dd.d, st, chase)
c.Watched = a.autoCallWatched(dd.d.Call)
c.Hidden = a.autoCallHidden(dd.d.Call)
cands = append(cands, c)
}
tx := a.autoCallTX()
act := a.autoCallEngine().OnPeriod(autocall.Period{
Instance: inst, Key: key, At: at, TRPeriod: tr, Decodes: cands, TX: tx,
MyCall: a.opCall,
})
a.autoCallDo(act)
}
// candidateOf turns one decode and the log's verdict on it into a candidate.
//
// Split out and kept pure because ONE line of it was wrong for weeks and
// nothing could catch it: the entity's verdict was read as the station's.
// chaseExtras is what the operator hunts BESIDES entities — the cluster's
// orthogonal markers, from the same switches that decide whether the badges are
// shown at all (Settings → DX Cluster). One answer for the eye and the
// transmitter: a marker withdrawn from the screen is not one to call for.
type chaseExtras struct{ pota, grid, pfx, county, state bool }
func candidateOf(d autocall.Decode, st SpotStatus, ch chaseExtras) autocall.Candidate {
c := autocall.Candidate{
Decode: d,
// The ENTITY's verdict decides what is still needed…
Need: autoCallNeedOf(st.Status),
// …and THIS CALLSIGN on this band and mode decides whether calling it
// would be a duplicate.
//
// Status was used for both. "worked" there means the COUNTRY is in the
// log on this band and mode, so on a band where the operator has most of
// them, nearly every station on the air was refused as already worked —
// a trace of one evening shows twenty decodes out of twenty-one turned
// away that way, the watched DXpedition among them.
Worked: st.WorkedSlot,
// The need exists only because a QSL never came: worth chasing, and worth
// less than the same need never worked at all.
Unconfirmed: st.UnconfStatus,
}
// NOTHING LEFT ON THE ENTITY, AND STILL WORTH A CALL.
//
// A prefix, a square, a county, a state, a park: never worked, orthogonal to
// the entity's verdict, and exactly what the operator ticked in the chase
// settings. They ranked at nothing-needed, so auto-call sat through a
// never-worked WPX prefix calling CQ and did not answer it.
//
// Only when the entity has nothing to add — a new band that is ALSO a new
// prefix is a new band, and says so.
if c.Need == autocall.NeedNone {
switch {
case ch.pfx && st.NewPfx:
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "prefix", st.UnconfPfx
case ch.county && st.NewCounty:
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "county", st.UnconfCty
case ch.state && st.NewState:
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "state", st.UnconfState
case ch.grid && st.NewGrid:
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "square", st.GridState == "unconf"
case ch.pota && st.NewPOTA:
c.Need, c.Extra = autocall.NeedExtra, "park"
}
}
return c
}
// autoCallChase reads the chase switches the cluster and the decode list use.
//
// Read once per period rather than per decode: they are settings-store reads,
// and the period loop runs over every station on the band.
func (a *App) autoCallChase() chaseExtras {
on := func(key string) bool {
if a.settings == nil {
return true
}
v, _ := a.settings.Get(a.ctx, "ui.opslog."+key)
return v != "0" // unset means on, as it does on the screen
}
return chaseExtras{
pota: on("chasePota"),
grid: on("chaseGrids"),
pfx: on("chasePfx"),
county: on("chaseCounty"),
state: on("chaseState"),
}
}
// autoCallDo carries out a decision and records it.
func (a *App) autoCallDo(act autocall.Action) {
if act.Reason != "" {
a.acMu.Lock()
a.acReason = act.Reason
a.acMu.Unlock()
applog.Printf("autocall: %s", act.Reason)
}
switch act.Kind {
case autocall.DoReply:
d := act.Decode
if err := a.AnswerDecode(d.Instance, d.Ms, d.SNR, d.DT, d.AudioHz, d.ModeRaw, d.MsgRaw, d.LowConf); err != nil {
applog.Printf("autocall: the call to %s could not be sent: %v", d.Call, err)
}
case autocall.DoHalt:
// Soft: let the over finish, then stop transmitting. Hard: stop now.
// The engine decides — see Action.Soft.
if err := a.HaltDecodeTx("", act.Soft); err != nil {
applog.Printf("autocall: halt failed: %v", err)
}
}
if act.Kind != autocall.DoNothing || act.Reason != "" {
a.emitAutoCall()
}
}
// autoCallNoteTX is the attempt counter, fed from the decoder's own status.
//
// ONCE PER TRANSMIT PERIOD. Status arrives every second and says "transmitting"
// throughout the over, so counting each one would spend the whole allowance of
// seven calls inside a single fifteen-second slot — the counter has to measure
// transmissions, not seconds of carrier.
func (a *App) autoCallNoteTX(tx autocall.TXState) {
if !a.GetAutoCallSettings().Enabled {
return
}
a.acMu.Lock()
// Per receiver as well as per period: in a split view both decoders report
// their own transmissions, and one key for both would let the second one's
// carrier swallow the first one's count.
key := tx.Instance + "|" + acPeriodKey(time.Now().UTC(), a.acTR[tx.Instance])
if a.acTXPeriod == key {
a.acMu.Unlock()
return
}
a.acTXPeriod = key
a.acMu.Unlock()
a.autoCallDo(a.autoCallEngine().NoteTX(tx))
}
// autoCallTX is the last transmit state reported, as the engine wants it.
func (a *App) autoCallTX() autocall.TXState {
a.acMu.Lock()
defer a.acMu.Unlock()
return a.acTX
}
func (a *App) autoCallSetTX(tx autocall.TXState) {
a.acMu.Lock()
a.acTX = tx
a.acMu.Unlock()
}
// SetAutoCallVisible is the decodes panel saying what it is SHOWING.
//
// The panel owns the filters and therefore owns the answer: reimplementing them
// here would give the screen and the transmitter two definitions of the same
// word, which is how they end up disagreeing. It sends the callsigns that
// survive its filters, and the engine calls nothing else.
//
// An empty list with active=false means "no filtering in force" — the panel was
// closed, or has never been opened this session — and the ladder decides alone.
func (a *App) SetAutoCallVisible(calls []string, active bool) {
set := make(map[string]bool, len(calls))
for _, c := range calls {
if c = strings.ToUpper(strings.TrimSpace(c)); c != "" {
set[c] = true
}
}
a.acMu.Lock()
a.acVisible, a.acVisibleOn = set, active
a.acMu.Unlock()
}
// autoCallHidden reports whether the panel's filters are keeping a station off
// the screen. Unknown when nothing is being published: not hidden.
func (a *App) autoCallHidden(call string) bool {
a.acMu.Lock()
defer a.acMu.Unlock()
if !a.acVisibleOn {
return false
}
return !a.acVisible[strings.ToUpper(strings.TrimSpace(call))]
}
// autoCallNeedOf maps the cluster's own status vocabulary onto the ladder. One
// vocabulary for both, so a station that reads NEW BAND in the decodes list is
// the same NEW BAND the auto-call ranks — two answers to one question is how
// the panel and the caller quietly start disagreeing.
func autoCallNeedOf(status string) autocall.Need {
switch status {
case "new":
return autocall.NeedDXCC
case "new-band-mode", "new-band":
// New on both counts is at least a new band, and it is the better catch
// of the two — it must not fall below a plain new band.
return autocall.NeedBand
case "new-mode":
return autocall.NeedMode
case "new-slot":
return autocall.NeedSlot
}
return autocall.NeedNone
}
func (a *App) autoCallNeed(call, band, mode string) autocall.Need {
st := a.ClusterSpotStatuses([]SpotQuery{{Call: call, Band: band, Mode: mode}})
if len(st) == 0 {
return autocall.NeedNone
}
return autoCallNeedOf(st[0].Status)
}
// autoCallWatched asks the watch list, which is the same list the spot alerts
// and the cluster colouring use.
func (a *App) autoCallWatched(call string) bool {
if a.watchlist == nil {
return false
}
_, ok := a.watchlist.Match(strings.ToUpper(strings.TrimSpace(call)))
return ok
}
// startAutoCall starts the engine's loop, with auto-call OFF.
//
// It is never on from a stored value. This is the one setting in the program
// that puts the station on the air by itself, and the operator who left it on
// last night is not necessarily the one at the desk now — nor necessarily at
// the desk at all: OpsLog starts with Windows, and a rig that powers up with it
// would begin calling into an empty shack, on whatever band the radio happens
// to be on, hours after anybody decided that was a good idea.
//
// Arming it is one click, and it is a click somebody has to make.
func (a *App) startAutoCall() {
a.disarmAutoCall("launch")
go a.autoCallLoop()
}
// disarmAutoCall switches auto-call off and writes that down.
//
// The setting is what the toolbar and the settings panel both read, so turning
// the engine off without storing it would show a lit switch over a silent
// transmitter — and the operator's next click, meaning "on", would send "off".
func (a *App) disarmAutoCall(why string) {
if a.settingOr(keyAutoCallOn, "0") == "1" {
applog.Printf("autocall: off at %s — it is never armed from a stored setting", why)
}
a.setSetting(keyAutoCallOn, "0")
a.applyAutoCall()
}
func (a *App) autoCallLoop() {
// A quarter of a second. The sweeper is what closes a period, so its tick is
// part of the same budget as acQuiet: a two-second tick added up to two
// seconds of its own to every answer, which is most of the margin there is.
// The work per tick is a map read.
t := time.NewTicker(250 * time.Millisecond)
defer t.Stop()
for range t.C {
if a.ctx == nil {
return
}
a.autoCallSweep()
a.autoCallSilence()
}
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"testing"
"hamlog/internal/autocall"
)
// The entity's verdict is not the station's.
//
// From the air: on 10 m, where most countries are already in the log, the
// engine refused twenty decodes out of twenty-one as "worked" — a watched
// DXpedition calling CQ among them — because the ENTITY's status was read as
// the station's.
// allChased is the default: every orthogonal marker ticked.
var allChased = chaseExtras{pota: true, grid: true, pfx: true, county: true, state: true}
func TestCandidateWorkedIsTheCallsignNotTheEntity(t *testing.T) {
d := autocall.Decode{Call: "J38DX", Band: "10m", Mode: "FT8", IsNew: true}
// Grenada worked on 10 m FT8, this callsign never worked: nothing is needed
// from it, and calling it is NOT a duplicate.
c := candidateOf(d, SpotStatus{Status: "worked", WorkedSlot: false}, allChased)
if c.Worked {
t.Error("a station never worked was refused because its entity was")
}
if c.Need != autocall.NeedNone {
t.Errorf("need = %v on a worked entity, want none", c.Need)
}
// The same callsign already in the log on this band and mode IS a duplicate.
if c := candidateOf(d, SpotStatus{Status: "worked", WorkedSlot: true}, allChased); !c.Worked {
t.Error("a callsign already worked on this band and mode was not flagged")
}
// And a real need still carries through, with the unconfirmed distinction.
c = candidateOf(d, SpotStatus{Status: "new-band", UnconfStatus: true}, allChased)
if c.Need != autocall.NeedBand || !c.Unconfirmed {
t.Errorf("new-band unconfirmed came through as %v (unconf=%v)", c.Need, c.Unconfirmed)
}
}
// A station whose ENTITY has nothing left to give can still be the reason the
// operator is on the band: a WPX prefix, a county, a square, a park that has
// never been worked. Reported from the air — auto-call sat through a
// never-worked prefix calling CQ and did nothing.
func TestOrthogonalMarkersAreWorthACall(t *testing.T) {
d := autocall.Decode{Call: "BH2SWB", Band: "10m", Mode: "FT8", IsNew: true}
worked := SpotStatus{Status: "worked"}
for _, tc := range []struct {
what string
st SpotStatus
want string
}{
{"prefix", SpotStatus{Status: "worked", NewPfx: true}, "prefix"},
{"county", SpotStatus{Status: "worked", NewCounty: true}, "county"},
{"state", SpotStatus{Status: "worked", NewState: true}, "state"},
{"square", SpotStatus{Status: "worked", NewGrid: true}, "square"},
{"park", SpotStatus{Status: "worked", NewPOTA: true}, "park"},
} {
c := candidateOf(d, tc.st, allChased)
if c.Need != autocall.NeedExtra || c.Extra != tc.want {
t.Errorf("%s: need=%v extra=%q, want extra %q", tc.what, c.Need, c.Extra, tc.want)
}
// And not when the operator does not chase that kind of thing: the
// switch that withdraws the badge withdraws the call with it.
if c := candidateOf(d, tc.st, chaseExtras{}); c.Need != autocall.NeedNone {
t.Errorf("%s: called for a marker the operator does not chase", tc.what)
}
}
// It is the LOWEST rung: a real need on the entity still says what it is.
if c := candidateOf(d, SpotStatus{Status: "new-band", NewPfx: true}, allChased); c.Need != autocall.NeedBand {
t.Errorf("need = %v — a new band that is also a new prefix is a new band", c.Need)
}
// Nothing anywhere is still nothing.
if c := candidateOf(d, worked, allChased); c.Need != autocall.NeedNone {
t.Errorf("need = %v on a station with nothing to gain", c.Need)
}
}
+27 -4
View File
@@ -93,14 +93,27 @@ func keepKnownBands(want []string) []string {
return out
}
// pskrMaxGrids bounds the receiver squares the broker is asked to filter on.
// Each is one subscription, and the traffic follows the area: 2000 km is about
// 615 squares, which the broker takes in batches without complaint. The cap is
// there so an absurd radius cannot turn into thousands of subscriptions — past
// it the nearest squares are kept, and the log says how many.
const pskrMaxGrids = 900
// bandOpenNearKm reads the stored receiver radius, falling back to the default
// for anything unset or out of range. The bounds are the two ways to make the
// watch useless: below 25 km almost nobody is ever near enough to hear anything,
// and past 1000 km the reports stop being about the operator's own path — which
// is the entire premise of measuring from a receiver rather than a transmitter.
// and past 3000 km a "nearby" receiver is on the far side of a continent, which
// says nothing about the operator's own path.
//
// The ceiling was 1000 km, chosen with Europe in mind, where that radius holds a
// dozen countries' worth of receivers. It is the wrong number in VK: a station
// there can have almost nobody inside 300 km, and the chase list stayed empty
// not because nothing was on the air but because nothing was listening close
// enough to count.
func bandOpenNearKm(raw string) int {
n, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || n < 25 || n > 1000 {
if err != nil || n < 25 || n > 3000 {
return pskr.DefaultNearKm
}
return n
@@ -194,7 +207,17 @@ func (a *App) startBandOpenFeed() {
// hundred survived the NearKm test below. One ring of squares — about the
// same 300 km — is 0.2 to 1.2 a second, and the same for every operator,
// where filtering by DXCC ranged from 1.2 (OH) to 72.5 (K).
rxGrids := geo.NeighbourGrids(a.opLat, a.opLon, 1)
//
// The set follows the radius the operator asked for. It was one fixed ring
// whatever they set — about 300 km — so raising the radius bought nothing:
// the reports that would have satisfied it were never sent to us in the
// first place, and an operator in a thinly-populated region who widened the
// circle to find some receivers saw no change at all.
rxGrids := geo.GridsWithin(a.opLat, a.opLon, float64(s.NearKm), pskrMaxGrids)
if len(rxGrids) == 0 {
rxGrids = geo.NeighbourGrids(a.opLat, a.opLon, 1)
}
applog.Printf("pskr: receiver filter — %d squares within %d km of the station", len(rxGrids), s.NearKm)
var onGrid func(call, grid string)
if chaseGrids {
+6
View File
@@ -33,6 +33,12 @@ func TestBulkEditFieldsAreWritable(t *testing.T) {
id := m[1]
// Handled before the column map: freq takes a numeric path (freq_hz +
// band together) and the extras live in extras_json.
// The integer My-station fields take their own numeric path
// (BulkSetIntField + bulkEditableIntCols in the repo), added after this
// guard was written — which is exactly the drift it exists to catch.
if id == "my_dxcc" || id == "my_cq_zone" || id == "my_itu_zone" {
continue
}
if id == "freq" || qso.IsBulkEditableExtra(id) {
continue
}
+556
View File
@@ -1,4 +1,560 @@
[
{
"version": "0.27.14",
"date": "",
"en": [
"Auto-call sees a decode that arrives after the others. A decoder sends a period in a burst and stragglers follow — a deep decode a second behind the rest — and the straggler was judged on its own, with the thirty stations of its own period nowhere in sight. The period now stays open until the next one starts, and a late arrival is weighed against all of it.",
"Icom CI-V: address 00 can be set, and “Other (custom address)” stays chosen. Zero was treated as “not configured” and every save put the rig back to the IC-7610s 98 — the model list following it, since it is derived from the address rather than stored.",
"Cloudlog / Wavelog upload: a simplex contact is no longer uploaded as split. Every QSO carried a receive band and frequency equal to the transmit side, and Wavelog draws both — an ordinary FT8 contact read “17m/17m”. In ADIF an absent BAND_RX means “same as transmit”, so they are now written only when they differ. The record forwarded to another logger on the UDP link still carries them in full (Log4OM reads BAND_RX).",
"Cluster: “S/F” in a spot comment is read as FT8, alongside “superfox”, “sfox” and “F/H”. They are all the same DXpedition transmit mode, and the comment was falling through to the band plan and coming out DATA.",
"Auto-call never parks a watched callsign. After a few series of unanswered calls a station is set aside for the session — the right answer for one the LOG picked out, the wrong one for a station YOU named: a DXpedition running a pileup takes more than two series to get through to, which is exactly why it is on the list. The rest between series still applies.",
"Right-click → Send to: an upload to a service with no credentials is refused, and says which ones are missing and where. It used to run on its own and report into the QSL Managers console, which is not open when the command came from the QSO list — so it looked exactly like an upload that worked. Cloudlog / Wavelog and HamQTH also name themselves properly in the toast.",
"[NEW] The mouse wheel steps the RST fields, in the entry strip and in the QSO editor. It counts the way an operator does — 57, 58, 59, 59+5, 59+10, 59+15, 59+20 — one S-unit up to nine and then five decibels at a time, and one decibel on a digital report. R and T do not move. The dropdown beside them lists the reports worth having to hand, not every legal one, so the wheel works on the value rather than walking the list.",
"PSK Reporter panel: with the whole-band scope, clicking a decode no longer resets the report count to zero. The window there belongs to the BAND — every FTx report on it, filtered by target only when the analysis is drawn — and it was being emptied on every target change, throwing away an hour of evidence at the exact moment it was worth something. The narrow scope still clears it, because there the window is one stations.",
"Changing mode with a callsign in the field now fixes the report. The “the operator chose this report” flag was holding across a change of mode, where it means nothing — “+00” is not a weak SSB report, it is not a report at all — and anything that fills the field from the rig (the S-meter readouts in the rig consoles) sets that flag too, so it could stay in the wrong notation for the whole QSO. A judgement that can be carried across is carried (57 → 579, 599 → 59); otherwise the modes preset answers.",
"[NEW] The world map opens centred on YOUR square, not on Greenwich. Centred on 0° it left an Australian looking at their own country in the bottom-right corner with every path running off both edges; centred on their own longitude the same map reads the way their antenna does — the Americas to the east, Europe and Africa to the west. The latitude leans towards your hemisphere without following you to the pole, and a view you have panned to yourself still wins.",
"The FT decodes map and the grid-square map remember where you left them — centre and zoom, portable with the data folder like the world maps own view. Panning a map is the operator saying which part of the world they are working, and it was being thrown away on every tab switch.",
"The world map now waits for the stations square before painting. It used to draw the world at 0° and then move to your longitude, fetching a screenful of tiles and discarding it on every first run; it is built once, knowing where it is looking. A profile with no locator still gets the default view after a moment rather than a blank panel.",
"Auto-call: the rest between two series is counted in the stations own overs, not in minutes, and is ONE by default. Two minutes is four overs on FT8 — by then the DX has worked four other callers and half the time it has gone. Seven calls, one over listened through, and it goes again if the station is still there (Settings → DXHunter, “Rest (overs)”).",
"Auto-call: a period the station was decoded in is never counted as a miss. A period is judged more than once — the decodes arrive in a burst and stragglers follow — and a later judgement holds a partial view of it, not evidence of absence: a station answering in that very period showed “1/3 missed” against it.",
"The auto-call readout moved out of the Auto button and beside it: the station being called is the biggest thing on the row, the calls and the missed periods each carry a label instead of reading as one number, and a station being waited for shows with an hourglass. The button had been changing width every period.",
"FT map: the station dots answer the same two gestures as the decodes list — one click takes the callsign into the entry, two answer it. The hit area is wider than the dot, and a double click no longer zooms the map on its way through."
],
"fr": [
"Lauto-call voit un décodage qui arrive après les autres. Un décodeur envoie une période en rafale, puis les retardataires — un décodage « deep » une seconde plus tard — et le retardataire était jugé tout seul, sans les trente stations de sa propre période. La période reste maintenant ouverte jusquau début de la suivante, et un arrivant tardif est pesé face à lensemble.",
"Icom CI-V : ladresse 00 peut être saisie, et « Other (custom address) » reste sélectionné. Le zéro était pris pour « non configuré » et chaque enregistrement remettait le poste sur le 98 de lIC-7610 — la liste des modèles suivant, puisquelle est déduite de ladresse et non enregistrée.",
"Upload Cloudlog / Wavelog : un contact simplex nest plus envoyé comme un split. Chaque QSO portait une bande et une fréquence de réception égales à l’émission, et Wavelog affiche les deux — un FT8 ordinaire se lisait « 17m/17m ». En ADIF, un BAND_RX absent signifie « identique à l’émission » : ils ne sont donc écrits que sils diffèrent. Lenregistrement transmis à un autre logiciel par UDP les porte toujours en entier (Log4OM lit BAND_RX).",
"Cluster : « S/F » dans un commentaire de spot est lu comme du FT8, au même titre que « superfox », « sfox » et « F/H ». Cest le même mode d’émission DXpédition, et le commentaire retombait sur le plan de bande pour ressortir en DATA.",
"Lauto-call ne met jamais de côté un indicatif de la watchlist. Après quelques séries dappels sans réponse, une station est écartée pour la session — la bonne réponse pour une station choisie par le CARNET, la mauvaise pour une station que VOUS avez nommée : un DX en pile-up demande plus de deux séries pour passer, et cest précisément pour ça quil est sur la liste. Le repos entre séries sapplique toujours.",
"Clic droit → Envoyer vers : un envoi vers un service non configuré est refusé, en disant ce qui manque et où. Il partait tout seul et rendait compte dans la console du gestionnaire QSL, qui nest pas ouverte quand la commande vient de la liste des QSO — ça ressemblait donc exactement à un envoi réussi. Cloudlog / Wavelog et HamQTH sannoncent aussi sous leur nom dans le message.",
"[NEW] La molette fait défiler les champs RST, dans la barre de saisie comme dans l’éditeur de QSO. Elle compte comme un opérateur — 57, 58, 59, 59+5, 59+10, 59+15, 59+20 — un point S jusqu’à neuf puis cinq décibels à la fois, et un décibel sur un report numérique. R et T ne bougent pas. La liste déroulante à côté contient les reports quon veut sous la main, pas tous les reports légaux : la molette agit donc sur la valeur plutôt que de parcourir la liste.",
"Panneau PSK Reporter : en portée « toute la bande », cliquer sur un décodage ne remet plus le nombre de reports à zéro. La fenêtre appartient là à la BANDE — tous les reports FTx qui y circulent, filtrés par cible seulement à laffichage — et elle était vidée à chaque changement de cible, jetant une heure dobservations au moment précis où elles servent. La portée étroite continue de la vider : là, la fenêtre est celle dune seule station.",
"Changer de mode avec un indicatif dans le champ corrige désormais le report. Le drapeau « lopérateur a choisi ce report » tenait au travers dun changement de mode, où il ne veut rien dire — « +00 » nest pas un report SSB faible, ce nest pas un report du tout — et tout ce qui remplit le champ depuis le poste (les lectures S-mètre des consoles) lève ce drapeau aussi : la notation pouvait rester fausse pour tout le QSO. Un jugement transposable lest (57 → 579, 599 → 59) ; sinon le préréglage du mode répond.",
"[NEW] La carte du monde souvre centrée sur VOTRE locator, plus sur Greenwich. Centrée sur 0°, elle laissait un Australien avec son pays dans le coin en bas à droite et tous les trajets qui sortaient des deux bords ; centrée sur sa longitude, la même carte se lit comme son antenne travaille — les Amériques à lest, lEurope et lAfrique à louest. La latitude penche vers votre hémisphère sans vous suivre jusquau pôle, et une vue que vous avez déplacée vous-même reste prioritaire.",
"La carte des décodages FTx et la carte des locators retiennent où vous les avez laissées — centre et zoom, portables avec le dossier de données comme la vue de la carte du monde. Déplacer une carte, cest dire quelle partie du monde on travaille, et c’était jeté à chaque changement donglet.",
"La carte du monde attend désormais le locator de la station avant de peindre. Elle dessinait le monde à 0° puis se déplaçait sur votre longitude, chargeant un écran de tuiles jeté aussitôt à chaque premier lancement ; elle est construite une fois, en sachant où elle regarde. Un profil sans locator obtient toujours la vue par défaut après un instant, pas un panneau vide.",
"Auto-call : le repos entre deux séries se compte en tours de la station, plus en minutes, et vaut UN par défaut. Deux minutes, cest quatre tours en FT8 — le DX a travaillé quatre autres appelants entre-temps, et la moitié du temps il est parti. Sept appels, un tour écouté, et ça repart si la station est toujours là (Réglages → DXHunter, « Repos (tours) »).",
"Auto-call : une période où la station a été décodée nest plus comptée comme un raté. Une période est jugée plusieurs fois — les décodages arrivent en rafale puis les retardataires — et un jugement tardif nen donne quune vue partielle, pas la preuve dune absence : une station qui répondait dans cette période exacte se voyait compter « 1/3 raté ».",
"Laffichage de lauto-call sort du bouton Auto pour se placer à côté : la station appelée est l’élément le plus lisible de la ligne, les appels et les périodes ratées portent chacun leur étiquette au lieu de se lire comme un seul nombre, et une station attendue saffiche avec un sablier. Le bouton changeait de largeur à chaque période.",
"Carte FTx : les points des stations répondent aux mêmes deux gestes que la liste des décodages — un clic met lindicatif dans la saisie, deux lappellent. La zone cliquable est plus large que le point, et un double clic ne zoome plus la carte au passage."
]
},
{
"version": "0.27.13",
"date": "",
"en": [
"[NEW] Rotor widget redrawn: a night world map behind a square azimuth scale, an orange pointer following the mouse so a click lands where it is aimed, a yellow marker on the azimuth ordered until the antenna gets there, and quick turns in columns of six. The Ultrabeam boom and its second lobe are still drawn, and Station Control keeps the plain dial — dial only, since anything under it is pushed off the bottom of the row. Design contributed by EC1KD — thank you.",
"[NEW] Settings → Rotator now chooses the dial: the new world-map compass or the classic one. Both are kept — one reads across the shack, the other is the compact dial that was there before — and the choice applies to the docked widget and to Station Control at once.",
"Rotor widget: the Stop button no longer flickers on a rotor standing still. Movement was inferred from a one-degree change, which is less than the jitter a controller reports at rest.",
"[NEW] A docked watch-list panel, showing only what is ON THE AIR and still needed — one line per band and mode — callsign, band, mode, what it is worth, how long ago — freshest first, click to tune, with the clusters own NEW DXCC / NEW BAND / NEW SLOT badge on each row. The Watchlist tab is a tab, and an operator working FT8 lives on the decodes one: a station you asked to be told about was appearing on a screen you were not looking at. Turn it on with the bell in the toolbar.",
"[NEW] FT decodes: a distance column, next to the square it is computed from and in your own unit (km or miles). Rounded to whole units — a four-character grid is a square tens of kilometres wide and nothing finer is honest.",
"[NEW] The PSK Reporter panel switches to the station auto-call is WAITING for (the hourglass), while nothing is being called. Its analysis takes a history query and a period or two of live reports to fill, so starting it when the DX finally comes free is starting it too late — the wait is exactly what it should be spent on.",
"[NEW] Chase new: a band selection of its own, under the option (160 m to 70 cm). The stations band list still applies underneath — this one says what is worth WATCHING tonight.",
"Chase new: the panel says what the feed is doing — up or down, how many reports it has taken, how many receiver squares it is subscribed to. An empty list said nothing about whether anything was arriving at all.",
"FT decodes: a receiver column appears when more than one decoder feeds the merged list, and the list empties for a receiver that changes band — half a screen of stations no longer reachable is worse than an empty one.",
"FT decodes: a pink WL badge marks a station on your watch list, and the period clock turns red while transmitting. It is the one thing on the screen that moves, so “am I on the air” is readable from across the room.",
"FT decodes: a message addressed to YOU is set in green, whole and bold, with a green edge on the row — read from the far side of the shack. The station you are calling keeps a light tint and its callsign picked out in red: most of what it sends goes to other people, and colouring those lines the same way said you were in a QSO you were not in.",
"Cluster: “superfox”, “super fox”, “fox/hound” and “F/H” in a spot comment are read as FT8. They are WSJT-Xs DXpedition transmit modes, not modes of their own, and the comment fell through to the bands default.",
"Auto-call: what it calls, and in what order. It answers only what the decodes list is SHOWING — the filters above it (CQ only, LoTW only, the category chips, continents, minimum report, search) now steer the transmitter as well as the eye, and the separate “LoTW users only” option is gone. A new prefix, county, state, square or park is worth a call too, at the foot of the ladder and only for the kinds you chase (Settings → DX Cluster). A watched callsign outranks everything not on the list, a real need beats an unconfirmed one at the same level, and a better station may take over from one that has not answered yet — never from a QSO in progress.",
"Auto-call: it calls THROUGH a pileup. It used to give up the moment the station it was calling answered somebody else — which is precisely how a DX with a queue behaves, and the only way to be the next one is to keep calling while it works the others. Still bounded by the call and miss counters, and a station in mid-exchange is still never CHOSEN as a new target.",
"Auto-call: safety and control. It is always OFF at launch and after a profile switch, never armed from a stored setting — it is the one feature that puts the station on the air by itself, and OpsLog starts with Windows. Halt is now a verdict: the station is set aside for the session and never called again until auto-call is switched off and on. It says what it is waiting for, showing a wanted station that is working somebody else next to the Auto button. And it can log every decision (Settings → DXHunter): one line per period saying what was on the air and why each station was refused.",
"Auto-call: a round of fixes from on-air use. It no longer cuts your own 73 short, calls four to six seconds late, starts a call and drops it a second later, ignores a station calling you, misreads MSHVs two-answers-in-one-line, refuses nearly everything on the air because it read the entitys “worked” as the stations, or opens with two misses already counted against a station it has only just picked.",
"Switching profile no longer leaves the previous logbooks verdicts on the screen. Coming back from a profile with an empty log, every decode and spot stayed badged NEW until a restart: the worked-index, the chase-new list and the cached verdicts are now all dropped when the logbook changes.",
"Chase new fixes: the header counts both what the filters show and what the panel holds (“3 of 12 heard”), a stored filter set that hides everything is ignored, and switching between “new” and “new + unconfirmed” re-reads the list instead of leaving old verdicts on screen.",
"PSK Reporter panel fixes: the history is asked for in both feed scopes and refreshed every five minutes, so a target picked shortly after start-up no longer shows an empty page; the suggested transmit offset always has an answer when there is data; and the texts say ten minutes, which is the window actually used.",
"Callbook lookup: a compound callsign with a page of its OWN keeps that pages location. HP/WE9G is filed on QRZ under exactly that form, with the Panama address and square the station is operating from, and OpsLog was throwing it away — the rule that drops a home address from a portable call was applying to pages that describe the operation itself. The records own country tells the two apart. Cached lookups heal on the next read; QSOs already logged without a grid keep it empty."
],
"fr": [
"[NEW] Widget rotor redessiné : carte du monde nocturne derrière une échelle dazimut carrée, aiguille orange qui suit la souris pour quun clic parte où on vise, repère jaune sur lazimut demandé jusqu’à larrivée de lantenne, et directions rapides en colonnes de six. Le boom Ultrabeam et son deuxième lobe sont toujours tracés, et Station Control garde le cadran seul — rien en dessous, ce qui y était se retrouvait coupé en bas de la rangée. Design proposé par EC1KD — merci à lui.",
"[NEW] Réglages → Rotator permet de choisir le cadran : la nouvelle boussole carte du monde ou le cadran classique. Les deux sont conservés — lun se lit de loin, lautre est le cadran compact davant — et le choix sapplique aussitôt au widget docké comme à Station Control.",
"Widget rotor : le bouton Stop ne clignote plus sur un rotor à larrêt. Le mouvement était déduit dun changement dun degré, soit moins que le tremblement de lecture dun contrôleur au repos.",
"[NEW] Un panneau watchlist docké, qui ne montre que ce qui est EN LAIR et manque encore — une ligne par bande et mode — indicatif, bande, mode, ce que ça vaut, il y a combien de temps — le plus frais en haut, clic pour sy caler, avec le badge NEW DXCC / NEW BAND / NEW SLOT du cluster sur chaque ligne. Longlet Watchlist est un onglet, et en FT8 on vit sur celui des décodes : une station quon avait demandé à surveiller apparaissait sur un écran quon ne regardait pas. À activer avec la cloche dans la barre doutils.",
"[NEW] FT decodes : une colonne distance, à côté du locator dont elle est calculée et dans votre unité (km ou miles). Arrondie à lunité — un locator à quatre caractères est un carré de plusieurs dizaines de kilomètres, rien de plus fin ne serait honnête.",
"[NEW] Le panneau PSK Reporter bascule sur la station que lauto-call ATTEND (le sablier), tant que rien nest appelé. Son analyse met une requête dhistorique et une période ou deux à se remplir : la lancer quand le DX se libère enfin, cest la lancer trop tard — lattente sert précisément à ça.",
"[NEW] Chase new : sélection de bandes propre au panneau, sous loption (160 m à 70 cm). La liste de bandes de la station sapplique toujours en dessous — celle-ci dit ce quon veut SURVEILLER ce soir.",
"Chase new : le panneau indique l’état du flux — connecté ou non, nombre de rapports reçus, nombre de carrés de réception souscrits. Une liste vide ne disait rien sur ce qui arrivait vraiment.",
"FT decodes : une colonne récepteur apparaît quand plusieurs décodeurs alimentent la liste fusionnée, et la liste se vide pour un récepteur qui change de bande — un demi-écran de stations devenues inaccessibles est pire quun écran vide.",
"FT decodes : un badge WL rose marque une station de votre watchlist, et lhorloge de période passe au rouge en émission. Cest le seul élément qui bouge à l’écran, donc « suis-je en émission » se lit de loin.",
"FT decodes : un message qui VOUS est adressé saffiche en vert, en entier et en gras, avec un liseré vert sur la ligne — lisible de lautre bout du shack. La station que vous appelez garde une teinte légère et son indicatif en rouge : lessentiel de ce quelle émet sadresse à dautres, et colorer ces lignes pareil laissait croire à un QSO en cours.",
"Cluster : « superfox », « super fox », « fox/hound » et « F/H » dans un commentaire de spot sont lus comme du FT8. Ce sont les modes d’émission DXpédition de WSJT-X, pas des modes en soi, et le commentaire retombait sur le mode par défaut de la bande.",
"Auto-call : ce quil appelle, et dans quel ordre. Il ne répond qu’à ce que la liste des décodes AFFICHE — les filtres du dessus (CQ seul, LoTW seul, les pastilles de catégorie, les continents, le rapport minimum, la recherche) pilotent désormais l’émission autant que l’œil, et loption distincte « utilisateurs LoTW uniquement » disparaît. Un nouveau préfixe, comté, état, locator ou parc vaut aussi un appel, au dernier barreau de l’échelle et seulement pour ce que vous chassez (Réglages → DX Cluster). Un indicatif en watchlist passe devant tout ce qui ny est pas, un vrai besoin devant un besoin non confirmé de même niveau, et une meilleure station peut prendre la place dune autre qui na pas encore répondu — jamais celle dun QSO en cours.",
"Auto-call : il appelle À TRAVERS un pile-up. Il abandonnait dès que la station appelée répondait à quelquun dautre — or cest exactement ce que fait un DX avec une file, et le seul moyen d’être le suivant est de continuer à appeler pendant quil travaille les autres. Toujours borné par les compteurs dappels et de ratés, et une station en plein échange nest toujours jamais CHOISIE comme nouvelle cible.",
"Auto-call : sécurité et contrôle. Il est toujours ARRÊTÉ au lancement et après un changement de profil, jamais armé depuis un réglage enregistré — cest la seule fonction qui met la station en émission toute seule, et OpsLog démarre avec Windows. Halt devient un verdict : la station est mise de côté pour la session et nest plus appelée jusqu’à ce que lauto-call soit désactivé puis réactivé. Il dit ce quil attend, en affichant à côté du bouton Auto la station voulue qui travaille quelquun dautre. Et il peut journaliser chaque décision (Réglages → DXHunter) : une ligne par période disant ce qui était sur lair et pourquoi chaque station a été refusée.",
"Auto-call : série de corrections issues du trafic. Il ne coupe plus votre 73, nappelle plus avec quatre à six secondes de retard, ne lance plus un appel pour le couper une seconde après, ne laisse plus sans réponse une station qui vous appelle, lit correctement les doubles réponses MSHV sur une même ligne, ne refuse plus presque tout ce qui passe parce quil prenait le « worked » de lentité pour celui de la station, et ne démarre plus avec deux ratés au compteur sur une station tout juste choisie.",
"Changer de profil ne laisse plus les verdicts du carnet précédent à l’écran. En revenant dun profil au log vide, tous les décodes et spots restaient marqués NEW jusquau redémarrage : lindex des contacts, la liste chase new et les verdicts en cache sont désormais vidés au changement de carnet.",
"Chase new, corrections : len-tête compte à la fois ce que les filtres montrent et ce que le panneau contient (« 3 sur 12 entendues »), un jeu de filtres enregistré qui cache tout est ignoré, et le passage entre « new » et « new + non confirmés » relit la liste au lieu de laisser danciens verdicts à l’écran.",
"Panneau PSK Reporter, corrections : lhistorique est demandé dans les deux portées du flux et rafraîchi toutes les cinq minutes, donc une cible choisie peu après le démarrage naffiche plus une page vide ; loffset d’émission suggéré donne toujours une réponse quand il y a des données ; et les textes annoncent dix minutes, la fenêtre réellement utilisée.",
"Recherche callbook : un indicatif composé qui a sa PROPRE fiche garde la position de cette fiche. HP/WE9G est déposé sur QRZ sous cette forme exacte, avec ladresse et le locator du Panama doù la station émet, et OpsLog les jetait — la règle qui écarte ladresse personnelle dun indicatif portable sappliquait aussi aux fiches qui décrivent lopération elle-même. Cest le pays de la fiche qui les distingue. Les fiches en cache se corrigent à la lecture suivante ; les QSO déjà enregistrés sans locator le restent."
]
},
{
"version": "0.27.12",
"date": "",
"en": [
"Help menu: “Join the Discord” opens the OpsLog Discord server in your browser.",
"Icom console: a tick box makes the band buttons recall the radio's own band stacking registers — where you last were on that band, in the mode you were in. Press the same band again to step through its three registers, exactly as the radio's band key does. A band the register cannot be read for still sends the fixed frequency.",
"Shared CAT: a frequency or mode just commanded is reported back at once instead of on the next poll, and a mode the radio is already in is no longer re-sent. WSJT-X waits for that readback before it believes the band changed — over a network CI-V link that wait was several seconds.",
"Chase new / band openings: the receiver radius goes up to 3000 km, and the PSK Reporter subscription now follows it. Widening the circle used to change nothing, because the reports it would have accepted were never sent to us. 300 km holds no receivers at all in much of VK, ZL or North America.",
"The radius is also reachable from the Chase new option itself, rather than only from inside the band-opening watch.",
"New PSK Reporter panel beside the FT decodes, which can be hidden: for the station you are calling, has he decoded YOU and how long ago, who near you he is hearing, who near him heard you, how many stations he is working through, and where his receive passband is free. Follows the station you click or call. Off by default — Settings → DXHunter/spots. The narrow feed is a few messages a second; a whole-band option is there for a fast machine.",
"FT Map: the arcs no longer run off the side of the map. The map shows one world, and a path crossing the antimeridian was drawn past 180° into the blank space beside it — from VK or ZL that is most of them, each one ending nowhere while its own marker sat on the far coast. A path now leaves one edge and re-enters at the other, at the latitude it left.",
"NEW — Auto-call: OpsLog can answer FT8/FT4 decodes on its own. It picks the best station on the air by what the log still needs — a watched callsign outranking the same need from anybody else — and, above all, it knows when to stop: 7 calls (15 for a watched callsign), 3 of the stations own transmit periods with no decode of it, a four-minute backstop, and three series per station for a whole session. A station in the middle of a QSO with somebody else is never called, because it cannot answer. Every decision is written to the log with its reason, the toolbar button shows the target and the count as it goes, and Halt stops it. OFF by default — Settings → DXHunter/spots. It keys your transmitter without asking, so read that panels warning before switching it on, and switch DXHunters own auto-call off: two programs answering decodes from one shack transmit over each other.",
"PSK Reporter panel: the window is ten minutes, and the history query now fills BOTH directions when the target changes. Side by side with DXHunter on the same station at the same moment it showed 18 decodes against 27, and a co-area station missing — five minutes catches about one upload cycle per reporting station, and most of them report every five.",
"Auto-call: “Chase only” takes SEVERAL callsigns, separated by spaces or commas. Nothing off the list is called, the priority ladder still orders the ones on it, and working one leaves the others callable.",
"Two new themes on DXHunters palette: “DXHunter”, its slate and its blue exactly, and “DXHunter orange”, the same slate with OpsLogs own orange accent. Both carry its status colours (emerald, amber, cyan, red), so a green number means the same thing in either window. Settings → General → Theme.",
"The auto-call chase list is also in the decodes toolbar, beside the Auto button: naming the station you are waiting for is done while watching the band, not in a settings tree. It is the same setting as the one in Preferences — type into either and both show it.",
"FT decodes: one click SELECTS a station — it fills the entry and points the panels at it, like a cluster spot — and a double click calls it. A single click used to hand the decode straight to WSJT-X as a reply, so brushing a row while reading the band started transmitting.",
"Auto-call with two decoders running: while a station is being called, a period from the other receiver is not looked at. It cannot start a second QSO over the one in progress however good the station it hears, its periods count no missed periods against a target that was never on its band, and its transmissions are not counted as calls to that target.",
"Watchlist: drawn as DXHunter draws it — the callsign in the interface font rather than monospaced (the difference that shows with the two windows side by side), badges at its size, a check or a warning triangle at the head of each spot line, and frequencies as 7.056 rather than 7.0560.",
"A decoder is named by what it IS, not by what it announces: Nexus sends its packets as “Tempo”, the engine inside it, and OpsLog showed a program the operator had never heard of. The id itself is untouched — it is what a reply, a halt and the auto-call are routed by.",
"WSJT-X colouring can grey out a station already worked on this band AND in this mode — the duplicate. Its own switch under the highlight option, off by default: the other three verdicts pick out a handful of decodes, this one can match most of a period on a well-filled log. Grey rather than a colour, because it says the opposite of the others.",
"Contest watchlist (Settings → DXHunter): beside the auto-add pattern there is now a list of callsigns, one per line. A pattern collects a fleet that shares a string — TM29WWA, HB9WWA, F4WWA/P — but not the station taking part under a callsign that says nothing about the event. Named in the list, it joins the contest watchlist the moment it is spotted."
],
"fr": [
"Menu Aide : « Rejoindre le Discord » ouvre le serveur Discord dOpsLog dans votre navigateur.",
"Console Icom : une case à cocher fait rappeler aux boutons de bande les registres de bande de la radio — là où vous étiez la dernière fois sur cette bande, dans le mode où vous étiez. Réappuyez sur la même bande pour parcourir ses trois registres, comme le fait la touche de bande du poste. Une bande dont le registre est illisible envoie toujours la fréquence fixe.",
"CAT partagé : une fréquence ou un mode quon vient de commander est renvoyé immédiatement, sans attendre le sondage suivant, et un mode que la radio a déjà nest plus réémis. WSJT-X attend cette relecture avant de croire au changement de bande — sur une liaison CI-V réseau, cette attente durait plusieurs secondes.",
"Chase new / ouvertures : le rayon des récepteurs monte à 3000 km, et labonnement PSK Reporter le suit désormais. Élargir le cercle ne changeait rien, car les reports quil aurait acceptés ne nous étaient jamais envoyés. 300 km ne contient aucun récepteur dans une bonne partie de VK, ZL ou dAmérique du Nord.",
"Ce rayon est aussi accessible depuis loption Chase new elle-même, et non plus seulement depuis la veille douvertures.",
"Nouveau panneau PSK Reporter à côté des décodages FTx, masquable : pour la station que vous appelez, vous a-t-il décodé et depuis combien de temps, qui entend-il près de chez vous, qui près de lui vous a entendu, combien de stations défilent chez lui, et où son passe-bande est libre. Il suit la station que vous cliquez ou appelez. Désactivé par défaut — Réglages → DXHunter/spots. Le flux étroit ne coûte que quelques messages par seconde ; une option « toute la bande » existe pour une machine rapide.",
"FT Map : les arcs ne partent plus hors de la carte. La carte naffiche quun seul monde, et un chemin franchissant lantiméridien était tracé au-delà de 180°, dans le vide à côté — depuis VK ou ZL cest la majorité dentre eux, chacun finissant nulle part alors que son propre marqueur était sur la côte opposée. Un chemin sort désormais par un bord et revient par lautre, à la latitude où il est sorti.",
"NOUVEAU — Appel automatique : OpsLog peut répondre seul aux décodages FT8/FT4. Il choisit la meilleure station en fonction de ce qui manque au log — un indicatif surveillé passant devant le même besoin chez un autre — et surtout il sait sarrêter : 7 appels (15 pour un indicatif surveillé), 3 périodes d’émission de la station sans la décoder, un butoir de quatre minutes, et trois séries par station pour toute une session. Une station en plein QSO avec quelquun dautre nest jamais appelée : elle ne peut pas répondre. Chaque décision est écrite dans le journal avec sa raison, le bouton de la barre affiche la cible et le décompte en direct, et Stop linterrompt. DÉSACTIVÉ par défaut — Réglages → DXHunter/spots. Il met votre émetteur en marche sans vous demander : lisez lavertissement du panneau avant de lactiver, et coupez lappel automatique de DXHunter — deux programmes qui répondent aux décodages du même shack s’émettent dessus.",
"Panneau PSK Reporter : la fenêtre passe à dix minutes, et la requête dhistorique remplit désormais les DEUX sens au changement de cible. Côte à côte avec DXHunter sur la même station au même instant, il affichait 18 décodages contre 27, et une station de la région manquait — cinq minutes ne captent quun cycle denvoi par station, et la plupart nenvoient que toutes les cinq minutes.",
"Appel automatique : « Chasser uniquement » accepte PLUSIEURS indicatifs, séparés par des espaces ou des virgules. Rien hors de la liste nest appelé, l’échelle de priorité départage ceux qui y sont, et en travailler un laisse les autres appelables.",
"Deux nouveaux thèmes sur la palette de DXHunter : « DXHunter », son ardoise et son bleu à lidentique, et « DXHunter orange », la même ardoise avec lorange dOpsLog. Les deux reprennent ses couleurs d’état (émeraude, ambre, cyan, rouge) : un nombre vert veut dire la même chose dans les deux fenêtres. Réglages → Général → Thème.",
"La liste de chasse de lappel automatique est aussi dans la barre des décodages, à côté du bouton Auto : nommer la station quon attend se fait en regardant la bande, pas dans un arbre de réglages. Cest le même réglage que dans les Préférences — saisi dans lun, il apparaît dans lautre.",
"Décodages FT : un clic SÉLECTIONNE une station — il remplit la saisie et y pointe les panneaux, comme un spot du cluster — et un double-clic lappelle. Un simple clic passait le décodage directement à WSJT-X en réponse : frôler une ligne en lisant la bande déclenchait une émission.",
"Appel automatique avec deux décodeurs : pendant quune station est appelée, une période de lautre récepteur nest pas examinée. Il ne peut pas ouvrir un second QSO par-dessus celui en cours, si bonne que soit la station quil entend ; ses périodes ne comptent aucune période manquée contre une cible qui na jamais été sur sa bande, et ses émissions ne comptent pas comme des appels vers elle.",
"Watchlist : dessinée comme DXHunter la dessine — lindicatif dans la police de linterface plutôt quen chasse fixe (la différence qui saute aux yeux avec les deux fenêtres côte à côte), pastilles à sa taille, coche ou triangle dalerte en tête de chaque ligne de spot, et fréquences en 7.056 plutôt que 7.0560.",
"Un décodeur est nommé par ce quil EST, non par ce quil annonce : Nexus envoie ses paquets sous le nom « Tempo », le moteur quil embarque, et OpsLog affichait un programme inconnu de lopérateur. Lidentifiant lui-même nest pas touché : cest par lui que passent une réponse, un Stop et lappel automatique.",
"Coloration WSJT-X : possibilité de griser une station déjà travaillée sur cette bande ET dans ce mode — le doublon. Sa propre case sous loption de coloration, désactivée par défaut : les trois autres verdicts désignent quelques décodages, celui-ci peut concerner la majorité dune période sur un log bien rempli. En gris et non en couleur, car il dit linverse des autres.",
"Watchlist contest (Réglages → DXHunter) : à côté du motif dajout automatique, une liste dindicatifs, un par ligne. Un motif attrape une flotte qui partage une chaîne — TM29WWA, HB9WWA, F4WWA/P — mais pas la station engagée sous un indicatif qui ne dit rien de l’événement. Nommée dans la liste, elle rejoint la watchlist contest dès quelle est spottée."
]
},
{
"version": "0.27.11",
"date": "",
"en": [
"Country resolution with the ClubLog file enabled: retired prefixes are recognised again. cty.dat describes the world as it is TODAY — Niue moved to E6, so ZK2 reverted to New Zealand there, and every ZK2 contact ever made was silently relabelled New Zealand, taking a confirmed entity out of the operators DXCC with it. ZK1 lost the Cook Islands the same way. ClubLogs prefix table still knows both and is now consulted whenever no per-callsign exception applies, instead of only for callsigns that happened to have one. It never overrules an exact “=CALLSIGN” entry in cty.dat, and where it has no answer cty.dat still decides. Reprocess an affected import with Update from ClubLog.",
"CQ and ITU zones now come from the callbook when it has them. cty.dat carries ONE representative pair per entity, and OpsLog was stamping it over QRZ.coms per-station answer — so every Asiatic Russia contact was logged CQ 17 / ITU 30, whatever the operators real zone (RU0LL is 19/34, UA0SDX 18/32). That is a WAZ credit for a zone never worked. The entity still comes from cty.dat, which is the authority on what a callsign IS; a zone says where the station SITS, and only the callbook knows that within a country eight CQ zones wide. Where the callbook is silent, cty.dat fills as before.",
"Callsign cache: a lookup is now stored as the callbook returned it, and the country file is applied when it is read. A value OpsLog derived can no longer come back later looking like something the page said — which is how the wrong zones outlived their fix — and a country-file update now reaches rows already cached.",
"“Send to Cloudlog / Wavelog” joins the right-click upload menu. It was the one configured service missing from it: Cloudlog keeps no per-QSO sent status — deliberately, since it dedupes server-side — and the menu had been built around that status. An explicit selection needs no status to be safe, which is exactly why the absence stops mattering here.",
"HAMLOG.online uploads are closed. The site no longer issues API keys and its upload API takes nothing else, so the auto-upload switch and the “Send to” entry armed something that could only fail. Everything else stays: their confirmations still import from a file (which never needed a key), and the sent/received state already in your log remains readable, filterable and bulk-editable. The upload code is kept whole against the day keys come back.",
"Yaesu console: the power slider no longer springs back to 100 W on a 200 W radio. The console already knew an FTDX101MP could do 200 — that is what it drew the slider to — but the command that sets it was clamped to 100, so the rig was politely given half of what was asked for and the next poll showed it. FTDX101MP, FT-DX5000 and FTDX9000 reach 200 W now, and a rig reporting more than expected is still believed.",
"Chase new: clicking a row now does what clicking a cluster spot does — including telling WSJT-X, JTDX or MSHV to change mode, so picking an FT4 station while the decoder sits in FT8 actually moves it. It also brings across the mode and its RST preset, and any park or summit reference. It used to do a hand-picked half of that, which left the one thing the window exists for — jumping onto a station — decoding the wrong mode.",
"FT decodes: JTDX on FT4 no longer reads as Q65. A decode carries a one-character mode marker, and the two programs disagree about “:” — Q65 in WSJT-X, FT4 in JTDX — so the character alone cannot answer, and the wrong answer poisoned every verdict behind it: new mode, new slot, the mode filter. The sending programs own status names the mode in full and now settles it; the markers both forks agree on are untouched."
],
"fr": [
"Résolution des entités avec le fichier ClubLog activé : les préfixes retirés sont de nouveau reconnus. cty.dat décrit le monde tel quil est AUJOURDHUI — Niue est passée en E6, donc ZK2 y est revenu à la Nouvelle-Zélande, et tous les contacts ZK2 jamais faits étaient silencieusement réétiquetés Nouvelle-Zélande, emportant une entité confirmée hors du DXCC de lopérateur. ZK1 perdait les Cook du Sud de la même façon. La table de préfixes ClubLog connaît toujours les deux : elle est désormais consultée dès quaucune exception par indicatif ne sapplique, et non plus seulement pour les indicatifs qui en avaient une. Elle ne prime jamais sur une entrée exacte « =INDICATIF » de cty.dat, et là où elle na pas de réponse cest cty.dat qui tranche. Repassez un import concerné par « Mettre à jour depuis ClubLog ».",
"Les zones CQ et ITU proviennent désormais du callbook quand il les connaît. cty.dat ne porte QUUNE paire représentative par entité, et OpsLog limposait par-dessus la réponse par station de QRZ.com — tout contact avec la Russie asiatique était donc enregistré en CQ 17 / ITU 30, quelle que soit la zone réelle (RU0LL est en 19/34, UA0SDX en 18/32). Cest un crédit WAZ pour une zone jamais travaillée. Lentité vient toujours de cty.dat, qui fait autorité sur ce quun indicatif EST ; une zone dit où la station SE TROUVE, et seul le callbook le sait dans un pays large de huit zones CQ. Là où le callbook se tait, cty.dat comble comme avant.",
"Cache des indicatifs : une recherche est désormais stockée telle que le callbook la rendue, le fichier pays étant appliqué à la lecture. Une valeur déduite par OpsLog ne peut plus revenir plus tard avec lapparence de ce qua dit la page — cest ainsi que les mauvaises zones survivaient à leur correctif — et une mise à jour du fichier pays atteint maintenant les fiches déjà en cache.",
"« Envoyer vers Cloudlog / Wavelog » rejoint le menu denvoi du clic droit. C’était le seul service configuré qui y manquait : Cloudlog ne conserve aucun statut denvoi par QSO — volontairement, puisquil dédoublonne côté serveur — et le menu était construit autour de ce statut. Une sélection explicite na besoin daucun statut pour être sûre : cest précisément pourquoi cette absence cesse de compter ici.",
"Les envois vers HAMLOG.online sont fermés. Le site ne délivre plus de clé API et son interface denvoi naccepte rien dautre : la case denvoi automatique et lentrée « Envoyer vers » armaient donc quelque chose qui ne pouvait qu’échouer. Tout le reste demeure : leurs confirmations simportent toujours depuis un fichier (ce qui na jamais demandé de clé), et l’état envoyé/reçu déjà présent dans votre log reste lisible, filtrable et modifiable en masse. Le code denvoi est conservé intact pour le jour où les clés reviendraient.",
"Console Yaesu : le curseur de puissance ne revient plus à 100 W sur une radio de 200 W. La console savait déjà quun FTDX101MP peut sortir 200 — cest à cela quelle dimensionnait son curseur — mais la commande denvoi était bornée à 100 : le poste recevait donc poliment la moitié de ce quon lui demandait, et le sondage suivant laffichait. FTDX101MP, FT-DX5000 et FTDX9000 atteignent désormais 200 W, et une radio qui annonce plus que prévu reste crue sur parole.",
"Chase new : cliquer une ligne fait désormais ce que fait un clic sur un spot du cluster — y compris demander à WSJT-X, JTDX ou MSHV de changer de mode, si bien que choisir une station FT4 alors que le décodeur est en FT8 ly amène vraiment. Le mode et son RST par défaut suivent aussi, de même que toute référence de parc ou de sommet. La fenêtre nen faisait quune moitié choisie à la main, ce qui laissait la seule chose pour laquelle elle existe — sauter sur une station — décoder dans le mauvais mode.",
"FT decodes : JTDX en FT4 ne saffiche plus en Q65. Un décodage porte un marqueur de mode dun seul caractère, et les deux logiciels ne saccordent pas sur « : » — Q65 pour WSJT-X, FT4 pour JTDX : le caractère seul ne peut donc pas trancher, et sa mauvaise réponse contaminait tout ce qui en découle — nouveau mode, nouveau slot, filtre de mode. Le statut envoyé par le logiciel lui-même nomme le mode en entier et tranche désormais ; les marqueurs sur lesquels les deux saccordent ne changent pas."
]
},
{
"version": "0.27.10",
"date": "",
"en": [
"Widget order (Settings → Appearance): the row to the right of the entry can be rearranged by dragging — the whole row is the handle, and a line shows where it will land. QSO entry and the F1-F5 panel head the list, locked — they are not part of that row and nothing should be allowed to push what you type into behind a rotator dial. A widget you have switched off keeps its place and comes back where you left it, and the main view follows as you drag.",
"Rotator, GS-232 over a serial port: the port is opened once and kept, instead of being reopened for every command. An Arduino-based controller — K3NGs firmware, the ERC family — RESETS when its serial port is opened, so OpsLog was rebooting it several times a second and every command landed in the bootloader: a controller that answered a terminal perfectly reported “no reply to C” here. A freshly opened port is now left to boot before the first command, stale bytes from a previous exchange are discarded, and a failed exchange releases the port so the next one starts clean."
],
"fr": [
"Ordre des widgets (Réglages → Apparence) : la rangée à droite de la saisie se réorganise par glisser-déposer — toute la ligne se saisit, et un trait montre où elle atterrira. La saisie du QSO et le panneau F1-F5 ouvrent la liste, verrouillés — ils ne font pas partie de cette rangée, et rien ne doit pouvoir repousser ce dans quoi vous tapez derrière une boussole de rotor. Un widget désactivé garde sa place et revient là où vous laviez laissé, et la vue principale suit pendant que vous glissez.",
"Rotor, GS-232 sur port série : le port est ouvert une fois et conservé, au lieu d’être rouvert à chaque commande. Un contrôleur à base dArduino — le firmware K3NG, la famille ERC — REDÉMARRE à louverture de son port série : OpsLog le redémarrait donc plusieurs fois par seconde et chaque commande tombait dans le bootloader. Un contrôleur qui répondait parfaitement à un terminal annonçait ici « no reply to C ». Un port fraîchement ouvert a désormais le temps de démarrer avant la première commande, les octets résiduels dun échange précédent sont écartés, et un échange en échec libère le port pour que le suivant reparte propre."
]
},
{
"version": "0.27.9",
"date": "",
"en": [
"FT decodes warn when the decoding application announces a band the radio is not on — the signature of a lost CAT link, where it repeats the last frequency it knew and every decode after that carries a stale band. Nothing downstream could tell, so NEW BAND was being judged against a band the operator had left. OpsLog says it rather than deciding: a second receiver on another band is a real setup, and it costs that one only a line to read past.",
"Voice keyer with CAT keying: an option saying the keyers audio arrives on the radios DATA / USB input rather than the microphone socket. A Kenwood TS-590 has two transmit commands — TX opens the front mic, TX1 the rear ACC2/USB — so a keyer playing through the rigs own sound card was transmitting dead air while the radio listened to a microphone nobody was speaking into. Shown on the Kenwood backend only — no other radio family draws the distinction — and the Test PTT button exercises the same path."
],
"fr": [
"Les FT decodes signalent quand le logiciel de décodage annonce une bande sur laquelle la radio nest pas — la signature dune liaison CAT perdue, où il répète la dernière fréquence connue et où tous les décodages suivants portent une bande périmée. Rien en aval ne pouvait sen apercevoir : NOUVELLE BANDE était donc jugé sur une bande quittée. OpsLog le dit sans décider à votre place : un second récepteur sur une autre bande est une configuration légitime, et il ne lui en coûte quune ligne à ignorer.",
"Voice keyer avec PTT CAT : une option indiquant que laudio du keyer arrive sur lentrée DATA / USB de la radio et non sur la prise micro. Un Kenwood TS-590 a deux commandes d’émission — TX ouvre le micro de face avant, TX1 lACC2/USB — si bien quun keyer jouant par la carte son du poste émettait dans le vide pendant que la radio écoutait un micro devant lequel personne ne parlait. Affichée sur le backend Kenwood uniquement — aucune autre famille de postes ne fait cette distinction — et le bouton Test PTT emprunte le même chemin."
]
},
{
"version": "0.27.8",
"date": "",
"en": [
"The band matrixs DIG row is now a rotation: click it and it answers for FT8, then FT4, then each digital mode your mode list holds — in YOUR order — then back to DIG. One row per digital mode would be the honest layout and there is no height for it beside the other widgets, so the row keeps its place and changes what it says. The label column is sized once for the longest mode it can show, so the matrix never shifts as the rotation comes round to RTTY.",
"Icom console: LSB and USB are separate buttons and can finally be commanded by name — the single SSB button resolved the sideband from the band, so there was no way to ask an IC-7300 for USB on 40 m. A rig reporting the folded “SSB” still lights the side its frequency implies.",
"Icom console: a 60 m band button, and the antenna and PSK controls only appear on radios that have them. An IC-7300 has one antenna socket and no native PSK mode, so ANT1/ANT2 could only ever disagree with its front panel and the PSK button was dead furniture.",
"Icom console: the mic gain is no longer hidden outside phone modes — on USB-D it still sets what the radio transmits at, and an operator who lives in FT8 had none at all.",
"Icom CI-V: address 0x00 can be chosen. It was silently refused and replaced by the IC-7610 default, with no way to say what was meant."
],
"fr": [
"La ligne DIG de la matrice devient une rotation : un clic et elle répond pour FT8, puis FT4, puis chaque mode numérique de votre liste — dans VOTRE ordre — puis retour à DIG. Une ligne par mode numérique serait la mise en page honnête et la hauteur manque à côté des autres widgets : la ligne garde donc sa place et change ce quelle dit. La colonne des libellés est dimensionnée une fois pour le plus long mode quelle peut afficher : la matrice ne bouge donc plus quand la rotation arrive sur RTTY.",
"Console Icom : LSB et USB sont deux boutons distincts et peuvent enfin être demandés par leur nom — le bouton SSB unique déduisait la bande latérale de la fréquence, impossible donc de demander lUSB à un IC-7300 sur 40 m. Une radio qui annonce le « SSB » générique allume malgré tout le côté que sa fréquence implique.",
"Console Icom : un bouton de bande 60 m, et les commandes antenne et PSK napparaissent que sur les radios qui en disposent. Un IC-7300 na quune prise dantenne et pas de mode PSK natif : ANT1/ANT2 ne pouvait que contredire sa face avant, et le bouton PSK était un meuble mort.",
"Console Icom : le gain micro nest plus masqué hors des modes phonie — en USB-D il règle toujours le niveau d’émission, et un opérateur qui vit en FT8 nen avait aucun.",
"CI-V Icom : ladresse 0x00 peut être choisie. Elle était refusée en silence et remplacée par le défaut IC-7610, sans moyen de dire ce que lon voulait."
]
},
{
"version": "0.27.7",
"date": "",
"en": [
"DX Cluster: a disconnected server keeps its pill, so it can be reconnected — disconnecting one used to make it vanish along with the only way back.",
"HamQTH: an “Upload the whole log” button in the QSL Manager — one file instead of one request per QSO, so a first sync takes seconds rather than the better part of an hour. It REPLACES the log held on HamQTH (the site has no partial upload), so it asks first, is scoped to the callsign this profile uploads as, and compresses a large log to stay under the 20 MB limit.",
"Outbound ADIF (forwarding a logged QSO to another logger such as Log4OM): the receive side is filled in when the contact was not split, so BAND_RX and FREQ_RX are present. The importer already did this; the logging paths did not, so what a QSO carried depended on which door it came in through.",
"HamQTH joins the places the other services already were: two Recent-QSOs columns (sent status and date), the QSO filter, and bulk edit — the last one so a log uploaded to HamQTH by hand can be marked as sent instead of being offered for upload all over again.",
"HamQTH whole-log upload: it reports itself in the console like every other action — the callsign it is scoped to, how many of the logbooks QSOs that leaves, the file size, and HamQTHs own reply — and says plainly that HamQTH imports the file in the background and e-mails any ADIF errors, so a site count that lags or stops short is explained rather than mysterious.",
"QSO editor: correcting a frequency now moves its band with it, TX and RX — a QSO fixed to 7.1 MHz no longer stays filed on 20m. The band is only touched when the frequency lands in a known allocation, so a half-typed number never blanks it.",
"CAT: an option to put the radio in USB for digital modes (Settings → CAT), for every backend. Clicking an FT8 spot on a rig whose CAT layer resolves “digital” to RTTY/FSK — OmniRig does, per rig file — landed the operator in FSK, which cannot pass FT8 at all. The QSO is still logged as FT8: only the radio changes.",
"Fixed: a “worked but not confirmed” badge turned solid again after the next QSO, so an entity worked on a band still read NEW BAND as if it had never been worked there. Four hand-written copies of the same status mapping had drifted apart — the two that re-fetch dropped the unconfirmed flags, and none of them ever carried the grid one. There is one mapping now.",
"The Chase switches (POTA, US counties, prefixes, grids) now hold in FT decodes and in Chase new, not only in the DX Cluster — they say what you hunt, not which panel is open. Unchecked, their badges and filter chips disappear from all three and only the entity verdicts remain: NEW DXCC, band, mode, slot.",
"New DXHunter page in Preferences: every Chase setting moves there from the DX Cluster page — the hunt, its confirmation sources, and the POTA / SOTA / US counties / prefixes / grids switches. They govern three screens now, so they no longer live under the name of one of them; more of DXHunters ideas will land beside them.",
"Chase US states joins the other switches: unchecked, the NEW STATE badge and its filter chip go quiet everywhere.",
"FT Map: zooming no longer leaves a dead strip along the bottom. Leaflet measures its container once, when the map is created — which here is the instant the tab is selected, before the layout has settled — and it is now told whenever that box changes size.",
"FT decodes: the continent filter shows all seven, always, instead of only those currently on the feed — the row no longer reshuffles under the pointer when the first Asian station decodes, and it says what the filter can do before anything has been heard.",
"Panadapter spots now carry SmartSDRs priority: an entity never worked comes first, then the band / mode / slot needs, then POTA, SOTA, county and prefix, and everything else last. The radio stacks spots that sit close in frequency behind a “+” and draws only one — it picks by priority, so a new DXCC was disappearing behind stations already in the log simply because OpsLog never said which was worth the space.",
"A station already worked on the SAME band and mode no longer advertises a need. Working it a second time cannot turn a missing QSL into a confirmation, so the badge goes quiet on that callsign — and stays on every other station of the entity, which is where the need can actually be answered."
],
"fr": [
"DX Cluster : un serveur déconnecté garde sa pastille et peut donc être reconnecté — le déconnecter le faisait disparaître avec le seul moyen dy revenir.",
"HamQTH : un bouton « Envoyer tout le log » dans le QSL Manager — un seul fichier au lieu dune requête par QSO, une première synchro passe de près dune heure à quelques secondes. Il REMPLACE le log stocké sur HamQTH (le site na pas denvoi partiel) : il demande donc confirmation, se limite à lindicatif du profil et compresse un gros log pour rester sous la limite de 20 Mo.",
"ADIF sortant (transfert dun QSO vers un autre log, Log4OM par exemple) : le côté réception est renseigné quand le contact n’était pas en split, donc BAND_RX et FREQ_RX sont présents. Limport le faisait déjà, pas les chemins de log — ce quun QSO transportait dépendait donc de la porte par laquelle il était entré.",
"HamQTH rejoint les endroits où les autres services étaient déjà : deux colonnes dans les QSO récents (statut et date denvoi), le filtre de QSO et l’édition groupée — cette dernière pour quun log envoyé à la main sur HamQTH puisse être marqué comme envoyé au lieu d’être reproposé à lenvoi.",
"Envoi du log complet HamQTH : il rend compte dans la console comme toutes les autres actions — lindicatif retenu, combien de QSO du journal cela représente, la taille du fichier et la réponse de HamQTH — et indique clairement que HamQTH importe le fichier en arrière-plan et envoie les erreurs ADIF par e-mail : un compteur en retard ou incomplet sur le site est ainsi expliqué au lieu d’être mystérieux.",
"Éditeur de QSO : corriger une fréquence déplace désormais sa bande avec elle, TX comme RX — un QSO corrigé à 7,1 MHz ne reste plus classé en 20m. La bande nest touchée que si la fréquence tombe dans une allocation connue : un nombre à moitié tapé ne lefface jamais.",
"CAT : une option pour mettre la radio en USB sur les modes numériques (Réglages → CAT), pour tous les backends. Cliquer un spot FT8 sur un poste dont la couche CAT traduit « numérique » par RTTY/FSK — cest le cas dOmniRig, selon le fichier radio — faisait basculer lopérateur en FSK, incapable de passer du FT8. Le QSO reste enregistré en FT8 : seule la radio change.",
"Corrigé : un badge « contacté mais non confirmé » redevenait plein dès le QSO suivant, si bien quune entité contactée sur une bande affichait NEW BAND comme si elle ne lavait jamais été. Quatre copies écrites à la main de la même conversion de statut avaient divergé — les deux qui rafraîchissent perdaient les drapeaux « non confirmé », et aucune ne transportait celui des locators. Il ny en a plus quune.",
"Les cases Chasse (POTA, comtés US, préfixes, locators) sappliquent désormais aux FT decodes et à Chase new, plus seulement au DX Cluster — elles disent ce que vous chassez, pas quel panneau est ouvert. Décochées, leurs badges et leurs puces de filtre disparaissent des trois écrans et il ne reste que les verdicts dentité : NOUVEAU DXCC, bande, mode, slot.",
"Nouvelle page DXHunter dans les Préférences : tous les réglages de chasse y déménagent depuis la page DX Cluster — le mode de chasse, ses sources de confirmation et les cases POTA / SOTA / comtés US / préfixes / locators. Ils commandent trois écrans, ils ne vivent donc plus sous le nom dun seul ; dautres idées de DXHunter viendront sy ajouter.",
"Chasser les états US rejoint les autres cases : décochée, le badge NOUVEL ÉTAT et sa puce de filtre se taisent partout.",
"FT Map : le zoom ne laisse plus une bande morte en bas. Leaflet mesure son conteneur une seule fois, à la création de la carte — ici linstant où longlet est sélectionné, avant que la mise en page ne se soit stabilisée — et il est désormais prévenu à chaque changement de taille.",
"FT decodes : le filtre continent affiche les sept, toujours, au lieu des seuls présents dans le flux — la rangée ne se réorganise plus sous le pointeur quand la première station asiatique décode, et elle annonce ce que le filtre sait faire avant même davoir entendu quoi que ce soit.",
"Les spots du panadapter portent désormais la priorité SmartSDR : une entité jamais contactée dabord, puis les besoins bande / mode / slot, puis POTA, SOTA, comté et préfixe, et le reste en dernier. La radio empile les spots proches en fréquence derrière un « + » et nen dessine quun — elle choisit par priorité, si bien quun nouveau DXCC disparaissait derrière des stations déjà au log, faute pour OpsLog davoir dit laquelle méritait la place.",
"Une station déjà contactée sur la MÊME bande et le même mode nannonce plus de besoin. La recontacter ne transformera pas une QSL manquante en confirmation : le badge se tait sur cet indicatif — et reste sur toutes les autres stations de lentité, là où le besoin peut réellement être comblé."
]
},
{
"version": "0.27.6",
"date": "",
"en": [
"Switching the settings database no longer shows “OpsLog is already running”: the automatic relaunch now waits for the closing instance to release its lock instead of racing it.",
"HamQTH upload: 8th external service — real-time QSO upload to the HamQTH online logbook with your callbook credentials (auto-upload on log, “Send to…” right-click, QSL Manager backlog upload, connection test).",
"Fixed: the right-click “Send to HAMLOG.online” was uploading the selection to QRZ.com with the QRZ key — it now goes to HAMLOG.online.",
"LoTW: TQSL no longer refuses non-US stations over MY_CNTY — the field is stripped before signing unless it is the US “XX,County” shape LoTW actually validates (a Canadian “ONTARIO,Kawartha” was rejecting the whole record). Exports also stop gluing a full state name onto the county.",
"DX Cluster: two new chase switches — Chase US counties and Chase new prefixes — and unchecking Chase new grids now also withdraws the NEW GRID badge. Each switch removes its badge from the spots AND its chip from the status filters, like Chase POTA always did.",
"Confirmations: a HamQTH row — sent only, defaulting to R (to upload), since HamQTH publishes no confirmations to receive. Setting such a default no longer disables the auto-upload it was meant to arm (it also affected HAMLOG.online).",
"New DXpeditions tab (Tools): the announced operations from NG3Ks ADXO next to the DX-World news feed. Every announcement is judged against YOUR log and carries one badge — NEW DXCC, NEW BAND, NEW SLOT… — with an “only what I need” filter, and one click adds its callsigns to the watchlist. The news headlines carry the same Watch button, over the callsigns mined out of their titles.",
"Watchlist: adding or removing a callsign now raises the same kind of notification as a new version, instead of a message inside the watchlist page — a call can be added from the cluster or the DXpeditions tab, where that message was never seen.",
"QSO editor, QSL Info: a HamQTH channel and its row in the status table — sent only, the received column showing a dash since the site publishes no confirmations.",
"QSO editor, QSL Info: the confirmation channels are listed paper QSL and LoTW first — the two that carry an ARRL award — then alphabetically, in both the picker and the status table.",
"Band map: a chevron in the footer folds the colour legend away and brings it back — four lines of a short screen, remembered between sessions.",
"Band map: ctrl+wheel no longer zooms it — that gesture is the window zoom everywhere else in OpsLog. The + and buttons keep the zoom.",
"DX Cluster: the server pills drop the CONNECTED/DISCONNECTED word — the colour already said it — and clicking one now connects or disconnects that server on its own, without opening Settings. State, retries, address and last error moved into the tooltip."
],
"fr": [
"Changer de base de réglages naffiche plus « OpsLog is already running » : la relance automatique attend désormais que linstance qui se ferme libère son verrou au lieu de la prendre de vitesse.",
"Upload HamQTH : 8e service externe — envoi des QSO en temps réel vers le logbook HamQTH avec vos identifiants du lookup (upload auto au log, « Envoyer vers… » au clic droit, rattrapage via le QSL Manager, test de connexion).",
"Corrigé : le clic droit « Envoyer vers HAMLOG.online » envoyait la sélection à QRZ.com avec la clé QRZ — elle part maintenant vers HAMLOG.online.",
"LoTW : TQSL ne refuse plus les stations hors US à cause de MY_CNTY — le champ est retiré avant signature sauf sil a la forme US « XX,County » que LoTW valide réellement (un « ONTARIO,Kawartha » canadien rejetait tout lenregistrement). Lexport cesse aussi de coller un nom d’état complet devant le comté.",
"DX Cluster : deux nouvelles cases — Chasser les comtés US et Chasser les nouveaux préfixes — et décocher Chasser les nouveaux locators retire désormais aussi le badge NEW GRID. Chaque case enlève son badge des spots ET sa puce des filtres de statut, comme Chase POTA le faisait déjà.",
"Confirmations : une ligne HamQTH — envoi seulement, à R (à envoyer) par défaut, HamQTH ne publiant aucune confirmation à recevoir. Définir un tel défaut ne désactive plus lupload automatique quil était censé armer (cela touchait aussi HAMLOG.online).",
"Nouvel onglet DXpéditions (Outils) : les opérations annoncées par lADXO de NG3K à côté du fil dactualités DX-World. Chaque annonce est jugée sur VOTRE log et porte un badge — NOUVEAU DXCC, NOUVELLE BANDE, NOUVEAU SLOT… — avec un filtre « seulement ce quil me manque », et un clic ajoute ses indicatifs à la watchlist. Les actualités portent le même bouton Surveiller, sur les indicatifs extraits de leurs titres.",
"Watchlist : ajouter ou retirer un indicatif déclenche désormais une notification du même type que celle des nouvelles versions, au lieu dun message dans la page watchlist — un indicatif peut être ajouté depuis le cluster ou longlet DXpéditions, où ce message n’était jamais vu.",
"Éditeur de QSO, onglet QSL : un canal HamQTH et sa ligne dans le tableau des statuts — envoi seulement, la colonne reçu affichant un tiret puisque le site ne publie aucune confirmation.",
"Éditeur de QSO, onglet QSL : les canaux de confirmation sont classés QSL papier puis LoTW — les deux qui comptent pour un diplôme ARRL — puis par ordre alphabétique, dans le sélecteur comme dans le tableau.",
"Band map : un chevron dans le pied de page replie la légende des couleurs et la fait revenir — quatre lignes gagnées sur un petit écran, mémorisé dune session à lautre.",
"Band map : ctrl+molette ne zoome plus la carte — ce geste est le zoom de la fenêtre partout ailleurs dans OpsLog. Les boutons + et gardent le zoom.",
"DX Cluster : les pastilles de serveur perdent le mot CONNECTED/DISCONNECTED — la couleur le disait déjà — et cliquer sur lune connecte ou déconnecte ce serveur seul, sans passer par les réglages. État, tentatives, adresse et dernière erreur passent dans linfobulle."
]
},
{
"version": "0.27.5",
"date": "",
"en": [
"QSL Manager: Club Log confirmations — downloads your log matches (getmatches API) and stamps two new columns, ClubLog match status and match date, available everywhere: table columns, filters, bulk edit and the QSO editor.",
"Super Check Partial: option to merge Club Logs weekly call list (~180k calls heard on the air in the last 3 years) with MASTER.SCP.",
"Fixed: opening the app could silently stop at startup (settings showing as default, “db not initialized”) when a database migration targeted a table that database no longer holds — migrations now skip what does not apply, and a startup failure is written to the log.",
"FT Map / Grid squares: the map no longer floats above the menus and the Preferences dialog.",
"KPA500: saving ANY settings page no longer power-cycles the amplifier. A save rebuilt every amplifier connection, and closing the COM port drops DTR/RTS — the KPA500s power switch. An unchanged amplifier now keeps its running connection across saves.",
"Column picker: columns are listed alphabetically inside each group.",
"Confirmations: a “Club Log received” default for new QSOs, set to No out of the box — the match download flips it to Y."
],
"fr": [
"QSL Manager : confirmations Club Log — télécharge vos matches de log (API getmatches) et remplit deux nouvelles colonnes, statut et date de match ClubLog, disponibles partout : colonnes du tableau, filtres, édition groupée et éditeur de QSO.",
"Super Check Partial : option pour fusionner la liste hebdomadaire de Club Log (~180k indicatifs entendus sur lair ces 3 dernières années) avec MASTER.SCP.",
"Corrigé : lapplication pouvait se figer silencieusement au démarrage (réglages par défaut, « db not initialized ») quand une migration visait une table absente de cette base — les migrations ignorent désormais ce qui ne sapplique pas, et un échec de démarrage est écrit dans le log.",
"FT Map / Grid squares : la carte ne passe plus au-dessus des menus et de la fenêtre Préférences.",
"KPA500 : sauvegarder nimporte quelle page des réglages n’éteint plus lampli. Une sauvegarde reconstruisait chaque connexion dampli, et fermer le port COM relâche DTR/RTS — linterrupteur dalimentation du KPA500. Un ampli inchangé garde désormais sa connexion à travers les sauvegardes.",
"Sélecteur de colonnes : les colonnes sont triées alphabétiquement dans chaque groupe.",
"Confirmations : un défaut « Club Log reçu » pour les nouveaux QSO, à Non par défaut — le téléchargement des matches le passe à Y."
]
},
{
"version": "0.27.4",
"date": "",
"en": [
"FT decodes: a State column between the locator and the country — the two-letter badge plus the full name — for the WAS chasers.",
"FT decodes: a NEW STATE badge and filter — a US state never worked lights up in the status column, and the filter chip shows only those. The WAS chase is complete.",
"The hunt goes global: a “Chase” setting (DX Cluster page) decides for EVERY category — DXCC, band, mode, slot, prefix, county, state, grid — whether “worked but never confirmed” still counts as something to chase, judged against the confirmation sources you pick (LoTW, QSL card, eQSL, QRZ.com). Such needs show as dimmed badges: a QSL to chase, not a QSO to make. The grids own Chase selector folds into it.",
"New FTx menu gathering FT Decodes, the new FT Map and the Grid squares map.",
"FT Map: a world map of the live FTx decodes — great-circle arcs from your QTH to every station heard in the last 30 minutes, coloured by band, with the PSK-Reporter palette and a basemap picker.",
"Maps: one single world (no more side-by-side copies), the surround follows the theme colour, and zooming stays centred — on the FT Map and the Grid squares map.",
"Watchlist: fixed columns in the spot rows, so band, mode and frequency line up instead of drifting with the country name."
],
"fr": [
"FT decodes : une colonne État entre le locator et le pays — le badge deux lettres plus le nom complet — pour les chasseurs de WAS.",
"FT decodes : un badge et un filtre NOUVEL ÉTAT — un état US jamais contacté sallume dans la colonne statut, et la puce de filtre ne montre que ceux-là. La chasse WAS est complète.",
"La chasse devient globale : un réglage « Chasse » (page DX Cluster) décide pour TOUTES les catégories — DXCC, bande, mode, slot, préfixe, comté, état, grille — si « contacté mais jamais confirmé » reste à chasser, jugé selon les sources de confirmation choisies (LoTW, carte QSL, eQSL, QRZ.com). Ces besoins saffichent en badges atténués : une QSL à chasser, pas un QSO à faire. Le sélecteur Chasse des grilles fusionne dedans.",
"Nouveau menu FTx regroupant FT Decodes, la nouvelle FT Map et la carte Grid squares.",
"FT Map : une carte du monde des décodages FTx en direct — arcs orthodromiques depuis votre QTH vers chaque station entendue dans les 30 dernières minutes, colorés par bande, avec la palette PSK Reporter et un choix de fond de carte.",
"Cartes : un seul monde (fini les copies côte à côte), le pourtour suit la couleur du thème et le zoom reste centré — sur la FT Map et la carte Grid squares.",
"Watchlist : colonnes fixes dans les lignes de spots — bande, mode et fréquence s'alignent au lieu de dériver avec le nom du pays."
]
},
{
"version": "0.27.3",
"date": "",
"en": [
"KPA500: the amplifier no longer switches itself off and commands respond instantly. A command this model does not know (the KPA1500s ATU poll) was tearing the link down every cycle, and each reconnect toggled the serial control lines — which are the KPA500s power switch. The lines are now held steady, silence is not treated as a dead link, and the baud is picked from a list.",
"FT decodes: within a period, decodes are listed in arrival order — mirroring the decoders own window — instead of strongest-first.",
"Voice keyer: twelve message slots (F1F12) instead of six.",
"Preferences open smoothly on a busy station: while the dialog is open, cluster spots, FT decodes and CAT snapshots queue quietly instead of repainting the whole window behind it — everything catches up the moment it closes.",
"Voice keyer: a delete button per message — removes the recording and clears the label."
],
"fr": [
"KPA500 : lampli ne s’éteint plus tout seul et les commandes répondent instantanément. Une commande inconnue de ce modèle (le poll ATU du KPA1500) détruisait le lien à chaque cycle, et chaque reconnexion basculait les lignes de contrôle série — qui sont linterrupteur du KPA500. Les lignes sont désormais tenues stables, le silence nest plus traité comme un lien mort, et le baud se choisit dans une liste.",
"FT decodes : dans une période, les décodages sont listés dans lordre darrivée — comme la fenêtre du décodeur — au lieu du plus fort dabord.",
"Manipulateur vocal : douze messages (F1F12) au lieu de six.",
"Les Préférences restent fluides sur une station chargée : dialogue ouvert, les spots cluster, les décodages FT et les instantanés CAT patientent en file au lieu de repeindre toute la fenêtre derrière — tout se rattrape à la fermeture.",
"Manipulateur vocal : un bouton supprimer par message — efface lenregistrement et le libellé."
]
},
{
"version": "0.27.2",
"date": "",
"en": [
"Bulk operations work on any size of selection — setting a field, fixing frequencies, deleting, marking uploads and exporting the selection all failed with “too many SQL variables” past a few tens of thousands of QSOs. Statements are now issued in slices.",
"Elecraft console: the power meter reads in real watts. The K3s bargraph is relative to a range that flips at 12 W — calibrated against a real radios full table, the PC setting picks the range and the bar converts to watts.",
"Watchlist: a visual pass toward DXHunters look — pink callsigns, counter pills, quieter cards with a hover, the ⚡ back on the DXpedition badge.",
"WSJT-X / JTDX: OpsLog can highlight decodes in the decoders own Band Activity window from your log — watchlist members pink, new DXCC green, new band orange (option in Settings → Connections). And a freshly-started decoder is asked to replay its on-screen decodes, so the FT decodes panel starts full.",
"WSJT-X / JTDX / MSHV: only a CHANGED DX Call updates the entry — the decoder re-broadcasts the same call endlessly, and it kept overwriting a spot clicked in OpsLog.",
"Map: Zoom DX toward a polar entity no longer frames a band of blank white above the top of the world — the camera stays within the maps ±85°, the path still draws.",
"WSJT-X / JTDX / MSHV: clicking a spot in a digital mode the decoder speaks (FT8, FT4, JT65…) switches the decoders mode too — option in Settings → Connections, on by default."
],
"fr": [
"Les opérations groupées fonctionnent quelle que soit la taille de la sélection — définir un champ, corriger des fréquences, supprimer, marquer les uploads et exporter la sélection échouaient avec « too many SQL variables » au-delà de quelques dizaines de milliers de QSO. Les requêtes sont désormais émises par tranches.",
"Console Elecraft : le wattmètre lit en vrais watts. Le bargraph du K3 est relatif à une gamme qui bascule à 12 W — calibré sur la table complète dune vraie radio, le réglage PC choisit la gamme et la barre se convertit en watts.",
"Watchlist : une passe visuelle vers le look DXHunter — indicatifs roses, compteurs en pastilles, cartes plus feutrées avec survol, le ⚡ de retour sur le badge DXpedition.",
"WSJT-X / JTDX : OpsLog peut surligner les décodages dans la fenêtre Band Activity du décodeur selon votre log — watchlist en rose, nouveau DXCC en vert, nouvelle bande en orange (option dans Réglages → Connections). Et un décodeur fraîchement détecté rejoue ses décodages à l’écran, donc le panneau FT decodes démarre plein.",
"WSJT-X / JTDX / MSHV : seul un DX Call qui CHANGE met à jour la saisie — le décodeur rediffuse le même call sans fin, et il écrasait un spot cliqué dans OpsLog.",
"Carte : Zoom DX vers une entité polaire ne cadre plus une bande blanche au-dessus du haut du monde — la caméra reste dans les ±85° de la carte, le trajet se dessine toujours.",
"WSJT-X / JTDX / MSHV : cliquer un spot dans un mode numérique que le décodeur parle (FT8, FT4, JT65…) change aussi le mode du décodeur — option dans Réglages → Connections, activée par défaut."
]
},
{
"version": "0.27.1",
"date": "",
"en": [
"Elecraft console: the S-meter is calibrated against a real K3 — S9 and the +dB readings now match the radios own display (they read about two S-units low).",
"Elecraft console: an SWR spike (the K3s own blip at the end of an FT8 frame) no longer fades over twelve seconds — the peak shows briefly, then the meter returns straight to the live reading.",
"Icom: the IC-7760 joins the model list (CI-V address B2h).",
"Icom console: the IC-7760 gets its real attenuator steps — 6/12/18 dB.",
"Icom network audio: the RX stream is decoded correctly — mono is requested and the payload offset was confirmed on a real IC-7760, curing the garbled, clicking audio.",
"Icom: switching back from a data mode (USB-D1) to plain USB now sticks — if the rig ignores the data-flag command, it is repeated with the modern one-frame form (0x26). Seen on the IC-7760.",
"Icom console: a DATA mode button (USB-D, what FT8 wants), the PSK button drives the rigs that have a native PSK mode, and the power meter reads in real watts on the IC-7760s 250 W scale.",
"Icom network audio: the right codec is requested (16-bit mono LPCM), settled by experiment on a real IC-7760.",
"CAT settings: reopening the panel now shows the radio that is actually selected — it always showed the first one, with the fields (MY_RIG included) silently editing the wrong entry.",
"IC-7760: the power meter is calibrated against the radio (100 W reads 100 W on the 250 W face) and the RF power setting reads in watts, not a percentage.",
"Icom network audio: toggling Listening off and on no longer chops the sound — the monitor restarts as network-fed instead of also opening a USB capture.",
"QSO recorder: starting a fresh manual take resets the counter for good — it used to flash 0 and jump back to the previous takes elapsed time.",
"MY_RIG follows the radio actually connected: the entry form now asks the active radio first, before the per-band default — an IC-7760 on the air no longer logs as the Flex the band plan names.",
"QSO recorder: a manual take on a network Icom records the network RX stream, not the “From radio” sound card (which captured another radios DAX on a mixed station).",
"Icom network audio: duplicate and late-retransmitted packets no longer reach the recorder — recordings came out longer than the QSO, slowed and stuttering, while the speakers played fine.",
"Icom network audio: it now starts with the app — launching OpsLog connected with audio silently off, and the option had to be unticked and re-saved to hear anything.",
"Icom network: when the rig goes quiet on CI-V while the session stays up (seen on the IC-7760), the CI-V flow is re-opened on the spot — recovery in seconds instead of the 35-second full reconnect.",
"Icom network: after a session dies young, the redial waits 20 s so the rig can purge the old session first — stops the reconnect-die-reconnect spiral seen on the IC-7760, where each fresh session answered for a second and was then strangled by the previous ones cleanup.",
"Icom network audio, phase 5: the radio can now TAKE audio — with RX audio enabled, pick “Radio (network audio)” as the To-radio device and the voice keyer plays straight to the rig over the LAN, no cable, no virtual sound card. First tested on the IC-7760.",
"Icom network audio: listening is now a remembered choice — Stop listening keeps the speakers off across reconnects and restarts, while the stream stays open for the QSO recorder and the voice keyer.",
"Icom console: a speaker button beside ON/OFF toggles listening to the network RX audio right from the console — no more trip through Settings → Audio; recordings and the voice keyer keep working either way, and the choice is remembered.",
"CAT settings: a “Play it through the speakers” checkbox sits under the Icom RX-audio option — the same remembered switch as the consoles speaker button, applied immediately.",
"Icom network audio: “Talk to radio” now works over the LAN too — live microphone straight to the rig, PTT included. With RX audio and a headset, OpsLog is a complete remote station: hear, talk, key CW and log over one network link.",
"Icom console: the SUB display shows the sub receivers frequency at all times (it was blank until split), and engaging split copies the mode to the TX VFO so it matches the main.",
"Icom network audio: the stream now opens regardless of the speaker choice — speakers-off (or a failed output device) was silently disabling the whole stream: no audio session, no recordings, no voice keyer, despite the RX-audio option being ticked.",
"Icom network: a radio in standby keeps its session and the consoles ON button — the quiet-CI-V recovery was tearing the session down every 15 s, so a radio that was off when OpsLog started could never be powered on.",
"Icom console: the MOX button carries your microphone on a network station, and engaging split aligns the TX VFOs mode with the main.",
"Icom console: the spectrum scope is removed. Every model streams its waveform differently — and on the IC-7760 enabling it killed the whole CI-V link, audio included. The radios own scope does it better.",
"Icom: any leftover waveform stream is switched off at connect — the scope flag lives in the radio and survived sessions, and its flood is what was killing the IC-7760s CI-V link.",
"Icom console: an ATU chip beside SPLIT engages or DISENGAGES the internal tuner — TUNE started a cycle but could never take the tuner back out of line.",
"Icom console: the TX meters get needle inertia — power holds its peak instead of flickering to 0 W between syllables, SWR shows the real peak then returns to the live value — and the wattage readout no longer wraps at three digits.",
"Icom console: the meters use the same LED bars as the Flex and Elecraft consoles, and the mode badge says USB or LSB instead of an ambiguous SSB.",
"Cluster: spots on the standard FT8/FT4 frequencies of every band now read FT8/FT4 when the comment names no mode — 30 m, 60 m, 80 m FT4, 17 m FT4 and 12 m were falling into the generic DATA bucket.",
"OmniRig (Yaesu): with split engaged, tuning to a spot moves BOTH VFOs — on an FT-2000 the write landed on the TX VFO only, so the transmitter QSYed and the receiver stayed behind.",
"Worked before: a slow lookup can no longer overwrite a newer one — typing a second callsign quickly could leave the previous calls QSOs displayed under the new calls name.",
"Icom network: OpsLog now sends its own pings every 500 ms on all three streams, as RS-BA1 and wfview do — a client that only ever replied was judged absent by the rig, which stopped serving CI-V data about a minute into every session: the recurring sound/CAT dropouts."
],
"fr": [
"Console Elecraft : le S-mètre est calibré sur un vrai K3 — S9 et les +dB correspondent désormais à laffichage de la radio (il lisait environ deux points S trop bas).",
"Console Elecraft : un pic de ROS (le sursaut du K3 en fin de trame FT8) ne sestompe plus pendant douze secondes — le pic saffiche brièvement, puis le ros-mètre revient directement à la valeur réelle.",
"Icom : lIC-7760 rejoint la liste des modèles (adresse CI-V B2h).",
"Console Icom : lIC-7760 reçoit ses vrais crans datténuateur — 6/12/18 dB.",
"Audio réseau Icom : le flux RX est décodé correctement — le mono est demandé et loffset du payload a été confirmé sur un vrai IC-7760, ce qui guérit le son inaudible et les clics.",
"Icom : revenir dun mode data (USB-D1) au USB simple tient désormais — si la radio ignore la commande du drapeau data, elle est répétée sous la forme moderne en une trame (0x26). Constaté sur lIC-7760.",
"Console Icom : un bouton de mode DATA (USB-D, celui de FT8), le bouton PSK pilote les radios qui ont un vrai mode PSK, et le wattmètre lit en watts réels sur l’échelle 250 W de lIC-7760.",
"Audio réseau Icom : le bon codec est demandé (LPCM mono 16 bits), déterminé par lexpérience sur un vrai IC-7760.",
"Réglages CAT : rouvrir le panneau montre désormais la radio réellement sélectionnée — il montrait toujours la première, et les champs (MY_RIG compris) modifiaient silencieusement la mauvaise entrée.",
"IC-7760 : le wattmètre est calibré sur la radio (100 W affiche 100 W sur l’échelle 250 W) et le réglage RF power se lit en watts, plus en pourcentage.",
"Audio réseau Icom : couper puis relancer Listening ne hache plus le son — le moniteur redémarre alimenté par le réseau au lieu douvrir en plus une capture USB.",
"Enregistreur de QSO : démarrer une nouvelle prise manuelle remet le compteur à zéro pour de bon — il affichait 0 puis resautait au temps de la prise précédente.",
"MY_RIG suit la radio réellement connectée : le formulaire interroge dabord la radio active, avant le défaut par bande — un IC-7760 à lantenne ne se logue plus comme le Flex prévu par le plan de bande.",
"Enregistreur de QSO : une prise manuelle sur un Icom réseau enregistre le flux RX réseau, pas la carte son « From Radio » (qui capturait le DAX dune autre radio sur une station mixte).",
"Audio réseau Icom : les paquets dupliqués ou retransmis en retard natteignent plus lenregistreur — les enregistrements sortaient plus longs que le QSO, ralentis et hachés, alors que les haut-parleurs jouaient bien.",
"Audio réseau Icom : il démarre maintenant avec lapplication — au lancement, la connexion se faisait audio coupé, et il fallait décocher/recocher loption pour entendre quelque chose.",
"Réseau Icom : quand la radio se tait sur le CI-V alors que la session tient (constaté sur lIC-7760), le flux CI-V est rouvert immédiatement — récupération en quelques secondes au lieu des 35 secondes de reconnexion complète.",
"Réseau Icom : après une session morte jeune, la renumérotation attend 20 s pour que la radio purge dabord lancienne session — stoppe la spirale reconnexion-mort-reconnexion vue sur lIC-7760, où chaque session neuve répondait une seconde avant d’être étranglée par le nettoyage de la précédente.",
"Audio réseau Icom, phase 5 : la radio peut maintenant RECEVOIR de laudio — avec le RX audio activé, choisissez « Radio (network audio) » comme périphérique To Radio et le voice keyer joue directement vers la radio par le LAN, sans câble ni carte son virtuelle. Premier test sur lIC-7760.",
"Audio réseau Icom : l’écoute est désormais un choix mémorisé — Stop listening garde les enceintes coupées à travers reconnexions et redémarrages, tandis que le flux reste ouvert pour lenregistreur de QSO et le voice keyer.",
"Console Icom : un bouton haut-parleur à côté de ON/OFF bascule l’écoute du RX audio réseau depuis la console — fini laller-retour dans Réglages → Audio ; enregistrements et voice keyer continuent de fonctionner, et le choix est mémorisé.",
"Réglages CAT : une case « Écouter dans les enceintes » sous loption RX audio Icom — le même interrupteur mémorisé que le bouton haut-parleur de la console, appliqué immédiatement.",
"Audio réseau Icom : « Talk to radio » fonctionne aussi par le LAN — micro en direct vers la radio, PTT compris. Avec le RX audio et un casque, OpsLog devient une station remote complète : écouter, parler, manipuler la CW et loguer sur un seul lien réseau.",
"Console Icom : laffichage SUB montre la fréquence du sub receiver en permanence (il restait vide hors split), et activer le split copie le mode sur le VFO TX pour quil suive le main.",
"Audio réseau Icom : le flux souvre désormais indépendamment du choix d’écoute — enceintes coupées (ou périphérique de sortie en échec) désactivait silencieusement tout le flux : pas de session audio, ni enregistrements, ni voice keyer, malgré la case RX audio cochée.",
"Réseau Icom : une radio en veille garde sa session et le bouton ON de la console — la récupération du CI-V muet détruisait la session toutes les 15 s, donc une radio éteinte au lancement dOpsLog ne pouvait jamais être allumée.",
"Console Icom : le bouton MOX emporte votre micro sur une station réseau, et activer le split aligne le mode du VFO TX sur le main.",
"Console Icom : le scope spectral est retiré. Chaque modèle streame sa forme donde différemment — et sur lIC-7760 son activation tuait tout le lien CI-V, audio compris. Le scope de la radio fait ça mieux.",
"Icom : tout flux waveform résiduel est coupé à la connexion — le drapeau scope vit dans la radio et survivait aux sessions, et son flot est ce qui tuait le lien CI-V de lIC-7760.",
"Console Icom : une puce ATU à côté de SPLIT engage ou DÉSENGAGE le tuner interne — TUNE lançait un cycle mais ne pouvait jamais remettre le tuner hors ligne.",
"Console Icom : les mètres TX gagnent une inertie daiguille — la puissance tient sa crête au lieu de retomber à 0 W entre les syllabes, le ROS montre la vraie crête puis revient à la valeur vive — et laffichage en watts ne passe plus à la ligne à trois chiffres.",
"Console Icom : les mètres utilisent les mêmes barres LED que les consoles Flex et Elecraft, et le badge de mode dit USB ou LSB au lieu dun SSB ambigu.",
"Cluster : les spots sur les fréquences standard FT8/FT4 de chaque bande lisent désormais FT8/FT4 quand le commentaire ne nomme pas de mode — 30 m, 60 m, FT4 80 m, FT4 17 m et 12 m tombaient dans le bloc DATA générique.",
"OmniRig (Yaesu) : avec le split actif, se rendre sur un spot déplace LES DEUX VFO — sur un FT-2000 l’écriture natteignait que le VFO TX, donc l’émetteur QSYait et le récepteur restait derrière.",
"Déjà contacté : une recherche lente ne peut plus écraser une plus récente — taper un second indicatif rapidement pouvait laisser les QSO du call précédent affichés sous le nom du nouveau.",
"Réseau Icom : OpsLog envoie désormais ses propres pings toutes les 500 ms sur les trois flux, comme RS-BA1 et wfview — un client qui ne faisait que répondre était jugé absent par la radio, qui cessait de servir le CI-V au bout dune minute : les coupures récurrentes de son/CAT."
]
},
{
"version": "0.27.0",
"date": "",
"en": [
"⭐ NEW — Watchlist (Tools): the DXHunter watchlist as an OpsLog tab — callsigns you are hunting (exact, or a family with a trailing * — VK9* catches every VK9), the live cluster spots under each with the NEW badges and a Needed/Worked verdict per slot, an ON AIR badge, per-entry alerts through the normal alert path, and CONTEST entries judged per UTC day (at midnight everything is workable again).Each entry is enriched from ClubLog automatically: DXpedition badge, OQRS, LiveStream link, QSO total and 24-hour rate — nothing to configure. Reads and writes DXHunters own watchlist.json format — drop your existing file into the data folder to carry it over.",
"TCI (SunSDR): the meter subscription is renewed when the radio announces it is ready — sent only at connect, it could fall inside the initial state dump and be ignored, which left the transmit power and SWR empty. The log now also records the subscription and the first sensor frames, so a report can tell “the radio never sends them” from “they arrived and were dropped”.",
"Sending a spot while in split now pre-fills the RX frequency — where the station actually is — instead of the TX frequency, which spotted the pile-up five up from the DX.",
"Cluster: NEW SLOT gets its own colour — the sky cyan the panadapter palette already uses for it, clearly apart from POTA green and the yellows. The NEW SLOT and NEW COUNTY filter chips now wear the same colours as the badges they select, and the NEW CALL filter works even when the slot-highlight display option is off.",
"Elecraft console: a mode row — CW, USB, LSB, DATA and DATA RTTY. The two DATA buttons set the K3s submode as well (DATA A for FT8/FT4, FSK D for RTTY): switching to DATA by mode alone kept whatever submode the last session left, which is how a K3 “in DATA” keys FT8 with no audio.",
"DX Cluster settings: “I chase POTA” and “I chase SOTA”, on by default. Unticked, the NEW POTA badge, colour and filter disappear — a new-band + new-POTA spot reads NEW BAND alone — and the reference columns stay empty.",
"Elecraft console: RIT and XIT use the same control as the Icom and TCI consoles — ± buttons, mouse wheel, typed value, Ctrl+←/→ — and the power slider moves in 1 W steps instead of 5.",
"Bulk edit: My DXCC, My CQ zone and My ITU zone join the My-station fields — numbers checked against their real ranges, empty clears.",
"Clicking a spot in the Chase new panel now applies the FlexRadio panadapter width/zoom, like every other spot click.",
"FlexRadio: the panadapter width/zoom now applies reliably when double-clicking a cluster spot — the zoom is sent after the tune settles, so SmartSDRs own pan-follow on a band change no longer overrides it."
],
"fr": [
"⭐ NOUVEAU — Watchlist (Outils) : la watchlist de DXHunter en onglet OpsLog — indicatifs chassés (exacts, ou une famille avec une * finale — VK9* attrape tous les VK9), les spots cluster en direct sous chaque entrée avec les badges NOUVEAU et un verdict Manquant/Contacté par créneau, un badge ON AIR, des alertes par entrée via le circuit d'alerte normal, et des entrées CONTEST jugées par jour UTC (à minuit tout redevient à faire).Chaque entrée est enrichie automatiquement depuis ClubLog : badge DXpedition, OQRS, lien LiveStream, total de QSO et rythme sur 24 h — rien à configurer. Lit et écrit le format watchlist.json de DXHunter — déposez votre fichier existant dans le dossier data pour le récupérer.",
"TCI (SunSDR) : l'abonnement aux mesures est renouvelé quand la radio annonce qu'elle est prête — envoyé seulement à la connexion, il pouvait tomber pendant l'envoi initial de l'état et être ignoré, laissant la puissance et le ROS vides en émission. Le journal enregistre aussi l'abonnement et les premières trames de mesure, pour distinguer « la radio ne les envoie jamais » de « elles arrivaient et étaient perdues ».",
"Envoyer un spot en split préremplit désormais la fréquence RX — là où la station se trouve réellement — au lieu de la fréquence TX, qui spottait le pile-up cinq au-dessus du DX.",
"Cluster : NOUVEAU SLOT reçoit sa propre couleur — le cyan ciel que la palette du panadapter lui donne déjà, bien distinct du vert POTA et des jaunes. Les puces de filtre NOUVEAU SLOT et NOUVEAU COMTÉ portent désormais les couleurs des badges qu'elles sélectionnent, et le filtre NOUVEAU CALL fonctionne même quand l'option de surlignage par slot est désactivée.",
"Console Elecraft : une rangée de modes — CW, USB, LSB, DATA et DATA RTTY. Les deux boutons DATA règlent aussi le sous-mode du K3 (DATA A pour FT8/FT4, FSK D pour le RTTY) : passer en DATA par le seul mode gardait le sous-mode de la session précédente, et un K3 « en DATA » manipulait le FT8 sans audio.",
"Réglages DX Cluster : « Je chasse le POTA » et « Je chasse le SOTA », cochés par défaut. Décochés, le badge, la couleur et le filtre NOUVEAU POTA disparaissent — un spot nouvelle bande + nouveau POTA affiche seulement NOUVELLE BANDE — et les colonnes de références restent vides.",
"Console Elecraft : le RIT et le XIT utilisent la même commande que les consoles Icom et TCI — boutons ±, molette, valeur tapée, Ctrl+←/→ — et le curseur de puissance avance par pas de 1 W au lieu de 5.",
"Édition groupée : My DXCC, My CQ zone et My ITU zone rejoignent les champs Ma station — valeurs vérifiées contre leurs bornes réelles, vide efface.",
"Cliquer un spot dans le panneau Chase new applique désormais la largeur/zoom du panadapter FlexRadio, comme tout autre clic de spot.",
"FlexRadio : la largeur/zoom du panadapter sapplique désormais de façon fiable au double-clic sur un spot du cluster — le zoom est envoyé après le tune, pour que le suivi du panadapter de SmartSDR lors dun changement de bande ne l’écrase plus."
]
},
{
"version": "0.26.23",
"date": "",
"en": [
"LoTW: the downloaded confirmations list gains a Station column, so a list spanning several callsigns says which one each confirmation belongs to. Shown only when the report actually carries more than the one station.",
"The band maps follow your IARU region (Settings → General): Region 2s 40 m runs to 7300 kHz with phone from 7125 and its 80 m to 4000, Region 3s 80 m to 3900 — the band edges and the CW/digital/phone shading all adjust."
],
"fr": [
"LoTW : la liste des confirmations téléchargées gagne une colonne Station, pour savoir à quel indicatif appartient chaque confirmation quand le téléchargement en couvre plusieurs. Affichée seulement si le rapport en contient effectivement.",
"Les cartes de bande suivent votre région IARU (Réglages → Général) : le 40 m de la Région 2 va jusqu'à 7300 kHz avec la phonie dès 7125 et son 80 m jusqu'à 4000, le 80 m de la Région 3 jusqu'à 3900 — les limites de bande et les zones CW/numérique/phonie s'ajustent."
]
},
{
"version": "0.26.22",
"date": "",
"en": [
"LoTW download: the QSL details (QSL date, grid, state, county) are now optional and off by default — LoTW takes about ten times longer to build that report, twenty minutes against two on the same account, and marking a confirmation needs none of it. Still asked for automatically when adding the QSOs not found in the log.",
"Band map: the tooltip now also names a new prefix or a new grid square. They stay off the 22-pixel colour strip, but leaving them out of the text made the two panels look as though they disagreed — the cluster said NEW PFX about a spot the map called Worked, and both were right. The maps “Worked” also says whose: it is the ENTITY that was worked on that band and mode, not the callsign, which is what made the two readings look contradictory.",
"TCI (SunSDR): clicking a cluster spot no longer needs a second click to get the mode right — the sideband was chosen from the frequency the radio had last reported instead of the one just asked for. The log also names the ExpertSDR version now, and says so when it is older than the 1.5 that panorama spots need.",
"Two screens: OpsLog no longer walks off the desktop. On a monitor placed left of the primary one, the saved position was being added to that monitors own origin at every launch, so the window moved one screen further out each time until it was invisible.",
"CW over TCI: a SunSDR can now be keyed through its own macro keyer — pick TCI as the keyer engine (Settings → CW Keyer) and macros, auto-call and the speed control all work over the link already open, with no WinKeyer and no second serial port. NOT TESTED on the air yet.",
"LoTW upload: a refusal now shows TQSLs own explanation — which contacts were already uploaded, which fell outside the certificates dates — instead of the bare “no QSOs processed”, and names the two settings that cause it.",
"Main tab: the docked cluster now has ONE header row — its title, live count and Filters button sit with Clear filters and Columns, as Recent QSOs beside it already did. The pane is titled DX Cluster.",
"A busy cluster no longer makes the rest of the interface sluggish: incoming spots are grouped into fewer, larger updates as the feed gets faster (up to half a second), instead of redrawing the window twenty times a second. A quiet cluster still shows each spot as it lands.",
"SunSDR console: the meters work. The S-meter, transmit power and SWR are pushed by the radio only to a client that subscribes, and OpsLog never did — it was reading commands ExpertSDR3 does not send.",
"Cluster: the “N new spots” counter no longer jumps to the whole buffer. It was looking for the row it had frozen on, and a station spotted again replaces its row — so the count fell through to “everything is new”.",
"E-mail: a refused SMTP login now says what to do about it — Microsoft 365 and outlook.com have switched off password-based SMTP, and an app password does not bring it back.",
"TCI panorama spots: the colour was sent as a negative number and ExpertSDR dropped every spot in silence. It now goes out as the unsigned ARGB integer the protocol document uses, and the first few spots are written to the log verbatim.",
"Cluster: “Group duplicates” was hiding the same station on OTHER bands and modes — a DXpedition spotted on five bands showed as one line and four slots disappeared. A duplicate is now what it should always have been: the same station on the same band and mode."
],
"fr": [
"Téléchargement LoTW : les détails QSL (date du QSL, locator, état, comté) deviennent optionnels et désactivés par défaut — LoTW met environ dix fois plus longtemps à construire ce rapport, vingt minutes contre deux sur le même compte, et marquer une confirmation n'en a pas besoin. Toujours demandés automatiquement quand on ajoute les QSO absents du log.",
"Carte des bandes : l'infobulle indique aussi un nouveau préfixe ou un nouveau locator. Ils restent hors de la bande de couleur de 22 pixels, mais les omettre du texte donnait l'impression que les deux panneaux se contredisaient — le cluster annonçait NOUVEAU PFX pour un spot que la carte disait contacté, et les deux avaient raison. Le « Contacté » de la carte dit aussi de qui il parle : c'est l'ENTITÉ qui a été contactée sur cette bande et ce mode, pas l'indicatif — d'où l'impression de contradiction.",
"TCI (SunSDR) : cliquer un spot du cluster ne demande plus un second clic pour obtenir le bon mode — la bande latérale était choisie d'après la fréquence encore annoncée par la radio au lieu de celle qu'on venait de demander. Le journal indique aussi la version d'ExpertSDR, et signale si elle est antérieure à la 1.5 qu'exigent les spots sur le panorama.",
"Deux écrans : OpsLog ne s'échappe plus du bureau. Sur un écran placé à gauche de l'écran principal, la position enregistrée était ajoutée à l'origine de cet écran à chaque lancement, si bien que la fenêtre s'éloignait d'un écran à chaque fois jusqu'à devenir invisible.",
"CW en TCI : un SunSDR peut désormais être manipulé par son propre keyer à macros — choisissez TCI comme moteur (Réglages → Manipulateur CW) et les macros, l'appel automatique et le réglage de vitesse passent par la liaison déjà ouverte, sans WinKeyer ni second port série. PAS ENCORE TESTÉ sur l'air.",
"Envoi LoTW : un refus affiche désormais l'explication de TQSL — quels contacts étaient déjà envoyés, lesquels tombaient hors des dates du certificat — au lieu du seul « no QSOs processed », et nomme les deux réglages qui en sont la cause.",
"Onglet Main : le cluster ancré n'a plus qu'UNE ligne d'en-tête — son titre, le compteur live et le bouton Filtres rejoignent Effacer les filtres et Colonnes, comme le faisait déjà la liste des QSO récents à côté. Le panneau s'intitule DX Cluster.",
"Un cluster chargé ne ralentit plus le reste de l'interface : les spots entrants sont regroupés en mises à jour moins nombreuses à mesure que le flux s'accélère (jusqu'à une demi-seconde), au lieu de redessiner la fenêtre vingt fois par seconde. Sur un cluster calme, chaque spot s'affiche toujours dès son arrivée.",
"Console SunSDR : les mesures fonctionnent. Le S-mètre, la puissance et le ROS ne sont envoyés qu'à un client qui s'abonne, ce qu'OpsLog ne faisait pas — il lisait des commandes qu'ExpertSDR3 n'envoie pas.",
"Cluster : le compteur « N nouveaux spots » ne saute plus à la taille du tampon. Il cherchait la ligne sur laquelle il s'était figé, or une station re-spottée remplace sa ligne — le compte basculait donc sur « tout est nouveau ».",
"E-mail : un refus d'authentification SMTP explique désormais quoi faire — Microsoft 365 et outlook.com ont désactivé le SMTP par mot de passe, et un mot de passe d'application ne le rétablit pas.",
"Spots sur le panorama TCI : la couleur partait en nombre négatif et ExpertSDR écartait chaque spot en silence. Elle est désormais envoyée en entier ARGB non signé, comme dans la documentation du protocole, et les premiers spots sont écrits tels quels dans le journal.",
"Cluster : « Grouper les doublons » masquait la même station sur les AUTRES bandes et modes — une expédition spottée sur cinq bandes n'affichait qu'une ligne et quatre créneaux disparaissaient. Un doublon est désormais ce qu'il aurait toujours dû être : la même station sur la même bande et le même mode."
]
},
{
"version": "0.26.21",
"date": "",
"en": [
"Distances can be shown in miles (Settings → General): the cluster and Recent QSOs columns, the map path box, the rotator buttons and the band-opening list all follow, and the column headers name the unit.",
"LoTW download: the report is now counted in megabytes as it arrives, LoTWs \"busy\" answer (HTTP 503) is retried twice instead of failing, and a transfer that stops moving for two minutes says so rather than showing \"working\" indefinitely."
],
"fr": [
"Les distances peuvent s'afficher en miles (Réglages → Général) : les colonnes du cluster et des QSO récents, l'encart du tracé sur la carte, les boutons du rotor et la liste des ouvertures suivent, et l'unité est indiquée dans les en-têtes de colonne.",
"Téléchargement LoTW : le rapport est compté en mégaoctets au fur et à mesure, la réponse « occupé » de LoTW (HTTP 503) est retentée deux fois au lieu d'échouer, et un transfert qui n'avance plus pendant deux minutes le dit au lieu d'afficher « en cours » indéfiniment."
]
},
{
"version": "0.26.20",
"date": "",
"en": [
"Each radio carries its own MY_RIG (Settings → CAT), written on every QSO made with it — ahead of the per-band station in Operating conditions, which says what you planned to use rather than which radio is keying. Left empty, nothing changes.",
"Worked-before matrix: a dot in the corner of a cell now means the callsign you are working has already been worked on that slot, whatever colour the entity status gave the cell. Its two colours (worked / confirmed with this callsign) are in Appearance with the other matrix colours.",
"Icom spectrum scope: the command shape (with or without the main/sub selector) is now worked out from the radios own answers instead of a list of models. A radio that has scope control but no waveform stream over CI-V — the IC-7851 — says so in the panel rather than showing a black rectangle.",
"Elecraft KPA: the status-bar chip now shows the amplifier as connected and switches it between OPERATE and STANDBY like the other brands, and an offline KPA no longer calls itself an Acom.",
"Cluster: a SOTA column, read from the summit reference the SOTA feeds put in the spot comment. Clicking the spot fills the QSOs SOTA award reference, as a POTA spot already did. Turn the column on in Columns. Add cluster.sota.org.uk:7300 as a server to receive the summit spots.",
"Awards: a \"Slots to confirm\" filter and a running count beside the reference total, so the gap between worked and confirmed band-slots — the Challenge difference — can be seen reference by reference instead of only as two numbers.",
"QSL Manager: a QRZ button next to the Paper QSL search, opening the callsign on QRZ.com.",
"LoTW: an \"All my callsigns\" option beside the download. The download is scoped to the profiles own callsign, so a QSO made as a portable or contest call was confirmed at LoTW and never marked here. Confirmations made under a callsign this logbook has never used are skipped, and a suspiciously small report now shows what LoTW actually answered.",
"LoTW download: \"All\" really means all — without a date LoTW answered with a handful of recent confirmations, which looked like a successful download of an empty account. A full account also has time to arrive: the two-minute limit that ended in \"context deadline exceeded\" is now twenty."
],
"fr": [
"Chaque radio porte son propre MY_RIG (Réglages → CAT), inscrit sur chaque QSO fait avec elle — avant la station par bande des Conditions de trafic, qui dit ce qui était prévu et non quelle radio émet. Laissé vide, rien ne change.",
"Matrice des contacts : un point dans le coin d'une case indique que l'indicatif en cours a déjà été contacté sur ce créneau, quelle que soit la couleur donnée par le statut de l'entité. Ses deux couleurs (contacté / confirmé avec cet indicatif) se règlent dans Apparence avec les autres couleurs de la matrice.",
"Scope Icom : la forme des commandes (avec ou sans le sélecteur main/sub) est déduite des réponses de la radio au lieu d'une liste de modèles. Une radio qui pilote son scope mais ne l'envoie pas en CI-V — l'IC-7851 — l'indique dans le panneau au lieu d'afficher un rectangle noir.",
"Elecraft KPA : la pastille de la barre d'état montre enfin l'amplificateur comme connecté et bascule OPERATE / STANDBY comme les autres marques, et un KPA hors ligne ne s'annonce plus comme un Acom.",
"Cluster : une colonne SOTA, lue dans la référence de sommet que les flux SOTA mettent dans le commentaire du spot. Cliquer le spot remplit la référence SOTA du QSO, comme le faisait déjà un spot POTA. Colonne à activer dans Colonnes. Ajoutez le serveur cluster.sota.org.uk:7300 pour recevoir les spots de sommets.",
"Awards : un filtre « Slots à confirmer » et un compteur à côté du total de références, pour voir l'écart entre créneaux contactés et confirmés — la différence du Challenge — référence par référence et non plus seulement en deux chiffres.",
"Gestionnaire QSL : un bouton QRZ à côté de la recherche QSL papier, qui ouvre l'indicatif sur QRZ.com.",
"LoTW : une option « Tous mes indicatifs » à côté du téléchargement. Celui-ci est limité à l'indicatif du profil, si bien qu'un QSO fait sous un indicatif portable ou de contest était confirmé chez LoTW sans jamais être marqué ici. Les confirmations faites sous un indicatif que ce carnet n'a jamais utilisé sont ignorées, et un rapport anormalement petit affiche désormais ce que LoTW a réellement répondu.",
"Téléchargement LoTW : « Tout » veut enfin dire tout — sans date, LoTW ne renvoyait qu'une poignée de confirmations récentes, ce qui ressemblait à un téléchargement réussi d'un compte vide. Un compte complet a aussi le temps d'arriver : la limite de deux minutes, qui finissait en « context deadline exceeded », passe à vingt."
]
},
{
"version": "0.26.19",
"date": "",
"en": [
"Several radios, and one click to change which one is connected. Settings → CAT holds a list — name them, add one (it starts as a copy of the current one), remove one — and the CAT button in the status bar switches between them. The logbook, the callsign and everything else about the station stay exactly as they are, which is what making a profile per radio could not do."
],
"fr": [
"Plusieurs radios, et un clic pour changer celle qui est connectée. Réglages → CAT contient une liste — les nommer, en ajouter une (elle part d'une copie de la courante), en retirer une — et le bouton CAT de la barre d'état passe de l'une à l'autre. Le carnet, l'indicatif et tout le reste de la station ne bougent pas, ce qu'un profil par radio ne permettait pas."
]
},
{
"version": "0.26.18",
"date": "",
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"context"
"path/filepath"
"testing"
"time"
"hamlog/internal/clublog"
"hamlog/internal/dxcc"
"hamlog/internal/qso"
)
// Reported from a real import: every ZK2 contact came back as New Zealand and a
// confirmed entity — Niue — disappeared from the operator's DXCC.
//
// cty.dat is not wrong, it is CURRENT: Niue moved to E6, so ZK2 reverted to New
// Zealand there. ClubLog still knows ZK2 was Niue, and knowing that is the whole
// reason for enabling its country file.
func TestClublogPrefixRescuesRetiredPrefixes(t *testing.T) {
dir := filepath.Join("build", "bin", "data")
dm := dxcc.NewManager(dir)
if err := dm.LoadFromDisk(); err != nil {
t.Skipf("cty.dat not available here: %v", err)
}
cm := clublog.NewManager("", dir)
if err := cm.EnsureLoaded(); err != nil {
t.Skipf("ClubLog country file not available here: %v", err)
}
a := &App{ctx: context.Background(), dxcc: dm, clublog: cm}
when := time.Date(2005, 6, 1, 0, 0, 0, 0, time.UTC)
cases := []struct {
call string
want int // ADIF entity
why string
}{
{"ZK2KK", 188, "Niue — the reported case"},
{"ZK1XYZ", 234, "South Cook Islands, lost the same way"},
}
for _, c := range cases {
q := qso.QSO{Callsign: c.call, QSODate: when}
if !a.applyClublogException(&q, true) {
t.Errorf("%s: ClubLog changed nothing (%s)", c.call, c.why)
continue
}
if q.DXCC == nil || *q.DXCC != c.want {
got := 0
if q.DXCC != nil {
got = *q.DXCC
}
t.Errorf("%s resolved to %d (%s), want %d — %s", c.call, got, q.Country, c.want, c.why)
}
}
// A callsign ClubLog's prefix table does not know must be left to cty.dat
// rather than blanked: silence is not an answer.
q := qso.QSO{Callsign: "E6AG", QSODate: when}
if a.applyClublogException(&q, true) && q.DXCC != nil && *q.DXCC != 188 {
t.Errorf("E6AG was moved off Niue, to %d", *q.DXCC)
}
}
+38
View File
@@ -0,0 +1,38 @@
package main
import (
"testing"
"hamlog/internal/qso"
)
// A Confirmations default of "R" means "I intend to upload this", not "it has
// gone" — reading it as sent would silently disable the auto-upload the default
// was set to arm. Only a real stamp blocks.
func TestExtrasSaysSent(t *testing.T) {
pending := []string{"", " ", "R", "r", "N", "Q", "I"}
for _, v := range pending {
if extrasSaysSent(v) {
t.Errorf("extrasSaysSent(%q) = true, want false (still to upload)", v)
}
}
sent := []string{"Y", "y", "20260831"} // "Y" today, a date in older builds
for _, v := range sent {
if !extrasSaysSent(v) {
t.Errorf("extrasSaysSent(%q) = false, want true (already uploaded)", v)
}
}
}
// The HamQTH default lands on the extras key the uploader reads, and must leave
// the QSO eligible.
func TestHamQTHDefaultStaysUploadable(t *testing.T) {
q := &qso.QSO{Callsign: "F4BPO"}
applyQSLDefaultsTo(q, defaultQSLDefaults())
if got := q.Extras[hamqthSentKey]; got != "R" {
t.Fatalf("HamQTH sent extra = %q, want %q", got, "R")
}
if extrasSaysSent(q.Extras[hamqthSentKey]) {
t.Error("a freshly logged QSO reads as already uploaded to HamQTH")
}
}
+978 -319
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -194,7 +194,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
</div>
<span className={cn('inline-flex items-center gap-1.5 text-sm', kpa.connected ? 'text-muted-foreground' : 'text-danger')}>
<span className={cn('size-2 rounded-full', kpa.connected ? 'bg-success' : 'bg-danger')} />
{kpa.connected ? (kpa.tuning ? t('flxp.kpaTuning') : (kpa.power_on ? 'ON' : 'OFF')) : t('flxp.acomOffline')}
{kpa.connected ? (kpa.tuning ? t('flxp.kpaTuning') : (kpa.power_on ? 'ON' : 'OFF')) : t('flxp.kpaOffline')}
</span>
{kpa.connected && (
<span className="text-sm font-mono text-muted-foreground tabular-nums">
+119 -1
View File
@@ -1,4 +1,6 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { EventsEmit } from '../../wailsjs/runtime/runtime';
import { GripVertical, Lock } from 'lucide-react';
import { GetMatrixColors, GetRowColors, SaveMatrixColors, SaveRowColors } from '../../wailsjs/go/main/App';
import { Checkbox } from '@/components/ui/checkbox';
import { useI18n } from '@/lib/i18n';
@@ -127,6 +129,12 @@ function MatrixColorsSection() {
<span className="inline-block w-7 h-5 rounded bg-mx-dx-work" />
<span className="inline-block w-7 h-5 rounded bg-mx-none" />
<span className="inline-block w-7 h-5 rounded bg-mx-none ring-2 ring-mx-cur ring-inset" />
<span className="relative inline-block w-7 h-5 rounded bg-mx-dx-conf">
<span className="absolute top-[4px] right-[4px] size-[5px] rounded-full bg-mx-mark-work ring-1 ring-background" />
</span>
<span className="relative inline-block w-7 h-5 rounded bg-mx-dx-conf">
<span className="absolute top-[4px] right-[4px] size-[5px] rounded-full bg-mx-mark-conf ring-1 ring-background" />
</span>
</div>
<button type="button" onClick={reset}
@@ -284,6 +292,116 @@ export function AppearancePanel() {
)}
<MatrixColorsSection />
<WidgetOrderSection />
</div>
);
}
// The row of widgets to the right of the entry, in the order they appear.
//
// Flexbox does the moving in the main view — this list only decides the order
// property each one gets. That is why a widget switched OFF still holds its
// place here: it comes back where the operator left it rather than at the end.
export const WIDGET_KEYS = [
'livestations', 'chat', 'rotor', 'motorant', 'antgenius',
'amp', 'tuner', 'scp', 'chasenew', 'watchlist', 'dvk', 'winkeyer', 'photo',
] as const;
const WIDGET_LABELS: Record<string, string> = {
livestations: 'wo.livestations', chat: 'wo.chat', rotor: 'wo.rotor',
motorant: 'wo.motorant', antgenius: 'wo.antgenius', amp: 'wo.amp',
tuner: 'wo.tuner', scp: 'wo.scp', chasenew: 'wo.chasenew', watchlist: 'wo.watchlist',
dvk: 'wo.dvk', winkeyer: 'wo.winkeyer', photo: 'wo.photo',
};
function readWidgetOrder(): string[] {
try {
const raw = localStorage.getItem('opslog.widgetOrder');
const arr = raw ? JSON.parse(raw) : null;
if (Array.isArray(arr)) {
// A key from an older build that no longer exists is dropped; a widget
// added since joins the end. An old preference can never hide a new one.
const known = arr.filter((k: any) => (WIDGET_KEYS as readonly string[]).includes(k));
return [...known, ...WIDGET_KEYS.filter((k) => !known.includes(k))];
}
} catch { /* corrupt pref → the default order */ }
return [...WIDGET_KEYS];
}
function WidgetOrderSection() {
const { t } = useI18n();
const [order, setOrder] = useState<string[]>(readWidgetOrder);
const dragKey = useRef<string | null>(null);
const [dragging, setDragging] = useState<string | null>(null);
// Where the row would land. Drawn as a line above the target rather than by
// colouring it: the question a dragging hand asks is "between which two", and
// a highlighted row answers a different one.
const [over, setOver] = useState<string | null>(null);
const commit = (keys: string[]) => {
setOrder(keys);
try { localStorage.setItem('opslog.widgetOrder', JSON.stringify(keys)); } catch { /* private mode */ }
// The main view listens: an order is meant to be watched as it is dragged,
// not discovered after closing Preferences.
EventsEmit('widgets:order', keys);
};
const moveTo = (from: string, to: string) => {
if (from === to) return;
const next = order.filter((k) => k !== from);
const at = next.indexOf(to);
next.splice(at < 0 ? next.length : at, 0, from);
commit(next);
};
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold">{t('wo.title')}</h3>
<p className="text-xs text-muted-foreground">{t('wo.hint')}</p>
<div className="space-y-1 max-w-md">
{/* The two that cannot move, shown so the order reads as the whole row
rather than as a list that mysteriously starts at the third item. */}
{['wo.entry', 'wo.details'].map((k) => (
<div key={k}
className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2 py-1.5 text-sm text-muted-foreground">
<Lock className="size-3.5 shrink-0 opacity-60" />
<span className="flex-1 min-w-0 truncate">{t(k)}</span>
</div>
))}
{order.map((k) => (
// The WHOLE row is the handle, not the grip alone: a list whose rows
// can only be moved by a 16-pixel icon is a list most people conclude
// cannot be moved. The grip stays as the sign that it can.
<div key={k} draggable
onDragStart={(e) => { dragKey.current = k; setDragging(k); e.dataTransfer.effectAllowed = 'move'; }}
onDragEnd={() => { dragKey.current = null; setDragging(null); setOver(null); }}
onDragOver={(e) => {
if (!dragKey.current) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (over !== k) setOver(k);
}}
onDragLeave={() => { if (over === k) setOver(null); }}
onDrop={(e) => {
if (!dragKey.current) return;
e.preventDefault();
moveTo(dragKey.current, k);
setOver(null);
}}
title={t('wo.drag')}
className={cn('flex items-center gap-2 rounded-md border bg-card px-2 py-1.5 text-sm select-none',
'cursor-grab active:cursor-grabbing transition-shadow',
dragging === k ? 'opacity-50 border-primary shadow-lg' : 'border-border hover:border-foreground/30',
// The landing line, on the edge the row would take.
over === k && dragging !== k && 'shadow-[inset_0_3px_0_0_var(--primary)]')}>
<GripVertical className="size-4 shrink-0 text-muted-foreground/50" />
<span className="flex-1 min-w-0 truncate">{t(WIDGET_LABELS[k] ?? k)}</span>
</div>
))}
</div>
<button type="button" onClick={() => commit([...WIDGET_KEYS])}
className="text-xs text-muted-foreground hover:text-foreground underline">
{t('wo.reset')}
</button>
</div>
);
}
+40 -4
View File
@@ -65,6 +65,19 @@ function cellStatus(r: AwardRef, band: string): CellStatus {
if (r.bands?.includes(band)) return 'worked';
return 'none';
}
// slotsToConfirm counts the band-slots worked with this reference and not yet
// confirmed on any of them — the QSLs still outstanding, one per cell showing W.
//
// It is the difference the Challenge line makes visible in the aggregate (1832
// worked against 1554 confirmed) without saying WHERE it is. Counted over the
// bands actually on screen, so it always adds up to the columns in front of the
// operator rather than to a band set they filtered out.
function slotsToConfirm(r: AwardRef, bands: string[]): number {
let n = 0;
for (const b of bands) if (cellStatus(r, b) === 'worked') n++;
return n;
}
const CELL_STYLE: Record<CellStatus, string> = {
validated: 'bg-success text-success-foreground',
confirmed: 'bg-warning text-warning-foreground',
@@ -112,7 +125,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
const [refSearch, setRefSearch] = useState('');
const [editing, setEditing] = useState(false);
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf' | 'slots_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.
@@ -304,6 +317,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
if (refFilter === 'worked' && !r.worked) return false;
if (refFilter === 'notworked' && r.worked) return false;
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
// Worked-not-confirmed by SLOT, not by reference: an entity confirmed on
// 20 m still has a 15 m contact waiting for its card, and every filter
// above answers "no" for it because the entity itself is confirmed.
if (refFilter === 'slots_notconf' && slotsToConfirm(r, gridBands) === 0) 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
@@ -339,7 +356,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
}
return a.ref.localeCompare(b.ref, undefined, { numeric: true }) * dir;
});
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir]);
}, [current, refSearch, refFilter, modeFilter, refSort, refSortDir, gridBands]);
// The gap itself, over whatever the other filters left on screen: the number
// of cells an operator would have to turn green to close it.
const slotGap = useMemo(
() => filteredRefs.reduce((n, r) => n + slotsToConfirm(r, gridBands), 0),
[filteredRefs, gridBands],
);
// 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
@@ -468,7 +492,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
<Input className="h-8 w-56 pl-7 text-sm" placeholder={t('awp.filterReferences')} value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
</div>
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
{([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')]] as const).map(([k, label]) => (
{([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')], ['slots_notconf', t('awp.filterSlotsNotCfmd')]] as const).map(([k, label]) => (
<button key={k} onClick={() => setRefFilter(k)}
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
{label}
@@ -484,6 +508,11 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
))}
</div>
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
{slotGap > 0 && (
<span className="text-xs text-muted-foreground" title={t('awp.slotGapTip')}>
· <span className="font-semibold text-foreground">{slotGap}</span> {t('awp.slotGap')}
</span>
)}
{/* Only for an award scoped to a DXCC entity. "In this award's
scope but with no reference" needs a scope to be in: on a
worldwide reference award — POTA, SOTA, IOTA, WWFF — every
@@ -604,7 +633,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged, onPaperQSL }: {
<td key={b} className="border-b border-l border-border/30 p-0 text-center">
{s === 'none' ? <span className="block w-11 h-7" /> : (
<button
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110',
// Chasing the slots still to confirm, the
// confirmed ones are context, not the answer:
// a row is kept for its W cells and its V
// cells would otherwise be the loudest thing
// on it. Faded rather than hidden — which
// band is already done is worth seeing.
refFilter === 'slots_notconf' && s !== 'worked' && 'opacity-25')}
title={t('awp.cellTitle', { ref: r.ref, band: b })}
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
>{CELL_LABEL[s]}</button>
+61 -71
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { bandRange, bandSegments, subscribeIaruRegion, type SegMode } from '@/lib/bandplan';
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
@@ -76,6 +77,13 @@ const BMP_MARKER_LABEL: Record<string, string> = {
new_pota: 'bmp.legendNewPota',
new_county: 'bmp.legendNewCounty',
worked_call: 'bmp.legendWorkedCall',
// Not on the strip — the pill is 22 px and a fourth segment turns it into a
// colour code nobody reads — but the TOOLTIP has room, and leaving them out of
// it made the two panels contradict each other: the cluster said NEW PFX about
// a spot the map called "Worked". Both were true (the entity is worked on this
// slot, the WPX prefix never has been) and neither view said so.
new_pfx: 'clg2.newPfx',
new_grid: 'clg2.newGrid',
};
interface Props {
@@ -107,59 +115,26 @@ interface Props {
keyNav?: boolean;
}
const BAND_RANGES: Record<string, [number, number]> = {
'160m': [1800, 2000],
'80m': [3500, 3800],
'60m': [5350, 5450],
'40m': [7000, 7200],
'30m': [10100, 10150],
'20m': [14000, 14350],
'17m': [18068, 18168],
'15m': [21000, 21450],
'12m': [24890, 24990],
'10m': [28000, 29700],
'6m': [50000, 50500],
'4m': [70000, 70500],
'2m': [144000, 146000],
'70cm': [430000, 440000],
// Band edges and mode segments come from lib/bandplan, which knows the
// operator's IARU region (Settings → General): a Region 2 operator's 40 m runs
// to 7300 with phone from 7125, and drawing Region 1's plan under their spots
// cut the top 100 kHz of the band off the map.
//
// Sub-band shading colours: these are IDENTITIES (which mode the segment is
// for), not states, so they take categorical hues — never the status tokens.
// 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. Checked with the palette validator in both
// themes (violet failed the dark protan step; magenta clears every check).
const SEG_COLOR: Record<SegMode, string> = {
cw: 'var(--chart-7)', // magenta
digi: 'var(--chart-1)', // blue
phone: 'var(--chart-2)', // aqua
};
// 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, 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, colour, label }: { cls?: string; colour?: string; label: string }) {
return (
@@ -297,14 +272,28 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
// 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;
if (!dispOpts.muteWorked && !dispOpts.slotHighlight && dispOpts.chasePota) 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] ?? [];
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight, dispOpts.chasePota]);
// Re-render when the operator changes their IARU region in Settings.
const [, setRegionTick] = useState(0);
useEffect(() => subscribeIaruRegion(() => setRegionTick((n) => n + 1)), []);
const range = bandRange(band);
const segments = bandSegments(band).map(([a, b, m]) => [a, b, SEG_COLOR[m]] as [number, number, string]);
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
// The legend is a reference, not a running display: once its colours are
// learnt it is four lines of a short screen spent saying nothing new. Folded
// away by a toggle, and the choice is remembered.
const [legendOpen, setLegendOpen] = useState(() => {
try { return localStorage.getItem('opslog.bmpLegend') !== '0'; } catch { return true; }
});
const toggleLegend = () => setLegendOpen((v) => {
const next = !v;
try { localStorage.setItem('opslog.bmpLegend', next ? '1' : '0'); } catch { /* private mode */ }
return next;
});
// The docked map follows the rig, so a band change must bring up THAT band's
// remembered zoom.
useEffect(() => { setZoomIdx(readZoom(band)); }, [band]);
@@ -482,19 +471,10 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (!range) return;
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
changeZoom(e.deltaY > 0 ? -1 : 1);
}
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [range]);
// No ctrl+wheel zoom here any more: ctrl+wheel is the WINDOW zoom everywhere
// else in OpsLog (View ▸ Zoom in/out), and one gesture that resizes the whole
// app over one panel and one band map over another is a gesture nobody can
// trust. The + / buttons keep the zoom, deliberately and visibly.
// 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.
@@ -737,7 +717,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
'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)}${markersFor(entry).map((m) => ' · ' + t(BMP_MARKER_LABEL[m.key])).join('')}${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)}${activeMarkers(entry).map((m) => ' · ' + t(BMP_MARKER_LABEL[m.key])).join('')}${p.spot.comment ? ' · ' + p.spot.comment : ''}${p.spot.spotter ? ' · de ' + p.spot.spotter : ''}`}
>
{/* Left accent strip. With no extra marker it repeats the status
colour, exactly as before; otherwise it splits into one
@@ -775,6 +755,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
</div>
</div>
{/* Colour legend — what each pill colour means. */}
{legendOpen && (
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
@@ -787,13 +768,22 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
))}
{/* 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")} />
<LegendDot colour={SEG_COLOR.cw} label={t("bmp.legendCW")} />
<LegendDot colour={SEG_COLOR.digi} label={t("bmp.legendData")} />
<LegendDot colour={SEG_COLOR.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">
)}
<div className="px-3 py-1 flex items-center gap-2 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono shrink-0">
<span className="flex-1 text-center">
{t('bmp.footerHint')}
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
</span>
<button type="button" onClick={toggleLegend}
title={legendOpen ? t('bmp.legendHide') : t('bmp.legendShow')}
aria-label={legendOpen ? t('bmp.legendHide') : t('bmp.legendShow')}
className="shrink-0 inline-flex items-center gap-0.5 rounded px-1 py-px hover:bg-muted hover:text-foreground">
{legendOpen ? <ChevronDown className="size-3" /> : <ChevronUp className="size-3" />}
</button>
</div>
</div>
);
+98 -17
View File
@@ -15,7 +15,10 @@ interface Props {
busy: boolean;
currentBand: string;
currentMode: string;
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
bands?: string[];
// The operator's configured mode list, in THEIR order: the digital row
// rotates through it.
modes?: 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.
@@ -81,29 +84,71 @@ const STATUS_CLASSES: Record<string, string> = {
// i18n keys the Appearance panel's colour pickers use, so the two can never
// disagree about which green is which. swatch = the background class (or a
// special ring marker for the current-entry cell).
const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [
const LEGEND: { swatch: string; ring?: boolean; mark?: string; label: string }[] = [
{ swatch: 'bg-mx-call-conf', label: 'mx.callConf' },
{ swatch: 'bg-mx-call-work', label: 'mx.callWork' },
{ swatch: 'bg-mx-dx-conf', label: 'mx.dxConf' },
{ swatch: 'bg-mx-dx-work', label: 'mx.dxWork' },
{ swatch: 'bg-mx-none', label: 'mx.none' },
{ swatch: 'bg-mx-none', ring: true, label: 'mx.current' },
{ swatch: 'bg-mx-none', mark: 'w', label: 'mx.markWork' },
{ swatch: 'bg-mx-none', mark: 'c', label: 'mx.markConf' },
];
function cellTitle(t: (k: string) => string, band: string, cls: string, status: string, current: boolean): string {
// CallMark — "this callsign has already been worked on this slot".
//
// Drawn the same way on every cell, whatever colour the entity status gave it:
// the operator learns one shape and reads it without first working out what the
// background means. Only the fill changes, and only with the callsign's own
// state (worked / confirmed) — never with the entity's. The ring is the theme
// background, which is what keeps the dot legible over all five cell colours.
function CallMark({ state = 'w' }: { state?: string }) {
return (
<span
className={cn(
'pointer-events-none absolute top-[4px] right-[4px] size-[5px] rounded-full ring-1 ring-background',
state === 'c' ? 'bg-mx-mark-conf' : 'bg-mx-mark-work',
)}
/>
);
}
function cellTitle(t: (k: string) => string, band: string, cls: string, status: string, current: boolean, call = ''): string {
const desc =
status === 'call_c' ? t('mx.tipCallConf') :
status === 'call_w' ? t('mx.tipCallWork') :
status === 'dxcc_c' ? t('mx.tipDxConf') :
status === 'dxcc_w' ? t('mx.tipDxWork') :
t('mx.tipNone');
return `${band} ${cls}: ${desc}${current ? ' — ' + t('mx.current') : ''}`;
const mine = call === 'c' ? t('mx.tipThisCallConf') : call === 'w' ? t('mx.tipThisCall') : '';
return `${band} ${cls}: ${desc}${mine ? ' — ' + mine : ''}${current ? ' — ' + t('mx.current') : ''}`;
}
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, modes, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
const { t } = useI18n();
// Cell drill-down: which band+class the operator clicked, or null.
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
// The DIGITAL row is a rotation, not a fixed row.
//
// One row for every digital mode would be the honest layout and there is no
// height for it — the matrix sits in a fixed panel beside a dozen widgets.
// So the row keeps its place and changes what it answers: DIG (all of them),
// then each digital mode the operator actually uses, in the order their mode
// list gives, then back to DIG. The backend publishes the same cells under
// both the class name and the raw mode, so a rotation costs no round trip.
const digModes = useMemo(
() => (modes ?? [])
.map((m) => (m || '').toUpperCase().trim())
.filter((m) => m !== '' && m !== 'CW' && !PHONE_MODES.has(m)),
[modes],
);
const [digIdx, setDigIdx] = useState(0); // 0 = the DIG group itself
// A shorter mode list (the operator edited it) must not strand the rotation
// on a row that no longer exists.
const digPos = digModes.length ? digIdx % (digModes.length + 1) : 0;
const digRow = digPos === 0 ? 'DIG' : digModes[digPos - 1];
const cycleDig = () => setDigIdx((i) => (digModes.length ? (i + 1) % (digModes.length + 1) : 0));
// 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(
@@ -126,6 +171,16 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
return m;
}, [wb]);
// Worked-with-this-callsign, per cell — carried separately by the backend
// because collapsing it into the status is what hid it.
const callMap = useMemo(() => {
const m = new Map<string, string>();
for (const s of wb?.band_status ?? []) {
if ((s as any).call) m.set(`${s.band}|${s.class}`, (s as any).call);
}
return m;
}, [wb]);
// "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).
@@ -211,11 +266,11 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
return (
<section
className={cn(
'flex items-center gap-4 px-3 py-2 flex-wrap shrink-0',
'flex items-center gap-2 px-2 py-2 flex-wrap shrink-0',
newOne && 'bg-gradient-to-br from-warning-muted to-warning-muted rounded-lg',
)}
>
<div className="flex items-center gap-2 min-w-[220px] flex-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
{newOne ? (
<>
<Badge className="bg-warning text-warning-foreground gap-1 px-3 py-1 text-[11px]">
@@ -238,7 +293,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
{forCall}
</Badge>
)}
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
<Badge className="bg-primary text-primary-foreground px-2 py-0.5 text-xs normal-case font-semibold tracking-normal leading-tight">
{dxccName || `DXCC #${dxcc}`}
</Badge>
{mwBadge}
@@ -279,7 +334,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
<table className="border-separate" style={{ borderSpacing: 3 }}>
<thead>
<tr>
<th className="w-[26px]" />
<th className="w-[38px] min-w-[38px] max-w-[38px]" />
{cols.map((b) => (
<th
key={b.tag}
@@ -294,13 +349,29 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
</tr>
</thead>
<tbody>
{CLASSES.map((cls) => {
const classCurrent = classMatchesMode(cls, currentMode);
{CLASSES.map((clsBase) => {
const cls = clsBase === 'DIG' ? digRow : clsBase;
// On a specific digital mode the "you are here" mark has to be that
// mode, not any digital one — otherwise every FT4 entry lights the
// FT8 row it happens to be cycled to.
const classCurrent = cls === clsBase
? classMatchesMode(cls, currentMode)
: (currentMode || '').toUpperCase() === cls;
return (
<tr key={cls}>
<th
onClick={clsBase === 'DIG' && digModes.length ? cycleDig : undefined}
title={clsBase === 'DIG' && digModes.length ? t('bsg.digCycle') : undefined}
className={cn(
'font-mono text-[11px] font-semibold pr-1.5 text-right w-[26px]',
// Sized once for the LONGEST label the rotation can show,
// and pinned there: a column that grows when RTTY comes
// round shifts every band beneath it, and the eye reads
// that as the matrix moving rather than the row changing.
'font-mono font-semibold pr-1.5 text-right w-[38px] min-w-[38px] max-w-[38px] overflow-hidden',
// Beyond four characters (PSK31, MSK144) the type gives way
// instead of the column.
cls.length > 4 ? 'text-[9px]' : 'text-[11px]',
clsBase === 'DIG' && digModes.length ? 'cursor-pointer hover:text-foreground' : '',
classCurrent ? 'text-primary font-extrabold' : 'text-muted-foreground',
)}
>
@@ -308,21 +379,29 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
</th>
{cols.map((b) => {
const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
// The same cell's other answer: worked with THIS callsign
// here. The status above is the entity's, and a confirmed
// entity outranks a worked call — so chasing a DXpedition,
// the cell could say "confirmed" about a contact made years
// ago and nothing about the one made this morning.
const mine = callMap.get(`${b.tag}|${cls}`) ?? '';
const isCurrent = hasCall && b.tag === currentBand && classCurrent;
return (
<td
key={b.tag}
title={cellTitle(t, b.tag, cls, st, isCurrent) + (st ? ' — ' + t('mx.tipClick') : '')}
title={cellTitle(t, b.tag, cls, st, isCurrent, mine) + (st ? ' — ' + t('mx.tipClick') : '')}
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
className={cn(
'w-[28px] h-[24px] rounded transition-colors p-0',
'relative 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-mx-cur ring-inset',
)}
/>
>
{mine ? <CallMark state={mine} /> : null}
</td>
);
})}
</tr>
@@ -337,11 +416,13 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
<span key={l.label} className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span
className={cn(
'inline-block size-3 rounded shrink-0',
'relative inline-block size-3 rounded shrink-0',
l.swatch,
l.ring && 'ring-2 ring-mx-cur ring-inset',
)}
/>
>
{l.mark ? <CallMark state={l.mark} /> : null}
</span>
{t(l.label)}
</span>
))}
@@ -46,6 +46,8 @@ const FIELDS: FieldDef[] = [
{ 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: 'clublog_rcvd', label: 'bulk.fClublogRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'clublog_rcvd_date', label: 'bulk.fClublogRcvdDate', 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' },
// HAMLOG.online: no promoted column, written into extras_json (see
@@ -54,12 +56,17 @@ const FIELDS: FieldDef[] = [
{ id: 'hamlog_sent_date', label: 'bulk.fHamlogSentDate', group: 'QSL / upload', kind: 'date' },
{ id: 'hamlog_rcvd', label: 'bulk.fHamlogRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'hamlog_rcvd_date', label: 'bulk.fHamlogRcvdDate', group: 'QSL / upload', kind: 'date' },
{ id: 'hamqth_sent', label: 'bulk.fHamqthSent', group: 'QSL / upload', kind: 'status' },
{ id: 'hamqth_sent_date', label: 'bulk.fHamqthSentDate', 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_dxcc', label: 'bulk.fMyDxcc', group: 'My station', kind: 'text' },
{ id: 'my_cq_zone', label: 'bulk.fMyCqZone', group: 'My station', kind: 'text' },
{ id: 'my_itu_zone', label: 'bulk.fMyItuZone', group: 'My station', kind: 'text' },
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
{ id: 'my_street', label: 'bulk.fMyStreet', group: 'My station', kind: 'text' },
+45 -9
View File
@@ -10,10 +10,12 @@
// new is being decoded on FT8/FT4/JS8 near here — not that the band is dead.
import { useEffect, useMemo, useState } from 'react';
import { Radar, Loader2, X } from 'lucide-react';
import { formatDistance } from '@/lib/units';
import { useI18n } from '@/lib/i18n';
import { chaseAllows } from '@/lib/spotDisplay';
import { markerColour } from '@/lib/spotMarkers';
import { cn } from '@/lib/utils';
import { GetChaseNewSpots } from '../../wailsjs/go/main/App';
import { GetChaseNewSpots, GetPSKReporterStatus } from '../../wailsjs/go/main/App';
export interface ChaseNewSpot {
call: string;
@@ -59,6 +61,10 @@ const CATEGORIES: Array<{ key: Category; labelKey: string; colour: string }> = [
];
// categoryOf is the single thing a row says about a station.
//
// A category the operator does not chase is not a category here either: with
// prefixes and squares switched off this panel showed rows whose only reason
// for being listed had been withdrawn everywhere else.
function categoryOf(s: ChaseNewSpot): Category | null {
switch (s.status) {
case 'new': return 'dxcc';
@@ -67,22 +73,32 @@ function categoryOf(s: ChaseNewSpot): Category | null {
case 'new-mode': return 'mode';
case 'new-slot': return 'slot';
}
if (s.new_pfx) return 'pfx';
if (s.new_grid) return 'grid';
if (s.new_pfx && chaseAllows('pfx')) return 'pfx';
if (s.new_grid && chaseAllows('grid')) return 'grid';
return null;
}
const FILTER_KEY = 'opslog.chaseNewFilters';
function allowedCategories(): Category[] {
return CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => c.key);
}
function loadFilters(): Set<Category> {
const allowed = allowedCategories();
try {
const raw = localStorage.getItem(FILTER_KEY);
if (raw) {
const list = JSON.parse(raw) as Category[];
if (Array.isArray(list)) return new Set(list);
// A stored set holding NONE of the categories on offer hides the whole
// panel, for ever, with nothing to say why — and that is exactly what a
// preference written by an older build does once a category is renamed.
// Treated as "no preference": a panel that shows nothing at every launch
// is never what was meant, and the chips are one click away.
if (Array.isArray(list) && list.some((k) => allowed.includes(k))) return new Set(list);
}
} catch { /* a corrupt preference is not worth a broken panel */ }
return new Set(CATEGORIES.map((c) => c.key));
return new Set(allowed);
}
export function ChaseNewPanel({ onPick, onClose }: Props) {
@@ -90,6 +106,10 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
const [spots, setSpots] = useState<ChaseNewSpot[]>([]);
const [loaded, setLoaded] = useState(false);
const [on, setOn] = useState<Set<Category>>(loadFilters);
// The FEED, not the list: connected or not, how many reports it has taken,
// and what it is filtered on. Without it an empty panel says nothing about
// whether anything is arriving at all — which is the first question.
const [feed, setFeed] = useState<any>(null);
// Polled rather than pushed: the feed can deliver several a second under an
// opening, and an event per row would be a redraw per row for a list nobody
@@ -100,6 +120,8 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
try {
const r = ((await GetChaseNewSpots()) ?? []) as ChaseNewSpot[];
if (alive) { setSpots(r); setLoaded(true); }
const st = await GetPSKReporterStatus();
if (alive) setFeed(st);
} catch { /* the feed may not be up yet */ }
};
tick();
@@ -131,7 +153,7 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
{/* Filters, in the same order and colours as the badges they hide. */}
<div className="flex flex-1 flex-wrap items-center gap-1">
{CATEGORIES.map((c) => (
{CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => (
<button
key={c.key}
type="button"
@@ -148,8 +170,13 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
))}
</div>
{/* Both numbers: what is on screen, and what was heard. They differ
exactly when a category is switched off, which is the one case an
operator reads this panel as broken. */}
<span className="shrink-0 text-[10px] text-muted-foreground">
{loaded ? t('chn.count', { n: shown.length }) : ''}
{loaded ? (shown.length === spots.length
? t('chn.count', { n: spots.length })
: t('chn.countOf', { n: shown.length, total: spots.length })) : ''}
</span>
{onClose && (
<button
@@ -186,7 +213,7 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
title={[
s.country,
s.grid,
s.dist_km ? `${s.dist_km} km` : '',
s.dist_km ? formatDistance(s.dist_km) : '',
s.freq_hz ? `${(s.freq_hz / 1000).toFixed(1)} kHz` : '',
].filter(Boolean).join(' · ')}
>
@@ -212,7 +239,16 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
)}
</div>
<p className="border-t border-border px-2 py-1 text-[10px] text-muted-foreground">{t('chn.digitalOnly')}</p>
{/* The radius comes from the FEED, not from a sentence: it was written
into this line as "~300 km" and stayed 300 while the setting said
1000, which is the panel telling the operator their change did not
take when it had. */}
<p className="border-t border-border px-2 py-1 text-[10px] text-muted-foreground">
{t('chn.heardWithin', { km: feed?.near_km || 300 })}
{feed && (feed.running
? <span className="text-success"> · {t('chn.feedOn', { n: feed.received ?? 0, sq: feed.squares ?? 0 })}</span>
: <span className="text-warning"> · {feed.last_err ? t('chn.feedErr', { e: feed.last_err }) : t('chn.feedOff')}</span>)}
</p>
</div>
);
}
+71 -19
View File
@@ -13,8 +13,9 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
import { markerColour } from '@/lib/spotMarkers';
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
import { applySpotDisplay, chasePota, chaseSota, readSpotDisplayOptions } from '@/lib/spotDisplay';
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
import { distanceUnit, distanceValue, subscribeDistanceUnit } from '@/lib/units';
import { useI18n } from '@/lib/i18n';
type TFn = (key: string, vars?: Record<string, string | number>) => string;
@@ -45,6 +46,7 @@ export type ClusterSpot = {
raw: string;
repeats?: number;
pota_ref?: string;
sota_ref?: string;
pota_name?: string;
};
@@ -63,6 +65,9 @@ export type SpotStatusEntry = {
state?: string;
new_pota?: boolean;
new_pfx?: boolean;
unconf_status?: boolean;
unconf_pfx?: boolean;
unconf_cty?: boolean;
pfx?: string;
// lotw: the DX uploads to LoTW, per ARRL user list. Inert until downloaded.
lotw?: boolean;
@@ -82,6 +87,11 @@ type Props = {
// stray click while looking took the operator off the station they were
// working. Looking and going are now two different gestures.
onSpotSelect?: (s: ClusterSpot) => void;
// Anything the caller wants on the LEFT of the toolbar — the pane's title, its
// live count, its Filters button. Docked in a pane, the title used to sit on a
// row of its own above this one, so the cluster ate two lines of a short pane
// where Recent QSOs beside it ate one. Reported by VK4DX.
headerLeft?: React.ReactNode;
};
const COL_STATE_KEY = 'hamlog.clusterColState.v1';
@@ -141,6 +151,12 @@ function statusFor(p: any): SpotStatusEntry | undefined {
// 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
// NEW SLOT gets its own hue. It shared the amber NEW family while the NEW PFX
// marker sits in caution yellow — two different facts, two near-identical
// colours in the same cell. Sky cyan, the exact colour the panadapter palette
// ships for new-slot (#5AC8FA), so the two views tell one story — and clearly
// apart from the POTA green the first attempt (aqua) sat next to.
const NEWSLOT = '#5AC8FA';
const WKD = 'var(--info)'; // blue: this callsign is already in the log
// FILLING the cell that carries the fact, rather than only tinting its text.
@@ -183,9 +199,10 @@ function statusColor(s: SpotStatusEntry | undefined): string | null {
case 'new-band-mode':
case 'new-band':
case 'new-mode':
case 'new-slot':
case 'new-call':
return NEW;
case 'new-slot':
return NEWSLOT;
default:
return s?.worked_call ? WKD : null;
}
@@ -290,13 +307,15 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
: s?.status === 'new-slot' ? t('clg2.newSlot')
: s?.status === 'new-call' ? t('clg2.newCall')
: t('clg2.wkdCall');
parts.push({ text: label, color: main });
// Dimmed when the need is only a missing confirmation — the grid's
// own convention, now spoken by every category.
parts.push(s?.unconf_status ? { text: label, color: main, dim: true } : { 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_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county'), dim: !!s?.unconf_cty });
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 (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx'), dim: !!s?.unconf_pfx });
// Worked-but-unconfirmed is a QSL to chase, not a QSO to make. Same hue
// held back, so it reads as "less" of the same thing rather than a
// different fact — and the label says which.
@@ -329,13 +348,26 @@ 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',
headerName: t('clg2.c.pota'), width: 92, cellClass: 'font-mono',
// Through a valueGetter so the chase switch empties the column live.
valueGetter: (p: any) => (chasePota() ? p.data?.pota_ref ?? '' : ''),
defaultVisible: true,
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),
},
{
// SOTA sits next to POTA and reads the same way. The reference comes from the
// spot's comment, so it is present on the SOTA feeds and empty elsewhere —
// which is why the column is off by default rather than an empty column for
// everyone who does not watch summits.
group: 'Spot', label: t('clg2.c.sota'), colId: 'sota',
headerName: t('clg2.c.sota'), width: 100, cellClass: 'font-mono',
valueGetter: (p: any) => (chaseSota() ? p.data?.sota_ref ?? '' : ''),
defaultVisible: false,
cellStyle: () => ({ color: 'var(--success)' }) as any,
},
{
group: 'Spot', label: t('clg2.c.freq'), colId: 'freq',
headerName: t('clg2.c.freq'), field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
@@ -432,8 +464,13 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
},
{
group: 'Geo', label: t('clg2.c.distance_km'), colId: 'distance_km',
headerName: t('clg2.h.distance_km'), field: 'distance_km' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
valueFormatter: (p) => p.value ? String(p.value) : '',
// The header carries the unit, so the cells stay bare numbers and the
// column still sorts on the km the backend sent — converting the VALUE
// would sort miles as if they were kilometres either way, but it would
// also round twice.
headerName: t('clg2.h.distance_km') + ' (' + distanceUnit() + ')',
field: 'distance_km' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono',
valueFormatter: (p) => p.value ? String(distanceValue(p.value)) : '',
comparator: (a, b) => (a ?? 0) - (b ?? 0),
},
{
@@ -506,13 +543,15 @@ const GROUP_ORDER = ['Spot', 'Geo'];
const CLG_GRP_KEYS: Record<string, string> = { Spot: 'clg2.grpSpot', Geo: 'clg2.grpGeo' };
const groupLabel = (t: TFn, g: string): string => t(CLG_GRP_KEYS[g] ?? g);
export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect }: Props) {
export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect, headerLeft }: Props) {
const { t } = useI18n();
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
// Localized column catalog — rebuilt when the language changes.
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
const [distUnit, setDistUnit] = useState(distanceUnit);
useEffect(() => subscribeDistanceUnit(() => setDistUnit(distanceUnit())), []);
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t, distUnit]);
// 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
@@ -603,16 +642,27 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect }: Pro
const [held, setHeld] = useState<ClusterSpot[] | null>(null);
const shown = held ?? rows;
// How many arrived since the freeze. Counted by finding the frozen top row in
// the live list rather than by comparing lengths: the list is a ring buffer,
// so once it is full the length stops growing and a length comparison would
// report nothing new for the rest of the evening.
const spotID = (r: ClusterSpot) => `${(r as any).received_at}-${r.dx_call}-${(r as any).source_id}`;
// How many arrived since the freeze — counted by TIME, not by finding the
// frozen top row again.
//
// Looking for that row was wrong in the ordinary case: a station spotted again
// REPLACES its row (that is the de-dupe), so the row we froze on disappears
// from the live list the moment somebody re-spots it — and the count fell
// through to "everything is new", jumping from 4 to the buffer cap. Reported
// as "it shows 4, 5 new spots and then 500 all at once".
//
// A timestamp survives both the replacement and the ring buffer, which was the
// reason the length was not used either.
const spotTime = (r: ClusterSpot) => Date.parse(String((r as any).received_at ?? '')) || 0;
const waiting = useMemo(() => {
if (!held || held.length === 0) return 0;
const top = spotID(held[0]);
const i = rows.findIndex((r) => spotID(r) === top);
return i < 0 ? rows.length : i; // fell out of the buffer: everything is new
const since = spotTime(held[0]);
if (!since) return 0; // no usable timestamp — say nothing rather than a number
let n = 0;
for (const r of rows) {
if (spotTime(r) > since) n++;
}
return n;
}, [held, rows]);
const onBodyScroll = (e: { top: number }) => {
@@ -670,7 +720,9 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect }: Pro
return (
<>
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
<div className="flex items-center gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
{headerLeft}
<div className="flex-1" />
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
title={t('clg2.clearFiltersTitle')}>
<FilterX className="size-3.5" /> {t('clg2.clearFilters')}
@@ -0,0 +1,240 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { RefreshCw, Star, ExternalLink } from 'lucide-react';
import { GetDXpeditions, GetDXWorldNews, RefreshDXpeditions, WatchlistEntries, WatchlistAdd } from '../../wailsjs/go/main/App';
import { BrowserOpenURL, EventsOn } from '../../wailsjs/runtime/runtime';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
// DXpeditions — the two feeds the DX world announces itself on, side by side.
//
// Left: NG3K's ADXO, the structured announcements, each judged against THIS log
// so the list reads as "what I still need" rather than "what is on". Right:
// DX-World's headlines, whose callsigns are mined out of the title so they can
// be watched with the same one click.
type DXped = {
dxcc: string; callsign: string; calls?: string[];
start_date: string; end_date: string;
bands?: string[]; modes?: string[];
qsl: string; operators: string; source: string; link: string;
status: string; // active | upcoming
status_chase: string; // new | new-band-mode | new-band | new-mode | new-slot | worked | ''
unconfirmed?: boolean;
};
type News = {
title: string; link: string; pub_date: string; excerpt: string;
creator: string; image_url: string; tag: string; calls?: string[];
};
// One badge per expedition, the strongest verdict winning — the same palette
// and the same words the cluster uses, so the two views teach one vocabulary.
const CHASE_BADGE: Record<string, { label: string; colour: string }> = {
'new': { label: 'clg2.newDxcc', colour: 'var(--danger)' },
'new-band-mode': { label: 'clg2.newBandMode', colour: 'var(--danger)' },
'new-band': { label: 'clg2.newBand', colour: 'var(--warning)' },
'new-mode': { label: 'clg2.newMode', colour: 'var(--caution)' },
'new-slot': { label: 'clg2.newSlot', colour: '#5AC8FA' },
'worked': { label: 'wl.worked', colour: 'var(--info)' },
};
export function DXpeditionsPanel() {
const { t } = useI18n();
const [peds, setPeds] = useState<DXped[]>([]);
const [news, setNews] = useState<News[]>([]);
const [watched, setWatched] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const [neededOnly, setNeededOnly] = useState(() => localStorage.getItem('opslog.dxpedNeeded') === '1');
const loadWatchlist = useCallback(async () => {
try {
const e: any[] = await WatchlistEntries();
setWatched(new Set((e ?? []).map((x) => String(x.callsign ?? '').toUpperCase())));
} catch { /* the list simply shows every call as unwatched */ }
}, []);
const load = useCallback(async () => {
setBusy(true);
setErr('');
const [p, n] = await Promise.allSettled([GetDXpeditions(), GetDXWorldNews()]);
if (p.status === 'fulfilled') setPeds((p.value as any) ?? []);
else setErr(String((p.reason as any)?.message ?? p.reason));
if (n.status === 'fulfilled') setNews((n.value as any) ?? []);
setBusy(false);
}, []);
useEffect(() => { void load(); void loadWatchlist(); }, [load, loadWatchlist]);
useEffect(() => EventsOn('watchlist:changed', () => { void loadWatchlist(); }), [loadWatchlist]);
const refresh = async () => { await RefreshDXpeditions(); await load(); };
const addAll = async (calls: string[]) => {
for (const c of calls) {
if (!watched.has(c.toUpperCase())) {
try { await WatchlistAdd(c, false); } catch { /* reported by the notice */ }
}
}
await loadWatchlist();
};
const shown = useMemo(
() => (neededOnly ? peds.filter((p) => p.status_chase && p.status_chase !== 'worked') : peds),
[peds, neededOnly]);
// A row's calls: the mined ones, else whatever the announcement called it.
const callsOf = (p: DXped) => (p.calls?.length ? p.calls : p.callsign ? [p.callsign] : []);
return (
<div className="flex h-full min-h-0 gap-3">
{/* ── Announcements (ADXO) ── */}
<section className="flex flex-col min-h-0 flex-[3] rounded-lg border border-border bg-card overflow-hidden">
<header className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
<span className="text-sm font-semibold">{t('dxp.announced')}</span>
<span className="text-[11px] text-muted-foreground">{t('dxp.source', { name: 'NG3K ADXO' })}</span>
<label className="ml-auto flex items-center gap-1.5 text-[11px] cursor-pointer text-muted-foreground hover:text-foreground">
<input type="checkbox" checked={neededOnly}
onChange={(e) => { setNeededOnly(e.target.checked); localStorage.setItem('opslog.dxpedNeeded', e.target.checked ? '1' : '0'); }} />
{t('dxp.neededOnly')}
</label>
<Button variant="outline" size="sm" onClick={refresh} disabled={busy}>
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> {t('dxp.refresh')}
</Button>
</header>
{err && <div className="px-3 py-2 text-xs text-danger shrink-0">{err}</div>}
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
{shown.length === 0 && !busy && (
<p className="text-xs text-muted-foreground text-center py-6">{t('dxp.none')}</p>
)}
{shown.map((p, i) => {
const calls = callsOf(p);
const badge = CHASE_BADGE[p.status_chase];
const allWatched = calls.length > 0 && calls.every((c) => watched.has(c.toUpperCase()));
return (
<article key={`${p.callsign}-${p.start_date}-${i}`}
className={cn('rounded-md border p-2 text-xs',
p.status === 'active' ? 'border-success/40 bg-success/5' : 'border-border bg-muted/20')}>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono font-bold text-sm text-info">{p.callsign || '—'}</span>
<span className="font-medium">{p.dxcc}</span>
{p.status === 'active' && (
<span className="px-1 py-px rounded text-[10px] font-bold uppercase bg-success-muted text-success-muted-foreground">
{t('dxp.onAir')}
</span>
)}
{badge && (
<span className="px-1 py-px rounded text-[10px] font-bold uppercase tracking-wide border"
title={p.unconfirmed ? t('dec.unconfTip') : undefined}
style={p.unconfirmed
? { color: badge.colour, borderColor: badge.colour, borderStyle: 'dashed', opacity: 0.6 }
: { color: badge.colour, borderColor: badge.colour }}>
{t(badge.label)}
</span>
)}
<span className="ml-auto text-[11px] text-muted-foreground whitespace-nowrap">
{p.start_date} {p.end_date}
</span>
</div>
<div className="mt-1 flex items-center gap-2 flex-wrap text-[11px] text-muted-foreground">
{!!p.bands?.length && <span>{p.bands.join(' · ')}</span>}
{!!p.modes?.length && <span className="text-foreground/70">{p.modes.join(' ')}</span>}
{p.qsl && <span>QSL: {p.qsl}</span>}
{p.source && <span>· {p.source}</span>}
</div>
{p.operators && <p className="mt-0.5 text-[11px] text-muted-foreground truncate">{p.operators}</p>}
<div className="mt-1.5 flex items-center gap-2">
<Button variant={allWatched ? 'ghost' : 'outline'} size="sm" className="h-6 text-[11px]"
disabled={calls.length === 0 || allWatched}
onClick={() => addAll(calls)}
title={t('dxp.watchTip')}>
<Star className={cn('size-3', allWatched && 'fill-current text-warning')} />
{allWatched ? t('dxp.watched') : t('dxp.watch')}
</Button>
{p.link && (
<button type="button" onClick={() => BrowserOpenURL(p.link)}
className="text-[11px] text-muted-foreground hover:text-foreground inline-flex items-center gap-1">
<ExternalLink className="size-3" /> {t('dxp.open')}
</button>
)}
</div>
</article>
);
})}
</div>
</section>
{/* ── News (DX-World) ── */}
<section className="flex flex-col min-h-0 flex-[2] rounded-lg border border-border bg-card overflow-hidden">
<header className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
<span className="text-sm font-semibold">{t('dxp.news')}</span>
<span className="text-[11px] text-muted-foreground">{t('dxp.source', { name: 'DX-World' })}</span>
</header>
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
{news.length === 0 && !busy && (
<p className="text-xs text-muted-foreground text-center py-6">{t('dxp.noNews')}</p>
)}
{news.map((n, i) => {
const newsCalls = n.calls ?? [];
const newsAllWatched = newsCalls.length > 0 && newsCalls.every((c) => watched.has(c.toUpperCase()));
return (
<article key={`${n.link}-${i}`} className="rounded-md border border-border bg-muted/20 p-2">
<div className="flex items-start gap-2">
{n.image_url && (
<img src={n.image_url} alt="" className="size-12 rounded object-cover shrink-0"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
{n.tag && (
<span className="px-1 py-px rounded text-[9px] font-bold uppercase bg-primary/15 text-primary">{n.tag}</span>
)}
<button type="button" onClick={() => n.link && BrowserOpenURL(n.link)}
className="text-xs font-medium text-left hover:underline">{n.title}</button>
</div>
<p className="mt-0.5 text-[11px] text-muted-foreground line-clamp-3">{n.excerpt}</p>
{/* The callsigns are shown, not clicked: watching is the same
one button as an announcement, so the gesture is learned
once for the whole tab. */}
<div className="mt-1 flex items-center gap-1.5 flex-wrap">
{n.pub_date && (
<span className="text-[10px] text-muted-foreground">
{new Date(n.pub_date).toLocaleDateString()}
</span>
)}
{(n.calls ?? []).map((c) => (
<span key={c} className={cn('font-mono text-[10px]',
watched.has(c.toUpperCase()) ? 'text-warning' : 'text-info')}>
{c}
</span>
))}
</div>
<div className="mt-1.5 flex items-center gap-2">
<Button variant={newsAllWatched ? 'ghost' : 'outline'} size="sm" className="h-6 text-[11px]"
disabled={newsCalls.length === 0 || newsAllWatched}
onClick={() => addAll(newsCalls)}
title={t('dxp.watchTip')}>
<Star className={cn('size-3', newsAllWatched && 'fill-current text-warning')} />
{newsAllWatched ? t('dxp.watched') : t('dxp.watch')}
</Button>
{n.link && (
<button type="button" onClick={() => BrowserOpenURL(n.link)}
className="text-[11px] text-muted-foreground hover:text-foreground inline-flex items-center gap-1">
<ExternalLink className="size-3" /> {t('dxp.open')}
</button>
)}
</div>
</div>
</div>
</article>
);
})}
</div>
</section>
</div>
);
}
+349 -53
View File
@@ -13,11 +13,16 @@
// come from the same resolver the cluster uses, so a call means the same thing in
// both panels rather than being judged twice by two rules.
import { useEffect, useMemo, useState } from 'react';
import { Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
import { AlertTriangle, Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { chaseAllows } from '@/lib/spotDisplay';
import { pathBetween } from '@/lib/maidenhead';
import { distanceValue, distanceUnit, subscribeDistanceUnit } from '@/lib/units';
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
import { writeUiPref } from '@/lib/uiPref';
import { SetAutoCallVisible } from '../../wailsjs/go/main/App';
import { decoderName } from '@/lib/decoderName';
export type Decode = {
call: string;
@@ -72,8 +77,14 @@ type StatusEntry = {
new_pota?: boolean;
new_pfx?: boolean;
new_grid?: boolean;
new_state?: boolean;
unconf_status?: boolean;
unconf_pfx?: boolean;
unconf_cty?: boolean;
unconf_state?: boolean;
// "new" = never worked, "unconf" = worked and awaiting a confirmation.
grid_state?: string;
state?: string;
grid?: string;
lotw?: boolean;
};
@@ -88,8 +99,17 @@ interface Props {
// receiver reported last, which is a coin toss — each pane needs its own.
txStates?: Record<string, TxMsg>;
spotStatus: Record<string, StatusEntry>;
// The band the RIG is on, when CAT is connected. Only ever compared with what
// the decoder announces — see the drift warning.
rigBand?: string;
onCall: (d: Decode) => void;
// A single click: take the station without transmitting — fill the entry, and
// point the panels at it. Absent, a click falls back to onCall.
onSelect?: (d: Decode) => void;
myCall?: string;
// The station's own square: distance is measured from it, and without one the
// column stays empty rather than guessing.
myGrid?: string;
// Drop every decode and transmit message held for this panel. The list is a
// live view, not data — clearing it costs nothing but the seconds until the
// next period lands.
@@ -105,6 +125,15 @@ interface Props {
// machine off is not something to go hunting through a settings tree for.
autoCallOn?: boolean;
onToggleAutoCall?: () => void;
// The engine's own account of what it is doing, straight from the backend.
autoCall?: { target: string; waiting: string; calls: number; max: number; misses: number; max_miss: number; stopped: boolean; reason: string };
// The chase list, here as well as in Preferences: naming the station you are
// waiting for is done WHILE watching the band, not in a settings tree.
autoCallOnly?: string;
onSetAutoCallOnly?: (list: string) => void;
// The watch list, as PATTERNS (VK9*, 3Y0J). A decode of one is worth saying
// so where the operator is reading the band, not only in the watchlist tab.
watchlist?: string[];
}
// The "new" categories, as toggle badges — the same idea and the same colours as
@@ -112,7 +141,10 @@ interface Props {
//
// All off means no filtering at all: this is a decode LOG first, and a panel
// that starts by hiding most of the band would be lying about what is on it.
type NewCat = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid' | 'pota' | 'cty';
type NewCat = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid' | 'pota' | 'cty' | 'state';
// The seven, in the order an operator reads them.
const CONTINENTS = ['AF', 'AN', 'AS', 'EU', 'NA', 'OC', 'SA'];
const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [
{ key: 'dxcc', label: 'dec.stNew', colour: 'var(--success)' },
@@ -123,6 +155,7 @@ const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [
{ key: 'grid', label: 'dec.bgGrid', colour: markerColour('new_grid') },
{ key: 'pfx', label: 'dec.bgPfx', colour: markerColour('new_pfx') },
{ key: 'cty', label: 'dec.bgCounty', colour: markerColour('new_county') },
{ key: 'state', label: 'dec.bgState', colour: markerColour('new_state') },
];
// catsOf lists everything a decode is new for. A station can be several at once
@@ -143,9 +176,25 @@ function catsOf(e: StatusEntry | undefined): Set<NewCat> {
if (e.new_grid) out.add('grid');
if (e.new_pfx) out.add('pfx');
if (e.new_county) out.add('cty');
if (e.new_state) out.add('state');
return out;
}
// isWatched applies the watch list's own rule — a trailing "*" is a prefix,
// anything else is the whole callsign — so a decode is judged here exactly as
// the backend judges a spot. Two rules for one list is how a badge and an alert
// start disagreeing about the same station.
function isWatched(call: string, patterns: string[] | undefined): boolean {
if (!patterns || patterns.length === 0 || !call) return false;
const c = call.toUpperCase();
for (const raw of patterns) {
const p = (raw ?? '').toUpperCase().trim();
if (!p) continue;
if (p.endsWith('*') ? c.startsWith(p.slice(0, -1)) : c === p) return true;
}
return false;
}
const CAT_KEY = 'opslog.decodeCats';
const SPLIT_KEY = 'opslog.decodeSplit';
const FILTER_KEY = 'opslog.decodeFilters';
@@ -227,9 +276,14 @@ const CELL_LAST = 'flex items-center min-w-0 px-2 gap-1 overflow-hidden';
// One declaration per column, in display order: the header, the widths and the
// resize handles all read from this, so a column cannot be resized in the header
// and stay the old width in the body.
type ColKey = 'time' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'country' | 'status';
type ColKey = 'time' | 'rx' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'dist' | 'state' | 'country' | 'status';
const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
{ key: 'time', tkey: 'dec.colTime', def: 64, min: 44 },
// WHICH RECEIVER heard it. Shown only while more than one is feeding, and
// that is the case it exists for: two decoders on one band send the same
// stations twice, each with its own SNR and DT, and a merged list gave no way
// at all to tell a second receiver from a duplicate.
{ key: 'rx', tkey: 'dec.colRx', def: 74, min: 44 },
{ key: 'snr', tkey: 'dec.colSnr', def: 50, min: 36 },
{ key: 'dt', tkey: 'dec.colDt', def: 44, min: 32 },
{ key: 'freq', tkey: 'dec.colFreq', def: 56, min: 40 },
@@ -241,9 +295,31 @@ const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
// The grid was carried and shown nowhere: it drives the NEW GRID badge, and
// an operator chasing squares could see the verdict but never the square.
{ key: 'grid', tkey: 'dec.colGrid', def: 62, min: 46 },
// For the WAS chasers: the badge carries the two letters, the name spells
// them out — "SD" alone is a quiz for a European.
// Next to the square it is computed from. A four-character grid is a square
// tens of kilometres wide, so this is rounded to whole units and never
// pretends to more: it answers "is that station across the pond or across the
// valley", which is what decides whether the report is worth anything.
{ key: 'dist', tkey: 'dec.colDist', def: 70, min: 46 },
{ key: 'state', tkey: 'dec.colState', def: 120, min: 56 },
{ key: 'country', tkey: 'dec.colCountry', def: 140, min: 70 },
{ key: 'status', tkey: 'dec.colStatus', def: 186, min: 80 },
];
const US_STATES: Record<string, string> = {
AL: 'Alabama', AK: 'Alaska', AZ: 'Arizona', AR: 'Arkansas', CA: 'California',
CO: 'Colorado', CT: 'Connecticut', DE: 'Delaware', FL: 'Florida', GA: 'Georgia',
HI: 'Hawaii', ID: 'Idaho', IL: 'Illinois', IN: 'Indiana', IA: 'Iowa',
KS: 'Kansas', KY: 'Kentucky', LA: 'Louisiana', ME: 'Maine', MD: 'Maryland',
MA: 'Massachusetts', MI: 'Michigan', MN: 'Minnesota', MS: 'Mississippi',
MO: 'Missouri', MT: 'Montana', NE: 'Nebraska', NV: 'Nevada', NH: 'New Hampshire',
NJ: 'New Jersey', NM: 'New Mexico', NY: 'New York', NC: 'North Carolina',
ND: 'North Dakota', OH: 'Ohio', OK: 'Oklahoma', OR: 'Oregon', PA: 'Pennsylvania',
RI: 'Rhode Island', SC: 'South Carolina', SD: 'South Dakota', TN: 'Tennessee',
TX: 'Texas', UT: 'Utah', VT: 'Vermont', VA: 'Virginia', WA: 'Washington',
WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming', DC: 'District of Columbia',
};
const COL_MAX = 600;
const COLW_KEY = 'opslog.decodeColWidths';
@@ -308,12 +384,15 @@ function ColResizer({ onResize, onReset }: { onResize: (dx: number) => void; onR
//
// Colours match the cluster list and the band map — the same fact must not be
// amber in one panel and green in the next.
const ENTITY_BADGE: Record<string, { label: string; cls: string }> = {
'new': { label: 'dec.stNew', cls: 'bg-success text-success-foreground' },
'new-band': { label: 'dec.stBand', cls: 'bg-warning text-warning-foreground' },
'new-mode': { label: 'dec.stMode', cls: 'bg-info text-info-foreground' },
'new-slot': { label: 'dec.stSlot', cls: 'bg-caution text-caution-foreground' },
'new-call': { label: 'dec.stCall', cls: 'bg-muted text-muted-foreground' },
const ENTITY_BADGE: Record<string, { label: string; cls: string; colour: string }> = {
// colour is the category's own hue, for the UNCONFIRMED rendering: the
// filled cls puts light text on a filled chip, and dimming that to a
// transparent background left light-on-nothing — an invisible badge.
'new': { label: 'dec.stNew', cls: 'bg-success text-success-foreground', colour: 'var(--success)' },
'new-band': { label: 'dec.stBand', cls: 'bg-warning text-warning-foreground', colour: 'var(--warning)' },
'new-mode': { label: 'dec.stMode', cls: 'bg-info text-info-foreground', colour: 'var(--info)' },
'new-slot': { label: 'dec.stSlot', cls: 'bg-caution text-caution-foreground', colour: 'var(--caution)' },
'new-call': { label: 'dec.stCall', cls: 'bg-muted text-muted-foreground', colour: 'var(--muted-foreground)' },
};
// entityBadgesFor turns a status into the badges that describe it.
@@ -324,7 +403,7 @@ const ENTITY_BADGE: Record<string, { label: string; cls: string }> = {
// passed the BAND and MODE filters and then showed no reason for being there,
// which is precisely what was reported. catsOf has always split the status into
// its two categories; this is the same split, on the screen.
function entityBadgesFor(status: string): { label: string; cls: string }[] {
function entityBadgesFor(status: string): { label: string; cls: string; colour: string }[] {
if (status === 'new-band-mode') {
return [ENTITY_BADGE['new-band'], ENTITY_BADGE['new-mode']];
}
@@ -343,6 +422,7 @@ function entityBadgesFor(status: string): { label: string; cls: string }[] {
const EXTRA_BADGES: { key: keyof StatusEntry; marker: SpotMarkerKey; label: string }[] = [
{ key: 'new_pota', marker: 'new_pota', label: 'dec.bgPota' },
{ key: 'new_grid', marker: 'new_grid', label: 'dec.bgGrid' },
{ key: 'new_state', marker: 'new_state', label: 'dec.bgState' },
{ key: 'new_pfx', marker: 'new_pfx', label: 'dec.bgPfx' },
{ key: 'new_county', marker: 'new_county', label: 'dec.bgCounty' },
];
@@ -376,16 +456,31 @@ function periodLabel(ms: number, trSec: number): string {
// read "CQ CQ PE1NAO JO32" — the badge and the message's own first word saying
// the same thing twice. Highlighting the word already in the line keeps the
// scannability and drops the stutter.
function renderMsg(msg: string, me: string, calling: string) {
function renderMsg(msg: string, me: string, calling: string, toMe: boolean) {
if (!msg) return null;
// THE WHOLE LINE, when it is addressed to YOU.
//
// Token colouring is for reading the band — it picks your call out of a wall
// of other people's traffic. A message sent TO you is a different question:
// not "is my call in there somewhere" but "what did he just send me", read
// from the far side of the shack, so the whole line carries the colour.
//
// Only that case. The station you are calling also transmits to everybody
// else — "LA8WRA LA1RAU/P +00" is your target reporting to another caller —
// and setting those lines in the same strong colour said you were in a QSO
// you were not in. Its callsign is picked out by the token pass below, which
// is what "he is on the air, working somebody else" should look like.
if (toMe) {
return <span className="font-bold text-success">{msg}</span>;
}
// Split on whitespace and colour the tokens that matter, rather than the
// whole line: an operator scanning a slot is looking for their own call in
// the first position (someone answering) and for the station being called.
const parts = msg.split(/(s+)/);
const parts = msg.split(/(\s+)/);
return (
<>
{parts.map((tok, i) => {
if (/^s+$/.test(tok)) return tok;
if (/^\s+$/.test(tok)) return tok;
const bare = tok.replace(/[<>]/g, '').toUpperCase();
if (i === 0 && /^CQ$/i.test(tok)) return <span key={i} className="font-bold text-success">{tok.toUpperCase()}</span>;
if (me && bare === me) return <span key={i} className="font-bold text-success">{tok}</span>;
@@ -404,7 +499,7 @@ function renderMsg(msg: string, me: string, calling: string) {
//
// It is the one moving thing on the panel, and it answers the question an
// operator actually has between overs: how long until the next batch.
function PeriodClock({ trSec, mode }: { trSec: number; mode?: string }) {
function PeriodClock({ trSec, mode, tx }: { trSec: number; mode?: string; tx?: boolean }) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
// 100 ms: smooth enough for a bar that fills in three and three quarter
@@ -420,19 +515,26 @@ function PeriodClock({ trSec, mode }: { trSec: number; mode?: string }) {
// The last fifth of a slot is when a decode is imminent and an operator
// deciding whether to answer has run out of time to think.
const closing = left <= trSec / 5;
// TRANSMITTING outranks both. The bar is the one thing on this screen that
// moves continuously, so it is what the eye is already on — and "am I on the
// air" is the state worth reading from across the room. Red, and it stays red
// for the whole over rather than turning amber near the end of it.
const tone = tx ? 'danger' : closing ? 'warning' : '';
return (
<span className="flex items-center gap-2 shrink-0" title={mode ? `${mode} · ${trSec}s` : `${trSec}s`}>
<Timer className={cn('size-4', closing ? 'text-warning' : 'text-muted-foreground')} />
<Timer className={cn('size-4',
tone === 'danger' ? 'text-danger' : tone === 'warning' ? 'text-warning' : 'text-muted-foreground')} />
<span className="relative h-1.5 w-24 rounded-full bg-muted overflow-hidden">
<span
className={cn('absolute inset-y-0 left-0 rounded-full transition-[width] duration-100 ease-linear',
closing ? 'bg-warning' : 'bg-primary')}
tone === 'danger' ? 'bg-danger' : tone === 'warning' ? 'bg-warning' : 'bg-primary')}
style={{ width: `${pct}%` }}
/>
</span>
<span className={cn('font-mono text-sm tabular-nums w-10 text-right',
closing ? 'text-warning font-semibold' : 'text-muted-foreground')}>
tone === 'danger' ? 'text-danger font-semibold'
: tone === 'warning' ? 'text-warning font-semibold' : 'text-muted-foreground')}>
{left.toFixed(1)}
</span>
<span className="text-xs text-muted-foreground">
@@ -498,21 +600,29 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
// the same way it was grouped.
tr: trSeconds(g.decodes[0]?.mode ?? g.tx[0]?.mode, g.decodes[0]?.tr_period),
tx: g.tx,
// Strongest first inside a period: the eye should land on what is
// workable, and time within a slot means nothing — they were all
// transmitting simultaneously.
decodes: g.decodes.sort((x, y) => y.snr - x.snr),
// ARRIVAL order inside a period, per the operator: it mirrors the
// decoder's own window line for line, which makes the two screens
// comparable at a glance — the strongest-first sort scrambled that
// correspondence, and SNR is right there in its column anyway.
decodes: g.decodes,
}));
}
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, onCall, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall }: Props) {
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, myGrid, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: Props) {
const { t } = useI18n();
// Column widths, dragged in the header and shared by every row. Persisted
// through writeUiPref (not raw localStorage) so the layout travels with data/
// like every other portable preference.
const [colw, setColw] = useState<ColWidths>(loadWidths);
const template = useMemo(() => COLS.map((c) => `${colw[c.key]}px`).join(' '), [colw]);
const tableW = useMemo(() => COLS.reduce((s, c) => s + colw[c.key], 0), [colw]);
// The chase list as TYPED. Re-seeded whenever the stored value changes —
// from Preferences, or from another window — but never while the box has the
// focus, or a status arriving mid-word would rewrite what is being typed.
const [onlyText, setOnlyText] = useState(autoCallOnly ?? '');
useEffect(() => {
const el = document.activeElement as HTMLElement | null;
if (el && el.tagName === 'INPUT' && el.getAttribute('placeholder') === t('dec.chasePh')) return;
setOnlyText(autoCallOnly ?? '');
}, [autoCallOnly, t]);
const setColWidth = (key: ColKey, px: number) => {
const col = COLS.find((c) => c.key === key)!;
const w = Math.min(COL_MAX, Math.max(col.min, Math.round(px)));
@@ -563,6 +673,21 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
// The mode currently on the air, for the slot clock. The newest decode knows
// best; between overs the transmit state still does.
// A decoder that has lost its CAT link keeps announcing the last dial
// frequency it knew, and every decode after that carries a stale band. Nothing
// downstream can tell: the entity verdicts, the band filter and the FT map all
// believe what the decoder said, and an operator ends up reading NEW BAND for a
// band they are not on. (Seen for real: MSHV lost CAT, kept saying 80 m, and
// Korea showed as a new band because on 80 m it would have been.)
//
// Said, not decided. Using the rig's band instead would be wrong for anyone
// decoding a second receiver on another band, and a warning costs that setup
// nothing but a line it can read past.
const decoderBand = decodes.length ? (decodes[decodes.length - 1].band ?? '') : '';
const bandDrift = !!rigBand && !!decoderBand
&& rigBand.toLowerCase() !== decoderBand.toLowerCase();
const driftInstance = decodes.length ? (decodes[decodes.length - 1].instance ?? '') : '';
const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode;
const liveTr = trSeconds(liveMode, decodes.length ? decodes[decodes.length - 1].tr_period : undefined);
@@ -574,6 +699,8 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
// opens with our callsign, sometimes bracketed when the sender compressed a
// non-standard call.
const me = (myCall ?? '').toUpperCase();
const [, bumpUnit] = useState(0);
useEffect(() => subscribeDistanceUnit(() => bumpUnit((n) => n + 1)), []);
const calling = (txState?.dx_call ?? '').toUpperCase();
const answersMe = (msg?: string): boolean => {
if (!me || !msg) return false;
@@ -599,27 +726,31 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
// dropdowns were pure furniture for most operators; they appear the day a
// second instance puts a second band on the link, which is the only day they
// mean anything.
const { bands, modes, conts, instances } = useMemo(() => {
const b = new Set<string>(), m = new Set<string>(), c = new Set<string>(), i = new Set<string>();
const { bands, modes, instances } = useMemo(() => {
const b = new Set<string>(), m = new Set<string>(), i = new Set<string>();
for (const d of decodes) {
if (d.band) b.add(d.band);
if (d.mode) m.add(d.mode);
if (d.instance) i.add(d.instance);
const ct = statusOf(d)?.continent;
if (ct) c.add(ct);
}
return { bands: [...b].sort(), modes: [...m].sort(), conts: [...c].sort(), instances: [...i].sort() };
return { bands: [...b].sort(), modes: [...m].sort(), instances: [...i].sort() };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [decodes, spotStatus]);
// The chips are the continents on the feed — a selector with nothing to choose
// is furniture — PLUS anything currently selected. Without that second half a
// filter can strand itself: pick AF, the last African station stops decoding,
// and the list empties with no chip left to switch it back off.
const contChips = useMemo(
() => [...new Set([...conts, ...contList])].sort(),
[conts, contList],
);
// The receiver column is dead weight with one decoder — which is nearly
// everybody — so it is not there at all until a second one starts feeding.
const cols = useMemo(() => COLS.filter((c) => c.key !== 'rx' || (instances.length > 1 && !splitByInstance)),
[instances.length, splitByInstance]);
const template = useMemo(() => cols.map((c) => `${colw[c.key]}px`).join(' '), [cols, colw]);
const tableW = useMemo(() => cols.reduce((s, c) => s + colw[c.key], 0), [cols, colw]);
// All seven, always, in their usual order.
//
// The chips used to be built from the continents ON the feed, which read as a
// list that reshuffled itself every period: a chip appeared when the first
// Asian station decoded and moved everything sideways under the pointer. A
// fixed row can be aimed at — you learn where OC is and it stays there — and
// it also says what the filter can do before anything has been heard.
const contChips = CONTINENTS;
const filtered = useMemo(() => {
const q = search.trim().toUpperCase();
@@ -645,6 +776,22 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [decodes, spotStatus, cqOnly, lotwOnly, cats, bandSel, modeSel, contSel, minSnr, search]);
// What this panel is SHOWING, published to the auto-call engine.
//
// The filters are the operator's control over the transmitter as well as
// over the list: a station filtered off the screen is not called. The panel
// sends the callsigns rather than the filter settings, so there is one
// definition of "shown" and not two — the engine cannot disagree with what
// is in front of the operator.
useEffect(() => {
const calls = [...new Set(filtered.map((d) => (d.call ?? '').toUpperCase()).filter(Boolean))];
SetAutoCallVisible(calls, true).catch(() => {});
}, [filtered]);
// Closed, it publishes nothing: filters that are not on the screen cannot
// silence the engine behind the operator's back.
useEffect(() => () => { SetAutoCallVisible([], false).catch(() => {}); }, []);
// Group into periods, newest first, and drop the operator's transmissions into
// the slot they went out in.
@@ -660,7 +807,8 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
}
return instances.map((inst) => ({
key: inst,
label: inst,
// What the program is called, not the id it announces — see decoderName.
label: decoderName(inst),
tx: txStates?.[inst],
periods: buildPeriods(
filtered.filter((d) => (d.instance ?? '') === inst),
@@ -700,7 +848,23 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
{/* The slot clock. Taken from the newest decode's mode, falling back to
what the transmit state reports, so it is right the moment anything
is heard and keeps running when the band goes quiet. */}
<PeriodClock trSec={liveTr} mode={liveMode} />
{/* Any receiver on the air colours it: with two decoders the shared
txState is whichever reported last, and "somebody here is
transmitting" is what the bar has to say. */}
<PeriodClock trSec={liveTr} mode={liveMode}
tx={!!txState?.transmitting || Object.values(txStates ?? {}).some((s) => s?.transmitting)} />
{bandDrift && (
<span
title={t('dec.bandDriftTip')}
className="inline-flex items-center gap-1 rounded-full border border-warning px-2 py-0.5 text-[11px] font-semibold text-warning">
<AlertTriangle className="size-3.5" />
{t('dec.bandDrift', {
app: decoderName(driftInstance) || t('dec.bandDriftApp'),
dec: decoderBand.toUpperCase(),
rig: (rigBand ?? '').toUpperCase(),
})}
</span>
)}
<span className="w-px h-5 bg-border/60 mx-1" />
<button type="button" className={chip(cqOnly, 'success')} onClick={() => setCqOnly(!cqOnly)}>
@@ -713,7 +877,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
{/* Per-category badges, in the colours of the flags they select the
same vocabulary as the Chase New panel. */}
<span className="flex items-center gap-1 pl-1 border-l border-border/60 ml-1" title={t('dec.catsHint')}>
{NEW_CATS.map((c) => {
{NEW_CATS.filter((c) => chaseAllows(c.key)).map((c) => {
const on = cats.has(c.key);
return (
<button
@@ -827,6 +991,88 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
filter. Halt goes LAST it is the one that must be findable without
reading, and the end of the row is the one position that never moves
as filters come and go. */}
{/* Auto-call. Deliberately next to Halt: the two belong together, and
what it is doing right now which station, how many calls of how
many is on the button itself, because a thing that keys the
transmitter must never be a switch with no readout. */}
{onToggleAutoCall && (
<button
type="button"
onClick={onToggleAutoCall}
title={autoCall?.stopped ? t('dec.autoStoppedTip') : t('dec.autoCallTip')}
className={cn('h-8 px-2.5 rounded-lg text-sm inline-flex items-center gap-1.5 border font-medium',
!autoCallOn
? 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
: autoCall?.stopped
? 'border-warning bg-warning text-warning-foreground'
: 'border-success bg-success text-success-foreground')}
>
<Bot className="size-3.5" />
{t('dec.autoCall')}
</button>
)}
{/* WHAT IT IS DOING, BESIDE THE SWITCH RATHER THAN ON IT.
A thing that keys the transmitter must never be a switch with no
readout but the readout was crammed inside the button as
"Auto D2ACE 1/7 ·1/3", where the two counters read as one number and
the whole control changed width every period. Out here each figure
gets a label, and the station being called is the biggest thing on
the row. */}
{autoCallOn && autoCall?.target && (
<span className="h-8 inline-flex items-center gap-2 rounded-lg border border-success/50 bg-success/10 px-2"
title={t('dec.autoTargetTip', { call: autoCall.target })}>
<span className="font-mono text-sm font-bold text-success">{autoCall.target}</span>
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<span className="uppercase tracking-wide">{t('dec.autoCalls')}</span>
<span className="font-mono tabular-nums text-foreground">{autoCall.calls}/{autoCall.max}</span>
</span>
{/* Only once there is one to report: a zero that is always there is
a figure nobody reads, and it was the half of the pair that got
mistaken for the call count. */}
{autoCall.misses > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<span className="uppercase tracking-wide">{t('dec.autoMisses')}</span>
<span className={cn('font-mono tabular-nums',
autoCall.misses + 1 >= autoCall.max_miss ? 'text-warning' : 'text-foreground')}>
{autoCall.misses}/{autoCall.max_miss}
</span>
</span>
)}
</span>
)}
{/* Wanted, decoded, and in a QSO with somebody else. Holding fire looks
exactly like having nothing to do, and the operator had no way to
tell them apart. */}
{autoCallOn && !autoCall?.target && autoCall?.waiting && (
<span className="h-8 inline-flex items-center gap-1.5 rounded-lg border border-warning/50 bg-warning/10 px-2 animate-pulse"
title={t('dec.autoWaitTip', { call: autoCall.waiting })}>
<span className="text-sm"></span>
<span className="font-mono text-sm font-bold text-warning">{autoCall.waiting}</span>
<span className="text-[11px] uppercase tracking-wide text-muted-foreground">{t('dec.autoWaiting')}</span>
</span>
)}
{/* The chase list. Raw text while typing, committed on blur or Enter:
the stored value is upper-cased and trimmed, and binding the box to
that makes the space bar look dead in a field whose whole purpose
is a list separated by spaces. */}
{onSetAutoCallOnly && (
<input
type="text"
value={onlyText}
onChange={(e) => setOnlyText(e.target.value)}
onBlur={() => { if (onlyText.toUpperCase() !== (autoCallOnly ?? '')) onSetAutoCallOnly(onlyText); }}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
if (e.key === 'Escape') setOnlyText(autoCallOnly ?? '');
}}
placeholder={t('dec.chasePh')}
title={t('dec.chaseTip')}
className={cn('h-8 w-44 rounded-lg border px-2 text-sm font-mono uppercase bg-background',
(autoCallOnly ?? '').trim()
? 'border-primary text-foreground'
: 'border-border text-muted-foreground')}
/>
)}
{onHalt && (
<button
type="button"
@@ -893,7 +1139,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
<span className="flex-1" />
{txState.band && <span className="text-xs text-muted-foreground shrink-0">{txState.band}</span>}
{txState.instance && instances.length > 1 && (
<span className="text-xs text-muted-foreground shrink-0">{txState.instance}</span>
<span className="text-xs text-muted-foreground shrink-0">{decoderName(txState.instance)}</span>
)}
</div>
)}
@@ -952,17 +1198,17 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
<div className="shrink-0 border-b border-border bg-background overflow-hidden">
<div className={cn(ROW, 'h-7 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground')}
style={{ gridTemplateColumns: template, width: tableW }}>
{COLS.map((c, i) => (
{cols.map((c, i) => (
<span key={c.key}
// Not CELL_LAST for the final column: its overflow-hidden would
// clip that column's own resize handle.
className={cn('relative flex items-center min-w-0 px-2',
i < COLS.length - 1 && 'border-r border-border/30',
i < cols.length - 1 && 'border-r border-border/30',
// The three numeric columns label their own right edge, where the
// figures are.
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq') && 'justify-end')}
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq' || c.key === 'dist') && 'justify-end')}
title={c.key === 'dt' ? t('dec.colDtTitle') : c.key === 'freq' ? t('dec.colFreqTitle') : undefined}>
<span className="truncate">{t(c.tkey)}</span>
<span className="truncate">{c.key === 'dist' ? `${t(c.tkey)} (${distanceUnit()})` : t(c.tkey)}</span>
<ColResizer
onResize={(dx) => setColWidth(c.key, colw[c.key] + dx)}
onReset={() => setColWidth(c.key, c.def)}
@@ -1021,7 +1267,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
const e = statusOf(d);
const st = e?.status && e.status !== 'worked' ? e.status : '';
const entities = st ? entityBadgesFor(st) : [];
const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key]);
const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key] && chaseAllows(b.key as string));
const mine = !!me && d.call === me;
const hot = entities.length > 0 || extras.length > 0;
// Someone answering us outranks everything else on the screen.
@@ -1033,11 +1279,19 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
<button
key={`${d.call}-${d.freq_hz}-${i}`}
type="button"
onClick={() => onCall(d)}
// ONE click selects, TWO transmit — the cluster's rule, and
// the only safe one here: a single click used to hand the
// decode straight to WSJT-X as a Reply, so brushing a row
// while reading the band started calling a station.
onClick={() => (onSelect ?? onCall)(d)}
onDoubleClick={() => onCall(d)}
title={t('dec.callTitle', { call: d.call })}
style={{ gridTemplateColumns: template, width: tableW }}
className={cn(ROW, 'text-left border-b border-border/20 transition-colors',
replying ? 'bg-success/20 hover:bg-success/25'
replying ? 'bg-success/25 hover:bg-success/30 border-l-2 border-l-success'
// The station being called: a tint saying "he is on the
// air", not the QSO treatment above — most of what he
// sends is to other people.
: worked ? 'bg-danger/15 hover:bg-danger/20'
: mine ? 'bg-info/10'
: 'hover:bg-muted/50')}
@@ -1050,6 +1304,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
{hhmmssCompact(d.at)}
</span>
{/* Which receiver heard it present only while more than one
is feeding a merged list. */}
{cols.some((c) => c.key === 'rx') && (
<span className={cn(CELL, 'text-[11px] text-muted-foreground truncate')}
title={d.instance ?? ''}>
{decoderName(d.instance)}
</span>
)}
<span className={cn(CELL, 'justify-end font-mono text-[13px] font-semibold tabular-nums', snrTone(d.snr))}>
{d.snr > 0 ? `+${d.snr}` : d.snr}
</span>
@@ -1083,7 +1346,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
{/* The message carries the callsign already which is why
there is no column repeating it. */}
<span className={cn(CELL, 'font-mono text-[13px]')}>
<span className="truncate">{renderMsg(d.msg ?? '', me, calling)}</span>
<span className="truncate">{renderMsg(d.msg ?? '', me, calling, replying)}</span>
</span>
{/* The decode's own grid first, the remembered one as the
@@ -1096,6 +1359,23 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
<span className="truncate">{d.grid || e?.grid || ''}</span>
</span>
<span className={cn(CELL, 'justify-end font-mono text-[11px] tabular-nums text-muted-foreground/80')}>
{(() => {
const g = d.grid || e?.grid || '';
const path = myGrid && g ? pathBetween(myGrid, g) : null;
return path ? distanceValue(path.distanceShort).toLocaleString() : '';
})()}
</span>
<span className={cn(CELL, 'gap-1.5')}>
{e?.state && (
<>
<span className="rounded px-1.5 py-px text-[11px] font-bold bg-info/15 text-info border border-info/40 shrink-0">{e.state}</span>
<span className="text-[11px] text-muted-foreground truncate">{US_STATES[e.state.toUpperCase()] ?? ''}</span>
</>
)}
</span>
<span className={cn(CELL, 'text-[11px] text-muted-foreground')}>
<span className="truncate">{e?.country ?? ''}</span>
</span>
@@ -1112,8 +1392,21 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
{t('dec.wkd')}
</span>
)}
{/* Plainest first: the LoTW letter, the worked note, then
the coloured badges the watch list leading them, being
the operator's own answer rather than the log's. */}
{isWatched(d.call, watchlist) && (
<span className="rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0 text-white"
style={{ background: '#f472b6' }} title={t('dec.wlTip')}>
{t('dec.wl')}
</span>
)}
{entities.map((b) => (
<span key={b.label} className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0', b.cls)}>
<span key={b.label}
title={e?.unconf_status ? t('dec.unconfTip') : undefined}
className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0',
e?.unconf_status ? 'border bg-transparent' : b.cls)}
style={e?.unconf_status ? { color: b.colour, borderColor: b.colour, borderStyle: 'dashed', opacity: 0.6 } : undefined}>
{t(b.label)}
</span>
))}
@@ -1123,16 +1416,19 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
// Same hue, held back — the badge reads as "less" of the
// same thing, and its label says which. One badge for both
// is what made a station worked an hour ago read as new.
const unconf = b.key === 'new_grid' && e?.grid_state === 'unconf';
const unconf = (b.key === 'new_grid' && e?.grid_state === 'unconf')
|| (b.key === 'new_state' && !!e?.unconf_state)
|| (b.key === 'new_county' && !!e?.unconf_cty)
|| (b.key === 'new_pfx' && !!e?.unconf_pfx);
const c = markerColour(b.marker);
return (
<span key={b.key as string}
title={unconf ? t('dec.bgGridUnconfTip') : undefined}
title={unconf ? t('dec.unconfTip') : undefined}
className="rounded border px-1 py-px text-[10px] font-semibold uppercase tracking-wide bg-transparent shrink-0"
style={unconf
? { borderColor: c, color: c, opacity: 0.45, borderStyle: 'dashed' }
: { borderColor: c, color: c }}>
{unconf ? t('dec.bgGridUnconf') : t(b.label)}
{t(b.label)}
</span>
);
})}
+3 -1
View File
@@ -71,6 +71,7 @@ interface Props {
band: string;
mode: string;
bands?: string[]; // configured bands for the worked-before matrix columns
modes?: string[]; // configured modes, in order — the matrix cycles its digital row through them
// The station's satellites, for the SAT_NAME dropdown. Passed in rather than
// read here: the list lives in Preferences, and App already reloads it when
// Preferences close — a panel reading it once at mount would need a restart.
@@ -155,7 +156,7 @@ function Field({ label, span = 1, className, children }: { label: string; span?:
);
}
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, satellites = [], slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) {
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, modes, satellites = [], 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
@@ -294,6 +295,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
currentBand={slotCall ? (slotBand ?? '') : band}
currentMode={slotCall ? (slotMode ?? '') : mode}
bands={bands}
modes={modes}
hasCall={slotCall ? true : callsign.trim() !== ''}
forCall={slotCall}
onEditQso={onEditQso}
+1 -1
View File
@@ -19,7 +19,7 @@ type Props = {
phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked
};
// Operating panel for the Digital Voice Keyer — transmits the recorded F1F6
// Operating panel for the Digital Voice Keyer — transmits the recorded F1F12
// 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, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) {
+81 -31
View File
@@ -4,20 +4,21 @@ import {
GetKenwoodState, RefreshKenwood, SetKenwoodPower, SetKenwoodAFGain, SetKenwoodTX, TuneKenwoodATU,
SetKenwoodRFGain, SetKenwoodMicGain, SetKenwoodSquelch, SetKenwoodPreamp, SetKenwoodAtt,
SetKenwoodNB, SetKenwoodNR, SetKenwoodAGC, SetKenwoodFilter, SetKenwoodAntenna,
SetKenwoodRIT, SetKenwoodXIT, ClearKenwoodRIT, SetKenwoodKeySpeed, ToggleKenwoodATU, NudgeKenwoodRIT,
SetKenwoodRIT, SetKenwoodXIT, ClearKenwoodRIT, SetKenwoodKeySpeed, ToggleKenwoodATU, SetKenwoodRITOffset, SetKenwoodPanelMode,
GetCATState,
} from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { sMeterRST } from '@/lib/rst';
import { ShiftRow } from '@/components/ShiftRow';
import { MeterBar } from '@/components/MeterBar';
import { WheelRange } from '@/components/WheelRange';
type KenwoodState = {
available: boolean; model?: string; elecraft: boolean; mode?: string;
available: boolean; model?: string; elecraft: boolean; mode?: string; data_sub?: string;
transmitting: boolean; split: boolean; split_tx_hz?: number;
s_meter: number; s_meter_raw: number;
power_meter: number; swr: number; swr_raw: number;
power_meter: number; power_w?: number; swr: number; swr_raw: number;
rf_power: number; af_gain: number; rf_gain: number; mic_gain: number; squelch: number;
preamp: boolean; att: boolean; nb: boolean; nr: boolean; agc?: string;
filter_hz: number; antenna: number; rit: boolean; xit: boolean; rit_offset: number; key_speed: number;
@@ -41,18 +42,22 @@ const ZERO: KenwoodState = {
// nobody notices are missing.
const FILTERS = [200, 400, 700, 1000, 1800, 2400, 2800, 4000];
// Raw S-meter → S units. The K3 answers 0-21 across S0…S9+60; S9 is taken at
// raw 9 and each step above it as 6 dB. PROVISIONAL, like the rest of the
// scaling: the raw value is on screen and in the log, so a real radio settles
// it rather than this comment.
const S9_RAW = 9;
const DB_PER_RAW = 6;
function sParts(rawV: number): { s: number; over: number; label: string } {
if (rawV >= S9_RAW) {
const over = Math.max(0, Math.round((rawV - S9_RAW) * DB_PER_RAW));
// Raw S-meter → S units, CALIBRATED against a real K3 beside its own display
// (2026-08): raw 5 reads S7 on the radio, raw 9 reads S9+20. So S9 sits near
// raw 6.5 — not 9, which showed everything two S-units low — and each raw step
// above it is worth ~8 dB, shown in the 10 dB steps the K3's own meter uses.
// A Kenwood answers 0-30 on the same command and keeps the simple 1-per-raw
// scale until someone calibrates one against a real radio too.
const K3_S9_RAW = 6.5;
const K3_DB_PER_RAW = 8;
function sParts(rawV: number, elecraft: boolean): { s: number; over: number; label: string } {
const s9raw = elecraft ? K3_S9_RAW : 9;
if (rawV >= s9raw) {
let over = Math.max(0, Math.round((rawV - s9raw) * (elecraft ? K3_DB_PER_RAW : 6)));
if (elecraft) over = Math.round(over / 10) * 10;
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
}
const s = Math.max(0, Math.min(9, rawV));
const s = Math.max(0, Math.min(9, Math.round(rawV * (elecraft ? 9 / K3_S9_RAW : 1))));
return { s, over: 0, label: `S${s}` };
}
@@ -89,9 +94,34 @@ function Toggle({ label, on, off, onClick }: { label: string; on: boolean; off:
);
}
// isDataMode: what the rig reports for MD6 varies — "DATA", or the configured
// digital default (FT8…) — so the DATA family is "not one of the native modes".
function isDataMode(m?: string): boolean {
switch ((m ?? '').toUpperCase()) {
case '': case 'CW': case 'USB': case 'LSB': case 'SSB': case 'FM': case 'AM': case 'RTTY':
return false;
}
return true;
}
export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) => void }) {
const { t } = useI18n();
const [st, setSt] = useState<KenwoodState>(ZERO);
// Ctrl+Left/Right shifts the RIT by ±10 Hz while RIT is on — the same
// keyboard clarifier the Icom and TCI consoles have, for zero-beating a
// caller without touching the mouse.
const stRef = useRef(st); stRef.current = st;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!e.ctrlKey || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
const v = stRef.current;
if (!v.available || !v.rit) return;
e.preventDefault();
SetKenwoodRITOffset((v.rit_offset || 0) + (e.key === 'ArrowRight' ? 10 : -10)).catch(() => {});
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const [freqHz, setFreqHz] = useState(0);
const [err, setErr] = useState('');
// Optimistic overlay: a slider must follow the finger, not the poll. Dropped
@@ -149,6 +179,28 @@ export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) =>
{freqHz > 0 ? (freqHz / 1e6).toFixed(6) : '—'}
</div>
</div>
{/* Mode row. The two DATA buttons are why it exists: MD6 alone keeps
whatever submode the last session left, and a K3 "in DATA" with FSK D
still armed keys FT8 with no audio. DATA sends MD6+DT0 (DATA A, the
soundcard path), RTTY sends MD6+DT2 (FSK D). The active DATA button
follows the rig's own DT answer. */}
<div className="inline-flex rounded-md border border-border overflow-hidden">
{([
['CW', 'CW', view.mode === 'CW'],
['USB', 'USB', view.mode === 'USB'],
['LSB', 'LSB', view.mode === 'LSB'],
['DATA', t('k3.modeData'), isDataMode(view.mode) && view.data_sub !== 'FSK D' && view.data_sub !== 'PSK D'],
['RTTY', t('k3.modeRtty'), (isDataMode(view.mode) && (view.data_sub === 'FSK D' || view.data_sub === 'PSK D')) || view.mode === 'RTTY'],
] as [string, string, boolean][]).map(([cmd, label, on]) => (
<button key={cmd} type="button" disabled={off}
title={cmd === 'DATA' ? t('k3.modeDataHint') : cmd === 'RTTY' ? t('k3.modeRttyHint') : label}
onClick={() => SetKenwoodPanelMode(cmd).catch((e: any) => setErr(String(e?.message ?? e)))}
className={cn('px-2.5 py-1.5 text-xs font-bold border-l border-border first:border-l-0',
on ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{label}
</button>
))}
</div>
<button type="button" className="text-[11px] text-muted-foreground hover:text-foreground flex items-center gap-1"
onClick={() => RefreshKenwood().catch(() => {})} title={t('k3.refreshHint')}>
<SlidersHorizontal className="size-3.5" />
@@ -163,14 +215,15 @@ export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) =>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<MeterBar label="S-METER" value={view.transmitting ? 0 : view.s_meter} lo={0} hi={100}
accent="#16a34a" segColor={sSegColor}
display={view.transmitting ? '—' : sParts(view.s_meter_raw).label}
display={view.transmitting ? '—' : sParts(view.s_meter_raw, view.elecraft).label}
onClick={() => {
if (view.transmitting || !onReportRST) return;
const sp = sParts(view.s_meter_raw);
const sp = sParts(view.s_meter_raw, view.elecraft);
onReportRST(sMeterRST(sp.s, sp.over, view.mode));
}}
title={t('k3.sMeterHint', { raw: String(view.s_meter_raw) })} />
<MeterBar label="PWR" value={view.transmitting ? view.power_meter : 0} lo={0} hi={100} accent="#0ea5e9" />
<MeterBar label="PWR" value={view.transmitting ? view.power_meter : 0} lo={0} hi={100} accent="#0ea5e9"
display={view.transmitting && view.elecraft ? `${view.power_w ?? 0} W` : undefined} />
{/* 0 means "not measured", and it must not render as a perfect 1.0:
a match that looks ideal on an antenna nobody has measured is the one
reading that can cost a radio. */}
@@ -216,7 +269,7 @@ export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) =>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-6 gap-y-2">
<label className="flex items-center gap-2 text-xs">
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.power')}</span>
<WheelRange min={0} max={110} step={5} disabled={off}
<WheelRange min={0} max={110} step={1} disabled={off}
value={view.rf_power ?? 0}
onChange={(n) => put({ rf_power: n }, () => SetKenwoodPower(n))} />
<span className="w-12 text-right font-mono tabular-nums">{view.rf_power ?? 0} W</span>
@@ -279,21 +332,18 @@ export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) =>
))}
</div>
{/* RIT / XIT. Clear zeroes both offsets at once the radio's own RC,
and what an operator means by "clear it". */}
{/* RIT / XIT the shared ShiftRow, exactly as the Icom and TCI consoles
drive theirs: ± / wheel / type, Ctrl+/ while RIT is on. The K3 has
ONE offset for both, so both rows show it, like the Icom's. */}
<div className="space-y-1.5">
<ShiftRow label="RIT" accent="#8b5cf6" on={view.rit} hz={view.rit_offset || 0} disabled={off}
onToggle={() => SetKenwoodRIT(!view.rit).catch(setErrMsg)}
onSet={(hz) => SetKenwoodRITOffset(hz).catch(setErrMsg)} />
<ShiftRow label="XIT" accent="#f59e0b" on={view.xit} hz={view.rit_offset || 0} disabled={off}
onToggle={() => SetKenwoodXIT(!view.xit).catch(setErrMsg)}
onSet={(hz) => SetKenwoodRITOffset(hz).catch(setErrMsg)} />
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Toggle label="RIT" on={view.rit} off={off} onClick={() => SetKenwoodRIT(!view.rit).catch(setErrMsg)} />
<Toggle label="XIT" on={view.xit} off={off} onClick={() => SetKenwoodXIT(!view.xit).catch(setErrMsg)} />
{/* The offset itself. A lit RIT button says the feature is on and
nothing about where it has put the receiver. */}
{[-100, -10, 10, 100].map((d) => (
<Toggle key={d} label={(d > 0 ? '+' : '') + d} on={false} off={off}
onClick={() => NudgeKenwoodRIT(d).catch(setErrMsg)} />
))}
<span className="text-[11px] font-mono tabular-nums text-muted-foreground w-16">
{view.rit_offset > 0 ? '+' : ''}{view.rit_offset || 0} Hz
</span>
<Toggle label={t('k3.clear')} on={false} off={off} onClick={() => ClearKenwoodRIT().catch(setErrMsg)} />
{/* Antenna only when the radio answered AN a K3 without the internal
ATU has one socket and no switch to offer. */}
{view.antenna > 0 && (<>
+231
View File
@@ -0,0 +1,231 @@
import { useEffect, useRef, useState } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { gridToLatLon, greatCirclePoints, splitAtAntimeridian } from '@/lib/maidenhead';
import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { loadMapView, saveMapView, MAP_VIEW_FT } from '@/lib/mapView';
// FT Map — the live decode feed as geography: every station decoded in the
// last half hour, an arc from the operator's own square to theirs, coloured by
// band the way PSK Reporter taught everyone to read it. Wholly display: the
// decode list is the same one the FT decodes tab shows, and a station with no
// grid (never sent one in a CQ) simply cannot be placed and is not drawn.
//
// Performance is a design constraint, not an afterthought: the panel only
// exists while its tab is active (the parent unmounts it otherwise), the map
// renders with canvas (one <canvas>, not one DOM node per arc), the arcs are
// capped, and redraws happen when the DECODE LIST changes — every 15 s in FT8,
// not per frame.
// The map is handed the SAME decode objects the list works from — it only reads
// a few of the fields. The rest travel with them so a click here can answer the
// station exactly as a double-click in the list does.
export type FTMapDecode = {
call: string;
grid?: string;
band?: string;
snr: number;
at: string;
[k: string]: unknown;
};
// The band palette every PSK Reporter user already knows, near enough.
const BAND_COLOURS: Record<string, string> = {
'160m': '#7f7f7f', '80m': '#e550e5', '60m': '#00008b', '40m': '#5555ff',
'30m': '#62d962', '20m': '#f2c40c', '17m': '#f2f261', '15m': '#cca166',
'12m': '#b22222', '10m': '#ff69b4', '6m': '#ff0000', '4m': '#cc0044',
'2m': '#ff1493', '70cm': '#999900',
};
const bandColour = (b?: string) => BAND_COLOURS[(b ?? '').toLowerCase()] || '#9ca3af';
const MAX_ARCS = 300;
const MAX_AGE_MS = 30 * 60_000;
export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
decodes: FTMapDecode[];
myGrid: string;
// One click takes the station, two answer it — the list's own gestures.
onSelect?: (d: FTMapDecode) => void;
onCall?: (d: FTMapDecode) => void;
}) {
// Held in refs so the redraw below does not have to list them as dependencies
// and rebuild every arc whenever the parent re-renders.
const selectRef = useRef(onSelect);
const callRef = useRef(onCall);
useEffect(() => { selectRef.current = onSelect; callRef.current = onCall; }, [onSelect, onCall]);
// Distinguishing the two gestures is ours to do: Leaflet fires click before
// dblclick and leaves the telling apart to the handler.
const clickTimer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(clickTimer.current), []);
// Where this map was left. Panning and zooming it is the operator saying which
// part of the world they are working; throwing that away on every tab switch
// made it something to set up again rather than something to glance at.
const saved = useRef(loadMapView(MAP_VIEW_FT));
const { t } = useI18n();
const divRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const layerRef = useRef<L.LayerGroup | null>(null);
const baseRef = useRef<L.TileLayer | null>(null);
const labelsRef = useRef<L.TileLayer | null>(null);
const [basemap, setBasemap] = useState<BasemapKey>(() =>
(localStorage.getItem('opslog.ftmapBase') as BasemapKey) || 'satellite');
// The map itself, once.
useEffect(() => {
if (!divRef.current || mapRef.current) return;
// ONE world: noWrap tiles inside hard bounds, no side-by-side copies —
// and the space beyond the edge is the theme's own surface (style.css).
const m = L.map(divRef.current, {
zoomControl: true, attributionControl: true,
// No maxBounds: with the world smaller than the window the clamp
// dragged every zoom into a corner. noWrap tiles alone keep one world.
worldCopyJump: false, preferCanvas: true,
center: saved.current ? [saved.current.lat, saved.current.lon] : [25, 0],
zoom: saved.current ? saved.current.zoom : 2,
minZoom: 2,
});
// Every move, not just the deliberate ones: a zoom is as much a choice as a
// pan, and there is no moment afterwards at which to ask.
m.on('moveend', () => {
const c = m.getCenter();
saveMapView(MAP_VIEW_FT, c.lat, c.lng, m.getZoom());
});
mapRef.current = m;
layerRef.current = L.layerGroup().addTo(m);
// Leaflet measures its container ONCE, when the map is created, and then
// draws tiles for that size for ever. This panel is mounted the moment its
// tab is selected — before the flex layout has settled — and the window can
// be resized under it, so the stale measurement showed as a strip of dead
// space along the bottom where tiles were never asked for. The observer
// hands it the real size whenever the box changes.
const ro = new ResizeObserver(() => m.invalidateSize({ animate: false }));
ro.observe(divRef.current);
// Once more after the first paint: the first observation can arrive while
// the panel is still zero-height, and no further resize follows a layout
// that settles by itself.
const settle = window.setTimeout(() => m.invalidateSize({ animate: false }), 100);
return () => {
window.clearTimeout(settle);
ro.disconnect();
m.remove();
mapRef.current = null;
layerRef.current = null;
};
}, []);
// Basemap follows the picker.
useEffect(() => {
const m = mapRef.current;
if (!m) return;
baseRef.current?.remove(); labelsRef.current?.remove();
const bm = BASEMAPS[basemap];
const opts: L.TileLayerOptions = {
maxNativeZoom: bm.maxNativeZoom,
noWrap: true,
bounds: L.latLngBounds(L.latLng(-85.0511, -180), L.latLng(85.0511, 180)),
};
baseRef.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
if (bm.labelsUrl) labelsRef.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
localStorage.setItem('opslog.ftmapBase', basemap);
}, [basemap]);
// The arcs, redrawn when the decode list changes. Newest last so they paint
// on top; opacity falls with age so the map reads as "now" with a memory.
useEffect(() => {
const layer = layerRef.current;
if (!layer) return;
layer.clearLayers();
const from = gridToLatLon(myGrid);
if (!from) return;
const now = Date.now();
const placed = decodes
.filter((d) => d.grid && Date.parse(d.at) > now - MAX_AGE_MS)
.slice(-MAX_ARCS);
// One line per CALL (its freshest sighting): the same CQer decoded thirty
// times in ten minutes is one path on the air, not thirty strokes of it.
const byCall = new Map<string, FTMapDecode>();
for (const d of placed) byCall.set(d.call.toUpperCase(), d);
L.circleMarker([from.lat, from.lon], {
radius: 5, color: '#fff', weight: 2, fillColor: '#e11d48', fillOpacity: 1,
}).addTo(layer);
for (const d of byCall.values()) {
const to = gridToLatLon(d.grid!);
if (!to) continue;
const age = now - Date.parse(d.at);
const fade = Math.max(0.15, 1 - age / MAX_AGE_MS);
const colour = bandColour(d.band);
// Cut at the antimeridian: this map shows ONE world, so a path running
// past ±180 has to leave one edge and come back at the other. Without it
// every arc out of VK or ZL was drawn into the blank space off the side
// of the map, its far end sitting alone on the opposite coast.
const pts = splitAtAntimeridian(greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48));
L.polyline(pts as L.LatLngExpression[][], {
color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
}).addTo(layer);
const mk = L.circleMarker([to.lat, to.lon], {
// A three-pixel dot is a fine mark and a poor target, so the visible
// radius stays and an invisible one twice the size takes the clicks.
radius: 3, color: colour, weight: 1, fillColor: colour, fillOpacity: 0.9 * fade,
}).bindTooltip(`${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`, { direction: 'top' })
.addTo(layer);
const hit = L.circleMarker([to.lat, to.lon], {
radius: 9, opacity: 0, fillOpacity: 0, interactive: true,
}).addTo(layer);
for (const target of [mk, hit]) {
target.on('click', (e) => {
// Not to the map: a click on a station is not a click on the water.
L.DomEvent.stopPropagation(e as unknown as Event);
window.clearTimeout(clickTimer.current);
clickTimer.current = window.setTimeout(() => selectRef.current?.(d), 250);
});
target.on('dblclick', (e) => {
// stop(), not stopPropagation(): the map zooms on a double click, and
// answering a station is not a request to zoom in on it.
L.DomEvent.stop(e as unknown as Event);
window.clearTimeout(clickTimer.current);
callRef.current?.(d);
});
}
}
}, [decodes, myGrid]);
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
return (
// isolate: Leaflet stacks its panes and controls up to z-index 1000, which
// beat the app menus and the Settings dialog. A stacking context of our own
// keeps all of it inside this panel.
<div className="relative isolate z-0 h-full w-full min-h-0">
<div ref={divRef} className="absolute inset-0 rounded-lg overflow-hidden" />
{/* Basemap picker, MainMap's own vocabulary. */}
<div className="absolute top-2 left-12 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
<button key={k} type="button" onClick={() => setBasemap(k)}
className={cn('px-2 py-0.5 rounded text-[11px]',
basemap === k ? 'bg-primary text-primary-foreground font-semibold' : 'text-muted-foreground hover:bg-muted')}>
{BASEMAPS[k].label}
</button>
))}
</div>
{/* Band legend — only the bands actually on screen. */}
{bands.length > 0 && (
<div className="absolute bottom-2 left-2 z-[1000] flex flex-wrap gap-x-2.5 gap-y-1 rounded-md bg-background/85 backdrop-blur px-2 py-1.5 border border-border">
{bands.map((b) => (
<span key={b} className="flex items-center gap-1 text-[11px] text-foreground">
<span className="inline-block w-3 h-[3px] rounded" style={{ background: bandColour(b) }} />
{b.toUpperCase()}
</span>
))}
</div>
)}
{!gridToLatLon(myGrid) && (
<div className="absolute inset-0 z-[1000] flex items-center justify-center pointer-events-none">
<span className="rounded-md bg-background/90 border border-border px-3 py-2 text-sm text-muted-foreground">
{t('ftmap.noGrid')}
</span>
</div>
)}
</div>
);
}
@@ -86,6 +86,8 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ 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: 'clublog_qso_download_status', label: 'fltb.fClublogRcvd', type: 'text' },
{ value: 'clublog_qso_download_date', label: 'fltb.fClublogRcvdDate', type: 'adifdate' },
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
// HAMLOG.online: no promoted column, filtered through extras_json (see
@@ -94,6 +96,8 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'hamlog_sent_date', label: 'fltb.fHamlogSentDate', type: 'adifdate' },
{ value: 'hamlog_rcvd', label: 'fltb.fHamlogRcvd', type: 'text' },
{ value: 'hamlog_rcvd_date', label: 'fltb.fHamlogRcvdDate', type: 'adifdate' },
{ value: 'hamqth_sent', label: 'fltb.fHamqthSent', type: 'text' },
{ value: 'hamqth_sent_date', label: 'fltb.fHamqthSentDate', type: 'adifdate' },
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
+13 -8
View File
@@ -8,6 +8,7 @@ import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { BASEMAPS, addBasemap, loadBasemap, type BasemapKey } from '@/components/MainMap';
import { writeUiPref } from '@/lib/uiPref';
import { loadMapView, saveMapView, MAP_VIEW_GRIDS } from '@/lib/mapView';
// GridSquareMap — every Maidenhead square in the log, drawn on a world map.
//
@@ -128,15 +129,19 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
// impossible on any window wider than it is tall: one step out was already
// too far in.
minZoom: 0,
maxBoundsViscosity: 1, // don't let a drag slide the world off to one side
}).setView([20, 0], 2);
// The whole world, once, whatever the window is shaped like — computed by
// Leaflet from the container rather than guessed with a zoom number.
m.fitWorld({ animate: false });
// Latitude only: the poles are the edge of the projection and there is
// nothing beyond them, while leaving longitude free keeps a drag from
// fighting the operator near the date line.
m.setMaxBounds(L.latLngBounds(L.latLng(-85, -Infinity), L.latLng(85, Infinity)));
// Leaflet from the container rather than guessed with a zoom number. Unless
// the operator has already chosen a view: theirs is the answer, and fitting
// the world over it would undo the choice on every tab switch.
const saved = loadMapView(MAP_VIEW_GRIDS);
if (saved) m.setView([saved.lat, saved.lon], saved.zoom);
else m.fitWorld({ animate: false });
m.on('moveend', () => {
const c = m.getCenter();
saveMapView(MAP_VIEW_GRIDS, c.lat, c.lng, m.getZoom());
});
mapRef.current = m;
layerRef.current = L.layerGroup().addTo(m);
// Leaflet measures its container ONCE, when the map is created, and never
@@ -148,7 +153,7 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
// fitWorld above ran against a container that may still have had no size —
// the tab is mounted hidden. Fit ONCE more the first time it really has one,
// and never again: after that the view belongs to the operator.
let fitted = false;
let fitted = !!saved; // a remembered view is already the right size
const ro = new ResizeObserver(() => {
m.invalidateSize({ animate: false });
const el = hostRef.current;
+208 -314
View File
@@ -1,15 +1,16 @@
import { useEffect, useRef, useState } from 'react';
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna, Filter, Power } from 'lucide-react';
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna, Filter, Power, Volume2, VolumeX } from 'lucide-react';
import {
GetIcomState, IcomRefresh,
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
IcomSetANF, IcomSetAPF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetPTT,
IcomSetScope, IcomScopeData, IcomSetScopeMode, IcomSetScopeEdges, GetCATState, SetCATFrequency, SetCATMode,
AudioMonitorActive, AudioStartMonitor, AudioStopMonitor,
IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetATU, IcomConsolePTT,
GetCATState, SetCATFrequency, SetCATMode,
IcomSetRIT, IcomSetRITOn, IcomSetXITOn,
IcomSetAntenna, IcomSetPBTInner, IcomSetPBTOuter, IcomSetManualNotch, IcomSetNotchPos,
IcomSetSquelch, IcomSetComp, IcomSetCompLevel, IcomSetMonitor, IcomSetMonLevel,
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower,
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower, IcomRecallBand,
} from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
@@ -19,7 +20,7 @@ import { ShiftRow } from '@/components/ShiftRow';
type IcomState = {
available: boolean; model?: string; mode?: string;
transmitting: boolean; split: boolean;
transmitting: boolean; split: boolean; sub_hz?: number; atu_on?: boolean;
s_meter: number; power_meter: number; swr_meter: number;
rf_power: number; mic_gain: number;
af_gain: number; rf_gain: number;
@@ -52,7 +53,13 @@ const ZERO: IcomState = {
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: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 },
// 60 m: the middle of the IARU Region 1 allocation (5351.5-5366.5 kHz), which
// every 60 m-capable rig can display. Where the band is channelised (the US)
// the operator moves to their channel from here — the button is a way onto
// the band, not a claim about what may be transmitted on it.
{ l: '60', hz: 5_354_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 },
];
@@ -61,8 +68,9 @@ 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.
// These frequencies are the FALLBACK: with the band stacking registers switched
// on the radio is asked where the operator last was instead, and one of these is
// only sent for a band or a model whose register cannot be read.
//
// 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
@@ -77,19 +85,47 @@ function bandsFor(model?: string): Band[] {
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.
const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM'];
// Mode buttons for the console (like RS-BA1's row).
//
// LSB and USB by NAME, not one "SSB" button that resolves by band: the band
// convention is right for a logged mode and useless when the operator means
// "put this radio in USB on 40 m", which the console could not express at all.
//
// PSK is native only on the 7610/7760/7851 class; every other rig NAKs 0x12, so
// there the button is dead furniture — see modesFor. Soundcard PSK31 rides on
// DATA, which every rig can do.
const MODES_BASE = ['LSB', 'USB', 'CW', 'RTTY', 'AM', 'FM', 'DATA'];
function hasNativePSK(model?: string): boolean {
const m = (model ?? '').toUpperCase();
return m.includes('7610') || m.includes('7760') || m.includes('7851') ||
m.includes('7800') || m.includes('7700');
}
function modesFor(model?: string): string[] {
if (!hasNativePSK(model)) return MODES_BASE;
return [...MODES_BASE.slice(0, 4), 'PSK', ...MODES_BASE.slice(4)];
}
// Which radios actually have an antenna selector on the CI-V command (0x12).
// An IC-7300 has ONE socket: offering it ANT1/ANT2 was two buttons that could
// only ever disagree with the front panel.
function hasAntennaSelector(model?: string): boolean {
const m = (model ?? '').toUpperCase();
return m.includes('7610') || m.includes('7760') || m.includes('7851') ||
m.includes('7800') || m.includes('7700') || m.includes('9700');
}
// Attenuator steps are MODEL-dependent even though the CI-V command (0x11) is the
// same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18
// dB stepped attenuator; the IC-7300/705/7100 have a single 20 dB attenuator; the
// dB stepped attenuator, and the IC-7760 the same (confirmed on a real one); the
// IC-7300/705/7100 have a single 20 dB attenuator; the
// IC-9700 a single 10 dB. Offering the wrong steps = a dead button (the rig NAKs
// e.g. 6 dB on a 7300). Default to the common single 20 dB for unknown models.
function attOptions(model?: string): { v: string; l: string }[] {
const m = (model ?? '').toUpperCase();
const OFF = { v: '0', l: 'OFF' };
if (/(7610|7700|7800|7850|7851)/.test(m)) {
if (/(7610|7700|7760|7800|7850|7851)/.test(m)) {
return [OFF, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
}
if (m.includes('9700')) return [OFF, { v: '10', l: '10dB' }];
@@ -128,9 +164,50 @@ function fmtVFO(hz?: number): string {
}
// modeMatches marks a mode button active, folding the rig's USB/LSB into SSB.
function modeMatches(btn: string, cur?: string): boolean {
// icomWatts turns the backend's 0-100 meter percentage back into watts on the
// IC-7760's own meter face. The backend value is linear in the RAW meter byte
// (0-255 → 0-100), but Icom's calibration is not: raw 143 is half deflection
// and raw 213 is full scale. On a real 7760 a measured 100 W sits at half
// deflection of the 250 W face — the linear ×2.5 first tried showed 140 W for
// it. Below half scale watts run 0→100, above it 100→250.
// The anchors are MEASURED on the real radio, not derived: a known 50 W read
// raw ≈89 and a known 100 W read raw 143 (Icom's documented half-deflection),
// with raw 213 = full scale = 250 W. The face is not linear in watts at the
// bottom — a two-segment guess showed 50 W as 62 — so watts interpolate
// between the measured anchors, and a new measurement just adds a row.
// Third measured anchor (2026-08-30): a real 200 W read full deflection —
// raw 213 is 200 W on this rig, not the 250 the printed face suggests.
const ICOM_7760_PO: [number, number][] = [[0, 0], [89, 50], [143, 100], [213, 200]];
function icomWatts(pct: number): { w: number; defl: number } {
const raw = Math.max(0, pct * 2.55);
const defl = raw <= 143 ? (raw / 143) * 50 : Math.min(100, 50 + ((raw - 143) / 70) * 50);
let w = 200;
for (let i = 1; i < ICOM_7760_PO.length; i++) {
const [r0, w0] = ICOM_7760_PO[i - 1], [r1, w1] = ICOM_7760_PO[i];
if (raw <= r1) { w = w0 + ((raw - r0) / (r1 - r0)) * (w1 - w0); break; }
}
return { w: Math.round(w), defl };
}
// Which sideband a bare "SSB" means at this frequency — the same convention the
// backend applies when it resolves the mode for the radio.
function sideForHz(hz?: number): string | null {
if (!hz || hz <= 0) return null;
return hz < 10_000_000 ? 'LSB' : 'USB';
}
function modeMatches(btn: string, cur?: string, hz?: number): boolean {
if (!cur) return false;
if (btn === 'SSB') return cur === 'SSB' || cur === 'USB' || cur === 'LSB';
// A rig that reports the folded ADIF "SSB" still lights the side its
// frequency implies, so the row is never blank on a phone contact.
if (btn === 'USB' || btn === 'LSB') {
if (cur === btn) return true;
return cur === 'SSB' && btn === (sideForHz(hz) ?? '');
}
// The backend surfaces USB-D as the operator's digital default (FT8…), or as
// plain DATA — either way it is the DATA button that should light.
if (btn === 'DATA') return ['DATA', 'FT8', 'FT4', 'JS8', 'JT65', 'JT9', 'MFSK', 'OLIVIA'].includes(cur);
if (btn === 'PSK') return cur === 'PSK' || cur === 'PSK31';
return btn === cur;
}
@@ -241,11 +318,11 @@ function Meter({ label, value, accent, scale, onClick, title }: { label: string;
<div className="flex-1 h-2.5 rounded-full bg-muted/60 overflow-hidden">
<div className="h-full rounded-full transition-[width] duration-150" style={{ width: `${v}%`, background: accent }} />
</div>
<span className="w-10 text-right text-[11px] font-mono tabular-nums text-muted-foreground">{scale ?? v}</span>
<span className="w-14 shrink-0 whitespace-nowrap text-right text-[11px] font-mono tabular-nums text-muted-foreground">{scale ?? v}</span>
</>
);
if (onClick) {
return <button type="button" onClick={onClick} title={title} className="flex items-center gap-2 w-full rounded hover:bg-muted/50 -mx-1 px-1 py-0.5">{body}</button>;
return <button type="button" onClick={onClick} title={title} className="flex items-center gap-2 w-full rounded cursor-pointer hover:bg-muted/50 -mx-1 px-1 py-0.5">{body}</button>;
}
return <div className="flex items-center gap-2">{body}</div>;
}
@@ -253,6 +330,13 @@ function Meter({ label, value, accent, scale, onClick, title }: { label: string;
// sParts turns the raw 0-100 S-meter into S-unit + dB-over-S9 (S9 ≈ 47% on the
// CI-V 0-255 scale, +60 dB near full scale). Used for both the display label and
// the RST-tx value on click.
// Green to S9, amber through +20, red above — the Elecraft console's scale.
function sSegColor(frac: number) {
if (frac > 0.78) return '#dc2626';
if (frac > 0.55) return '#f59e0b';
return '#16a34a';
}
function sParts(v: number): { s: number; over: number; label: string } {
if (v >= 47) {
const over = Math.max(0, Math.round((v - 47) * 60 / 47));
@@ -280,291 +364,48 @@ function wfColor(v: number): [number, number, number] {
return WF_STOPS[WF_STOPS.length - 1][1];
}
// ScopePanadapter — enables the rig's spectrum-scope stream and draws the
// reassembled sweep as a modern SDR panadapter: a glowing filled spectrum trace
// on top and a scrolling colour waterfall below. Amplitudes are raw rig scale
// (~0-160), normalised to the tallest recent peak so the trace fills the height.
function ScopePanadapter() {
const { t } = useI18n();
const [on, setOn] = useState(false);
const [fixed, setFixed] = useState(true);
const canvasRef = useRef<HTMLCanvasElement>(null);
const wfRef = useRef<HTMLCanvasElement>(null); // waterfall
const peakRef = useRef(160); // running amplitude ceiling for auto-scale
const holdRef = useRef<number[]>([]); // per-bin peak-hold line
const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune
const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune
const centerRef = useRef(0); // scope centre we last set (for pan ◀/▶)
// The spectrum scope is GONE, deliberately. Every Icom streams its waveform
// differently — the IC-7851 controls a scope it never streams, and a real
// IC-7760 stops answering CI-V altogether a few frames in, taking CAT and
// audio down with it — and chasing a per-model frame layout for a decoration
// is not worth a console that drops the link. The radio has a better scope
// on its own front panel.
const toggle = () => {
const next = !on;
setOn(next);
IcomSetScope(next).catch(() => {});
};
// Centre/pan the FIXED scope: set the edges to centre ±50 kHz (a 100 kHz
// window). "Centre" uses the live VFO; ◀/▶ shift the window by 50 kHz. This
// just writes the rig's fixed edges — simple and independent of the waveform
// decode.
const SCOPE_HALF = 50_000;
const applyEdges = (center: number) => {
if (center <= 0) return;
centerRef.current = center;
setFixed(true);
IcomSetScopeEdges(center - SCOPE_HALF, center + SCOPE_HALF).catch(() => {});
};
const centerOnVfo = async () => {
let c = vfoRef.current;
if (c <= 0) { try { const cs = await GetCATState(); c = cs?.freq_hz || 0; } catch {} }
applyEdges(c);
};
const pan = (dir: number) => applyEdges((centerRef.current || vfoRef.current) + dir * SCOPE_HALF);
const setMode = (nextFixed: boolean) => {
setFixed(nextFixed);
IcomSetScopeMode(nextFixed).catch(() => {});
};
// Stop the stream when the panel unmounts.
useEffect(() => () => { IcomSetScope(false).catch(() => {}); }, []);
useEffect(() => {
if (!on) return;
let raf = 0, lastSeq = -1, alive = true;
const tick = async () => {
if (!alive) return;
try {
const sw = await IcomScopeData();
if (sw && sw.seq !== lastSeq && sw.amp && sw.amp.length) {
lastSeq = sw.seq;
setFixed(sw.fixed);
spanRef.current = { low: sw.low_hz, high: sw.high_hz };
let vfo = 0;
try {
const cs = await GetCATState();
vfo = cs?.split && cs.freq_rx_hz ? cs.freq_rx_hz : (cs?.freq_hz || 0);
} catch {}
if (vfo > 0) vfoRef.current = vfo;
draw(sw.amp, sw.low_hz, sw.high_hz, vfoRef.current, sw.fixed);
}
} catch {}
if (alive) raf = window.setTimeout(() => { raf = requestAnimationFrame(tick); }, 40) as unknown as number;
};
raf = requestAnimationFrame(tick);
return () => { alive = false; cancelAnimationFrame(raf); window.clearTimeout(raf); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [on]);
// Double-click tunes the rig to the clicked frequency.
const onDblClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
const cv = canvasRef.current;
const { low, high } = spanRef.current;
if (!cv || !(low > 0 && high > low)) return;
const rect = cv.getBoundingClientRect();
const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const hz = Math.round((low + frac * (high - low)) / 100) * 100; // nearest 100 Hz
SetCATFrequency(hz).catch(() => {});
};
// Mouse-wheel over the scope QSYs ±100 Hz. Non-passive listener so we can
// preventDefault (else the page scrolls); optimistic vfoRef so quick spins
// accumulate before the poll reconciles.
useEffect(() => {
const el = canvasRef.current;
if (!el || !on) return;
const onWheel = (e: WheelEvent) => {
if (!vfoRef.current) return;
e.preventDefault();
const next = vfoRef.current + (e.deltaY < 0 ? 100 : -100);
vfoRef.current = next;
SetCATFrequency(next).catch(() => {});
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [on]);
const draw = (amp: number[], lowHz: number, highHz: number, vfoHz: number, fixedMode: boolean) => {
const cv = canvasRef.current;
if (!cv) return;
const dpr = window.devicePixelRatio || 1;
const w = cv.clientWidth, h = cv.clientHeight;
if (cv.width !== w * dpr || cv.height !== h * dpr) { cv.width = w * dpr; cv.height = h * dpr; }
const ctx = cv.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// Auto-scale: track the peak, decaying slowly so the floor doesn't jump.
const peak = Math.max(...amp);
peakRef.current = Math.max(peak, peakRef.current * 0.95, 40);
const scale = peakRef.current;
const n = amp.length;
// Background — deep navy vertical gradient.
const bg = ctx.createLinearGradient(0, 0, 0, h);
bg.addColorStop(0, '#0b1220'); bg.addColorStop(1, '#05070e');
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
// Grid.
ctx.strokeStyle = 'rgba(120,150,200,0.08)';
ctx.lineWidth = 1;
for (let i = 1; i < 4; i++) { const y = (h * i) / 4; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
for (let i = 1; i < 8; i++) { const x = (w * i) / 8; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
const xOf = (i: number) => (i / (n - 1)) * w;
const yOf = (v: number) => h - Math.min(1, v / scale) * h;
// Peak-hold line (slow decay) — a faint ghost of recent maxima.
const hold = holdRef.current;
if (hold.length !== n) hold.length = n, hold.fill(0);
for (let i = 0; i < n; i++) hold[i] = Math.max(amp[i], hold[i] * 0.92);
// Filled spectrum area.
ctx.beginPath();
ctx.moveTo(0, h);
for (let i = 0; i < n; i++) ctx.lineTo(xOf(i), yOf(amp[i]));
ctx.lineTo(w, h); ctx.closePath();
const grad = ctx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, 'rgba(56,189,248,0.40)');
grad.addColorStop(1, 'rgba(56,189,248,0.02)');
ctx.fillStyle = grad; ctx.fill();
// Peak-hold trace (thin, faint).
ctx.beginPath();
for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(hold[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }
ctx.strokeStyle = 'rgba(148,197,255,0.35)'; ctx.lineWidth = 1; ctx.stroke();
// Live spectrum trace with a soft glow.
ctx.save();
ctx.shadowColor = 'rgba(56,189,248,0.7)'; ctx.shadowBlur = 6;
ctx.beginPath();
for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(amp[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }
ctx.strokeStyle = '#7dd3fc'; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.stroke();
ctx.restore();
// VFO marker: you should ALWAYS see where you are. Exact position when the VFO
// is inside the span; the centre in CTR mode; clamped to the nearest edge with
// a sideways arrow in FIX mode when the fixed scope doesn't cover the VFO (so
// you can tell which way to tune to get it back on-screen).
const haveVfo = vfoHz > 0 && lowHz > 0 && highHz > lowHz;
const inSpan = haveVfo && vfoHz >= lowHz && vfoHz <= highHz;
let markerX = -1;
let offEdge = 0; // -1 = VFO off the left edge, +1 = off the right
if (inSpan) markerX = ((vfoHz - lowHz) / (highHz - lowHz)) * w;
else if (!fixedMode) markerX = w / 2;
else if (haveVfo) { offEdge = vfoHz < lowHz ? -1 : 1; markerX = offEdge < 0 ? 1 : w - 1; }
if (markerX >= 0) {
const x = markerX;
ctx.fillStyle = 'rgba(244,63,94,0.10)'; ctx.fillRect(x - 5, 0, 10, h);
ctx.strokeStyle = 'rgba(244,63,94,0.9)'; ctx.lineWidth = 1.25;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
ctx.fillStyle = 'rgba(244,63,94,0.95)';
if (offEdge === 0) {
ctx.beginPath(); ctx.moveTo(x - 4, 0); ctx.lineTo(x + 4, 0); ctx.lineTo(x, 6); ctx.closePath(); ctx.fill();
} else {
const yh = 8; ctx.beginPath(); ctx.moveTo(x, yh - 5); ctx.lineTo(x + offEdge * 7, yh); ctx.lineTo(x, yh + 5); ctx.closePath(); ctx.fill();
}
}
// Frequency scale. In fixed mode the rig reports usable edge frequencies, so
// we label low/centre/high from them. In centre mode the header frame's edge
// pair isn't a usable low..high range, but the scope is centred on the VFO —
// so we always label the centre with the live VFO frequency (which we fetch
// each sweep), and only add edge labels when the reported edges genuinely
// bracket the VFO. That guarantees you always see your frequency in CTR.
const mhz = (hz: number) => (hz / 1e6).toFixed(3);
ctx.font = '10px ui-monospace, monospace';
ctx.textBaseline = 'bottom';
ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 3;
ctx.fillStyle = 'rgba(226,232,240,0.85)';
const label = (txt: string, x: number, align: CanvasTextAlign) => { ctx.textAlign = align; ctx.fillText(txt, x, h - 3); };
const validEdges = lowHz > 0 && highHz > lowHz;
if (fixedMode) {
if (validEdges) {
label(mhz(lowHz), 4, 'left');
label(mhz((lowHz + highHz) / 2), w / 2, 'center');
label(mhz(highHz), w - 4, 'right');
}
} else {
if (validEdges && vfoHz >= lowHz && vfoHz <= highHz) {
label(mhz(lowHz), 4, 'left');
label(mhz(highHz), w - 4, 'right');
}
if (vfoHz > 0) label(mhz(vfoHz), w / 2, 'center');
}
ctx.shadowBlur = 0;
drawWaterfall(amp, scale);
};
// drawWaterfall scrolls the history down one row and paints the newest sweep
// as a colour-mapped line at the top.
const drawWaterfall = (amp: number[], scale: number) => {
const cv = wfRef.current;
if (!cv) return;
const w = Math.max(1, cv.clientWidth), h = Math.max(1, cv.clientHeight);
if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
const ctx = cv.getContext('2d');
if (!ctx) return;
// Scroll everything down by one pixel row.
ctx.drawImage(cv, 0, 0, w, h - 1, 0, 1, w, h - 1);
// Paint the new top row.
const row = ctx.createImageData(w, 1);
const n = amp.length;
for (let x = 0; x < w; x++) {
const i = Math.min(n - 1, Math.round((x / (w - 1)) * (n - 1)));
const [r, g, b] = wfColor(amp[i] / scale);
const o = x * 4;
row.data[o] = r; row.data[o + 1] = g; row.data[o + 2] = b; row.data[o + 3] = 255;
}
ctx.putImageData(row, 0, 0);
};
// Collapsible card: when the scope is off, only the header band shows (the
// canvas is hidden entirely) so it doesn't waste vertical space. The CTR/FIX
// and ON/OFF controls live in the header itself.
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">
<Activity className="size-4" style={{ color: '#38bdf8' }} />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('icmp.spectrum')}</span>
<div className="ml-auto flex items-center gap-2 shrink-0">
{on && (
<div className="inline-flex rounded-md border border-border overflow-hidden">
<button type="button" onClick={() => pan(-1)} title={t('icmp.scopePanDown')}
className="px-2 py-1 text-xs font-bold bg-card text-muted-foreground hover:bg-muted border-r border-border"></button>
<button type="button" onClick={centerOnVfo} title={t('icmp.scopeCenterVfo')}
className="px-2 py-1 text-[11px] font-bold bg-card text-muted-foreground hover:bg-muted border-r border-border"></button>
<button type="button" onClick={() => pan(1)} title={t('icmp.scopePanUp')}
className="px-2 py-1 text-xs font-bold bg-card text-muted-foreground hover:bg-muted"></button>
</div>
)}
{on && (
<Segmented value={fixed ? 'FIX' : 'CTR'} options={[{ v: 'CTR', l: 'CTR' }, { v: 'FIX', l: 'FIX' }]}
onChange={(v) => setMode(v === 'FIX')} />
)}
<Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} />
</div>
</div>
{on && (
<div className="p-3">
<div className="rounded-xl overflow-hidden ring-1 ring-info/20 shadow-lg shadow-sky-500/5 bg-[#05070e]">
<canvas ref={canvasRef} onDoubleClick={onDblClick}
className="w-full block cursor-crosshair" style={{ height: 140 }} />
<canvas ref={wfRef} className="w-full block" style={{ height: 96 }} />
</div>
</div>
)}
</div>
);
}
// IcomPanel — full control surface (RX DSP + TX) for an Icom on the CI-V backend.
// Unlike the Flex (which pushes state), the Icom is polled: meters/TX state are
// read every cache cycle; DSP set-controls are optimistic and reconcile on the
// next poll. Front-panel knob changes for DSP show after ↻ Refresh.
export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (rst: string) => void; isNetwork?: boolean } = {}) {
// The speaker toggle lives HERE, next to ON/OFF, because that is where the
// operator is looking — burying "stop listening" behind Settings → Audio
// meant a trip through two panels to mute a radio sitting in the same room.
const [listening, setListening] = useState(false);
useEffect(() => {
if (!isNetwork) return;
let alive = true;
const ask = () => AudioMonitorActive().then((v) => { if (alive) setListening(!!v); }).catch(() => {});
ask();
const id = window.setInterval(ask, 2000);
return () => { alive = false; window.clearInterval(id); };
}, [isNetwork]);
const toggleListening = () => {
const next = !listening;
setListening(next);
(next ? AudioStartMonitor() : Promise.resolve(AudioStopMonitor())).catch(() => setListening(!next));
};
const { t } = useI18n();
const [st, setSt] = useState<IcomState>(ZERO);
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
const [tuning, setTuning] = useState(false);
// Band buttons: recall the radio's own band stacking register instead of
// sending a frequency picked here. Remembered per operator, not per session —
// it is a preference about how a button behaves, and having to set it again
// at every launch would make it not worth having.
const [bandStack, setBandStack] = useState(() => localStorage.getItem('opslog.icomBandStack') === '1');
const toggleBandStack = () => setBandStack((v) => {
const n = !v;
try { localStorage.setItem('opslog.icomBandStack', n ? '1' : '0'); } catch { /* private mode */ }
return n;
});
// Which register each band was last recalled from, so pressing the same band
// again walks 1 → 2 → 3 → 1, exactly as the radio's own band key does.
const bandRegRef = useRef<Record<string, number>>({});
const txRef = useRef(false);
const stRef = useRef<IcomState>(ZERO); stRef.current = st;
@@ -573,6 +414,18 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
GetCATState().then((c) => setCat(c ?? null)).catch(() => {});
};
const setMode = (m: string) => { setCat((c: any) => (c ? { ...c, mode: m } : c)); SetCATMode(m).catch(() => {}); };
// A band button. With the stacking registers on, ask the radio where the
// operator last was on that band; pressing the band it is already on steps to
// the next register, and the fixed frequency below is the fallback for a band
// or a model whose register the backend will not read — never a dead button.
const bandClick = (b: Band, here: boolean) => {
if (!bandStack) { SetCATFrequency(b.hz).catch(() => {}); return; }
const reg = here ? (bandRegRef.current[b.l] ?? 1) % 3 + 1 : 1;
IcomRecallBand(b.l, reg)
.then(() => { bandRegRef.current[b.l] = reg; load(); })
.catch(() => SetCATFrequency(b.hz).catch(() => {}));
};
// Initial one-shot read of the rig's DSP snapshot on mount (the 500ms poll only
// re-reads the cache; the backend also loads DSP on the first responsive read).
const refresh = async () => {
@@ -596,7 +449,9 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
const toggleMox = () => {
const next = !txRef.current;
txRef.current = next;
set({ transmitting: next }, () => IcomSetPTT(next));
// Through the console binding: on a network station the PC microphone
// rides with the PTT (silence otherwise); on USB it keys and nothing more.
set({ transmitting: next }, () => IcomConsolePTT(next));
};
const tune = async () => {
@@ -639,8 +494,13 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
// other is TX (freq_hz); otherwise there's a single VFO (freq_hz).
const split = !!cat?.split;
const mainHz: number = split ? (cat?.freq_rx_hz || 0) : (cat?.freq_hz || 0);
const subHz: number = split ? (cat?.freq_hz || 0) : 0;
const curMode: string = cat?.mode || st.mode || '';
// The sub receiver's dial is worth seeing whether or not split is on —
// in split the CAT state's TX freq is the authority, otherwise the panel's
// own sub_hz read.
const subHz: number = split ? (cat?.freq_hz || 0) : (st.sub_hz || 0);
// The panel's own mode first: it carries the sideband (USB/LSB) where the
// CAT state folds both into ADIF's SSB.
const curMode: string = st.mode || cat?.mode || '';
// Mode-dependent controls: VOX / speech-comp / mic are voice-only (hidden on
// CW and data); APF (audio peak filter) is CW-only. Fold USB/LSB into phone.
const um = curMode.toUpperCase();
@@ -669,6 +529,14 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
rig's LAN server stays alive in standby, so both work. */}
{isNetwork && (
<>
<button type="button" onClick={toggleListening}
title={listening ? t('icmp.speakerOffHint') : t('icmp.speakerOnHint')}
className={cn('inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-bold',
listening
? 'border-primary/60 bg-primary/10 text-primary hover:bg-primary/20'
: 'border-border bg-card text-muted-foreground hover:bg-muted')}>
{listening ? <Volume2 className="size-3.5" /> : <VolumeX className="size-3.5" />}
</button>
<button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')}
className="inline-flex items-center gap-1 rounded-md border border-success/60 bg-success/10 px-2 py-1 text-xs font-bold text-success hover:bg-success/20">
<Power className="size-3.5" /> ON
@@ -710,9 +578,10 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
</div>
</div>
{/* Mode selector row (RS-BA1's SSB/CW/RTTY/PSK/AM/FM). */}
<div className="grid grid-cols-6 border-t border-border/60 divide-x divide-border/60">
{MODES.map((m) => {
const on = modeMatches(m, curMode);
<div className="grid border-t border-border/60 divide-x divide-border/60"
style={{ gridTemplateColumns: `repeat(${modesFor(st.model).length}, minmax(0, 1fr))` }}>
{modesFor(st.model).map((m) => {
const on = modeMatches(m, curMode, mainHz);
return (
<button key={m} type="button" onClick={() => setMode(m)}
className={cn('py-1.5 text-[11px] font-bold tracking-wide transition-colors',
@@ -724,28 +593,41 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
</div>
</div>
{/* Live meters — always visible: S (RX, click → RST), Po in watts, SWR. */}
<div className="rounded-xl border border-border bg-card px-3 py-2.5 shadow-sm grid grid-cols-1 sm:grid-cols-3 gap-x-5 gap-y-2">
{/* Live meters the SAME LED MeterBar every other console uses (Flex,
Elecraft, the amp cards): one instrument look across the app, per the
operator's "les consoles doivent se ressembler". S is clickable RST. */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{(() => { const sp = sParts(st.s_meter); return (
<Meter label="S" value={st.s_meter} accent="#22c55e" scale={sp.label}
<MeterBar label="S-METER" value={st.transmitting ? 0 : st.s_meter} lo={0} hi={100}
accent="#16a34a" segColor={sSegColor}
display={st.transmitting ? '—' : sp.label}
title={onReportRST ? t('rst.clickToFill') : undefined}
onClick={onReportRST ? () => onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} />
onClick={onReportRST ? () => { if (!st.transmitting) onReportRST(sMeterRST(sp.s, sp.over, st.mode)); } : undefined} />
); })()}
<Meter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter} W`} />
<Meter label="SWR" value={st.swr_meter} accent="#f59e0b" scale={st.swr_meter > 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} />
{(() => {
if ((st.model ?? '').includes('7760')) {
const { w, defl } = icomWatts(st.power_meter);
return <MeterBar label="PWR" value={defl} lo={0} hi={100} accent="#0ea5e9" display={`${w} W`} />;
}
return <MeterBar label="PWR" value={st.power_meter} lo={0} hi={100} accent="#0ea5e9" display={`${st.power_meter} W`} />;
})()}
<MeterBar label="SWR" value={st.swr_meter > 0 ? 1 + st.swr_meter / 33.3 : 0} lo={1} hi={4} accent="#f59e0b"
display={st.swr_meter > 0 ? (1 + st.swr_meter / 33.3).toFixed(1) : '—'} />
</div>
{/* Spectrum panadapter (full width). */}
<ScopePanadapter />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Band buttons + antenna selection. */}
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
<label className="flex items-center gap-1.5 mb-1.5 text-[11px] text-muted-foreground cursor-pointer select-none"
title={t('icmp.bandStackHint')}>
<input type="checkbox" checked={bandStack} onChange={toggleBandStack} className="accent-primary" />
{t('icmp.bandStack')}
</label>
<div className="grid grid-cols-5 gap-1.5">
{bandsFor(st.model).map((b) => {
const here = bandOfHz(mainHz) === b.l;
return (
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
<button key={b.l} type="button" onClick={() => bandClick(b, here)}
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
@@ -756,10 +638,12 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
);
})}
</div>
{hasAntennaSelector(st.model) && (
<Row label={t('icmp.antenna')}>
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} />
</Row>
)}
</Card>
{/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */}
@@ -777,9 +661,16 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
<Card icon={Mic} title={t('icmp.transmit')} accent="#ef4444">
<Row label={t('icmp.power')}>
<Slider value={st.rf_power} accent="#ef4444" onChange={(v) => set({ rf_power: v }, () => IcomSetRFPower(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_power}</span>
{/* PC is a percentage of the rig's rated power; on a 200 W rig the
operator thinks in watts, so say it in watts there. */}
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">
{(st.model ?? '').includes('7760') ? `${st.rf_power * 2} W` : st.rf_power}
</span>
</Row>
{isPhone && (
{/* Not phone-only: on USB-D the same control still sets what the radio
transmits at, and hiding it left an operator who lives in FT8 with
no mic gain at all. */}
{(
<Row label={t('icmp.mic')}>
<Slider value={st.mic_gain} accent="#ef4444" onChange={(v) => set({ mic_gain: v }, () => IcomSetMicGain(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_gain}</span>
@@ -792,6 +683,9 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
{tx ? 'TX ON' : 'MOX'}
</button>
<Chip label="SPLIT" on={st.split} onClick={() => set({ split: !st.split }, () => IcomSetSplit(!st.split))} />
{/* Tuner IN/OUT TUNE below starts a cycle but could never take the
tuner back out of line. */}
<Chip label="ATU" on={!!st.atu_on} onClick={() => set({ atu_on: !st.atu_on } as any, () => IcomSetATU(!st.atu_on))} />
<button type="button" onClick={tune} disabled={tuning}
className={cn('w-14 shrink-0 px-2 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
tuning ? 'bg-warning border-warning text-warning-foreground animate-pulse' : 'bg-card text-foreground border-border hover:bg-muted')}>
+60 -16
View File
@@ -4,15 +4,14 @@ import 'leaflet/dist/leaflet.css';
import { nightPolygon } from '../lib/greyline';
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
import { writeUiPref } from '@/lib/uiPref';
import { formatDistance } from '@/lib/units';
import { loadMapView, saveMapView, MAP_VIEW_WORLD } from '@/lib/mapView';
// Persisted free-pan view of the world map (when auto-zoom is off).
function loadMapView(): { lat: number; lon: number; zoom: number } | null {
try { const v = JSON.parse(localStorage.getItem('opslog.mapView') || 'null'); return v && typeof v.zoom === 'number' ? v : null; }
catch { return null; }
}
function saveMapView(m: L.Map) {
const loadWorldView = () => loadMapView(MAP_VIEW_WORLD);
function saveWorldView(m: L.Map) {
const c = m.getCenter();
writeUiPref('opslog.mapView', JSON.stringify({ lat: c.lat, lon: c.lng, zoom: m.getZoom() }));
saveMapView(MAP_VIEW_WORLD, c.lat, c.lng, m.getZoom());
}
// The Main tab is built from two independent map panes that the operator can
@@ -27,6 +26,26 @@ function saveMapView(m: L.Map) {
// unwrapLon makes a lat/lon ring continuous in longitude (each point within
// 180° of the previous) so a polygon crossing the antimeridian doesn't snap
// across the whole map. Coords may exceed ±180; Leaflet (worldCopyJump) is fine.
// homeView is where a world map should open for THIS station.
//
// Centred on 0° a world map puts Europe in the middle and leaves an Australian
// looking at their own country in the bottom-right corner, with the paths to
// everywhere they work running off both edges. Centred on their own longitude
// the same map reads the way their antenna does: the Americas to the east,
// Europe and Africa to the west. Leaflet repeats the world horizontally
// (worldCopyJump), so this is a choice of viewpoint and costs nothing — the
// tiles either side are the same world, and the great-circle code already draws
// across the seam (see unwrapLon).
//
// The latitude is damped, not followed: a station at 69° north centred on itself
// would spend half the map on the Arctic. It leans towards the operator's
// hemisphere and stops well short of the pole.
function homeView(grid: string): [number, number] {
const home = gridToLatLon(grid);
if (!home) return [20, 0];
return [Math.max(-35, Math.min(45, home.lat * 0.6 + 8)), home.lon];
}
function unwrapLon(ring: [number, number][]): [number, number][] {
const out: [number, number][] = [];
let prev = NaN;
@@ -177,31 +196,50 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
const autoZoomRef = useRef(autoZoom);
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
// WAIT FOR THE SQUARE BEFORE PAINTING.
//
// The station's own locator arrives from the profile a moment after this
// component mounts. Building the map immediately meant painting the world at
// 0°, then moving it to the operator's longitude — a screenful of tiles
// fetched from Esri and thrown away on every first run. The map is built once,
// when it knows where it is looking.
//
// Not for ever, though: an operator with no locator set (or a profile still
// loading after two seconds) gets the default view rather than a blank panel.
const [mapReady, setMapReady] = useState(() => !!gridToLatLon(fromGrid) || !!loadWorldView());
useEffect(() => {
if (mapReady) return;
if (gridToLatLon(fromGrid)) { setMapReady(true); return; }
const t = window.setTimeout(() => setMapReady(true), 2000);
return () => window.clearTimeout(t);
}, [fromGrid, mapReady]);
// One-time map creation.
useEffect(() => {
if (worldRef.current && !worldMap.current) {
if (worldRef.current && !worldMap.current && mapReady) {
// preferCanvas: the beam lobe is a dense FAN of translucent radials — up to
// ~120 thick strokes with a bidirectional Ultrabeam. As SVG that is ~120
// composited paths re-rasterised on every pan, zoom and redraw, which is
// enough to blow WebView2's raster budget and leave the window painting in
// patches. On canvas it is a single layer.
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true, preferCanvas: true })
.setView([20, 0], 1);
.setView(homeView(fromGrid), 1);
addBasemap(m, basemap, baseLayer, labelsLayer);
worldOverlay.current = L.layerGroup().addTo(m);
worldMap.current = m;
const sv = loadMapView();
const sv = loadWorldView();
if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom);
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
m.on('moveend', () => { if (!autoZoomRef.current) saveWorldView(m); });
}
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
// Resizing the pane is the ONLY thing that needs invalidateSize. Calling it on
// every overlay redraw (as before) forced a full repaint of the map each time
// the rotor moved a degree.
const ro = new ResizeObserver(() => worldMap.current?.invalidateSize());
if (worldRef.current) ro.observe(worldRef.current);
return () => { window.clearTimeout(t); ro.disconnect(); };
}, []);
}, [mapReady]);
// Swap the basemap (and its optional place-name overlay) when the operator
// picks a different one. Vector overlays (path/beam) live in Leaflet's
@@ -369,8 +407,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
if (autoZoom) {
if (from && to && arcPts) {
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
arcPts.forEach((p) => bounds.extend(p as L.LatLngExpression));
// Latitudes clamped to Mercator's edge (±85°): the arc to a polar
// entity (Franz Josef Land) peaks near 88°N, and fitting the raw
// points framed a band of tile-less white above the top of the world.
// The line itself still draws to wherever it goes — only the CAMERA
// stays where there is a map to show.
const clamp = (lat: number) => Math.max(-85, Math.min(85, lat));
const bounds = L.latLngBounds([[clamp(from.lat), from.lon], [clamp(to.lat), to.lon]]);
arcPts.forEach((p) => bounds.extend([clamp(p[0]), p[1]] as L.LatLngExpression));
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
} else if (to) {
wm.setView([to.lat, to.lon], 3);
@@ -420,7 +464,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
setAutoZoom(v);
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
const m = worldMap.current;
if (!v && m) saveMapView(m);
if (!v && m) saveWorldView(m);
}}
title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'}
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
@@ -446,8 +490,8 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
</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
<span className="text-muted-foreground"> · LP</span> {Math.round(path.distanceLong).toLocaleString()} km</div>
<div><span className="text-muted-foreground">Dist</span> {formatDistance(path.distanceShort)}
<span className="text-muted-foreground"> · LP</span> {formatDistance(path.distanceLong)}</div>
<div><span className="text-muted-foreground">Az SP</span> {Math.round(path.bearingShort)}°
<span className="text-muted-foreground"> · LP</span> {Math.round(path.bearingLong)}°</div>
</div>
@@ -0,0 +1,329 @@
// PSKReporterPanel — can the station I am about to call actually hear me?
//
// The decodes list to the left says who is transmitting. It cannot say anything
// about the other direction, and on FT8 that is the whole question: the DX's
// pileup is invisible from here, and a station whose region is not open to
// yours will not hear you however many times you call.
//
// Every number here comes from PSK Reporter — reports uploaded by ordinary
// stations saying "I decoded X" — over a five-minute window. Nothing is
// inferred and nothing is remembered: when the window empties the panel says it
// does not know, which is the honest answer and the reason each block also says
// what it is measuring.
//
// The backend (internal/pskrtgt) does the analysis; this draws it and polls.
import { useEffect, useMemo, useState } from 'react';
import { Activity, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { GetPSKAnalysis, SetPSKTarget } from '../../wailsjs/go/main/App';
export type PSKEntry = {
call: string;
grid?: string;
snr: number;
offset_hz: number;
age_sec: number;
};
export type PSKAnalysis = {
target?: string;
mode?: string;
enabled: boolean;
online: boolean;
spots: number;
he_me: boolean;
he_me_seconds: number;
he_me_snr: number;
he_me_offset_hz: number;
target_uploads: boolean;
target_grid?: string;
near_him_count: number;
near_him_top?: PSKEntry[];
from_my_area_count: number;
from_my_area_top?: PSKEntry[];
path_open: boolean;
heard_by_count: number;
heard_near_me: number;
heard_near_me_top?: PSKEntry[];
decoded_by_count: number;
decoded_by_top?: PSKEntry[];
decoded_by_calls?: string[];
pileup_count: number;
dial_hz: number;
ceiling_hz: number;
decodes_in_window: number;
bins?: { offset_hz: number; count: number; avg_snr: number }[];
suggested_offset: number;
};
interface Props {
// The station to analyse and the mode it was heard on. Set by clicking a
// decode, or by whoever the digital application says it is calling.
target: string;
mode?: string;
// The operator's own dial, which is what turns a report's frequency into an
// audio offset. Without it the passband block has nothing to say.
dialHz?: number;
// The local decodes, for "callers you hear": stations WE are decoding that
// are calling the same DX. That is the competition measured at this end,
// which no amount of PSK Reporter data can show.
callers: number;
callerCalls?: string[];
onCollapse: () => void;
}
// The passband strip: 60 Hz bins, drawn from 200 Hz to 4000 Hz. The bin edges
// have to match the backend's alignment exactly — it keys them on multiples of
// 60 from zero, so a strip starting at 200 would ask for edges that never
// exist and draw an empty histogram over a busy passband.
const LO = 200, HI = 4000, STEP = 60;
const FIRST_EDGE = Math.floor(LO / STEP) * STEP;
const COLS = Math.floor((HI - FIRST_EDGE) / STEP);
export function PSKReporterPanel({ target, mode, dialHz, callers, callerCalls, onCollapse }: Props) {
const { t } = useI18n();
const [a, setA] = useState<PSKAnalysis | null>(null);
// One second, matching the panel's own claim about how fresh it is. The call
// is a snapshot of an in-memory window — no query and no network of its own.
useEffect(() => {
let stop = false;
const tick = () => {
GetPSKAnalysis().then((r) => { if (!stop) setA(r as unknown as PSKAnalysis); }).catch(() => {});
};
tick();
const id = window.setInterval(tick, 1000);
return () => { stop = true; window.clearInterval(id); };
}, []);
// The target is re-asserted rather than sent once. The backend treats an
// unchanged callsign as a no-op, and this way a broker that dropped while
// nobody was looking comes back on its own instead of leaving a panel that
// is permanently, silently empty.
useEffect(() => {
SetPSKTarget(target ?? '', mode ?? '', dialHz ?? 0).catch(() => {});
if (!target) return;
const id = window.setInterval(() => {
SetPSKTarget(target, mode ?? '', dialHz ?? 0).catch(() => {});
}, 15000);
return () => window.clearInterval(id);
}, [target, mode, dialHz]);
const bins = a?.bins ?? [];
const maxCount = useMemo(() => bins.reduce((m, b) => Math.max(m, b.count || 0), 0) || 1, [bins]);
const byOffset = useMemo(() => {
const m = new Map<number, { count: number; avg_snr: number }>();
for (const b of bins) m.set(b.offset_hz, b);
return m;
}, [bins]);
const columns = useMemo(() => {
const out: { edge: number; count: number; snr: number | null }[] = [];
for (let i = 0; i < COLS; i++) {
const edge = FIRST_EDGE + i * STEP;
const b = byOffset.get(edge);
out.push({ edge, count: b?.count ?? 0, snr: b?.avg_snr ?? null });
}
return out;
}, [byOffset]);
// Confirmed pileup: a station we hear calling this DX that the DX has also
// decoded. Two independent pieces of evidence, so it is the one number here
// that is not a proxy for anything.
const confirmed = useMemo(() => {
const heard = new Set((a?.decoded_by_calls ?? []).map((c) => c.toUpperCase()));
return (callerCalls ?? []).filter((c) => heard.has(c.toUpperCase())).length;
}, [a?.decoded_by_calls, callerCalls]);
const snr = (v: number) => `${v > 0 ? '+' : ''}${v}`;
const Tile = ({ label, value, foot, tone, title }: {
label: string; value: number | string; foot: string; tone: string; title?: string;
}) => (
<div className="px-2 py-1.5 rounded-md bg-muted/40 border border-border/60" title={title}>
<div className="text-[9px] uppercase tracking-wide text-muted-foreground">{label}</div>
<div className={cn('text-lg font-bold leading-tight', tone)}>{value}</div>
<div className="text-[10px] text-muted-foreground">{foot}</div>
</div>
);
return (
<div className="w-[340px] shrink-0 flex flex-col min-h-0 border-l border-border bg-card">
{/* Header: what is being watched, and whether the feed is actually up. A
panel full of zeros means one of two very different things. */}
<div className="flex items-center gap-2 px-2.5 py-2 border-b border-border shrink-0">
<Activity className="size-4 text-primary shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('psk.title')}
</span>
{target && <span className="text-xs font-mono text-foreground truncate"> {target}</span>}
<span className="ml-auto flex items-center gap-2 text-[10px] shrink-0">
{a?.target && a.spots > 0 && (
<span className="text-muted-foreground" title={t('psk.spotsTip')}>{t('psk.spots', { n: a.spots })}</span>
)}
{a?.enabled === false
? <span className="text-muted-foreground">{t('psk.off')}</span>
: a?.online
? <span className="text-success"> {t('psk.online')}</span>
: <span className="text-muted-foreground"> {t('psk.offline')}</span>}
<button type="button" onClick={onCollapse} title={t('psk.hide')}
className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground">
<ChevronRight className="size-4" />
</button>
</span>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-2.5 py-2 space-y-3">
{a?.enabled === false ? (
<p className="text-xs text-muted-foreground italic">{t('psk.enableHint')}</p>
) : !target ? (
<p className="text-xs text-muted-foreground italic">{t('psk.pickHint')}</p>
) : (
<>
{/* ── The answer ──────────────────────────────────────────── */}
<div className="flex items-center gap-3">
<div className={cn('shrink-0 size-8 rounded-full border flex items-center justify-center text-base',
a?.he_me ? 'bg-success/20 border-success/50 text-success'
: a?.path_open ? 'bg-warning/20 border-warning/50 text-warning'
: 'bg-muted border-border text-muted-foreground')}>
{a?.he_me ? '✓' : a?.path_open ? '≈' : '·'}
</div>
<div className="min-w-0">
{a?.he_me ? (
<>
<div className="text-sm font-semibold text-success">{t('psk.heardYou', { s: a.he_me_seconds })}</div>
<div className="text-[11px] font-mono text-muted-foreground">
{snr(a.he_me_snr)} dB{a.he_me_offset_hz > 0 ? ` @ +${a.he_me_offset_hz} Hz` : ''}
</div>
</>
) : a?.path_open ? (
<>
<div className="text-sm font-semibold text-warning">{t('psk.pathOpen')}</div>
<div className="text-[11px] text-muted-foreground">{t('psk.pathOpenSub', { n: a.from_my_area_count })}</div>
</>
) : (
<>
<div className="text-sm font-semibold text-muted-foreground">{t('psk.notYet')}</div>
<div className="text-[11px] text-muted-foreground">{t('psk.notYetSub')}</div>
</>
)}
</div>
</div>
{/* Your signal reported next to him. Only when he has not decoded
you himself that is strictly stronger evidence, and two
banners saying the same thing differently is noise. */}
{!a?.he_me && (a?.near_him_count ?? 0) > 0 && a?.target_grid && (
<div className="px-2 py-1.5 rounded-md bg-info/10 border border-info/30">
<div className="flex items-baseline justify-between gap-2 mb-0.5">
<span className="text-[10px] uppercase tracking-wide font-semibold text-info">
{t('psk.nearHim', { g: a.target_grid })}
</span>
<span className="text-[10px] text-muted-foreground">{t('psk.nRx', { n: a.near_him_count })}</span>
</div>
<div className="flex flex-wrap gap-x-2 font-mono text-[11px]">
{(a.near_him_top ?? []).map((h) => (
<span key={h.call} className="text-info" title={`${h.call} ${h.grid ?? ''} · ${h.age_sec}s`}>
{h.call} <span className="text-muted-foreground">{snr(h.snr)}</span>
</span>
))}
</div>
</div>
)}
{/* ── The four numbers ────────────────────────────────────── */}
<div className="grid grid-cols-2 gap-1.5">
<Tile label={t('psk.tFromArea')} value={a?.from_my_area_count ?? 0} foot={t('psk.tFromAreaFoot')}
tone="text-success"
title={(a?.from_my_area_top ?? []).map((h) => `${h.call} (${h.grid ?? '?'}) ${snr(h.snr)}`).join(' · ')} />
<Tile label={t('psk.tPileup')} value={a?.pileup_count ?? 0} foot={t('psk.tPileupFoot')}
tone="text-primary" title={t('psk.tPileupTip')} />
<Tile label={t('psk.tHeardNear')} value={a?.heard_near_me ?? 0} foot={t('psk.tHeardNearFoot')}
tone="text-info"
title={t('psk.tHeardNearTip', { n: a?.heard_by_count ?? 0 })} />
<Tile label={t('psk.tCallers')} value={confirmed > 0 ? `${callers} (${confirmed})` : callers}
foot={confirmed > 0 ? t('psk.tCallersFootConf') : t('psk.tCallersFoot')}
tone="text-warning" title={t('psk.tCallersTip')} />
</div>
{/* The one thing that turns an empty panel from a verdict into a
missing measurement. */}
{a?.target_uploads ? (
<div className="text-[11px] text-success"> {t('psk.uploads')}</div>
) : (
<div className="px-2 py-1.5 rounded-md bg-warning/10 border border-warning/30 text-[11px] text-warning">
{t('psk.noUploads', { c: target })}
</div>
)}
{/* ── Who near you he is hearing ──────────────────────────── */}
<div>
<div className="text-[10px] uppercase tracking-wide text-muted-foreground mb-0.5">{t('psk.fromAreaList')}</div>
{(a?.from_my_area_top ?? []).length > 0 ? (
<div className="flex flex-col gap-0.5 font-mono text-[11px]">
{(a?.from_my_area_top ?? []).slice(0, 4).map((h) => (
<div key={h.call} className="flex items-baseline gap-2 truncate"
title={t('psk.rowTip', { c: h.call, g: h.grid ?? '?', s: h.age_sec, d: snr(h.snr) })}>
<span className="font-semibold text-foreground w-20 truncate">{h.call}</span>
<span className="text-muted-foreground w-12">({(h.grid ?? '?').slice(0, 4)})</span>
<span className="text-success w-14">{snr(h.snr)} dB</span>
{h.offset_hz > 0 && h.offset_hz < 10000 && (
<span className="ml-auto text-muted-foreground whitespace-nowrap">@ +{h.offset_hz} Hz</span>
)}
</div>
))}
</div>
) : (
<div className="text-[11px] text-muted-foreground italic">{t('psk.fromAreaEmpty')}</div>
)}
</div>
{/* ── His passband ────────────────────────────────────────── */}
<div>
<div className="flex items-baseline justify-between gap-2 mb-1">
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">{t('psk.passband')}</span>
<span className="text-[10px] font-mono text-muted-foreground">
{(a?.ceiling_hz ?? 0) > 0
? t('psk.ceiling', { hz: a!.ceiling_hz, n: a!.decodes_in_window })
: (a?.decodes_in_window ?? 0) > 0 ? t('psk.noDial') : t('psk.noDecodes')}
</span>
</div>
<div className="relative flex items-end gap-px h-10 rounded bg-muted/40 px-1 py-0.5 overflow-hidden">
{columns.map((c) => {
const ratio = c.count / maxCount;
return (
<div key={c.edge}
className={cn('flex-1 min-w-0 rounded-sm',
c.count === 0 ? 'bg-border'
: ratio > 0.66 ? 'bg-primary'
: ratio > 0.33 ? 'bg-primary/70' : 'bg-primary/40')}
style={{ height: `${Math.max(2, Math.round(ratio * 36))}px` }}
title={`${c.edge}-${c.edge + STEP} Hz · ${c.count}${c.snr !== null ? ` @ ${c.snr.toFixed(0)} dB` : ''}`} />
);
})}
{(a?.suggested_offset ?? 0) > 0 && (
<div className="absolute top-0 bottom-0 w-0.5 bg-success pointer-events-none"
style={{ left: `${((a!.suggested_offset - LO) / (HI - LO)) * 100}%`, boxShadow: '0 0 4px currentColor' }}
title={t('psk.tryOffset', { hz: a!.suggested_offset })} />
)}
</div>
<div className="relative h-3 mt-0.5 text-[9px] font-mono text-muted-foreground">
{[1000, 2000, 3000, 4000].map((hz) => (
<span key={hz} className="absolute whitespace-nowrap"
style={{ left: `${((hz - LO) / (HI - LO)) * 100}%`, transform: `translateX(${hz === HI ? '-100%' : '-50%'})` }}>
{hz}
</span>
))}
</div>
{(a?.suggested_offset ?? 0) > 0 && (
<div className="text-center text-[11px] font-mono text-success mt-0.5">
🎯 {t('psk.tryOffset', { hz: a!.suggested_offset })}
</div>
)}
</div>
</>
)}
</div>
</div>
);
}
+63 -3
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, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
import { GetLoTWQSLDetail, SetLoTWQSLDetail, GetLoTWDownloadAllCalls, SetLoTWDownloadAllCalls, OpenExternalURL, FindQSOsForUpload, UploadFullLogHamQTH, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, 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';
@@ -31,7 +31,7 @@ const UPLOAD_COLS: ColDef<UploadRow>[] = [
];
type Confirmation = {
callsign: string; qso_date: string; band: string; mode: string; country: string;
callsign: string; station?: string; qso_date: string; band: string; mode: string; country: string;
new_dxcc: boolean; new_band: boolean; new_mode: boolean; new_slot: boolean;
};
@@ -42,6 +42,7 @@ const SERVICES = [
{ v: 'eqsl', label: 'eQSL.cc' },
{ v: 'lotw', label: 'LoTW' },
{ v: 'hamlog', label: 'HAMLOG.online' },
{ v: 'hamqth', label: 'HamQTH' },
{ v: 'pota', label: 'POTA hunter log' },
{ v: 'paper', label: 'Paper QSL' },
];
@@ -255,6 +256,15 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
const [searching, setSearching] = useState(false);
const [error, setError] = useState('');
const [addNotFound, setAddNotFound] = useState(false);
// LoTW only: pull the whole account rather than this profile's callsign.
const [lotwAllCalls, setLotwAllCalls] = useState(false);
// LoTW only: ask for the QSL dates and station details. Ten times slower to
// build, so it is a choice rather than the default it used to be.
const [lotwDetail, setLotwDetail] = useState(false);
useEffect(() => {
GetLoTWDownloadAllCalls().then((v: boolean) => setLotwAllCalls(!!v)).catch(() => {});
GetLoTWQSLDetail().then((v: boolean) => setLotwDetail(!!v)).catch(() => {});
}, []);
// Download date window: 'last' = incremental since last pull, 'date' = from a
// chosen date, 'all' = everything.
const [sinceMode, setSinceMode] = useState<'last' | 'date' | 'all'>('last');
@@ -434,6 +444,18 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
{paperBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
{t('qslm.search')}
</Button>
{/* The station's QRZ page, one click away: writing a card means
reading the address, the manager and whether they even want
paper, and all three are on that page. */}
<Button size="sm" variant="outline" className="h-8" disabled={!paperCall.trim()}
title={t('qslm.qrzTitle')}
onClick={() => {
const c = paperCall.trim().toUpperCase();
if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch(() => {});
}}>
<ExternalLink className="size-3.5" />
QRZ
</Button>
<span className="text-[11px] text-muted-foreground self-center">{t('qslm.paperHint')}</span>
</>
) : (
@@ -598,6 +620,10 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 px-2">{t('qslm.thDateUtc')}</th><th className="py-1.5 px-2">{t('qslm.thCallsign')}</th>
{/* Which of the operator's callsigns the contact was made
under. Only worth a column when the list can hold more than
one see "All my callsigns" on the download. */}
{shownConfs.some((c) => c.station) && <th className="py-1.5 px-2">{t('qslm.thStation')}</th>}
<th className="py-1.5 px-2">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thMode')}</th>
<th className="py-1.5 px-2">{t('qslm.thCountry')}</th><th className="py-1.5 px-2">{t('qslm.thNew')}</th>
</tr>
@@ -607,6 +633,9 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
<tr key={i} className="border-b border-border/40">
<td className="py-1 px-2 font-mono">{fmtDate(c.qso_date)}</td>
<td className="py-1 px-2 font-mono font-bold">{c.callsign}</td>
{shownConfs.some((x) => x.station) && (
<td className="py-1 px-2 font-mono text-muted-foreground">{c.station ?? ''}</td>
)}
<td className="py-1 px-2">{c.band}</td>
<td className="py-1 px-2">{c.mode}</td>
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
@@ -694,7 +723,25 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
{service !== 'pota' && service !== 'paper' && (
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
<div className="flex items-center gap-2 flex-wrap">
{service === 'hamlog' ? (
{service === 'hamqth' ? (
// HamQTH's file endpoint REPLACES the remote log — its own
// documentation is explicit that partial uploads do not exist. So
// it is offered as its own deliberate act, never as the batch path
// behind "send these": that would delete everything not selected.
<Button variant="outline" size="sm" disabled={busy}
title={t('qslm.hqFullTitle')}
onClick={async () => {
if (!window.confirm(t('qslm.hqFullConfirm'))) return;
// Same three lines every other action here runs: without
// setShowLog the whole upload reported itself into a panel
// nobody was showing, and the tab sat on "Pick a service".
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
try { await UploadFullLogHamQTH(); }
catch (e: any) { setBusy(false); setLogLines((l) => [...l, String(e?.message ?? e)]); }
}}>
<UploadCloud className="size-3.5" /> {t('qslm.hqFull')}
</Button>
) : service === 'hamlog' ? (
<>
<Button variant="outline" size="sm" onClick={importHamlogCfm} disabled={busy}
title={t('qslm.hamlogImportTitle')}>
@@ -736,6 +783,19 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
{t('qslm.addNotFound')}
</label>
{service === 'lotw' && (
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title={t('qslm.lotwDetailTitle')}>
<Checkbox checked={lotwDetail || addNotFound} disabled={addNotFound}
onCheckedChange={(c) => { setLotwDetail(!!c); SetLoTWQSLDetail(!!c); }} />
{t('qslm.lotwDetail')}
</label>
)}
{service === 'lotw' && (
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title={t('qslm.lotwAllCallsTitle')}>
<Checkbox checked={lotwAllCalls} onCheckedChange={(c) => { setLotwAllCalls(!!c); SetLoTWDownloadAllCalls(!!c); }} />
{t('qslm.lotwAllCalls')}
</label>
)}
</>)}
</div>
<Button size="sm" onClick={upload} disabled={selectedCount === 0 || busy}>
+2 -1
View File
@@ -34,7 +34,8 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
{ service: 'hrdlog', name: 'HRDLog.net' },
{ service: 'eqsl', name: 'eQSL.cc' },
{ service: 'lotw', name: 'LoTW' },
{ service: 'hamlog', name: 'HAMLOG.online' },
{ service: 'hamqth', name: 'HamQTH' },
{ service: 'cloudlog', name: 'Cloudlog / Wavelog' },
];
// Lightweight right-click menu for the QSO grids. AG Grid's native context
+94 -37
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
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 { rstOptions, stepRST, type RSTLists } from '@/lib/rst';
import { AwardRefSelector } from '@/components/AwardRefSelector';
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
import { applyAwardRefs } from '@/lib/awardRefs';
@@ -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 { bandForMHz } from '@/lib/bandplan';
import { titleCase, sentenceCase } from '@/lib/textCase';
import type { QSOForm } from '@/types';
@@ -71,7 +72,7 @@ const CONFIRMATIONS: ConfDef[] = [
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any },
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any, rcvd: 'clublog_qso_download_status' as any, rcvdDate: 'clublog_qso_download_date' as any },
{ key: 'HRDLOG', label: 'HRDLog', sent: 'hrdlog_qso_upload_status' as any, sentDate: 'hrdlog_qso_upload_date' as any },
];
// i18n label keys for confirmation channels whose label has translatable words
@@ -80,6 +81,19 @@ const CONF_LABEL_KEYS: Record<string, string> = {
QSL: 'qedit.confQslPaper',
};
// Reading order for the channel list: the two that carry an ARRL award first —
// paper QSL and LoTW — then everything else alphabetically, so a channel is
// found by its name rather than by remembering the order it was added in.
const CONF_FIRST: Record<string, number> = { QSL: 0, LOTW: 1 };
function confOrder<T extends { key: string; label: string }>(rows: T[]): T[] {
return rows.slice().sort((a, b) => {
const ra = CONF_FIRST[a.key] ?? 2;
const rb = CONF_FIRST[b.key] ?? 2;
if (ra !== rb) return ra - rb;
return a.label.localeCompare(b.label);
});
}
// 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.
@@ -98,6 +112,15 @@ const HAMLOG_KEYS = {
rcvdDate: 'APP_OPSLOG_HAMLOG_QSL_DATE',
};
// HamQTH — extras again (ADIF names no HamQTH field), and SENT only: the site
// publishes no confirmation feed, so a received column here would be a promise
// nothing can keep.
const HAMQTH_CONF = 'HAMQTH';
const HAMQTH_KEYS = {
sent: 'APP_OPSLOG_HAMQTH_SENT',
sentDate: 'APP_OPSLOG_HAMQTH_SENT_DATE',
};
// Colour-coded status cell for the confirmation grid.
function StatusCell({ value }: { value?: string }) {
const { t } = useI18n();
@@ -290,6 +313,19 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
const splitHz = (hz?: number) => hz
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
: { khz: '', hz: '' };
// Correcting a frequency corrects its band. The pair has to agree — the log,
// every award and every upload are read on the BAND — and an operator fixing
// a wrong frequency is not also expecting to fix the band by hand, which is
// exactly how a QSO ends up filed on 20m at 7 MHz.
//
// Only when the number lands in a known allocation: half a frequency is typed
// on the way to all of it, and a band must never be blanked by that.
const syncBand = (khz: string, hz: string, field: 'band' | 'band_rx') => {
if (!khz.trim()) return;
const b = bandForMHz((parseInt(khz, 10) * 1000 + (parseInt(hz, 10) || 0)) / 1_000_000);
if (b) set(field, b as any);
};
const f0 = splitHz(draft.freq_hz);
const fr0 = splitHz(draft.freq_rx_hz);
const [freqKHz, setFreqKHz] = useState(f0.khz);
@@ -562,9 +598,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
</div>
<div className="flex flex-col w-20"><Label>S</Label>
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_sent', v)} /></div>
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType
onChange={(v) => set('rst_sent', v)}
onWheelStep={(d) => set('rst_sent', stepRST(draft.rst_sent ?? '', d, draft.mode ?? ''))} /></div>
<div className="flex flex-col w-20"><Label>R</Label>
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_rcvd', v)} /></div>
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType
onChange={(v) => set('rst_rcvd', v)}
onWheelStep={(d) => set('rst_rcvd', stepRST(draft.rst_rcvd ?? '', d, draft.mode ?? ''))} /></div>
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
title={t('qedit.fetchTitle')}>
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
@@ -612,13 +652,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
</div>
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">{t('qedit.txFreq')}</Label>
<Input value={freqKHz} onChange={(e) => setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
<Input value={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
<Input value={freqKHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqKHz(v); syncBand(v, freqHz, 'band'); }} className="font-mono w-24" placeholder="kHz" />
<Input value={freqHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqHz(v); syncBand(freqKHz, v, 'band'); }} maxLength={3} className="font-mono w-16" placeholder="Hz" />
</div>
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">{t('qedit.rxFreq')}</Label>
<Input value={freqRxKHz} onChange={(e) => setFreqRxKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
<Input value={freqRxHz} onChange={(e) => setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
<Input value={freqRxKHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqRxKHz(v); syncBand(v, freqRxHz, 'band_rx'); }} className="font-mono w-24" placeholder="kHz" />
<Input value={freqRxHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqRxHz(v); syncBand(freqRxKHz, v, 'band_rx'); }} maxLength={3} className="font-mono w-16" placeholder="Hz" />
</div>
</div>
@@ -747,19 +787,34 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<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>)}
{/* Listed here but NOT in CONFIRMATIONS: that table maps
{confOrder([
...CONFIRMATIONS.map((c) => ({ key: c.key, label: CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label })),
{ key: OPSLOG_CONF, label: t('qedit.confOpsLog') },
{ key: HAMLOG_CONF, label: 'HAMLOG.online' },
{ key: HAMQTH_CONF, label: 'HamQTH' },
]).map((c) => <SelectItem key={c.key} value={c.key}>{c.label}</SelectItem>)}
{/* The three above that are NOT in CONFIRMATIONS the
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>
<SelectItem value={HAMLOG_CONF}>HAMLOG.online</SelectItem>
OpsLog card, HAMLOG.online and HamQTH are backed by
ADIF extras rather than QSO columns, and each gets its
own editor below instead of the generic
sent/received/date grid, which has no field to bind
to. */}
</SelectContent>
</Select>
</div>
{confSel === HAMLOG_CONF ? (
{confSel === HAMQTH_CONF ? (
<>
<div className="grid grid-cols-2 gap-3">
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMQTH_KEYS.sent)} onChange={(v) => exPut(HAMQTH_KEYS.sent, v)} /></div>
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={exVal(HAMQTH_KEYS.sentDate)} onChange={(v) => exPut(HAMQTH_KEYS.sentDate, v)} /></div>
</div>
<p className="text-[11px] text-muted-foreground">
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
</p>
</>
) : confSel === HAMLOG_CONF ? (
<>
<div className="grid grid-cols-2 gap-3">
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMLOG_KEYS.sent)} onChange={(v) => exPut(HAMLOG_KEYS.sent, v)} /></div>
@@ -845,30 +900,32 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
</tr>
</thead>
<tbody>
{CONFIRMATIONS.map((c) => (
{/* The extras-backed channels (OpsLog card,
HAMLOG.online, HamQTH) sit in the same ordered list
as the column-backed ones: the reader is looking for
a name, not for a storage detail. A dash in RECEIVED
means the channel has nothing to receive Club Log
and HamQTH publish no confirmations which is not
the same statement as "N". */}
{confOrder([
...CONFIRMATIONS.map((c) => ({
key: c.key,
label: CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label,
sent: val(c.sent),
rcvd: c.rcvd ? val(c.rcvd) : null,
})),
{ key: OPSLOG_CONF, label: t('qedit.confOpsLog'), sent: opslogQslSent ? 'Y' : 'N', rcvd: qslReceived ? 'Y' : 'N' },
{ key: HAMLOG_CONF, label: 'HAMLOG.online', sent: exVal(HAMLOG_KEYS.sent), rcvd: exVal(HAMLOG_KEYS.rcvd) },
{ key: HAMQTH_CONF, label: 'HamQTH', sent: exVal(HAMQTH_KEYS.sent), rcvd: null },
]).map((c) => (
<tr key={c.key} className="text-xs">
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground"></span>}</td>
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{c.label}</td>
<td className="w-24"><StatusCell value={c.sent} /></td>
<td className="w-24">{c.rcvd === null
? <span className="block text-center text-[11px] text-muted-foreground"></span>
: <StatusCell value={c.rcvd} />}</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>
{/* HAMLOG.online — extras again, same hand-written row. */}
<tr className="text-xs">
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">HAMLOG.online</td>
<td className="w-24"><StatusCell value={exVal(HAMLOG_KEYS.sent)} /></td>
<td className="w-24"><StatusCell value={exVal(HAMLOG_KEYS.rcvd)} /></td>
</tr>
</tbody>
</table>
</div>
+17 -4
View File
@@ -9,6 +9,7 @@ import { formatDateTimeUTC, formatDateOnly, getDateFormat, subscribeDateFormat }
import { AgGridReact } from 'ag-grid-react';
import { Columns3, FilterX, ListChecks } from 'lucide-react';
import type { QSOForm } from '@/types';
import { distanceUnit, distanceValue, subscribeDistanceUnit } from '@/lib/units';
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
@@ -177,8 +178,9 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
{ 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),
{ group: 'Contacted', label: t('rqg.c.distance_km'), colId: 'distance_km',
headerName: t('rqg.h.distance_km') + ' (' + distanceUnit() + ')', width: 95, type: 'rightAligned', cellClass: 'font-mono',
valueGetter: (p) => { const km = qsoDistanceKm(p.data, myGrid); return km ? distanceValue(km) : km; },
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 },
@@ -217,6 +219,9 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
{ group: 'Uploads', label: t('rqg.c.hamlog_sent_date'), colId: 'hamlog_sent_date', headerName: t('rqg.h.hamlog_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_SENT_DATE']), defaultVisible: false },
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd'), colId: 'hamlog_rcvd', headerName: t('rqg.h.hamlog_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_HAMLOG_QSO_CFM'] || e['APP_OPSLOG_HAMLOG_QSL'] || 'N'; }, defaultVisible: false },
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd_date'), colId: 'hamlog_rcvd_date', headerName: t('rqg.h.hamlog_rcvd_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_QSL_DATE']), defaultVisible: false },
// HamQTH, the extras again — sent only, the site having no confirmations.
{ group: 'Uploads', label: t('rqg.c.hamqth_sent'), colId: 'hamqth_sent', headerName: t('rqg.h.hamqth_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_HAMQTH_SENT'] || 'N'; }, defaultVisible: false },
{ group: 'Uploads', label: t('rqg.c.hamqth_sent_date'), colId: 'hamqth_sent_date', headerName: t('rqg.h.hamqth_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMQTH_SENT_DATE']), 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: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
@@ -233,6 +238,8 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
{ 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) },
{ group: 'Uploads', label: t('rqg.c.clublog_rcvd'), colId: 'clublog_qso_download_status', headerName: t('rqg.c.clublog_rcvd'), field: 'clublog_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
{ group: 'Uploads', label: t('rqg.c.clublog_rcvd_date'), colId: 'clublog_qso_download_date', headerName: t('rqg.h.clublog_rcvd_date'), field: 'clublog_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
// ── Contest ──
{ group: 'Contest', label: t('rqg.c.contest_id'), colId: 'contest_id', headerName: t('rqg.h.contest_id'), field: 'contest_id' as any, width: 110 },
@@ -335,7 +342,9 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
// 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]);
const [distUnit, setDistUnit] = useState(distanceUnit);
useEffect(() => subscribeDistanceUnit(() => setDistUnit(distanceUnit())), []);
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid, dateFmt, distUnit]);
// 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.
@@ -780,7 +789,11 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
</div>
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
{GROUP_ORDER.map((group) => {
const cols = COL_CATALOG.filter((c) => c.group === group);
// Alphabetical within the group: the catalog's order is the
// GRID's column order, which appends newcomers at the end — so
// the two ClubLog rows sat at opposite ends of the Uploads box.
const cols = COL_CATALOG.filter((c) => c.group === group)
.slice().sort((a, b) => (a.label ?? '').localeCompare(b.label ?? ''));
if (cols.length === 0) return null;
return (
<div key={group} className="rounded-md border border-border p-2.5">
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,381 @@
// RotorCompassClassic — the original rotor dial, kept as an option.
//
// Settings → Rotator chooses between this and the current compass. It was
// replaced rather than removed because the two answer the same question in
// different ways: this one is a small light-map dial with the quick turns in a
// column beside it, and an operator used to it should not have to relearn a
// panel to keep working.
//
// An azimuthal-equidistant rotor display (à la 4O3A RotorGenius).
//
// A world map centred on the operator's QTH (north up) fills the dial, ringed by
// a green azimuth bezel. A green needle shows the antenna heading (two needles
// when an Ultrabeam is bidirectional, the opposite one when reversed); a small
// red marker on the bezel shows the short-path bearing to the DX. Click the dial
// to turn the antenna there.
import { useEffect, useMemo, useRef, useState } from 'react';
import { geoAzimuthalEquidistant, geoPath, geoGraticule10 } from 'd3-geo';
import { feature } from 'topojson-client';
import landTopo from 'world-atlas/land-110m.json';
import { Compass, X, Play, Square } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
// Decode the coastline outline once (≈110 m simplified land polygons).
const LAND = feature(landTopo as any, (landTopo as any).objects.land);
const GRATICULE = geoGraticule10();
interface Props {
bearing?: number | null; // short-path azimuth to DX (deg)
headings: number[]; // radiating heading(s) — rotor + Ultrabeam pattern
boomHeading?: number | null; // mechanical boom (rotor) azimuth, shown grey when it differs
pattern?: 'normal' | 'reverse' | 'bi' | null; // Ultrabeam pattern (for the badge)
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;
// Quick-turn buttons and the azimuth box, shown only where the caller wants
// them: Station Control draws its own GoTo/Stop around this compass, and two
// sets of the same controls side by side would be nothing but confusing.
presets?: { label: string; azimuth: number }[];
onStop?: () => void;
}
const SIZE = 168;
const C = SIZE / 2;
const R = C - 6; // outer bezel radius
const MAP_R = R - 6; // map/clip radius (inside the bezel)
function pt(az: number, radius: number): [number, number] {
const a = ((az - 90) * Math.PI) / 180;
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
}
export function RotorCompassClassic({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose, presets, onStop }: Props) {
const { t } = useI18n();
// Raw text, not a number: binding the input to a normalised value makes
// Backspace fight the operator on the way from "230" to "23". It is parsed
// when it is sent, and only then.
const [azText, setAzText] = useState('');
const showControls = !!(presets || onStop);
// Which preset was just pressed, so it can light up for a moment.
//
// A rotor takes seconds to start moving and the needle barely twitches at
// first, so without this the only answer to "did that register?" is to press
// it again — which is how an antenna ends up ordered somewhere twice. The
// acknowledgement has to come from the button itself, at once.
const [flashIdx, setFlashIdx] = useState<number | null>(null);
const flashTimer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(flashTimer.current), []);
const pressPreset = (i: number, az: number) => {
if (!onGoto) return;
setFlashIdx(i);
window.clearTimeout(flashTimer.current);
flashTimer.current = window.setTimeout(() => setFlashIdx(null), 450);
onGoto(az);
};
// Stop needs the same acknowledgement, for the same reason and one more: it
// is pressed when something is already wrong, and a button that stays inert
// gets hit again and again. Its own flag, so stopping does not blank a preset
// that is still lit.
const [stopFlash, setStopFlash] = useState(false);
const stopTimer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(stopTimer.current), []);
const pressStop = () => {
if (!onStop) return;
setStopFlash(true);
window.clearTimeout(stopTimer.current);
stopTimer.current = window.setTimeout(() => setStopFlash(false), 450);
onStop();
};
// 0-359 and nothing else. 360 is refused rather than folded to 0 — it is
// almost always a typo for 36 or 306, and a rotor swinging through north on a
// slip of the finger is worth one rejected keypress.
const sendAz = () => {
const s = azText.trim();
if (s === '' || !onGoto) return;
const n = Number(s);
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0 || n > 359) return;
onGoto(n);
setAzText('');
};
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' } ],
[],
);
// Project the world centred on the QTH (north up; antipode at the bezel).
const { land, grat } = useMemo(() => {
if (centerLat == null || centerLon == null) return { land: '', grat: '' };
const proj = geoAzimuthalEquidistant()
.rotate([-centerLon, -centerLat])
.clipAngle(179.9)
.scale(MAP_R / Math.PI)
.translate([C, C]);
const path = geoPath(proj as any);
return { land: path(LAND as any) || '', grat: path(GRATICULE as any) || '' };
}, [centerLat, centerLon]);
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
if (!onGoto) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * SIZE - C;
const y = ((e.clientY - rect.top) / rect.height) * SIZE - C;
let az = (Math.atan2(y, x) * 180) / Math.PI + 90;
az = ((az % 360) + 360) % 360;
onGoto(Math.round(az));
}
const headLabel = headings.length ? headings[0] : null;
// Short and long path to the DX, in figures.
//
// The bezel already carries the short path as a red marker, but a marker is a
// direction, not a number — the same pair sits in the status bar at 10px and
// operators reported not being able to read it. It lives here because it
// belongs to the compass: every place that draws one gets the readout, instead
// of each caller inventing its own. Clickable when the caller can turn, like
// the status bar's. Built as a value because it is placed in one of two
// columns depending on whether the controls are shown.
const pathReadout = (
<div className="flex gap-1.5 mt-2 font-mono w-full">
{([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => (
<button key={lbl} type="button" disabled={az == null || !onGoto}
onClick={() => { if (az != null && onGoto) onGoto(Math.round(az)); }}
title={az == null ? '' : `${lbl} ${Math.round(az)}°`}
className={
'flex-1 rounded-md border py-1 text-xs font-semibold tabular-nums transition-colors active:scale-95 ' +
(az == null
? 'border-border text-muted-foreground/50 cursor-not-allowed'
: onGoto
? 'border-info-border text-info-muted-foreground hover:bg-info-muted cursor-pointer'
: 'border-border text-muted-foreground cursor-default')
}>
{lbl} {az == null ? '—' : `${Math.round(az)}°`}
</button>
))}
</div>
);
return (
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
{/* Header — matches the WinKeyer / Voice keyer panels. */}
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
<Compass className="size-4 text-primary shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Rotor</span>
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-success' : 'bg-muted-foreground/40')}
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
<div className="flex-1" />
{pattern && (
<span
className={cn('px-1 py-px rounded text-[9px] font-bold tracking-wide',
pattern === 'reverse' ? 'bg-warning-muted text-warning-muted-foreground'
: pattern === 'bi' ? 'bg-info-muted text-info-muted-foreground'
: 'bg-success-muted text-success-muted-foreground')}
title={pattern === 'reverse' ? 'Ultrabeam reversed — radiates opposite the boom'
: pattern === 'bi' ? 'Ultrabeam bidirectional — radiates both ways'
: 'Ultrabeam normal'}>
{pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'}
</span>
)}
<span className="font-mono text-sm font-bold text-success tabular-nums">
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
</span>
{onClose && (
<button className="text-muted-foreground hover:text-foreground" title="Hide rotor" onClick={onClose}>
<X className="size-3.5" />
</button>
)}
</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>
)}
{/* Dial on the left, controls on the right. The controls column is sized to
fit WITHIN the dial's height: the widget sits in a row whose height is
set by the entry strip, so it may grow sideways but never downwards. */}
<div className="flex items-start gap-2 p-2 min-h-0">
{/* flex-col: the readout goes BELOW the dial. This wrapper was a row, so a
sibling of the <svg> landed beside it. */}
<div className="flex flex-col items-center justify-center min-h-0 shrink-0">
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
style={{ width: SIZE, height: SIZE }}
onClick={handleClick}
>
<defs>
<clipPath id="rotorDial"><circle cx={C} cy={C} r={MAP_R} /></clipPath>
</defs>
{/* water + world map, clipped to the dial */}
<circle cx={C} cy={C} r={MAP_R} fill="#d3e7f1" />
<g clipPath="url(#rotorDial)">
{grat && <path d={grat} fill="none" stroke="#9cc0d6" strokeWidth={0.4} opacity={0.7} />}
{land && <path d={land} fill="#dfe2cf" stroke="#9aa589" strokeWidth={0.4} />}
</g>
{/* green azimuth bezel */}
<circle cx={C} cy={C} r={R} fill="none" stroke="#16a34a" strokeWidth={5} />
{/* ticks every 10°, longer at 30° */}
{Array.from({ length: 36 }, (_, i) => i * 10).map((d) => {
const major = d % 30 === 0;
const [x1, y1] = pt(d, MAP_R);
const [x2, y2] = pt(d, MAP_R - (major ? 7 : 4));
return <line key={d} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#475569" strokeWidth={major ? 1 : 0.6} opacity={0.7} />;
})}
{/* cardinal labels + degree numbers at 45° */}
{cardinals.map(({ d, l }) => {
const [x, y] = pt(d, MAP_R - 13);
return <text key={l} x={x} y={y} textAnchor="middle" dominantBaseline="central" className="fill-slate-700" style={{ fontSize: l.length > 1 ? 7 : 9, fontWeight: 700 }}>{l}</text>;
})}
{/* DX short-path bearing → small red marker on the bezel */}
{bearing != null && (() => { const [x, y] = pt(bearing, MAP_R); return (
<circle cx={x} cy={y} r={3} fill="#dc2626" stroke="#fff" strokeWidth={1} />
); })()}
{/* mechanical boom (rotor) heading grey dashed needle, shown when the
Ultrabeam radiates somewhere other than the boom (reverse/bi) so the
operator sees where the antenna physically points vs where it boom-sits */}
{boomHeading != null && pattern && pattern !== 'normal' && (() => {
const [x, y] = pt(boomHeading, MAP_R - 2);
return (
<g>
<title>Boom (rotor) {Math.round(boomHeading)}°</title>
<line x1={C} y1={C} x2={x} y2={y} stroke="#64748b" strokeWidth={2} strokeDasharray="3 3" strokeLinecap="round" />
<circle cx={x} cy={y} r={3} fill="#64748b" stroke="#fff" strokeWidth={1} />
</g>
);
})()}
{/* radiating heading needle(s) — green; two when bidirectional */}
{headings.map((h, i) => { const [x, y] = pt(h, MAP_R - 2); return (
<g key={i}>
<line x1={C} y1={C} x2={x} y2={y} stroke="#15803d" strokeWidth={3} strokeLinecap="round" opacity={i === 0 ? 1 : 0.55} />
<polygon points={`${x},${y} ${pt(h - 5, MAP_R - 12).join(',')} ${pt(h + 5, MAP_R - 12).join(',')}`} fill="#15803d" opacity={i === 0 ? 1 : 0.55} />
</g>
); })}
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
</svg>
{/* With the controls column present the readout goes at the FOOT OF IT
instead: the dial sets the widget's height, the controls are shorter
than the dial, and that leftover space is exactly the right size for
the pair. Under the dial it would push the whole widget taller. */}
{!showControls && pathReadout}
</div>
{/* Quick turns + free azimuth + Stop. */}
{showControls && (
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
{/* Two columns so six regions fit beside the dial rather than under
it. An operator with fewer keeps the same compact block. */}
{!!presets?.length && (
<div className="grid grid-cols-2 gap-1">
{presets.map((p, i) => (
<button
key={`${p.label}-${i}`}
type="button"
disabled={!onGoto}
onClick={() => pressPreset(i, p.azimuth)}
title={`${p.label}${p.azimuth}°`}
className={cn(
'rounded-md border py-1 text-xs font-semibold truncate transition-all duration-150 active:scale-95',
flashIdx === i
// Lit, and showing the azimuth it just sent: the label alone
// would only say the press landed, not what was ordered.
? 'border-success bg-success text-success-foreground scale-95'
: 'border-border bg-muted/40',
onGoto && flashIdx !== i ? 'hover:bg-muted' : '',
!onGoto ? 'opacity-50 cursor-not-allowed' : '',
)}
>
{flashIdx === i ? `${p.azimuth}°` : p.label}
</button>
))}
</div>
)}
{/* Free azimuth: Enter sends, so the whole thing is type-three-digits
-and-go without reaching for the mouse. */}
<div className="flex gap-1">
<input
type="text"
inputMode="numeric"
value={azText}
onChange={(e) => setAzText(e.target.value.replace(/[^0-9]/g, '').slice(0, 3))}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendAz(); } }}
placeholder={t('rotor.azPh')}
title={t('rotor.azTitle')}
disabled={!onGoto}
className="min-w-0 flex-1 rounded-md border border-border bg-background px-2 py-1 text-xs font-mono tabular-nums text-center disabled:opacity-50"
/>
<button
type="button"
onClick={sendAz}
disabled={!onGoto || azText.trim() === ''}
title={t('rotor.go')}
className="flex items-center gap-1 rounded-md border border-success/60 bg-success-muted px-2 py-1 text-xs font-bold text-success-muted-foreground transition-transform hover:bg-success/25 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Play className="size-3" /> {t('rotor.go')}
</button>
</div>
{onStop && (
<button
type="button"
onClick={pressStop}
title={t('rotor.stop')}
className={cn(
'flex items-center justify-center gap-1.5 rounded-md border py-1 text-xs font-bold transition-all duration-150 active:scale-95',
stopFlash
// Solid, not a tint: STOP reads the same in both languages, so
// the fill is the whole acknowledgement.
? 'border-destructive bg-destructive text-destructive-foreground scale-95'
: 'border-destructive/60 bg-destructive/15 text-destructive hover:bg-destructive/25',
)}
>
<Square className="size-3 fill-current" /> {t('rotor.stop')}
</button>
)}
{/* mt-auto: the readout sits at the FOOT of the column, level with the
bottom of the dial, instead of floating under the Stop button. */}
<div className="mt-auto">{pathReadout}</div>
</div>
)}
</div>
</section>
);
}
File diff suppressed because it is too large Load Diff
@@ -11,6 +11,8 @@ import { useI18n } from '@/lib/i18n';
import { writeUiPref } from '@/lib/uiPref';
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
import { RotorCompass } from '@/components/RotorCompass';
import { RotorCompassClassic } from '@/components/RotorCompassClassic';
import { rotorStyle, subscribeRotorStyle } from '@/lib/rotorStyle';
import { AmpCard } from '@/components/AmpCard';
import { TunerCard } from '@/components/TunerCard';
import type { TGStatus } from '@/components/TunerGeniusPanel';
@@ -128,6 +130,11 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
}) {
const [goto, setGoto] = useState('');
const [err, setErr] = useState('');
// Both compasses in the app follow the same preference (Settings → Rotator):
// one operator's choice of dial, not one per panel.
const [dial, setDial] = useState(() => rotorStyle());
useEffect(() => subscribeRotorStyle(() => setDial(rotorStyle())), []);
const Dial = dial === 'classic' ? RotorCompassClassic : RotorCompass;
const turn = (az: number) => {
const a = ((Math.round(az) % 360) + 360) % 360;
@@ -145,9 +152,12 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
</div>
<div className="p-3 flex gap-4 items-start">
{/* The SP/LP readout lives INSIDE RotorCompass, so every compass in the
app carries it rather than each caller drawing its own. */}
<RotorCompass
{/* The compact compass is the dial alone short and long path are the
two coloured dots on it. The figures are in the status bar. */}
{/* The dial-only form is square and fills the box it is given, so its
size is set here rather than baked into the component. */}
<div className={dial === 'classic' ? 'shrink-0' : 'w-[210px] shrink-0'}>
<Dial
bearing={bearing ?? null}
headings={hd.ok ? [hd.azimuth] : []}
centerLat={centerLat ?? null}
@@ -158,6 +168,7 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
onGoto={(az) => turn(az)}
/>
</div>
<div className="flex-1 min-w-0 space-y-2">
<div className="font-mono">
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
import {
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
GetWsjtHighlight, SetWsjtHighlight, GetWsjtHighlightWorked, SetWsjtHighlightWorked, GetWsjtFollowMode, SetWsjtFollowMode,
} from '../../wailsjs/go/main/App';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -158,6 +159,15 @@ const TRIGGERS = [
type Props = { onError: (msg: string) => void };
export function UDPIntegrationsPanel({ onError }: Props) {
const [highlightOn, setHighlightOn] = useState(false);
const [hlWorked, setHlWorked] = useState(false);
const [followMode, setFollowMode] = useState(true);
useEffect(() => {
GetWsjtHighlight().then((v) => setHighlightOn(!!v)).catch(() => {});
GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {});
GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {});
GetWsjtFollowMode().then((v) => setFollowMode(!!v)).catch(() => {});
}, []);
const { t } = useI18n();
const [items, setItems] = useState<UDPConfig[]>([]);
const [loading, setLoading] = useState(true);
@@ -229,10 +239,36 @@ export function UDPIntegrationsPanel({ onError }: Props) {
return (
<div className="space-y-4">
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
{t('udpp.intro')}
</div>
{/* Log-aware colours in WSJT-X / JTDX's own window lives HERE because
this panel is where the WSJT-X link is configured. */}
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl">
<Checkbox checked={highlightOn}
onCheckedChange={(c) => { setHighlightOn(!!c); void SetWsjtHighlight(!!c); }} />
<span>
{t('udpp.highlight')}
<span className="block text-[11px] text-muted-foreground">{t('udpp.highlightHint')}</span>
</span>
</label>
{/* Nested under the switch above: the same feature, and meaningless
while that one is off. */}
{highlightOn && (
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl pl-6">
<Checkbox checked={hlWorked}
onCheckedChange={(c) => { setHlWorked(!!c); void SetWsjtHighlightWorked(!!c); }} />
<span>
{t('udpp.hlWorked')}
<span className="block text-[11px] text-muted-foreground">{t('udpp.hlWorkedHint')}</span>
</span>
</label>
)}
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl">
<Checkbox checked={followMode}
onCheckedChange={(c) => { setFollowMode(!!c); void SetWsjtFollowMode(!!c); }} />
<span>
{t('udpp.followMode')}
<span className="block text-[11px] text-muted-foreground">{t('udpp.followModeHint')}</span>
</span>
</label>
<Section
title={t('udpp.inboundTitle')}
icon={<ArrowDownToLine className="size-4" />}
+355
View File
@@ -0,0 +1,355 @@
// Watchlist — the DXHunter concept as an OpsLog tab.
//
// One card per watched callsign (or prefix), the live spots that match it
// underneath, and the two questions an operator actually asks answered on every
// line: is this slot still NEEDED, and what would it be worth (the same NEW
// DXCC / band / mode / slot badges the cluster shows, resolved from the same
// index). A CONTEST entry is judged against the current UTC day — at midnight
// everything reads "work today" again; the boundary lives in the backend query,
// so there is nothing to reset.
//
// The design is DXHunter's — the layout, the badges, the wording — repainted in
// the app's theme tokens rather than its hard-coded slate/pink.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Eye, Plus, Trash2, Bell, BellOff, Trophy, Search, Check, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import {
WatchlistEntries, WatchlistAdd, WatchlistRemove, WatchlistSetNotify,
WatchlistSetContest, WatchlistWorkedSlots,
GetWatchlistContestPattern, SetWatchlistContestPattern, OpenExternalURL,
} from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import type { ClusterSpot, SpotStatusEntry } from '@/components/ClusterGrid';
import { inferSpotMode, spotStatusKey } from '@/lib/spot';
import { useWatchlistSpots, matchesEntry, newBadge, type WLEntry } from '@/lib/watchlistSpots';
interface Props {
spots: ClusterSpot[];
spotStatus: Record<string, SpotStatusEntry>;
onSpotSelect?: (s: ClusterSpot) => void;
onSpotClick?: (s: ClusterSpot) => void;
}
export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: Props) {
const { t } = useI18n();
const [entries, setEntries] = useState<WLEntry[]>([]);
const [addCall, setAddCall] = useState('');
const [addContest, setAddContest] = useState(false);
const [search, setSearch] = useState('');
// The filters survive leaving the tab: the component unmounts on every tab
// switch, and filters that reset each time are filters nobody trusts.
const [neededOnly, setNeededOnlyRaw] = useState(() => localStorage.getItem('opslog.wlNeededOnly') === '1');
const [activeOnly, setActiveOnlyRaw] = useState(() => localStorage.getItem('opslog.wlActiveOnly') === '1');
const [family, setFamilyRaw] = useState<'all' | 'normal' | 'contest'>(() => {
const v = localStorage.getItem('opslog.wlFamily');
return v === 'normal' || v === 'contest' ? v : 'all';
});
const setNeededOnly = (f: (v: boolean) => boolean) => setNeededOnlyRaw((v) => { const nv = f(v); try { localStorage.setItem('opslog.wlNeededOnly', nv ? '1' : '0'); } catch {} return nv; });
const setActiveOnly = (f: (v: boolean) => boolean) => setActiveOnlyRaw((v) => { const nv = f(v); try { localStorage.setItem('opslog.wlActiveOnly', nv ? '1' : '0'); } catch {} return nv; });
const setFamily = (v: 'all' | 'normal' | 'contest') => { setFamilyRaw(v); try { localStorage.setItem('opslog.wlFamily', v); } catch {} };
// Mode filter — DXHunter's "All Modes" select. DIGI matches the digital class
// (FT8, FT4, a generic DATA spot…), the named modes match exactly.
const [modeFilter, setModeFilterRaw] = useState(() => localStorage.getItem('opslog.wlMode') || 'ALL');
const setModeFilter = (v: string) => { setModeFilterRaw(v); try { localStorage.setItem('opslog.wlMode', v); } catch {} };
const modeMatches = (s2: ClusterSpot): boolean => {
if (modeFilter === 'ALL') return true;
const m = (inferSpotMode(s2.comment ?? '', s2.freq_hz) || '').toUpperCase();
if (modeFilter === 'DIGI') return !['', 'CW', 'SSB', 'USB', 'LSB', 'FM', 'AM'].includes(m);
if (modeFilter === 'SSB') return m === 'SSB' || m === 'USB' || m === 'LSB';
return m === modeFilter;
};
const [error, setError] = useState('');
// A quiet confirmation under the toolbar; both messages clear themselves —
// a stale "added" from ten minutes ago reads as a fresh one.
const [notice, setNotice] = useState('');
const noticeTimer = useRef<number | undefined>(undefined);
const flash = (msg: string, isError: boolean) => {
if (noticeTimer.current) window.clearTimeout(noticeTimer.current);
setError(isError ? msg : '');
setNotice(isError ? '' : msg);
noticeTimer.current = window.setTimeout(() => { setError(''); setNotice(''); }, 4000) as unknown as number;
};
const refresh = useCallback(async () => {
try { setEntries(((await WatchlistEntries()) ?? []) as any as WLEntry[]); }
catch (e: any) { setError(String(e?.message ?? e)); }
}, []);
// Refreshed on mount, when the backend auto-adds (event), and on a slow tick
// so last-seen and the spot counters stay honest while the tab sits open.
useEffect(() => {
void refresh();
const off = EventsOn('watchlist:changed', () => { void refresh(); });
const id = window.setInterval(() => { void refresh(); }, 30_000);
return () => { off(); window.clearInterval(id); };
}, [refresh]);
// The auto-contest pattern (DXHunter's contest_prefix): spots whose call
// contains it are added as contest entries by the backend.
const [pattern, setPattern] = useState('');
useEffect(() => { GetWatchlistContestPattern().then((p: string) => setPattern(p ?? '')).catch(() => {}); }, []);
// Live spots per entry — prefix-matched, newest first, deduped per band+mode
// (one line per slot; the freshest spot represents it).
// Which entries are on the air, and which of their slots are still needed.
// One definition, shared with the docked watch-list widget — the answer
// involves a debounced query per visible slot, and two copies of it would be
// two bursts of the same question and two ideas of what "needed" means.
const { spotsFor, workedFor, settled, onAir, worked } = useWatchlistSpots(entries, spots);
const add = async () => {
const c = addCall.trim().toUpperCase();
if (!c) return;
try {
await WatchlistAdd(c, addContest);
setAddCall('');
// The confirmation is the app-level notice (App.tsx, on watchlist:changed)
// — saying it twice, once per place a call can be added from, was noise.
await refresh();
} catch (e: any) { flash(String(e?.message ?? e), true); }
};
// One click, like the bell and the trophy beside it: removing a watchlist
// entry is cheap to undo (type it again), so it does not earn a confirmation
// the other two buttons do not have.
const remove = async (call: string) => {
try { await WatchlistRemove(call); await refresh(); }
catch (e: any) { flash(String(e?.message ?? e), true); }
};
const isOnAir = onAir;
const shown = entries.filter((e) => {
if (family === 'normal' && e.isContest) return false;
if (family === 'contest' && !e.isContest) return false;
if (search && !e.callsign.includes(search.trim().toUpperCase())) return false;
const list = (spotsFor.get(e.callsign) ?? []).filter(modeMatches).filter((s2) => settled(e, s2));
if (activeOnly && list.length === 0) return false;
if (neededOnly && !list.some((s2) => !workedFor(e, s2))) return false;
return true;
});
const counters = useMemo(() => {
let active = 0, needed = 0;
for (const e of entries) {
const list = (spotsFor.get(e.callsign) ?? []).filter(modeMatches).filter((s2) => settled(e, s2));
if (list.length > 0) active++;
if (list.some((s2) => !workedFor(e, s2))) needed++;
}
return { total: entries.length, active, needed };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entries, spotsFor, worked, modeFilter]);
// The cluster's own badge for a spot, read from the shared status index.
const dxccBadge = (s: ClusterSpot): { label: string; color: string } | null => {
const st = spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)];
const b = newBadge(st?.status);
return b ? { label: t(b.key), color: b.colour } : null;
};
// Three decimals, and no trailing zeros beyond them: 7.056 rather than
// 7.0560, 14.0745 rather than 14.074500. DXHunter's own rule, and the one an
// operator reads a cluster line with.
const fmtMHz = (hz: number) => {
const [int, dec] = (hz / 1e6).toFixed(6).split('.');
return int + '.' + dec.slice(0, 3) + dec.slice(3).replace(/0+$/, '');
};
const chip = (color: string, text: string, extra?: string) => (
<span className={cn('px-1.5 py-0.5 rounded text-[11px] font-semibold border', extra)}
style={{ color, borderColor: `color-mix(in srgb, ${color} 40%, transparent)`, background: `color-mix(in srgb, ${color} 12%, transparent)` }}>
{text}
</span>
);
return (
// Capped and centred: a card is a reading surface, and callsign-to-badge
// lines stretched across a 34-inch window are not readable, they are long.
<div className="flex flex-col min-h-0 flex-1 gap-2 p-2 w-full max-w-5xl mx-auto">
{/* Header: the counters alone, centred they are the tab's headline.
Everything one INTERACTS with lives on the second row. */}
<div className="flex items-center justify-center gap-2.5 text-xs text-muted-foreground">
<Eye className="size-4 text-primary shrink-0" />
<span className="flex items-center gap-1.5">{t('wl.cTotal')}
<b className="px-2 py-0.5 rounded bg-muted text-foreground">{counters.total}</b></span>
<span className="opacity-40">|</span>
<span className="flex items-center gap-1.5">{t('wl.cActive')}
<b className="px-2 py-0.5 rounded text-info border border-info/30 bg-info/10">{counters.active}</b></span>
<span className="opacity-40">|</span>
<span className="flex items-center gap-1.5">{t('wl.cNeeded')}
<b className={cn('px-2 py-0.5 rounded border', counters.needed > 0
? 'text-warning border-warning/40 bg-warning/10'
: 'text-muted-foreground border-border bg-muted/40')}>{counters.needed}</b></span>
</div>
{/* toolbar */}
<div className="flex items-start justify-between gap-x-3 gap-y-1.5 flex-wrap">
<div className="flex items-center gap-2 shrink-0">
<Input className="h-8 w-40 font-mono placeholder:font-sans placeholder:normal-case" placeholder={t('wl.addPh')} value={addCall}
onChange={(e) => setAddCall(e.target.value.toUpperCase())}
onKeyDown={(e) => { if (e.key === 'Enter') void add(); }} />
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('wl.contestHint')}>
<Checkbox checked={addContest} onCheckedChange={(c) => setAddContest(!!c)} />
<Trophy className="size-3.5 text-warning" /> {t('wl.addAsContest')}
</label>
<Button size="sm" className="h-8" onClick={() => void add()} disabled={!addCall.trim()}>
<Plus className="size-3.5" /> {t('wl.add')}
</Button>
<Input className="h-8 w-28 font-mono placeholder:font-sans placeholder:normal-case" placeholder={t('wl.patternPh')}
title={t('wl.patternHint')}
value={pattern}
onChange={(e) => setPattern(e.target.value.toUpperCase())}
onBlur={() => { void SetWatchlistContestPattern(pattern.trim()); }} />
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="relative">
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input className="h-8 w-28 pl-7 text-sm" placeholder={t('wl.searchPh')} value={search}
onChange={(e) => setSearch(e.target.value)} />
</div>
{/* families kept together, told apart per review of the DXHunter port,
no global contest mode: each entry carries its own rule. */}
<div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
{([['all', t('wl.famAll')], ['normal', t('wl.famNormal')], ['contest', t('wl.famContest')]] as const).map(([k, label]) => (
<button key={k} type="button" onClick={() => setFamily(k)}
className={cn('px-2 py-1 border-l border-border first:border-l-0',
family === k ? 'bg-accent font-medium' : 'text-muted-foreground hover:bg-accent/50')}>
{label}
</button>
))}
</div>
<select value={modeFilter} onChange={(e) => setModeFilter(e.target.value)}
title={t('wl.modeFilter')}
className="h-7 px-1.5 rounded-md border border-border bg-background text-xs text-foreground">
{['ALL', 'CW', 'SSB', 'FT8', 'FT4', 'RTTY', 'DIGI'].map((m) => (
<option key={m} value={m}>{m === 'ALL' ? t('wl.allModes') : m}</option>
))}
</select>
<button type="button" onClick={() => setActiveOnly((v) => !v)}
className={cn('px-2 py-1 rounded-md border text-xs', activeOnly ? 'bg-accent border-border font-medium' : 'border-border text-muted-foreground hover:bg-accent/50')}>
{t('wl.activeOnly')}
</button>
<button type="button" onClick={() => setNeededOnly((v) => !v)}
className={cn('px-2 py-1 rounded-md border text-xs', neededOnly ? 'bg-accent border-border font-medium' : 'border-border text-muted-foreground hover:bg-accent/50')}>
{t('wl.neededOnly')}
</button>
</div>
</div>
{error && <div className="text-xs text-danger px-1">{error}</div>}
{notice && <div className="text-xs text-success px-1">{notice}</div>}
{/* cards */}
<div className="flex-1 min-h-0 overflow-y-auto space-y-2 pr-1">
{shown.length === 0 ? (
<div className="text-sm text-muted-foreground text-center pt-16 max-w-md mx-auto leading-relaxed">
{entries.length === 0 ? t('wl.empty') : t('wl.noneMatch')}
</div>
) : shown.map((e) => {
const all = (spotsFor.get(e.callsign) ?? []).filter(modeMatches).filter((s2) => settled(e, s2));
const needed = all.filter((s) => !workedFor(e, s)).length;
// Needed-only hides the worked LINES as well as the all-worked cards:
// a filter that says needed and still lists five green Worked rows is
// answering a different question than the one asked.
const list = neededOnly ? all.filter((s) => !workedFor(e, s)) : all;
return (
<div key={e.callsign}
className={cn('rounded border bg-card/70 p-3 transition-colors hover:bg-accent/20',
needed > 0 ? 'border-warning/40' : 'border-border/70',
e.isContest && 'border-l-4 border-l-warning')}>
<div className="flex items-center gap-2 flex-wrap">
{/* Proportional, not monospaced: DXHunter sets this one in the
interface font and the difference is the first thing an
operator notices with the two windows side by side. There is
nothing to align here it is a heading, not a column. */}
<span className="text-lg font-bold" style={{ color: '#f472b6' }}>{e.callsign}</span>
{isOnAir(e) && chip('var(--danger)', t('wl.onAir'), 'animate-pulse')}
{e.isContest && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-semibold bg-warning-muted text-warning-muted-foreground border border-warning-border"
title={t('wl.contestHint')}>
<Trophy className="size-3" /> {t('wl.contest')}
</span>
)}
{e.isExpedition && chip('var(--chart-5)', '⚡ ' + t('wl.expedition'))}
{e.clubLogTotalQSOs > 0 && <span className="text-[11px] text-muted-foreground">{e.clubLogTotalQSOs.toLocaleString()} QSOs{e.clubLogQSOs24h > 0 ? ` · ${e.clubLogQSOs24h}/24h` : ''}</span>}
{e.clubLogHasOQRS && chip('var(--success)', 'OQRS')}
{e.clubLogLiveStream && (
<a href="#" onClick={(ev) => { ev.preventDefault(); void OpenExternalURL(`https://clublog.org/livestream/${e.callsign.replace('*', '')}`); }}
className="px-1.5 py-0.5 rounded text-[11px] font-semibold border text-info border-info/40 bg-info/10 hover:bg-info/20">Live</a>
)}
{list.length > 0 && (needed > 0
? chip('var(--warning)', e.isContest ? t('wl.nToday', { n: needed }) : t('wl.nNeeded', { n: needed }))
: chip('var(--success)', e.isContest ? t('wl.workedToday') : t('wl.allWorked')))}
{e.lastSeenStr && e.lastSeenStr !== 'Never' && (
<span className="text-[11px] text-muted-foreground"> {e.lastSeenStr}</span>
)}
{e.spotCount > 0 && (
<span className="text-[11px] text-muted-foreground/70"> {t('wl.totalSpots', { n: e.spotCount })}</span>
)}
<div className="flex-1" />
<button type="button" title={t('wl.toggleContest')}
onClick={() => void WatchlistSetContest(e.callsign, !e.isContest).then(refresh)}
className={cn('p-1 rounded hover:bg-muted', e.isContest ? 'text-warning' : 'text-muted-foreground/40')}>
<Trophy className="size-3.5" />
</button>
<button type="button" title={e.notify ? t('wl.notifyOff') : t('wl.notifyOn')}
onClick={() => void WatchlistSetNotify(e.callsign, !e.notify).then(refresh)}
className={cn('p-1 rounded hover:bg-muted', e.notify ? 'text-warning' : 'text-muted-foreground/40')}>
{e.notify ? <Bell className="size-3.5" /> : <BellOff className="size-3.5" />}
</button>
<button type="button" title={t('wl.remove')}
onClick={() => void remove(e.callsign)}
className="p-1 rounded hover:bg-muted text-muted-foreground/40 hover:text-danger">
<Trash2 className="size-3.5" />
</button>
</div>
{list.length > 0 ? (
<div className="mt-2 space-y-1 max-h-52 overflow-y-auto">
{list.slice(0, 12).map((s, i) => {
const done = workedFor(e, s);
const badge = dxccBadge(s);
const mode = inferSpotMode(s.comment ?? '', s.freq_hz);
return (
<button key={i} type="button"
onClick={() => onSpotSelect?.(s)}
onDoubleClick={() => onSpotClick?.(s)}
title={t('wl.spotTip')}
className={cn('w-full flex items-center gap-2 px-2 py-1.5 rounded text-[11px] bg-muted/25 hover:bg-muted/70 transition-colors text-left',
!done && 'border-l-2 border-warning')}>
{/* Worked or wanted, said with a symbol at the head of
the line as well as with the stripe down its side
the same two marks DXHunter uses, and the one an eye
finds first when a card holds ten rows. */}
{done
? <Check className="size-4 shrink-0 text-success" />
: <AlertTriangle className="size-4 shrink-0 text-warning" />}
{/* Fixed columns: an elastic country made band/mode/freq start wherever the name ended — every row its own ruler. */}
<span className="font-bold text-info shrink-0 w-24 truncate">{s.dx_call}</span>
<span className="text-muted-foreground truncate shrink-0 w-44">{(s as any).country ?? ''}</span>
<span className="px-1.5 rounded bg-muted shrink-0 w-11 text-center">{s.band}</span>
<span className="px-1.5 rounded shrink-0 w-11 text-center" style={mode ? { color: 'var(--chart-5)', background: 'color-mix(in srgb, var(--chart-5) 12%, transparent)' } : undefined}>{mode || ' '}</span>
<span className="font-mono text-muted-foreground shrink-0 w-16 text-right">{fmtMHz(s.freq_hz)}</span>
{badge && chip(badge.color, badge.label)}
<div className="flex-1" />
{done
? chip('var(--success)', e.isContest ? t('wl.todayOk') : t('wl.worked'))
: chip('var(--warning)', e.isContest ? t('wl.workToday') : t('wl.needed'))}
<span className="text-muted-foreground/70 shrink-0">{s.time_utc ?? ''}</span>
</button>
);
})}
</div>
) : (
<div className="mt-2 text-[11px] text-muted-foreground text-center py-1.5 bg-muted/30 rounded">
{t('wl.noSpots')}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
// WatchlistWidget — the watch list reduced to what is worth acting on RIGHT
// NOW: entries that are on the air and still needed.
//
// The Watchlist tab is a tab, and an operator working FT8 lives on the decodes
// one. A station they asked to be told about would appear on a screen they are
// not looking at — so the same answer is docked in the widget strip, which sits
// above the tabs and is therefore always in view. Only active AND needed: a
// list of everything watched is the tab's job, and it would not fit here.
import { useEffect, useMemo, useState } from 'react';
import { Bell, X } from 'lucide-react';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { inferSpotMode, spotStatusKey } from '@/lib/spot';
import { useWatchlistSpots, newBadge, type WLEntry } from '@/lib/watchlistSpots';
import type { ClusterSpot, SpotStatusEntry } from '@/components/ClusterGrid';
import { WatchlistEntries } from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime';
interface Props {
spots: ClusterSpot[];
// The cluster's own verdicts. WHAT a station is worth is the reason to leave
// what you are doing for it — "on the air and not worked" says nothing about
// whether it is a new entity or a fifth band on one worked in 1998.
spotStatus: Record<string, SpotStatusEntry>;
// A row is a spot: clicking it does what clicking one in the cluster does.
onPick?: (s: ClusterSpot) => void;
onClose?: () => void;
}
function age(s: ClusterSpot): string {
const ts = Date.parse(String((s as any).received_at ?? ''));
if (!(ts > 0)) return '';
const m = Math.floor((Date.now() - ts) / 60000);
if (m < 1) return 'now';
return `${m}m`;
}
export function WatchlistWidget({ spots, spotStatus, onPick, onClose }: Props) {
const { t } = useI18n();
const [entries, setEntries] = useState<WLEntry[]>([]);
const load = () => { WatchlistEntries().then((e: any) => setEntries((e ?? []) as WLEntry[])).catch(() => {}); };
useEffect(() => {
load();
// The list changes from four places (the tab, the cluster's star, the
// contest auto-add, an import), and a widget that only reads it at mount
// would quietly watch the wrong set for the rest of the session.
const off = EventsOn('watchlist:changed', load);
return () => { off?.(); };
}, []);
const { spotsFor, workedFor, settled } = useWatchlistSpots(entries, spots);
// Ticking, because every row carries an age and the freshest thing here is
// the reason to look at it at all.
const [, tick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => tick((n) => n + 1), 30000);
return () => window.clearInterval(id);
}, []);
// One row per (entry, needed slot): the same station on two bands is two
// chances to work it, and collapsing them would hide the one that is open.
const rows = useMemo(() => {
const out: { e: WLEntry; s: ClusterSpot }[] = [];
for (const e of entries) {
for (const s of spotsFor.get(e.callsign) ?? []) {
if (!settled(e, s) || workedFor(e, s)) continue;
out.push({ e, s });
}
}
// Freshest first: a spot from twenty minutes ago is history, and this
// panel is short.
return out.sort((a, b) =>
Date.parse(String((b.s as any).received_at ?? '')) - Date.parse(String((a.s as any).received_at ?? '')));
}, [entries, spotsFor, workedFor, settled]);
return (
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
<Bell className="size-4 text-primary shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('wlw.title')}
</span>
<span className={cn('rounded px-1.5 py-px text-[10px] font-bold',
rows.length > 0 ? 'bg-warning text-warning-foreground' : 'bg-muted text-muted-foreground')}>
{rows.length}
</span>
<div className="flex-1" />
{onClose && (
<button type="button" onClick={onClose} title={t('wlw.hide')}
className="text-muted-foreground hover:text-foreground transition-colors">
<X className="size-3.5" />
</button>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{rows.length === 0 ? (
// Empty is the normal state, and it means something precise — say it,
// rather than leaving a blank box that reads as broken.
<div className="px-3 py-3 text-xs text-muted-foreground">
{entries.length === 0 ? t('wlw.emptyList') : t('wlw.emptyNone')}
</div>
) : rows.map(({ e, s }, i) => {
const mode = inferSpotMode(s.comment ?? '', s.freq_hz) || '';
const badge = newBadge(spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]?.status);
return (
<button
key={`${e.callsign}-${s.dx_call}-${s.band}-${mode}-${i}`}
type="button"
onClick={() => onPick?.(s)}
title={e.callsign === s.dx_call
? t('wlw.pick', { call: s.dx_call })
: `${t('wlw.pick', { call: s.dx_call })} — ${e.callsign}`}
className="w-full text-left px-2 py-1 border-b border-border/20 hover:bg-muted/50 transition-colors flex items-center gap-1.5"
>
{/* One line, in reading order: who, where, what it is worth, how
long ago. The callsign is the only part allowed to give way
everything else is short and fixed, and a row that wraps is a
row an operator has to parse instead of scan. */}
<span className="font-mono text-[13px] font-bold text-warning truncate">{s.dx_call}</span>
{s.band && (
<span className="rounded px-1 py-px text-[10px] font-bold uppercase bg-info-muted text-info-muted-foreground shrink-0">
{s.band}
</span>
)}
{mode && <span className="font-mono text-[10px] text-muted-foreground shrink-0">{mode}</span>}
<div className="flex-1" />
{/* Why it is worth interrupting a QSO for — or not. */}
{badge && (
<span className="rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide border shrink-0"
style={{ color: badge.colour, borderColor: `color-mix(in srgb, ${badge.colour} 45%, transparent)`,
background: `color-mix(in srgb, ${badge.colour} 12%, transparent)` }}>
{t(badge.key)}
</span>
)}
<span className="font-mono text-[10px] text-muted-foreground/70 tabular-nums shrink-0 w-7 text-right">
{age(s)}
</span>
</button>
);
})}
</div>
</section>
);
}
+4 -4
View File
@@ -26,7 +26,7 @@ interface Props {
wpm: number;
macros: WKMacro[];
sent: string; // text echoed back by the keyer as it transmits
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | 'kenwood'; // CW output engine (chosen in Settings → CW Keyer)
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | 'kenwood' | 'tci'; // 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;
@@ -109,16 +109,16 @@ export function WinkeyerPanel({
<Radio className="size-4 text-primary shrink-0" />
{/* CW output engine (chosen in Settings → CW Keyer). */}
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : source === 'kenwood' ? 'Kenwood CW' : 'WinKeyer'}
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : source === 'kenwood' ? 'Kenwood CW' : source === 'tci' ? 'TCI CW' : 'WinKeyer'}
</span>
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
<div className="flex-1" />
{source === 'icom' || source === 'flex' || source === 'yaesu' || source === 'kenwood' ? (
{source === 'icom' || source === 'flex' || source === 'yaesu' || source === 'kenwood' || source === 'tci' ? (
<span className="text-[11px] font-medium text-muted-foreground">
{source === 'flex'
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
: source === 'yaesu' || source === 'kenwood'
: source === 'yaesu' || source === 'kenwood' || source === 'tci'
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
</span>
+20 -1
View File
@@ -9,7 +9,7 @@ import { cn } from '@/lib/utils';
// can't hold a typo'd value that isn't in the list.
export function Combobox({
value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false,
showToggle = false,
showToggle = false, onWheelStep,
}: {
value: string;
onChange: (v: string) => void;
@@ -21,6 +21,10 @@ export function Combobox({
// fields read live by other actions — e.g. RST, so a CW macro sent without
// leaving the field uses the value just typed.
commitOnType?: boolean;
// Wheel over the field steps the value. The control cannot know what a step
// means — a decibel here, an S-unit there — so it reports the direction and
// the owner decides.
onWheelStep?: (dir: 1 | -1) => void;
// Draw a chevron that opens the full list on click.
//
// Without it this control is indistinguishable from a plain text box: it opens
@@ -42,6 +46,21 @@ export function Combobox({
// leave the list floating over the wrong row.
const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 });
// A NATIVE, non-passive listener: React attaches onWheel passively, where
// preventDefault does nothing at all and the panel scrolls away under the
// field being adjusted.
useEffect(() => {
const el = ref.current;
if (!el || !onWheelStep) return;
const onWheel = (e: WheelEvent) => {
if (e.deltaY === 0) return;
e.preventDefault();
onWheelStep(e.deltaY < 0 ? 1 : -1); // up is a better report
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [onWheelStep]);
useEffect(() => {
function onDoc(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
-221
View File
@@ -1,221 +0,0 @@
// Auto-call: let OpsLog answer a decode without the operator clicking it.
//
// WITHDRAWN FROM THE INTERFACE, because DXHunter already does it.
//
// Two programs from the same shack deciding on their own to answer the same
// decode is worse than either doing it alone: they cannot see each other, so
// they key over one another, and afterwards there is no telling which of them
// called. The duplicate is the one to remove, and DXHunter is where this lives.
//
// There is no switch in Preferences and no button on the decodes toolbar, and
// App.tsx returns before the loop can run. The file is kept whole: the rules
// below are the delicate part, argued over and tested, and rewriting them from
// memory later would be worse than leaving them here. Reinstating the feature
// means restoring all three — the settings page, the button, and the guard.
//
// This KEYS THE TRANSMITTER on its own, which is why the rules here are written
// as a series of refusals rather than a search for a reason to call. Everything
// below has to be true; anything unknown means no.
//
// The decision is made here, in one pure function, precisely because it is the
// dangerous part: it can be read, argued with and tested without a radio.
export type AutoCallCriteria = {
dxcc: boolean; // entity never worked
bandmode: boolean; // entity worked, but neither this band nor this mode
band: boolean; // entity never worked on this band
mode: boolean; // entity never worked in this mode
slot: boolean; // band and mode each worked, never together
grid: boolean; // square wanted under the grid scope
county: boolean; // US county never worked
pota: boolean; // park never worked
// No SOTA here, though the shape invites it: a decode carries no summit
// reference and the backend publishes no "new summit" flag, so a criterion
// for it could never be true. It WAS declared, translated and impossible to
// tick — a field that lies about what the feature can do.
pfx: boolean; // CQ WPX prefix never worked
};
export type AutoCallSettings = {
enabled: boolean;
criteria: AutoCallCriteria;
// Callsigns to answer on sight, wildcards allowed (4S7*, */P). Each is still
// subject to `watchCriteria` — "call TM0HQ, but only if it is a new band" is
// the request, not "call it every time it appears".
watch: string[];
// Empty means call a watched callsign whenever it is not already worked.
watchCriteria: AutoCallCriteria;
// Seconds to ignore a callsign after calling it, so a station that keeps
// sending CQ is not re-answered every slot while the QSO is in progress.
cooldownSec: number;
};
export const emptyCriteria: AutoCallCriteria = {
dxcc: false, bandmode: false, band: false, mode: false, slot: false,
grid: false, county: false, pota: false, pfx: false,
};
export const defaultAutoCall: AutoCallSettings = {
// OFF, and it stays off until asked for. Unattended transmit is not something
// to inherit from an upgrade.
enabled: false,
criteria: { ...emptyCriteria },
watch: [],
watchCriteria: { ...emptyCriteria },
cooldownSec: 120,
};
const AC_KEY = 'opslog.autoCall';
export function loadAutoCall(): AutoCallSettings {
try {
const raw = localStorage.getItem(AC_KEY);
if (!raw) return { ...defaultAutoCall };
const v = JSON.parse(raw);
const out: AutoCallSettings = {
...defaultAutoCall,
...v,
criteria: { ...emptyCriteria, ...(v?.criteria ?? {}) },
watchCriteria: { ...emptyCriteria, ...(v?.watchCriteria ?? {}) },
watch: Array.isArray(v?.watch) ? v.watch : [],
};
// DISARMED ON SIGHT, and written back disabled.
//
// The runtime guard in App.tsx stops this build from calling anyone, but it
// leaves "enabled": true sitting in storage, where any build without the
// guard — an older one an operator reinstalls, a machine that upgrades
// later — reads it and keys the transmitter for a feature with no switch
// left to turn off. A withdrawn feature that keys a radio has to be
// disarmed where it is REMEMBERED, not only where it runs.
if (out.enabled) {
out.enabled = false;
try { localStorage.setItem(AC_KEY, JSON.stringify(out)); } catch { /* private mode: the guard still holds */ }
}
return out;
} catch { return { ...defaultAutoCall }; }
}
export const autoCallKey = AC_KEY;
// A decode's resolved novelty, the same shape the panel already renders from.
export type DecodeStatus = {
status?: string;
worked_call?: boolean;
new_grid?: boolean;
grid_state?: string;
new_county?: boolean;
new_pota?: boolean;
new_pfx?: boolean;
};
// matchesWildcard is the same rule the alert filters use: * is any run, ? is one.
export function matchesWildcard(pattern: string, call: string): boolean {
const p = pattern.trim().toUpperCase();
const c = call.trim().toUpperCase();
if (!p) return false;
const re = new RegExp('^' + p.split('').map((ch) => (
ch === '*' ? '.*' : ch === '?' ? '.' : ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
)).join('') + '$');
return re.test(c);
}
// anyCriterion is false for an all-off set, which is what makes "watch this
// callsign, no conditions" expressible.
function anyCriterion(c: AutoCallCriteria): boolean {
return Object.values(c).some(Boolean);
}
// meets reports whether a decode satisfies at least one ticked criterion.
function meets(c: AutoCallCriteria, e: DecodeStatus): boolean {
if (c.dxcc && e.status === 'new') return true;
// Each status is exclusive, so a station that is new on both counts matches
// ONLY this criterion — ticking "new band" alone would not catch it, which is
// the wrong way round: it is the better catch of the two.
if (c.bandmode && e.status === 'new-band-mode') return true;
if (c.band && e.status === 'new-band') return true;
if (c.mode && e.status === 'new-mode') return true;
if (c.slot && e.status === 'new-slot') return true;
// A square that is merely UNCONFIRMED is not called: the QSO is already made,
// and calling again would work a duplicate to chase a QSL.
if (c.grid && e.new_grid && e.grid_state !== 'unconf') return true;
if (c.county && e.new_county) return true;
if (c.pota && e.new_pota) return true;
if (c.pfx && e.new_pfx) return true;
return false;
}
export type AutoCallDecode = {
call: string;
cq?: boolean;
msg?: string;
instance?: string;
};
// shouldAutoCall decides whether to answer one decode. The reason is returned
// for the log: an automatic transmission with no record of WHY is the thing an
// operator cannot argue with after the fact.
export function shouldAutoCall(
s: AutoCallSettings,
d: AutoCallDecode,
e: DecodeStatus | undefined,
opts: {
// busy is "some receiver is mid-QSO", NOT "this one is transmitting".
//
// The distinction is the whole bug it fixes: with two instances the caller
// used to test a single global transmit flag, which belonged to whichever
// receiver reported last. So while slice A worked a station, slice B looked
// idle and auto-call started another QSO on it — and the moment either
// finished it chained straight into the next. One station at a time means
// one across ALL receivers, not one per receiver.
busy: boolean;
calledAt: Map<string, number>;
now: number;
myCall?: string;
},
): { call: boolean; reason: string } {
const no = (why: string) => ({ call: false, reason: why });
if (!s.enabled) return no('off');
if (!e) return no('status not resolved yet');
const call = (d.call ?? '').trim().toUpperCase();
if (!call) return no('no callsign');
// Never answer ourselves, however the decode reached us.
if (opts.myCall && call === opts.myCall.trim().toUpperCase()) return no('own callsign');
// NOT limited to a CQ, deliberately.
//
// It used to be, on the reasoning that answering a station mid-QSO is calling
// over somebody. That reasoning ignored the case the feature exists for: a
// DXpedition running a pileup never sends CQ at all — it works caller after
// caller — so the rule sat out the one contact auto-call was turned on for. A
// new entity on 15 m FT8, decode after decode, and not a single transmission.
//
// WHEN to transmit is not ours to decide either: the Reply goes to the
// decoder, and MSHV starts at once while JTDX waits for a CQ. Two correct
// behaviours, both belonging to the program that owns the timing. Here the
// question is only whether the station is one the operator wants — the
// criteria below answer that, and the cooldown and the busy check keep it from
// calling twice.
// Not while ANY receiver is mid-QSO — transmitting, or holding a DX call it
// has not finished with. Starting a second exchange before the first is done
// is what turned this into a machine that called without stopping.
if (opts.busy) return no('a QSO is already in progress');
const last = opts.calledAt.get(call);
if (last !== undefined && opts.now - last < s.cooldownSec * 1000) return no('called recently');
// The watch list first: an explicitly named station outranks the general
// criteria, and may carry conditions of its own.
const watched = s.watch.some((p) => matchesWildcard(p, call));
if (watched) {
if (!anyCriterion(s.watchCriteria)) {
// No conditions attached: call it unless it is already worked.
return e.worked_call ? no('watched, but already worked') : { call: true, reason: 'watch list' };
}
return meets(s.watchCriteria, e)
? { call: true, reason: 'watch list + criteria' }
: no('watched, but no criterion met');
}
if (!anyCriterion(s.criteria)) return no('no criteria ticked');
return meets(s.criteria, e)
? { call: true, reason: 'criteria' }
: no('no criterion met');
}
+136
View File
@@ -0,0 +1,136 @@
// IARU band plans, by region.
//
// The band maps used to carry one hard-coded table — Region 1's — so a US or
// Australian operator saw 40 m stop at 7200 while stations sat spotted at 7250,
// and the SSB wash started 25 kHz early. The region is a station-level fact the
// operator states once (Settings → General); everything drawing a band edge or
// a mode segment reads it from here.
//
// The tables are deliberately coarse: a band map's wash is CONTEXT, not a
// regulatory chart. Only the segments that differ between regions in ways an
// operator notices are split out; national fine print (US licence classes,
// beacon slots) does not belong in a background tint.
import { writeUiPref } from '@/lib/uiPref';
export type IaruRegion = 1 | 2 | 3;
export const KEY_IARU_REGION = 'opslog.iaruRegion';
export function iaruRegion(): IaruRegion {
try {
const v = localStorage.getItem(KEY_IARU_REGION);
return v === '2' ? 2 : v === '3' ? 3 : 1;
} catch { return 1; }
}
export function setIaruRegion(r: IaruRegion): void {
writeUiPref(KEY_IARU_REGION, String(r));
listeners.forEach((l) => l());
}
// The band maps capture the plan inside their render, so a change must reach
// them the way the distance unit reaches the grids.
const listeners = new Set<() => void>();
export function subscribeIaruRegion(fn: () => void): () => void {
listeners.add(fn);
return () => { listeners.delete(fn); };
}
export type SegMode = 'cw' | 'digi' | 'phone';
type Seg = [number, number, SegMode];
interface Plan {
ranges: Record<string, [number, number]>;
segments: Record<string, Seg[]>;
}
// Region 1 — Europe / Africa / Middle East / northern Asia. The baseline the
// other two override.
const R1: Plan = {
ranges: {
'160m': [1800, 2000], '80m': [3500, 3800], '60m': [5350, 5450],
'40m': [7000, 7200], '30m': [10100, 10150], '20m': [14000, 14350],
'17m': [18068, 18168], '15m': [21000, 21450], '12m': [24890, 24990],
'10m': [28000, 29700], '6m': [50000, 50500], '4m': [70000, 70500],
'2m': [144000, 146000], '70cm': [430000, 440000],
},
segments: {
'160m': [[1800, 1838, 'cw'], [1838, 1840, 'digi'], [1840, 2000, 'phone']],
'80m': [[3500, 3580, 'cw'], [3580, 3600, 'digi'], [3600, 3800, 'phone']],
'60m': [[5350, 5450, 'phone']],
'40m': [[7000, 7040, 'cw'], [7040, 7100, 'digi'], [7100, 7200, 'phone']],
'30m': [[10100, 10130, 'cw'], [10130, 10150, 'digi']],
'20m': [[14000, 14070, 'cw'], [14070, 14100, 'digi'], [14100, 14350, 'phone']],
'17m': [[18068, 18095, 'cw'], [18095, 18110, 'digi'], [18110, 18168, 'phone']],
'15m': [[21000, 21070, 'cw'], [21070, 21150, 'digi'], [21150, 21450, 'phone']],
'12m': [[24890, 24915, 'cw'], [24915, 24940, 'digi'], [24940, 24990, 'phone']],
'10m': [[28000, 28070, 'cw'], [28070, 28300, 'digi'], [28300, 29700, 'phone']],
'6m': [[50000, 50100, 'cw'], [50100, 50500, 'phone']],
},
};
// Region 2 — the Americas. 80 m runs to 4000 and 40 m to 7300, with phone from
// 7125 (the whole point of asking the region: a KP4 ragchew on 7250 must not
// hang past the top of the map).
const R2: Plan = {
ranges: {
...R1.ranges,
'80m': [3500, 4000], '40m': [7000, 7300],
'6m': [50000, 54000], '2m': [144000, 148000], '70cm': [420000, 450000],
},
segments: {
...R1.segments,
'80m': [[3500, 3570, 'cw'], [3570, 3600, 'digi'], [3600, 4000, 'phone']],
'40m': [[7000, 7040, 'cw'], [7040, 7125, 'digi'], [7125, 7300, 'phone']],
'6m': [[50000, 50100, 'cw'], [50100, 54000, 'phone']],
},
};
// Region 3 — Asia-Pacific. 80 m to 3900; 40 m as Region 1's shape.
const R3: Plan = {
ranges: {
...R1.ranges,
'80m': [3500, 3900],
'6m': [50000, 54000], '2m': [144000, 148000], '70cm': [430000, 450000],
},
segments: {
...R1.segments,
'80m': [[3500, 3535, 'cw'], [3535, 3600, 'digi'], [3600, 3900, 'phone']],
'6m': [[50000, 50100, 'cw'], [50100, 54000, 'phone']],
},
};
function plan(): Plan {
const r = iaruRegion();
return r === 2 ? R2 : r === 3 ? R3 : R1;
}
export function bandRange(band: string): [number, number] | undefined {
return plan().ranges[band];
}
export function bandSegments(band: string): Seg[] {
return plan().segments[band] ?? [];
}
// bandForMHz maps a dial frequency (MHz) to its ADIF band, or '' when it falls
// outside every known allocation.
//
// Lives here rather than in a panel because more than one place has to answer
// the same question the same way: the entry form retunes on a typed frequency,
// and the QSO editor has to keep band and frequency agreeing when one of them
// is corrected. Kept in step with BandFromHz on the Go side.
export function bandForMHz(mhz: number): string {
if (!mhz || isNaN(mhz)) return '';
const plan: [number, number, string][] = [
[1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'],
[10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'],
[24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'],
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [902, 928, '33cm'], [1240, 1300, '23cm'],
// Microwave, ADIF 3.1.7 ranges.
[2300, 2450, '13cm'], [3300, 3500, '9cm'], [5650, 5925, '6cm'], [10000, 10500, '3cm'],
[24000, 24250, '1.25cm'], [47000, 47200, '6mm'], [75500, 81000, '4mm'],
[119980, 123000, '2.5mm'], [134000, 149000, '2mm'], [241000, 250000, '1mm'],
];
for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b;
return '';
}
+25
View File
@@ -0,0 +1,25 @@
// What a decoding program is CALLED, as against what it calls itself.
//
// Every WSJT-X-family packet carries an "id" naming the sending program, and
// OpsLog shows it wherever a receiver has to be told apart from another. Most
// of them send the name on the box: "WSJT-X", "JTDX", "MSHV".
//
// Nexus does not. It announces itself as "Tempo" — the name of the engine
// inside it — so an operator running Nexus saw a program on their screen they
// have never heard of, and had to work out that it was theirs.
//
// Only the LABEL is translated. The id stays the routing key everywhere else:
// a Reply, a Halt and the auto-call's own bookkeeping are matched against what
// the program sent, and renaming that would send them to nobody.
const NAMES: Record<string, string> = {
TEMPO: 'Nexus',
};
export function decoderName(id?: string): string {
const raw = (id ?? '').trim();
if (!raw) return '';
// Matched on the leading word: some programs append a version or an instance
// number ("WSJT-X - 2", "Tempo 1.4"), and the name is the part before it.
const head = raw.split(/[\s\-–—]+/)[0].toUpperCase();
return NAMES[head] ?? raw;
}
+214 -72
View File
File diff suppressed because one or more lines are too long
+44
View File
@@ -172,6 +172,50 @@ export function greatCirclePoints(
return out;
}
// splitAtAntimeridian cuts a continuous (unwrapped) path into the pieces that
// fit on a map showing ONE world, each piece with longitudes back inside ±180.
//
// greatCirclePoints deliberately lets longitude run past ±180 so the polyline
// stays smooth. That is right for a map with repeating world copies, and wrong
// for one without: an arc from Australia to South America came out at 190°,
// 210°, 250° — drawn into the empty space off the right-hand edge, ending
// nowhere, while its own end marker sat correctly on the far left. From VK,
// where most paths cross the antimeridian, that was most of the map's arcs.
//
// Each crossing ends one piece at exactly ±180 and starts the next at the
// opposite edge, at the SAME latitude, so the line leaves one side of the map
// and re-enters the other at the height it left. Leaflet takes the result as a
// multi-polyline, so one path is still one layer.
export function splitAtAntimeridian(pts: [number, number][]): [number, number][][] {
if (pts.length === 0) return [];
// Which copy of the world a longitude belongs to: 0 is the map's own.
const world = (lon: number) => Math.floor((lon + 180) / 360);
const norm = (lon: number) => lon - 360 * world(lon);
const out: [number, number][][] = [];
let cur: [number, number][] = [];
for (let i = 0; i < pts.length; i++) {
const [lat, lon] = pts[i];
if (i > 0) {
const [pLat, pLon] = pts[i - 1];
const wPrev = world(pLon), wCur = world(lon);
if (wPrev !== wCur) {
const east = wCur > wPrev;
// The meridian actually crossed, in unwrapped degrees.
const edge = 180 + 360 * Math.min(wPrev, wCur);
const f = (edge - pLon) / (lon - pLon);
const edgeLat = pLat + f * (lat - pLat);
cur.push([edgeLat, east ? 180 : -180]);
out.push(cur);
cur = [[edgeLat, east ? -180 : 180]];
}
}
cur.push([lat, norm(lon)]);
}
if (cur.length > 1) out.push(cur);
else if (cur.length === 1 && out.length === 0) out.push(cur);
return out;
}
function toRad(d: number): number { return (d * Math.PI) / 180; }
function toDeg(r: number): number { return (r * 180) / Math.PI; }
+31
View File
@@ -0,0 +1,31 @@
// Remembered map views — where each map was left, per map.
//
// Panning and zooming a map is an operator saying "this is the part of the world
// I work". Throwing that away on every tab switch made the maps something to
// set up again each time rather than something to glance at, and the world map
// was the only one that remembered anything.
//
// One key per map, and portable (see lib/uiPref) like the world map's own: a
// copied data folder brings the views with it.
import { writeUiPref } from '@/lib/uiPref';
export type MapView = { lat: number; lon: number; zoom: number };
// The keys in use. Named here rather than typed at each call site so a rename
// cannot silently orphan somebody's saved view.
export const MAP_VIEW_WORLD = 'opslog.mapView';
export const MAP_VIEW_FT = 'opslog.ftMapView';
export const MAP_VIEW_GRIDS = 'opslog.gridMapView';
export function loadMapView(key: string): MapView | null {
try {
const v = JSON.parse(localStorage.getItem(key) || 'null');
return v && typeof v.zoom === 'number' && typeof v.lat === 'number' && typeof v.lon === 'number' ? v : null;
} catch {
return null; // corrupt or unreadable → open where the map would by default
}
}
export function saveMapView(key: string, lat: number, lon: number, zoom: number): void {
writeUiPref(key, JSON.stringify({ lat, lon, zoom }));
}
+6 -1
View File
@@ -14,9 +14,11 @@ export type MatrixColors = {
entity_worked: string;
not_worked: string;
current_entry: string;
mark_worked: string;
mark_confirmed: string;
};
// The six settings fields and the CSS custom property each one drives. Also the
// The settings fields and the CSS custom property each one drives. Also the
// display order — the same order the legend under the matrix reads in, so the
// settings panel and the grid can never disagree about which green is which.
export const MATRIX_VARS: { key: keyof Omit<MatrixColors, 'enabled'>; cssVar: string; label: string }[] = [
@@ -26,12 +28,15 @@ export const MATRIX_VARS: { key: keyof Omit<MatrixColors, 'enabled'>; cssVar: st
{ key: 'entity_worked', cssVar: '--mx-dx-work', label: 'mx.dxWork' },
{ key: 'not_worked', cssVar: '--mx-none', label: 'mx.none' },
{ key: 'current_entry', cssVar: '--mx-cur', label: 'mx.current' },
{ key: 'mark_worked', cssVar: '--mx-mark-work', label: 'mx.markWork' },
{ key: 'mark_confirmed', cssVar: '--mx-mark-conf', label: 'mx.markConf' },
];
export const emptyMatrixColors = (): MatrixColors => ({
enabled: false,
call_confirmed: '', call_worked: '', entity_confirmed: '',
entity_worked: '', not_worked: '', current_entry: '',
mark_worked: '', mark_confirmed: '',
});
// applyMatrixColors stamps (or clears) the overrides on <html>. Safe to call as
+39
View File
@@ -0,0 +1,39 @@
// Which rotor dial to draw.
//
// Two compasses ship: the current one (night map, square scale, mouse pointer,
// target marker) and the original small light-map dial. Neither is more correct
// than the other — one operator reads the big map at a glance, another wants the
// compact dial that was there before — so it is a preference, not a migration.
//
// A UI preference rather than a settings-database key: it decides what a widget
// looks like, nothing is transmitted from it, and it belongs to the screen it is
// read on. It is portable all the same (see lib/uiPref), so a copied folder
// keeps the dial its owner chose.
import { writeUiPref } from '@/lib/uiPref';
export const KEY_ROTOR_STYLE = 'opslog.rotorStyle';
export type RotorStyle = 'modern' | 'classic';
export function rotorStyle(): RotorStyle {
try {
return localStorage.getItem(KEY_ROTOR_STYLE) === 'classic' ? 'classic' : 'modern';
} catch {
return 'modern';
}
}
// Same shape as the distance unit: the compasses are rendered from two call
// sites, and a preference changed in Settings has to reach both without either
// of them polling for it.
const listeners = new Set<() => void>();
export function setRotorStyle(style: RotorStyle): void {
writeUiPref(KEY_ROTOR_STYLE, style);
listeners.forEach((l) => l());
}
export function subscribeRotorStyle(fn: () => void): () => void {
listeners.add(fn);
return () => { listeners.delete(fn); };
}
+82
View File
@@ -36,3 +36,85 @@ export function rstOptions(mode: string, lists: RSTLists): string[] {
if (l && l.length) return l;
return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20'];
}
// stepRST moves a report one step up or down, the way an operator would say it.
//
// The dropdown beside these fields is a list of the values worth having to hand,
// not of every legal report: nobody keeps 41 dB figures in it. So the wheel
// works on the VALUE, not on the list — a digital report moves by a decibel and
// an RST by one S-unit, which is what the hand on the wheel is asking for.
//
// dir is +1 for wheel up (a better report) and -1 for down.
export function stepRST(value: string, dir: number, mode: string): string {
const v = (value || '').trim();
if (v === '') return v;
if (rstCategory(mode) === 'digital') {
// A dB report: "-12", "+05", "0". Kept in its own shape — signed and two
// digits — because that is how every decoder writes it and how the operator
// reads it back off the screen.
const n = parseInt(v, 10);
if (!Number.isFinite(n)) return v;
// The range WSJT-X itself reports in, with room either side. Beyond it the
// number stops meaning anything.
const next = Math.max(-30, Math.min(35, n + dir));
return (next < 0 ? '-' : '+') + String(Math.abs(next)).padStart(2, '0');
}
// RST/RS, and the S9+ ladder above it. What an operator counts through is
//
// … 57 58 59 59+5 59+10 59+15 59+20 …
//
// — one S-unit up to nine, then five decibels at a time, because that is how
// a strong signal is reported and how the S-meter is read (see sMeterRST).
// Stepping the S digit and leaving the "+20" where it was would have gone
// from 59+20 to 58+20, which nobody has ever said on the air.
//
// R and T do not move: they are judgements about readability and tone, and a
// wheel has no business changing them.
const m = /^(\d)(\d)(\d?)(?:\+(\d+))?$/.exec(v);
if (!m) return v;
const head = m[1];
const tone = m[3];
let sUnit = parseInt(m[2], 10);
let over = m[4] === undefined ? 0 : parseInt(m[4], 10);
if (over > 0) {
// Above S9: five at a time, and back down through +5 to a plain 59.
over = Math.max(0, Math.min(60, over + dir * 5));
} else if (sUnit >= 9 && dir > 0) {
over = 5; // 59 → 59+5
} else {
sUnit = Math.max(1, Math.min(9, sUnit + dir));
}
return head + String(sUnit) + tone + (over > 0 ? '+' + over : '');
}
// rstFitsMode says whether a report belongs to the family a mode reports in.
//
// The three families are written differently and are not interchangeable: a
// signed decibel figure ("+00"), a three-figure RST ("599"), a two-figure RS
// ("59", with an optional "+20" above S9). "+00" on SSB is not a weak report,
// it is not a report at all.
export function rstFitsMode(value: string, mode: string): boolean {
const v = (value || '').trim();
if (v === '') return false;
switch (rstCategory(mode)) {
case 'digital': return /^[+-]\d{1,2}$/.test(v);
case 'cw': return /^\d{3}(\+\d+)?$/.test(v);
default: return /^\d{2}(\+\d+)?$/.test(v);
}
}
// convertRST carries a report across the CW/phone divide, keeping the operator's
// own judgement of the signal: 57 becomes 579, 599 becomes 59.
//
// Only between those two — a decibel figure says nothing about readability and
// an RST says nothing in decibels, so there the preset is the honest answer.
// Returns '' when it cannot be done.
export function convertRST(value: string, toMode: string): string {
const v = (value || '').trim();
const to = rstCategory(toMode);
if (to === 'digital') return '';
const m = /^(\d)(\d)(\d?)((?:\+\d+)?)$/.exec(v);
if (!m) return '';
if (to === 'cw') return m[1] + m[2] + '9' + m[4]; // RS → RST, tone 9
return m[1] + m[2] + m[4]; // RST → RS, tone dropped
}
+14 -5
View File
@@ -15,6 +15,15 @@ export function cleanSpotter(s: string): string {
// alone instead of guessing wrong.
export function inferSpotMode(comment: string, freqHz: number): string {
const c = (comment || '').toUpperCase();
// SuperFox and Fox/Hound are FT8 — they are WSJT-X's DXpedition transmit modes,
// not modes of their own, and they turn up in a comment written every way an
// operator can shorten them: "super fox", SFOX, S/F, F/H.
//
// A spot commented "super fox" fell through to the band plan and came out
// DATA, and that verdict is not cosmetic: the
// band+mode status is computed from this answer, so a ZD8 on 21.071 read as a
// new DATA slot rather than the new FT8 one it is.
if (/\bSUPER\s*FOX\b|\bSFOX\b|\bS\/F\b|\bFOX\s*\/?\s*HOUND\b|\bF\/H\b/.test(c)) return 'FT8';
if (/\bFT8\b/.test(c)) return 'FT8';
if (/\bFT4\b/.test(c)) return 'FT4';
if (/\bJS8\b/.test(c)) return 'JS8';
@@ -36,17 +45,17 @@ export function inferSpotMode(comment: string, freqHz: number): string {
type Seg = [number, number, string];
const segs: Seg[] = [
[1.8, 1.838, 'CW'], [1.838, 1.84, 'FT8'], [1.84, 2.0, 'SSB'],
[3.5, 3.58, 'CW'], [3.573, 3.576, 'FT8'], [3.58, 3.6, 'DATA'], [3.6, 4.0, 'SSB'],
[5.3, 5.5, 'SSB'],
[3.573, 3.575, 'FT8'], [3.575, 3.578, 'FT4'], [3.5, 3.58, 'CW'], [3.58, 3.6, 'DATA'], [3.6, 4.0, 'SSB'],
[5.357, 5.36, 'FT8'], [5.3, 5.5, 'SSB'],
[7.0, 7.04, 'CW'], [7.074, 7.077, 'FT8'], [7.0475, 7.0485, 'FT4'],
[7.04, 7.1, 'DATA'], [7.1, 7.3, 'SSB'],
[10.1, 10.13, 'CW'], [10.13, 10.15, 'DATA'],
[10.136, 10.139, 'FT8'], [10.14, 10.143, 'FT4'], [10.1, 10.13, 'CW'], [10.13, 10.15, 'DATA'],
[14.0, 14.07, 'CW'], [14.074, 14.077, 'FT8'], [14.08, 14.0815, 'FT4'],
[14.07, 14.1, 'DATA'], [14.1, 14.35, 'SSB'],
[18.068, 18.095, 'CW'], [18.1, 18.103, 'FT8'], [18.095, 18.11, 'DATA'], [18.11, 18.168, 'SSB'],
[18.1, 18.103, 'FT8'], [18.104, 18.107, 'FT4'], [18.068, 18.095, 'CW'], [18.095, 18.11, 'DATA'], [18.11, 18.168, 'SSB'],
[21.0, 21.07, 'CW'], [21.074, 21.077, 'FT8'], [21.14, 21.143, 'FT4'],
[21.07, 21.15, 'DATA'], [21.15, 21.45, 'SSB'],
[24.89, 24.915, 'CW'], [24.915, 24.917, 'FT8'], [24.915, 24.94, 'DATA'], [24.94, 24.99, 'SSB'],
[24.915, 24.918, 'FT8'], [24.919, 24.922, 'FT4'], [24.89, 24.915, 'CW'], [24.915, 24.94, 'DATA'], [24.94, 24.99, 'SSB'],
[28.0, 28.07, 'CW'], [28.074, 28.077, 'FT8'], [28.18, 28.183, 'FT4'],
[28.07, 28.3, 'DATA'], [28.3, 29.7, 'SSB'],
[50.0, 50.1, 'CW'], [50.313, 50.316, 'FT8'], [50.318, 50.321, 'FT4'],
+84 -3
View File
@@ -11,7 +11,63 @@
//
// They compose deliberately: mute what is done, light up what is not.
export type SpotDisplayOptions = { muteWorked: boolean; slotHighlight: boolean };
export type SpotDisplayOptions = {
muteWorked: boolean; slotHighlight: boolean;
// Chase switches (Settings → DX Cluster), ON by default. An operator who does
// not chase parks does not want NEW POTA shouting from every activator spot:
// off, the marker is withdrawn at the display layer — a "new band + new POTA"
// spot simply reads NEW BAND — and the reference column goes quiet. The facts
// keep being computed; only the telling stops, so ticking the box back on
// needs no rescan.
chasePota: boolean; chaseSota: boolean;
chaseCounty: boolean; chasePfx: boolean; chaseGrid: boolean; chaseState: boolean;
};
// chasePota/chaseSota read the switches directly — for the places that show a
// REFERENCE rather than a status (the POTA and SOTA columns).
export function chasePota(): boolean {
try { return localStorage.getItem('opslog.chasePota') !== '0'; } catch { return true; }
}
export function chaseSota(): boolean {
try { return localStorage.getItem('opslog.chaseSota') !== '0'; } catch { return true; }
}
export function chaseCounty(): boolean {
try { return localStorage.getItem('opslog.chaseCounty') !== '0'; } catch { return true; }
}
export function chasePfx(): boolean {
try { return localStorage.getItem('opslog.chasePfx') !== '0'; } catch { return true; }
}
export function chaseState(): boolean {
try { return localStorage.getItem('opslog.chaseState') !== '0'; } catch { return true; }
}
// chaseGrid mirrors the backend "chase new grids" setting (Settings writes the
// mirror on load and on toggle) so the display layer can gate NEW GRID without
// an async round-trip per row.
export function chaseGrid(): boolean {
try { return localStorage.getItem('opslog.chaseGrids') !== '0'; } catch { return true; }
}
// chaseAllows answers "does this operator chase this kind of thing at all?"
// for the ORTHOGONAL markers — park, square, prefix, county.
//
// The switches were written for the cluster and stayed there, so an operator
// who does not chase parks still met NEW POTA in the decode list and in Chase
// new: the same badge, withdrawn on one screen and shouting on the next. The
// setting is about what the operator hunts, not about which panel is open.
//
// The keys are both the status-entry field names (new_pota…) and the shorter
// category names the panels filter with (pota…), so one call serves both.
// Anything without a switch of its own — a new US state — is always allowed.
export function chaseAllows(key: string): boolean {
switch (key) {
case 'new_pota': case 'pota': return chasePota();
case 'new_grid': case 'grid': return chaseGrid();
case 'new_pfx': case 'pfx': return chasePfx();
case 'new_county': case 'cty': return chaseCounty();
case 'new_state': case 'state': return chaseState();
default: return true;
}
}
// Both options are withdrawn from the filter panel for now. The machinery below
// is deliberately kept whole — it is correct and hard-won — so putting the two
@@ -23,14 +79,21 @@ export type SpotDisplayOptions = { muteWorked: boolean; slotHighlight: boolean }
export const SPOT_DISPLAY_OPTIONS_EXPOSED = false;
export function readSpotDisplayOptions(): SpotDisplayOptions {
if (!SPOT_DISPLAY_OPTIONS_EXPOSED) return { muteWorked: false, slotHighlight: false };
// The EXPOSED flag only withdraws the two original switches; the chase
// switches are live regardless.
if (!SPOT_DISPLAY_OPTIONS_EXPOSED) {
return { muteWorked: false, slotHighlight: false, chasePota: chasePota(), chaseSota: chaseSota(), chaseCounty: chaseCounty(), chasePfx: chasePfx(), chaseGrid: chaseGrid(), chaseState: chaseState() };
}
try {
return {
muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1',
slotHighlight: localStorage.getItem('opslog.clusterSlotHighlight') === '1',
chasePota: chasePota(), chaseSota: chaseSota(),
chaseCounty: chaseCounty(), chasePfx: chasePfx(), chaseGrid: chaseGrid(),
chaseState: chaseState(),
};
} catch {
return { muteWorked: false, slotHighlight: false };
return { muteWorked: false, slotHighlight: false, chasePota: true, chaseSota: true, chaseCounty: true, chasePfx: true, chaseGrid: true, chaseState: true };
}
}
@@ -42,6 +105,7 @@ type Entry = {
new_pota?: boolean;
new_pfx?: boolean;
new_grid?: boolean;
new_state?: boolean;
} | undefined;
// applySpotDisplay rewrites a status entry per the options, so every consumer —
@@ -72,6 +136,23 @@ export function applySpotDisplay<T extends Entry>(s: T, o: SpotDisplayOptions):
if (o.muteWorked) {
e = { ...e, worked_call: false } as NonNullable<T>;
}
if (!o.chasePota && e.new_pota) {
e = { ...e, new_pota: false } as NonNullable<T>;
}
// Same withdrawal for the other extra markers: the facts keep being
// computed, only the telling stops — re-ticking a box needs no rescan.
if (!o.chaseCounty && e.new_county) {
e = { ...e, new_county: false } as NonNullable<T>;
}
if (!o.chasePfx && e.new_pfx) {
e = { ...e, new_pfx: false } as NonNullable<T>;
}
if (!o.chaseGrid && e.new_grid) {
e = { ...e, new_grid: false } as NonNullable<T>;
}
if (!o.chaseState && e.new_state) {
e = { ...e, new_state: false } as NonNullable<T>;
}
return e;
}
+2 -1
View File
@@ -18,7 +18,7 @@
// grid magenta — the last hue in the categorical set that is not already
// spoken for here and does not read as a status; a grid is
// never urgent the way a new entity is
export type SpotMarkerKey = 'new_pota' | 'new_county' | 'new_pfx' | 'worked_call' | 'new_grid';
export type SpotMarkerKey = 'new_pota' | 'new_county' | 'new_pfx' | 'worked_call' | 'new_grid' | 'new_state';
export type SpotMarker = {
key: SpotMarkerKey;
@@ -32,6 +32,7 @@ export const SPOT_MARKERS: SpotMarker[] = [
{ key: 'new_county', colour: 'var(--chart-5)', labelKey: 'clg2.newCounty' },
{ key: 'new_pfx', colour: 'var(--caution)', labelKey: 'clg2.newPfx' },
{ key: 'new_grid', colour: 'var(--chart-7)', labelKey: 'clg2.newGrid' },
{ key: 'new_state', colour: 'var(--chart-3)', labelKey: 'clg2.newState' },
{ key: 'worked_call', colour: 'var(--info)', labelKey: 'clg2.wkdCall' },
];
+2 -2
View File
@@ -7,14 +7,14 @@ import { GetUIPref } from '../../wailsjs/go/main/App';
// 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' | 'light-nordic' | 'sahara'
| 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'dark-indigo' | 'dark-teal' | 'dark-plum' | 'high-contrast';
| 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'dark-indigo' | 'dark-teal' | 'dark-plum' | 'dxhunter' | 'dxhunter-orange' | 'high-contrast';
// 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', 'light-nordic', 'sahara',
'dim-slate', 'dark-warm', 'dark-graphite', 'dark-indigo', 'dark-teal', 'dark-plum',
'dim-slate', 'dark-warm', 'dark-graphite', 'dark-indigo', 'dark-teal', 'dark-plum', 'dxhunter', 'dxhunter-orange',
'high-contrast',
];
+11
View File
@@ -29,6 +29,9 @@ const PORTABLE_KEYS = [
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
// The same, for the FT decodes map and the grid-square map: a view an
// operator set up is theirs, and it should follow the folder like the rest.
'opslog.ftMapView', 'opslog.gridMapView',
'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)
@@ -40,6 +43,14 @@ const PORTABLE_KEYS = [
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
'opslog.distanceMiles', // distances shown in statute miles rather than km
'opslog.iaruRegion', // IARU region (1/2/3) — band edges and segments on the band maps
'opslog.chasePota', // show POTA references and the NEW POTA marker on spots
'opslog.chaseSota', // show SOTA references on spots
// The other chase switches. In the DB as well as locally because AUTO-CALL
// reads them: what the operator does not hunt is not something to transmit
// for, and the backend cannot see localStorage.
'opslog.chasePfx', 'opslog.chaseCounty', 'opslog.chaseState', 'opslog.chaseGrids',
'opslog.activeTab', // last selected tab
'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares
'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}
+49
View File
@@ -0,0 +1,49 @@
// Distance units.
//
// Everything is COMPUTED in kilometres — the great-circle maths, the backend's
// distance_km on a spot, the map's path lengths — and converted once, here, at
// display time. Storing miles anywhere would mean two sources of truth for the
// same number and a rounding error that grows with every hop.
//
// The preference is portable (see lib/uiPref): an operator who works in miles
// works in miles on every machine they copy their folder to.
import { writeUiPref } from '@/lib/uiPref';
export const KEY_MILES = 'opslog.distanceMiles';
const KM_PER_MILE = 1.609344; // statute miles, the ones a US licence is used in
export function useMiles(): boolean {
try { return localStorage.getItem(KEY_MILES) === '1'; } catch { return false; }
}
export function setUseMiles(on: boolean): void {
writeUiPref(KEY_MILES, on ? '1' : '0');
listeners.forEach((l) => l());
}
// subscribeDistanceUnit notifies on a change. The grids capture the unit inside
// their column definitions (header text and formatter both), so without this a
// toggle would only show up on the next language change or restart.
const listeners = new Set<() => void>();
export function subscribeDistanceUnit(fn: () => void): () => void {
listeners.add(fn);
return () => { listeners.delete(fn); };
}
// distanceValue converts a distance in km to the operator's unit, rounded to a
// whole unit — the precision the inputs actually justify (a 4-character grid is
// a square tens of kilometres wide).
export function distanceValue(km: number): number {
if (!isFinite(km)) return 0;
return Math.round(useMiles() ? km / KM_PER_MILE : km);
}
// distanceUnit is the short label: "km" or "mi".
export function distanceUnit(): string {
return useMiles() ? 'mi' : 'km';
}
// formatDistance is value + unit, thousands-separated: "12 345 km".
export function formatDistance(km: number): string {
return `${distanceValue(km).toLocaleString()} ${distanceUnit()}`;
}
+133
View File
@@ -0,0 +1,133 @@
// What the watch list is HEARING, and what of it is still needed.
//
// Two places ask the same question — the Watchlist tab and the docked widget —
// and the answer involves a debounced round trip to the logbook per visible
// slot. Written twice it would be two definitions of "needed" drifting apart,
// and two bursts of the same query on every spot; written here it is one.
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ClusterSpot } from '@/components/ClusterGrid';
import { inferSpotMode } from '@/lib/spot';
import { WatchlistWorkedSlots } from '../../wailsjs/go/main/App';
export interface WLEntry {
callsign: string; lastSeenStr: string; addedAt: string; spotCount: number;
isContest: boolean; notify: boolean;
isExpedition: boolean; clubLogQSOs24h: number; clubLogTotalQSOs: number;
clubLogHasOQRS: boolean; clubLogLiveStream: boolean;
}
// A spot is ON AIR while its last sighting is this fresh.
export const ON_AIR_MS = 10 * 60 * 1000;
// Exact unless the entry carries a trailing * — the same rule the backend's
// Match applies to the live stream, mirrored so the list and the alerts can
// never disagree about what an entry covers.
export function matchesEntry(call: string, pattern: string): boolean {
const c = call.toUpperCase();
if (pattern.endsWith('*')) {
const p = pattern.slice(0, -1);
return p !== '' && c.startsWith(p);
}
return c === pattern;
}
// What the cluster's verdict is worth saying, and in what colour.
//
// The order of severity is the one the band map and Chase new use: a new entity
// first, then the band and mode inside it. Shared because the tab and the docked
// widget must not label the same spot differently — an operator reads one of
// them to decide whether to leave what they are doing.
export const NEW_BADGES: Record<string, { key: string; colour: string }> = {
'new': { key: 'wl.newDxcc', colour: 'var(--danger)' },
'new-band-mode': { key: 'clg2.newBandMode', colour: 'var(--danger)' },
'new-band': { key: 'clg2.newBand', colour: 'var(--warning)' },
'new-mode': { key: 'clg2.newMode', colour: 'var(--caution)' },
'new-slot': { key: 'clg2.newSlot', colour: '#5AC8FA' },
};
export function newBadge(status?: string): { key: string; colour: string } | null {
return (status && NEW_BADGES[status]) || null;
}
export interface WatchlistSpots {
// The raw verdicts, exposed for dependency arrays: it changes only when an
// answer arrives, where the closures below are new on every render.
worked: Record<string, boolean>;
// The spots each entry covers, deduplicated by band+mode.
spotsFor: Map<string, ClusterSpot[]>;
// Worked on this exact slot (and, for a contest entry, today).
workedFor: (e: WLEntry, s: ClusterSpot) => boolean;
// Whether the logbook has actually answered for this pair yet. A spot whose
// verdict has not come back is NOT drawn: showing it as needed and
// withdrawing it half a second later made the list twitch on every burst,
// and nobody needs a spot 400 ms early — they need it settled.
settled: (e: WLEntry, s: ClusterSpot) => boolean;
onAir: (e: WLEntry) => boolean;
}
export function useWatchlistSpots(entries: WLEntry[], spots: ClusterSpot[]): WatchlistSpots {
const [worked, setWorked] = useState<Record<string, boolean>>({});
const spotsFor = useMemo(() => {
const map = new Map<string, ClusterSpot[]>();
for (const e of entries) map.set(e.callsign, []);
for (const s of spots) {
for (const e of entries) {
if (matchesEntry(s.dx_call ?? '', e.callsign)) { map.get(e.callsign)!.push(s); break; }
}
}
for (const [k, list] of map) {
const seen = new Set<string>();
map.set(k, list.filter((s) => {
const key = `${(s.band ?? '')}|${inferSpotMode(s.comment ?? '', s.freq_hz)}|${s.dx_call}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
}));
}
return map;
}, [spots, entries]);
// Debounced: a spot burst must cost one round trip, not one per spot.
const queryTimer = useRef<number | undefined>(undefined);
useEffect(() => {
if (queryTimer.current) window.clearTimeout(queryTimer.current);
queryTimer.current = window.setTimeout(async () => {
const queries: { call: string; band: string; mode: string; contest: boolean }[] = [];
const keys: string[] = [];
for (const e of entries) {
for (const s of spotsFor.get(e.callsign) ?? []) {
const mode = inferSpotMode(s.comment ?? '', s.freq_hz) || '';
queries.push({ call: s.dx_call, band: s.band ?? '', mode, contest: e.isContest });
keys.push(`${s.dx_call}|${s.band ?? ''}|${mode}|${e.isContest ? 1 : 0}`);
}
}
if (queries.length === 0) { setWorked({}); return; }
try {
const res: boolean[] = (await WatchlistWorkedSlots(queries as any)) ?? [];
// MERGED, not replaced: replacing made every already-answered key
// momentarily unknown on each refresh, which re-hid settled lines.
setWorked((prev) => {
const next = { ...prev };
keys.forEach((k, i) => { next[k] = !!res[i]; });
return next;
});
} catch { /* the badges just stay conservative */ }
}, 150) as unknown as number;
return () => { if (queryTimer.current) window.clearTimeout(queryTimer.current); };
}, [spotsFor, entries]);
const wkey = (e: WLEntry, s: ClusterSpot) =>
`${s.dx_call}|${s.band ?? ''}|${inferSpotMode(s.comment ?? '', s.freq_hz) || ''}|${e.isContest ? 1 : 0}`;
return {
worked,
spotsFor,
workedFor: (e, s) => worked[wkey(e, s)] ?? false,
settled: (e, s) => wkey(e, s) in worked,
onAir: (e) => (spotsFor.get(e.callsign) ?? []).some((s) => {
const ts = Date.parse(String((s as any).received_at ?? ''));
return ts > 0 && Date.now() - ts < ON_AIR_MS;
}),
};
}
+169 -3
View File
@@ -91,6 +91,11 @@
be recoloured on its own without dragging every other warning in the app
with it (Appearance matrix colours). */
--mx-cur: var(--warning);
/* The "worked with this callsign" dot. Declared ONCE, like --mx-cur: it is
drawn over every one of the five cell colours, so it follows the theme's own
foreground/background pair rather than a per-theme colour of its own. */
--mx-mark-work: var(--foreground);
--mx-mark-conf: var(--foreground);
--scrollbar-thumb: #b8a880;
--scrollbar-thumb-hover: #968455;
@@ -569,8 +574,8 @@
"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-dx-conf: #a78bfa;
--mx-dx-work: #5b4a9e;
--mx-none: #2c2f4d;
--scrollbar-thumb: #383c63;
@@ -857,6 +862,156 @@
color-scheme: light;
}
/* ---- Theme 13: DXHunter its own slate and blue -----------------------
Ported so an operator running both side by side does not switch between two
colour worlds every time they look up. It is Tailwind's slate scale, which
is what DXHunter is built on: page at slate-900, panels at slate-800, rules
at slate-700, and blue-500 for the accent counted across its sources, not
guessed from one panel: blue is 132 uses to violet's 25, and the violet is
the PSK Reporter panel alone. Active tab, focus border, primary button: all
blue-500. The status colours are DXHunter's too emerald for good, amber
for attention, cyan for information, red for trouble so a green number
means the same thing in both windows. */
[data-theme="dxhunter"] {
--background: #0f172a; /* slate-900 — the page */
--foreground: #e2e8f0; /* slate-200 */
--card: #1e293b; /* slate-800 — panels lift off the page */
--card-foreground: #e2e8f0;
--popover: #1e293b;
--popover-foreground: #e2e8f0;
--primary: #3b82f6; /* blue-500 — active tabs, focus, buttons */
--primary-foreground: #f8fafc;
--secondary: #273449;
--secondary-foreground: #e2e8f0;
--muted: #1c2941; /* toolbars / table headers */
--muted-foreground: #94a3b8; /* slate-400 — DXHunter's muted text */
--accent: #2c3b54; /* hover / selection tint */
--accent-foreground: #cbd5e1;
--destructive: #ef4444;
--destructive-foreground: #fef2f2;
--destructive-muted: #3a1518;
--destructive-muted-foreground: #fca5a5;
--border: #334155; /* slate-700 — every rule in DXHunter */
--input: #334155;
--ring: #60a5fa; /* blue-400 — its focus border */
--success: #34d399; /* emerald-400 — "online", confirmed */
--success-foreground: #04211a;
--success-muted: #0e3029;
--success-muted-foreground: #6ee7b7;
--success-border: #17564a;
--warning: #fbbf24; /* amber-400 */
--warning-foreground: #211803;
--warning-muted: #33280f;
--warning-muted-foreground: #fcd34d;
--warning-border: #4f3e15;
--caution: #facc15;
--caution-foreground: #211e04;
--caution-muted: #322d0e;
--caution-muted-foreground: #fde047;
--caution-border: #4b4315;
--danger: #f87171; /* red-400 — rose is barely used there */
--danger-foreground: #250912;
--danger-muted: #3a1621;
--danger-muted-foreground: #fda4af;
--danger-border: #5a2735;
--info: #22d3ee; /* cyan-400 — "heard near you" */
--info-foreground: #04212a;
--info-muted: #0c2f3b;
--info-muted-foreground: #67e8f9;
--info-border: #155e6e;
/* Blue is the primary, so the entity ramp goes VIOLET rather than reading
as a button and violet is where DXHunter puts its own second accent. */
--mx-call-conf: #22c55e;
--mx-call-work: #2c7a52;
--mx-dx-conf: #a78bfa;
--mx-dx-work: #5b4a9e;
--mx-none: #334155;
--scrollbar-thumb: #334155; /* DXHunter's own scrollbar */
--scrollbar-thumb-hover: #475569;
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(226, 232, 240, 0.05);
color-scheme: dark;
}
/* ---- Theme 14: DXHunter orange the same slate, OpsLog's own accent --
DXHunter's slate, kept exactly page, panels, rules, muted text, and its
status colours with OpsLog's orange in place of its blue. For an operator
who wants the two windows to sit together without OpsLog losing the accent
it is recognised by. The entity ramp goes VIOLET here for the same reason as
in the blue version: it must not read as the accent. */
[data-theme="dxhunter-orange"] {
--background: #0f172a; /* slate-900 — the page */
--foreground: #e2e8f0; /* slate-200 */
--card: #1e293b; /* slate-800 — panels lift off the page */
--card-foreground: #e2e8f0;
--popover: #1e293b;
--popover-foreground: #e2e8f0;
--primary: #f97316; /* orange-500 — OpsLog's own accent */
--primary-foreground: #1c0a02;
--secondary: #273449;
--secondary-foreground: #e2e8f0;
--muted: #1c2941; /* toolbars / table headers */
--muted-foreground: #94a3b8; /* slate-400 — DXHunter's muted text */
--accent: #2c3b54; /* hover / selection tint */
--accent-foreground: #cbd5e1;
--destructive: #ef4444;
--destructive-foreground: #fef2f2;
--destructive-muted: #3a1518;
--destructive-muted-foreground: #fca5a5;
--border: #334155; /* slate-700 — every rule in DXHunter */
--input: #334155;
--ring: #fdba74; /* orange-300 focus ring */
--success: #34d399; /* emerald-400 — "online", confirmed */
--success-foreground: #04211a;
--success-muted: #0e3029;
--success-muted-foreground: #6ee7b7;
--success-border: #17564a;
--warning: #fbbf24; /* amber-400 */
--warning-foreground: #211803;
--warning-muted: #33280f;
--warning-muted-foreground: #fcd34d;
--warning-border: #4f3e15;
--caution: #facc15;
--caution-foreground: #211e04;
--caution-muted: #322d0e;
--caution-muted-foreground: #fde047;
--caution-border: #4b4315;
--danger: #f87171; /* red-400 — rose is barely used there */
--danger-foreground: #250912;
--danger-muted: #3a1621;
--danger-muted-foreground: #fda4af;
--danger-border: #5a2735;
--info: #22d3ee; /* cyan-400 — "heard near you" */
--info-foreground: #04212a;
--info-muted: #0c2f3b;
--info-muted-foreground: #67e8f9;
--info-border: #155e6e;
/* Blue is the primary, so the entity ramp goes VIOLET rather than reading
as a button and violet is where DXHunter puts its own second accent. */
--mx-call-conf: #22c55e;
--mx-call-work: #2c7a52;
--mx-dx-conf: #a78bfa;
--mx-dx-work: #5b4a9e;
--mx-none: #334155;
--scrollbar-thumb: #334155; /* DXHunter's own scrollbar */
--scrollbar-thumb-hover: #475569;
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(226, 232, 240, 0.05);
color-scheme: dark;
}
/* 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
@@ -900,7 +1055,9 @@
[data-theme="high-contrast"],
[data-theme="dark-indigo"],
[data-theme="dark-teal"],
[data-theme="dark-plum"] {
[data-theme="dark-plum"],
[data-theme="dxhunter"],
[data-theme="dxhunter-orange"] {
--chart-1: #3987e5;
--chart-2: #199e70;
--chart-3: #c98500;
@@ -981,6 +1138,8 @@
--color-mx-dx-work: var(--mx-dx-work);
--color-mx-none: var(--mx-none);
--color-mx-cur: var(--mx-cur);
--color-mx-mark-work: var(--mx-mark-work);
--color-mx-mark-conf: var(--mx-mark-conf);
--radius: 0.5rem;
@@ -1079,3 +1238,10 @@
.overflow-scroll {
overscroll-behavior: contain;
}
/* The ground beside the planet, on every map: Leaflet paints its container a
hard-coded light grey (#ddd), which reads as a broken tile against any
theme. The surround follows the theme's own card surface instead. */
.leaflet-container {
background: var(--card) !important;
}
+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.26.18';
export const APP_VERSION = '0.27.14';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+125
View File
@@ -12,8 +12,10 @@ import {award} from '../models';
import {awardref} from '../models';
import {bandopen} from '../models';
import {cluster} from '../models';
import {dxped} from '../models';
import {extsvc} from '../models';
import {powergenius} from '../models';
import {pskrtgt} from '../models';
import {pskr} from '../models';
import {psu} from '../models';
import {spe} from '../models';
@@ -30,6 +32,7 @@ import {lotwusers} from '../models';
import {lookup} from '../models';
import {netctl} from '../models';
import {scp} from '../models';
import {watchlist} from '../models';
export function ACOMSetOperate(arg1:boolean):Promise<void>;
@@ -43,6 +46,10 @@ export function ADIFVersion():Promise<string>;
export function ActivateProfile(arg1:number):Promise<void>;
export function ActiveRadioID():Promise<string>;
export function ActiveRadioMyRig():Promise<string>;
export function AddQSO(arg1:qso.QSO):Promise<number>;
export function AmpFanMode(arg1:string,arg2:string):Promise<void>;
@@ -137,6 +144,8 @@ export function ComputeQSOAwardRefs(arg1:qso.QSO):Promise<Array<main.QSOAwardRef
export function ComputeStationInfo(arg1:string,arg2:string):Promise<main.StationInfoComputed>;
export function ConfigureDecoderMode(arg1:string):Promise<void>;
export function ConnectAllClusters():Promise<void>;
export function ConnectClusterServer(arg1:number):Promise<void>;
@@ -153,6 +162,8 @@ export function CreateDatabase(arg1:string):Promise<void>;
export function DVKCancelRecord():Promise<void>;
export function DVKDelete(arg1:number):Promise<void>;
export function DVKPlay(arg1:number):Promise<void>;
export function DVKPreview(arg1:number):Promise<void>;
@@ -401,8 +412,14 @@ export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
export function GetAntGeniusStatus():Promise<antgenius.Status>;
export function GetAudioMonitorPref():Promise<boolean>;
export function GetAudioSettings():Promise<main.AudioSettings>;
export function GetAutoCallSettings():Promise<main.AutoCallSettings>;
export function GetAutoCallStatus():Promise<main.AutoCallStatus>;
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
export function GetAward(arg1:string,arg2:string):Promise<award.Result>;
@@ -437,10 +454,14 @@ export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
export function GetChaseNew():Promise<boolean>;
export function GetChaseNewBands():Promise<Array<string>>;
export function GetChaseNewGrids():Promise<boolean>;
export function GetChaseNewSpots():Promise<Array<main.ChaseNewSpot>>;
export function GetChaseSettings():Promise<main.ChaseSettings>;
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
@@ -463,6 +484,10 @@ export function GetDVKMessages():Promise<Array<main.DVKMessage>>;
export function GetDVKStatus():Promise<main.DVKStatus>;
export function GetDXWorldNews():Promise<Array<dxped.News>>;
export function GetDXpeditions():Promise<Array<main.DXpedition>>;
export function GetDataDir():Promise<string>;
export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
@@ -501,6 +526,10 @@ export function GetLiveOpenings():Promise<Array<bandopen.Opening>>;
export function GetLiveStations():Promise<Array<main.LiveStation>>;
export function GetLoTWDownloadAllCalls():Promise<boolean>;
export function GetLoTWQSLDetail():Promise<boolean>;
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
export function GetLogFilePath():Promise<string>;
@@ -527,8 +556,12 @@ export function GetPGXLStatus():Promise<powergenius.Status>;
export function GetPOTAToken():Promise<string>;
export function GetPSKAnalysis():Promise<pskrtgt.Analysis>;
export function GetPSKReporterStatus():Promise<pskr.Status>;
export function GetPSKTargetSettings():Promise<main.PSKTargetSettings>;
export function GetPSUSettings():Promise<main.PSUSettings>;
export function GetPSUStatus():Promise<psu.Status>;
@@ -541,6 +574,8 @@ export function GetQSO(arg1:number):Promise<qso.QSO>;
export function GetQSORate():Promise<main.QSORate>;
export function GetRadios():Promise<Array<main.RadioConfig>>;
export function GetRelayAuto():Promise<main.RelayAutoConfig>;
export function GetRotatorHeading():Promise<main.RotatorHeading>;
@@ -593,6 +628,10 @@ export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
export function GetWatchlistContestCalls():Promise<string>;
export function GetWatchlistContestPattern():Promise<string>;
export function GetWebPublishConfig():Promise<webpub.Config>;
export function GetWebPublishStatus():Promise<main.WebPublishStatus>;
@@ -605,16 +644,28 @@ export function GetWinkeyerStatus():Promise<winkeyer.Status>;
export function GetWorkedCallVariants():Promise<boolean>;
export function GetWsjtFollowMode():Promise<boolean>;
export function GetWsjtHighlight():Promise<boolean>;
export function GetWsjtHighlightWorked():Promise<boolean>;
export function GetYaesuBandAntennas():Promise<Record<string, number>>;
export function GetYaesuState():Promise<cat.YaesuTXState>;
export function GridSquares(arg1:string):Promise<Array<qso.GridSquare>>;
export function HaltAutoCall():Promise<void>;
export function HaltDecodeTx(arg1:string,arg2:boolean):Promise<void>;
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
export function IcomConsolePTT(arg1:boolean):Promise<void>;
export function IcomRecallBand(arg1:string,arg2:number):Promise<number>;
export function IcomRefresh():Promise<void>;
export function IcomScopeData():Promise<cat.ScopeSweep>;
@@ -629,6 +680,8 @@ export function IcomSetANF(arg1:boolean):Promise<void>;
export function IcomSetAPF(arg1:boolean):Promise<void>;
export function IcomSetATU(arg1:boolean):Promise<void>;
export function IcomSetAntenna(arg1:number):Promise<void>;
export function IcomSetAntiVOX(arg1:number):Promise<void>;
@@ -689,6 +742,8 @@ export function IcomSetScopeMode(arg1:boolean):Promise<void>;
export function IcomSetSplit(arg1:boolean):Promise<void>;
export function IcomSetSplitOffset(arg1:number):Promise<void>;
export function IcomSetSquelch(arg1:number):Promise<void>;
export function IcomSetVOX(arg1:boolean):Promise<void>;
@@ -745,6 +800,8 @@ export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>;
export function ListQSOFiltered(arg1:qso.QueryFilter):Promise<Array<qso.QSO>>;
export function ListRadios():Promise<Array<main.RadioListEntry>>;
export function ListSerialPorts():Promise<Array<string>>;
export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>>;
@@ -911,6 +968,8 @@ export function RecomputeAwardRefsForCode(arg1:string):Promise<number>;
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function RefreshDXpeditions():Promise<void>;
export function RefreshKenwood():Promise<void>;
export function RefreshSolar():Promise<void>;
@@ -937,6 +996,8 @@ export function ReportLiveActivity(arg1:number,arg2:string,arg3:string):Promise<
export function RescanAwards():Promise<void>;
export function ResetAutoCall():Promise<void>;
export function ResetAwardDefs():Promise<Array<award.Def>>;
export function ResetDatabaseToDefault():Promise<void>;
@@ -983,6 +1044,8 @@ export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
export function SaveAutoCallSettings(arg1:main.AutoCallSettings):Promise<void>;
export function SaveAutostartPrograms(arg1:Array<main.AutostartProgram>):Promise<void>;
export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>;
@@ -997,6 +1060,8 @@ export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
export function SaveCabrilloFile():Promise<string>;
export function SaveChaseSettings(arg1:main.ChaseSettings):Promise<void>;
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
@@ -1031,12 +1096,16 @@ export function SavePGXLSettings(arg1:main.PGXLSettings):Promise<void>;
export function SavePOTAToken(arg1:string):Promise<void>;
export function SavePSKTargetSettings(arg1:main.PSKTargetSettings):Promise<void>;
export function SavePSUSettings(arg1:main.PSUSettings):Promise<void>;
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
export function SaveRadios(arg1:Array<main.RadioConfig>):Promise<void>;
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
@@ -1083,10 +1152,18 @@ export function SendLogToDeveloper():Promise<void>;
export function SendQSORecordingEmail(arg1:number):Promise<void>;
export function SetActiveRadio(arg1:string):Promise<void>;
export function SetActiveRotor(arg1:number):Promise<void>;
export function SetAlertEmailTo(arg1:string):Promise<void>;
export function SetAutoCall(arg1:boolean):Promise<void>;
export function SetAutoCallOnly(arg1:string):Promise<void>;
export function SetAutoCallVisible(arg1:Array<string>,arg2:boolean):Promise<void>;
export function SetCATFrequency(arg1:number):Promise<void>;
export function SetCATMode(arg1:string):Promise<void>;
@@ -1097,6 +1174,8 @@ export function SetCWDecoderPitch(arg1:number):Promise<void>;
export function SetChaseNew(arg1:boolean):Promise<void>;
export function SetChaseNewBands(arg1:Array<string>):Promise<void>;
export function SetChaseNewGrids(arg1:boolean):Promise<void>;
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
@@ -1131,6 +1210,8 @@ export function SetKenwoodNB(arg1:boolean):Promise<void>;
export function SetKenwoodNR(arg1:boolean):Promise<void>;
export function SetKenwoodPanelMode(arg1:string):Promise<void>;
export function SetKenwoodPower(arg1:number):Promise<void>;
export function SetKenwoodPreamp(arg1:boolean):Promise<void>;
@@ -1139,6 +1220,8 @@ export function SetKenwoodRFGain(arg1:number):Promise<void>;
export function SetKenwoodRIT(arg1:boolean):Promise<void>;
export function SetKenwoodRITOffset(arg1:number):Promise<void>;
export function SetKenwoodSquelch(arg1:number):Promise<void>;
export function SetKenwoodTX(arg1:boolean):Promise<void>;
@@ -1147,14 +1230,22 @@ export function SetKenwoodXIT(arg1:boolean):Promise<void>;
export function SetLinkedAmps(arg1:Array<string>):Promise<void>;
export function SetLoTWDownloadAllCalls(arg1:boolean):Promise<void>;
export function SetLoTWQSLDetail(arg1:boolean):Promise<void>;
export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise<void>;
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
export function SetPSKTarget(arg1:string,arg2:string,arg3:number):Promise<void>;
export function SetPSUOutput(arg1:boolean):Promise<void>;
export function SetPassphrase(arg1:string):Promise<void>;
export function SetScpClublogEnabled(arg1:boolean):Promise<void>;
export function SetScpEnabled(arg1:boolean):Promise<void>;
export function SetSpotMax(arg1:number):Promise<void>;
@@ -1205,10 +1296,20 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
export function SetUltrabeamDirection(arg1:number):Promise<void>;
export function SetWatchlistContestCalls(arg1:string):Promise<void>;
export function SetWatchlistContestPattern(arg1:string):Promise<void>;
export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
export function SetWorkedCallVariants(arg1:boolean):Promise<void>;
export function SetWsjtFollowMode(arg1:boolean):Promise<void>;
export function SetWsjtHighlight(arg1:boolean):Promise<void>;
export function SetWsjtHighlightWorked(arg1:boolean):Promise<void>;
export function SetYaesuAFGain(arg1:number):Promise<void>;
export function SetYaesuAGC(arg1:string):Promise<void>;
@@ -1261,8 +1362,16 @@ export function SyncFolderNow():Promise<number>;
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
export function TCISendCW(arg1:string):Promise<void>;
export function TCISetKeySpeed(arg1:number):Promise<void>;
export function TCIStopCW():Promise<void>;
export function TailLogFile(arg1:number):Promise<string>;
export function TakeAutoCallTarget(arg1:string,arg2:string,arg3:string):Promise<void>;
export function TestCloudlogUpload():Promise<string>;
export function TestClublogUpload():Promise<string>;
@@ -1273,6 +1382,8 @@ export function TestEmail(arg1:string):Promise<void>;
export function TestHRDLogUpload():Promise<string>;
export function TestHamQTHUpload():Promise<string>;
export function TestLoTWUpload():Promise<string>;
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
@@ -1327,8 +1438,22 @@ export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>;
export function UploadCallsign(arg1:string):Promise<string>;
export function UploadFullLogHamQTH():Promise<void>;
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
export function WatchlistAdd(arg1:string,arg2:boolean):Promise<void>;
export function WatchlistEntries():Promise<Array<watchlist.Entry>>;
export function WatchlistRemove(arg1:string):Promise<void>;
export function WatchlistSetContest(arg1:string,arg2:boolean):Promise<void>;
export function WatchlistSetNotify(arg1:string,arg2:boolean):Promise<void>;
export function WatchlistWorkedSlots(arg1:Array<main.WatchlistSlotQuery>):Promise<Array<boolean>>;
export function WebPublishColumns():Promise<Array<Record<string, string>>>;
export function WinkeyerBackspace():Promise<void>;
+244
View File
@@ -26,6 +26,14 @@ export function ActivateProfile(arg1) {
return window['go']['main']['App']['ActivateProfile'](arg1);
}
export function ActiveRadioID() {
return window['go']['main']['App']['ActiveRadioID']();
}
export function ActiveRadioMyRig() {
return window['go']['main']['App']['ActiveRadioMyRig']();
}
export function AddQSO(arg1) {
return window['go']['main']['App']['AddQSO'](arg1);
}
@@ -214,6 +222,10 @@ export function ComputeStationInfo(arg1, arg2) {
return window['go']['main']['App']['ComputeStationInfo'](arg1, arg2);
}
export function ConfigureDecoderMode(arg1) {
return window['go']['main']['App']['ConfigureDecoderMode'](arg1);
}
export function ConnectAllClusters() {
return window['go']['main']['App']['ConnectAllClusters']();
}
@@ -246,6 +258,10 @@ export function DVKCancelRecord() {
return window['go']['main']['App']['DVKCancelRecord']();
}
export function DVKDelete(arg1) {
return window['go']['main']['App']['DVKDelete'](arg1);
}
export function DVKPlay(arg1) {
return window['go']['main']['App']['DVKPlay'](arg1);
}
@@ -742,10 +758,22 @@ export function GetAntGeniusStatus() {
return window['go']['main']['App']['GetAntGeniusStatus']();
}
export function GetAudioMonitorPref() {
return window['go']['main']['App']['GetAudioMonitorPref']();
}
export function GetAudioSettings() {
return window['go']['main']['App']['GetAudioSettings']();
}
export function GetAutoCallSettings() {
return window['go']['main']['App']['GetAutoCallSettings']();
}
export function GetAutoCallStatus() {
return window['go']['main']['App']['GetAutoCallStatus']();
}
export function GetAutostartPrograms() {
return window['go']['main']['App']['GetAutostartPrograms']();
}
@@ -814,6 +842,10 @@ export function GetChaseNew() {
return window['go']['main']['App']['GetChaseNew']();
}
export function GetChaseNewBands() {
return window['go']['main']['App']['GetChaseNewBands']();
}
export function GetChaseNewGrids() {
return window['go']['main']['App']['GetChaseNewGrids']();
}
@@ -822,6 +854,10 @@ export function GetChaseNewSpots() {
return window['go']['main']['App']['GetChaseNewSpots']();
}
export function GetChaseSettings() {
return window['go']['main']['App']['GetChaseSettings']();
}
export function GetChatHistory(arg1) {
return window['go']['main']['App']['GetChatHistory'](arg1);
}
@@ -866,6 +902,14 @@ export function GetDVKStatus() {
return window['go']['main']['App']['GetDVKStatus']();
}
export function GetDXWorldNews() {
return window['go']['main']['App']['GetDXWorldNews']();
}
export function GetDXpeditions() {
return window['go']['main']['App']['GetDXpeditions']();
}
export function GetDataDir() {
return window['go']['main']['App']['GetDataDir']();
}
@@ -942,6 +986,14 @@ export function GetLiveStations() {
return window['go']['main']['App']['GetLiveStations']();
}
export function GetLoTWDownloadAllCalls() {
return window['go']['main']['App']['GetLoTWDownloadAllCalls']();
}
export function GetLoTWQSLDetail() {
return window['go']['main']['App']['GetLoTWQSLDetail']();
}
export function GetLoTWUsersStatus() {
return window['go']['main']['App']['GetLoTWUsersStatus']();
}
@@ -994,10 +1046,18 @@ export function GetPOTAToken() {
return window['go']['main']['App']['GetPOTAToken']();
}
export function GetPSKAnalysis() {
return window['go']['main']['App']['GetPSKAnalysis']();
}
export function GetPSKReporterStatus() {
return window['go']['main']['App']['GetPSKReporterStatus']();
}
export function GetPSKTargetSettings() {
return window['go']['main']['App']['GetPSKTargetSettings']();
}
export function GetPSUSettings() {
return window['go']['main']['App']['GetPSUSettings']();
}
@@ -1022,6 +1082,10 @@ export function GetQSORate() {
return window['go']['main']['App']['GetQSORate']();
}
export function GetRadios() {
return window['go']['main']['App']['GetRadios']();
}
export function GetRelayAuto() {
return window['go']['main']['App']['GetRelayAuto']();
}
@@ -1126,6 +1190,14 @@ export function GetUltrabeamStatus() {
return window['go']['main']['App']['GetUltrabeamStatus']();
}
export function GetWatchlistContestCalls() {
return window['go']['main']['App']['GetWatchlistContestCalls']();
}
export function GetWatchlistContestPattern() {
return window['go']['main']['App']['GetWatchlistContestPattern']();
}
export function GetWebPublishConfig() {
return window['go']['main']['App']['GetWebPublishConfig']();
}
@@ -1150,6 +1222,18 @@ export function GetWorkedCallVariants() {
return window['go']['main']['App']['GetWorkedCallVariants']();
}
export function GetWsjtFollowMode() {
return window['go']['main']['App']['GetWsjtFollowMode']();
}
export function GetWsjtHighlight() {
return window['go']['main']['App']['GetWsjtHighlight']();
}
export function GetWsjtHighlightWorked() {
return window['go']['main']['App']['GetWsjtHighlightWorked']();
}
export function GetYaesuBandAntennas() {
return window['go']['main']['App']['GetYaesuBandAntennas']();
}
@@ -1162,6 +1246,10 @@ export function GridSquares(arg1) {
return window['go']['main']['App']['GridSquares'](arg1);
}
export function HaltAutoCall() {
return window['go']['main']['App']['HaltAutoCall']();
}
export function HaltDecodeTx(arg1, arg2) {
return window['go']['main']['App']['HaltDecodeTx'](arg1, arg2);
}
@@ -1170,6 +1258,14 @@ export function HasBuiltinReferences(arg1) {
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
}
export function IcomConsolePTT(arg1) {
return window['go']['main']['App']['IcomConsolePTT'](arg1);
}
export function IcomRecallBand(arg1, arg2) {
return window['go']['main']['App']['IcomRecallBand'](arg1, arg2);
}
export function IcomRefresh() {
return window['go']['main']['App']['IcomRefresh']();
}
@@ -1198,6 +1294,10 @@ export function IcomSetAPF(arg1) {
return window['go']['main']['App']['IcomSetAPF'](arg1);
}
export function IcomSetATU(arg1) {
return window['go']['main']['App']['IcomSetATU'](arg1);
}
export function IcomSetAntenna(arg1) {
return window['go']['main']['App']['IcomSetAntenna'](arg1);
}
@@ -1318,6 +1418,10 @@ export function IcomSetSplit(arg1) {
return window['go']['main']['App']['IcomSetSplit'](arg1);
}
export function IcomSetSplitOffset(arg1) {
return window['go']['main']['App']['IcomSetSplitOffset'](arg1);
}
export function IcomSetSquelch(arg1) {
return window['go']['main']['App']['IcomSetSquelch'](arg1);
}
@@ -1430,6 +1534,10 @@ export function ListQSOFiltered(arg1) {
return window['go']['main']['App']['ListQSOFiltered'](arg1);
}
export function ListRadios() {
return window['go']['main']['App']['ListRadios']();
}
export function ListSerialPorts() {
return window['go']['main']['App']['ListSerialPorts']();
}
@@ -1762,6 +1870,10 @@ export function RefreshCtyDat() {
return window['go']['main']['App']['RefreshCtyDat']();
}
export function RefreshDXpeditions() {
return window['go']['main']['App']['RefreshDXpeditions']();
}
export function RefreshKenwood() {
return window['go']['main']['App']['RefreshKenwood']();
}
@@ -1814,6 +1926,10 @@ export function RescanAwards() {
return window['go']['main']['App']['RescanAwards']();
}
export function ResetAutoCall() {
return window['go']['main']['App']['ResetAutoCall']();
}
export function ResetAwardDefs() {
return window['go']['main']['App']['ResetAwardDefs']();
}
@@ -1906,6 +2022,10 @@ export function SaveAudioSettings(arg1) {
return window['go']['main']['App']['SaveAudioSettings'](arg1);
}
export function SaveAutoCallSettings(arg1) {
return window['go']['main']['App']['SaveAutoCallSettings'](arg1);
}
export function SaveAutostartPrograms(arg1) {
return window['go']['main']['App']['SaveAutostartPrograms'](arg1);
}
@@ -1934,6 +2054,10 @@ export function SaveCabrilloFile() {
return window['go']['main']['App']['SaveCabrilloFile']();
}
export function SaveChaseSettings(arg1) {
return window['go']['main']['App']['SaveChaseSettings'](arg1);
}
export function SaveClusterServer(arg1) {
return window['go']['main']['App']['SaveClusterServer'](arg1);
}
@@ -2002,6 +2126,10 @@ export function SavePOTAToken(arg1) {
return window['go']['main']['App']['SavePOTAToken'](arg1);
}
export function SavePSKTargetSettings(arg1) {
return window['go']['main']['App']['SavePSKTargetSettings'](arg1);
}
export function SavePSUSettings(arg1) {
return window['go']['main']['App']['SavePSUSettings'](arg1);
}
@@ -2014,6 +2142,10 @@ export function SaveQSLDefaults(arg1) {
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
}
export function SaveRadios(arg1) {
return window['go']['main']['App']['SaveRadios'](arg1);
}
export function SaveRelayAuto(arg1) {
return window['go']['main']['App']['SaveRelayAuto'](arg1);
}
@@ -2106,6 +2238,10 @@ export function SendQSORecordingEmail(arg1) {
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
}
export function SetActiveRadio(arg1) {
return window['go']['main']['App']['SetActiveRadio'](arg1);
}
export function SetActiveRotor(arg1) {
return window['go']['main']['App']['SetActiveRotor'](arg1);
}
@@ -2114,6 +2250,18 @@ export function SetAlertEmailTo(arg1) {
return window['go']['main']['App']['SetAlertEmailTo'](arg1);
}
export function SetAutoCall(arg1) {
return window['go']['main']['App']['SetAutoCall'](arg1);
}
export function SetAutoCallOnly(arg1) {
return window['go']['main']['App']['SetAutoCallOnly'](arg1);
}
export function SetAutoCallVisible(arg1, arg2) {
return window['go']['main']['App']['SetAutoCallVisible'](arg1, arg2);
}
export function SetCATFrequency(arg1) {
return window['go']['main']['App']['SetCATFrequency'](arg1);
}
@@ -2134,6 +2282,10 @@ export function SetChaseNew(arg1) {
return window['go']['main']['App']['SetChaseNew'](arg1);
}
export function SetChaseNewBands(arg1) {
return window['go']['main']['App']['SetChaseNewBands'](arg1);
}
export function SetChaseNewGrids(arg1) {
return window['go']['main']['App']['SetChaseNewGrids'](arg1);
}
@@ -2202,6 +2354,10 @@ export function SetKenwoodNR(arg1) {
return window['go']['main']['App']['SetKenwoodNR'](arg1);
}
export function SetKenwoodPanelMode(arg1) {
return window['go']['main']['App']['SetKenwoodPanelMode'](arg1);
}
export function SetKenwoodPower(arg1) {
return window['go']['main']['App']['SetKenwoodPower'](arg1);
}
@@ -2218,6 +2374,10 @@ export function SetKenwoodRIT(arg1) {
return window['go']['main']['App']['SetKenwoodRIT'](arg1);
}
export function SetKenwoodRITOffset(arg1) {
return window['go']['main']['App']['SetKenwoodRITOffset'](arg1);
}
export function SetKenwoodSquelch(arg1) {
return window['go']['main']['App']['SetKenwoodSquelch'](arg1);
}
@@ -2234,6 +2394,14 @@ export function SetLinkedAmps(arg1) {
return window['go']['main']['App']['SetLinkedAmps'](arg1);
}
export function SetLoTWDownloadAllCalls(arg1) {
return window['go']['main']['App']['SetLoTWDownloadAllCalls'](arg1);
}
export function SetLoTWQSLDetail(arg1) {
return window['go']['main']['App']['SetLoTWQSLDetail'](arg1);
}
export function SetMotorFollow(arg1, arg2, arg3) {
return window['go']['main']['App']['SetMotorFollow'](arg1, arg2, arg3);
}
@@ -2242,6 +2410,10 @@ export function SetOpsLogQSLReceived(arg1, arg2) {
return window['go']['main']['App']['SetOpsLogQSLReceived'](arg1, arg2);
}
export function SetPSKTarget(arg1, arg2, arg3) {
return window['go']['main']['App']['SetPSKTarget'](arg1, arg2, arg3);
}
export function SetPSUOutput(arg1) {
return window['go']['main']['App']['SetPSUOutput'](arg1);
}
@@ -2250,6 +2422,10 @@ export function SetPassphrase(arg1) {
return window['go']['main']['App']['SetPassphrase'](arg1);
}
export function SetScpClublogEnabled(arg1) {
return window['go']['main']['App']['SetScpClublogEnabled'](arg1);
}
export function SetScpEnabled(arg1) {
return window['go']['main']['App']['SetScpEnabled'](arg1);
}
@@ -2350,6 +2526,14 @@ export function SetUltrabeamDirection(arg1) {
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
}
export function SetWatchlistContestCalls(arg1) {
return window['go']['main']['App']['SetWatchlistContestCalls'](arg1);
}
export function SetWatchlistContestPattern(arg1) {
return window['go']['main']['App']['SetWatchlistContestPattern'](arg1);
}
export function SetWinkeyerTrace(arg1) {
return window['go']['main']['App']['SetWinkeyerTrace'](arg1);
}
@@ -2358,6 +2542,18 @@ export function SetWorkedCallVariants(arg1) {
return window['go']['main']['App']['SetWorkedCallVariants'](arg1);
}
export function SetWsjtFollowMode(arg1) {
return window['go']['main']['App']['SetWsjtFollowMode'](arg1);
}
export function SetWsjtHighlight(arg1) {
return window['go']['main']['App']['SetWsjtHighlight'](arg1);
}
export function SetWsjtHighlightWorked(arg1) {
return window['go']['main']['App']['SetWsjtHighlightWorked'](arg1);
}
export function SetYaesuAFGain(arg1) {
return window['go']['main']['App']['SetYaesuAFGain'](arg1);
}
@@ -2462,10 +2658,26 @@ export function SyncPOTAHunterLog(arg1, arg2) {
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
}
export function TCISendCW(arg1) {
return window['go']['main']['App']['TCISendCW'](arg1);
}
export function TCISetKeySpeed(arg1) {
return window['go']['main']['App']['TCISetKeySpeed'](arg1);
}
export function TCIStopCW() {
return window['go']['main']['App']['TCIStopCW']();
}
export function TailLogFile(arg1) {
return window['go']['main']['App']['TailLogFile'](arg1);
}
export function TakeAutoCallTarget(arg1, arg2, arg3) {
return window['go']['main']['App']['TakeAutoCallTarget'](arg1, arg2, arg3);
}
export function TestCloudlogUpload() {
return window['go']['main']['App']['TestCloudlogUpload']();
}
@@ -2486,6 +2698,10 @@ export function TestHRDLogUpload() {
return window['go']['main']['App']['TestHRDLogUpload']();
}
export function TestHamQTHUpload() {
return window['go']['main']['App']['TestHamQTHUpload']();
}
export function TestLoTWUpload() {
return window['go']['main']['App']['TestLoTWUpload']();
}
@@ -2594,10 +2810,38 @@ export function UploadCallsign(arg1) {
return window['go']['main']['App']['UploadCallsign'](arg1);
}
export function UploadFullLogHamQTH() {
return window['go']['main']['App']['UploadFullLogHamQTH']();
}
export function UploadQSOsManual(arg1, arg2) {
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
}
export function WatchlistAdd(arg1, arg2) {
return window['go']['main']['App']['WatchlistAdd'](arg1, arg2);
}
export function WatchlistEntries() {
return window['go']['main']['App']['WatchlistEntries']();
}
export function WatchlistRemove(arg1) {
return window['go']['main']['App']['WatchlistRemove'](arg1);
}
export function WatchlistSetContest(arg1, arg2) {
return window['go']['main']['App']['WatchlistSetContest'](arg1, arg2);
}
export function WatchlistSetNotify(arg1, arg2) {
return window['go']['main']['App']['WatchlistSetNotify'](arg1, arg2);
}
export function WatchlistWorkedSlots(arg1) {
return window['go']['main']['App']['WatchlistWorkedSlots'](arg1);
}
export function WebPublishColumns() {
return window['go']['main']['App']['WebPublishColumns']();
}
+465
View File
@@ -983,6 +983,7 @@ export namespace cat {
mode?: string;
transmitting: boolean;
split: boolean;
sub_hz: number;
s_meter: number;
power_meter: number;
swr_meter: number;
@@ -1006,6 +1007,7 @@ export namespace cat {
att: number;
filter: number;
antenna: number;
atu_on: boolean;
pbt_inner: number;
pbt_outer: number;
manual_notch: boolean;
@@ -1030,6 +1032,7 @@ export namespace cat {
this.mode = source["mode"];
this.transmitting = source["transmitting"];
this.split = source["split"];
this.sub_hz = source["sub_hz"];
this.s_meter = source["s_meter"];
this.power_meter = source["power_meter"];
this.swr_meter = source["swr_meter"];
@@ -1053,6 +1056,7 @@ export namespace cat {
this.att = source["att"];
this.filter = source["filter"];
this.antenna = source["antenna"];
this.atu_on = source["atu_on"];
this.pbt_inner = source["pbt_inner"];
this.pbt_outer = source["pbt_outer"];
this.manual_notch = source["manual_notch"];
@@ -1072,12 +1076,14 @@ export namespace cat {
model?: string;
elecraft: boolean;
mode?: string;
data_sub?: string;
transmitting: boolean;
split: boolean;
split_tx_hz: number;
s_meter: number;
s_meter_raw: number;
power_meter: number;
power_w: number;
swr: number;
swr_raw: number;
rf_power: number;
@@ -1108,12 +1114,14 @@ export namespace cat {
this.model = source["model"];
this.elecraft = source["elecraft"];
this.mode = source["mode"];
this.data_sub = source["data_sub"];
this.transmitting = source["transmitting"];
this.split = source["split"];
this.split_tx_hz = source["split_tx_hz"];
this.s_meter = source["s_meter"];
this.s_meter_raw = source["s_meter_raw"];
this.power_meter = source["power_meter"];
this.power_w = source["power_w"];
this.swr = source["swr"];
this.swr_raw = source["swr_raw"];
this.rf_power = source["rf_power"];
@@ -1196,6 +1204,7 @@ export namespace cat {
low_hz: number;
high_hz: number;
fixed: boolean;
unsupported: boolean;
static createFrom(source: any = {}) {
return new ScopeSweep(source);
@@ -1208,6 +1217,7 @@ export namespace cat {
this.low_hz = source["low_hz"];
this.high_hz = source["high_hz"];
this.fixed = source["fixed"];
this.unsupported = source["unsupported"];
}
}
export class TCIPanelState {
@@ -1456,6 +1466,37 @@ export namespace contest {
}
export namespace dxped {
export class News {
title: string;
link: string;
pub_date: string;
excerpt: string;
creator: string;
image_url: string;
tag: string;
calls: string[];
static createFrom(source: any = {}) {
return new News(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.title = source["title"];
this.link = source["link"];
this.pub_date = source["pub_date"];
this.excerpt = source["excerpt"];
this.creator = source["creator"];
this.image_url = source["image_url"];
this.tag = source["tag"];
this.calls = source["calls"];
}
}
}
export namespace extsvc {
export class ServiceConfig {
@@ -1512,6 +1553,7 @@ export namespace extsvc {
eqsl: ServiceConfig;
cloudlog: ServiceConfig;
hamlog: ServiceConfig;
hamqth: ServiceConfig;
delete_remote: boolean;
static createFrom(source: any = {}) {
@@ -1527,6 +1569,7 @@ export namespace extsvc {
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
this.hamlog = this.convertValues(source["hamlog"], ServiceConfig);
this.hamqth = this.convertValues(source["hamqth"], ServiceConfig);
this.delete_remote = source["delete_remote"];
}
@@ -1881,6 +1924,7 @@ export namespace main {
preroll_seconds: number;
ptt_method: string;
ptt_port: string;
ptt_data: boolean;
format: string;
from_gain: number;
mic_gain: number;
@@ -1902,6 +1946,7 @@ export namespace main {
this.preroll_seconds = source["preroll_seconds"];
this.ptt_method = source["ptt_method"];
this.ptt_port = source["ptt_port"];
this.ptt_data = source["ptt_data"];
this.format = source["format"];
this.from_gain = source["from_gain"];
this.mic_gain = source["mic_gain"];
@@ -1909,6 +1954,66 @@ export namespace main {
this.qso_play_gain = source["qso_play_gain"];
}
}
export class AutoCallSettings {
enabled: boolean;
only: string;
attempts: number;
watched_attempts: number;
misses: number;
max_rounds: number;
rest_periods: number;
on_screen_only: boolean;
trace: boolean;
static createFrom(source: any = {}) {
return new AutoCallSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.only = source["only"];
this.attempts = source["attempts"];
this.watched_attempts = source["watched_attempts"];
this.misses = source["misses"];
this.max_rounds = source["max_rounds"];
this.rest_periods = source["rest_periods"];
this.on_screen_only = source["on_screen_only"];
this.trace = source["trace"];
}
}
export class AutoCallStatus {
enabled: boolean;
only: string;
target: string;
waiting: string;
calls: number;
max: number;
misses: number;
max_miss: number;
stopped: boolean;
greylisted: number;
reason: string;
static createFrom(source: any = {}) {
return new AutoCallStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.only = source["only"];
this.target = source["target"];
this.waiting = source["waiting"];
this.calls = source["calls"];
this.max = source["max"];
this.misses = source["misses"];
this.max_miss = source["max_miss"];
this.stopped = source["stopped"];
this.greylisted = source["greylisted"];
this.reason = source["reason"];
}
}
export class AutostartLaunchResult {
id: string;
name: string;
@@ -2255,6 +2360,7 @@ export namespace main {
backend: string;
omnirig_rig: number;
omnirig_vfo: string;
digi_as_usb: boolean;
flex_host: string;
flex_port: number;
flex_spots: boolean;
@@ -2307,6 +2413,7 @@ export namespace main {
this.backend = source["backend"];
this.omnirig_rig = source["omnirig_rig"];
this.omnirig_vfo = source["omnirig_vfo"];
this.digi_as_usb = source["digi_as_usb"];
this.flex_host = source["flex_host"];
this.flex_port = source["flex_port"];
this.flex_spots = source["flex_spots"];
@@ -2420,6 +2527,20 @@ export namespace main {
this.at = source["at"];
}
}
export class ChaseSettings {
mode: string;
sources: string[];
static createFrom(source: any = {}) {
return new ChaseSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.mode = source["mode"];
this.sources = source["sources"];
}
}
export class ChatMessage {
id: number;
operator: string;
@@ -2648,6 +2769,44 @@ export namespace main {
this.rec_slot = source["rec_slot"];
}
}
export class DXpedition {
dxcc: string;
callsign: string;
calls: string[];
start_date: string;
end_date: string;
bands: string[];
modes: string[];
qsl: string;
operators: string;
source: string;
link: string;
status: string;
status_chase: string;
unconfirmed: boolean;
static createFrom(source: any = {}) {
return new DXpedition(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.dxcc = source["dxcc"];
this.callsign = source["callsign"];
this.calls = source["calls"];
this.start_date = source["start_date"];
this.end_date = source["end_date"];
this.bands = source["bands"];
this.modes = source["modes"];
this.qsl = source["qsl"];
this.operators = source["operators"];
this.source = source["source"];
this.link = source["link"];
this.status = source["status"];
this.status_chase = source["status_chase"];
this.unconfirmed = source["unconfirmed"];
}
}
export class DatabaseSettings {
path: string;
default_path: string;
@@ -3066,6 +3225,8 @@ export namespace main {
entity_worked: string;
not_worked: string;
current_entry: string;
mark_worked: string;
mark_confirmed: string;
static createFrom(source: any = {}) {
return new MatrixColors(source);
@@ -3080,6 +3241,8 @@ export namespace main {
this.entity_worked = source["entity_worked"];
this.not_worked = source["not_worked"];
this.current_entry = source["current_entry"];
this.mark_worked = source["mark_worked"];
this.mark_confirmed = source["mark_confirmed"];
}
}
@@ -3214,6 +3377,20 @@ export namespace main {
}
}
export class PSKTargetSettings {
enabled: boolean;
scope: string;
static createFrom(source: any = {}) {
return new PSKTargetSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.scope = source["scope"];
}
}
export class PSUSettings {
enabled: boolean;
com_port: string;
@@ -3266,11 +3443,13 @@ export namespace main {
eqsl_sent: string;
eqsl_rcvd: string;
clublog_status: string;
clublog_confirmed: string;
hrdlog_status: string;
qrzcom_status: string;
qrzcom_confirmed: string;
hamlog_status: string;
hamlog_confirmed: string;
hamqth_status: string;
static createFrom(source: any = {}) {
return new QSLDefaults(source);
@@ -3285,11 +3464,13 @@ export namespace main {
this.eqsl_sent = source["eqsl_sent"];
this.eqsl_rcvd = source["eqsl_rcvd"];
this.clublog_status = source["clublog_status"];
this.clublog_confirmed = source["clublog_confirmed"];
this.hrdlog_status = source["hrdlog_status"];
this.qrzcom_status = source["qrzcom_status"];
this.qrzcom_confirmed = source["qrzcom_confirmed"];
this.hamlog_status = source["hamlog_status"];
this.hamlog_confirmed = source["hamlog_confirmed"];
this.hamqth_status = source["hamqth_status"];
}
}
export class QSLEmailTemplates {
@@ -3569,6 +3750,62 @@ export namespace main {
}
}
export class RadioConfig {
id: string;
name: string;
settings: CATSettings;
my_rig: string;
static createFrom(source: any = {}) {
return new RadioConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.settings = this.convertValues(source["settings"], CATSettings);
this.my_rig = source["my_rig"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class RadioListEntry {
id: string;
name: string;
label: string;
backend: string;
active: boolean;
static createFrom(source: any = {}) {
return new RadioListEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.label = source["label"];
this.backend = source["backend"];
this.active = source["active"];
}
}
export class RelayAutoRule {
device_id: string;
relay: number;
@@ -3768,6 +4005,7 @@ export namespace main {
enabled: boolean;
count: number;
updated?: string;
clublog: boolean;
static createFrom(source: any = {}) {
return new ScpStatus(source);
@@ -3778,6 +4016,7 @@ export namespace main {
this.enabled = source["enabled"];
this.count = source["count"];
this.updated = source["updated"];
this.clublog = source["clublog"];
}
}
export class SecretStatus {
@@ -3887,6 +4126,11 @@ export namespace main {
new_county: boolean;
county?: string;
state?: string;
new_state: boolean;
unconf_status?: boolean;
unconf_pfx?: boolean;
unconf_cty?: boolean;
unconf_state?: boolean;
new_pota: boolean;
grid?: string;
new_grid: boolean;
@@ -3913,6 +4157,11 @@ export namespace main {
this.new_county = source["new_county"];
this.county = source["county"];
this.state = source["state"];
this.new_state = source["new_state"];
this.unconf_status = source["unconf_status"];
this.unconf_pfx = source["unconf_pfx"];
this.unconf_cty = source["unconf_cty"];
this.unconf_state = source["unconf_state"];
this.new_pota = source["new_pota"];
this.grid = source["grid"];
this.new_grid = source["new_grid"];
@@ -4237,6 +4486,24 @@ export namespace main {
this.text = source["text"];
}
}
export class WatchlistSlotQuery {
call: string;
band: string;
mode: string;
contest: boolean;
static createFrom(source: any = {}) {
return new WatchlistSlotQuery(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.call = source["call"];
this.band = source["band"];
this.mode = source["mode"];
this.contest = source["contest"];
}
}
export class WebPublishStatus {
last_run: string;
last_err: string;
@@ -4703,6 +4970,8 @@ export namespace pskr {
last_err?: string;
broker: string;
bands: string[];
near_km: number;
squares: number;
static createFrom(source: any = {}) {
return new Status(source);
@@ -4716,6 +4985,8 @@ export namespace pskr {
this.last_err = source["last_err"];
this.broker = source["broker"];
this.bands = source["bands"];
this.near_km = source["near_km"];
this.squares = source["squares"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -4739,6 +5010,132 @@ export namespace pskr {
}
export namespace pskrtgt {
export class Bin {
offset_hz: number;
count: number;
avg_snr: number;
static createFrom(source: any = {}) {
return new Bin(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.offset_hz = source["offset_hz"];
this.count = source["count"];
this.avg_snr = source["avg_snr"];
}
}
export class Entry {
call: string;
grid: string;
snr: number;
offset_hz: number;
age_sec: number;
static createFrom(source: any = {}) {
return new Entry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.call = source["call"];
this.grid = source["grid"];
this.snr = source["snr"];
this.offset_hz = source["offset_hz"];
this.age_sec = source["age_sec"];
}
}
export class Analysis {
target: string;
mode?: string;
enabled: boolean;
online: boolean;
spots: number;
he_me: boolean;
he_me_seconds: number;
he_me_snr: number;
he_me_offset_hz: number;
target_uploads: boolean;
target_grid?: string;
near_him_count: number;
near_him_top: Entry[];
from_my_area_count: number;
from_my_area_top: Entry[];
path_open: boolean;
heard_by_count: number;
heard_near_me: number;
heard_near_me_top: Entry[];
decoded_by_count: number;
decoded_by_top: Entry[];
decoded_by_calls: string[];
pileup_count: number;
dial_hz: number;
ceiling_hz: number;
decodes_in_window: number;
bins: Bin[];
suggested_offset: number;
static createFrom(source: any = {}) {
return new Analysis(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.target = source["target"];
this.mode = source["mode"];
this.enabled = source["enabled"];
this.online = source["online"];
this.spots = source["spots"];
this.he_me = source["he_me"];
this.he_me_seconds = source["he_me_seconds"];
this.he_me_snr = source["he_me_snr"];
this.he_me_offset_hz = source["he_me_offset_hz"];
this.target_uploads = source["target_uploads"];
this.target_grid = source["target_grid"];
this.near_him_count = source["near_him_count"];
this.near_him_top = this.convertValues(source["near_him_top"], Entry);
this.from_my_area_count = source["from_my_area_count"];
this.from_my_area_top = this.convertValues(source["from_my_area_top"], Entry);
this.path_open = source["path_open"];
this.heard_by_count = source["heard_by_count"];
this.heard_near_me = source["heard_near_me"];
this.heard_near_me_top = this.convertValues(source["heard_near_me_top"], Entry);
this.decoded_by_count = source["decoded_by_count"];
this.decoded_by_top = this.convertValues(source["decoded_by_top"], Entry);
this.decoded_by_calls = source["decoded_by_calls"];
this.pileup_count = source["pileup_count"];
this.dial_hz = source["dial_hz"];
this.ceiling_hz = source["ceiling_hz"];
this.decodes_in_window = source["decodes_in_window"];
this.bins = this.convertValues(source["bins"], Bin);
this.suggested_offset = source["suggested_offset"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace psu {
export class Status {
@@ -4971,6 +5368,7 @@ export namespace qso {
band: string;
class: string;
status: string;
call?: string;
static createFrom(source: any = {}) {
return new BandStatus(source);
@@ -4981,6 +5379,7 @@ export namespace qso {
this.band = source["band"];
this.class = source["class"];
this.status = source["status"];
this.call = source["call"];
}
}
export class Bucket {
@@ -5155,6 +5554,8 @@ export namespace qso {
qrzcom_qso_upload_status?: string;
qrzcom_qso_download_date?: string;
qrzcom_qso_download_status?: string;
clublog_qso_download_date?: string;
clublog_qso_download_status?: string;
contest_id?: string;
srx?: number;
stx?: number;
@@ -5297,6 +5698,8 @@ export namespace qso {
this.qrzcom_qso_upload_status = source["qrzcom_qso_upload_status"];
this.qrzcom_qso_download_date = source["qrzcom_qso_download_date"];
this.qrzcom_qso_download_status = source["qrzcom_qso_download_status"];
this.clublog_qso_download_date = source["clublog_qso_download_date"];
this.clublog_qso_download_status = source["clublog_qso_download_status"];
this.contest_id = source["contest_id"];
this.srx = source["srx"];
this.stx = source["stx"];
@@ -5924,6 +6327,68 @@ export namespace udp {
}
export namespace watchlist {
export class Entry {
callsign: string;
// Go type: time
lastSeen: any;
lastSeenStr: string;
// Go type: time
addedAt: any;
spotCount: number;
isContest: boolean;
notify: boolean;
isExpedition: boolean;
clubLogQSOs24h: number;
clubLogTotalQSOs: number;
clubLogHasOQRS: boolean;
clubLogLiveStream: boolean;
// Go type: time
clubLogUpdatedAt?: any;
static createFrom(source: any = {}) {
return new Entry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.callsign = source["callsign"];
this.lastSeen = this.convertValues(source["lastSeen"], null);
this.lastSeenStr = source["lastSeenStr"];
this.addedAt = this.convertValues(source["addedAt"], null);
this.spotCount = source["spotCount"];
this.isContest = source["isContest"];
this.notify = source["notify"];
this.isExpedition = source["isExpedition"];
this.clubLogQSOs24h = source["clubLogQSOs24h"];
this.clubLogTotalQSOs = source["clubLogTotalQSOs"];
this.clubLogHasOQRS = source["clubLogHasOQRS"];
this.clubLogLiveStream = source["clubLogLiveStream"];
this.clubLogUpdatedAt = this.convertValues(source["clubLogUpdatedAt"], null);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace webpub {
export class Config {
+48 -5
View File
@@ -120,12 +120,36 @@ func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (in
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).
// Uploads target other services — keep it standard (no app-specific tags),
// and say nothing about the receive side when there is nothing to say: in
// ADIF an absent BAND_RX/FREQ_RX means "same as transmit", and a logger
// given both draws both. Wavelog reads a simplex FT8 contact uploaded with
// BAND_RX filled in as split and shows it as "17m/17m".
writeRecord(bw, q, false, nil)
bw.Flush()
return b.String()
}
// ForwardRecordADIF is the record sent to ANOTHER LOGGER on the UDP link.
//
// The receive side is written even when it repeats the transmit side, which is
// the opposite of the upload rule above and is deliberate: Log4OM reads BAND_RX
// and found nothing there for contacts logged by a path that left it blank. A
// logger on the same desk is being handed a copy of our record, not published
// to a service that will draw conclusions from every tag present.
func ForwardRecordADIF(q qso.QSO) string {
var b strings.Builder
bw := bufio.NewWriter(&b)
writeRecord(bw, q, false, nil, keepRX)
bw.Flush()
return b.String()
}
// keepRX marks a record whose receive side must be written out in full.
type rxMode int
const keepRX rxMode = 1
// FullRecordADIF serialises one QSO LOSSLESSLY — including the APP_* extras —
// so it can be written out and read back with nothing dropped. Used by the
// offline queue: a QSO parked in the safety file must come back identical
@@ -161,7 +185,18 @@ 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, allow map[string]bool) {
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]bool, rx ...rxMode) {
// The receive side, unless it merely repeats the transmit side. See
// SingleRecordADIF and ForwardRecordADIF.
bandRX, freqRX := q.BandRX, q.FreqRXHz
if len(rx) == 0 || rx[0] != keepRX {
if strings.EqualFold(strings.TrimSpace(bandRX), strings.TrimSpace(q.Band)) {
bandRX = ""
}
if freqRX != nil && q.FreqHz != nil && *freqRX == *q.FreqHz {
freqRX = nil
}
}
// 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
@@ -194,7 +229,7 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
w("TIME_OFF", q.QSODateOff.UTC().Format("150405"))
}
w("BAND", q.Band)
w("BAND_RX", q.BandRX)
w("BAND_RX", bandRX)
mode, submode := modeForExport(q.Mode, q.Submode)
w("MODE", mode)
@@ -203,8 +238,8 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
if q.FreqHz != nil && *q.FreqHz > 0 {
w("FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
}
if q.FreqRXHz != nil && *q.FreqRXHz > 0 {
w("FREQ_RX", strconv.FormatFloat(float64(*q.FreqRXHz)/1_000_000, 'f', 6, 64))
if freqRX != nil && *freqRX > 0 {
w("FREQ_RX", strconv.FormatFloat(float64(*freqRX)/1_000_000, 'f', 6, 64))
}
w("RST_SENT", q.RSTSent)
@@ -267,6 +302,8 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
w("QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
w("CLUBLOG_QSO_DOWNLOAD_DATE", q.ClublogDownloadDate)
w("CLUBLOG_QSO_DOWNLOAD_STATUS", q.ClublogDownloadStatus)
// --- Contest ---
w("CONTEST_ID", q.ContestID)
@@ -441,5 +478,11 @@ func adifCounty(state, county string) string {
if c == "" || s == "" || strings.Contains(c, ",") {
return c
}
// The "STATE,County" join is the ADIF secondary-subdivision format, and that
// enumeration is a US thing — a two-letter state code. Prefixing a Canadian
// "ONTARIO" produced "ONTARIO,Kawartha", which is valid nowhere.
if len(s) != 2 {
return c
}
return strings.ToUpper(s) + "," + c
}
+3
View File
@@ -161,6 +161,9 @@ var Fields = []FieldDef{
{Name: "QRZCOM_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
{Name: "QRZCOM_QSO_DOWNLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
{Name: "QRZCOM_QSO_DOWNLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
// App-defined pair (no standard ADIF field): Club Log's log-match download.
{Name: "CLUBLOG_QSO_DOWNLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
{Name: "CLUBLOG_QSO_DOWNLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
{Name: "HAMLOGEU_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
{Name: "HAMLOGEU_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL"},
{Name: "HAMQTH_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
+3
View File
@@ -287,6 +287,7 @@ var adifPromoted = stringSet(
"hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
"qrzcom_qso_upload_date", "qrzcom_qso_upload_status",
"qrzcom_qso_download_date", "qrzcom_qso_download_status",
"clublog_qso_download_date", "clublog_qso_download_status",
// Contest
"contest_id", "srx", "stx", "srx_string", "stx_string",
"check", "precedence", "arrl_sect",
@@ -493,6 +494,8 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
q.QRZComDownloadDate = rec["qrzcom_qso_download_date"]
q.QRZComDownloadStatus = rec["qrzcom_qso_download_status"]
q.ClublogDownloadDate = rec["clublog_qso_download_date"]
q.ClublogDownloadStatus = rec["clublog_qso_download_status"]
// Contest
q.ContestID = rec["contest_id"]
+8 -4
View File
@@ -27,27 +27,31 @@ type modeSeg struct {
var bandPlan = []modeSeg{
{1_800_000, 1_838_000, "CW"}, {1_838_000, 1_840_000, "FT8"}, {1_840_000, 2_000_000, "SSB"},
{3_573_000, 3_576_000, "FT8"}, {3_500_000, 3_580_000, "CW"},
{3_573_000, 3_575_000, "FT8"}, {3_575_000, 3_578_000, "FT4"}, {3_500_000, 3_580_000, "CW"},
{3_580_000, 3_600_000, "DATA"}, {3_600_000, 4_000_000, "SSB"},
{5_300_000, 5_500_000, "SSB"},
{5_357_000, 5_360_000, "FT8"}, {5_300_000, 5_500_000, "SSB"},
{7_074_000, 7_077_000, "FT8"}, {7_047_500, 7_048_500, "FT4"},
{7_000_000, 7_040_000, "CW"}, {7_040_000, 7_100_000, "DATA"}, {7_100_000, 7_300_000, "SSB"},
// 30 m: CW to 10.130, data above it — and nothing else. No SSB on this band.
// The FT8/FT4 watering holes come first, or a 10.136 spot with a bare
// comment reads as generic DATA — which is what every skimmerless spot of a
// DXpedition's 30 m FT8 slot did.
{10_136_000, 10_139_000, "FT8"}, {10_140_000, 10_143_000, "FT4"},
{10_100_000, 10_130_000, "CW"}, {10_130_000, 10_150_000, "DATA"},
{14_074_000, 14_077_000, "FT8"}, {14_080_000, 14_081_500, "FT4"},
{14_000_000, 14_070_000, "CW"}, {14_070_000, 14_100_000, "DATA"}, {14_100_000, 14_350_000, "SSB"},
{18_100_000, 18_103_000, "FT8"},
{18_100_000, 18_103_000, "FT8"}, {18_104_000, 18_107_000, "FT4"},
{18_068_000, 18_095_000, "CW"}, {18_095_000, 18_110_000, "DATA"}, {18_110_000, 18_168_000, "SSB"},
{21_074_000, 21_077_000, "FT8"}, {21_140_000, 21_143_000, "FT4"},
{21_000_000, 21_070_000, "CW"}, {21_070_000, 21_150_000, "DATA"}, {21_150_000, 21_450_000, "SSB"},
{24_915_000, 24_917_000, "FT8"},
{24_915_000, 24_918_000, "FT8"}, {24_919_000, 24_922_000, "FT4"},
{24_890_000, 24_915_000, "CW"}, {24_915_000, 24_940_000, "DATA"}, {24_940_000, 24_990_000, "SSB"},
{28_074_000, 28_077_000, "FT8"}, {28_180_000, 28_183_000, "FT4"},
+23
View File
@@ -356,6 +356,29 @@ func (m *Manager) StartTXAudio(micDev, toRadioDev string) error {
return nil
}
// StartTXAudioNetwork pipes the live microphone into a SEND function instead
// of a render device — the talk button when the radio is reached over its own
// link. No ring and no pacing goroutine: the microphone delivers in real time,
// and the sender re-frames to the rig's cadence, so the capture callback IS
// the clock.
func (m *Manager) StartTXAudioNetwork(micDev string, send func([]byte) error) error {
m.mu.Lock()
if m.txStop != nil {
m.mu.Unlock()
return fmt.Errorf("TX audio already running")
}
stop := make(chan struct{})
m.txStop = stop
m.mu.Unlock()
go func() {
if err := captureStream(micDev, stop, func(chunk []byte) { _ = send(chunk) }); err != nil {
LogSink("audio: network TX capture from %q failed: %v", DeviceName(micDev), err)
}
}()
m.notify()
return nil
}
// StopTXAudio stops the TX mic→rig passthrough.
func (m *Manager) StopTXAudio() {
m.mu.Lock()
+1 -1
View File
@@ -64,4 +64,4 @@ func NetworkPlayerReady() bool { return networkPlayer() != nil }
// comes back with. Named, because "the device could not be opened" would send
// an operator hunting through Windows sound settings for a device that never
// existed.
var errNoNetworkRadio = errors.New("no radio is connected to take the audio — check the CAT link (the radio output only works with a TCI radio)")
var errNoNetworkRadio = errors.New("no radio is connected to take the audio — check the CAT link (a TCI radio, or a network Icom with RX audio enabled)")
File diff suppressed because it is too large Load Diff
+978
View File
@@ -0,0 +1,978 @@
package autocall
import (
"fmt"
"strings"
"testing"
"time"
)
const me = "F4BPO"
// base is a slot boundary, so slot parity in the tests is the real arithmetic.
var base = time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC)
func at(period int) time.Time { return base.Add(time.Duration(period) * 15 * time.Second) }
func cq(call string, need Need, snr int, opts ...func(*Candidate)) Candidate {
c := Candidate{
Decode: Decode{Call: call, Band: "20m", Mode: "FT8", SNR: snr, CQ: true,
Msg: "CQ " + call + " JN36", TRPeriod: 15, IsNew: true},
Need: need,
}
for _, o := range opts {
o(&c)
}
return c
}
func watched(c *Candidate) { c.Watched = true }
func worked(c *Candidate) { c.Worked = true }
// busy is a station in the middle of an exchange with somebody else.
func busy(call string, need Need, snr int) Candidate {
c := cq(call, need, snr)
c.CQ = false
c.Msg = "VP6D " + call + " JN36"
return c
}
// callsMe is a station answering us.
func callsMe(call string, need Need, snr int) Candidate {
c := cq(call, need, snr)
c.CQ = false
c.Msg = me + " " + call + " -12"
return c
}
func period(n int, decodes ...Candidate) Period {
for i := range decodes {
decodes[i].At = at(n)
}
return Period{Key: fmt.Sprintf("p%d", n), At: at(n), TRPeriod: 15, Decodes: decodes, MyCall: me}
}
func on() *Engine { return New(Settings{Enabled: true}) }
// ── The ladder ────────────────────────────────────────────────────────────
func TestLadderOrder(t *testing.T) {
// Every rung. The watched ones come FIRST, ordered among themselves by what
// is needed — the list is the operator's answer, not a tie-breaker.
order := []Candidate{
cq("A", NeedDXCC, 0, watched), cq("C", NeedBand, 0, watched),
cq("E", NeedMode, 0, watched), cq("G", NeedSlot, 0, watched),
cq("I", NeedNone, 0, watched),
cq("B", NeedDXCC, 0), cq("D", NeedBand, 0),
cq("F", NeedMode, 0), cq("H", NeedSlot, 0),
// The orthogonal markers sit at the foot of the ladder: worth a call
// when nothing better is on the air, never worth leaving a band for.
cq("J", NeedExtra, 0),
}
for i := 1; i < len(order); i++ {
if rank(order[i-1]) <= rank(order[i]) {
t.Errorf("%s (%d) does not outrank %s (%d)",
order[i-1].Call, rank(order[i-1]), order[i].Call, rank(order[i]))
}
}
// A station with nothing needed and not watched is not called at all.
if rank(cq("Z", NeedNone, 0)) != 0 {
t.Error("a station with nothing to gain from it ranks above zero")
}
// And the pick agrees with the ladder, whatever order the period lists them.
e := on()
a := e.OnPeriod(period(0, order[8], order[5], order[0], order[6]))
if a.Kind != DoReply || a.Decode.Call != "A" {
t.Fatalf("picked %+v, want the watched new entity", a)
}
// A watched station with NOTHING needed still beats a new entity that is not
// watched — the case that sent an operator hunting: a watched DXpedition sat
// on the band all evening while the engine worked what the log wanted.
e = on()
if a := e.OnPeriod(period(2, cq("RARE", NeedDXCC, 0), cq("WATCHED", NeedNone, -20, watched))); a.Decode.Call != "WATCHED" {
t.Errorf("picked %q, want the watched callsign", a.Decode.Call)
}
}
func TestStrongestWinsBetweenEquals(t *testing.T) {
e := on()
a := e.OnPeriod(period(0, cq("WEAK", NeedBand, -20), cq("LOUD", NeedBand, -5)))
if a.Decode.Call != "LOUD" {
t.Errorf("picked %q, want the strongest of two equal needs", a.Decode.Call)
}
}
// ── The busy station ──────────────────────────────────────────────────────
func TestBusyStationIsNeverCalled(t *testing.T) {
e := on()
// The new entity is answering a DXpedition; the new band is calling CQ.
a := e.OnPeriod(period(0, busy("RARE", NeedDXCC, -3), cq("DL1XX", NeedBand, -15)))
if a.Kind != DoReply || a.Decode.Call != "DL1XX" {
t.Fatalf("called %+v — a station in mid-QSO cannot answer and must not be called", a)
}
// It is not banned: the moment it calls CQ it takes the slot back, once the
// QSO in hand is over.
e2 := on()
if a := e2.OnPeriod(period(0, cq("RARE", NeedDXCC, -3))); a.Decode.Call != "RARE" {
t.Errorf("the same station calling CQ was not called: %+v", a)
}
}
func TestFinalFrameIsCallable(t *testing.T) {
e := on()
c := busy("RARE", NeedDXCC, -3)
c.Msg = "IK2AAA RARE RR73" // one frame from being free
if a := e.OnPeriod(period(0, c)); a.Kind != DoReply {
t.Errorf("a station sending its last frame is free next period: %+v", a)
}
}
// ── The brakes ────────────────────────────────────────────────────────────
func TestAttemptsCapAtSevenAndFifteen(t *testing.T) {
for _, tc := range []struct {
name string
opt func(*Candidate)
want int
}{{"plain", func(*Candidate) {}, 7}, {"watched", watched, 15}} {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5, tc.opt)))
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
for i := 1; i < tc.want; i++ {
if a := e.NoteTX(tx); a.Kind != DoNothing {
t.Fatalf("%s: gave up at call %d of %d", tc.name, i, tc.want)
}
}
a := e.NoteTX(tx)
if a.Kind != DoHalt {
t.Errorf("%s: still calling after %d attempts", tc.name, tc.want)
}
if e.Target() != "" {
t.Errorf("%s: target still held after giving up", tc.name)
}
}
}
func TestOnlyFreshCallsCount(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
// The rest of the exchange is not a call: without this the seven were spent
// on one QSO in progress.
for _, msg := range []string{"DX " + me + " -12", "DX " + me + " R-12", "DX " + me + " RR73"} {
if a := e.NoteTX(TXState{Transmitting: true, Msg: msg}); a.Kind != DoNothing {
t.Fatalf("%q ended the series", msg)
}
}
if e.Status().Attempts != 0 {
t.Errorf("attempts = %d after three QSO frames, want 0", e.Status().Attempts)
}
// A transmission aimed at somebody else counts for nothing either.
e.NoteTX(TXState{Transmitting: true, Msg: "OTHER " + me + " JN36"})
if e.Status().Attempts != 0 {
t.Errorf("a call to another station was counted against the target")
}
}
func TestMissesOnlyCountTheStationsOwnPeriods(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))) // learns nothing yet
e.OnPeriod(period(2, cq("DX", NeedDXCC, -5))) // seen: it transmits on even periods
// Its listening periods say nothing about it. Ten of them must not add up
// to a give-up.
for _, p := range []int{3, 5, 7, 9, 11} {
if a := e.OnPeriod(period(p)); a.Kind != DoNothing {
t.Fatalf("gave up during the station's own listening period %d", p)
}
}
if e.Status().Misses != 0 {
t.Errorf("misses = %d over five listening periods, want 0", e.Status().Misses)
}
// Absent from two of its transmit periods: still holding.
e.OnPeriod(period(4))
e.OnPeriod(period(6))
if e.Target() == "" {
t.Fatal("gave up after two misses, the limit is three")
}
if a := e.OnPeriod(period(8)); a.Kind != DoHalt {
t.Errorf("still holding after three missed transmit periods: %+v", a)
}
}
func TestOneMissPerPeriodEvenIfTheHandlerRunsTwice(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
e.OnPeriod(period(2, cq("DX", NeedDXCC, -5)))
p := period(4)
e.OnPeriod(p)
e.OnPeriod(p)
e.OnPeriod(p)
if e.Status().Misses != 1 {
t.Errorf("misses = %d after one period handled three times, want 1", e.Status().Misses)
}
}
func TestClockBackstopReleasesAStuckTarget(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
// Decoded every one of its periods and never answering, with the decoder
// never reporting a transmission: no counter can advance.
for p := 2; p <= 16; p += 2 {
e.OnPeriod(period(p, cq("DX", NeedDXCC, -5)))
}
if a := e.OnPeriod(period(18, cq("DX", NeedDXCC, -5))); a.Kind != DoHalt {
t.Errorf("a target held past MaxHold with no counter moving was never released: %+v", a)
}
}
// ── After a series ────────────────────────────────────────────────────────
func TestAReleasedStationYieldsToAnythingBetter(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedBand, -5)))
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
for i := 0; i < 7; i++ {
e.NoteTX(tx)
}
if e.Target() != "" {
t.Fatal("still holding after seven calls")
}
// It is still there, and so is a new entity: the entity takes the slot.
a := e.OnPeriod(period(2, cq("DX", NeedBand, -5), cq("RARE", NeedDXCC, -20)))
if a.Decode.Call != "RARE" {
t.Errorf("picked %q, want the higher priority over the station just released", a.Decode.Call)
}
}
// A series that ended in a brake is followed by ONE of the station's own overs
// passed over — long enough to be a pause, short enough that the DX everybody
// is chasing is still there when the calling resumes. It used to be two
// minutes, which on FT8 is four overs: by then the DX has worked four other
// callers, and half the time it has gone.
func TestAReleasedStationSitsOutOneOfItsOvers(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedBand, -5)))
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
for i := 0; i < 7; i++ {
e.NoteTX(tx)
}
// Its very next over is passed over, whatever else is on the air: seven
// calls, a halt, and the same station called again four seconds later is not
// a rest, it is a stutter.
if a := e.OnPeriod(period(2, cq("DX", NeedBand, -5))); a.Kind != DoNothing {
t.Fatalf("called again during the rest: %+v", a)
}
// And the one after that goes: still there, still wanted, and the operator
// has listened through an over.
if a := e.OnPeriod(period(4, cq("DX", NeedBand, -5))); a.Kind != DoReply {
t.Errorf("after one over: %+v, want the station called again", a)
}
}
func TestTheAttemptsCapLetsTheOverFinish(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
var a Action
for i := 0; i < 7; i++ {
a = e.NoteTX(tx)
}
if a.Kind != DoHalt {
t.Fatalf("no halt after seven calls: %+v", a)
}
// The cap trips WHILE the seventh call is going out — cutting the carrier
// there sends half a call. It asks the decoder to finish the over first.
if !a.Soft {
t.Error("the attempts cap cut the transmission that counted it")
}
}
func TestAStationIsParkedAfterItsRounds(t *testing.T) {
e := on()
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
// Rounds are spaced past the rest (2 min = 8 periods) or the station would
// simply be resting rather than parked.
for round := 1; round <= 3; round++ {
a := e.OnPeriod(period(round*10, cq("DX", NeedBand, -5)))
if a.Kind != DoReply {
t.Fatalf("round %d: not called (%+v)", round, a)
}
for i := 0; i < 7; i++ {
e.NoteTX(tx)
}
}
// Three series of seven is twenty-one calls. That is the end of it for this
// session — the whole point of the exercise is that it cannot reach fifty.
if a := e.OnPeriod(period(60, cq("DX", NeedBand, -5))); a.Kind != DoNothing {
t.Errorf("a fourth series was started: %+v", a)
}
}
// ── Handing over ──────────────────────────────────────────────────────────
func TestFinishedQSOMovesToTheNextPriority(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
done := callsMe("DX", NeedDXCC, -5)
done.Msg = me + " DX RR73"
// The period the QSO ends in belongs to our own 73 — nothing else is called.
if a := e.OnPeriod(period(2, done, cq("NEXT", NeedBand, -10))); a.Kind != DoNothing {
t.Fatalf("took the slot our 73 goes out in: %+v", a)
}
a := e.OnPeriod(period(4, cq("NEXT", NeedBand, -10)))
if a.Kind != DoReply || a.Decode.Call != "NEXT" {
t.Errorf("next period: %+v, want the next priority", a)
}
}
func TestFinishedQSOWithNoPriorityAnswersWhoeverIsCallingUs(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
done := callsMe("DX", NeedDXCC, -5)
done.Msg = me + " DX RR73"
// Two stations calling us, nothing needed from either: the strongest wins —
// on the period after the one our 73 goes out in.
weak := callsMe("WEAK", NeedNone, -18)
weak.Watched = true
loud := callsMe("LOUD", NeedNone, -4)
loud.Watched = true
e.OnPeriod(period(2, done))
a := e.OnPeriod(period(4, weak, loud))
if a.Kind != DoReply || a.Decode.Call != "LOUD" {
t.Errorf("answered %+v, want the strongest of the stations calling us", a)
}
}
func TestAlreadyWorkedIsNeverCalled(t *testing.T) {
e := on()
a := e.OnPeriod(period(0, cq("DX", NeedDXCC, -5, worked)))
if a.Kind != DoNothing {
t.Errorf("called a station already in the log on this band and mode: %+v", a)
}
}
func TestReplayedHistoryIsNeverCalled(t *testing.T) {
e := on()
old := cq("DX", NeedDXCC, -5)
old.IsNew = false
if a := e.OnPeriod(period(0, old)); a.Kind != DoNothing {
t.Errorf("answered a replayed decode: %+v", a)
}
}
func TestNoCallStartsOverATransmissionInProgress(t *testing.T) {
e := on()
p := period(0, cq("DX", NeedDXCC, -5))
p.TX = TXState{Transmitting: true}
if a := e.OnPeriod(p); a.Kind != DoNothing {
t.Errorf("started a call while the decoder was transmitting: %+v", a)
}
}
// ── The "call this station" field ─────────────────────────────────────────
func TestOnlyCallsThatStation(t *testing.T) {
e := New(Settings{Enabled: true, Only: "VP6D"})
a := e.OnPeriod(period(0, cq("RARE", NeedDXCC, -5), cq("VP6D", NeedSlot, -20)))
if a.Kind != DoReply || a.Decode.Call != "VP6D" {
t.Fatalf("picked %+v, want the station named in the field", a)
}
// Same brakes, then a hard stop: there is nothing else it was asked to do.
tx := TXState{Transmitting: true, Msg: "VP6D " + me + " JN36"}
for i := 0; i < 7; i++ {
e.NoteTX(tx)
}
if !e.Status().Stopped {
t.Error("an explicit target ran out of attempts and the feature did not stop")
}
if a := e.OnPeriod(period(2, cq("VP6D", NeedSlot, -20))); a.Kind != DoNothing {
t.Errorf("kept calling after the stop: %+v", a)
}
e.Reset()
if a := e.OnPeriod(period(4, cq("VP6D", NeedSlot, -20))); a.Kind != DoReply {
t.Errorf("the operator restarted it and nothing happened: %+v", a)
}
}
func TestDisabledDoesNothingAtAll(t *testing.T) {
e := New(Settings{})
if a := e.OnPeriod(period(0, cq("DX", NeedDXCC, 0))); a.Kind != DoNothing {
t.Errorf("switched off and still calling: %+v", a)
}
if a := e.NoteTX(TXState{Transmitting: true, Msg: "DX " + me + " JN36"}); a.Kind != DoNothing {
t.Errorf("switched off and still counting: %+v", a)
}
}
func TestOnlyStopsOnceThatStationIsWorked(t *testing.T) {
e := New(Settings{Enabled: true, Only: "VP6D"})
e.OnPeriod(period(0, cq("VP6D", NeedDXCC, -10)))
done := callsMe("VP6D", NeedDXCC, -10)
done.Msg = me + " VP6D RR73"
e.OnPeriod(period(2, done))
// The station is still on the air calling CQ. It has been worked: an
// explicit request is for one QSO, not for the whole evening.
if a := e.OnPeriod(period(4, cq("VP6D", NeedDXCC, -10))); a.Kind != DoNothing {
t.Errorf("called the named station again after working it: %+v", a)
}
}
func TestAStationJustWorkedIsNotCalledBackWhileTheLogCatchesUp(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
done := callsMe("DX", NeedDXCC, -5)
done.Msg = me + " DX RR73"
e.OnPeriod(period(2, done))
// Still flagged as a new entity — the QSO is not in the log yet — and still
// calling CQ. It must not be answered again.
if a := e.OnPeriod(period(4, cq("DX", NeedDXCC, -5))); a.Kind != DoNothing {
t.Errorf("called back a station worked two periods ago: %+v", a)
}
}
func TestChaseListTakesSeveralCallsigns(t *testing.T) {
// Typed the way an operator types a list: commas, spaces, or both.
for _, field := range []string{"VP6D 3Y0J", "vp6d,3y0j", "VP6D, 3Y0J", " VP6D ;3Y0J "} {
if got := onlyList(field); len(got) != 2 || got[0] != "VP6D" || got[1] != "3Y0J" {
t.Errorf("%q parsed as %v, want [VP6D 3Y0J]", field, got)
}
}
e := New(Settings{Enabled: true, Only: "VP6D, 3Y0J"})
// Nothing off the list is called, however rare it is.
if a := e.OnPeriod(period(0, cq("RARE", NeedDXCC, -1))); a.Kind != DoNothing {
t.Errorf("called a station that is not on the chase list: %+v", a)
}
// Between two listed stations the ladder still decides: the new entity over
// the new slot, whatever their order in the period.
a := e.OnPeriod(period(2, cq("3Y0J", NeedSlot, -1), cq("VP6D", NeedDXCC, -22)))
if a.Kind != DoReply || a.Decode.Call != "VP6D" {
t.Fatalf("picked %+v, want the new entity of the two listed", a)
}
// Working one of them leaves the other callable — the list is a hunt, not a
// single request.
done := callsMe("VP6D", NeedDXCC, -22)
done.Msg = me + " VP6D RR73"
e.OnPeriod(period(4, done, cq("3Y0J", NeedSlot, -1)))
a = e.OnPeriod(period(6, cq("3Y0J", NeedSlot, -1)))
if a.Kind != DoReply || a.Decode.Call != "3Y0J" {
t.Errorf("after working VP6D: %+v, want the other station on the list", a)
}
}
// ── Two decoders at once (the split view) ─────────────────────────────────
func onInst(inst string, p Period) Period {
p.Instance = inst
for i := range p.Decodes {
p.Decodes[i].Instance = inst
}
return p
}
func TestTheOtherReceiverCannotBreakTheQSOInProgress(t *testing.T) {
e := on()
// Calling a new band on receiver A.
if a := e.OnPeriod(onInst("A", period(0, cq("DL1XX", NeedBand, -10)))); a.Decode.Call != "DL1XX" {
t.Fatalf("first call went to %+v", a)
}
// Receiver B now hears a new ENTITY — a better catch by every rule. It must
// still not be called: one station at a time, and the QSO in hand is the
// one already under way.
if a := e.OnPeriod(onInst("B", period(1, cq("RARE", NeedDXCC, -1)))); a.Kind != DoNothing {
t.Errorf("the other receiver started a second QSO: %+v", a)
}
// And B's periods count no misses against A's target: the station is not
// absent from B, it was never on that band.
e.OnPeriod(onInst("B", period(3, cq("RARE", NeedDXCC, -1))))
e.OnPeriod(onInst("B", period(5, cq("RARE", NeedDXCC, -1))))
e.OnPeriod(onInst("B", period(7, cq("RARE", NeedDXCC, -1))))
if e.Status().Misses != 0 || e.Target() != "DL1XX" {
t.Errorf("misses = %d, target = %q — the other receiver's periods were counted",
e.Status().Misses, e.Target())
}
// B transmitting its own QSO is not us calling DL1XX either.
for i := 0; i < 9; i++ {
e.NoteTX(TXState{Transmitting: true, Instance: "B", Msg: "DL1XX " + me + " JN36"})
}
if e.Status().Attempts != 0 {
t.Errorf("attempts = %d from the other receiver's transmissions, want 0", e.Status().Attempts)
}
// A's own transmissions do count.
e.NoteTX(TXState{Transmitting: true, Instance: "A", Msg: "DL1XX " + me + " JN36"})
if e.Status().Attempts != 1 {
t.Errorf("attempts = %d after one call from the calling receiver, want 1", e.Status().Attempts)
}
// Once the QSO is over, the other receiver's station is free to be taken.
done := callsMe("DL1XX", NeedBand, -10)
done.Msg = me + " DL1XX RR73"
e.OnPeriod(onInst("A", period(9, done)))
if a := e.OnPeriod(onInst("B", period(11, cq("RARE", NeedDXCC, -1)))); a.Decode.Call != "RARE" {
t.Errorf("after the QSO ended, the other receiver was still locked out: %+v", a)
}
}
// The one reported from the air: his RRR arrives, we are sending our 73, and
// the engine picked the next station in the same instant. WSJT-X acts on a
// reply at once — it dropped the exchange and started calling the new station,
// cutting the 73 a second in.
func TestNothingIsCalledOverOurOwnTransmission(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
done := callsMe("DX", NeedDXCC, -5)
done.Msg = me + " DX RRR"
p := period(2, done, cq("NEXT", NeedBand, -10))
p.TX = TXState{Transmitting: true, Msg: "DX " + me + " 73"}
if a := e.OnPeriod(p); a.Kind != DoNothing {
t.Fatalf("called %+v while our own over was still going out", a)
}
// The QSO is still released — only the next call waits.
if e.Target() != "" {
t.Errorf("target = %q after the QSO finished, want none", e.Target())
}
// Next period, carrier down: now it may take the next station.
if a := e.OnPeriod(period(4, cq("NEXT", NeedBand, -10))); a.Kind != DoReply || a.Decode.Call != "NEXT" {
t.Errorf("once the transmission ended: %+v, want the next station called", a)
}
}
func TestTheHoldClockRestartsWhenTheStationAnswers(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
// Called for a long time — nearly the backstop — and then he answers.
for p := 2; p <= 14; p += 2 {
e.OnPeriod(period(p, cq("DX", NeedDXCC, -5)))
}
e.OnPeriod(period(16, callsMe("DX", NeedDXCC, -5)))
// The exchange must not be halted by a backstop that was counting the wait.
for _, p := range []int{18, 20} {
if a := e.OnPeriod(period(p, callsMe("DX", NeedDXCC, -5))); a.Kind == DoHalt {
t.Fatalf("period %d: halted an exchange in progress (%s)", p, a.Reason)
}
}
}
func TestARealNeedOutranksAnUnconfirmedOne(t *testing.T) {
real := cq("REAL", NeedBand, -20)
unconf := cq("UNCONF", NeedBand, -2)
unconf.Unconfirmed = true
// Same need, and the unconfirmed one is 18 dB louder. The band never worked
// is still the catch: the other is a QSL to chase, not a QSO to make.
e := on()
if a := e.OnPeriod(period(0, unconf, real)); a.Decode.Call != "REAL" {
t.Errorf("picked %q, want the band never worked", a.Decode.Call)
}
// Watched changes that, and is meant to: the list is the operator's answer.
unconf.Watched = true
e = on()
if a := e.OnPeriod(period(0, unconf, real)); a.Decode.Call != "UNCONF" {
t.Errorf("picked %q, want the watched station", a.Decode.Call)
}
unconf.Watched = false
// Between two unwatched stations, an unconfirmed BAND still beats a real
// SLOT: the band is the bigger prize whatever state it is in.
slot := cq("SLOT", NeedSlot, 0)
e = on()
if a := e.OnPeriod(period(0, slot, unconf)); a.Decode.Call != "UNCONF" {
t.Errorf("picked %q, want the unconfirmed band over a real slot", a.Decode.Call)
}
}
// ── The operator's Halt ───────────────────────────────────────────────────
func TestHaltSetsTheStationAsideForTheSession(t *testing.T) {
e := on()
if a := e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))); a.Decode.Call != "DX" {
t.Fatalf("not called: %+v", a)
}
// The operator stops it mid-call.
if got := e.Halt(); got != "DX" {
t.Fatalf("Halt returned %q, want the station being called", got)
}
if e.Target() != "" {
t.Error("the target survived a halt")
}
// Still the loudest new entity on the air, period after period. It is the
// operator's verdict, so it is not called again — not next period, not in
// ten minutes.
for _, n := range []int{2, 4, 40} {
if a := e.OnPeriod(period(n, cq("DX", NeedDXCC, -5))); a.Kind != DoNothing {
t.Errorf("period %d: called a station the operator had stopped: %+v", n, a)
}
}
// Naming it explicitly does not override the operator either.
e.SetSettings(Settings{Enabled: true, Only: "DX"})
if a := e.OnPeriod(period(42, cq("DX", NeedDXCC, -5))); a.Kind != DoNothing {
t.Errorf("the chase list overrode a halt: %+v", a)
}
// Switching auto-call off and on is what starts over.
e.Reset()
e.SetSettings(Settings{Enabled: true})
if a := e.OnPeriod(period(44, cq("DX", NeedDXCC, -5))); a.Kind != DoReply {
t.Errorf("after a restart the station is fair game again: %+v", a)
}
}
func TestHaltWithNothingBeingCalledIsJustAHalt(t *testing.T) {
e := on()
if got := e.Halt(); got != "" {
t.Errorf("Halt returned %q with no target", got)
}
if e.Greylisted() != 0 {
t.Errorf("greylisted %d stations from an idle halt", e.Greylisted())
}
}
func TestTraceSaysWhyNobodyWasCalled(t *testing.T) {
var lines []string
e := on()
e.SetTrace(func(f string, args ...any) { lines = append(lines, fmt.Sprintf(f, args...)) })
worked := cq("A", NeedDXCC, -5, worked)
nothing := cq("B", NeedNone, -5)
busyOne := busy("C", NeedBand, -5)
e.Halt() // nothing held: no-op, keeps the set empty
e.OnPeriod(period(0, worked, nothing, busyOne))
if len(lines) == 0 {
t.Fatal("tracing on and nothing was written")
}
got := lines[len(lines)-1]
for _, want := range []string{"decodes=3", "callable=0", "worked=1", "nothing-needed=1", "busy=1"} {
if !strings.Contains(got, want) {
t.Errorf("trace %q does not say %q", got, want)
}
}
// And when it DOES call, the line names the candidates it ranked.
lines = nil
e.OnPeriod(period(2, cq("DX", NeedBand, -9, watched)))
if len(lines) == 0 || !strings.Contains(lines[0], "DX(watched band,-9 dB,r118)") {
t.Errorf("trace %v does not describe the station it called", lines)
}
}
func TestAStationCallingUsIsAnsweredEvenWithNothingToGain(t *testing.T) {
e := on()
// A QSO has just ended and two stations are calling us. Neither is worth
// anything to the log — and both are worth answering: they have heard us,
// they are waiting, and the contact is one over away.
weak := callsMe("WEAK", NeedNone, -18)
loud := callsMe("LOUD", NeedNone, -3)
a := e.OnPeriod(period(0, weak, loud))
if a.Kind != DoReply || a.Decode.Call != "LOUD" {
t.Fatalf("answered %+v, want the strongest of the stations calling us", a)
}
// A caller already worked on this band and mode is a duplicate, not a QSO.
e = on()
done := callsMe("DUPE", NeedNone, -1)
done.Worked = true
if a := e.OnPeriod(period(2, done)); a.Kind != DoNothing {
t.Errorf("answered a station already in the log on this band and mode: %+v", a)
}
// And one the operator halted stays halted, however politely it calls.
e = on()
e.OnPeriod(period(4, cq("STOP", NeedDXCC, -5)))
e.Halt()
if a := e.OnPeriod(period(6, callsMe("STOP", NeedDXCC, -5))); a.Kind != DoNothing {
t.Errorf("answered a station the operator had stopped: %+v", a)
}
// A needed station still comes first: a caller with nothing to gain is
// answered when nothing better is on the air, which is what was asked for.
e = on()
if a := e.OnPeriod(period(8, callsMe("CALLER", NeedNone, 0), cq("RARE", NeedDXCC, -20))); a.Decode.Call != "RARE" {
t.Errorf("picked %q, want the new entity ahead of a caller with nothing needed", a.Decode.Call)
}
}
// TestAStationWorkingSomebodyElseIsNotCHOSEN — the test above is about a target
// already being called; this is about picking one. A reply to a decode in
// mid-exchange is a reply WSJT-X and JTDX may refuse outright, so it never
// starts a call there.
func TestAStationWorkingSomebodyElseIsNotCHOSEN(t *testing.T) {
e := on()
busyNow := busy("ON7GB", NeedSlot, +5)
busyNow.Msg = "PY2SAD ON7GB JO21"
if a := e.OnPeriod(period(0, busyNow)); a.Kind == DoReply {
t.Fatalf("called %+v — it is in a QSO with PY2SAD", a)
}
if e.Target() != "" {
t.Errorf("target is %q", e.Target())
}
// Its final frame is different: one period from free is the best moment
// there is to be calling it.
last := busy("ON7GB", NeedSlot, +5)
last.Msg = "PY2SAD ON7GB RR73"
if a := e.OnPeriod(period(2, last)); a.Kind != DoReply {
t.Errorf("%+v — a station on its last frame was not called", a)
}
}
func TestAFinalFrameToSomebodyElseIsNotABusyTarget(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedSlot, 0)))
// Sending RR73 to another station: one period from being free, and the best
// moment there is to be calling it.
wrap := busy("DX", NeedSlot, 0)
wrap.Msg = "PY2SAD DX RR73"
if a := e.OnPeriod(period(2, wrap)); a.Kind != DoNothing || e.Target() != "DX" {
t.Errorf("dropped a station that was finishing: %+v (target %q)", a, e.Target())
}
}
func TestNothingHiddenByThePanelIsCalled(t *testing.T) {
e := New(Settings{Enabled: true, OnScreenOnly: true})
// The operator has filtered the list down to CQs: what is not on the screen
// is not called, whatever the log makes of it.
hidden := cq("RARE", NeedDXCC, 0)
hidden.Hidden = true
shown := cq("PLAIN", NeedSlot, -20)
if a := e.OnPeriod(period(0, hidden, shown)); a.Decode.Call != "PLAIN" {
t.Errorf("picked %q, want the station the panel is showing", a.Decode.Call)
}
// With the panel publishing nothing, nothing is hidden and the ladder
// decides alone.
e = New(Settings{Enabled: true, OnScreenOnly: true})
free := cq("RARE", NeedDXCC, 0)
if a := e.OnPeriod(period(2, free, shown)); a.Decode.Call != "RARE" {
t.Errorf("picked %q with no filtering in force, want the new entity", a.Decode.Call)
}
}
// MSHV answers two stations in one transmission and the decoder prints them as
// one line. Reported from the air: the DX was answering us in the second half
// and the engine, reading only the first, dropped it as busy.
func TestAMultiAnswerLineIsReadWhole(t *testing.T) {
fox := cq("HK0/PY8WW", NeedDXCC, -17, watched)
fox.CQ = false
fox.Msg = "YV5ALI RR73; " + me + " <HK0/PY8WW> -08"
if !callingUs(fox, me) {
t.Error("the second half is a report to us and was not read as one")
}
if !callable(fox, me) {
t.Error("a station answering us was judged uncallable")
}
// Held as the target, that line must not release it — it is the answer we
// were waiting for, not a station working somebody else.
e := on()
e.OnPeriod(period(0, cq("HK0/PY8WW", NeedDXCC, -17, watched)))
if a := e.OnPeriod(period(2, fox)); a.Kind != DoNothing || e.Target() != "HK0/PY8WW" {
t.Errorf("dropped the DX as it answered us: %+v (target %q)", a, e.Target())
}
// The exchange is under way, so the attempt counter has done its job.
if e.Status().Attempts != 0 {
t.Errorf("attempts = %d while in QSO, want 0", e.Status().Attempts)
}
// And its final frame to us, in the same shape, ends the QSO.
done := fox
done.Msg = "IK2ABC RR73; " + me + " <HK0/PY8WW> RR73"
if !finished([]Candidate{done}, "HK0/PY8WW", me) {
t.Error("a 73 to us inside a multi-answer line did not end the QSO")
}
}
// Reported from the air twice: the DX sends RR73, our own 73 is about to go
// out, and the engine answered somebody else in that very slot — two
// transmissions in one period, and the station at the other end never got the
// frame that closes the contact.
func TestTheSlotAfterAQSOBelongsToOur73(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("HP/WE9G", NeedDXCC, -6)))
bye := callsMe("HP/WE9G", NeedDXCC, -6)
bye.Msg = "<" + me + "> HP/WE9G RR73"
// A new band is decoding in the same period and is not called.
a := e.OnPeriod(period(2, bye, cq("GW8DX", NeedBand, +2)))
if a.Kind != DoNothing {
t.Fatalf("called %+v in the slot our 73 goes out in", a)
}
if !strings.Contains(a.Reason, "73") {
t.Errorf("reason %q does not say why the slot was left alone", a.Reason)
}
// It is still there fifteen seconds later, which is the whole cost.
if a := e.OnPeriod(period(4, cq("GW8DX", NeedBand, +2))); a.Kind != DoReply || a.Decode.Call != "GW8DX" {
t.Errorf("next period: %+v, want the new band called", a)
}
}
func TestABetterStationTakesOverBeforeAnybodyHasAnswered(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("PLAIN", NeedSlot, +10)))
// A watched station comes on the air: nothing has answered us yet, so the
// call in progress is worth less than the one now possible.
a := e.OnPeriod(period(2, cq("PLAIN", NeedSlot, +10), cq("WATCHED", NeedNone, -15, watched)))
if a.Kind != DoReply || a.Decode.Call != "WATCHED" {
t.Fatalf("%+v — a better station did not take over", a)
}
// Equal rungs do NOT take over while the station being called is on the air.
e = on()
e.OnPeriod(period(4, cq("A", NeedBand, 0)))
if a := e.OnPeriod(period(6, cq("A", NeedBand, 0), cq("B", NeedBand, +20))); a.Kind != DoNothing {
t.Errorf("%+v — swapped between two stations of equal value", a)
}
// But they do when it is absent: seven calls into the void while a station
// of the same value is calling CQ is what the operator was watching.
if a := e.OnPeriod(period(8, cq("B", NeedBand, +20))); a.Kind != DoReply || a.Decode.Call != "B" {
t.Errorf("%+v — kept calling a station that was not on the air", a)
}
// Once the station has answered, nothing takes its place.
e = on()
e.OnPeriod(period(10, cq("DX", NeedSlot, 0)))
e.OnPeriod(period(12, callsMe("DX", NeedSlot, 0)))
if a := e.OnPeriod(period(14, callsMe("DX", NeedSlot, 0), cq("RARE", NeedDXCC, 0, watched))); a.Kind != DoNothing {
t.Errorf("%+v — abandoned an exchange in progress", a)
}
}
// TestAnExchangeInProgressIsNeverAbandoned is the shack report: mid-QSO with
// V31MA — its report decoded, our RR73 going out — and a station of a higher
// rung called us from the other side of the screen. The engine switched.
func TestAnExchangeInProgressIsNeverAbandoned(t *testing.T) {
e := on()
// V31MA is worth nothing on the ladder: worked before, nothing needed.
if a := e.OnPeriod(period(0, callsMe("V31MA", NeedNone, -12))); a.Kind != DoReply {
t.Fatalf("%+v — a station calling us was not answered", a)
}
// Its report arrives in the same period a better station calls us. That
// period used to be judged before the reply was taken into account.
a := e.OnPeriod(period(2,
callsMe("V31MA", NeedNone, -12),
callsMe("F5NNN", NeedSlot, -10)))
if a.Kind != DoNothing {
t.Fatalf("%+v — left V31MA mid-exchange", a)
}
if e.target == nil || e.target.Call != "V31MA" {
t.Fatalf("target is %v — the QSO in progress lost its place", e.target)
}
// Our own transmission settles it too: a report is not an opening call, so
// even a QSO the operator started by hand is protected.
e = on()
e.OnPeriod(period(4, cq("V31MA", NeedNone, -12, watched)))
e.NoteTX(TXState{Transmitting: true, Msg: "V31MA F4BPO RR73"})
if !e.answered {
t.Error("sending a report did not count as being inside the exchange")
}
if a := e.OnPeriod(period(6, cq("V31MA", NeedNone, -12, watched), callsMe("F5NNN", NeedDXCC, -10))); a.Kind != DoNothing {
t.Errorf("%+v — abandoned a QSO we were in the middle of", a)
}
if e.attempts != 0 {
t.Errorf("attempts=%d — an exchange frame was counted as a call", e.attempts)
}
}
// TestAPileupIsWorkedByCallingThroughIt is the shack report: D44TWO finishing a
// contact, the call started, the DX coming back to somebody else — and the
// engine giving up on a station that was one period from being free.
func TestAPileupIsWorkedByCallingThroughIt(t *testing.T) {
e := on()
if a := e.OnPeriod(period(0, cq("D44TWO", NeedDXCC, -7))); a.Kind != DoReply {
t.Fatalf("%+v", a)
}
// It answers three other callers in a row — which is what a DX with a queue
// does, and calling through it is how the queue is joined.
does := func(n int) Action { return e.OnPeriod(period(n, busy("D44TWO", NeedDXCC, -7))) }
for _, n := range []int{2, 4, 6} {
if a := does(n); a.Kind != DoNothing {
t.Fatalf("%+v — stopped calling a station working the pileup", a)
}
if e.Target() != "D44TWO" {
t.Fatalf("target is %q — the station was released while it worked others", e.Target())
}
}
// And when it comes to us, the exchange starts as usual.
if a := e.OnPeriod(period(8, callsMe("D44TWO", NeedDXCC, -7))); a.Kind != DoNothing {
t.Errorf("%+v", a)
}
if !e.answered {
t.Error("the reply was not taken as the start of the exchange")
}
// A BETTER station may still take the slot while it is busy: nothing has
// been answered, so nothing is lost by moving.
e = on()
e.OnPeriod(period(10, cq("D44TWO", NeedSlot, -7)))
if a := e.OnPeriod(period(12, busy("D44TWO", NeedSlot, -7), cq("RARE", NeedDXCC, -20, watched))); a.Kind != DoReply || a.Decode.Call != "RARE" {
t.Errorf("%+v — a watched new one did not take the slot from a busy station", a)
}
}
// TestTheFirstCallStartsWithNoMisses is the shack report: a CQ answered for the
// first time, and the toolbar already reading two misses out of three.
func TestTheFirstCallStartsWithNoMisses(t *testing.T) {
e := on()
// It calls CQ on an odd slot; we answer.
if a := e.OnPeriod(period(1, cq("ER1CW", NeedBand, -2))); a.Kind != DoReply {
t.Fatalf("%+v", a)
}
// The next period is OURS: we are transmitting to it, and of course it is
// not decoded. That is not a miss.
pp := period(2)
pp.TX = TXState{Transmitting: true}
e.OnPeriod(pp)
if e.misses != 0 {
t.Fatalf("misses=%d after our own transmit period", e.misses)
}
// Nor is a period of the wrong parity with nothing in it.
e.OnPeriod(period(4))
if e.misses != 0 {
t.Fatalf("misses=%d — counted a period the station never transmits in", e.misses)
}
// ITS period, and it is not there: that is a miss.
e.OnPeriod(period(3))
if e.misses != 1 {
t.Fatalf("misses=%d — the station's own silent period was not counted", e.misses)
}
}
// Parking answers "it will not answer, stop wasting the evening on it" — which
// is a verdict about a station the LOG picked out, not about one the OPERATOR
// did. From the air: a watched ZD8GB, six streams a period, refused for the
// rest of the session after two series of unanswered calls.
func TestAWatchedCallsignIsNeverParked(t *testing.T) {
spent := func(c Candidate) *Engine {
e := New(Settings{Enabled: true, MaxRounds: 2})
e.OnPeriod(period(0)) // the engine's clock, so the rest below is period time
for i := 0; i < 2; i++ {
e.giveUp(strings.ToUpper(c.Call), c) // a series ended by a brake
}
return e
}
// Period 8, well past the rest that follows a series: what is left is the
// parking, and only the parking.
plain := cq("PLAIN", NeedDXCC, 0)
if a := spent(plain).OnPeriod(period(8, plain)); a.Kind == DoReply {
t.Errorf("%+v — a station whose series are spent was called again", a)
}
dx := cq("ZD8GB", NeedDXCC, 0, watched)
if a := spent(dx).OnPeriod(period(8, dx)); a.Kind != DoReply {
t.Errorf("%+v — a watched DXpedition was parked for the session", a)
}
}
// A period is judged more than once — the decodes arrive in a burst and
// stragglers follow — and a later judgement of it holds a partial view, not
// evidence that the station has gone. From the air: D2ACE answered in the very
// period the counter then read as a miss.
func TestAPeriodTheTargetWasSeenInIsNeverAMiss(t *testing.T) {
e := on()
if a := e.OnPeriod(period(1, cq("D2ACE", NeedBand, 13))); a.Kind != DoReply {
t.Fatalf("%+v", a)
}
// Its own next period: decoded, so the exchange is under way.
p := period(3, callsMe("D2ACE", NeedBand, 13))
e.OnPeriod(p)
if e.Status().Misses != 0 {
t.Fatalf("misses=%d after being decoded", e.Status().Misses)
}
// The same period again, this time from a flush that carries only the
// stragglers — the target is not among them.
e.OnPeriod(period(3))
if got := e.Status().Misses; got != 0 {
t.Errorf("misses=%d — a second flush of a period it was seen in counted against it", got)
}
}
+121 -2
View File
@@ -9,6 +9,7 @@ package cat
import (
"fmt"
"runtime"
"strings"
"sync"
"time"
)
@@ -240,15 +241,81 @@ func (m *Manager) freqOffsetHz() int64 {
// display trick: the readout would say 144 and every spot click, band change and
// memory recall would send the rig somewhere 116 MHz away.
func (m *Manager) SetFrequency(hz int64) error {
real := hz
if off := m.freqOffsetHz(); off != 0 && hz > off {
hz -= off
}
return m.exec(func(b Backend) error { return b.SetFrequency(hz) })
err := m.exec(func(b Backend) error { return b.SetFrequency(hz) })
if err == nil {
m.noteCommandedFreq(real)
}
return err
}
// noteCommandedFreq publishes a frequency the radio has just acknowledged,
// without waiting for the next poll to come round and read it back.
//
// The wait is what this is about. A rigctl client — WSJT-X above all — sets a
// frequency and then READS it back before it believes it is there, and until
// then it will not decode, transmit or even update its own dial. Everything
// answering "f" here comes from the last poll, so the answer was the OLD
// frequency for as long as a poll cycle takes; on a rig reached over the
// internet, where one cycle is many round trips, a band change from WSJT-X took
// ten seconds to be believed while the radio itself had moved instantly.
//
// Only when NOT split. In split the two frequencies mean different VFOs and a
// guess about which one just moved is how a client ends up writing the transmit
// frequency onto the dial — the poll is left to settle that case.
func (m *Manager) noteCommandedFreq(hz int64) {
if hz <= 0 {
return
}
m.mu.Lock()
st := m.state
if !st.Connected || st.Split || st.FreqHz == hz {
m.mu.Unlock()
return
}
st.FreqHz = hz
st.Band = BandFromHz(hz)
st.UpdatedAt = time.Now()
m.state = st
m.mu.Unlock()
m.emitState()
}
// SetMode dispatches a SetMode call to the CAT goroutine.
func (m *Manager) SetMode(mode string) error {
return m.exec(func(b Backend) error { return b.SetMode(mode) })
err := m.exec(func(b Backend) error { return b.SetMode(mode) })
if err == nil {
m.noteCommandedMode(mode)
}
return err
}
// noteCommandedMode is the mode half of noteCommandedFreq, and exists for the
// same client readback.
//
// "DATA" is deliberately not published. A backend reports data mode under the
// operator's own digital mode (FT8, JS8, RTTY…), and that name is what a QSO is
// logged with — a plain "DATA" standing in for a poll cycle is a mode nobody
// works, in a field that ends up in an ADIF file. The poll is a fraction of a
// second away and knows the real name.
func (m *Manager) noteCommandedMode(mode string) {
if mode == "" || strings.EqualFold(mode, "DATA") {
return
}
m.mu.Lock()
st := m.state
if !st.Connected || st.Mode == mode {
m.mu.Unlock()
return
}
st.Mode = mode
st.UpdatedAt = time.Now()
m.state = st
m.mu.Unlock()
m.emitState()
}
// SetPTT dispatches a transmit on/off request to the CAT goroutine.
@@ -256,6 +323,33 @@ func (m *Manager) SetPTT(on bool) error {
return m.exec(func(b Backend) error { return b.SetPTT(on) })
}
// dataPTTSetter is implemented by a backend that can key the DATA input rather
// than the microphone. A Kenwood TS-590 has two transmit commands and takes its
// audio from a different socket for each: TX (or TX0) opens the front mic, TX1
// the rear ACC2/USB. Send the wrong one and the radio transmits in silence,
// because the audio arriving on USB is simply not the input it is listening to.
type dataPTTSetter interface {
SetPTTData(on bool) error
}
// SetPTTSource keys the transmitter, saying WHERE the audio is coming from.
//
// data=true means "the audio reaches the radio on its data/USB input" — what a
// voice keyer playing through the rig's own sound card needs. A backend that
// draws no distinction (every rig where one PTT is all there is) falls back to
// the ordinary key, so nothing changes for it.
func (m *Manager) SetPTTSource(on, data bool) error {
if !data {
return m.SetPTT(on)
}
return m.exec(func(b Backend) error {
if d, ok := b.(dataPTTSetter); ok {
return d.SetPTTData(on)
}
return b.SetPTT(on)
})
}
// splitSetter is implemented by the backends that can arm split AND place the
// transmit frequency. Both together: arming without setting the dial transmits
// on whatever the transmit VFO happened to hold, which is worse than refusing.
@@ -304,6 +398,14 @@ type SpotInfo struct {
BackgroundColor string
Comment string
LifetimeSec int // panadapter display seconds before auto-removal (0 = backend default)
// Priority is SmartSDR's own tie-breaker, 1 (highest) to 5.
//
// It matters because the panadapter has finite room: spots close in
// frequency are stacked behind a "+" and only one of them is drawn. The
// radio picks that one by priority — so an entity never worked can sit
// invisible behind three stations already in the log unless we say which
// is worth the space. 0 leaves the field off the command entirely.
Priority int
}
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
@@ -572,6 +674,10 @@ type IcomTXState struct {
// Transmit + live status (polled).
Transmitting bool `json:"transmitting"`
Split bool `json:"split"`
// SubHz is the unselected VFO / sub receiver's frequency — shown on the
// console's SUB display whether or not split is on: a dual-receiver rig
// (IC-7610/7760) has a second dial worth seeing at all times.
SubHz int64 `json:"sub_hz"`
SMeter int `json:"s_meter"` // 0-100 (raw 0-255; S9≈120)
PowerMeter int `json:"power_meter"` // 0-100 (TX Po)
SWRMeter int `json:"swr_meter"` // 0-100 (TX SWR)
@@ -599,6 +705,7 @@ type IcomTXState struct {
Filter int `json:"filter"` // 1 | 2 | 3 (FIL1/2/3)
// Antenna (IC-7610 = ANT1/ANT2).
Antenna int `json:"antenna"` // 1 | 2 (0 = unknown)
ATUOn bool `json:"atu_on"` // internal tuner engaged
// Filter fine controls: Twin PBT + manual notch (0-100, 50 = centre).
PBTInner int `json:"pbt_inner"`
PBTOuter int `json:"pbt_outer"`
@@ -636,6 +743,8 @@ type IcomController interface {
SetRFPower(int) error
SetMicGain(int) error
SetIcomSplit(bool) error
SetIcomSplitOffset(bool, int64) error
SetATU(bool) error
TuneATU() error
SetScope(bool) error // enable/disable the spectrum-scope waveform stream
SetScopeMode(bool) error // true = fixed span, false = center-on-VFO
@@ -662,6 +771,10 @@ type IcomController interface {
SetVOXGain(int) error
SetAntiVOX(int) error
SetPower(bool) error // turn the transceiver on/off (manual — never auto on connect)
// RecallBandStack moves the VFO to what the radio's own band stacking
// register holds — the operator's last frequency and mode on that band.
// Returns the frequency landed on.
RecallBandStack(band, reg int) (int64, error)
}
// ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's
@@ -674,6 +787,12 @@ type ScopeSweep struct {
LowHz int64 `json:"low_hz"` // left edge frequency (0 when unknown)
HighHz int64 `json:"high_hz"` // right edge frequency (0 when unknown)
Fixed bool `json:"fixed"` // true = fixed-span mode, false = center-on-VFO
// Unsupported: this radio refuses the waveform-output command, so there will
// never be a sweep. The IC-7851 does — its last firmware is from 2016, older
// than the CI-V waveform stream — while still answering the scope's other
// commands. Reported so the panadapter can say so instead of showing a black
// rectangle that looks like a bug in OpsLog.
Unsupported bool `json:"unsupported"`
}
// IcomState returns the current Icom DSP state, or (zero, false) when the active
+53
View File
@@ -36,6 +36,7 @@ const (
CmdVfoFreq = 0x25 // read a specific VFO's freq (sub 0x00 selected, 0x01 unselected)
CmdPTT = 0x1C // sub 0x00 = PTT
CmdExtra = 0x1A // sub 0x06 = data mode on modern Icoms
CmdModeDataFil = 0x26 // sub 0x00 = selected VFO: mode + data flag + filter in one frame
CmdReadID = 0x19 // sub 0x00 = rig's own CI-V address (identifies model)
CmdPower = 0x18 // power on/off (sub 0x01 = on, 0x00 = off; on needs an FE wake preamble)
@@ -130,6 +131,8 @@ const (
ModeFM = 0x05
ModeCWR = 0x07
ModeRTTYR = 0x08
ModePSK = 0x12 // native PSK (IC-7610/7760/7851 class; older rigs NAK it)
ModePSKR = 0x13
)
// Frame builds a complete CI-V frame (preamble … end) for payload, which is the
@@ -305,6 +308,8 @@ func ModeToADIF(m byte, data bool) string {
return "CW"
case ModeRTTY, ModeRTTYR:
return "RTTY"
case ModePSK, ModePSKR:
return "PSK31"
case ModeAM:
return "AM"
case ModeFM:
@@ -357,6 +362,8 @@ func ModelName(addr byte) string {
return "IC-9700"
case 0xA4:
return "IC-705"
case 0xB2:
return "IC-7760"
case 0xB6:
return "IC-7300MKII"
}
@@ -418,3 +425,49 @@ func indexPreamble(buf []byte, from int) int {
}
return -1
}
// ── Band stacking registers (CI-V 0x1A sub 0x01) ──────────────────────────
//
// Every modern Icom remembers the last few frequency/mode pairs used on each
// band, and its front-panel band key walks through them. That is why pressing
// [14] on the radio lands on the FT8 watering hole rather than on a number
// somebody chose in software: the register is the operator's OWN last visit.
//
// The frame is a READ — it asks the rig what a register holds and changes
// nothing — so a rig that does not know the command answers NG and the caller
// is exactly where it started.
//
// → 1A 01 <band> <reg>
// ← 1A 01 <band> <reg> <freq 5 BCD, LE> <mode> <filter> <data mode> …
//
// Anything past the data-mode byte (duplex, tone, DV squelch on the VHF rigs)
// is not read here: the question is where the operator last was, and the answer
// to that is the frequency and the mode.
const SubBandStack = 0x01
// BandStack is one register's contents.
type BandStack struct {
FreqHz int64
Mode byte
Data bool // the data-mode flag that goes with Mode
}
// DecodeBandStack reads the payload of a 0x1A 0x01 reply, i.e. everything after
// the command byte. ok is false for a frame that is not the register asked for,
// which is what a desynchronised read looks like.
func DecodeBandStack(data []byte, band, reg byte) (BandStack, bool) {
if len(data) < 9 || data[0] != SubBandStack || data[1] != band || data[2] != reg {
return BandStack{}, false
}
hz, ok := BCDToFreq(data[3:8])
if !ok || hz <= 0 {
return BandStack{}, false
}
bs := BandStack{FreqHz: hz, Mode: data[8]}
// Filter then data mode. A rig that stops at the filter byte is not an
// error — it is a register without a data flag, so the flag stays false.
if len(data) >= 11 {
bs.Data = data[10] != 0
}
return bs, true
}
+33
View File
@@ -204,3 +204,36 @@ func TestBCDToFreqRejectsNonDecimal(t *testing.T) {
t.Error("a short but valid BCD frame must still decode")
}
}
// A band stacking register reply, byte for byte as the rigs send it:
// 1A 01 <band> <reg> <freq 5 LE-BCD> <mode> <filter> <data>. The payload here
// is everything after the command byte, which is what Decoded.Data holds.
func TestDecodeBandStack(t *testing.T) {
// 14.074.000 MHz, USB, FIL1, data mode on — the FT8 stack on 20 m.
frame := []byte{0x01, 0x05, 0x03, 0x00, 0x40, 0x07, 0x14, 0x00, ModeUSB, 0x01, 0x01}
bs, ok := DecodeBandStack(frame, 0x05, 0x03)
if !ok {
t.Fatal("a well-formed register was rejected")
}
if bs.FreqHz != 14_074_000 {
t.Errorf("freq = %d, want 14074000", bs.FreqHz)
}
if bs.Mode != ModeUSB || !bs.Data {
t.Errorf("mode = 0x%02X data = %v, want USB + data", bs.Mode, bs.Data)
}
// The register ASKED FOR is part of the answer: a reply about another one is
// a desynchronised read, not a frequency to send the radio to.
if _, ok := DecodeBandStack(frame, 0x05, 0x01); ok {
t.Error("a reply for register 3 was accepted as register 1")
}
if _, ok := DecodeBandStack(frame, 0x03, 0x03); ok {
t.Error("a reply about 40 m was accepted as 20 m")
}
// A register without the data-mode byte is a shorter frame, not a bad one.
if bs, ok := DecodeBandStack(frame[:10], 0x05, 0x03); !ok || bs.FreqHz != 14_074_000 || bs.Data {
t.Errorf("short register = %+v ok=%v, want the frequency with no data flag", bs, ok)
}
if _, ok := DecodeBandStack([]byte{0x01, 0x05, 0x03}, 0x05, 0x03); ok {
t.Error("a truncated frame was accepted")
}
}
+5
View File
@@ -1557,6 +1557,10 @@ func (f *Flex) SendSpot(s SpotInfo) error {
if hadOld {
f.send(fmt.Sprintf("spot remove %d", old))
}
prio := ""
if s.Priority >= 1 && s.Priority <= 5 {
prio = fmt.Sprintf(" priority=%d", s.Priority)
}
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
// Convert to a real Flex mode (USB/LSB/CW/DIGU/…): SmartSDR only switches the
@@ -1575,6 +1579,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
if c := flexEncode(s.Comment); c != "" {
cmd += " comment=" + c
}
cmd += prio
seq := f.send(cmd)
if seq > 0 {
// Remember which call this add was for; the R<seq> response carries the
+130 -9
View File
@@ -22,17 +22,20 @@ package cat
// audio stream is opt-in and entirely separate from control/CI-V.
import (
"fmt"
"net"
"sync"
"sync/atomic"
"time"
)
// icaAudioOffset is where the PCM payload begins inside an audio data packet
// (wfview audio_packet: 16-byte common header + ident@0x10 + datalen@0x12 +
// sendseq@0x14 → audio@0x16). Isolated as a const so a capture-confirmed change
// is a one-line edit.
const icaAudioOffset = 0x16
// icaAudioOffset is where the PCM payload begins inside an audio data packet.
// CONFIRMED on a real IC-7760 (2026-08-29): 16-byte common header, ident@0x10,
// send seq (BE) @0x12, payload length (BE uint32) @0x14 — 0x500 observed on
// every packet — and the PCM starts at 0x18. The 0x16 first guessed from
// wfview's struct swallowed two header bytes into the audio, one broken sample
// per packet: a 50 Hz click track under everything.
const icaAudioOffset = 0x18
// icaDumpFirst is how many initial audio packets to hex-dump to the debug log for
// offset verification. After the layout is confirmed on a real rig this can go to
@@ -55,6 +58,14 @@ type icomAudio struct {
rxMissing map[uint16]int
dumped int // packets hex-dumped so far (≤ icaDumpFirst)
pingSeq uint16 // client-ping counter — the rig wants OUR pings too (see icnPing)
started time.Time
// Live-TX state — see SendTXChunk.
txMu sync.Mutex
txRem []byte
txOuter uint16
txSend uint16
lastRx atomic.Int64 // UnixNano of last packet (liveness)
done chan struct{}
@@ -100,6 +111,7 @@ func dialIcomAudio(host string, sink func([]byte), cancel <-chan struct{}) (*ico
_ = conn.SetReadBuffer(1 << 20)
a := &icomAudio{
conn: conn, aID: aID, aRemote: aRemote,
started: time.Now(),
sink: sink,
rxMissing: make(map[uint16]int),
done: make(chan struct{}),
@@ -115,6 +127,7 @@ func dialIcomAudio(host string, sink func([]byte), cancel <-chan struct{}) (*ico
func (a *icomAudio) audioPump() {
buf := make([]byte, 8192)
lastIdle := time.Now()
lastPing := time.Now()
lastReq := time.Now()
for {
select {
@@ -132,12 +145,19 @@ func (a *icomAudio) audioPump() {
case typ == 0x05: // rig-initiated disconnect
debugLog.Printf("icom audio: rig sent DISCONNECT — audio stream dropped by the rig")
case typ == 0x00 && k > icaAudioOffset: // audio data packet
a.trackRxSeq(icnLE.Uint16(buf[6:]))
fresh := a.trackRxSeq(icnLE.Uint16(buf[6:]))
if a.dumped < icaDumpFirst {
a.dumped++
debugLog.Printf("icom audio raw #%d: len=%d head=% X", a.dumped, k, buf[:min(icaAudioOffset+8, k)])
}
if a.sink != nil {
// Only a packet that ADVANCES the sequence reaches the sink. A
// duplicate or a late retransmit used to be delivered as if it
// were the next 20 ms of audio: the monitor's capped ring threw
// the surplus away (the speakers stayed clean), but the recorder
// keeps every sample it is given — the file grew longer than the
// QSO and played back slowed and stuttering, each lost-then-
// resent packet heard twice.
if fresh && a.sink != nil {
payload := append([]byte(nil), buf[icaAudioOffset:k]...)
a.sink(payload)
}
@@ -147,6 +167,11 @@ func (a *icomAudio) audioPump() {
_, _ = a.conn.Write(icnCtrl(0x00, 0, a.aID, a.aRemote))
lastIdle = time.Now()
}
if time.Since(lastPing) > 500*time.Millisecond {
a.pingSeq++
_, _ = a.conn.Write(icnPing(a.pingSeq, a.aID, a.aRemote, uint32(time.Since(a.started).Milliseconds())))
lastPing = time.Now()
}
if time.Since(lastReq) > 100*time.Millisecond {
a.sendRetransmitReq()
lastReq = time.Now()
@@ -157,26 +182,36 @@ func (a *icomAudio) audioPump() {
// trackRxSeq / sendRetransmitReq mirror icomNet's receive-side retransmit exactly
// (audio is as loss-sensitive as the scope stream). Duplicated deliberately so
// the audio stream owns its own seq state with no shared locking.
func (a *icomAudio) trackRxSeq(seq uint16) {
//
// The return value says whether this packet moves the stream FORWARD — the
// only kind the sink may hear. A duplicate is the same 20 ms again; a late
// retransmit would play old audio in the middle of new. Both are accounted
// for here and dropped by the caller.
func (a *icomAudio) trackRxSeq(seq uint16) bool {
if !a.rxHaveSeq {
a.rxHaveSeq = true
a.rxLastSeq = seq
return
return true
}
switch d := int16(seq - a.rxLastSeq); {
case d == 0:
return false
case d < 0:
delete(a.rxMissing, seq)
return false
case d == 1:
a.rxLastSeq = seq
return true
case int(d) <= icnMaxMissing:
for f := a.rxLastSeq + 1; f != seq; f++ {
a.rxMissing[f] = 0
}
a.rxLastSeq = seq
return true
default:
a.rxMissing = make(map[uint16]int)
a.rxLastSeq = seq
return true
}
}
@@ -217,3 +252,89 @@ func (a *icomAudio) sendRetransmitReq() {
_, _ = a.conn.Write(b)
}
}
// PlayTX sends one already-decoded message out over the audio session, paced
// at the stream's own 20 ms / 320-sample cadence, and returns when the last
// packet has gone or stop is closed.
//
// The frame mirrors what the rig itself sends on this socket byte for byte —
// ident 0x81 0x01 (LPCM mono 16-bit), a big-endian send sequence at 0x12, the
// payload length at 0x14, PCM from 0x18 — with the IDs swapped for direction.
// PTT is the caller's business, exactly as on the TCI and sound-card paths.
func (a *icomAudio) PlayTX(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
mono := decodeToMono(pcm, ch, bits)
if len(mono) == 0 {
return fmt.Errorf("the message is empty")
}
if rate > 0 && rate != 16000 {
mono = resampleLinear(mono, rate, 16000)
}
// Through the SAME framer and counters as the live microphone. Each of the
// two used to number its own packets from 1, and a voice-keyer message sent
// after a talk session re-used sequence numbers the rig had already seen —
// it keyed for the full length of the message and modulated none of it.
const frame = 320 // samples per packet: 20 ms at 16 kHz, the rig's own cadence
tick := time.NewTicker(20 * time.Millisecond)
defer tick.Stop()
buf := make([]byte, frame*2)
for pos := 0; pos < len(mono); pos += frame {
select {
case <-stop:
return nil
case <-a.done:
return fmt.Errorf("the audio stream closed mid-message")
case <-tick.C:
}
for i := 0; i < frame; i++ {
var v float32
if pos+i < len(mono) {
v = mono[pos+i] * 32767
}
if v > 32767 {
v = 32767
} else if v < -32768 {
v = -32768
}
icnLE.PutUint16(buf[i*2:], uint16(int16(v)))
}
if err := a.SendTXChunk(buf); err != nil {
return err
}
}
return nil
}
// Live TX — the microphone, not a recorded message. Chunks arrive at the
// microphone's own real-time pace (16 kHz mono 16-bit, whatever length the
// capture delivers) and are re-framed into the rig's 320-sample packets; the
// remainder waits for the next chunk. Counters and remainder live on the
// stream so a talk session survives across calls.
func (a *icomAudio) SendTXChunk(pcm []byte) error {
a.txMu.Lock()
defer a.txMu.Unlock()
a.txRem = append(a.txRem, pcm...)
const frameBytes = 640
for len(a.txRem) >= frameBytes {
select {
case <-a.done:
return fmt.Errorf("the audio stream closed")
default:
}
pkt := make([]byte, 0x18+frameBytes)
icnLE.PutUint32(pkt[0:], uint32(len(pkt)))
a.txOuter++
icnLE.PutUint16(pkt[6:], a.txOuter)
icnLE.PutUint32(pkt[8:], a.aID)
icnLE.PutUint32(pkt[12:], a.aRemote)
pkt[0x10], pkt[0x11] = 0x81, 0x01
icnBE.PutUint16(pkt[0x12:], a.txSend)
a.txSend++
icnBE.PutUint32(pkt[0x14:], frameBytes)
copy(pkt[0x18:], a.txRem[:frameBytes])
a.txRem = a.txRem[frameBytes:]
if _, err := a.conn.Write(pkt); err != nil {
return fmt.Errorf("sending mic audio to the rig: %w", err)
}
}
return nil
}
+212 -9
View File
@@ -43,7 +43,7 @@ var icnBE = binary.BigEndian
// = CI-V only (the proven default). The audio stream is fully separate from CAT,
// so enabling it can't affect freq/mode/DSP control.
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string, audioSink func([]byte)) *IcomSerial {
if civAddr <= 0 || civAddr > 0xFF {
if civAddr < 0 || civAddr > 0xFF {
civAddr = 0x98 // IC-7610
}
if digitalDefault == "" {
@@ -56,6 +56,15 @@ func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string, aud
model: "Icom",
scopeFixed: true,
}
// lastNetConnect is when the previous session came up, and it drives a
// deliberate pause: on a real IC-7760 a session sometimes goes deaf on CI-V
// while the rig still holds it half-open, and a session dialled straight
// back in answers for a second and is then strangled when the rig finally
// purges the old one — reconnect, die, reconnect, die, twenty seconds a
// lap and no audio the whole time. When the last session died YOUNG, wait
// out the rig's cleanup before dialling again; a session that lived long
// reconnects immediately, as ever.
var lastNetConnect time.Time
b.open = func() (civTransport, error) {
if strings.TrimSpace(host) == "" {
return nil, fmt.Errorf("no rig host configured")
@@ -63,7 +72,20 @@ func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string, aud
b.dialMu.Lock()
cancel := b.dialCancel
b.dialMu.Unlock()
return dialIcomNet(host, user, pass, "OpsLog", b.rigAddr, cancel, audioSink)
if !lastNetConnect.IsZero() && time.Since(lastNetConnect) < 90*time.Second {
debugLog.Printf("icom net: the last session died young — pausing 20 s before redialling so the rig can purge it first")
for i := 0; i < 40; i++ {
if icnCanceled(cancel) {
return nil, errDialCanceled
}
time.Sleep(500 * time.Millisecond)
}
}
tr, err := dialIcomNet(host, user, pass, "OpsLog", b.rigAddr, cancel, audioSink)
if err == nil {
lastNetConnect = time.Now()
}
return tr, err
}
return b
}
@@ -97,6 +119,19 @@ type icomNet struct {
// CAT goroutine) and during dial — never by the pump — so no lock is needed.
vTracked uint16
vCivSeq uint16
seqMu sync.Mutex // guards vTracked/vCivSeq: the command loop AND the pump's quiet-recovery both send
// Client-ping sequence counters, one per stream, and the connection's epoch
// for the ping timestamps. Owned by their pump goroutines — no locking.
civPingSeq uint16
ctrlPingSeq uint16
started time.Time
// txCiv counts CI-V command packets sent, and txAtData snapshots it at the
// last received CI-V data. Their difference during a silence answers the
// question the reconnects cannot: were we still ASKING when the answers
// stopped? Zero writes-since-data would mean the fault is our own poll
// loop, not the rig — and RS-BA1 showing no such dropouts points that way.
txCiv atomic.Uint32
txAtData atomic.Uint32
rx chan []byte // CI-V byte chunks from civPump → Read (control replies)
scopeRx chan []byte // scope (0x27) frames, kept off rx so the panadapter
@@ -227,10 +262,13 @@ func (n *icomNet) Write(p []byte) (int, error) {
if icnTrace {
debugLog.Printf("icom net TX: % X", p)
}
seq := n.vTracked
pkt := icnCivData(seq, n.vID, n.vRemote, n.vCivSeq, p)
n.seqMu.Lock()
seq, civSeq := n.vTracked, n.vCivSeq
n.vTracked++
n.vCivSeq++
n.seqMu.Unlock()
n.txCiv.Add(1)
pkt := icnCivData(seq, n.vID, n.vRemote, civSeq, p)
n.sentMu.Lock()
n.sentBuf[seq] = pkt
delete(n.sentBuf, seq-1024) // keep the buffer bounded (~last 1024 packets) so
@@ -281,6 +319,7 @@ func (n *icomNet) Close() error {
func (n *icomNet) ctrlPump() {
buf := make([]byte, 4096)
lastIdle := time.Now()
lastCtrlPing := time.Now()
lastToken := time.Now() // token was just granted during dial
for {
select {
@@ -321,6 +360,11 @@ func (n *icomNet) ctrlPump() {
}
// Renew well inside the rig's ~2-min token timeout. 30 s (was 45) leaves room
// for one lost renewal + its retransmit before the token would lapse.
if time.Since(lastCtrlPing) > 500*time.Millisecond {
n.ctrlPingSeq++
_, _ = n.ctrl.Write(icnPing(n.ctrlPingSeq, n.cID, n.cRemote, uint32(time.Since(n.started).Milliseconds())))
lastCtrlPing = time.Now()
}
if time.Since(lastToken) > 30*time.Second {
n.renewToken()
lastToken = time.Now()
@@ -359,6 +403,25 @@ func (n *icomNet) civPump() {
buf := make([]byte, 8192)
lastIdle := time.Now()
lastReq := time.Now()
// Diagnosis for the recurring 2-3-minute silence a real IC-7760 shows on
// this stream while the control link stays alive: when the stream has been
// quiet for 10 s, say so ONCE, with the last read error — a socket error
// and a rig that stopped talking are different repairs, and the 30 s
// watchdog that follows cannot tell them apart from where it sits.
debugLog.Printf("icom net: client pings armed on the CI-V stream (every 500 ms)")
lastPkt := time.Now()
lastPing := time.Now()
lastData := time.Now() // CI-V payload packets (replies + transceive)
lastScope := time.Time{} // scope frames within those
var lastErr error
quietSaid := false
gaveUp := false
// sawData: this session has heard at least one CI-V payload. A radio in
// STANDBY is silent on CI-V by design — only the transport chats — and the
// quiet-recovery below must not treat that as a fault: it was tearing the
// session down every 15 s, and with it the console and its ON button, so a
// radio that was off when OpsLog started could never be turned on at all.
sawData := false
for {
select {
case <-n.done:
@@ -366,7 +429,14 @@ func (n *icomNet) civPump() {
default:
}
_ = n.civ.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
if k, err := n.civ.Read(buf); err == nil && k >= 16 {
k, err := n.civ.Read(buf)
if err != nil {
if e, ok := err.(net.Error); !ok || !e.Timeout() {
lastErr = err // a REAL socket error, not the read deadline
}
}
if err == nil && k >= 16 {
lastPkt = time.Now()
n.markRx()
switch typ := icnLE.Uint16(buf[4:]); {
case typ == 0x07: // ping
@@ -379,6 +449,13 @@ func (n *icomNet) civPump() {
n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
debugLog.Printf("icom net: rig sent DISCONNECT on CI-V stream — session dropped by the rig")
case typ == 0x00 && k > 0x15 && buf[0x10] == 0xc1: // CI-V data
if quietSaid {
debugLog.Printf("icom net: CI-V replies are back after %s", time.Since(lastData).Round(time.Second))
quietSaid, gaveUp = false, false
}
sawData = true
lastData = time.Now()
n.txAtData.Store(n.txCiv.Load())
n.trackRxSeq(icnLE.Uint16(buf[6:])) // note gaps for retransmit
civBytes := buf[0x15:k]
cp := append([]byte(nil), civBytes...)
@@ -388,6 +465,7 @@ func (n *icomNet) civPump() {
// feeder in IcomSerial picks them up. Everything else is a control
// reply → rx → Read.
if len(civBytes) >= 5 && civBytes[4] == 0x27 {
lastScope = time.Now()
icnEnqueueDrop(n.scopeRx, cp)
break
}
@@ -410,14 +488,69 @@ func (n *icomNet) civPump() {
}
}
}
if !quietSaid && sawData && time.Since(lastData) > 10*time.Second {
quietSaid = true
scopeAge := "never"
if !lastScope.IsZero() {
scopeAge = time.Since(lastScope).Round(time.Second).String()
}
debugLog.Printf("icom net: no CI-V DATA for 10 s (transport last heard %s ago; last scope frame %s ago; last socket error: %v; missing-seq backlog: %d; CI-V commands SENT since the last answer: %d)",
time.Since(lastPkt).Round(time.Second), scopeAge, lastErr, len(n.rxMissing), n.txCiv.Load()-n.txAtData.Load())
// And try the gentle repair before the 30 s watchdog tears the whole
// session down: if the rig quietly closed the CI-V data flow (the
// transport is still chatting, so the session itself stands), saying
// "open" again on the same stream is all it should take. Harmless
// when the cause is elsewhere — the watchdog still fires at 30 s.
n.seqMu.Lock()
seq, civSeq := n.vTracked, n.vCivSeq
n.vTracked++
n.vCivSeq++
n.seqMu.Unlock()
ocPkt := icnOpenClose(seq, n.vID, n.vRemote, civSeq, 0x04)
n.sentMu.Lock()
n.sentBuf[seq] = ocPkt
n.sentMu.Unlock()
_, _ = n.civ.Write(ocPkt)
debugLog.Printf("icom net: re-sent the CI-V open on the existing stream")
}
// The reopen was given five seconds. On a real IC-7760 it never works —
// the rig ignores it and only a fresh session brings CI-V back — so
// rather than sit out the 30 s watchdog, fail the link NOW and let the
// manager rebuild it: the outage drops from ~35 s to ~15.
if quietSaid && !gaveUp && time.Since(lastData) > 15*time.Second {
gaveUp = true
n.dead.Store(true)
debugLog.Printf("icom net: the reopen did not bring CI-V back — forcing a fresh session")
}
if time.Since(lastIdle) > 150*time.Millisecond {
_, _ = n.civ.Write(icnCtrl(0x00, 0, n.vID, n.vRemote))
// Idles carry a REAL sequence number, drawn from the same counter as
// the data packets, and sit in the retransmit buffer like them —
// which is how RS-BA1 and wfview number theirs. Ours used seq 0 on
// every idle, seven a second, interleaved with properly-numbered
// data; a rig that follows the sequence tolerates that for a minute
// or so and then stops serving CI-V data on the stream — which is
// the shape of every dropout this loaner IC-7760 has shown, with
// the scope and the TX path both since eliminated.
n.seqMu.Lock()
iseq := n.vTracked
n.vTracked++
n.seqMu.Unlock()
ipkt := icnCtrl(0x00, iseq, n.vID, n.vRemote)
n.sentMu.Lock()
n.sentBuf[iseq] = ipkt
n.sentMu.Unlock()
_, _ = n.civ.Write(ipkt)
lastIdle = time.Now()
}
if time.Since(lastReq) > 100*time.Millisecond {
n.sendRetransmitReq()
lastReq = time.Now()
}
if time.Since(lastPing) > 500*time.Millisecond {
n.civPingSeq++
_, _ = n.civ.Write(icnPing(n.civPingSeq, n.vID, n.vRemote, uint32(time.Since(n.started).Milliseconds())))
lastPing = time.Now()
}
}
}
@@ -661,6 +794,7 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan
cTracked: cTracked, cAuthSeq: cInner,
cToken: token, cTokReq: tokReq,
cSentBuf: make(map[uint16][]byte),
started: time.Now(),
}
n.markRx() // the successful handshake counts as initial rig activity
// openClose(open) starts the CI-V data flow. We intentionally DO NOT power the
@@ -712,6 +846,11 @@ func icnHandshake(c *net.UDPConn, myID uint32, cancel <-chan struct{}) (uint32,
}
typ := icnLE.Uint16(p[4:])
sentid := icnLE.Uint32(p[8:])
// Every packet the rig sends during the handshake, verbatim. A radio that
// answers SOMETHING unrecognised (a newer model, a different firmware) and
// a radio that answers nothing are different faults, and a silent timeout
// hides which one this is.
icnHandshakeProbe(p)
switch typ {
case 0x04: // iAmHere
remoteID = sentid
@@ -843,11 +982,20 @@ func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, use
copy(b[0x40:0x60], []byte("IC-7610"))
copy(b[0x60:0x70], icnPasscode(user))
b[0x70] = rxEnable // rxenable: 1 opens the 50003 RX audio stream, 0 = CI-V only
b[0x71] = 0x00 // txenable (Phase 5)
b[0x72] = 0x10 // rxcodec
// TX rides the same audio session: whenever the operator wants RX audio the
// TX side is opened with it, so the voice keyer can play to the radio with
// no cable. Costs nothing when unused — no packets flow until a message is
// actually played.
b[0x71] = rxEnable // txenable
// rxcodec 0x04 = LPCM, ONE channel, 16-BIT — settled by experiment on a
// real IC-7760, one wrong guess at a time: 0x10 produced 1280-byte payloads
// of interleaved 16-bit stereo, 0x02 produced 320-byte payloads of 8-bit
// mono (samples hugging 0x80). 0x04 sits between them in the codec table
// (as in wfview): 16-bit mono, the format the 16 kHz playback path plays.
b[0x72] = 0x04 // rxcodec
b[0x73] = 0x04 // txcodec
icnBE.PutUint32(b[0x74:], 16000)
icnBE.PutUint32(b[0x78:], 8000)
icnBE.PutUint32(b[0x78:], 16000) // TX sample rate — the same 16 kHz everything else here runs at
icnBE.PutUint32(b[0x7c:], uint32(civPort))
icnBE.PutUint32(b[0x80:], uint32(audioPort))
icnBE.PutUint32(b[0x84:], 100)
@@ -855,6 +1003,26 @@ func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, use
return b
}
// PlayTXAudio plays one voice-keyer message through the 50003 audio session.
// On the transport because the transport owns the session; the IcomSerial
// controller forwards here after a type assertion, exactly as the TCI path
// reaches its radio.
func (n *icomNet) PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
if n.audio == nil {
return fmt.Errorf("the radio's audio stream is not open — enable RX audio in Settings → CAT first")
}
return n.audio.PlayTX(pcm, rate, ch, bits, stop)
}
// TXAudioSender returns a function that streams live microphone chunks to the
// rig — the talk button's road, where PlayTXAudio is the voice keyer's.
func (n *icomNet) TXAudioSender() (func([]byte) error, error) {
if n.audio == nil {
return nil, fmt.Errorf("the radio's audio stream is not open — enable RX audio in Settings → CAT first")
}
return n.audio.SendTXChunk, nil
}
func icnOpenClose(seq uint16, sentid, rcvdid uint32, civSeq uint16, magic byte) []byte {
b := make([]byte, 0x16)
icnLE.PutUint32(b[0:], 0x16)
@@ -903,3 +1071,38 @@ func icnPasscode(s string) []byte {
}
return out
}
// icnHandshakeProbe logs the first packets seen during a control handshake —
// capped hard, because a working handshake would log for ever.
var icnProbeCount int
func icnHandshakeProbe(p []byte) {
if icnProbeCount >= 12 {
return
}
icnProbeCount++
n := len(p)
if n > 32 {
n = 32
}
debugLog.Printf("icom net: handshake rx %d bytes: % X", len(p), p[:n])
}
// icnPing builds a CLIENT ping request (wfview's ping_packet: 0x15 bytes,
// type 0x07, reply=0, a monotonic time at 0x11). The rig answers each one —
// and, decisively, treats them as the client's sign of life: wfview sends one
// every 500 ms on every stream, and OpsLog, which only ever REPLIED to the
// rig's pings, watched the IC-7760 stop serving CI-V data about a minute into
// every session. Same socket answered, same transport alive: the rig had
// simply concluded nobody was listening.
func icnPing(seq uint16, sentid, rcvdid uint32, ms uint32) []byte {
b := make([]byte, 0x15)
icnLE.PutUint32(b[0:], 0x15)
icnLE.PutUint16(b[4:], 0x07)
icnLE.PutUint16(b[6:], seq)
icnLE.PutUint32(b[8:], sentid)
icnLE.PutUint32(b[12:], rcvdid)
b[0x10] = 0x00
icnLE.PutUint32(b[0x11:], ms)
return b
}
+310 -27
View File
@@ -94,6 +94,9 @@ type IcomSerial struct {
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read
// via ScopeData from the binding goroutine).
dualScope bool
// Set when the rig rejects the waveform-output command in both shapes: it has
// no stream to give, and asking again on every enable is noise.
scopeUnsupported bool
scopeMu sync.Mutex
scopeAmp []byte
scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame)
@@ -113,6 +116,12 @@ type IcomSerial struct {
// "alive but silent" tolerance below, which used to be
// unbounded and left the display frozen for ever
silentGrace time.Duration // current width of that tolerance (backs off, see ReadState)
// Needle inertia for the TX meters — the CI-V meters are point samples, and
// between two SSB syllables a poll lands on 0 W and a perfect SWR. See
// meterPeak (yaesu_panel.go): power decays like a needle, SWR holds then
// snaps back to the live truth.
powerPeak meterPeak
swrPeak meterPeak
dspLoaded bool // readDSP has run since the rig became responsive (loads all
// the panel's set-once controls once the rig actually answers)
// When the console last asked for the DSP snapshot. The meters and the
@@ -163,12 +172,13 @@ const (
)
// NewIcomSerial builds an (unconnected) Icom serial backend. baud defaults to
// 115200, rig address to the IC-7610's 0x98 when out of range.
// 115200, rig address to the IC-7610's 0x98 when out of range — and 0x00 is in
// range: it is a valid CI-V address that some rigs and interfaces are set to.
func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *IcomSerial {
if baud <= 0 {
baud = 115200
}
if civAddr <= 0 || civAddr > 0xFF {
if civAddr < 0 || civAddr > 0xFF {
civAddr = 0x98 // IC-7610
}
if digitalDefault == "" {
@@ -259,6 +269,21 @@ func (b *IcomSerial) Connect() error {
// non-default address still RENDERS; this flag only drives the SET/read commands
// (mode, span, edges), which need the 0x00 selector to be accepted on the 7300.
b.dualScope = idAddr == 0x98 || idAddr == 0xA2 || idAddr == 0x94
// Silence any LEFTOVER waveform stream, BLIND, before anything else. The
// 0x27 output flag lives in the RADIO and survives sessions; its flood is
// what makes the IC-7760 stop answering CI-V — so waiting for CI-V to
// answer before sending this (the first attempt did) was the egg asking
// the chicken to hatch it. Fire-and-forget: an unanswered set costs one
// frame, and the rig acts on what it decodes whether or not we hear the
// acknowledgement. The front-panel display is deliberately untouched.
// Synchronously: Connect already runs on the CAT goroutine, and a stray
// goroutine writing to the shared link would interleave with the command
// loop. Two forms, because the selector byte differs by model and a NAK
// costs one frame — and the results are LOGGED, because a kill that quietly
// fails leaves the flood running and the next dropout unexplained.
err1 := b.execScope("waveform output off (leftover)", civ.SubScopeOn, 0)
err2 := b.exec(civ.CmdScope, civ.SubScopeOn, 0x00, 0x00) // main-selector form
applog.Printf("icom: waveform-off sent at connect (plain: %v, with selector: %v)", err1, err2)
// Defer the DSP snapshot until the rig actually answers CI-V. Over the network
// the rig may still be booting (or off) at Connect, so an immediate readDSP
// would time out and leave every control at 0 / off with no retry. ReadState
@@ -344,7 +369,12 @@ func (b *IcomSerial) ReadState() (RigState, error) {
case readerGone:
debugLog.Printf("icom net: the CI-V reader has exited — the connection is dead however alive the control link looks → reconnecting")
case at.Alive():
debugLog.Printf("icom net: control link answers but no CI-V reply for %s → reconnecting. Another program (WSJT-X/OmniRig, the Remote Utility) has most likely taken the CI-V session.", silentFor.Round(time.Second))
// No verdict on WHY: this fires for a session taken by another
// program (WSJT-X, the Remote Utility) AND for a rig that simply
// stopped answering — a real IC-7760 did exactly that once with
// nothing else on the network. Blaming a hijacker sent that
// operator hunting software that was not installed.
debugLog.Printf("icom net: control link answers but no CI-V reply for %s → reconnecting. Either another program took the CI-V session (WSJT-X/OmniRig/Remote Utility), or the rig stopped answering on its own.", silentFor.Round(time.Second))
default:
debugLog.Printf("icom net: control link went quiet (no rig packets for >6 s) → reconnecting. If this recurs every ~2-3 min, the rig is invalidating the session (token renewal rejected).")
}
@@ -393,6 +423,19 @@ func (b *IcomSerial) ReadState() (RigState, error) {
}
b.dspMu.Lock()
b.dsp.Mode = s.Mode
// The console says which SIDEBAND, not the ADIF family: "SSB" on 40 m
// leaves the operator guessing whether the rig is where convention puts
// it. The log keeps SSB; the panel shows what the radio is actually doing.
switch b.curModeByte {
case civ.ModeUSB:
if s.Mode == "SSB" {
b.dsp.Mode = "USB"
}
case civ.ModeLSB:
if s.Mode == "SSB" {
b.dsp.Mode = "LSB"
}
}
b.dspMu.Unlock()
}
@@ -406,8 +449,13 @@ func (b *IcomSerial) ReadState() (RigState, error) {
// all, for the user's Set* commands.
if b.pollN%4 == 1 {
b.splitOn, b.splitTXFreq = false, 0
if on, ok := b.readSplit(); ok && on {
on, okSplit := b.readSplit()
// The unselected VFO is read split or NOT: on a dual-receiver rig it is
// the sub receiver's dial, and the console showed it blank until split
// was engaged.
if txHz, ok2 := b.readTXFreq(); ok2 && txHz > 0 {
b.setCache(func(st *IcomTXState) { st.SubHz = txHz })
if okSplit && on {
b.splitOn, b.splitTXFreq = true, txHz
}
}
@@ -432,12 +480,16 @@ func (b *IcomSerial) ReadState() (RigState, error) {
sm, _ = b.readMeter(civ.SubMeterS)
po, swr = 0, 0
if tx {
now := time.Now()
if v, ok := b.readMeter(civ.SubMeterPo); ok {
po = v
po = b.powerPeak.update(v, now)
}
if v, ok := b.readMeter(civ.SubMeterSWR); ok {
swr = v
swr = b.swrPeak.updateSnap(v, now)
}
} else {
// Cleared with the carrier, so the next transmission starts fresh.
b.powerPeak, b.swrPeak = meterPeak{}, meterPeak{}
}
}
b.dspMu.Lock()
@@ -506,6 +558,13 @@ func (b *IcomSerial) refreshFrontPanel() {
b.dsp.Filter = int(f)
b.dspMu.Unlock()
}
case 2:
// Tuner in/out — the radio's own TUNER button changes it mid-session.
if v, ok := b.readSwitchSub(civ.CmdATU, civ.SubATU); ok {
b.dspMu.Lock()
b.dsp.ATUOn = v == 1
b.dspMu.Unlock()
}
}
}
@@ -536,6 +595,15 @@ func (b *IcomSerial) SetMode(mode string) error {
if err != nil {
return err
}
return b.setModeBytes(mode, code, data)
}
// setModeBytes is SetMode once the mode is already a CI-V byte and a data flag.
// Split out for the band-stacking recall, which gets both FROM the radio and
// must not go back through an ADIF name to reach them: a register holding CW-R
// or LSB would come back as plain CW or as whatever the band convention says,
// i.e. not the mode the operator left there.
func (b *IcomSerial) setModeBytes(mode string, code byte, data bool) error {
// Set the base mode (keeping the rig's current filter by sending only the
// mode byte), then set the data-mode flag for digital modes.
if err := b.execIdempotent("set mode "+mode, civ.CmdSetMode, code); err != nil {
@@ -556,6 +624,18 @@ func (b *IcomSerial) SetMode(mode string) error {
}
// Filter 0x01 (FIL1) is the conventional default for the data-mode set.
_ = b.execIdempotent("set data mode", civ.CmdExtra, civ.SubDataMode, dataByte, 0x01)
// Trust, then verify: the IC-7760 acknowledges 1A 06 and stays in USB-D
// anyway, which left an operator unable to get back to plain USB at all.
// When the readback disagrees, say it again with 0x26 — mode, data flag and
// filter in one frame, the command the newer rigs actually honour. Only on
// a mismatch, so rigs that predate 0x26 never see it.
if got := b.readDataMode(); got != data {
if err := b.execIdempotent("set mode+data (0x26)", civ.CmdModeDataFil, 0x00, code, dataByte, 0x01); err != nil {
applog.Printf("icom: data flag stuck at %v after mode %s, and 0x26 failed too: %v", got, mode, err)
} else {
applog.Printf("icom: data flag stuck after 1A06, corrected via 0x26 (mode %s)", mode)
}
}
return nil
}
@@ -848,6 +928,19 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
loggedCfg[f.Data[0]] = true
applog.Printf("icom scope cfg 0x%02X: data=[% X]", f.Data[0], f.Data)
}
// The rig just told us its own layout: a mode/span/edge answer of
// three bytes or more carries the main/sub selector, one of two
// bytes does not. Worth reading, because the SET commands take the
// same shape and several firmwares answer a wrong-shaped set with
// silence rather than a rejection — which is not something the
// retry in execScope can act on.
if f.Data[0] == civ.SubScopeMode && len(f.Data) >= 2 {
if sel := len(f.Data) >= 3; sel != b.dualScope {
applog.Printf("icom scope: the rig answers 0x%02X with %d bytes — using the %s form",
f.Data[0], len(f.Data)-1, map[bool]string{true: "27 xx 00 …", false: "27 xx …"}[sel])
b.dualScope = sel
}
}
continue
}
if rawN < 24 {
@@ -982,6 +1075,43 @@ func (b *IcomSerial) assembleSweep(regions map[byte][]byte, total byte) {
}
}
// execScope sends a 0x27 SET and, if the rig rejects it, sends it once more in
// the other shape — with or without the leading main/sub selector byte — and
// remembers which one this rig speaks.
//
// The shape used to be decided from the CI-V address, which meant every new
// model was a blank scope until someone reported it: the IC-7851 (0x8E) rejects
// "27 11 01" outright and wants "27 11 00 01", exactly as the IC-7610 does not.
// A rejection is a cheap and unambiguous answer, so ask the rig instead of
// keeping a list. Only the SET commands need this — the waveform parser already
// detects the selector per frame.
func (b *IcomSerial) execScope(what string, sub byte, args ...byte) error {
try := func(sel bool) error {
p := []byte{civ.CmdScope, sub}
if sel {
p = append(p, 0x00) // main scope
}
return b.exec(append(p, args...)...)
}
err := try(b.dualScope)
// Only a REJECTION means "wrong shape". A timeout says nothing (several
// firmwares simply don't ack a 0x27 set), and retrying it in the other shape
// would flip a working rig onto the wrong one.
if err == nil || !strings.Contains(err.Error(), "rejected") {
return err
}
err2 := try(!b.dualScope)
applog.Printf("icom scope: %s rejected in the %s form — the other form gave: %v",
what, map[bool]string{true: "27 xx 00 …", false: "27 xx …"}[b.dualScope], err2)
if err2 == nil {
b.dualScope = !b.dualScope
applog.Printf("icom scope: %s rejected — this rig wants the %s form (selector=%v)",
what, map[bool]string{true: "27 xx 00 …", false: "27 xx …"}[b.dualScope], b.dualScope)
return nil
}
return err
}
// SetScope enables or disables the spectrum scope. Two commands are needed and
// RS-BA1 sends both: 0x27 0x10 turns the scope DISPLAY on (without it the rig
// streams nothing — the case when we're remote and can't touch the front panel),
@@ -1000,15 +1130,25 @@ func (b *IcomSerial) SetScope(on bool) error {
// radio, and closing OpsLog (SetScope(false)) blanking a local IC-7300's
// screen is exactly the regression this avoids. Some firmwares don't ack a
// 0x27 set; a timeout isn't fatal, so log and continue.
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, 0x01); err != nil {
if err := b.execScope("display on", civ.SubScopeOnOff, 0x01); err != nil {
applog.Printf("icom scope: display on ack: %v", err)
}
}
// Waveform data OUTPUT over CI-V: enabled with the scope, and — crucially —
// the ONLY thing we switch off on disable, so the radio's own scope display is
// left exactly as the operator had it.
if err := b.exec(civ.CmdScope, civ.SubScopeOn, boolByte(on)); err != nil {
if err := b.execScope("data output", civ.SubScopeOn, boolByte(on)); err != nil {
applog.Printf("icom scope: output on=%v ack: %v", on, err)
// Rejected in both shapes = the command does not exist on this rig, which
// is a permanent answer and not a bad guess on our part. Remember it: the
// panel can then say so, and we stop asking a radio that has already
// said no.
if strings.Contains(err.Error(), "rejected") {
applog.Printf("icom scope: %s does not stream its scope over CI-V — control commands only", b.model)
b.scopeMu.Lock()
b.scopeUnsupported = true
b.scopeMu.Unlock()
}
}
b.scopeMu.Lock()
b.scopeOn = on
@@ -1041,13 +1181,7 @@ func (b *IcomSerial) scopeReadCfg() {
// makes the scope follow the VFO, so tuning pans the view left/right.
func (b *IcomSerial) SetScopeMode(fixed bool) error {
mode := boolByte(fixed) // 0 = center, 1 = fixed (verify on rig via the cfg log)
var payload []byte
if b.dualScope {
payload = []byte{civ.CmdScope, civ.SubScopeMode, 0x00, mode}
} else {
payload = []byte{civ.CmdScope, civ.SubScopeMode, mode}
}
if err := b.exec(payload...); err != nil {
if err := b.execScope("set mode", civ.SubScopeMode, mode); err != nil {
applog.Printf("icom scope: set mode fixed=%v ack: %v", fixed, err)
}
b.scopeMu.Lock()
@@ -1093,13 +1227,8 @@ func (b *IcomSerial) SetScopeEdges(low, high int64) error {
if rangeID == 0 {
return fmt.Errorf("icom scope: freq out of range")
}
if b.dualScope {
_ = b.exec(civ.CmdScope, civ.SubScopeMode, 0x00, 0x01) // fixed mode (main)
_ = b.exec(civ.CmdScope, civ.SubScopeEdge, 0x00, 0x01) // activate edge set 1
} else {
_ = b.exec(civ.CmdScope, civ.SubScopeMode, 0x01)
_ = b.exec(civ.CmdScope, civ.SubScopeEdge, 0x01)
}
_ = b.execScope("fixed mode", civ.SubScopeMode, 0x01)
_ = b.execScope("edge set 1", civ.SubScopeEdge, 0x01)
payload := append([]byte{civ.CmdScope, civ.SubScopeFixEdge, rangeID, 0x01}, civ.FreqToBCD(low)...)
payload = append(payload, civ.FreqToBCD(high)...)
b.scopeMu.Lock()
@@ -1263,7 +1392,8 @@ func (b *IcomSerial) ScopeData() ScopeSweep {
for i, v := range b.scopeAmp {
amp[i] = int(v)
}
return ScopeSweep{Amp: amp, Seq: b.scopeSeq, LowHz: b.scopeLow, HighHz: b.scopeHigh, Fixed: b.scopeFixed}
return ScopeSweep{Amp: amp, Seq: b.scopeSeq, LowHz: b.scopeLow, HighHz: b.scopeHigh, Fixed: b.scopeFixed,
Unsupported: b.scopeUnsupported}
}
// exec sends a set command and waits for the rig's OK (FB) / NG (FA) ack.
@@ -1423,12 +1553,26 @@ func (b *IcomSerial) modeCode(mode string) (code byte, data bool, err error) {
return civ.ModeCW, false, nil
case "SSB":
return usb, false, nil
case "USB":
// The SIDEBAND, asked for by name. "SSB" resolves to whichever side the
// band convention wants, which is right for a logged mode and useless
// when the operator means "put this radio in USB" — on 40 m there was no
// way to say it at all, and the console's own button could not either.
return civ.ModeUSB, false, nil
case "LSB":
return civ.ModeLSB, false, nil
case "AM":
return civ.ModeAM, false, nil
case "FM":
return civ.ModeFM, false, nil
case "RTTY", "FSK":
return civ.ModeRTTY, false, nil
case "PSK":
// The console button, not the ADIF mode: the rigs that HAVE a native PSK
// mode (7610/7760/7851 class) get it; a 7300 NAKs 0x12 and the button
// stays dead there, exactly as RS-BA1's does. Soundcard PSK31 stays on
// USB-D below, where every rig can do it.
return civ.ModePSK, false, nil
case "FT8", "FT4", "PSK31", "MFSK", "JS8", "JT65", "JT9", "OLIVIA", "DATA", "DIGITALVOICE":
// Digital data modes ride on USB with the data flag set (FT8 etc.).
return civ.ModeUSB, true, nil
@@ -1779,10 +1923,16 @@ func (b *IcomSerial) SetMicGain(p int) error {
}
func (b *IcomSerial) SetIcomSplit(on bool) error {
return b.SetIcomSplitOffset(on, 0)
}
// SetIcomSplitOffset enables split with a CHOSEN TX offset in Hz (0 = the
// usual convention: +1 kHz on CW, +5 kHz otherwise). The console offers
// +1/+5/+10 directly, DXpedition style.
func (b *IcomSerial) SetIcomSplitOffset(on bool, offsetHz int64) error {
if on {
// Enable split with the usual "work him up" TX offset: +1 kHz on CW,
// +5 kHz otherwise (SSB). Set the unselected (TX) VFO to RX+offset first,
// then turn split on. 0x25 0x01 + BCD sets the unselected VFO's frequency.
// Set the unselected (TX) VFO to RX+offset first, then turn split on.
// 0x25 0x01 + BCD sets the unselected VFO's frequency.
rx := b.curFreq
if rx <= 0 {
if hz, err := b.readFreq(); err == nil {
@@ -1790,12 +1940,39 @@ func (b *IcomSerial) SetIcomSplit(on bool) error {
}
}
if rx > 0 {
offset := int64(5000)
offset := offsetHz
if offset <= 0 {
offset = 5000
if b.curModeByte == civ.ModeCW || b.curModeByte == civ.ModeCWR {
offset = 1000
}
}
_ = b.exec(append([]byte{civ.CmdVfoFreq, civ.SubVfoUnselected}, civ.FreqToBCD(rx+offset)...)...)
}
// And the MODE crosses with it: a split where the TX VFO is still on
// yesterday's mode transmits FM into a CW pileup. 0x26 0x01 sets the
// unselected VFO's mode + data flag in one frame; rigs that predate
// 0x26 NAK it harmlessly and behave as they always did.
if b.curModeByte == 0 {
// A fresh session may not have read the mode yet — ask, or the copy
// below would send mode 0 (LSB) whatever the main is on.
if m, ok := b.readMode(); ok {
b.curModeByte = m
}
}
if b.curModeByte != 0 {
dataByte := byte(0)
if got := b.readDataMode(); got {
dataByte = 1
}
// Logged, not discarded: the first version swallowed the error and a
// real 7760's sub VFO kept yesterday's mode with nothing to show why.
if err := b.exec(civ.CmdModeDataFil, 0x01, b.curModeByte, dataByte, 0x01); err != nil {
applog.Printf("icom: split mode-align (26 01 %02X %d) refused: %v", b.curModeByte, dataByte, err)
} else {
applog.Printf("icom: split mode-align OK (26 01 %02X %d)", b.curModeByte, dataByte)
}
}
}
if err := b.exec(civ.CmdSplit, boolByte(on)); err != nil {
return err
@@ -1929,6 +2106,17 @@ func (b *IcomSerial) TuneATU() error {
return b.exec(civ.CmdATU, civ.SubATU, 0x02)
}
// SetATU engages or DISENGAGES the internal tuner (0x1C 0x01: 1 = in line,
// 0 = through). Tune existed without this, which meant a tuner once engaged
// could not be taken out of line again from the console.
func (b *IcomSerial) SetATU(on bool) error {
if err := b.exec(civ.CmdATU, civ.SubATU, boolByte(on)); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.ATUOn = on })
return nil
}
func (b *IcomSerial) setCache(fn func(*IcomTXState)) {
b.dspMu.Lock()
fn(&b.dsp)
@@ -1971,3 +2159,98 @@ func agcValue(name string) byte {
}
return 0
}
// PlayTXAudio hands a voice-keyer message to the network audio session, when
// this rig is reached over one. A USB-connected Icom takes its audio through
// its own sound card instead, and says so.
func (b *IcomSerial) PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
// port is written once per Connect, on the CAT goroutine this is fetched
// from (IcomDo); at worst a reconnect leaves this one connection stale, and
// a stale transport fails fast on its closed done channel.
port := b.port
// A nil port fails the assertions below too — and used to fail them with
// the sound-card message, sending an operator whose radio was simply OFF
// hunting through their audio devices.
if port == nil {
return fmt.Errorf("not connected to the radio — check the CAT link")
}
type txPlayer interface {
PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error
}
if p, ok := port.(txPlayer); ok {
return p.PlayTXAudio(pcm, rate, ch, bits, stop)
}
return fmt.Errorf("this rig takes transmit audio through its USB sound card, not the CAT link")
}
// TXAudioSender hands back the network transport's live-mic sender, when this
// rig is reached over one.
func (b *IcomSerial) TXAudioSender() (func([]byte) error, error) {
port := b.port
if port == nil {
return nil, fmt.Errorf("not connected to the radio — check the CAT link")
}
type sender interface {
TXAudioSender() (func([]byte) error, error)
}
if p, ok := port.(sender); ok {
return p.TXAudioSender()
}
return nil, fmt.Errorf("this rig takes transmit audio through its USB sound card, not the CAT link")
}
// ── Band stacking registers ───────────────────────────────────────────────
// RecallBandStack puts the VFO where the operator last was on a band, by asking
// the radio rather than by holding an opinion about it.
//
// The console's band buttons used to send a frequency chosen in software — a
// reasonable middle-of-the-band number, and never where anybody actually
// operates. The radio already knows better: every band key press it has ever
// had is remembered in that band's stacking registers, so register 1 is the
// last place used on that band, and cycling through 2 and 3 walks back through
// the ones before it — CW where CW was worked, and the FT8 frequency where FT8
// was worked, without either being written down anywhere.
//
// Reads the register, then sets frequency and mode from it. Returns the
// frequency it landed on, so the caller can say where it went; a register the
// rig will not read leaves the radio untouched and returns an error, which is
// what makes the caller's fallback to a plain frequency safe.
func (b *IcomSerial) RecallBandStack(band, reg int) (int64, error) {
if b.port == nil {
return 0, fmt.Errorf("not connected")
}
if band <= 0 || reg < 1 || reg > 3 {
return 0, fmt.Errorf("icom: band stack %d/%d is not a register", band, reg)
}
bb, rb := civ.ByteToBCD(band), civ.ByteToBCD(reg)
if err := b.write(civ.CmdExtra, civ.SubBandStack, bb, rb); err != nil {
return 0, err
}
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdExtra && len(d.Data) >= 2 && d.Data[0] == civ.SubBandStack
})
if err != nil {
return 0, err
}
bs, ok := civ.DecodeBandStack(f.Data, bb, rb)
if !ok {
// Logged with the raw frame: the register layout has a tail that differs
// between models, and a rig that answers something we cannot read is the
// one thing worth seeing here.
applog.Printf("icom: band stack %d/%d — cannot read the register from % X", band, reg, f.Data)
return 0, fmt.Errorf("icom: band stacking register %d/%d not understood", band, reg)
}
if err := b.SetFrequency(bs.FreqHz); err != nil {
return 0, err
}
// The mode is best-effort. Landing on the right frequency in the wrong mode
// is a nuisance; refusing the whole recall over it would send the operator
// back to a button that does less.
if bs.Mode != 0 {
if err := b.setModeBytes(civ.ModeToADIF(bs.Mode, bs.Data), bs.Mode, bs.Data); err != nil {
applog.Printf("icom: band stack %d/%d — frequency set, mode 0x%02X refused: %v", band, reg, bs.Mode, err)
}
}
return bs.FreqHz, nil
}
+22
View File
@@ -632,6 +632,28 @@ func (k *Kenwood) SetPTT(on bool) error {
return k.write("RX;")
}
// SetPTTData keys the transmitter on the DATA input: TX1 on a TS-590, which is
// ACC2/USB rather than the front microphone. The radio's own manual is explicit
// that the parameter chooses the input — "0: SEND (normal transmission using
// the MIC input), 1: DATA SEND (ACC2/USB input)" — so a voice keyer playing
// into the rig's USB codec has to say TX1 or it transmits dead air while the
// radio listens to a microphone nobody is speaking into.
//
// Unkeying is the same RX either way; there is no data-flavoured stop.
func (k *Kenwood) SetPTTData(on bool) error {
k.mu.Lock()
defer k.mu.Unlock()
if k.port == nil {
return fmt.Errorf("kenwood: not connected")
}
k.tx = on
if on {
k.txAt = time.Now()
return k.write("TX1;")
}
return k.write("RX;")
}
func (k *Kenwood) write(cmd string) error {
if k.port == nil {
return fmt.Errorf("kenwood: not connected")
+95 -1
View File
@@ -35,6 +35,11 @@ type KenwoodTXState struct {
Model string `json:"model,omitempty"`
Elecraft bool `json:"elecraft"` // a K3/K4 rather than a Kenwood
Mode string `json:"mode,omitempty"`
// DataSub is the K3/K4 DATA submode while in DATA mode (DT): "DATA A",
// "AFSK A", "FSK D" or "PSK D". Empty on a Kenwood, and outside DATA. The
// panel's two DATA buttons need it to show WHICH data mode the rig is in —
// FT8 wants DATA A, RTTY wants FSK D, and MD6 alone cannot say which.
DataSub string `json:"data_sub,omitempty"`
Transmitting bool `json:"transmitting"`
Split bool `json:"split"`
@@ -48,6 +53,12 @@ type KenwoodTXState struct {
// PowerMeter is 0-100 while transmitting. SWR is the ratio; 0 means "not
// measured", NOT a perfect match.
PowerMeter int `json:"power_meter"`
// PowerW is the transmit power in WATTS, derived from the bargraph and the
// meter's RANGE. The K3's bar is relative to a range that flips at 12 W —
// calibrated against a real one: 10 W showed 83 (10/12), 100 W showed 83
// too (100/120). The bar alone never was watts; with the PC setting to
// pick the range, it converts. 0 while receiving.
PowerW int `json:"power_w"`
SWR float64 `json:"swr"`
SWRRaw int `json:"swr_raw"`
@@ -107,10 +118,12 @@ type KenwoodPanelController interface {
SetKenwoodRIT(bool) error
SetKenwoodXIT(bool) error
NudgeKenwoodRIT(int) error
SetKenwoodRITOffset(int) error
ClearKenwoodRIT() error
SetKenwoodTX(bool) error
TuneKenwoodATU() error
ToggleKenwoodATU() error
SetKenwoodPanelMode(string) error
}
// kenwoodPanelSlowBeat is how many polls pass between full re-reads of the
@@ -155,6 +168,7 @@ func (k *Kenwood) readPanel(mode string, split bool, txHz int64, txNow bool) {
// Cleared, not frozen: a power bar left standing after the carrier drops
// reads as a live transmission.
k.panel.PowerMeter = 0
k.panel.PowerW = 0
k.panel.SWR, k.panel.SWRRaw = 0, 0
k.powerPeak, k.swrPeak = meterPeak{}, meterPeak{}
// The S-meter only means anything while receiving.
@@ -212,6 +226,14 @@ func (k *Kenwood) readPanelSettings() {
if v, ok := k.askNum("NR;", "NR", 1); ok {
k.panel.NR = v != 0
}
// The DATA submode, Elecraft only (a plain Kenwood has no DT and would "?;"
// it). Read every settings beat: the operator changes it from the rig's own
// front panel mid-session, and the two DATA buttons must follow.
if k.elecraft {
if v, ok := k.askNum("DT;", "DT", 1); ok {
k.panel.DataSub = kenwoodDataSubName(v)
}
}
if v, ok := k.askNum("GT;", "GT", 3); ok {
k.panel.AGC = kenwoodAGCName(v)
}
@@ -326,6 +348,13 @@ func (k *Kenwood) readTXMeters() {
defer func() { k.noLatch = false }()
if v, ok := k.askNum("BG;", "BG", 2); ok {
k.panel.PowerMeter = k.powerPeak.update(kenwoodBargraphPercent(v), now)
if k.elecraft {
scale := 120
if k.panel.RFPower > 0 && k.panel.RFPower <= 12 {
scale = 12 // the K3's QRP range
}
k.panel.PowerW = k.panel.PowerMeter * scale / 100
}
}
// SW; — SETTLED, from Elecraft's own release note: three digits, tenths of a
// ratio. "SW023;" is 2.3:1, and "SW999;" is the 99.9:1 it reports instead of
@@ -334,7 +363,7 @@ func (k *Kenwood) readTXMeters() {
if v, ok := k.askNum("SW;", "SW", 3); ok {
k.panel.SWRRaw = v
if v > 0 {
k.panel.SWR = float64(k.swrPeak.update(v, now)) / 10
k.panel.SWR = float64(k.swrPeak.updateSnap(v, now)) / 10
}
}
if k.metersLogged >= 20 {
@@ -564,6 +593,16 @@ func (k *Kenwood) SetKenwoodXIT(on bool) error {
// ClearKenwoodRIT zeroes the RIT/XIT offset, both at once — which is what RC
// does and what the operator means by "clear it".
// SetKenwoodRITOffset writes the offset as an ABSOLUTE value — what the shared
// ShiftRow control speaks. Nudge stays for the keyboard clarifier; both funnel
// into the same RO write.
func (k *Kenwood) SetKenwoodRITOffset(hz int) error {
k.mu.Lock()
cur := k.panel.RITOffset
k.mu.Unlock()
return k.NudgeKenwoodRIT(hz - cur)
}
func (k *Kenwood) ClearKenwoodRIT() error {
return k.setPanel("RC;")
}
@@ -650,3 +689,58 @@ func (k *Kenwood) askNum(cmd, prefix string, digits int) (int, bool) {
}
return n, true
}
// kenwoodDataSubName decodes DT (K3/K4 programmer's reference).
func kenwoodDataSubName(v int) string {
switch v {
case 0:
return "DATA A"
case 1:
return "AFSK A"
case 2:
return "FSK D"
case 3:
return "PSK D"
}
return ""
}
// SetKenwoodPanelMode is the panel's mode row: CW / USB / LSB / DATA / RTTY.
//
// It exists because SetMode is the LOGGER's path — it maps an ADIF mode and
// honours the data-mode preference — while a panel button is the operator
// saying exactly what the rig should do. The two DATA cases are the point:
// "DATA" is MD6 + DT0 (DATA A, the soundcard submode FT8/FT4 modulate through)
// and "RTTY" is MD6 + DT2 (FSK D, the K3's direct-keyed RTTY). MD6 alone keeps
// whatever submode a prior session left, which is how a K3 "in DATA" transmits
// FT8 with no audio — the report behind this row.
func (k *Kenwood) SetKenwoodPanelMode(mode string) error {
k.mu.Lock()
defer k.mu.Unlock()
if k.port == nil {
return fmt.Errorf("kenwood: not connected")
}
var cmds []string
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "CW":
cmds = []string{"MD3;"}
case "USB":
cmds = []string{"MD2;"}
case "LSB":
cmds = []string{"MD1;"}
case "DATA":
cmds = []string{"MD6;", "DT0;"}
case "RTTY":
cmds = []string{"MD6;", "DT2;"}
default:
return fmt.Errorf("kenwood panel: unknown mode %q", mode)
}
for _, c := range cmds {
if err := k.write(c); err != nil {
return err
}
}
// Re-read the settings on the next poll so Mode and DataSub confirm at once.
k.panelCycle = kenwoodPanelSlowBeat
return nil
}
+7
View File
@@ -553,6 +553,13 @@ func (o *OmniRig) SetFrequency(hz int64) error {
props := []string{prop, "Freq"}
if onSubVFO {
props = []string{prop}
} else if isYaesu && split&pmSplitOn != 0 && split&pmSplitOff == 0 {
// Yaesu with SPLIT engaged: the generic Freq write lands on the TX
// VFO — an FT-2000 moved B and left A (the receiver) behind, so a
// spot click QSYed the transmitter and nothing audible changed.
// Both VFOs are written: the radio arrives on the spot RX and TX
// together, split left as the operator had it.
props = []string{"FreqA", "FreqB", "Freq"}
}
for _, p := range props {
if _, e := oleutil.PutProperty(o.rig, p, hz32); e != nil {
+111 -6
View File
@@ -27,6 +27,14 @@ type TCI struct {
digitalDefault string // surfaced when the rig reports a digital mode (FT8/…)
spotsEnabled bool // mirror cluster spots onto the TCI panorama
// wantFreq is the frequency last COMMANDED and not yet echoed back, used to
// pick the sideband before the radio has confirmed the move.
wantFreq int64
// What the server said it is, from its "protocol:" announcement.
serverName string
serverVersion string
// How many spots have been logged verbatim (the first few only).
spotsSent int
// OnSpotClick is called when the user clicks one of our spots on the TCI
// panorama (callsign + freq), so the host can fill the entry form. Set before
@@ -149,6 +157,15 @@ func (t *TCI) Connect() error {
t.mu.Unlock()
debugLog.Printf("TCI: connected to %s", url)
go t.reader(conn)
// Ask for the meters. Nothing measures anything until this goes out: the
// S-meter, the transmit power and the SWR are all pushed by the radio, and
// only to a client that has subscribed. 200 ms is the rate the protocol's own
// examples use — fast enough for a needle, slow enough not to flood a socket
// that also carries audio.
if t.spotsEnabled {
debugLog.Printf("TCI: panorama spots are ON — spots will be sent to the radio")
}
t.subscribeSensors("connect")
if t.spotsEnabled {
// Forget what we thought was on the panorama at the same moment the radio
// is told to drop it. Kept, the memory would suppress the next spot for
@@ -228,11 +245,19 @@ func (t *TCI) SendSpot(s SpotInfo) error {
// other two matching what already works here.
_ = t.send(fmt.Sprintf("spot_delete:%s;", call))
}
// TCI's SPOT command wants the colour as a signed 32-bit DECIMAL integer in
// 0xAARRGGBB order — NOT a "0x…" hex string (e.g. "spot:UN7GK,cw,14025000,
// -16776961,test;"). ExpertSDR silently drops a spot whose colour field it
// can't parse as a number, which is why spots never showed on the panorama
// while tuning (a separate command) still worked.
// The colour is a DECIMAL ARGB integer, and an UNSIGNED one.
//
// Expert Electronics' own protocol document gives the whole command:
//
// SPOT:RN6LHF,CW,7100000,16711680,ANY_TEXT;
//
// 16711680 is 0x00FF0000 — positive, alpha zero. This backend was sending
// the same number as a SIGNED 32-bit value, taken from a third-party
// example: with the alpha byte set to FF for opacity, 0xFFFFA500 becomes
// -22336, and a spot whose colour field ExpertSDR cannot read is dropped in
// silence. Reported on ExpertSDR3 1.3 (which speaks TCI 2.x, so the version
// was never the problem): everything else worked and the panorama stayed
// empty.
hex := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(s.Color), "#"), "0x")
if hex == "" {
hex = "FFFFA500" // opaque orange default
@@ -253,7 +278,15 @@ func (t *TCI) SendSpot(s SpotInfo) error {
}
// Commas/semicolons would break TCI's comma-separated argument parsing.
text := strings.NewReplacer(",", " ", ";", " ").Replace(s.Comment)
return t.send(fmt.Sprintf("spot:%s,%s,%d,%d,%s;", call, mode, s.FreqHz, int32(argb), text))
cmd := fmt.Sprintf("spot:%s,%s,%d,%d,%s;", call, mode, s.FreqHz, argb, text)
// The first few, verbatim. A spot that the radio ignores leaves no trace at
// all — no reply, no error — so the only evidence that OpsLog sent one, and
// in what shape, is this line.
if n := t.spotsSent; n < 3 {
t.spotsSent = n + 1
debugLog.Printf("TCI: sending spot #%d: %s", n+1, strings.TrimSuffix(cmd, ";"))
}
return t.send(cmd)
}
// Disconnect closes the WebSocket; the reader goroutine then exits.
@@ -334,6 +367,11 @@ func (t *TCI) ReadState() (RigState, error) {
// SetFrequency tunes VFO A (the main/RX VFO).
func (t *TCI) SetFrequency(hz int64) error {
// Remember what we ASKED for. SetMode reads it to choose the sideband, and
// the radio's own echo can be a moment behind — see SetMode.
t.mu.Lock()
t.wantFreq = hz
t.mu.Unlock()
return t.send(fmt.Sprintf("vfo:0,0,%d;", hz))
}
@@ -342,6 +380,17 @@ func (t *TCI) SetFrequency(hz int64) error {
func (t *TCI) SetMode(mode string) error {
t.mu.Lock()
freq := t.freqA
// Prefer the frequency we just COMMANDED over the one the radio has echoed.
//
// Clicking a spot sets the frequency and then the mode, and the sideband is
// chosen from the frequency (below 10 MHz → LSB). Read from the echo, that
// is the frequency we were on BEFORE the click whenever the echo has not
// landed yet: a 14 MHz spot clicked from 7 MHz got LSB, and clicking the same
// spot again — now that the echo has arrived — got USB. Reported from a
// SunSDR as "the frequency is right, the mode is wrong until I click twice".
if t.wantFreq > 0 {
freq = t.wantFreq
}
t.mu.Unlock()
m := adifToTCIMode(mode, freq)
if m == "" {
@@ -411,6 +460,24 @@ func (t *TCI) SetTXAudioSource(src string) {
}
// send writes a command to the WebSocket (one writer at a time).
// subscribeSensors asks the radio to push its meters. Nothing measures anything
// until this goes out — the S-meter, the transmit power and the SWR are all
// subscription-only (TCI §4.4) — and it is sent at connect AND again at every
// "ready", because a subscription sent during the server's initial dump can be
// dropped. 200 ms is the rate the protocol's own examples use.
func (t *TCI) subscribeSensors(when string) {
// Both cases, deliberately. Every other command this backend sends works in
// lower case, but the meters stayed silent on a real SunSDR through two
// rounds of fixes — and the protocol document's own examples are upper case
// (TX_SENSORS_ENABLE:true,200;). A server that is case-insensitive ignores
// the duplicate; one that is not finally hears the subscription.
e1 := t.send("rx_sensors_enable:true,200;")
e2 := t.send("tx_sensors_enable:true,200;")
_ = t.send("RX_SENSORS_ENABLE:true,200;")
_ = t.send("TX_SENSORS_ENABLE:true,200;")
debugLog.Printf("TCI: sensor subscription sent (%s, both cases): rx=%v tx=%v", when, e1, e2)
}
func (t *TCI) send(cmd string) error {
t.mu.Lock()
c := t.conn
@@ -493,6 +560,18 @@ func (t *TCI) handle(msg string) {
switch lower {
case "device":
t.device = strings.TrimSpace(args)
// The server's own announcement: "protocol:ExpertSDR3,1.9;" — its name and
// the TCI version it speaks. Worth keeping rather than filing under
// "unhandled": panorama spots need a version that HAS the spot command, and
// without this an operator on an older ExpertSDR sees nothing on the
// waterfall and nothing anywhere saying why.
case "protocol":
t.serverName, t.serverVersion = get(0), get(1)
debugLog.Printf("TCI: server is %s, TCI %s", t.serverName, t.serverVersion)
if t.spotsEnabled && tciSpotsUnsupported(t.serverVersion) {
debugLog.Printf("TCI: this server speaks TCI %s — panorama spots need 1.5 or later, so they will not appear",
t.serverVersion)
}
// The radio ANNOUNCES its audio format at connect —
// "audio_stream_sample_type:float32" and "audio_stream_channels:2" — which
// is better evidence than anything derived from a frame, and it arrives
@@ -505,6 +584,13 @@ func (t *TCI) handle(msg string) {
}
case "ready", "start":
t.ready = true
// (Re)subscribe to the meters HERE, not only at connect. ExpertSDR3
// dumps its whole state and then says "ready"; a unidirectional control
// command sent while that dump is still in flight can be ignored, and
// the report from a real SunSDR — transmit meters still empty after the
// connect-time subscription — has exactly that shape. From a goroutine:
// send takes t.mu, which this handler holds.
go t.subscribeSensors("ready")
case "stop":
t.ready = false
case "vfo":
@@ -516,6 +602,10 @@ func (t *TCI) handle(msg string) {
switch get(1) {
case "0":
t.freqA = hz
// The radio has caught up: from here the echo IS the truth.
if t.wantFreq != 0 && absInt64(hz-t.wantFreq) < 100 {
t.wantFreq = 0
}
case "1":
t.freqB = hz
}
@@ -651,3 +741,18 @@ func adifToTCIMode(mode string, freqHz int64) string {
return "digu"
}
}
// tciSpotsUnsupported reports whether a TCI version predates the spot commands.
//
// SPOT / SPOT_DELETE / SPOT_CLEAR arrived in TCI 1.5. An older ExpertSDR accepts
// the connection, answers frequency and mode perfectly, and silently ignores
// every spot — which is indistinguishable from a bug in the logger unless
// somebody says so. Anything unparseable is treated as supported: refusing to
// draw on a doubt would be the worse mistake.
func tciSpotsUnsupported(version string) bool {
var maj, min int
if n, err := fmt.Sscanf(strings.TrimSpace(version), "%d.%d", &maj, &min); n < 2 || err != nil {
return false
}
return maj < 1 || (maj == 1 && min < 5)
}
+72
View File
@@ -0,0 +1,72 @@
package cat
import (
"fmt"
"strings"
)
// CW keying over TCI — a sixth CW engine, so a SunSDR needs no WinKeyer and no
// second serial port: the radio's own macro keyer is driven over the WebSocket
// that already carries the CAT.
//
// The commands, from the TCI command table (confirmed against ars-ka0s/eesdr-tci,
// the same source that settled SPOT):
//
// CW_MACROS:<trx>,<text>; send text through the radio's keyer
// CW_MACROS_SPEED:<wpm>; the speed those macros are keyed at
// CW_MACROS_STOP; abort what is being keyed
// CW_MACROS_EMPTY; the radio saying the buffer has run dry
//
// CW_MSG (TCI 2.0) does the same with separate before/after callsign fields.
// CW_MACROS is used instead because it exists from 1.6 and OpsLog resolves the
// variables itself — the text handed here is already what should go on the air.
//
// Notably absent: there is no backspace. The FlexRadio CWX keyer can un-type
// what has not been sent yet; TCI can only stop. So the type-ahead correction
// the Flex engine offers is not offered here rather than faked.
// tciCWTextLimit caps one macro. A runaway paste down a WebSocket that also
// carries audio is worth refusing, and no real CW message is this long.
const tciCWTextLimit = 512
// SendCW keys a message through the radio's macro keyer.
func (t *TCI) SendCW(text string) error {
msg := sanitiseTCICW(text)
if msg == "" {
return nil
}
return t.send(fmt.Sprintf("cw_macros:0,%s;", msg))
}
// StopCW aborts the message being keyed.
func (t *TCI) StopCW() error { return t.send("cw_macros_stop;") }
// SetCWSpeed sets the macro keyer speed in words per minute.
//
// Only the MACRO speed: the paddle keyer has its own (CW_KEYER_SPEED) and an
// operator who has set their paddle to 28 wpm did not ask the logger to change
// it because a macro went out at 25.
func (t *TCI) SetCWSpeed(wpm int) error {
if wpm < 5 {
wpm = 5
}
if wpm > 60 {
wpm = 60
}
return t.send(fmt.Sprintf("cw_macros_speed:%d;", wpm))
}
// sanitiseTCICW makes a message safe to put in a TCI command.
//
// Commas and semicolons are the protocol's own separators — a comma inside the
// text would be read as another argument and a semicolon would end the command
// early, keying half a message and leaving the rest to be parsed as a command of
// its own. Neither belongs in Morse anyway.
func sanitiseTCICW(text string) string {
s := strings.ToUpper(strings.TrimSpace(text))
s = strings.NewReplacer(",", " ", ";", " ", "\r", " ", "\n", " ").Replace(s)
if len(s) > tciCWTextLimit {
s = s[:tciCWTextLimit]
}
return strings.TrimSpace(s)
}
+23
View File
@@ -96,3 +96,26 @@ func (m *Manager) TCIPanelDo(fn func(TCIPanelController) error) error {
return fn(tc)
})
}
// TCICWController is the radio's CW keyer, as the CW engine uses it.
//
// Three methods, and no backspace: TCI can stop a message but cannot un-type one
// (see tci_cw.go). Kept as its own interface rather than folded into the console
// one so a CW engine does not have to depend on forty panel setters to key a
// message.
type TCICWController interface {
SendCW(text string) error
StopCW() error
SetCWSpeed(wpm int) error
}
// TCICWDo dispatches one keyer command onto the CAT goroutine.
func (m *Manager) TCICWDo(fn func(TCICWController) error) error {
return m.exec(func(b Backend) error {
tc, ok := b.(TCICWController)
if !ok {
return fmt.Errorf("the active CAT backend is not a TCI radio")
}
return fn(tc)
})
}
+38 -1
View File
@@ -113,7 +113,8 @@ func (t *TCI) handlePanel(name string, get func(int) string, args string) bool {
// it. What the radio actually announces after the command settles whether
// this is our reading or its doing, and no amount of reasoning will.
switch name {
case "mute", "sql_enable", "sql_level", "tx_power", "tx_swr", "tune":
case "mute", "sql_enable", "sql_level", "tx_power", "tx_swr", "tune",
"tx_sensors", "rx_sensors", "rx_channel_sensors":
// Logged on arrival so an ANSWER can be told from a SILENCE: the log
// showed the transmit meters being asked for and nothing coming back,
// which on its own proves nothing — a reply that arrived and failed to
@@ -233,6 +234,42 @@ func (t *TCI) handlePanel(name string, get func(int) string, args string) bool {
if n, ok := num(get(1)); ok && forRX0() {
p.SMeter = n
}
// The meters of ExpertSDR3. The S-meter used to be read only from RX_SMETER
// and the transmit ones from TX_POWER / TX_SWR — commands this radio simply
// never sends, which is why the console's meters sat empty on a SunSDR in
// both RX and TX while everything else worked.
//
// The protocol's own answer (TCI Protocol.pdf, §4.4) is a SUBSCRIPTION:
//
// RX_SENSORS:<rx>,<dBm>; (deprecated in 2.0)
// RX_CHANNEL_SENSORS:<rx>,<channel>,<dBm>; (its replacement)
// TX_SENSORS:<trx>,<mic dBm>,<power W>,<peak W>,<SWR>;
//
// none of which arrives until the client asks with RX_SENSORS_ENABLE and
// TX_SENSORS_ENABLE — see Connect.
case "rx_sensors":
if v, err := strconv.ParseFloat(strings.TrimSpace(get(1)), 64); err == nil && forRX0() {
p.SMeter = int(v)
}
case "rx_channel_sensors":
// Main channel (A) of receiver 0: the one the console is showing.
if v, err := strconv.ParseFloat(strings.TrimSpace(get(2)), 64); err == nil &&
get(0) == "0" && get(1) == "0" {
p.SMeter = int(v)
}
case "tx_sensors":
if get(0) != "0" {
break
}
// arg3 is RMS power, arg4 the peak. The peak is what a power meter's
// needle does on speech; the RMS is what the operator is asked to keep
// under the amplifier's limit — so RMS is the number, as elsewhere.
if v, err := strconv.ParseFloat(strings.TrimSpace(get(2)), 64); err == nil {
p.TXPowerW = v
}
if v, err := strconv.ParseFloat(strings.TrimSpace(get(4)), 64); err == nil {
p.TXSWR = v
}
case "tune":
if forRX0() {
p.Tuning = yes(get(1))
+41
View File
@@ -0,0 +1,41 @@
package cat
import "testing"
func TestTCISpotsUnsupported(t *testing.T) {
// SPOT arrived in TCI 1.5. The SunSDR2 PRO report that prompted this was an
// ExpertSDR announcing 1.3 — spots accepted and silently dropped.
for _, c := range []struct {
version string
old bool
}{
{"1.3", true},
{"1.4", true},
{"1.5", false},
{"1.9", false},
{"2.0", false},
{"", false}, // unparseable → assume it works
{"weird", false}, // refusing to draw on a doubt is the worse mistake
} {
if got := tciSpotsUnsupported(c.version); got != c.old {
t.Errorf("tciSpotsUnsupported(%q) = %v, want %v", c.version, got, c.old)
}
}
}
func TestSanitiseTCICW(t *testing.T) {
// The separators must never survive: a comma would become another argument
// and a semicolon would end the command with the message half sent.
for _, c := range []struct{ in, want string }{
{"cq cq de f4bpo", "CQ CQ DE F4BPO"},
{" tu 599 ", "TU 599"},
{"73, gl", "73 GL"},
{"test;cw_macros_stop", "TEST CW_MACROS_STOP"},
{"line\r\nbreak", "LINE BREAK"},
{" ", ""},
} {
if got := sanitiseTCICW(c.in); got != c.want {
t.Errorf("sanitiseTCICW(%q) = %q, want %q", c.in, got, c.want)
}
}
}
+27 -1
View File
@@ -388,7 +388,14 @@ func (y *Yaesu) RefreshYaesu() error {
}
func (y *Yaesu) SetYaesuPower(w int) error {
return y.setAndRefresh(fmt.Sprintf("PC%03d;", clampInt(w, 5, 100)))
// The SAME ceiling the console draws its slider to. It was hard-coded at 100
// here while yaesuMaxPower already answered 200 for an FTDX101MP: asking for
// 200 W sent PC100, the rig obeyed, and the slider sprang back to 100 on the
// next poll — which is how the operator discovered it.
y.mu.Lock()
max := yaesuMaxPower(y.model, y.panel.RFPower)
y.mu.Unlock()
return y.setAndRefresh(fmt.Sprintf("PC%03d;", clampInt(w, 5, max)))
}
func (y *Yaesu) SetYaesuMicGain(p int) error {
@@ -905,3 +912,22 @@ func yaesuSplitCommand(cmd, vfo string, on bool) string {
}
return cmd + "0;"
}
// updateSnap is update for a meter where lingering is misinformation: the peak
// stands for the hold, then the display returns to the live sample AT ONCE.
// Made for the K3's SWR — the radio throws a brief SWR spike as an FT8 frame
// ends, and the quarter-of-the-gap decay above turned that one bad sample into
// twelve seconds of alarming red on the next transmission. A needle's inertia
// suits a power meter; an SWR reading is a warning light, and a warning that
// fades slowly reads as a fault that is slowly getting better.
func (m *meterPeak) updateSnap(sample int, now time.Time) int {
if sample >= m.val {
m.val, m.at = sample, now
return m.val
}
if now.Sub(m.at) < meterHold {
return m.val
}
m.val, m.at = sample, now
return m.val
}
+29
View File
@@ -0,0 +1,29 @@
package cat
import "testing"
// Reported on an FTDX101MP: the power slider sprang back to 100 W. The console
// already knew the rig could do 200 — yaesuMaxPower says so — but the SET path
// clamped to 100, so the radio was politely given half what was asked for.
func TestYaesuPowerCeilingFollowsTheModel(t *testing.T) {
cases := []struct {
model string
want int
}{
{"FTDX101MP", 200},
{"FT-DX5000", 200},
{"FTDX9000", 200},
{"FTDX101D", 100},
{"FTDX10", 100},
{"Yaesu (0999)", 100}, // unknown: ask for too little, never too much
}
for _, c := range cases {
if got := yaesuMaxPower(c.model, 0); got != c.want {
t.Errorf("%s ceiling = %d W, want %d", c.model, got, c.want)
}
}
// A rig REPORTING more than the table expects has just proved what it can do.
if got := yaesuMaxPower("FTDX10", 150); got != 150 {
t.Errorf("a rig reporting 150 W was capped at %d", got)
}
}
+4
View File
@@ -75,6 +75,10 @@ type Spot struct {
Historical bool `json:"historical,omitempty"`
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
POTAName string `json:"pota_name,omitempty"` // park name
// SOTARef comes from the COMMENT, not from an API: the SOTA clusters put the
// summit in the text of the spot they send ("W9/WI-001"), and there is no
// per-callsign endpoint to ask the way POTA has one.
SOTARef string `json:"sota_ref,omitempty"`
}
// State enumerates the per-server lifecycle.
+21
View File
@@ -0,0 +1,21 @@
package cluster
import "regexp"
// sotaRefRe matches a SOTA summit reference inside a spot comment.
//
// The shape is association/region-NNN — "W9/WI-001", "DM/BM-063", "VK3/VC-014",
// "F/AM-123" — and the association may carry digits. Anchored on both sides so
// a callsign like DL/SP9DPM/P can never be read as one, and deliberately
// narrower than "anything with a slash and a dash": POTA (US-4475) and WWFF
// (DLFF-0001) refs share the comment field and must not be caught here.
var sotaRefRe = regexp.MustCompile(`\b([A-Z0-9]{1,4}(?:/[A-Z0-9]{1,4})?/[A-Z]{2}-[0-9]{3})\b`)
// SOTARefFrom returns the first SOTA reference in a spot comment, or "".
func SOTARefFrom(comment string) string {
m := sotaRefRe.FindStringSubmatch(comment)
if m == nil {
return ""
}
return m[1]
}

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