Compare commits

...
50 Commits
Author SHA1 Message Date
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
85 changed files with 8827 additions and 853 deletions
View File
+506 -19
View File
@@ -26,6 +26,7 @@ import (
"hamlog/internal/antgenius" "hamlog/internal/antgenius"
"hamlog/internal/applog" "hamlog/internal/applog"
"hamlog/internal/audio" "hamlog/internal/audio"
"hamlog/internal/autocall"
"hamlog/internal/award" "hamlog/internal/award"
"hamlog/internal/awardref" "hamlog/internal/awardref"
"hamlog/internal/backup" "hamlog/internal/backup"
@@ -38,6 +39,7 @@ import (
"hamlog/internal/cwdecode" "hamlog/internal/cwdecode"
"hamlog/internal/db" "hamlog/internal/db"
"hamlog/internal/dxcc" "hamlog/internal/dxcc"
"hamlog/internal/dxped"
"hamlog/internal/email" "hamlog/internal/email"
"hamlog/internal/extsvc" "hamlog/internal/extsvc"
"hamlog/internal/geo" "hamlog/internal/geo"
@@ -53,6 +55,7 @@ import (
"hamlog/internal/powergenius" "hamlog/internal/powergenius"
"hamlog/internal/profile" "hamlog/internal/profile"
"hamlog/internal/pskr" "hamlog/internal/pskr"
"hamlog/internal/pskrtgt"
"hamlog/internal/psu" "hamlog/internal/psu"
"hamlog/internal/qslcard" "hamlog/internal/qslcard"
"hamlog/internal/qso" "hamlog/internal/qso"
@@ -99,6 +102,7 @@ const (
keyStationCallsign = "station.callsign" keyStationCallsign = "station.callsign"
keyStationOperator = "station.operator" keyStationOperator = "station.operator"
keyWatchlistContestPattern = "watchlist.contest_pattern" // auto-add contest calls containing this keyWatchlistContestPattern = "watchlist.contest_pattern" // auto-add contest calls containing this
keyWatchlistContestCalls = "watchlist.contest_calls" // …and these, named one by one
keyStationMyGrid = "station.my_grid" keyStationMyGrid = "station.my_grid"
keyStationCountry = "station.my_country" keyStationCountry = "station.my_country"
keyStationSOTA = "station.my_sota_ref" keyStationSOTA = "station.my_sota_ref"
@@ -116,6 +120,9 @@ const (
keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2 keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2
// Which VFO to believe when OmniRig names one. "" = trust the rig file. // Which VFO to believe when OmniRig names one. "" = trust the rig file.
keyCATOmniRigVFO = "cat.omnirig.vfo" // "" | "A" | "B" keyCATOmniRigVFO = "cat.omnirig.vfo" // "" | "A" | "B"
// Put the RADIO in USB for a digital mode instead of asking for the mode by
// name. Every backend, because the reason is the radio, not the link.
keyCATDigiUSB = "cat.digi_usb"
keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend) keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend)
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992) keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter
@@ -191,6 +198,7 @@ const (
keyAudioQSOPlayGain = "audio.qso_play_gain" // QSO-recording playback level % keyAudioQSOPlayGain = "audio.qso_play_gain" // QSO-recording playback level %
keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr" keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr"
keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT
keyAudioPTTData = "audio.ptt_data" // keyer audio arrives on the rig DATA/USB input
keyAudioFormat = "audio.qso_format" // "wav" | "mp3" keyAudioFormat = "audio.qso_format" // "wav" | "mp3"
keyAudioFromGain = "audio.from_gain" // From Radio (RX) mix level, percent keyAudioFromGain = "audio.from_gain" // From Radio (RX) mix level, percent
keyAudioMicGain = "audio.mic_gain" // mic mix level, percent keyAudioMicGain = "audio.mic_gain" // mic mix level, percent
@@ -361,6 +369,7 @@ const (
keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status" keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status"
keyQSLDefaultQRZComStatus = "qsl.qrzcom_status" keyQSLDefaultQRZComStatus = "qsl.qrzcom_status"
keyQSLDefaultQRZComCfm = "qsl.qrzcom_confirmed" keyQSLDefaultQRZComCfm = "qsl.qrzcom_confirmed"
keyQSLDefaultHamqthStatus = "qsl.hamqth_status"
keyQSLDefaultHamlogStatus = "qsl.hamlog_status" keyQSLDefaultHamlogStatus = "qsl.hamlog_status"
keyQSLDefaultHamlogCfm = "qsl.hamlog_confirmed" keyQSLDefaultHamlogCfm = "qsl.hamlog_confirmed"
@@ -401,6 +410,12 @@ const (
keyExtCloudlogAutoUpload = "extsvc.cloudlog.auto_upload" keyExtCloudlogAutoUpload = "extsvc.cloudlog.auto_upload"
keyExtCloudlogUploadMode = "extsvc.cloudlog.upload_mode" keyExtCloudlogUploadMode = "extsvc.cloudlog.upload_mode"
keyExtHamqthUsername = "extsvc.hamqth.username"
keyExtHamqthPassword = "extsvc.hamqth.password"
keyExtHamqthCallsign = "extsvc.hamqth.callsign"
keyExtHamqthAutoUpload = "extsvc.hamqth.auto_upload"
keyExtHamqthUploadMode = "extsvc.hamqth.upload_mode"
keyExtHamlogAPIKey = "extsvc.hamlog.api_key" keyExtHamlogAPIKey = "extsvc.hamlog.api_key"
keyExtHamlogAutoUpload = "extsvc.hamlog.auto_upload" keyExtHamlogAutoUpload = "extsvc.hamlog.auto_upload"
keyExtHamlogUploadMode = "extsvc.hamlog.upload_mode" keyExtHamlogUploadMode = "extsvc.hamlog.upload_mode"
@@ -450,6 +465,9 @@ type QSLDefaults struct {
// at "N" here exactly as they do for Club Log. // at "N" here exactly as they do for Club Log.
HamlogStatus string `json:"hamlog_status"` HamlogStatus string `json:"hamlog_status"`
HamlogCfm string `json:"hamlog_confirmed"` HamlogCfm string `json:"hamlog_confirmed"`
// HamQTH, same extras story — and SENT only: the site publishes no
// confirmation feed, so there is no received side to default.
HamqthStatus string `json:"hamqth_status"`
} }
// CATSettings is the user-tweakable rig-control configuration. Stored as // CATSettings is the user-tweakable rig-control configuration. Stored as
@@ -463,6 +481,9 @@ type CATSettings struct {
// the .ini: an IC-7610 file was seen declaring VFO B permanently while the // the .ini: an IC-7610 file was seen declaring VFO B permanently while the
// operator worked on the main VFO, so OpsLog wrote to A and read B. // operator worked on the main VFO, so OpsLog wrote to A and read B.
OmniRigVFO string `json:"omnirig_vfo"` // "" | "A" | "B" OmniRigVFO string `json:"omnirig_vfo"` // "" | "A" | "B"
// DigiAsUSB puts the radio in USB when a digital mode is selected, rather
// than naming the mode. What the QSO is LOGGED as never changes.
DigiAsUSB bool `json:"digi_as_usb"`
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend) FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992) FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
@@ -736,6 +757,31 @@ type App struct {
// It is the source that makes VHF detection work at all: the cluster and RBN // It is the source that makes VHF detection work at all: the cluster and RBN
// carry a handful of 6 m spots where PSK Reporter carries hundreds. // carry a handful of 6 m spots where PSK Reporter carries hundreds.
pskr *pskr.Watcher pskr *pskr.Watcher
// pskTgt is the SECOND PSK Reporter connection: the per-station analysis behind
// the FT decodes panel. Separate from a.pskr because the two want opposite
// slices of the same feed — everything heard near here, against everything
// about one callsign anywhere. Built on first use, nil while the panel is
// closed.
pskTgtMu sync.Mutex
pskTgt *pskrtgt.Watcher
// Auto-call: the engine, the period being collected, and the last transmit
// state the decoder reported. One mutex — every field here is touched by the
// UDP event loop and by the two-second sweeper, and never for long.
acMu sync.Mutex
ac *autocall.Engine
// Keyed by RECEIVER. Two decoders have two independent slot clocks — an
// FT8 window and an FT4 one do not even share a period length — and one
// buffer for both cut every slot into fragments, each judged as a period of
// its own. The engine then counted a missed period several times a slot.
acPeriod map[string]string
acAt map[string]time.Time
acTR map[string]int
acBuf map[string][]acDecode
acTX autocall.TXState
acReason string
acLastJudge time.Time
// acTXPeriod is the last transmit period already counted — see autoCallNoteTX.
acTXPeriod string
// Self-spot throttle: when and on what frequency we last announced ourselves. // Self-spot throttle: when and on what frequency we last announced ourselves.
// Held in memory only — a restart legitimately re-announces the station. // Held in memory only — a restart legitimately re-announces the station.
selfSpotMu sync.Mutex selfSpotMu sync.Mutex
@@ -751,9 +797,17 @@ type App struct {
// WSJT-X decode highlighting (message 13) — see app_wsjt_highlight.go. // WSJT-X decode highlighting (message 13) — see app_wsjt_highlight.go.
wsjtHighlightOn atomic.Bool wsjtHighlightOn atomic.Bool
// Greying out what is already worked is a sub-option of the same feature —
// read on the decode path, so an atomic rather than a settings lookup per
// decode.
wsjtHLWorkedOn atomic.Bool
wsjtHLMu sync.Mutex wsjtHLMu sync.Mutex
wsjtHLSent map[string]string wsjtHLSent map[string]string
watchPattern atomic.Value // auto-contest pattern (string), loaded at startup watchPattern atomic.Value // auto-contest pattern (string), loaded at startup
// The named contest callsigns, as a set, loaded with the pattern and read on
// the same hot path — one spot per station on the band, so no settings
// lookup and no split per spot.
watchCalls atomic.Value // map[string]struct{}
operating *operating.Repo operating *operating.Repo
udp *udp.Manager udp *udp.Manager
udpRepo *udp.Repo udpRepo *udp.Repo
@@ -795,6 +849,9 @@ type App struct {
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign) lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign)
scp *scp.Manager // Super Check Partial / N+1 callsign master list scp *scp.Manager // Super Check Partial / N+1 callsign master list
// dxped reads the ADXO announcements and the DX-World feed for the
// DXpeditions tab. Built lazily: nothing fetches until the tab is opened.
dxped *dxped.Manager
// NET Control: persistent net definitions/rosters (global JSON) + the live // NET Control: persistent net definitions/rosters (global JSON) + the live
// session (in-memory only — active stations currently in QSO). // session (in-memory only — active stations currently in QSO).
@@ -909,6 +966,7 @@ type App struct {
opLon float64 opLon float64
opSet bool opSet bool
opCall string // active profile callsign, cached for outbound UDP emitters opCall string // active profile callsign, cached for outbound UDP emitters
opGrid string // active profile locator, cached the same way and for the same reason
// Dedup for the frequency/mode outbound UDP emitters (PstRotator, N1MM): only // Dedup for the frequency/mode outbound UDP emitters (PstRotator, N1MM): only
// send when the frequency or mode actually changes. // send when the frequency or mode actually changes.
@@ -958,6 +1016,7 @@ func (a *App) refreshOperatorGrid() {
return return
} }
a.opCall = strings.ToUpper(strings.TrimSpace(p.Callsign)) a.opCall = strings.ToUpper(strings.TrimSpace(p.Callsign))
a.opGrid = strings.ToUpper(strings.TrimSpace(p.MyGrid))
lat, lon, ok := gridToLatLon(p.MyGrid) lat, lon, ok := gridToLatLon(p.MyGrid)
if !ok { if !ok {
return return
@@ -1466,7 +1525,9 @@ func (a *App) startup(ctx context.Context) {
a.pota = pota.New(func(format string, args ...any) { applog.Printf(format, args...) }) a.pota = pota.New(func(format string, args ...any) { applog.Printf(format, args...) })
a.startWatchlistClubLog() a.startWatchlistClubLog()
a.watchPattern.Store(strings.ToUpper(strings.TrimSpace(a.settingOr(keyWatchlistContestPattern, "")))) a.watchPattern.Store(strings.ToUpper(strings.TrimSpace(a.settingOr(keyWatchlistContestPattern, ""))))
a.watchCalls.Store(parseContestCalls(a.settingOr(keyWatchlistContestCalls, "")))
a.wsjtHighlightOn.Store(a.settingOr(keyWsjtHighlight, "0") == "1") a.wsjtHighlightOn.Store(a.settingOr(keyWsjtHighlight, "0") == "1")
a.wsjtHLWorkedOn.Store(a.settingOr(keyWsjtHLWorked, "0") == "1")
go a.pota.Run(a.ctx) go a.pota.Run(a.ctx)
// DX Cluster (multi-server): the spot callback enriches each spot // DX Cluster (multi-server): the spot callback enriches each spot
@@ -1595,6 +1656,9 @@ func (a *App) startup(ctx context.Context) {
// PSK Reporter. After the operator's grid is known: without it there is no // PSK Reporter. After the operator's grid is known: without it there is no
// distance to measure and no receiver squares to filter on, so it stays down. // distance to measure and no receiver squares to filter on, so it stays down.
a.startBandOpenFeed() a.startBandOpenFeed()
// Auto-call. After the watch list and the logbook: the ladder is meaningless
// without either, and it must never call anybody on a log it cannot read.
a.startAutoCall()
// One-time tidy-up of a field nothing used to record. Background, once. // One-time tidy-up of a field nothing used to record. Background, once.
a.backfillDistancesOnce() a.backfillDistancesOnce()
@@ -1858,6 +1922,7 @@ func (a *App) shutdown(ctx context.Context) {
// logger has spent seconds tearing down ports. // logger has spent seconds tearing down ports.
applog.Printf("shutdown: closing autostart programs") applog.Printf("shutdown: closing autostart programs")
a.CloseAutostartPrograms() a.CloseAutostartPrograms()
a.stopPSKTarget() // one TLS socket to a public broker; nothing to flush
applog.Printf("shutdown: stopping UDP") applog.Printf("shutdown: stopping UDP")
if a.udp != nil { if a.udp != nil {
a.udp.StopAll() a.udp.StopAll()
@@ -2851,7 +2916,9 @@ func (a *App) RestartApp() error {
if err != nil { if err != nil {
return fmt.Errorf("locate executable: %w", err) return fmt.Errorf("locate executable: %w", err)
} }
cmd := exec.Command(exe) // --relaunch: the child waits for OUR mutex instead of declaring us a
// duplicate — this instance is quitting, just not always fast enough.
cmd := exec.Command(exe, "--relaunch")
cmd.Dir = filepath.Dir(exe) cmd.Dir = filepath.Dir(exe)
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return fmt.Errorf("relaunch OpsLog: %w", err) return fmt.Errorf("relaunch OpsLog: %w", err)
@@ -2932,6 +2999,28 @@ func (a *App) reloadLookupProviders() {
// --- QSO bindings --- // --- QSO bindings ---
// fillRXDefaults stamps the receive side when the contact was not split.
//
// The ADIF importer has always done this — an absent BAND_RX/FREQ_RX means RX
// equals TX — but the LOGGING paths did not, so what a QSO carried depended on
// which door it came through. It shows up outside OpsLog: the record forwarded
// to another logger over UDP is written from the QSO as logged, and a receiver
// reading BAND_RX (Log4OM does) found nothing there for contacts logged by a
// path that left it blank.
//
// Applied at AddQSO, the one funnel every path goes through — manual entry, the
// WSJT-X/UDP log, CW, contest, net control, the ADIF monitor — so the database,
// the export and the forwarded copy all say the same thing.
func fillRXDefaults(q *qso.QSO) {
if strings.TrimSpace(q.BandRX) == "" {
q.BandRX = q.Band
}
if q.FreqRXHz == nil && q.FreqHz != nil {
v := *q.FreqHz
q.FreqRXHz = &v
}
}
func (a *App) AddQSO(q qso.QSO) (id int64, err error) { func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
if a.qso == nil { if a.qso == nil {
return 0, fmt.Errorf("db not initialized") return 0, fmt.Errorf("db not initialized")
@@ -2947,6 +3036,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
} }
}() }()
a.applyStationDefaults(&q, true) a.applyStationDefaults(&q, true)
fillRXDefaults(&q)
fillDistance(&q) fillDistance(&q)
a.applyDXCCNumber(&q) a.applyDXCCNumber(&q)
a.applyULSCounty(&q) // fill blank US county/grid from the offline ULS store a.applyULSCounty(&q) // fill blank US county/grid from the offline ULS store
@@ -8196,7 +8286,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil { if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized") return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
} }
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATKenwoodLink, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort) m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATKenwoodLink, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort, keyCATDigiUSB)
if err != nil { if err != nil {
return CATSettings{}, err return CATSettings{}, err
} }
@@ -8285,9 +8375,16 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 { if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 {
out.IcomBaud = n out.IcomBaud = n
} }
if n, _ := strconv.Atoi(m[keyCATIcomAddr]); n > 0 && n <= 0xFF { // 0x00 is a real CI-V address an operator may need (a bare interface, a rig
// left at its factory broadcast address), and "> 0" silently sent them back
// to the IC-7610 default with no way to say what they meant. An EMPTY
// setting is what means "never configured" — so the error is what decides,
// not the value.
if v := strings.TrimSpace(m[keyCATIcomAddr]); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 && n <= 0xFF {
out.IcomAddr = n out.IcomAddr = n
} }
}
if out.Backend == "" { if out.Backend == "" {
out.Backend = "omnirig" out.Backend = "omnirig"
} }
@@ -8297,6 +8394,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if v := strings.ToUpper(strings.TrimSpace(m[keyCATOmniRigVFO])); v == "A" || v == "B" { if v := strings.ToUpper(strings.TrimSpace(m[keyCATOmniRigVFO])); v == "A" || v == "B" {
out.OmniRigVFO = v out.OmniRigVFO = v
} }
out.DigiAsUSB = m[keyCATDigiUSB] == "1"
if n, _ := strconv.Atoi(m[keyCATOmniRigNum]); n == 1 || n == 2 { if n, _ := strconv.Atoi(m[keyCATOmniRigNum]); n == 1 || n == 2 {
out.OmniRigNum = n out.OmniRigNum = n
} }
@@ -8405,6 +8503,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATBackend: s.Backend, keyCATBackend: s.Backend,
keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum), keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum),
keyCATOmniRigVFO: strings.ToUpper(strings.TrimSpace(s.OmniRigVFO)), keyCATOmniRigVFO: strings.ToUpper(strings.TrimSpace(s.OmniRigVFO)),
keyCATDigiUSB: boolStr(s.DigiAsUSB),
keyCATFlexHost: strings.TrimSpace(s.FlexHost), keyCATFlexHost: strings.TrimSpace(s.FlexHost),
keyCATFlexPort: strconv.Itoa(s.FlexPort), keyCATFlexPort: strconv.Itoa(s.FlexPort),
keyCATFlexSpots: flexSpots, keyCATFlexSpots: flexSpots,
@@ -8475,6 +8574,10 @@ type AudioSettings struct {
PrerollSeconds int `json:"preroll_seconds"` // rolling pre-roll (default 8) PrerollSeconds int `json:"preroll_seconds"` // rolling pre-roll (default 8)
PTTMethod string `json:"ptt_method"` // "none" (VOX) | "rts" | "dtr" PTTMethod string `json:"ptt_method"` // "none" (VOX) | "rts" | "dtr"
PTTPort string `json:"ptt_port"` // COM port for serial PTT PTTPort string `json:"ptt_port"` // COM port for serial PTT
// PTTData: the keyer's audio reaches the radio on its DATA/USB input, not
// the microphone socket. CAT keying only — it changes which transmit
// command is sent (a Kenwood TS-590 takes TX1 instead of TX).
PTTData bool `json:"ptt_data"`
Format string `json:"format"` // "wav" | "mp3" Format string `json:"format"` // "wav" | "mp3"
FromGain int `json:"from_gain"` // From Radio (RX) mix level %, default 100 FromGain int `json:"from_gain"` // From Radio (RX) mix level %, default 100
MicGain int `json:"mic_gain"` // mic mix level %, default 100 MicGain int `json:"mic_gain"` // mic mix level %, default 100
@@ -8536,7 +8639,7 @@ func (a *App) GetAudioSettings() (AudioSettings, error) {
} }
m, err := a.settings.GetMany(a.ctx, m, err := a.settings.GetMany(a.ctx,
keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice, keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice,
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat, keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioPTTData, keyAudioFormat,
keyAudioFromGain, keyAudioMicGain, keyAudioTXGain, keyAudioQSOPlayGain) keyAudioFromGain, keyAudioMicGain, keyAudioTXGain, keyAudioQSOPlayGain)
if err != nil { if err != nil {
return out, err return out, err
@@ -8548,6 +8651,7 @@ func (a *App) GetAudioSettings() (AudioSettings, error) {
out.PTTMethod = v out.PTTMethod = v
} }
out.PTTPort = m[keyAudioPTTPort] out.PTTPort = m[keyAudioPTTPort]
out.PTTData = m[keyAudioPTTData] == "1"
out.FromRadio = m[keyAudioFromRadio] out.FromRadio = m[keyAudioFromRadio]
out.ToRadio = m[keyAudioToRadio] out.ToRadio = m[keyAudioToRadio]
out.RecordingDevice = m[keyAudioRecDevice] out.RecordingDevice = m[keyAudioRecDevice]
@@ -8618,6 +8722,7 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
keyAudioPreroll: strconv.Itoa(s.PrerollSeconds), keyAudioPreroll: strconv.Itoa(s.PrerollSeconds),
keyAudioPTTMethod: pttMethod, keyAudioPTTMethod: pttMethod,
keyAudioPTTPort: strings.TrimSpace(s.PTTPort), keyAudioPTTPort: strings.TrimSpace(s.PTTPort),
keyAudioPTTData: boolStr(s.PTTData),
keyAudioFormat: format, keyAudioFormat: format,
keyAudioFromGain: strconv.Itoa(s.FromGain), keyAudioFromGain: strconv.Itoa(s.FromGain),
keyAudioMicGain: strconv.Itoa(s.MicGain), keyAudioMicGain: strconv.Itoa(s.MicGain),
@@ -9106,6 +9211,7 @@ func (a *App) clusterEventWorker() {
FreqHz: s.FreqHz, FreqHz: s.FreqHz,
Callsign: s.DXCall, Callsign: s.DXCall,
Mode: mode, Mode: mode,
Priority: spotPriority(status),
Comment: spotComment(s.Comment, s.Spotter, s.Country, status), Comment: spotComment(s.Comment, s.Spotter, s.Country, status),
Color: col.Text, Color: col.Text,
BackgroundColor: col.Bg, BackgroundColor: col.Bg,
@@ -10187,14 +10293,26 @@ func (a *App) applyClublogException(q *qso.QSO, force bool) bool {
} }
e, ok := a.clublog.Resolve(q.Callsign, date) e, ok := a.clublog.Resolve(q.Callsign, date)
if !ok { if !ok {
// No exception COVERS this QSO's date. If the call nonetheless HAS a // No exception covers this QSO's date, so the question becomes which
// date-ranged exception (e.g. G1T = Scotland only from 2024-02-21), then // country file knows the PREFIX better.
// cty.dat's date-blind "=G1T → Scotland" override is WRONG for an older //
// QSO — resolve it by ClubLog's date-aware PREFIX table instead (G1 → // ClubLog's prefix table is date-ranged and cty.dat's is not, and that
// England for a 2012 contact). Ordinary calls (no exception) are left to // is not a detail: cty.dat describes the world as it is TODAY. Niue
// cty.dat. // moved to E6, so ZK2 went back to New Zealand there — and every ZK2
if a.clublog.HasException(q.Callsign) { // contact ever made was silently relabelled New Zealand, taking a
// confirmed entity out of an operator's DXCC with it. ZK1 loses the
// Cook Islands the same way. ClubLog still knows both.
//
// It only speaks when it HAS an answer (E6, TO5A and their like are not
// in its prefix table at all), and never over an exact "=CALLSIGN" entry
// in cty.dat: that is somebody having looked at this very callsign, and
// a prefix rule does not overrule it.
if pe, pok := a.clublog.ResolvePrefix(q.Callsign, date); pok { if pe, pok := a.clublog.ResolvePrefix(q.Callsign, date); pok {
ctyExact := false
if m, mok := a.dxcc.Lookup(q.Callsign); mok {
ctyExact = m.Exact
}
if !ctyExact {
e, ok = pe, true e, ok = pe, true
} }
} }
@@ -10544,7 +10662,7 @@ func (a *App) pttKey(cfg AudioSettings) error {
if a.cat == nil { if a.cat == nil {
return fmt.Errorf("CAT not initialized") return fmt.Errorf("CAT not initialized")
} }
if err := a.cat.SetPTT(true); err != nil { if err := a.cat.SetPTTSource(true, cfg.PTTData); err != nil {
applog.Printf("ptt: CAT SetPTT failed: %v", err) applog.Printf("ptt: CAT SetPTT failed: %v", err)
return err return err
} }
@@ -10941,6 +11059,7 @@ func defaultQSLDefaults() QSLDefaults {
EQSLSent: "R", EQSLRcvd: "N", EQSLSent: "R", EQSLRcvd: "N",
LOTWSent: "R", LOTWRcvd: "N", LOTWSent: "R", LOTWRcvd: "N",
ClublogStatus: "R", ClublogCfm: "N", HRDLogStatus: "R", ClublogStatus: "R", ClublogCfm: "N", HRDLogStatus: "R",
HamqthStatus: "R",
QRZComStatus: "R", QRZComCfm: "N", QRZComStatus: "R", QRZComCfm: "N",
} }
} }
@@ -10962,6 +11081,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
keyQSLDefaultClublogStatus, keyQSLDefaultClublogCfm, keyQSLDefaultHRDLogStatus, keyQSLDefaultClublogStatus, keyQSLDefaultClublogCfm, keyQSLDefaultHRDLogStatus,
keyQSLDefaultQRZComStatus, keyQSLDefaultQRZComCfm, keyQSLDefaultQRZComStatus, keyQSLDefaultQRZComCfm,
keyQSLDefaultHamlogStatus, keyQSLDefaultHamlogCfm, keyQSLDefaultHamlogStatus, keyQSLDefaultHamlogCfm,
keyQSLDefaultHamqthStatus,
) )
if err != nil { if err != nil {
return out, err return out, err
@@ -10978,6 +11098,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
out.QRZComStatus = m[keyQSLDefaultQRZComStatus] out.QRZComStatus = m[keyQSLDefaultQRZComStatus]
out.QRZComCfm = m[keyQSLDefaultQRZComCfm] out.QRZComCfm = m[keyQSLDefaultQRZComCfm]
out.HamlogStatus = m[keyQSLDefaultHamlogStatus] out.HamlogStatus = m[keyQSLDefaultHamlogStatus]
out.HamqthStatus = m[keyQSLDefaultHamqthStatus]
out.HamlogCfm = m[keyQSLDefaultHamlogCfm] out.HamlogCfm = m[keyQSLDefaultHamlogCfm]
return out, nil return out, nil
} }
@@ -11002,6 +11123,7 @@ func (a *App) SaveQSLDefaults(d QSLDefaults) error {
keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)), keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)),
keyQSLDefaultQRZComCfm: strings.ToUpper(strings.TrimSpace(d.QRZComCfm)), keyQSLDefaultQRZComCfm: strings.ToUpper(strings.TrimSpace(d.QRZComCfm)),
keyQSLDefaultHamlogStatus: strings.ToUpper(strings.TrimSpace(d.HamlogStatus)), keyQSLDefaultHamlogStatus: strings.ToUpper(strings.TrimSpace(d.HamlogStatus)),
keyQSLDefaultHamqthStatus: strings.ToUpper(strings.TrimSpace(d.HamqthStatus)),
keyQSLDefaultHamlogCfm: strings.ToUpper(strings.TrimSpace(d.HamlogCfm)), keyQSLDefaultHamlogCfm: strings.ToUpper(strings.TrimSpace(d.HamlogCfm)),
} { } {
if err := a.settings.Set(a.ctx, scope+k, v); err != nil { if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
@@ -11058,6 +11180,7 @@ func applyQSLDefaultsTo(q *qso.QSO, d QSLDefaults) {
// string field, and these two are map entries. Only set when the QSO does not // string field, and these two are map entries. Only set when the QSO does not
// already carry them, which is the same rule fill() applies. // already carry them, which is the same rule fill() applies.
setExtraDefault(q, hamlogSentKey, d.HamlogStatus) setExtraDefault(q, hamlogSentKey, d.HamlogStatus)
setExtraDefault(q, hamqthSentKey, d.HamqthStatus)
setExtraDefault(q, award.HamlogQSLKey, d.HamlogCfm) setExtraDefault(q, award.HamlogQSLKey, d.HamlogCfm)
} }
@@ -11175,7 +11298,9 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode, keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode,
keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID, keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID,
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote, keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote,
keyExtHamlogAPIKey, keyExtHamlogAutoUpload, keyExtHamlogUploadMode) keyExtHamlogAPIKey, keyExtHamlogAutoUpload, keyExtHamlogUploadMode,
keyExtHamqthUsername, keyExtHamqthPassword, keyExtHamqthCallsign,
keyExtHamqthAutoUpload, keyExtHamqthUploadMode)
if err != nil { if err != nil {
return out return out
} }
@@ -11259,6 +11384,21 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
AutoUpload: m[keyExtHamlogAutoUpload] == "1", AutoUpload: m[keyExtHamlogAutoUpload] == "1",
UploadMode: extsvc.UploadMode(m[keyExtHamlogUploadMode]), UploadMode: extsvc.UploadMode(m[keyExtHamlogUploadMode]),
} }
out.HamQTH = extsvc.ServiceConfig{
Username: m[keyExtHamqthUsername],
Password: m[keyExtHamqthPassword],
Callsign: m[keyExtHamqthCallsign],
AutoUpload: m[keyExtHamqthAutoUpload] == "1",
UploadMode: extsvc.UploadMode(m[keyExtHamqthUploadMode]),
}
// The callbook lookup already knows these credentials; blanks fall back to
// them so an operator who set up the lookup years ago is one checkbox away.
if out.HamQTH.Username == "" && out.HamQTH.Password == "" {
u, _ := a.settings.Get(a.ctx, keyHQUser)
p, _ := a.settings.Get(a.ctx, keyHQPassword)
out.HamQTH.Username = strings.TrimSpace(u)
out.HamQTH.Password = p
}
return out return out
} }
@@ -11333,6 +11473,7 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
if hamMode == string(extsvc.ModeOnClose) { if hamMode == string(extsvc.ModeOnClose) {
hamMode = string(extsvc.ModeImmediate) hamMode = string(extsvc.ModeImmediate)
} }
hqMode := modeOf(cfg.HamQTH.UploadMode)
hamAuto := "0" hamAuto := "0"
if cfg.Hamlog.AutoUpload { if cfg.Hamlog.AutoUpload {
hamAuto = "1" hamAuto = "1"
@@ -11388,6 +11529,11 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
keyExtHamlogAPIKey: strings.TrimSpace(cfg.Hamlog.APIKey), keyExtHamlogAPIKey: strings.TrimSpace(cfg.Hamlog.APIKey),
keyExtHamlogAutoUpload: hamAuto, keyExtHamlogAutoUpload: hamAuto,
keyExtHamlogUploadMode: hamMode, keyExtHamlogUploadMode: hamMode,
keyExtHamqthUsername: strings.TrimSpace(cfg.HamQTH.Username),
keyExtHamqthPassword: cfg.HamQTH.Password,
keyExtHamqthCallsign: strings.ToUpper(strings.TrimSpace(cfg.HamQTH.Callsign)),
keyExtHamqthAutoUpload: boolStr(cfg.HamQTH.AutoUpload),
keyExtHamqthUploadMode: hqMode,
} { } {
if err := a.settings.Set(a.ctx, scope+k, v); err != nil { if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
return err return err
@@ -11416,6 +11562,142 @@ func (a *App) TestClublogUpload() (string, error) {
return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog) return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog)
} }
// UploadFullLogHamQTH replaces the HamQTH log with this one, in one request.
//
// The per-QSO API is the only correct way to send a SELECTION, and at the pace
// it has to be driven a full backlog costs the better part of an hour. This is
// the other endpoint HamQTH offers: a whole log as one file, which is why it
// only ever runs on an explicit "replace my HamQTH log" — the site keeps
// nothing that is not in the file.
//
// Scoped to the callsign this profile uploads as: a database holding two
// operators' contacts must not push one operator's QSOs into the other's log.
func (a *App) UploadFullLogHamQTH() error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
cfg := a.loadExternalServices().HamQTH
if strings.TrimSpace(cfg.Username) == "" || cfg.Password == "" {
return fmt.Errorf("set the HamQTH username and password first")
}
go a.runFullLogHamQTH(cfg)
return nil
}
func (a *App) runFullLogHamQTH(cfg extsvc.ServiceConfig) {
emit := func(line string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
}
}
done := func(n int) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": n, "total": n})
}
}
ctx := a.ctx
owner := a.uploadOwnerCall(extsvc.ServiceHamQTH)
// Written to a temp file rather than a buffer so the ordinary, tested
// exporter does the work — the same one the Export menu uses.
tmp, err := os.CreateTemp("", "opslog-hamqth-*.adi")
if err != nil {
emit("Export failed: " + err.Error())
done(0)
return
}
path := tmp.Name()
_ = tmp.Close()
defer os.Remove(path)
if owner != "" {
emit("Station callsign: " + owner + " — QSOs logged under another of your callsigns are NOT included.")
}
emit("Exporting the log…")
var res adif.ExportResult
if owner != "" {
// station_callsign empty OR the owner call — an old QSO logged before
// the field existed belongs to whoever is uploading now.
f := qso.QueryFilter{Match: "OR", Conditions: []qso.Condition{
{Field: "station_callsign", Op: "eq", Value: ""},
{Field: "station_callsign", Op: "eq", Value: owner},
}}
res, err = a.ExportADIFFiltered(path, false, f, nil)
} else {
res, err = a.ExportADIF(path, false, nil)
}
if err != nil {
emit("Export failed: " + err.Error())
done(0)
return
}
data, rerr := os.ReadFile(path)
if rerr != nil {
emit("Export failed: " + rerr.Error())
done(0)
return
}
total, _ := a.qso.Count(ctx)
if total > 0 && int64(res.Count) != total {
emit(fmt.Sprintf("%d of %d QSOs in this logbook match %s and will be sent.", res.Count, total, owner))
}
emit(fmt.Sprintf("Exported %d QSO(s), %d KB of ADIF.", res.Count, res.SizeKB))
emit(fmt.Sprintf("Uploading to HamQTH — this REPLACES the log there…"))
up, uerr := extsvc.UploadHamQTHFullLog(ctx, nil, cfg, string(data))
if uerr != nil || !up.OK {
msg := up.Message
if uerr != nil {
msg = uerr.Error()
}
emit("Upload failed: " + msg)
applog.Printf("hamqth: full-log upload failed: %s", msg)
done(0)
return
}
emit("HamQTH replied: " + up.Message)
// Said plainly, because the numbers will not agree for a while and an
// operator comparing them straight away has every reason to think the
// upload failed: HamQTH ACCEPTS the file here and imports it later, at its
// own pace. What it makes of each record is reported by E-MAIL, never in
// this reply — so a count that stops short means the site rejected records,
// and the mail says which.
emit("Accepted — HamQTH imports the file in the BACKGROUND, so its QSO count will lag for a while.")
emit("If the count stops short, HamQTH e-mails the ADIF errors to your account address — this reply cannot carry them.")
// Everything is on HamQTH now, so nothing is still waiting to be sent. Only
// the rows that are not already stamped need writing.
pending, lerr := a.qso.ListMissingExtra(ctx, hamqthSentKey)
if lerr != nil {
applog.Printf("hamqth: marking sent: %v", lerr)
} else if len(pending) > 0 {
ids := make([]int64, 0, len(pending))
for _, q := range pending {
ids = append(ids, q.ID)
}
date := time.Now().UTC().Format("20060102")
if _, e := a.qso.BulkSetExtra(ctx, ids, hamqthSentKey, "Y"); e != nil {
applog.Printf("hamqth: marking sent: %v", e)
}
if _, e := a.qso.BulkSetExtra(ctx, ids, hamqthSentDateKey, date); e != nil {
applog.Printf("hamqth: marking sent date: %v", e)
}
emit(fmt.Sprintf("Marked %d QSO(s) as sent to HamQTH.", len(ids)))
}
applog.Printf("hamqth: full-log upload OK (%d QSOs)", res.Count)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("HamQTH: %d QSO uploaded", res.Count))
}
done(res.Count)
}
// TestHamQTHUpload checks the HamQTH credentials against the callbook login —
// authenticated, and unable to touch the log.
func (a *App) TestHamQTHUpload() (string, error) {
cfg := a.loadExternalServices().HamQTH
return extsvc.TestHamQTH(a.ctx, nil, cfg)
}
// TestHRDLogUpload validates that the HRDLog credentials are complete. // TestHRDLogUpload validates that the HRDLog credentials are complete.
func (a *App) TestHRDLogUpload() (string, error) { func (a *App) TestHRDLogUpload() (string, error) {
return extsvc.TestHRDLog(a.ctx, nil, a.loadExternalServices().HRDLog) return extsvc.TestHRDLog(a.ctx, nil, a.loadExternalServices().HRDLog)
@@ -11475,6 +11757,9 @@ func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.QSO, error) {
if extsvc.Service(service) == extsvc.ServiceHamlog { if extsvc.Service(service) == extsvc.ServiceHamlog {
return a.qso.ListMissingExtra(a.ctx, hamlogSentKey) return a.qso.ListMissingExtra(a.ctx, hamlogSentKey)
} }
if extsvc.Service(service) == extsvc.ServiceHamQTH {
return a.qso.ListMissingExtra(a.ctx, hamqthSentKey)
}
col := uploadColumnFor(service) col := uploadColumnFor(service)
if col == "" { if col == "" {
return nil, fmt.Errorf("unknown service %q", service) return nil, fmt.Errorf("unknown service %q", service)
@@ -11490,7 +11775,14 @@ func (a *App) UploadQSOsManual(service string, ids []int64) error {
return fmt.Errorf("db not initialized") return fmt.Errorf("db not initialized")
} }
svc := extsvc.Service(service) svc := extsvc.Service(service)
if uploadColumnFor(service) == "" && svc != extsvc.ServiceHamlog { // Cloudlog/Wavelog has no sent-status column ON PURPOSE — it dedupes
// server-side, which is exactly why an explicit selection needs no column to
// be safe. HAMLOG.online and HamQTH keep theirs in ADIF extras.
if svc == extsvc.ServiceHamlog {
return extsvc.ErrHamlogClosed
}
if uploadColumnFor(service) == "" &&
svc != extsvc.ServiceHamQTH && svc != extsvc.ServiceCloudlog {
return fmt.Errorf("unknown service %q", service) return fmt.Errorf("unknown service %q", service)
} }
cfg := a.loadExternalServices() cfg := a.loadExternalServices()
@@ -11714,6 +12006,46 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
} }
flush() flush()
} }
} else if svc == extsvc.ServiceHamlog || svc == extsvc.ServiceHamQTH || svc == extsvc.ServiceCloudlog {
// One record per request, extras-stamped services. Hamlog used to fall
// through to the QRZ branch below and got uploaded to the WRONG service
// with the QRZ key — the context menu offered what the loop never handled.
for i, id := range ids {
if i > 0 {
time.Sleep(manualUploadPace)
}
q, gerr := a.qso.GetByID(ctx, id)
call := ""
if gerr == nil {
call = q.Callsign
}
rec, ok := a.buildUploadADIF(id, "")
if !ok {
emit(call + " — skipped (no record)")
continue
}
var res extsvc.UploadResult
var err error
switch svc {
case extsvc.ServiceHamlog:
res, err = extsvc.UploadHamlog(ctx, nil, cfg.Hamlog, rec)
case extsvc.ServiceHamQTH:
res, err = extsvc.UploadHamQTH(ctx, nil, cfg.HamQTH, rec)
default:
res, err = extsvc.UploadCloudlog(ctx, nil, cfg.Cloudlog, rec)
}
if err == nil && res.OK {
a.markExtUploaded(svc, id, res.LogID)
uploaded++
emit(call + " — OK")
} else {
msg := res.Message
if err != nil {
msg = err.Error()
}
emit(call + " — FAILED: " + msg)
}
}
} else { } else {
// QRZ.com: one record per request (its logbook API has no batch upload), // QRZ.com: one record per request (its logbook API has no batch upload),
// paced for the same reason as HRDLog above. // paced for the same reason as HRDLog above.
@@ -11755,6 +12087,8 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
label := map[extsvc.Service]string{ label := map[extsvc.Service]string{
extsvc.ServiceQRZ: "QRZ.com", extsvc.ServiceClublog: "Club Log", extsvc.ServiceHRDLog: "HRDLog", extsvc.ServiceQRZ: "QRZ.com", extsvc.ServiceClublog: "Club Log", extsvc.ServiceHRDLog: "HRDLog",
extsvc.ServiceLoTW: "LoTW", extsvc.ServiceEQSL: "eQSL", extsvc.ServiceLoTW: "LoTW", extsvc.ServiceEQSL: "eQSL",
extsvc.ServiceHamlog: "HAMLOG.online", extsvc.ServiceHamQTH: "HamQTH",
extsvc.ServiceCloudlog: "Cloudlog / Wavelog",
}[svc] }[svc]
if label == "" { if label == "" {
label = string(svc) label = string(svc)
@@ -13490,14 +13824,21 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
// is not remembered — hence no on-close mode and no manual backlog. // is not remembered — hence no on-close mode and no manual backlog.
return true return true
case extsvc.ServiceHamlog: case extsvc.ServiceHamlog:
// The stamp is an extra, not a column — see markExtUploaded. Present means // The stamp is an extra, not a column — see markExtUploaded. A stamp that
// it has gone, which is what stops an on-demand re-upload of a whole log // MEANS SENT is what stops an on-demand re-upload of a whole log from
// from sending every contact twice. // sending every contact twice.
if q.Extras != nil && strings.TrimSpace(q.Extras[hamlogSentKey]) != "" { if q.Extras != nil && extrasSaysSent(q.Extras[hamlogSentKey]) {
applog.Printf("extsvc: QSO %d not eligible for hamlog — already sent on %s", id, q.Extras[hamlogSentKey]) applog.Printf("extsvc: QSO %d not eligible for hamlog — already sent on %s", id, q.Extras[hamlogSentKey])
return false return false
} }
return true return true
case extsvc.ServiceHamQTH:
// Same extras stamp as HAMLOG.online — ADIF names no HamQTH field.
if q.Extras != nil && extrasSaysSent(q.Extras[hamqthSentKey]) {
applog.Printf("extsvc: QSO %d not eligible for hamqth — already sent on %s", id, q.Extras[hamqthSentKey])
return false
}
return true
case extsvc.ServiceLoTW: case extsvc.ServiceLoTW:
for _, f := range a.loadExternalServices().LoTW.UploadFlags { for _, f := range a.loadExternalServices().LoTW.UploadFlags {
if strings.EqualFold(q.LOTWSent, f) { if strings.EqualFold(q.LOTWSent, f) {
@@ -13518,8 +13859,26 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
const ( const (
hamlogSentKey = "APP_OPSLOG_HAMLOG_SENT" hamlogSentKey = "APP_OPSLOG_HAMLOG_SENT"
hamlogSentDateKey = "APP_OPSLOG_HAMLOG_SENT_DATE" hamlogSentDateKey = "APP_OPSLOG_HAMLOG_SENT_DATE"
hamqthSentKey = "APP_OPSLOG_HAMQTH_SENT"
hamqthSentDateKey = "APP_OPSLOG_HAMQTH_SENT_DATE"
) )
// extrasSaysSent reads an extras upload stamp as "this QSO has GONE".
//
// Mere presence will not do. markExtUploaded writes "Y" (older builds wrote the
// date), but the Confirmations page pre-fills the same key with a TO-DO status
// — "R", requested, is the natural default for a service you intend to upload
// to — and reading that as "already gone" would silently disable the very
// auto-upload the default was set to arm. So the pending statuses mean pending,
// and anything else means sent.
func extrasSaysSent(v string) bool {
switch strings.ToUpper(strings.TrimSpace(v)) {
case "", "R", "N", "Q", "I":
return false
}
return true
}
func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) { func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
date := time.Now().UTC().Format("20060102") date := time.Now().UTC().Format("20060102")
// Use a fresh background context, NOT a.ctx: this stamp often runs during // Use a fresh background context, NOT a.ctx: this stamp often runs during
@@ -13565,6 +13924,10 @@ func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
if err = a.qso.SetExtra(ctx, id, hamlogSentKey, "Y"); err == nil { if err = a.qso.SetExtra(ctx, id, hamlogSentKey, "Y"); err == nil {
err = a.qso.SetExtra(ctx, id, hamlogSentDateKey, date) err = a.qso.SetExtra(ctx, id, hamlogSentDateKey, date)
} }
case extsvc.ServiceHamQTH:
if err = a.qso.SetExtra(ctx, id, hamqthSentKey, "Y"); err == nil {
err = a.qso.SetExtra(ctx, id, hamqthSentDateKey, date)
}
} }
if err != nil { if err != nil {
applog.Printf("extsvc: mark %s uploaded %d failed: %v", svc, id, err) applog.Printf("extsvc: mark %s uploaded %d failed: %v", svc, id, err)
@@ -14009,6 +14372,17 @@ func (a *App) consumeUDPEvents() {
map[bool]string{true: "", false: " — nothing we send can start a transmission while this is false"}[now.enabled]) map[bool]string{true: "", false: " — nothing we send can start a transmission while this is false"}[now.enabled])
} }
} }
// Auto-call counts what actually goes on the air from here: a reply
// we sent is a request, and this is the answer to whether the
// decoder acted on it.
acTX := autocall.TXState{
Transmitting: ev.Transmitting, Enabled: ev.TxEnabled,
DXCall: ev.DXCall, Msg: ev.TxMessage, Instance: ev.ProgramID,
}
a.autoCallSetTX(acTX)
if ev.Transmitting {
a.autoCallNoteTX(acTX)
}
wruntime.EventsEmit(a.ctx, "udp:tx_state", map[string]any{ wruntime.EventsEmit(a.ctx, "udp:tx_state", map[string]any{
"msg": ev.TxMessage, "msg": ev.TxMessage,
"transmitting": ev.Transmitting, "transmitting": ev.Transmitting,
@@ -14066,10 +14440,20 @@ func (a *App) consumeUDPEvents() {
// false on a Replay's resent history — shown, never auto-answered. // false on a Replay's resent history — shown, never auto-answered.
"is_new": ev.DecodeIsNew, "is_new": ev.DecodeIsNew,
}) })
// Auto-call sees the decode after the panel, never before: the list
// the operator reads must not wait on a decision about calling.
a.autoCallFeed(autocall.Decode{
Call: ev.DecodeCall, Band: bandForHz(ev.DecodeFreqHz), Mode: ev.Mode,
Msg: ev.DecodeMsg, SNR: ev.DecodeSNR, At: at, TRPeriod: ev.DecodeTRPeriod,
Instance: ev.ProgramID, CQ: ev.DecodeCQ,
Ms: ev.DecodeMs, DT: ev.DecodeDT, AudioHz: int64(ev.DecodeAudioHz),
ModeRaw: ev.DecodeModeRaw, MsgRaw: ev.DecodeMsgRaw, LowConf: ev.DecodeLowConf,
IsNew: ev.DecodeIsNew,
})
// Log-aware colour in the decoder's own window (see // Log-aware colour in the decoder's own window (see
// app_wsjt_highlight.go). After the emit: painting must never delay // app_wsjt_highlight.go). After the emit: painting must never delay
// the panel. // the panel.
a.maybeHighlightDecode(ev.ProgramID, ev.DecodeCall, bandForHz(ev.DecodeFreqHz)) a.maybeHighlightDecode(ev.ProgramID, ev.DecodeCall, bandForHz(ev.DecodeFreqHz), ev.Mode)
// A WSJT-X decode (heard station). Render it on the FlexRadio // A WSJT-X decode (heard station). Render it on the FlexRadio
// panadapter when the option is on; green + SNR comment, auto-expiring // panadapter when the option is on; green + SNR comment, auto-expiring
// after the configured duration. De-duped per call in the Flex backend. // after the configured duration. De-duped per call in the Flex backend.
@@ -14117,6 +14501,7 @@ func (a *App) consumeUDPEvents() {
BackgroundColor: bgCol, BackgroundColor: bgCol,
Comment: spotComment(fmt.Sprintf("%s %+ddB", ev.Mode, ev.DecodeSNR), "", a.countryFor(ev.DecodeCall), status), Comment: spotComment(fmt.Sprintf("%s %+ddB", ev.Mode, ev.DecodeSNR), "", a.countryFor(ev.DecodeCall), status),
LifetimeSec: secs, LifetimeSec: secs,
Priority: spotPriority(status),
}: }:
default: default:
// Radio not keeping up: skip the spot rather than stall the // Radio not keeping up: skip the spot rather than stall the
@@ -14555,6 +14940,7 @@ func (a *App) SetCATMode(mode string) error {
if a.cat == nil { if a.cat == nil {
return fmt.Errorf("cat not initialized") return fmt.Errorf("cat not initialized")
} }
mode = a.catModeForRadio(mode)
err := a.cat.SetMode(mode) err := a.cat.SetMode(mode)
if err != nil { if err != nil {
applog.Printf("cat: SetMode(%q) dispatch error: %v", mode, err) applog.Printf("cat: SetMode(%q) dispatch error: %v", mode, err)
@@ -14562,6 +14948,31 @@ func (a *App) SetCATMode(mode string) error {
return err return err
} }
// catModeForRadio translates a LOGGED mode into what the RADIO should be put
// in, for operators who have asked for USB on digital.
//
// The soundcard modes — FT8, FT4, PSK, JS8, Q65… — are all USB with audio in
// the microphone 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/PKT position,
// and the wrong one on an older set where the CAT layer resolves "digital" to
// RTTY/FSK: the operator lands in FSK, which keys the radio from a mark/space
// generator and cannot pass FT8 at all. OmniRig does exactly this, per rig
// file, and there is no arguing with it from here — so the option sends what
// the radio can actually do.
//
// This is the RADIO's mode only. The QSO is still logged as FT8: the mode is
// what the contact WAS, not what the front panel says.
func (a *App) catModeForRadio(mode string) string {
if strings.TrimSpace(mode) == "" || a.settingOr(keyCATDigiUSB, "") != "1" {
return mode
}
if qso.ModeClass(mode) != "DIG" {
return mode
}
applog.Printf("cat: %s → USB (digital modes set the radio to USB)", strings.ToUpper(mode))
return "USB"
}
// ── FlexRadio control tab (Phase 1: SmartSDR-style transmit controls) ── // ── FlexRadio control tab (Phase 1: SmartSDR-style transmit controls) ──
// These are no-ops / errors unless the active CAT backend is a FlexRadio. // These are no-ops / errors unless the active CAT backend is a FlexRadio.
@@ -14821,6 +15232,53 @@ func (a *App) IcomSetPower(on bool) error {
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetPower(on) }) return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetPower(on) })
} }
// icomBandCodes maps a band label to the code the band stacking registers use
// (CI-V 0x1A 0x01). Deliberately partial.
//
// The HF codes 01-09 are the same on every Icom that has HF, and are safe. What
// is NOT the same is everything above them: 50 MHz is 10 on an HF+6 rig, while
// an IC-9700 has no HF at all and numbers its three bands 144/430/1200 as 1/2/3.
// A band missing here is not a failure — the caller falls back to sending a
// frequency, which is what the button did before — so an unlisted band or an
// unlisted model loses nothing, and a GUESSED code would land the operator on
// another band entirely.
func icomBandCodes(model string) map[string]int {
m := strings.ToUpper(model)
if strings.Contains(m, "9700") {
return map[string]int{"2": 1, "70cm": 2, "23cm": 3}
}
// HF + 6 m: the 7300 / 7610 / 7760 / 7100 / 7851 / 705 / 9100 class. 60 m and
// 4 m are absent on purpose — they are not a band key on these radios.
return map[string]int{
"160": 1, "80": 2, "40": 3, "30": 4, "20": 5,
"17": 6, "15": 7, "12": 8, "10": 9, "6": 0x10,
}
}
// IcomRecallBand puts the radio where its own band stacking register says the
// operator last was on that band — the front panel's band key, from here.
//
// reg is 1, 2 or 3, so pressing the same band button again walks through the
// registers exactly as the radio's key does. Returns the frequency landed on;
// an error means nothing was changed and the caller should send its own
// frequency instead.
func (a *App) IcomRecallBand(band string, reg int) (int64, error) {
if a.cat == nil {
return 0, fmt.Errorf("cat not initialized")
}
code, ok := icomBandCodes(a.cat.State().Rig)[strings.ToLower(strings.TrimSpace(band))]
if !ok {
return 0, fmt.Errorf("no band stacking register for %s on this radio", band)
}
var hz int64
err := a.cat.IcomDo(func(ic cat.IcomController) error {
var e error
hz, e = ic.RecallBandStack(code, reg)
return e
})
return hz, err
}
// IcomSetScope enables/disables the spectrum-scope waveform stream. // IcomSetScope enables/disables the spectrum-scope waveform stream.
func (a *App) IcomSetScope(on bool) error { func (a *App) IcomSetScope(on bool) error {
if a.cat == nil { if a.cat == nil {
@@ -16231,6 +16689,14 @@ func (a *App) reloadAfterProfileSwitch() {
// per-profile settings too — rebuilt so a switch doesn't keep showing the // per-profile settings too — rebuilt so a switch doesn't keep showing the
// previous profile's view of who is worth chasing. // previous profile's view of who is worth chasing.
a.startWatchlistClubLog() a.startWatchlistClubLog()
// Auto-call is per profile — attempts, the "call only" callsign, whether it
// is on at all — and it TRANSMITS. A switch that left the previous profile's
// settings running would have the wrong station calling on the wrong log.
// Re-applied rather than restarted: the loop is one goroutine for the life
// of the process, and applyAutoCall clears the target when the feature is
// off in the profile just activated.
a.applyAutoCall()
a.autoCallEngine().Reset()
} }
// DuplicateProfile clones an existing profile under newName. Useful when // DuplicateProfile clones an existing profile under newName. Useful when
@@ -20736,6 +21202,12 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
if !worked { if !worked {
out[i].Status = "new" out[i].Status = "new"
if _, ever := entities[dxccNum]; ever { if _, ever := entities[dxccNum]; ever {
// Worked, never confirmed — and if it was THIS station on THIS
// slot, the need is not its to answer (see the rule below).
if out[i].WorkedSlot {
out[i].Status = "worked"
continue
}
out[i].UnconfStatus = true out[i].UnconfStatus = true
} }
continue continue
@@ -20772,6 +21244,21 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
} }
} }
} }
// THIS station, already in the log on THIS slot, has nothing left to
// give. Working it a second time on the same band and mode cannot make
// the entity count: the QSO is already there and only the QSL is
// missing, and a duplicate does not produce one. So the need is real —
// it is simply not this station's to answer, and shouting NEW DXCC over
// a callsign worked an hour ago is how an operator learns to distrust
// the colour. Another station in the entity still carries the badge.
//
// Only for a need that IS a missing confirmation: a genuinely new
// entity cannot co-exist with a worked callsign, so nothing real is
// suppressed here.
if out[i].UnconfStatus && out[i].WorkedSlot {
out[i].Status = "worked"
out[i].UnconfStatus = false
}
} }
return out return out
} }
+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
@@ -25,6 +25,7 @@ var sensitiveSettingKeys = map[string]bool{
keyExtLoTWKeyPassword: true, keyExtLoTWKeyPassword: true,
keyExtLoTWWebPassword: true, keyExtLoTWWebPassword: true,
keyExtHRDLogCode: true, keyExtHRDLogCode: true,
keyExtHamqthPassword: true,
keyExtEQSLPassword: true, keyExtEQSLPassword: true,
keyExtCloudlogAPIKey: true, keyExtCloudlogAPIKey: true,
// The web-publish config is one JSON blob and the FTP password lives inside // The web-publish config is one JSON blob and the FTP password lives inside
+95 -7
View File
@@ -42,6 +42,62 @@ func (a *App) SetWatchlistContestPattern(p string) {
a.setSettingGlobal(keyWatchlistContestPattern, 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. // WatchlistEntries returns the list for the tab.
func (a *App) WatchlistEntries() []watchlist.Entry { func (a *App) WatchlistEntries() []watchlist.Entry {
if a.watchlist == nil { if a.watchlist == nil {
@@ -55,7 +111,11 @@ func (a *App) WatchlistAdd(callsign string, contest bool) error {
if a.watchlist == nil { if a.watchlist == nil {
return fmt.Errorf("watchlist not initialized") return fmt.Errorf("watchlist not initialized")
} }
return a.watchlist.Add(callsign, contest) if err := a.watchlist.Add(callsign, contest); err != nil {
return err
}
a.notifyWatchlist(callsign, true)
return nil
} }
// WatchlistRemove deletes an entry. // WatchlistRemove deletes an entry.
@@ -63,7 +123,27 @@ func (a *App) WatchlistRemove(callsign string) error {
if a.watchlist == nil { if a.watchlist == nil {
return fmt.Errorf("watchlist not initialized") return fmt.Errorf("watchlist not initialized")
} }
return a.watchlist.Remove(callsign) 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. // WatchlistSetNotify arms the existing alert path (sound + toast) for an entry.
@@ -186,12 +266,20 @@ func (a *App) watchSpot(dxCall, band, mode, country, comment string, freqHz int6
} }
entry, notify, ok := a.watchlist.MarkSeen(dxCall) entry, notify, ok := a.watchlist.MarkSeen(dxCall)
if !ok { if !ok {
// Not watched yet — the auto-contest pattern may claim it. Contains, not // Not watched yet — the contest pattern or the named list may claim it.
// prefix: the event string sits anywhere in these calls (HB9WWA, F4WWA/P). // Contains, not prefix, for the pattern: the event string sits anywhere
if p := a.GetWatchlistContestPattern(); p != "" && // in those calls (HB9WWA, F4WWA/P). The named list is exact, and is what
strings.Contains(strings.ToUpper(dxCall), p) { // 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 { if err := a.watchlist.Add(dxCall, true); err == nil {
applog.Printf("watchlist: auto-added %s (contest pattern %q)", dxCall, p) 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) entry, notify, ok = a.watchlist.MarkSeen(dxCall)
if a.ctx != nil { if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "watchlist:changed") wruntime.EventsEmit(a.ctx, "watchlist:changed")
+73 -13
View File
@@ -17,6 +17,7 @@ import (
const ( const (
keyWsjtHighlight = "udp.wsjt.highlight" keyWsjtHighlight = "udp.wsjt.highlight"
keyWsjtFollowMode = "udp.wsjt.followmode" // spot clicks switch the decoder's mode 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 // wsjtModes are the modes a Configure message can meaningfully ask for — the
@@ -60,8 +61,55 @@ var (
hlNewBand = udp.RGB{R: 226, G: 122, B: 24} // orange hlNewBand = udp.RGB{R: 226, G: 122, B: 24} // orange
hlWhite = udp.RGB{R: 255, G: 255, B: 255} hlWhite = udp.RGB{R: 255, G: 255, B: 255}
hlBlack = udp.RGB{R: 20, G: 20, B: 20} 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. // GetWsjtHighlight reports whether decode highlighting is on.
func (a *App) GetWsjtHighlight() bool { func (a *App) GetWsjtHighlight() bool {
return a.settingOr(keyWsjtHighlight, "0") == "1" return a.settingOr(keyWsjtHighlight, "0") == "1"
@@ -77,13 +125,8 @@ func (a *App) SetWsjtHighlight(on bool) {
} }
a.setSetting(keyWsjtHighlight, v) a.setSetting(keyWsjtHighlight, v)
a.wsjtHighlightOn.Store(on) a.wsjtHighlightOn.Store(on)
if !on && a.udp != nil { if !on {
for _, inst := range a.udp.Instances() { a.clearWsjtHighlights()
_ = a.udp.SendClearHighlights(inst)
}
a.wsjtHLMu.Lock()
a.wsjtHLSent = map[string]string{}
a.wsjtHLMu.Unlock()
applog.Printf("wsjt highlight: off — cleared in every instance") applog.Printf("wsjt highlight: off — cleared in every instance")
} }
} }
@@ -92,12 +135,14 @@ func (a *App) SetWsjtHighlight(on bool) {
// it, when the option is on and the verdict is worth a colour. De-duplicated // 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 // per instance+call+verdict: a station CQing all evening is decoded four times
// a minute, and the instruction only needs to be said once. // a minute, and the instruction only needs to be said once.
func (a *App) maybeHighlightDecode(instance, call, band string) { func (a *App) maybeHighlightDecode(instance, call, band, mode string) {
if !a.wsjtHighlightOn.Load() || a.udp == nil || call == "" || instance == "" { if !a.wsjtHighlightOn.Load() || a.udp == nil || call == "" || instance == "" {
return return
} }
bg, fg, verdict := a.decodeHighlightVerdict(call, band) bg, fg, verdict := a.decodeHighlightVerdict(call, band, mode)
key := instance + "|" + strings.ToUpper(call) + "|" + band // 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() a.wsjtHLMu.Lock()
if a.wsjtHLSent == nil { if a.wsjtHLSent == nil {
a.wsjtHLSent = map[string]string{} a.wsjtHLSent = map[string]string{}
@@ -124,9 +169,11 @@ func (a *App) maybeHighlightDecode(instance, call, band string) {
} }
// decodeHighlightVerdict ranks a callsign: watchlist beats new-DXCC beats // decodeHighlightVerdict ranks a callsign: watchlist beats new-DXCC beats
// new-band; anything else is "no colour". The empty verdict doubles as the // new-band, and "already worked here" comes last of all — it is the only
// clear signal in maybeHighlightDecode. // verdict that says do NOT call, so anything worth calling for outranks it.
func (a *App) decodeHighlightVerdict(call, band string) (bg, fg *udp.RGB, verdict string) { // 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 a.watchlist != nil {
if _, ok := a.watchlist.Match(call); ok { if _, ok := a.watchlist.Match(call); ok {
c := hlWatchlist c := hlWatchlist
@@ -151,5 +198,18 @@ func (a *App) decodeHighlightVerdict(call, band string) (bg, fg *udp.RGB, verdic
} }
} }
} }
// 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, "" return nil, nil, ""
} }
+494
View File
@@ -0,0 +1,494 @@
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"
keyAutoCallRestMin = "autocall.rest_min"
)
// 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
// RestMin the pause between two of them.
MaxRounds int `json:"max_rounds"`
RestMin int `json:"rest_min"`
}
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),
Misses: num(keyAutoCallMisses, d.Misses),
MaxRounds: num(keyAutoCallRounds, d.MaxRounds),
RestMin: num(keyAutoCallRestMin, int(d.Rest/time.Minute)),
}
}
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)))
for key, v := range map[string]int{
keyAutoCallAttempts: s.Attempts, keyAutoCallWatched: s.WatchedAttempts,
keyAutoCallMisses: s.Misses, keyAutoCallRounds: s.MaxRounds,
keyAutoCallRestMin: s.RestMin,
} {
if v > 0 {
a.setSetting(key, strconv.Itoa(v))
}
}
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,
Attempts: s.Attempts, WatchedAttempts: s.WatchedAttempts,
Misses: s.Misses, MaxRounds: s.MaxRounds,
Rest: time.Duration(s.RestMin) * time.Minute,
}
}
// 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 !s.Enabled {
e.Reset()
a.acMu.Lock()
a.acPeriod, a.acBuf = nil, nil
a.acMu.Unlock()
}
a.emitAutoCall()
}
// ResetAutoCall is the operator's restart after it gave up — and what Halt
// does, since halting means "not this station".
func (a *App) ResetAutoCall() {
a.autoCallEngine().Reset()
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"`
Calls int `json:"calls"`
Max int `json:"max"`
Misses int `json:"misses"`
MaxMiss int `json:"max_miss"`
Stopped bool `json:"stopped"`
// 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, Calls: st.Attempts, Max: st.Max,
Misses: st.Misses, MaxMiss: st.MaxMiss, Stopped: st.Stopped,
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{}
}
if prev := a.acPeriod[inst]; prev != "" && prev != key {
prevAt, prevTR, buf := a.acAt[inst], a.acTR[inst], a.acBuf[inst]
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
a.acBuf[inst] = []acDecode{{d: d}}
a.acMu.Unlock()
a.autoCallJudge(inst, prev, prevAt, prevTR, buf)
return
}
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
a.acBuf[inst] = append(a.acBuf[inst], acDecode{d: d})
a.acMu.Unlock()
}
// 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
}
// One slot plus a margin: decodes for a period keep arriving for a
// second or two after it ends, and judging early would count a station
// as missing that is about to be listed.
if time.Since(a.acAt[inst]) < time.Duration(tr)*time.Second+4*time.Second {
continue
}
ready = append(ready, due{inst, key, a.acAt[inst], tr, a.acBuf[inst]})
delete(a.acPeriod, inst)
delete(a.acBuf, inst)
}
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()
quiet := a.acPeriod[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
}
cands := make([]autocall.Candidate, 0, len(uniq))
for _, dd := range uniq {
st := status[dd.d.Call+"|"+dd.d.Band+"|"+dd.d.Mode]
cands = append(cands, autocall.Candidate{
Decode: dd.d,
Need: autoCallNeedOf(st.Status),
Watched: a.autoCallWatched(dd.d.Call),
Worked: st.Status == "worked",
})
}
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)
}
// 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:
// autoTxOnly=false: stop now. The whole point of a brake is that it does
// not wait for the over in progress to finish.
if err := a.HaltDecodeTx("", false); 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()
}
// 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 arms the engine at launch.
//
// The stored "on" is honoured, and the engine starts with NO target: a program
// that came up already calling a station chosen before the last shutdown is not
// something an operator can be expected to anticipate.
func (a *App) startAutoCall() {
a.applyAutoCall()
go a.autoCallLoop()
}
func (a *App) autoCallLoop() {
t := time.NewTicker(2 * time.Second)
defer t.Stop()
for range t.C {
if a.ctx == nil {
return
}
a.autoCallSweep()
a.autoCallSilence()
}
}
+27 -4
View File
@@ -93,14 +93,27 @@ func keepKnownBands(want []string) []string {
return out 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 // 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 // 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, // 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 // and past 3000 km a "nearby" receiver is on the far side of a continent, which
// is the entire premise of measuring from a receiver rather than a transmitter. // 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 { func bandOpenNearKm(raw string) int {
n, err := strconv.Atoi(strings.TrimSpace(raw)) 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 pskr.DefaultNearKm
} }
return n return n
@@ -194,7 +207,17 @@ func (a *App) startBandOpenFeed() {
// hundred survived the NearKm test below. One ring of squares — about the // 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, // 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). // 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) var onGrid func(call, grid string)
if chaseGrids { if chaseGrids {
+182
View File
@@ -1,4 +1,186 @@
[ [
{
"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", "version": "0.27.5",
"date": "", "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")
}
}
+347 -238
View File
@@ -1,7 +1,7 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock, Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap, ChevronLeft, ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Star, Terminal, Trash2, Unlock, X, Zap,
} from 'lucide-react'; } from 'lucide-react';
import { import {
@@ -27,7 +27,7 @@ import {
GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate, GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate,
GetScpStatus, ScpLookup, GetScpStatus, ScpLookup,
OpenExternalURL, OpenExternalURL,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand, ConnectAllClusters, DisconnectAllClusters, ConnectClusterServer, DisconnectClusterServer, GetClusterStatus, SendClusterCommand,
ListClusterServers, ClusterSpotStatuses, SendClusterSpot, ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
GetCATSettings, PTTHotkeyDown, PTTHotkeyUp, GetCATSettings, PTTHotkeyDown, PTTHotkeyUp,
GetSolarData, GetSolarData,
@@ -54,11 +54,14 @@ import {
GetFlexState, FlexAmpOperate, GetFlexState, FlexAmpOperate,
GetPSKReporterStatus, GetLiveOpenings, GetChaseNew, GetPSKReporterStatus, GetLiveOpenings, GetChaseNew,
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair, QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
GetAutoCallStatus, SetAutoCall, SetAutoCallOnly, TakeAutoCallTarget, ResetAutoCall,
} from '../wailsjs/go/main/App'; } from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs'; import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
import { ListRadios, SetActiveRadio } from '../wailsjs/go/main/App'; import { ListRadios, SetActiveRadio } from '../wailsjs/go/main/App';
import { formatDistance } from '@/lib/units'; import { formatDistance } from '@/lib/units';
import { WIDGET_KEYS } from '@/components/AppearancePanel';
import { bandForMHz } from '@/lib/bandplan';
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime'; import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models'; import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -76,6 +79,7 @@ import { AutoEQSL } from '@/components/qsl/AutoEQSL';
import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ConfirmDialog } from '@/components/ConfirmDialog';
import { SettingsModal } from '@/components/SettingsModal'; import { SettingsModal } from '@/components/SettingsModal';
import { FTMapPanel } from '@/components/FTMapPanel'; import { FTMapPanel } from '@/components/FTMapPanel';
import { DXpeditionsPanel } from '@/components/DXpeditionsPanel';
import { FirstRunModal } from '@/components/FirstRunModal'; import { FirstRunModal } from '@/components/FirstRunModal';
import { QSOEditModal } from '@/components/QSOEditModal'; import { QSOEditModal } from '@/components/QSOEditModal';
import { BandMap } from '@/components/BandMap'; import { BandMap } from '@/components/BandMap';
@@ -100,7 +104,7 @@ import { ExportFieldsDialog } from '@/components/ExportFieldsDialog';
import { ShutdownProgress } from '@/components/ShutdownProgress'; import { ShutdownProgress } from '@/components/ShutdownProgress';
import { ClusterGrid } from '@/components/ClusterGrid'; import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot'; import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
import { applySpotDisplay, chasePota, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay'; import { applySpotDisplay, chaseCounty, chaseGrid, chasePfx, chasePota, chaseState, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
import { AnswerDecode, HaltDecodeTx, LogUIError, FlexTXOnBand, GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, GetSpotMax, IsNewUSCounty } from '../wailsjs/go/main/App'; import { AnswerDecode, HaltDecodeTx, LogUIError, FlexTXOnBand, GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, GetSpotMax, IsNewUSCounty } from '../wailsjs/go/main/App';
import { applyMatrixColors } from '@/lib/matrixColors'; import { applyMatrixColors } from '@/lib/matrixColors';
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
@@ -116,9 +120,9 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel'; import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
import { RotorCompass } from '@/components/RotorCompass'; import { RotorCompass } from '@/components/RotorCompass';
import { GridSquareMap } from '@/components/GridSquareMap'; import { GridSquareMap } from '@/components/GridSquareMap';
import { loadAutoCall, shouldAutoCall, autoCallKey, type AutoCallSettings } from '@/lib/autocall';
import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros'; import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros';
import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel'; import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel';
import { PSKReporterPanel } from '@/components/PSKReporterPanel';
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading'; import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { formatDateTimeUTC } from '@/lib/dateFormat'; import { formatDateTimeUTC } from '@/lib/dateFormat';
@@ -431,21 +435,6 @@ function entryQSYCommand(text: string): { band?: string; hz?: number } | null {
return null; return null;
} }
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 — kept in step with BandFromHz on the Go side.
[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 '';
}
// modeAccent maps a mode to a theme-aware colour for the live-stations widget: // modeAccent maps a mode to a theme-aware colour for the live-stations widget:
// CW gold, phone green, digital blue, unknown muted. // CW gold, phone green, digital blue, unknown muted.
@@ -774,6 +763,32 @@ export default function App() {
// Several amps side by side make a wide widget, so an operator running two // Several amps side by side make a wide widget, so an operator running two
// picks the one he watches while transmitting. Declared here because the poll // picks the one he watches while transmitting. Declared here because the poll
// below runs faster while the widget is open. // below runs faster while the widget is open.
// Widget order (Settings → Appearance). The row is a flex container, so the
// ORDER property moves a widget without touching the tree: every condition,
// every ref and every hook stays where it was, and a widget that is switched
// off simply is not there to be ordered.
//
// QSO entry and the F1-F5 panel are not in this list on purpose — they are
// not in this row at all, and an operator cannot be allowed to push the thing
// they type into behind a rotator dial.
const [widgetOrder, setWidgetOrder] = useState<string[]>(() => {
try {
const raw = localStorage.getItem('opslog.widgetOrder');
const arr = raw ? JSON.parse(raw) : null;
if (Array.isArray(arr) && arr.every((x) => typeof x === 'string')) return arr;
} catch { /* corrupt pref → the default order */ }
return [...WIDGET_KEYS];
});
useEffect(() => EventsOn('widgets:order', (keys: any) => {
if (Array.isArray(keys)) setWidgetOrder(keys.filter((k: any) => typeof k === 'string'));
}), []);
// A key the saved order has never heard of (a widget added since) goes to the
// end rather than to the front, where it would jump the queue on every
// upgrade.
const wOrder = (k: string) => {
const i = widgetOrder.indexOf(k);
return i < 0 ? WIDGET_KEYS.length + (WIDGET_KEYS as readonly string[]).indexOf(k) : i;
};
const [showAmpWidget, setShowAmpWidget] = useState(() => localStorage.getItem('opslog.showAmpWidget') !== '0'); const [showAmpWidget, setShowAmpWidget] = useState(() => localStorage.getItem('opslog.showAmpWidget') !== '0');
const [ampWidgetSel, setAmpWidgetSel] = useState(() => localStorage.getItem('opslog.ampSel.widget') || 'all'); const [ampWidgetSel, setAmpWidgetSel] = useState(() => localStorage.getItem('opslog.ampSel.widget') || 'all');
// Poll fast only while the amplifier widget is open: its meters must track TX // Poll fast only while the amplifier widget is open: its meters must track TX
@@ -1315,6 +1330,17 @@ export default function App() {
setActiveTab((t) => (t === 'grids' ? 'recent' : t)); setActiveTab((t) => (t === 'grids' ? 'recent' : t));
} }
const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1'); const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1');
const [dxpedTabOpen, setDxpedTabOpen] = useState(() => localStorage.getItem('opslog.dxpedTab') === '1');
function openDxpedTab() {
setDxpedTabOpen(true);
writeUiPref('opslog.dxpedTab', '1');
setActiveTab('dxped');
}
function closeDxpedTab() {
setDxpedTabOpen(false);
writeUiPref('opslog.dxpedTab', '0');
setActiveTab((t) => (t === 'dxped' ? 'recent' : t));
}
function openFtmapTab() { function openFtmapTab() {
setFtmapTabOpen(true); setFtmapTabOpen(true);
writeUiPref('opslog.ftmapTab', '1'); writeUiPref('opslog.ftmapTab', '1');
@@ -2100,7 +2126,41 @@ export default function App() {
// worked_slot must be carried explicitly like every other field: this map is // worked_slot must be carried explicitly like every other field: this map is
// assembled field by field, so a backend flag that nobody copies here simply // assembled field by field, so a backend flag that nobody copies here simply
// never reaches the panels — silently, since the extra key is just dropped. // never reaches the panels — silently, since the extra key is just dropped.
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; worked_slot?: boolean; new_county?: boolean; county?: string; state?: string; lotw?: boolean; spotter_continent?: string; grid?: string; new_grid?: boolean; new_state?: boolean; new_pota?: boolean; unconf_status?: boolean; unconf_pfx?: boolean; unconf_cty?: boolean; unconf_state?: boolean; new_pfx?: boolean; pfx?: string }>>({}); // spotStatusEntry maps ONE backend verdict onto the entry the panels read.
//
// There were four of these, written by hand at each call site, and they had
// drifted apart: the two that RE-fetch — after a QSO is logged, and when a
// pane becomes visible — rebuilt every entry without the unconfirmed flags.
// So a badge drawn correctly as "worked, QSL missing" turned solid at the
// next refresh and stayed that way, which is how South Africa came to read
// NEW BAND on a band with four contacts in the log. grid_state was never
// copied by any of them, so a known-but-unconfirmed square never dimmed at
// all. One function now, so a field added to the backend cannot reach three
// callers and miss the fourth.
const spotStatusEntry = (r: any) => ({
status: r.status ?? '',
country: r.country,
continent: r.continent,
worked_call: !!r.worked_call,
worked_slot: !!r.worked_slot,
new_county: !!r.new_county,
lotw: !!r.lotw,
spotter_continent: r.spotter_continent,
grid: r.grid,
grid_state: r.grid_state,
new_grid: !!r.new_grid,
county: r.county,
state: r.state,
new_state: !!r.new_state,
new_pota: !!r.new_pota,
new_pfx: !!r.new_pfx,
pfx: r.pfx,
unconf_status: !!r.unconf_status,
unconf_pfx: !!r.unconf_pfx,
unconf_cty: !!r.unconf_cty,
unconf_state: !!r.unconf_state,
});
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; worked_slot?: boolean; new_county?: boolean; county?: string; state?: string; lotw?: boolean; spotter_continent?: string; grid?: string; grid_state?: string; new_grid?: boolean; new_state?: boolean; new_pota?: boolean; unconf_status?: boolean; unconf_pfx?: boolean; unconf_cty?: boolean; unconf_state?: boolean; new_pfx?: boolean; pfx?: string }>>({});
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots // Live mirror of spotStatus so the incoming-spot buffer can tell which slots
// still need resolving without re-subscribing the cluster:spot listener. // still need resolving without re-subscribing the cluster:spot listener.
const spotStatusRef = useRef(spotStatus); const spotStatusRef = useRef(spotStatus);
@@ -2173,11 +2233,7 @@ export default function App() {
const next = { ...prev }; const next = { ...prev };
for (const r of res) { for (const r of res) {
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`; const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
next[k] = { next[k] = spotStatusEntry(r);
status: r.status ?? '', country: r.country, continent: (r as any).continent,
worked_call: !!(r as any).worked_call, worked_slot: !!(r as any).worked_slot, new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state,
new_pota: !!(r as any).new_pota, new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx,
};
} }
return next; return next;
}); });
@@ -2346,6 +2402,18 @@ export default function App() {
}, [showSettings]); }, [showSettings]);
const [showDuplicates, setShowDuplicates] = useState(false); const [showDuplicates, setShowDuplicates] = useState(false);
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null); const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
// Watchlist membership changes announce themselves here rather than inside
// the watchlist panel: a call can be added from the cluster or the
// DXpeditions tab, and the old inline message lived on a page nobody was
// looking at.
const [wlNotice, setWlNotice] = useState<{ call: string; added: boolean } | null>(null);
const wlNoticeTimer = useRef<number | undefined>(undefined);
useEffect(() => EventsOn('watchlist:changed', (e: any) => {
if (!e?.call) return;
setWlNotice({ call: String(e.call), added: !!e.added });
if (wlNoticeTimer.current) window.clearTimeout(wlNoticeTimer.current);
wlNoticeTimer.current = window.setTimeout(() => setWlNotice(null), 4000);
}), []);
const [checkingUpdate, setCheckingUpdate] = useState(false); const [checkingUpdate, setCheckingUpdate] = useState(false);
// Fresh update check on demand (opening About), so it never shows a stale // Fresh update check on demand (opening About), so it never shows a stale
// "you're up to date". Clears updateInfo when the latest check finds nothing. // "you're up to date". Clears updateInfo when the latest check finds nothing.
@@ -2498,136 +2566,34 @@ export default function App() {
// ── Auto-call ────────────────────────────────────────────────────── // ── Auto-call ──────────────────────────────────────────────────────
// //
// Answers a decode without the operator clicking it. The DECISION lives in // The DECISION is in the BACKEND (internal/autocall), because it keys a
// lib/autocall (one pure function, so the dangerous part can be read and // transmitter: it has to behave the same whether this tab is open or the
// argued with); this is only the plumbing that runs it and keys the radio. // window is minimised, and every rule it applies — seven calls, three missed
// // periods, the ladder — is a Go test rather than something only the air can
// Re-read when Preferences closes, like every other setting edited there. // check. What is left here is the switch, the readout, and handing over the
// station the operator clicks.
// The named command buttons, re-read when Preferences closes like every other // The named command buttons, re-read when Preferences closes like every other
// setting edited there. // setting edited there.
const [clusterMacros, setClusterMacros] = useState(loadClusterMacros); const [clusterMacros, setClusterMacros] = useState(loadClusterMacros);
useEffect(() => { if (!showSettings) setClusterMacros(loadClusterMacros()); }, [showSettings]); useEffect(() => { if (!showSettings) setClusterMacros(loadClusterMacros()); }, [showSettings]);
const clusterMacrosShown = useMemo(() => visibleClusterMacros(clusterMacros), [clusterMacros]); const clusterMacrosShown = useMemo(() => visibleClusterMacros(clusterMacros), [clusterMacros]);
// Auto-call is withdrawn — it duplicated DXHunter, which answers decodes from const [autoCallStatus, setAutoCallStatus] = useState<any>({ enabled: false, target: '', calls: 0, max: 0, misses: 0, max_miss: 0, stopped: false, reason: '' });
// the same shack. This flag is the single place that says so at runtime.
const AUTO_CALL_ENABLED = false;
const [autoCall, setAutoCall] = useState<AutoCallSettings>(loadAutoCall);
useEffect(() => { if (!showSettings) setAutoCall(loadAutoCall()); }, [showSettings]);
// When each callsign was last answered, so a station still calling CQ is not
// re-answered every slot while the QSO it started is still running.
const autoCalledRef = useRef<Map<string, number>>(new Map());
// Decodes are scanned once. Without this the same decode is reconsidered on
// every status refresh, and a cooldown that has just expired would fire again
// on a decode minutes old.
const autoSeenRef = useRef<Set<string>>(new Set());
// Set when a call goes out, so nothing else fires until the receiver's own
// status catches up and `busy` can be trusted again.
const autoHoldUntilRef = useRef(0);
// The station auto-call is currently working, and when it started.
//
// This is OpsLog's OWN record of "a QSO is running", and it exists because
// deriving that from the sender's Status was not enough: the moment
// WSJT-X/JTDX drops the DX call or the Enable-Tx flag between overs — which
// they do — the exchange looks finished and the next CQ gets answered,
// interleaving two and then three QSOs on one slice. A lock we set ourselves
// cannot be cleared by a flag we do not control.
//
// Released when that station's QSO is logged, when the operator halts or
// takes over by clicking a decode, and by the watchdog below.
const autoTargetRef = useRef<{ call: string; at: number } | null>(null);
// An exchange abandoned mid-way must not lock auto-call out for ever: four
// minutes covers a repeated FT8 QSO and still frees the next period soon
// enough to matter.
const AUTO_TARGET_MAX_MS = 240_000;
// Per receiver, when the carrier was last up. Feeds the stale-exchange
// backstop below.
const lastTxAtRef = useRef<Map<string, number>>(new Map());
useEffect(() => { useEffect(() => {
// AUTO-CALL IS WITHDRAWN — DXHunter already answers decodes, and two GetAutoCallStatus().then(setAutoCallStatus).catch(() => {});
// programs doing it from one shack key over each other. See lib/autocall.ts. // Pushed by the engine on every decision, and polled as well: the push is
// // what makes the counter move the instant a call goes out, the poll is what
// Returning here rather than deleting the loop: the decision it implements // recovers a status missed while the window was asleep.
// is the delicate part, argued over and tested, and worth keeping intact. const off = EventsOn('autocall:status', (s: any) => setAutoCallStatus(s));
// The guard is what matters — an operator whose stored preference still const id = window.setInterval(() => { GetAutoCallStatus().then(setAutoCallStatus).catch(() => {}); }, 3000);
// says "enabled" must not have their transmitter keyed by a feature they return () => { off(); window.clearInterval(id); };
// can no longer see, let alone switch off. }, []);
if (!AUTO_CALL_ENABLED) return; const toggleAutoCall = () => {
if (!autoCall.enabled) return; const next = !autoCallStatus?.enabled;
const now = Date.now(); setAutoCallStatus((s: any) => ({ ...s, enabled: next }));
// A QSO is in progress somewhere if ANY receiver is transmitting or still SetAutoCall(next).catch((e: any) => setError(String(e?.message ?? e)));
// holding a DX call it has not finished with. Both matter: between overs the };
// carrier is down but the exchange is not over, and calling someone else
// then is exactly the "it never stops" behaviour.
const busy = Object.values(txStates).some((tx) => {
if (tx?.transmitting) return true;
// A DX call still set means the exchange is not finished — but ONLY while
// the sender still intends to transmit. The watchdog stops transmission
// and leaves the DX call behind, and reading the call alone left auto-call
// waiting for a QSO that had already been given up on, for ever.
if (!tx?.dx_call?.trim()) return false;
if (tx.tx_enabled === false) return false;
// Backstop for a sender that never reports the toggle: an exchange with no
// transmission for three minutes is over, whatever the DX call still says.
// Measured from the last TIME THE CARRIER WAS UP — Status itself arrives
// every second and so can never go stale.
const last = lastTxAtRef.current.get(tx.instance ?? '');
return last === undefined || now - last < 180_000;
})
// The hold closes the gap between sending a Reply and the receiver saying
// it has acted on it — about a second. Without it the OTHER instance still
// looks idle in that window and gets a call of its own.
|| now < autoHoldUntilRef.current;
// Our own lock, evaluated after the watchdog so an abandoned exchange does
// not hold the transmitter shut.
if (autoTargetRef.current && now - autoTargetRef.current.at > AUTO_TARGET_MAX_MS) {
LogUIError('auto-call', `giving up on ${autoTargetRef.current.call} — nothing logged in four minutes`, '');
autoTargetRef.current = null;
}
const locked = busy || autoTargetRef.current !== null;
for (const d of decodes) {
const seenKey = `${d.call}|${d.ms ?? d.at}|${d.instance ?? ''}`;
if (autoSeenRef.current.has(seenKey)) continue;
// A Replay's resent history is display-only: answering a line the far
// end already dropped would fail anyway, and doing it at startup — the
// moment replays arrive — would be a transmitter firing on old news.
if ((d as any).is_new === false) { autoSeenRef.current.add(seenKey); continue; }
// Only decodes from the CURRENT period are worth answering: replying to a
// slot that has closed asks the far end to match a decode it has dropped.
if (now - Date.parse(d.at) > 30_000) { autoSeenRef.current.add(seenKey); continue; }
const e = spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`];
// NOT marked seen until its status has resolved. Statuses land a few
// hundred milliseconds after the decode, so consuming it on first sight
// would throw away almost every decode unjudged — the effect re-runs when
// spotStatus changes, and this is what lets it look again.
if (!e) continue;
autoSeenRef.current.add(seenKey);
const verdict = shouldAutoCall(autoCall, d, e as any, {
busy: locked,
calledAt: autoCalledRef.current,
now,
myCall: station.callsign,
});
if (!verdict.call) continue;
autoCalledRef.current.set(d.call.toUpperCase(), now);
autoHoldUntilRef.current = now + 12_000; // an FT8 slot, near enough
autoTargetRef.current = { call: d.call.toUpperCase(), at: now };
// Same reason as a manual click: put the transmitter on the decode's band
// before answering, or a second slice answers on the wrong one.
FlexTXOnBand(d.band ?? '').catch(() => {});
// Logged, always: an automatic transmission with no record of WHY is the
// one thing an operator cannot argue with after the fact.
LogUIError('auto-call', `calling ${d.call}${verdict.reason}`, '');
AnswerDecode(
d.instance ?? '', d.ms ?? 0, d.snr, d.dt ?? 0,
d.audio_hz ?? 0, d.mode_raw || d.mode || '', d.msg_raw ?? d.msg ?? '', !!d.low_conf,
).catch((e2: any) => setError(String(e2?.message ?? e2)));
onCallsignInput(d.call, { force: true });
break; // one per pass: a period can hold several, and we work one station
}
// The seen-set is bounded by the same half hour the decode list keeps.
if (autoSeenRef.current.size > 20000) autoSeenRef.current.clear();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [decodes, spotStatus, autoCall, txStates]);
// Staged like the cluster's, so a period arriving as one burst of fifty // Staged like the cluster's, so a period arriving as one burst of fifty
// packets costs one status lookup and one render, not fifty of each. // packets costs one status lookup and one render, not fifty of each.
const pendingDecodesRef = useRef<DecodeRow[]>([]); const pendingDecodesRef = useRef<DecodeRow[]>([]);
@@ -2779,6 +2745,14 @@ export default function App() {
const [showChaseNew, setShowChaseNew] = useState(() => localStorage.getItem('opslog.showChaseNew') !== '0'); const [showChaseNew, setShowChaseNew] = useState(() => localStorage.getItem('opslog.showChaseNew') !== '0');
const refreshChaseNew = useCallback(() => { GetChaseNew().then(setChaseNewOn).catch(() => {}); }, []); const refreshChaseNew = useCallback(() => { GetChaseNew().then(setChaseNewOn).catch(() => {}); }, []);
useEffect(() => { refreshChaseNew(); }, [refreshChaseNew]); useEffect(() => { refreshChaseNew(); }, [refreshChaseNew]);
// The PSK Reporter panel beside the decodes. Open/closed is remembered; the
// TARGET is whichever station is being worked, so it follows a click on a
// decode and the DX call the digital application reports, and nothing has to
// be selected twice.
const [pskPanelOpen, setPskPanelOpen] = useState(() => localStorage.getItem('opslog.pskPanel') === '1');
useEffect(() => { try { localStorage.setItem('opslog.pskPanel', pskPanelOpen ? '1' : '0'); } catch { /* private mode */ } }, [pskPanelOpen]);
const [pskTarget, setPskTarget] = useState('');
const [pskTargetMode, setPskTargetMode] = useState('');
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0'); const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
// Compact rotor widget (Settings → Rotator): dial + SP/LP only. RotorCompass // Compact rotor widget (Settings → Rotator): dial + SP/LP only. RotorCompass
// already draws exactly that when it is given neither presets nor onStop — // already draws exactly that when it is given neither presets nor onStop —
@@ -3644,22 +3618,7 @@ export default function App() {
const next = { ...prev }; const next = { ...prev };
for (const r of res) { for (const r of res) {
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`; const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
next[k] = { next[k] = spotStatusEntry(r);
status: r.status ?? '',
country: r.country,
continent: (r as any).continent,
worked_call: !!(r as any).worked_call,
worked_slot: !!(r as any).worked_slot,
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state,
new_state: !!(r as any).new_state,
unconf_status: !!(r as any).unconf_status,
unconf_pfx: !!(r as any).unconf_pfx,
unconf_cty: !!(r as any).unconf_cty,
unconf_state: !!(r as any).unconf_state,
new_pota: !!(r as any).new_pota,
new_pfx: !!(r as any).new_pfx,
pfx: (r as any).pfx,
};
} }
return next; return next;
}); });
@@ -3753,23 +3712,7 @@ export default function App() {
const next = { ...prev }; const next = { ...prev };
for (const r of res) { for (const r of res) {
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`; const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
next[k] = { next[k] = spotStatusEntry(r);
status: r.status ?? '',
country: r.country,
continent: (r as any).continent,
worked_call: !!(r as any).worked_call,
worked_slot: !!(r as any).worked_slot,
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw,
grid: (r as any).grid, new_grid: !!(r as any).new_grid,
county: (r as any).county, state: (r as any).state,
new_state: !!(r as any).new_state,
unconf_status: !!(r as any).unconf_status,
unconf_pfx: !!(r as any).unconf_pfx,
unconf_cty: !!(r as any).unconf_cty,
unconf_state: !!(r as any).unconf_state,
new_pota: !!(r as any).new_pota,
new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx,
};
} }
return next; return next;
}); });
@@ -3800,11 +3743,6 @@ export default function App() {
setTxState(m as TxMsgRow); setTxState(m as TxMsgRow);
if (m?.instance) { if (m?.instance) {
setTxStates((prev) => ({ ...prev, [m.instance]: m as TxMsgRow })); setTxStates((prev) => ({ ...prev, [m.instance]: m as TxMsgRow }));
// When this receiver last actually TRANSMITTED, which is not the same as
// when it last spoke: Status arrives about once a second whether the
// carrier is up or not, so it can never say how long an exchange has
// been stalled.
if (m.transmitting) lastTxAtRef.current.set(m.instance, Date.now());
} }
// The period history takes only real transmissions — Status repeats // The period history takes only real transmissions — Status repeats
// itself once a second whether the carrier is up or not. // itself once a second whether the carrier is up or not.
@@ -3921,11 +3859,6 @@ export default function App() {
try { try {
await LogUDPLoggedADIF(text); await LogUDPLoggedADIF(text);
await refresh(); await refresh();
// The QSO auto-call started has finished — release the lock so the next
// CQ can be answered. Matched on the callsign: a QSO logged from
// somewhere else must not free a run that is still going.
const logged = /<call:d+(?::[^>]*)?>([^<s]+)/i.exec(text)?.[1]?.toUpperCase();
if (logged && autoTargetRef.current?.call === logged) autoTargetRef.current = null;
} catch (e: any) { } catch (e: any) {
const msg = String(e?.message ?? e); const msg = String(e?.message ?? e);
// A re-broadcast of an already-logged QSO (Log4OM/WSJT-X) is benign — // A re-broadcast of an already-logged QSO (Log4OM/WSJT-X) is benign —
@@ -4267,17 +4200,7 @@ export default function App() {
const next = { ...prev }; const next = { ...prev };
for (const r of res) { for (const r of res) {
const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`; const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
next[k] = { next[k] = spotStatusEntry(r);
status: r.status ?? '',
country: r.country,
continent: (r as any).continent,
worked_call: !!(r as any).worked_call,
worked_slot: !!(r as any).worked_slot,
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state,
new_pota: !!(r as any).new_pota,
new_pfx: !!(r as any).new_pfx,
pfx: (r as any).pfx,
};
} }
return next; return next;
}); });
@@ -5126,6 +5049,7 @@ export default function App() {
]}, ]},
{ name: 'tools', label: t('menu.tools'), items: [ { name: 'tools', label: t('menu.tools'), items: [
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' }, { type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
{ type: 'item', label: t('dxp.tab'), action: 'tools.dxped' },
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' }, { type: 'item', label: t('stats.tab'), action: 'tools.stats' },
{ type: 'item', label: t('station.title'), action: 'tools.station' }, { type: 'item', label: t('station.title'), action: 'tools.station' },
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' }, { type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
@@ -5155,6 +5079,8 @@ export default function App() {
{ type: 'separator' }, { type: 'separator' },
{ type: 'item', label: t('logview.title'), action: 'help.log' }, { type: 'item', label: t('logview.title'), action: 'help.log' },
{ type: 'separator' }, { type: 'separator' },
{ type: 'item', label: t('help.discord'), action: 'help.discord' },
{ type: 'separator' },
{ type: 'item', label: t('help.donate'), action: 'help.donate', accent: true }, { type: 'item', label: t('help.donate'), action: 'help.donate', accent: true },
{ type: 'separator' }, { type: 'separator' },
{ type: 'item', label: t('help.about'), action: 'help.about' }, { type: 'item', label: t('help.about'), action: 'help.about' },
@@ -5180,6 +5106,7 @@ export default function App() {
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break; case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break; case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break; case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
case 'tools.dxped': openDxpedTab(); break;
case 'tools.decodes': openDecodesTab(); break; case 'tools.decodes': openDecodesTab(); break;
case 'tools.ftmap': openFtmapTab(); break; case 'tools.ftmap': openFtmapTab(); break;
case 'tools.grids': openGridsTab(); break; case 'tools.grids': openGridsTab(); break;
@@ -5198,6 +5125,7 @@ export default function App() {
case 'help.sendlog': sendLogToDeveloper(); break; case 'help.sendlog': sendLogToDeveloper(); break;
// Opens in the system browser, NOT the app WebView: a payment page must show // Opens in the system browser, NOT the app WebView: a payment page must show
// the address bar and padlock the donor knows how to check. // the address bar and padlock the donor knows how to check.
case 'help.discord': BrowserOpenURL('https://discord.gg/8ZsPDmH9q2'); break;
case 'help.donate': BrowserOpenURL('https://www.paypal.com/donate/?hosted_button_id=PDMY7KV99K38S'); break; case 'help.donate': BrowserOpenURL('https://www.paypal.com/donate/?hosted_button_id=PDMY7KV99K38S'); break;
} }
} }
@@ -6261,7 +6189,11 @@ export default function App() {
// worked spots; the separate "Hide worked" checkbox drops them — they // worked spots; the separate "Hide worked" checkbox drops them — they
// are opposite controls, so don't use both at once. // are opposite controls, so don't use both at once.
{ k: 'worked' as SpotFilterKey, label: 'WORKED', cls: 'bg-info-muted text-info-muted-foreground border-info-border' }, { k: 'worked' as SpotFilterKey, label: 'WORKED', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
]).filter((s: any) => s.k !== 'new-pota' || chasePota()) ]).filter((s: any) => (s.k !== 'new-pota' || chasePota())
&& (s.k !== 'new-county' || chaseCounty())
&& (s.k !== 'new-pfx' || chasePfx())
&& (s.k !== 'new-grid' || chaseGrid())
&& (s.k !== 'new-state' || chaseState()))
.map((s: any) => fChip(s.k, s.label, s.cls, clusterStatusFilter.has(s.k), .map((s: any) => fChip(s.k, s.label, s.cls, clusterStatusFilter.has(s.k),
() => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; }), s.style))} () => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; }), s.style))}
</div>, </div>,
@@ -6316,26 +6248,108 @@ export default function App() {
// The FT decodes panel, built in ONE place: it is offered both as a tab and as // The FT decodes panel, built in ONE place: it is offered both as a tab and as
// a Main-view pane, and two copies of this call would be two sets of props to // a Main-view pane, and two copies of this call would be two sets of props to
// keep in step. // keep in step.
// The dial the decodes are being read against. It has to be the DECODER's,
// not the rig's: a report at 14.074.850 is only "+850 Hz in his passband"
// measured from the same dial the digital application is using, and on split
// or with a transverter the rig's own frequency is a different number.
const pskDialHz = useMemo(() => {
for (const d of decodes) if (d.dial_hz && d.dial_hz > 0) return d.dial_hz;
return txState?.freq_hz ?? 0;
}, [decodes, txState?.freq_hz]);
// Stations WE are decoding that are calling the same DX — the competition at
// this end, which PSK Reporter cannot see: it carries who was heard, never
// who they were calling.
const pskCallers = useMemo(() => {
if (!pskTarget) return [] as string[];
const want = pskTarget.toUpperCase() + ' ';
const seen = new Set<string>();
for (const d of decodes) {
const msg = (d.msg ?? '').toUpperCase().trim();
if (!msg.startsWith(want)) continue;
const caller = msg.slice(want.length).trim().split(/\s+/)[0];
// "CQ" cannot be a caller, and neither can our own transmission coming
// back as a decode of ourselves.
if (caller && caller !== 'CQ' && caller !== (station.callsign ?? '').toUpperCase()) seen.add(caller);
}
return [...seen];
}, [decodes, pskTarget, station.callsign]);
// Follow the station the digital application says it is calling. A decode
// clicked here sets the target directly (see onCall); this covers the QSO
// started from the other side — WSJT-X's own double-click, or auto-call.
useEffect(() => {
const dx = (txState?.dx_call ?? '').toUpperCase().trim();
if (dx && dx !== pskTarget) {
setPskTarget(dx);
setPskTargetMode(txState?.mode ?? '');
}
}, [txState?.dx_call, txState?.mode, pskTarget]);
const renderDecodesPanel = () => ( const renderDecodesPanel = () => (
<div className="flex h-full min-h-0">
<div className="flex-1 min-w-0 flex flex-col min-h-0">
{renderDecodesList()}
</div>
{pskPanelOpen ? (
<PSKReporterPanel
target={pskTarget}
mode={pskTargetMode}
dialHz={pskDialHz}
callers={pskCallers.length}
callerCalls={pskCallers}
onCollapse={() => setPskPanelOpen(false)}
/>
) : (
// Collapsed to a strip rather than removed: a panel with no way back is
// one an operator loses, and the button has to say what it opens.
<button type="button" onClick={() => setPskPanelOpen(true)} title={t('psk.show')}
className="w-7 shrink-0 border-l border-border bg-card hover:bg-muted flex flex-col items-center gap-2 py-2 text-muted-foreground hover:text-foreground">
<ChevronLeft className="size-4" />
<span className="text-[10px] font-semibold uppercase tracking-wider [writing-mode:vertical-rl]">
{t('psk.title')}
</span>
</button>
)}
</div>
);
const renderDecodesList = () => (
<DecodesPanel <DecodesPanel
decodes={decodes} decodes={decodes}
txMsgs={txMsgs} txMsgs={txMsgs}
txState={txState} txState={txState}
txStates={txStates} txStates={txStates}
spotStatus={spotStatus as any} spotStatus={spotStatus as any}
// Only while CAT is actually connected: an empty band means "nothing to
// compare with", never "the rig is on no band".
rigBand={catState.connected ? (catState.band || '') : ''}
myCall={station.callsign} myCall={station.callsign}
// A click ANSWERS the station: it hands the decode back to WSJT-X/MSHV as // A DOUBLE click answers the station: it hands the decode back to
// a Reply, which is the same thing as double-clicking the line in their // WSJT-X/MSHV as a Reply, the same thing as double-clicking the line in
// own window. // their own window. A single click only selects it — see onSelect below,
// and the cluster, where the two gestures already mean this.
// //
// Deliberately NOT a rig tune, unlike a cluster spot. On FT8 the whole // Deliberately NOT a rig tune, unlike a cluster spot. On FT8 the whole
// band is inside one passband, so moving the dial changes nothing about // band is inside one passband, so moving the dial changes nothing about
// who gets answered — and it would only fight the digital application // who gets answered — and it would only fight the digital application
// for the VFO. The entry is still filled, so the QSO can be logged here. // for the VFO. The entry is still filled, so the QSO can be logged here.
// One click: take the station, transmit nothing. The entry is filled and
// the panels follow it, exactly as clicking a cluster spot does — so a
// row can be inspected, looked up and read about without keying up.
onSelect={(d) => {
setPskTarget((d.call ?? '').toUpperCase());
setPskTargetMode(d.mode ?? '');
onCallsignInput(d.call, { force: true });
}}
onCall={(d) => { onCall={(d) => {
// The operator has picked a station: that is now the QSO in progress, so // The station being answered is also the one worth analysing: the PSK
// auto-call must not answer someone else over the top of it. // Reporter panel follows the click rather than asking for a second one.
autoTargetRef.current = { call: (d.call ?? '').toUpperCase(), at: Date.now() }; setPskTarget((d.call ?? '').toUpperCase());
setPskTargetMode(d.mode ?? '');
// Auto-call adopts the station: the click chooses WHO, the watchdogs
// still decide how long it is called for.
TakeAutoCallTarget(d.call ?? '', d.band ?? '', d.mode ?? '').catch(() => {});
onCallsignInput(d.call, { force: true }); onCallsignInput(d.call, { force: true });
// With two slices on two bands, the Reply reaches the right INSTANCE but // With two slices on two bands, the Reply reaches the right INSTANCE but
// the radio still transmits on whichever slice holds the TX flag. Move // the radio still transmits on whichever slice holds the TX flag. Move
@@ -6354,7 +6368,6 @@ export default function App() {
// buffer goes too or the next flush would put back what was just cleared. // buffer goes too or the next flush would put back what was just cleared.
onClear={(instance) => { onClear={(instance) => {
if (!instance) { if (!instance) {
autoTargetRef.current = null;
pendingDecodesRef.current = []; pendingDecodesRef.current = [];
setDecodes([]); setDecodes([]);
setTxMsgs([]); setTxMsgs([]);
@@ -6372,10 +6385,20 @@ export default function App() {
// An empty instance lets the backend fall back to whichever application // An empty instance lets the backend fall back to whichever application
// last reported its status — the normal single-receiver case. // last reported its status — the normal single-receiver case.
onHalt={(instance) => { onHalt={(instance) => {
// Halt means stop, including whatever auto-call had started. // Halt means stop, including whatever auto-call had started — and it
autoTargetRef.current = null; // clears the engine's state, so a target it had given up on is not
// still sitting there when the operator switches it back on.
ResetAutoCall().catch(() => {});
HaltDecodeTx(instance, false).catch((e: any) => setError(String(e?.message ?? e))); HaltDecodeTx(instance, false).catch((e: any) => setError(String(e?.message ?? e)));
}} }}
autoCallOn={!!autoCallStatus?.enabled}
onToggleAutoCall={toggleAutoCall}
autoCall={autoCallStatus}
autoCallOnly={autoCallStatus?.only ?? ''}
onSetAutoCallOnly={(list) => {
setAutoCallStatus((st: any) => ({ ...st, only: list.toUpperCase() }));
SetAutoCallOnly(list).catch((e: any) => setError(String(e?.message ?? e)));
}}
/> />
); );
@@ -7073,6 +7096,26 @@ export default function App() {
<FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} /> <FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} />
)} )}
{wlNotice && (
<div className={cn('fixed right-4 z-[150] w-72 rounded-lg border bg-card shadow-xl p-3 animate-in slide-in-from-bottom-2 fade-in',
wlNotice.added ? 'border-warning/40' : 'border-border',
// Stacked above the update card when both are up.
updateInfo ? 'bottom-44' : 'bottom-4')}>
<div className="flex items-start gap-2">
<Star className={cn('size-4 mt-0.5 shrink-0', wlNotice.added ? 'fill-current text-warning' : 'text-muted-foreground')} />
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">
{t(wlNotice.added ? 'wlnote.added' : 'wlnote.removed', { call: wlNotice.call })}
</p>
<p className="text-xs text-muted-foreground">{t('wlnote.hint')}</p>
</div>
<button onClick={() => setWlNotice(null)}
className="shrink-0 text-muted-foreground hover:text-foreground" aria-label="Close">
<X className="size-3.5" />
</button>
</div>
</div>
)}
{updateInfo && ( {updateInfo && (
<div className="fixed bottom-4 right-4 z-[150] w-80 rounded-lg border border-primary/40 bg-card shadow-xl p-3 animate-in slide-in-from-bottom-2 fade-in"> <div className="fixed bottom-4 right-4 z-[150] w-80 rounded-lg border border-primary/40 bg-card shadow-xl p-3 animate-in slide-in-from-bottom-2 fade-in">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
@@ -7386,6 +7429,7 @@ export default function App() {
band={band} band={band}
mode={mode} mode={mode}
bands={bands} bands={bands}
modes={modes}
satellites={satellites} satellites={satellites}
onEditQso={openEdit} onEditQso={openEdit}
{...(!callsign.trim() && selQso ? { {...(!callsign.trim() && selQso ? {
@@ -7411,7 +7455,7 @@ export default function App() {
{/* Multi-op "who's on air" widget: every operator on the shared logbook, {/* Multi-op "who's on air" widget: every operator on the shared logbook,
their freq/mode (colour-coded) and OpsLog version. */} their freq/mode (colour-coded) and OpsLog version. */}
{showLiveStations && dbConn?.backend === 'mysql' && ( {showLiveStations && dbConn?.backend === 'mysql' && (
<div className="w-[248px] shrink-0 min-h-0 relative"> <div className="w-[248px] shrink-0 min-h-0 relative" style={{ order: wOrder('livestations') }}>
<div className="absolute inset-0 flex flex-col min-h-0 rounded-xl border border-border bg-card shadow-sm overflow-hidden"> <div className="absolute inset-0 flex flex-col min-h-0 rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-1.5 px-3 h-8 border-b border-border shrink-0"> <div className="flex items-center gap-1.5 px-3 h-8 border-b border-border shrink-0">
<Radio className="size-3.5 text-primary" /> <Radio className="size-3.5 text-primary" />
@@ -7455,7 +7499,7 @@ export default function App() {
// relative + absolute inner: the chat takes the row height (set by the // relative + absolute inner: the chat takes the row height (set by the
// entry strip) WITHOUT its message list growing the row, like the // entry strip) WITHOUT its message list growing the row, like the
// Stats panel. The list scrolls inside this fixed height. // Stats panel. The list scrolls inside this fixed height.
<div className="w-[280px] shrink-0 min-h-0 relative"> <div className="w-[280px] shrink-0 min-h-0 relative" style={{ order: wOrder('chat') }}>
<div className="absolute inset-0 flex flex-col min-h-0"> <div className="absolute inset-0 flex flex-col min-h-0">
<ChatPanel msgs={chatMsgs} online={chatOnline} myCall={station.callsign} <ChatPanel msgs={chatMsgs} online={chatOnline} myCall={station.callsign}
onSend={chatSend} onClose={() => setChatOpen(false)} /> onSend={chatSend} onClose={() => setChatOpen(false)} />
@@ -7467,7 +7511,7 @@ export default function App() {
controls column, so the widget is just the dial and needs only its controls column, so the widget is just the dial and needs only its
width. */} width. */}
{showRotor && (rotatorHeading.enabled || dxPath) && ( {showRotor && (rotatorHeading.enabled || dxPath) && (
<div className={cn('shrink-0 min-h-0', rotorCompact ? 'w-[196px]' : 'w-[320px]')}> <div className={cn('shrink-0 min-h-0', rotorCompact ? 'w-[196px]' : 'w-[320px]')} style={{ order: wOrder('rotor') }}>
<RotorCompass <RotorCompass
presets={rotorCompact ? undefined : rotorPresets} presets={rotorCompact ? undefined : rotorPresets}
onStop={rotorCompact ? undefined : () => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }} onStop={rotorCompact ? undefined : () => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
@@ -7487,7 +7531,7 @@ export default function App() {
</div> </div>
)} )}
{showMotorAnt && ubStatus.enabled && ( {showMotorAnt && ubStatus.enabled && (
<div className="w-[230px] shrink-0 min-h-0"> <div className="w-[230px] shrink-0 min-h-0" style={{ order: wOrder('motorant') }}>
<MotorAntennaWidget <MotorAntennaWidget
ant={ubStatus} ant={ubStatus}
refetch={pokeUbStatus} refetch={pokeUbStatus}
@@ -7498,7 +7542,7 @@ export default function App() {
</div> </div>
)} )}
{showAntGenius && agEnabled && ( {showAntGenius && agEnabled && (
<div className="w-[230px] shrink-0 min-h-0"> <div className="w-[230px] shrink-0 min-h-0" style={{ order: wOrder('antgenius') }}>
<AntGeniusPanel <AntGeniusPanel
status={agStatus} status={agStatus}
onActivate={agActivate} onActivate={agActivate}
@@ -7511,7 +7555,7 @@ export default function App() {
// One column per amplifier shown, so two amps stand side by side // One column per amplifier shown, so two amps stand side by side
// rather than making the widget twice as tall as the dock row. // rather than making the widget twice as tall as the dock row.
<div className="shrink-0 min-h-0" <div className="shrink-0 min-h-0"
style={{ width: `${Math.min(ampWidgetSel === 'all' ? ampSts.length : 1, 3) * 250 + 20}px` }}> style={{ width: `${Math.min(ampWidgetSel === 'all' ? ampSts.length : 1, 3) * 250 + 20}px`, order: wOrder('amp') }}>
<AmpWidget <AmpWidget
amps={ampSts} amps={ampSts}
flex={flexAmp} flex={flexAmp}
@@ -7521,7 +7565,7 @@ export default function App() {
</div> </div>
)} )}
{showTuner && tgEnabled && ( {showTuner && tgEnabled && (
<div className="w-[230px] shrink-0 min-h-0"> <div className="w-[230px] shrink-0 min-h-0" style={{ order: wOrder('tuner') }}>
<TunerGeniusPanel <TunerGeniusPanel
status={tgStatus} status={tgStatus}
onTune={tgTune} onTune={tgTune}
@@ -7533,7 +7577,7 @@ export default function App() {
</div> </div>
)} )}
{showScp && scpEnabled && ( {showScp && scpEnabled && (
<div className="w-[240px] shrink-0 min-h-0"> <div className="w-[240px] shrink-0 min-h-0" style={{ order: wOrder('scp') }}>
<ScpPanel <ScpPanel
result={scpResult} result={scpResult}
currentCall={callsign} currentCall={callsign}
@@ -7544,20 +7588,35 @@ export default function App() {
</div> </div>
)} )}
{chaseNewOn && showChaseNew && ( {chaseNewOn && showChaseNew && (
<div className="w-[420px] shrink-0 min-h-0"> <div className="w-[420px] shrink-0 min-h-0" style={{ order: wOrder('chasenew') }}>
{/* Same reflex as clicking a cluster spot: the callsign into the {/* Same reflex as clicking a cluster spot: the callsign into the
entry, and the rig onto the frequency it was decoded on. */} entry, and the rig onto the frequency it was decoded on. */}
<ChaseNewPanel onPick={(sp) => { <ChaseNewPanel onPick={(sp) => {
onCallsignInput(sp.call, { force: true }); // A row here is a spot like any other, so it goes through the
// And the panadapter width too — after the tune settles, for the // SAME handler as one clicked in the cluster: the rig tunes, the
// same SmartSDR pan-follow reason as handleSpotClick. // panadapter follows, the entry takes the mode and its RST
if (sp.freq_hz) void tuneRigCAT(sp.freq_hz, sp.mode).then(() => // preset, the park and summit references come across — and the
window.setTimeout(() => FlexZoomForSpot(sp.mode ?? '', sp.freq_hz ?? 0).catch(() => {}), 300)); // decoding application is told to change mode, which is what an
// operator clicking an FT4 row from an FT8 slot is asking for.
//
// It used to do a hand-picked half of that, so the one thing
// Chase new exists for — jumping onto a station — left WSJT-X
// decoding the wrong mode.
handleSpotClick({
dx_call: sp.call,
freq_hz: sp.freq_hz ?? 0,
band: sp.band,
// handleSpotClick infers the mode from the comment the way a
// cluster line carries it; the mode is what we have, so it is
// what we hand over.
comment: sp.mode ?? '',
spotter: '',
});
}} onClose={() => { setShowChaseNew(false); writeUiPref('opslog.showChaseNew', '0'); }} /> }} onClose={() => { setShowChaseNew(false); writeUiPref('opslog.showChaseNew', '0'); }} />
</div> </div>
)} )}
{dvkEnabled && ( {dvkEnabled && (
<div className="w-[320px] shrink-0 min-h-0"> <div className="w-[320px] shrink-0 min-h-0" style={{ order: wOrder('dvk') }}>
<DvkPanel <DvkPanel
messages={dvkMsgs} messages={dvkMsgs}
status={dvkStat} status={dvkStat}
@@ -7573,7 +7632,7 @@ export default function App() {
</div> </div>
)} )}
{wkEnabled && ( {wkEnabled && (
<div className="w-[380px] shrink-0 min-h-0"> <div className="w-[380px] shrink-0 min-h-0" style={{ order: wOrder('winkeyer') }}>
<WinkeyerPanel <WinkeyerPanel
// A rig keyer has no serial status of its own: it is connected // A rig keyer has no serial status of its own: it is connected
// exactly when its CAT backend is. Yaesu was missing from this // exactly when its CAT backend is. Yaesu was missing from this
@@ -7617,7 +7676,7 @@ export default function App() {
{/* QRZ photo: when the keyer is open it sits to its right at natural {/* QRZ photo: when the keyer is open it sits to its right at natural
(capped) width, shrinking the keyer panel rather than hiding it. */} (capped) width, shrinking the keyer panel rather than hiding it. */}
{lookupResult?.image_url && ( {lookupResult?.image_url && (
<div className={cn('min-w-0 flex items-center', (wkEnabled || dvkEnabled) ? 'shrink-0' : 'flex-1')}> <div className={cn('min-w-0 flex items-center', (wkEnabled || dvkEnabled) ? 'shrink-0' : 'flex-1')} style={{ order: wOrder('photo') }}>
<button <button
type="button" type="button"
onClick={() => lookupResult.image_url && setPhotoModal(lookupResult.image_url)} onClick={() => lookupResult.image_url && setPhotoModal(lookupResult.image_url)}
@@ -7813,6 +7872,21 @@ export default function App() {
</span> </span>
</TabsTrigger> </TabsTrigger>
)} )}
{dxpedTabOpen && (
<TabsTrigger value="dxped" className="gap-1.5">
{t('dxp.tab')}
<span
role="button"
aria-label="Close DXpeditions"
title="Close"
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
onPointerDown={(e) => { e.stopPropagation(); }}
onClick={(e) => { e.stopPropagation(); closeDxpedTab(); }}
>
<X className="size-3" />
</span>
</TabsTrigger>
)}
{ftmapTabOpen && ( {ftmapTabOpen && (
<TabsTrigger value="ftmap" className="gap-1.5"> <TabsTrigger value="ftmap" className="gap-1.5">
{t('ftmap.tab')} {t('ftmap.tab')}
@@ -8060,31 +8134,57 @@ export default function App() {
> >
Disconnect all Disconnect all
</Button> </Button>
{clusterServerStatuses.length === 0 && ( {clusterServers.filter((x) => x.enabled).length === 0 && (
<span className="text-xs text-muted-foreground italic"> <span className="text-xs text-muted-foreground italic">
No active sessions configure clusters in Settings DX Cluster. No active sessions configure clusters in Settings DX Cluster.
</span> </span>
)} )}
{clusterServerStatuses.map((s) => { {clusterServers
.filter((x) => x.enabled)
.sort((a, b) => a.sort_order - b.sort_order)
.map((srv) => {
// A disconnected server has no session to report, so it has no
// entry in the status list at all — the pill stands in for it
// as "disconnected", which is exactly the state you click to
// undo.
const live = clusterServerStatuses.find((x) => x.server_id === srv.id);
const s: ServerStatus = live ?? {
server_id: srv.id, name: srv.name, host: '', port: 0, state: 'disconnected',
};
const isMaster = clusterServers const isMaster = clusterServers
.filter((x) => x.enabled) .filter((x) => x.enabled)
.sort((a, b) => a.sort_order - b.sort_order)[0]?.id === s.server_id; .sort((a, b) => a.sort_order - b.sort_order)[0]?.id === s.server_id;
// The colour already says the state, so the word beside it was
// saying it twice — and the pills are the only place a single
// cluster can be reached without opening Settings. Clicking one
// now drops or reopens that session; everything the word used
// to carry (state, retries, last error, address) moves into the
// tooltip, where it costs no width.
const up = s.state === 'connected';
const busy = s.state === 'connecting' || s.state === 'reconnecting';
return ( return (
<span <button
key={s.server_id} key={s.server_id}
type="button"
onClick={async () => {
try {
if (up || busy) await DisconnectClusterServer(s.server_id);
else await ConnectClusterServer(s.server_id);
} catch (e: any) { setError(String(e?.message ?? e)); }
await reloadClusterMeta();
}}
className={cn( className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border', 'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border transition-opacity hover:opacity-80',
s.state === 'connected' ? 'bg-success-muted text-success-muted-foreground border-success-border' : s.state === 'connected' ? 'bg-success-muted text-success-muted-foreground border-success-border' :
s.state === 'connecting' || s.state === 'reconnecting' ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' : busy ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' :
s.state === 'error' ? 'bg-danger-muted text-danger-muted-foreground border-danger-border' : s.state === 'error' ? 'bg-danger-muted text-danger-muted-foreground border-danger-border' :
'bg-muted text-muted-foreground border-border', 'bg-muted text-muted-foreground border-border',
)} )}
title={`${s.host}:${s.port}${s.error ? ' — ' + s.error : ''}`} title={`${s.name}${s.state.toUpperCase()}${s.retries ? ` #${s.retries}` : ''}${s.host ? ` · ${s.host}:${s.port}` : ''}${s.error ? ' — ' + s.error : ''}\n${up || busy ? t('clu.pillDisconnect') : t('clu.pillConnect')}`}
> >
{isMaster && <span className="text-warning" title="Master (commands go here)"></span>} {isMaster && <span className="text-warning" title="Master (commands go here)"></span>}
{s.name} {s.name}
<span className="opacity-60 text-[9px] ml-0.5">{s.state.toUpperCase()}{s.retries ? ` #${s.retries}` : ''}</span> </button>
</span>
); );
})} })}
<div className="flex-1" /> <div className="flex-1" />
@@ -8426,6 +8526,15 @@ export default function App() {
</TabsContent> </TabsContent>
)} )}
{dxpedTabOpen && (
<TabsContent value="dxped" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
{activeTab === 'dxped' && (
<div className="h-full w-full min-h-0 p-1">
<DXpeditionsPanel />
</div>
)}
</TabsContent>
)}
{ftmapTabOpen && ( {ftmapTabOpen && (
<TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden"> <TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
{activeTab === 'ftmap' && ( {activeTab === 'ftmap' && (
+113 -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 { GetMatrixColors, GetRowColors, SaveMatrixColors, SaveRowColors } from '../../wailsjs/go/main/App';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
@@ -290,6 +292,116 @@ export function AppearancePanel() {
)} )}
<MatrixColorsSection /> <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', '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',
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> </div>
); );
} }
+27 -15
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 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 { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { bandRange, bandSegments, subscribeIaruRegion, type SegMode } from '@/lib/bandplan'; import { bandRange, bandSegments, subscribeIaruRegion, type SegMode } from '@/lib/bandplan';
@@ -283,6 +283,17 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
const range = bandRange(band); const range = bandRange(band);
const segments = bandSegments(band).map(([a, b, m]) => [a, b, SEG_COLOR[m]] as [number, number, string]); const segments = bandSegments(band).map(([a, b, m]) => [a, b, SEG_COLOR[m]] as [number, number, string]);
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band)); 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 // The docked map follows the rig, so a band change must bring up THAT band's
// remembered zoom. // remembered zoom.
useEffect(() => { setZoomIdx(readZoom(band)); }, [band]); useEffect(() => { setZoomIdx(readZoom(band)); }, [band]);
@@ -460,19 +471,10 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]); }, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
useEffect(() => { // No ctrl+wheel zoom here any more: ctrl+wheel is the WINDOW zoom everywhere
const el = scrollerRef.current; // else in OpsLog (View ▸ Zoom in/out), and one gesture that resizes the whole
if (!el) return; // app over one panel and one band map over another is a gesture nobody can
const onWheel = (e: WheelEvent) => { // trust. The + / buttons keep the zoom, deliberately and visibly.
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]);
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune // 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. // to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
@@ -753,6 +755,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
</div> </div>
</div> </div>
{/* Colour legend — what each pill colour means. */} {/* 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"> <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-danger" label={t('bmp.legendNewDxcc')} />
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} /> <LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
@@ -769,9 +772,18 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
<LegendDot colour={SEG_COLOR.digi} label={t("bmp.legendData")} /> <LegendDot colour={SEG_COLOR.digi} label={t("bmp.legendData")} />
<LegendDot colour={SEG_COLOR.phone} label={t("bmp.legendPhone")} /> <LegendDot colour={SEG_COLOR.phone} label={t("bmp.legendPhone")} />
</div> </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')} {t('bmp.footerHint')}
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>} {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>
</div> </div>
); );
+46 -6
View File
@@ -15,7 +15,10 @@ interface Props {
busy: boolean; busy: boolean;
currentBand: string; currentBand: string;
currentMode: 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 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 // 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. // to an entity with no position at all, and the block simply does not appear.
@@ -121,10 +124,31 @@ function cellTitle(t: (k: string) => string, band: string, cls: string, status:
return `${band} ${cls}: ${desc}${mine ? ' — ' + mine : ''}${current ? ' — ' + t('mx.current') : ''}`; 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(); const { t } = useI18n();
// Cell drill-down: which band+class the operator clicked, or null. // Cell drill-down: which band+class the operator clicked, or null.
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(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 // 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. // bands they actually use), falling back to the built-in default set.
const cols = useMemo( const cols = useMemo(
@@ -310,7 +334,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
<table className="border-separate" style={{ borderSpacing: 3 }}> <table className="border-separate" style={{ borderSpacing: 3 }}>
<thead> <thead>
<tr> <tr>
<th className="w-[26px]" /> <th className="w-[38px] min-w-[38px] max-w-[38px]" />
{cols.map((b) => ( {cols.map((b) => (
<th <th
key={b.tag} key={b.tag}
@@ -325,13 +349,29 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{CLASSES.map((cls) => { {CLASSES.map((clsBase) => {
const classCurrent = classMatchesMode(cls, currentMode); 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 ( return (
<tr key={cls}> <tr key={cls}>
<th <th
onClick={clsBase === 'DIG' && digModes.length ? cycleDig : undefined}
title={clsBase === 'DIG' && digModes.length ? t('bsg.digCycle') : undefined}
className={cn( 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', classCurrent ? 'text-primary font-extrabold' : 'text-muted-foreground',
)} )}
> >
@@ -56,6 +56,8 @@ const FIELDS: FieldDef[] = [
{ id: 'hamlog_sent_date', label: 'bulk.fHamlogSentDate', group: 'QSL / upload', kind: 'date' }, { 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', label: 'bulk.fHamlogRcvd', group: 'QSL / upload', kind: 'status' },
{ id: 'hamlog_rcvd_date', label: 'bulk.fHamlogRcvdDate', group: 'QSL / upload', kind: 'date' }, { 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 // My station / operator
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true }, { 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 }, { id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
+9 -4
View File
@@ -12,6 +12,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Radar, Loader2, X } from 'lucide-react'; import { Radar, Loader2, X } from 'lucide-react';
import { formatDistance } from '@/lib/units'; import { formatDistance } from '@/lib/units';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { chaseAllows } from '@/lib/spotDisplay';
import { markerColour } from '@/lib/spotMarkers'; import { markerColour } from '@/lib/spotMarkers';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { GetChaseNewSpots } from '../../wailsjs/go/main/App'; import { GetChaseNewSpots } from '../../wailsjs/go/main/App';
@@ -60,6 +61,10 @@ const CATEGORIES: Array<{ key: Category; labelKey: string; colour: string }> = [
]; ];
// categoryOf is the single thing a row says about a station. // 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 { function categoryOf(s: ChaseNewSpot): Category | null {
switch (s.status) { switch (s.status) {
case 'new': return 'dxcc'; case 'new': return 'dxcc';
@@ -68,8 +73,8 @@ function categoryOf(s: ChaseNewSpot): Category | null {
case 'new-mode': return 'mode'; case 'new-mode': return 'mode';
case 'new-slot': return 'slot'; case 'new-slot': return 'slot';
} }
if (s.new_pfx) return 'pfx'; if (s.new_pfx && chaseAllows('pfx')) return 'pfx';
if (s.new_grid) return 'grid'; if (s.new_grid && chaseAllows('grid')) return 'grid';
return null; return null;
} }
@@ -83,7 +88,7 @@ function loadFilters(): Set<Category> {
if (Array.isArray(list)) return new Set(list); if (Array.isArray(list)) return new Set(list);
} }
} catch { /* a corrupt preference is not worth a broken panel */ } } catch { /* a corrupt preference is not worth a broken panel */ }
return new Set(CATEGORIES.map((c) => c.key)); return new Set(CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => c.key));
} }
export function ChaseNewPanel({ onPick, onClose }: Props) { export function ChaseNewPanel({ onPick, onClose }: Props) {
@@ -132,7 +137,7 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
{/* Filters, in the same order and colours as the badges they hide. */} {/* Filters, in the same order and colours as the badges they hide. */}
<div className="flex flex-1 flex-wrap items-center gap-1"> <div className="flex flex-1 flex-wrap items-center gap-1">
{CATEGORIES.map((c) => ( {CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => (
<button <button
key={c.key} key={c.key}
type="button" type="button"
@@ -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>
);
}
+125 -20
View File
@@ -13,11 +13,13 @@
// come from the same resolver the cluster uses, so a call means the same thing in // 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. // both panels rather than being judged twice by two rules.
import { useEffect, useMemo, useState } from 'react'; 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 { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { chaseAllows } from '@/lib/spotDisplay';
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers'; import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { decoderName } from '@/lib/decoderName';
export type Decode = { export type Decode = {
call: string; call: string;
@@ -94,7 +96,13 @@ interface Props {
// receiver reported last, which is a coin toss — each pane needs its own. // receiver reported last, which is a coin toss — each pane needs its own.
txStates?: Record<string, TxMsg>; txStates?: Record<string, TxMsg>;
spotStatus: Record<string, StatusEntry>; 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; 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; myCall?: string;
// Drop every decode and transmit message held for this panel. The list is a // 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 // live view, not data — clearing it costs nothing but the seconds until the
@@ -111,6 +119,12 @@ interface Props {
// machine off is not something to go hunting through a settings tree for. // machine off is not something to go hunting through a settings tree for.
autoCallOn?: boolean; autoCallOn?: boolean;
onToggleAutoCall?: () => void; onToggleAutoCall?: () => void;
// The engine's own account of what it is doing, straight from the backend.
autoCall?: { target: 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 "new" categories, as toggle badges — the same idea and the same colours as // The "new" categories, as toggle badges — the same idea and the same colours as
@@ -120,6 +134,9 @@ interface Props {
// that starts by hiding most of the band would be lying about what is on it. // 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' | 'state'; 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 }[] = [ const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [
{ key: 'dxcc', label: 'dec.stNew', colour: 'var(--success)' }, { key: 'dxcc', label: 'dec.stNew', colour: 'var(--success)' },
{ key: 'band', label: 'dec.stBand', colour: 'var(--warning)' }, { key: 'band', label: 'dec.stBand', colour: 'var(--warning)' },
@@ -535,12 +552,21 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
})); }));
} }
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, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly }: Props) {
const { t } = useI18n(); const { t } = useI18n();
// Column widths, dragged in the header and shared by every row. Persisted // Column widths, dragged in the header and shared by every row. Persisted
// through writeUiPref (not raw localStorage) so the layout travels with data/ // through writeUiPref (not raw localStorage) so the layout travels with data/
// like every other portable preference. // like every other portable preference.
const [colw, setColw] = useState<ColWidths>(loadWidths); const [colw, setColw] = useState<ColWidths>(loadWidths);
// 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 template = useMemo(() => COLS.map((c) => `${colw[c.key]}px`).join(' '), [colw]); 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]); const tableW = useMemo(() => COLS.reduce((s, c) => s + colw[c.key], 0), [colw]);
const setColWidth = (key: ColKey, px: number) => { const setColWidth = (key: ColKey, px: number) => {
@@ -593,6 +619,21 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
// The mode currently on the air, for the slot clock. The newest decode knows // The mode currently on the air, for the slot clock. The newest decode knows
// best; between overs the transmit state still does. // 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 liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode;
const liveTr = trSeconds(liveMode, decodes.length ? decodes[decodes.length - 1].tr_period : undefined); const liveTr = trSeconds(liveMode, decodes.length ? decodes[decodes.length - 1].tr_period : undefined);
@@ -629,27 +670,25 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
// dropdowns were pure furniture for most operators; they appear the day a // 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 // second instance puts a second band on the link, which is the only day they
// mean anything. // mean anything.
const { bands, modes, conts, instances } = useMemo(() => { const { bands, modes, instances } = useMemo(() => {
const b = new Set<string>(), m = new Set<string>(), c = new Set<string>(), i = new Set<string>(); const b = new Set<string>(), m = new Set<string>(), i = new Set<string>();
for (const d of decodes) { for (const d of decodes) {
if (d.band) b.add(d.band); if (d.band) b.add(d.band);
if (d.mode) m.add(d.mode); if (d.mode) m.add(d.mode);
if (d.instance) i.add(d.instance); 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [decodes, spotStatus]); }, [decodes, spotStatus]);
// The chips are the continents on the feed — a selector with nothing to choose // All seven, always, in their usual order.
// is furniture — PLUS anything currently selected. Without that second half a //
// filter can strand itself: pick AF, the last African station stops decoding, // The chips used to be built from the continents ON the feed, which read as a
// and the list empties with no chip left to switch it back off. // list that reshuffled itself every period: a chip appeared when the first
const contChips = useMemo( // Asian station decoded and moved everything sideways under the pointer. A
() => [...new Set([...conts, ...contList])].sort(), // fixed row can be aimed at — you learn where OC is and it stays there — and
[conts, contList], // it also says what the filter can do before anything has been heard.
); const contChips = CONTINENTS;
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = search.trim().toUpperCase(); const q = search.trim().toUpperCase();
@@ -690,7 +729,8 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
} }
return instances.map((inst) => ({ return instances.map((inst) => ({
key: inst, key: inst,
label: inst, // What the program is called, not the id it announces — see decoderName.
label: decoderName(inst),
tx: txStates?.[inst], tx: txStates?.[inst],
periods: buildPeriods( periods: buildPeriods(
filtered.filter((d) => (d.instance ?? '') === inst), filtered.filter((d) => (d.instance ?? '') === inst),
@@ -731,6 +771,18 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
what the transmit state reports, so it is right the moment anything what the transmit state reports, so it is right the moment anything
is heard and keeps running when the band goes quiet. */} is heard and keeps running when the band goes quiet. */}
<PeriodClock trSec={liveTr} mode={liveMode} /> <PeriodClock trSec={liveTr} mode={liveMode} />
{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" /> <span className="w-px h-5 bg-border/60 mx-1" />
<button type="button" className={chip(cqOnly, 'success')} onClick={() => setCqOnly(!cqOnly)}> <button type="button" className={chip(cqOnly, 'success')} onClick={() => setCqOnly(!cqOnly)}>
@@ -743,7 +795,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
{/* Per-category badges, in the colours of the flags they select — the {/* Per-category badges, in the colours of the flags they select — the
same vocabulary as the Chase New panel. */} 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')}> <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); const on = cats.has(c.key);
return ( return (
<button <button
@@ -857,6 +909,54 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
filter. Halt goes LAST — it is the one that must be findable without 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 reading, and the end of the row is the one position that never moves
as filters come and go. */} 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')}
{autoCallOn && autoCall?.target && (
<span className="font-mono text-xs">
{autoCall.target} {autoCall.calls}/{autoCall.max}
{autoCall.misses > 0 ? ` ·${autoCall.misses}/${autoCall.max_miss}` : ''}
</span>
)}
</button>
)}
{/* 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 && ( {onHalt && (
<button <button
type="button" type="button"
@@ -923,7 +1023,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
<span className="flex-1" /> <span className="flex-1" />
{txState.band && <span className="text-xs text-muted-foreground shrink-0">{txState.band}</span>} {txState.band && <span className="text-xs text-muted-foreground shrink-0">{txState.band}</span>}
{txState.instance && instances.length > 1 && ( {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> </div>
)} )}
@@ -1051,7 +1151,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
const e = statusOf(d); const e = statusOf(d);
const st = e?.status && e.status !== 'worked' ? e.status : ''; const st = e?.status && e.status !== 'worked' ? e.status : '';
const entities = st ? entityBadgesFor(st) : []; 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 mine = !!me && d.call === me;
const hot = entities.length > 0 || extras.length > 0; const hot = entities.length > 0 || extras.length > 0;
// Someone answering us outranks everything else on the screen. // Someone answering us outranks everything else on the screen.
@@ -1063,7 +1163,12 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
<button <button
key={`${d.call}-${d.freq_hz}-${i}`} key={`${d.call}-${d.freq_hz}-${i}`}
type="button" 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 })} title={t('dec.callTitle', { call: d.call })}
style={{ gridTemplateColumns: template, width: tableW }} style={{ gridTemplateColumns: template, width: tableW }}
className={cn(ROW, 'text-left border-b border-border/20 transition-colors', className={cn(ROW, 'text-left border-b border-border/20 transition-colors',
+3 -1
View File
@@ -71,6 +71,7 @@ interface Props {
band: string; band: string;
mode: string; mode: string;
bands?: string[]; // configured bands for the worked-before matrix columns 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 // 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 // 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. // 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 { t } = useI18n();
const [internalOpen, setInternalOpen] = useState<TabName>('stats'); const [internalOpen, setInternalOpen] = useState<TabName>('stats');
const open = tab ?? internalOpen; // controlled when `tab` is provided 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} currentBand={slotCall ? (slotBand ?? '') : band}
currentMode={slotCall ? (slotMode ?? '') : mode} currentMode={slotCall ? (slotMode ?? '') : mode}
bands={bands} bands={bands}
modes={modes}
hasCall={slotCall ? true : callsign.trim() !== ''} hasCall={slotCall ? true : callsign.trim() !== ''}
forCall={slotCall} forCall={slotCall}
onEditQso={onEditQso} onEditQso={onEditQso}
+26 -4
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import { gridToLatLon, greatCirclePoints } from '@/lib/maidenhead'; import { gridToLatLon, greatCirclePoints, splitAtAntimeridian } from '@/lib/maidenhead';
import { BASEMAPS, type BasemapKey } from '@/components/MainMap'; import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
@@ -62,7 +62,25 @@ export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid
}); });
mapRef.current = m; mapRef.current = m;
layerRef.current = L.layerGroup().addTo(m); layerRef.current = L.layerGroup().addTo(m);
return () => { m.remove(); mapRef.current = null; layerRef.current = null; }; // 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. // Basemap follows the picker.
@@ -106,8 +124,12 @@ export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid
const age = now - Date.parse(d.at); const age = now - Date.parse(d.at);
const fade = Math.max(0.15, 1 - age / MAX_AGE_MS); const fade = Math.max(0.15, 1 - age / MAX_AGE_MS);
const colour = bandColour(d.band); const colour = bandColour(d.band);
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48); // Cut at the antimeridian: this map shows ONE world, so a path running
L.polyline(pts as L.LatLngExpression[], { // 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, color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
}).addTo(layer); }).addTo(layer);
L.circleMarker([to.lat, to.lon], { L.circleMarker([to.lat, to.lon], {
@@ -96,6 +96,8 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'hamlog_sent_date', label: 'fltb.fHamlogSentDate', type: 'adifdate' }, { value: 'hamlog_sent_date', label: 'fltb.fHamlogSentDate', type: 'adifdate' },
{ value: 'hamlog_rcvd', label: 'fltb.fHamlogRcvd', type: 'text' }, { value: 'hamlog_rcvd', label: 'fltb.fHamlogRcvd', type: 'text' },
{ value: 'hamlog_rcvd_date', label: 'fltb.fHamlogRcvdDate', type: 'adifdate' }, { 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: 'contest_id', label: 'fltb.fContestId', type: 'text' },
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' }, { value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' }, { value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
+96 -14
View File
@@ -10,7 +10,7 @@ import {
IcomSetRIT, IcomSetRITOn, IcomSetXITOn, IcomSetRIT, IcomSetRITOn, IcomSetXITOn,
IcomSetAntenna, IcomSetPBTInner, IcomSetPBTOuter, IcomSetManualNotch, IcomSetNotchPos, IcomSetAntenna, IcomSetPBTInner, IcomSetPBTOuter, IcomSetManualNotch, IcomSetNotchPos,
IcomSetSquelch, IcomSetComp, IcomSetCompLevel, IcomSetMonitor, IcomSetMonLevel, IcomSetSquelch, IcomSetComp, IcomSetCompLevel, IcomSetMonitor, IcomSetMonLevel,
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower, IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower, IcomRecallBand,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
@@ -53,7 +53,13 @@ const ZERO: IcomState = {
type Band = { l: string; hz: number }; type Band = { l: string; hz: number };
const HF_BANDS: Band[] = [ 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: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 },
{ l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 }, { l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 },
]; ];
@@ -62,8 +68,9 @@ const B2 = { l: '2', hz: 144_300_000 }; // SSB calling
const B70 = { l: '70cm', hz: 432_200_000 }; // SSB calling const B70 = { l: '70cm', hz: 432_200_000 }; // SSB calling
const B23 = { l: '23cm', hz: 1_296_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 // These frequencies are the FALLBACK: with the band stacking registers switched
// the plain SetFrequency command — no band-stacking codes needed. // 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 // 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 // do below. An IC-9700 has no HF at all, yet the console was showing it 160
@@ -78,9 +85,36 @@ function bandsFor(model?: string): Band[] {
return [...HF_BANDS, B6]; return [...HF_BANDS, B6];
} }
// Mode buttons for the console (like RS-BA1's row). SetCATMode picks USB/LSB for // Mode buttons for the console (like RS-BA1's row).
// SSB by frequency and the rig's data variant for digital modes. //
const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM', 'DATA']; // 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 // 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 // same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18
@@ -155,9 +189,21 @@ function icomWatts(pct: number): { w: number; defl: number } {
return { w: Math.round(w), defl }; return { w: Math.round(w), defl };
} }
function modeMatches(btn: string, cur?: string): boolean { // 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 (!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 // 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. // 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 === 'DATA') return ['DATA', 'FT8', 'FT4', 'JS8', 'JT65', 'JT9', 'MFSK', 'OLIVIA'].includes(cur);
@@ -347,6 +393,19 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
const [st, setSt] = useState<IcomState>(ZERO); const [st, setSt] = useState<IcomState>(ZERO);
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
const [tuning, setTuning] = useState(false); 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 txRef = useRef(false);
const stRef = useRef<IcomState>(ZERO); stRef.current = st; const stRef = useRef<IcomState>(ZERO); stRef.current = st;
@@ -355,6 +414,18 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
GetCATState().then((c) => setCat(c ?? null)).catch(() => {}); GetCATState().then((c) => setCat(c ?? null)).catch(() => {});
}; };
const setMode = (m: string) => { setCat((c: any) => (c ? { ...c, mode: m } : c)); SetCATMode(m).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 // 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). // re-reads the cache; the backend also loads DSP on the first responsive read).
const refresh = async () => { const refresh = async () => {
@@ -507,9 +578,10 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
</div> </div>
</div> </div>
{/* Mode selector row (RS-BA1's SSB/CW/RTTY/PSK/AM/FM). */} {/* Mode selector row (RS-BA1's SSB/CW/RTTY/PSK/AM/FM). */}
<div className="grid grid-cols-7 border-t border-border/60 divide-x divide-border/60"> <div className="grid border-t border-border/60 divide-x divide-border/60"
{MODES.map((m) => { style={{ gridTemplateColumns: `repeat(${modesFor(st.model).length}, minmax(0, 1fr))` }}>
const on = modeMatches(m, curMode); {modesFor(st.model).map((m) => {
const on = modeMatches(m, curMode, mainHz);
return ( return (
<button key={m} type="button" onClick={() => setMode(m)} <button key={m} type="button" onClick={() => setMode(m)}
className={cn('py-1.5 text-[11px] font-bold tracking-wide transition-colors', className={cn('py-1.5 text-[11px] font-bold tracking-wide transition-colors',
@@ -546,11 +618,16 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Band buttons + antenna selection. */} {/* Band buttons + antenna selection. */}
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2"> <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"> <div className="grid grid-cols-5 gap-1.5">
{bandsFor(st.model).map((b) => { {bandsFor(st.model).map((b) => {
const here = bandOfHz(mainHz) === b.l; const here = bandOfHz(mainHz) === b.l;
return ( 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} 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', className={cn('px-1 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
here here
@@ -561,10 +638,12 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
); );
})} })}
</div> </div>
{hasAntennaSelector(st.model) && (
<Row label={t('icmp.antenna')}> <Row label={t('icmp.antenna')}>
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]} <Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} /> onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} />
</Row> </Row>
)}
</Card> </Card>
{/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */} {/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */}
@@ -588,7 +667,10 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
{(st.model ?? '').includes('7760') ? `${st.rf_power * 2} W` : st.rf_power} {(st.model ?? '').includes('7760') ? `${st.rf_power * 2} W` : st.rf_power}
</span> </span>
</Row> </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')}> <Row label={t('icmp.mic')}>
<Slider value={st.mic_gain} accent="#ef4444" onChange={(v) => set({ mic_gain: v }, () => IcomSetMicGain(v))} /> <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> <span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_gain}</span>
@@ -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>
);
}
+21 -2
View File
@@ -8,7 +8,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { GetLoTWQSLDetail, SetLoTWQSLDetail, GetLoTWDownloadAllCalls, SetLoTWDownloadAllCalls, OpenExternalURL, 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 { Input } from '@/components/ui/input';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { EventsOn } from '../../wailsjs/runtime/runtime'; import { EventsOn } from '../../wailsjs/runtime/runtime';
@@ -42,6 +42,7 @@ const SERVICES = [
{ v: 'eqsl', label: 'eQSL.cc' }, { v: 'eqsl', label: 'eQSL.cc' },
{ v: 'lotw', label: 'LoTW' }, { v: 'lotw', label: 'LoTW' },
{ v: 'hamlog', label: 'HAMLOG.online' }, { v: 'hamlog', label: 'HAMLOG.online' },
{ v: 'hamqth', label: 'HamQTH' },
{ v: 'pota', label: 'POTA hunter log' }, { v: 'pota', label: 'POTA hunter log' },
{ v: 'paper', label: 'Paper QSL' }, { v: 'paper', label: 'Paper QSL' },
]; ];
@@ -722,7 +723,25 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
{service !== 'pota' && service !== 'paper' && ( {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 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"> <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} <Button variant="outline" size="sm" onClick={importHamlogCfm} disabled={busy}
title={t('qslm.hamlogImportTitle')}> title={t('qslm.hamlogImportTitle')}>
+2 -1
View File
@@ -34,7 +34,8 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
{ service: 'hrdlog', name: 'HRDLog.net' }, { service: 'hrdlog', name: 'HRDLog.net' },
{ service: 'eqsl', name: 'eQSL.cc' }, { service: 'eqsl', name: 'eQSL.cc' },
{ service: 'lotw', name: 'LoTW' }, { 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 // Lightweight right-click menu for the QSO grids. AG Grid's native context
+86 -33
View File
@@ -22,6 +22,7 @@ import { Combobox } from '@/components/ui/combobox';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { flagURL } from '@/lib/flags'; import { flagURL } from '@/lib/flags';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { bandForMHz } from '@/lib/bandplan';
import { titleCase, sentenceCase } from '@/lib/textCase'; import { titleCase, sentenceCase } from '@/lib/textCase';
import type { QSOForm } from '@/types'; import type { QSOForm } from '@/types';
@@ -80,6 +81,19 @@ const CONF_LABEL_KEYS: Record<string, string> = {
QSL: 'qedit.confQslPaper', 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 // 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 // columns and this channel is backed by ADIF extras — but it still belongs in
// the channel picker and the status table alongside the rest. // the channel picker and the status table alongside the rest.
@@ -98,6 +112,15 @@ const HAMLOG_KEYS = {
rcvdDate: 'APP_OPSLOG_HAMLOG_QSL_DATE', 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. // Colour-coded status cell for the confirmation grid.
function StatusCell({ value }: { value?: string }) { function StatusCell({ value }: { value?: string }) {
const { t } = useI18n(); const { t } = useI18n();
@@ -290,6 +313,19 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
const splitHz = (hz?: number) => hz const splitHz = (hz?: number) => hz
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') } ? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
: { khz: '', hz: '' }; : { 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 f0 = splitHz(draft.freq_hz);
const fr0 = splitHz(draft.freq_rx_hz); const fr0 = splitHz(draft.freq_rx_hz);
const [freqKHz, setFreqKHz] = useState(f0.khz); const [freqKHz, setFreqKHz] = useState(f0.khz);
@@ -612,13 +648,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="w-20 shrink-0">{t('qedit.txFreq')}</Label> <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={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) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" /> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="w-20 shrink-0">{t('qedit.rxFreq')}</Label> <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={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) => setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" /> <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>
</div> </div>
@@ -747,19 +783,34 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<Select value={confSel} onValueChange={setConfSel}> <Select value={confSel} onValueChange={setConfSel}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</SelectItem>)} {confOrder([
{/* Listed here but NOT in CONFIRMATIONS: that table maps ...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 QSO columns, and this channel lives in the ADIF
extras. It gets its own editor below rather than the OpsLog card, HAMLOG.online and HamQTH — are backed by
generic sent/received/date grid, which has no field ADIF extras rather than QSO columns, and each gets its
to bind to. */} own editor below instead of the generic
<SelectItem value={OPSLOG_CONF}>{t('qedit.confOpsLog')}</SelectItem> sent/received/date grid, which has no field to bind
<SelectItem value={HAMLOG_CONF}>HAMLOG.online</SelectItem> to. */}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </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 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> <div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMLOG_KEYS.sent)} onChange={(v) => exPut(HAMLOG_KEYS.sent, v)} /></div>
@@ -845,30 +896,32 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
</tr> </tr>
</thead> </thead>
<tbody> <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"> <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="font-medium pr-3 py-0.5 whitespace-nowrap">{c.label}</td>
<td className="w-24"><StatusCell value={val(c.sent)} /></td> <td className="w-24"><StatusCell value={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="w-24">{c.rcvd === null
? <span className="block text-center text-[11px] text-muted-foreground"></span>
: <StatusCell value={c.rcvd} />}</td>
</tr> </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> </tbody>
</table> </table>
</div> </div>
@@ -219,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_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'), 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 }, { 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. // 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 }, { 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 },
+394 -107
View File
@@ -45,7 +45,7 @@ import {
SetCIVTrace, SetCIVTrace,
CIVTraceEnabled, CIVTraceEnabled,
WinkeyerTraceEnabled, WinkeyerTraceEnabled,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload, GetExternalServices, SaveExternalServices, TestHamQTHUpload, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
GetPOTAToken, SavePOTAToken, GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations, TestLoTWUpload, ListTQSLStationLocations,
DownloadLoTWUsers, GetLoTWUsersStatus, DownloadLoTWUsers, GetLoTWUsersStatus,
@@ -60,7 +60,7 @@ import {
GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow, GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow,
GetRelayAuto, SaveRelayAuto, GetStationDevices, GetRelayAuto, SaveRelayAuto, GetStationDevices,
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards, GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax, GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetPSKTargetSettings, SavePSKTargetSettings, GetAutoCallSettings, SaveAutoCallSettings, GetWatchlistContestCalls, SetWatchlistContestCalls, GetWatchlistContestPattern, SetWatchlistContestPattern, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models'; import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -213,6 +213,7 @@ type SectionId =
| 'lists-modes' | 'lists-modes'
| 'lists-satellites' | 'lists-satellites'
| 'cluster' | 'cluster'
| 'dxhunter'
| 'backup' | 'backup'
| 'database' | 'database'
| 'autostart' | 'autostart'
@@ -325,6 +326,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
{ kind: 'item', label: t('sec.satellites'), id: 'lists-satellites' }, { kind: 'item', label: t('sec.satellites'), id: 'lists-satellites' },
]}, ]},
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' }, { kind: 'item', label: t('sec.cluster'), id: 'cluster' },
{ kind: 'item', label: t('sec.dxhunter'), id: 'dxhunter' },
{ kind: 'item', label: t('sec.udp'), id: 'udp' }, { kind: 'item', label: t('sec.udp'), id: 'udp' },
{ kind: 'item', label: t('sec.adifmon'), id: 'adifmon' }, { kind: 'item', label: t('sec.adifmon'), id: 'adifmon' },
{ kind: 'item', label: t('sec.foldersync'), id: 'foldersync' }, { kind: 'item', label: t('sec.foldersync'), id: 'foldersync' },
@@ -482,6 +484,8 @@ const THEME_SWATCH: Record<Exclude<ThemeChoice, 'auto'>, { bg: string; card: str
'dark-indigo': { bg: '#0e0f1f', card: '#181a30', accent: '#7c6cff' }, 'dark-indigo': { bg: '#0e0f1f', card: '#181a30', accent: '#7c6cff' },
'dark-teal': { bg: '#061c21', card: '#0d2c33', accent: '#22d3ee' }, 'dark-teal': { bg: '#061c21', card: '#0d2c33', accent: '#22d3ee' },
'dark-plum': { bg: '#180f1e', card: '#251830', accent: '#f472b6' }, 'dark-plum': { bg: '#180f1e', card: '#251830', accent: '#f472b6' },
'dxhunter': { bg: '#0f172a', card: '#1e293b', accent: '#3b82f6' },
'dxhunter-orange': { bg: '#0f172a', card: '#1e293b', accent: '#f97316' },
'high-contrast': { bg: '#000000', card: '#0d0d0d', accent: '#ff7a1a' }, 'high-contrast': { bg: '#000000', card: '#0d0d0d', accent: '#ff7a1a' },
}; };
@@ -1612,7 +1616,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false, icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0, tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001, digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001,
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false, ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false, digi_as_usb: false,
}); });
// Brand + connection, derived from the stored backend rather than held // Brand + connection, derived from the stored backend rather than held
// separately: two sources for one fact drift apart the first time something // separately: two sources for one fact drift apart the first time something
@@ -1700,13 +1704,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
type AudioSettings = { type AudioSettings = {
from_radio: string; to_radio: string; recording_device: string; listening_device: string; from_radio: string; to_radio: string; recording_device: string; listening_device: string;
qso_record: boolean; qso_dir: string; preroll_seconds: number; qso_record: boolean; qso_dir: string; preroll_seconds: number;
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3'; ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; ptt_data?: boolean; format: 'wav' | 'mp3';
from_gain: number; mic_gain: number; tx_gain: number; qso_play_gain: number; from_gain: number; mic_gain: number; tx_gain: number; qso_play_gain: number;
}; };
type AudioDev = { id: string; name: string; default: boolean }; type AudioDev = { id: string; name: string; default: boolean };
const [audioCfg, setAudioCfg] = useState<AudioSettings>({ const [audioCfg, setAudioCfg] = useState<AudioSettings>({
from_radio: '', to_radio: '', recording_device: '', listening_device: '', from_radio: '', to_radio: '', recording_device: '', listening_device: '',
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav', qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', ptt_data: false, format: 'wav',
from_gain: 100, mic_gain: 100, tx_gain: 100, qso_play_gain: 100, from_gain: 100, mic_gain: 100, tx_gain: 100, qso_play_gain: 100,
}); });
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]); const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
@@ -1825,6 +1829,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
clublog_status: string; clublog_confirmed: string; hrdlog_status: string; qrzcom_status: string; clublog_status: string; clublog_confirmed: string; hrdlog_status: string; qrzcom_status: string;
qrzcom_confirmed: string; qrzcom_confirmed: string;
hamlog_status: string; hamlog_confirmed: string; hamlog_status: string; hamlog_confirmed: string;
hamqth_status: string;
}; };
const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({ const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
qsl_sent: '', qsl_rcvd: '', qsl_sent: '', qsl_rcvd: '',
@@ -1832,7 +1837,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
eqsl_sent: '', eqsl_rcvd: '', eqsl_sent: '', eqsl_rcvd: '',
clublog_status: '', clublog_confirmed: '', hrdlog_status: '', qrzcom_status: '', clublog_status: '', clublog_confirmed: '', hrdlog_status: '', qrzcom_status: '',
qrzcom_confirmed: '', qrzcom_confirmed: '',
hamlog_status: '', hamlog_confirmed: '', hamlog_status: '', hamlog_confirmed: '', hamqth_status: '',
}); });
// External services (logbook upload). One block per service; only QRZ is // External services (logbook upload). One block per service; only QRZ is
@@ -1848,7 +1853,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// HRDLog only: publish the live frequency/mode/rig on hrdlog.net. // HRDLog only: publish the live frequency/mode/rig on hrdlog.net.
on_air?: boolean; on_air?: boolean;
}; };
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; hamlog: ExtServiceCfg; delete_remote?: boolean }; type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; hamlog: ExtServiceCfg; hamqth: ExtServiceCfg; delete_remote?: boolean };
const emptyExtCfg = (): ExtServiceCfg => ({ const emptyExtCfg = (): ExtServiceCfg => ({
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '', api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '', force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
@@ -1856,7 +1861,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
auto_upload: false, upload_mode: 'immediate', on_air: false, auto_upload: false, upload_mode: 'immediate', on_air: false,
}); });
const [extSvc, setExtSvc] = useState<ExternalServices>({ const [extSvc, setExtSvc] = useState<ExternalServices>({
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), hamlog: emptyExtCfg(), delete_remote: false, qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), hamlog: emptyExtCfg(), hamqth: emptyExtCfg(), delete_remote: false,
}); });
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null); const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [qrzTesting, setQrzTesting] = useState(false); const [qrzTesting, setQrzTesting] = useState(false);
@@ -2025,6 +2030,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
const [cloudlogTesting, setCloudlogTesting] = useState(false); const [cloudlogTesting, setCloudlogTesting] = useState(false);
const [hamlogTest, setHamlogTest] = useState<{ ok: boolean; msg: string } | null>(null); const [hamlogTest, setHamlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [hamlogTesting, setHamlogTesting] = useState(false); const [hamlogTesting, setHamlogTesting] = useState(false);
const [hamqthTest, setHamqthTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [hamqthTesting, setHamqthTesting] = useState(false);
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null); const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [eqslTesting, setEqslTesting] = useState(false); const [eqslTesting, setEqslTesting] = useState(false);
const [stationLocations, setStationLocations] = useState<string[]>([]); const [stationLocations, setStationLocations] = useState<string[]>([]);
@@ -2032,7 +2039,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// could not hold hooks at all; PanelHost lifted that restriction, and this // could not hold hooks at all; PanelHost lifted that restriction, and this
// stays put because moving it down would reset the tab on every reopen — // stays put because moving it down would reset the tab on every reopen —
// a choice now, not a workaround. // a choice now, not a workaround.
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'hamlog' | 'pota'>('qrz'); const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'hamlog' | 'hamqth' | 'pota'>('qrz');
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log). // POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
const [potaToken, setPotaToken] = useState(''); const [potaToken, setPotaToken] = useState('');
const [potaBusy, setPotaBusy] = useState(false); const [potaBusy, setPotaBusy] = useState(false);
@@ -2076,8 +2083,26 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
}; };
const [chaseGrids, setChaseGrids] = useState(false); const [chaseGrids, setChaseGrids] = useState(false);
const [chasePotaOn, setChasePotaOn] = useState(() => localStorage.getItem('opslog.chasePota') !== '0'); const [chasePotaOn, setChasePotaOn] = useState(() => localStorage.getItem('opslog.chasePota') !== '0');
const [chaseCountyOn, setChaseCountyOn] = useState(() => localStorage.getItem('opslog.chaseCounty') !== '0');
const [chasePfxOn, setChasePfxOn] = useState(() => localStorage.getItem('opslog.chasePfx') !== '0');
const [chaseStateOn, setChaseStateOn] = useState(() => localStorage.getItem('opslog.chaseState') !== '0');
const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0'); const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0');
const [chaseNew, setChaseNew] = useState(false); const [chaseNew, setChaseNew] = useState(false);
const [pskTgt, setPskTgt] = useState<any>({ enabled: false, scope: 'target' });
const [ac, setAc] = useState<any>({ enabled: false, only: '', attempts: 7, watched_attempts: 15, misses: 3, max_rounds: 3, rest_min: 2 });
// The named contest callsigns. Raw text in state, written on blur: it is a
// multi-line list, and normalising it on every keystroke would fight the
// Return key — the one key this box is built around.
const [contestCalls, setContestCalls] = useState('');
const [contestPattern, setContestPattern] = useState('');
const saveAC = async (next: any) => {
setAc(next);
try { await SaveAutoCallSettings(next); } catch { /* the toolbar shows what the engine is doing */ }
};
const savePSKTgt = async (next: any) => {
setPskTgt(next);
try { await SavePSKTargetSettings(next); } catch { /* the panel itself reports what the feed is doing */ }
};
const [spotTTL, setSpotTTL] = useState(0); const [spotTTL, setSpotTTL] = useState(0);
const [spotTTLText, setSpotTTLText] = useState('0'); const [spotTTLText, setSpotTTLText] = useState('0');
const [spotMaxText, setSpotMaxText] = useState('1000'); const [spotMaxText, setSpotMaxText] = useState('1000');
@@ -2097,8 +2122,18 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ } try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ }
try { setChaseGrids(await GetChaseNewGrids()); } catch { /* defaults stand */ } try {
const g = await GetChaseNewGrids();
setChaseGrids(g);
// Mirror for the display layer (spotDisplay.chaseGrid) — the cluster
// gates NEW GRID synchronously from localStorage.
writeUiPref('opslog.chaseGrids', g ? '1' : '0');
} catch { /* defaults stand */ }
try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ } try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ }
try { setPskTgt(await GetPSKTargetSettings()); } catch { /* defaults stand */ }
try { setAc(await GetAutoCallSettings()); } catch { /* defaults stand */ }
try { setContestCalls(await GetWatchlistContestCalls()); } catch { /* defaults stand */ }
try { setContestPattern(await GetWatchlistContestPattern()); } catch { /* defaults stand */ }
try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ } try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ }
try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ } try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ }
try { const n = await GetSpotMax(); setSpotMaxText(String(n)); } catch { /* defaults stand */ } try { const n = await GetSpotMax(); setSpotMaxText(String(n)); } catch { /* defaults stand */ }
@@ -3829,6 +3864,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</label> </> </label> </>
)} )}
</div> </div>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('cat.digiUsbHint')}>
<Checkbox
checked={!!catCfg.digi_as_usb}
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, digi_as_usb: !!c }))}
/>
{t('cat.digiUsb')}
</label>
{catCfg.backend === 'omnirig' && ( {catCfg.backend === 'omnirig' && (
<> <>
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
@@ -5218,6 +5260,260 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
setEditingServer(next); setEditingServer(next);
} }
// Chasing is not a cluster feature.
//
// These 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 do not
// belong under the name of one of them. The page is called DXHunter because
// that is what it is about — hunting DX — and because more of DXHunter's
// ideas are meant to land beside them.
function DXHunterPanel() {
return (
<>
<h3 className="text-sm font-semibold">{t('sec.dxhunter')}</h3>
<p className="text-xs text-muted-foreground -mt-1">{t('dxh.intro')}</p>
<div className="space-y-3">
<div className="border-t border-border/60 pt-3 space-y-2">
{/* Chase switches: off, the marker stops being TOLD, everywhere at
once badge, colour, filter chip, reference column. A "new band
+ new POTA" spot then reads NEW BAND alone. */}
{/* The GLOBAL hunt: every category (DXCC, band, mode, slot, prefix,
county, state, grid) judged as new-only or new-plus-unconfirmed,
against the confirmation sources the operator trusts. */}
<div className="rounded-lg border border-border p-3 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('chg.mode')}</span>
<Select value={chaseCfg.mode} onValueChange={(v) => { const next = { ...chaseCfg, mode: v }; setChaseCfg(next); void SaveChaseSettings(next as any); }}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="new">{t('gsc.huntNew')}</SelectItem>
<SelectItem value="new_unconfirmed">{t('gsc.huntUnconf')}</SelectItem>
</SelectContent>
</Select>
</div>
{chaseCfg.mode === 'new_unconfirmed' && (
<div className="flex items-center gap-4 flex-wrap pl-2">
<span className="text-xs text-muted-foreground">{t('chg.sources')}</span>
{([['lotw', 'LoTW'], ['card', t('chg.card')], ['eqsl', 'eQSL'], ['qrz', 'QRZ.com']] as const).map(([k, label]) => (
<label key={k} className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox checked={chaseCfg.sources.includes(k)}
onCheckedChange={(c) => {
const sources = c ? [...chaseCfg.sources, k] : chaseCfg.sources.filter((x) => x !== k);
const next = { ...chaseCfg, sources };
setChaseCfg(next); void SaveChaseSettings(next as any);
}} />
{label}
</label>
))}
</div>
)}
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chasePotaHint')}>
<Checkbox checked={chasePotaOn}
onCheckedChange={(c) => { const v = !!c; setChasePotaOn(v); writeUiPref('opslog.chasePota', v ? '1' : '0'); }} />
{t('clu.chasePota')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseSotaHint')}>
<Checkbox checked={chaseSotaOn}
onCheckedChange={(c) => { const v = !!c; setChaseSotaOn(v); writeUiPref('opslog.chaseSota', v ? '1' : '0'); }} />
{t('clu.chaseSota')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseCountyHint')}>
<Checkbox checked={chaseCountyOn}
onCheckedChange={(c) => { const v = !!c; setChaseCountyOn(v); writeUiPref('opslog.chaseCounty', v ? '1' : '0'); }} />
{t('clu.chaseCounty')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chasePfxHint')}>
<Checkbox checked={chasePfxOn}
onCheckedChange={(c) => { const v = !!c; setChasePfxOn(v); writeUiPref('opslog.chasePfx', v ? '1' : '0'); }} />
{t('clu.chasePfx')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseStateHint')}>
<Checkbox checked={chaseStateOn}
onCheckedChange={(c) => { const v = !!c; setChaseStateOn(v); writeUiPref('opslog.chaseState', v ? '1' : '0'); }} />
{t('clu.chaseState')}
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={chaseGrids} className="mt-0.5"
onCheckedChange={(c) => { setChaseGrids(!!c); writeUiPref('opslog.chaseGrids', c ? '1' : '0'); SetChaseNewGrids(!!c).catch(() => {}); }} />
<span>{t('clu.chaseGrids')} <span className="text-xs text-muted-foreground">{t('clu.chaseGridsHint')}</span></span>
</label>
{chaseGrids && (
<p className="pl-6 text-xs text-muted-foreground">
{t('clu.chaseGridsStat', { n: gridStat?.known ?? 0, p: gridStat?.pending ?? 0 })}
{pskrStatus?.running ? ` · ${t('bo.feedUp', { n: pskrStatus.received ?? 0 })}` : ''}
</p>
)}
{/* What counts as "I already have this square". There is no single
right answer a VUCC chaser counts a square per band, someone
filling a wall map counts it once so it is a choice, and the
same six GridTracker offers, because a square wanted in one and
not the other is a bug report every time. */}
<div className="pl-6 space-y-1.5 pt-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('gsc.scope')}</span>
<Select value={gridScope.scope} onValueChange={(v) => saveGridScope({ ...gridScope, scope: v })}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{(gridScope.scopes ?? []).map((s: any) => (
<SelectItem key={s.key} value={s.key}>{t(`gsc.scope_${s.key}`)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Chase new its own option, NOT nested under grid chasing. Chasing
squares and chasing entities are different wants; they only share
the PSK Reporter feed, which either one brings up. */}
<div className="border-t border-border/60 pt-3 space-y-2">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={chaseNew} className="mt-0.5"
onCheckedChange={(c) => { setChaseNew(!!c); SetChaseNew(!!c).catch(() => {}); }} />
<span>{t('chn.option')} <span className="text-xs text-muted-foreground">{t('chn.optionHelp')}</span></span>
</label>
{/* The same radius the band-opening watch uses one feed, one
circle but reachable from here, because an operator who only
wants the chase list would otherwise have to find it inside a
watch they never switched on. It is the setting that decides
whether this list has anything in it at all: where stations are
far apart, 300 km can hold no receivers whatsoever. */}
{chaseNew && (
<div className="flex items-center gap-2 flex-wrap pl-6">
<span className="text-xs text-muted-foreground">{t('bo.nearKm')}</span>
<Input
type="number" min={25} max={3000} step={25}
className="w-24 h-7 text-xs"
defaultValue={bandOpen.near_km ?? 300}
key={`cnk-${bandOpen.near_km ?? 300}`}
onBlur={(e) => {
const v = parseInt(e.target.value, 10);
if (!isNaN(v) && v !== bandOpen.near_km) saveBandOpen({ ...bandOpen, near_km: v });
}}
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
/>
<span className="text-xs text-muted-foreground">km</span>
<span className="text-xs text-muted-foreground">{t('chn.nearKmHint')}</span>
</div>
)}
</div>
{/* PSK Reporter analysis of the station being called. Same service as
the two options above, opposite question: those ask what is being
heard around here, this asks whether ONE station can hear you. */}
<div className="border-t border-border/60 pt-3 space-y-2">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={pskTgt.enabled} className="mt-0.5"
onCheckedChange={(c) => savePSKTgt({ ...pskTgt, enabled: !!c })} />
<span>{t('psk.setEnable')} <span className="text-xs text-muted-foreground">{t('psk.setEnableHint')}</span></span>
</label>
{pskTgt.enabled && (
<div className="pl-6 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">{t('psk.setScope')}</span>
<Select value={pskTgt.scope} onValueChange={(v) => savePSKTgt({ ...pskTgt, scope: v })}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="target">{t('psk.setScopeTarget')}</SelectItem>
<SelectItem value="band">{t('psk.setScopeBand')}</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">{t('psk.setScopeHint')}</p>
</div>
)}
</div>
{/* Contest how a special-event fleet finds its way onto the watch
list on its own. Two halves, because a fleet has two kinds of
member. */}
<div className="border-t border-border/60 pt-3 space-y-2">
<div className="text-sm font-medium">{t('wlc.title')}</div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">{t('wlc.pattern')}</span>
<Input className="h-7 w-32 text-xs font-mono uppercase"
defaultValue={contestPattern} key={`wlcp-${contestPattern}`}
placeholder={t('wlc.patternPh')}
onBlur={(e) => {
const v = e.target.value.toUpperCase().trim();
if (v !== contestPattern) { setContestPattern(v); void SetWatchlistContestPattern(v); }
}}
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} />
<span className="text-xs text-muted-foreground">{t('wlc.patternHint')}</span>
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t('wlc.calls')}</span>
<textarea
className="w-full h-24 rounded-md border border-border bg-background p-2 text-xs font-mono uppercase"
value={contestCalls}
placeholder={t('wlc.callsPh')}
onChange={(e) => setContestCalls(e.target.value)}
onBlur={(e) => void SetWatchlistContestCalls(e.target.value)}
/>
<p className="text-xs text-muted-foreground">{t('wlc.callsHint')}</p>
</div>
</div>
{/* Auto-call. Last in this section, and behind a warning: it is the
only setting in OpsLog that transmits without being asked to. */}
<div className="border-t border-border/60 pt-3 space-y-2">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={ac.enabled} className="mt-0.5"
onCheckedChange={(c) => saveAC({ ...ac, enabled: !!c })} />
<span>{t('ac.enable')} <span className="text-xs text-muted-foreground">{t('ac.enableHint')}</span></span>
</label>
<p className="text-xs text-warning">{t('ac.warn')}</p>
{ac.enabled && (
<div className="pl-6 space-y-2">
<p className="text-xs text-muted-foreground">{t('ac.ladder')}</p>
{/* A LIST: several callsigns, spaces or commas. Committed on
blur or Enter and kept as raw text while typing binding the
box to the parsed value is what makes the space key look dead,
and space is the one key this field needs. */}
<div className="flex items-start gap-2 flex-wrap">
<span className="text-xs text-muted-foreground mt-1.5">{t('ac.only')}</span>
<Input className="h-7 w-72 text-xs font-mono uppercase"
defaultValue={ac.only ?? ''} key={`aco-${ac.only ?? ''}`}
placeholder={t('ac.onlyPh')}
onBlur={(e) => saveAC({ ...ac, only: e.target.value.toUpperCase() })}
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} />
<span className="text-xs text-muted-foreground mt-1.5">{t('ac.onlyHint')}</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
{([
['attempts', t('ac.attempts'), 1, 30],
['watched_attempts', t('ac.watchedAttempts'), 1, 60],
['misses', t('ac.misses'), 1, 10],
['max_rounds', t('ac.rounds'), 1, 10],
['rest_min', t('ac.rest'), 1, 60],
] as [string, string, number, number][]).map(([k, label, min, max]) => (
<span key={k} className="inline-flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{label}</span>
<Input type="number" min={min} max={max} className="h-7 w-16 text-xs"
defaultValue={(ac as any)[k]} key={`ac-${k}-${(ac as any)[k]}`}
onBlur={(e) => {
const v = parseInt(e.target.value, 10);
if (!isNaN(v) && v >= min && v <= max && v !== (ac as any)[k]) saveAC({ ...ac, [k]: v });
}}
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} />
</span>
))}
</div>
</div>
)}
</div>
</div>
</>
);
}
function ClusterPanel() { function ClusterPanel() {
const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)); const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
// Written on every keystroke. This panel has no Save button, and a pair of // Written on every keystroke. This panel has no Save button, and a pair of
@@ -5420,97 +5716,6 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</div> </div>
</div> </div>
<div className="border-t border-border/60 pt-3 space-y-2">
{/* Chase switches: off, the marker stops being TOLD, everywhere at
once badge, colour, filter chip, reference column. A "new band
+ new POTA" spot then reads NEW BAND alone. */}
{/* The GLOBAL hunt: every category (DXCC, band, mode, slot, prefix,
county, state, grid) judged as new-only or new-plus-unconfirmed,
against the confirmation sources the operator trusts. */}
<div className="rounded-lg border border-border p-3 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('chg.mode')}</span>
<Select value={chaseCfg.mode} onValueChange={(v) => { const next = { ...chaseCfg, mode: v }; setChaseCfg(next); void SaveChaseSettings(next as any); }}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="new">{t('gsc.huntNew')}</SelectItem>
<SelectItem value="new_unconfirmed">{t('gsc.huntUnconf')}</SelectItem>
</SelectContent>
</Select>
</div>
{chaseCfg.mode === 'new_unconfirmed' && (
<div className="flex items-center gap-4 flex-wrap pl-2">
<span className="text-xs text-muted-foreground">{t('chg.sources')}</span>
{([['lotw', 'LoTW'], ['card', t('chg.card')], ['eqsl', 'eQSL'], ['qrz', 'QRZ.com']] as const).map(([k, label]) => (
<label key={k} className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox checked={chaseCfg.sources.includes(k)}
onCheckedChange={(c) => {
const sources = c ? [...chaseCfg.sources, k] : chaseCfg.sources.filter((x) => x !== k);
const next = { ...chaseCfg, sources };
setChaseCfg(next); void SaveChaseSettings(next as any);
}} />
{label}
</label>
))}
</div>
)}
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chasePotaHint')}>
<Checkbox checked={chasePotaOn}
onCheckedChange={(c) => { const v = !!c; setChasePotaOn(v); writeUiPref('opslog.chasePota', v ? '1' : '0'); }} />
{t('clu.chasePota')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseSotaHint')}>
<Checkbox checked={chaseSotaOn}
onCheckedChange={(c) => { const v = !!c; setChaseSotaOn(v); writeUiPref('opslog.chaseSota', v ? '1' : '0'); }} />
{t('clu.chaseSota')}
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={chaseGrids} className="mt-0.5"
onCheckedChange={(c) => { setChaseGrids(!!c); SetChaseNewGrids(!!c).catch(() => {}); }} />
<span>{t('clu.chaseGrids')} <span className="text-xs text-muted-foreground">{t('clu.chaseGridsHint')}</span></span>
</label>
{chaseGrids && (
<p className="pl-6 text-xs text-muted-foreground">
{t('clu.chaseGridsStat', { n: gridStat?.known ?? 0, p: gridStat?.pending ?? 0 })}
{pskrStatus?.running ? ` · ${t('bo.feedUp', { n: pskrStatus.received ?? 0 })}` : ''}
</p>
)}
{/* What counts as "I already have this square". There is no single
right answer a VUCC chaser counts a square per band, someone
filling a wall map counts it once so it is a choice, and the
same six GridTracker offers, because a square wanted in one and
not the other is a bug report every time. */}
<div className="pl-6 space-y-1.5 pt-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('gsc.scope')}</span>
<Select value={gridScope.scope} onValueChange={(v) => saveGridScope({ ...gridScope, scope: v })}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{(gridScope.scopes ?? []).map((s: any) => (
<SelectItem key={s.key} value={s.key}>{t(`gsc.scope_${s.key}`)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Chase new its own option, NOT nested under grid chasing. Chasing
squares and chasing entities are different wants; they only share
the PSK Reporter feed, which either one brings up. */}
<div className="border-t border-border/60 pt-3 space-y-2">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={chaseNew} className="mt-0.5"
onCheckedChange={(c) => { setChaseNew(!!c); SetChaseNew(!!c).catch(() => {}); }} />
<span>{t('chn.option')} <span className="text-xs text-muted-foreground">{t('chn.optionHelp')}</span></span>
</label>
</div>
{/* Band-opening watch. It lives HERE, with the cluster nodes, because {/* Band-opening watch. It lives HERE, with the cluster nodes, because
switching it on adds two of them the operator should see that switching it on adds two of them the operator should see that
happen where it happens rather than find nodes they did not add. */} happen where it happens rather than find nodes they did not add. */}
@@ -5546,7 +5751,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">{t('bo.nearKm')}</span> <span className="text-xs text-muted-foreground">{t('bo.nearKm')}</span>
<Input <Input
type="number" min={25} max={1000} step={25} type="number" min={25} max={3000} step={25}
className="w-24 h-7 text-xs" className="w-24 h-7 text-xs"
defaultValue={bandOpen.near_km ?? 300} defaultValue={bandOpen.near_km ?? 300}
key={`nk-${bandOpen.near_km ?? 300}`} key={`nk-${bandOpen.near_km ?? 300}`}
@@ -5735,6 +5940,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
{renderSelect('qrzcom_confirmed', FULL_OPTIONS)} {renderSelect('qrzcom_confirmed', FULL_OPTIONS)}
</div> </div>
</div> </div>
{/* HamQTH — no received side: the site has no confirmation feed. */}
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
<Label className="text-sm font-medium pb-1.5">HamQTH</Label>
<div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
{renderSelect('hamqth_status', FULL_OPTIONS)}
</div>
<div />
</div>
{/* HAMLOG.online */} {/* HAMLOG.online */}
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end"> <div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
<Label className="text-sm font-medium pb-1.5">HAMLOG.online</Label> <Label className="text-sm font-medium pb-1.5">HAMLOG.online</Label>
@@ -5928,6 +6142,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
{ k: 'lotw', label: 'LOTW', ready: true }, { k: 'lotw', label: 'LOTW', ready: true },
{ k: 'cloudlog', label: 'CLOUDLOG', ready: true }, { k: 'cloudlog', label: 'CLOUDLOG', ready: true },
{ k: 'hamlog', label: 'HAMLOG.ONLINE', ready: true }, { k: 'hamlog', label: 'HAMLOG.ONLINE', ready: true },
{ k: 'hamqth', label: 'HAMQTH', ready: true },
{ k: 'pota', label: 'POTA', ready: true }, { k: 'pota', label: 'POTA', ready: true },
]; ];
@@ -5997,6 +6212,21 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
} }
} }
const hamqth = extSvc.hamqth ?? emptyExtCfg();
const setHamqth = (patch: Partial<ExtServiceCfg>) =>
setExtSvc((s) => ({ ...s, hamqth: { ...(s.hamqth ?? emptyExtCfg()), ...patch } }));
async function testHamqth() {
setHamqthTesting(true);
setHamqthTest(null);
try {
const msg = await TestHamQTHUpload();
setHamqthTest({ ok: true, msg });
} catch (e: any) {
setHamqthTest({ ok: false, msg: String(e?.message ?? e) });
} finally {
setHamqthTesting(false);
}
}
const hamlog = extSvc.hamlog ?? emptyExtCfg(); const hamlog = extSvc.hamlog ?? emptyExtCfg();
const setHamlog = (patch: Partial<ExtServiceCfg>) => const setHamlog = (patch: Partial<ExtServiceCfg>) =>
setExtSvc((s) => ({ ...s, hamlog: { ...(s.hamlog ?? emptyExtCfg()), ...patch } })); setExtSvc((s) => ({ ...s, hamlog: { ...(s.hamlog ?? emptyExtCfg()), ...patch } }));
@@ -6342,11 +6572,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</div> </div>
<div className="border-t border-border/60 pt-3 space-y-3"> <div className="border-t border-border/60 pt-3 space-y-3">
<label className="flex items-center gap-2 text-sm cursor-pointer"> {/* The site stopped issuing API keys and its upload API takes
<Checkbox nothing else, so an auto-upload switch here would arm something
checked={hamlog.auto_upload} that can only fail. Their confirmations still arrive as a file,
onCheckedChange={(c) => setHamlog({ auto_upload: !!c })} which never needed a key. */}
/> <p className="text-xs rounded-md border border-warning/40 bg-warning/10 px-2 py-1.5 text-warning-muted-foreground">
{t('es.hamlogClosed')}
</p>
<label className="flex items-center gap-2 text-sm cursor-not-allowed opacity-50">
<Checkbox checked={false} disabled />
{t('es.autoUpload')} {t('es.autoUpload')}
</label> </label>
@@ -6376,6 +6610,43 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</div> </div>
</div> </div>
</div> </div>
) : extSvcTab === 'hamqth' ? (
<div className="space-y-4 max-w-2xl">
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">{t('es.username')}</Label>
<Input value={hamqth.username} onChange={(e) => setHamqth({ username: e.target.value })} className="text-xs w-64" />
<Label className="text-sm">{t('es.password')}</Label>
<Input type="password" value={hamqth.password} onChange={(e) => setHamqth({ password: e.target.value })} className="text-xs w-64" />
<Label className="text-sm">{t('es.hamqthCall')}</Label>
<Input value={hamqth.callsign} onChange={(e) => setHamqth({ callsign: e.target.value.toUpperCase() })} className="text-xs w-40 font-mono" placeholder={t('es.optional')} />
</div>
<div className="text-[10px] text-muted-foreground -mt-1">{t('es.hamqthHint')}</div>
<div className="border-t border-border/60 pt-3 space-y-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={hamqth.auto_upload} onCheckedChange={(c) => setHamqth({ auto_upload: !!c })} />
{t('es.autoUpload')}
</label>
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">{t('es.uploadTiming')}</Label>
<Select value={hamqth.upload_mode === 'delayed' ? 'delayed' : 'immediate'} onValueChange={(v) => setHamqth({ upload_mode: v })}>
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="immediate">{t('es.immediate')}</SelectItem>
<SelectItem value="delayed">{t('es.delayed')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={testHamqth} disabled={hamqthTesting}>
<UploadCloud className="size-3.5" /> {hamqthTesting ? t('es.testing') : t('es.testConn')}
</Button>
{hamqthTest && (
<span className={cn('text-xs', hamqthTest.ok ? 'text-success' : 'text-danger')}>{hamqthTest.msg}</span>
)}
</div>
</div>
</div>
) : extSvcTab === 'cloudlog' ? ( ) : extSvcTab === 'cloudlog' ? (
<div className="space-y-4 max-w-2xl"> <div className="space-y-4 max-w-2xl">
<div className="grid grid-cols-[170px_1fr] gap-3 items-center"> <div className="grid grid-cols-[170px_1fr] gap-3 items-center">
@@ -7134,6 +7405,21 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</Button> </Button>
)} )}
</div> </div>
{/* Kenwood only, because only a Kenwood acts on it: TX1 is that
family's second transmit command. Every other backend keys the
one way it knows, so showing the box there would be a switch
that changes nothing the same dead furniture as ANT2 on a
radio with one socket. */}
{audioCfg.ptt_method === 'cat' && catCfg.backend === 'kenwood' && (
<>
<span />
<label className="flex items-start gap-2 text-sm cursor-pointer" title={t('aud.pttDataHint')}>
<Checkbox className="mt-0.5" checked={!!audioCfg.ptt_data}
onCheckedChange={(c) => setAudioField({ ptt_data: !!c })} />
<span>{t('aud.pttData')}</span>
</label>
</>
)}
{(audioCfg.ptt_method === 'rts' || audioCfg.ptt_method === 'dtr') && ( {(audioCfg.ptt_method === 'rts' || audioCfg.ptt_method === 'dtr') && (
<> <>
<Label className="text-sm">{t('aud.pttPort')}</Label> <Label className="text-sm">{t('aud.pttPort')}</Label>
@@ -7953,6 +8239,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
'lists-modes': ModesPanel, 'lists-modes': ModesPanel,
'lists-satellites': SatellitesPanel, 'lists-satellites': SatellitesPanel,
cluster: ClusterPanel, cluster: ClusterPanel,
dxhunter: DXHunterPanel,
udp: UDPIntegrationsPanelWrapper, udp: UDPIntegrationsPanelWrapper,
// Module-scope components, wrapped so their props can be passed. The nested // Module-scope components, wrapped so their props can be passed. The nested
// panels below go through PanelHost instead — which is what now lets either // panels below go through PanelHost instead — which is what now lets either
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react'; import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
import { import {
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations, ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
GetWsjtHighlight, SetWsjtHighlight, GetWsjtFollowMode, SetWsjtFollowMode, GetWsjtHighlight, SetWsjtHighlight, GetWsjtHighlightWorked, SetWsjtHighlightWorked, GetWsjtFollowMode, SetWsjtFollowMode,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -160,9 +160,12 @@ type Props = { onError: (msg: string) => void };
export function UDPIntegrationsPanel({ onError }: Props) { export function UDPIntegrationsPanel({ onError }: Props) {
const [highlightOn, setHighlightOn] = useState(false); const [highlightOn, setHighlightOn] = useState(false);
const [hlWorked, setHlWorked] = useState(false);
const [followMode, setFollowMode] = useState(true); const [followMode, setFollowMode] = useState(true);
useEffect(() => { useEffect(() => {
GetWsjtHighlight().then((v) => setHighlightOn(!!v)).catch(() => {}); GetWsjtHighlight().then((v) => setHighlightOn(!!v)).catch(() => {});
GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {});
GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {});
GetWsjtFollowMode().then((v) => setFollowMode(!!v)).catch(() => {}); GetWsjtFollowMode().then((v) => setFollowMode(!!v)).catch(() => {});
}, []); }, []);
const { t } = useI18n(); const { t } = useI18n();
@@ -246,6 +249,18 @@ export function UDPIntegrationsPanel({ onError }: Props) {
<span className="block text-[11px] text-muted-foreground">{t('udpp.highlightHint')}</span> <span className="block text-[11px] text-muted-foreground">{t('udpp.highlightHint')}</span>
</span> </span>
</label> </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"> <label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl">
<Checkbox checked={followMode} <Checkbox checked={followMode}
onCheckedChange={(c) => { setFollowMode(!!c); void SetWsjtFollowMode(!!c); }} /> onCheckedChange={(c) => { setFollowMode(!!c); void SetWsjtFollowMode(!!c); }} />
+33 -14
View File
@@ -11,7 +11,7 @@
// The design is DXHunter's — the layout, the badges, the wording — repainted in // 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. // the app's theme tokens rather than its hard-coded slate/pink.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Eye, Plus, Trash2, Bell, BellOff, Trophy, Search } from 'lucide-react'; import { Eye, Plus, Trash2, Bell, BellOff, Trophy, Search, Check, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
@@ -181,7 +181,8 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
try { try {
await WatchlistAdd(c, addContest); await WatchlistAdd(c, addContest);
setAddCall(''); setAddCall('');
flash(t(addContest ? 'wl.addedContest' : 'wl.added', { call: c }), false); // 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(); await refresh();
} catch (e: any) { flash(String(e?.message ?? e), true); } } catch (e: any) { flash(String(e?.message ?? e), true); }
}; };
@@ -190,7 +191,7 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
// entry is cheap to undo (type it again), so it does not earn a confirmation // entry is cheap to undo (type it again), so it does not earn a confirmation
// the other two buttons do not have. // the other two buttons do not have.
const remove = async (call: string) => { const remove = async (call: string) => {
try { await WatchlistRemove(call); flash(t('wl.removed', { call }), false); await refresh(); } try { await WatchlistRemove(call); await refresh(); }
catch (e: any) { flash(String(e?.message ?? e), true); } catch (e: any) { flash(String(e?.message ?? e), true); }
}; };
@@ -234,8 +235,16 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
} }
}; };
// 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) => ( const chip = (color: string, text: string, extra?: string) => (
<span className={cn('px-1.5 py-0.5 rounded text-[10px] font-bold border', extra)} <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)` }}> style={{ color, borderColor: `color-mix(in srgb, ${color} 40%, transparent)`, background: `color-mix(in srgb, ${color} 12%, transparent)` }}>
{text} {text}
</span> </span>
@@ -331,14 +340,18 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
const list = neededOnly ? all.filter((s) => !workedFor(e, s)) : all; const list = neededOnly ? all.filter((s) => !workedFor(e, s)) : all;
return ( return (
<div key={e.callsign} <div key={e.callsign}
className={cn('rounded-lg border bg-card/70 p-3 transition-colors hover:bg-accent/20', className={cn('rounded border bg-card/70 p-3 transition-colors hover:bg-accent/20',
needed > 0 ? 'border-warning/40' : 'border-border/70', needed > 0 ? 'border-warning/40' : 'border-border/70',
e.isContest && 'border-l-4 border-l-warning')}> e.isContest && 'border-l-4 border-l-warning')}>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-lg font-bold font-mono" style={{ color: '#f472b6' }}>{e.callsign}</span> {/* 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')} {isOnAir(e) && chip('var(--danger)', t('wl.onAir'), 'animate-pulse')}
{e.isContest && ( {e.isContest && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border" <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')}> title={t('wl.contestHint')}>
<Trophy className="size-3" /> {t('wl.contest')} <Trophy className="size-3" /> {t('wl.contest')}
</span> </span>
@@ -348,16 +361,16 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
{e.clubLogHasOQRS && chip('var(--success)', 'OQRS')} {e.clubLogHasOQRS && chip('var(--success)', 'OQRS')}
{e.clubLogLiveStream && ( {e.clubLogLiveStream && (
<a href="#" onClick={(ev) => { ev.preventDefault(); void OpenExternalURL(`https://clublog.org/livestream/${e.callsign.replace('*', '')}`); }} <a href="#" onClick={(ev) => { ev.preventDefault(); void OpenExternalURL(`https://clublog.org/livestream/${e.callsign.replace('*', '')}`); }}
className="px-1.5 py-0.5 rounded text-[10px] font-bold border text-info border-info/40 bg-info/10 hover:bg-info/20">Live</a> 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 {list.length > 0 && (needed > 0
? chip('var(--warning)', e.isContest ? t('wl.nToday', { n: needed }) : t('wl.nNeeded', { n: needed })) ? 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')))} : chip('var(--success)', e.isContest ? t('wl.workedToday') : t('wl.allWorked')))}
{e.lastSeenStr && e.lastSeenStr !== 'Never' && ( {e.lastSeenStr && e.lastSeenStr !== 'Never' && (
<span className="text-[11px] text-muted-foreground">· {e.lastSeenStr}</span> <span className="text-[11px] text-muted-foreground"> {e.lastSeenStr}</span>
)} )}
{e.spotCount > 0 && ( {e.spotCount > 0 && (
<span className="text-[11px] text-muted-foreground/70">· {t('wl.totalSpots', { n: e.spotCount })}</span> <span className="text-[11px] text-muted-foreground/70"> {t('wl.totalSpots', { n: e.spotCount })}</span>
)} )}
<div className="flex-1" /> <div className="flex-1" />
<button type="button" title={t('wl.toggleContest')} <button type="button" title={t('wl.toggleContest')}
@@ -389,14 +402,20 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
onDoubleClick={() => onSpotClick?.(s)} onDoubleClick={() => onSpotClick?.(s)}
title={t('wl.spotTip')} 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', 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-[3px] border-warning')}> !done && 'border-l-2 border-warning')}>
{done && <span className='font-bold shrink-0 text-success'></span>} {/* 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. */} {/* Fixed columns: an elastic country made band/mode/freq start wherever the name ended — every row its own ruler. */}
<span className="font-mono font-bold text-info shrink-0 w-24 truncate">{s.dx_call}</span> <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="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 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="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">{(s.freq_hz / 1e6).toFixed(4)}</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)} {badge && chip(badge.color, badge.label)}
<div className="flex-1" /> <div className="flex-1" />
{done {done
-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');
}
+23
View File
@@ -111,3 +111,26 @@ export function bandRange(band: string): [number, number] | undefined {
export function bandSegments(band: string): Seg[] { export function bandSegments(band: string): Seg[] {
return plan().segments[band] ?? []; 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;
}
+112 -38
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; 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 toRad(d: number): number { return (d * Math.PI) / 180; }
function toDeg(r: number): number { return (r * 180) / Math.PI; } function toDeg(r: number): number { return (r * 180) / Math.PI; }
+57 -2
View File
@@ -20,6 +20,7 @@ export type SpotDisplayOptions = {
// keep being computed; only the telling stops, so ticking the box back on // keep being computed; only the telling stops, so ticking the box back on
// needs no rescan. // needs no rescan.
chasePota: boolean; chaseSota: boolean; chasePota: boolean; chaseSota: boolean;
chaseCounty: boolean; chasePfx: boolean; chaseGrid: boolean; chaseState: boolean;
}; };
// chasePota/chaseSota read the switches directly — for the places that show a // chasePota/chaseSota read the switches directly — for the places that show a
@@ -30,6 +31,43 @@ export function chasePota(): boolean {
export function chaseSota(): boolean { export function chaseSota(): boolean {
try { return localStorage.getItem('opslog.chaseSota') !== '0'; } catch { return true; } 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 // 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 // is deliberately kept whole — it is correct and hard-won — so putting the two
@@ -44,16 +82,18 @@ export function readSpotDisplayOptions(): SpotDisplayOptions {
// The EXPOSED flag only withdraws the two original switches; the chase // The EXPOSED flag only withdraws the two original switches; the chase
// switches are live regardless. // switches are live regardless.
if (!SPOT_DISPLAY_OPTIONS_EXPOSED) { if (!SPOT_DISPLAY_OPTIONS_EXPOSED) {
return { muteWorked: false, slotHighlight: false, chasePota: chasePota(), chaseSota: chaseSota() }; return { muteWorked: false, slotHighlight: false, chasePota: chasePota(), chaseSota: chaseSota(), chaseCounty: chaseCounty(), chasePfx: chasePfx(), chaseGrid: chaseGrid(), chaseState: chaseState() };
} }
try { try {
return { return {
muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1', muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1',
slotHighlight: localStorage.getItem('opslog.clusterSlotHighlight') === '1', slotHighlight: localStorage.getItem('opslog.clusterSlotHighlight') === '1',
chasePota: chasePota(), chaseSota: chaseSota(), chasePota: chasePota(), chaseSota: chaseSota(),
chaseCounty: chaseCounty(), chasePfx: chasePfx(), chaseGrid: chaseGrid(),
chaseState: chaseState(),
}; };
} catch { } catch {
return { muteWorked: false, slotHighlight: false, chasePota: true, chaseSota: true }; return { muteWorked: false, slotHighlight: false, chasePota: true, chaseSota: true, chaseCounty: true, chasePfx: true, chaseGrid: true, chaseState: true };
} }
} }
@@ -65,6 +105,7 @@ type Entry = {
new_pota?: boolean; new_pota?: boolean;
new_pfx?: boolean; new_pfx?: boolean;
new_grid?: boolean; new_grid?: boolean;
new_state?: boolean;
} | undefined; } | undefined;
// applySpotDisplay rewrites a status entry per the options, so every consumer — // applySpotDisplay rewrites a status entry per the options, so every consumer —
@@ -98,6 +139,20 @@ export function applySpotDisplay<T extends Entry>(s: T, o: SpotDisplayOptions):
if (!o.chasePota && e.new_pota) { if (!o.chasePota && e.new_pota) {
e = { ...e, new_pota: false } as NonNullable<T>; 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; return e;
} }
+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 // preference. The choice is persisted (localStorage + portable UI pref, so it
// travels with the data/ folder like the language). // travels with the data/ folder like the language).
export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'light-nordic' | 'sahara' 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, // Selectable, concrete themes (excludes 'auto') in display order: lights first,
// then darks, with high-contrast last — it is an accessibility choice, not a // then darks, with high-contrast last — it is an accessibility choice, not a
// taste one, and listing it among the moods buries it. // taste one, and listing it among the moods buries it.
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [ export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
'light-warm', 'light-cool', 'light-sage', 'light-nordic', 'sahara', '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', 'high-contrast',
]; ];
+155 -3
View File
@@ -574,8 +574,8 @@
"entity confirmed" cell would read as a button. */ "entity confirmed" cell would read as a button. */
--mx-call-conf: #22c55e; --mx-call-conf: #22c55e;
--mx-call-work: #2c7a52; --mx-call-work: #2c7a52;
--mx-dx-conf: #22d3ee; --mx-dx-conf: #a78bfa;
--mx-dx-work: #1b6b7c; --mx-dx-work: #5b4a9e;
--mx-none: #2c2f4d; --mx-none: #2c2f4d;
--scrollbar-thumb: #383c63; --scrollbar-thumb: #383c63;
@@ -862,6 +862,156 @@
color-scheme: light; 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) /* Data-viz palette (Statistics dashboard)
A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes 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 it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never
@@ -905,7 +1055,9 @@
[data-theme="high-contrast"], [data-theme="high-contrast"],
[data-theme="dark-indigo"], [data-theme="dark-indigo"],
[data-theme="dark-teal"], [data-theme="dark-teal"],
[data-theme="dark-plum"] { [data-theme="dark-plum"],
[data-theme="dxhunter"],
[data-theme="dxhunter-orange"] {
--chart-1: #3987e5; --chart-1: #3987e5;
--chart-2: #199e70; --chart-2: #199e70;
--chart-3: #c98500; --chart-3: #c98500;
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.27.5'; export const APP_VERSION = '0.27.12';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+44
View File
@@ -12,8 +12,10 @@ import {award} from '../models';
import {awardref} from '../models'; import {awardref} from '../models';
import {bandopen} from '../models'; import {bandopen} from '../models';
import {cluster} from '../models'; import {cluster} from '../models';
import {dxped} from '../models';
import {extsvc} from '../models'; import {extsvc} from '../models';
import {powergenius} from '../models'; import {powergenius} from '../models';
import {pskrtgt} from '../models';
import {pskr} from '../models'; import {pskr} from '../models';
import {psu} from '../models'; import {psu} from '../models';
import {spe} from '../models'; import {spe} from '../models';
@@ -414,6 +416,10 @@ export function GetAudioMonitorPref():Promise<boolean>;
export function GetAudioSettings():Promise<main.AudioSettings>; 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 GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
export function GetAward(arg1:string,arg2:string):Promise<award.Result>; export function GetAward(arg1:string,arg2:string):Promise<award.Result>;
@@ -476,6 +482,10 @@ export function GetDVKMessages():Promise<Array<main.DVKMessage>>;
export function GetDVKStatus():Promise<main.DVKStatus>; 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 GetDataDir():Promise<string>;
export function GetDatabaseSettings():Promise<main.DatabaseSettings>; export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
@@ -544,8 +554,12 @@ export function GetPGXLStatus():Promise<powergenius.Status>;
export function GetPOTAToken():Promise<string>; export function GetPOTAToken():Promise<string>;
export function GetPSKAnalysis():Promise<pskrtgt.Analysis>;
export function GetPSKReporterStatus():Promise<pskr.Status>; export function GetPSKReporterStatus():Promise<pskr.Status>;
export function GetPSKTargetSettings():Promise<main.PSKTargetSettings>;
export function GetPSUSettings():Promise<main.PSUSettings>; export function GetPSUSettings():Promise<main.PSUSettings>;
export function GetPSUStatus():Promise<psu.Status>; export function GetPSUStatus():Promise<psu.Status>;
@@ -612,6 +626,8 @@ export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>; export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
export function GetWatchlistContestCalls():Promise<string>;
export function GetWatchlistContestPattern():Promise<string>; export function GetWatchlistContestPattern():Promise<string>;
export function GetWebPublishConfig():Promise<webpub.Config>; export function GetWebPublishConfig():Promise<webpub.Config>;
@@ -630,6 +646,8 @@ export function GetWsjtFollowMode():Promise<boolean>;
export function GetWsjtHighlight():Promise<boolean>; export function GetWsjtHighlight():Promise<boolean>;
export function GetWsjtHighlightWorked():Promise<boolean>;
export function GetYaesuBandAntennas():Promise<Record<string, number>>; export function GetYaesuBandAntennas():Promise<Record<string, number>>;
export function GetYaesuState():Promise<cat.YaesuTXState>; export function GetYaesuState():Promise<cat.YaesuTXState>;
@@ -642,6 +660,8 @@ export function HasBuiltinReferences(arg1:string):Promise<boolean>;
export function IcomConsolePTT(arg1:boolean):Promise<void>; export function IcomConsolePTT(arg1:boolean):Promise<void>;
export function IcomRecallBand(arg1:string,arg2:number):Promise<number>;
export function IcomRefresh():Promise<void>; export function IcomRefresh():Promise<void>;
export function IcomScopeData():Promise<cat.ScopeSweep>; export function IcomScopeData():Promise<cat.ScopeSweep>;
@@ -944,6 +964,8 @@ export function RecomputeAwardRefsForCode(arg1:string):Promise<number>;
export function RefreshCtyDat():Promise<main.CtyDatInfo>; export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function RefreshDXpeditions():Promise<void>;
export function RefreshKenwood():Promise<void>; export function RefreshKenwood():Promise<void>;
export function RefreshSolar():Promise<void>; export function RefreshSolar():Promise<void>;
@@ -970,6 +992,8 @@ export function ReportLiveActivity(arg1:number,arg2:string,arg3:string):Promise<
export function RescanAwards():Promise<void>; export function RescanAwards():Promise<void>;
export function ResetAutoCall():Promise<void>;
export function ResetAwardDefs():Promise<Array<award.Def>>; export function ResetAwardDefs():Promise<Array<award.Def>>;
export function ResetDatabaseToDefault():Promise<void>; export function ResetDatabaseToDefault():Promise<void>;
@@ -1016,6 +1040,8 @@ export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>
export function SaveAudioSettings(arg1:main.AudioSettings):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 SaveAutostartPrograms(arg1:Array<main.AutostartProgram>):Promise<void>;
export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>; export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>;
@@ -1066,6 +1092,8 @@ export function SavePGXLSettings(arg1:main.PGXLSettings):Promise<void>;
export function SavePOTAToken(arg1:string):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 SavePSUSettings(arg1:main.PSUSettings):Promise<void>;
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>; export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
@@ -1126,6 +1154,10 @@ export function SetActiveRotor(arg1:number):Promise<void>;
export function SetAlertEmailTo(arg1:string):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 SetCATFrequency(arg1:number):Promise<void>; export function SetCATFrequency(arg1:number):Promise<void>;
export function SetCATMode(arg1:string):Promise<void>; export function SetCATMode(arg1:string):Promise<void>;
@@ -1198,6 +1230,8 @@ export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise<voi
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):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 SetPSUOutput(arg1:boolean):Promise<void>;
export function SetPassphrase(arg1:string):Promise<void>; export function SetPassphrase(arg1:string):Promise<void>;
@@ -1254,6 +1288,8 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
export function SetUltrabeamDirection(arg1:number):Promise<void>; export function SetUltrabeamDirection(arg1:number):Promise<void>;
export function SetWatchlistContestCalls(arg1:string):Promise<void>;
export function SetWatchlistContestPattern(arg1:string):Promise<void>; export function SetWatchlistContestPattern(arg1:string):Promise<void>;
export function SetWinkeyerTrace(arg1:boolean):Promise<void>; export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
@@ -1264,6 +1300,8 @@ export function SetWsjtFollowMode(arg1:boolean):Promise<void>;
export function SetWsjtHighlight(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 SetYaesuAFGain(arg1:number):Promise<void>;
export function SetYaesuAGC(arg1:string):Promise<void>; export function SetYaesuAGC(arg1:string):Promise<void>;
@@ -1324,6 +1362,8 @@ export function TCIStopCW():Promise<void>;
export function TailLogFile(arg1:number):Promise<string>; 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 TestCloudlogUpload():Promise<string>;
export function TestClublogUpload():Promise<string>; export function TestClublogUpload():Promise<string>;
@@ -1334,6 +1374,8 @@ export function TestEmail(arg1:string):Promise<void>;
export function TestHRDLogUpload():Promise<string>; export function TestHRDLogUpload():Promise<string>;
export function TestHamQTHUpload():Promise<string>;
export function TestLoTWUpload():Promise<string>; export function TestLoTWUpload():Promise<string>;
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>; export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
@@ -1388,6 +1430,8 @@ export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>;
export function UploadCallsign(arg1:string):Promise<string>; export function UploadCallsign(arg1:string):Promise<string>;
export function UploadFullLogHamQTH():Promise<void>;
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>; export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
export function WatchlistAdd(arg1:string,arg2:boolean):Promise<void>; export function WatchlistAdd(arg1:string,arg2:boolean):Promise<void>;
+84
View File
@@ -766,6 +766,14 @@ export function GetAudioSettings() {
return window['go']['main']['App']['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() { export function GetAutostartPrograms() {
return window['go']['main']['App']['GetAutostartPrograms'](); return window['go']['main']['App']['GetAutostartPrograms']();
} }
@@ -890,6 +898,14 @@ export function GetDVKStatus() {
return window['go']['main']['App']['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() { export function GetDataDir() {
return window['go']['main']['App']['GetDataDir'](); return window['go']['main']['App']['GetDataDir']();
} }
@@ -1026,10 +1042,18 @@ export function GetPOTAToken() {
return window['go']['main']['App']['GetPOTAToken'](); return window['go']['main']['App']['GetPOTAToken']();
} }
export function GetPSKAnalysis() {
return window['go']['main']['App']['GetPSKAnalysis']();
}
export function GetPSKReporterStatus() { export function GetPSKReporterStatus() {
return window['go']['main']['App']['GetPSKReporterStatus'](); return window['go']['main']['App']['GetPSKReporterStatus']();
} }
export function GetPSKTargetSettings() {
return window['go']['main']['App']['GetPSKTargetSettings']();
}
export function GetPSUSettings() { export function GetPSUSettings() {
return window['go']['main']['App']['GetPSUSettings'](); return window['go']['main']['App']['GetPSUSettings']();
} }
@@ -1162,6 +1186,10 @@ export function GetUltrabeamStatus() {
return window['go']['main']['App']['GetUltrabeamStatus'](); return window['go']['main']['App']['GetUltrabeamStatus']();
} }
export function GetWatchlistContestCalls() {
return window['go']['main']['App']['GetWatchlistContestCalls']();
}
export function GetWatchlistContestPattern() { export function GetWatchlistContestPattern() {
return window['go']['main']['App']['GetWatchlistContestPattern'](); return window['go']['main']['App']['GetWatchlistContestPattern']();
} }
@@ -1198,6 +1226,10 @@ export function GetWsjtHighlight() {
return window['go']['main']['App']['GetWsjtHighlight'](); return window['go']['main']['App']['GetWsjtHighlight']();
} }
export function GetWsjtHighlightWorked() {
return window['go']['main']['App']['GetWsjtHighlightWorked']();
}
export function GetYaesuBandAntennas() { export function GetYaesuBandAntennas() {
return window['go']['main']['App']['GetYaesuBandAntennas'](); return window['go']['main']['App']['GetYaesuBandAntennas']();
} }
@@ -1222,6 +1254,10 @@ export function IcomConsolePTT(arg1) {
return window['go']['main']['App']['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() { export function IcomRefresh() {
return window['go']['main']['App']['IcomRefresh'](); return window['go']['main']['App']['IcomRefresh']();
} }
@@ -1826,6 +1862,10 @@ export function RefreshCtyDat() {
return window['go']['main']['App']['RefreshCtyDat'](); return window['go']['main']['App']['RefreshCtyDat']();
} }
export function RefreshDXpeditions() {
return window['go']['main']['App']['RefreshDXpeditions']();
}
export function RefreshKenwood() { export function RefreshKenwood() {
return window['go']['main']['App']['RefreshKenwood'](); return window['go']['main']['App']['RefreshKenwood']();
} }
@@ -1878,6 +1918,10 @@ export function RescanAwards() {
return window['go']['main']['App']['RescanAwards'](); return window['go']['main']['App']['RescanAwards']();
} }
export function ResetAutoCall() {
return window['go']['main']['App']['ResetAutoCall']();
}
export function ResetAwardDefs() { export function ResetAwardDefs() {
return window['go']['main']['App']['ResetAwardDefs'](); return window['go']['main']['App']['ResetAwardDefs']();
} }
@@ -1970,6 +2014,10 @@ export function SaveAudioSettings(arg1) {
return window['go']['main']['App']['SaveAudioSettings'](arg1); return window['go']['main']['App']['SaveAudioSettings'](arg1);
} }
export function SaveAutoCallSettings(arg1) {
return window['go']['main']['App']['SaveAutoCallSettings'](arg1);
}
export function SaveAutostartPrograms(arg1) { export function SaveAutostartPrograms(arg1) {
return window['go']['main']['App']['SaveAutostartPrograms'](arg1); return window['go']['main']['App']['SaveAutostartPrograms'](arg1);
} }
@@ -2070,6 +2118,10 @@ export function SavePOTAToken(arg1) {
return window['go']['main']['App']['SavePOTAToken'](arg1); return window['go']['main']['App']['SavePOTAToken'](arg1);
} }
export function SavePSKTargetSettings(arg1) {
return window['go']['main']['App']['SavePSKTargetSettings'](arg1);
}
export function SavePSUSettings(arg1) { export function SavePSUSettings(arg1) {
return window['go']['main']['App']['SavePSUSettings'](arg1); return window['go']['main']['App']['SavePSUSettings'](arg1);
} }
@@ -2190,6 +2242,14 @@ export function SetAlertEmailTo(arg1) {
return window['go']['main']['App']['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 SetCATFrequency(arg1) { export function SetCATFrequency(arg1) {
return window['go']['main']['App']['SetCATFrequency'](arg1); return window['go']['main']['App']['SetCATFrequency'](arg1);
} }
@@ -2334,6 +2394,10 @@ export function SetOpsLogQSLReceived(arg1, arg2) {
return window['go']['main']['App']['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) { export function SetPSUOutput(arg1) {
return window['go']['main']['App']['SetPSUOutput'](arg1); return window['go']['main']['App']['SetPSUOutput'](arg1);
} }
@@ -2446,6 +2510,10 @@ export function SetUltrabeamDirection(arg1) {
return window['go']['main']['App']['SetUltrabeamDirection'](arg1); return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
} }
export function SetWatchlistContestCalls(arg1) {
return window['go']['main']['App']['SetWatchlistContestCalls'](arg1);
}
export function SetWatchlistContestPattern(arg1) { export function SetWatchlistContestPattern(arg1) {
return window['go']['main']['App']['SetWatchlistContestPattern'](arg1); return window['go']['main']['App']['SetWatchlistContestPattern'](arg1);
} }
@@ -2466,6 +2534,10 @@ export function SetWsjtHighlight(arg1) {
return window['go']['main']['App']['SetWsjtHighlight'](arg1); return window['go']['main']['App']['SetWsjtHighlight'](arg1);
} }
export function SetWsjtHighlightWorked(arg1) {
return window['go']['main']['App']['SetWsjtHighlightWorked'](arg1);
}
export function SetYaesuAFGain(arg1) { export function SetYaesuAFGain(arg1) {
return window['go']['main']['App']['SetYaesuAFGain'](arg1); return window['go']['main']['App']['SetYaesuAFGain'](arg1);
} }
@@ -2586,6 +2658,10 @@ export function TailLogFile(arg1) {
return window['go']['main']['App']['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() { export function TestCloudlogUpload() {
return window['go']['main']['App']['TestCloudlogUpload'](); return window['go']['main']['App']['TestCloudlogUpload']();
} }
@@ -2606,6 +2682,10 @@ export function TestHRDLogUpload() {
return window['go']['main']['App']['TestHRDLogUpload'](); return window['go']['main']['App']['TestHRDLogUpload']();
} }
export function TestHamQTHUpload() {
return window['go']['main']['App']['TestHamQTHUpload']();
}
export function TestLoTWUpload() { export function TestLoTWUpload() {
return window['go']['main']['App']['TestLoTWUpload'](); return window['go']['main']['App']['TestLoTWUpload']();
} }
@@ -2714,6 +2794,10 @@ export function UploadCallsign(arg1) {
return window['go']['main']['App']['UploadCallsign'](arg1); return window['go']['main']['App']['UploadCallsign'](arg1);
} }
export function UploadFullLogHamQTH() {
return window['go']['main']['App']['UploadFullLogHamQTH']();
}
export function UploadQSOsManual(arg1, arg2) { export function UploadQSOsManual(arg1, arg2) {
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2); return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
} }
+269
View File
@@ -1466,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 namespace extsvc {
export class ServiceConfig { export class ServiceConfig {
@@ -1522,6 +1553,7 @@ export namespace extsvc {
eqsl: ServiceConfig; eqsl: ServiceConfig;
cloudlog: ServiceConfig; cloudlog: ServiceConfig;
hamlog: ServiceConfig; hamlog: ServiceConfig;
hamqth: ServiceConfig;
delete_remote: boolean; delete_remote: boolean;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
@@ -1537,6 +1569,7 @@ export namespace extsvc {
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig); this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig); this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
this.hamlog = this.convertValues(source["hamlog"], ServiceConfig); this.hamlog = this.convertValues(source["hamlog"], ServiceConfig);
this.hamqth = this.convertValues(source["hamqth"], ServiceConfig);
this.delete_remote = source["delete_remote"]; this.delete_remote = source["delete_remote"];
} }
@@ -1891,6 +1924,7 @@ export namespace main {
preroll_seconds: number; preroll_seconds: number;
ptt_method: string; ptt_method: string;
ptt_port: string; ptt_port: string;
ptt_data: boolean;
format: string; format: string;
from_gain: number; from_gain: number;
mic_gain: number; mic_gain: number;
@@ -1912,6 +1946,7 @@ export namespace main {
this.preroll_seconds = source["preroll_seconds"]; this.preroll_seconds = source["preroll_seconds"];
this.ptt_method = source["ptt_method"]; this.ptt_method = source["ptt_method"];
this.ptt_port = source["ptt_port"]; this.ptt_port = source["ptt_port"];
this.ptt_data = source["ptt_data"];
this.format = source["format"]; this.format = source["format"];
this.from_gain = source["from_gain"]; this.from_gain = source["from_gain"];
this.mic_gain = source["mic_gain"]; this.mic_gain = source["mic_gain"];
@@ -1919,6 +1954,58 @@ export namespace main {
this.qso_play_gain = source["qso_play_gain"]; 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_min: number;
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_min = source["rest_min"];
}
}
export class AutoCallStatus {
enabled: boolean;
only: string;
target: string;
calls: number;
max: number;
misses: number;
max_miss: number;
stopped: boolean;
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.calls = source["calls"];
this.max = source["max"];
this.misses = source["misses"];
this.max_miss = source["max_miss"];
this.stopped = source["stopped"];
this.reason = source["reason"];
}
}
export class AutostartLaunchResult { export class AutostartLaunchResult {
id: string; id: string;
name: string; name: string;
@@ -2265,6 +2352,7 @@ export namespace main {
backend: string; backend: string;
omnirig_rig: number; omnirig_rig: number;
omnirig_vfo: string; omnirig_vfo: string;
digi_as_usb: boolean;
flex_host: string; flex_host: string;
flex_port: number; flex_port: number;
flex_spots: boolean; flex_spots: boolean;
@@ -2317,6 +2405,7 @@ export namespace main {
this.backend = source["backend"]; this.backend = source["backend"];
this.omnirig_rig = source["omnirig_rig"]; this.omnirig_rig = source["omnirig_rig"];
this.omnirig_vfo = source["omnirig_vfo"]; this.omnirig_vfo = source["omnirig_vfo"];
this.digi_as_usb = source["digi_as_usb"];
this.flex_host = source["flex_host"]; this.flex_host = source["flex_host"];
this.flex_port = source["flex_port"]; this.flex_port = source["flex_port"];
this.flex_spots = source["flex_spots"]; this.flex_spots = source["flex_spots"];
@@ -2672,6 +2761,44 @@ export namespace main {
this.rec_slot = source["rec_slot"]; 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 { export class DatabaseSettings {
path: string; path: string;
default_path: string; default_path: string;
@@ -3242,6 +3369,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 { export class PSUSettings {
enabled: boolean; enabled: boolean;
com_port: string; com_port: string;
@@ -3300,6 +3441,7 @@ export namespace main {
qrzcom_confirmed: string; qrzcom_confirmed: string;
hamlog_status: string; hamlog_status: string;
hamlog_confirmed: string; hamlog_confirmed: string;
hamqth_status: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new QSLDefaults(source); return new QSLDefaults(source);
@@ -3320,6 +3462,7 @@ export namespace main {
this.qrzcom_confirmed = source["qrzcom_confirmed"]; this.qrzcom_confirmed = source["qrzcom_confirmed"];
this.hamlog_status = source["hamlog_status"]; this.hamlog_status = source["hamlog_status"];
this.hamlog_confirmed = source["hamlog_confirmed"]; this.hamlog_confirmed = source["hamlog_confirmed"];
this.hamqth_status = source["hamqth_status"];
} }
} }
export class QSLEmailTemplates { export class QSLEmailTemplates {
@@ -4855,6 +4998,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 namespace psu {
export class Status { export class Status {
+6
View File
@@ -443,5 +443,11 @@ func adifCounty(state, county string) string {
if c == "" || s == "" || strings.Contains(c, ",") { if c == "" || s == "" || strings.Contains(c, ",") {
return 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 return strings.ToUpper(s) + "," + c
} }
+770
View File
@@ -0,0 +1,770 @@
// Package autocall answers FT8/FT4 decodes without the operator clicking them.
//
// THIS KEYS THE TRANSMITTER ON ITS OWN, which is why the decision lives here,
// in one place, as a function of state that can be read, argued with and
// tested — rather than spread through a panel that only runs while its tab is
// open. Everything below is written as a series of refusals: a call goes out
// only when nothing says it should not.
//
// ── What it is for ────────────────────────────────────────────────────────
// On a busy band the operator cannot read twenty decodes, judge each against
// the log and click the right one inside a fifteen-second slot. This does that
// part: it ranks what is on the air by what the log still needs, calls the best
// one, and — the harder half — knows when to STOP calling it.
//
// ── The ladder ────────────────────────────────────────────────────────────
// A watched callsign outranks the same need from anybody else, at every level:
//
// WL new DXCC > new DXCC > WL new band > new band > WL new mode > new mode
// > WL new slot > new slot > watched with nothing needed
//
// Watching a callsign is the operator saying "this one matters more than the
// rule", so it lifts a station by one rung rather than jumping the whole
// ladder: a watched new-slot must not outrank a new entity that will not come
// back.
//
// ── Why it stops ──────────────────────────────────────────────────────────
// The failure that matters is not calling the wrong station, it is calling one
// station for ever. Four independent brakes, any of which releases the target:
//
// - attempts: 7 calls, or 15 for a watched callsign
// - misses: 3 periods IN WHICH IT TRANSMITS with no decode of it
// - the clock: a target held longer than MaxHold is dropped whatever the
// counters say, because a counter that never advances never trips
// - rounds: a callsign released this way is rested, and after MaxRounds
// series it is parked for the session
//
// A released station is not banned. It can be picked again once rested, but
// only while nothing of HIGHER rank is callable — which is what keeps "seven
// calls, then straight back to the same station" from being fifty calls.
//
// ── Who is callable ───────────────────────────────────────────────────────
// A station in the middle of an exchange with somebody else is never targeted.
// It cannot answer, and WSJT-X and JTDX ignore a reply to it anyway; only the
// station calling CQ, calling US, or sending its last frame is worth a slot.
package autocall
import (
"fmt"
"regexp"
"strings"
"time"
"unicode"
)
// Need is what the log still wants from a station, worst to best.
type Need int
const (
NeedNone Need = iota
NeedSlot // entity worked on this band and in this mode, never together
NeedMode // entity never worked in this mode
NeedBand // entity never worked on this band
NeedDXCC // entity never worked at all
)
func (n Need) String() string {
switch n {
case NeedDXCC:
return "DXCC"
case NeedBand:
return "band"
case NeedMode:
return "mode"
case NeedSlot:
return "slot"
}
return "-"
}
// Decode is one line from the decoder, carrying both what the decision needs
// and what a reply has to send back untouched.
type Decode struct {
Call string
Band string
Mode string
Msg string
SNR int
At time.Time
TRPeriod int // slot length in seconds; 0 means "work it out from the mode"
Instance string
CQ bool
// Replay of the decoder's own line. WSJT-X matches a Reply against its
// decode list field for field, so these are passed through unread.
Ms uint32
DT float64
AudioHz int64
ModeRaw string
MsgRaw string
LowConf bool
// IsNew is false for a decode the sender replayed from its history. Shown
// in the panel, never answered: the station may have gone hours ago.
IsNew bool
}
// Candidate is a decode with the log's verdict on it attached.
type Candidate struct {
Decode
Need Need
Watched bool
// Worked is "already in the log on this band AND mode". Nothing is gained
// by calling it again — and while chasing confirmations it is the trap that
// makes the same station be called all evening, because an unconfirmed QSO
// leaves the entity flagged as still wanted.
Worked bool
}
// TXState is what the decoding application says it is doing.
type TXState struct {
Transmitting bool
Enabled bool // its own Enable Tx: nothing we send transmits while false
DXCall string
Msg string
Instance string
}
// Settings are the operator's.
type Settings struct {
Enabled bool
// Only is the "chase these callsigns" field: one or more, separated by
// spaces or commas. Filled, nothing else is ever called — the ladder still
// orders the ones listed, so "VP6D 3Y0J" calls whichever of the two is on
// the air and takes the better catch when both are. Wildcards are the watch
// list's job; this is a hunt list for right now.
//
// The brakes are unchanged, and hitting one stops the feature rather than
// moving on to somebody else — an explicit request deserves an explicit
// restart.
Only string
// Attempts before a target is released, and the larger allowance for a
// watched callsign.
Attempts int
WatchedAttempts int
// Misses is how many of the station's OWN transmit periods may pass with no
// decode of it before it is given up on.
Misses int
// Rest is how long a released callsign waits before it can be picked again,
// and MaxRounds how many such series it gets before being parked for the
// session.
Rest time.Duration
MaxRounds int
// MaxHold is the wall-clock backstop on one target.
MaxHold time.Duration
}
// Defaults are what the operator gets before touching anything.
func Defaults() Settings {
return Settings{
Attempts: 7, WatchedAttempts: 15, Misses: 3,
Rest: 2 * time.Minute, MaxRounds: 3, MaxHold: 4 * time.Minute,
}
}
func (s Settings) withDefaults() Settings {
d := Defaults()
if s.Attempts <= 0 {
s.Attempts = d.Attempts
}
if s.WatchedAttempts <= 0 {
s.WatchedAttempts = d.WatchedAttempts
}
if s.Misses <= 0 {
s.Misses = d.Misses
}
if s.Rest <= 0 {
s.Rest = d.Rest
}
if s.MaxRounds <= 0 {
s.MaxRounds = d.MaxRounds
}
if s.MaxHold <= 0 {
s.MaxHold = d.MaxHold
}
return s
}
// ActionKind is what the caller must do about the decision.
type ActionKind int
const (
DoNothing ActionKind = iota
DoReply // answer this decode
DoHalt // stop the decoder transmitting
)
// Action is the decision, with the reason in plain words. The reason is not
// decoration: it is the only way an operator can tell an auto-call that is
// working from one that is stuck, and it goes to the log line by line.
type Action struct {
Kind ActionKind
Decode Decode
Reason string
}
// Engine holds the state between periods. Not safe for concurrent use; the
// caller owns the serialisation, which in OpsLog is the UDP event loop.
type Engine struct {
set Settings
// target is the station being called, held as the decode last seen of it so
// a reply always carries a fresh timestamp.
target *Candidate
// targetInst is the receiver the target was picked on, so the brakes are
// counted against ITS periods and its transmissions.
targetInst string
// heldSince is when this station BECAME the target, and is never refreshed
// while it stays one. It was, and that quietly disabled the clock backstop:
// a station decoded every period for ever kept pushing its own deadline
// forward, which is exactly the case the backstop exists for.
heldSince time.Time
attempts int
misses int
lastMiss string // period already counted, so one period counts once
txSlot int // which of the two slots the target transmits in; -1 unknown
stopped bool // an explicit "Only" target gave up: needs a restart
stoppedOn string
// rested says when a released callsign may be considered again, and rounds
// how many series it has already had this session.
rested map[string]time.Time
rounds map[string]int
// done is every station the exchange was completed with this session.
//
// The log is the authority on what is worked, and it is SLOW: the QSO is
// logged when the operator (or the watcher) gets to it, several periods
// later. In between, the station we have just worked is still flagged as a
// new entity and is still on the air sending its 73 — which read as a
// perfectly good station to call, and the engine called it straight back.
done map[string]bool
}
func New(s Settings) *Engine {
return &Engine{set: s.withDefaults(), txSlot: -1,
rested: map[string]time.Time{}, rounds: map[string]int{}, done: map[string]bool{}}
}
// SetSettings swaps the settings in place. A target already being called is
// kept: the operator adjusting a limit is not asking to abandon the QSO in
// flight, and switching the feature off is a separate call.
func (e *Engine) SetSettings(s Settings) { e.set = s.withDefaults() }
// Target is the callsign being called, for the panel. Empty when idle.
func (e *Engine) Target() string {
if e.target == nil {
return ""
}
return e.target.Call
}
// Status is what the panel shows: who, how far into the brakes, and whether
// the engine has given up and is waiting for the operator.
type Status struct {
Target string `json:"target"`
Attempts int `json:"attempts"`
Max int `json:"max"`
Misses int `json:"misses"`
MaxMiss int `json:"max_miss"`
Stopped bool `json:"stopped"`
StoppedOn string `json:"stopped_on"`
}
func (e *Engine) Status() Status {
st := Status{Misses: e.misses, MaxMiss: e.set.Misses, Stopped: e.stopped, StoppedOn: e.stoppedOn}
if e.target != nil {
st.Target = e.target.Call
st.Attempts = e.attempts
st.Max = e.maxAttempts(*e.target)
}
return st
}
// Reset clears everything except the settings — the operator's Halt, a profile
// change, or switching the feature off and on again.
func (e *Engine) Reset() {
e.target, e.attempts, e.misses, e.txSlot = nil, 0, 0, -1
e.targetInst = ""
e.lastMiss, e.stopped, e.stoppedOn = "", false, ""
e.rested, e.rounds = map[string]time.Time{}, map[string]int{}
e.done = map[string]bool{}
}
// Take hands the operator's own click to the engine: the station they picked
// becomes the target, with the counters restarted. Everything after it — the
// brakes, the hand-off at the end of the QSO — then applies as usual.
func (e *Engine) Take(c Candidate) {
e.target, e.targetInst = &c, c.Instance
e.heldSince = c.At
e.attempts, e.misses, e.txSlot, e.lastMiss = 0, 0, -1, ""
e.stopped, e.stoppedOn = false, ""
}
// maxAttempts is the allowance for one target: larger for a watched callsign,
// because that is the operator saying this one is worth the extra slots.
func (e *Engine) maxAttempts(c Candidate) int {
if c.Watched {
return e.set.WatchedAttempts
}
return e.set.Attempts
}
// rank is the ladder, as one number. Watched lifts a station by one rung, and
// a watched station with nothing needed sits at the bottom rather than being
// ineligible: the operator asked for that callsign.
//
// 9 WL DXCC 8 DXCC 7 WL band 6 band
// 5 WL mode 4 mode 3 WL slot 2 slot 1 watched, nothing needed
// 0 nothing to call for
func rank(c Candidate) int {
if c.Need == NeedNone {
if c.Watched {
return 1
}
return 0
}
r := int(c.Need) * 2 // slot 2, mode 4, band 6, DXCC 8
if c.Watched {
r++
}
return r
}
// onlyList splits the chase field. Space or comma, either way: an operator
// typing a list types it the way they think of it, and a field that silently
// ignores half of what was entered is worse than one that refuses it.
func onlyList(field string) []string {
out := []string{}
for _, tok := range strings.FieldsFunc(strings.ToUpper(field), func(r rune) bool {
return r == ',' || r == ';' || unicode.IsSpace(r)
}) {
if tok = strings.TrimSpace(tok); tok != "" {
out = append(out, tok)
}
}
return out
}
func inList(list []string, call string) bool {
for _, c := range list {
if c == call {
return true
}
}
return false
}
var gridRe = regexp.MustCompile(`^[A-R]{2}[0-9]{2}([A-X]{2})?$`)
// isGrid is the grid test with the exchange tokens taken out first.
//
// "RR73" IS a valid Maidenhead square by the pattern — two letters in A-R, two
// digits — and it is also the second most common thing to find in that position
// of a message. Counting it as a fresh call spent an attempt on every QSO the
// engine actually completed, which is precisely backwards: a station that
// answers should cost nothing.
func isGrid(tok string) bool {
switch tok {
case "RR73", "RRR", "73", "RR", "R":
return false
}
return gridRe.MatchString(tok)
}
// callable reports whether a station can be answered RIGHT NOW.
//
// A station in mid-exchange is committed to somebody else: it will not answer,
// and WSJT-X and JTDX refuse to act on a reply to it at all — so calling it is
// at best a wasted slot and at worst the operator watching an auto-call that
// appears to do nothing. CQ, a message addressed to us, and the final frame of
// somebody else's QSO (the station is free from the next period) are the three
// states worth a call.
func callable(c Candidate, myCall string) bool {
if c.CQ {
return true
}
toks := strings.Fields(strings.ToUpper(strings.TrimSpace(c.Msg)))
if len(toks) == 0 {
return false // nothing to read: assume it is busy rather than call blind
}
if myCall != "" && toks[0] == strings.ToUpper(myCall) {
return true // it is calling us
}
switch toks[len(toks)-1] {
case "RR73", "RRR", "73":
return true
}
return false
}
// callingUs is the stronger half of callable: the station has our callsign in
// its message, so it has heard us and is waiting for an answer.
func callingUs(c Candidate, myCall string) bool {
if myCall == "" || c.CQ {
return false
}
toks := strings.Fields(strings.ToUpper(strings.TrimSpace(c.Msg)))
return len(toks) > 0 && toks[0] == strings.ToUpper(myCall)
}
// finished reports that the exchange with this station is over: it sent us the
// last frame. Read from ITS message rather than from our own transmit state,
// which says only what we did.
func finished(decodes []Candidate, call, myCall string) bool {
if myCall == "" {
return false
}
me := strings.ToUpper(myCall)
call = strings.ToUpper(call)
for _, c := range decodes {
if strings.ToUpper(c.Call) != call {
continue
}
toks := strings.Fields(strings.ToUpper(strings.TrimSpace(c.Msg)))
if len(toks) < 2 || toks[0] != me {
continue
}
switch toks[len(toks)-1] {
case "RR73", "RRR", "73":
return true
}
}
return false
}
// slotOf is which of the two alternating slots a moment falls in. FT8 stations
// transmit in one and listen in the other, so a station is legitimately absent
// half the time — counting that as a miss dropped a perfectly workable station
// after two unlucky periods.
func slotOf(at time.Time, trSec int) int {
if trSec <= 0 {
trSec = 15
}
return int((at.UTC().Unix() / int64(trSec)) % 2)
}
// Period is one slot's worth of decodes, with the state of the world around it.
type Period struct {
// Instance is the receiver this period came from. With two decoders running
// — the split view, two bands — their slots are separate: a station absent
// from the OTHER receiver's period says nothing about the one being called
// on this one, and counting it as a miss dropped a target that was being
// decoded perfectly well.
Instance string
// Key identifies the period, so a handler that runs twice for one slot
// cannot count the same miss twice.
Key string
At time.Time
TRPeriod int
Decodes []Candidate
TX TXState
MyCall string
}
// OnPeriod is the decision, taken once per receive period.
func (e *Engine) OnPeriod(p Period) Action {
if !e.set.Enabled {
return Action{}
}
// ONE station at a time, on the receiver it is being called on.
//
// This is the guarantee with two decoders running: while a target is held,
// a period from any OTHER receiver is not even looked at. It cannot start a
// second QSO over the top of the one in progress, however much better the
// station it hears is, and its periods count no missed periods against a
// target that was never on its band.
if e.target != nil && e.targetInst != "" && p.Instance != "" && p.Instance != e.targetInst {
return Action{}
}
// ── An existing target ────────────────────────────────────────────────
if e.target != nil {
t := *e.target
tc := strings.ToUpper(t.Call)
// The exchange is over — either the station sent us its last frame, or
// the log now holds it. Hand straight on to the next station in the same
// period rather than waiting for the next one: the slot is the resource.
if finished(p.Decodes, tc, p.MyCall) || workedNow(p.Decodes, tc) {
e.release(tc, false)
// Straight on to the next station, list or no list: pick() is what
// knows the chase list, and with one on it the answer is simply the
// next callsign there — a list of two DXpeditions must not stop
// after the first.
return e.pick(p, fmt.Sprintf("QSO with %s finished", tc))
}
// The clock backstop. Every counter below depends on decodes arriving in
// a particular shape; this one does not depend on anything.
if !e.heldSince.IsZero() && p.At.Sub(e.heldSince) > e.set.MaxHold {
e.giveUp(tc, t)
return Action{Kind: DoHalt, Reason: fmt.Sprintf("%s held for %s with nothing to show for it", tc, e.set.MaxHold)}
}
if seen := bestOf(p.Decodes, tc); seen != nil {
// The decode is refreshed so a reply carries a current timestamp; the
// hold clock is NOT — see heldSince.
e.target = seen
e.misses, e.lastMiss = 0, ""
e.txSlot = slotOf(p.At, periodSecs(p, *seen))
// In QSO: the decoder is sequencing the exchange on its own and the
// attempt counter has done its job. Interrupting it with another
// reply is how two transmissions land in one slot.
if callingUs(*seen, p.MyCall) {
e.attempts = 0
}
return Action{}
}
// Absent. Only its OWN transmit periods count against it.
if e.txSlot >= 0 && slotOf(p.At, periodSecs(p, t)) != e.txSlot {
return Action{}
}
if e.lastMiss == p.Key {
return Action{}
}
e.lastMiss = p.Key
e.misses++
if e.misses >= e.set.Misses {
e.giveUp(tc, t)
return Action{Kind: DoHalt, Reason: fmt.Sprintf("%s not decoded for %d of its own periods", tc, e.set.Misses)}
}
return Action{}
}
// ── No target ─────────────────────────────────────────────────────────
if e.stopped {
return Action{} // an explicit target gave up; the operator restarts it
}
// Never start a call over a transmission in progress: the reply would land
// in a slot the decoder is already using.
if p.TX.Transmitting {
return Action{}
}
return e.pick(p, "")
}
// pick chooses the best station on the air and answers it.
func (e *Engine) pick(p Period, why string) Action {
only := onlyList(e.set.Only)
var best *Candidate
var bestRank int
// bestRank of everything eligible, rested or not: a station that is resting
// may only be re-picked while nothing better is on the air, and that is the
// comparison.
topRank := 0
for i := range p.Decodes {
c := p.Decodes[i]
if !e.eligible(c, p, only) {
continue
}
if r := rank(c); r > topRank {
topRank = r
}
}
for i := range p.Decodes {
c := p.Decodes[i]
if !e.eligible(c, p, only) {
continue
}
r := rank(c)
if resting, ok := e.rested[strings.ToUpper(c.Call)]; ok && p.At.Before(resting) {
// Rested: allowed back only when it is the best thing there is.
// Anything of higher rank takes the slot instead.
if r < topRank {
continue
}
}
if best == nil || better(c, r, *best, bestRank, p.MyCall) {
cc := c
best, bestRank = &cc, r
}
}
if best == nil {
if why != "" {
return Action{Kind: DoNothing, Reason: why + " — nothing else worth calling"}
}
return Action{}
}
e.target, e.heldSince, e.targetInst = best, p.At, best.Instance
e.attempts, e.misses, e.txSlot, e.lastMiss = 0, 0, -1, ""
reason := fmt.Sprintf("calling %s (%s%s)", best.Call, watchedTag(*best), best.Need)
if why != "" {
reason = why + " — " + reason
}
return Action{Kind: DoReply, Decode: best.Decode, Reason: reason}
}
func watchedTag(c Candidate) string {
if c.Watched {
return "watched "
}
return ""
}
// eligible is every refusal that applies before a station is even ranked.
func (e *Engine) eligible(c Candidate, p Period, only []string) bool {
call := strings.ToUpper(strings.TrimSpace(c.Call))
if call == "" || call == strings.ToUpper(p.MyCall) {
return false
}
if !c.IsNew {
return false // replayed history: the station may be hours gone
}
if len(only) > 0 {
// The named stations, and each only until the QSO with it is made: an
// explicit request is not a standing order to work the same station all
// evening. The others on the list stay callable.
return inList(only, call) && !e.done[call] && !c.Worked
}
if c.Worked || e.done[call] {
return false
}
if rank(c) == 0 {
return false
}
if e.rounds[call] >= e.set.MaxRounds {
return false // parked for the session
}
// Mid-exchange with somebody else: it cannot answer us — see callable.
return callable(c, p.MyCall)
}
// better orders two eligible stations: the need first, then the one already
// calling us, then a CQ over a station about to be free, then the strongest.
func better(a Candidate, ra int, b Candidate, rb int, myCall string) bool {
if ra != rb {
return ra > rb
}
if x, y := callingUs(a, myCall), callingUs(b, myCall); x != y {
return x
}
if a.CQ != b.CQ {
return a.CQ
}
return a.SNR > b.SNR
}
// release clears the target. gaveUp marks it as a series that ended in a brake
// rather than in a QSO.
func (e *Engine) release(call string, gaveUp bool) {
e.target, e.attempts, e.misses, e.txSlot, e.lastMiss = nil, 0, 0, -1, ""
e.targetInst = ""
if !gaveUp {
// A finished QSO is not a failed series — the callsign keeps its rounds —
// but it IS finished: nothing more is wanted from this station today,
// whatever the log still says while it catches up.
delete(e.rounds, call)
e.done[call] = true
}
}
func (e *Engine) giveUp(call string, t Candidate) {
e.release(call, true)
e.rounds[call]++
e.rested[call] = time.Now().Add(e.set.Rest)
// An explicit "call this station" that runs out of attempts stops the
// feature instead of moving on. There is nothing else it was asked to do.
if strings.TrimSpace(e.set.Only) != "" {
e.stopped, e.stoppedOn = true, call
}
}
// NoteTX counts what actually goes on the air, and is the ONLY place attempts
// are counted or capped.
//
// Counted here rather than where the reply is sent, because those are different
// things: a reply may be refused by the decoder, and the decoder transmits
// several times per QSO on its own. Capped here too — the period handler used
// to cap as well, and a target could sit at twenty-four calls while none of its
// conditions were met. One place that counts and stops cannot overshoot.
//
// A FRESH call only: the third token of an FT8 call is a grid, where the rest
// of the exchange carries a report, R-report or 73. Without that, the answer to
// "how many times have we called this station" counted the whole QSO.
func (e *Engine) NoteTX(tx TXState) Action {
if !e.set.Enabled || e.target == nil || !tx.Transmitting {
return Action{}
}
// The OTHER decoder transmitting is not us calling this station: in a split
// view both are on the air, and counting both spent the seven calls in half
// the time — on a target the other receiver had never heard of.
if e.targetInst != "" && tx.Instance != "" && tx.Instance != e.targetInst {
return Action{}
}
t := *e.target
tc := strings.ToUpper(t.Call)
msg := strings.ToUpper(strings.TrimSpace(tx.Msg))
switch {
case msg != "":
toks := strings.Fields(msg)
if len(toks) < 3 || toks[0] != tc || !isGrid(toks[2]) {
return Action{}
}
default:
// JTDX reports no transmit message. The DX call is all there is, so
// every transmit period aimed at the target counts — more generous, and
// far better than a counter frozen at zero for ever.
if strings.ToUpper(strings.TrimSpace(tx.DXCall)) != tc {
return Action{}
}
}
e.attempts++
if e.attempts < e.maxAttempts(t) {
return Action{}
}
max := e.maxAttempts(t)
e.giveUp(tc, t)
return Action{Kind: DoHalt, Reason: fmt.Sprintf("%s called %d times without an answer", tc, max)}
}
// bestOf returns the most callable decode of one station in a period.
//
// A station running several streams at once — a DXpedition answering four
// callers — appears several times in one period: a report to one, RR73 to
// another, a CQ to the band. Judging it on whichever line came first reads a
// station that is free right now as busy.
func bestOf(decodes []Candidate, call string) *Candidate {
var best *Candidate
for i := range decodes {
c := decodes[i]
if strings.ToUpper(c.Call) != call {
continue
}
if best == nil || (c.CQ && !best.CQ) || (c.CQ == best.CQ && c.SNR > best.SNR) {
cc := c
best = &cc
}
}
return best
}
// workedNow reports that the log has caught up with a station mid-series — the
// QSO was logged, by this or by another hand.
func workedNow(decodes []Candidate, call string) bool {
for _, c := range decodes {
if strings.ToUpper(c.Call) == call && c.Worked {
return true
}
}
return false
}
func periodSecs(p Period, c Candidate) int {
if c.TRPeriod > 0 {
return c.TRPeriod
}
if p.TRPeriod > 0 {
return p.TRPeriod
}
return 15
}
// TargetInstance names the receiver the current target is being called on, and
// the callsign. Both empty when idle.
func (e *Engine) TargetInstance() (instance, call string) {
if e.target == nil {
return "", ""
}
return e.targetInst, e.target.Call
}
+462
View File
@@ -0,0 +1,462 @@
package autocall
import (
"fmt"
"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, in the order the operator asked for.
order := []Candidate{
cq("A", NeedDXCC, 0, watched), cq("B", NeedDXCC, 0),
cq("C", NeedBand, 0, watched), cq("D", NeedBand, 0),
cq("E", NeedMode, 0, watched), cq("F", NeedMode, 0),
cq("G", NeedSlot, 0, watched), cq("H", NeedSlot, 0),
cq("I", NeedNone, 0, watched),
}
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[7], order[3], order[0], order[5]))
if a.Kind != DoReply || a.Decode.Call != "A" {
t.Fatalf("picked %+v, want the watched new entity", a)
}
}
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)
}
}
func TestAReleasedStationIsCalledAgainWhenNothingBetterIsOnTheAir(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)
}
a := e.OnPeriod(period(2, cq("DX", NeedBand, -5)))
if a.Kind != DoReply || a.Decode.Call != "DX" {
t.Errorf("nothing better on the air and the station was not called again: %+v", a)
}
}
func TestAStationIsParkedAfterItsRounds(t *testing.T) {
e := on()
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
for round := 1; round <= 3; round++ {
a := e.OnPeriod(period(round*2, 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(20, 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"
a := e.OnPeriod(period(2, done, cq("NEXT", NeedBand, -10)))
if a.Kind != DoReply || a.Decode.Call != "NEXT" {
t.Errorf("after the QSO ended: %+v, want the next priority in the same period", 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.
weak := callsMe("WEAK", NeedNone, -18)
weak.Watched = true
loud := callsMe("LOUD", NeedNone, -4)
loud.Watched = true
a := e.OnPeriod(period(2, done, 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"
a = e.OnPeriod(period(4, done, 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)
}
}
+108 -2
View File
@@ -9,6 +9,7 @@ package cat
import ( import (
"fmt" "fmt"
"runtime" "runtime"
"strings"
"sync" "sync"
"time" "time"
) )
@@ -240,15 +241,81 @@ func (m *Manager) freqOffsetHz() int64 {
// display trick: the readout would say 144 and every spot click, band change and // display trick: the readout would say 144 and every spot click, band change and
// memory recall would send the rig somewhere 116 MHz away. // memory recall would send the rig somewhere 116 MHz away.
func (m *Manager) SetFrequency(hz int64) error { func (m *Manager) SetFrequency(hz int64) error {
real := hz
if off := m.freqOffsetHz(); off != 0 && hz > off { if off := m.freqOffsetHz(); off != 0 && hz > off {
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. // SetMode dispatches a SetMode call to the CAT goroutine.
func (m *Manager) SetMode(mode string) error { 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. // 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) }) 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 // splitSetter is implemented by the backends that can arm split AND place the
// transmit frequency. Both together: arming without setting the dial transmits // transmit frequency. Both together: arming without setting the dial transmits
// on whatever the transmit VFO happened to hold, which is worse than refusing. // on whatever the transmit VFO happened to hold, which is worse than refusing.
@@ -304,6 +398,14 @@ type SpotInfo struct {
BackgroundColor string BackgroundColor string
Comment string Comment string
LifetimeSec int // panadapter display seconds before auto-removal (0 = backend default) 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 // Spotter is an OPTIONAL backend capability: show cluster spots on the radio
@@ -669,6 +771,10 @@ type IcomController interface {
SetVOXGain(int) error SetVOXGain(int) error
SetAntiVOX(int) error SetAntiVOX(int) error
SetPower(bool) error // turn the transceiver on/off (manual — never auto on connect) 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 // ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's
+46
View File
@@ -425,3 +425,49 @@ func indexPreamble(buf []byte, from int) int {
} }
return -1 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") 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 { if hadOld {
f.send(fmt.Sprintf("spot remove %d", old)) 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", cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix()) float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
// Convert to a real Flex mode (USB/LSB/CW/DIGU/…): SmartSDR only switches the // 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 != "" { if c := flexEncode(s.Comment); c != "" {
cmd += " comment=" + c cmd += " comment=" + c
} }
cmd += prio
seq := f.send(cmd) seq := f.send(cmd)
if seq > 0 { if seq > 0 {
// Remember which call this add was for; the R<seq> response carries the // Remember which call this add was for; the R<seq> response carries the
+73
View File
@@ -594,6 +594,15 @@ func (b *IcomSerial) SetMode(mode string) error {
if err != nil { if err != nil {
return err 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 // 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. // mode byte), then set the data-mode flag for digital modes.
if err := b.execIdempotent("set mode "+mode, civ.CmdSetMode, code); err != nil { if err := b.execIdempotent("set mode "+mode, civ.CmdSetMode, code); err != nil {
@@ -1543,6 +1552,14 @@ func (b *IcomSerial) modeCode(mode string) (code byte, data bool, err error) {
return civ.ModeCW, false, nil return civ.ModeCW, false, nil
case "SSB": case "SSB":
return usb, false, nil 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": case "AM":
return civ.ModeAM, false, nil return civ.ModeAM, false, nil
case "FM": case "FM":
@@ -2180,3 +2197,59 @@ func (b *IcomSerial) TXAudioSender() (func([]byte) error, error) {
} }
return nil, fmt.Errorf("this rig takes transmit audio through its USB sound card, not the CAT link") 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;") 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 { func (k *Kenwood) write(cmd string) error {
if k.port == nil { if k.port == nil {
return fmt.Errorf("kenwood: not connected") return fmt.Errorf("kenwood: not connected")
+8 -1
View File
@@ -388,7 +388,14 @@ func (y *Yaesu) RefreshYaesu() error {
} }
func (y *Yaesu) SetYaesuPower(w int) 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 { func (y *Yaesu) SetYaesuMicGain(p int) error {
+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)
}
}
+11 -1
View File
@@ -40,6 +40,14 @@ type Match struct {
Continent string `json:"continent"` Continent string `json:"continent"`
Lat float64 `json:"lat"` Lat float64 `json:"lat"`
Lon float64 `json:"lon"` Lon float64 `json:"lon"`
// Exact marks a hit on cty.dat's "=CALLSIGN" list rather than on a prefix.
//
// The two carry very different authority. A prefix match is a rule of thumb
// about a block of callsigns; an exact entry is somebody having looked at
// THIS callsign and written down where it was. A second country file may
// improve on the first kind and should not be allowed to overrule the
// second.
Exact bool `json:"exact,omitempty"`
} }
type prefixEntry struct { type prefixEntry struct {
@@ -149,7 +157,9 @@ func (db *DB) Lookup(callsign string) (Match, bool) {
return Match{}, false return Match{}, false
} }
if e, ok := db.exact[call]; ok { if e, ok := db.exact[call]; ok {
return materialize(e), true m := materialize(e)
m.Exact = true
return m, true
} }
// KG4 special case: Guantanamo Bay (DXCC 105) is "KG4" followed by EXACTLY // KG4 special case: Guantanamo Bay (DXCC 105) is "KG4" followed by EXACTLY
// two characters (KG4XX). "KG4", "KG4X", "KG4XYZ"… are continental USA. // two characters (KG4XX). "KG4", "KG4X", "KG4XYZ"… are continental USA.
+546
View File
@@ -0,0 +1,546 @@
// Package dxped reads the two feeds the DX world announces itself on.
//
// NG3K's ADXO is the STRUCTURED one: an RSS item per announced operation whose
// description is a fixed, dash-separated sentence — dates, entity, callsign,
// QSL route, source, then the operators/bands/modes prose. It is what a
// DXpedition list is actually made of.
//
// DX-World's feed is NEWS: WordPress posts with a headline and an excerpt. It
// carries no structure to act on, but the headline nearly always names the
// callsign, so the calls are mined from the title and the reader can act on
// them the same way (watchlist, chase status).
//
// Both are cached: the announcements change a few times a day, the news a few
// times an hour, and neither is worth a request per screen repaint.
package dxped
import (
"context"
"encoding/xml"
"fmt"
"html"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
)
const (
adxoURL = "https://www.ng3k.com/adxo.xml"
dxworldURL = "https://dx-world.net/feed/"
adxoTTL = 1 * time.Hour
dxworldTTL = 30 * time.Minute
)
// Activation is one announced operation, as ADXO describes it.
type Activation struct {
DXCC string `json:"dxcc"`
Callsign string `json:"callsign"` // display form; "A, B" when several
Calls []string `json:"calls"` // the individual callsigns, normalised
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
Bands []string `json:"bands"`
Modes []string `json:"modes"`
QSL string `json:"qsl"`
Operators string `json:"operators"`
Source string `json:"source"`
Link string `json:"link"`
Status string `json:"status"` // "active" | "upcoming"
}
// News is one DX-World post.
type News struct {
Title string `json:"title"`
Link string `json:"link"`
PubDate string `json:"pub_date"` // RFC3339, "" when unparseable
Excerpt string `json:"excerpt"`
Creator string `json:"creator"`
ImageURL string `json:"image_url"`
Tag string `json:"tag"` // NEWS / UPDATE / NEW ACTIVITY…
Calls []string `json:"calls"` // callsigns mined from the headline
}
// Manager holds both caches and fetches on demand.
type Manager struct {
mu sync.RWMutex
acts []Activation
actsAt time.Time
news []News
newsAt time.Time
client *http.Client
fetching sync.Mutex // one refresh at a time, whichever pane asked
}
func New() *Manager {
return &Manager{client: &http.Client{Timeout: 30 * time.Second}}
}
// Activations returns the cached announcements, refreshing when stale.
func (m *Manager) Activations(ctx context.Context) ([]Activation, error) {
m.mu.RLock()
fresh := time.Since(m.actsAt) < adxoTTL && m.acts != nil
out := m.acts
m.mu.RUnlock()
if fresh {
return out, nil
}
m.fetching.Lock()
defer m.fetching.Unlock()
// Someone else may have refreshed while we waited for the lock.
m.mu.RLock()
fresh = time.Since(m.actsAt) < adxoTTL && m.acts != nil
out = m.acts
m.mu.RUnlock()
if fresh {
return out, nil
}
acts, err := m.fetchADXO(ctx)
if err != nil {
// Stale beats empty: a feed that is down should not blank a list the
// operator was reading a minute ago.
m.mu.RLock()
defer m.mu.RUnlock()
return m.acts, err
}
m.mu.Lock()
m.acts, m.actsAt = acts, time.Now()
m.mu.Unlock()
return acts, nil
}
// News returns the cached DX-World posts, refreshing when stale.
func (m *Manager) News(ctx context.Context) ([]News, error) {
m.mu.RLock()
fresh := time.Since(m.newsAt) < dxworldTTL && m.news != nil
out := m.news
m.mu.RUnlock()
if fresh {
return out, nil
}
m.fetching.Lock()
defer m.fetching.Unlock()
m.mu.RLock()
fresh = time.Since(m.newsAt) < dxworldTTL && m.news != nil
out = m.news
m.mu.RUnlock()
if fresh {
return out, nil
}
news, err := m.fetchDXWorld(ctx)
if err != nil {
m.mu.RLock()
defer m.mu.RUnlock()
return m.news, err
}
m.mu.Lock()
m.news, m.newsAt = news, time.Now()
m.mu.Unlock()
return news, nil
}
// Invalidate drops both caches so the next read refetches.
func (m *Manager) Invalidate() {
m.mu.Lock()
m.actsAt, m.newsAt = time.Time{}, time.Time{}
m.mu.Unlock()
}
func (m *Manager) get(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "OpsLog")
resp, err := m.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("http %d", resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, 8<<20))
}
// ── ADXO ────────────────────────────────────────────────────────────────
type rssItem struct {
Title string `xml:"title"`
Description string `xml:"description"`
Link string `xml:"link"`
PubDate string `xml:"pubDate"`
Creator string `xml:"creator"`
Enclosure struct {
URL string `xml:"url,attr"`
} `xml:"enclosure"`
MediaContent struct {
URL string `xml:"url,attr"`
} `xml:"content"`
}
type rssFeed struct {
Items []rssItem `xml:"channel>item"`
}
func (m *Manager) fetchADXO(ctx context.Context) ([]Activation, error) {
body, err := m.get(ctx, adxoURL)
if err != nil {
return nil, fmt.Errorf("adxo: %w", err)
}
var feed rssFeed
if err := xml.Unmarshal(body, &feed); err != nil {
return nil, fmt.Errorf("adxo: parse: %w", err)
}
now := time.Now()
out := make([]Activation, 0, len(feed.Items))
for _, it := range feed.Items {
a := parseActivation(it.Description, it.Link)
if a == nil {
continue
}
a.Status = activationStatus(a.StartDate, a.EndDate, now)
if a.Status == "ended" {
continue // the list is about what is on or coming
}
out = append(out, *a)
}
return out, nil
}
var spaceRe = regexp.MustCompile(`\s+`)
// parseActivation reads one ADXO description. Its shape is fixed and has been
// for twenty years:
//
// "Feb 17-Mar 30, 2026 -- Entity -- CALL -- QSL: route -- Source: who (date)
// -- By ops; bands; modes; notes"
func parseActivation(desc, link string) *Activation {
desc = spaceRe.ReplaceAllString(strings.NewReplacer("\n", " ", "\r", " ").Replace(desc), " ")
desc = strings.TrimSpace(html.UnescapeString(desc))
if desc == "" {
return nil
}
parts := strings.Split(desc, " -- ")
if len(parts) < 3 {
return nil
}
a := &Activation{Link: link}
a.StartDate, a.EndDate = parseDateRange(strings.TrimSpace(parts[0]))
a.DXCC = strings.TrimSpace(parts[1])
a.Callsign = strings.TrimSpace(parts[2])
for _, p := range parts[3:] {
p = strings.TrimSpace(p)
switch {
case strings.HasPrefix(p, "QSL:"):
a.QSL = strings.TrimSpace(strings.TrimPrefix(p, "QSL:"))
case strings.HasPrefix(p, "Source:"):
a.Source = strings.TrimSpace(strings.TrimPrefix(p, "Source:"))
}
}
// The tail carries "By <ops>; <bands>; <modes>; <notes>".
tail := parts[len(parts)-1]
if i := strings.Index(tail, "By "); i >= 0 {
sub := strings.Split(tail[i+3:], ";")
if len(sub) > 0 {
a.Operators = strings.TrimSpace(sub[0])
}
if len(sub) > 1 {
a.Bands = parseBands(sub[1])
}
if len(sub) > 2 {
a.Modes = parseModes(sub[2])
}
}
// The callsign field often holds only the PREFIX; the real calls hide in the
// operators prose as "W2APF as PJ2/W2APF". Prefer those when present.
if calls := callsAfterAs(a.Operators); len(calls) > 0 {
prefix := strings.ToUpper(strings.TrimSpace(parts[2]))
for i := range calls {
calls[i] = normalizeCall(calls[i], prefix)
}
a.Calls = calls
a.Callsign = strings.Join(calls, ", ")
} else if c := strings.ToUpper(a.Callsign); plausibleCall(c) {
a.Calls = []string{c}
}
return a
}
// parseDateRange handles the two forms ADXO writes: "Feb 17-Mar 30, 2026" and
// "Mar 3-20, 2026" (the month carried over).
var (
fullRangeRe = regexp.MustCompile(`(?i)(\w+ \d+)\s*-\s*(\w+ \d+),\s*(\d{4})`)
shortRangeRe = regexp.MustCompile(`(?i)(\w+) (\d+)\s*-\s*(\d+),\s*(\d{4})`)
singleDayRe = regexp.MustCompile(`(?i)(\w+ \d+),\s*(\d{4})`)
)
func parseDateRange(s string) (start, end string) {
if m := fullRangeRe.FindStringSubmatch(s); m != nil {
return m[1] + ", " + m[3], m[2] + ", " + m[3]
}
if m := shortRangeRe.FindStringSubmatch(s); m != nil {
return m[1] + " " + m[2] + ", " + m[4], m[1] + " " + m[3] + ", " + m[4]
}
if m := singleDayRe.FindStringSubmatch(s); m != nil {
return m[1] + ", " + m[2], m[1] + ", " + m[2]
}
return s, s
}
func parseADXODate(s string) (time.Time, bool) {
for _, layout := range []string{"Jan 2, 2006", "January 2, 2006"} {
if t, err := time.Parse(layout, strings.TrimSpace(s)); err == nil {
return t, true
}
}
return time.Time{}, false
}
func activationStatus(start, end string, now time.Time) string {
s, okS := parseADXODate(start)
e, okE := parseADXODate(end)
if !okS || !okE {
return "upcoming" // unreadable dates: keep it, an operator can read them
}
switch {
case now.Before(s):
return "upcoming"
// The end date is a DAY, so an operation is on until that day is over.
case now.After(e.Add(24 * time.Hour)):
return "ended"
default:
return "active"
}
}
// callsAfterAs mines "…as CALL…" out of the operators prose:
//
// "W2APF as PJ2/W2APF" → PJ2/W2APF
// "SQ2RAD as VP2EAD, M0PLX as VP2ELX" → VP2EAD, VP2ELX
var asCallRe = regexp.MustCompile(`(?i)\bas\s+([A-Z0-9]+(?:/[A-Z0-9]+)*)`)
func callsAfterAs(operators string) []string {
var out []string
seen := map[string]bool{}
for _, m := range asCallRe.FindAllStringSubmatch(operators, -1) {
c := strings.ToUpper(strings.TrimSpace(m[1]))
if !plausibleCall(c) || seen[c] {
continue
}
seen[c] = true
out = append(out, c)
}
return out
}
// normalizeCall puts the DXCC prefix first: a cluster spot says JD1/JG8NQJ, and
// matching the log against JG8NQJ/JD1 would find nothing.
func normalizeCall(call, dxccPrefix string) string {
if dxccPrefix == "" || !strings.Contains(call, "/") {
return call
}
left, right, _ := strings.Cut(call, "/")
if strings.HasPrefix(right, dxccPrefix) && !strings.HasPrefix(left, dxccPrefix) {
return right + "/" + left
}
return call
}
var (
// A span carries its unit only once — "160-6m" is the commonest way ADXO
// states coverage, and reading it as "6m" alone loses the whole low end.
bandRe = regexp.MustCompile(`(?i)\b(?:(\d{1,4})\s*-\s*)?(\d{1,4})\s*(m|cm)\b`)
// The modes ADXO actually writes. A list beats a pattern here: "FT8" and
// "SSB" have no shape in common, and inventing one invites "QSL" as a mode.
knownModes = []string{"SSB", "CW", "FT8", "FT4", "RTTY", "PSK", "SSTV", "AM", "FM", "JT65", "JS8", "Q65", "MSK144", "DIGI", "DATA"}
)
func parseBands(s string) []string {
var out []string
seen := map[string]bool{}
add := func(num, unit string) {
if num == "" {
return
}
b := strings.ToLower(num + unit)
if seen[b] {
return
}
seen[b] = true
out = append(out, b)
}
// A span keeps only its endpoints: ADXO states coverage in prose, and the
// two ends are the part it gives reliably.
for _, m := range bandRe.FindAllStringSubmatch(s, -1) {
add(m[1], m[3])
add(m[2], m[3])
}
return out
}
func parseModes(s string) []string {
up := strings.ToUpper(s)
var out []string
seen := map[string]bool{}
for _, mode := range knownModes {
if seen[mode] {
continue
}
if regexp.MustCompile(`\b` + mode + `\b`).MatchString(up) {
seen[mode] = true
out = append(out, mode)
}
}
return out
}
// ── DX-World ────────────────────────────────────────────────────────────
var (
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
tagPrefixRe = regexp.MustCompile(`(?i)^\s*\[([^\]]+)\]\s*`)
)
func stripHTML(s string) string {
s = htmlTagRe.ReplaceAllString(s, " ")
s = html.UnescapeString(s)
return strings.TrimSpace(spaceRe.ReplaceAllString(s, " "))
}
func (m *Manager) fetchDXWorld(ctx context.Context) ([]News, error) {
body, err := m.get(ctx, dxworldURL)
if err != nil {
return nil, fmt.Errorf("dx-world: %w", err)
}
var feed rssFeed
if err := xml.Unmarshal(body, &feed); err != nil {
return nil, fmt.Errorf("dx-world: parse: %w", err)
}
out := make([]News, 0, len(feed.Items))
for _, it := range feed.Items {
title := stripHTML(it.Title)
tag, title := splitTag(title)
n := News{
Title: title,
Link: strings.TrimSpace(it.Link),
Creator: strings.TrimSpace(it.Creator),
Tag: tag,
Calls: callsInHeadline(title),
}
if t, err := time.Parse(time.RFC1123Z, strings.TrimSpace(it.PubDate)); err == nil {
n.PubDate = t.UTC().Format(time.RFC3339)
} else if t, err := time.Parse(time.RFC1123, strings.TrimSpace(it.PubDate)); err == nil {
n.PubDate = t.UTC().Format(time.RFC3339)
}
ex := stripHTML(it.Description)
if t2, rest := splitTag(ex); t2 != "" {
if n.Tag == "" {
n.Tag = t2
}
ex = rest
}
n.Excerpt = truncateRunes(ex, 400)
if u := strings.TrimSpace(it.Enclosure.URL); u != "" {
n.ImageURL = u
} else if u := strings.TrimSpace(it.MediaContent.URL); u != "" {
n.ImageURL = u
}
out = append(out, n)
}
return out, nil
}
// splitTag pulls the leading "[UPDATE]" DX-World puts on most posts.
func splitTag(s string) (tag, rest string) {
m := tagPrefixRe.FindStringSubmatchIndex(s)
if m == nil {
return "", s
}
tag = strings.ToUpper(strings.TrimSpace(s[m[2]:m[3]]))
rest = strings.TrimSpace(s[m[1]:])
rest = strings.TrimPrefix(strings.TrimPrefix(rest, " "), "- ")
return tag, strings.TrimSpace(rest)
}
func truncateRunes(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return strings.TrimSpace(string(r[:max])) + "…"
}
// callsInHeadline mines callsigns out of a news headline — "3B7M, St Brandon"
// or "TX5S team lands". The reader can then chase or watch them, which is the
// whole reason a news feed sits next to the announcements.
func callsInHeadline(title string) []string {
var out []string
seen := map[string]bool{}
for _, tok := range strings.FieldsFunc(title, func(r rune) bool {
return !(r == '/' || (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z'))
}) {
c := strings.ToUpper(tok)
if !plausibleCall(c) || seen[c] {
continue
}
seen[c] = true
out = append(out, c)
}
return out
}
var (
bandTokenRe = regexp.MustCompile(`^\d{1,4}(M|CM)$`)
// Jargon shaped exactly like a callsign. Every one of these was seen in a
// real headline before it earned its place here.
notACall = map[string]bool{
"FT8": true, "FT4": true, "JT65": true, "JT9": true, "JS8": true, "Q65": true,
"MSK144": true, "PSK31": true, "SSTV": true, "OQRS": true, "LOTW": true,
"IOTA": true, "SOTA": true, "POTA": true, "WWFF": true, "DXCC": true,
"CQWW": true, "CQWPX": true, "ARRL": true, "3D": true, "4K": true,
}
)
// plausibleCall keeps tokens shaped like an amateur callsign: 312 characters
// of AZ/09 (with optional /prefix or /suffix), at least one letter AND one
// digit, and a letter somewhere after the first digit — which is what separates
// a callsign from a band or a year.
func plausibleCall(s string) bool {
s = strings.ToUpper(strings.TrimSpace(s))
if len(s) < 3 || len(s) > 12 || notACall[s] || bandTokenRe.MatchString(s) {
return false
}
// Judge the longest part — the real call in "PJ2/W2APF" either way.
base := s
if strings.Contains(s, "/") {
base = ""
for _, p := range strings.Split(s, "/") {
if len(p) > len(base) {
base = p
}
}
}
var hasLetter, hasDigit, letterAfterDigit bool
seenDigit := false
for _, r := range base {
switch {
case r >= 'A' && r <= 'Z':
hasLetter = true
if seenDigit {
letterAfterDigit = true
}
case r >= '0' && r <= '9':
hasDigit = true
seenDigit = true
default:
return false
}
}
return hasLetter && hasDigit && letterAfterDigit
}
+86
View File
@@ -0,0 +1,86 @@
package dxped
import (
"reflect"
"testing"
"time"
)
// Pinned against REAL feed text: the parser reads a fixed sentence, and a
// wrong split silently empties a DXpedition list rather than failing loudly.
func TestParseActivation(t *testing.T) {
desc := "Aug 24-31, 2026 -- St Kitts and Nevis -- V47JA -- QSL: LoTW -- " +
"Source: W5JON (Aug 1, 2026) -- By W5JON as V47JA fm Calypso Bay; 160-6m; SSB FT8; yagi, verticals"
a := parseActivation(desc, "https://www.qrz.com/lookup/v47ja")
if a == nil {
t.Fatal("parseActivation returned nil on a real ADXO description")
}
if a.DXCC != "St Kitts and Nevis" {
t.Errorf("DXCC = %q", a.DXCC)
}
if a.Callsign != "V47JA" {
t.Errorf("Callsign = %q, want V47JA (mined from 'as')", a.Callsign)
}
if a.QSL != "LoTW" {
t.Errorf("QSL = %q", a.QSL)
}
if a.StartDate != "Aug 24, 2026" || a.EndDate != "Aug 31, 2026" {
t.Errorf("dates = %q..%q", a.StartDate, a.EndDate)
}
if want := []string{"160m", "6m"}; !reflect.DeepEqual(a.Bands, want) {
t.Errorf("Bands = %v, want %v", a.Bands, want)
}
if want := []string{"SSB", "FT8"}; !reflect.DeepEqual(a.Modes, want) {
t.Errorf("Modes = %v, want %v", a.Modes, want)
}
}
// The callsign column often holds only the prefix; the operators prose holds
// the real thing, and a slashed call must lead with the DXCC prefix or no spot
// will ever match it.
func TestCallsAfterAsAndNormalise(t *testing.T) {
if got := callsAfterAs("SQ2RAD as VP2EAD, M0PLX as VP2ELX"); !reflect.DeepEqual(got, []string{"VP2EAD", "VP2ELX"}) {
t.Errorf("callsAfterAs = %v", got)
}
if got := normalizeCall("JG8NQJ/JD1", "JD1"); got != "JD1/JG8NQJ" {
t.Errorf("normalizeCall = %q, want JD1/JG8NQJ", got)
}
if got := normalizeCall("PJ2/W2APF", "PJ2"); got != "PJ2/W2APF" {
t.Errorf("normalizeCall rewrote an already-correct call: %q", got)
}
}
func TestActivationStatus(t *testing.T) {
now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
cases := []struct{ start, end, want string }{
{"Aug 24, 2026", "Aug 31, 2026", "active"},
{"Sep 10, 2026", "Sep 20, 2026", "upcoming"},
{"Aug 1, 2026", "Aug 10, 2026", "ended"},
{"Aug 24, 2026", "Aug 27, 2026", "active"}, // ends TODAY: still on
{"garbage", "garbage", "upcoming"},
}
for _, c := range cases {
if got := activationStatus(c.start, c.end, now); got != c.want {
t.Errorf("activationStatus(%q,%q) = %q, want %q", c.start, c.end, got, c.want)
}
}
}
// Mining a headline must find calls without inventing them out of jargon.
func TestCallsInHeadline(t *testing.T) {
cases := []struct {
title string
want []string
}{
{"3B7M, St Brandon", []string{"3B7M"}},
{"TX5S team lands on Clipperton", []string{"TX5S"}},
{"FT8 activity on 160m in 2026", nil},
{"VP6D QSL via OQRS, LoTW", []string{"VP6D"}},
{"JD1/JG8NQJ from Minami Torishima", []string{"JD1/JG8NQJ"}},
}
for _, c := range cases {
if got := callsInHeadline(c.title); !reflect.DeepEqual(got, c.want) {
t.Errorf("callsInHeadline(%q) = %v, want %v", c.title, got, c.want)
}
}
}
+4
View File
@@ -38,6 +38,9 @@ const (
ServiceCloudlog Service = "cloudlog" ServiceCloudlog Service = "cloudlog"
// ServiceHamlog is HAMLOG.online — one API key, an ADIF record per QSO. // ServiceHamlog is HAMLOG.online — one API key, an ADIF record per QSO.
ServiceHamlog Service = "hamlog" ServiceHamlog Service = "hamlog"
// ServiceHamQTH is the HamQTH online logbook — the callbook credentials,
// one ADIF record per QSO.
ServiceHamQTH Service = "hamqth"
) )
// UploadMode selects when an auto-upload fires after a QSO is saved. // UploadMode selects when an auto-upload fires after a QSO is saved.
@@ -133,6 +136,7 @@ type ExternalServices struct {
EQSL ServiceConfig `json:"eqsl"` EQSL ServiceConfig `json:"eqsl"`
Cloudlog ServiceConfig `json:"cloudlog"` Cloudlog ServiceConfig `json:"cloudlog"`
Hamlog ServiceConfig `json:"hamlog"` Hamlog ServiceConfig `json:"hamlog"`
HamQTH ServiceConfig `json:"hamqth"`
// DeleteRemote asks OpsLog to withdraw a QSO from QRZ.com and Club Log when // DeleteRemote asks OpsLog to withdraw a QSO from QRZ.com and Club Log when
// it is deleted locally. Off unless the operator turns it on: neither // it is deleted locally. Off unless the operator turns it on: neither
+20
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -104,7 +105,26 @@ func UploadHamlog(ctx context.Context, client *http.Client, cfg ServiceConfig, a
return uploadHamlogTo(ctx, client, hamlogAPIEndpoint, cfg, adifRecord) return uploadHamlogTo(ctx, client, hamlogAPIEndpoint, cfg, adifRecord)
} }
// ErrHamlogClosed is why nothing is sent to HAMLOG.online any more.
//
// The site stopped issuing API keys, and the upload API takes nothing else. An
// operator without a key cannot obtain one, and one WITH an old key is the
// exception this cannot be built around — so the door is closed here rather
// than left ajar for a request that can only fail.
//
// The code stays: their confirmations still arrive as an ADIF FILE (QSL Manager
// → HAMLOG.online → Import confirmations), which never needed a key, and the
// sent/received state already in operators' logs stays readable, filterable and
// bulk-editable.
var ErrHamlogClosed = errors.New("hamlog: HAMLOG.online no longer issues API keys, so uploading is not possible — their confirmations can still be imported from a file")
func uploadHamlogTo(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) { func uploadHamlogTo(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
return UploadResult{}, ErrHamlogClosed
}
// uploadHamlogLive is the upload as it was, kept whole against the day keys
// come back. Nothing calls it.
func uploadHamlogLive(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
key := strings.TrimSpace(cfg.APIKey) key := strings.TrimSpace(cfg.APIKey)
if key == "" { if key == "" {
return UploadResult{}, fmt.Errorf("hamlog: API key not set — get one at %s", hamlogKeyPage) return UploadResult{}, fmt.Errorf("hamlog: API key not set — get one at %s", hamlogKeyPage)
+17 -3
View File
@@ -3,6 +3,7 @@ package extsvc
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -28,7 +29,7 @@ func TestUploadHamlogRequestShape(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
res, err := uploadHamlogTo(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "KEY123"}, "<call:5>F4BPO <eor>") res, err := uploadHamlogLive(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "KEY123"}, "<call:5>F4BPO <eor>")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -64,7 +65,7 @@ func TestHamlogFailureIsNotSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(tc.body)) _, _ = w.Write([]byte(tc.body))
})) }))
res, err := uploadHamlogTo(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "K"}, "<eor>") res, err := uploadHamlogLive(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "K"}, "<eor>")
srv.Close() srv.Close()
if err != nil { if err != nil {
t.Fatalf("%s: %v", tc.body, err) t.Fatalf("%s: %v", tc.body, err)
@@ -80,8 +81,21 @@ func TestHamlogFailureIsNotSuccess(t *testing.T) {
// Nothing leaves without a key, and the message says where to get one. // Nothing leaves without a key, and the message says where to get one.
func TestUploadHamlogNeedsAKey(t *testing.T) { func TestUploadHamlogNeedsAKey(t *testing.T) {
_, err := UploadHamlog(context.Background(), nil, ServiceConfig{}, "<eor>") _, err := uploadHamlogLive(context.Background(), nil, "http://example.invalid", ServiceConfig{}, "<eor>")
if err == nil || !strings.Contains(err.Error(), hamlogKeyPage) { if err == nil || !strings.Contains(err.Error(), hamlogKeyPage) {
t.Fatalf("err = %v, want it to point at %s", err, hamlogKeyPage) t.Fatalf("err = %v, want it to point at %s", err, hamlogKeyPage)
} }
} }
// The door is shut: HAMLOG.online stopped issuing API keys, so an upload that
// could only fail is refused before it is attempted. The request-shape tests
// above still cover the code kept against the day keys come back.
func TestUploadHamlogIsClosed(t *testing.T) {
res, err := UploadHamlog(context.Background(), nil, ServiceConfig{APIKey: "KEY123"}, "<eor>")
if !errors.Is(err, ErrHamlogClosed) {
t.Fatalf("err = %v, want ErrHamlogClosed", err)
}
if res.OK {
t.Error("a refused upload reported success")
}
}
+268
View File
@@ -0,0 +1,268 @@
package extsvc
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
"time"
)
// HamQTH real-time QSO upload.
//
// One POST per QSO to qso_realtime.php with the account username/password —
// the same credentials the HamQTH callbook lookup uses. The answer is the
// HTTP status code, not a body format: 200 saved, 400 rejected (bad band,
// duplicate…, reason in the body), 403 wrong credentials, 500 server error.
const hamqthUploadURL = "https://www.hamqth.com/qso_realtime.php"
// hamqthFullLogURL takes a WHOLE log as a file. Note "whole": HamQTH's own
// documentation says "you always have to upload whole log. HamQTH doesn't
// support partial upload" — the file REPLACES what is on the site. That is why
// it is not the batch path for a selection, and why the caller must have said
// so out loud before we get here.
const hamqthFullLogURL = "https://www.hamqth.com/prg_log_upload.php"
// hamqthMaxUpload is the documented ceiling for one upload.
const hamqthMaxUpload = 20 << 20
// hamqthCompressAbove is where a plain .adi stops being sent as text. Well
// under the limit: the multipart envelope and the form fields ride along too.
const hamqthCompressAbove = 12 << 20
// hamqthLoginURL is the callbook session login — the one authenticated HamQTH
// endpoint that cannot change anything in the log, which is what the settings
// Test button must call.
const hamqthLoginURL = "https://www.hamqth.com/xml.php"
// UploadHamQTH pushes one ADIF record to the HamQTH online logbook.
func UploadHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
return uploadHamQTHTo(ctx, client, hamqthUploadURL, cfg, adifRecord)
}
func uploadHamQTHTo(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
user := strings.TrimSpace(cfg.Username)
switch {
case user == "":
return UploadResult{}, fmt.Errorf("hamqth: username not set")
case cfg.Password == "":
return UploadResult{}, fmt.Errorf("hamqth: password not set")
}
rec := strings.TrimSpace(adifRecord)
if rec == "" {
return UploadResult{}, fmt.Errorf("hamqth: empty ADIF record")
}
form := url.Values{}
form.Set("u", user)
form.Set("p", cfg.Password)
// c: the logbook callsign when the account holds several; empty means the
// account's own call, which is the common case.
if c := strings.ToUpper(strings.TrimSpace(cfg.Callsign)); c != "" {
form.Set("c", c)
}
form.Set("adif", rec)
form.Set("prg", "OpsLog")
form.Set("cmd", "insert")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return UploadResult{}, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return UploadResult{}, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
msg := strings.TrimSpace(string(body))
switch resp.StatusCode {
case http.StatusOK:
return UploadResult{OK: true}, nil
case http.StatusBadRequest:
// "Rejected" covers duplicates too. A duplicate is a SUCCESS for our
// purposes — the QSO is in the logbook, retrying it forever isn't —
// same treatment HRDLog's <insert>0 gets.
if strings.Contains(strings.ToLower(msg), "dupl") {
return UploadResult{OK: true, Ignored: true, Message: "already in logbook"}, nil
}
if msg == "" {
msg = "QSO rejected"
}
return UploadResult{OK: false, Message: msg}, nil
case http.StatusForbidden:
return UploadResult{}, fmt.Errorf("hamqth: wrong username or password")
default:
if msg != "" && len(msg) < 200 {
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d: %s", resp.StatusCode, msg)
}
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d", resp.StatusCode)
}
}
// UploadHamQTHFullLog replaces the account's log with the given ADIF.
//
// DESTRUCTIVE by design of the remote API, not by ours: everything on HamQTH
// for this callsign that is not in this file stops existing. The caller owns
// the confirmation.
//
// The file goes in the multipart field "f" (HamQTH's own curl example:
// curl -F [email protected] -F send_log=OK -F u=… -F p=…). A large log is sent as a
// tar.gz — one of the archive formats the site unpacks — because the ceiling is
// 20 MB and a six-figure log passes it as plain text.
func UploadHamQTHFullLog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifText string) (UploadResult, error) {
user := strings.TrimSpace(cfg.Username)
switch {
case user == "":
return UploadResult{}, fmt.Errorf("hamqth: username not set")
case cfg.Password == "":
return UploadResult{}, fmt.Errorf("hamqth: password not set")
case strings.TrimSpace(adifText) == "":
return UploadResult{}, fmt.Errorf("hamqth: nothing to upload")
}
payload := []byte(adifText)
name := "opslog.adi"
if len(payload) > hamqthCompressAbove {
gz, err := tarGzADIF(payload)
if err != nil {
return UploadResult{}, fmt.Errorf("hamqth: compressing the log: %w", err)
}
payload, name = gz, "opslog.tar.gz"
}
if len(payload) > hamqthMaxUpload {
return UploadResult{}, fmt.Errorf("hamqth: the log is %d MB compressed, over HamQTH's %d MB limit",
len(payload)>>20, hamqthMaxUpload>>20)
}
var body bytes.Buffer
mw := multipart.NewWriter(&body)
_ = mw.WriteField("u", user)
_ = mw.WriteField("p", cfg.Password)
if c := strings.ToUpper(strings.TrimSpace(cfg.Callsign)); c != "" {
_ = mw.WriteField("c", c)
}
_ = mw.WriteField("send_log", "OK")
fw, err := mw.CreateFormFile("f", name)
if err != nil {
return UploadResult{}, err
}
if _, err := fw.Write(payload); err != nil {
return UploadResult{}, err
}
if err := mw.Close(); err != nil {
return UploadResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hamqthFullLogURL, &body)
if err != nil {
return UploadResult{}, err
}
req.Header.Set("Content-Type", mw.FormDataContentType())
if client == nil {
// A whole log is a long POST on a slow uplink.
client = &http.Client{Timeout: 10 * time.Minute}
}
resp, err := client.Do(req)
if err != nil {
return UploadResult{}, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
msg := strings.TrimSpace(string(raw))
if looksLikeHTML(msg) {
msg = ""
}
if resp.StatusCode != http.StatusOK {
if msg != "" && len(msg) < 300 {
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d: %s", resp.StatusCode, msg)
}
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d", resp.StatusCode)
}
// The site answers in prose, and only its own refusals are worth reading
// back: the ADIF itself is validated later, in the background, and any
// complaint about it reaches the operator by e-mail rather than here.
low := strings.ToLower(msg)
switch {
case strings.Contains(low, "successfully"):
return UploadResult{OK: true, Message: msg}, nil
case strings.Contains(low, "wrong username"), strings.Contains(low, "password"):
return UploadResult{}, fmt.Errorf("hamqth: %s", msg)
case strings.Contains(low, "cannot upload log for this callsign"):
return UploadResult{}, fmt.Errorf("hamqth: %s", msg)
case msg == "":
// HTTP 200 with nothing to say: taken as accepted, and said so.
return UploadResult{OK: true, Message: "uploaded (no reply text)"}, nil
default:
return UploadResult{OK: false, Message: msg}, nil
}
}
// tarGzADIF wraps the ADIF as log.adi inside a tar.gz — the archive must carry
// a .adi/.adif member for HamQTH to find the log in it.
func tarGzADIF(adif []byte) ([]byte, error) {
var out bytes.Buffer
gz := gzip.NewWriter(&out)
tw := tar.NewWriter(gz)
if err := tw.WriteHeader(&tar.Header{
Name: "opslog.adi", Mode: 0o644, Size: int64(len(adif)),
}); err != nil {
return nil, err
}
if _, err := tw.Write(adif); err != nil {
return nil, err
}
if err := tw.Close(); err != nil {
return nil, err
}
if err := gz.Close(); err != nil {
return nil, err
}
return out.Bytes(), nil
}
// TestHamQTH verifies the credentials against the callbook session login —
// authenticated, and unable to touch the log.
func TestHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
user := strings.TrimSpace(cfg.Username)
if user == "" || cfg.Password == "" {
return "", fmt.Errorf("hamqth: set the username and password first")
}
q := url.Values{}
q.Set("u", user)
q.Set("p", cfg.Password)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, hamqthLoginURL+"?"+q.Encode(), nil)
if err != nil {
return "", err
}
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
s := string(body)
if strings.Contains(s, "<session_id>") {
return fmt.Sprintf("Connected to HamQTH as %s.", user), nil
}
if i := strings.Index(s, "<error>"); i >= 0 {
e := s[i+len("<error>"):]
if j := strings.Index(e, "</error>"); j >= 0 {
return "", fmt.Errorf("hamqth: %s", strings.TrimSpace(e[:j]))
}
}
return "", fmt.Errorf("hamqth: unexpected answer — check the username and password")
}
+31
View File
@@ -11,6 +11,8 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp"
"strconv"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
@@ -337,7 +339,36 @@ func fileExists(p string) bool {
// they were already uploaded OR outside the callsign certificate's date range. // they were already uploaded OR outside the callsign certificate's date range.
// Reporting either as success is how a contact came to be stamped "uploaded" // Reporting either as success is how a contact came to be stamped "uploaded"
// while LoTW had never seen it. // while LoTW had never seen it.
// scrubMyCnty removes MY_CNTY fields TQSL would refuse. LoTW's secondary
// subdivisions are the US county enumeration — "XX,County" with a two-letter
// state — and TQSL rejects the whole record over anything else, so a Canadian
// station's "ONTARIO,Kawartha" (or a bare county) must simply not be sent.
// MY_STATE and MY_GRIDSQUARE already locate the station for LoTW.
var myCntyRe = regexp.MustCompile(`(?i)<MY_CNTY:([0-9]+)(?::[A-Za-z])?>`)
func scrubMyCnty(adif string) string {
for {
loc := myCntyRe.FindStringSubmatchIndex(adif)
if loc == nil {
return adif
}
n, _ := strconv.Atoi(adif[loc[2]:loc[3]])
end := loc[1] + n
if end > len(adif) {
end = len(adif)
}
val := adif[loc[1]:end]
if len(val) > 3 && val[2] == ',' {
// "XX,..." — the US shape TQSL accepts; leave it for the county hunters.
rest := scrubMyCnty(adif[end:])
return adif[:end] + rest
}
adif = adif[:loc[0]] + strings.TrimLeft(adif[end:], " ")
}
}
func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord string) (UploadResult, error) { func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord string) (UploadResult, error) {
adifRecord = scrubMyCnty(adifRecord)
tqsl := strings.TrimSpace(cfg.TQSLPath) tqsl := strings.TrimSpace(cfg.TQSLPath)
loc := strings.TrimSpace(cfg.StationLocation) loc := strings.TrimSpace(cfg.StationLocation)
switch { switch {
+23
View File
@@ -0,0 +1,23 @@
package extsvc
import "testing"
// TQSL refuses whole records over a MY_CNTY it cannot validate, and its
// validation is the US "XX,County" enumeration — so anything else must be
// stripped before signing, and the US shape must survive untouched.
func TestScrubMyCnty(t *testing.T) {
cases := []struct{ in, want string }{
{"<CALL:5>F4BPO<MY_CNTY:16>ONTARIO,Kawartha<MY_STATE:7>ONTARIO<EOR>",
"<CALL:5>F4BPO<MY_STATE:7>ONTARIO<EOR>"},
{"<MY_CNTY:8>Kawartha<EOR>", "<EOR>"},
{"<MY_CNTY:9>NY,Monroe<EOR>", "<MY_CNTY:9>NY,Monroe<EOR>"},
{"<CALL:4>K1AB<EOR>", "<CALL:4>K1AB<EOR>"},
{"<MY_CNTY:8>Kawartha<EOR>\n<MY_CNTY:9>NY,Monroe<EOR>",
"<EOR>\n<MY_CNTY:9>NY,Monroe<EOR>"},
}
for _, c := range cases {
if got := scrubMyCnty(c.in); got != c.want {
t.Errorf("scrubMyCnty(%q) = %q, want %q", c.in, got, c.want)
}
}
}
+32 -11
View File
@@ -87,6 +87,7 @@ type Manager struct {
mu sync.Mutex mu sync.Mutex
cfg ExternalServices cfg ExternalServices
rnd *rand.Rand rnd *rand.Rand
hamlogClosedOnce sync.Once
} }
// maxUploadAttempts bounds retries of a transient upload failure. // maxUploadAttempts bounds retries of a transient upload failure.
@@ -140,6 +141,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
cfg.EQSL = cfg.EQSL.normalised() cfg.EQSL = cfg.EQSL.normalised()
cfg.Cloudlog = cfg.Cloudlog.normalised() cfg.Cloudlog = cfg.Cloudlog.normalised()
cfg.Hamlog = cfg.Hamlog.normalised() cfg.Hamlog = cfg.Hamlog.normalised()
cfg.HamQTH = cfg.HamQTH.normalised()
m.cfg = cfg m.cfg = cfg
// Summary of what is armed, written at startup and on every settings save. // Summary of what is armed, written at startup and on every settings save.
@@ -153,7 +155,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
}{ }{
{"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW}, {"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW},
{"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog}, {"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog},
{"hamlog", cfg.Hamlog}, {"hamlog", cfg.Hamlog}, {"hamqth", cfg.HamQTH},
} { } {
if s.cfg.AutoUpload { if s.cfg.AutoUpload {
on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode)) on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode))
@@ -229,12 +231,20 @@ func (m *Manager) OnQSOLogged(id int64) {
m.route(ServiceCloudlog, id, c) m.route(ServiceCloudlog, id, c)
} }
} }
// HAMLOG.online — one API key and nothing else to get wrong. // HAMLOG.online is closed to uploads — see ErrHamlogClosed. Said once per
if h := cfg.Hamlog; h.AutoUpload { // session rather than per QSO, because an operator who left the switch on
if h.APIKey == "" { // deserves to know why nothing leaves, and does not deserve it every minute.
m.logf("extsvc: hamlog auto-upload is ON but no API key is set (QSO %d not sent)", id) if cfg.Hamlog.AutoUpload {
m.hamlogClosedOnce.Do(func() {
m.logf("extsvc: %v", ErrHamlogClosed)
})
}
// HamQTH — the callbook credentials double as the logbook login.
if h := cfg.HamQTH; h.AutoUpload {
if h.Username == "" || h.Password == "" {
m.logf("extsvc: hamqth auto-upload is ON but the username/password is not set (QSO %d not sent)", id)
} else { } else {
m.route(ServiceHamlog, id, h) m.route(ServiceHamQTH, id, h)
} }
} }
} }
@@ -287,8 +297,9 @@ func (m *Manager) onCloseServices() []Service {
if c := cfg.Cloudlog; c.AutoUpload && c.UploadMode == ModeOnClose && c.URL != "" && c.APIKey != "" && c.StationID != "" { if c := cfg.Cloudlog; c.AutoUpload && c.UploadMode == ModeOnClose && c.URL != "" && c.APIKey != "" && c.StationID != "" {
out = append(out, ServiceCloudlog) out = append(out, ServiceCloudlog)
} }
if h := cfg.Hamlog; h.AutoUpload && h.UploadMode == ModeOnClose && h.APIKey != "" {
out = append(out, ServiceHamlog) if h := cfg.HamQTH; h.AutoUpload && h.UploadMode == ModeOnClose && h.Username != "" && h.Password != "" {
out = append(out, ServiceHamQTH)
} }
return out return out
} }
@@ -336,8 +347,9 @@ func (m *Manager) FlushOnClose() int {
uploaded += m.flushOneByOne(svc, ids, cfg.HRDLog) uploaded += m.flushOneByOne(svc, ids, cfg.HRDLog)
case ServiceCloudlog: case ServiceCloudlog:
uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog) uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog)
case ServiceHamlog:
uploaded += m.flushOneByOne(svc, ids, cfg.Hamlog) case ServiceHamQTH:
uploaded += m.flushOneByOne(svc, ids, cfg.HamQTH)
} }
} }
return uploaded return uploaded
@@ -577,7 +589,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
switch svc { switch svc {
case ServiceQRZ, ServiceLoTW: case ServiceQRZ, ServiceLoTW:
owner = cfg.ForceStationCallsign owner = cfg.ForceStationCallsign
case ServiceClublog, ServiceHRDLog: case ServiceClublog, ServiceHRDLog, ServiceHamQTH:
owner = cfg.Callsign owner = cfg.Callsign
case ServiceEQSL: case ServiceEQSL:
owner = cfg.Username owner = cfg.Username
@@ -669,6 +681,15 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
return false, false return false, false
} }
res, err = UploadHamlog(ctx, m.deps.Client, cfg, record) res, err = UploadHamlog(ctx, m.deps.Client, cfg, record)
case ServiceHamQTH:
// The c parameter names the logbook when the account holds several;
// the QSO keeps its own STATION_CALLSIGN in the ADIF.
record, ok := m.deps.BuildADIF(id, "")
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false, false
}
res, err = UploadHamQTH(ctx, m.deps.Client, cfg, record)
default: default:
return false, false return false, false
} }
+63
View File
@@ -11,6 +11,7 @@ package geo
import ( import (
"math" "math"
"sort"
"strings" "strings"
) )
@@ -118,3 +119,65 @@ func NeighbourGrids(lat, lon float64, ring int) []string {
} }
return out return out
} }
// GridsWithin returns every Maidenhead square whose centre lies within km of
// (lat, lon), nearest first, at most max of them.
//
// NeighbourGrids answers "the ring around here", which is the right shape for a
// few hundred kilometres and the wrong one past that: a ring is a square, so
// asking for 2000 km through it means 1369 squares, most of them further away
// than the ones it left out. This measures instead, and the count then follows
// the AREA asked for rather than the corner of a box.
//
// Nearest first because the caller has to be able to trim: these become one
// broker subscription each, and when there are more than can be afforded, the
// squares to keep are the close ones.
func GridsWithin(lat, lon, km float64, max int) []string {
if km <= 0 || max <= 0 {
return nil
}
// One square is 1° of latitude and 2° of longitude. Sweep a box big enough
// to hold the circle — a degree of latitude is ~111 km everywhere, and a
// degree of longitude never MORE than that, so this cannot cut the circle.
steps := int(km/111.0) + 1
type cand struct {
grid string
d float64
}
seen := map[string]bool{}
out := []cand{}
for dLat := -steps; dLat <= steps; dLat++ {
for dLon := -2 * steps; dLon <= 2*steps; dLon++ {
la := lat + float64(dLat)
lo := lon + float64(dLon)*2
if la > 90 || la < -90 {
continue
}
g := LatLonToGrid(la, lo)
if seen[g] {
continue
}
// Measured to the square's own centre, not to the sample point, so
// two samples landing in one square agree about how far it is.
cLat, cLon, ok := GridToLatLon(g)
if !ok {
continue
}
d := HaversineKm(lat, lon, cLat, cLon)
if d > km {
continue
}
seen[g] = true
out = append(out, cand{g, d})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].d < out[j].d })
if len(out) > max {
out = out[:max]
}
grids := make([]string, len(out))
for i, c := range out {
grids[i] = c.grid
}
return grids
}
@@ -113,3 +113,27 @@ func TestDecodeKeepsTheRawModeMarkerForReplies(t *testing.T) {
ev.DecodeModeRaw, "~") ev.DecodeModeRaw, "~")
} }
} }
// Reported from a real shack: JTDX decoding FT4 showed up as Q65.
//
// The two forks disagree about ":" — Q65 in WSJT-X, FT4 in JTDX — so the
// character alone cannot answer, and the wrong answer poisons every verdict
// that follows: new mode, new slot, the mode filter. The program's own Status
// names the mode in full and settles it.
func TestAmbiguousModeCharDefersToTheProgram(t *testing.T) {
cases := []struct {
raw, status, want, why string
}{
{":", "FT4", "FT4", "JTDX decoding FT4"},
{":", "Q65", "Q65", "WSJT-X decoding Q65"},
{":", "", "Q65", "no Status yet — WSJT-X's reading, the older and commoner"},
// The unambiguous markers are unaffected, Status or no Status.
{"~", "FT4", "FT8", "a tilde is FT8 in both"},
{"+", "", "FT4", "a plus is FT4 in both"},
}
for _, c := range cases {
if got := DecodeModeName(c.raw, c.status); got != c.want {
t.Errorf("DecodeModeName(%q, %q) = %q, want %q — %s", c.raw, c.status, got, c.want, c.why)
}
}
}
+22 -1
View File
@@ -523,10 +523,24 @@ var decodeModeChar = map[string]string{
"#": "JT65", "#": "JT65",
"@": "JT9", "@": "JT9",
"&": "MSK144", "&": "MSK144",
":": "Q65",
"`": "FST4", "`": "FST4",
} }
// ambiguousModeChar is a marker the forks do not agree on.
//
// ":" is Q65 in WSJT-X and FT4 in JTDX, so the character alone cannot answer:
// a JTDX operator decoding FT4 was told they were on Q65, and every verdict
// downstream — new mode, new slot, the mode filter — was computed against a
// mode nobody was using.
//
// The sender's own Status settles it. It comes from the same program, names the
// mode in full, and is re-sent whenever it changes, so it knows what that
// program is decoding in a way one character never can. The fallback is
// WSJT-X's reading, which is the older and commoner one.
var ambiguousModeChar = map[string]string{
":": "Q65",
}
// DecodeModeName resolves a Decode's mode field to a real mode name. statusMode // DecodeModeName resolves a Decode's mode field to a real mode name. statusMode
// is the mode from the same program's last Status, used when the field is a // is the mode from the same program's last Status, used when the field is a
// marker we do not know, or empty. // marker we do not know, or empty.
@@ -535,6 +549,13 @@ func DecodeModeName(raw, statusMode string) string {
if m, ok := decodeModeChar[raw]; ok { if m, ok := decodeModeChar[raw]; ok {
return m return m
} }
if fallback, ok := ambiguousModeChar[raw]; ok {
// Believe the program over the character it happened to print.
if st := strings.ToUpper(strings.TrimSpace(statusMode)); st != "" {
return st
}
return fallback
}
// A mode name is at least two alphanumeric characters ("FT8", "JS8", "Q65"). // A mode name is at least two alphanumeric characters ("FT8", "JS8", "Q65").
// Anything shorter, or carrying punctuation, is a marker rather than a name. // Anything shorter, or carrying punctuation, is a marker rather than a name.
if len(raw) >= 2 { if len(raw) >= 2 {
+22 -5
View File
@@ -167,9 +167,14 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
r.Callsign = call r.Callsign = call
r.Source = p.Name() r.Source = p.Name()
r.FetchedAt = time.Now().UTC() r.FetchedAt = time.Now().UTC()
fillFromDXCC(&r, dxcc)
normalizeNames(&r) normalizeNames(&r)
// Cached BEFORE the cty.dat pass, so the row is a copy of the
// callbook page rather than of our conclusions about it. Every
// read runs the pass again (see the cache-hit path above), so a
// later cty.dat update reaches old rows — and a value we derived
// can never come back looking like something the page said.
_ = m.cache.Put(ctx, r) _ = m.cache.Put(ctx, r)
fillFromDXCC(&r, dxcc)
return r, nil return r, nil
} }
if errors.Is(err, ErrNotFound) { if errors.Is(err, ErrNotFound) {
@@ -208,9 +213,9 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
if !saysNothingAboutLocation(call) { if !saysNothingAboutLocation(call) {
clearHomeLocation(&r) clearHomeLocation(&r)
} }
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
normalizeNames(&r) normalizeNames(&r)
_ = m.cache.Put(ctx, r) _ = m.cache.Put(ctx, r) // the page as it was; cty.dat is applied on read
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
return r, nil return r, nil
} }
} }
@@ -441,11 +446,23 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
r.Continent = cont r.Continent = cont
filled = true filled = true
} }
if cqz != 0 { // Zones FILL, they do not override.
//
// The rule above is right for the country and wrong for the zones, because
// they answer different questions. An entity is what a callsign IS, and
// cty.dat is the authority on that. A zone is where the station SITS, and a
// large entity has many: Asiatic Russia spans CQ 16 to 23 and ITU 20 to 34,
// and cty.dat carries one representative pair for the whole country. Stamping
// it on every UA0 threw away the callbook's per-station answer and recorded a
// WAZ credit for a zone the operator had not worked — RU0LL is CQ 19, ITU 34,
// and was logged 17/30.
//
// So the callbook wins where it spoke, and cty.dat fills the silence.
if cqz != 0 && r.CQZ == 0 {
r.CQZ = cqz r.CQZ = cqz
filled = true filled = true
} }
if ituz != 0 { if ituz != 0 && r.ITUZ == 0 {
r.ITUZ = ituz r.ITUZ = ituz
filled = true filled = true
} }
+36
View File
@@ -0,0 +1,36 @@
package lookup
import "testing"
// asiaticRussia stands in for cty.dat: one representative zone pair for a
// country eight CQ zones wide.
func asiaticRussia(cqz, ituz int) fakeDXCC {
return fakeDXCC{"RU0LL": {num: 15, country: "Asiatic Russia", cont: "AS", cqz: cqz, ituz: ituz}}
}
// A zone is where the station SITS; an entity is what the callsign IS. Asiatic
// Russia spans CQ 16-23 and ITU 20-34, and cty.dat carries one representative
// pair for the whole country — so stamping it over a callbook's per-station
// answer records a WAZ credit for a zone the operator never worked.
//
// Reported with real callsigns: RU0LL is CQ 19 / ITU 34 and was logged 17/30.
func TestCallbookZonesSurviveTheEntityDefault(t *testing.T) {
// What QRZ said about this very station.
r := Result{Callsign: "RU0LL", CQZ: 19, ITUZ: 34}
fillFromDXCC(&r, asiaticRussia(17, 30))
if r.CQZ != 19 || r.ITUZ != 34 {
t.Errorf("callbook zones were overwritten: CQ%d ITU%d, want CQ19 ITU34", r.CQZ, r.ITUZ)
}
if r.Country != "Asiatic Russia" {
t.Errorf("the ENTITY must still come from cty.dat, got %q", r.Country)
}
}
func TestEntityZonesFillSilence(t *testing.T) {
// A callbook that says nothing about zones still gets an answer.
r := Result{Callsign: "RU0LL"}
fillFromDXCC(&r, asiaticRussia(17, 30))
if r.CQZ != 17 || r.ITUZ != 30 {
t.Errorf("empty zones were not filled: CQ%d ITU%d", r.CQZ, r.ITUZ)
}
}
+31 -5
View File
@@ -140,8 +140,17 @@ func New(cfg Config) *Watcher {
// same measurement is 0.2 to 1.2 — the load follows distance, which is what the // same measurement is 0.2 to 1.2 — the load follows distance, which is what the
// feed is actually about, and it is the same for every operator. // feed is actually about, and it is the same for every operator.
func (w *Watcher) topics() []string { func (w *Watcher) topics() []string {
bands := w.cfg.Bands
// One subscription per band per square multiplies, and past a point the
// cheaper trade is to take every band from those squares and drop the
// unwanted ones here: four bands over six hundred squares is 2400
// subscriptions, where "+" is 600 for maybe three times the messages —
// which are then filtered locally, as they already are for everything else.
if len(bands) > 1 && len(bands)*len(w.cfg.RxGrids) > 1000 {
bands = []string{"+"}
}
out := []string{} out := []string{}
for _, b := range w.cfg.Bands { for _, b := range bands {
if len(w.cfg.RxGrids) == 0 { if len(w.cfg.RxGrids) == 0 {
out = append(out, "pskr/filter/v2/"+b+"/#") out = append(out, "pskr/filter/v2/"+b+"/#")
continue continue
@@ -181,13 +190,30 @@ func (w *Watcher) Start() error {
opts.OnConnect = func(c mqtt.Client) { opts.OnConnect = func(c mqtt.Client) {
w.cfg.Logf("pskr: connected to %s", w.cfg.Broker) w.cfg.Logf("pskr: connected to %s", w.cfg.Broker)
for _, topic := range w.topics() { topics := w.topics()
if tok := c.Subscribe(topic, 0, w.handle); tok.Wait() && tok.Error() != nil { // In batches, not one at a time. Each Subscribe waits for its own
w.cfg.Logf("pskr: subscribe %s failed: %v", topic, tok.Error()) // acknowledgement, which is fine for the nine squares this started with
// and is minutes of waiting for the six hundred a 2000 km radius asks
// for — during which the feed is only partly subscribed and the panel
// looks broken.
const batch = 100
subbed := 0
for i := 0; i < len(topics); i += batch {
end := i + batch
if end > len(topics) {
end = len(topics)
}
filters := make(map[string]byte, end-i)
for _, t := range topics[i:end] {
filters[t] = 0
}
if tok := c.SubscribeMultiple(filters, w.handle); tok.Wait() && tok.Error() != nil {
w.cfg.Logf("pskr: subscribing to %d topics failed: %v", len(filters), tok.Error())
continue continue
} }
w.cfg.Logf("pskr: watching %s", topic) subbed += len(filters)
} }
w.cfg.Logf("pskr: watching %d topics (%d bands × %d receiver squares)", subbed, len(w.cfg.Bands), max(len(w.cfg.RxGrids), 1))
} }
opts.OnConnectionLost = func(_ mqtt.Client, err error) { opts.OnConnectionLost = func(_ mqtt.Client, err error) {
w.mu.Lock() w.mu.Lock()
+863
View File
@@ -0,0 +1,863 @@
// Package pskrtgt answers one question about one station: can they hear me?
//
// It is the other way round from internal/pskr. That watcher asks what is
// happening AROUND HERE — reports collected near the operator, whoever sent
// them — and it is the right shape for finding a band opening or a new entity.
// This one starts from a callsign the operator wants to work and gathers the
// evidence about that path, in both directions:
//
// - did the DX decode MY call, and how long ago
// - who NEAR ME did the DX decode (the path is open at my end)
// - who near the DX decoded ME (the path is open at his end, even when he
// uploads nothing himself)
// - how many stations he is decoding right now (the pileup I am up against)
// - where in his receive passband those decodes land, so a caller can pick a
// slot he is not already covered on
//
// Nothing here is persisted and nothing is inferred from a QSO: it is a sliding
// window of PSK Reporter reports, and when the window empties the answer goes
// back to "not known", which is the honest answer.
//
// The window is FIVE minutes. An FT8 cycle is fifteen seconds, so that is
// twenty chances for a path to show itself — short enough that "he decoded you"
// still means now, long enough that one missed cycle does not erase it.
package pskrtgt
import (
"encoding/json"
"encoding/xml"
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
// DefaultBroker is PSK Reporter's public MQTT endpoint, TLS. Same one the
// band-opening watcher uses — two connections to it, because the two want
// opposite slices of the feed and neither can be filtered out of the other's.
const DefaultBroker = "tls://mqtt.pskreporter.info:1884"
const (
// window is how far back a report still counts.
//
// TEN minutes. Five was chosen as "recent enough to still mean now", and on
// the air it meant half the evidence: PSK Reporter's uploaders batch their
// reports, many of them every five minutes, so a five-minute window catches
// roughly one upload cycle per station. Side by side with DXHunter on the
// same DX, the same second: 18 decodes here against 27 there, and a station
// missing from "from your area" that was simply six minutes old.
//
// It is a window on ONE station's activity, not on the band: ten minutes of
// a DX working a pileup is still what he is doing now.
window = 10 * time.Minute
// pileupWindow is the tighter one for "how many stations is he working
// through". A station he decoded four minutes ago has very likely moved on,
// and counting it inflates the only number an operator uses to decide
// whether it is worth calling at all.
pileupWindow = 2 * time.Minute
// The passband histogram: 60 Hz bins from 200 Hz to 4000 Hz. Above 4 kHz
// there is essentially no FT8, and drawing the empty space made the strip
// look broken rather than empty.
binHz = 60
lowHz = 200
highHz = 4000
)
// Scope decides how much of the feed is subscribed to.
type Scope string
const (
// ScopeTarget subscribes to three filters: what the DX transmits, what he
// receives, and who hears the operator. A handful of messages a second, and
// the REST backfill fills the window the moment the target changes.
ScopeTarget Scope = "target"
// ScopeBand subscribes to the whole band's FTx traffic. Switching target is
// then instant with no backfill, at the cost of every message on the band —
// hundreds a second when 20 m is busy.
ScopeBand Scope = "band"
)
// spot is one PSK Reporter reception report, as the v2 payload carries it.
type spot struct {
Freq int64 `json:"f"`
Mode string `json:"md"`
SNR int `json:"rp"`
TxCall string `json:"sc"`
TxGrid string `json:"sl"`
RxCall string `json:"rc"`
RxGrid string `json:"rl"`
Band string `json:"b"`
// at is stamped on arrival. The payload's own timestamps differ between
// versions of the feed, and everything here is measured in minutes.
at time.Time
}
// Entry is one station in one of the lists the panel shows.
type Entry struct {
Call string `json:"call"`
Grid string `json:"grid"`
SNR int `json:"snr"`
OffsetHz int `json:"offset_hz"` // audio offset from the operator's dial
AgeSec int `json:"age_sec"`
}
// Bin is one 60 Hz slice of the DX's receive passband.
type Bin struct {
OffsetHz int `json:"offset_hz"`
Count int `json:"count"`
AvgSNR float64 `json:"avg_snr"`
}
// Analysis is the whole snapshot the panel draws, recomputed on demand.
type Analysis struct {
Target string `json:"target"`
Mode string `json:"mode,omitempty"`
// Enabled is the operator's switch; Online is whether the broker is
// actually connected. A panel that says nothing has to be able to say WHY.
Enabled bool `json:"enabled"`
Online bool `json:"online"`
// Spots is everything in the window, the sign that the feed is alive even
// when every counter below is legitimately zero.
Spots int `json:"spots"`
// HeMe is the answer to the question. The rest is what to do when it is no.
HeMe bool `json:"he_me"`
HeMeSeconds int `json:"he_me_seconds"`
HeMeSNR int `json:"he_me_snr"`
HeMeOffset int `json:"he_me_offset_hz"`
// TargetUploads distinguishes "he is not hearing anybody" from "his software
// tells PSK Reporter nothing" — without it, a silent panel reads as a dead
// band when it may be a full one.
TargetUploads bool `json:"target_uploads"`
TargetGrid string `json:"target_grid,omitempty"`
// Near the DX: stations in his square that decoded the operator. This is
// what still works when he uploads nothing himself.
NearHimCount int `json:"near_him_count"`
NearHimTop []Entry `json:"near_him_top"`
// Near the operator: stations in his own field that the DX decoded.
FromMyAreaCount int `json:"from_my_area_count"`
FromMyAreaTop []Entry `json:"from_my_area_top"`
PathOpen bool `json:"path_open"`
// Who heard the DX, worldwide and locally.
HeardByCount int `json:"heard_by_count"`
HeardNearMe int `json:"heard_near_me"`
HeardNearMeTop []Entry `json:"heard_near_me_top"`
// The pileup: everyone he decoded (window), and the recent slice of it.
DecodedByCount int `json:"decoded_by_count"`
DecodedByTop []Entry `json:"decoded_by_top"`
DecodedByCalls []string `json:"decoded_by_calls"`
PileupCount int `json:"pileup_count"`
// His receive passband, and a slot in it that nobody is using.
DialHz int64 `json:"dial_hz"`
CeilingHz int `json:"ceiling_hz"`
DecodesInWindow int `json:"decodes_in_window"`
Bins []Bin `json:"bins"`
SuggestedOffset int `json:"suggested_offset"`
}
// Config is what the watcher needs from the application.
type Config struct {
Broker string
Scope Scope
// MyCall and MyGrid are the operator's. Both matter: the callsign is what
// "he decoded you" is looked up by, and the grid decides what counts as
// "near me" — its first two characters, a Maidenhead FIELD, which is a few
// hundred kilometres rather than a whole continent.
MyCall string
MyGrid string
// Continent resolves a callsign to EU/NA/AS/… It is only a FALLBACK, for an
// operator whose grid is not set: without a grid there is nothing to compare
// squares with, and a continent is better than nothing. Injected so this
// package does not pull in the country file.
Continent func(call string) string
Logf func(string, ...any)
}
// Watcher owns the MQTT connection and the window.
type Watcher struct {
mu sync.Mutex
cfg Config
client mqtt.Client
target string // the callsign being analysed, upper case
mode string // FT8 / FT4 — the target's mode, for the band-scope topic
band string // band tag currently subscribed to under ScopeBand
dialHz int64 // the operator's dial, for audio offsets
// subs is what we are subscribed to right now, so a target change can take
// the old filters down without guessing at their shape.
subs []string
spots []spot
// backfilled remembers the target the REST history was fetched for, so the
// panel's polling cannot re-fetch it every second. PSK Reporter's query API
// answers that with a rate limit, and rightly.
backfilled string
}
func New(cfg Config) *Watcher {
if cfg.Broker == "" {
cfg.Broker = DefaultBroker
}
if cfg.Scope == "" {
cfg.Scope = ScopeTarget
}
if cfg.Logf == nil {
cfg.Logf = func(string, ...any) {}
}
return &Watcher{cfg: cfg}
}
// Watch points the analysis at a callsign. Connects on the first call, so an
// operator who never opens the panel never opens a socket.
//
// Called repeatedly with the same target — the panel re-asserts it as the
// operator works — so everything expensive here is guarded on an actual change.
func (w *Watcher) Watch(target, mode string, dialHz int64) error {
target = strings.ToUpper(strings.TrimSpace(target))
mode = strings.ToUpper(strings.TrimSpace(mode))
if mode == "" {
mode = "FT8"
}
if target == "" {
w.Stop()
return nil
}
w.mu.Lock()
changed := target != w.target || mode != w.mode
w.target, w.mode = target, mode
if dialHz > 0 {
w.dialHz = dialHz
}
band := bandTag(w.dialHz)
bandChanged := band != "" && band != w.band
// Set BEFORE any connect: the subscription is built from it, and a first
// connect that found it empty would subscribe to every band at once under
// the band-wide scope — the one case where that is expensive.
if band != "" {
w.band = band
}
client := w.client
w.mu.Unlock()
if client == nil || !client.IsConnected() {
c, err := w.connect()
if err != nil {
return err
}
w.mu.Lock()
w.client, client = c, c
w.mu.Unlock()
// connect() subscribes on its own OnConnect handler; anything below
// would only repeat it.
changed = false
bandChanged = false
}
if !changed && !bandChanged {
return nil
}
w.mu.Lock()
// The window belongs to the target it was collected for. Keeping it across a
// change would answer the new question with the old station's evidence.
if changed {
w.spots = w.spots[:0]
}
w.mu.Unlock()
if err := w.resubscribe(client); err != nil {
return err
}
if changed && w.cfg.Scope == ScopeTarget {
// Under the band-wide subscription the window is already full of the new
// target's reports; under the narrow one it is empty, and the REST query
// is what makes the panel useful in the first fifteen seconds instead of
// after five minutes.
go w.backfill(target, mode)
}
return nil
}
// resubscribe replaces every filter with the ones the current target and scope
// want. Takes the old ones down first: a target change that only ADDED filters
// would leave the previous station's reports arriving for ever.
func (w *Watcher) resubscribe(c mqtt.Client) error {
w.mu.Lock()
old := w.subs
topics := w.topicsLocked()
w.subs = topics
target, scope := w.target, w.cfg.Scope
w.mu.Unlock()
if len(old) > 0 {
if tok := c.Unsubscribe(old...); tok.Wait() && tok.Error() != nil {
w.cfg.Logf("pskr target: unsubscribe failed: %v", tok.Error())
}
}
if len(topics) == 0 {
return nil
}
filters := make(map[string]byte, len(topics))
for _, t := range topics {
filters[t] = 0
}
if tok := c.SubscribeMultiple(filters, w.handle); tok.Wait() && tok.Error() != nil {
return fmt.Errorf("pskr target: subscribe: %w", tok.Error())
}
w.cfg.Logf("pskr target: watching %s (%s scope, %d filters)", target, scope, len(topics))
return nil
}
// topicsLocked builds the subscription list. The v2 topic is
//
// pskr/filter/v2/<band>/<mode>/<tx call>/<rx call>/<tx grid>/<rx grid>/<tx dxcc>/<rx dxcc>
//
// so both directions of one callsign are addressable at the broker, which is
// the whole reason the narrow scope costs almost nothing.
func (w *Watcher) topicsLocked() []string {
if w.target == "" {
return nil
}
if w.cfg.Scope == ScopeBand {
band := w.band
if band == "" {
band = "+"
}
return []string{"pskr/filter/v2/" + band + "/" + w.mode + "/#"}
}
out := []string{
// What he is transmitting: who is hearing him.
"pskr/filter/v2/+/" + w.mode + "/" + w.target + "/#",
// What he is receiving: the pileup, and whether the operator is in it.
"pskr/filter/v2/+/" + w.mode + "/+/" + w.target + "/#",
}
// Who hears the OPERATOR. Only some of those receivers are near the DX, and
// those are the ones that answer "can I be heard over there" on a DX who
// uploads nothing himself. Left out when the callsign is not configured
// rather than subscribing to a filter with an empty level in it.
if my := strings.ToUpper(strings.TrimSpace(w.cfg.MyCall)); my != "" {
out = append(out, "pskr/filter/v2/+/"+w.mode+"/"+my+"/#")
}
return out
}
func (w *Watcher) connect() (mqtt.Client, error) {
opts := mqtt.NewClientOptions().
AddBroker(w.cfg.Broker).
SetClientID(fmt.Sprintf("opslog-tgt-%d", time.Now().UnixNano())).
SetCleanSession(true).
SetAutoReconnect(true).
SetConnectRetry(true).
SetConnectRetryInterval(30 * time.Second).
SetConnectTimeout(15 * time.Second).
SetOrderMatters(false)
// Re-subscribe on every connect, reconnects included: the session is clean,
// so the broker remembers nothing and a dropped link would otherwise come
// back up subscribed to nothing at all — a panel that goes quiet for ever
// while still saying "online".
opts.OnConnect = func(c mqtt.Client) {
w.mu.Lock()
w.subs = nil
target, mode := w.target, w.mode
w.mu.Unlock()
if err := w.resubscribe(c); err != nil {
w.cfg.Logf("pskr target: %v", err)
}
if target != "" && w.cfg.Scope == ScopeTarget {
go w.backfill(target, mode)
}
}
opts.OnConnectionLost = func(_ mqtt.Client, err error) {
w.cfg.Logf("pskr target: connection lost: %v (will retry)", err)
}
c := mqtt.NewClient(opts)
tok := c.Connect()
if !tok.WaitTimeout(15*time.Second) || tok.Error() != nil {
err := tok.Error()
if err == nil {
err = fmt.Errorf("timeout")
}
return nil, fmt.Errorf("pskr target: connect %s: %w", w.cfg.Broker, err)
}
return c, nil
}
func (w *Watcher) handle(_ mqtt.Client, m mqtt.Message) {
var s spot
if err := json.Unmarshal(m.Payload(), &s); err != nil {
return
}
if s.TxCall == "" || s.RxCall == "" {
return
}
s.TxCall = strings.ToUpper(s.TxCall)
s.RxCall = strings.ToUpper(s.RxCall)
s.TxGrid = strings.ToUpper(s.TxGrid)
s.RxGrid = strings.ToUpper(s.RxGrid)
s.at = time.Now()
w.mu.Lock()
w.spots = append(w.spots, s)
w.mu.Unlock()
}
// Stop drops the target and the connection. The window goes with it: it is
// evidence about a station nobody is asking about any more.
func (w *Watcher) Stop() {
w.mu.Lock()
c := w.client
w.client, w.target, w.band, w.subs, w.backfilled = nil, "", "", nil, ""
w.spots = nil
w.mu.Unlock()
if c != nil {
c.Disconnect(250)
}
}
// SetDial updates the frequency audio offsets are measured against.
func (w *Watcher) SetDial(hz int64) {
if hz <= 0 {
return
}
w.mu.Lock()
w.dialHz = hz
w.mu.Unlock()
}
// SetOperator refreshes the operator's own callsign and grid. Called when the
// station profile changes: every "near me" answer is measured from these, and a
// stale pair would quietly measure them from somebody else's station.
func (w *Watcher) SetOperator(call, grid string) {
w.mu.Lock()
w.cfg.MyCall = strings.ToUpper(strings.TrimSpace(call))
w.cfg.MyGrid = strings.ToUpper(strings.TrimSpace(grid))
w.mu.Unlock()
}
// Snapshot recomputes the analysis from the window.
func (w *Watcher) Snapshot() Analysis {
w.mu.Lock()
defer w.mu.Unlock()
now := time.Now()
cutoff := now.Add(-window)
kept := w.spots[:0]
for _, s := range w.spots {
if s.at.After(cutoff) {
kept = append(kept, s)
}
}
w.spots = kept
a := Analysis{
Target: w.target,
Mode: w.mode,
Online: w.client != nil && w.client.IsConnected(),
DialHz: w.dialHz,
Spots: len(w.spots),
}
if w.target == "" {
return a
}
myCall := strings.ToUpper(strings.TrimSpace(w.cfg.MyCall))
myField := ""
if g := strings.ToUpper(strings.TrimSpace(w.cfg.MyGrid)); len(g) >= 2 {
myField = g[:2]
}
myCont := ""
if myField == "" && myCall != "" && w.cfg.Continent != nil {
myCont = strings.ToUpper(w.cfg.Continent(myCall))
}
// His square, taken from any report where he was transmitting. It is what
// "near him" is measured against, so without it that whole answer is
// unavailable rather than approximated.
for i := range w.spots {
if w.spots[i].TxCall == w.target && len(w.spots[i].TxGrid) >= 4 {
a.TargetGrid = w.spots[i].TxGrid[:4]
}
}
entry := func(call, grid string, s *spot) Entry {
off := 0
if w.dialHz > 0 {
off = int(s.Freq - w.dialHz)
}
return Entry{Call: call, Grid: grid, SNR: s.SNR, OffsetHz: off,
AgeSec: int(now.Sub(s.at).Seconds())}
}
// One entry per station, overwritten as newer reports arrive, so a station
// calling every cycle counts once and shows its latest report.
heardBy := map[string]Entry{}
heardNearMe := map[string]Entry{}
fromMyArea := map[string]Entry{}
decodedBy := map[string]Entry{}
nearHim := map[string]Entry{}
pileup := map[string]struct{}{}
pileupCutoff := now.Add(-pileupWindow)
type acc struct {
n int
sum float64
}
bins := map[int]*acc{}
var lastHeMe *spot
near := func(theirGrid, call string) bool {
if myField != "" {
return strings.HasPrefix(strings.ToUpper(theirGrid), myField)
}
if myCont != "" && w.cfg.Continent != nil {
return strings.ToUpper(w.cfg.Continent(call)) == myCont
}
return false
}
for i := range w.spots {
s := &w.spots[i]
// He transmitted: somebody heard him.
if s.TxCall == w.target {
e := entry(s.RxCall, s.RxGrid, s)
heardBy[s.RxCall] = e
if near(s.RxGrid, s.RxCall) {
heardNearMe[s.RxCall] = e
}
}
// The operator transmitted and a station in the DX's own square heard
// it. That is a path to his region, proved without his help.
if a.TargetGrid != "" && myCall != "" && s.TxCall == myCall &&
strings.HasPrefix(s.RxGrid, a.TargetGrid) {
nearHim[s.RxCall] = entry(s.RxCall, s.RxGrid, s)
}
// He received: this is the pileup, the passband, and the answer.
if s.RxCall == w.target {
a.DecodesInWindow++
if s.TxCall == myCall {
if lastHeMe == nil || s.at.After(lastHeMe.at) {
lastHeMe = s
}
continue // the operator is not part of his own pileup
}
decodedBy[s.TxCall] = entry(s.TxCall, s.TxGrid, s)
if s.at.After(pileupCutoff) {
pileup[s.TxCall] = struct{}{}
}
if near(s.TxGrid, s.TxCall) {
fromMyArea[s.TxCall] = entry(s.TxCall, s.TxGrid, s)
}
if w.dialHz > 0 {
off := int(s.Freq - w.dialHz)
if off >= lowHz && off <= highHz {
edge := (off / binHz) * binHz
b := bins[edge]
if b == nil {
b = &acc{}
bins[edge] = b
}
b.n++
b.sum += float64(s.SNR)
if off > a.CeilingHz {
a.CeilingHz = off
}
}
}
}
}
if lastHeMe != nil {
a.HeMe = true
a.HeMeSeconds = int(now.Sub(lastHeMe.at).Seconds())
a.HeMeSNR = lastHeMe.SNR
if w.dialHz > 0 {
a.HeMeOffset = int(lastHeMe.Freq - w.dialHz)
}
}
a.TargetUploads = a.DecodesInWindow > 0
a.HeardByCount = len(heardBy)
a.HeardNearMe = len(heardNearMe)
a.HeardNearMeTop = top(heardNearMe, 5)
a.FromMyAreaCount = len(fromMyArea)
a.FromMyAreaTop = top(fromMyArea, 5)
a.PathOpen = a.FromMyAreaCount > 0
a.NearHimCount = len(nearHim)
a.NearHimTop = top(nearHim, 5)
a.DecodedByCount = len(decodedBy)
a.DecodedByTop = top(decodedBy, 10)
a.DecodedByCalls = make([]string, 0, len(decodedBy))
for c := range decodedBy {
a.DecodedByCalls = append(a.DecodedByCalls, c)
}
sort.Strings(a.DecodedByCalls)
a.PileupCount = len(pileup)
a.Bins = make([]Bin, 0, len(bins))
for edge, b := range bins {
avg := 0.0
if b.n > 0 {
avg = b.sum / float64(b.n)
}
a.Bins = append(a.Bins, Bin{OffsetHz: edge, Count: b.n, AvgSNR: avg})
}
sort.Slice(a.Bins, func(i, j int) bool { return a.Bins[i].OffsetHz < a.Bins[j].OffsetHz })
a.SuggestedOffset = suggestOffset(a.Bins, a.CeilingHz)
return a
}
// top returns the freshest entries from a per-callsign map, newest first.
func top(m map[string]Entry, limit int) []Entry {
out := make([]Entry, 0, len(m))
for _, e := range m {
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool { return out[i].AgeSec < out[j].AgeSec })
if len(out) > limit {
out = out[:limit]
}
return out
}
// suggestOffset picks an audio slot to call on: the middle of the widest run of
// empty bins below the ceiling.
//
// Below the CEILING, not below 4000 Hz. The ceiling is the highest offset he
// has actually decoded, and it is the only evidence available about how wide
// his receiver is set — plenty of stations run 2500 Hz. Suggesting 3400 Hz to
// somebody whose passband stops at 2700 is advice to transmit into a filter.
func suggestOffset(bins []Bin, ceiling int) int {
if ceiling < 1000 {
return 0
}
used := map[int]bool{}
for _, b := range bins {
if b.Count > 0 {
used[b.OffsetHz] = true
// The neighbours too: FT8 is 50 Hz wide and the bins are 60, so a
// signal on a bin edge covers the next one as surely as its own.
used[b.OffsetHz-binHz] = true
used[b.OffsetHz+binHz] = true
}
}
bestStart, bestLen := -1, 0
start, run := -1, 0
// From 1000 Hz up: below that is where every default transmit offset sits,
// so it is the most crowded part of the passband and the least useful
// advice.
for edge := 1020; edge+binHz <= ceiling; edge += binHz {
if used[edge] {
start, run = -1, 0
continue
}
if start < 0 {
start = edge
}
run++
if run > bestLen {
bestStart, bestLen = start, run
}
}
if bestStart < 0 || bestLen < 2 {
return 0
}
return bestStart + bestLen*binHz/2
}
// bandTag names the band a dial frequency is on, in PSK Reporter's own
// vocabulary ("20m"). Only used by the band-wide scope, to subscribe to one
// band instead of all of them.
func bandTag(hz int64) string {
khz := hz / 1000
switch {
case khz >= 1800 && khz <= 2000:
return "160m"
case khz >= 3500 && khz <= 4000:
return "80m"
case khz >= 5250 && khz <= 5450:
return "60m"
case khz >= 7000 && khz <= 7300:
return "40m"
case khz >= 10100 && khz <= 10150:
return "30m"
case khz >= 14000 && khz <= 14350:
return "20m"
case khz >= 18068 && khz <= 18168:
return "17m"
case khz >= 21000 && khz <= 21450:
return "15m"
case khz >= 24890 && khz <= 24990:
return "12m"
case khz >= 28000 && khz <= 29700:
return "10m"
case khz >= 50000 && khz <= 54000:
return "6m"
case khz >= 70000 && khz <= 70500:
return "4m"
case khz >= 144000 && khz <= 148000:
return "2m"
case khz >= 430000 && khz <= 440000:
return "70cm"
}
return ""
}
// ── REST backfill ─────────────────────────────────────────────────────────
//
// The narrow subscription starts empty, and five minutes of waiting is not an
// answer to "should I call this station now". PSK Reporter's query API hands
// back the last quarter hour in one request, so the window is populated before
// the first cycle finishes.
//
// Fetched ONCE per target. The panel polls every second, and a query per poll
// is what gets an application rate-limited off the service for everyone.
type pskrReport struct {
Sender string `xml:"senderCallsign,attr"`
SenderGrid string `xml:"senderLocator,attr"`
Receiver string `xml:"receiverCallsign,attr"`
ReceiverGrid string `xml:"receiverLocator,attr"`
Frequency string `xml:"frequency,attr"`
SNR string `xml:"sNR,attr"`
Mode string `xml:"mode,attr"`
FlowStartSecs string `xml:"flowStartSeconds,attr"`
}
type pskrReports struct {
XMLName xml.Name `xml:"receptionReports"`
Reports []pskrReport `xml:"receptionReport"`
}
// backfill fetches the last quarter hour for a target, in BOTH directions.
//
// Two queries, because the panel asks two questions and the service answers
// them separately: what the target RECEIVED (his pileup, the passband, whether
// he decoded us) and what he TRANSMITTED (who is hearing him, and how much of
// that is near us). The live feed fills both eventually; a target picked ten
// seconds ago has neither, and with the narrow subscription there is nothing in
// the window at all until his own uploader next reports.
//
// Fetched ONCE per target. The panel polls every second, and a query per poll
// is what gets an application rate-limited off the service for everyone.
func (w *Watcher) backfill(target, mode string) {
w.mu.Lock()
if w.backfilled == target {
w.mu.Unlock()
return
}
w.backfilled = target
w.mu.Unlock()
got := 0
for _, dir := range []struct{ param, what string }{
{"receiverCallsign", "decoded by him"},
{"senderCallsign", "who is hearing him"},
} {
q := url.Values{}
q.Set(dir.param, target)
q.Set("mode", mode)
q.Set("flowStartSeconds", strconv.Itoa(-900))
q.Set("nolocator", "0")
// The pskquery5 endpoint rather than retrieve.pskreporter.info: this is
// the one DXHunter has been using against the live service, and a
// backfill that silently returns nothing is worse than none at all.
req, err := http.NewRequest("GET", "https://pskreporter.info/cgi-bin/pskquery5.pl?"+q.Encode(), nil)
if err != nil {
continue
}
req.Header.Set("User-Agent", "OpsLog (PSK Reporter target analysis)")
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
if err != nil {
w.cfg.Logf("pskr target: history for %s (%s) unavailable: %v", target, dir.what, err)
continue
}
if resp.StatusCode != http.StatusOK {
// 503 is the service saying "too often". Worth a line, because the
// panel then fills at the live feed's pace and looks slow for no
// visible reason.
w.cfg.Logf("pskr target: history for %s (%s) refused (HTTP %d)", target, dir.what, resp.StatusCode)
resp.Body.Close()
continue
}
var rr pskrReports
err = xml.NewDecoder(resp.Body).Decode(&rr)
resp.Body.Close()
if err != nil {
continue
}
got += w.absorb(target, rr.Reports)
}
if got > 0 {
w.cfg.Logf("pskr target: %d recent reports for %s from the history queries", got, target)
}
}
// absorb adds fetched reports to the window, skipping what the live feed has
// already delivered. Without the check the same report arrives twice — once by
// MQTT, once by query — and every count that is not per-callsign doubles: the
// decode total, and the bars of the passband.
func (w *Watcher) absorb(target string, reports []pskrReport) int {
now := time.Now()
w.mu.Lock()
defer w.mu.Unlock()
// Still the same target? The operator may have moved on while this was in
// flight, and dropping a stale answer into the window would attribute one
// station's pileup to another.
if w.target != target {
return 0
}
type key struct {
tx, rx string
hz int64
}
seen := make(map[key]bool, len(w.spots))
for i := range w.spots {
seen[key{w.spots[i].TxCall, w.spots[i].RxCall, w.spots[i].Freq}] = true
}
added := 0
for _, r := range reports {
hz, _ := strconv.ParseInt(r.Frequency, 10, 64)
snr, _ := strconv.Atoi(r.SNR)
k := key{strings.ToUpper(r.Sender), strings.ToUpper(r.Receiver), hz}
if hz == 0 || seen[k] {
continue
}
at := now
if secs, err := strconv.ParseInt(r.FlowStartSecs, 10, 64); err == nil {
switch {
case secs > 1_000_000_000:
at = time.Unix(secs, 0) // an absolute time
case secs < 0:
at = now.Add(time.Duration(secs) * time.Second) // an age in seconds
}
}
// Stamped with its REAL age, so it ages out of the window on its own and
// a quarter-hour-old decode is never read as "he heard you just now".
if at.Before(now.Add(-window)) {
continue
}
seen[k] = true
w.spots = append(w.spots, spot{
Freq: hz, Mode: strings.ToUpper(r.Mode), SNR: snr,
TxCall: k.tx, TxGrid: strings.ToUpper(r.SenderGrid),
RxCall: k.rx, RxGrid: strings.ToUpper(r.ReceiverGrid),
at: at,
})
added++
}
return added
}
+134
View File
@@ -0,0 +1,134 @@
package pskrtgt
import (
"testing"
"time"
)
// feed builds a watcher with a window already populated, so the analysis can be
// pinned without a broker.
func feed(target string, spots ...spot) *Watcher {
w := New(Config{MyCall: "F4BPO", MyGrid: "JN36BQ"})
w.target, w.mode, w.dialHz = target, "FT8", 14_074_000
w.spots = spots
return w
}
func rep(tx, txGrid, rx, rxGrid string, snr int, offset int, ago time.Duration) spot {
return spot{
TxCall: tx, TxGrid: txGrid, RxCall: rx, RxGrid: rxGrid,
SNR: snr, Freq: 14_074_000 + int64(offset), at: time.Now().Add(-ago),
}
}
func TestHeardYouIsTheOperatorsOwnCallOnly(t *testing.T) {
w := feed("YI5RLS",
rep("F4BPO", "JN36", "YI5RLS", "LM43", -14, 1200, 20*time.Second),
rep("F4XYZ", "JN36", "YI5RLS", "LM43", -8, 900, 30*time.Second),
)
a := w.Snapshot()
if !a.HeMe {
t.Fatal("the DX decoded the operator and the panel says he did not")
}
if a.HeMeSNR != -14 || a.HeMeOffset != 1200 {
t.Errorf("he_me = %d dB @ %d Hz, want -14 dB @ 1200 Hz", a.HeMeSNR, a.HeMeOffset)
}
// The operator is not part of the pileup he is calling into: counting
// yourself as competition is how a "1 caller" band looks contested.
if a.PileupCount != 1 {
t.Errorf("pileup = %d, want 1 (the other station only)", a.PileupCount)
}
// F4XYZ shares the operator's Maidenhead field, so the path from this
// region is demonstrably open.
if !a.PathOpen || a.FromMyAreaCount != 1 {
t.Errorf("from my area = %d (open=%v), want 1 open", a.FromMyAreaCount, a.PathOpen)
}
}
func TestNearHimNeedsHisSquareAndTheOperatorsCall(t *testing.T) {
// He transmits (so his square is known), and a station in that square hears
// the operator. He himself has decoded nobody.
w := feed("YI5RLS",
rep("YI5RLS", "LM43", "OH5CX", "KP30", -3, 0, 40*time.Second),
rep("F4BPO", "JN36", "YI9XY", "LM43CC", -19, 1500, 25*time.Second),
)
a := w.Snapshot()
if a.TargetGrid != "LM43" {
t.Fatalf("his square = %q, want LM43", a.TargetGrid)
}
if a.NearHimCount != 1 || len(a.NearHimTop) != 1 || a.NearHimTop[0].Call != "YI9XY" {
t.Errorf("near him = %d %v, want the one receiver in his square", a.NearHimCount, a.NearHimTop)
}
// He uploads nothing: the panel must be able to say so, or an operator
// reads an empty panel as a closed band.
if a.TargetUploads {
t.Error("he received nothing in the window, yet the panel claims he uploads")
}
if a.HeMe {
t.Error("nobody reported HIM decoding the operator")
}
}
func TestWindowDropsWhatIsTooOld(t *testing.T) {
// Inside the window: a report from six minutes ago is still evidence. Five
// minutes was too short — measured against DXHunter on the same station at
// the same moment, it hid a third of the decodes and a co-area station.
w := feed("YI5RLS",
rep("F4BPO", "JN36", "YI5RLS", "LM43", -14, 1200, 6*time.Minute),
)
if a := w.Snapshot(); !a.HeMe {
t.Errorf("a six-minute-old report was dropped from a ten-minute window: %+v", a)
}
// Past it, it goes.
w = feed("YI5RLS",
rep("F4BPO", "JN36", "YI5RLS", "LM43", -14, 1200, 11*time.Minute),
)
if a := w.Snapshot(); a.HeMe || a.Spots != 0 {
t.Errorf("an eleven-minute-old report survived: %+v", a)
}
}
func TestSuggestOffsetAvoidsTheOccupiedBinsAndTheCeiling(t *testing.T) {
// Busy from 1000 to 1500 Hz, empty from 1560 to 2400, ceiling 2400.
bins := []Bin{}
for hz := 1020; hz <= 1500; hz += binHz {
bins = append(bins, Bin{OffsetHz: hz, Count: 3})
}
bins = append(bins, Bin{OffsetHz: 2400, Count: 1})
got := suggestOffset(bins, 2400)
if got < 1620 || got > 2340 {
t.Errorf("suggested %d Hz, want somewhere in the empty 1560-2400 run", got)
}
// A passband that stops low must not produce advice above it: transmitting
// past the DX's filter is the one outcome worse than picking a busy slot.
if got := suggestOffset(bins, 1500); got != 0 {
t.Errorf("suggested %d Hz with a 1500 Hz ceiling and no room, want none", got)
}
if got := suggestOffset(nil, 0); got != 0 {
t.Errorf("suggested %d Hz with no data at all, want none", got)
}
}
func TestTopicsFollowTheScope(t *testing.T) {
w := New(Config{MyCall: "F4BPO", Scope: ScopeTarget})
w.target, w.mode, w.band = "YI5RLS", "FT8", "20m"
got := w.topicsLocked()
want := []string{
"pskr/filter/v2/+/FT8/YI5RLS/#",
"pskr/filter/v2/+/FT8/+/YI5RLS/#",
"pskr/filter/v2/+/FT8/F4BPO/#",
}
if len(got) != len(want) {
t.Fatalf("narrow scope = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("filter %d = %q, want %q", i, got[i], want[i])
}
}
w.cfg.Scope = ScopeBand
if got := w.topicsLocked(); len(got) != 1 || got[0] != "pskr/filter/v2/20m/FT8/#" {
t.Errorf("band scope = %v, want the one band-wide filter", got)
}
}
+27 -2
View File
@@ -995,6 +995,12 @@ var bulkEditableExtras = map[string]string{
"hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE", "hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE",
"hamlog_rcvd": "APP_HAMLOG_QSO_CFM", "hamlog_rcvd": "APP_HAMLOG_QSO_CFM",
"hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE", "hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE",
// HamQTH, same story and SENT only — the site publishes no confirmations,
// so there is no received side to edit. Bulk-editable for the one case that
// matters: a log uploaded to HamQTH by hand, which OpsLog would otherwise
// offer to send all over again.
"hamqth_sent": "APP_OPSLOG_HAMQTH_SENT",
"hamqth_sent_date": "APP_OPSLOG_HAMQTH_SENT_DATE",
} }
// BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "". // BulkExtraKey maps a frontend field id to its ADIF key in extras_json, or "".
@@ -1403,6 +1409,10 @@ var filterableExtras = map[string]string{
"hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE", "hamlog_sent_date": "APP_OPSLOG_HAMLOG_SENT_DATE",
"hamlog_rcvd": "APP_HAMLOG_QSO_CFM", "hamlog_rcvd": "APP_HAMLOG_QSO_CFM",
"hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE", "hamlog_rcvd_date": "APP_OPSLOG_HAMLOG_QSL_DATE",
// HamQTH — sent only, and filterable for the question that precedes every
// backlog upload: "which contacts have never gone there".
"hamqth_sent": "APP_OPSLOG_HAMQTH_SENT",
"hamqth_sent_date": "APP_OPSLOG_HAMQTH_SENT_DATE",
} }
// FilterableFields returns the whitelist (for the frontend to build its field // FilterableFields returns the whitelist (for the frontend to build its field
@@ -2003,7 +2013,7 @@ type WorkedBefore struct {
// at all about yesterday. // at all about yesterday.
type BandStatus struct { type BandStatus struct {
Band string `json:"band"` // ADIF lowercase band, e.g. "20m" Band string `json:"band"` // ADIF lowercase band, e.g. "20m"
Class string `json:"class"` // "PH" | "CW" | "DIG" Class string `json:"class"` // "PH" | "CW" | "DIG", or a raw digital mode ("FT8", "RTTY"…)
Status string `json:"status"` // "call_c" | "call_w" | "dxcc_c" | "dxcc_w" Status string `json:"status"` // "call_c" | "call_w" | "dxcc_c" | "dxcc_w"
// Call is "", "w" (worked with this callsign) or "c" (confirmed with it). // Call is "", "w" (worked with this callsign) or "c" (confirmed with it).
Call string `json:"call,omitempty"` Call string `json:"call,omitempty"`
@@ -2392,12 +2402,26 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
return wb, fmt.Errorf("scan band status: %w", err) return wb, fmt.Errorf("scan band status: %w", err)
} }
code := bandStatusCode(callW == 1, callC == 1, dxccConfirmed == 1) code := bandStatusCode(callW == 1, callC == 1, dxccConfirmed == 1)
k := cellKey{band: band, class: modeClass(mode)} keys := []cellKey{{band: band, class: modeClass(mode)}}
// The DIGITAL row can be cycled through the individual modes in the UI —
// FT8, then FT4, then RTTY — so the same cell is also published under the
// raw mode name. The query already grouped by mode; only the collapse to
// a class threw that away, and re-asking the database for it would be a
// second scan to learn what we had just read.
//
// Digital only: PH and CW have nothing to cycle through, and publishing
// "SSB" beside "PH" would just double the payload.
if um := strings.ToUpper(mode); modeClass(mode) == "DIG" && um != "" {
keys = append(keys, cellKey{band: band, class: um})
}
for _, k := range keys {
if cur, ok := best[k]; !ok || code > cur { if cur, ok := best[k]; !ok || code > cur {
best[k] = code best[k] = code
} }
}
// Confirmed beats worked here too, and neither is ever erased by the // Confirmed beats worked here too, and neither is ever erased by the
// entity: this is only ever about the callsign. // entity: this is only ever about the callsign.
for _, k := range keys {
switch { switch {
case callC == 1: case callC == 1:
callByCell[k] = "c" callByCell[k] = "c"
@@ -2405,6 +2429,7 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
callByCell[k] = "w" callByCell[k] = "w"
} }
} }
}
statusRows.Close() statusRows.Close()
codeStr := bandStatusNames codeStr := bandStatusNames
for k, code := range best { for k, code := range best {
+26
View File
@@ -262,7 +262,18 @@ func (s *Server) serve(c net.Conn) {
return return
} }
req := strings.TrimSpace(line) req := strings.TrimSpace(line)
started := time.Now()
resp, quit := s.handle(req) resp, quit := s.handle(req)
// A command the radio took a visible time to accept is worth a line of its
// own, always — not behind the trace switch below.
//
// This is the shape every "WSJT-X takes ten seconds to change band" report
// has: the client is waiting on us, we are waiting on the rig, and the log
// showed neither. Which command, and how long, is the whole diagnosis —
// and one second is already far outside anything a healthy link does.
if took := time.Since(started); took > time.Second && req != "" {
s.log("rigctld: %q took %s — the radio was slow to answer, the client waited that long", req, took.Round(10*time.Millisecond))
}
// The whole exchange, when tracing is on. // The whole exchange, when tracing is on.
// //
// Only PTT transitions were ever recorded, so when JTDX aborted a // Only PTT transitions were ever recorded, so when JTDX aborted a
@@ -362,6 +373,21 @@ func (s *Server) handle(line string) (resp string, quit bool) {
if len(args) < 1 { if len(args) < 1 {
return rprt(-1), false return rprt(-1), false
} }
// Only touch the radio on a CHANGE, the same rule set_ptt above follows.
//
// WSJT-X restates the mode on every band change and after every transmit,
// almost always the mode the rig is already in. On a native backend that
// is not free: an Icom set_mode is the mode frame, the data-mode frame and
// a readback to check the rig honoured them, each a round trip — over a
// remote CI-V link that is seconds, spent to arrive where we already were,
// with the client blocked on the answer the whole time.
//
// Compared in the CLIENT's own vocabulary — what "m" would report against
// what it just asked for — so nothing it can observe changes. A rig whose
// mode we do not know yet (empty) is never assumed.
if cur := s.rig.Mode(); cur != "" && adifToHamlib(cur) == adifToHamlib(hamlibToADIF(args[0])) {
return rprt(0), false
}
if err := s.rig.SetMode(hamlibToADIF(args[0])); err != nil { if err := s.rig.SetMode(hamlibToADIF(args[0])); err != nil {
s.log("rigctld: set_mode %q failed: %v", args[0], err) s.log("rigctld: set_mode %q failed: %v", args[0], err)
return rprt(-9), false return rprt(-9), false
+28
View File
@@ -332,3 +332,31 @@ func TestModeMapping(t *testing.T) {
} }
} }
} }
// A mode the rig is already in must not reach it. WSJT-X restates the mode on
// every band change, and on a native backend each restatement is several CI-V
// round trips with the client blocked on the answer.
func TestSetModeSkipsWhenUnchanged(t *testing.T) {
cases := []struct {
rig string // what the rig reports (ADIF)
ask string // what the client asks for (hamlib)
want int // times the radio should be touched
}{
{"CW", "CW", 0},
{"SSB", "USB", 0}, // "SSB" is reported as USB to a client
{"FT8", "PKTUSB", 0}, // data rides on USB; both name it PKTUSB
{"USB", "PKTUSB", 1}, // out of data mode into it: a real change
{"CW", "USB", 1}, // a real change
{"", "USB", 1}, // mode unknown: never assume
}
for _, c := range cases {
f := &fakeRig{mode: c.rig, freq: 14074000}
s := New(4532, f, nil)
if got, _ := s.handle("M " + c.ask); got != "RPRT 0\n" {
t.Fatalf("set_mode %q on a rig in %q = %q", c.ask, c.rig, got)
}
if n := len(f.setModes); n != c.want {
t.Errorf("rig in %q, client asked %q: rig touched %d times, want %d", c.rig, c.ask, n, c.want)
}
}
}
+97 -11
View File
@@ -28,6 +28,7 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"go.bug.st/serial" "go.bug.st/serial"
@@ -71,32 +72,99 @@ func NewSerial(comPort string, baud int) *Client {
return &Client{ComPort: comPort, Baud: baud} return &Client{ComPort: comPort, Baud: baud}
} }
// roundTrip opens a connection (TCP or serial per the client's config), sends // bootSettle is how long a freshly opened serial port is left alone before the
// one CR-terminated command and (when wantReply) reads one CR/LF-terminated // first command.
// reply line. //
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) { // An Arduino-based controller — K3NG's firmware, the ERC family — RESETS when
var conn io.ReadWriteCloser // the serial port is opened: the DTR line pulses its reset pin, and the
if c.ComPort != "" { // bootloader then holds the processor for a second or more. A command sent into
baud := c.Baud // that window is simply lost, which is exactly how a controller that answers
// PuTTY perfectly reports "no reply" here.
const bootSettle = 2 * time.Second
// heldPort is an open serial port, kept between calls.
//
// The package holds it rather than the Client because the callers build a FRESH
// Client for every poll (one per heading request), and the port has to outlive
// them. Reopening per command is what made an Arduino controller reboot several
// times a second and never answer anything. A serial port is a single-owner
// resource in any case: two clients for COM5 would be two handles on one cable.
type heldPort struct {
p serial.Port
openedAt time.Time
}
var (
portsMu sync.Mutex
openPorts = map[string]*heldPort{}
)
// acquire returns the open port for com, opening it if needed.
func acquire(com string, baud int) (*heldPort, error) {
portsMu.Lock()
defer portsMu.Unlock()
if h, ok := openPorts[com]; ok && h.p != nil {
return h, nil
}
if baud <= 0 { if baud <= 0 {
baud = 9600 baud = 9600
} }
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: baud}) sp, err := serial.Open(com, &serial.Mode{BaudRate: baud})
if err != nil { if err != nil {
return "", fmt.Errorf("open rotator %s @ %d baud: %w", c.ComPort, baud, err) return nil, fmt.Errorf("open rotator %s @ %d baud: %w", com, baud, err)
} }
_ = sp.SetReadTimeout(200 * time.Millisecond) _ = sp.SetReadTimeout(200 * time.Millisecond)
conn = sp h := &heldPort{p: sp, openedAt: time.Now()}
openPorts[com] = h
return h, nil
}
// drop closes and forgets a port, so the next call opens a fresh one. Called
// when an exchange fails: a half-spoken conversation is worse than a new one.
func drop(com string) {
portsMu.Lock()
defer portsMu.Unlock()
if h, ok := openPorts[com]; ok {
if h.p != nil {
_ = h.p.Close()
}
delete(openPorts, com)
}
}
// roundTrip sends one CR-terminated command and (when wantReply) reads one
// CR/LF-terminated reply line. Serial keeps its port open between calls; TCP
// dials per call, which is what the ARCO's LAN side expects.
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
var conn io.ReadWriteCloser
if c.ComPort != "" {
h, err := acquire(c.ComPort, c.Baud)
if err != nil {
return "", err
}
// Let a just-reset controller finish booting before speaking to it.
if wait := bootSettle - time.Since(h.openedAt); wait > 0 {
time.Sleep(wait)
}
conn = h.p
// Whatever is already in the buffer belongs to the last exchange — the
// trailing LF of the previous reply, or a line the controller volunteered
// while nobody was reading. Read as the answer to THIS command it would
// be an answer to the wrong question.
drain(h.p)
} else { } else {
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout) nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
if err != nil { if err != nil {
return "", fmt.Errorf("connect ARCO %s:%d: %w", c.Host, c.Port, err) return "", fmt.Errorf("connect ARCO %s:%d: %w", c.Host, c.Port, err)
} }
_ = nc.SetDeadline(time.Now().Add(ioTimeout)) _ = nc.SetDeadline(time.Now().Add(ioTimeout))
defer nc.Close()
conn = nc conn = nc
} }
defer conn.Close()
if _, err := conn.Write([]byte(cmd + "\r")); err != nil { if _, err := conn.Write([]byte(cmd + "\r")); err != nil {
if c.ComPort != "" {
drop(c.ComPort)
}
return "", fmt.Errorf("send %q: %w", cmd, err) return "", fmt.Errorf("send %q: %w", cmd, err)
} }
if !wantReply { if !wantReply {
@@ -121,11 +189,29 @@ func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
} }
line := strings.TrimSpace(sb.String()) line := strings.TrimSpace(sb.String())
if line == "" { if line == "" {
// Silence may mean the port is fine and the controller is not, or that
// the handle is stale (a USB adapter unplugged and replugged). Let go of
// it so the next attempt starts from a clean open rather than repeating
// the same silence for ever.
if c.ComPort != "" {
drop(c.ComPort)
}
return "", fmt.Errorf("no reply to %q", cmd) return "", fmt.Errorf("no reply to %q", cmd)
} }
return line, nil return line, nil
} }
// drain empties whatever is waiting, without blocking for long.
func drain(sp serial.Port) {
buf := make([]byte, 128)
for i := 0; i < 4; i++ {
n, err := sp.Read(buf)
if n == 0 || err != nil {
return
}
}
}
// GoTo points the antenna at the given azimuth (0-359). GS-232A takes M000-M450 // GoTo points the antenna at the given azimuth (0-359). GS-232A takes M000-M450
// (overlap rotators accept >360); we normalise to [0,360). // (overlap rotators accept >360); we normalise to [0,360).
func (c *Client) GoTo(az int) error { func (c *Client) GoTo(az int) error {
+34
View File
@@ -0,0 +1,34 @@
package gs232
import (
"strconv"
"strings"
"testing"
)
// Real replies, as the controllers actually send them — a K3NG answering "C"
// with "+0140" and CR+LF among them (reported from a live controller).
func TestAzimuthReplies(t *testing.T) {
cases := []struct {
raw string
want int
}{
{"+0140\r\n", 140}, // GS-232A, K3NG firmware
{"+0000\r", 0}, // due north
{"+0359\r\n", 359}, // just short of it
{"AZ=140\r\n", 140}, // GS-232B flavour
{"AZ=140 EL=000\r\n", 140}, // GS-232B with elevation on the same line
{"\r\n+0075\r\n", 75}, // a leftover terminator ahead of the answer
}
for _, c := range cases {
m := azRe.FindStringSubmatch(strings.TrimSpace(c.raw))
if m == nil {
t.Errorf("no azimuth found in %q", c.raw)
continue
}
got, _ := strconv.Atoi(m[1])
if got%360 != c.want {
t.Errorf("%q parsed as %d, want %d", c.raw, got%360, c.want)
}
}
}
+10 -6
View File
@@ -48,14 +48,14 @@ func hasFlag(args []string, flag string) bool {
} }
// acquireInstance grabs the single-instance mutex. On a normal launch it's a plain // acquireInstance grabs the single-instance mutex. On a normal launch it's a plain
// try (fail → another OpsLog is running, so exit). On a --post-update relaunch the // try (fail → another OpsLog is running, so exit). On a --post-update or
// previous instance may still be shutting down and holding the mutex, so retry for // --relaunch start the previous instance may still be shutting down and holding
// a few seconds until it frees. // the mutex, so retry for a few seconds until it frees.
func acquireInstance(postUpdate bool) bool { func acquireInstance(wait bool) bool {
if acquireSingleInstance() { if acquireSingleInstance() {
return true return true
} }
if !postUpdate { if !wait {
return false return false
} }
deadline := time.Now().Add(20 * time.Second) deadline := time.Now().Add(20 * time.Second)
@@ -90,7 +90,11 @@ func main() {
// to free instead of bailing out. Then clear the old exe it left behind. // to free instead of bailing out. Then clear the old exe it left behind.
bootLogLaunch() bootLogLaunch()
postUpdate := hasFlag(os.Args[1:], "--post-update") postUpdate := hasFlag(os.Args[1:], "--post-update")
if !acquireInstance(postUpdate) { // A self-relaunch (database switch) races its own parent: the new process
// regularly wins the start against the old one's teardown, and the operator
// got "OpsLog is already running" for following instructions. Same patience
// as post-update.
if !acquireInstance(postUpdate || hasFlag(os.Args[1:], "--relaunch")) {
// SAID, not merely done. This exit is correct — a second instance would // SAID, not merely done. This exit is correct — a second instance would
// fight the first over the rig — but it happened in total silence: no // fight the first over the rig — but it happened in total silence: no
// window, no data folder, no log, which is indistinguishable from a // window, no data folder, no log, which is indistinguishable from a
+5
View File
@@ -29,6 +29,11 @@ func TestProfileSwitchReappliesEveryStartupDevice(t *testing.T) {
"startAllEnabledClusters": "the cluster panel reconnects itself; its servers are a global list", "startAllEnabledClusters": "the cluster panel reconnects itself; its servers are a global list",
"startGridCache": "a shared on-disk grid cache, not a profile's", "startGridCache": "a shared on-disk grid cache, not a profile's",
"startBandOpenFeed": "PSK Reporter, keyed on the operator grid it re-reads itself", "startBandOpenFeed": "PSK Reporter, keyed on the operator grid it re-reads itself",
// The auto-call loop is ONE goroutine for the life of the process —
// starting a second on every profile switch would give one engine two
// sweepers. Its settings do follow the profile: reloadAfterProfileSwitch
// calls applyAutoCall, which re-reads them and clears the target.
"startAutoCall": "a single sweeper goroutine; applyAutoCall in the reload carries the settings",
} }
startup := body(t, string(src), "func (a *App) startup(ctx context.Context) {") startup := body(t, string(src), "func (a *App) startup(ctx context.Context) {")
+150
View File
@@ -0,0 +1,150 @@
package main
// PSK Reporter target analysis — the panel beside the FT decodes.
//
// The FT decodes list says who is on the air. It cannot say whether the station
// you are about to call can hear you, and on FT8 that is the only question that
// matters: a pileup is invisible from this end, and calling a DX who is not
// hearing your region at all is the commonest way to spend twenty minutes for
// nothing.
//
// internal/pskrtgt does the work. This file is the wiring: settings, lifecycle,
// and the bindings the panel polls.
import (
"fmt"
"strings"
"hamlog/internal/applog"
"hamlog/internal/pskrtgt"
)
const (
keyPSKTargetOn = "pskrtgt.enabled" // the operator's master switch
keyPSKTargetScope = "pskrtgt.scope" // "target" (narrow) | "band" (whole band)
)
// PSKTargetSettings is the panel's shape in Settings.
type PSKTargetSettings struct {
Enabled bool `json:"enabled"`
// Scope is how much of the PSK Reporter feed is subscribed to. The narrow
// one is three filters about two callsigns; the wide one is every FTx report
// on the band. The difference is a few messages a second against several
// hundred, which on an old PC is the difference between a panel and a
// problem — so the narrow one is the default, and the wide one is there for
// an operator who switches target constantly and has the machine for it.
Scope string `json:"scope"`
}
func (a *App) GetPSKTargetSettings() PSKTargetSettings {
scope := a.settingOr(keyPSKTargetScope, string(pskrtgt.ScopeTarget))
if scope != string(pskrtgt.ScopeBand) {
scope = string(pskrtgt.ScopeTarget)
}
return PSKTargetSettings{
Enabled: a.settingOr(keyPSKTargetOn, "0") == "1",
Scope: scope,
}
}
func (a *App) SavePSKTargetSettings(s PSKTargetSettings) error {
on := "0"
if s.Enabled {
on = "1"
}
scope := s.Scope
if scope != string(pskrtgt.ScopeBand) {
scope = string(pskrtgt.ScopeTarget)
}
a.setSetting(keyPSKTargetOn, on)
a.setSetting(keyPSKTargetScope, scope)
// Rebuilt rather than reconfigured: the scope decides the subscriptions, and
// a watcher that changed them under itself would have to unpick filters it
// no longer knows the shape of. It reconnects on the next Watch call, which
// the panel makes every second anyway.
a.stopPSKTarget()
applog.Printf("pskr target: %v (scope %s)", s.Enabled, scope)
return nil
}
// pskTargetWatcher returns the watcher, building it on first use. Nothing is
// connected until a target is set, so an operator who never opens the panel
// never opens a socket to the broker.
func (a *App) pskTargetWatcher() *pskrtgt.Watcher {
a.pskTgtMu.Lock()
defer a.pskTgtMu.Unlock()
if a.pskTgt != nil {
return a.pskTgt
}
s := a.GetPSKTargetSettings()
a.pskTgt = pskrtgt.New(pskrtgt.Config{
Scope: pskrtgt.Scope(s.Scope),
MyCall: a.opCall,
MyGrid: a.opGrid,
// Only a fallback, for a station with no grid set — see the package.
Continent: func(call string) string {
if a.dxcc == nil {
return ""
}
if m, ok := a.dxcc.Lookup(call); ok {
return m.Continent
}
return ""
},
Logf: applog.Printf,
})
return a.pskTgt
}
func (a *App) stopPSKTarget() {
a.pskTgtMu.Lock()
w := a.pskTgt
a.pskTgt = nil
a.pskTgtMu.Unlock()
if w != nil {
w.Stop()
}
}
// SetPSKTarget points the analysis at a callsign — normally the station the
// operator has just clicked or is calling. An empty callsign takes the feed
// down: no target, no subscription.
//
// mode is the target's own (FT8/FT4), and dialHz the operator's dial, which is
// what turns a report's frequency into an audio offset.
func (a *App) SetPSKTarget(call, mode string, dialHz int64) error {
if !a.GetPSKTargetSettings().Enabled {
a.stopPSKTarget()
return nil
}
call = strings.ToUpper(strings.TrimSpace(call))
if call == "" {
a.stopPSKTarget()
return nil
}
w := a.pskTargetWatcher()
// The operator's identity is re-asserted on every call rather than captured
// once: a profile switch changes both the callsign "he decoded you" looks
// for and the square "near me" is measured from.
w.SetOperator(a.opCall, a.opGrid)
if err := w.Watch(call, mode, dialHz); err != nil {
return fmt.Errorf("PSK Reporter: %w", err)
}
return nil
}
// GetPSKAnalysis is what the panel polls. Always answers, even with the feature
// off or no target set — the panel says WHY it is empty, and it can only do
// that if it is told.
func (a *App) GetPSKAnalysis() pskrtgt.Analysis {
s := a.GetPSKTargetSettings()
a.pskTgtMu.Lock()
w := a.pskTgt
a.pskTgtMu.Unlock()
if w == nil {
return pskrtgt.Analysis{Enabled: s.Enabled}
}
an := w.Snapshot()
an.Enabled = s.Enabled
return an
}
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"strings"
"testing"
"hamlog/internal/adif"
"hamlog/internal/qso"
)
// A contact that was not split still has a receive side, and the record
// forwarded to another logger has to carry it: Log4OM reads BAND_RX.
func TestFillRXDefaults(t *testing.T) {
hz := int64(14074000)
q := qso.QSO{Callsign: "F4BPO", Band: "20m", FreqHz: &hz}
fillRXDefaults(&q)
if q.BandRX != "20m" {
t.Errorf("BandRX = %q, want 20m", q.BandRX)
}
if q.FreqRXHz == nil || *q.FreqRXHz != hz {
t.Errorf("FreqRXHz = %v, want %d", q.FreqRXHz, hz)
}
// Assert on the RECORD another logger reads, not merely on the struct:
// both halves of the receive side have to reach it.
rec := strings.ToUpper(adif.SingleRecordADIF(q))
if !strings.Contains(rec, "<BAND_RX:3>20M") {
t.Errorf("BAND_RX missing from the forwarded record:\n%s", rec)
}
if !strings.Contains(rec, "<FREQ_RX:9>14.074000") {
t.Errorf("FREQ_RX missing from the forwarded record:\n%s", rec)
}
}
// A genuine split contact keeps what it was given.
func TestFillRXDefaultsKeepsSplit(t *testing.T) {
tx, rx := int64(14195000), int64(14205000)
q := qso.QSO{Callsign: "F4BPO", Band: "20m", BandRX: "17m", FreqHz: &tx, FreqRXHz: &rx}
fillRXDefaults(&q)
if q.BandRX != "17m" || q.FreqRXHz == nil || *q.FreqRXHz != rx {
t.Errorf("split QSO was overwritten: band_rx=%q freq_rx=%v", q.BandRX, q.FreqRXHz)
}
}
+24
View File
@@ -314,6 +314,30 @@ func bracket(s string) string {
// //
// Read from the same cached palette as the colours — see spotColorFor — because // Read from the same cached palette as the colours — see spotColorFor — because
// it is consulted on every spot from every cluster. // it is consulted on every spot from every cluster.
// spotPriority ranks a spot for SmartSDR's own tie-breaker, 1 (highest) to 5.
//
// The panadapter has finite room: spots close in frequency are stacked behind a
// "+" and only one of them is drawn. The radio chooses by priority — so an
// entity never worked was disappearing behind three stations already in the
// log, simply because OpsLog never said which was worth the space.
//
// The tiers are the operator's own: the entity never worked first, then the
// pieces of one already worked (band, mode, slot), then the reference hunts
// (POTA, SOTA, county, prefix), and everything else last. My own callsign rides
// at the top with the first — it is how a multi-op sees where it already is.
func spotPriority(status string) int {
switch status {
case "new", "my-call":
return 1
case "new-band-mode", "new-band", "new-mode", "new-slot":
return 2
case "new-pota", "new-sota", "new-county", "new-pfx":
return 3
default:
return 5
}
}
func (a *App) spotHidden(status string) bool { func (a *App) spotHidden(status string) bool {
a.spotColorsMu.Lock() a.spotColorsMu.Lock()
if a.spotColorsCache == nil { if a.spotColorsCache == nil {
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.27.5" appVersion = "0.27.12"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"testing"
"time"
"hamlog/internal/qso"
)
// The operator's report: South Africa worked on 15m FT8 but confirmed only by
// eQSL/Club Log/QRZ, with the chase set to "new + unconfirmed" over LoTW and
// paper QSL. The verdict must be NEW BAND — that is what the settings ask for —
// and it must be flagged UNCONFIRMED, so the badge reads "worked, QSL missing"
// rather than "never worked here".
func TestUnconfirmedBandIsFlagged(t *testing.T) {
a := syncTestApp(t)
hz := int64(21074000)
add := func(call, band, mode string, lotw, eqsl string, dxcc int) {
q := qso.QSO{
Callsign: call, Band: band, Mode: mode,
QSODate: time.Now().UTC().Add(-48 * time.Hour),
FreqHz: &hz, DXCC: &dxcc, Country: "South Africa",
LOTWRcvd: lotw, EQSLRcvd: eqsl,
}
if _, err := a.qso.Add(a.ctx, q); err != nil {
t.Fatalf("add %s: %v", call, err)
}
}
// 20m is LoTW-confirmed, so the ENTITY is confirmed…
add("ZS1AAA", "20m", "FT8", "Y", "Y", 462)
// …but every 15m contact is confirmed only by eQSL, which this operator
// does not count.
add("ZS6GAV", "15m", "FT8", "N", "Y", 462)
add("ZS4AW", "15m", "FT8", "N", "N", 462)
pred := qso.ConfirmSourcesPredicate([]string{"lotw", "card"})
all, err := a.qso.EntitySlotMapPred(a.ctx, func(_ string, dx int, _ string) int { return dx }, nil, "")
if err != nil {
t.Fatalf("all ledger: %v", err)
}
conf, err := a.qso.EntitySlotMapPred(a.ctx, func(_ string, dx int, _ string) int { return dx }, nil, pred)
if err != nil {
t.Fatalf("confirmed ledger: %v", err)
}
if _, ok := all[462]; !ok {
t.Fatal("the all-QSO ledger has no South Africa at all")
}
if _, ok := all[462].Bands["15m"]; !ok {
t.Error("all-QSO ledger: 15m missing — the dimming test can never fire")
}
if _, ok := all[462].Slots["15m"]["FT8"]; !ok {
t.Error("all-QSO ledger: 15m/FT8 slot missing")
}
c, ok := conf[462]
if !ok {
t.Fatal("confirmed ledger: South Africa missing — the 20m LoTW QSO should put it there")
}
if _, ok := c.Bands["15m"]; ok {
t.Error("confirmed ledger has 15m, but no 15m QSO is confirmed by LoTW or card")
}
// The two halves the UI shows.
status := spotEntityStatus(true, false, true, false, "FT8")
if status != "new-band" {
t.Errorf("status = %q, want new-band", status)
}
_, bAll := all[462].Bands["15m"]
_, mAll := all[462].Modes["FT8"]
_, sAll := all[462].Slots["15m"]["FT8"]
if got := spotEntityStatus(true, bAll, mAll, sAll, "FT8"); got != "worked" {
t.Errorf("all-ledger verdict = %q, want worked (→ the badge should be dimmed)", got)
}
}
+114
View File
@@ -0,0 +1,114 @@
package main
import (
"context"
"path/filepath"
"testing"
"time"
"hamlog/internal/db"
"hamlog/internal/dxcc"
"hamlog/internal/qso"
"hamlog/internal/settings"
)
// The operator's exact case, end to end through the real verdict path.
//
// South Africa is confirmed (LoTW) on 20m and worked-but-unconfirmed on 15m,
// with the hunt set to "new + unconfirmed" over LoTW and paper QSL. A 15m FT8
// decode must come back NEW BAND — that is what the settings ask — and carry
// the UNCONFIRMED flag, which is what draws the badge as a missing QSL rather
// than a band never worked.
func TestClusterStatusFlagsUnconfirmedBand(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "log.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { conn.Close() })
a := &App{ctx: context.Background(), qso: qso.NewRepo(conn), settings: settings.NewStore(conn)}
a.settingsScoped.Store(true)
if err := a.settings.Set(a.ctx, keyChaseMode, "new_unconfirmed"); err != nil {
t.Fatalf("set chase mode: %v", err)
}
if err := a.settings.Set(a.ctx, keyChaseConfirm, "lotw,card"); err != nil {
t.Fatalf("set chase sources: %v", err)
}
// The real prefix table, so ZS4AW resolves the way it does in the app.
a.dxcc = dxcc.NewManager(filepath.Join("build", "bin", "data"))
if err := a.dxcc.LoadFromDisk(); err != nil {
t.Skipf("cty.dat not available here: %v", err)
}
za := 462
add := func(call, band, mode, lotw string) {
hz := int64(21074000)
if _, err := a.qso.Add(a.ctx, qso.QSO{
Callsign: call, Band: band, Mode: mode, FreqHz: &hz,
QSODate: time.Now().UTC().Add(-72 * time.Hour),
DXCC: &za, Country: "South Africa", LOTWRcvd: lotw,
}); err != nil {
t.Fatalf("add %s: %v", call, err)
}
}
add("ZS1AAA", "20m", "FT8", "Y") // the entity IS confirmed, elsewhere
add("ZS6GAV", "15m", "FT8", "N") // 15m worked, never confirmed
add("ZS4AW", "15m", "FT8", "N")
// A station of the entity that is NOT the one already worked on this slot:
// that case has a rule of its own, tested below.
got := a.ClusterSpotStatuses([]SpotQuery{{Call: "ZS9XX", Band: "15m", Mode: "FT8"}})
if len(got) != 1 {
t.Fatalf("got %d results", len(got))
}
if got[0].Status != "new-band" {
t.Fatalf("status = %q, want new-band", got[0].Status)
}
if !got[0].UnconfStatus {
t.Error("UnconfStatus is false — the badge draws solid, as if 15m had never been worked")
}
}
// The same station, already worked on the same band and mode, has nothing left
// to give: working it again cannot turn a missing QSL into a confirmation. The
// need belongs to the entity, not to that callsign, so the badge goes quiet on
// it — and stays on every other station of the entity.
func TestWorkedSameSlotDropsTheUnconfirmedBadge(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "log.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { conn.Close() })
a := &App{ctx: context.Background(), qso: qso.NewRepo(conn), settings: settings.NewStore(conn)}
a.settingsScoped.Store(true)
_ = a.settings.Set(a.ctx, keyChaseMode, "new_unconfirmed")
_ = a.settings.Set(a.ctx, keyChaseConfirm, "lotw,card")
a.dxcc = dxcc.NewManager(filepath.Join("build", "bin", "data"))
if err := a.dxcc.LoadFromDisk(); err != nil {
t.Skipf("cty.dat not available here: %v", err)
}
hz := int64(10136000)
if _, err := a.qso.Add(a.ctx, qso.QSO{
Callsign: "TN8GD", Band: "30m", Mode: "FT8", FreqHz: &hz,
QSODate: time.Now().UTC().Add(-24 * time.Hour),
}); err != nil {
t.Fatalf("add: %v", err)
}
got := a.ClusterSpotStatuses([]SpotQuery{
{Call: "TN8GD", Band: "30m", Mode: "FT8"}, // the very station worked
{Call: "TN4XY", Band: "30m", Mode: "FT8"}, // another in the same entity
})
if got[0].Status == "new" || got[0].UnconfStatus {
t.Errorf("the worked station still advertises a need: status=%q unconf=%v",
got[0].Status, got[0].UnconfStatus)
}
if got[1].Status != "new" {
t.Errorf("another station in the entity lost its badge: status=%q", got[1].Status)
}
}