Compare commits

..
80 Commits
Author SHA1 Message Date
rouggy bc880ef6bd chore: release v0.27.17 2026-09-07 23:38:41 +02:00
rouggy 7ff0c2ac69 fix(settings): Preferences stopped lagging behind the keyboard
Typing a cluster macro cost two things per character. The state lived on
SettingsModal, so every keystroke in one of those twenty-four boxes
re-rendered the WHOLE preferences dialog — every list, every form, every
panel. And the save wrote through to Go and into the database on each
one: a round trip per character.

The editor is now its own module-scoped component with its own state, so
a keystroke re-renders twelve rows. And writeUiPrefDebounced holds the
database write until the typing stops, while the local cache — which is
what everything reads back — is still written at once. Pending values are
flushed when the page goes away, so typing and immediately closing does
not lose the last word.

The behaviour is unchanged: still saved as you type, still no Save
button, because a text box whose contents only take effect on some other
button is how work gets lost.

Also folds the satellite changelog into one [NEW] entry. Satellites are
new in this version — nobody reading the notes has seen any of it — so a
running account of how it was built, tab then tracking then rotator then
where the settings moved to, is the wrong shape. One entry saying what it
does.
2026-09-07 23:25:46 +02:00
rouggy fa6e30545a feat(cluster): three more nodes in the list — F5LEN, F5MZN, KM3T
dxcluster.f5len.org:7373, f5mzn.org:9000 and dxcc.km3t.net:7373, all
general DX clusters. Nine to pick from now.
2026-09-07 23:21:25 +02:00
rouggy 3e206268b4 feat(cluster): start from a known node instead of a blank form
Setting up a telnet cluster is the step operators get stuck on. The
address and the port are two pieces of information nobody has to hand,
and a typo in either looks exactly like a node that is down — there is
nothing to read, only a connection that never comes.

So the editor offers a list, and choosing one fills the fields. Six to
begin with: F4BPO, DXFun, SOTA, POTA, and the two Reverse Beacon feeds —
listed separately and named for what they carry, because they are one
network on two ports where 7000 is CW and RTTY and 7001 is FT8 and FT4,
and no amount of trying will tell you which is which.

The preset then gets out of the way. Everything stays editable, the name
is only filled when the operator has not chosen one of their own, and a
node typed in by hand behaves exactly the same. Reopening a node created
from a preset shows it selected, so the list also answers "which one is
this".
2026-09-07 23:17:20 +02:00
rouggy 659e33676a feat(decodes): sort a slot by SNR, frequency, distance, country or status
Click the heading. Within each PERIOD and never across them: the slots
are what this panel is — what was on the air in one fifteen-second
window — and a list sorted end to end would mix three minutes of decodes
into one column of numbers with no way to tell which window any of them
came from.

One click sorts the way that column is worth reading — strongest signal,
lowest frequency, furthest DX, A to Z, most wanted — the second reverses
it, and the third gives arrival order back. Arrival order stays the
default and stays one click away, because it mirrors the decoder's own
window line for line, which is what makes the two screens comparable at a
glance.

Status ranks by the cluster's own order, so the two views rank the same
things the same way, with the markers that are orthogonal to the entity —
a new county on a worked country — sorted above the plain duplicates.

A station that never sent a grid cannot be placed, and an unresolved
country is not a name: both sort to the end whichever way the column
goes, rather than pretending to a distance of zero and heading the list
under "nearest first".
2026-09-07 23:09:03 +02:00
rouggy cf44b37bf4 feat(sat): a sky plot — the pass seen from underneath it
The map answers "where is the satellite over the earth". This answers
"where do I look", which during a pass is the question that matters.

The projection is the one every tracker uses and every operator already
reads: the centre is the zenith, the rim is the horizon, north is up. So
the radius is (90 − elevation), not the elevation — a bird overhead is a
dot in the middle, and a pass that hugs the rim is one that never rises.
Whether it comes over the roof or along the treeline is something no
amount of azimuth and elevation digits conveys, and one glance settles.

The whole pass is drawn: a dashed track with arrowheads for the direction
of travel, a hollow circle where it rises, a filled one where it sets,
and a cross where the satellite is now — green above the horizon, grey
below, because the numbers are still right down there and nothing can be
worked through the earth.

The track is fetched once a minute, not once a second: the SHAPE of a
pass does not change while it happens. Only the marker moves, and that
rides on the tuning poll that was already running.
2026-09-07 22:50:29 +02:00
rouggy 37dadeda84 feat(sat): the pass list becomes a table worth reading
Headings, because a column of numbers with nothing above it makes an
operator work out what they are looking at every time. A real table, so
the satellite column takes the width the longest name needs — "ZHUHAI-1
OVS-1A" was cut to eight characters in a fixed one.

Colour where it carries meaning, and nowhere else. The maximum elevation
is the quality of the pass, so it is coloured like one: a bird 70°
overhead and one scraping 12° along the horizon are not the same evening,
and the table should say so without the operator reading every number. A
pass in progress is green, one starting within five minutes is amber. And
a dot for the mode: FM and SSB call for a completely different set-up,
and which the next pass is decides whether you reach for a handheld or
for the whole station.

The rotator's baud rate is a dropdown, like every other one in OpsLog.
2026-09-07 22:43:55 +02:00
rouggy b478cbfd2a feat(sat): a wider frequency list, a resizable readout, one locator
Three things reported together from the tab.

The shipped frequency plan went from eleven satellites to twenty-five:
the eight Tevel FM cubesats, EO-88, AO-109, CAS-4A and 4B, TO-108,
GreenCube's single-frequency digipeater, and QO-100's wideband
transponder beside its narrowband one. It remains a starting point in a
file the operator can correct — a transponder gets switched and no
release should be needed to follow it — and the picker still lists every
bird in the element set when the "with a plan" filter is unticked.

The readout column drags to any width between 240 and 720 pixels,
double-clicks back to its default, and folds away entirely. How much map
against how much detail is the operator's call: watching a footprint
cross an ocean and working a pass want opposite things.

And the locator is no longer asked for twice. Passes are predicted from
the station locator, which is set once in Station information; the field
here was only ever for an antenna at another site, so it says so and sits
folded away. Nobody should have to wonder which of two locators is in
use.

Also: "Driven by" is two columns wide. "OpsLog (EasyComm II)" did not fit
in a third of the row, and a truncated choice is a choice that cannot be
read.
2026-09-07 22:36:48 +02:00
rouggy 76022ff91c fix(udp): several FT8 programs no longer fight over the callsign field
Reported from a station running MSHV, WSJT-X and JTDX together: click a
call in MSHV and the entry field filled, emptied, refilled — once a
second — with the map zooming in and out to match.

Two causes, both about reading one program's statement as another's.

The clear was tracked per LISTENER. Several decoders commonly share one
multicast group, so an idle WSJT-X reporting no DX Call — which is simply
true, and which it repeats every second — was read as MSHV abandoning the
station it was calling. "The operator cleared the DX Call" is a statement
about one program, never about a socket, so it is now tracked per
program, and a clear carries the id of whoever made it.

And nothing arbitrated between them. The program that announces a station
now holds the entry field, and the others cannot touch it until it lets
go: it clears its own call, it stops sending (closed), or the QSO is
logged. That is the operator's own suggestion, and it is the right one —
between overs there is no way to tell "I have nothing" from "I am not the
one you are working" except by remembering who was.

Refusing another program's callsign is logged once per focus, not once a
second: an operator whose second decoder "stopped filling the call" needs
something to read.
2026-09-07 21:23:11 +02:00
rouggy b918a8395b fix(maps): one imagery choice per map, not one for two of them
The world map and the grid-square map shared a single key, so picking
satellite imagery to look at grids repainted the main map too, and there
was no way to have terrain on one and streets on the other. They are
different maps answering different questions, and the imagery that suits
one is not the imagery that suits the next.

Four keys now, one per map, in lib/mapBase beside the remembered views —
named in one place so a rename cannot silently orphan somebody's choice —
and portable, so a copied data folder brings them along. The grid map
inherits whatever was set under the old shared key rather than being
reset to the default: an operator who chose imagery there keeps it.
2026-09-07 21:06:10 +02:00
rouggy 2283734210 feat(sat): PstRotator can point the antenna too
It handles azimuth and elevation, and a great many stations already run
it in front of a controller OpsLog has never heard of. For those,
OpsLog talking to the controller itself would be a second program
fighting PstRotator over the same cable — so it hands over the bearing
instead, and lets PstRotator turn the mast.

Both kinds sit behind one small interface, chosen in Settings. Neither is
more correct than the other: the right one is whichever the station
already has working.

The 450° overlap is deliberately NOT applied on the PstRotator path.
PstRotator knows which machine is on the other end and does its own; two
programs each deciding to go the long way round is exactly how an antenna
unwinds in the middle of a pass.

Position queries are asked at most every three seconds rather than on
every tick. A PstRotator query binds a socket and waits up to a second
and a half, and many setups answer nothing at all — so one silence is
enough and it stops asking, reporting the commanded position instead and
saying that is what it is.
2026-09-07 17:11:23 +02:00
rouggy 9dfa6f7d39 refactor(sat): one satellite list, and out of Hardware
Settings ▸ Lists ▸ Satellites is gone. It was a text box an operator
typed their birds into by hand, and it had nothing to do with the
satellites the tracker knew: the same station kept two lists of the same
satellites and they drifted apart. The SAT_NAME box on the entry form now
offers the followed set — or every satellite with a frequency plan when
none is followed — merged with whatever that old list still holds, read
and never written, because what it holds is somebody's past work.

And the satellite section moved out of Hardware, which it never was.
Which birds you chase, where your antenna stands and how old your
elements are is operating; the rotator inside it is the only hardware
there, and one block does not make a section a device.
2026-09-07 15:41:56 +02:00
rouggy 3ed48336af feat(sat): set it up in Settings, work the pass in the tab
Two things belong in different places, and they were in one.

Settings → Satellites now holds the setup: which satellites to follow —
the same two-column shape as the awards, for the same reason, since a
feed carries two hundred birds and an operator works six — and the
orbital elements, their age, the fetch, and pasting your own. Following
none still means every satellite with both elements and a frequency plan,
so somebody who has not chosen yet is not handed an empty tab.

The panel keeps only what a pass needs. A countdown to AOS, or to LOS
once it is up, because that is the number that decides whether you sit
down; a bar for where in the pass you are, since mid-pass the useful
question is not the clock but whether you are past the peak; rise, peak
and set with compass directions, because "rises SW" is a direction to
look in and 213° is arithmetic. Distance, altitude and footprint. And
approaching or receding, which is the sign of the whole Doppler
correction and the only thing that explains why the frequencies are
moving the way they are.

The countdowns run in the browser from two timestamps. Predicting a pass
steps the orbit across a day thirty seconds at a time, which is not
something to do once a second for a clock the page can keep itself.
2026-09-07 15:34:26 +02:00
rouggy 7f03b046ab fix(sat): the locator was already set, and the map was on top of everything
Two things reported together on the new tab.

The locator: the station's is on the ACTIVE PROFILE, not in a settings
key. keyStationMyGrid is a legacy key that EnsureDefault migrated into
the profile long ago and nothing writes any more, so reading it told an
operator with a perfectly good locator on screen that he had not set one
— and refused every pass prediction on the strength of it.

The map: Leaflet stacks its panes and controls up to z-index 1000, which
without a stacking context of their own float over the whole application
— Preferences opened behind the map, its Save and Close buttons under it.
The other three maps in OpsLog each carry `isolate` for exactly this
reason; this one was missing it.
2026-09-07 12:07:12 +02:00
rouggy 90e363f49e feat(sat): point the antenna — EasyComm II az/el rotator
EasyComm is what satellite rotator controllers agreed on, so a box that
works with SatPC32, Gpredict or Hamlib works here. Serial or TCP, and its
own settings rather than the HF rotator's: an az/el pair is a different
machine on a different port, and an operator who has both must not have
to choose.

A great many EasyComm controllers — the Arduino trackers above all —
accept commands and never say a word back. That is legal and common, so a
silent controller is not treated as a broken one: it is still driven, and
the last commanded position is reported in its place, marked as commanded
rather than read. A stuck rotator must not be able to hide behind an
order it never carried out, which is why the panel shows the antenna's
position beside the satellite's.

The 450° overlap is the reason a satellite rotator is worth having, so it
is used: a pass crossing north continues past 360 instead of unwinding
three quarters of a turn with the antenna sweeping the ground. Below the
configured elevation the mast is left alone — the numbers are right all
the way round the orbit, but a rotator that chases a satellite through
the far side of the earth spends the night turning, and a mast has a
finite number of turns in it.
2026-09-07 11:34:35 +02:00
rouggy 465481f8f1 feat(sat): Doppler tracking on the radio
The hard part of satellite tuning is not the arithmetic, it is deciding
who owns the dial. A tracker that forces both frequencies fights the
operator every time they turn the knob to follow a station across a
linear transponder; one that never touches the receiver leaves them
chasing a signal that slides nine kilohertz across a 70 cm pass.

So the operator owns the receiver and the tracker follows them. Every
second it asks the radio where the receiver actually is. Where it put it,
nothing has changed. Further than a dial-turn's tolerance, and the
operator has chosen a station: what they landed on is converted back into
a nominal frequency, and the transmitter is derived from that. Which is
the division of labour on a linear bird — the operator listens, the radio
does the sums.

Three ways to reach the radio, because a satellite pair is a shape of
operating rather than a manufacturer's feature. An IC-9700 or IC-9100 is
asked for its OWN satellite mode: it pairs main and sub, gives full
duplex, and keeps the dials linked the way its designers meant, which is
always better than an imitation built out of split. A Flex gets two
slices, A the downlink and B the uplink, created when missing, because
"slice B does not exist" is not something to make an operator fix at the
start of a ten-minute pass. Everything else gets the downlink, and is
told so — half the job announced beats half the job hidden.

What goes in the log is the NOMINAL pair. Two stations working each other
through a transponder read different numbers off their dials at the same
instant; the only figure they can both agree on is the transponder's own.
FREQ is the uplink and FREQ_RX the downlink — the one place a satellite
QSO differs from every other kind, and the reason FREQ alone cannot
describe one.
2026-09-07 11:27:06 +02:00
rouggy 680bf410fe feat(sat): the Satellites tab
Three questions answered at once, because on a pass there is no time to
go looking for any of them: where the bird is, when the next one comes,
and what to tune. The map draws each satellite's footprint — the honest
answer to "can I hear it", since everything inside the circle has the
satellite above its horizon — and the selected one's path over the
ground. The pass list is every favourite in time order, the one in
progress in green.

The readout shows the corrected frequency large and the nominal one
beneath it. Only one of them, and an operator cannot tell a Doppler
correction from a mistuned transponder.

The map opens on the station rather than the Atlantic, and remembers
where it was left like the others. The panel is mounted only while its
tab is visible: it asks for the tuning once a second, and there is no
reason to compute an orbit nobody is looking at.
2026-09-07 10:56:28 +02:00
rouggy 1009d06a4c feat(sat): the station side — elements, plan, passes and tuning
What internal/sat could not know: where the antenna is, which birds the
operator cares about, and where the files live.

Startup reads the cached elements and the frequency plan from disk and
nothing else — one file and a few hundred parses, so the tab is full the
moment it is opened, on a shack PC with no internet as much as on one
with. Fetching is the slow, optional half and never blocks a launch; it
happens on its own only when the set is stale and the operator asked for
it.

Elements pasted in by hand go in their own file. The feed cache is
replaced wholesale on every refresh, so a freshly launched satellite —
whose elements circulate on a mailing list days before any feed carries
it, which is exactly the week everybody wants to hear it — would
otherwise be wiped by the first automatic update.

The list joins both halves and shows what is missing on either side. A
bird with elements and no plan is one the operator can still track; a
bird with a plan and no elements is the visible symptom of an element set
that is too old. Dropping either turns a fixable configuration problem
into a satellite that "does not exist".

GetSatelliteTuning is the working answer, and everything that will later
drive a radio is built on top of it rather than beside it, so the display
and the rig can never disagree. It keeps the operator's frequency
nominal and applies Doppler only on the way out: on a linear pass the
station being answered stays put on the dial while both radios chase the
shift. A geostationary bird is corrected by nothing at all.
2026-09-07 10:51:16 +02:00
rouggy 7a84f00060 feat(sat): element feeds and the frequency plan
Two things the tracker cannot work without, both kept apart from the
orbital maths on purpose.

The elements come from Celestrak's amateur group, with PE0SAT as the
fallback for the hour when Celestrak is rate-limiting a hundred trackers
at once. A malformed satellite is skipped rather than fatal — a feed of
two hundred birds with one bad checksum must still give the operator the
other hundred and ninety-nine — and the count is returned so the app can
say so. The cache is plain TLE text in the data directory, written
beside and renamed, and only replaced once a feed has produced usable
elements: a captive portal must not take away the set the station
already had. Loading it first is what makes the satellite tab full on a
shack PC with no internet.

The frequency plan is separate because it changes for different reasons:
elements every few days from a feed, a transponder when the satellite is
commanded into another mode. The shipped list is a starting point, copied
to the data directory on first use and read from there afterwards, so an
operator can correct a frequency without waiting for a release and keep
the correction across updates — and a file they have broken is reported,
not overwritten.

UplinkFor is the part that matters on the air. On an inverting linear
transponder, tuning up the downlink means going down the uplink; get it
backwards and you transmit at the far end of the passband from the
station you can hear, which is the classic first evening on a linear
bird.

Names are matched on letters and digits alone. Celestrak says
"RADFXSAT (FOX-1B)" where every operator says AO-91, and nobody spells
Es'hail the same way twice.
2026-09-07 10:45:50 +02:00
rouggy 2d71351080 feat(sat): the sky engine — elements, look angles, passes, Doppler
The foundation of the satellite branch, and nothing above it yet: where a
satellite is (SGP4 from akhenakh/sgp4, Apache-2.0 and pure Go, so the
no-cgo rule holds), where it will be (passes with an elevation floor,
because a three-degree scrape is a line in a table that will never be a
QSO), and what its motion does to a frequency.

The Doppler pair is the part worth being careful about: the two
corrections go in OPPOSITE directions. The downlink arrives shifted and
we tune to meet it; the uplink must LEAVE shifted the other way to land
on the transponder's nominal input. A test pins the signs and the size —
7 km/s on 2 m is about 3.4 kHz.

Elements keep their raw lines beside the parsed form: that is what the
cache stores and what an operator pastes by hand for a bird no feed
carries yet, which is exactly when everyone wants to hear it.
2026-09-07 10:36:56 +02:00
rouggy b0f76a8ba1 fix(yaesu): the RTTY sideband reaches the rig that is already connected
Ticking "RTTY on USB" changed nothing: the flag is deliberately absent
from catLinkSig — none of these preferences is worth dropping the CAT
link, and with it WSJT-X's rigctl session, to apply — so saving the
settings left the link alone and the running client kept its old answer
until the next launch.

Preferences that the link does not depend on are now pushed to the
connected rig when the settings are saved, through the Yaesu escape on
the manager. SetRTTYUpper joins the controller interface for that: it is
a preference rather than a command, but it has to be reachable on a rig
that is already talking.
2026-09-07 09:51:13 +02:00
rouggy ed062a040c fix(rotor widget): several rotors no longer run off the bottom
The selector row appears above the dial when there is more than one
rotor, and the widget's height is not its own to take — it sits in a
strip sized by the entry form beside it. The row was simply added, so the
SP/LP pair and half the Stop button went off the end.

The dial (24 px), the three button rows (8 px each) and the padding
(4 px) now give that height back between them, which is a selector row
almost exactly. The dial and the controls column stay the same height as
each other, so the two columns still line up.
2026-09-07 09:42:48 +02:00
rouggy 0430aab78e fix(update): relaunch without a hidden PowerShell
Windows Defender removed 0.27.14 from a station as
Trojan:Script/Wacatac.H!ml. That detection is machine-learning, not a
signature, and the behaviour it scored is ours: an unsigned binary
replaces itself on disk, clears the mark-of-the-web, and spawns a
windowless PowerShell that waits for its own process to die before
starting another executable. Byte for byte, that is a dropper; the model
reads the shape, not the intention, and "Script/" names the PowerShell.

The wait it was written for is not needed. --post-update already makes
the new instance patient with the single-instance mutex — twenty seconds
of it — so the new exe can be started directly while this one is still
shutting down and simply wait its turn.

The deferred-swap fallback keeps its helper: nothing else on a stock
Windows can wait for a pid and then move a file over an image that is
still running. It is reached only when the rename failed, never on the
ordinary path.
2026-09-07 09:36:02 +02:00
rouggy 5d526d29db chore: release v0.27.16 2026-09-06 23:29:46 +02:00
rouggy 09c4358626 chore(changelog): the Yaesu work opens 0.27.16
The eight-digit rigs, the typed watering hole and the RTTY sideband all
landed after the 0.27.15 release commit, so they were sitting in a block
that had already shipped. 0.27.15 says what was released again.
2026-09-06 23:29:31 +02:00
rouggy 9ce7cf3b69 feat: a typed watering hole carries its mode; Yaesu RTTY sideband
His log settles the 28.074 case: "SetCATFrequency 28.074 MHz" and the
state still reads mode=USB — nothing sent a mode, so the rig simply
stayed where it was. A spot click has always carried one; a frequency
typed by hand carried none. It now uses the same table and the same
tolerance as a spot (±3 kHz of a known FT8/FT4/JS8 frequency), and only
towards the digital modes: tuning AWAY from one leaves the mode alone,
because there the frequency says nothing about what the operator means.

RTTY on Yaesu is a choice the log cannot make: ADIF records "RTTY" and
the rig has MD06 (RTTY-L) and MD09 (RTTY-U). The older lower sideband
stays the default and a station whose FSK controller wants the upper one
says so once in Settings → CAT.
2026-09-06 23:01:08 +02:00
rouggy 3745d23339 feat(yaesu): eight-digit rigs — the width comes from the radio
An FTDX3000 rejects every FA command: the FTDX10 family writes a
frequency in nine digits and everything before it — FTDX3000, FTDX5000,
FTDX1200, FT-2000, FT-950, FT-450 — writes eight, answering the longer
form with "?;". The rig would not follow and nothing said why.

The width is LEARNED from the rig's own replies rather than tabulated: it
announces the format in every answer to FA;, so it comes from the radio
in front of the operator instead of from a model list that is always one
release behind — and a Yaesu this backend has never heard of is right on
the first read. Nine until the first reply lands, which is what the
modern rigs use and what this backend was written against.

The five older ID codes are named too, so the console says FTDX3000
rather than a bare number.
2026-09-06 21:11:19 +02:00
rouggy 23f324e606 chore: release v0.27.15 2026-09-06 20:25:56 +02:00
rouggy 44a18ec799 chore(ui): shorter WSJT-X highlight wording
The main hint named three fixed colours, which stopped being true the
moment the operator could choose them. And the paragraph under "Mark
stations already worked" explained a trade-off nobody reads standing at
the radio; the switch says what it does.
2026-09-06 20:15:56 +02:00
rouggy 1e003bbdb7 chore(changelog): 0.27.15 reads novelties first
Same order as the last block: what is new leads, each fix follows the
thing it belongs to — the antenna pair, then the Icom network run, then
the rest.
2026-09-06 20:13:46 +02:00
rouggy 421bc372ee fix(steppir): the same fast poll, and motion reported at the command
The shortened transmit gag was written for an Ultrabeam that is now polled
four times a second while it moves. The SteppIR was still on two seconds
and reported nothing at all until its own poll came round, so the gap
between the 900 ms grace and the first poll that would have seen the
movement was a hole in "block TX while the elements travel" — the
transmitter released in the middle of a move.

It now reports a commanded move at once (bounded, so an antenna that
never answers cannot latch the inhibit on), on a COPY of the cached
status so the flag cannot leak into what the poll goroutine owns, and
follows the motors with its poll rate exactly as the Ultrabeam does.
2026-09-06 20:10:39 +02:00
rouggy c8c48d408f feat(motor antenna): amber at the order, not at the answer
The transmit gag starts when the move is commanded; the indicator waited
for a status poll to say the elements were travelling, so the two
disagreed by a second or more — and on an automatic follow (band change
with tracking on) there was no sign at all until a poll landed. The
backend now announces the order as it goes out, the interface shows it at
once and re-reads the antenna immediately, and the real status takes over
the moment it arrives.

The WSJT-X "grey out stations already worked" switch is renamed "Mark
stations already worked": with the colour now chosen by the operator the
old name described the wrong thing, but the switch still answers a
question the palette cannot — WHETHER dupes should be marked at all. On a
well-filled log they are most of a period, and a window where nearly
every line is coloured has stopped saying anything.
2026-09-06 20:06:05 +02:00
rouggy d001616767 fix: release TX when the elements stop; WSJT-X colours, and worked beats the watch list
The motorised antenna gagged the transmitter for a second or two after it
had finished moving. Three delays were stacked: the antenna polled every
two seconds, the gag held for three after the command whatever the
antenna said, and the widget refreshed every three. The antenna is now
asked four times a second WHILE IT MOVES — an idle one has nothing to say
and stays at two seconds — the gag only bridges the command itself
(900 ms), and the widget follows at half a second while moving.

WSJT-X highlighting:

- A watch-list station already worked on this band and mode is no longer
  painted as one to call. The list is a statement of intent, not of what
  is left to do, and its pink outranked every other verdict including the
  log's, so a worked station stayed pink for the session with nothing to
  tell it from one still needed.
- The four colours are the operator's to choose (Settings → UDP). Only
  the background: the text colour is derived by luma, so a dark blue
  cannot come back as black-on-black in somebody else's window. Changing
  one clears the installed highlights, or the de-duplication would keep
  showing yesterday's colour until a callsign changed verdict.
2026-09-06 18:43:08 +02:00
rouggy 07ee48e20c feat(rotor dial): circular scale and a beam, from EC1KD's revision
The square ring put the ticks in the corners where the room is, and made
every distance from the centre depend on direction — a marker at 45 sat
further out than one at north. A dial is read by angle, so the ring it is
read against is the same distance away all the way round now, and the
markers need one radius instead of a per-direction one.

The antenna is a sector that fades outwards rather than an arrow: an
antenna does not look along a line, it looks through a lobe, and the
arrow claimed a precision the beamwidth does not have. Where the mouse
would send it is drawn the same way, and its azimuth cross-fades into the
big readout while aiming — the figure is read at the moment the beam is,
so it belongs in the same place rather than somewhere to look away to.

Three colours, three meanings, kept apart: green where the antenna IS
(as everywhere else in OpsLog), orange where a click would send it,
yellow what was ORDERED. His revision drew all of them orange and yellow,
which loses the distinction the dial exists for — and drew the second
lobe of a bidirectional Ultrabeam at full strength, indistinguishable
from the main one. It is dimmed again; the dashed boom, the REV/BI badge
and the compact form are untouched.
2026-09-06 18:31:52 +02:00
rouggy 9614e3498a fix(icom net): the audio starts at once, not half a minute later
The conninfo that authorises the RX stream goes out during the login,
before the audio socket exists — it has to, since it is what authorises
it. So the rig is told to send audio to :50003 while nothing is bound
there, gets an ICMP port-unreachable back, backs off, and the audio turns
up only when its own retry timer comes round: twenty to thirty seconds by
the operator's watch, ninety in one log.

It is sent once more the moment the port is listening, which is the same
message the session already carries — RS-BA1 repeats it too. The audio
dial moved above the pumps, because after ctrlPump is running the
control-stream auth state belongs to it.
2026-09-06 18:26:24 +02:00
rouggy 0f082e1301 diag(audio): say why the Listening device is silent
"I turned the sound back on and nothing comes out of the speakers", with
a log that reports success at every step: the network audio stream up,
664-byte packets arriving, the monitor started. The render goroutine's
error was thrown away — a device unplugged, renamed by Windows or unable
to open at 16 kHz fails exactly there, silently.

It is logged now, with the device id. Two once-only lines either side of
it say whether the decoded audio is reaching a running monitor or
arriving with nobody listening, which separates a device fault from the
speakers simply being switched off.
2026-09-06 18:22:39 +02:00
rouggy 2808bead97 fix(uploads): Club Log is configured — it never needed an API key
The guard I added for services with no credentials demanded one, and
nobody has ever set it: OpsLog carries its own Club Log APPLICATION key
(clublogAppAPIKey), so the account is an email, a password and the
logbook callsign. An operator whose live upload had been working for
months was told the service was not configured the moment he sent QSOs by
hand after an import.

Written in app.go, the rules drifted from the uploaders on the first try.
They now live in internal/extsvc beside the Upload* functions that
enforce them, each case mirroring that function's own guard — which also
caught Cloudlog, where the station profile is required and the check did
not ask for it. The message names the fields actually missing rather than
listing everything the service takes.
2026-09-06 18:06:06 +02:00
rouggy e8f9e68759 diag(icom net): name the last CI-V commands before the silence
Audio off, and the IC-7760 still answers at connect and then never again:
twenty-eight commands sent, not one reply, packets still arriving on the
stream. A count says how much went unanswered and never which command
went out last — which is the one thing that can identify a frame this rig
does not tolerate, on a radio nobody here has.

The transport now keeps the command headers of the last eight frames and
prints them with the silence report.
2026-09-06 17:49:32 +02:00
rouggy f2168d339b diag(icom net): name the audio stream when CI-V goes quiet
The RX audio stream is experimental and shares the rig's session with
CI-V. The shape in an operator's log is unmistakable: audio packets
arriving by the hundred while not one CI-V reply comes back, the watchdog
tearing the session down, twenty seconds' pause, and the whole thing
again — read from outside as "the Icom keeps disconnecting", with
nothing pointing at the switch that would end it.

The log now says it, and names the setting. icomAudio counts what it has
delivered so the line distinguishes "audio is enabled" from "audio is
arriving", which is the half that makes it a suspect.
2026-09-06 17:42:37 +02:00
rouggy 507d1f0882 fix(icom net): a sleeping rig keeps its session, and the console is reachable
From an operator's log: an IC-7760 in standby, and OpsLog dialling and
dropping every forty seconds for as long as it was left there — control
link up, login OK, token renewed, and not one CI-V answer.

lastGoodAt bounds "the link answers but no CI-V comes back". It belongs
to a session and was never cleared when a new one opened, so a rig that
went to standby half an hour ago handed every fresh session a
half-hour-old last good read: past the grace before the first command was
even sent. Torn down at once, redialled twenty seconds later, torn down
again. Cleared on connect, the rule reads as it was written — silent
since connect is a rig in standby, and the session is kept so it can be
woken.

The console it is woken from was missing too. It appeared only once the
live CAT state said "icom", which a sleeping rig never says, so the ON
button was absent at the one moment it exists for. It now follows the
CONFIGURED radio — which also had to start following a radio switched
from the status bar, instead of waiting for a trip through Settings and a
Save that changed nothing.
2026-09-06 17:35:53 +02:00
rouggy 381a7fdc40 fix(adif): one record, one line
An exported file was full of blank gaps: a record ran down a dozen lines
and the next appeared to start in the middle of the page. ADDRESS is a
multi-line field by the standard and callbooks and other loggers fill it
that way — "Kabul", four blank lines, "Afghanistan" — and the writer
copied the value out as it stood. The files were always valid, since ADIF
counts bytes; they were unreadable, and so was anything that quoted them.

Line breaks inside a value are now joined with a comma and tabs become
spaces, which is how an address reads on one line anyway. The length
prefix is computed after the flattening, so the record stays exact, and
every path through the writer gets it: the file exports, the uploads and
the record forwarded over UDP.

The changelog's 0.27.15 block also takes back the FT-map hover fix, which
landed after the 0.27.14 release commit and was sitting in that block.
2026-09-06 17:09:18 +02:00
rouggy c8e2e3a29f fix(ft map): the hover label came back
The invisible circle that catches the clicks sits ON TOP of the dot, so
it takes the hover as well — and the tooltip was bound only to the dot
underneath. From the moment the stations became clickable, pointing at
one said nothing: the callsign, square and report an operator reads off
the map had simply gone.
2026-09-06 16:56:58 +02:00
rouggy 92a3189784 chore(changelog): 0.27.14 reads novelties first
The block is read top to bottom: what is new leads, each fix follows the
thing it belongs to, and the five genuinely new features carry [NEW].
2026-09-06 14:58:54 +02:00
rouggy 0af6cdedcd chore: release v0.27.14 2026-09-06 14:58:42 +02:00
rouggy ab1b45da07 feat(ft map): click a station to take it, double-click to answer it
The map draws a dot per decoded station and they did nothing. They now
answer the same two gestures as the decodes list, because the two are
views of the same list and a click has to mean the same thing on both:
one takes the callsign into the entry (and points the PSK Reporter panel
at it), two answer it.

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

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

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

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

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

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

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

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

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

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

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

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

Auto-call:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Said, not decided. Taking the rig's band instead would be wrong for
anyone decoding a second receiver on another band, and a warning costs
that setup nothing but a line to read past. Shown only while CAT is
actually connected: an empty band means there is nothing to compare
with, never that the rig is on no band. Opens 0.27.9.
2026-09-02 10:25:17 +02:00
120 changed files with 18615 additions and 1209 deletions
View File
+327 -20
View File
@@ -26,6 +26,7 @@ import (
"hamlog/internal/antgenius"
"hamlog/internal/applog"
"hamlog/internal/audio"
"hamlog/internal/autocall"
"hamlog/internal/award"
"hamlog/internal/awardref"
"hamlog/internal/backup"
@@ -54,6 +55,7 @@ import (
"hamlog/internal/powergenius"
"hamlog/internal/profile"
"hamlog/internal/pskr"
"hamlog/internal/pskrtgt"
"hamlog/internal/psu"
"hamlog/internal/qslcard"
"hamlog/internal/qso"
@@ -65,6 +67,7 @@ import (
"hamlog/internal/rotator/pst"
"hamlog/internal/rotator/spid"
"hamlog/internal/rotgenius"
"hamlog/internal/sat"
"hamlog/internal/scp"
"hamlog/internal/settings"
"hamlog/internal/solar"
@@ -100,6 +103,7 @@ const (
keyStationCallsign = "station.callsign"
keyStationOperator = "station.operator"
keyWatchlistContestPattern = "watchlist.contest_pattern" // auto-add contest calls containing this
keyWatchlistContestCalls = "watchlist.contest_calls" // …and these, named one by one
keyStationMyGrid = "station.my_grid"
keyStationCountry = "station.my_country"
keyStationSOTA = "station.my_sota_ref"
@@ -154,6 +158,7 @@ const (
// the rule since is that nothing a backend does may be steered by another
// backend's setting.
keyCATYaesuLowLines = "cat.yaesu.low_dtr_rts" // deassert DTR/RTS on connect
keyCATYaesuRTTYUSB = "cat.yaesu.rtty_usb" // set RTTY on USB rather than the older LSB
keyCATKenwoodLowLines = "cat.kenwood.low_dtr_rts" // deassert DTR/RTS on connect
keyCATKenwoodDataMode = "cat.kenwood.data_mode" // data modes → "usb" | "data" (MD6) | "keep"
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
@@ -195,6 +200,7 @@ const (
keyAudioQSOPlayGain = "audio.qso_play_gain" // QSO-recording playback level %
keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr"
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"
keyAudioFromGain = "audio.from_gain" // From Radio (RX) mix level, percent
keyAudioMicGain = "audio.mic_gain" // mic mix level, percent
@@ -507,6 +513,10 @@ type CATSettings struct {
// interfaces that read either line as PTT. Off by default: lowering them
// stops some USB-serial interfaces transmitting at all.
YaesuLowLines bool `json:"yaesu_low_lines"`
// YaesuRTTYUSB: ADIF says "RTTY" and Yaesu has both sidebands, so the rig
// cannot be driven from the logged mode alone. Off = RTTY-L, the older
// convention.
YaesuRTTYUSB bool `json:"yaesu_rtty_usb"`
KenwoodLowLines bool `json:"kenwood_low_lines"`
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
@@ -749,10 +759,51 @@ type App struct {
// is consulted once per message.
chaseBandsMu sync.RWMutex
chaseBands map[string]bool
// The Chase new panel's OWN band filter, as the set switched off. Read per
// message like the list above, so an atomic rather than a settings lookup.
chaseNewBandsOff atomic.Value // map[string]bool
// pskr is the PSK Reporter MQTT feed, up only while the opening watch is on.
// 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.
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
// acFed is when the last decode of the open period ARRIVED, wall clock. The
// period's own timestamp cannot answer "has it gone quiet" — it is the start
// of the slot, thirteen seconds before its decodes exist.
acFed 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
// acJudged is the last period key each receiver has been judged on, and
// acDirty says decodes have arrived for it since. Together they let a period
// be judged AGAIN when a straggler turns up, with the whole period in hand.
acJudged map[string]string
acDirty map[string]bool
// What the decodes panel is SHOWING, and whether it is publishing at all.
// The panel owns the filters; this is its answer, not a second copy of them.
acVisible map[string]bool
acVisibleOn bool
// Self-spot throttle: when and on what frequency we last announced ourselves.
// Held in memory only — a restart legitimately re-announces the station.
selfSpotMu sync.Mutex
@@ -768,9 +819,17 @@ type App struct {
// WSJT-X decode highlighting (message 13) — see app_wsjt_highlight.go.
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
wsjtHLSent map[string]string
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
udp *udp.Manager
udpRepo *udp.Repo
@@ -826,6 +885,23 @@ type App struct {
alertStore *alerts.Store // DX-cluster spot alert rules (global JSON)
// udpFocus arbitrates the entry field between several decoders running at
// once — see app_udp_focus.go.
udpFocus udpFocus
// Satellites. The elements (where the birds are) and the frequency plan
// (what to do with the radio) are held apart because they come from
// different places and change for different reasons — a feed every few
// days, an operator's correction when a transponder is switched.
satMu sync.Mutex
satStore *sat.Store // orbital elements, by satellite name
satBirds *sat.Birds // uplink/downlink plan
satFetch *sat.Fetcher // element feeds + the on-disk cache
// satTrack is the live tracker: the goroutine that walks the radio through a
// pass. nil when nothing is being tracked.
satTrackMu sync.Mutex
satTrack *satTracker
cwMu sync.Mutex // guards the CW decoder lifecycle
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
@@ -929,6 +1005,7 @@ type App struct {
opLon float64
opSet bool
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
// send when the frequency or mode actually changes.
@@ -978,6 +1055,7 @@ func (a *App) refreshOperatorGrid() {
return
}
a.opCall = strings.ToUpper(strings.TrimSpace(p.Callsign))
a.opGrid = strings.ToUpper(strings.TrimSpace(p.MyGrid))
lat, lon, ok := gridToLatLon(p.MyGrid)
if !ok {
return
@@ -1133,6 +1211,7 @@ func (a *App) startup(ctx context.Context) {
audio.AlertSink = func(format string, args ...any) { a.toast(fmt.Sprintf(format, args...)) }
extsvc.LogSink = applog.Printf // log raw QRZ (and other) service responses for diagnosis
lookup.LogSink = applog.Printf // which call was queried, and why a portable lookup fell back
audio.Logf = applog.Printf // why the Listening device stayed silent, above all
db.LogSink = applog.Printf // which schema migrations ran, and how long they took
// Version and executable FIRST, before anything else can fail. Diagnosing a
// report means knowing which build produced the log, and that was previously
@@ -1486,7 +1565,9 @@ func (a *App) startup(ctx context.Context) {
a.pota = pota.New(func(format string, args ...any) { applog.Printf(format, args...) })
a.startWatchlistClubLog()
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.wsjtHLWorkedOn.Store(a.settingOr(keyWsjtHLWorked, "0") == "1")
go a.pota.Run(a.ctx)
// DX Cluster (multi-server): the spot callback enriches each spot
@@ -1580,6 +1661,10 @@ func (a *App) startup(ctx context.Context) {
a.alertStore = as
}
// Satellites: the cached elements and the frequency plan. Local files only —
// any element fetch it decides to make goes to the network on its own.
a.startSatellites()
// Ultrabeam antenna: connect in the background if enabled.
a.startUltrabeam()
// Antenna Genius switch: connect in the background if enabled.
@@ -1612,9 +1697,13 @@ func (a *App) startup(ctx context.Context) {
a.startGridCache()
a.chaseNew = newChaseNewStore()
a.refreshChaseNew()
a.refreshChaseNewBands()
// 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.
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.
a.backfillDistancesOnce()
@@ -1878,6 +1967,11 @@ func (a *App) shutdown(ctx context.Context) {
// logger has spent seconds tearing down ports.
applog.Printf("shutdown: closing autostart programs")
a.CloseAutostartPrograms()
a.stopPSKTarget() // one TLS socket to a public broker; nothing to flush
// Before CAT goes down: disarming satellite mode takes the radio out of full
// duplex and puts the transmitter back where the operator is listening, and
// that has to happen while the link is still up.
a.StopSatelliteTracking()
applog.Printf("shutdown: stopping UDP")
if a.udp != nil {
a.udp.StopAll()
@@ -2546,7 +2640,20 @@ func (a *App) switchLogbook(p profile.Profile) error {
_ = old.Close()
}
applog.Printf("logbook switched to %s for profile %q", backend, p.Name)
// EVERY worked/new verdict belonged to the OLD logbook.
//
// The award matrices above are only half of it: the in-memory worked-index
// the alert engine reads, the chase-new list, and the frontend's own cache of
// resolved spot verdicts all still hold the previous log's answers. An
// operator switching to an empty profile and back found every decode on the
// screen badged NEW — the empty log's verdicts, cached under the full one,
// and nothing short of a restart cleared them.
if a.chaseNew != nil {
a.chaseNew.clear()
}
go a.rebuildWorkedIndex()
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "spotstatus:invalidate")
wruntime.EventsEmit(a.ctx, "logbook:changed")
}
return nil
@@ -2991,6 +3098,10 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
}
}()
a.applyStationDefaults(&q, true)
// Before fillRXDefaults, which copies the transmit frequency into the receive
// one: on a satellite the two are on different bands, and letting that copy
// happen first would bury the downlink under the uplink.
a.applySatellite(&q)
fillRXDefaults(&q)
fillDistance(&q)
a.applyDXCCNumber(&q)
@@ -3025,6 +3136,9 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
}
if err == nil {
q.ID = id
// The contact is over, so no decoder holds the entry field any more: the
// next station may come from whichever one hears it first.
a.udpFocus.release("QSO logged")
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh (in-memory)
a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async)
// Snapshot the QSO recording SYNCHRONOUSLY, BEFORE announcing the log: the
@@ -3079,7 +3193,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
// a few lines above and is not on the copy taken at insert time.
a.syncPublishAsync(syncfolder.OpAdd, id, nil)
if a.udp != nil {
rec := adif.SingleRecordADIF(qc)
rec := adif.ForwardRecordADIF(qc)
a.udp.EmitLoggedADIF(rec)
a.udp.EmitLoggedQSOWSJT(wsjtLoggedQSO(qc), rec)
a.udpTriggerQSOLogged(qc)
@@ -8241,7 +8355,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil {
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, keyCATDigiUSB)
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATYaesuRTTYUSB, 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 {
return CATSettings{}, err
}
@@ -8267,6 +8381,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
KenwoodLink: kenwoodLinkOr(m[keyCATKenwoodLink], m[keyCATKenwoodHost]),
KenwoodBaud: 9600,
YaesuLowLines: m[keyCATYaesuLowLines] == "1",
YaesuRTTYUSB: m[keyCATYaesuRTTYUSB] == "1",
KenwoodLowLines: m[keyCATKenwoodLowLines] == "1",
KenwoodDataMode: m[keyCATKenwoodDataMode],
IcomPort: m[keyCATIcomPort],
@@ -8400,7 +8515,11 @@ func (a *App) SaveCATSettings(s CATSettings) error {
if s.IcomBaud <= 0 {
s.IcomBaud = 115200
}
if s.IcomAddr <= 0 || s.IcomAddr > 0xFF {
// 0x00 IS an address. Rejecting it as "unset" is what sent an operator's
// custom-address rig straight back to the IC-7610's 0x98 every time the
// settings were saved — the model dropdown snapping back with it, since it
// reads the address rather than a stored model.
if s.IcomAddr < 0 || s.IcomAddr > 0xFF {
s.IcomAddr = 0x98
}
if s.PollMs < 50 || s.PollMs > 2000 {
@@ -8476,6 +8595,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATKenwoodLink: kenwoodLinkOr(s.KenwoodLink, s.KenwoodHost),
keyCATKenwoodBaud: strconv.Itoa(s.KenwoodBaud),
keyCATYaesuLowLines: b01(s.YaesuLowLines),
keyCATYaesuRTTYUSB: b01(s.YaesuRTTYUSB),
keyCATKenwoodLowLines: b01(s.KenwoodLowLines),
keyCATKenwoodDataMode: strings.ToLower(strings.TrimSpace(s.KenwoodDataMode)),
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
@@ -8529,6 +8649,10 @@ type AudioSettings struct {
PrerollSeconds int `json:"preroll_seconds"` // rolling pre-roll (default 8)
PTTMethod string `json:"ptt_method"` // "none" (VOX) | "rts" | "dtr"
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"
FromGain int `json:"from_gain"` // From Radio (RX) mix level %, default 100
MicGain int `json:"mic_gain"` // mic mix level %, default 100
@@ -8590,7 +8714,7 @@ func (a *App) GetAudioSettings() (AudioSettings, error) {
}
m, err := a.settings.GetMany(a.ctx,
keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice,
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat,
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioPTTData, keyAudioFormat,
keyAudioFromGain, keyAudioMicGain, keyAudioTXGain, keyAudioQSOPlayGain)
if err != nil {
return out, err
@@ -8602,6 +8726,7 @@ func (a *App) GetAudioSettings() (AudioSettings, error) {
out.PTTMethod = v
}
out.PTTPort = m[keyAudioPTTPort]
out.PTTData = m[keyAudioPTTData] == "1"
out.FromRadio = m[keyAudioFromRadio]
out.ToRadio = m[keyAudioToRadio]
out.RecordingDevice = m[keyAudioRecDevice]
@@ -8672,6 +8797,7 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
keyAudioPreroll: strconv.Itoa(s.PrerollSeconds),
keyAudioPTTMethod: pttMethod,
keyAudioPTTPort: strings.TrimSpace(s.PTTPort),
keyAudioPTTData: boolStr(s.PTTData),
keyAudioFormat: format,
keyAudioFromGain: strconv.Itoa(s.FromGain),
keyAudioMicGain: strconv.Itoa(s.MicGain),
@@ -10242,14 +10368,26 @@ func (a *App) applyClublogException(q *qso.QSO, force bool) bool {
}
e, ok := a.clublog.Resolve(q.Callsign, date)
if !ok {
// No exception COVERS this QSO's date. If the call nonetheless HAS a
// date-ranged exception (e.g. G1T = Scotland only from 2024-02-21), then
// 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 →
// England for a 2012 contact). Ordinary calls (no exception) are left to
// cty.dat.
if a.clublog.HasException(q.Callsign) {
// No exception covers this QSO's date, so the question becomes which
// country file knows the PREFIX better.
//
// ClubLog's prefix table is date-ranged and cty.dat's is not, and that
// is not a detail: cty.dat describes the world as it is TODAY. Niue
// moved to E6, so ZK2 went back to New Zealand there — and every ZK2
// 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 {
ctyExact := false
if m, mok := a.dxcc.Lookup(q.Callsign); mok {
ctyExact = m.Exact
}
if !ctyExact {
e, ok = pe, true
}
}
@@ -10599,7 +10737,7 @@ func (a *App) pttKey(cfg AudioSettings) error {
if a.cat == nil {
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)
return err
}
@@ -11712,14 +11850,45 @@ func (a *App) UploadQSOsManual(service string, ids []int64) error {
return fmt.Errorf("db not initialized")
}
svc := extsvc.Service(service)
if uploadColumnFor(service) == "" && svc != extsvc.ServiceHamlog && svc != extsvc.ServiceHamQTH {
// 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)
}
cfg := a.loadExternalServices()
// NOT CONFIGURED IS AN ANSWER, AND IT BELONGS HERE.
//
// The upload runs on its own goroutine and reports through the QSL Manager's
// console, which is not open when the command was given from a right-click in
// the QSO list: an upload to a service with no credentials looked exactly
// like one that worked. Refused synchronously instead, where the caller can
// show it.
if err := uploadConfigured(svc, cfg); err != nil {
return err
}
go a.runManualUpload(svc, ids, cfg)
return nil
}
// uploadConfigured reports whether a service can be uploaded to at all.
//
// The rules live in internal/extsvc, beside the uploaders that enforce them.
// Written here instead they drifted at once: Club Log was refused for a missing
// API key that nobody has ever set — OpsLog carries its own application key —
// and an operator whose live upload had worked for months was told his service
// was not configured.
func uploadConfigured(svc extsvc.Service, cfg extsvc.ExternalServices) error {
if err := extsvc.Configured(svc, cfg); err != nil {
return fmt.Errorf("%w (Settings → External services)", err)
}
return nil
}
func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.ExternalServices) {
emit := func(line string) {
if a.ctx != nil {
@@ -11936,7 +12105,7 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
}
flush()
}
} else if svc == extsvc.ServiceHamlog || svc == extsvc.ServiceHamQTH {
} 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.
@@ -11956,10 +12125,13 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
}
var res extsvc.UploadResult
var err error
if svc == extsvc.ServiceHamlog {
switch svc {
case extsvc.ServiceHamlog:
res, err = extsvc.UploadHamlog(ctx, nil, cfg.Hamlog, rec)
} else {
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)
@@ -12015,6 +12187,7 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
extsvc.ServiceQRZ: "QRZ.com", extsvc.ServiceClublog: "Club Log", extsvc.ServiceHRDLog: "HRDLog",
extsvc.ServiceLoTW: "LoTW", extsvc.ServiceEQSL: "eQSL",
extsvc.ServiceHamlog: "HAMLOG.online", extsvc.ServiceHamQTH: "HamQTH",
extsvc.ServiceCloudlog: "Cloudlog / Wavelog",
}[svc]
if label == "" {
label = string(svc)
@@ -14215,7 +14388,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
// path — otherwise a QSO logged FROM WSJT-X/JTDX/MSHV was never re-emitted
// to the outbound ADIF listeners (Log4OM, N1MM, gridtracker…).
if a.udp != nil {
rec := adif.SingleRecordADIF(qc)
rec := adif.ForwardRecordADIF(qc)
a.udp.EmitLoggedADIF(rec)
a.udp.EmitLoggedQSOWSJT(wsjtLoggedQSO(qc), rec)
a.udpTriggerQSOLogged(qc)
@@ -14298,6 +14471,17 @@ func (a *App) consumeUDPEvents() {
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{
"msg": ev.TxMessage,
"transmitting": ev.Transmitting,
@@ -14355,10 +14539,20 @@ func (a *App) consumeUDPEvents() {
// false on a Replay's resent history — shown, never auto-answered.
"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
// app_wsjt_highlight.go). After the emit: painting must never delay
// 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
// panadapter when the option is on; green + SNR comment, auto-expiring
// after the configured duration. De-duped per call in the Flex backend.
@@ -14429,6 +14623,13 @@ func (a *App) consumeUDPEvents() {
"adif": ev.LoggedADIF,
})
case ev.ClearCall:
// Only from the program the entry field belongs to. An idle decoder
// alongside the one being worked clears its own DX Call for reasons
// of its own, and that must not empty a field somebody else filled.
if !a.udpFocus.holds(ev.ProgramID) {
break
}
a.udpFocus.release("DX Call cleared")
applog.Printf("udp: emit udp:clear_call (DX Call cleared in the digital app)\n")
wruntime.EventsEmit(a.ctx, "udp:clear_call", map[string]any{
"service": string(ev.Service),
@@ -14453,6 +14654,12 @@ func (a *App) consumeUDPEvents() {
wruntime.EventsEmit(a.ctx, "udp:remote_call", ev.DXCall)
}
case ev.DXCall != "":
// With two or three decoders running, the one announcing a station
// takes the entry field and keeps it until it lets go. See udpFocus.
if !a.udpFocus.claim(ev.ProgramID) {
a.udpFocus.noteIgnored(ev.ProgramID, ev.DXCall)
break
}
applog.Printf("udp: emit udp:dx_call %q (mode=%s freq=%d)\n", ev.DXCall, ev.Mode, ev.FreqHz)
wruntime.EventsEmit(a.ctx, "udp:dx_call", map[string]any{
"call": ev.DXCall,
@@ -15137,6 +15344,53 @@ func (a *App) IcomSetPower(on bool) error {
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.
func (a *App) IcomSetScope(on bool) error {
if a.cat == nil {
@@ -16022,6 +16276,14 @@ func (a *App) reloadCAT() {
go a.startQSORecorderIfEnabled()
}
a.reloadCATShare(s)
// Preferences that the LINK does not depend on, pushed to the rig that is
// already connected. They are deliberately absent from catLinkSig — none of
// them is worth dropping the CAT link (and with it WSJT-X's rigctl session)
// to apply — so without this they waited for the next launch, and the
// operator ticking "RTTY on USB" watched the rig go on choosing LSB.
if s.Enabled && s.Backend == "yaesu" && a.cat != nil {
_ = a.cat.YaesuDo(func(y cat.YaesuController) error { y.SetRTTYUpper(s.YaesuRTTYUSB); return nil })
}
// Nothing about the link changed → leave it connected. See catLinkSig.
if sig := catLinkSig(s); sig == a.catSig {
applog.Printf("cat: settings saved, link unchanged — staying connected")
@@ -16082,6 +16344,7 @@ func (a *App) reloadCAT() {
// (a rig file that hides the VFO, a Freq property meaning A on one model
// and B on another); talking to the radio directly removes it.
yz := cat.NewYaesu(s.YaesuPort, s.YaesuBaud, s.DigitalDefault)
yz.SetRTTYUpper(s.YaesuRTTYUSB)
yz.SetLowerLines(s.YaesuLowLines)
a.cat.Start(yz)
case "kenwood", "elecraft":
@@ -16547,6 +16810,20 @@ func (a *App) reloadAfterProfileSwitch() {
// per-profile settings too — rebuilt so a switch doesn't keep showing the
// previous profile's view of who is worth chasing.
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.
//
// Off, exactly as at launch: a switch changes the callsign, the log and
// usually the antenna, and none of that is a state in which a transmitter
// should resume by itself because this profile's stored setting said on.
// Re-applied rather than restarted: the loop is one goroutine for the life
// of the process.
a.disarmAutoCall("profile switch")
a.autoCallEngine().Reset()
// Same reasoning for the satellite tracker: it transmits, and the new profile
// may be a different station on a different antenna.
a.StopSatelliteTracking()
}
// DuplicateProfile clones an existing profile under newName. Useful when
@@ -18101,7 +18378,22 @@ func motorBandAllowed(bands []string, hz int64) bool {
// refreshed every couple of seconds — so a fresh command opens a short grace
// window during which TX stays inhibited even before the poll confirms motion,
// closing the gap at the start of a move.
func (a *App) noteMotorMoveCommanded() { a.motorMoveCmdNs.Store(time.Now().UnixNano()) }
// noteMotorMoveCommanded records — and ANNOUNCES — that the antenna has just
// been told to move.
//
// The transmit gag already started here; the screen did not. It waited for a
// status poll to come round and say the elements were travelling, so the
// indicator turned orange a second or more after the button was pressed, and on
// an automatic follow (band change with tracking on) the operator had no warning
// at all until the poll landed. The event lets the interface show it at the
// instant the order goes out, which is also when the transmitter is gagged —
// one moment, one appearance.
func (a *App) noteMotorMoveCommanded() {
a.motorMoveCmdNs.Store(time.Now().UnixNano())
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "motorant:move")
}
}
// applyMotorInhibit sets or clears the Flex transmit inhibit, but only when the
// active CAT backend IS a FlexRadio — the inhibit is a Flex-API feature; with any
@@ -18130,7 +18422,13 @@ func (a *App) applyMotorInhibit(on bool) {
// OR within a grace window after a commanded move — and always releases the
// inhibit when it stops (so a settings change / shutdown never leaves TX blocked).
func (a *App) motorTXInhibitLoop(c motorAntenna, bands []string, stop <-chan struct{}) {
const grace = 3 * time.Second // cover the poll latency at the very start of a move
// Just long enough to bridge ONE fast poll at the start of a move, when the
// antenna has been told to go but has not yet said it is going. It was three
// seconds, from when the status poll ran every two: a move that took a second
// left the transmitter gagged for two more, with the elements already in
// place. The antenna is now polled four times a second while it travels, so
// this only has to cover the command itself.
const grace = 900 * time.Millisecond
ticker := time.NewTicker(300 * time.Millisecond)
defer ticker.Stop()
defer a.applyMotorInhibit(false) // never leave TX blocked when the loop ends
@@ -20428,6 +20726,15 @@ func (a *App) SaveChaseSettings(cs ChaseSettings) error {
a.clusterStatusMu.Lock()
a.clusterStatusIdx = nil
a.clusterStatusMu.Unlock()
// Chase new judges each decode AS IT ARRIVES and keeps only what was new at
// that moment, so its list is a set of verdicts reached under the old rule —
// and nothing in it would ever be re-judged. Switching to "new only" left
// rows listed that are merely unconfirmed; switching the other way could not
// bring back what had already been refused. Emptied, it refills from the
// live feed within a period or two, under the rule now in force.
if a.chaseNew != nil {
a.chaseNew.clear()
}
// The frontend caches resolved verdicts and only asks about unknown keys —
// without this it kept judging half the screen under the OLD rules until a
// restart, which read as the new mode simply not working.
+1
View File
@@ -20,6 +20,7 @@ var deniedCallHashes = map[string]struct{}{
"0741c9e394b42f43191899105553b47155ddc3026da12b5360701f9c181ff123": {},
"ab4926a3a0ab76d41b5b99cd3ad0683584970c341c29427c1dfa4b3c329ce415": {},
"9d17c9c213a6cc89c12d7520bcf21c86a0cf43d17e82e74f33f3a77cb865d28a": {},
"94ee059335e587e501cc4bf90613e0814f00a7b08bc7c648fd865a2af6a22cc2": {},
}
// callDenied reports whether a callsign is on deniedCallHashes. The call is
+980
View File
@@ -0,0 +1,980 @@
package main
// Satellites — the wiring around internal/sat.
//
// The package knows orbits and frequency plans; this file is what the station
// knows: where the antenna is, which birds the operator cares about, and where
// the elements are kept. Nothing here talks to a radio or a rotator yet — that
// is the next layer, and it is deliberately built on top of GetSatelliteTuning
// rather than beside it, so what the operator reads on screen and what gets
// sent to the rig can never disagree.
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"hamlog/internal/applog"
"hamlog/internal/sat"
)
const (
keySatFavorites = "sat.favorites" // comma-separated satellite names
keySatMinEl = "sat.min_el" // degrees; passes lower than this are not listed
keySatWindowH = "sat.window_h" // hours of pass predictions
keySatAutoTLE = "sat.auto_tle" // fetch elements at startup when the set is stale
keySatGrid = "sat.grid" // locator override ("" = the station's own)
keySatAltM = "sat.alt_m" // antenna height above sea level, metres
// The az/el rotator. Its own settings rather than the HF rotator's: a
// satellite station's elevation rotator is a different machine on a
// different port, and an operator who has both must not have to choose.
keySatRotOn = "sat.rot_enabled"
// Which program drives the mast: OpsLog itself over EasyComm, or PstRotator,
// which many stations already run in front of their controller. Its own port
// key because it is a different program on a different port from an EasyComm
// controller, and an operator who tries both must not lose the first setting
// to the second.
keySatRotType = "sat.rot_type" // "easycomm" | "pstrotator"
keySatRotPstPort = "sat.rot_pst_port" // PstRotator's UDP command port
keySatRotTransport = "sat.rot_transport" // "serial" | "tcp"
keySatRotHost = "sat.rot_host"
keySatRotPort = "sat.rot_port"
keySatRotCOM = "sat.rot_com"
keySatRotBaud = "sat.rot_baud"
keySatRotMaxAz = "sat.rot_max_az" // 360 or 450
keySatRotMinEl = "sat.rot_min_el" // don't drive the rotator below this elevation
keySatRotStep = "sat.rot_step" // degrees of change worth a command
keySatRotPark = "sat.rot_park" // park at az 0 / el 0 when tracking stops
)
// customTLEName holds elements the operator pasted in by hand.
//
// Kept apart from the feed cache because the cache is REPLACED wholesale on
// every refresh: a freshly launched satellite, whose elements arrive on a
// mailing list days before any feed carries it, would be wiped by the first
// automatic update — which is precisely the week everybody wants to hear it.
const customTLEName = "satellites.custom.tle"
// SatSettings is the station's side of satellite work.
type SatSettings struct {
Favorites []string `json:"favorites"`
MinEl int `json:"min_el"`
WindowH int `json:"window_h"`
AutoTLE bool `json:"auto_tle"`
Grid string `json:"grid"`
AltM int `json:"alt_m"`
// The az/el rotator.
RotOn bool `json:"rot_on"`
RotType string `json:"rot_type"`
RotPstPort int `json:"rot_pst_port"`
RotTransport string `json:"rot_transport"`
RotHost string `json:"rot_host"`
RotPort int `json:"rot_port"`
RotCOM string `json:"rot_com"`
RotBaud int `json:"rot_baud"`
RotMaxAz int `json:"rot_max_az"`
RotMinEl int `json:"rot_min_el"`
RotStep int `json:"rot_step"`
RotPark bool `json:"rot_park"`
}
// SatTransponder is one path through a satellite, as the UI needs it.
type SatTransponder struct {
Label string `json:"label"`
Mode string `json:"mode"`
DownLo int64 `json:"down_lo"`
DownHi int64 `json:"down_hi"`
UpLo int64 `json:"up_lo"`
UpHi int64 `json:"up_hi"`
Inverting bool `json:"inverting"`
CTCSS float64 `json:"ctcss"`
Linear bool `json:"linear"`
}
// SatBird is a satellite as the operator sees it: the frequency plan joined to
// whatever elements we hold for it.
type SatBird struct {
Name string `json:"name"`
NORAD int `json:"norad"`
Geostationary bool `json:"geostationary"`
Favorite bool `json:"favorite"`
HasElements bool `json:"has_elements"`
ElementName string `json:"element_name"` // the feed's spelling, when it differs
EpochAgeH float64 `json:"epoch_age_h"`
Transponders []SatTransponder `json:"transponders"`
}
// SatTLEInfo describes the element set the station is working from.
type SatTLEInfo struct {
Count int `json:"count"`
FetchedAt time.Time `json:"fetched_at"`
AgeH float64 `json:"age_h"`
Stale bool `json:"stale"`
Custom int `json:"custom"` // hand-entered satellites among the count
}
// SatTuning is where to listen and where to transmit, right now.
//
// Both the nominal and the corrected pair are returned on purpose: the nominal
// is what goes in the log (see the ADIF note on SAT_NAME) and the corrected is
// what goes to the radio. An operator staring at a display that shows only one
// of them cannot tell a Doppler correction from a mistuned transponder.
type SatTuning struct {
Name string `json:"name"`
Transponder string `json:"transponder"`
Mode string `json:"mode"`
NominalDown int64 `json:"nominal_down"`
NominalUp int64 `json:"nominal_up"`
DownHz int64 `json:"down_hz"`
UpHz int64 `json:"up_hz"`
CTCSS float64 `json:"ctcss"`
Inverting bool `json:"inverting"`
Az float64 `json:"az"`
El float64 `json:"el"`
RangeKm float64 `json:"range_km"`
RangeRate float64 `json:"range_rate"`
Visible bool `json:"visible"`
At time.Time `json:"at"`
// Where the satellite is over the earth. Carried with the tuning because
// they are read together and change together — the panel would otherwise ask
// twice a second for two halves of one instant.
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
AltKm float64 `json:"alt_km"`
Footprint float64 `json:"footprint_km"`
}
// SatPassInfo is the pass in progress, or the next one.
//
// Separate from the tuning and polled far more slowly: predicting a pass steps
// the orbit thirty seconds at a time across hours, which is not something to do
// once a second for a countdown a browser can run itself from two timestamps.
type SatPassInfo struct {
Name string `json:"name"`
HasPass bool `json:"has_pass"`
// InPass distinguishes "it is up now" from "it rises at". The pass in
// progress is reported whatever its maximum elevation: an operator watching
// a satellite go over does not want it hidden because it fell below the
// threshold that filters the TABLE of what is worth waiting for.
InPass bool `json:"in_pass"`
AOS time.Time `json:"aos"`
LOS time.Time `json:"los"`
AOSAz float64 `json:"aos_az"`
LOSAz float64 `json:"los_az"`
MaxEl float64 `json:"max_el"`
MaxElAz float64 `json:"max_el_az"`
MaxElAt time.Time `json:"max_el_at"`
Duration float64 `json:"duration_s"`
}
// ── Lifecycle ───────────────────────────────────────────────────────────────
// startSatellites loads what is already on disk and, only if asked, goes to the
// network.
//
// Cache first and synchronously: it is one file and a few hundred parses, and
// it means the satellite tab is populated the instant it is opened, on a shack
// PC with no internet as much as on one with. The fetch is the slow, optional
// half and never blocks a launch.
func (a *App) startSatellites() {
dir := a.dataDir
birds, err := sat.LoadBirds(dir)
if err != nil {
// LoadBirds always returns a usable list; the error says the operator's
// own file was refused, which they need to be told about.
applog.Printf("sat: %v", err)
}
store := sat.NewStore()
fetch := sat.NewFetcher(dir)
fetch.Logf = applog.Printf
if els, at, err := fetch.LoadCache(); err == nil {
store.Replace(els, at)
applog.Printf("sat: %d satellites from the cached element set (%s old)", len(els), time.Since(at).Round(time.Minute))
} else if !os.IsNotExist(err) {
applog.Printf("sat: the cached element set could not be read: %v", err)
}
a.satMu.Lock()
a.satStore, a.satBirds, a.satFetch = store, birds, fetch
a.satMu.Unlock()
a.loadCustomElements()
set := a.satSettings()
if set.AutoTLE && a.satTLEInfo().Stale {
go func() {
if _, err := a.RefreshSatelliteTLE(); err != nil {
applog.Printf("sat: %v", err)
}
}()
}
}
// satParts hands back the three pieces under the lock, building them if the
// startup path has not run — a binding called from a tab the operator opened
// before startup finished must not answer "no satellites".
func (a *App) satParts() (*sat.Store, *sat.Birds, *sat.Fetcher) {
a.satMu.Lock()
if a.satStore == nil {
a.satMu.Unlock()
a.startSatellites()
a.satMu.Lock()
}
s, b, f := a.satStore, a.satBirds, a.satFetch
a.satMu.Unlock()
return s, b, f
}
// ── Settings ────────────────────────────────────────────────────────────────
func (a *App) satSettings() SatSettings {
// The rotator defaults are the common case, not a blank form: EasyComm over
// a serial port at 9600, a 360° machine, and a five-degree step — which on a
// beam with any gain at all is well inside the beamwidth and keeps a pass
// from being a command a second.
out := SatSettings{
MinEl: 10, WindowH: 24, AutoTLE: true,
RotType: satRotEasycomm, RotPstPort: 12000,
RotTransport: "serial", RotPort: 4533, RotBaud: 9600,
RotMaxAz: 360, RotMinEl: 0, RotStep: 5,
}
if a.settings == nil {
return out
}
m, err := a.settings.GetMany(a.ctx,
keySatFavorites, keySatMinEl, keySatWindowH, keySatAutoTLE, keySatGrid, keySatAltM,
keySatRotOn, keySatRotType, keySatRotPstPort, keySatRotTransport, keySatRotHost, keySatRotPort, keySatRotCOM,
keySatRotBaud, keySatRotMaxAz, keySatRotMinEl, keySatRotStep, keySatRotPark)
if err != nil {
return out
}
out.RotOn = m[keySatRotOn] == "1"
if ty := m[keySatRotType]; ty == satRotPst || ty == satRotEasycomm {
out.RotType = ty
}
if v, err := strconv.Atoi(m[keySatRotPstPort]); err == nil && v > 0 && v <= 65535 {
out.RotPstPort = v
}
if tr := m[keySatRotTransport]; tr == "tcp" || tr == "serial" {
out.RotTransport = tr
}
out.RotHost = strings.TrimSpace(m[keySatRotHost])
if v, err := strconv.Atoi(m[keySatRotPort]); err == nil && v > 0 && v <= 65535 {
out.RotPort = v
}
out.RotCOM = strings.TrimSpace(m[keySatRotCOM])
if v, err := strconv.Atoi(m[keySatRotBaud]); err == nil && v >= 1200 && v <= 115200 {
out.RotBaud = v
}
if v, err := strconv.Atoi(m[keySatRotMaxAz]); err == nil && v == 450 {
out.RotMaxAz = 450
}
if v, err := strconv.Atoi(m[keySatRotMinEl]); err == nil && v >= -10 && v <= 30 {
out.RotMinEl = v
}
if v, err := strconv.Atoi(m[keySatRotStep]); err == nil && v >= 1 && v <= 30 {
out.RotStep = v
}
out.RotPark = m[keySatRotPark] == "1"
for _, n := range strings.Split(m[keySatFavorites], ",") {
if n = strings.TrimSpace(n); n != "" {
out.Favorites = append(out.Favorites, n)
}
}
if v, err := strconv.Atoi(m[keySatMinEl]); err == nil && v >= 0 && v <= 60 {
out.MinEl = v
}
if v, err := strconv.Atoi(m[keySatWindowH]); err == nil && v >= 1 && v <= 168 {
out.WindowH = v
}
if v, ok := m[keySatAutoTLE]; ok && v != "" {
out.AutoTLE = v == "1"
}
out.Grid = strings.TrimSpace(m[keySatGrid])
if v, err := strconv.Atoi(m[keySatAltM]); err == nil && v > -500 && v < 9000 {
out.AltM = v
}
return out
}
// GetSatSettings returns the satellite preferences.
func (a *App) GetSatSettings() (SatSettings, error) {
if a.settings == nil {
return SatSettings{}, fmt.Errorf("db not initialized")
}
return a.satSettings(), nil
}
// SaveSatSettings stores them.
func (a *App) SaveSatSettings(s SatSettings) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if s.MinEl < 0 || s.MinEl > 60 {
s.MinEl = 10
}
if s.WindowH < 1 || s.WindowH > 168 {
s.WindowH = 24
}
var favs []string
seen := map[string]bool{}
for _, n := range s.Favorites {
n = strings.TrimSpace(n)
if n == "" || seen[strings.ToUpper(n)] {
continue
}
seen[strings.ToUpper(n)] = true
favs = append(favs, n)
}
if s.RotType != satRotPst {
s.RotType = satRotEasycomm
}
if s.RotPstPort <= 0 || s.RotPstPort > 65535 {
s.RotPstPort = 12000
}
if s.RotTransport != "tcp" {
s.RotTransport = "serial"
}
if s.RotMaxAz != 450 {
s.RotMaxAz = 360
}
if s.RotStep < 1 || s.RotStep > 30 {
s.RotStep = 5
}
if s.RotPort <= 0 || s.RotPort > 65535 {
s.RotPort = 4533
}
if s.RotBaud < 1200 || s.RotBaud > 115200 {
s.RotBaud = 9600
}
for k, v := range map[string]string{
keySatFavorites: strings.Join(favs, ","),
keySatMinEl: strconv.Itoa(s.MinEl),
keySatWindowH: strconv.Itoa(s.WindowH),
keySatAutoTLE: boolStr(s.AutoTLE),
keySatGrid: strings.ToUpper(strings.TrimSpace(s.Grid)),
keySatAltM: strconv.Itoa(s.AltM),
keySatRotOn: boolStr(s.RotOn),
keySatRotType: s.RotType,
keySatRotPstPort: strconv.Itoa(s.RotPstPort),
keySatRotTransport: s.RotTransport,
keySatRotHost: strings.TrimSpace(s.RotHost),
keySatRotPort: strconv.Itoa(s.RotPort),
keySatRotCOM: strings.TrimSpace(s.RotCOM),
keySatRotBaud: strconv.Itoa(s.RotBaud),
keySatRotMaxAz: strconv.Itoa(s.RotMaxAz),
keySatRotMinEl: strconv.Itoa(s.RotMinEl),
keySatRotStep: strconv.Itoa(s.RotStep),
keySatRotPark: boolStr(s.RotPark),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
}
}
return nil
}
// satObserver is the ground station: the satellite grid if the operator set one,
// otherwise the station's own.
//
// A locator, not a latitude and longitude: it is what every logbook already
// holds, and its six-character precision is a couple of kilometres — three
// hundredths of a degree of azimuth at the worst possible geometry, far below
// any rotator's backlash.
func (a *App) satObserver() (sat.Observer, error) {
set := a.satSettings()
grid := set.Grid
if grid == "" && a.profiles != nil {
// The station locator lives on the ACTIVE PROFILE, not in a settings key.
// keyStationMyGrid is a legacy key that EnsureDefault migrated into the
// profile years ago and nothing writes any more — reading it told an
// operator with a perfectly good locator on screen that he had not set
// one.
if p, err := a.profiles.Active(a.ctx); err == nil {
grid = p.MyGrid
}
}
grid = strings.TrimSpace(grid)
lat, lon, ok := gridToLatLon(grid)
if !ok {
return sat.Observer{}, fmt.Errorf("your locator is not set — Settings ▸ Station, or Settings ▸ Satellites for a different site")
}
return sat.Observer{Lat: lat, Lon: lon, AltM: float64(set.AltM)}, nil
}
// GetSatelliteObserver reports the ground station the predictions are made for,
// so the UI can show it — and say plainly when there is none.
func (a *App) GetSatelliteObserver() (map[string]any, error) {
obs, err := a.satObserver()
if err != nil {
return nil, err
}
return map[string]any{"lat": obs.Lat, "lon": obs.Lon, "alt_m": obs.AltM}, nil
}
// ── Elements ────────────────────────────────────────────────────────────────
func (a *App) customTLEPath() string { return filepath.Join(a.dataDir, customTLEName) }
// loadCustomElements merges the hand-entered file over the feed's set. Last
// writer wins in the store, so an operator's own elements for a satellite
// override the feed's — which is the whole point of having typed them.
func (a *App) loadCustomElements() int {
f, err := os.Open(a.customTLEPath())
if err != nil {
return 0
}
defer f.Close()
els, skipped, err := sat.ParseTLESet(f)
if err != nil {
applog.Printf("sat: %s could not be read: %v", customTLEName, err)
return 0
}
if skipped > 0 {
applog.Printf("sat: %d entries in %s were unusable", skipped, customTLEName)
}
store, _, _ := a.satParts()
for _, e := range els {
store.Put(e)
}
return len(els)
}
func (a *App) customElementCount() int {
f, err := os.Open(a.customTLEPath())
if err != nil {
return 0
}
defer f.Close()
els, _, err := sat.ParseTLESet(f)
if err != nil {
return 0
}
return len(els)
}
func (a *App) satTLEInfo() SatTLEInfo {
store, _, _ := a.satParts()
at := store.FetchedAt()
info := SatTLEInfo{Count: store.Len(), FetchedAt: at, Custom: a.customElementCount()}
if !at.IsZero() {
info.AgeH = time.Since(at).Hours()
info.Stale = time.Since(at) > sat.StaleAfter
} else {
info.Stale = true // nothing on disk yet: the operator has to be told to fetch
}
return info
}
// GetSatelliteTLEInfo describes the element set, including how old it is.
func (a *App) GetSatelliteTLEInfo() SatTLEInfo { return a.satTLEInfo() }
// RefreshSatelliteTLE downloads a fresh element set.
func (a *App) RefreshSatelliteTLE() (SatTLEInfo, error) {
store, _, fetch := a.satParts()
ctx := a.ctx
if ctx == nil {
ctx = context.Background()
}
els, err := fetch.Fetch(ctx)
if err != nil {
return a.satTLEInfo(), err
}
store.Replace(els, time.Now())
a.loadCustomElements() // the operator's own elements go back on top
info := a.satTLEInfo()
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "sat:tle", info)
}
return info, nil
}
// AddSatelliteElements takes elements pasted in by hand — two or three lines
// per satellite — and keeps them across feed refreshes.
func (a *App) AddSatelliteElements(text string) (int, error) {
els, skipped, err := sat.ParseTLESet(strings.NewReader(text))
if err != nil {
return 0, fmt.Errorf("those are not usable elements: %w", err)
}
existing := map[string]bool{}
var keep []sat.Element
if f, ferr := os.Open(a.customTLEPath()); ferr == nil {
old, _, _ := sat.ParseTLESet(f)
f.Close()
keep = old
}
// The new set wins for a satellite already in the file: pasting elements is
// how an operator UPDATES a bird the feeds do not carry.
for _, e := range els {
existing[strings.ToUpper(e.Name)] = true
}
var out []sat.Element
for _, e := range keep {
if !existing[strings.ToUpper(e.Name)] {
out = append(out, e)
}
}
out = append(out, els...)
var b strings.Builder
for _, e := range out {
if e.Name != "" {
b.WriteString(e.Name + "\n")
}
b.WriteString(e.Line1 + "\n" + e.Line2 + "\n")
}
if err := os.WriteFile(a.customTLEPath(), []byte(b.String()), 0o644); err != nil {
return 0, err
}
n := a.loadCustomElements()
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "sat:tle", a.satTLEInfo())
}
if skipped > 0 {
applog.Printf("sat: %d pasted entries were unusable and were skipped", skipped)
}
return n, nil
}
// ── The list ────────────────────────────────────────────────────────────────
// GetSatelliteBirds joins the frequency plan to the elements.
//
// Both halves are listed, not just their intersection: a bird with elements and
// no plan is one the operator can still track and add frequencies for, and a
// bird with a plan and no elements is the one visible symptom of an element set
// that is too old or too narrow — silently dropping either turns a fixable
// configuration problem into a satellite that "does not exist".
func (a *App) GetSatelliteBirds() []SatBird {
store, birds, _ := a.satParts()
set := a.satSettings()
fav := map[string]bool{}
for _, n := range set.Favorites {
fav[strings.ToUpper(n)] = true
}
out := make([]SatBird, 0, birds.Len())
planned := map[string]bool{}
for _, b := range birds.All() {
item := SatBird{Name: b.Name, Geostationary: b.Geostationary, Favorite: fav[strings.ToUpper(b.Name)]}
for _, t := range b.Transponders {
item.Transponders = append(item.Transponders, SatTransponder{
Label: t.Label, Mode: t.Mode,
DownLo: t.DownLo, DownHi: t.DownHi, UpLo: t.UpLo, UpHi: t.UpHi,
Inverting: t.Inverting, CTCSS: t.CTCSS, Linear: t.Linear(),
})
}
if e, ok := satElement(store, b); ok {
item.HasElements = true
item.NORAD = e.NORAD
item.EpochAgeH = e.Age().Hours()
planned[strings.ToUpper(e.Name)] = true
if !strings.EqualFold(e.Name, b.Name) {
item.ElementName = e.Name
}
}
out = append(out, item)
}
// The rest of the element set, so nothing the station holds is invisible.
for _, n := range store.Names() {
if planned[strings.ToUpper(n)] {
continue
}
e, ok := store.Get(n)
if !ok {
continue
}
out = append(out, SatBird{
Name: e.Name, NORAD: e.NORAD, HasElements: true,
EpochAgeH: e.Age().Hours(), Favorite: fav[strings.ToUpper(e.Name)],
})
}
sort.Slice(out, func(i, j int) bool {
// Favourites first, then the birds we can actually use, then by name.
if out[i].Favorite != out[j].Favorite {
return out[i].Favorite
}
iu := len(out[i].Transponders) > 0 && out[i].HasElements
ju := len(out[j].Transponders) > 0 && out[j].HasElements
if iu != ju {
return iu
}
return out[i].Name < out[j].Name
})
return out
}
// GetSatelliteNames is the list behind the entry form's SAT_NAME box.
//
// One list, not two. It used to be a text box in Settings ▸ Lists that an
// operator typed their birds into by hand, which then had nothing to do with
// the satellites the tracker knew — the same station kept two lists of the same
// satellites and they drifted apart. This is the followed set (or every
// satellite with a frequency plan, when none is followed), plus anything the
// old hand-kept list still holds so nobody's typing is thrown away.
//
// SAT_NAME is compared character for character by the awards and by LoTW, so
// offering the spelling already used beats inventing a new one every pass.
func (a *App) GetSatelliteNames() []string {
seen := map[string]bool{}
var out []string
add := func(n string) {
n = strings.ToUpper(strings.TrimSpace(n))
if n == "" || seen[n] {
return
}
seen[n] = true
out = append(out, n)
}
for _, n := range a.satNames(nil) {
add(n)
}
// The legacy list. Read, never written: the panel that edited it is gone,
// and what it holds is somebody's past work.
if a.settings != nil {
if raw, _ := a.settings.Get(a.ctx, keyListsSatellites); raw != "" {
var legacy []string
if json.Unmarshal([]byte(raw), &legacy) == nil {
for _, n := range legacy {
add(n)
}
}
}
}
sort.Strings(out)
return out
}
// satElement finds the elements for a bird, trying its aliases.
//
// The feed's name and the operator's name for the same satellite are routinely
// different, and the element set is keyed by the feed's.
func satElement(store *sat.Store, b sat.Bird) (sat.Element, bool) {
if e, ok := store.Get(b.Name); ok {
return e, true
}
for _, alias := range b.Aliases {
if e, ok := store.Get(alias); ok {
return e, true
}
}
// Last resort: scan, matching on letters and digits alone — that is how
// "RADFXSAT (FOX-1B)" and "AO-91" meet.
for _, n := range store.Names() {
if b.Matches(n) {
if e, ok := store.Get(n); ok {
return e, true
}
}
}
return sat.Element{}, false
}
// ── Tracking ────────────────────────────────────────────────────────────────
// satNames resolves the names the UI asked for, falling back to the favourites
// and then to every planned bird we hold elements for.
func (a *App) satNames(names []string) []string {
if len(names) > 0 {
return names
}
set := a.satSettings()
if len(set.Favorites) > 0 {
return set.Favorites
}
var out []string
for _, b := range a.GetSatelliteBirds() {
if b.HasElements && len(b.Transponders) > 0 {
out = append(out, b.Name)
}
}
return out
}
// satResolve maps an operator-facing name onto the element set's own spelling.
func (a *App) satResolve(name string) (string, bool) {
store, birds, _ := a.satParts()
if _, ok := store.Get(name); ok {
return name, true
}
if b, ok := birds.Find(name); ok {
if e, ok2 := satElement(store, b); ok2 {
return e.Name, true
}
}
return "", false
}
// GetSatellitePositions is where the given satellites are right now — the map's
// question, and the rotator's.
func (a *App) GetSatellitePositions(names []string) ([]sat.Position, error) {
obs, err := a.satObserver()
if err != nil {
return nil, err
}
store, _, _ := a.satParts()
now := time.Now().UTC()
var out []sat.Position
for _, n := range a.satNames(names) {
real, ok := a.satResolve(n)
if !ok {
continue
}
p, err := store.Track(real, obs, now)
if err != nil {
continue
}
p.Name = n // answer in the operator's vocabulary, not the feed's
out = append(out, p)
}
return out, nil
}
// GetSatelliteGroundTrack is the path a satellite draws over the ground, for
// the map: one point a minute, forward from now.
func (a *App) GetSatelliteGroundTrack(name string, minutes int) ([]sat.Position, error) {
if minutes <= 0 || minutes > 360 {
minutes = 120
}
obs, err := a.satObserver()
if err != nil {
return nil, err
}
real, ok := a.satResolve(name)
if !ok {
return nil, fmt.Errorf("%s is not in the element set", name)
}
store, _, _ := a.satParts()
now := time.Now().UTC()
out := make([]sat.Position, 0, minutes+1)
for i := 0; i <= minutes; i++ {
p, err := store.Track(real, obs, now.Add(time.Duration(i)*time.Minute))
if err != nil {
return nil, err
}
p.Name = name
out = append(out, p)
}
return out, nil
}
// GetSatellitePasses lists what is coming, in time order.
func (a *App) GetSatellitePasses(names []string, hours int) ([]sat.Pass, error) {
obs, err := a.satObserver()
if err != nil {
return nil, err
}
set := a.satSettings()
if hours <= 0 {
hours = set.WindowH
}
if hours > 168 {
hours = 168
}
store, _, _ := a.satParts()
want := a.satNames(names)
// The store is keyed by the feed's names; remember which operator name each
// answer belongs to so the table reads the way the operator thinks.
real := make([]string, 0, len(want))
back := map[string]string{}
for _, n := range want {
r, ok := a.satResolve(n)
if !ok {
continue
}
real = append(real, r)
back[r] = n
}
passes := store.NextPasses(real, obs, time.Now().UTC(), time.Duration(hours)*time.Hour, set.MinEl)
for i := range passes {
if n, ok := back[passes[i].Name]; ok {
passes[i].Name = n
}
}
return passes, nil
}
// SatSkyPoint is one moment of a pass as the antenna sees it.
type SatSkyPoint struct {
At time.Time `json:"at"`
Az float64 `json:"az"`
El float64 `json:"el"`
}
// GetSatelliteSkyTrack is the pass drawn as a path across the sky.
//
// The map answers "where is it over the earth"; this answers "where do I look",
// which on a pass is the question that matters. An operator reading a polar
// plot knows in one glance whether the bird comes over the top or clips the
// horizon behind the house — something no amount of azimuth and elevation
// digits conveys.
func (a *App) GetSatelliteSkyTrack(name string, points int) ([]SatSkyPoint, error) {
if points < 8 || points > 400 {
points = 120
}
p, err := a.GetSatelliteNextPass(name)
if err != nil {
return nil, err
}
if !p.HasPass {
return nil, nil
}
obs, err := a.satObserver()
if err != nil {
return nil, err
}
real, ok := a.satResolve(name)
if !ok {
return nil, fmt.Errorf("%s is not in the element set", name)
}
store, _, _ := a.satParts()
span := p.LOS.Sub(p.AOS)
if span <= 0 {
return nil, nil
}
out := make([]SatSkyPoint, 0, points+1)
for i := 0; i <= points; i++ {
at := p.AOS.Add(time.Duration(float64(span) * float64(i) / float64(points)))
pos, err := store.Track(real, obs, at)
if err != nil {
return nil, err
}
// Below the horizon at the very ends, by a fraction of a degree, because
// the pass boundaries come from a coarser search than this sampling. A
// negative elevation would draw the track outside the horizon circle.
if pos.El < 0 {
pos.El = 0
}
out = append(out, SatSkyPoint{At: at.UTC(), Az: pos.Az, El: pos.El})
}
return out, nil
}
// GetSatelliteNextPass is the pass in progress, or the next one to come.
//
// The one question that decides whether an operator sits down at the radio, and
// the reason a satellite tab is worth having at all: how long have I got, and
// how high does it get.
func (a *App) GetSatelliteNextPass(name string) (SatPassInfo, error) {
out := SatPassInfo{Name: name}
obs, err := a.satObserver()
if err != nil {
return out, err
}
real, ok := a.satResolve(name)
if !ok {
return out, fmt.Errorf("%s is not in the element set", name)
}
store, _, _ := a.satParts()
now := time.Now().UTC()
// From a little before now: a pass that started two minutes ago is the one
// the operator is in, and asking from this instant would skip it and report
// the next orbit instead — an hour and a half away, while the satellite is
// overhead.
from := now.Add(-30 * time.Minute)
// Elevation zero, not the operator's minimum. That threshold filters the
// table of passes worth waiting for; it must not hide the pass they are
// actually working.
passes, err := store.Passes(real, obs, from, now.Add(26*time.Hour), 0)
if err != nil {
return out, err
}
for _, p := range passes {
if p.LOS.Before(now) {
continue // already over
}
out.HasPass = true
out.InPass = !p.AOS.After(now)
out.AOS, out.LOS = p.AOS, p.LOS
out.AOSAz, out.LOSAz = p.AOSAz, p.LOSAz
out.MaxEl, out.MaxElAz, out.MaxElAt = p.MaxEl, p.MaxElAz, p.MaxElAt
out.Duration = p.Duration
return out, nil
}
return out, nil
}
// GetSatelliteTuning is the working answer: where to listen, where to transmit,
// and where the bird is, for one satellite and one transponder.
//
// downHz is where the operator has tuned inside the passband, in NOMINAL terms
// — 0 means the middle of it. Keeping the operator's frequency nominal, and
// applying Doppler only on the way out to the radio, is what makes a linear
// pass workable: the station being answered stays put on the dial while both
// radios chase the shift.
func (a *App) GetSatelliteTuning(name string, transponder int, downHz int64) (SatTuning, error) {
_, birds, _ := a.satParts()
b, ok := birds.Find(name)
if !ok {
return SatTuning{}, fmt.Errorf("%s has no frequency plan — add one in %s", name, sat.BirdsName)
}
if transponder < 0 || transponder >= len(b.Transponders) {
transponder = 0
}
if len(b.Transponders) == 0 {
return SatTuning{}, fmt.Errorf("%s has no transponder listed", b.Name)
}
t := b.Transponders[transponder]
if downHz <= 0 {
// While the tracker is running it owns the nominal frequency — it moves
// as the operator tunes. Reading the centre of the passband instead would
// show a frequency nobody is on the moment they hunt for a station.
downHz = a.satTrackedNominal(b.Name, transponder)
}
if downHz <= 0 {
downHz = t.Centre()
}
out := SatTuning{
Name: b.Name,
Transponder: t.Label,
Mode: t.Mode,
NominalDown: downHz,
NominalUp: t.UplinkFor(downHz),
CTCSS: t.CTCSS,
Inverting: t.Inverting,
At: time.Now().UTC(),
}
// Geostationary: it does not move, so there is nothing to correct and no
// look angle worth recomputing every second. QO-100 is simply pointed at
// once and left alone.
if b.Geostationary {
out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
out.Visible = true
return out, nil
}
obs, err := a.satObserver()
if err != nil {
// No locator: the frequencies are still worth having, uncorrected.
out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
return out, nil
}
real, ok := a.satResolve(name)
if !ok {
out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
return out, fmt.Errorf("%s is not in the element set — refresh the elements", b.Name)
}
store, _, _ := a.satParts()
p, err := store.Track(real, obs, out.At)
if err != nil {
out.DownHz, out.UpHz = out.NominalDown, out.NominalUp
return out, err
}
sh := sat.Doppler(p, out.NominalDown, out.NominalUp)
out.DownHz, out.UpHz = sh.DownHz, sh.UpHz
out.Az, out.El, out.RangeKm, out.RangeRate = p.Az, p.El, p.RangeKm, p.RangeRate
out.Lat, out.Lon, out.AltKm, out.Footprint = p.Lat, p.Lon, p.AltKm, p.Footprint
out.Visible = p.Visible()
return out, nil
}
+140
View File
@@ -0,0 +1,140 @@
package main
// The two ways a satellite station points its antenna.
//
// Some operators drive their az/el rotator directly — EasyComm II, what
// SatPC32 and Gpredict speak. Others already run PstRotator, which sits between
// them and a dozen different controllers and handles az AND el; for those,
// OpsLog talking to the controller itself would be a second program fighting
// PstRotator over the same cable.
//
// So both, behind one small interface, chosen in Settings. Neither is more
// "correct" than the other: the right one is whichever the station already has
// working.
import (
"fmt"
"math"
"strings"
"sync"
"hamlog/internal/rotator/easycomm"
"hamlog/internal/rotator/pst"
)
// satRotator is what the tracker needs of an antenna: point it, ask where it
// is, and let go of it at the end of the pass.
type satRotator interface {
Point(az, el float64) error
// Heading reports where the antenna is. live is false when the answer is
// the last commanded position rather than a reading — a stuck rotator must
// not be able to hide behind an order it never carried out.
Heading() (az, el float64, live bool, err error)
Close()
}
// The rotator kinds, as stored.
const (
satRotEasycomm = "easycomm"
satRotPst = "pstrotator"
)
// newSatRotator builds the configured controller.
func newSatRotator(s SatSettings) (satRotator, error) {
switch s.RotType {
case satRotPst:
if strings.TrimSpace(s.RotHost) == "" && s.RotPort <= 0 {
return nil, fmt.Errorf("no address for PstRotator")
}
return &pstSatRotator{c: pst.New(s.RotHost, s.RotPstPort), maxAz: s.RotMaxAz}, nil
default:
if s.RotTransport == "tcp" {
if strings.TrimSpace(s.RotHost) == "" {
return nil, fmt.Errorf("no address for the rotator")
}
return easycomm.New(s.RotHost, s.RotPort, s.RotMaxAz), nil
}
if strings.TrimSpace(s.RotCOM) == "" {
return nil, fmt.Errorf("no COM port for the rotator")
}
return easycomm.NewSerial(s.RotCOM, s.RotBaud, s.RotMaxAz), nil
}
}
// pstSatRotator points the antenna through PstRotator.
//
// PstRotator takes whole degrees and does its own overlap handling for a 450°
// rotator — it knows which controller is on the other end, and OpsLog does not.
// So the azimuth is sent plainly, and the 450° logic that EasyComm needs is
// deliberately NOT applied here: two programs each deciding to go the long way
// round is how an antenna ends up unwinding in the middle of a pass.
type pstSatRotator struct {
c *pst.Client
maxAz int
mu sync.Mutex
// lastAz/lastEl are what was commanded, for the display when PstRotator
// does not answer a position query — which is the usual case for the many
// setups whose controller reports nothing back to it either.
lastAz, lastEl float64
commanded bool
azSilent bool // the azimuth query went unanswered; stop asking
elSilent bool // likewise for elevation, and far more common
}
func (p *pstSatRotator) Point(az, el float64) error {
a := math.Mod(az, 360)
if a < 0 {
a += 360
}
if el < 0 {
el = 0
}
if el > 180 {
el = 180
}
if err := p.c.GoTo(int(math.Round(a)), true, int(math.Round(el))); err != nil {
return err
}
p.mu.Lock()
p.lastAz, p.lastEl, p.commanded = a, el, true
p.mu.Unlock()
return nil
}
func (p *pstSatRotator) Heading() (float64, float64, bool, error) {
p.mu.Lock()
azSilent, elSilent, la, le, commanded := p.azSilent, p.elSilent, p.lastAz, p.lastEl, p.commanded
p.mu.Unlock()
az, el, live := la, le, false
if !azSilent {
if v, _, err := p.c.Heading(); err == nil {
az, live = float64(v), true
} else {
// One silence is enough. Each query binds a socket and waits a second
// and a half; repeating that every few seconds for a setup that will
// never answer is a stall per poll for nothing.
p.mu.Lock()
p.azSilent = true
p.mu.Unlock()
}
}
if !elSilent {
if v, _, err := p.c.Elevation(); err == nil {
el = float64(v)
} else {
p.mu.Lock()
p.elSilent = true
p.mu.Unlock()
}
}
if !live && !commanded {
return 0, 0, false, fmt.Errorf("PstRotator does not report the antenna position")
}
return az, el, live, nil
}
// Close: nothing to release. Every PstRotator command is one datagram, and the
// socket lives for the length of a single write.
func (p *pstSatRotator) Close() {}
+622
View File
@@ -0,0 +1,622 @@
package main
// Doppler tracking — walking the radio through a pass.
//
// The hard part of satellite tuning is not the arithmetic, it is deciding who
// owns the dial. A tracker that simply forces both frequencies fights the
// operator every time they turn the knob to follow a station across a linear
// transponder, and one that never touches the receiver leaves them chasing a
// signal that slides 9 kHz across a 70 cm pass.
//
// So: the operator owns the receiver, and the tracker follows them. Every tick
// it asks the radio where the receiver actually is. If that is where the tracker
// put it, nothing has changed and it keeps correcting from the same NOMINAL
// frequency. If it has moved further than a dial-turn's tolerance, the operator
// has chosen a new station: the tracker converts what they landed on back into a
// nominal frequency and carries on from there. The transmitter is derived from
// the nominal and never argued with — which is exactly the division of labour on
// a linear bird, where the operator listens and the radio does the sums.
import (
"fmt"
"math"
"strings"
"sync"
"time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"hamlog/internal/applog"
"hamlog/internal/cat"
"hamlog/internal/qso"
"hamlog/internal/sat"
)
// satTickEvery is how often the radio is re-pointed. One second: at the middle
// of a 70 cm pass the downlink moves about 60 Hz a second, which is audible on
// SSB within two or three of them and inaudible within one.
const satTickEvery = time.Second
// satDialTolerance is how far the receiver may differ from where the tracker put
// it before that difference is read as the operator tuning.
//
// 200 Hz is comfortably more than the rounding and the round-trip lag between
// setting a frequency and reading it back, and comfortably less than the
// smallest deliberate move anybody makes hunting a station on a transponder.
const satDialTolerance = 200
// satLightKmS is the speed of light in km/s, for turning a heard frequency back
// into a nominal one. The same constant internal/sat corrects with.
const satLightKmS = 299792.458
type satTracker struct {
name string
tp int
mu sync.Mutex
// nominalDown is where the operator is, expressed as if the satellite were
// standing still. Everything else is derived from it, and it is the only
// thing a dial movement changes.
nominalDown int64
lastDown int64 // what was last sent to the radio
lastUp int64
status SatTrackStatus
fails int
// The az/el rotator, built once at the start of the pass so a serial port is
// opened once rather than on every command. nil when none is configured.
rot satRotator
rotStep float64
rotMinE float64
rotPark bool
rotAz float64 // last commanded, so a step smaller than the beamwidth costs nothing
rotEl float64
rotSent bool
rotReadAt time.Time // when the controller was last asked where it is
stop chan struct{}
done chan struct{}
}
// SatTrackStatus is what the tracker is doing, for the panel.
type SatTrackStatus struct {
On bool `json:"on"`
Name string `json:"name"`
Transponder string `json:"transponder"`
Mode string `json:"mode"`
NominalDown int64 `json:"nominal_down"`
NominalUp int64 `json:"nominal_up"`
DownHz int64 `json:"down_hz"`
UpHz int64 `json:"up_hz"`
Az float64 `json:"az"`
El float64 `json:"el"`
Visible bool `json:"visible"`
Radio string `json:"radio"` // what the rig is doing: "sat", "downlink-only", ""
Error string `json:"error"`
// Where the antenna is. RotLive distinguishes a reading from the controller
// from the last position it was TOLD to go to — a stuck rotator must not be
// able to hide behind a command it never carried out.
RotOn bool `json:"rot_on"`
RotAz float64 `json:"rot_az"`
RotEl float64 `json:"rot_el"`
RotLive bool `json:"rot_live"`
}
// StartSatelliteTracking arms the radio and starts following the satellite.
func (a *App) StartSatelliteTracking(name string, transponder int) error {
if a.cat == nil {
return fmt.Errorf("CAT is not running")
}
_, birds, _ := a.satParts()
b, ok := birds.Find(name)
if !ok || len(b.Transponders) == 0 {
return fmt.Errorf("%s has no frequency plan to tune to", name)
}
if transponder < 0 || transponder >= len(b.Transponders) {
transponder = 0
}
a.StopSatelliteTracking()
t := &satTracker{
name: b.Name,
tp: transponder,
nominalDown: b.Transponders[transponder].Centre(),
stop: make(chan struct{}),
done: make(chan struct{}),
}
t.status = SatTrackStatus{On: true, Name: b.Name, Transponder: b.Transponders[transponder].Label, Mode: b.Transponders[transponder].Mode}
// The rotator, if there is one. A geostationary bird is pointed at once and
// left alone, so it gets one command rather than a loop.
set := a.satSettings()
if set.RotOn {
r, rerr := newSatRotator(set)
if rerr != nil {
applog.Printf("sat: no rotator: %v", rerr)
t.status.Error = rerr.Error()
} else {
t.rot = r
t.rotStep, t.rotMinE, t.rotPark = float64(set.RotStep), float64(set.RotMinEl), set.RotPark
}
}
// Arm the radio for the pair. A rig that cannot hold one is NOT a failure:
// it can still be tuned to the downlink, which is most of a receive-heavy
// pass, and saying so beats refusing to track at all.
radio := "downlink-only"
if a.cat.SatCapable() {
if err := a.cat.SatDo(func(st cat.SatTuner) error { return st.SetSatellite(true) }); err != nil {
applog.Printf("sat: could not arm satellite mode: %v", err)
t.status.Error = err.Error()
} else {
radio = "sat"
}
}
t.status.Radio = radio
a.satTrackMu.Lock()
a.satTrack = t
a.satTrackMu.Unlock()
go a.satTrackLoop(t)
applog.Printf("sat: tracking %s (%s), radio %s", t.name, t.status.Transponder, radio)
return nil
}
// StopSatelliteTracking hands the radio back.
func (a *App) StopSatelliteTracking() {
a.satTrackMu.Lock()
t := a.satTrack
a.satTrack = nil
a.satTrackMu.Unlock()
if t == nil {
return
}
close(t.stop)
<-t.done
if a.cat != nil && a.cat.SatCapable() {
if err := a.cat.SatDo(func(st cat.SatTuner) error { return st.SetSatellite(false) }); err != nil {
applog.Printf("sat: could not disarm satellite mode: %v", err)
}
}
applog.Printf("sat: tracking stopped (%s)", t.name)
a.emitSatTrack(SatTrackStatus{})
}
// TestSatelliteRotator opens the configured controller and asks it where it is.
//
// The one question worth asking before a pass: is this port the rotator, and
// does it talk back? A controller that accepts commands silently is a normal,
// working one — so that answer is a success with a caveat, not a failure.
func (a *App) TestSatelliteRotator() (string, error) {
set := a.satSettings()
if !set.RotOn {
return "", fmt.Errorf("the satellite rotator is switched off")
}
c, err := newSatRotator(set)
if err != nil {
return "", err
}
defer c.Close()
az, el, live, err := c.Heading()
if err != nil {
return "", err
}
if !live {
return "The controller accepted the command but does not report its position — normal for many controllers. It will still be driven.", nil
}
return fmt.Sprintf("The rotator is at %.1f° azimuth, %.1f° elevation.", az, el), nil
}
// GetSatelliteTracking reports what the tracker is doing.
func (a *App) GetSatelliteTracking() SatTrackStatus {
a.satTrackMu.Lock()
t := a.satTrack
a.satTrackMu.Unlock()
if t == nil {
return SatTrackStatus{}
}
t.mu.Lock()
defer t.mu.Unlock()
return t.status
}
// satTrackedNominal is the nominal downlink the tracker is currently working
// from, or 0 when it is not tracking this satellite and transponder.
func (a *App) satTrackedNominal(name string, transponder int) int64 {
a.satTrackMu.Lock()
t := a.satTrack
a.satTrackMu.Unlock()
if t == nil || t.tp != transponder || !strings.EqualFold(t.name, name) {
return 0
}
t.mu.Lock()
defer t.mu.Unlock()
return t.nominalDown
}
func (a *App) emitSatTrack(s SatTrackStatus) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "sat:track", s)
}
}
func (a *App) satTrackLoop(t *satTracker) {
defer close(t.done)
defer t.releaseRotator()
tick := time.NewTicker(satTickEvery)
defer tick.Stop()
for {
a.satTrackStep(t)
select {
case <-t.stop:
return
case <-tick.C:
}
}
}
// satTrackStep is one pass of the loop: read the dial, work out the pair, send
// what changed.
func (a *App) satTrackStep(t *satTracker) {
_, birds, _ := a.satParts()
b, ok := birds.Find(t.name)
if !ok || t.tp >= len(b.Transponders) {
return
}
tp := b.Transponders[t.tp]
t.mu.Lock()
nominal := t.nominalDown
lastDown, lastUp := t.lastDown, t.lastUp
t.mu.Unlock()
// Where the satellite is, and how fast it is running away. A geostationary
// bird is neither: its range rate is zero, so the zero position below gives
// a zero shift without a special case, and asking for a look angle we do not
// need would only fail on a station with no locator.
var pos sat.Position
visible := true
if !b.Geostationary {
obs, err := a.satObserver()
if err != nil {
t.setError(err.Error())
return
}
real, ok := a.satResolve(t.name)
if !ok {
t.setError(fmt.Sprintf("%s is not in the element set", t.name))
return
}
store, _, _ := a.satParts()
p, err := store.Track(real, obs, time.Now().UTC())
if err != nil {
t.setError(err.Error())
return
}
pos = p
visible = p.Visible()
}
// The fractional shift, positive when the satellite is approaching. Only the
// dial arithmetic below needs it as a number; the pair itself comes from
// sat.Doppler, so there is exactly one place where the sign of a correction
// is decided.
factor := -pos.RangeRate / satLightKmS
// Where did the operator leave the receiver? If it is not where the tracker
// put it, they have moved to another station and that is the new nominal.
if lastDown > 0 && tp.Linear() {
if actual, err := a.satReceiveHz(); err == nil && actual > 0 {
if abs64i(actual-lastDown) > satDialTolerance {
moved := satNominalFromDial(actual, factor)
if moved >= tp.DownLo && moved <= tp.DownHi {
nominal = moved
t.mu.Lock()
t.nominalDown = moved
t.mu.Unlock()
}
}
}
}
nomUp := tp.UplinkFor(nominal)
sh := sat.Doppler(pos, nominal, nomUp)
down, up := sh.DownHz, sh.UpHz
t.mu.Lock()
t.status = SatTrackStatus{
On: true, Name: b.Name, Transponder: tp.Label, Mode: tp.Mode,
NominalDown: nominal, NominalUp: nomUp,
DownHz: down, UpHz: up,
Az: pos.Az, El: pos.El, Visible: visible,
Radio: t.status.Radio, Error: t.status.Error,
}
t.mu.Unlock()
t.pointRotator(pos, b.Geostationary)
t.readRotator()
t.mu.Lock()
st := t.status
t.mu.Unlock()
a.emitSatTrack(st)
// Only send what has actually moved. The step is the smallest change worth a
// command: on SSB a listener hears twenty hertz, on an FM channel nothing
// under a couple of hundred matters at all.
step := int64(20)
if strings.EqualFold(tp.Mode, "FM") {
step = 200
}
if abs64i(down-lastDown) < step && abs64i(up-lastUp) < step {
return
}
mode := tp.Mode
if lastDown != 0 {
mode = "" // set once, at the start of the pass — see satMode/satSetMode
}
err := a.satTune(down, up, mode, mode)
t.mu.Lock()
if err == nil {
t.lastDown, t.lastUp, t.fails = down, up, 0
t.status.Error = ""
} else {
t.fails++
t.status.Error = err.Error()
}
fails := t.fails
t.mu.Unlock()
if err != nil && (fails == 1 || fails%30 == 0) {
// Once, then once every half minute: a radio that has gone away must be
// visible in the log without filling it.
applog.Printf("sat: tuning %s failed (%d in a row): %v", t.name, fails, err)
}
}
// satNominalFromDial turns a frequency the operator tuned to into the nominal
// one it corresponds to.
//
// The inverse of the downlink correction: what comes out of the transponder at
// nominal arrives at heard = nominal × (1 + f). Doing this is what lets the
// operator hunt across a linear passband without the tracker dragging them back
// — where they land becomes the new truth, and the uplink follows it.
func satNominalFromDial(heardHz int64, factor float64) int64 {
if heardHz <= 0 || factor <= -1 {
return heardHz
}
return int64(math.Round(float64(heardHz) / (1 + factor)))
}
// pointRotator keeps the antenna on the satellite.
//
// Below the configured elevation the rotator is left alone. Not because the
// numbers stop being right — they are right all the way round the orbit — but
// because a rotator that chases a satellite through the far side of the earth
// spends the whole night turning, and a mast is a mechanical thing with a
// finite number of turns in it.
func (t *satTracker) pointRotator(pos sat.Position, geostationary bool) {
if t.rot == nil {
return
}
if !geostationary && pos.El < t.rotMinE {
return
}
// A step below the beamwidth is a command for nothing. Compared against what
// was last COMMANDED rather than where the rotator says it is: a rotator in
// motion is always somewhere between the two, and comparing against that
// would order a fresh move on every tick of a slew.
az, el := pos.Az, pos.El
if geostationary {
// A satellite that does not move needs pointing once. Its own az/el were
// not computed (there is nothing to compute), so leave the rotator where
// the operator put it.
if t.rotSent {
return
}
}
if t.rotSent && math.Abs(az-t.rotAz) < t.rotStep && math.Abs(el-t.rotEl) < t.rotStep {
return
}
if err := t.rot.Point(az, el); err != nil {
t.setError(err.Error())
return
}
t.rotAz, t.rotEl, t.rotSent = az, el, true
}
// readRotator asks the controller where it actually is, for the display.
//
// Separate from the pointing, and it runs on every tick rather than only when a
// command was sent: watching the antenna crawl towards the bearing is how an
// operator sees a rotator that is slow, stalled, or turning the wrong way. A
// controller that does not answer says so once and is not asked again.
func (t *satTracker) readRotator() {
if t.rot == nil {
return
}
// Not on every tick. A PstRotator query binds a socket and waits up to a
// second and a half for an answer, and a held serial port still costs a
// round trip; three seconds is often enough to watch an antenna slew and
// rare enough not to sit in the way of the tuning.
if time.Since(t.rotReadAt) < 3*time.Second {
return
}
t.rotReadAt = time.Now()
az, el, live, err := t.rot.Heading()
t.mu.Lock()
defer t.mu.Unlock()
if err != nil {
t.status.RotOn = true
return
}
t.status.RotOn, t.status.RotAz, t.status.RotEl, t.status.RotLive = true, az, el, live
}
// releaseRotator hands the mast back at the end of a pass.
func (t *satTracker) releaseRotator() {
if t.rot == nil {
return
}
if t.rotPark && t.rotSent {
// Elevation down first and azimuth to north: a dish or a pair of yagis
// left pointing at the sky is what a gale takes away.
if err := t.rot.Point(0, 0); err != nil {
applog.Printf("sat: could not park the rotator: %v", err)
}
}
t.rot.Close()
t.rot = nil
}
func (t *satTracker) setError(msg string) {
t.mu.Lock()
t.status.Error = msg
t.mu.Unlock()
}
// satTune sends the pair to whichever radio is connected.
func (a *App) satTune(downHz, upHz int64, downMode, upMode string) error {
if a.cat == nil {
return fmt.Errorf("CAT is not running")
}
if a.cat.SatCapable() {
return a.cat.SatDo(func(st cat.SatTuner) error {
return st.TuneSatellite(downHz, upHz, downMode, upMode)
})
}
// No satellite pair on this backend: the downlink is what it can do, and the
// operator was told so when tracking started (Radio = "downlink-only").
if err := a.cat.SetFrequency(downHz); err != nil {
return err
}
if downMode != "" {
return a.cat.SetMode(downMode)
}
return nil
}
// satReceiveHz is where the receiver is, asked of the backend that knows.
func (a *App) satReceiveHz() (int64, error) {
if a.cat == nil {
return 0, fmt.Errorf("CAT is not running")
}
if a.cat.SatCapable() {
var hz int64
err := a.cat.SatDo(func(st cat.SatTuner) error {
v, e := st.SatReceiveHz()
hz = v
return e
})
return hz, err
}
st := a.cat.State()
if st.RxFreqHz > 0 {
return st.RxFreqHz, nil
}
return st.FreqHz, nil
}
func abs64i(v int64) int64 {
if v < 0 {
return -v
}
return v
}
// ── What goes in the log ────────────────────────────────────────────────────
// applySatellite stamps a QSO made through a satellite.
//
// The NOMINAL frequencies are logged, never the Doppler-corrected ones. Two
// stations working each other through a transponder read different numbers off
// their dials at the same instant — that is what Doppler means — and the only
// figure they can both agree on, and the only one that means anything to
// somebody reading the log later, is the transponder's own. LoTW matches on the
// band, so nothing is lost; a log full of 435.847 231 would simply be a record
// of where one radio happened to be.
func (a *App) applySatellite(q *qso.QSO) {
a.satTrackMu.Lock()
t := a.satTrack
a.satTrackMu.Unlock()
if t == nil {
return
}
t.mu.Lock()
name, down, up := t.status.Name, t.status.NominalDown, t.status.NominalUp
az, el := t.status.Az, t.status.El
t.mu.Unlock()
if name == "" || down <= 0 {
return
}
// Nothing the operator filled in is overwritten. A QSO edited by hand, or
// imported, or logged from a second radio while the tracker happened to be
// running, keeps what it was given.
if strings.TrimSpace(q.PropMode) == "" {
q.PropMode = "SAT"
}
if q.PropMode != "SAT" {
return // they said it was something else — meteor scatter, EME
}
if strings.TrimSpace(q.SatName) == "" {
q.SatName = name
}
if strings.TrimSpace(q.SatMode) == "" {
q.SatMode = satModeLetters(up, down)
}
// The transmit frequency is the uplink and the receive frequency the
// downlink — which is the one place a satellite QSO differs from every other
// kind, and the reason FREQ alone cannot describe one.
if up > 0 {
q.FreqHz = &up
if b := bandForHz(up); b != "" {
q.Band = b
}
}
d := down
q.FreqRXHz = &d
if b := bandForHz(down); b != "" {
q.BandRX = b
}
if q.AntAz == nil && (az != 0 || el != 0) {
v := az
q.AntAz = &v
}
if q.AntEl == nil && el != 0 {
v := el
q.AntEl = &v
}
}
// satModeLetters is the ADIF SAT_MODE: the uplink band's letter, then the
// downlink's — "U/V" for 435 up, 145 down. The letters are AMSAT's, and they
// are what every satellite operator writes on a QSL card.
func satModeLetters(upHz, downHz int64) string {
u, d := satBandLetter(upHz), satBandLetter(downHz)
if u == "" || d == "" {
return ""
}
return u + "/" + d
}
func satBandLetter(hz int64) string {
switch {
case hz <= 0:
return ""
case hz < 30_000_000:
return "A" // 10 m — mode A's downlink
case hz < 148_000_000:
return "V" // 2 m
case hz < 450_000_000:
return "U" // 70 cm
case hz < 1_300_000_000:
return "L" // 23 cm
case hz < 2_500_000_000:
return "S" // 13 cm
case hz < 6_000_000_000:
return "C" // 6 cm
case hz < 11_000_000_000:
return "X" // 3 cm
}
return "K" // 24 GHz and above
}
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"testing"
"hamlog/internal/sat"
)
// The dial arithmetic has to be the exact inverse of the correction, or every
// touch of the knob would nudge the nominal frequency a little further off and
// the uplink would walk across the passband over a pass.
func TestSatNominalFromDialRoundTrip(t *testing.T) {
// A range of range rates: hard approach, drifting, hard recession. ±8 km/s
// covers a low orbit overhead.
for _, rate := range []float64{-8, -3.2, -0.4, 0, 0.4, 3.2, 8} {
p := sat.Position{RangeRate: rate}
for _, nominal := range []int64{29_450_000, 145_900_000, 435_850_000, 10_489_675_000} {
sh := sat.Doppler(p, nominal, 0)
factor := -rate / satLightKmS
got := satNominalFromDial(sh.DownHz, factor)
if diff := got - nominal; diff > 1 || diff < -1 {
t.Errorf("rate %.1f km/s, %d Hz: heard %d, came back as %d (%+d)",
rate, nominal, sh.DownHz, got, diff)
}
}
}
}
// SAT_MODE is what goes on a QSL card, and the letters are the uplink's then
// the downlink's — the order operators write and the order ADIF wants.
func TestSatModeLetters(t *testing.T) {
for _, tc := range []struct {
name string
up, down int64
want string
}{
{"FO-29: 2 m up, 70 cm down", 145_950_000, 435_850_000, "V/U"},
{"AO-91: 70 cm up, 2 m down", 435_250_000, 145_960_000, "U/V"},
{"AO-7 mode A: 2 m up, 10 m down", 145_900_000, 29_450_000, "V/A"},
{"QO-100: 13 cm up, 3 cm down", 2_400_175_000, 10_489_675_000, "S/X"},
{"receive only", 0, 145_800_000, ""},
} {
if got := satModeLetters(tc.up, tc.down); got != tc.want {
t.Errorf("%s: got %q, wanted %q", tc.name, got, tc.want)
}
}
}
+108
View File
@@ -0,0 +1,108 @@
package main
// Which decoder the entry field belongs to, when several are running.
//
// A station running WSJT-X, JTDX and MSHV at once has three programs sending
// Status once a second each. Click a call in one of them and only that one has
// a DX Call; the other two are idle and say so. Both statements are true, and
// both arrive — so the entry field is filled by the program the operator is
// working and emptied by the two that are not, once a second, and the map
// zooms in and out with it.
//
// So the first program to announce a station is FOCUSED, and until it lets go
// the others cannot touch the entry field. That is the operator's own answer:
// "if I call on one program, keep that one's UDP for the duration of the QSO".
//
// Focus is released when the focused program clears its own DX Call, when it
// stops sending altogether (it was closed), or when a QSO is logged — never on
// a timer that could hand the field to another program mid-over.
import (
"strings"
"sync"
"time"
"hamlog/internal/applog"
)
// udpFocusIdle is how long a focused program may go silent before the focus is
// given up.
//
// Generous on purpose: a decoder sends Status every second, so anything above a
// few seconds means it has been closed or has lost its network. Thirty is long
// enough to survive a machine that stutters and short enough that a program
// closed mid-QSO does not lock the entry field for the rest of the evening.
const udpFocusIdle = 30 * time.Second
type udpFocus struct {
mu sync.Mutex
inst string
at time.Time
// told marks that the log already carries the line explaining why another
// program's callsign is being ignored. Once per focus, not once a second.
told map[string]bool
}
// claim records that inst is announcing a station, and reports whether inst is
// the program the entry field currently belongs to.
func (f *udpFocus) claim(inst string) bool {
if inst == "" {
return true // a sender with no id: nothing to arbitrate between
}
f.mu.Lock()
defer f.mu.Unlock()
if f.inst == "" || f.inst == inst || time.Since(f.at) > udpFocusIdle {
if f.inst != inst {
applog.Printf("udp: the entry field follows %s while it is calling", inst)
f.told = nil
}
f.inst, f.at = inst, time.Now()
return true
}
return false
}
// holds reports whether inst may act on the entry field, without claiming it.
// Used for the clear: a program that is not focused clearing its own DX Call
// says nothing about the QSO in progress somewhere else.
func (f *udpFocus) holds(inst string) bool {
if inst == "" {
return true
}
f.mu.Lock()
defer f.mu.Unlock()
if f.inst == "" || time.Since(f.at) > udpFocusIdle {
return true
}
return f.inst == inst
}
// release gives the field up — the focused program cleared its call, or a QSO
// was logged and the next station may come from anywhere.
func (f *udpFocus) release(why string) {
f.mu.Lock()
had := f.inst
f.inst, f.at, f.told = "", time.Time{}, nil
f.mu.Unlock()
if had != "" {
applog.Printf("udp: the entry field is free again (%s let go: %s)", had, why)
}
}
// noteIgnored logs, once per focused program, that another one's callsign was
// not applied. Without it the behaviour is invisible: an operator whose second
// decoder "stopped filling the call" has nothing to read.
func (f *udpFocus) noteIgnored(inst, call string) {
f.mu.Lock()
if f.told == nil {
f.told = map[string]bool{}
}
first := !f.told[inst]
f.told[inst] = true
holder := f.inst
f.mu.Unlock()
if first {
applog.Printf("udp: [%s] %q not applied — %s has the entry field while it is calling",
inst, strings.ToUpper(call), holder)
}
}
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"testing"
"time"
)
// The reported failure, in order: MSHV is called on, WSJT-X and JTDX sit idle
// beside it, and every one of their Status packets used to empty the entry
// field that MSHV had just filled — once a second, with the map zooming in and
// out to match.
func TestUdpFocusKeepsTheFieldWithTheCallingProgram(t *testing.T) {
var f udpFocus
if !f.claim("MSHV") {
t.Fatal("the first program to announce a station must take the field")
}
// The other two, announcing stations of their own, are refused.
if f.claim("WSJT-X") {
t.Error("WSJT-X took the field while MSHV was calling")
}
if f.claim("JTDX") {
t.Error("JTDX took the field while MSHV was calling")
}
// And their clears do not empty it — this is the half that caused the flicker.
if f.holds("WSJT-X") {
t.Error("an idle WSJT-X was allowed to clear MSHV's callsign")
}
if !f.holds("MSHV") {
t.Error("MSHV lost the right to clear its own callsign")
}
// MSHV moving to the next station keeps the field.
if !f.claim("MSHV") {
t.Error("the focused program must keep the field across stations")
}
}
// Letting go, three ways.
func TestUdpFocusRelease(t *testing.T) {
var f udpFocus
// The focused program clears its own call.
f.claim("MSHV")
f.release("DX Call cleared")
if !f.claim("WSJT-X") {
t.Error("after a release the next program should be able to take the field")
}
// A QSO is logged.
f.release("QSO logged")
if !f.claim("JTDX") {
t.Error("logging a QSO must free the field for whichever program hears the next station")
}
// The focused program is closed and stops sending. Its hold lapses rather
// than locking the entry field for the rest of the evening.
f.mu.Lock()
f.at = time.Now().Add(-udpFocusIdle - time.Second)
f.mu.Unlock()
if !f.claim("MSHV") {
t.Error("a silent program must not hold the field for ever")
}
}
// A sender with no program id — an ADIF relay, a remote "set call" — is not
// something to arbitrate between, and must never be locked out.
func TestUdpFocusIgnoresUnnamedSenders(t *testing.T) {
var f udpFocus
f.claim("MSHV")
if !f.claim("") {
t.Error("an unnamed sender was refused the entry field")
}
if !f.holds("") {
t.Error("an unnamed sender was refused a clear")
}
}
+69 -5
View File
@@ -42,6 +42,62 @@ func (a *App) SetWatchlistContestPattern(p string) {
a.setSettingGlobal(keyWatchlistContestPattern, p)
}
// The named contest callsigns: the other half of the auto-add, and the half the
// pattern cannot express.
//
// A special-event fleet is usually a common string in the callsign — WWA in
// TM29WWA, HB9WWA, F4WWA/P — and the pattern collects those on sight. But an
// entry list is not a naming convention: R7W can be part of the same event and
// share nothing with it, and no pattern will ever catch that station without
// catching half the band with it. So the two work together: the pattern for the
// family, this list for everybody else.
//
// Stored GLOBALLY, like the pattern and the list itself — an event is worth
// hunting whichever station profile is on.
func (a *App) GetWatchlistContestCalls() string {
return a.settingOr(keyWatchlistContestCalls, "")
}
// SetWatchlistContestCalls stores the list as typed and caches the parsed set.
//
// The RAW text is what is stored: the operator's line breaks and their order
// are how the list is read back a week later, and rewriting it into a
// normalised single line loses the only structure it has.
func (a *App) SetWatchlistContestCalls(list string) {
a.setSettingGlobal(keyWatchlistContestCalls, list)
a.watchCalls.Store(parseContestCalls(list))
applog.Printf("watchlist: %d named contest callsigns", len(parseContestCalls(list)))
}
// parseContestCalls splits the box into a set. One per line is what is asked
// for, and commas, semicolons and spaces are accepted too: a list pasted from
// an announcement arrives in whatever shape the announcement used.
func parseContestCalls(list string) map[string]struct{} {
out := map[string]struct{}{}
for _, tok := range strings.FieldsFunc(strings.ToUpper(list), func(r rune) bool {
return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
}) {
if tok = strings.TrimSpace(tok); tok != "" {
out[tok] = struct{}{}
}
}
return out
}
// isNamedContestCall answers the hot path from the cached set.
func (a *App) isNamedContestCall(call string) bool {
v := a.watchCalls.Load()
if v == nil {
return false
}
set, ok := v.(map[string]struct{})
if !ok {
return false
}
_, found := set[strings.ToUpper(strings.TrimSpace(call))]
return found
}
// WatchlistEntries returns the list for the tab.
func (a *App) WatchlistEntries() []watchlist.Entry {
if a.watchlist == nil {
@@ -210,12 +266,20 @@ func (a *App) watchSpot(dxCall, band, mode, country, comment string, freqHz int6
}
entry, notify, ok := a.watchlist.MarkSeen(dxCall)
if !ok {
// Not watched yet — the auto-contest pattern may claim it. Contains, not
// prefix: the event string sits anywhere in these calls (HB9WWA, F4WWA/P).
if p := a.GetWatchlistContestPattern(); p != "" &&
strings.Contains(strings.ToUpper(dxCall), p) {
// Not watched yet — the contest pattern or the named list may claim it.
// Contains, not prefix, for the pattern: the event string sits anywhere
// in those calls (HB9WWA, F4WWA/P). The named list is exact, and is what
// catches the entries whose callsign says nothing about the event.
p := a.GetWatchlistContestPattern()
byPattern := p != "" && strings.Contains(strings.ToUpper(dxCall), p)
byName := a.isNamedContestCall(dxCall)
if byPattern || byName {
if err := a.watchlist.Add(dxCall, true); err == nil {
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)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "watchlist:changed")
+176 -24
View File
@@ -7,6 +7,8 @@ package main
// the spot grid, so the two windows can never disagree.
import (
"fmt"
"strconv"
"strings"
"hamlog/internal/applog"
@@ -17,6 +19,14 @@ import (
const (
keyWsjtHighlight = "udp.wsjt.highlight"
keyWsjtFollowMode = "udp.wsjt.followmode" // spot clicks switch the decoder's mode
keyWsjtHLWorked = "udp.wsjt.highlight_worked"
// One key per verdict, holding "#RRGGBB". Only the BACKGROUND is stored: the
// text colour is computed from it, so a chosen colour can never come out
// unreadable in somebody else's window.
keyWsjtColWatchlist = "udp.wsjt.colour.watchlist"
keyWsjtColNewDXCC = "udp.wsjt.colour.new_dxcc"
keyWsjtColNewBand = "udp.wsjt.colour.new_band"
keyWsjtColWorked = "udp.wsjt.colour.worked"
)
// wsjtModes are the modes a Configure message can meaningfully ask for — the
@@ -52,16 +62,136 @@ func (a *App) ConfigureDecoderMode(mode string) {
a.udp.SendConfigureMode(mode)
}
// The palette. Fixed colours, not theme tokens — they are painted into another
// application's window, which has no idea what theme OpsLog wears.
// The DEFAULT palette. Fixed colours, not theme tokens — they are painted into
// another application's window, which has no idea what theme OpsLog wears, and
// the operator can change each of them (see WsjtHighlightColours).
var (
hlWatchlist = udp.RGB{R: 244, G: 114, B: 182} // the watchlist pink
hlNewDXCC = udp.RGB{R: 22, G: 130, B: 60} // green
hlNewBand = udp.RGB{R: 226, G: 122, B: 24} // orange
hlWhite = udp.RGB{R: 255, G: 255, B: 255}
hlBlack = udp.RGB{R: 20, G: 20, B: 20}
// Worked already, on this band and in this mode. Grey on purpose, and the
// only DIM colour of the four: the others say "look at this", and this one
// says the opposite — it has to recede, not compete with them.
hlWorked = udp.RGB{R: 75, G: 85, B: 99}
)
// WsjtHighlightColours is the operator's palette, one background per verdict.
type WsjtHighlightColours struct {
Watchlist string `json:"watchlist"`
NewDXCC string `json:"new_dxcc"`
NewBand string `json:"new_band"`
Worked string `json:"worked"`
}
// GetWsjtHighlightColours returns the palette in "#RRGGBB", defaults included.
func (a *App) GetWsjtHighlightColours() WsjtHighlightColours {
return WsjtHighlightColours{
Watchlist: a.settingOr(keyWsjtColWatchlist, hexOfRGB(hlWatchlist)),
NewDXCC: a.settingOr(keyWsjtColNewDXCC, hexOfRGB(hlNewDXCC)),
NewBand: a.settingOr(keyWsjtColNewBand, hexOfRGB(hlNewBand)),
Worked: a.settingOr(keyWsjtColWorked, hexOfRGB(hlWorked)),
}
}
// SetWsjtHighlightColours stores the palette and repaints.
//
// The repaint is the whole point of clearing: the de-duplication remembers what
// it has already told each decoder, so without this a callsign keeps yesterday's
// colour until it changes verdict — and the operator, having just picked a new
// one, sees nothing happen.
func (a *App) SetWsjtHighlightColours(c WsjtHighlightColours) {
set := func(key, v, def string) {
if _, ok := parseHexRGB(v); !ok {
v = def
}
a.setSetting(key, strings.ToUpper(strings.TrimSpace(v)))
}
set(keyWsjtColWatchlist, c.Watchlist, hexOfRGB(hlWatchlist))
set(keyWsjtColNewDXCC, c.NewDXCC, hexOfRGB(hlNewDXCC))
set(keyWsjtColNewBand, c.NewBand, hexOfRGB(hlNewBand))
set(keyWsjtColWorked, c.Worked, hexOfRGB(hlWorked))
a.clearWsjtHighlights()
applog.Printf("wsjt highlight: palette changed — repainting")
}
// colourFor reads one verdict's background and picks a legible foreground.
//
// The text colour is DERIVED, never stored: an operator choosing a dark blue
// would otherwise get black text on it in somebody else's window and conclude
// the feature is broken. Rec. 601 luma, the same rule a browser's contrast
// checker uses, with the threshold where black stops being readable.
func (a *App) colourFor(key, def string) (udp.RGB, udp.RGB) {
bg, ok := parseHexRGB(a.settingOr(key, def))
if !ok {
bg, _ = parseHexRGB(def)
}
luma := (299*int(bg.R) + 587*int(bg.G) + 114*int(bg.B)) / 1000
if luma < 140 {
return bg, hlWhite
}
return bg, hlBlack
}
func hexOfRGB(c udp.RGB) string { return fmt.Sprintf("#%02X%02X%02X", c.R, c.G, c.B) }
// parseHexRGB reads "#RRGGBB" (or "RRGGBB"). Anything else is refused rather
// than half-read: a colour that silently becomes black is worse than a default.
func parseHexRGB(s string) (udp.RGB, bool) {
s = strings.TrimPrefix(strings.TrimSpace(s), "#")
if len(s) != 6 {
return udp.RGB{}, false
}
v, err := strconv.ParseUint(s, 16, 32)
if err != nil {
return udp.RGB{}, false
}
return udp.RGB{R: byte(v >> 16), G: byte(v >> 8), B: byte(v)}, true
}
// GetWsjtHighlightWorked reports whether stations already worked on this band
// and mode are greyed out as well.
//
// Its own switch, and off by default. The other three verdicts pick out a
// handful of decodes in a period; this one can match most of them on a
// well-filled log, and a screen where nearly every line is coloured has stopped
// saying anything. It is worth having only for an operator who wants the dupes
// struck out rather than the catches picked out.
func (a *App) GetWsjtHighlightWorked() bool {
return a.settingOr(keyWsjtHLWorked, "0") == "1"
}
// SetWsjtHighlightWorked flips it, and repaints.
//
// Turning it OFF has to clear what it painted: those callsigns keep their grey
// in the decoder's window otherwise, and nothing would ever say another word
// about them — the de-duplication remembers that they were already told.
func (a *App) SetWsjtHighlightWorked(on bool) {
v := "0"
if on {
v = "1"
}
a.setSetting(keyWsjtHLWorked, v)
a.wsjtHLWorkedOn.Store(on)
a.clearWsjtHighlights()
applog.Printf("wsjt highlight: worked stations %v", on)
}
// clearWsjtHighlights wipes every instruction OpsLog installed, in every
// running decoder, and forgets what it had said.
func (a *App) clearWsjtHighlights() {
if a.udp == nil {
return
}
for _, inst := range a.udp.Instances() {
_ = a.udp.SendClearHighlights(inst)
}
a.wsjtHLMu.Lock()
a.wsjtHLSent = map[string]string{}
a.wsjtHLMu.Unlock()
}
// GetWsjtHighlight reports whether decode highlighting is on.
func (a *App) GetWsjtHighlight() bool {
return a.settingOr(keyWsjtHighlight, "0") == "1"
@@ -77,13 +207,8 @@ func (a *App) SetWsjtHighlight(on bool) {
}
a.setSetting(keyWsjtHighlight, v)
a.wsjtHighlightOn.Store(on)
if !on && a.udp != nil {
for _, inst := range a.udp.Instances() {
_ = a.udp.SendClearHighlights(inst)
}
a.wsjtHLMu.Lock()
a.wsjtHLSent = map[string]string{}
a.wsjtHLMu.Unlock()
if !on {
a.clearWsjtHighlights()
applog.Printf("wsjt highlight: off — cleared in every instance")
}
}
@@ -92,12 +217,14 @@ func (a *App) SetWsjtHighlight(on bool) {
// it, when the option is on and the verdict is worth a colour. De-duplicated
// per instance+call+verdict: a station CQing all evening is decoded four times
// a minute, and the instruction only needs to be said once.
func (a *App) maybeHighlightDecode(instance, call, band string) {
func (a *App) maybeHighlightDecode(instance, call, band, mode string) {
if !a.wsjtHighlightOn.Load() || a.udp == nil || call == "" || instance == "" {
return
}
bg, fg, verdict := a.decodeHighlightVerdict(call, band)
key := instance + "|" + strings.ToUpper(call) + "|" + band
bg, fg, verdict := a.decodeHighlightVerdict(call, band, mode)
// The MODE is part of the key: one receiver can be handed FT8 and FT4 in
// the same session, and the verdict on a callsign is not the same in both.
key := instance + "|" + strings.ToUpper(call) + "|" + band + "|" + strings.ToUpper(mode)
a.wsjtHLMu.Lock()
if a.wsjtHLSent == nil {
a.wsjtHLSent = map[string]string{}
@@ -124,32 +251,57 @@ func (a *App) maybeHighlightDecode(instance, call, band string) {
}
// decodeHighlightVerdict ranks a callsign: watchlist beats new-DXCC beats
// new-band; anything else is "no colour". The empty verdict doubles as the
// clear signal in maybeHighlightDecode.
func (a *App) decodeHighlightVerdict(call, band string) (bg, fg *udp.RGB, verdict string) {
if a.watchlist != nil {
if _, ok := a.watchlist.Match(call); ok {
c := hlWatchlist
f := hlBlack
return &c, &f, "watchlist"
}
}
// new-band, and "already worked here" comes last of all — it is the only
// verdict that says do NOT call, so anything worth calling for outranks it.
// Anything else is "no colour", and the empty verdict doubles as the clear
// signal in maybeHighlightDecode.
func (a *App) decodeHighlightVerdict(call, band, mode string) (bg, fg *udp.RGB, verdict string) {
c := a.clusterStatusMaps()
// ALREADY WORKED HERE, whatever else the station is.
//
// Settled first because it is the one fact that cancels the others. A watch
// list entry worked on this band and mode stayed pink for the rest of the
// session — the list is a statement of intent, not of what is left to do, and
// the colour that means "call this one" was being shown for a station already
// in the log. From the operator's side there was no way to tell the two
// apart, which is the only thing the colours are for.
workedHere := false
if band != "" && mode != "" && c.workedCallSlots != nil {
m := strings.ToUpper(strings.TrimSpace(mode))
if c.normMode != nil {
m = c.normMode(m)
}
_, workedHere = c.workedCallSlots[strings.ToUpper(call)+"|"+strings.ToLower(band)+"|"+m]
}
if a.watchlist != nil && !workedHere {
if _, ok := a.watchlist.Match(call); ok {
bgc, fgc := a.colourFor(keyWsjtColWatchlist, hexOfRGB(hlWatchlist))
return &bgc, &fgc, "watchlist"
}
}
if a.dxcc != nil {
if m, ok := a.dxcc.Lookup(call); ok && m.Entity != nil {
num := dxcc.EntityDXCC(m.Entity.Name)
ent := c.entities[num]
if ent == nil {
bgc, fgc := hlNewDXCC, hlWhite
bgc, fgc := a.colourFor(keyWsjtColNewDXCC, hexOfRGB(hlNewDXCC))
return &bgc, &fgc, "new-dxcc"
}
if band != "" {
if _, workedBand := ent.Bands[strings.ToLower(band)]; !workedBand {
bgc, fgc := hlNewBand, hlBlack
bgc, fgc := a.colourFor(keyWsjtColNewBand, hexOfRGB(hlNewBand))
return &bgc, &fgc, "new-band"
}
}
}
}
// 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.
// Its own option: on a well-filled log this matches most of a period, and a
// screen where nearly every line is coloured has stopped saying anything.
if workedHere && a.wsjtHLWorkedOn.Load() {
bgc, fgc := a.colourFor(keyWsjtColWorked, hexOfRGB(hlWorked))
return &bgc, &fgc, "worked"
}
return nil, nil, ""
}
+736
View File
@@ -0,0 +1,736 @@
package main
// Auto-call — the wiring around internal/autocall.
//
// The DECISION is in that package, alone and tested. This file does the three
// things it cannot do for itself: cut the decode stream into periods, tell it
// what the log still needs from each station, and carry out what it decides.
//
// It lives in the backend rather than in the panel because it keys a
// transmitter: it must behave identically whether the FT decodes tab is open,
// behind another tab, or the window is minimised — and because every rule it
// applies is then a Go test rather than something only the air can check.
import (
"fmt"
"strconv"
"strings"
"time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"hamlog/internal/applog"
"hamlog/internal/autocall"
)
const (
keyAutoCallOn = "autocall.enabled"
keyAutoCallOnly = "autocall.only"
keyAutoCallAttempts = "autocall.attempts"
keyAutoCallWatched = "autocall.watched_attempts"
keyAutoCallMisses = "autocall.misses"
keyAutoCallRounds = "autocall.max_rounds"
// Periods, not minutes — see autocall.Settings.RestPeriods. A new key rather
// than a reinterpreted one: the old value was in minutes, and reading "2" as
// two periods or as two minutes are eight periods apart.
keyAutoCallRest = "autocall.rest_periods"
keyAutoCallOnScreen = "autocall.on_screen_only"
keyAutoCallTrace = "autocall.trace"
)
// AutoCallSettings is the panel's shape. Durations are in minutes because that
// is what the operator is asked for.
type AutoCallSettings struct {
Enabled bool `json:"enabled"`
Only string `json:"only"`
// Attempts / WatchedAttempts: how many calls one station gets before it is
// released. The larger allowance is for a callsign on the watch list.
Attempts int `json:"attempts"`
WatchedAttempts int `json:"watched_attempts"`
// Misses: periods in which the station itself transmits, with no decode of
// it, before it is given up on.
Misses int `json:"misses"`
// MaxRounds: how many series of calls one station gets in a session, and
// RestPeriods the pause between two of them, counted in the station's own
// transmit periods.
MaxRounds int `json:"max_rounds"`
RestPeriods int `json:"rest_periods"`
// OnScreenOnly: call only what the decodes panel is showing, so its filters
// steer the transmitter as well as the eye.
OnScreenOnly bool `json:"on_screen_only"`
// Trace writes one line per period to the log: what was on the air, why
// each station was refused, and what was decided. For diagnosing "it is not
// calling anything" — and it is a line every fifteen seconds, so it is off
// unless asked for.
Trace bool `json:"trace"`
}
// intOr reads a stored integer, keeping a stored ZERO — which num() cannot,
// since it treats zero as "nothing set".
func intOr(v string, def int) int {
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil || n < 0 {
return def
}
return n
}
func (a *App) GetAutoCallSettings() AutoCallSettings {
d := autocall.Defaults()
num := func(key string, def int) int {
n, err := strconv.Atoi(strings.TrimSpace(a.settingOr(key, "")))
if err != nil || n <= 0 {
return def
}
return n
}
return AutoCallSettings{
// Never on from a stored value alone — see startAutoCall.
Enabled: a.settingOr(keyAutoCallOn, "0") == "1",
Only: strings.ToUpper(strings.TrimSpace(a.settingOr(keyAutoCallOnly, ""))),
Attempts: num(keyAutoCallAttempts, d.Attempts),
WatchedAttempts: num(keyAutoCallWatched, d.WatchedAttempts),
// On by default: the filters are in front of the operator, and a station
// they have hidden is one they have said they do not want.
OnScreenOnly: a.settingOr(keyAutoCallOnScreen, "1") == "1",
Trace: a.settingOr(keyAutoCallTrace, "0") == "1",
Misses: num(keyAutoCallMisses, d.Misses),
MaxRounds: num(keyAutoCallRounds, d.MaxRounds),
// Zero is meaningful (call it again next period), so it is read directly
// rather than through num(), which treats zero as "unset".
RestPeriods: intOr(a.settingOr(keyAutoCallRest, ""), d.RestPeriods),
}
}
func (a *App) SaveAutoCallSettings(s AutoCallSettings) error {
a.setSetting(keyAutoCallOn, map[bool]string{true: "1", false: "0"}[s.Enabled])
a.setSetting(keyAutoCallOnly, strings.ToUpper(strings.TrimSpace(s.Only)))
a.setSetting(keyAutoCallOnScreen, map[bool]string{true: "1", false: "0"}[s.OnScreenOnly])
a.setSetting(keyAutoCallTrace, map[bool]string{true: "1", false: "0"}[s.Trace])
for key, v := range map[string]int{
keyAutoCallAttempts: s.Attempts, keyAutoCallWatched: s.WatchedAttempts,
keyAutoCallMisses: s.Misses, keyAutoCallRounds: s.MaxRounds,
} {
if v > 0 {
a.setSetting(key, strconv.Itoa(v))
}
}
// Stored even at zero, unlike the counters above: no rest at all is a
// setting, not an empty field.
if s.RestPeriods >= 0 {
a.setSetting(keyAutoCallRest, strconv.Itoa(s.RestPeriods))
}
a.applyAutoCall()
applog.Printf("autocall: %v (only=%q, %d/%d calls, %d misses, %d rounds)",
s.Enabled, s.Only, s.Attempts, s.WatchedAttempts, s.Misses, s.MaxRounds)
return nil
}
// SetAutoCallOnly is the chase-list field in the decodes toolbar.
//
// Its own binding rather than a settings round-trip: the toolbar knows one
// field, and handing back a whole struct it never read is how a Preferences
// window left open somewhere quietly reverts a limit that was just changed.
func (a *App) SetAutoCallOnly(list string) error {
s := a.GetAutoCallSettings()
s.Only = list
return a.SaveAutoCallSettings(s)
}
// SetAutoCall is the toolbar switch above the decodes.
func (a *App) SetAutoCall(on bool) error {
s := a.GetAutoCallSettings()
s.Enabled = on
return a.SaveAutoCallSettings(s)
}
// autoCallEngine returns the engine, built on first use.
func (a *App) autoCallEngine() *autocall.Engine {
a.acMu.Lock()
defer a.acMu.Unlock()
if a.ac == nil {
a.ac = autocall.New(a.autoCallSettings())
}
return a.ac
}
func (a *App) autoCallSettings() autocall.Settings {
s := a.GetAutoCallSettings()
return autocall.Settings{
Enabled: s.Enabled, Only: s.Only, OnScreenOnly: s.OnScreenOnly,
Attempts: s.Attempts, WatchedAttempts: s.WatchedAttempts,
Misses: s.Misses, MaxRounds: s.MaxRounds,
RestPeriods: s.RestPeriods,
}
}
// applyAutoCall pushes the settings into the engine, and clears its state when
// the feature is switched off — an operator turning it off is entitled to have
// it forget the station it was calling, not resume it half an hour later.
func (a *App) applyAutoCall() {
e := a.autoCallEngine()
s := a.autoCallSettings()
e.SetSettings(s)
if a.GetAutoCallSettings().Trace {
e.SetTrace(func(f string, args ...any) { applog.Printf("autocall: "+f, args...) })
} else {
e.SetTrace(nil)
}
if !s.Enabled {
e.Reset()
a.acMu.Lock()
a.acPeriod, a.acBuf = nil, nil
a.acJudged, a.acDirty = nil, nil
a.acMu.Unlock()
}
a.emitAutoCall()
}
// ResetAutoCall is the operator's restart after the engine gave up on an
// explicit target: it clears every verdict, including the grey list.
func (a *App) ResetAutoCall() {
a.autoCallEngine().Reset()
a.emitAutoCall()
}
// HaltAutoCall is the Halt button while a call is in progress.
//
// It does NOT clear the engine's state, which is what Halt used to do: that
// wiped the rests and the rounds along with everything else, so the station the
// operator had just stopped was eligible again in the same second and the next
// period called it straight back.
func (a *App) HaltAutoCall() {
call := a.autoCallEngine().Halt()
if call != "" {
a.acMu.Lock()
a.acReason = fmt.Sprintf("%s stopped by the operator — set aside until auto-call is switched off and on", call)
a.acMu.Unlock()
applog.Printf("autocall: %s", a.acReason)
}
a.emitAutoCall()
}
// AutoCallStatus is what the toolbar shows.
type AutoCallStatus struct {
Enabled bool `json:"enabled"`
// Only is the chase list, carried in the status so the field in the decodes
// toolbar and the one in Preferences are never two versions of the truth:
// whichever is typed into, both show it.
Only string `json:"only"`
Target string `json:"target"`
// Waiting: what it would call if that station were not in a QSO.
Waiting string `json:"waiting"`
Calls int `json:"calls"`
Max int `json:"max"`
Misses int `json:"misses"`
MaxMiss int `json:"max_miss"`
Stopped bool `json:"stopped"`
// Greylisted counts the stations the operator has stopped this session, so
// the toolbar can say why a station on the air is never called.
Greylisted int `json:"greylisted"`
// Reason is the last decision in plain words. An auto-call that is doing
// nothing on purpose looks exactly like one that is broken.
Reason string `json:"reason"`
}
func (a *App) GetAutoCallStatus() AutoCallStatus {
st := a.autoCallEngine().Status()
a.acMu.Lock()
reason := a.acReason
a.acMu.Unlock()
set := a.GetAutoCallSettings()
return AutoCallStatus{
Enabled: set.Enabled, Only: set.Only,
Target: st.Target, Waiting: st.Waiting, Calls: st.Attempts, Max: st.Max,
Misses: st.Misses, MaxMiss: st.MaxMiss, Stopped: st.Stopped,
Greylisted: a.autoCallEngine().Greylisted(),
Reason: reason,
}
}
func (a *App) emitAutoCall() {
if a.ctx == nil {
return
}
wruntime.EventsEmit(a.ctx, "autocall:status", a.GetAutoCallStatus())
}
// TakeAutoCallTarget adopts the station the operator has just clicked, so a
// manual pick gets the same watchdogs as an automatic one — the click is the
// choice of station, not a decision to call it for ever.
func (a *App) TakeAutoCallTarget(call, band, mode string) {
if !a.GetAutoCallSettings().Enabled {
return
}
call = strings.ToUpper(strings.TrimSpace(call))
if call == "" {
return
}
a.autoCallEngine().Take(autocall.Candidate{
Decode: autocall.Decode{Call: call, Band: band, Mode: mode, At: time.Now().UTC(), IsNew: true},
Need: a.autoCallNeed(call, band, mode),
Watched: a.autoCallWatched(call),
})
a.emitAutoCall()
}
// ── The decode stream, cut into periods ───────────────────────────────────
// acDecode is one decode held until its period is complete.
type acDecode struct {
d autocall.Decode
tx bool // the decode is our own transmission echoed back
}
// autoCallFeed takes one decode from the UDP loop.
//
// Decodes arrive one datagram at a time and a decision needs the whole period:
// the best station in it, and whether the target was there at all. They are
// therefore buffered under the period they belong to, and the period is judged
// when the next one starts — or, if the band goes quiet, by the sweeper below,
// which is what makes "not decoded for three of its periods" reachable when the
// answer is that nothing is being decoded at all.
func (a *App) autoCallFeed(d autocall.Decode) {
if !a.GetAutoCallSettings().Enabled {
return
}
inst := d.Instance
key := acPeriodKey(d.At, d.TRPeriod)
a.acMu.Lock()
if a.acPeriod == nil {
a.acPeriod, a.acAt, a.acTR, a.acBuf = map[string]string{}, map[string]time.Time{}, map[string]int{}, map[string][]acDecode{}
a.acFed = map[string]time.Time{}
a.acJudged, a.acDirty = map[string]string{}, map[string]bool{}
}
if prev := a.acPeriod[inst]; prev != "" && prev != key {
prevAt, prevTR, buf := a.acAt[inst], a.acTR[inst], a.acBuf[inst]
// Only if something in it has NOT been judged. The buffer now outlives
// the judgement (see below), so without this the arrival of the next
// period would judge the previous one a second time on the very same
// decodes — and act on them, a slot late.
pending := a.acDirty[inst] || a.acJudged[inst] != prev
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
a.acBuf[inst] = []acDecode{{d: d}}
a.acFed[inst], a.acDirty[inst] = time.Now(), true
a.acMu.Unlock()
// The previous period ends here whatever the sweeper was going to do: a
// decode stamped with the next slot is proof the old one is over.
if pending {
a.autoCallJudge(inst, prev, prevAt, prevTR, buf)
}
return
}
// APPENDED, never restarted. The buffer survives the period being judged,
// so a decode that arrives after the others belongs to the same period and
// is weighed against all of them.
//
// It used to be dropped and then judged on its own: the sweeper cleared the
// buffer, a straggler opened a "new" one under the same key, and the ladder
// was applied to whatever handful had come late — with the other thirty
// stations of that period nowhere in sight. A deep decode arriving a second
// after the burst is exactly the station worth calling.
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
a.acFed[inst], a.acDirty[inst] = time.Now(), true
a.acBuf[inst] = append(a.acBuf[inst], acDecode{d: d})
a.acMu.Unlock()
}
// acQuiet is how long a period is left open after its LAST decode arrives.
//
// This is the whole timing budget of the feature. A decoder finishes a period
// and sends its decodes about a second before the next slot opens, so the
// answer has to be back before that boundary — a reply that arrives after it
// makes the decoder start its call several seconds into the slot, which is what
// an operator sees as "it calls late" and what a station on the other end sees
// as a message it cannot decode.
//
// It was a whole slot plus four seconds, measured from the DECODE'S OWN
// TIMESTAMP — the start of the period, not the moment it arrived — so the
// answer left about four seconds INTO the next slot, every time.
//
// 800 ms: long enough for a busy period's decodes to arrive together (measured
// in bursts of a few hundred milliseconds), short enough to answer inside the
// same second they landed.
const acQuiet = 800 * time.Millisecond
// autoCallSweep closes the periods nothing has closed for us. Called on a timer.
//
// Per receiver, because with two decoders one may fall silent while the other
// is busy — and it is the silent one's period that has to close for a missed
// period to be counted at all.
func (a *App) autoCallSweep() {
if !a.GetAutoCallSettings().Enabled {
return
}
type due struct {
inst, key string
at time.Time
tr int
buf []acDecode
}
var ready []due
a.acMu.Lock()
for inst, key := range a.acPeriod {
if key == "" {
continue
}
tr := a.acTR[inst]
if tr <= 0 {
tr = 15
}
// Measured from when the last decode ARRIVED, not from the period it
// belongs to: a decode is stamped with the start of its own slot, so
// waiting "a slot plus four seconds" from that stamp is waiting until
// the middle of the NEXT slot. See acQuiet.
if time.Since(a.acFed[inst]) < acQuiet {
continue
}
// Judged once per period, and again only when something new has come in
// for it. The period itself is kept open until a decode from the NEXT one
// arrives, so a straggler is judged with the whole period behind it.
if a.acJudged[inst] == key && !a.acDirty[inst] {
continue
}
ready = append(ready, due{inst, key, a.acAt[inst], tr, a.acBuf[inst]})
a.acJudged[inst], a.acDirty[inst] = key, false
}
a.acMu.Unlock()
for _, d := range ready {
a.autoCallJudge(d.inst, d.key, d.at, d.tr, d.buf)
}
}
// autoCallSilence is the empty period. With the band dead, no decode ever
// arrives to close the next one, and the target's absence would never be
// counted — so a period with nothing in it is still a period.
//
// Only for the receiver the target is being called on: an idle second decoder
// has no periods to miss.
func (a *App) autoCallSilence() {
if !a.GetAutoCallSettings().Enabled {
return
}
inst, target := a.autoCallEngine().TargetInstance()
if target == "" {
return
}
a.acMu.Lock()
// Nothing pending for this receiver, and nothing judged for a while: the
// band has gone quiet under it.
quiet := !a.acDirty[inst] && time.Since(a.acLastJudge) > 20*time.Second
tr := a.acTR[inst]
a.acMu.Unlock()
if !quiet {
return
}
now := time.Now().UTC()
a.autoCallJudge(inst, acPeriodKey(now, tr), now, tr, nil)
}
func acPeriodKey(at time.Time, trSec int) string {
if trSec <= 0 {
trSec = 15
}
return fmt.Sprintf("%d", at.UTC().Unix()/int64(trSec))
}
// autoCallJudge resolves what the log needs from each station in the period,
// runs the decision, and carries it out.
func (a *App) autoCallJudge(inst, key string, at time.Time, tr int, buf []acDecode) {
a.acMu.Lock()
a.acLastJudge = time.Now()
a.acMu.Unlock()
// One status call for the whole period. It reads a cached worked-index, but
// it is still per-callsign work and a busy period is thirty of them.
seen := map[string]bool{}
var q []SpotQuery
var uniq []acDecode
for _, dd := range buf {
k := dd.d.Call + "|" + dd.d.Band + "|" + dd.d.Mode
if seen[k] {
// The same station twice in one period is one candidate, judged on
// its most callable line — the engine's bestOf does that, so both
// decodes are kept; only the status lookup is deduplicated.
uniq = append(uniq, dd)
continue
}
seen[k] = true
uniq = append(uniq, dd)
q = append(q, SpotQuery{Call: dd.d.Call, Band: dd.d.Band, Mode: dd.d.Mode})
}
status := map[string]SpotStatus{}
for _, st := range a.ClusterSpotStatuses(q) {
status[st.Call+"|"+st.Band+"|"+st.Mode] = st
}
chase := a.autoCallChase()
cands := make([]autocall.Candidate, 0, len(uniq))
for _, dd := range uniq {
st := status[dd.d.Call+"|"+dd.d.Band+"|"+dd.d.Mode]
c := candidateOf(dd.d, st, chase)
c.Watched = a.autoCallWatched(dd.d.Call)
c.Hidden = a.autoCallHidden(dd.d.Call)
cands = append(cands, c)
}
tx := a.autoCallTX()
act := a.autoCallEngine().OnPeriod(autocall.Period{
Instance: inst, Key: key, At: at, TRPeriod: tr, Decodes: cands, TX: tx,
MyCall: a.opCall,
})
a.autoCallDo(act)
}
// candidateOf turns one decode and the log's verdict on it into a candidate.
//
// Split out and kept pure because ONE line of it was wrong for weeks and
// nothing could catch it: the entity's verdict was read as the station's.
// chaseExtras is what the operator hunts BESIDES entities — the cluster's
// orthogonal markers, from the same switches that decide whether the badges are
// shown at all (Settings → DX Cluster). One answer for the eye and the
// transmitter: a marker withdrawn from the screen is not one to call for.
type chaseExtras struct{ pota, grid, pfx, county, state bool }
func candidateOf(d autocall.Decode, st SpotStatus, ch chaseExtras) autocall.Candidate {
c := autocall.Candidate{
Decode: d,
// The ENTITY's verdict decides what is still needed…
Need: autoCallNeedOf(st.Status),
// …and THIS CALLSIGN on this band and mode decides whether calling it
// would be a duplicate.
//
// Status was used for both. "worked" there means the COUNTRY is in the
// log on this band and mode, so on a band where the operator has most of
// them, nearly every station on the air was refused as already worked —
// a trace of one evening shows twenty decodes out of twenty-one turned
// away that way, the watched DXpedition among them.
Worked: st.WorkedSlot,
// The need exists only because a QSL never came: worth chasing, and worth
// less than the same need never worked at all.
Unconfirmed: st.UnconfStatus,
}
// NOTHING LEFT ON THE ENTITY, AND STILL WORTH A CALL.
//
// A prefix, a square, a county, a state, a park: never worked, orthogonal to
// the entity's verdict, and exactly what the operator ticked in the chase
// settings. They ranked at nothing-needed, so auto-call sat through a
// never-worked WPX prefix calling CQ and did not answer it.
//
// Only when the entity has nothing to add — a new band that is ALSO a new
// prefix is a new band, and says so.
if c.Need == autocall.NeedNone {
switch {
case ch.pfx && st.NewPfx:
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "prefix", st.UnconfPfx
case ch.county && st.NewCounty:
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "county", st.UnconfCty
case ch.state && st.NewState:
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "state", st.UnconfState
case ch.grid && st.NewGrid:
c.Need, c.Extra, c.Unconfirmed = autocall.NeedExtra, "square", st.GridState == "unconf"
case ch.pota && st.NewPOTA:
c.Need, c.Extra = autocall.NeedExtra, "park"
}
}
return c
}
// autoCallChase reads the chase switches the cluster and the decode list use.
//
// Read once per period rather than per decode: they are settings-store reads,
// and the period loop runs over every station on the band.
func (a *App) autoCallChase() chaseExtras {
on := func(key string) bool {
if a.settings == nil {
return true
}
v, _ := a.settings.Get(a.ctx, "ui.opslog."+key)
return v != "0" // unset means on, as it does on the screen
}
return chaseExtras{
pota: on("chasePota"),
grid: on("chaseGrids"),
pfx: on("chasePfx"),
county: on("chaseCounty"),
state: on("chaseState"),
}
}
// autoCallDo carries out a decision and records it.
func (a *App) autoCallDo(act autocall.Action) {
if act.Reason != "" {
a.acMu.Lock()
a.acReason = act.Reason
a.acMu.Unlock()
applog.Printf("autocall: %s", act.Reason)
}
switch act.Kind {
case autocall.DoReply:
d := act.Decode
if err := a.AnswerDecode(d.Instance, d.Ms, d.SNR, d.DT, d.AudioHz, d.ModeRaw, d.MsgRaw, d.LowConf); err != nil {
applog.Printf("autocall: the call to %s could not be sent: %v", d.Call, err)
}
case autocall.DoHalt:
// Soft: let the over finish, then stop transmitting. Hard: stop now.
// The engine decides — see Action.Soft.
if err := a.HaltDecodeTx("", act.Soft); err != nil {
applog.Printf("autocall: halt failed: %v", err)
}
}
if act.Kind != autocall.DoNothing || act.Reason != "" {
a.emitAutoCall()
}
}
// autoCallNoteTX is the attempt counter, fed from the decoder's own status.
//
// ONCE PER TRANSMIT PERIOD. Status arrives every second and says "transmitting"
// throughout the over, so counting each one would spend the whole allowance of
// seven calls inside a single fifteen-second slot — the counter has to measure
// transmissions, not seconds of carrier.
func (a *App) autoCallNoteTX(tx autocall.TXState) {
if !a.GetAutoCallSettings().Enabled {
return
}
a.acMu.Lock()
// Per receiver as well as per period: in a split view both decoders report
// their own transmissions, and one key for both would let the second one's
// carrier swallow the first one's count.
key := tx.Instance + "|" + acPeriodKey(time.Now().UTC(), a.acTR[tx.Instance])
if a.acTXPeriod == key {
a.acMu.Unlock()
return
}
a.acTXPeriod = key
a.acMu.Unlock()
a.autoCallDo(a.autoCallEngine().NoteTX(tx))
}
// autoCallTX is the last transmit state reported, as the engine wants it.
func (a *App) autoCallTX() autocall.TXState {
a.acMu.Lock()
defer a.acMu.Unlock()
return a.acTX
}
func (a *App) autoCallSetTX(tx autocall.TXState) {
a.acMu.Lock()
a.acTX = tx
a.acMu.Unlock()
}
// SetAutoCallVisible is the decodes panel saying what it is SHOWING.
//
// The panel owns the filters and therefore owns the answer: reimplementing them
// here would give the screen and the transmitter two definitions of the same
// word, which is how they end up disagreeing. It sends the callsigns that
// survive its filters, and the engine calls nothing else.
//
// An empty list with active=false means "no filtering in force" — the panel was
// closed, or has never been opened this session — and the ladder decides alone.
func (a *App) SetAutoCallVisible(calls []string, active bool) {
set := make(map[string]bool, len(calls))
for _, c := range calls {
if c = strings.ToUpper(strings.TrimSpace(c)); c != "" {
set[c] = true
}
}
a.acMu.Lock()
a.acVisible, a.acVisibleOn = set, active
a.acMu.Unlock()
}
// autoCallHidden reports whether the panel's filters are keeping a station off
// the screen. Unknown when nothing is being published: not hidden.
func (a *App) autoCallHidden(call string) bool {
a.acMu.Lock()
defer a.acMu.Unlock()
if !a.acVisibleOn {
return false
}
return !a.acVisible[strings.ToUpper(strings.TrimSpace(call))]
}
// autoCallNeedOf maps the cluster's own status vocabulary onto the ladder. One
// vocabulary for both, so a station that reads NEW BAND in the decodes list is
// the same NEW BAND the auto-call ranks — two answers to one question is how
// the panel and the caller quietly start disagreeing.
func autoCallNeedOf(status string) autocall.Need {
switch status {
case "new":
return autocall.NeedDXCC
case "new-band-mode", "new-band":
// New on both counts is at least a new band, and it is the better catch
// of the two — it must not fall below a plain new band.
return autocall.NeedBand
case "new-mode":
return autocall.NeedMode
case "new-slot":
return autocall.NeedSlot
}
return autocall.NeedNone
}
func (a *App) autoCallNeed(call, band, mode string) autocall.Need {
st := a.ClusterSpotStatuses([]SpotQuery{{Call: call, Band: band, Mode: mode}})
if len(st) == 0 {
return autocall.NeedNone
}
return autoCallNeedOf(st[0].Status)
}
// autoCallWatched asks the watch list, which is the same list the spot alerts
// and the cluster colouring use.
func (a *App) autoCallWatched(call string) bool {
if a.watchlist == nil {
return false
}
_, ok := a.watchlist.Match(strings.ToUpper(strings.TrimSpace(call)))
return ok
}
// startAutoCall starts the engine's loop, with auto-call OFF.
//
// It is never on from a stored value. This is the one setting in the program
// that puts the station on the air by itself, and the operator who left it on
// last night is not necessarily the one at the desk now — nor necessarily at
// the desk at all: OpsLog starts with Windows, and a rig that powers up with it
// would begin calling into an empty shack, on whatever band the radio happens
// to be on, hours after anybody decided that was a good idea.
//
// Arming it is one click, and it is a click somebody has to make.
func (a *App) startAutoCall() {
a.disarmAutoCall("launch")
go a.autoCallLoop()
}
// disarmAutoCall switches auto-call off and writes that down.
//
// The setting is what the toolbar and the settings panel both read, so turning
// the engine off without storing it would show a lit switch over a silent
// transmitter — and the operator's next click, meaning "on", would send "off".
func (a *App) disarmAutoCall(why string) {
if a.settingOr(keyAutoCallOn, "0") == "1" {
applog.Printf("autocall: off at %s — it is never armed from a stored setting", why)
}
a.setSetting(keyAutoCallOn, "0")
a.applyAutoCall()
}
func (a *App) autoCallLoop() {
// A quarter of a second. The sweeper is what closes a period, so its tick is
// part of the same budget as acQuiet: a two-second tick added up to two
// seconds of its own to every answer, which is most of the margin there is.
// The work per tick is a map read.
t := time.NewTicker(250 * time.Millisecond)
defer t.Stop()
for range t.C {
if a.ctx == nil {
return
}
a.autoCallSweep()
a.autoCallSilence()
}
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"testing"
"hamlog/internal/autocall"
)
// The entity's verdict is not the station's.
//
// From the air: on 10 m, where most countries are already in the log, the
// engine refused twenty decodes out of twenty-one as "worked" — a watched
// DXpedition calling CQ among them — because the ENTITY's status was read as
// the station's.
// allChased is the default: every orthogonal marker ticked.
var allChased = chaseExtras{pota: true, grid: true, pfx: true, county: true, state: true}
func TestCandidateWorkedIsTheCallsignNotTheEntity(t *testing.T) {
d := autocall.Decode{Call: "J38DX", Band: "10m", Mode: "FT8", IsNew: true}
// Grenada worked on 10 m FT8, this callsign never worked: nothing is needed
// from it, and calling it is NOT a duplicate.
c := candidateOf(d, SpotStatus{Status: "worked", WorkedSlot: false}, allChased)
if c.Worked {
t.Error("a station never worked was refused because its entity was")
}
if c.Need != autocall.NeedNone {
t.Errorf("need = %v on a worked entity, want none", c.Need)
}
// The same callsign already in the log on this band and mode IS a duplicate.
if c := candidateOf(d, SpotStatus{Status: "worked", WorkedSlot: true}, allChased); !c.Worked {
t.Error("a callsign already worked on this band and mode was not flagged")
}
// And a real need still carries through, with the unconfirmed distinction.
c = candidateOf(d, SpotStatus{Status: "new-band", UnconfStatus: true}, allChased)
if c.Need != autocall.NeedBand || !c.Unconfirmed {
t.Errorf("new-band unconfirmed came through as %v (unconf=%v)", c.Need, c.Unconfirmed)
}
}
// A station whose ENTITY has nothing left to give can still be the reason the
// operator is on the band: a WPX prefix, a county, a square, a park that has
// never been worked. Reported from the air — auto-call sat through a
// never-worked prefix calling CQ and did nothing.
func TestOrthogonalMarkersAreWorthACall(t *testing.T) {
d := autocall.Decode{Call: "BH2SWB", Band: "10m", Mode: "FT8", IsNew: true}
worked := SpotStatus{Status: "worked"}
for _, tc := range []struct {
what string
st SpotStatus
want string
}{
{"prefix", SpotStatus{Status: "worked", NewPfx: true}, "prefix"},
{"county", SpotStatus{Status: "worked", NewCounty: true}, "county"},
{"state", SpotStatus{Status: "worked", NewState: true}, "state"},
{"square", SpotStatus{Status: "worked", NewGrid: true}, "square"},
{"park", SpotStatus{Status: "worked", NewPOTA: true}, "park"},
} {
c := candidateOf(d, tc.st, allChased)
if c.Need != autocall.NeedExtra || c.Extra != tc.want {
t.Errorf("%s: need=%v extra=%q, want extra %q", tc.what, c.Need, c.Extra, tc.want)
}
// And not when the operator does not chase that kind of thing: the
// switch that withdraws the badge withdraws the call with it.
if c := candidateOf(d, tc.st, chaseExtras{}); c.Need != autocall.NeedNone {
t.Errorf("%s: called for a marker the operator does not chase", tc.what)
}
}
// It is the LOWEST rung: a real need on the entity still says what it is.
if c := candidateOf(d, SpotStatus{Status: "new-band", NewPfx: true}, allChased); c.Need != autocall.NeedBand {
t.Errorf("need = %v — a new band that is also a new prefix is a new band", c.Need)
}
// Nothing anywhere is still nothing.
if c := candidateOf(d, worked, allChased); c.Need != autocall.NeedNone {
t.Errorf("need = %v on a station with nothing to gain", c.Need)
}
}
+27 -4
View File
@@ -93,14 +93,27 @@ func keepKnownBands(want []string) []string {
return out
}
// pskrMaxGrids bounds the receiver squares the broker is asked to filter on.
// Each is one subscription, and the traffic follows the area: 2000 km is about
// 615 squares, which the broker takes in batches without complaint. The cap is
// there so an absurd radius cannot turn into thousands of subscriptions — past
// it the nearest squares are kept, and the log says how many.
const pskrMaxGrids = 900
// bandOpenNearKm reads the stored receiver radius, falling back to the default
// for anything unset or out of range. The bounds are the two ways to make the
// watch useless: below 25 km almost nobody is ever near enough to hear anything,
// and past 1000 km the reports stop being about the operator's own path — which
// is the entire premise of measuring from a receiver rather than a transmitter.
// and past 3000 km a "nearby" receiver is on the far side of a continent, which
// says nothing about the operator's own path.
//
// The ceiling was 1000 km, chosen with Europe in mind, where that radius holds a
// dozen countries' worth of receivers. It is the wrong number in VK: a station
// there can have almost nobody inside 300 km, and the chase list stayed empty
// not because nothing was on the air but because nothing was listening close
// enough to count.
func bandOpenNearKm(raw string) int {
n, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || n < 25 || n > 1000 {
if err != nil || n < 25 || n > 3000 {
return pskr.DefaultNearKm
}
return n
@@ -194,7 +207,17 @@ func (a *App) startBandOpenFeed() {
// hundred survived the NearKm test below. One ring of squares — about the
// same 300 km — is 0.2 to 1.2 a second, and the same for every operator,
// where filtering by DXCC ranged from 1.2 (OH) to 72.5 (K).
rxGrids := geo.NeighbourGrids(a.opLat, a.opLon, 1)
//
// The set follows the radius the operator asked for. It was one fixed ring
// whatever they set — about 300 km — so raising the radius bought nothing:
// the reports that would have satisfied it were never sent to us in the
// first place, and an operator in a thinly-populated region who widened the
// circle to find some receivers saw no change at all.
rxGrids := geo.GridsWithin(a.opLat, a.opLon, float64(s.NearKm), pskrMaxGrids)
if len(rxGrids) == 0 {
rxGrids = geo.NeighbourGrids(a.opLat, a.opLon, 1)
}
applog.Printf("pskr: receiver filter — %d squares within %d km of the station", len(rxGrids), s.NearKm)
var onGrid func(call, grid string)
if chaseGrids {
+252
View File
@@ -1,4 +1,256 @@
[
{
"version": "0.27.17",
"date": "",
"en": [
"[NEW] Satellites. A new tab (Tools → Satellites) works the amateur birds from end to end. A map with each satellite's footprint and the selected one's path over the ground; a sky plot the way every tracker draws one, centre straight up and rim at the horizon, with the whole pass and where the bird is on it; a countdown to AOS — or to LOS once it is up — with rise, peak and set, their compass directions, distance, altitude and footprint; and a pass table for everything you follow.\n\nTrack puts the radio on the satellite and keeps it there, once a second: an IC-9700 or IC-9100 in its own satellite mode, a FlexRadio on two slices (A the downlink, B the uplink, created if missing, full duplex on) — and any other rig on the downlink, which it says plainly rather than half-doing the job. Tune the receiver where you like: the tracker reads the dial, takes it as the station you have chosen, and moves the transmitter to match. An az/el rotator follows along, either driven directly over EasyComm II or handed to PstRotator if you already run it; a 450° rotator is used as one, so a pass crossing north continues instead of unwinding.\n\nQSOs made while tracking are logged with the NOMINAL frequencies, SAT_NAME, SAT_MODE and PROP_MODE=SAT — the transponder's own numbers, which both stations can agree on, rather than where one radio happened to be.\n\nOrbital elements come from Celestrak with a mirror behind it and are kept on disk, so the tab is full the moment it opens even with no internet; elements for a bird no feed carries yet can be pasted in and survive every refresh. Twenty-five satellites ship with a frequency plan — the FM and linear birds, GreenCube, QO-100 narrow and wide — in a file you can correct yourself when a transponder is switched. Everything about setting it up lives in Settings → Satellites, including which satellites you follow, chosen the way you choose awards.",
"Each map keeps its own imagery. The world map and the grid-square map shared one setting, so choosing satellite imagery to look at grids repainted the main map as well, and there was no way to have terrain on one and plain streets on the other. All four — world, grid squares, FT map, satellites — now remember their own choice, and it travels with the data folder like the remembered views. A choice already made for the grid map is carried over, not reset.",
"Two or three FT8 programs at once no longer fight over the callsign field. Click a station in MSHV and only MSHV has a DX Call; WSJT-X and JTDX beside it are idle and say so once a second each — and OpsLog was reading those as MSHV abandoning the station, so the entry emptied and refilled at 1 Hz and the map zoomed in and out with it. A cleared DX Call is now read per program, never across the listener; and the program that announces a station keeps the entry field until it clears its own call, is closed, or the QSO is logged.",
"The FT decodes table sorts on SNR, frequency, distance, country and status — click the heading. Within each slot and never across them: the periods are what the panel is, and a list sorted end to end would mix three minutes of decodes into one column with no way to tell which window any of them came from. One click sorts the way that column is worth reading (strongest signal, furthest DX, lowest frequency, A to Z, most wanted first), the second reverses it, the third gives back the order the decoder heard them in. Stations with no grid, or no country resolved yet, sort to the end either way rather than pretending to a distance of zero.",
"The cluster editor offers a list of known nodes. Setting up a telnet cluster is the step operators get stuck on: the address and the port are two pieces of information nobody has to hand, and a typo in either looks exactly like a node that is down. Pick one and the fields fill in — F4BPO, DXFun, F5LEN, F5MZN, KM3T, SOTA, POTA, and the two Reverse Beacon feeds, which are one network on two ports where 7000 carries CW and RTTY and 7001 carries FT8 and FT4. Everything stays editable, and a node typed in by hand works exactly the same. More will be added.",
"Preferences no longer lag behind the keyboard. Typing a cluster macro re-rendered the whole dialog on every keystroke and wrote a row into the database per character; the twenty-four boxes now stand on their own and the database write waits for the typing to stop."
],
"fr": [
"[NOUVEAU] Satellites. Un nouvel onglet (Outils → Satellites) permet de travailler les satellites amateurs de bout en bout. Une carte avec l'empreinte de chacun et la trace au sol du satellite sélectionné ; une vue du ciel comme la dessine n'importe quel tracker, centre à la verticale et bord à l'horizon, avec le passage entier et la position du satellite dessus ; un compte à rebours jusqu'à l'AOS — ou jusqu'au LOS une fois levé — avec lever, culmination et coucher, leurs directions à la boussole, distance, altitude et empreinte ; et un tableau des passages de tout ce que vous suivez.\n\n« Suivre » met la radio sur le satellite et l'y maintient, chaque seconde : un IC-9700 ou IC-9100 dans son propre mode satellite, un FlexRadio sur deux slices (A la descente, B la montée, créées si elles manquent, full duplex activé) — et n'importe quel autre poste sur la descente seule, ce qu'il annonce clairement plutôt que de faire le travail à moitié. Accordez le récepteur où vous voulez : le suivi lit le VFO, y voit la station que vous avez choisie, et déplace l'émetteur en conséquence. Un rotor az/él suit aussi, piloté directement en EasyComm II ou confié à PstRotator si vous le faites déjà tourner ; un rotor 450° est utilisé comme tel, et un passage qui traverse le nord continue au lieu de se dérouler.\n\nLes QSO faits pendant le suivi sont enregistrés avec les fréquences NOMINALES, SAT_NAME, SAT_MODE et PROP_MODE=SAT — les chiffres du transpondeur, sur lesquels les deux stations peuvent s'accorder, plutôt que l'endroit où une radio se trouvait.\n\nLes éléments orbitaux viennent de Celestrak, avec un miroir derrière, et sont conservés sur disque : l'onglet est rempli dès son ouverture, même sans internet. Les éléments d'un satellite qu'aucun flux ne diffuse encore peuvent être collés à la main et survivent à chaque mise à jour. Vingt-cinq satellites sont livrés avec un plan de fréquences — les FM et les linéaires, GreenCube, QO-100 bande étroite et large — dans un fichier que vous pouvez corriger vous-même quand un transpondeur change de mode. Toute la configuration est dans Réglages → Satellites, y compris le choix des satellites suivis, sélectionnés comme on choisit ses diplômes.",
"Chaque carte garde son propre fond. La carte principale et celle des carrés partageaient un seul réglage : choisir la vue satellite pour regarder les carrés repeignait aussi la carte principale, et il n'y avait aucun moyen d'avoir le relief sur l'une et les rues sur l'autre. Les quatre — principale, carrés, FT map, satellites — retiennent désormais leur propre choix, qui suit le dossier de données comme les positions mémorisées. Un choix déjà fait pour la carte des carrés est repris, pas réinitialisé.",
"Deux ou trois logiciels FT8 en même temps ne se disputent plus le champ indicatif. Cliquez une station dans MSHV et lui seul a un DX Call ; WSJT-X et JTDX à côté sont au repos et le disent une fois par seconde chacun — et OpsLog y lisait MSHV abandonnant la station : le champ se vidait et se remplissait à 1 Hz, la carte zoomant au même rythme. Un DX Call effacé est désormais lu par programme, jamais à l'échelle du port ; et le logiciel qui annonce une station garde le champ jusqu'à ce qu'il efface son propre indicatif, soit fermé, ou que le QSO soit enregistré.",
"Le tableau des décodages FT se trie sur SNR, fréquence, distance, pays et statut — cliquez l'en-tête. À l'intérieur de chaque créneau et jamais au travers : les périodes sont la raison d'être du panneau, et un tri de bout en bout mélangerait trois minutes de décodages en une colonne sans plus savoir de quelle fenêtre chacun vient. Un clic trie dans le sens où la colonne se lit (signal le plus fort, DX le plus lointain, fréquence la plus basse, de A à Z, le plus recherché d'abord), un second inverse, un troisième rend l'ordre dans lequel le décodeur les a entendus. Les stations sans locator, ou dont le pays n'est pas encore résolu, se rangent à la fin dans les deux sens plutôt que de se faire passer pour une distance nulle.",
"L'éditeur de cluster propose une liste de nœuds connus. La configuration d'un cluster telnet est l'étape où l'on se bloque : l'adresse et le port sont deux informations que personne n'a sous la main, et une faute de frappe dans l'une ou l'autre ressemble exactement à un nœud en panne. On en choisit un et les champs se remplissent — F4BPO, DXFun, F5LEN, F5MZN, KM3T, SOTA, POTA, et les deux flux Reverse Beacon, qui sont un même réseau sur deux ports où 7000 porte la CW et le RTTY et 7001 le FT8 et le FT4. Tout reste modifiable, et un nœud saisi à la main fonctionne exactement pareil. D'autres seront ajoutés.",
"Les Préférences ne traînent plus derrière le clavier. Saisir une macro de cluster redessinait tout le dialogue à chaque frappe et écrivait une ligne en base par caractère ; les vingt-quatre champs sont désormais indépendants et l'écriture en base attend la fin de la saisie."
]
},
{
"version": "0.27.16",
"date": "",
"en": [
"[NEW] Typing a digital watering hole sets the mode with it. A spot click has always carried one; a frequency typed by hand carried none, so the rig stayed in SSB on 28.074 while the operator waited for decodes. Same table and same tolerance as a spot (±3 kHz of a known FT8/FT4/JS8 frequency), only towards the digital modes: tuning away from one leaves the mode alone, because there the frequency says nothing about what you mean to do.",
"Yaesu CAT now drives the older radios. The FTDX10, FT-991A, FT-891 and FT-710 write a frequency in nine digits; everything before them — FTDX3000, FTDX5000, FTDX1200, FT-2000, FT-950, FT-450 — writes eight and answers a nine-digit command with a rejection, which is what an FTDX3000 owner saw: every FA refused and a radio that would not follow. The width is taken from the rigs own reply rather than from a table of models, so a set is in the format that radio speaks — including models this backend has never heard of.",
"Yaesu: RTTY can be set on USB (Settings → CAT). ADIF records only “RTTY” and the rig has both sidebands, so the log cannot answer for it — the older RTTY-L stays the default, and a station whose FSK controller wants the upper one says so once. The choice reaches the radio already connected: it is not part of what defines the link, so the link is not rebuilt for it — and until now that meant it waited for the next launch while the rig went on choosing LSB.",
"The update no longer relaunches OpsLog through a hidden PowerShell. An unsigned program that replaces itself on disk, clears the mark-of-the-web and then spawns a windowless PowerShell to start another executable is — byte for byte — the shape of a dropper, and Windows Defenders machine-learning model reads the shape, not the intention: 0.27.14 was removed from a station under Trojan:Script/Wacatac.H!ml. The new version simply starts itself and waits its turn on the single-instance lock, which it already knew how to do. Only the rare fallback path, when the running file cannot even be renamed, still needs a helper that outlives the process.",
"Rotor widget: with more than one rotor the panel no longer runs off the bottom. The selector row appears above the dial, and the widgets height is not its own to take — it sits in a strip sized by the entry form beside it — so the SP/LP pair and half the Stop button were cut off. The dial, the button rows and the padding now give that row back between them, in proportion, and nothing is dropped."
],
"fr": [
"[NEW] Taper une fréquence dappel numérique règle le mode avec elle. Un clic sur un spot en portait un depuis toujours ; une fréquence tapée à la main nen portait aucun, si bien que le poste restait en SSB sur 28.074 pendant quon attendait les décodages. Même table et même tolérance quun spot (±3 kHz dune fréquence FT8/FT4/JS8 connue), et seulement vers les modes numériques : en sen éloignant le mode nest pas touché, car là la fréquence ne dit rien de ce quon veut faire.",
"Le CAT Yaesu pilote désormais les postes plus anciens. FTDX10, FT-991A, FT-891 et FT-710 écrivent une fréquence sur neuf chiffres ; tout ce qui précède — FTDX3000, FTDX5000, FTDX1200, FT-2000, FT-950, FT-450 — l’écrit sur huit et rejette une commande à neuf chiffres. Cest ce que voyait un possesseur de FTDX3000 : chaque FA refusée et une radio qui ne suivait pas. Le format est pris dans la réponse du poste plutôt que dans une table de modèles : lenvoi part donc dans la langue de cette radio-là, y compris pour des modèles que ce backend ne connaît pas.",
"Yaesu : le RTTY peut être placé en USB (Réglages → CAT). LADIF nenregistre que « RTTY » et le poste a les deux bandes latérales : le log ne peut pas répondre à sa place. Le RTTY-L ancien reste par défaut, et une station dont linterface FSK veut la supérieure le dit une fois. Le choix atteint le poste déjà connecté : il ne fait pas partie de ce qui définit la liaison, donc celle-ci nest pas reconstruite pour lui — et jusquici cela voulait dire quil attendait le prochain lancement pendant que le poste continuait de choisir la LSB.",
"La mise à jour ne relance plus OpsLog par un PowerShell caché. Un programme non signé qui se remplace sur le disque, efface la marque « téléchargé depuis Internet » puis lance un PowerShell sans fenêtre pour démarrer un autre exécutable a — à loctet près — la forme dun dropper, et le modèle dapprentissage de Windows Defender lit la forme, pas lintention : la 0.27.14 a été supprimée chez un OM sous Trojan:Script/Wacatac.H!ml. La nouvelle version se lance elle-même et attend son tour sur le verrou dinstance unique, ce quelle savait déjà faire. Seul le repli rare, quand le fichier en cours dexécution ne peut même pas être renommé, garde un assistant qui survit au processus.",
"Widget rotor : avec plusieurs rotors, le panneau ne déborde plus par le bas. La rangée de sélection apparaît au-dessus du cadran, et la hauteur du widget ne lui appartient pas — il occupe une bande dont la hauteur est fixée par la saisie à côté — si bien que la paire SP/LP et la moitié du bouton Stop se retrouvaient coupées. Le cadran, les rangées de boutons et les marges rendent désormais cette hauteur entre eux, chacun pour sa part, sans rien supprimer."
]
},
{
"version": "0.27.15",
"date": "",
"en": [
"[NEW] The WSJT-X / JTDX highlight colours are yours to choose (Settings → UDP), one per verdict — watch list, new DXCC, new band, worked. Only the background is set: the text colour is worked out from it, so a chosen colour cannot come back unreadable in the decoders window. The “grey out stations already worked” switch keeps its purpose — it decides WHETHER dupes are marked, not what colour they are — and is now called “Mark stations already worked”.",
"[NEW] Rotor dial: a circular scale and a beam instead of an arrow. The square ring made a marker at 45° sit further from the centre than one at north — a dial is read by angle, so the ring it is read against is now the same distance away all the way round. The antenna is drawn as a sector that fades outwards, which is the shape of the thing it stands for; where the mouse would send it appears in the same shape in orange, and its azimuth in place of the current heading while you aim. Green for where the antenna is, orange for where it would go, yellow for what was ordered — the second lobe of a bidirectional Ultrabeam and the dashed boom are unchanged. Design from EC1KD again.",
"WSJT-X highlighting: a watch-list station already worked on this band and mode is no longer painted as one to call. The list is a statement of intent, not of what is left to do, and its pink outranked everything — including the log — so a station already worked stayed pink for the session with no way to tell it from one still needed.",
"Motorised antenna: the transmitter is released as soon as the elements stop. Three delays were stacked between the antenna finishing and the operator being allowed to call — the antenna polled every two seconds, the transmit gag held for three after the command whatever the antenna said, and the screen refreshed every three. Both the Ultrabeam and the SteppIR are now polled four times a second WHILE IT MOVES (and left at two seconds when it is still, where nothing changes), the gag only bridges the command itself, and the widget follows at half a second. The SteppIR also reports a commanded move at once, as the Ultrabeam already did: it says nothing until its own poll comes round, so the shortened gag would otherwise have released the transmitter in the middle of a move.",
"Motorised antenna: the indicator turns amber the instant the move is ORDERED, from a button or from an automatic band change with tracking on, instead of waiting for the antenna to say it is moving. The transmit gag already started there; the screen did not, so the two disagreed by a second or more — and on a follow there was no warning at all until a poll landed.",
"Icom network audio starts at once instead of half a minute later. The message that authorises the stream is sent during the login, before the audio socket exists — so the rig was told to send audio to a port nothing was bound to, got a port-unreachable back, and only resumed when its own retry timer came round. It is sent once more as soon as the port is listening.",
"Icom over the network: when CI-V goes quiet while the experimental RX audio stream is still delivering, the log now says so and names the switch to try. The two share the rigs session, and the shape in the field is exactly that — hundreds of audio packets arriving, not one CI-V reply, the watchdog tearing the session down, and the whole thing starting again. The silence report also lists the last eight CI-V commands sent: a rig that answers at connect and then never again has usually been sent something it does not like, and a count of unanswered commands never said which one.",
"Icom over the network: a rig left in standby no longer sits in a dial-and-drop loop. The clock that bounds “the control link answers but no CI-V comes back” belongs to a session and was never cleared when a new one opened, so every fresh session started already past its grace — torn down at once, redialled twenty seconds later, and torn down again for as long as the radio was asleep. Silent since connect is now read as what it is: a rig in standby, with the session kept so it can be woken.",
"The Icom console appears whenever the configured radio is an Icom, not only once the rig is talking — the console is where the power-ON button lives, so it used to be missing at the one moment it was needed. The consoles configured backend also follows a radio switched from the status bar, instead of waiting for a trip through Settings and a Save that changed nothing.",
"Audio: the Listening device now says when it cannot be opened. A device unplugged, renamed by Windows or unable to run at 16 kHz failed silently while everything upstream reported success — the stream up, the packets arriving, the monitor started — which is the whole of “I turned the sound on and nothing comes out”. The log also says, once, whether the network RX audio is reaching the speakers or arriving with nobody listening.",
"ADIF export: a record is written on one line again. ADDRESS is a multi-line field by the standard and callbooks and other loggers fill it that way — “Kabul”, four blank lines, “Afghanistan” — and OpsLog wrote it out as it was, so a record ran down a dozen lines with the next apparently starting in the middle of the page. Line breaks inside a value are now joined with a comma, which is how an address reads on one line anyway. The files were always valid (ADIF counts bytes); they were unreadable.",
"Club Log uploads are no longer refused as “not configured”. The check added for services with no credentials demanded a Club Log API key, which nobody has ever set — OpsLog carries its own application key — so an operator whose live upload had worked for months was turned away when sending QSOs by hand. Each services requirements now live beside the uploader that enforces them, and the message names the fields that are actually missing.",
"FT map: the callsign, square and report show on hover again. The invisible circle that catches the clicks sits on top of the dot, so it takes the hover too — and the label was bound only to the dot underneath, which left the map silent from the moment the stations became clickable."
],
"fr": [
"[NEW] Les couleurs de mise en évidence WSJT-X / JTDX sont au choix (Réglages → UDP), une par verdict — watchlist, nouveau DXCC, nouvelle bande, contactée. Seul le fond se règle : la couleur du texte en est déduite, pour quune couleur choisie ne revienne jamais illisible dans la fenêtre du décodeur. Loption « griser les stations déjà contactées » garde sa raison d’être — elle décide SI les doublons sont marqués, pas de quelle couleur — et sappelle désormais « Marquer les stations déjà contactées ».",
"[NEW] Cadran rotor : échelle circulaire et faisceau au lieu dune flèche. Lanneau carré plaçait un repère à 45° plus loin du centre quun repère au nord — un cadran se lit par langle, donc lanneau qui sert de référence est désormais à la même distance tout autour. Lantenne est dessinée comme un secteur qui sestompe vers lextérieur, ce qui est la forme de ce quil représente ; là où la souris lenverrait apparaît dans la même forme en orange, et son azimut à la place du cap courant pendant quon vise. Vert pour où lantenne est, orange pour où elle irait, jaune pour ce qui a été demandé — le deuxième lobe dun Ultrabeam bidirectionnel et le boom en pointillés sont inchangés. Design dEC1KD, encore.",
"Mise en évidence WSJT-X : une station de la watchlist déjà contactée sur cette bande et ce mode nest plus peinte comme une station à appeler. La liste dit une intention, pas ce quil reste à faire, et son rose passait devant tout — y compris le carnet — si bien quune station déjà faite restait rose toute la session, sans moyen de la distinguer dune station encore à faire.",
"Antenne motorisée : l’émission est rendue dès que les éléments sarrêtent. Trois délais sajoutaient entre la fin du mouvement et le droit dappeler — lantenne interrogée toutes les deux secondes, le blocage d’émission maintenu trois secondes après la commande quoi quen dise lantenne, et l’écran rafraîchi toutes les trois. LUltrabeam comme la SteppIR sont désormais interrogées quatre fois par seconde PENDANT quelle bouge (et laissée à deux secondes à larrêt, où rien ne change), le blocage ne couvre plus que la commande elle-même, et le widget suit à la demi-seconde. La SteppIR signale aussi un mouvement dès quil est commandé, comme le faisait déjà lUltrabeam : elle ne dit rien avant sa propre lecture, et le blocage raccourci aurait sinon rendu l’émission en plein mouvement.",
"Antenne motorisée : lindicateur passe à lambre à linstant où le mouvement est COMMANDÉ, par un bouton comme par un changement de bande automatique quand le suivi est actif, au lieu dattendre que lantenne dise quelle bouge. Le blocage d’émission démarrait déjà là ; l’écran non, et les deux se contredisaient dune seconde ou plus — sur un suivi, il ny avait aucun signe avant larrivée dune lecture.",
"Laudio réseau Icom démarre tout de suite au lieu dune demi-minute plus tard. Le message qui autorise le flux part pendant la connexion, avant que la prise audio nexiste : le poste se voyait donc demander d’émettre vers un port où personne n’écoutait, recevait un « port injoignable » en retour, et ne reprenait quau tour suivant de son propre minuteur. Il est renvoyé dès que le port écoute.",
"Icom en réseau : quand le CI-V devient muet alors que le flux audio expérimental continue darriver, le journal le dit et nomme loption à essayer. Les deux partagent la session du poste, et cest exactement la forme observée en vrai — des centaines de paquets audio, pas une réponse CI-V, le chien de garde qui coupe la session, et tout qui recommence. Le rapport de silence liste aussi les huit dernières commandes CI-V envoyées : un poste qui répond à la connexion puis plus jamais sest en général vu envoyer quelque chose quil naime pas, et un compteur de commandes sans réponse na jamais dit laquelle.",
"Icom en réseau : un poste laissé en veille ne tourne plus en boucle connexion/déconnexion. Lhorloge qui borne « la liaison de contrôle répond mais aucun CI-V ne revient » appartient à une session et n’était jamais remise à zéro à louverture de la suivante : chaque nouvelle session démarrait déjà au-delà de son délai de grâce — coupée aussitôt, rappelée vingt secondes plus tard, recoupée, aussi longtemps que la radio dormait. « Silencieux depuis la connexion » se lit désormais pour ce que cest : un poste en veille, dont on garde la session pour pouvoir le réveiller.",
"La console Icom saffiche dès que la radio configurée est un Icom, et pas seulement quand le poste parle — cest là que se trouve le bouton dallumage, il manquait donc au seul moment où il servait. Le backend configuré suit aussi un changement de radio fait depuis la barre d’état, au lieu dattendre un passage dans les réglages et un « Enregistrer » qui ne changeait rien.",
"Audio : le périphérique d’écoute signale désormais quand il ne peut pas souvrir. Un périphérique débranché, renommé par Windows ou incapable de fonctionner en 16 kHz échouait en silence pendant que tout en amont annonçait le succès — flux ouvert, paquets reçus, moniteur démarré — ce qui est exactement le « jai remis le son et rien ne sort ». Le journal dit aussi, une fois, si laudio réseau atteint les haut-parleurs ou arrive sans que personne n’écoute.",
"Export ADIF : un enregistrement tient de nouveau sur une ligne. ADDRESS est un champ multiligne selon la norme, et les callbooks comme les autres logiciels le remplissent ainsi — « Kabul », quatre lignes vides, « Afghanistan » — quOpsLog recopiait tel quel : un enregistrement s’étalait sur une douzaine de lignes, le suivant semblant commencer au milieu de la page. Les retours à la ligne dans une valeur sont désormais réunis par une virgule, ce qui est de toute façon la façon de lire une adresse sur une ligne. Les fichiers étaient valides (lADIF compte les octets) ; ils étaient illisibles.",
"Les envois vers Club Log ne sont plus refusés comme « non configuré ». Le contrôle ajouté pour les services sans identifiants réclamait une clé API Club Log que personne na jamais saisie — OpsLog embarque la sienne — et un opérateur dont lenvoi automatique fonctionnait depuis des mois se voyait éconduit au moment denvoyer des QSO à la main. Les exigences de chaque service vivent désormais à côté du code qui les applique, et le message nomme les champs réellement manquants.",
"Carte FTx : lindicatif, le locator et le report réapparaissent au survol. Le cercle invisible qui capte les clics est au-dessus du point, donc il capte aussi le survol — et l’étiquette n’était liée quau point du dessous, ce qui rendait la carte muette dès que les stations sont devenues cliquables."
]
},
{
"version": "0.27.14",
"date": "",
"en": [
"[NEW] The mouse wheel steps the RST fields, in the entry strip and in the QSO editor. It counts the way an operator does — 57, 58, 59, 59+5, 59+10, 59+15, 59+20 — one S-unit up to nine and then five decibels at a time, and one decibel on a digital report. R and T do not move. The dropdown beside them lists the reports worth having to hand, not every legal one, so the wheel works on the value rather than walking the list.",
"[NEW] The world map opens centred on YOUR square, not on Greenwich. Centred on 0° it left an Australian looking at their own country in the bottom-right corner with every path running off both edges; centred on their own longitude the same map reads the way their antenna does — the Americas to the east, Europe and Africa to the west. The latitude leans towards your hemisphere without following you to the pole, and a view you have panned to yourself still wins.",
"The world map now waits for the stations square before painting. It used to draw the world at 0° and then move to your longitude, fetching a screenful of tiles and discarding it on every first run; it is built once, knowing where it is looking. A profile with no locator still gets the default view after a moment rather than a blank panel.",
"[NEW] The FT decodes map and the grid-square map remember where you left them — centre and zoom, portable with the data folder like the world maps own view. Panning a map is the operator saying which part of the world they are working, and it was being thrown away on every tab switch.",
"[NEW] FT map: the station dots answer the same two gestures as the decodes list — one click takes the callsign into the entry, two answer it. The hit area is wider than the dot, and a double click no longer zooms the map on its way through.",
"The auto-call readout moved out of the Auto button and beside it: the station being called is the biggest thing on the row, the calls and the missed periods each carry a label instead of reading as one number, and a station being waited for shows with an hourglass. The button had been changing width every period.",
"Auto-call: the rest between two series is counted in the stations own overs, not in minutes, and is ONE by default. Two minutes is four overs on FT8 — by then the DX has worked four other callers and half the time it has gone. Seven calls, one over listened through, and it goes again if the station is still there (Settings → DXHunter, “Rest (overs)”).",
"Auto-call sees a decode that arrives after the others. A decoder sends a period in a burst and stragglers follow — a deep decode a second behind the rest — and the straggler was judged on its own, with the thirty stations of its own period nowhere in sight. The period now stays open until the next one starts, and a late arrival is weighed against all of it.",
"Auto-call: a period the station was decoded in is never counted as a miss. A period is judged more than once — the decodes arrive in a burst and stragglers follow — and a later judgement holds a partial view of it, not evidence of absence: a station answering in that very period showed “1/3 missed” against it.",
"Auto-call never parks a watched callsign. After a few series of unanswered calls a station is set aside for the session — the right answer for one the LOG picked out, the wrong one for a station YOU named: a DXpedition running a pileup takes more than two series to get through to, which is exactly why it is on the list. The rest between series still applies.",
"PSK Reporter panel: with the whole-band scope, clicking a decode no longer resets the report count to zero. The window there belongs to the BAND — every FTx report on it, filtered by target only when the analysis is drawn — and it was being emptied on every target change, throwing away an hour of evidence at the exact moment it was worth something. The narrow scope still clears it, because there the window is one stations.",
"Changing mode with a callsign in the field now fixes the report. The “the operator chose this report” flag was holding across a change of mode, where it means nothing — “+00” is not a weak SSB report, it is not a report at all — and anything that fills the field from the rig (the S-meter readouts in the rig consoles) sets that flag too, so it could stay in the wrong notation for the whole QSO. A judgement that can be carried across is carried (57 → 579, 599 → 59); otherwise the modes preset answers.",
"Icom CI-V: address 00 can be set, and “Other (custom address)” stays chosen. Zero was treated as “not configured” and every save put the rig back to the IC-7610s 98 — the model list following it, since it is derived from the address rather than stored.",
"Cloudlog / Wavelog upload: a simplex contact is no longer uploaded as split. Every QSO carried a receive band and frequency equal to the transmit side, and Wavelog draws both — an ordinary FT8 contact read “17m/17m”. In ADIF an absent BAND_RX means “same as transmit”, so they are now written only when they differ. The record forwarded to another logger on the UDP link still carries them in full (Log4OM reads BAND_RX).",
"Right-click → Send to: an upload to a service with no credentials is refused, and says which ones are missing and where. It used to run on its own and report into the QSL Managers console, which is not open when the command came from the QSO list — so it looked exactly like an upload that worked. Cloudlog / Wavelog and HamQTH also name themselves properly in the toast.",
"Cluster: “S/F” in a spot comment is read as FT8, alongside “superfox”, “sfox” and “F/H”. They are all the same DXpedition transmit mode, and the comment was falling through to the band plan and coming out DATA."
],
"fr": [
"[NEW] La molette fait défiler les champs RST, dans la barre de saisie comme dans l’éditeur de QSO. Elle compte comme un opérateur — 57, 58, 59, 59+5, 59+10, 59+15, 59+20 — un point S jusqu’à neuf puis cinq décibels à la fois, et un décibel sur un report numérique. R et T ne bougent pas. La liste déroulante à côté contient les reports quon veut sous la main, pas tous les reports légaux : la molette agit donc sur la valeur plutôt que de parcourir la liste.",
"[NEW] La carte du monde souvre centrée sur VOTRE locator, plus sur Greenwich. Centrée sur 0°, elle laissait un Australien avec son pays dans le coin en bas à droite et tous les trajets qui sortaient des deux bords ; centrée sur sa longitude, la même carte se lit comme son antenne travaille — les Amériques à lest, lEurope et lAfrique à louest. La latitude penche vers votre hémisphère sans vous suivre jusquau pôle, et une vue que vous avez déplacée vous-même reste prioritaire.",
"La carte du monde attend désormais le locator de la station avant de peindre. Elle dessinait le monde à 0° puis se déplaçait sur votre longitude, chargeant un écran de tuiles jeté aussitôt à chaque premier lancement ; elle est construite une fois, en sachant où elle regarde. Un profil sans locator obtient toujours la vue par défaut après un instant, pas un panneau vide.",
"[NEW] La carte des décodages FTx et la carte des locators retiennent où vous les avez laissées — centre et zoom, portables avec le dossier de données comme la vue de la carte du monde. Déplacer une carte, cest dire quelle partie du monde on travaille, et c’était jeté à chaque changement donglet.",
"[NEW] Carte FTx : les points des stations répondent aux mêmes deux gestes que la liste des décodages — un clic met lindicatif dans la saisie, deux lappellent. La zone cliquable est plus large que le point, et un double clic ne zoome plus la carte au passage.",
"Laffichage de lauto-call sort du bouton Auto pour se placer à côté : la station appelée est l’élément le plus lisible de la ligne, les appels et les périodes ratées portent chacun leur étiquette au lieu de se lire comme un seul nombre, et une station attendue saffiche avec un sablier. Le bouton changeait de largeur à chaque période.",
"Auto-call : le repos entre deux séries se compte en tours de la station, plus en minutes, et vaut UN par défaut. Deux minutes, cest quatre tours en FT8 — le DX a travaillé quatre autres appelants entre-temps, et la moitié du temps il est parti. Sept appels, un tour écouté, et ça repart si la station est toujours là (Réglages → DXHunter, « Repos (tours) »).",
"Lauto-call voit un décodage qui arrive après les autres. Un décodeur envoie une période en rafale, puis les retardataires — un décodage « deep » une seconde plus tard — et le retardataire était jugé tout seul, sans les trente stations de sa propre période. La période reste maintenant ouverte jusquau début de la suivante, et un arrivant tardif est pesé face à lensemble.",
"Auto-call : une période où la station a été décodée nest plus comptée comme un raté. Une période est jugée plusieurs fois — les décodages arrivent en rafale puis les retardataires — et un jugement tardif nen donne quune vue partielle, pas la preuve dune absence : une station qui répondait dans cette période exacte se voyait compter « 1/3 raté ».",
"Lauto-call ne met jamais de côté un indicatif de la watchlist. Après quelques séries dappels sans réponse, une station est écartée pour la session — la bonne réponse pour une station choisie par le CARNET, la mauvaise pour une station que VOUS avez nommée : un DX en pile-up demande plus de deux séries pour passer, et cest précisément pour ça quil est sur la liste. Le repos entre séries sapplique toujours.",
"Panneau PSK Reporter : en portée « toute la bande », cliquer sur un décodage ne remet plus le nombre de reports à zéro. La fenêtre appartient là à la BANDE — tous les reports FTx qui y circulent, filtrés par cible seulement à laffichage — et elle était vidée à chaque changement de cible, jetant une heure dobservations au moment précis où elles servent. La portée étroite continue de la vider : là, la fenêtre est celle dune seule station.",
"Changer de mode avec un indicatif dans le champ corrige désormais le report. Le drapeau « lopérateur a choisi ce report » tenait au travers dun changement de mode, où il ne veut rien dire — « +00 » nest pas un report SSB faible, ce nest pas un report du tout — et tout ce qui remplit le champ depuis le poste (les lectures S-mètre des consoles) lève ce drapeau aussi : la notation pouvait rester fausse pour tout le QSO. Un jugement transposable lest (57 → 579, 599 → 59) ; sinon le préréglage du mode répond.",
"Icom CI-V : ladresse 00 peut être saisie, et « Other (custom address) » reste sélectionné. Le zéro était pris pour « non configuré » et chaque enregistrement remettait le poste sur le 98 de lIC-7610 — la liste des modèles suivant, puisquelle est déduite de ladresse et non enregistrée.",
"Upload Cloudlog / Wavelog : un contact simplex nest plus envoyé comme un split. Chaque QSO portait une bande et une fréquence de réception égales à l’émission, et Wavelog affiche les deux — un FT8 ordinaire se lisait « 17m/17m ». En ADIF, un BAND_RX absent signifie « identique à l’émission » : ils ne sont donc écrits que sils diffèrent. Lenregistrement transmis à un autre logiciel par UDP les porte toujours en entier (Log4OM lit BAND_RX).",
"Clic droit → Envoyer vers : un envoi vers un service non configuré est refusé, en disant ce qui manque et où. Il partait tout seul et rendait compte dans la console du gestionnaire QSL, qui nest pas ouverte quand la commande vient de la liste des QSO — ça ressemblait donc exactement à un envoi réussi. Cloudlog / Wavelog et HamQTH sannoncent aussi sous leur nom dans le message.",
"Cluster : « S/F » dans un commentaire de spot est lu comme du FT8, au même titre que « superfox », « sfox » et « F/H ». Cest le même mode d’émission DXpédition, et le commentaire retombait sur le plan de bande pour ressortir en DATA."
]
},
{
"version": "0.27.13",
"date": "",
"en": [
"[NEW] Rotor widget redrawn: a night world map behind a square azimuth scale, an orange pointer following the mouse so a click lands where it is aimed, a yellow marker on the azimuth ordered until the antenna gets there, and quick turns in columns of six. The Ultrabeam boom and its second lobe are still drawn, and Station Control keeps the plain dial — dial only, since anything under it is pushed off the bottom of the row. Design contributed by EC1KD — thank you.",
"[NEW] Settings → Rotator now chooses the dial: the new world-map compass or the classic one. Both are kept — one reads across the shack, the other is the compact dial that was there before — and the choice applies to the docked widget and to Station Control at once.",
"Rotor widget: the Stop button no longer flickers on a rotor standing still. Movement was inferred from a one-degree change, which is less than the jitter a controller reports at rest.",
"[NEW] A docked watch-list panel, showing only what is ON THE AIR and still needed — one line per band and mode — callsign, band, mode, what it is worth, how long ago — freshest first, click to tune, with the clusters own NEW DXCC / NEW BAND / NEW SLOT badge on each row. The Watchlist tab is a tab, and an operator working FT8 lives on the decodes one: a station you asked to be told about was appearing on a screen you were not looking at. Turn it on with the bell in the toolbar.",
"[NEW] FT decodes: a distance column, next to the square it is computed from and in your own unit (km or miles). Rounded to whole units — a four-character grid is a square tens of kilometres wide and nothing finer is honest.",
"[NEW] The PSK Reporter panel switches to the station auto-call is WAITING for (the hourglass), while nothing is being called. Its analysis takes a history query and a period or two of live reports to fill, so starting it when the DX finally comes free is starting it too late — the wait is exactly what it should be spent on.",
"[NEW] Chase new: a band selection of its own, under the option (160 m to 70 cm). The stations band list still applies underneath — this one says what is worth WATCHING tonight.",
"Chase new: the panel says what the feed is doing — up or down, how many reports it has taken, how many receiver squares it is subscribed to. An empty list said nothing about whether anything was arriving at all.",
"FT decodes: a receiver column appears when more than one decoder feeds the merged list, and the list empties for a receiver that changes band — half a screen of stations no longer reachable is worse than an empty one.",
"FT decodes: a pink WL badge marks a station on your watch list, and the period clock turns red while transmitting. It is the one thing on the screen that moves, so “am I on the air” is readable from across the room.",
"FT decodes: a message addressed to YOU is set in green, whole and bold, with a green edge on the row — read from the far side of the shack. The station you are calling keeps a light tint and its callsign picked out in red: most of what it sends goes to other people, and colouring those lines the same way said you were in a QSO you were not in.",
"Cluster: “superfox”, “super fox”, “fox/hound” and “F/H” in a spot comment are read as FT8. They are WSJT-Xs DXpedition transmit modes, not modes of their own, and the comment fell through to the bands default.",
"Auto-call: what it calls, and in what order. It answers only what the decodes list is SHOWING — the filters above it (CQ only, LoTW only, the category chips, continents, minimum report, search) now steer the transmitter as well as the eye, and the separate “LoTW users only” option is gone. A new prefix, county, state, square or park is worth a call too, at the foot of the ladder and only for the kinds you chase (Settings → DX Cluster). A watched callsign outranks everything not on the list, a real need beats an unconfirmed one at the same level, and a better station may take over from one that has not answered yet — never from a QSO in progress.",
"Auto-call: it calls THROUGH a pileup. It used to give up the moment the station it was calling answered somebody else — which is precisely how a DX with a queue behaves, and the only way to be the next one is to keep calling while it works the others. Still bounded by the call and miss counters, and a station in mid-exchange is still never CHOSEN as a new target.",
"Auto-call: safety and control. It is always OFF at launch and after a profile switch, never armed from a stored setting — it is the one feature that puts the station on the air by itself, and OpsLog starts with Windows. Halt is now a verdict: the station is set aside for the session and never called again until auto-call is switched off and on. It says what it is waiting for, showing a wanted station that is working somebody else next to the Auto button. And it can log every decision (Settings → DXHunter): one line per period saying what was on the air and why each station was refused.",
"Auto-call: a round of fixes from on-air use. It no longer cuts your own 73 short, calls four to six seconds late, starts a call and drops it a second later, ignores a station calling you, misreads MSHVs two-answers-in-one-line, refuses nearly everything on the air because it read the entitys “worked” as the stations, or opens with two misses already counted against a station it has only just picked.",
"Switching profile no longer leaves the previous logbooks verdicts on the screen. Coming back from a profile with an empty log, every decode and spot stayed badged NEW until a restart: the worked-index, the chase-new list and the cached verdicts are now all dropped when the logbook changes.",
"Chase new fixes: the header counts both what the filters show and what the panel holds (“3 of 12 heard”), a stored filter set that hides everything is ignored, and switching between “new” and “new + unconfirmed” re-reads the list instead of leaving old verdicts on screen.",
"PSK Reporter panel fixes: the history is asked for in both feed scopes and refreshed every five minutes, so a target picked shortly after start-up no longer shows an empty page; the suggested transmit offset always has an answer when there is data; and the texts say ten minutes, which is the window actually used.",
"Callbook lookup: a compound callsign with a page of its OWN keeps that pages location. HP/WE9G is filed on QRZ under exactly that form, with the Panama address and square the station is operating from, and OpsLog was throwing it away — the rule that drops a home address from a portable call was applying to pages that describe the operation itself. The records own country tells the two apart. Cached lookups heal on the next read; QSOs already logged without a grid keep it empty."
],
"fr": [
"[NEW] Widget rotor redessiné : carte du monde nocturne derrière une échelle dazimut carrée, aiguille orange qui suit la souris pour quun clic parte où on vise, repère jaune sur lazimut demandé jusqu’à larrivée de lantenne, et directions rapides en colonnes de six. Le boom Ultrabeam et son deuxième lobe sont toujours tracés, et Station Control garde le cadran seul — rien en dessous, ce qui y était se retrouvait coupé en bas de la rangée. Design proposé par EC1KD — merci à lui.",
"[NEW] Réglages → Rotator permet de choisir le cadran : la nouvelle boussole carte du monde ou le cadran classique. Les deux sont conservés — lun se lit de loin, lautre est le cadran compact davant — et le choix sapplique aussitôt au widget docké comme à Station Control.",
"Widget rotor : le bouton Stop ne clignote plus sur un rotor à larrêt. Le mouvement était déduit dun changement dun degré, soit moins que le tremblement de lecture dun contrôleur au repos.",
"[NEW] Un panneau watchlist docké, qui ne montre que ce qui est EN LAIR et manque encore — une ligne par bande et mode — indicatif, bande, mode, ce que ça vaut, il y a combien de temps — le plus frais en haut, clic pour sy caler, avec le badge NEW DXCC / NEW BAND / NEW SLOT du cluster sur chaque ligne. Longlet Watchlist est un onglet, et en FT8 on vit sur celui des décodes : une station quon avait demandé à surveiller apparaissait sur un écran quon ne regardait pas. À activer avec la cloche dans la barre doutils.",
"[NEW] FT decodes : une colonne distance, à côté du locator dont elle est calculée et dans votre unité (km ou miles). Arrondie à lunité — un locator à quatre caractères est un carré de plusieurs dizaines de kilomètres, rien de plus fin ne serait honnête.",
"[NEW] Le panneau PSK Reporter bascule sur la station que lauto-call ATTEND (le sablier), tant que rien nest appelé. Son analyse met une requête dhistorique et une période ou deux à se remplir : la lancer quand le DX se libère enfin, cest la lancer trop tard — lattente sert précisément à ça.",
"[NEW] Chase new : sélection de bandes propre au panneau, sous loption (160 m à 70 cm). La liste de bandes de la station sapplique toujours en dessous — celle-ci dit ce quon veut SURVEILLER ce soir.",
"Chase new : le panneau indique l’état du flux — connecté ou non, nombre de rapports reçus, nombre de carrés de réception souscrits. Une liste vide ne disait rien sur ce qui arrivait vraiment.",
"FT decodes : une colonne récepteur apparaît quand plusieurs décodeurs alimentent la liste fusionnée, et la liste se vide pour un récepteur qui change de bande — un demi-écran de stations devenues inaccessibles est pire quun écran vide.",
"FT decodes : un badge WL rose marque une station de votre watchlist, et lhorloge de période passe au rouge en émission. Cest le seul élément qui bouge à l’écran, donc « suis-je en émission » se lit de loin.",
"FT decodes : un message qui VOUS est adressé saffiche en vert, en entier et en gras, avec un liseré vert sur la ligne — lisible de lautre bout du shack. La station que vous appelez garde une teinte légère et son indicatif en rouge : lessentiel de ce quelle émet sadresse à dautres, et colorer ces lignes pareil laissait croire à un QSO en cours.",
"Cluster : « superfox », « super fox », « fox/hound » et « F/H » dans un commentaire de spot sont lus comme du FT8. Ce sont les modes d’émission DXpédition de WSJT-X, pas des modes en soi, et le commentaire retombait sur le mode par défaut de la bande.",
"Auto-call : ce quil appelle, et dans quel ordre. Il ne répond qu’à ce que la liste des décodes AFFICHE — les filtres du dessus (CQ seul, LoTW seul, les pastilles de catégorie, les continents, le rapport minimum, la recherche) pilotent désormais l’émission autant que l’œil, et loption distincte « utilisateurs LoTW uniquement » disparaît. Un nouveau préfixe, comté, état, locator ou parc vaut aussi un appel, au dernier barreau de l’échelle et seulement pour ce que vous chassez (Réglages → DX Cluster). Un indicatif en watchlist passe devant tout ce qui ny est pas, un vrai besoin devant un besoin non confirmé de même niveau, et une meilleure station peut prendre la place dune autre qui na pas encore répondu — jamais celle dun QSO en cours.",
"Auto-call : il appelle À TRAVERS un pile-up. Il abandonnait dès que la station appelée répondait à quelquun dautre — or cest exactement ce que fait un DX avec une file, et le seul moyen d’être le suivant est de continuer à appeler pendant quil travaille les autres. Toujours borné par les compteurs dappels et de ratés, et une station en plein échange nest toujours jamais CHOISIE comme nouvelle cible.",
"Auto-call : sécurité et contrôle. Il est toujours ARRÊTÉ au lancement et après un changement de profil, jamais armé depuis un réglage enregistré — cest la seule fonction qui met la station en émission toute seule, et OpsLog démarre avec Windows. Halt devient un verdict : la station est mise de côté pour la session et nest plus appelée jusqu’à ce que lauto-call soit désactivé puis réactivé. Il dit ce quil attend, en affichant à côté du bouton Auto la station voulue qui travaille quelquun dautre. Et il peut journaliser chaque décision (Réglages → DXHunter) : une ligne par période disant ce qui était sur lair et pourquoi chaque station a été refusée.",
"Auto-call : série de corrections issues du trafic. Il ne coupe plus votre 73, nappelle plus avec quatre à six secondes de retard, ne lance plus un appel pour le couper une seconde après, ne laisse plus sans réponse une station qui vous appelle, lit correctement les doubles réponses MSHV sur une même ligne, ne refuse plus presque tout ce qui passe parce quil prenait le « worked » de lentité pour celui de la station, et ne démarre plus avec deux ratés au compteur sur une station tout juste choisie.",
"Changer de profil ne laisse plus les verdicts du carnet précédent à l’écran. En revenant dun profil au log vide, tous les décodes et spots restaient marqués NEW jusquau redémarrage : lindex des contacts, la liste chase new et les verdicts en cache sont désormais vidés au changement de carnet.",
"Chase new, corrections : len-tête compte à la fois ce que les filtres montrent et ce que le panneau contient (« 3 sur 12 entendues »), un jeu de filtres enregistré qui cache tout est ignoré, et le passage entre « new » et « new + non confirmés » relit la liste au lieu de laisser danciens verdicts à l’écran.",
"Panneau PSK Reporter, corrections : lhistorique est demandé dans les deux portées du flux et rafraîchi toutes les cinq minutes, donc une cible choisie peu après le démarrage naffiche plus une page vide ; loffset d’émission suggéré donne toujours une réponse quand il y a des données ; et les textes annoncent dix minutes, la fenêtre réellement utilisée.",
"Recherche callbook : un indicatif composé qui a sa PROPRE fiche garde la position de cette fiche. HP/WE9G est déposé sur QRZ sous cette forme exacte, avec ladresse et le locator du Panama doù la station émet, et OpsLog les jetait — la règle qui écarte ladresse personnelle dun indicatif portable sappliquait aussi aux fiches qui décrivent lopération elle-même. Cest le pays de la fiche qui les distingue. Les fiches en cache se corrigent à la lecture suivante ; les QSO déjà enregistrés sans locator le restent."
]
},
{
"version": "0.27.12",
"date": "",
"en": [
"Help menu: “Join the Discord” opens the OpsLog Discord server in your browser.",
"Icom console: a tick box makes the band buttons recall the radio's own band stacking registers — where you last were on that band, in the mode you were in. Press the same band again to step through its three registers, exactly as the radio's band key does. A band the register cannot be read for still sends the fixed frequency.",
"Shared CAT: a frequency or mode just commanded is reported back at once instead of on the next poll, and a mode the radio is already in is no longer re-sent. WSJT-X waits for that readback before it believes the band changed — over a network CI-V link that wait was several seconds.",
"Chase new / band openings: the receiver radius goes up to 3000 km, and the PSK Reporter subscription now follows it. Widening the circle used to change nothing, because the reports it would have accepted were never sent to us. 300 km holds no receivers at all in much of VK, ZL or North America.",
"The radius is also reachable from the Chase new option itself, rather than only from inside the band-opening watch.",
"New PSK Reporter panel beside the FT decodes, which can be hidden: for the station you are calling, has he decoded YOU and how long ago, who near you he is hearing, who near him heard you, how many stations he is working through, and where his receive passband is free. Follows the station you click or call. Off by default — Settings → DXHunter/spots. The narrow feed is a few messages a second; a whole-band option is there for a fast machine.",
"FT Map: the arcs no longer run off the side of the map. The map shows one world, and a path crossing the antimeridian was drawn past 180° into the blank space beside it — from VK or ZL that is most of them, each one ending nowhere while its own marker sat on the far coast. A path now leaves one edge and re-enters at the other, at the latitude it left.",
"NEW — Auto-call: OpsLog can answer FT8/FT4 decodes on its own. It picks the best station on the air by what the log still needs — a watched callsign outranking the same need from anybody else — and, above all, it knows when to stop: 7 calls (15 for a watched callsign), 3 of the stations own transmit periods with no decode of it, a four-minute backstop, and three series per station for a whole session. A station in the middle of a QSO with somebody else is never called, because it cannot answer. Every decision is written to the log with its reason, the toolbar button shows the target and the count as it goes, and Halt stops it. OFF by default — Settings → DXHunter/spots. It keys your transmitter without asking, so read that panels warning before switching it on, and switch DXHunters own auto-call off: two programs answering decodes from one shack transmit over each other.",
"PSK Reporter panel: the window is ten minutes, and the history query now fills BOTH directions when the target changes. Side by side with DXHunter on the same station at the same moment it showed 18 decodes against 27, and a co-area station missing — five minutes catches about one upload cycle per reporting station, and most of them report every five.",
"Auto-call: “Chase only” takes SEVERAL callsigns, separated by spaces or commas. Nothing off the list is called, the priority ladder still orders the ones on it, and working one leaves the others callable.",
"Two new themes on DXHunters palette: “DXHunter”, its slate and its blue exactly, and “DXHunter orange”, the same slate with OpsLogs own orange accent. Both carry its status colours (emerald, amber, cyan, red), so a green number means the same thing in either window. Settings → General → Theme.",
"The auto-call chase list is also in the decodes toolbar, beside the Auto button: naming the station you are waiting for is done while watching the band, not in a settings tree. It is the same setting as the one in Preferences — type into either and both show it.",
"FT decodes: one click SELECTS a station — it fills the entry and points the panels at it, like a cluster spot — and a double click calls it. A single click used to hand the decode straight to WSJT-X as a reply, so brushing a row while reading the band started transmitting.",
"Auto-call with two decoders running: while a station is being called, a period from the other receiver is not looked at. It cannot start a second QSO over the one in progress however good the station it hears, its periods count no missed periods against a target that was never on its band, and its transmissions are not counted as calls to that target.",
"Watchlist: drawn as DXHunter draws it — the callsign in the interface font rather than monospaced (the difference that shows with the two windows side by side), badges at its size, a check or a warning triangle at the head of each spot line, and frequencies as 7.056 rather than 7.0560.",
"A decoder is named by what it IS, not by what it announces: Nexus sends its packets as “Tempo”, the engine inside it, and OpsLog showed a program the operator had never heard of. The id itself is untouched — it is what a reply, a halt and the auto-call are routed by.",
"WSJT-X colouring can grey out a station already worked on this band AND in this mode — the duplicate. Its own switch under the highlight option, off by default: the other three verdicts pick out a handful of decodes, this one can match most of a period on a well-filled log. Grey rather than a colour, because it says the opposite of the others.",
"Contest watchlist (Settings → DXHunter): beside the auto-add pattern there is now a list of callsigns, one per line. A pattern collects a fleet that shares a string — TM29WWA, HB9WWA, F4WWA/P — but not the station taking part under a callsign that says nothing about the event. Named in the list, it joins the contest watchlist the moment it is spotted."
],
"fr": [
"Menu Aide : « Rejoindre le Discord » ouvre le serveur Discord dOpsLog dans votre navigateur.",
"Console Icom : une case à cocher fait rappeler aux boutons de bande les registres de bande de la radio — là où vous étiez la dernière fois sur cette bande, dans le mode où vous étiez. Réappuyez sur la même bande pour parcourir ses trois registres, comme le fait la touche de bande du poste. Une bande dont le registre est illisible envoie toujours la fréquence fixe.",
"CAT partagé : une fréquence ou un mode quon vient de commander est renvoyé immédiatement, sans attendre le sondage suivant, et un mode que la radio a déjà nest plus réémis. WSJT-X attend cette relecture avant de croire au changement de bande — sur une liaison CI-V réseau, cette attente durait plusieurs secondes.",
"Chase new / ouvertures : le rayon des récepteurs monte à 3000 km, et labonnement PSK Reporter le suit désormais. Élargir le cercle ne changeait rien, car les reports quil aurait acceptés ne nous étaient jamais envoyés. 300 km ne contient aucun récepteur dans une bonne partie de VK, ZL ou dAmérique du Nord.",
"Ce rayon est aussi accessible depuis loption Chase new elle-même, et non plus seulement depuis la veille douvertures.",
"Nouveau panneau PSK Reporter à côté des décodages FTx, masquable : pour la station que vous appelez, vous a-t-il décodé et depuis combien de temps, qui entend-il près de chez vous, qui près de lui vous a entendu, combien de stations défilent chez lui, et où son passe-bande est libre. Il suit la station que vous cliquez ou appelez. Désactivé par défaut — Réglages → DXHunter/spots. Le flux étroit ne coûte que quelques messages par seconde ; une option « toute la bande » existe pour une machine rapide.",
"FT Map : les arcs ne partent plus hors de la carte. La carte naffiche quun seul monde, et un chemin franchissant lantiméridien était tracé au-delà de 180°, dans le vide à côté — depuis VK ou ZL cest la majorité dentre eux, chacun finissant nulle part alors que son propre marqueur était sur la côte opposée. Un chemin sort désormais par un bord et revient par lautre, à la latitude où il est sorti.",
"NOUVEAU — Appel automatique : OpsLog peut répondre seul aux décodages FT8/FT4. Il choisit la meilleure station en fonction de ce qui manque au log — un indicatif surveillé passant devant le même besoin chez un autre — et surtout il sait sarrêter : 7 appels (15 pour un indicatif surveillé), 3 périodes d’émission de la station sans la décoder, un butoir de quatre minutes, et trois séries par station pour toute une session. Une station en plein QSO avec quelquun dautre nest jamais appelée : elle ne peut pas répondre. Chaque décision est écrite dans le journal avec sa raison, le bouton de la barre affiche la cible et le décompte en direct, et Stop linterrompt. DÉSACTIVÉ par défaut — Réglages → DXHunter/spots. Il met votre émetteur en marche sans vous demander : lisez lavertissement du panneau avant de lactiver, et coupez lappel automatique de DXHunter — deux programmes qui répondent aux décodages du même shack s’émettent dessus.",
"Panneau PSK Reporter : la fenêtre passe à dix minutes, et la requête dhistorique remplit désormais les DEUX sens au changement de cible. Côte à côte avec DXHunter sur la même station au même instant, il affichait 18 décodages contre 27, et une station de la région manquait — cinq minutes ne captent quun cycle denvoi par station, et la plupart nenvoient que toutes les cinq minutes.",
"Appel automatique : « Chasser uniquement » accepte PLUSIEURS indicatifs, séparés par des espaces ou des virgules. Rien hors de la liste nest appelé, l’échelle de priorité départage ceux qui y sont, et en travailler un laisse les autres appelables.",
"Deux nouveaux thèmes sur la palette de DXHunter : « DXHunter », son ardoise et son bleu à lidentique, et « DXHunter orange », la même ardoise avec lorange dOpsLog. Les deux reprennent ses couleurs d’état (émeraude, ambre, cyan, rouge) : un nombre vert veut dire la même chose dans les deux fenêtres. Réglages → Général → Thème.",
"La liste de chasse de lappel automatique est aussi dans la barre des décodages, à côté du bouton Auto : nommer la station quon attend se fait en regardant la bande, pas dans un arbre de réglages. Cest le même réglage que dans les Préférences — saisi dans lun, il apparaît dans lautre.",
"Décodages FT : un clic SÉLECTIONNE une station — il remplit la saisie et y pointe les panneaux, comme un spot du cluster — et un double-clic lappelle. Un simple clic passait le décodage directement à WSJT-X en réponse : frôler une ligne en lisant la bande déclenchait une émission.",
"Appel automatique avec deux décodeurs : pendant quune station est appelée, une période de lautre récepteur nest pas examinée. Il ne peut pas ouvrir un second QSO par-dessus celui en cours, si bonne que soit la station quil entend ; ses périodes ne comptent aucune période manquée contre une cible qui na jamais été sur sa bande, et ses émissions ne comptent pas comme des appels vers elle.",
"Watchlist : dessinée comme DXHunter la dessine — lindicatif dans la police de linterface plutôt quen chasse fixe (la différence qui saute aux yeux avec les deux fenêtres côte à côte), pastilles à sa taille, coche ou triangle dalerte en tête de chaque ligne de spot, et fréquences en 7.056 plutôt que 7.0560.",
"Un décodeur est nommé par ce quil EST, non par ce quil annonce : Nexus envoie ses paquets sous le nom « Tempo », le moteur quil embarque, et OpsLog affichait un programme inconnu de lopérateur. Lidentifiant lui-même nest pas touché : cest par lui que passent une réponse, un Stop et lappel automatique.",
"Coloration WSJT-X : possibilité de griser une station déjà travaillée sur cette bande ET dans ce mode — le doublon. Sa propre case sous loption de coloration, désactivée par défaut : les trois autres verdicts désignent quelques décodages, celui-ci peut concerner la majorité dune période sur un log bien rempli. En gris et non en couleur, car il dit linverse des autres.",
"Watchlist contest (Réglages → DXHunter) : à côté du motif dajout automatique, une liste dindicatifs, un par ligne. Un motif attrape une flotte qui partage une chaîne — TM29WWA, HB9WWA, F4WWA/P — mais pas la station engagée sous un indicatif qui ne dit rien de l’événement. Nommée dans la liste, elle rejoint la watchlist contest dès quelle est spottée."
]
},
{
"version": "0.27.11",
"date": "",
"en": [
"Country resolution with the ClubLog file enabled: retired prefixes are recognised again. cty.dat describes the world as it is TODAY — Niue moved to E6, so ZK2 reverted to New Zealand there, and every ZK2 contact ever made was silently relabelled New Zealand, taking a confirmed entity out of the operators DXCC with it. ZK1 lost the Cook Islands the same way. ClubLogs prefix table still knows both and is now consulted whenever no per-callsign exception applies, instead of only for callsigns that happened to have one. It never overrules an exact “=CALLSIGN” entry in cty.dat, and where it has no answer cty.dat still decides. Reprocess an affected import with Update from ClubLog.",
"CQ and ITU zones now come from the callbook when it has them. cty.dat carries ONE representative pair per entity, and OpsLog was stamping it over QRZ.coms per-station answer — so every Asiatic Russia contact was logged CQ 17 / ITU 30, whatever the operators real zone (RU0LL is 19/34, UA0SDX 18/32). That is a WAZ credit for a zone never worked. The entity still comes from cty.dat, which is the authority on what a callsign IS; a zone says where the station SITS, and only the callbook knows that within a country eight CQ zones wide. Where the callbook is silent, cty.dat fills as before.",
"Callsign cache: a lookup is now stored as the callbook returned it, and the country file is applied when it is read. A value OpsLog derived can no longer come back later looking like something the page said — which is how the wrong zones outlived their fix — and a country-file update now reaches rows already cached.",
"“Send to Cloudlog / Wavelog” joins the right-click upload menu. It was the one configured service missing from it: Cloudlog keeps no per-QSO sent status — deliberately, since it dedupes server-side — and the menu had been built around that status. An explicit selection needs no status to be safe, which is exactly why the absence stops mattering here.",
"HAMLOG.online uploads are closed. The site no longer issues API keys and its upload API takes nothing else, so the auto-upload switch and the “Send to” entry armed something that could only fail. Everything else stays: their confirmations still import from a file (which never needed a key), and the sent/received state already in your log remains readable, filterable and bulk-editable. The upload code is kept whole against the day keys come back.",
"Yaesu console: the power slider no longer springs back to 100 W on a 200 W radio. The console already knew an FTDX101MP could do 200 — that is what it drew the slider to — but the command that sets it was clamped to 100, so the rig was politely given half of what was asked for and the next poll showed it. FTDX101MP, FT-DX5000 and FTDX9000 reach 200 W now, and a rig reporting more than expected is still believed.",
"Chase new: clicking a row now does what clicking a cluster spot does — including telling WSJT-X, JTDX or MSHV to change mode, so picking an FT4 station while the decoder sits in FT8 actually moves it. It also brings across the mode and its RST preset, and any park or summit reference. It used to do a hand-picked half of that, which left the one thing the window exists for — jumping onto a station — decoding the wrong mode.",
"FT decodes: JTDX on FT4 no longer reads as Q65. A decode carries a one-character mode marker, and the two programs disagree about “:” — Q65 in WSJT-X, FT4 in JTDX — so the character alone cannot answer, and the wrong answer poisoned every verdict behind it: new mode, new slot, the mode filter. The sending programs own status names the mode in full and now settles it; the markers both forks agree on are untouched."
],
"fr": [
"Résolution des entités avec le fichier ClubLog activé : les préfixes retirés sont de nouveau reconnus. cty.dat décrit le monde tel quil est AUJOURDHUI — Niue est passée en E6, donc ZK2 y est revenu à la Nouvelle-Zélande, et tous les contacts ZK2 jamais faits étaient silencieusement réétiquetés Nouvelle-Zélande, emportant une entité confirmée hors du DXCC de lopérateur. ZK1 perdait les Cook du Sud de la même façon. La table de préfixes ClubLog connaît toujours les deux : elle est désormais consultée dès quaucune exception par indicatif ne sapplique, et non plus seulement pour les indicatifs qui en avaient une. Elle ne prime jamais sur une entrée exacte « =INDICATIF » de cty.dat, et là où elle na pas de réponse cest cty.dat qui tranche. Repassez un import concerné par « Mettre à jour depuis ClubLog ».",
"Les zones CQ et ITU proviennent désormais du callbook quand il les connaît. cty.dat ne porte QUUNE paire représentative par entité, et OpsLog limposait par-dessus la réponse par station de QRZ.com — tout contact avec la Russie asiatique était donc enregistré en CQ 17 / ITU 30, quelle que soit la zone réelle (RU0LL est en 19/34, UA0SDX en 18/32). Cest un crédit WAZ pour une zone jamais travaillée. Lentité vient toujours de cty.dat, qui fait autorité sur ce quun indicatif EST ; une zone dit où la station SE TROUVE, et seul le callbook le sait dans un pays large de huit zones CQ. Là où le callbook se tait, cty.dat comble comme avant.",
"Cache des indicatifs : une recherche est désormais stockée telle que le callbook la rendue, le fichier pays étant appliqué à la lecture. Une valeur déduite par OpsLog ne peut plus revenir plus tard avec lapparence de ce qua dit la page — cest ainsi que les mauvaises zones survivaient à leur correctif — et une mise à jour du fichier pays atteint maintenant les fiches déjà en cache.",
"« Envoyer vers Cloudlog / Wavelog » rejoint le menu denvoi du clic droit. C’était le seul service configuré qui y manquait : Cloudlog ne conserve aucun statut denvoi par QSO — volontairement, puisquil dédoublonne côté serveur — et le menu était construit autour de ce statut. Une sélection explicite na besoin daucun statut pour être sûre : cest précisément pourquoi cette absence cesse de compter ici.",
"Les envois vers HAMLOG.online sont fermés. Le site ne délivre plus de clé API et son interface denvoi naccepte rien dautre : la case denvoi automatique et lentrée « Envoyer vers » armaient donc quelque chose qui ne pouvait qu’échouer. Tout le reste demeure : leurs confirmations simportent toujours depuis un fichier (ce qui na jamais demandé de clé), et l’état envoyé/reçu déjà présent dans votre log reste lisible, filtrable et modifiable en masse. Le code denvoi est conservé intact pour le jour où les clés reviendraient.",
"Console Yaesu : le curseur de puissance ne revient plus à 100 W sur une radio de 200 W. La console savait déjà quun FTDX101MP peut sortir 200 — cest à cela quelle dimensionnait son curseur — mais la commande denvoi était bornée à 100 : le poste recevait donc poliment la moitié de ce quon lui demandait, et le sondage suivant laffichait. FTDX101MP, FT-DX5000 et FTDX9000 atteignent désormais 200 W, et une radio qui annonce plus que prévu reste crue sur parole.",
"Chase new : cliquer une ligne fait désormais ce que fait un clic sur un spot du cluster — y compris demander à WSJT-X, JTDX ou MSHV de changer de mode, si bien que choisir une station FT4 alors que le décodeur est en FT8 ly amène vraiment. Le mode et son RST par défaut suivent aussi, de même que toute référence de parc ou de sommet. La fenêtre nen faisait quune moitié choisie à la main, ce qui laissait la seule chose pour laquelle elle existe — sauter sur une station — décoder dans le mauvais mode.",
"FT decodes : JTDX en FT4 ne saffiche plus en Q65. Un décodage porte un marqueur de mode dun seul caractère, et les deux logiciels ne saccordent pas sur « : » — Q65 pour WSJT-X, FT4 pour JTDX : le caractère seul ne peut donc pas trancher, et sa mauvaise réponse contaminait tout ce qui en découle — nouveau mode, nouveau slot, filtre de mode. Le statut envoyé par le logiciel lui-même nomme le mode en entier et tranche désormais ; les marqueurs sur lesquels les deux saccordent ne changent pas."
]
},
{
"version": "0.27.10",
"date": "",
"en": [
"Widget order (Settings → Appearance): the row to the right of the entry can be rearranged by dragging — the whole row is the handle, and a line shows where it will land. QSO entry and the F1-F5 panel head the list, locked — they are not part of that row and nothing should be allowed to push what you type into behind a rotator dial. A widget you have switched off keeps its place and comes back where you left it, and the main view follows as you drag.",
"Rotator, GS-232 over a serial port: the port is opened once and kept, instead of being reopened for every command. An Arduino-based controller — K3NGs firmware, the ERC family — RESETS when its serial port is opened, so OpsLog was rebooting it several times a second and every command landed in the bootloader: a controller that answered a terminal perfectly reported “no reply to C” here. A freshly opened port is now left to boot before the first command, stale bytes from a previous exchange are discarded, and a failed exchange releases the port so the next one starts clean."
],
"fr": [
"Ordre des widgets (Réglages → Apparence) : la rangée à droite de la saisie se réorganise par glisser-déposer — toute la ligne se saisit, et un trait montre où elle atterrira. La saisie du QSO et le panneau F1-F5 ouvrent la liste, verrouillés — ils ne font pas partie de cette rangée, et rien ne doit pouvoir repousser ce dans quoi vous tapez derrière une boussole de rotor. Un widget désactivé garde sa place et revient là où vous laviez laissé, et la vue principale suit pendant que vous glissez.",
"Rotor, GS-232 sur port série : le port est ouvert une fois et conservé, au lieu d’être rouvert à chaque commande. Un contrôleur à base dArduino — le firmware K3NG, la famille ERC — REDÉMARRE à louverture de son port série : OpsLog le redémarrait donc plusieurs fois par seconde et chaque commande tombait dans le bootloader. Un contrôleur qui répondait parfaitement à un terminal annonçait ici « no reply to C ». Un port fraîchement ouvert a désormais le temps de démarrer avant la première commande, les octets résiduels dun échange précédent sont écartés, et un échange en échec libère le port pour que le suivant reparte propre."
]
},
{
"version": "0.27.9",
"date": "",
"en": [
"FT decodes warn when the decoding application announces a band the radio is not on — the signature of a lost CAT link, where it repeats the last frequency it knew and every decode after that carries a stale band. Nothing downstream could tell, so NEW BAND was being judged against a band the operator had left. OpsLog says it rather than deciding: a second receiver on another band is a real setup, and it costs that one only a line to read past.",
"Voice keyer with CAT keying: an option saying the keyers audio arrives on the radios DATA / USB input rather than the microphone socket. A Kenwood TS-590 has two transmit commands — TX opens the front mic, TX1 the rear ACC2/USB — so a keyer playing through the rigs own sound card was transmitting dead air while the radio listened to a microphone nobody was speaking into. Shown on the Kenwood backend only — no other radio family draws the distinction — and the Test PTT button exercises the same path."
],
"fr": [
"Les FT decodes signalent quand le logiciel de décodage annonce une bande sur laquelle la radio nest pas — la signature dune liaison CAT perdue, où il répète la dernière fréquence connue et où tous les décodages suivants portent une bande périmée. Rien en aval ne pouvait sen apercevoir : NOUVELLE BANDE était donc jugé sur une bande quittée. OpsLog le dit sans décider à votre place : un second récepteur sur une autre bande est une configuration légitime, et il ne lui en coûte quune ligne à ignorer.",
"Voice keyer avec PTT CAT : une option indiquant que laudio du keyer arrive sur lentrée DATA / USB de la radio et non sur la prise micro. Un Kenwood TS-590 a deux commandes d’émission — TX ouvre le micro de face avant, TX1 lACC2/USB — si bien quun keyer jouant par la carte son du poste émettait dans le vide pendant que la radio écoutait un micro devant lequel personne ne parlait. Affichée sur le backend Kenwood uniquement — aucune autre famille de postes ne fait cette distinction — et le bouton Test PTT emprunte le même chemin."
]
},
{
"version": "0.27.8",
"date": "",
+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)
}
}
+561 -208
View File
File diff suppressed because it is too large Load Diff
+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 { Checkbox } from '@/components/ui/checkbox';
import { useI18n } from '@/lib/i18n';
@@ -290,6 +292,116 @@ export function AppearancePanel() {
)}
<MatrixColorsSection />
<WidgetOrderSection />
</div>
);
}
// The row of widgets to the right of the entry, in the order they appear.
//
// Flexbox does the moving in the main view — this list only decides the order
// property each one gets. That is why a widget switched OFF still holds its
// place here: it comes back where the operator left it rather than at the end.
export const WIDGET_KEYS = [
'livestations', 'chat', 'rotor', 'motorant', 'antgenius',
'amp', 'tuner', 'scp', 'chasenew', 'watchlist', 'dvk', 'winkeyer', 'photo',
] as const;
const WIDGET_LABELS: Record<string, string> = {
livestations: 'wo.livestations', chat: 'wo.chat', rotor: 'wo.rotor',
motorant: 'wo.motorant', antgenius: 'wo.antgenius', amp: 'wo.amp',
tuner: 'wo.tuner', scp: 'wo.scp', chasenew: 'wo.chasenew', watchlist: 'wo.watchlist',
dvk: 'wo.dvk', winkeyer: 'wo.winkeyer', photo: 'wo.photo',
};
function readWidgetOrder(): string[] {
try {
const raw = localStorage.getItem('opslog.widgetOrder');
const arr = raw ? JSON.parse(raw) : null;
if (Array.isArray(arr)) {
// A key from an older build that no longer exists is dropped; a widget
// added since joins the end. An old preference can never hide a new one.
const known = arr.filter((k: any) => (WIDGET_KEYS as readonly string[]).includes(k));
return [...known, ...WIDGET_KEYS.filter((k) => !known.includes(k))];
}
} catch { /* corrupt pref → the default order */ }
return [...WIDGET_KEYS];
}
function WidgetOrderSection() {
const { t } = useI18n();
const [order, setOrder] = useState<string[]>(readWidgetOrder);
const dragKey = useRef<string | null>(null);
const [dragging, setDragging] = useState<string | null>(null);
// Where the row would land. Drawn as a line above the target rather than by
// colouring it: the question a dragging hand asks is "between which two", and
// a highlighted row answers a different one.
const [over, setOver] = useState<string | null>(null);
const commit = (keys: string[]) => {
setOrder(keys);
try { localStorage.setItem('opslog.widgetOrder', JSON.stringify(keys)); } catch { /* private mode */ }
// The main view listens: an order is meant to be watched as it is dragged,
// not discovered after closing Preferences.
EventsEmit('widgets:order', keys);
};
const moveTo = (from: string, to: string) => {
if (from === to) return;
const next = order.filter((k) => k !== from);
const at = next.indexOf(to);
next.splice(at < 0 ? next.length : at, 0, from);
commit(next);
};
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold">{t('wo.title')}</h3>
<p className="text-xs text-muted-foreground">{t('wo.hint')}</p>
<div className="space-y-1 max-w-md">
{/* The two that cannot move, shown so the order reads as the whole row
rather than as a list that mysteriously starts at the third item. */}
{['wo.entry', 'wo.details'].map((k) => (
<div key={k}
className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2 py-1.5 text-sm text-muted-foreground">
<Lock className="size-3.5 shrink-0 opacity-60" />
<span className="flex-1 min-w-0 truncate">{t(k)}</span>
</div>
))}
{order.map((k) => (
// The WHOLE row is the handle, not the grip alone: a list whose rows
// can only be moved by a 16-pixel icon is a list most people conclude
// cannot be moved. The grip stays as the sign that it can.
<div key={k} draggable
onDragStart={(e) => { dragKey.current = k; setDragging(k); e.dataTransfer.effectAllowed = 'move'; }}
onDragEnd={() => { dragKey.current = null; setDragging(null); setOver(null); }}
onDragOver={(e) => {
if (!dragKey.current) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (over !== k) setOver(k);
}}
onDragLeave={() => { if (over === k) setOver(null); }}
onDrop={(e) => {
if (!dragKey.current) return;
e.preventDefault();
moveTo(dragKey.current, k);
setOver(null);
}}
title={t('wo.drag')}
className={cn('flex items-center gap-2 rounded-md border bg-card px-2 py-1.5 text-sm select-none',
'cursor-grab active:cursor-grabbing transition-shadow',
dragging === k ? 'opacity-50 border-primary shadow-lg' : 'border-border hover:border-foreground/30',
// The landing line, on the edge the row would take.
over === k && dragging !== k && 'shadow-[inset_0_3px_0_0_var(--primary)]')}>
<GripVertical className="size-4 shrink-0 text-muted-foreground/50" />
<span className="flex-1 min-w-0 truncate">{t(WIDGET_LABELS[k] ?? k)}</span>
</div>
))}
</div>
<button type="button" onClick={() => commit([...WIDGET_KEYS])}
className="text-xs text-muted-foreground hover:text-foreground underline">
{t('wo.reset')}
</button>
</div>
);
}
+35 -5
View File
@@ -15,7 +15,7 @@ import { useI18n } from '@/lib/i18n';
import { chaseAllows } from '@/lib/spotDisplay';
import { markerColour } from '@/lib/spotMarkers';
import { cn } from '@/lib/utils';
import { GetChaseNewSpots } from '../../wailsjs/go/main/App';
import { GetChaseNewSpots, GetPSKReporterStatus } from '../../wailsjs/go/main/App';
export interface ChaseNewSpot {
call: string;
@@ -80,15 +80,25 @@ function categoryOf(s: ChaseNewSpot): Category | null {
const FILTER_KEY = 'opslog.chaseNewFilters';
function allowedCategories(): Category[] {
return CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => c.key);
}
function loadFilters(): Set<Category> {
const allowed = allowedCategories();
try {
const raw = localStorage.getItem(FILTER_KEY);
if (raw) {
const list = JSON.parse(raw) as Category[];
if (Array.isArray(list)) return new Set(list);
// A stored set holding NONE of the categories on offer hides the whole
// panel, for ever, with nothing to say why — and that is exactly what a
// preference written by an older build does once a category is renamed.
// Treated as "no preference": a panel that shows nothing at every launch
// is never what was meant, and the chips are one click away.
if (Array.isArray(list) && list.some((k) => allowed.includes(k))) return new Set(list);
}
} catch { /* a corrupt preference is not worth a broken panel */ }
return new Set(CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => c.key));
return new Set(allowed);
}
export function ChaseNewPanel({ onPick, onClose }: Props) {
@@ -96,6 +106,10 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
const [spots, setSpots] = useState<ChaseNewSpot[]>([]);
const [loaded, setLoaded] = useState(false);
const [on, setOn] = useState<Set<Category>>(loadFilters);
// The FEED, not the list: connected or not, how many reports it has taken,
// and what it is filtered on. Without it an empty panel says nothing about
// whether anything is arriving at all — which is the first question.
const [feed, setFeed] = useState<any>(null);
// Polled rather than pushed: the feed can deliver several a second under an
// opening, and an event per row would be a redraw per row for a list nobody
@@ -106,6 +120,8 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
try {
const r = ((await GetChaseNewSpots()) ?? []) as ChaseNewSpot[];
if (alive) { setSpots(r); setLoaded(true); }
const st = await GetPSKReporterStatus();
if (alive) setFeed(st);
} catch { /* the feed may not be up yet */ }
};
tick();
@@ -154,8 +170,13 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
))}
</div>
{/* Both numbers: what is on screen, and what was heard. They differ
exactly when a category is switched off, which is the one case an
operator reads this panel as broken. */}
<span className="shrink-0 text-[10px] text-muted-foreground">
{loaded ? t('chn.count', { n: shown.length }) : ''}
{loaded ? (shown.length === spots.length
? t('chn.count', { n: spots.length })
: t('chn.countOf', { n: shown.length, total: spots.length })) : ''}
</span>
{onClose && (
<button
@@ -218,7 +239,16 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
)}
</div>
<p className="border-t border-border px-2 py-1 text-[10px] text-muted-foreground">{t('chn.digitalOnly')}</p>
{/* The radius comes from the FEED, not from a sentence: it was written
into this line as "~300 km" and stayed 300 while the setting said
1000, which is the panel telling the operator their change did not
take when it had. */}
<p className="border-t border-border px-2 py-1 text-[10px] text-muted-foreground">
{t('chn.heardWithin', { km: feed?.near_km || 300 })}
{feed && (feed.running
? <span className="text-success"> · {t('chn.feedOn', { n: feed.received ?? 0, sq: feed.squares ?? 0 })}</span>
: <span className="text-warning"> · {feed.last_err ? t('chn.feedErr', { e: feed.last_err }) : t('chn.feedOff')}</span>)}
</p>
</div>
);
}
+386 -29
View File
@@ -12,13 +12,17 @@
// Status flags (new entity / band / mode / slot / grid / prefix / POTA / county)
// come from the same resolver the cluster uses, so a call means the same thing in
// both panels rather than being judged twice by two rules.
import { useEffect, useMemo, useState } from 'react';
import { Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { chaseAllows } from '@/lib/spotDisplay';
import { pathBetween } from '@/lib/maidenhead';
import { distanceValue, distanceUnit, subscribeDistanceUnit } from '@/lib/units';
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
import { writeUiPref } from '@/lib/uiPref';
import { SetAutoCallVisible } from '../../wailsjs/go/main/App';
import { decoderName } from '@/lib/decoderName';
export type Decode = {
call: string;
@@ -95,8 +99,17 @@ interface Props {
// receiver reported last, which is a coin toss — each pane needs its own.
txStates?: Record<string, TxMsg>;
spotStatus: Record<string, StatusEntry>;
// The band the RIG is on, when CAT is connected. Only ever compared with what
// the decoder announces — see the drift warning.
rigBand?: string;
onCall: (d: Decode) => void;
// A single click: take the station without transmitting — fill the entry, and
// point the panels at it. Absent, a click falls back to onCall.
onSelect?: (d: Decode) => void;
myCall?: string;
// The station's own square: distance is measured from it, and without one the
// column stays empty rather than guessing.
myGrid?: string;
// Drop every decode and transmit message held for this panel. The list is a
// live view, not data — clearing it costs nothing but the seconds until the
// next period lands.
@@ -112,6 +125,15 @@ interface Props {
// machine off is not something to go hunting through a settings tree for.
autoCallOn?: boolean;
onToggleAutoCall?: () => void;
// The engine's own account of what it is doing, straight from the backend.
autoCall?: { target: string; waiting: string; calls: number; max: number; misses: number; max_miss: number; stopped: boolean; reason: string };
// The chase list, here as well as in Preferences: naming the station you are
// waiting for is done WHILE watching the band, not in a settings tree.
autoCallOnly?: string;
onSetAutoCallOnly?: (list: string) => void;
// The watch list, as PATTERNS (VK9*, 3Y0J). A decode of one is worth saying
// so where the operator is reading the band, not only in the watchlist tab.
watchlist?: string[];
}
// The "new" categories, as toggle badges — the same idea and the same colours as
@@ -158,6 +180,21 @@ function catsOf(e: StatusEntry | undefined): Set<NewCat> {
return out;
}
// isWatched applies the watch list's own rule — a trailing "*" is a prefix,
// anything else is the whole callsign — so a decode is judged here exactly as
// the backend judges a spot. Two rules for one list is how a badge and an alert
// start disagreeing about the same station.
function isWatched(call: string, patterns: string[] | undefined): boolean {
if (!patterns || patterns.length === 0 || !call) return false;
const c = call.toUpperCase();
for (const raw of patterns) {
const p = (raw ?? '').toUpperCase().trim();
if (!p) continue;
if (p.endsWith('*') ? c.startsWith(p.slice(0, -1)) : c === p) return true;
}
return false;
}
const CAT_KEY = 'opslog.decodeCats';
const SPLIT_KEY = 'opslog.decodeSplit';
const FILTER_KEY = 'opslog.decodeFilters';
@@ -239,9 +276,14 @@ const CELL_LAST = 'flex items-center min-w-0 px-2 gap-1 overflow-hidden';
// One declaration per column, in display order: the header, the widths and the
// resize handles all read from this, so a column cannot be resized in the header
// and stay the old width in the body.
type ColKey = 'time' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'state' | 'country' | 'status';
type ColKey = 'time' | 'rx' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'dist' | 'state' | 'country' | 'status';
const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
{ key: 'time', tkey: 'dec.colTime', def: 64, min: 44 },
// WHICH RECEIVER heard it. Shown only while more than one is feeding, and
// that is the case it exists for: two decoders on one band send the same
// stations twice, each with its own SNR and DT, and a merged list gave no way
// at all to tell a second receiver from a duplicate.
{ key: 'rx', tkey: 'dec.colRx', def: 74, min: 44 },
{ key: 'snr', tkey: 'dec.colSnr', def: 50, min: 36 },
{ key: 'dt', tkey: 'dec.colDt', def: 44, min: 32 },
{ key: 'freq', tkey: 'dec.colFreq', def: 56, min: 40 },
@@ -255,6 +297,11 @@ const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
{ key: 'grid', tkey: 'dec.colGrid', def: 62, min: 46 },
// For the WAS chasers: the badge carries the two letters, the name spells
// them out — "SD" alone is a quiz for a European.
// Next to the square it is computed from. A four-character grid is a square
// tens of kilometres wide, so this is rounded to whole units and never
// pretends to more: it answers "is that station across the pond or across the
// valley", which is what decides whether the report is worth anything.
{ key: 'dist', tkey: 'dec.colDist', def: 70, min: 46 },
{ key: 'state', tkey: 'dec.colState', def: 120, min: 56 },
{ key: 'country', tkey: 'dec.colCountry', def: 140, min: 70 },
{ key: 'status', tkey: 'dec.colStatus', def: 186, min: 80 },
@@ -273,6 +320,39 @@ const US_STATES: Record<string, string> = {
WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming', DC: 'District of Columbia',
};
// The columns worth sorting on. Not every column: time is what the periods
// already are, and sorting a slot by callsign or message answers no question an
// operator has.
type SortKey = 'snr' | 'freq' | 'dist' | 'country' | 'status';
const SORTABLE: SortKey[] = ['snr', 'freq', 'dist', 'country', 'status'];
// Which way each column is worth reading FIRST — strongest signal, lowest
// frequency, furthest DX, A to Z, most wanted. Clicking again reverses it.
const SORT_FIRST: Record<SortKey, 'asc' | 'desc'> = {
snr: 'desc', freq: 'asc', dist: 'desc', country: 'asc', status: 'desc',
};
// How wanted a station is, as a number to sort by. The cluster's own order,
// most wanted first — a new entity above a new band above a new slot — so the
// two views rank the same things the same way.
const STATUS_RANK: Record<string, number> = {
'new': 100, 'new-band-mode': 90, 'new-band': 80, 'new-mode': 70, 'new-slot': 60,
'new-call': 30, 'worked': 10,
};
function statusRank(e?: StatusEntry): number {
if (!e) return 0;
let r = STATUS_RANK[e.status ?? ''] ?? 0;
// The markers that are orthogonal to the entity: a new county on a worked
// country is still something to chase, and should not sort with the plain
// duplicates.
if (e.new_pota) r = Math.max(r, 50);
if (e.new_county) r = Math.max(r, 45);
if (e.new_grid) r = Math.max(r, 44);
if (e.new_state) r = Math.max(r, 43);
if (e.new_pfx) r = Math.max(r, 42);
return r;
}
const COL_MAX = 600;
const COLW_KEY = 'opslog.decodeColWidths';
@@ -409,16 +489,31 @@ function periodLabel(ms: number, trSec: number): string {
// read "CQ CQ PE1NAO JO32" — the badge and the message's own first word saying
// the same thing twice. Highlighting the word already in the line keeps the
// scannability and drops the stutter.
function renderMsg(msg: string, me: string, calling: string) {
function renderMsg(msg: string, me: string, calling: string, toMe: boolean) {
if (!msg) return null;
// THE WHOLE LINE, when it is addressed to YOU.
//
// Token colouring is for reading the band — it picks your call out of a wall
// of other people's traffic. A message sent TO you is a different question:
// not "is my call in there somewhere" but "what did he just send me", read
// from the far side of the shack, so the whole line carries the colour.
//
// Only that case. The station you are calling also transmits to everybody
// else — "LA8WRA LA1RAU/P +00" is your target reporting to another caller —
// and setting those lines in the same strong colour said you were in a QSO
// you were not in. Its callsign is picked out by the token pass below, which
// is what "he is on the air, working somebody else" should look like.
if (toMe) {
return <span className="font-bold text-success">{msg}</span>;
}
// Split on whitespace and colour the tokens that matter, rather than the
// whole line: an operator scanning a slot is looking for their own call in
// the first position (someone answering) and for the station being called.
const parts = msg.split(/(s+)/);
const parts = msg.split(/(\s+)/);
return (
<>
{parts.map((tok, i) => {
if (/^s+$/.test(tok)) return tok;
if (/^\s+$/.test(tok)) return tok;
const bare = tok.replace(/[<>]/g, '').toUpperCase();
if (i === 0 && /^CQ$/i.test(tok)) return <span key={i} className="font-bold text-success">{tok.toUpperCase()}</span>;
if (me && bare === me) return <span key={i} className="font-bold text-success">{tok}</span>;
@@ -437,7 +532,7 @@ function renderMsg(msg: string, me: string, calling: string) {
//
// It is the one moving thing on the panel, and it answers the question an
// operator actually has between overs: how long until the next batch.
function PeriodClock({ trSec, mode }: { trSec: number; mode?: string }) {
function PeriodClock({ trSec, mode, tx }: { trSec: number; mode?: string; tx?: boolean }) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
// 100 ms: smooth enough for a bar that fills in three and three quarter
@@ -453,19 +548,26 @@ function PeriodClock({ trSec, mode }: { trSec: number; mode?: string }) {
// The last fifth of a slot is when a decode is imminent and an operator
// deciding whether to answer has run out of time to think.
const closing = left <= trSec / 5;
// TRANSMITTING outranks both. The bar is the one thing on this screen that
// moves continuously, so it is what the eye is already on — and "am I on the
// air" is the state worth reading from across the room. Red, and it stays red
// for the whole over rather than turning amber near the end of it.
const tone = tx ? 'danger' : closing ? 'warning' : '';
return (
<span className="flex items-center gap-2 shrink-0" title={mode ? `${mode} · ${trSec}s` : `${trSec}s`}>
<Timer className={cn('size-4', closing ? 'text-warning' : 'text-muted-foreground')} />
<Timer className={cn('size-4',
tone === 'danger' ? 'text-danger' : tone === 'warning' ? 'text-warning' : 'text-muted-foreground')} />
<span className="relative h-1.5 w-24 rounded-full bg-muted overflow-hidden">
<span
className={cn('absolute inset-y-0 left-0 rounded-full transition-[width] duration-100 ease-linear',
closing ? 'bg-warning' : 'bg-primary')}
tone === 'danger' ? 'bg-danger' : tone === 'warning' ? 'bg-warning' : 'bg-primary')}
style={{ width: `${pct}%` }}
/>
</span>
<span className={cn('font-mono text-sm tabular-nums w-10 text-right',
closing ? 'text-warning font-semibold' : 'text-muted-foreground')}>
tone === 'danger' ? 'text-danger font-semibold'
: tone === 'warning' ? 'text-warning font-semibold' : 'text-muted-foreground')}>
{left.toFixed(1)}
</span>
<span className="text-xs text-muted-foreground">
@@ -539,14 +641,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, myGrid, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: Props) {
const { t } = useI18n();
// Column widths, dragged in the header and shared by every row. Persisted
// through writeUiPref (not raw localStorage) so the layout travels with data/
// like every other portable preference.
const [colw, setColw] = useState<ColWidths>(loadWidths);
const template = useMemo(() => COLS.map((c) => `${colw[c.key]}px`).join(' '), [colw]);
const tableW = useMemo(() => COLS.reduce((s, c) => s + colw[c.key], 0), [colw]);
// The chase list as TYPED. Re-seeded whenever the stored value changes —
// from Preferences, or from another window — but never while the box has the
// focus, or a status arriving mid-word would rewrite what is being typed.
const [onlyText, setOnlyText] = useState(autoCallOnly ?? '');
useEffect(() => {
const el = document.activeElement as HTMLElement | null;
if (el && el.tagName === 'INPUT' && el.getAttribute('placeholder') === t('dec.chasePh')) return;
setOnlyText(autoCallOnly ?? '');
}, [autoCallOnly, t]);
const setColWidth = (key: ColKey, px: number) => {
const col = COLS.find((c) => c.key === key)!;
const w = Math.min(COL_MAX, Math.max(col.min, Math.round(px)));
@@ -595,8 +704,85 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
const statusOf = (d: Decode): StatusEntry | undefined =>
spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`];
// ── Sorting, inside a period ──────────────────────────────────────────
//
// WITHIN each slot and never across them. The periods are the point of this
// panel — what was on the air in one fifteen-second window — and a list
// sorted end to end by signal would mix three minutes of decodes into one
// column of numbers with no way to tell which slot any of them came from.
//
// Arrival order stays the default and stays one click away, because it
// mirrors the decoder's own window line for line, which is what makes the
// two screens comparable at a glance.
const [sortSpec, setSortSpec] = usePersisted('sort', '');
const [sortKey, sortDir] = useMemo(() => {
const [k, d] = String(sortSpec || '').split(':');
return [SORTABLE.includes(k as SortKey) ? (k as SortKey) : '', d === 'asc' ? 'asc' : 'desc'] as const;
}, [sortSpec]);
// One click sorts the way that column is worth reading — strongest signal,
// furthest DX, lowest frequency, A to Z, most wanted. The second reverses it,
// the third gives arrival order back.
const toggleSort = (k: SortKey) => {
if (sortKey !== k) { setSortSpec(`${k}:${SORT_FIRST[k]}`); return; }
if (sortDir === SORT_FIRST[k]) { setSortSpec(`${k}:${SORT_FIRST[k] === 'asc' ? 'desc' : 'asc'}`); return; }
setSortSpec('');
};
const sortValue = useCallback((d: Decode, k: SortKey): number | string => {
const e = statusOf(d);
switch (k) {
case 'snr': return d.snr;
case 'freq': return d.freq_hz ?? 0;
case 'dist': {
const g = d.grid || e?.grid || '';
const path = myGrid && g ? pathBetween(myGrid, g) : null;
// A station that never sent a grid cannot be placed. Sorted to the end
// whichever way round the column goes, rather than pretending to a
// distance of zero and sitting at the top of "nearest first".
return path ? path.distanceShort : Number.NaN;
}
case 'country': return (e?.country ?? '').toUpperCase();
case 'status': return statusRank(e);
}
}, [spotStatus, myGrid]);
const sortDecodes = useCallback((list: Decode[]): Decode[] => {
if (!sortKey) return list;
const sign = sortDir === 'asc' ? 1 : -1;
return [...list].sort((a, b) => {
const va = sortValue(a, sortKey), vb = sortValue(b, sortKey);
const na = typeof va === 'number' && Number.isNaN(va);
const nb = typeof vb === 'number' && Number.isNaN(vb);
if (na !== nb) return na ? 1 : -1; // unknowns last, both ways
if (na && nb) return 0;
if (typeof va === 'string' || typeof vb === 'string') {
const sa = String(va), sb = String(vb);
// An empty country is an unknown too, not a name that sorts first.
if (!sa !== !sb) return sa ? -1 : 1;
return sign * sa.localeCompare(sb);
}
return sign * ((va as number) - (vb as number));
});
}, [sortKey, sortDir, sortValue]);
// The mode currently on the air, for the slot clock. The newest decode knows
// best; between overs the transmit state still does.
// A decoder that has lost its CAT link keeps announcing the last dial
// frequency it knew, and every decode after that carries a stale band. Nothing
// downstream can tell: the entity verdicts, the band filter and the FT map all
// believe what the decoder said, and an operator ends up reading NEW BAND for a
// band they are not on. (Seen for real: MSHV lost CAT, kept saying 80 m, and
// Korea showed as a new band because on 80 m it would have been.)
//
// Said, not decided. Using the rig's band instead would be wrong for anyone
// decoding a second receiver on another band, and a warning costs that setup
// nothing but a line it can read past.
const decoderBand = decodes.length ? (decodes[decodes.length - 1].band ?? '') : '';
const bandDrift = !!rigBand && !!decoderBand
&& rigBand.toLowerCase() !== decoderBand.toLowerCase();
const driftInstance = decodes.length ? (decodes[decodes.length - 1].instance ?? '') : '';
const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode;
const liveTr = trSeconds(liveMode, decodes.length ? decodes[decodes.length - 1].tr_period : undefined);
@@ -608,6 +794,8 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
// opens with our callsign, sometimes bracketed when the sender compressed a
// non-standard call.
const me = (myCall ?? '').toUpperCase();
const [, bumpUnit] = useState(0);
useEffect(() => subscribeDistanceUnit(() => bumpUnit((n) => n + 1)), []);
const calling = (txState?.dx_call ?? '').toUpperCase();
const answersMe = (msg?: string): boolean => {
if (!me || !msg) return false;
@@ -644,6 +832,12 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [decodes, spotStatus]);
// The receiver column is dead weight with one decoder — which is nearly
// everybody — so it is not there at all until a second one starts feeding.
const cols = useMemo(() => COLS.filter((c) => c.key !== 'rx' || (instances.length > 1 && !splitByInstance)),
[instances.length, splitByInstance]);
const template = useMemo(() => cols.map((c) => `${colw[c.key]}px`).join(' '), [cols, colw]);
const tableW = useMemo(() => cols.reduce((s, c) => s + colw[c.key], 0), [cols, colw]);
// All seven, always, in their usual order.
//
// The chips used to be built from the continents ON the feed, which read as a
@@ -677,6 +871,22 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [decodes, spotStatus, cqOnly, lotwOnly, cats, bandSel, modeSel, contSel, minSnr, search]);
// What this panel is SHOWING, published to the auto-call engine.
//
// The filters are the operator's control over the transmitter as well as
// over the list: a station filtered off the screen is not called. The panel
// sends the callsigns rather than the filter settings, so there is one
// definition of "shown" and not two — the engine cannot disagree with what
// is in front of the operator.
useEffect(() => {
const calls = [...new Set(filtered.map((d) => (d.call ?? '').toUpperCase()).filter(Boolean))];
SetAutoCallVisible(calls, true).catch(() => {});
}, [filtered]);
// Closed, it publishes nothing: filters that are not on the screen cannot
// silence the engine behind the operator's back.
useEffect(() => () => { SetAutoCallVisible([], false).catch(() => {}); }, []);
// Group into periods, newest first, and drop the operator's transmissions into
// the slot they went out in.
@@ -687,19 +897,24 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
// Each pane cuts its own periods: the bands differ, so the slot boundaries and
// the transmit messages belong to one receiver and not the other.
const panes = useMemo(() => {
// The sort is applied to each period's decodes, never to the periods
// themselves: the slots stay newest-first, which is what the panel is.
const sorted = (ps: ReturnType<typeof buildPeriods>) =>
sortKey ? ps.map((p) => ({ ...p, decodes: sortDecodes(p.decodes) })) : ps;
if (!splitByInstance || instances.length < 2) {
return [{ key: '', label: '', tx: txState ?? undefined, periods: buildPeriods(filtered, txMsgs) }];
return [{ key: '', label: '', tx: txState ?? undefined, periods: sorted(buildPeriods(filtered, txMsgs)) }];
}
return instances.map((inst) => ({
key: inst,
label: inst,
// What the program is called, not the id it announces — see decoderName.
label: decoderName(inst),
tx: txStates?.[inst],
periods: buildPeriods(
periods: sorted(buildPeriods(
filtered.filter((d) => (d.instance ?? '') === inst),
txMsgs.filter((m) => (m.instance ?? '') === inst),
),
)),
}));
}, [filtered, txMsgs, splitByInstance, instances, txState, txStates]);
}, [filtered, txMsgs, splitByInstance, instances, txState, txStates, sortKey, sortDecodes]);
const resetFilters = () => {
@@ -732,7 +947,23 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
{/* The slot clock. Taken from the newest decode's mode, falling back to
what the transmit state reports, so it is right the moment anything
is heard and keeps running when the band goes quiet. */}
<PeriodClock trSec={liveTr} mode={liveMode} />
{/* Any receiver on the air colours it: with two decoders the shared
txState is whichever reported last, and "somebody here is
transmitting" is what the bar has to say. */}
<PeriodClock trSec={liveTr} mode={liveMode}
tx={!!txState?.transmitting || Object.values(txStates ?? {}).some((s) => s?.transmitting)} />
{bandDrift && (
<span
title={t('dec.bandDriftTip')}
className="inline-flex items-center gap-1 rounded-full border border-warning px-2 py-0.5 text-[11px] font-semibold text-warning">
<AlertTriangle className="size-3.5" />
{t('dec.bandDrift', {
app: decoderName(driftInstance) || t('dec.bandDriftApp'),
dec: decoderBand.toUpperCase(),
rig: (rigBand ?? '').toUpperCase(),
})}
</span>
)}
<span className="w-px h-5 bg-border/60 mx-1" />
<button type="button" className={chip(cqOnly, 'success')} onClick={() => setCqOnly(!cqOnly)}>
@@ -859,6 +1090,88 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
filter. Halt goes LAST — it is the one that must be findable without
reading, and the end of the row is the one position that never moves
as filters come and go. */}
{/* Auto-call. Deliberately next to Halt: the two belong together, and
what it is doing right now — which station, how many calls of how
many — is on the button itself, because a thing that keys the
transmitter must never be a switch with no readout. */}
{onToggleAutoCall && (
<button
type="button"
onClick={onToggleAutoCall}
title={autoCall?.stopped ? t('dec.autoStoppedTip') : t('dec.autoCallTip')}
className={cn('h-8 px-2.5 rounded-lg text-sm inline-flex items-center gap-1.5 border font-medium',
!autoCallOn
? 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
: autoCall?.stopped
? 'border-warning bg-warning text-warning-foreground'
: 'border-success bg-success text-success-foreground')}
>
<Bot className="size-3.5" />
{t('dec.autoCall')}
</button>
)}
{/* WHAT IT IS DOING, BESIDE THE SWITCH RATHER THAN ON IT.
A thing that keys the transmitter must never be a switch with no
readout — but the readout was crammed inside the button as
"Auto D2ACE 1/7 ·1/3", where the two counters read as one number and
the whole control changed width every period. Out here each figure
gets a label, and the station being called is the biggest thing on
the row. */}
{autoCallOn && autoCall?.target && (
<span className="h-8 inline-flex items-center gap-2 rounded-lg border border-success/50 bg-success/10 px-2"
title={t('dec.autoTargetTip', { call: autoCall.target })}>
<span className="font-mono text-sm font-bold text-success">{autoCall.target}</span>
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<span className="uppercase tracking-wide">{t('dec.autoCalls')}</span>
<span className="font-mono tabular-nums text-foreground">{autoCall.calls}/{autoCall.max}</span>
</span>
{/* Only once there is one to report: a zero that is always there is
a figure nobody reads, and it was the half of the pair that got
mistaken for the call count. */}
{autoCall.misses > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<span className="uppercase tracking-wide">{t('dec.autoMisses')}</span>
<span className={cn('font-mono tabular-nums',
autoCall.misses + 1 >= autoCall.max_miss ? 'text-warning' : 'text-foreground')}>
{autoCall.misses}/{autoCall.max_miss}
</span>
</span>
)}
</span>
)}
{/* Wanted, decoded, and in a QSO with somebody else. Holding fire looks
exactly like having nothing to do, and the operator had no way to
tell them apart. */}
{autoCallOn && !autoCall?.target && autoCall?.waiting && (
<span className="h-8 inline-flex items-center gap-1.5 rounded-lg border border-warning/50 bg-warning/10 px-2 animate-pulse"
title={t('dec.autoWaitTip', { call: autoCall.waiting })}>
<span className="text-sm"></span>
<span className="font-mono text-sm font-bold text-warning">{autoCall.waiting}</span>
<span className="text-[11px] uppercase tracking-wide text-muted-foreground">{t('dec.autoWaiting')}</span>
</span>
)}
{/* The chase list. Raw text while typing, committed on blur or Enter:
the stored value is upper-cased and trimmed, and binding the box to
that makes the space bar look dead — in a field whose whole purpose
is a list separated by spaces. */}
{onSetAutoCallOnly && (
<input
type="text"
value={onlyText}
onChange={(e) => setOnlyText(e.target.value)}
onBlur={() => { if (onlyText.toUpperCase() !== (autoCallOnly ?? '')) onSetAutoCallOnly(onlyText); }}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
if (e.key === 'Escape') setOnlyText(autoCallOnly ?? '');
}}
placeholder={t('dec.chasePh')}
title={t('dec.chaseTip')}
className={cn('h-8 w-44 rounded-lg border px-2 text-sm font-mono uppercase bg-background',
(autoCallOnly ?? '').trim()
? 'border-primary text-foreground'
: 'border-border text-muted-foreground')}
/>
)}
{onHalt && (
<button
type="button"
@@ -925,7 +1238,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
<span className="flex-1" />
{txState.band && <span className="text-xs text-muted-foreground shrink-0">{txState.band}</span>}
{txState.instance && instances.length > 1 && (
<span className="text-xs text-muted-foreground shrink-0">{txState.instance}</span>
<span className="text-xs text-muted-foreground shrink-0">{decoderName(txState.instance)}</span>
)}
</div>
)}
@@ -984,23 +1297,33 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
<div className="shrink-0 border-b border-border bg-background overflow-hidden">
<div className={cn(ROW, 'h-7 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground')}
style={{ gridTemplateColumns: template, width: tableW }}>
{COLS.map((c, i) => (
{cols.map((c, i) => {
const sortable = SORTABLE.includes(c.key as SortKey);
const active = sortable && sortKey === c.key;
return (
<span key={c.key}
// Not CELL_LAST for the final column: its overflow-hidden would
// clip that column's own resize handle.
className={cn('relative flex items-center min-w-0 px-2',
i < COLS.length - 1 && 'border-r border-border/30',
i < cols.length - 1 && 'border-r border-border/30',
// The three numeric columns label their own right edge, where the
// figures are.
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq') && 'justify-end')}
title={c.key === 'dt' ? t('dec.colDtTitle') : c.key === 'freq' ? t('dec.colFreqTitle') : undefined}>
<span className="truncate">{t(c.tkey)}</span>
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq' || c.key === 'dist') && 'justify-end',
sortable && 'cursor-pointer select-none hover:text-foreground',
active && 'text-primary')}
onClick={sortable ? () => toggleSort(c.key as SortKey) : undefined}
title={sortable ? t('dec.sortTip')
: c.key === 'dt' ? t('dec.colDtTitle')
: c.key === 'freq' ? t('dec.colFreqTitle') : undefined}>
<span className="truncate">{c.key === 'dist' ? `${t(c.tkey)} (${distanceUnit()})` : t(c.tkey)}</span>
{active && <span className="ml-0.5 shrink-0">{sortDir === 'asc' ? '▲' : '▼'}</span>}
<ColResizer
onResize={(dx) => setColWidth(c.key, colw[c.key] + dx)}
onReset={() => setColWidth(c.key, c.def)}
/>
</span>
))}
);
})}
</div>
</div>
@@ -1065,11 +1388,19 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
<button
key={`${d.call}-${d.freq_hz}-${i}`}
type="button"
onClick={() => onCall(d)}
// ONE click selects, TWO transmit — the cluster's rule, and
// the only safe one here: a single click used to hand the
// decode straight to WSJT-X as a Reply, so brushing a row
// while reading the band started calling a station.
onClick={() => (onSelect ?? onCall)(d)}
onDoubleClick={() => onCall(d)}
title={t('dec.callTitle', { call: d.call })}
style={{ gridTemplateColumns: template, width: tableW }}
className={cn(ROW, 'text-left border-b border-border/20 transition-colors',
replying ? 'bg-success/20 hover:bg-success/25'
replying ? 'bg-success/25 hover:bg-success/30 border-l-2 border-l-success'
// The station being called: a tint saying "he is on the
// air", not the QSO treatment above — most of what he
// sends is to other people.
: worked ? 'bg-danger/15 hover:bg-danger/20'
: mine ? 'bg-info/10'
: 'hover:bg-muted/50')}
@@ -1082,6 +1413,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
{hhmmssCompact(d.at)}
</span>
{/* Which receiver heard it — present only while more than one
is feeding a merged list. */}
{cols.some((c) => c.key === 'rx') && (
<span className={cn(CELL, 'text-[11px] text-muted-foreground truncate')}
title={d.instance ?? ''}>
{decoderName(d.instance)}
</span>
)}
<span className={cn(CELL, 'justify-end font-mono text-[13px] font-semibold tabular-nums', snrTone(d.snr))}>
{d.snr > 0 ? `+${d.snr}` : d.snr}
</span>
@@ -1115,7 +1455,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
{/* The message carries the callsign already — which is why
there is no column repeating it. */}
<span className={cn(CELL, 'font-mono text-[13px]')}>
<span className="truncate">{renderMsg(d.msg ?? '', me, calling)}</span>
<span className="truncate">{renderMsg(d.msg ?? '', me, calling, replying)}</span>
</span>
{/* The decode's own grid first, the remembered one as the
@@ -1128,6 +1468,14 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
<span className="truncate">{d.grid || e?.grid || ''}</span>
</span>
<span className={cn(CELL, 'justify-end font-mono text-[11px] tabular-nums text-muted-foreground/80')}>
{(() => {
const g = d.grid || e?.grid || '';
const path = myGrid && g ? pathBetween(myGrid, g) : null;
return path ? distanceValue(path.distanceShort).toLocaleString() : '';
})()}
</span>
<span className={cn(CELL, 'gap-1.5')}>
{e?.state && (
<>
@@ -1153,6 +1501,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
{t('dec.wkd')}
</span>
)}
{/* Plainest first: the LoTW letter, the worked note, then
the coloured badges — the watch list leading them, being
the operator's own answer rather than the log's. */}
{isWatched(d.call, watchlist) && (
<span className="rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0 text-white"
style={{ background: '#f472b6' }} title={t('dec.wlTip')}>
{t('dec.wl')}
</span>
)}
{entities.map((b) => (
<span key={b.label}
title={e?.unconf_status ? t('dec.unconfTip') : undefined}
+72 -9
View File
@@ -1,10 +1,12 @@
import { useEffect, useRef, useState } from 'react';
import L from 'leaflet';
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 { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { loadMapView, saveMapView, MAP_VIEW_FT } from '@/lib/mapView';
import { loadMapBase, saveMapBase, MAP_BASE_FT } from '@/lib/mapBase';
// FT Map — the live decode feed as geography: every station decoded in the
// last half hour, an arc from the operator's own square to theirs, coloured by
@@ -18,12 +20,16 @@ import { useI18n } from '@/lib/i18n';
// capped, and redraws happen when the DECODE LIST changes — every 15 s in FT8,
// not per frame.
// The map is handed the SAME decode objects the list works from — it only reads
// a few of the fields. The rest travel with them so a click here can answer the
// station exactly as a double-click in the list does.
export type FTMapDecode = {
call: string;
grid?: string;
band?: string;
snr: number;
at: string;
[k: string]: unknown;
};
// The band palette every PSK Reporter user already knows, near enough.
@@ -38,7 +44,26 @@ const bandColour = (b?: string) => BAND_COLOURS[(b ?? '').toLowerCase()] || '#9c
const MAX_ARCS = 300;
const MAX_AGE_MS = 30 * 60_000;
export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid: string }) {
export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
decodes: FTMapDecode[];
myGrid: string;
// One click takes the station, two answer it — the list's own gestures.
onSelect?: (d: FTMapDecode) => void;
onCall?: (d: FTMapDecode) => void;
}) {
// Held in refs so the redraw below does not have to list them as dependencies
// and rebuild every arc whenever the parent re-renders.
const selectRef = useRef(onSelect);
const callRef = useRef(onCall);
useEffect(() => { selectRef.current = onSelect; callRef.current = onCall; }, [onSelect, onCall]);
// Distinguishing the two gestures is ours to do: Leaflet fires click before
// dblclick and leaves the telling apart to the handler.
const clickTimer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(clickTimer.current), []);
// Where this map was left. Panning and zooming it is the operator saying which
// part of the world they are working; throwing that away on every tab switch
// made it something to set up again rather than something to glance at.
const saved = useRef(loadMapView(MAP_VIEW_FT));
const { t } = useI18n();
const divRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
@@ -58,7 +83,15 @@ export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid
// No maxBounds: with the world smaller than the window the clamp
// dragged every zoom into a corner. noWrap tiles alone keep one world.
worldCopyJump: false, preferCanvas: true,
center: [25, 0], zoom: 2, minZoom: 2,
center: saved.current ? [saved.current.lat, saved.current.lon] : [25, 0],
zoom: saved.current ? saved.current.zoom : 2,
minZoom: 2,
});
// Every move, not just the deliberate ones: a zoom is as much a choice as a
// pan, and there is no moment afterwards at which to ask.
m.on('moveend', () => {
const c = m.getCenter();
saveMapView(MAP_VIEW_FT, c.lat, c.lng, m.getZoom());
});
mapRef.current = m;
layerRef.current = L.layerGroup().addTo(m);
@@ -96,7 +129,7 @@ export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid
};
baseRef.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
if (bm.labelsUrl) labelsRef.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
localStorage.setItem('opslog.ftmapBase', basemap);
saveMapBase(MAP_BASE_FT, basemap);
}, [basemap]);
// The arcs, redrawn when the decode list changes. Newest last so they paint
@@ -124,14 +157,44 @@ export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid
const age = now - Date.parse(d.at);
const fade = Math.max(0.15, 1 - age / MAX_AGE_MS);
const colour = bandColour(d.band);
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48);
L.polyline(pts as L.LatLngExpression[], {
// Cut at the antimeridian: this map shows ONE world, so a path running
// past ±180 has to leave one edge and come back at the other. Without it
// every arc out of VK or ZL was drawn into the blank space off the side
// of the map, its far end sitting alone on the opposite coast.
const pts = splitAtAntimeridian(greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48));
L.polyline(pts as L.LatLngExpression[][], {
color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
}).addTo(layer);
L.circleMarker([to.lat, to.lon], {
const label = `${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`;
const mk = L.circleMarker([to.lat, to.lon], {
// A three-pixel dot is a fine mark and a poor target, so the visible
// radius stays and an invisible one three times the size takes the
// clicks.
radius: 3, color: colour, weight: 1, fillColor: colour, fillOpacity: 0.9 * fade,
}).bindTooltip(`${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`, { direction: 'top' })
.addTo(layer);
}).bindTooltip(label, { direction: 'top' }).addTo(layer);
// The tooltip goes on the HIT circle too, and it is the one that matters:
// being on top, it takes the hover as well as the click, and binding it
// only to the dot underneath left the map silent from the moment the dots
// became clickable — the callsign and report an operator reads by pointing
// at a station had simply gone.
const hit = L.circleMarker([to.lat, to.lon], {
radius: 9, opacity: 0, fillOpacity: 0, interactive: true,
}).bindTooltip(label, { direction: 'top' }).addTo(layer);
for (const target of [mk, hit]) {
target.on('click', (e) => {
// Not to the map: a click on a station is not a click on the water.
L.DomEvent.stopPropagation(e as unknown as Event);
window.clearTimeout(clickTimer.current);
clickTimer.current = window.setTimeout(() => selectRef.current?.(d), 250);
});
target.on('dblclick', (e) => {
// stop(), not stopPropagation(): the map zooms on a double click, and
// answering a station is not a request to zoom in on it.
L.DomEvent.stop(e as unknown as Event);
window.clearTimeout(clickTimer.current);
callRef.current?.(d);
});
}
}
}, [decodes, myGrid]);
+18 -6
View File
@@ -6,8 +6,10 @@ import { GridSquares } from '../../wailsjs/go/main/App';
import { gridSquareBounds, gridToLatLon } from '@/lib/maidenhead';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { BASEMAPS, addBasemap, loadBasemap, type BasemapKey } from '@/components/MainMap';
import { BASEMAPS, addBasemap, type BasemapKey } from '@/components/MainMap';
import { loadMapBase, saveMapBase, MAP_BASE_GRIDS, MAP_BASE_WORLD } from '@/lib/mapBase';
import { writeUiPref } from '@/lib/uiPref';
import { loadMapView, saveMapView, MAP_VIEW_GRIDS } from '@/lib/mapView';
// GridSquareMap — every Maidenhead square in the log, drawn on a world map.
//
@@ -85,7 +87,9 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
() => (SCOPES.some((s) => s.key === localStorage.getItem(SCOPE_KEY))
? (localStorage.getItem(SCOPE_KEY) as ScopeKey) : 'DIGI'));
const [basemap, setBasemap] = useState<BasemapKey>(loadBasemap);
// This map's own imagery. It shared the world map's key until they were
// separated, so a choice made back then is inherited rather than reset.
const [basemap, setBasemap] = useState<BasemapKey>(() => loadMapBase(MAP_BASE_GRIDS, 'light', MAP_BASE_WORLD));
const [confColour, setConfColour] = useState(() => localStorage.getItem(COL_CONFIRMED_KEY) ?? '');
const [workedColour, setWorkedColour] = useState(() => localStorage.getItem(COL_WORKED_KEY) ?? '');
// Repaint the squares when the THEME changes, not the basemap: the fills come
@@ -131,8 +135,16 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
}).setView([20, 0], 2);
// The whole world, once, whatever the window is shaped like — computed by
// Leaflet from the container rather than guessed with a zoom number.
m.fitWorld({ animate: false });
// Leaflet from the container rather than guessed with a zoom number. Unless
// the operator has already chosen a view: theirs is the answer, and fitting
// the world over it would undo the choice on every tab switch.
const saved = loadMapView(MAP_VIEW_GRIDS);
if (saved) m.setView([saved.lat, saved.lon], saved.zoom);
else m.fitWorld({ animate: false });
m.on('moveend', () => {
const c = m.getCenter();
saveMapView(MAP_VIEW_GRIDS, c.lat, c.lng, m.getZoom());
});
mapRef.current = m;
layerRef.current = L.layerGroup().addTo(m);
// Leaflet measures its container ONCE, when the map is created, and never
@@ -144,7 +156,7 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
// fitWorld above ran against a container that may still have had no size —
// the tab is mounted hidden. Fit ONCE more the first time it really has one,
// and never again: after that the view belongs to the operator.
let fitted = false;
let fitted = !!saved; // a remembered view is already the right size
const ro = new ResizeObserver(() => {
m.invalidateSize({ animate: false });
const el = hostRef.current;
@@ -244,7 +256,7 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
</span>
<select
value={basemap}
onChange={(e) => { const v = e.target.value as BasemapKey; setBasemap(v); writeUiPref('opslog.mapBasemap', v); }}
onChange={(e) => { const v = e.target.value as BasemapKey; setBasemap(v); saveMapBase(MAP_BASE_GRIDS, v); }}
title={t('gsm.basemap')}
className="h-6 rounded border border-border bg-background px-1 text-[11px]"
>
+35 -4
View File
@@ -10,7 +10,7 @@ import {
IcomSetRIT, IcomSetRITOn, IcomSetXITOn,
IcomSetAntenna, IcomSetPBTInner, IcomSetPBTOuter, IcomSetManualNotch, IcomSetNotchPos,
IcomSetSquelch, IcomSetComp, IcomSetCompLevel, IcomSetMonitor, IcomSetMonLevel,
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower,
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower, IcomRecallBand,
} from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
@@ -68,8 +68,9 @@ const B2 = { l: '2', hz: 144_300_000 }; // SSB calling
const B70 = { l: '70cm', hz: 432_200_000 }; // SSB calling
const B23 = { l: '23cm', hz: 1_296_200_000 }; // SSB calling
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
// the plain SetFrequency command — no band-stacking codes needed.
// These frequencies are the FALLBACK: with the band stacking registers switched
// on the radio is asked where the operator last was instead, and one of these is
// only sent for a band or a model whose register cannot be read.
//
// Which buttons to OFFER depends on the radio, exactly as the attenuator steps
// do below. An IC-9700 has no HF at all, yet the console was showing it 160
@@ -392,6 +393,19 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
const [st, setSt] = useState<IcomState>(ZERO);
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
const [tuning, setTuning] = useState(false);
// Band buttons: recall the radio's own band stacking register instead of
// sending a frequency picked here. Remembered per operator, not per session —
// it is a preference about how a button behaves, and having to set it again
// at every launch would make it not worth having.
const [bandStack, setBandStack] = useState(() => localStorage.getItem('opslog.icomBandStack') === '1');
const toggleBandStack = () => setBandStack((v) => {
const n = !v;
try { localStorage.setItem('opslog.icomBandStack', n ? '1' : '0'); } catch { /* private mode */ }
return n;
});
// Which register each band was last recalled from, so pressing the same band
// again walks 1 → 2 → 3 → 1, exactly as the radio's own band key does.
const bandRegRef = useRef<Record<string, number>>({});
const txRef = useRef(false);
const stRef = useRef<IcomState>(ZERO); stRef.current = st;
@@ -400,6 +414,18 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
GetCATState().then((c) => setCat(c ?? null)).catch(() => {});
};
const setMode = (m: string) => { setCat((c: any) => (c ? { ...c, mode: m } : c)); SetCATMode(m).catch(() => {}); };
// A band button. With the stacking registers on, ask the radio where the
// operator last was on that band; pressing the band it is already on steps to
// the next register, and the fixed frequency below is the fallback for a band
// or a model whose register the backend will not read — never a dead button.
const bandClick = (b: Band, here: boolean) => {
if (!bandStack) { SetCATFrequency(b.hz).catch(() => {}); return; }
const reg = here ? (bandRegRef.current[b.l] ?? 1) % 3 + 1 : 1;
IcomRecallBand(b.l, reg)
.then(() => { bandRegRef.current[b.l] = reg; load(); })
.catch(() => SetCATFrequency(b.hz).catch(() => {}));
};
// Initial one-shot read of the rig's DSP snapshot on mount (the 500ms poll only
// re-reads the cache; the backend also loads DSP on the first responsive read).
const refresh = async () => {
@@ -592,11 +618,16 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Band buttons + antenna selection. */}
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
<label className="flex items-center gap-1.5 mb-1.5 text-[11px] text-muted-foreground cursor-pointer select-none"
title={t('icmp.bandStackHint')}>
<input type="checkbox" checked={bandStack} onChange={toggleBandStack} className="accent-primary" />
{t('icmp.bandStack')}
</label>
<div className="grid grid-cols-5 gap-1.5">
{bandsFor(st.model).map((b) => {
const here = bandOfHz(mainHz) === b.l;
return (
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
<button key={b.l} type="button" onClick={() => bandClick(b, here)}
title={here ? t('icmp.bandCurrent', { b: b.l }) : undefined}
className={cn('px-1 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
here
+54 -15
View File
@@ -3,17 +3,16 @@ import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { nightPolygon } from '../lib/greyline';
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
import { loadMapBase, saveMapBase, MAP_BASE_WORLD } from '@/lib/mapBase';
import { writeUiPref } from '@/lib/uiPref';
import { formatDistance } from '@/lib/units';
import { loadMapView, saveMapView, MAP_VIEW_WORLD } from '@/lib/mapView';
// Persisted free-pan view of the world map (when auto-zoom is off).
function loadMapView(): { lat: number; lon: number; zoom: number } | null {
try { const v = JSON.parse(localStorage.getItem('opslog.mapView') || 'null'); return v && typeof v.zoom === 'number' ? v : null; }
catch { return null; }
}
function saveMapView(m: L.Map) {
const loadWorldView = () => loadMapView(MAP_VIEW_WORLD);
function saveWorldView(m: L.Map) {
const c = m.getCenter();
writeUiPref('opslog.mapView', JSON.stringify({ lat: c.lat, lon: c.lng, zoom: m.getZoom() }));
saveMapView(MAP_VIEW_WORLD, c.lat, c.lng, m.getZoom());
}
// The Main tab is built from two independent map panes that the operator can
@@ -28,6 +27,26 @@ function saveMapView(m: L.Map) {
// unwrapLon makes a lat/lon ring continuous in longitude (each point within
// 180° of the previous) so a polygon crossing the antimeridian doesn't snap
// across the whole map. Coords may exceed ±180; Leaflet (worldCopyJump) is fine.
// homeView is where a world map should open for THIS station.
//
// Centred on 0° a world map puts Europe in the middle and leaves an Australian
// looking at their own country in the bottom-right corner, with the paths to
// everywhere they work running off both edges. Centred on their own longitude
// the same map reads the way their antenna does: the Americas to the east,
// Europe and Africa to the west. Leaflet repeats the world horizontally
// (worldCopyJump), so this is a choice of viewpoint and costs nothing — the
// tiles either side are the same world, and the great-circle code already draws
// across the seam (see unwrapLon).
//
// The latitude is damped, not followed: a station at 69° north centred on itself
// would spend half the map on the Arctic. It leans towards the operator's
// hemisphere and stops well short of the pole.
function homeView(grid: string): [number, number] {
const home = gridToLatLon(grid);
if (!home) return [20, 0];
return [Math.max(-35, Math.min(45, home.lat * 0.6 + 8)), home.lon];
}
function unwrapLon(ring: [number, number][]): [number, number][] {
const out: [number, number][] = [];
let prev = NaN;
@@ -98,9 +117,10 @@ export const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: st
attr: 'Tiles &copy; Esri — Source: Esri, Maxar, Earthstar Geographics',
labelsUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}' },
};
// loadBasemap is the WORLD map's imagery. Each map keeps its own — see
// lib/mapBase, which is where the keys live.
export function loadBasemap(): BasemapKey {
const v = localStorage.getItem('opslog.mapBasemap');
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
return loadMapBase(MAP_BASE_WORLD, 'light');
}
// addBasemap (re)installs the imagery layer and, for satellite, its transparent
@@ -178,31 +198,50 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
const autoZoomRef = useRef(autoZoom);
useEffect(() => { autoZoomRef.current = autoZoom; }, [autoZoom]);
// WAIT FOR THE SQUARE BEFORE PAINTING.
//
// The station's own locator arrives from the profile a moment after this
// component mounts. Building the map immediately meant painting the world at
// 0°, then moving it to the operator's longitude — a screenful of tiles
// fetched from Esri and thrown away on every first run. The map is built once,
// when it knows where it is looking.
//
// Not for ever, though: an operator with no locator set (or a profile still
// loading after two seconds) gets the default view rather than a blank panel.
const [mapReady, setMapReady] = useState(() => !!gridToLatLon(fromGrid) || !!loadWorldView());
useEffect(() => {
if (mapReady) return;
if (gridToLatLon(fromGrid)) { setMapReady(true); return; }
const t = window.setTimeout(() => setMapReady(true), 2000);
return () => window.clearTimeout(t);
}, [fromGrid, mapReady]);
// One-time map creation.
useEffect(() => {
if (worldRef.current && !worldMap.current) {
if (worldRef.current && !worldMap.current && mapReady) {
// preferCanvas: the beam lobe is a dense FAN of translucent radials — up to
// ~120 thick strokes with a bidirectional Ultrabeam. As SVG that is ~120
// composited paths re-rasterised on every pan, zoom and redraw, which is
// enough to blow WebView2's raster budget and leave the window painting in
// patches. On canvas it is a single layer.
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true, preferCanvas: true })
.setView([20, 0], 1);
.setView(homeView(fromGrid), 1);
addBasemap(m, basemap, baseLayer, labelsLayer);
worldOverlay.current = L.layerGroup().addTo(m);
worldMap.current = m;
const sv = loadMapView();
const sv = loadWorldView();
if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom);
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
m.on('moveend', () => { if (!autoZoomRef.current) saveWorldView(m); });
}
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
// Resizing the pane is the ONLY thing that needs invalidateSize. Calling it on
// every overlay redraw (as before) forced a full repaint of the map each time
// the rotor moved a degree.
const ro = new ResizeObserver(() => worldMap.current?.invalidateSize());
if (worldRef.current) ro.observe(worldRef.current);
return () => { window.clearTimeout(t); ro.disconnect(); };
}, []);
}, [mapReady]);
// Swap the basemap (and its optional place-name overlay) when the operator
// picks a different one. Vector overlays (path/beam) live in Leaflet's
@@ -408,7 +447,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
<button
key={k}
type="button"
onClick={() => { setBasemap(k); writeUiPref('opslog.mapBasemap', k); }}
onClick={() => { setBasemap(k); saveMapBase(MAP_BASE_WORLD, k); }}
title={`Basemap: ${BASEMAPS[k].label}`}
className={`px-2 py-1 text-[11px] font-medium transition-colors ${
basemap === k ? 'bg-primary text-primary-foreground' : 'bg-card/90 text-muted-foreground hover:bg-card'
@@ -427,7 +466,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
setAutoZoom(v);
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
const m = worldMap.current;
if (!v && m) saveMapView(m);
if (!v && m) saveWorldView(m);
}}
title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'}
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
@@ -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>
);
}
+1 -1
View File
@@ -34,8 +34,8 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
{ service: 'hrdlog', name: 'HRDLog.net' },
{ service: 'eqsl', name: 'eQSL.cc' },
{ service: 'lotw', name: 'LoTW' },
{ service: 'hamlog', name: 'HAMLOG.online' },
{ service: 'hamqth', name: 'HamQTH' },
{ service: 'cloudlog', name: 'Cloudlog / Wavelog' },
];
// Lightweight right-click menu for the QSO grids. AG Grid's native context
+7 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Trash2, Search, Loader2, CalendarDays } from 'lucide-react';
import { LookupCallsign, LookupCallsignFresh, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs, GetListsSettings, OpenExternalURL, SetOpsLogQSLReceived } from '../../wailsjs/go/main/App';
import { rstOptions, type RSTLists } from '@/lib/rst';
import { rstOptions, stepRST, type RSTLists } from '@/lib/rst';
import { AwardRefSelector } from '@/components/AwardRefSelector';
import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
import { applyAwardRefs } from '@/lib/awardRefs';
@@ -598,9 +598,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
</div>
<div className="flex flex-col w-20"><Label>S</Label>
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_sent', v)} /></div>
<Combobox value={draft.rst_sent ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType
onChange={(v) => set('rst_sent', v)}
onWheelStep={(d) => set('rst_sent', stepRST(draft.rst_sent ?? '', d, draft.mode ?? ''))} /></div>
<div className="flex flex-col w-20"><Label>R</Label>
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType onChange={(v) => set('rst_rcvd', v)} /></div>
<Combobox value={draft.rst_rcvd ?? ''} options={rstOptions(draft.mode ?? '', rstLists)} commitOnType
onChange={(v) => set('rst_rcvd', v)}
onWheelStep={(d) => set('rst_rcvd', stepRST(draft.rst_rcvd ?? '', d, draft.mode ?? ''))} /></div>
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
title={t('qedit.fetchTitle')}>
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} {t('qedit.fetch')}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,381 @@
// RotorCompassClassic — the original rotor dial, kept as an option.
//
// Settings → Rotator chooses between this and the current compass. It was
// replaced rather than removed because the two answer the same question in
// different ways: this one is a small light-map dial with the quick turns in a
// column beside it, and an operator used to it should not have to relearn a
// panel to keep working.
//
// An azimuthal-equidistant rotor display (à la 4O3A RotorGenius).
//
// A world map centred on the operator's QTH (north up) fills the dial, ringed by
// a green azimuth bezel. A green needle shows the antenna heading (two needles
// when an Ultrabeam is bidirectional, the opposite one when reversed); a small
// red marker on the bezel shows the short-path bearing to the DX. Click the dial
// to turn the antenna there.
import { useEffect, useMemo, useRef, useState } from 'react';
import { geoAzimuthalEquidistant, geoPath, geoGraticule10 } from 'd3-geo';
import { feature } from 'topojson-client';
import landTopo from 'world-atlas/land-110m.json';
import { Compass, X, Play, Square } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
// Decode the coastline outline once (≈110 m simplified land polygons).
const LAND = feature(landTopo as any, (landTopo as any).objects.land);
const GRATICULE = geoGraticule10();
interface Props {
bearing?: number | null; // short-path azimuth to DX (deg)
headings: number[]; // radiating heading(s) — rotor + Ultrabeam pattern
boomHeading?: number | null; // mechanical boom (rotor) azimuth, shown grey when it differs
pattern?: 'normal' | 'reverse' | 'bi' | null; // Ultrabeam pattern (for the badge)
centerLat?: number | null; // operator latitude (projection centre)
centerLon?: number | null; // operator longitude
rotorEnabled?: boolean;
rotors?: string[]; // logical rotor names; >1 → show a selector
activeRotor?: number; // index of the selected rotor (0-based)
onSelectRotor?: (i: number) => void; // switch the active rotor
onGoto?: (az: number) => void; // click-to-turn
onClose?: () => void;
// Quick-turn buttons and the azimuth box, shown only where the caller wants
// them: Station Control draws its own GoTo/Stop around this compass, and two
// sets of the same controls side by side would be nothing but confusing.
presets?: { label: string; azimuth: number }[];
onStop?: () => void;
}
const SIZE = 168;
const C = SIZE / 2;
const R = C - 6; // outer bezel radius
const MAP_R = R - 6; // map/clip radius (inside the bezel)
function pt(az: number, radius: number): [number, number] {
const a = ((az - 90) * Math.PI) / 180;
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
}
export function RotorCompassClassic({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose, presets, onStop }: Props) {
const { t } = useI18n();
// Raw text, not a number: binding the input to a normalised value makes
// Backspace fight the operator on the way from "230" to "23". It is parsed
// when it is sent, and only then.
const [azText, setAzText] = useState('');
const showControls = !!(presets || onStop);
// Which preset was just pressed, so it can light up for a moment.
//
// A rotor takes seconds to start moving and the needle barely twitches at
// first, so without this the only answer to "did that register?" is to press
// it again — which is how an antenna ends up ordered somewhere twice. The
// acknowledgement has to come from the button itself, at once.
const [flashIdx, setFlashIdx] = useState<number | null>(null);
const flashTimer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(flashTimer.current), []);
const pressPreset = (i: number, az: number) => {
if (!onGoto) return;
setFlashIdx(i);
window.clearTimeout(flashTimer.current);
flashTimer.current = window.setTimeout(() => setFlashIdx(null), 450);
onGoto(az);
};
// Stop needs the same acknowledgement, for the same reason and one more: it
// is pressed when something is already wrong, and a button that stays inert
// gets hit again and again. Its own flag, so stopping does not blank a preset
// that is still lit.
const [stopFlash, setStopFlash] = useState(false);
const stopTimer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(stopTimer.current), []);
const pressStop = () => {
if (!onStop) return;
setStopFlash(true);
window.clearTimeout(stopTimer.current);
stopTimer.current = window.setTimeout(() => setStopFlash(false), 450);
onStop();
};
// 0-359 and nothing else. 360 is refused rather than folded to 0 — it is
// almost always a typo for 36 or 306, and a rotor swinging through north on a
// slip of the finger is worth one rejected keypress.
const sendAz = () => {
const s = azText.trim();
if (s === '' || !onGoto) return;
const n = Number(s);
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0 || n > 359) return;
onGoto(n);
setAzText('');
};
const cardinals = useMemo(
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
[],
);
// Project the world centred on the QTH (north up; antipode at the bezel).
const { land, grat } = useMemo(() => {
if (centerLat == null || centerLon == null) return { land: '', grat: '' };
const proj = geoAzimuthalEquidistant()
.rotate([-centerLon, -centerLat])
.clipAngle(179.9)
.scale(MAP_R / Math.PI)
.translate([C, C]);
const path = geoPath(proj as any);
return { land: path(LAND as any) || '', grat: path(GRATICULE as any) || '' };
}, [centerLat, centerLon]);
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
if (!onGoto) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * SIZE - C;
const y = ((e.clientY - rect.top) / rect.height) * SIZE - C;
let az = (Math.atan2(y, x) * 180) / Math.PI + 90;
az = ((az % 360) + 360) % 360;
onGoto(Math.round(az));
}
const headLabel = headings.length ? headings[0] : null;
// Short and long path to the DX, in figures.
//
// The bezel already carries the short path as a red marker, but a marker is a
// direction, not a number — the same pair sits in the status bar at 10px and
// operators reported not being able to read it. It lives here because it
// belongs to the compass: every place that draws one gets the readout, instead
// of each caller inventing its own. Clickable when the caller can turn, like
// the status bar's. Built as a value because it is placed in one of two
// columns depending on whether the controls are shown.
const pathReadout = (
<div className="flex gap-1.5 mt-2 font-mono w-full">
{([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => (
<button key={lbl} type="button" disabled={az == null || !onGoto}
onClick={() => { if (az != null && onGoto) onGoto(Math.round(az)); }}
title={az == null ? '' : `${lbl} ${Math.round(az)}°`}
className={
'flex-1 rounded-md border py-1 text-xs font-semibold tabular-nums transition-colors active:scale-95 ' +
(az == null
? 'border-border text-muted-foreground/50 cursor-not-allowed'
: onGoto
? 'border-info-border text-info-muted-foreground hover:bg-info-muted cursor-pointer'
: 'border-border text-muted-foreground cursor-default')
}>
{lbl} {az == null ? '—' : `${Math.round(az)}°`}
</button>
))}
</div>
);
return (
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
{/* Header — matches the WinKeyer / Voice keyer panels. */}
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
<Compass className="size-4 text-primary shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Rotor</span>
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-success' : 'bg-muted-foreground/40')}
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
<div className="flex-1" />
{pattern && (
<span
className={cn('px-1 py-px rounded text-[9px] font-bold tracking-wide',
pattern === 'reverse' ? 'bg-warning-muted text-warning-muted-foreground'
: pattern === 'bi' ? 'bg-info-muted text-info-muted-foreground'
: 'bg-success-muted text-success-muted-foreground')}
title={pattern === 'reverse' ? 'Ultrabeam reversed — radiates opposite the boom'
: pattern === 'bi' ? 'Ultrabeam bidirectional — radiates both ways'
: 'Ultrabeam normal'}>
{pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'}
</span>
)}
<span className="font-mono text-sm font-bold text-success tabular-nums">
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
</span>
{onClose && (
<button className="text-muted-foreground hover:text-foreground" title="Hide rotor" onClick={onClose}>
<X className="size-3.5" />
</button>
)}
</div>
{/* Multiple rotors — pick which one the dial shows and turns. */}
{rotors && rotors.length > 1 && (
<div className="flex flex-wrap gap-1 px-2 pt-1.5">
{rotors.map((nm, i) => {
const active = (activeRotor ?? 0) === i;
const label = nm?.trim() || `Rotor ${i + 1}`;
return (
<button
key={i}
onClick={() => onSelectRotor?.(i)}
className={cn(
'flex-1 min-w-[48px] px-1.5 py-0.5 rounded text-[10px] font-semibold truncate transition-colors',
active
? 'bg-success text-success-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/70',
)}
title={label}
>
{label}
</button>
);
})}
</div>
)}
{/* Dial on the left, controls on the right. The controls column is sized to
fit WITHIN the dial's height: the widget sits in a row whose height is
set by the entry strip, so it may grow sideways but never downwards. */}
<div className="flex items-start gap-2 p-2 min-h-0">
{/* flex-col: the readout goes BELOW the dial. This wrapper was a row, so a
sibling of the <svg> landed beside it. */}
<div className="flex flex-col items-center justify-center min-h-0 shrink-0">
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
style={{ width: SIZE, height: SIZE }}
onClick={handleClick}
>
<defs>
<clipPath id="rotorDial"><circle cx={C} cy={C} r={MAP_R} /></clipPath>
</defs>
{/* water + world map, clipped to the dial */}
<circle cx={C} cy={C} r={MAP_R} fill="#d3e7f1" />
<g clipPath="url(#rotorDial)">
{grat && <path d={grat} fill="none" stroke="#9cc0d6" strokeWidth={0.4} opacity={0.7} />}
{land && <path d={land} fill="#dfe2cf" stroke="#9aa589" strokeWidth={0.4} />}
</g>
{/* green azimuth bezel */}
<circle cx={C} cy={C} r={R} fill="none" stroke="#16a34a" strokeWidth={5} />
{/* ticks every 10°, longer at 30° */}
{Array.from({ length: 36 }, (_, i) => i * 10).map((d) => {
const major = d % 30 === 0;
const [x1, y1] = pt(d, MAP_R);
const [x2, y2] = pt(d, MAP_R - (major ? 7 : 4));
return <line key={d} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#475569" strokeWidth={major ? 1 : 0.6} opacity={0.7} />;
})}
{/* cardinal labels + degree numbers at 45° */}
{cardinals.map(({ d, l }) => {
const [x, y] = pt(d, MAP_R - 13);
return <text key={l} x={x} y={y} textAnchor="middle" dominantBaseline="central" className="fill-slate-700" style={{ fontSize: l.length > 1 ? 7 : 9, fontWeight: 700 }}>{l}</text>;
})}
{/* DX short-path bearing → small red marker on the bezel */}
{bearing != null && (() => { const [x, y] = pt(bearing, MAP_R); return (
<circle cx={x} cy={y} r={3} fill="#dc2626" stroke="#fff" strokeWidth={1} />
); })()}
{/* mechanical boom (rotor) heading grey dashed needle, shown when the
Ultrabeam radiates somewhere other than the boom (reverse/bi) so the
operator sees where the antenna physically points vs where it boom-sits */}
{boomHeading != null && pattern && pattern !== 'normal' && (() => {
const [x, y] = pt(boomHeading, MAP_R - 2);
return (
<g>
<title>Boom (rotor) {Math.round(boomHeading)}°</title>
<line x1={C} y1={C} x2={x} y2={y} stroke="#64748b" strokeWidth={2} strokeDasharray="3 3" strokeLinecap="round" />
<circle cx={x} cy={y} r={3} fill="#64748b" stroke="#fff" strokeWidth={1} />
</g>
);
})()}
{/* radiating heading needle(s) — green; two when bidirectional */}
{headings.map((h, i) => { const [x, y] = pt(h, MAP_R - 2); return (
<g key={i}>
<line x1={C} y1={C} x2={x} y2={y} stroke="#15803d" strokeWidth={3} strokeLinecap="round" opacity={i === 0 ? 1 : 0.55} />
<polygon points={`${x},${y} ${pt(h - 5, MAP_R - 12).join(',')} ${pt(h + 5, MAP_R - 12).join(',')}`} fill="#15803d" opacity={i === 0 ? 1 : 0.55} />
</g>
); })}
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
</svg>
{/* With the controls column present the readout goes at the FOOT OF IT
instead: the dial sets the widget's height, the controls are shorter
than the dial, and that leftover space is exactly the right size for
the pair. Under the dial it would push the whole widget taller. */}
{!showControls && pathReadout}
</div>
{/* Quick turns + free azimuth + Stop. */}
{showControls && (
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
{/* Two columns so six regions fit beside the dial rather than under
it. An operator with fewer keeps the same compact block. */}
{!!presets?.length && (
<div className="grid grid-cols-2 gap-1">
{presets.map((p, i) => (
<button
key={`${p.label}-${i}`}
type="button"
disabled={!onGoto}
onClick={() => pressPreset(i, p.azimuth)}
title={`${p.label}${p.azimuth}°`}
className={cn(
'rounded-md border py-1 text-xs font-semibold truncate transition-all duration-150 active:scale-95',
flashIdx === i
// Lit, and showing the azimuth it just sent: the label alone
// would only say the press landed, not what was ordered.
? 'border-success bg-success text-success-foreground scale-95'
: 'border-border bg-muted/40',
onGoto && flashIdx !== i ? 'hover:bg-muted' : '',
!onGoto ? 'opacity-50 cursor-not-allowed' : '',
)}
>
{flashIdx === i ? `${p.azimuth}°` : p.label}
</button>
))}
</div>
)}
{/* Free azimuth: Enter sends, so the whole thing is type-three-digits
-and-go without reaching for the mouse. */}
<div className="flex gap-1">
<input
type="text"
inputMode="numeric"
value={azText}
onChange={(e) => setAzText(e.target.value.replace(/[^0-9]/g, '').slice(0, 3))}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendAz(); } }}
placeholder={t('rotor.azPh')}
title={t('rotor.azTitle')}
disabled={!onGoto}
className="min-w-0 flex-1 rounded-md border border-border bg-background px-2 py-1 text-xs font-mono tabular-nums text-center disabled:opacity-50"
/>
<button
type="button"
onClick={sendAz}
disabled={!onGoto || azText.trim() === ''}
title={t('rotor.go')}
className="flex items-center gap-1 rounded-md border border-success/60 bg-success-muted px-2 py-1 text-xs font-bold text-success-muted-foreground transition-transform hover:bg-success/25 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Play className="size-3" /> {t('rotor.go')}
</button>
</div>
{onStop && (
<button
type="button"
onClick={pressStop}
title={t('rotor.stop')}
className={cn(
'flex items-center justify-center gap-1.5 rounded-md border py-1 text-xs font-bold transition-all duration-150 active:scale-95',
stopFlash
// Solid, not a tint: STOP reads the same in both languages, so
// the fill is the whole acknowledgement.
? 'border-destructive bg-destructive text-destructive-foreground scale-95'
: 'border-destructive/60 bg-destructive/15 text-destructive hover:bg-destructive/25',
)}
>
<Square className="size-3 fill-current" /> {t('rotor.stop')}
</button>
)}
{/* mt-auto: the readout sits at the FOOT of the column, level with the
bottom of the dial, instead of floating under the Stop button. */}
<div className="mt-auto">{pathReadout}</div>
</div>
)}
</div>
</section>
);
}
+791
View File
@@ -0,0 +1,791 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen, Radar } from 'lucide-react';
import {
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack,
StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking,
} from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { Button } from '@/components/ui/button';
import { gridToLatLon, splitAtAntimeridian } from '@/lib/maidenhead';
import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
import { loadMapView, saveMapView } from '@/lib/mapView';
import { loadMapBase, saveMapBase, MAP_BASE_SAT } from '@/lib/mapBase';
import { writeUiPref } from '@/lib/uiPref';
import { SkyPlot, type SkyPoint } from '@/components/SkyPlot';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
// Satellites — where the birds are, and what to do with the radio.
//
// The tab answers the three questions a pass poses, and answers them where they
// are asked: how long have I got (the countdown), where is it (the map), what
// do I tune (the readout). Everything else — which satellites to follow, where
// the elements come from, the rotator — is maintenance and lives in Settings.
// During a pass there is no time to configure anything.
type Bird = {
name: string; norad: number; geostationary: boolean; favorite: boolean;
has_elements: boolean; element_name: string; epoch_age_h: number;
transponders?: {
label: string; mode: string; down_lo: number; down_hi: number;
up_lo: number; up_hi: number; inverting: boolean; ctcss: number; linear: boolean;
}[];
};
type Position = {
name: string; lat: number; lon: number; alt_km: number; footprint_km: number;
az: number; el: number; range_km: number; range_rate: number;
};
type Pass = {
name: string; aos: string; los: string; aos_az: number; los_az: number;
max_el: number; max_el_az: number; max_el_at: string; duration_s: number;
};
type PassInfo = {
name: string; has_pass: boolean; in_pass: boolean;
aos: string; los: string; aos_az: number; los_az: number;
max_el: number; max_el_az: number; max_el_at: string; duration_s: number;
};
type Tuning = {
name: string; transponder: string; mode: string;
nominal_down: number; nominal_up: number; down_hz: number; up_hz: number;
ctcss: number; inverting: boolean;
az: number; el: number; range_km: number; range_rate: number; visible: boolean;
lat: number; lon: number; alt_km: number; footprint_km: number;
};
type Track = {
on: boolean; name: string; transponder: string; mode: string;
nominal_down: number; nominal_up: number; down_hz: number; up_hz: number;
az: number; el: number; visible: boolean;
radio: string; // "sat" | "downlink-only" | ""
error: string;
rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean;
};
const MAP_VIEW_SAT = 'opslog.satMapView';
// The readout column. Wide enough by default to hold a frequency to the hertz
// without wrapping, and adjustable because how much map an operator wants
// against how much detail is theirs to decide — a station watching a footprint
// cross an ocean wants the map, one working a pass wants the numbers.
const SIDE_W_KEY = 'opslog.satSideWidth';
const SIDE_SHOWN_KEY = 'opslog.satSideShown';
const SIDE_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720;
const SKY_SHOWN_KEY = 'opslog.satSkyShown';
const fmtHz = (hz: number) => {
if (!hz) return '—';
// Six decimals: a linear transponder is tuned to the hundred hertz, and the
// Doppler correction moves the last three digits every second.
return (hz / 1e6).toFixed(6).replace(/(\d)(?=(\d{3})+\.)/g, '$1 ');
};
const fmtDeg = (d: number) => `${d.toFixed(1)}°`;
const fmtKm = (km: number) => `${Math.round(km).toLocaleString()} km`;
const hhmm = (iso: string) => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? '—' : d.toISOString().slice(11, 16);
};
const hhmmss = (iso: string) => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? '—' : d.toISOString().slice(11, 19);
};
const inMin = (iso: string) => Math.round((Date.parse(iso) - Date.now()) / 60000);
// A countdown an operator can act on. Seconds while they matter, then minutes,
// then hours — nobody needs "1h 04m 37s", and nobody wants "0m" for the last
// fifty seconds before a satellite rises.
function fmtCountdown(ms: number): string {
const s = Math.max(0, Math.round(ms / 1000));
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`;
const h = Math.floor(s / 3600);
return `${h}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`;
}
// The eight points of the compass, for an azimuth an operator reads rather than
// computes. "rises at 213°" is a number; "rises SW" is a direction to look in.
const COMPASS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
const compass = (deg: number) => COMPASS[Math.round(((deg % 360) + 360) % 360 / 45) % 8];
// How good a pass is, as a colour. A bird 70° overhead and one scraping 12°
// along the horizon are not the same evening, and the table should say so
// without the operator reading every number.
function elClass(el: number): string {
if (el >= 50) return 'text-success';
if (el >= 25) return 'text-foreground';
if (el >= 15) return 'text-caution';
return 'text-muted-foreground';
}
// The mode a satellite is worked in, as a dot: FM and SSB call for a completely
// different set-up, and which of the two the next pass is decides whether the
// operator reaches for a handheld or the whole station.
const MODE_COLOUR: Record<string, string> = {
FM: 'var(--info)',
SSB: 'var(--success)',
CW: 'var(--caution)',
DATA: 'var(--warning)',
};
function ModeDot({ mode }: { mode: string }) {
const colour = MODE_COLOUR[mode];
if (!colour) return null;
return (
<span
title={mode}
className="ml-1.5 inline-block size-1.5 rounded-full align-middle"
style={{ backgroundColor: colour }}
/>
);
}
export function SatellitePanel({ myGrid }: { myGrid: string }) {
const { t } = useI18n();
const [birds, setBirds] = useState<Bird[]>([]);
const [sel, setSel] = useState<string>(() => localStorage.getItem('opslog.satSelected') || '');
const [tpIdx, setTpIdx] = useState(0);
const [positions, setPositions] = useState<Position[]>([]);
const [passes, setPasses] = useState<Pass[]>([]);
const [tuning, setTuning] = useState<Tuning | null>(null);
const [pass, setPass] = useState<PassInfo | null>(null);
const [tle, setTle] = useState<{ count: number; age_h: number; stale: boolean; custom: number } | null>(null);
const [tracking, setTracking] = useState<Track | null>(null);
const [sky, setSky] = useState<SkyPoint[]>([]);
const [skyShown, setSkyShown] = useState(() => localStorage.getItem(SKY_SHOWN_KEY) !== '0');
const [err, setErr] = useState('');
// A clock of its own, so every countdown on the panel ticks from one instant
// and none of them needs a round trip to Go to lose a second.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, []);
// What the operator follows. Chosen in Settings; following nothing means
// every satellite we can both find and tune, which is what somebody who has
// not chosen yet should see.
const shown = useMemo(() => {
const favs = birds.filter((b) => b.favorite);
if (favs.length > 0) return favs;
return birds.filter((b) => b.has_elements && (b.transponders?.length ?? 0) > 0);
}, [birds]);
const bird = useMemo(() => birds.find((b) => b.name === sel) ?? null, [birds, sel]);
const tp = bird?.transponders?.[tpIdx] ?? null;
// The mode each satellite is worked in, for the pass table's dot. Its first
// transponder: on a bird that has two, the first is the one it is known for.
const modeOf = useMemo(() => {
const m = new Map(birds.map((b) => [b.name, b.transponders?.[0]?.mode ?? ''] as const));
return (name: string) => m.get(name) ?? '';
}, [birds]);
// ── Data ─────────────────────────────────────────────────────────────────
const loadBirds = useCallback(async () => {
try {
const list: Bird[] = (await GetSatelliteBirds()) as any;
setBirds(list ?? []);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}, []);
const loadTle = useCallback(async () => {
try { setTle((await GetSatelliteTLEInfo()) as any); } catch { /* shown as unknown */ }
}, []);
const loadPasses = useCallback(async () => {
try {
setPasses(((await GetSatellitePasses([], 0)) as any) ?? []);
setErr('');
} catch (e: any) { setErr(String(e?.message ?? e)); }
}, []);
useEffect(() => { loadBirds(); loadTle(); loadPasses(); }, [loadBirds, loadTle, loadPasses]);
useEffect(() => EventsOn('sat:tle', () => { loadTle(); loadBirds(); loadPasses(); }), [loadTle, loadBirds, loadPasses]);
useEffect(() => { if (sel) localStorage.setItem('opslog.satSelected', sel); }, [sel]);
useEffect(() => { setTpIdx(0); }, [sel]);
// Keep the selection inside what is followed: an operator who narrows the list
// in Settings must not be left looking at a satellite that is no longer there.
useEffect(() => {
if (shown.length === 0) return;
if (!sel || !shown.some((b) => b.name === sel)) setSel(shown[0].name);
}, [shown, sel]);
// The map's satellites, every five seconds: a low orbit moves about a third of
// a degree of longitude in that time, which is a pixel or two at this zoom.
useEffect(() => {
let live = true;
const tick = async () => {
try {
const p: Position[] = ((await GetSatellitePositions([])) as any) ?? [];
if (live) setPositions(p);
} catch { /* a missing locator is already reported by the passes call */ }
};
tick();
const id = window.setInterval(tick, 5000);
return () => { live = false; window.clearInterval(id); };
}, []);
// The readout, every second: this is the number an operator types into a
// radio, and a Doppler correction on 70 cm moves by a few tens of hertz a
// second at the middle of a pass.
useEffect(() => {
if (!sel) { setTuning(null); return; }
let live = true;
const tick = async () => {
try {
const tn: Tuning = (await GetSatelliteTuning(sel, tpIdx, 0)) as any;
if (live) setTuning(tn);
} catch { if (live) setTuning(null); }
};
tick();
const id = window.setInterval(tick, 1000);
return () => { live = false; window.clearInterval(id); };
}, [sel, tpIdx]);
// The pass, every twenty seconds. Predicting one steps the orbit across hours;
// the countdown itself is two timestamps and a clock, which the browser runs.
useEffect(() => {
if (!sel) { setPass(null); return; }
let live = true;
const tick = async () => {
try {
const p: PassInfo = (await GetSatelliteNextPass(sel)) as any;
if (live) setPass(p);
} catch { if (live) setPass(null); }
};
tick();
const id = window.setInterval(tick, 20_000);
return () => { live = false; window.clearInterval(id); };
}, [sel]);
// The pass drawn across the sky. Once a minute is plenty: the SHAPE of a
// pass does not change while it happens — only the marker on it moves, and
// that comes from the tuning poll a second at a time.
useEffect(() => {
if (!sel || !skyShown) { setSky([]); return; }
let live = true;
const load = async () => {
try {
const pts: SkyPoint[] = ((await GetSatelliteSkyTrack(sel, 120)) as any) ?? [];
if (live) setSky(pts);
} catch { if (live) setSky([]); }
};
load();
const id = window.setInterval(load, 60_000);
return () => { live = false; window.clearInterval(id); };
}, [sel, skyShown]);
useEffect(() => { writeUiPref(SKY_SHOWN_KEY, skyShown ? '1' : '0'); }, [skyShown]);
// Passes are cheap but not free, and they change slowly.
useEffect(() => {
const id = window.setInterval(loadPasses, 5 * 60_000);
return () => window.clearInterval(id);
}, [loadPasses]);
// The tracker's own state, pushed as it moves. Polled as well, at a lazy
// rate, so a panel opened while tracking is already running is not blank
// until the next tick.
useEffect(() => {
const read = async () => {
try { setTracking((await GetSatelliteTracking()) as any); } catch { /* not tracking */ }
};
read();
const off = EventsOn('sat:track', (s: any) => setTracking(s ?? null));
const id = window.setInterval(read, 10_000);
return () => { off(); window.clearInterval(id); };
}, []);
const toggleTracking = async () => {
setErr('');
try {
if (tracking?.on) {
await StopSatelliteTracking();
setTracking(null);
} else {
await StartSatelliteTracking(sel, tpIdx);
setTracking((await GetSatelliteTracking()) as any);
}
} catch (e: any) { setErr(String(e?.message ?? e)); }
};
// ── Map ──────────────────────────────────────────────────────────────────
const divRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const layerRef = useRef<L.LayerGroup | null>(null);
const baseRef = useRef<L.TileLayer | null>(null);
const labelsRef = useRef<L.TileLayer | null>(null);
const [basemap, setBasemap] = useState<BasemapKey>(() => loadMapBase(MAP_BASE_SAT, 'light'));
const saved = useRef(loadMapView(MAP_VIEW_SAT));
const [track, setTrack] = useState<Position[]>([]);
const home = useMemo(() => gridToLatLon(myGrid), [myGrid]);
// ── The readout column ───────────────────────────────────────────────────
const [sideW, setSideW] = useState<number>(() => {
const n = parseFloat(localStorage.getItem(SIDE_W_KEY) || '');
return Number.isFinite(n) && n >= SIDE_W_MIN && n <= SIDE_W_MAX ? n : SIDE_W_DEFAULT;
});
const [sideShown, setSideShown] = useState(() => localStorage.getItem(SIDE_SHOWN_KEY) !== '0');
useEffect(() => { writeUiPref(SIDE_W_KEY, String(Math.round(sideW))); }, [sideW]);
useEffect(() => { writeUiPref(SIDE_SHOWN_KEY, sideShown ? '1' : '0'); }, [sideShown]);
// Dragging the grip. Measured from where the pointer STARTED rather than from
// the container, and with the pointer captured — without the capture the map
// underneath swallows the moves the instant the cursor crosses it.
const startSideDrag = (e: React.PointerEvent) => {
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
const x0 = e.clientX;
const w0 = sideW;
const onMove = (ev: PointerEvent) => {
setSideW(Math.min(SIDE_W_MAX, Math.max(SIDE_W_MIN, Math.round(w0 + (x0 - ev.clientX)))));
};
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
};
useEffect(() => {
if (!divRef.current || mapRef.current) return;
const m = L.map(divRef.current, {
zoomControl: true, attributionControl: true,
worldCopyJump: false, preferCanvas: true,
// Opened on the station, not on the Atlantic: the passes that matter are
// the ones over the operator's own head.
center: saved.current ? [saved.current.lat, saved.current.lon] : [home?.lat ?? 25, home?.lon ?? 0],
zoom: saved.current ? saved.current.zoom : 3,
minZoom: 2,
});
m.on('moveend', () => {
const c = m.getCenter();
saveMapView(MAP_VIEW_SAT, c.lat, c.lng, m.getZoom());
});
mapRef.current = m;
layerRef.current = L.layerGroup().addTo(m);
const ro = new ResizeObserver(() => m.invalidateSize({ animate: false }));
ro.observe(divRef.current);
const settle = window.setTimeout(() => m.invalidateSize({ animate: false }), 100);
return () => {
window.clearTimeout(settle);
ro.disconnect();
m.remove();
mapRef.current = null;
layerRef.current = null;
};
}, [home?.lat, home?.lon]);
useEffect(() => {
const m = mapRef.current;
if (!m) return;
baseRef.current?.remove(); labelsRef.current?.remove();
const bm = BASEMAPS[basemap];
const opts: L.TileLayerOptions = {
maxNativeZoom: bm.maxNativeZoom, noWrap: true,
bounds: L.latLngBounds(L.latLng(-85.0511, -180), L.latLng(85.0511, 180)),
};
baseRef.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
if (bm.labelsUrl) labelsRef.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
saveMapBase(MAP_BASE_SAT, basemap);
}, [basemap]);
// The selected bird's path over the ground, redrawn when the selection
// changes and every couple of minutes as it walks off the front of it.
useEffect(() => {
if (!sel) { setTrack([]); return; }
let live = true;
const load = async () => {
try {
const pts: Position[] = ((await GetSatelliteGroundTrack(sel, 100)) as any) ?? [];
if (live) setTrack(pts);
} catch { if (live) setTrack([]); }
};
load();
const id = window.setInterval(load, 120_000);
return () => { live = false; window.clearInterval(id); };
}, [sel]);
useEffect(() => {
const layer = layerRef.current;
if (!layer) return;
layer.clearLayers();
if (home) {
L.circleMarker([home.lat, home.lon], {
radius: 5, color: '#fff', weight: 2, fillColor: '#e11d48', fillOpacity: 1,
}).bindTooltip(myGrid, { direction: 'top' }).addTo(layer);
}
if (track.length > 1) {
const pts = splitAtAntimeridian(track.map((p) => [p.lat, p.lon] as [number, number]));
L.polyline(pts as L.LatLngExpression[][], {
color: 'var(--info)', weight: 1.2, opacity: 0.7, dashArray: '4 4', smoothFactor: 0,
}).addTo(layer);
}
const wanted = new Set(shown.map((b) => b.name));
for (const p of positions) {
if (!wanted.has(p.name) && p.name !== sel) continue;
const chosen = p.name === sel;
const up = p.el > 0;
const colour = chosen ? '#22c55e' : up ? '#f59e0b' : '#9ca3af';
// The footprint is the honest answer to "can I hear it": everything inside
// the circle has the satellite above its horizon.
L.circle([p.lat, p.lon], {
radius: p.footprint_km * 1000,
color: colour, weight: chosen ? 1.2 : 0.8, opacity: chosen ? 0.7 : 0.35,
fillColor: colour, fillOpacity: chosen ? 0.1 : 0.05,
}).addTo(layer);
L.circleMarker([p.lat, p.lon], {
radius: chosen ? 6 : 4, color: '#fff', weight: 1,
fillColor: colour, fillOpacity: 1,
})
.bindTooltip(`${p.name} · ${fmtDeg(p.el)} · ${Math.round(p.alt_km)} km`, { direction: 'top' })
.on('click', () => setSel(p.name))
.addTo(layer);
}
}, [positions, track, home?.lat, home?.lon, myGrid, sel, shown]);
// ── Render ───────────────────────────────────────────────────────────────
// The pass, as a countdown and a bar. Both derived here from two timestamps,
// so they move every second without asking Go anything.
const aosMs = pass?.has_pass ? Date.parse(pass.aos) : 0;
const losMs = pass?.has_pass ? Date.parse(pass.los) : 0;
const inPass = !!pass?.has_pass && now >= aosMs && now < losMs;
const progress = inPass && losMs > aosMs ? (now - aosMs) / (losMs - aosMs) : 0;
return (
<div className="flex flex-col h-full min-h-0 gap-1 p-1">
{/* Header: what to work, and the one button that touches the radio.
Everything else about satellites which ones, where the elements come
from, the rotator is in Settings, because none of it is something to
do while a bird is going over. */}
<div className="flex items-center gap-2 flex-wrap px-1 shrink-0">
<SatIcon className="size-4 text-muted-foreground" />
<select
className="h-7 rounded-md border border-border bg-background px-2 text-xs min-w-[12rem]"
value={sel}
onChange={(e) => setSel(e.target.value)}
>
{shown.length === 0 && <option value="">{t('sat.noneFollowed')}</option>}
{shown.map((b) => (
<option key={b.name} value={b.name} disabled={!b.has_elements && !b.geostationary}>
{b.name}{b.has_elements || b.geostationary ? '' : `${t('sat.noElements')}`}
</option>
))}
</select>
{(bird?.transponders?.length ?? 0) > 1 && (
<select
className="h-7 rounded-md border border-border bg-background px-2 text-xs"
value={tpIdx}
onChange={(e) => setTpIdx(Number(e.target.value))}
>
{bird!.transponders!.map((x, i) => (
<option key={i} value={i}>{x.label}</option>
))}
</select>
)}
<Button
size="sm"
variant={tracking?.on ? 'default' : 'outline'}
className={cn('h-7 px-2 gap-1.5', tracking?.on && 'bg-success text-background hover:bg-success/90')}
onClick={toggleTracking}
disabled={!bird?.transponders?.length}
title={tracking?.on
? (tracking.radio === 'sat' ? t('sat.trackingFull') : t('sat.trackingDown'))
: t('sat.trackTip')}
>
<Radio className="size-3.5" />
{tracking?.on ? t('sat.tracking') : t('sat.track')}
</Button>
{tracking?.on && tracking.radio === 'downlink-only' && (
<span className="text-[11px] text-warning">{t('sat.downlinkOnly')}</span>
)}
<div className="flex-1" />
{/* Elements are maintenance, so only their AGE is here and only when
it has become a reason the panel might be wrong. */}
{tle?.stale && <span className="text-[11px] text-warning">{t('sat.tleStale')}</span>}
<select
className="h-7 rounded-md border border-border bg-background px-2 text-xs"
value={basemap}
onChange={(e) => setBasemap(e.target.value as BasemapKey)}
>
{Object.entries(BASEMAPS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
<Button
variant="ghost" size="sm"
className={cn('h-7 px-1.5', skyShown && 'text-success')}
onClick={() => setSkyShown((v) => !v)}
title={skyShown ? t('sat.hideSky') : t('sat.showSky')}
>
<Radar className="size-3.5" />
</Button>
{/* Put the whole window on the map. On a laptop the readout takes a
third of the screen, and there are moments watching a footprint
cross an ocean when the map IS the answer. */}
<Button
variant="ghost" size="sm" className="h-7 px-1.5"
onClick={() => setSideShown((v) => !v)}
title={sideShown ? t('sat.hideSide') : t('sat.showSide')}
>
{sideShown ? <PanelRightClose className="size-3.5" /> : <PanelRightOpen className="size-3.5" />}
</Button>
</div>
{err && <div className="px-2 text-[11px] text-danger shrink-0">{err}</div>}
<div className="flex gap-1 flex-1 min-h-0">
{/* The map. isolate is load-bearing, not tidiness: Leaflet stacks its
own panes and controls up to z-index 1000, which without a stacking
context of their own float over Preferences and every dialog in the
app the map ends up on top of the very buttons that would close
it. */}
<div className="relative isolate z-0 flex-1 min-w-0 rounded-lg overflow-hidden border border-border">
<div ref={divRef} className="h-full w-full" />
</div>
{sideShown && (
<div
role="separator"
aria-orientation="vertical"
title={t('sat.sideWidthTip')}
onPointerDown={startSideDrag}
onDoubleClick={() => setSideW(SIDE_W_DEFAULT)}
className="group relative shrink-0 w-2 cursor-col-resize flex items-center justify-center"
>
<span className="h-10 w-[3px] rounded-full bg-border group-hover:bg-primary transition-colors" />
</div>
)}
<div className={cn('shrink-0 flex flex-col gap-1 min-h-0', !sideShown && 'hidden')}
style={{ width: sideW }}>
{/* The pass. The first thing an operator looks at and the reason they
sit down: how long have I got, and how high does it get. */}
<div className={cn('rounded-lg border bg-card p-2',
inPass ? 'border-success/60' : 'border-border')}>
<div className="flex items-baseline justify-between gap-2">
<span className="font-medium text-sm truncate">{bird?.name ?? '—'}</span>
<span className="text-[11px] text-muted-foreground truncate">{tp?.label ?? ''}</span>
</div>
{bird?.geostationary ? (
<div className="mt-1 text-[11px] text-muted-foreground">{t('sat.geoHint')}</div>
) : !pass?.has_pass ? (
<div className="mt-1 text-[11px] text-muted-foreground">{t('sat.noPassSoon')}</div>
) : (
<>
<div className="mt-1 flex items-baseline gap-2">
<span className={cn('text-[10px] uppercase tracking-wide',
inPass ? 'text-success' : 'text-muted-foreground')}>
{inPass ? t('sat.los') : t('sat.aos')}
</span>
<span className={cn('text-xl font-semibold tabular-nums leading-none',
inPass && 'text-success')}>
{fmtCountdown((inPass ? losMs : aosMs) - now)}
</span>
<span className="text-[11px] text-muted-foreground tabular-nums ml-auto">
{hhmmss(inPass ? pass.los : pass.aos)}Z
</span>
</div>
{/* Where in the pass we are. A bar because the useful question
mid-pass is not the clock but "am I past the peak". */}
<div className="mt-1.5 h-1 rounded-full bg-muted overflow-hidden">
<div className="h-full bg-success transition-[width] duration-1000 ease-linear"
style={{ width: `${Math.round(progress * 100)}%` }} />
</div>
<div className="mt-1.5 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
<PassBit label={t('sat.rise')} value={`${hhmm(pass.aos)} ${compass(pass.aos_az)}`} />
<PassBit label={t('sat.peak')} value={`${Math.round(pass.max_el)}° ${compass(pass.max_el_az)}`}
strong={pass.max_el >= 30} />
<PassBit label={t('sat.set')} value={`${hhmm(pass.los)} ${compass(pass.los_az)}`} />
</div>
</>
)}
</div>
{/* The sky, seen from underneath it: the centre is straight up, the
rim is the horizon, north is at the top. One glance says whether
the pass comes over the roof or along the treeline. */}
{skyShown && (
<div className="rounded-lg border border-border bg-card p-2">
<SkyPlot
track={sky}
az={tuning?.az ?? null}
el={tuning?.el ?? null}
name={bird?.name}
visible={!!tuning?.visible}
/>
</div>
)}
{/* Where it is, right now. */}
<div className="rounded-lg border border-border bg-card p-2">
<div className="grid grid-cols-2 gap-2">
<Readout label={t('sat.az')} value={tuning ? fmtDeg(tuning.az) : '—'}
sub={tuning ? compass(tuning.az) : ''} />
<Readout label={t('sat.el')} value={tuning ? fmtDeg(tuning.el) : '—'}
colour={tuning?.visible ? 'var(--success)' : 'var(--muted-foreground)'}
sub={tuning ? (tuning.visible ? t('sat.up') : t('sat.below')) : ''} />
</div>
<div className="mt-2 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
<PassBit label={t('sat.range')} value={tuning?.range_km ? fmtKm(tuning.range_km) : '—'} />
<PassBit label={t('sat.altitude')} value={tuning?.alt_km ? fmtKm(tuning.alt_km) : '—'} />
<PassBit label={t('sat.footprint')} value={tuning?.footprint_km ? fmtKm(tuning.footprint_km) : '—'} />
</div>
{/* Approaching or receding, which is the sign of the whole Doppler
correction and the one number that explains why the frequencies
are moving the way they are. */}
{!!tuning && !bird?.geostationary && (
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground tabular-nums">
{tuning.range_rate < 0
? <ArrowUp className="size-3 text-success" />
: <ArrowDown className="size-3 text-warning" />}
<span>{tuning.range_rate < 0 ? t('sat.approaching') : t('sat.receding')}</span>
<span>{Math.abs(tuning.range_rate).toFixed(2)} km/s</span>
</div>
)}
{/* Where the antenna is, beside where the satellite is. The two
differing is a rotator still slewing; the two differing for a
long time is a rotator that is stuck, and that is worth being
able to see without walking outside. */}
{tracking?.rot_on && (
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-baseline gap-2 text-[11px] tabular-nums">
<span className="text-muted-foreground uppercase tracking-wide text-[10px]">{t('sat.antenna')}</span>
<span className="font-medium">{fmtDeg(tracking.rot_az)} / {fmtDeg(tracking.rot_el)}</span>
{!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
</div>
)}
</div>
{/* What to tune. */}
<div className="rounded-lg border border-border bg-card p-2">
<div className="space-y-1">
<FreqRow label={t('sat.down')} hz={tuning?.down_hz ?? 0} nominal={tuning?.nominal_down ?? 0} />
<FreqRow label={t('sat.up')} hz={tuning?.up_hz ?? 0} nominal={tuning?.nominal_up ?? 0} />
</div>
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-muted-foreground">
{!!tp?.mode && <span>{tp.mode}</span>}
{!!tp?.ctcss && <span>CTCSS {tp.ctcss.toFixed(1)}</span>}
{tp?.inverting && <span>{t('sat.inverting')}</span>}
{tp?.linear && <span>{Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz</span>}
{bird?.geostationary && <span>{t('sat.geo')}</span>}
</div>
</div>
{/* What is coming. */}
<div className="rounded-lg border border-border bg-card flex-1 min-h-0 flex flex-col">
<div className="px-2 py-1 text-[11px] font-medium text-muted-foreground border-b border-border shrink-0">
{t('sat.nextPasses')}
</div>
<div className="flex-1 min-h-0 overflow-auto">
{passes.length === 0 && (
<div className="p-2 text-[11px] text-muted-foreground">{t('sat.noPasses')}</div>
)}
{passes.length > 0 && (
// A real table, so the name column takes the width the longest
// name needs — "ZHUHAI-1 OVS-1A" was cut to eight characters in
// a fixed one — and the rest keeps its columns lined up under
// headings that say what the numbers are.
<table className="w-full text-[11px] tabular-nums">
<thead className="sticky top-0 z-10 bg-card">
<tr className="text-[10px] uppercase tracking-wide text-muted-foreground">
<th className="text-left font-medium px-2 py-1">{t('sat.thSat')}</th>
<th className="text-left font-medium py-1">{t('sat.thAos')}</th>
<th className="text-left font-medium py-1">{t('sat.thLos')}</th>
<th className="text-right font-medium py-1">{t('sat.thMaxEl')}</th>
<th className="text-right font-medium px-2 py-1">{t('sat.thIn')}</th>
</tr>
</thead>
<tbody>
{passes.map((p, i) => {
const aos = Date.parse(p.aos);
const running = aos <= now && Date.parse(p.los) > now;
const soon = !running && aos - now < 5 * 60_000;
return (
<tr
key={`${p.name}-${p.aos}-${i}`}
onClick={() => setSel(p.name)}
className={cn(
'cursor-pointer hover:bg-accent/50 border-t border-border/40',
p.name === sel && 'bg-accent/40',
running && 'bg-success/10',
)}
>
<td className="px-2 py-1 whitespace-nowrap">
<span className={cn('font-medium', running && 'text-success')}>{p.name}</span>
<ModeDot mode={modeOf(p.name)} />
</td>
<td className="py-1 whitespace-nowrap">{hhmm(p.aos)}</td>
<td className="py-1 whitespace-nowrap text-muted-foreground">{hhmm(p.los)}</td>
{/* The elevation is the quality of the pass, so it is
coloured like one: a 70° pass overhead and a 12°
scrape along the horizon are not the same evening. */}
<td className={cn('py-1 text-right font-medium', elClass(p.max_el))}>
{Math.round(p.max_el)}°
</td>
<td className={cn('px-2 py-1 text-right whitespace-nowrap',
running ? 'text-success font-medium' : soon ? 'text-warning' : 'text-muted-foreground')}>
{running ? t('sat.now') : fmtCountdown(aos - now)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
</div>
</div>
</div>
);
}
function Readout({ label, value, colour, sub }: { label: string; value: string; colour?: string; sub?: string }) {
return (
<div className="rounded-md bg-muted/40 px-2 py-1">
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</div>
<div className="flex items-baseline gap-1.5">
<span className="text-lg font-semibold tabular-nums leading-tight" style={colour ? { color: colour } : undefined}>
{value}
</span>
{!!sub && <span className="text-[10px] text-muted-foreground">{sub}</span>}
</div>
</div>
);
}
function PassBit({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
return (
<div className="min-w-0">
<div className="text-[10px] uppercase tracking-wide text-muted-foreground truncate">{label}</div>
<div className={cn('truncate', strong && 'font-semibold text-foreground')}>{value}</div>
</div>
);
}
// The corrected frequency large, the nominal one small beside it. Showing only
// one of them leaves an operator unable to tell a Doppler correction from a
// mistuned transponder.
function FreqRow({ label, hz, nominal }: { label: string; hz: number; nominal: number }) {
const shift = hz && nominal ? hz - nominal : 0;
return (
<div className="flex items-baseline gap-2">
<span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span>
<span className="text-base font-semibold tabular-nums">{fmtHz(hz)}</span>
{!!shift && (
<span className="text-[10px] tabular-nums text-muted-foreground">
{shift > 0 ? '+' : ''}{Math.abs(Math.round(shift))} Hz
</span>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
+142
View File
@@ -0,0 +1,142 @@
import { useI18n } from '@/lib/i18n';
// The sky, seen from underneath it.
//
// The map answers "where is the satellite over the earth". This answers "where
// do I look", which during a pass is the question that matters: whether the
// bird comes over the top or clips the horizon behind the house is something no
// amount of azimuth and elevation digits conveys, and one glance at a polar
// plot settles it.
//
// The projection is the one every satellite tracker uses and every operator
// already reads: the centre is the zenith, the outer circle is the horizon, and
// north is up. So the radius is (90 elevation), NOT the elevation — a
// satellite overhead is a dot in the middle, and a pass that stays near the rim
// is one that never rises.
export type SkyPoint = { at: string; az: number; el: number };
export function SkyPlot({ track, az, el, name, visible, size = 300 }: {
// The pass, sampled from rise to set. Empty draws the dial alone, which is
// still worth showing: it says where the antenna is pointing now.
track: SkyPoint[];
// Where the satellite is at this instant, or null when it is not up.
az?: number | null;
el?: number | null;
name?: string;
visible?: boolean;
size?: number;
}) {
const { t } = useI18n();
const R = size / 2 - 14; // the horizon circle
const cx = size / 2, cy = size / 2;
// Where a bearing and an elevation land on the dial.
const pt = (azDeg: number, elDeg: number): [number, number] => {
const r = R * (90 - Math.max(0, Math.min(90, elDeg))) / 90;
const a = (azDeg * Math.PI) / 180;
return [cx + r * Math.sin(a), cy - r * Math.cos(a)];
};
const rings = [15, 30, 45, 60, 75];
const path = track.length > 1
? track.map((p, i) => `${i === 0 ? 'M' : 'L'}${pt(p.az, p.el).map((v) => v.toFixed(1)).join(' ')}`).join(' ')
: '';
// Ticks every 10°, longer every 30°, so the rim reads as a compass rather
// than a plain circle.
const ticks = [];
for (let a = 0; a < 360; a += 10) {
const long = a % 30 === 0;
const rad = (a * Math.PI) / 180;
const r1 = R, r2 = R - (long ? 7 : 4);
ticks.push(
<line key={a}
x1={cx + r1 * Math.sin(rad)} y1={cy - r1 * Math.cos(rad)}
x2={cx + r2 * Math.sin(rad)} y2={cy - r2 * Math.cos(rad)}
stroke="var(--border)" strokeWidth={long ? 1.4 : 0.8} />,
);
}
const here = az != null && el != null ? pt(az, el) : null;
const start = track.length > 1 ? pt(track[0].az, track[0].el) : null;
const end = track.length > 1 ? pt(track[track.length - 1].az, track[track.length - 1].el) : null;
return (
<svg viewBox={`0 0 ${size} ${size}`} className="w-full h-auto select-none" role="img"
aria-label={t('sat.skyPlot')}>
<defs>
{/* An arrowhead on the track: a pass has a direction, and which way the
satellite is travelling decides where to point the antenna next. */}
<marker id="skyArrow" viewBox="0 0 10 10" refX="6" refY="5"
markerWidth="5" markerHeight="5" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--success)" />
</marker>
</defs>
<circle cx={cx} cy={cy} r={R} fill="var(--muted)" fillOpacity={0.25}
stroke="var(--border)" strokeWidth={1.5} />
{rings.map((e) => (
<circle key={e} cx={cx} cy={cy} r={R * (90 - e) / 90}
fill="none" stroke="var(--border)" strokeWidth={0.6} strokeDasharray="3 4" />
))}
{ticks}
{/* The cardinal cross. */}
<line x1={cx} y1={cy - R} x2={cx} y2={cy + R} stroke="var(--border)" strokeWidth={0.6} />
<line x1={cx - R} y1={cy} x2={cx + R} y2={cy} stroke="var(--border)" strokeWidth={0.6} />
{([
{ lbl: 'N', x: cx, y: cy - R - 3, anchor: 'middle' },
{ lbl: 'S', x: cx, y: cy + R + 11, anchor: 'middle' },
{ lbl: 'E', x: cx + R + 4, y: cy + 4, anchor: 'start' },
{ lbl: 'W', x: cx - R - 4, y: cy + 4, anchor: 'end' },
] as const).map((c) => (
<text key={c.lbl} x={c.x} y={c.y} textAnchor={c.anchor}
fontSize={11} fontWeight={600} fill="var(--muted-foreground)">{c.lbl}</text>
))}
{/* Elevation labels along the west arm, the way a tracker draws them. */}
{[0, 30, 60].map((e) => (
<text key={e} x={cx - R * (90 - e) / 90 + 2} y={cy + 10} fontSize={8}
fill="var(--muted-foreground)" opacity={0.8}>{e}°</text>
))}
{/* The pass. */}
{path && (
<path d={path} fill="none" stroke="var(--success)" strokeWidth={1.6}
strokeDasharray="5 3" markerMid="url(#skyArrow)" markerEnd="url(#skyArrow)"
opacity={0.85} />
)}
{start && <circle cx={start[0]} cy={start[1]} r={3} fill="none" stroke="var(--success)" strokeWidth={1.4} />}
{end && <circle cx={end[0]} cy={end[1]} r={3} fill="var(--success)" opacity={0.6} />}
{/* Where it is now. Hollow and grey below the horizon: the numbers are
still right, but nothing can be worked through the earth. */}
{here && (
<g>
<line x1={here[0] - 6} y1={here[1]} x2={here[0] + 6} y2={here[1]}
stroke={visible ? 'var(--success)' : 'var(--muted-foreground)'} strokeWidth={1.4} />
<line x1={here[0]} y1={here[1] - 6} x2={here[0]} y2={here[1] + 6}
stroke={visible ? 'var(--success)' : 'var(--muted-foreground)'} strokeWidth={1.4} />
<circle cx={here[0]} cy={here[1]} r={4}
fill={visible ? 'var(--success)' : 'none'}
stroke={visible ? 'var(--background)' : 'var(--muted-foreground)'} strokeWidth={1.2} />
</g>
)}
{/* The name and the look angles, in the middle, where a tracker puts them
big enough to read from the other side of the shack. */}
{!!name && (
<text x={cx} y={cy - R * 0.42} textAnchor="middle" fontSize={18} fontWeight={600}
fill="var(--foreground)" opacity={0.85}>{name}</text>
)}
{az != null && el != null && (
<text x={cx} y={cy - R * 0.22} textAnchor="middle" fontSize={12}
fill="var(--muted-foreground)" className="tabular-nums">
AZ {az.toFixed(1)}° EL {el.toFixed(1)}°
</text>
)}
</svg>
);
}
@@ -11,6 +11,8 @@ import { useI18n } from '@/lib/i18n';
import { writeUiPref } from '@/lib/uiPref';
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
import { RotorCompass } from '@/components/RotorCompass';
import { RotorCompassClassic } from '@/components/RotorCompassClassic';
import { rotorStyle, subscribeRotorStyle } from '@/lib/rotorStyle';
import { AmpCard } from '@/components/AmpCard';
import { TunerCard } from '@/components/TunerCard';
import type { TGStatus } from '@/components/TunerGeniusPanel';
@@ -128,6 +130,11 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
}) {
const [goto, setGoto] = useState('');
const [err, setErr] = useState('');
// Both compasses in the app follow the same preference (Settings → Rotator):
// one operator's choice of dial, not one per panel.
const [dial, setDial] = useState(() => rotorStyle());
useEffect(() => subscribeRotorStyle(() => setDial(rotorStyle())), []);
const Dial = dial === 'classic' ? RotorCompassClassic : RotorCompass;
const turn = (az: number) => {
const a = ((Math.round(az) % 360) + 360) % 360;
@@ -145,9 +152,12 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
</div>
<div className="p-3 flex gap-4 items-start">
{/* The SP/LP readout lives INSIDE RotorCompass, so every compass in the
app carries it rather than each caller drawing its own. */}
<RotorCompass
{/* The compact compass is the dial alone short and long path are the
two coloured dots on it. The figures are in the status bar. */}
{/* The dial-only form is square and fills the box it is given, so its
size is set here rather than baked into the component. */}
<div className={dial === 'classic' ? 'shrink-0' : 'w-[210px] shrink-0'}>
<Dial
bearing={bearing ?? null}
headings={hd.ok ? [hd.azimuth] : []}
centerLat={centerLat ?? null}
@@ -158,6 +168,7 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
onGoto={(az) => turn(az)}
/>
</div>
<div className="flex-1 min-w-0 space-y-2">
<div className="font-mono">
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
@@ -2,7 +2,8 @@ import React, { useCallback, useEffect, useState } from 'react';
import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
import {
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
GetWsjtHighlight, SetWsjtHighlight, GetWsjtFollowMode, SetWsjtFollowMode,
GetWsjtHighlight, SetWsjtHighlight, GetWsjtHighlightWorked, SetWsjtHighlightWorked, GetWsjtFollowMode, SetWsjtFollowMode,
GetWsjtHighlightColours, SetWsjtHighlightColours,
} from '../../wailsjs/go/main/App';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -160,9 +161,16 @@ type Props = { onError: (msg: string) => void };
export function UDPIntegrationsPanel({ onError }: Props) {
const [highlightOn, setHighlightOn] = useState(false);
const [hlWorked, setHlWorked] = useState(false);
// The palette, as chosen. Background per verdict; the text colour is the
// backends business (see colourFor).
const [colours, setColours] = useState({ watchlist: '#F472B6', new_dxcc: '#16823C', new_band: '#E27A18', worked: '#4B5563' });
const [followMode, setFollowMode] = useState(true);
useEffect(() => {
GetWsjtHighlight().then((v) => setHighlightOn(!!v)).catch(() => {});
GetWsjtHighlightColours().then((c: any) => { if (c) setColours(c); }).catch(() => {});
GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {});
GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {});
GetWsjtFollowMode().then((v) => setFollowMode(!!v)).catch(() => {});
}, []);
const { t } = useI18n();
@@ -246,6 +254,54 @@ export function UDPIntegrationsPanel({ onError }: Props) {
<span className="block text-[11px] text-muted-foreground">{t('udpp.highlightHint')}</span>
</span>
</label>
{/* The palette, nested under the switch for the same reason as the box
below it. One colour per verdict, and the background only: the text
colour is worked out from it, so a chosen colour cannot come back
unreadable in the decoder's window. */}
{highlightOn && (
<div className="pl-6 max-w-2xl space-y-1.5">
<div className="text-[11px] text-muted-foreground">{t('udpp.hlColours')}</div>
<div className="flex flex-wrap gap-3">
{([
['watchlist', t('udpp.hlWatchlist')],
['new_dxcc', t('udpp.hlNewDxcc')],
['new_band', t('udpp.hlNewBand')],
['worked', t('udpp.hlWorkedC')],
] as const).map(([k, label]) => (
<label key={k} className="inline-flex items-center gap-1.5 text-xs">
<input
type="color"
value={(colours as any)[k] || '#000000'}
onChange={(e) => {
const next = { ...colours, [k]: e.target.value.toUpperCase() };
setColours(next);
void SetWsjtHighlightColours(next as any);
}}
className="h-6 w-8 rounded border border-border bg-background p-0.5 cursor-pointer"
/>
{label}
</label>
))}
<button type="button" className="text-xs text-muted-foreground underline hover:text-foreground"
onClick={() => {
const def = { watchlist: '#F472B6', new_dxcc: '#16823C', new_band: '#E27A18', worked: '#4B5563' };
setColours(def as any);
void SetWsjtHighlightColours(def as any);
}}>
{t('udpp.hlReset')}
</button>
</div>
</div>
)}
{/* 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>
</label>
)}
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl">
<Checkbox checked={followMode}
onCheckedChange={(c) => { setFollowMode(!!c); void SetWsjtFollowMode(!!c); }} />
+39 -106
View File
@@ -11,7 +11,7 @@
// The design is DXHunter's — the layout, the badges, the wording — repainted in
// the app's theme tokens rather than its hard-coded slate/pink.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Eye, Plus, Trash2, Bell, BellOff, Trophy, Search } from 'lucide-react';
import { Eye, Plus, Trash2, Bell, BellOff, Trophy, Search, Check, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
@@ -25,13 +25,7 @@ import {
import { EventsOn } from '../../wailsjs/runtime/runtime';
import type { ClusterSpot, SpotStatusEntry } from '@/components/ClusterGrid';
import { inferSpotMode, spotStatusKey } from '@/lib/spot';
interface WLEntry {
callsign: string; lastSeenStr: string; addedAt: string; spotCount: number;
isContest: boolean; notify: boolean;
isExpedition: boolean; clubLogQSOs24h: number; clubLogTotalQSOs: number;
clubLogHasOQRS: boolean; clubLogLiveStream: boolean;
}
import { useWatchlistSpots, matchesEntry, newBadge, type WLEntry } from '@/lib/watchlistSpots';
interface Props {
spots: ClusterSpot[];
@@ -40,21 +34,6 @@ interface Props {
onSpotClick?: (s: ClusterSpot) => void;
}
// A spot is ON AIR for the badge while its last sighting is this fresh.
const ON_AIR_MS = 10 * 60 * 1000;
// Exact unless the entry carries a trailing * — the same rule the backend's
// Match applies to the live stream, mirrored so the tab and the alerts can
// never disagree about what an entry covers.
function matchesEntry(call: string, pattern: string): boolean {
const c = call.toUpperCase();
if (pattern.endsWith('*')) {
const p = pattern.slice(0, -1);
return p !== '' && c.startsWith(p);
}
return c === pattern;
}
export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: Props) {
const { t } = useI18n();
const [entries, setEntries] = useState<WLEntry[]>([]);
@@ -95,8 +74,6 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
noticeTimer.current = window.setTimeout(() => { setError(''); setNotice(''); }, 4000) as unknown as number;
};
// worked answer per "call|band|modeclass|contest" key.
const [worked, setWorked] = useState<Record<string, boolean>>({});
const refresh = useCallback(async () => {
try { setEntries(((await WatchlistEntries()) ?? []) as any as WLEntry[]); }
@@ -117,63 +94,11 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
// Live spots per entry — prefix-matched, newest first, deduped per band+mode
// (one line per slot; the freshest spot represents it).
const spotsFor = useMemo(() => {
const map = new Map<string, ClusterSpot[]>();
for (const e of entries) map.set(e.callsign, []);
for (const s of spots) {
for (const e of entries) {
if (matchesEntry(s.dx_call ?? '', e.callsign)) { map.get(e.callsign)!.push(s); break; }
}
}
for (const [k, list] of map) {
const seen = new Set<string>();
map.set(k, list.filter((s) => {
const key = `${(s.band ?? '')}|${inferSpotMode(s.comment ?? '', s.freq_hz)}|${s.dx_call}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
}));
}
return map;
}, [spots, entries]);
// The worked answers, refreshed when the visible slots change. Debounced: a
// spot burst must cost one round trip, not one per spot.
const queryTimer = useRef<number | undefined>(undefined);
useEffect(() => {
if (queryTimer.current) window.clearTimeout(queryTimer.current);
queryTimer.current = window.setTimeout(async () => {
const queries: { call: string; band: string; mode: string; contest: boolean }[] = [];
const keys: string[] = [];
for (const e of entries) {
for (const s of spotsFor.get(e.callsign) ?? []) {
const mode = inferSpotMode(s.comment ?? '', s.freq_hz) || '';
queries.push({ call: s.dx_call, band: s.band ?? '', mode, contest: e.isContest });
keys.push(`${s.dx_call}|${s.band ?? ''}|${mode}|${e.isContest ? 1 : 0}`);
}
}
if (queries.length === 0) { setWorked({}); return; }
try {
const res: boolean[] = (await WatchlistWorkedSlots(queries as any)) ?? [];
// MERGED, not replaced: replacing made every already-answered key
// momentarily unknown on each refresh, which re-hid settled lines.
setWorked((prev) => {
const next = { ...prev };
keys.forEach((k, i) => { next[k] = !!res[i]; });
return next;
});
} catch { /* the badges just stay conservative */ }
}, 150) as unknown as number;
return () => { if (queryTimer.current) window.clearTimeout(queryTimer.current); };
}, [spotsFor, entries]);
const wkey = (e: WLEntry, s: ClusterSpot) =>
`${s.dx_call}|${s.band ?? ''}|${inferSpotMode(s.comment ?? '', s.freq_hz) || ''}|${e.isContest ? 1 : 0}`;
const workedFor = (e: WLEntry, s: ClusterSpot): boolean => worked[wkey(e, s)] ?? false;
// A spot whose verdict has not come back yet is NOT drawn. Showing it as
// Needed and withdrawing it half a second later made the list twitch on
// every burst — and nobody needs a spot 400 ms early, they need it settled.
const settled = (e: WLEntry, s: ClusterSpot): boolean => wkey(e, s) in worked;
// Which entries are on the air, and which of their slots are still needed.
// One definition, shared with the docked watch-list widget — the answer
// involves a debounced query per visible slot, and two copies of it would be
// two bursts of the same question and two ideas of what "needed" means.
const { spotsFor, workedFor, settled, onAir, worked } = useWatchlistSpots(entries, spots);
const add = async () => {
const c = addCall.trim().toUpperCase();
@@ -195,11 +120,7 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
catch (e: any) { flash(String(e?.message ?? e), true); }
};
const isOnAir = (e: WLEntry): boolean =>
(spotsFor.get(e.callsign) ?? []).some((s) => {
const ts = Date.parse(String((s as any).received_at ?? ''));
return ts > 0 && Date.now() - ts < ON_AIR_MS;
});
const isOnAir = onAir;
const shown = entries.filter((e) => {
if (family === 'normal' && e.isContest) return false;
@@ -225,18 +146,20 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
// The cluster's own badge for a spot, read from the shared status index.
const dxccBadge = (s: ClusterSpot): { label: string; color: string } | null => {
const st = spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)];
switch (st?.status) {
case 'new': return { label: t('wl.newDxcc'), color: 'var(--danger)' };
case 'new-band-mode': return { label: t('clg2.newBandMode'), color: 'var(--danger)' };
case 'new-band': return { label: t('clg2.newBand'), color: 'var(--warning)' };
case 'new-mode': return { label: t('clg2.newMode'), color: 'var(--caution)' };
case 'new-slot': return { label: t('clg2.newSlot'), color: '#5AC8FA' };
default: return null;
}
const b = newBadge(st?.status);
return b ? { label: t(b.key), color: b.colour } : null;
};
// Three decimals, and no trailing zeros beyond them: 7.056 rather than
// 7.0560, 14.0745 rather than 14.074500. DXHunter's own rule, and the one an
// operator reads a cluster line with.
const fmtMHz = (hz: number) => {
const [int, dec] = (hz / 1e6).toFixed(6).split('.');
return int + '.' + dec.slice(0, 3) + dec.slice(3).replace(/0+$/, '');
};
const chip = (color: string, text: string, extra?: string) => (
<span className={cn('px-1.5 py-0.5 rounded text-[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)` }}>
{text}
</span>
@@ -332,14 +255,18 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
const list = neededOnly ? all.filter((s) => !workedFor(e, s)) : all;
return (
<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',
e.isContest && 'border-l-4 border-l-warning')}>
<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')}
{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')}>
<Trophy className="size-3" /> {t('wl.contest')}
</span>
@@ -349,16 +276,16 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
{e.clubLogHasOQRS && chip('var(--success)', 'OQRS')}
{e.clubLogLiveStream && (
<a href="#" onClick={(ev) => { ev.preventDefault(); void OpenExternalURL(`https://clublog.org/livestream/${e.callsign.replace('*', '')}`); }}
className="px-1.5 py-0.5 rounded text-[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
? chip('var(--warning)', e.isContest ? t('wl.nToday', { n: needed }) : t('wl.nNeeded', { n: needed }))
: chip('var(--success)', e.isContest ? t('wl.workedToday') : t('wl.allWorked')))}
{e.lastSeenStr && e.lastSeenStr !== 'Never' && (
<span className="text-[11px] text-muted-foreground">· {e.lastSeenStr}</span>
<span className="text-[11px] text-muted-foreground"> {e.lastSeenStr}</span>
)}
{e.spotCount > 0 && (
<span className="text-[11px] text-muted-foreground/70">· {t('wl.totalSpots', { n: e.spotCount })}</span>
<span className="text-[11px] text-muted-foreground/70"> {t('wl.totalSpots', { n: e.spotCount })}</span>
)}
<div className="flex-1" />
<button type="button" title={t('wl.toggleContest')}
@@ -390,14 +317,20 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
onDoubleClick={() => onSpotClick?.(s)}
title={t('wl.spotTip')}
className={cn('w-full flex items-center gap-2 px-2 py-1.5 rounded text-[11px] bg-muted/25 hover:bg-muted/70 transition-colors text-left',
!done && 'border-l-[3px] border-warning')}>
{done && <span className='font-bold shrink-0 text-success'></span>}
!done && 'border-l-2 border-warning')}>
{/* Worked or wanted, said with a symbol at the head of
the line as well as with the stripe down its side
the same two marks DXHunter uses, and the one an eye
finds first when a card holds ten rows. */}
{done
? <Check className="size-4 shrink-0 text-success" />
: <AlertTriangle className="size-4 shrink-0 text-warning" />}
{/* Fixed columns: an elastic country made band/mode/freq start wherever the name ended — every row its own ruler. */}
<span className="font-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="px-1.5 rounded bg-muted shrink-0 w-11 text-center">{s.band}</span>
<span className="px-1.5 rounded shrink-0 w-11 text-center" style={mode ? { color: 'var(--chart-5)', background: 'color-mix(in srgb, var(--chart-5) 12%, transparent)' } : undefined}>{mode || ' '}</span>
<span className="font-mono text-muted-foreground shrink-0 w-16 text-right">{(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)}
<div className="flex-1" />
{done
+147
View File
@@ -0,0 +1,147 @@
// WatchlistWidget — the watch list reduced to what is worth acting on RIGHT
// NOW: entries that are on the air and still needed.
//
// The Watchlist tab is a tab, and an operator working FT8 lives on the decodes
// one. A station they asked to be told about would appear on a screen they are
// not looking at — so the same answer is docked in the widget strip, which sits
// above the tabs and is therefore always in view. Only active AND needed: a
// list of everything watched is the tab's job, and it would not fit here.
import { useEffect, useMemo, useState } from 'react';
import { Bell, X } from 'lucide-react';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { inferSpotMode, spotStatusKey } from '@/lib/spot';
import { useWatchlistSpots, newBadge, type WLEntry } from '@/lib/watchlistSpots';
import type { ClusterSpot, SpotStatusEntry } from '@/components/ClusterGrid';
import { WatchlistEntries } from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime';
interface Props {
spots: ClusterSpot[];
// The cluster's own verdicts. WHAT a station is worth is the reason to leave
// what you are doing for it — "on the air and not worked" says nothing about
// whether it is a new entity or a fifth band on one worked in 1998.
spotStatus: Record<string, SpotStatusEntry>;
// A row is a spot: clicking it does what clicking one in the cluster does.
onPick?: (s: ClusterSpot) => void;
onClose?: () => void;
}
function age(s: ClusterSpot): string {
const ts = Date.parse(String((s as any).received_at ?? ''));
if (!(ts > 0)) return '';
const m = Math.floor((Date.now() - ts) / 60000);
if (m < 1) return 'now';
return `${m}m`;
}
export function WatchlistWidget({ spots, spotStatus, onPick, onClose }: Props) {
const { t } = useI18n();
const [entries, setEntries] = useState<WLEntry[]>([]);
const load = () => { WatchlistEntries().then((e: any) => setEntries((e ?? []) as WLEntry[])).catch(() => {}); };
useEffect(() => {
load();
// The list changes from four places (the tab, the cluster's star, the
// contest auto-add, an import), and a widget that only reads it at mount
// would quietly watch the wrong set for the rest of the session.
const off = EventsOn('watchlist:changed', load);
return () => { off?.(); };
}, []);
const { spotsFor, workedFor, settled } = useWatchlistSpots(entries, spots);
// Ticking, because every row carries an age and the freshest thing here is
// the reason to look at it at all.
const [, tick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => tick((n) => n + 1), 30000);
return () => window.clearInterval(id);
}, []);
// One row per (entry, needed slot): the same station on two bands is two
// chances to work it, and collapsing them would hide the one that is open.
const rows = useMemo(() => {
const out: { e: WLEntry; s: ClusterSpot }[] = [];
for (const e of entries) {
for (const s of spotsFor.get(e.callsign) ?? []) {
if (!settled(e, s) || workedFor(e, s)) continue;
out.push({ e, s });
}
}
// Freshest first: a spot from twenty minutes ago is history, and this
// panel is short.
return out.sort((a, b) =>
Date.parse(String((b.s as any).received_at ?? '')) - Date.parse(String((a.s as any).received_at ?? '')));
}, [entries, spotsFor, workedFor, settled]);
return (
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
<Bell className="size-4 text-primary shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('wlw.title')}
</span>
<span className={cn('rounded px-1.5 py-px text-[10px] font-bold',
rows.length > 0 ? 'bg-warning text-warning-foreground' : 'bg-muted text-muted-foreground')}>
{rows.length}
</span>
<div className="flex-1" />
{onClose && (
<button type="button" onClick={onClose} title={t('wlw.hide')}
className="text-muted-foreground hover:text-foreground transition-colors">
<X className="size-3.5" />
</button>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{rows.length === 0 ? (
// Empty is the normal state, and it means something precise — say it,
// rather than leaving a blank box that reads as broken.
<div className="px-3 py-3 text-xs text-muted-foreground">
{entries.length === 0 ? t('wlw.emptyList') : t('wlw.emptyNone')}
</div>
) : rows.map(({ e, s }, i) => {
const mode = inferSpotMode(s.comment ?? '', s.freq_hz) || '';
const badge = newBadge(spotStatus[spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz)]?.status);
return (
<button
key={`${e.callsign}-${s.dx_call}-${s.band}-${mode}-${i}`}
type="button"
onClick={() => onPick?.(s)}
title={e.callsign === s.dx_call
? t('wlw.pick', { call: s.dx_call })
: `${t('wlw.pick', { call: s.dx_call })} — ${e.callsign}`}
className="w-full text-left px-2 py-1 border-b border-border/20 hover:bg-muted/50 transition-colors flex items-center gap-1.5"
>
{/* One line, in reading order: who, where, what it is worth, how
long ago. The callsign is the only part allowed to give way
everything else is short and fixed, and a row that wraps is a
row an operator has to parse instead of scan. */}
<span className="font-mono text-[13px] font-bold text-warning truncate">{s.dx_call}</span>
{s.band && (
<span className="rounded px-1 py-px text-[10px] font-bold uppercase bg-info-muted text-info-muted-foreground shrink-0">
{s.band}
</span>
)}
{mode && <span className="font-mono text-[10px] text-muted-foreground shrink-0">{mode}</span>}
<div className="flex-1" />
{/* Why it is worth interrupting a QSO for — or not. */}
{badge && (
<span className="rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide border shrink-0"
style={{ color: badge.colour, borderColor: `color-mix(in srgb, ${badge.colour} 45%, transparent)`,
background: `color-mix(in srgb, ${badge.colour} 12%, transparent)` }}>
{t(badge.key)}
</span>
)}
<span className="font-mono text-[10px] text-muted-foreground/70 tabular-nums shrink-0 w-7 text-right">
{age(s)}
</span>
</button>
);
})}
</div>
</section>
);
}
+20 -1
View File
@@ -9,7 +9,7 @@ import { cn } from '@/lib/utils';
// can't hold a typo'd value that isn't in the list.
export function Combobox({
value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false,
showToggle = false,
showToggle = false, onWheelStep,
}: {
value: string;
onChange: (v: string) => void;
@@ -21,6 +21,10 @@ export function Combobox({
// fields read live by other actions — e.g. RST, so a CW macro sent without
// leaving the field uses the value just typed.
commitOnType?: boolean;
// Wheel over the field steps the value. The control cannot know what a step
// means — a decibel here, an S-unit there — so it reports the direction and
// the owner decides.
onWheelStep?: (dir: 1 | -1) => void;
// Draw a chevron that opens the full list on click.
//
// Without it this control is indistinguishable from a plain text box: it opens
@@ -42,6 +46,21 @@ export function Combobox({
// leave the list floating over the wrong row.
const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 });
// A NATIVE, non-passive listener: React attaches onWheel passively, where
// preventDefault does nothing at all and the panel scrolls away under the
// field being adjusted.
useEffect(() => {
const el = ref.current;
if (!el || !onWheelStep) return;
const onWheel = (e: WheelEvent) => {
if (e.deltaY === 0) return;
e.preventDefault();
onWheelStep(e.deltaY < 0 ? 1 : -1); // up is a better report
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [onWheelStep]);
useEffect(() => {
function onDoc(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
-221
View File
@@ -1,221 +0,0 @@
// Auto-call: let OpsLog answer a decode without the operator clicking it.
//
// WITHDRAWN FROM THE INTERFACE, because DXHunter already does it.
//
// Two programs from the same shack deciding on their own to answer the same
// decode is worse than either doing it alone: they cannot see each other, so
// they key over one another, and afterwards there is no telling which of them
// called. The duplicate is the one to remove, and DXHunter is where this lives.
//
// There is no switch in Preferences and no button on the decodes toolbar, and
// App.tsx returns before the loop can run. The file is kept whole: the rules
// below are the delicate part, argued over and tested, and rewriting them from
// memory later would be worse than leaving them here. Reinstating the feature
// means restoring all three — the settings page, the button, and the guard.
//
// This KEYS THE TRANSMITTER on its own, which is why the rules here are written
// as a series of refusals rather than a search for a reason to call. Everything
// below has to be true; anything unknown means no.
//
// The decision is made here, in one pure function, precisely because it is the
// dangerous part: it can be read, argued with and tested without a radio.
export type AutoCallCriteria = {
dxcc: boolean; // entity never worked
bandmode: boolean; // entity worked, but neither this band nor this mode
band: boolean; // entity never worked on this band
mode: boolean; // entity never worked in this mode
slot: boolean; // band and mode each worked, never together
grid: boolean; // square wanted under the grid scope
county: boolean; // US county never worked
pota: boolean; // park never worked
// No SOTA here, though the shape invites it: a decode carries no summit
// reference and the backend publishes no "new summit" flag, so a criterion
// for it could never be true. It WAS declared, translated and impossible to
// tick — a field that lies about what the feature can do.
pfx: boolean; // CQ WPX prefix never worked
};
export type AutoCallSettings = {
enabled: boolean;
criteria: AutoCallCriteria;
// Callsigns to answer on sight, wildcards allowed (4S7*, */P). Each is still
// subject to `watchCriteria` — "call TM0HQ, but only if it is a new band" is
// the request, not "call it every time it appears".
watch: string[];
// Empty means call a watched callsign whenever it is not already worked.
watchCriteria: AutoCallCriteria;
// Seconds to ignore a callsign after calling it, so a station that keeps
// sending CQ is not re-answered every slot while the QSO is in progress.
cooldownSec: number;
};
export const emptyCriteria: AutoCallCriteria = {
dxcc: false, bandmode: false, band: false, mode: false, slot: false,
grid: false, county: false, pota: false, pfx: false,
};
export const defaultAutoCall: AutoCallSettings = {
// OFF, and it stays off until asked for. Unattended transmit is not something
// to inherit from an upgrade.
enabled: false,
criteria: { ...emptyCriteria },
watch: [],
watchCriteria: { ...emptyCriteria },
cooldownSec: 120,
};
const AC_KEY = 'opslog.autoCall';
export function loadAutoCall(): AutoCallSettings {
try {
const raw = localStorage.getItem(AC_KEY);
if (!raw) return { ...defaultAutoCall };
const v = JSON.parse(raw);
const out: AutoCallSettings = {
...defaultAutoCall,
...v,
criteria: { ...emptyCriteria, ...(v?.criteria ?? {}) },
watchCriteria: { ...emptyCriteria, ...(v?.watchCriteria ?? {}) },
watch: Array.isArray(v?.watch) ? v.watch : [],
};
// DISARMED ON SIGHT, and written back disabled.
//
// The runtime guard in App.tsx stops this build from calling anyone, but it
// leaves "enabled": true sitting in storage, where any build without the
// guard — an older one an operator reinstalls, a machine that upgrades
// later — reads it and keys the transmitter for a feature with no switch
// left to turn off. A withdrawn feature that keys a radio has to be
// disarmed where it is REMEMBERED, not only where it runs.
if (out.enabled) {
out.enabled = false;
try { localStorage.setItem(AC_KEY, JSON.stringify(out)); } catch { /* private mode: the guard still holds */ }
}
return out;
} catch { return { ...defaultAutoCall }; }
}
export const autoCallKey = AC_KEY;
// A decode's resolved novelty, the same shape the panel already renders from.
export type DecodeStatus = {
status?: string;
worked_call?: boolean;
new_grid?: boolean;
grid_state?: string;
new_county?: boolean;
new_pota?: boolean;
new_pfx?: boolean;
};
// matchesWildcard is the same rule the alert filters use: * is any run, ? is one.
export function matchesWildcard(pattern: string, call: string): boolean {
const p = pattern.trim().toUpperCase();
const c = call.trim().toUpperCase();
if (!p) return false;
const re = new RegExp('^' + p.split('').map((ch) => (
ch === '*' ? '.*' : ch === '?' ? '.' : ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
)).join('') + '$');
return re.test(c);
}
// anyCriterion is false for an all-off set, which is what makes "watch this
// callsign, no conditions" expressible.
function anyCriterion(c: AutoCallCriteria): boolean {
return Object.values(c).some(Boolean);
}
// meets reports whether a decode satisfies at least one ticked criterion.
function meets(c: AutoCallCriteria, e: DecodeStatus): boolean {
if (c.dxcc && e.status === 'new') return true;
// Each status is exclusive, so a station that is new on both counts matches
// ONLY this criterion — ticking "new band" alone would not catch it, which is
// the wrong way round: it is the better catch of the two.
if (c.bandmode && e.status === 'new-band-mode') return true;
if (c.band && e.status === 'new-band') return true;
if (c.mode && e.status === 'new-mode') return true;
if (c.slot && e.status === 'new-slot') return true;
// A square that is merely UNCONFIRMED is not called: the QSO is already made,
// and calling again would work a duplicate to chase a QSL.
if (c.grid && e.new_grid && e.grid_state !== 'unconf') return true;
if (c.county && e.new_county) return true;
if (c.pota && e.new_pota) return true;
if (c.pfx && e.new_pfx) return true;
return false;
}
export type AutoCallDecode = {
call: string;
cq?: boolean;
msg?: string;
instance?: string;
};
// shouldAutoCall decides whether to answer one decode. The reason is returned
// for the log: an automatic transmission with no record of WHY is the thing an
// operator cannot argue with after the fact.
export function shouldAutoCall(
s: AutoCallSettings,
d: AutoCallDecode,
e: DecodeStatus | undefined,
opts: {
// busy is "some receiver is mid-QSO", NOT "this one is transmitting".
//
// The distinction is the whole bug it fixes: with two instances the caller
// used to test a single global transmit flag, which belonged to whichever
// receiver reported last. So while slice A worked a station, slice B looked
// idle and auto-call started another QSO on it — and the moment either
// finished it chained straight into the next. One station at a time means
// one across ALL receivers, not one per receiver.
busy: boolean;
calledAt: Map<string, number>;
now: number;
myCall?: string;
},
): { call: boolean; reason: string } {
const no = (why: string) => ({ call: false, reason: why });
if (!s.enabled) return no('off');
if (!e) return no('status not resolved yet');
const call = (d.call ?? '').trim().toUpperCase();
if (!call) return no('no callsign');
// Never answer ourselves, however the decode reached us.
if (opts.myCall && call === opts.myCall.trim().toUpperCase()) return no('own callsign');
// NOT limited to a CQ, deliberately.
//
// It used to be, on the reasoning that answering a station mid-QSO is calling
// over somebody. That reasoning ignored the case the feature exists for: a
// DXpedition running a pileup never sends CQ at all — it works caller after
// caller — so the rule sat out the one contact auto-call was turned on for. A
// new entity on 15 m FT8, decode after decode, and not a single transmission.
//
// WHEN to transmit is not ours to decide either: the Reply goes to the
// decoder, and MSHV starts at once while JTDX waits for a CQ. Two correct
// behaviours, both belonging to the program that owns the timing. Here the
// question is only whether the station is one the operator wants — the
// criteria below answer that, and the cooldown and the busy check keep it from
// calling twice.
// Not while ANY receiver is mid-QSO — transmitting, or holding a DX call it
// has not finished with. Starting a second exchange before the first is done
// is what turned this into a machine that called without stopping.
if (opts.busy) return no('a QSO is already in progress');
const last = opts.calledAt.get(call);
if (last !== undefined && opts.now - last < s.cooldownSec * 1000) return no('called recently');
// The watch list first: an explicitly named station outranks the general
// criteria, and may carry conditions of its own.
const watched = s.watch.some((p) => matchesWildcard(p, call));
if (watched) {
if (!anyCriterion(s.watchCriteria)) {
// No conditions attached: call it unless it is already worked.
return e.worked_call ? no('watched, but already worked') : { call: true, reason: 'watch list' };
}
return meets(s.watchCriteria, e)
? { call: true, reason: 'watch list + criteria' }
: no('watched, but no criterion met');
}
if (!anyCriterion(s.criteria)) return no('no criteria ticked');
return meets(s.criteria, e)
? { call: true, reason: 'criteria' }
: no('no criterion met');
}
+6 -2
View File
@@ -10,7 +10,7 @@
// Stored through writeUiPref like every other portable preference, so the
// buttons travel with data/ rather than living in one browser profile.
import { writeUiPref } from '@/lib/uiPref';
import { writeUiPrefDebounced } from '@/lib/uiPref';
export type ClusterMacro = {
label: string; // what the button says
@@ -43,8 +43,12 @@ export function loadClusterMacros(): ClusterMacro[] {
return out;
}
// Debounced, because this is called on every keystroke in twenty-four text
// boxes. The local cache is written at once — it is what everything reads back
// — and only the database write waits for the typing to stop. A round trip into
// Go per character is what "the letters appear after I have moved on" was.
export function saveClusterMacros(macros: ClusterMacro[]): void {
writeUiPref(clusterMacrosKey, JSON.stringify(macros));
writeUiPrefDebounced(clusterMacrosKey, JSON.stringify(macros));
}
// visibleClusterMacros drops the slots that would send nothing. The COMMAND is
+50
View File
@@ -0,0 +1,50 @@
// Cluster nodes worth starting from.
//
// Setting up a telnet cluster is the step operators get stuck on: the host and
// the port are two pieces of information nobody has to hand, a typo produces a
// silent failure to connect, and the ports are not guessable — a Reverse Beacon
// feed on 7000 carries CW and RTTY while 7001 carries FT8 and FT4, which no
// amount of trying will tell you.
//
// So the editor offers a list. It fills the fields and then gets out of the
// way: everything stays editable, because a node moves or an operator wants a
// different name for it, and a preset that could not be corrected would be
// worse than none.
//
// The list is meant to grow. One entry per node, and nothing here is special —
// a node added by hand behaves exactly the same.
export type ClusterPreset = {
name: string;
host: string;
port: number;
// What it carries, in a few words: the dropdown is chosen from, not read, and
// "SOTA" means nothing to somebody who has never chased a summit.
about: string;
// Sent one per line after login. Empty for the nodes that need nothing.
init?: string;
};
export const CLUSTER_PRESETS: ClusterPreset[] = [
{ name: 'F4BPO', host: 'cluster.f4bpo.com', port: 7300,
about: 'General DX cluster (OpsLog authors node)' },
{ name: 'DXFun', host: 'dxfun.com', port: 8000,
about: 'General DX cluster, worldwide' },
{ name: 'F5LEN', host: 'dxcluster.f5len.org', port: 7373,
about: 'General DX cluster' },
{ name: 'F5MZN', host: 'f5mzn.org', port: 9000,
about: 'General DX cluster' },
{ name: 'KM3T', host: 'dxcc.km3t.net', port: 7373,
about: 'General DX cluster' },
{ name: 'SOTA', host: 'cluster.sota.org.uk', port: 7300,
about: 'Summits On The Air spots' },
{ name: 'POTA', host: 'pota-cluster.iz2lsc.eu', port: 7373,
about: 'Parks On The Air spots' },
// The two Reverse Beacon feeds are one network on two ports, and which port
// decides which modes arrive. Getting that wrong looks exactly like a dead
// node, so they are listed separately and named for what they carry.
{ name: 'RBN CW', host: 'telnet.reversebeacon.net', port: 7000,
about: 'Reverse Beacon Network — CW and RTTY skimmers' },
{ name: 'RBN FTx', host: 'telnet.reversebeacon.net', port: 7001,
about: 'Reverse Beacon Network — FT8 and FT4 skimmers' },
];
+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;
}
+224 -36
View File
File diff suppressed because one or more lines are too long
+44
View File
@@ -172,6 +172,50 @@ export function greatCirclePoints(
return out;
}
// splitAtAntimeridian cuts a continuous (unwrapped) path into the pieces that
// fit on a map showing ONE world, each piece with longitudes back inside ±180.
//
// greatCirclePoints deliberately lets longitude run past ±180 so the polyline
// stays smooth. That is right for a map with repeating world copies, and wrong
// for one without: an arc from Australia to South America came out at 190°,
// 210°, 250° — drawn into the empty space off the right-hand edge, ending
// nowhere, while its own end marker sat correctly on the far left. From VK,
// where most paths cross the antimeridian, that was most of the map's arcs.
//
// Each crossing ends one piece at exactly ±180 and starts the next at the
// opposite edge, at the SAME latitude, so the line leaves one side of the map
// and re-enters the other at the height it left. Leaflet takes the result as a
// multi-polyline, so one path is still one layer.
export function splitAtAntimeridian(pts: [number, number][]): [number, number][][] {
if (pts.length === 0) return [];
// Which copy of the world a longitude belongs to: 0 is the map's own.
const world = (lon: number) => Math.floor((lon + 180) / 360);
const norm = (lon: number) => lon - 360 * world(lon);
const out: [number, number][][] = [];
let cur: [number, number][] = [];
for (let i = 0; i < pts.length; i++) {
const [lat, lon] = pts[i];
if (i > 0) {
const [pLat, pLon] = pts[i - 1];
const wPrev = world(pLon), wCur = world(lon);
if (wPrev !== wCur) {
const east = wCur > wPrev;
// The meridian actually crossed, in unwrapped degrees.
const edge = 180 + 360 * Math.min(wPrev, wCur);
const f = (edge - pLon) / (lon - pLon);
const edgeLat = pLat + f * (lat - pLat);
cur.push([edgeLat, east ? 180 : -180]);
out.push(cur);
cur = [[edgeLat, east ? -180 : 180]];
}
}
cur.push([lat, norm(lon)]);
}
if (cur.length > 1) out.push(cur);
else if (cur.length === 1 && out.length === 0) out.push(cur);
return out;
}
function toRad(d: number): number { return (d * Math.PI) / 180; }
function toDeg(r: number): number { return (r * 180) / Math.PI; }
+38
View File
@@ -0,0 +1,38 @@
// Which imagery each map draws on — one choice per map.
//
// The world map and the grid-square map used to share a single key, so picking
// satellite imagery to look at grids also repainted the main map, and there was
// no way to have terrain on one and plain streets on the other. They are
// different maps answering different questions, and the imagery that suits one
// is not the imagery that suits the next.
//
// Portable (see lib/uiPref) like the remembered views in lib/mapView: a copied
// data folder brings the choices with it.
import { writeUiPref } from '@/lib/uiPref';
import type { BasemapKey } from '@/components/MainMap';
// The keys in use. Named here rather than typed at each call site so a rename
// cannot silently orphan somebody's choice.
export const MAP_BASE_WORLD = 'opslog.mapBasemap';
export const MAP_BASE_GRIDS = 'opslog.gridMapBase';
export const MAP_BASE_FT = 'opslog.ftmapBase';
export const MAP_BASE_SAT = 'opslog.satMapBase';
const VALID = ['light', 'voyager', 'street', 'satellite'];
// loadMapBase reads one map's choice.
//
// inheritFrom exists for the split: the grid map's choice lived under the world
// map's key until they were separated, so an operator who had chosen imagery
// there keeps it instead of being silently reset to the default.
export function loadMapBase(key: string, fallback: BasemapKey, inheritFrom?: string): BasemapKey {
const read = (k: string) => {
const v = localStorage.getItem(k);
return v && VALID.includes(v) ? (v as BasemapKey) : null;
};
return read(key) ?? (inheritFrom ? read(inheritFrom) : null) ?? fallback;
}
export function saveMapBase(key: string, v: BasemapKey): void {
writeUiPref(key, v);
}
+31
View File
@@ -0,0 +1,31 @@
// Remembered map views — where each map was left, per map.
//
// Panning and zooming a map is an operator saying "this is the part of the world
// I work". Throwing that away on every tab switch made the maps something to
// set up again each time rather than something to glance at, and the world map
// was the only one that remembered anything.
//
// One key per map, and portable (see lib/uiPref) like the world map's own: a
// copied data folder brings the views with it.
import { writeUiPref } from '@/lib/uiPref';
export type MapView = { lat: number; lon: number; zoom: number };
// The keys in use. Named here rather than typed at each call site so a rename
// cannot silently orphan somebody's saved view.
export const MAP_VIEW_WORLD = 'opslog.mapView';
export const MAP_VIEW_FT = 'opslog.ftMapView';
export const MAP_VIEW_GRIDS = 'opslog.gridMapView';
export function loadMapView(key: string): MapView | null {
try {
const v = JSON.parse(localStorage.getItem(key) || 'null');
return v && typeof v.zoom === 'number' && typeof v.lat === 'number' && typeof v.lon === 'number' ? v : null;
} catch {
return null; // corrupt or unreadable → open where the map would by default
}
}
export function saveMapView(key: string, lat: number, lon: number, zoom: number): void {
writeUiPref(key, JSON.stringify({ lat, lon, zoom }));
}
+39
View File
@@ -0,0 +1,39 @@
// Which rotor dial to draw.
//
// Two compasses ship: the current one (night map, square scale, mouse pointer,
// target marker) and the original small light-map dial. Neither is more correct
// than the other — one operator reads the big map at a glance, another wants the
// compact dial that was there before — so it is a preference, not a migration.
//
// A UI preference rather than a settings-database key: it decides what a widget
// looks like, nothing is transmitted from it, and it belongs to the screen it is
// read on. It is portable all the same (see lib/uiPref), so a copied folder
// keeps the dial its owner chose.
import { writeUiPref } from '@/lib/uiPref';
export const KEY_ROTOR_STYLE = 'opslog.rotorStyle';
export type RotorStyle = 'modern' | 'classic';
export function rotorStyle(): RotorStyle {
try {
return localStorage.getItem(KEY_ROTOR_STYLE) === 'classic' ? 'classic' : 'modern';
} catch {
return 'modern';
}
}
// Same shape as the distance unit: the compasses are rendered from two call
// sites, and a preference changed in Settings has to reach both without either
// of them polling for it.
const listeners = new Set<() => void>();
export function setRotorStyle(style: RotorStyle): void {
writeUiPref(KEY_ROTOR_STYLE, style);
listeners.forEach((l) => l());
}
export function subscribeRotorStyle(fn: () => void): () => void {
listeners.add(fn);
return () => { listeners.delete(fn); };
}
+82
View File
@@ -36,3 +36,85 @@ export function rstOptions(mode: string, lists: RSTLists): string[] {
if (l && l.length) return l;
return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20'];
}
// stepRST moves a report one step up or down, the way an operator would say it.
//
// The dropdown beside these fields is a list of the values worth having to hand,
// not of every legal report: nobody keeps 41 dB figures in it. So the wheel
// works on the VALUE, not on the list — a digital report moves by a decibel and
// an RST by one S-unit, which is what the hand on the wheel is asking for.
//
// dir is +1 for wheel up (a better report) and -1 for down.
export function stepRST(value: string, dir: number, mode: string): string {
const v = (value || '').trim();
if (v === '') return v;
if (rstCategory(mode) === 'digital') {
// A dB report: "-12", "+05", "0". Kept in its own shape — signed and two
// digits — because that is how every decoder writes it and how the operator
// reads it back off the screen.
const n = parseInt(v, 10);
if (!Number.isFinite(n)) return v;
// The range WSJT-X itself reports in, with room either side. Beyond it the
// number stops meaning anything.
const next = Math.max(-30, Math.min(35, n + dir));
return (next < 0 ? '-' : '+') + String(Math.abs(next)).padStart(2, '0');
}
// RST/RS, and the S9+ ladder above it. What an operator counts through is
//
// … 57 58 59 59+5 59+10 59+15 59+20 …
//
// — one S-unit up to nine, then five decibels at a time, because that is how
// a strong signal is reported and how the S-meter is read (see sMeterRST).
// Stepping the S digit and leaving the "+20" where it was would have gone
// from 59+20 to 58+20, which nobody has ever said on the air.
//
// R and T do not move: they are judgements about readability and tone, and a
// wheel has no business changing them.
const m = /^(\d)(\d)(\d?)(?:\+(\d+))?$/.exec(v);
if (!m) return v;
const head = m[1];
const tone = m[3];
let sUnit = parseInt(m[2], 10);
let over = m[4] === undefined ? 0 : parseInt(m[4], 10);
if (over > 0) {
// Above S9: five at a time, and back down through +5 to a plain 59.
over = Math.max(0, Math.min(60, over + dir * 5));
} else if (sUnit >= 9 && dir > 0) {
over = 5; // 59 → 59+5
} else {
sUnit = Math.max(1, Math.min(9, sUnit + dir));
}
return head + String(sUnit) + tone + (over > 0 ? '+' + over : '');
}
// rstFitsMode says whether a report belongs to the family a mode reports in.
//
// The three families are written differently and are not interchangeable: a
// signed decibel figure ("+00"), a three-figure RST ("599"), a two-figure RS
// ("59", with an optional "+20" above S9). "+00" on SSB is not a weak report,
// it is not a report at all.
export function rstFitsMode(value: string, mode: string): boolean {
const v = (value || '').trim();
if (v === '') return false;
switch (rstCategory(mode)) {
case 'digital': return /^[+-]\d{1,2}$/.test(v);
case 'cw': return /^\d{3}(\+\d+)?$/.test(v);
default: return /^\d{2}(\+\d+)?$/.test(v);
}
}
// convertRST carries a report across the CW/phone divide, keeping the operator's
// own judgement of the signal: 57 becomes 579, 599 becomes 59.
//
// Only between those two — a decibel figure says nothing about readability and
// an RST says nothing in decibels, so there the preset is the honest answer.
// Returns '' when it cannot be done.
export function convertRST(value: string, toMode: string): string {
const v = (value || '').trim();
const to = rstCategory(toMode);
if (to === 'digital') return '';
const m = /^(\d)(\d)(\d?)((?:\+\d+)?)$/.exec(v);
if (!m) return '';
if (to === 'cw') return m[1] + m[2] + '9' + m[4]; // RS → RST, tone 9
return m[1] + m[2] + m[4]; // RST → RS, tone dropped
}
+9
View File
@@ -15,6 +15,15 @@ export function cleanSpotter(s: string): string {
// alone instead of guessing wrong.
export function inferSpotMode(comment: string, freqHz: number): string {
const c = (comment || '').toUpperCase();
// SuperFox and Fox/Hound are FT8 — they are WSJT-X's DXpedition transmit modes,
// not modes of their own, and they turn up in a comment written every way an
// operator can shorten them: "super fox", SFOX, S/F, F/H.
//
// A spot commented "super fox" fell through to the band plan and came out
// DATA, and that verdict is not cosmetic: the
// band+mode status is computed from this answer, so a ZD8 on 21.071 read as a
// new DATA slot rather than the new FT8 one it is.
if (/\bSUPER\s*FOX\b|\bSFOX\b|\bS\/F\b|\bFOX\s*\/?\s*HOUND\b|\bF\/H\b/.test(c)) return 'FT8';
if (/\bFT8\b/.test(c)) return 'FT8';
if (/\bFT4\b/.test(c)) return 'FT4';
if (/\bJS8\b/.test(c)) return 'JS8';
+2 -2
View File
@@ -7,14 +7,14 @@ import { GetUIPref } from '../../wailsjs/go/main/App';
// preference. The choice is persisted (localStorage + portable UI pref, so it
// travels with the data/ folder like the language).
export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'light-nordic' | 'sahara'
| 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'dark-indigo' | 'dark-teal' | 'dark-plum' | 'high-contrast';
| 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'dark-indigo' | 'dark-teal' | 'dark-plum' | 'dxhunter' | 'dxhunter-orange' | 'high-contrast';
// Selectable, concrete themes (excludes 'auto') in display order: lights first,
// then darks, with high-contrast last — it is an accessibility choice, not a
// taste one, and listing it among the moods buries it.
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
'light-warm', 'light-cool', 'light-sage', 'light-nordic', 'sahara',
'dim-slate', 'dark-warm', 'dark-graphite', 'dark-indigo', 'dark-teal', 'dark-plum',
'dim-slate', 'dark-warm', 'dark-graphite', 'dark-indigo', 'dark-teal', 'dark-plum', 'dxhunter', 'dxhunter-orange',
'high-contrast',
];
+55 -1
View File
@@ -29,10 +29,14 @@ const PORTABLE_KEYS = [
'opslog.bandMapBands', // bands shown side-by-side in the Band Map tab
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
// The same, for the FT decodes map and the grid-square map: a view an
// operator set up is theirs, and it should follow the folder like the rest.
'opslog.ftMapView', 'opslog.gridMapView', 'opslog.satMapView',
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
'opslog.mapBasemap', // world map basemap (light / street / satellite)
// One imagery choice per map — world, grid squares, FT map, satellites.
'opslog.mapBasemap', 'opslog.gridMapBase', 'opslog.ftmapBase', 'opslog.satMapBase',
'opslog.dateFormat', // how dates are DISPLAYED (iso / fr / us); storage stays ISO
'opslog.mapGreyline', // world map: grey line (day/night terminator) shown
'opslog.awardRefSort', 'opslog.awardRefSortDir', // award reference table: sort column and direction
@@ -44,6 +48,10 @@ const PORTABLE_KEYS = [
'opslog.iaruRegion', // IARU region (1/2/3) — band edges and segments on the band maps
'opslog.chasePota', // show POTA references and the NEW POTA marker on spots
'opslog.chaseSota', // show SOTA references on spots
// The other chase switches. In the DB as well as locally because AUTO-CALL
// reads them: what the operator does not hunt is not something to transmit
// for, and the backend cannot see localStorage.
'opslog.chasePfx', 'opslog.chaseCounty', 'opslog.chaseState', 'opslog.chaseGrids',
'opslog.activeTab', // last selected tab
'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares
'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}
@@ -51,6 +59,8 @@ const PORTABLE_KEYS = [
'opslog.clusterMuteWorked', // cluster/band map: no colour or badge on worked spots
'opslog.clusterSlotHighlight', // cluster/band map: colour calls not worked on this band+mode
'opslog.bandMapWidth', // docked band map: column width (px)
'opslog.satSideWidth', 'opslog.satSideShown', // Satellites tab: readout column width, and whether it is shown
'opslog.satSkyShown', // Satellites tab: the polar sky plot
'opslog.bandMapTabWidth', // Band map tab: shared card width (px)
'opslog.bandMapZoom', // band map zoom (px/kHz step) remembered per band, as one {band: index} map
'opslog.decodeColWidths', // FT decodes table: per-column widths (px), as one {col: px} map
@@ -83,6 +93,50 @@ export async function syncPortablePrefs(): Promise<void> {
}));
}
// writeUiPrefDebounced is writeUiPref for a value that changes AS SOMEBODY
// TYPES.
//
// The local cache is written at once, because that is what the interface reads
// back and it costs nothing. The DATABASE write is held until the typing stops:
// writeUiPref crosses into Go and writes a row, and doing that per character in
// a text box is a round trip per keystroke — twenty-four boxes of cluster
// macros was exactly that, and it showed as characters appearing after the
// finger had left the key.
//
// Pending writes are flushed when the page goes away, so a value typed and
// immediately followed by a close is not lost.
const pendingPrefs = new Map<string, { value: string; timer: number }>();
export function writeUiPrefDebounced(key: string, value: string, ms = 400): void {
try { localStorage.setItem(key, value); } catch { /* quota / private mode */ }
const prev = pendingPrefs.get(key);
if (prev) window.clearTimeout(prev.timer);
const timer = window.setTimeout(() => {
pendingPrefs.delete(key);
SetUIPref(key, value).catch((e: any) => {
try { LogUIError('ui pref', 'could not store ' + key + ': ' + String(e?.message ?? e), ''); } catch { /* nothing left to try */ }
});
}, ms);
pendingPrefs.set(key, { value, timer });
}
// flushUiPrefs writes every pending value immediately.
export function flushUiPrefs(): void {
for (const [key, p] of pendingPrefs) {
window.clearTimeout(p.timer);
SetUIPref(key, p.value).catch(() => { /* the local cache still holds it */ });
}
pendingPrefs.clear();
}
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', flushUiPrefs);
// Closing the app does not always fire beforeunload in a WebView; a hidden
// page is the earlier and more reliable signal.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flushUiPrefs();
});
}
// writeUiPref write-throughs a value to the local cache AND the portable DB.
// Use it everywhere these keys are written instead of localStorage.setItem.
export function writeUiPref(key: string, value: string): void {
+133
View File
@@ -0,0 +1,133 @@
// What the watch list is HEARING, and what of it is still needed.
//
// Two places ask the same question — the Watchlist tab and the docked widget —
// and the answer involves a debounced round trip to the logbook per visible
// slot. Written twice it would be two definitions of "needed" drifting apart,
// and two bursts of the same query on every spot; written here it is one.
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ClusterSpot } from '@/components/ClusterGrid';
import { inferSpotMode } from '@/lib/spot';
import { WatchlistWorkedSlots } from '../../wailsjs/go/main/App';
export interface WLEntry {
callsign: string; lastSeenStr: string; addedAt: string; spotCount: number;
isContest: boolean; notify: boolean;
isExpedition: boolean; clubLogQSOs24h: number; clubLogTotalQSOs: number;
clubLogHasOQRS: boolean; clubLogLiveStream: boolean;
}
// A spot is ON AIR while its last sighting is this fresh.
export const ON_AIR_MS = 10 * 60 * 1000;
// Exact unless the entry carries a trailing * — the same rule the backend's
// Match applies to the live stream, mirrored so the list and the alerts can
// never disagree about what an entry covers.
export function matchesEntry(call: string, pattern: string): boolean {
const c = call.toUpperCase();
if (pattern.endsWith('*')) {
const p = pattern.slice(0, -1);
return p !== '' && c.startsWith(p);
}
return c === pattern;
}
// What the cluster's verdict is worth saying, and in what colour.
//
// The order of severity is the one the band map and Chase new use: a new entity
// first, then the band and mode inside it. Shared because the tab and the docked
// widget must not label the same spot differently — an operator reads one of
// them to decide whether to leave what they are doing.
export const NEW_BADGES: Record<string, { key: string; colour: string }> = {
'new': { key: 'wl.newDxcc', colour: 'var(--danger)' },
'new-band-mode': { key: 'clg2.newBandMode', colour: 'var(--danger)' },
'new-band': { key: 'clg2.newBand', colour: 'var(--warning)' },
'new-mode': { key: 'clg2.newMode', colour: 'var(--caution)' },
'new-slot': { key: 'clg2.newSlot', colour: '#5AC8FA' },
};
export function newBadge(status?: string): { key: string; colour: string } | null {
return (status && NEW_BADGES[status]) || null;
}
export interface WatchlistSpots {
// The raw verdicts, exposed for dependency arrays: it changes only when an
// answer arrives, where the closures below are new on every render.
worked: Record<string, boolean>;
// The spots each entry covers, deduplicated by band+mode.
spotsFor: Map<string, ClusterSpot[]>;
// Worked on this exact slot (and, for a contest entry, today).
workedFor: (e: WLEntry, s: ClusterSpot) => boolean;
// Whether the logbook has actually answered for this pair yet. A spot whose
// verdict has not come back is NOT drawn: showing it as needed and
// withdrawing it half a second later made the list twitch on every burst,
// and nobody needs a spot 400 ms early — they need it settled.
settled: (e: WLEntry, s: ClusterSpot) => boolean;
onAir: (e: WLEntry) => boolean;
}
export function useWatchlistSpots(entries: WLEntry[], spots: ClusterSpot[]): WatchlistSpots {
const [worked, setWorked] = useState<Record<string, boolean>>({});
const spotsFor = useMemo(() => {
const map = new Map<string, ClusterSpot[]>();
for (const e of entries) map.set(e.callsign, []);
for (const s of spots) {
for (const e of entries) {
if (matchesEntry(s.dx_call ?? '', e.callsign)) { map.get(e.callsign)!.push(s); break; }
}
}
for (const [k, list] of map) {
const seen = new Set<string>();
map.set(k, list.filter((s) => {
const key = `${(s.band ?? '')}|${inferSpotMode(s.comment ?? '', s.freq_hz)}|${s.dx_call}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
}));
}
return map;
}, [spots, entries]);
// Debounced: a spot burst must cost one round trip, not one per spot.
const queryTimer = useRef<number | undefined>(undefined);
useEffect(() => {
if (queryTimer.current) window.clearTimeout(queryTimer.current);
queryTimer.current = window.setTimeout(async () => {
const queries: { call: string; band: string; mode: string; contest: boolean }[] = [];
const keys: string[] = [];
for (const e of entries) {
for (const s of spotsFor.get(e.callsign) ?? []) {
const mode = inferSpotMode(s.comment ?? '', s.freq_hz) || '';
queries.push({ call: s.dx_call, band: s.band ?? '', mode, contest: e.isContest });
keys.push(`${s.dx_call}|${s.band ?? ''}|${mode}|${e.isContest ? 1 : 0}`);
}
}
if (queries.length === 0) { setWorked({}); return; }
try {
const res: boolean[] = (await WatchlistWorkedSlots(queries as any)) ?? [];
// MERGED, not replaced: replacing made every already-answered key
// momentarily unknown on each refresh, which re-hid settled lines.
setWorked((prev) => {
const next = { ...prev };
keys.forEach((k, i) => { next[k] = !!res[i]; });
return next;
});
} catch { /* the badges just stay conservative */ }
}, 150) as unknown as number;
return () => { if (queryTimer.current) window.clearTimeout(queryTimer.current); };
}, [spotsFor, entries]);
const wkey = (e: WLEntry, s: ClusterSpot) =>
`${s.dx_call}|${s.band ?? ''}|${inferSpotMode(s.comment ?? '', s.freq_hz) || ''}|${e.isContest ? 1 : 0}`;
return {
worked,
spotsFor,
workedFor: (e, s) => worked[wkey(e, s)] ?? false,
settled: (e, s) => wkey(e, s) in worked,
onAir: (e) => (spotsFor.get(e.callsign) ?? []).some((s) => {
const ts = Date.parse(String((s as any).received_at ?? ''));
return ts > 0 && Date.now() - ts < ON_AIR_MS;
}),
};
}
+155 -3
View File
@@ -574,8 +574,8 @@
"entity confirmed" cell would read as a button. */
--mx-call-conf: #22c55e;
--mx-call-work: #2c7a52;
--mx-dx-conf: #22d3ee;
--mx-dx-work: #1b6b7c;
--mx-dx-conf: #a78bfa;
--mx-dx-work: #5b4a9e;
--mx-none: #2c2f4d;
--scrollbar-thumb: #383c63;
@@ -862,6 +862,156 @@
color-scheme: light;
}
/* ---- Theme 13: DXHunter its own slate and blue -----------------------
Ported so an operator running both side by side does not switch between two
colour worlds every time they look up. It is Tailwind's slate scale, which
is what DXHunter is built on: page at slate-900, panels at slate-800, rules
at slate-700, and blue-500 for the accent counted across its sources, not
guessed from one panel: blue is 132 uses to violet's 25, and the violet is
the PSK Reporter panel alone. Active tab, focus border, primary button: all
blue-500. The status colours are DXHunter's too emerald for good, amber
for attention, cyan for information, red for trouble so a green number
means the same thing in both windows. */
[data-theme="dxhunter"] {
--background: #0f172a; /* slate-900 — the page */
--foreground: #e2e8f0; /* slate-200 */
--card: #1e293b; /* slate-800 — panels lift off the page */
--card-foreground: #e2e8f0;
--popover: #1e293b;
--popover-foreground: #e2e8f0;
--primary: #3b82f6; /* blue-500 — active tabs, focus, buttons */
--primary-foreground: #f8fafc;
--secondary: #273449;
--secondary-foreground: #e2e8f0;
--muted: #1c2941; /* toolbars / table headers */
--muted-foreground: #94a3b8; /* slate-400 — DXHunter's muted text */
--accent: #2c3b54; /* hover / selection tint */
--accent-foreground: #cbd5e1;
--destructive: #ef4444;
--destructive-foreground: #fef2f2;
--destructive-muted: #3a1518;
--destructive-muted-foreground: #fca5a5;
--border: #334155; /* slate-700 — every rule in DXHunter */
--input: #334155;
--ring: #60a5fa; /* blue-400 — its focus border */
--success: #34d399; /* emerald-400 — "online", confirmed */
--success-foreground: #04211a;
--success-muted: #0e3029;
--success-muted-foreground: #6ee7b7;
--success-border: #17564a;
--warning: #fbbf24; /* amber-400 */
--warning-foreground: #211803;
--warning-muted: #33280f;
--warning-muted-foreground: #fcd34d;
--warning-border: #4f3e15;
--caution: #facc15;
--caution-foreground: #211e04;
--caution-muted: #322d0e;
--caution-muted-foreground: #fde047;
--caution-border: #4b4315;
--danger: #f87171; /* red-400 — rose is barely used there */
--danger-foreground: #250912;
--danger-muted: #3a1621;
--danger-muted-foreground: #fda4af;
--danger-border: #5a2735;
--info: #22d3ee; /* cyan-400 — "heard near you" */
--info-foreground: #04212a;
--info-muted: #0c2f3b;
--info-muted-foreground: #67e8f9;
--info-border: #155e6e;
/* Blue is the primary, so the entity ramp goes VIOLET rather than reading
as a button and violet is where DXHunter puts its own second accent. */
--mx-call-conf: #22c55e;
--mx-call-work: #2c7a52;
--mx-dx-conf: #a78bfa;
--mx-dx-work: #5b4a9e;
--mx-none: #334155;
--scrollbar-thumb: #334155; /* DXHunter's own scrollbar */
--scrollbar-thumb-hover: #475569;
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(226, 232, 240, 0.05);
color-scheme: dark;
}
/* ---- Theme 14: DXHunter orange the same slate, OpsLog's own accent --
DXHunter's slate, kept exactly page, panels, rules, muted text, and its
status colours with OpsLog's orange in place of its blue. For an operator
who wants the two windows to sit together without OpsLog losing the accent
it is recognised by. The entity ramp goes VIOLET here for the same reason as
in the blue version: it must not read as the accent. */
[data-theme="dxhunter-orange"] {
--background: #0f172a; /* slate-900 — the page */
--foreground: #e2e8f0; /* slate-200 */
--card: #1e293b; /* slate-800 — panels lift off the page */
--card-foreground: #e2e8f0;
--popover: #1e293b;
--popover-foreground: #e2e8f0;
--primary: #f97316; /* orange-500 — OpsLog's own accent */
--primary-foreground: #1c0a02;
--secondary: #273449;
--secondary-foreground: #e2e8f0;
--muted: #1c2941; /* toolbars / table headers */
--muted-foreground: #94a3b8; /* slate-400 — DXHunter's muted text */
--accent: #2c3b54; /* hover / selection tint */
--accent-foreground: #cbd5e1;
--destructive: #ef4444;
--destructive-foreground: #fef2f2;
--destructive-muted: #3a1518;
--destructive-muted-foreground: #fca5a5;
--border: #334155; /* slate-700 — every rule in DXHunter */
--input: #334155;
--ring: #fdba74; /* orange-300 focus ring */
--success: #34d399; /* emerald-400 — "online", confirmed */
--success-foreground: #04211a;
--success-muted: #0e3029;
--success-muted-foreground: #6ee7b7;
--success-border: #17564a;
--warning: #fbbf24; /* amber-400 */
--warning-foreground: #211803;
--warning-muted: #33280f;
--warning-muted-foreground: #fcd34d;
--warning-border: #4f3e15;
--caution: #facc15;
--caution-foreground: #211e04;
--caution-muted: #322d0e;
--caution-muted-foreground: #fde047;
--caution-border: #4b4315;
--danger: #f87171; /* red-400 — rose is barely used there */
--danger-foreground: #250912;
--danger-muted: #3a1621;
--danger-muted-foreground: #fda4af;
--danger-border: #5a2735;
--info: #22d3ee; /* cyan-400 — "heard near you" */
--info-foreground: #04212a;
--info-muted: #0c2f3b;
--info-muted-foreground: #67e8f9;
--info-border: #155e6e;
/* Blue is the primary, so the entity ramp goes VIOLET rather than reading
as a button and violet is where DXHunter puts its own second accent. */
--mx-call-conf: #22c55e;
--mx-call-work: #2c7a52;
--mx-dx-conf: #a78bfa;
--mx-dx-work: #5b4a9e;
--mx-none: #334155;
--scrollbar-thumb: #334155; /* DXHunter's own scrollbar */
--scrollbar-thumb-hover: #475569;
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(226, 232, 240, 0.05);
color-scheme: dark;
}
/* Data-viz palette (Statistics dashboard)
A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes
it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never
@@ -905,7 +1055,9 @@
[data-theme="high-contrast"],
[data-theme="dark-indigo"],
[data-theme="dark-teal"],
[data-theme="dark-plum"] {
[data-theme="dark-plum"],
[data-theme="dxhunter"],
[data-theme="dxhunter-orange"] {
--chart-1: #3987e5;
--chart-2: #199e70;
--chart-3: #c98500;
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.27.8';
export const APP_VERSION = '0.27.17';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+82
View File
@@ -15,9 +15,11 @@ import {cluster} from '../models';
import {dxped} from '../models';
import {extsvc} from '../models';
import {powergenius} from '../models';
import {pskrtgt} from '../models';
import {pskr} from '../models';
import {psu} from '../models';
import {spe} from '../models';
import {sat} from '../models';
import {solar} from '../models';
import {tunergenius} from '../models';
import {webpub} from '../models';
@@ -51,6 +53,8 @@ export function ActiveRadioMyRig():Promise<string>;
export function AddQSO(arg1:qso.QSO):Promise<number>;
export function AddSatelliteElements(arg1:string):Promise<number>;
export function AmpFanMode(arg1:string,arg2:string):Promise<void>;
export function AmpOperate(arg1:string,arg2:boolean):Promise<void>;
@@ -415,6 +419,10 @@ export function GetAudioMonitorPref():Promise<boolean>;
export function GetAudioSettings():Promise<main.AudioSettings>;
export function GetAutoCallSettings():Promise<main.AutoCallSettings>;
export function GetAutoCallStatus():Promise<main.AutoCallStatus>;
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
export function GetAward(arg1:string,arg2:string):Promise<award.Result>;
@@ -449,6 +457,8 @@ export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
export function GetChaseNew():Promise<boolean>;
export function GetChaseNewBands():Promise<Array<string>>;
export function GetChaseNewGrids():Promise<boolean>;
export function GetChaseNewSpots():Promise<Array<main.ChaseNewSpot>>;
@@ -549,8 +559,12 @@ export function GetPGXLStatus():Promise<powergenius.Status>;
export function GetPOTAToken():Promise<string>;
export function GetPSKAnalysis():Promise<pskrtgt.Analysis>;
export function GetPSKReporterStatus():Promise<pskr.Status>;
export function GetPSKTargetSettings():Promise<main.PSKTargetSettings>;
export function GetPSUSettings():Promise<main.PSUSettings>;
export function GetPSUStatus():Promise<psu.Status>;
@@ -577,6 +591,30 @@ export function GetRowColors():Promise<main.RowColorSettings>;
export function GetSPEStatus():Promise<spe.Status>;
export function GetSatSettings():Promise<main.SatSettings>;
export function GetSatelliteBirds():Promise<Array<main.SatBird>>;
export function GetSatelliteGroundTrack(arg1:string,arg2:number):Promise<Array<sat.Position>>;
export function GetSatelliteNames():Promise<Array<string>>;
export function GetSatelliteNextPass(arg1:string):Promise<main.SatPassInfo>;
export function GetSatelliteObserver():Promise<Record<string, any>>;
export function GetSatellitePasses(arg1:Array<string>,arg2:number):Promise<Array<sat.Pass>>;
export function GetSatellitePositions(arg1:Array<string>):Promise<Array<sat.Position>>;
export function GetSatelliteSkyTrack(arg1:string,arg2:number):Promise<Array<main.SatSkyPoint>>;
export function GetSatelliteTLEInfo():Promise<main.SatTLEInfo>;
export function GetSatelliteTracking():Promise<main.SatTrackStatus>;
export function GetSatelliteTuning(arg1:string,arg2:number,arg3:number):Promise<main.SatTuning>;
export function GetScpStatus():Promise<main.ScpStatus>;
export function GetSecretStatus():Promise<main.SecretStatus>;
@@ -617,6 +655,8 @@ export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
export function GetWatchlistContestCalls():Promise<string>;
export function GetWatchlistContestPattern():Promise<string>;
export function GetWebPublishConfig():Promise<webpub.Config>;
@@ -635,18 +675,26 @@ export function GetWsjtFollowMode():Promise<boolean>;
export function GetWsjtHighlight():Promise<boolean>;
export function GetWsjtHighlightColours():Promise<main.WsjtHighlightColours>;
export function GetWsjtHighlightWorked():Promise<boolean>;
export function GetYaesuBandAntennas():Promise<Record<string, number>>;
export function GetYaesuState():Promise<cat.YaesuTXState>;
export function GridSquares(arg1:string):Promise<Array<qso.GridSquare>>;
export function HaltAutoCall():Promise<void>;
export function HaltDecodeTx(arg1:string,arg2:boolean):Promise<void>;
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
export function IcomConsolePTT(arg1:boolean):Promise<void>;
export function IcomRecallBand(arg1:string,arg2:number):Promise<number>;
export function IcomRefresh():Promise<void>;
export function IcomScopeData():Promise<cat.ScopeSweep>;
@@ -953,6 +1001,8 @@ export function RefreshDXpeditions():Promise<void>;
export function RefreshKenwood():Promise<void>;
export function RefreshSatelliteTLE():Promise<main.SatTLEInfo>;
export function RefreshSolar():Promise<void>;
export function RefreshYaesuPanel():Promise<void>;
@@ -977,6 +1027,8 @@ export function ReportLiveActivity(arg1:number,arg2:string,arg3:string):Promise<
export function RescanAwards():Promise<void>;
export function ResetAutoCall():Promise<void>;
export function ResetAwardDefs():Promise<Array<award.Def>>;
export function ResetDatabaseToDefault():Promise<void>;
@@ -1023,6 +1075,8 @@ export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
export function SaveAutoCallSettings(arg1:main.AutoCallSettings):Promise<void>;
export function SaveAutostartPrograms(arg1:Array<main.AutostartProgram>):Promise<void>;
export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>;
@@ -1073,6 +1127,8 @@ export function SavePGXLSettings(arg1:main.PGXLSettings):Promise<void>;
export function SavePOTAToken(arg1:string):Promise<void>;
export function SavePSKTargetSettings(arg1:main.PSKTargetSettings):Promise<void>;
export function SavePSUSettings(arg1:main.PSUSettings):Promise<void>;
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
@@ -1089,6 +1145,8 @@ export function SaveRotorPresets(arg1:Array<main.RotorPreset>):Promise<void>;
export function SaveRowColors(arg1:main.RowColorSettings):Promise<void>;
export function SaveSatSettings(arg1:main.SatSettings):Promise<void>;
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
export function SaveSpotColors(arg1:main.SpotColors):Promise<void>;
@@ -1133,6 +1191,12 @@ export function SetActiveRotor(arg1:number):Promise<void>;
export function SetAlertEmailTo(arg1:string):Promise<void>;
export function SetAutoCall(arg1:boolean):Promise<void>;
export function SetAutoCallOnly(arg1:string):Promise<void>;
export function SetAutoCallVisible(arg1:Array<string>,arg2:boolean):Promise<void>;
export function SetCATFrequency(arg1:number):Promise<void>;
export function SetCATMode(arg1:string):Promise<void>;
@@ -1143,6 +1207,8 @@ export function SetCWDecoderPitch(arg1:number):Promise<void>;
export function SetChaseNew(arg1:boolean):Promise<void>;
export function SetChaseNewBands(arg1:Array<string>):Promise<void>;
export function SetChaseNewGrids(arg1:boolean):Promise<void>;
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
@@ -1205,6 +1271,8 @@ export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise<voi
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
export function SetPSKTarget(arg1:string,arg2:string,arg3:number):Promise<void>;
export function SetPSUOutput(arg1:boolean):Promise<void>;
export function SetPassphrase(arg1:string):Promise<void>;
@@ -1261,6 +1329,8 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
export function SetUltrabeamDirection(arg1:number):Promise<void>;
export function SetWatchlistContestCalls(arg1:string):Promise<void>;
export function SetWatchlistContestPattern(arg1:string):Promise<void>;
export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
@@ -1271,6 +1341,10 @@ export function SetWsjtFollowMode(arg1:boolean):Promise<void>;
export function SetWsjtHighlight(arg1:boolean):Promise<void>;
export function SetWsjtHighlightColours(arg1:main.WsjtHighlightColours):Promise<void>;
export function SetWsjtHighlightWorked(arg1:boolean):Promise<void>;
export function SetYaesuAFGain(arg1:number):Promise<void>;
export function SetYaesuAGC(arg1:string):Promise<void>;
@@ -1313,10 +1387,14 @@ export function SetYaesuVOX(arg1:boolean):Promise<void>;
export function StartCWDecoder():Promise<void>;
export function StartSatelliteTracking(arg1:string,arg2:number):Promise<void>;
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
export function StopCWDecoder():Promise<void>;
export function StopSatelliteTracking():Promise<void>;
export function SwitchCATRig(arg1:number):Promise<void>;
export function SyncFolderNow():Promise<number>;
@@ -1331,6 +1409,8 @@ export function TCIStopCW():Promise<void>;
export function TailLogFile(arg1:number):Promise<string>;
export function TakeAutoCallTarget(arg1:string,arg2:string,arg3:string):Promise<void>;
export function TestCloudlogUpload():Promise<string>;
export function TestClublogUpload():Promise<string>;
@@ -1355,6 +1435,8 @@ export function TestQRZUpload():Promise<string>;
export function TestRotatorDevice(arg1:main.RotatorDevice,arg2:number):Promise<void>;
export function TestSatelliteRotator():Promise<string>;
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
+160
View File
@@ -38,6 +38,10 @@ export function AddQSO(arg1) {
return window['go']['main']['App']['AddQSO'](arg1);
}
export function AddSatelliteElements(arg1) {
return window['go']['main']['App']['AddSatelliteElements'](arg1);
}
export function AmpFanMode(arg1, arg2) {
return window['go']['main']['App']['AmpFanMode'](arg1, arg2);
}
@@ -766,6 +770,14 @@ export function GetAudioSettings() {
return window['go']['main']['App']['GetAudioSettings']();
}
export function GetAutoCallSettings() {
return window['go']['main']['App']['GetAutoCallSettings']();
}
export function GetAutoCallStatus() {
return window['go']['main']['App']['GetAutoCallStatus']();
}
export function GetAutostartPrograms() {
return window['go']['main']['App']['GetAutostartPrograms']();
}
@@ -834,6 +846,10 @@ export function GetChaseNew() {
return window['go']['main']['App']['GetChaseNew']();
}
export function GetChaseNewBands() {
return window['go']['main']['App']['GetChaseNewBands']();
}
export function GetChaseNewGrids() {
return window['go']['main']['App']['GetChaseNewGrids']();
}
@@ -1034,10 +1050,18 @@ export function GetPOTAToken() {
return window['go']['main']['App']['GetPOTAToken']();
}
export function GetPSKAnalysis() {
return window['go']['main']['App']['GetPSKAnalysis']();
}
export function GetPSKReporterStatus() {
return window['go']['main']['App']['GetPSKReporterStatus']();
}
export function GetPSKTargetSettings() {
return window['go']['main']['App']['GetPSKTargetSettings']();
}
export function GetPSUSettings() {
return window['go']['main']['App']['GetPSUSettings']();
}
@@ -1090,6 +1114,54 @@ export function GetSPEStatus() {
return window['go']['main']['App']['GetSPEStatus']();
}
export function GetSatSettings() {
return window['go']['main']['App']['GetSatSettings']();
}
export function GetSatelliteBirds() {
return window['go']['main']['App']['GetSatelliteBirds']();
}
export function GetSatelliteGroundTrack(arg1, arg2) {
return window['go']['main']['App']['GetSatelliteGroundTrack'](arg1, arg2);
}
export function GetSatelliteNames() {
return window['go']['main']['App']['GetSatelliteNames']();
}
export function GetSatelliteNextPass(arg1) {
return window['go']['main']['App']['GetSatelliteNextPass'](arg1);
}
export function GetSatelliteObserver() {
return window['go']['main']['App']['GetSatelliteObserver']();
}
export function GetSatellitePasses(arg1, arg2) {
return window['go']['main']['App']['GetSatellitePasses'](arg1, arg2);
}
export function GetSatellitePositions(arg1) {
return window['go']['main']['App']['GetSatellitePositions'](arg1);
}
export function GetSatelliteSkyTrack(arg1, arg2) {
return window['go']['main']['App']['GetSatelliteSkyTrack'](arg1, arg2);
}
export function GetSatelliteTLEInfo() {
return window['go']['main']['App']['GetSatelliteTLEInfo']();
}
export function GetSatelliteTracking() {
return window['go']['main']['App']['GetSatelliteTracking']();
}
export function GetSatelliteTuning(arg1, arg2, arg3) {
return window['go']['main']['App']['GetSatelliteTuning'](arg1, arg2, arg3);
}
export function GetScpStatus() {
return window['go']['main']['App']['GetScpStatus']();
}
@@ -1170,6 +1242,10 @@ export function GetUltrabeamStatus() {
return window['go']['main']['App']['GetUltrabeamStatus']();
}
export function GetWatchlistContestCalls() {
return window['go']['main']['App']['GetWatchlistContestCalls']();
}
export function GetWatchlistContestPattern() {
return window['go']['main']['App']['GetWatchlistContestPattern']();
}
@@ -1206,6 +1282,14 @@ export function GetWsjtHighlight() {
return window['go']['main']['App']['GetWsjtHighlight']();
}
export function GetWsjtHighlightColours() {
return window['go']['main']['App']['GetWsjtHighlightColours']();
}
export function GetWsjtHighlightWorked() {
return window['go']['main']['App']['GetWsjtHighlightWorked']();
}
export function GetYaesuBandAntennas() {
return window['go']['main']['App']['GetYaesuBandAntennas']();
}
@@ -1218,6 +1302,10 @@ export function GridSquares(arg1) {
return window['go']['main']['App']['GridSquares'](arg1);
}
export function HaltAutoCall() {
return window['go']['main']['App']['HaltAutoCall']();
}
export function HaltDecodeTx(arg1, arg2) {
return window['go']['main']['App']['HaltDecodeTx'](arg1, arg2);
}
@@ -1230,6 +1318,10 @@ export function IcomConsolePTT(arg1) {
return window['go']['main']['App']['IcomConsolePTT'](arg1);
}
export function IcomRecallBand(arg1, arg2) {
return window['go']['main']['App']['IcomRecallBand'](arg1, arg2);
}
export function IcomRefresh() {
return window['go']['main']['App']['IcomRefresh']();
}
@@ -1842,6 +1934,10 @@ export function RefreshKenwood() {
return window['go']['main']['App']['RefreshKenwood']();
}
export function RefreshSatelliteTLE() {
return window['go']['main']['App']['RefreshSatelliteTLE']();
}
export function RefreshSolar() {
return window['go']['main']['App']['RefreshSolar']();
}
@@ -1890,6 +1986,10 @@ export function RescanAwards() {
return window['go']['main']['App']['RescanAwards']();
}
export function ResetAutoCall() {
return window['go']['main']['App']['ResetAutoCall']();
}
export function ResetAwardDefs() {
return window['go']['main']['App']['ResetAwardDefs']();
}
@@ -1982,6 +2082,10 @@ export function SaveAudioSettings(arg1) {
return window['go']['main']['App']['SaveAudioSettings'](arg1);
}
export function SaveAutoCallSettings(arg1) {
return window['go']['main']['App']['SaveAutoCallSettings'](arg1);
}
export function SaveAutostartPrograms(arg1) {
return window['go']['main']['App']['SaveAutostartPrograms'](arg1);
}
@@ -2082,6 +2186,10 @@ export function SavePOTAToken(arg1) {
return window['go']['main']['App']['SavePOTAToken'](arg1);
}
export function SavePSKTargetSettings(arg1) {
return window['go']['main']['App']['SavePSKTargetSettings'](arg1);
}
export function SavePSUSettings(arg1) {
return window['go']['main']['App']['SavePSUSettings'](arg1);
}
@@ -2114,6 +2222,10 @@ export function SaveRowColors(arg1) {
return window['go']['main']['App']['SaveRowColors'](arg1);
}
export function SaveSatSettings(arg1) {
return window['go']['main']['App']['SaveSatSettings'](arg1);
}
export function SaveSelfSpotSettings(arg1) {
return window['go']['main']['App']['SaveSelfSpotSettings'](arg1);
}
@@ -2202,6 +2314,18 @@ export function SetAlertEmailTo(arg1) {
return window['go']['main']['App']['SetAlertEmailTo'](arg1);
}
export function SetAutoCall(arg1) {
return window['go']['main']['App']['SetAutoCall'](arg1);
}
export function SetAutoCallOnly(arg1) {
return window['go']['main']['App']['SetAutoCallOnly'](arg1);
}
export function SetAutoCallVisible(arg1, arg2) {
return window['go']['main']['App']['SetAutoCallVisible'](arg1, arg2);
}
export function SetCATFrequency(arg1) {
return window['go']['main']['App']['SetCATFrequency'](arg1);
}
@@ -2222,6 +2346,10 @@ export function SetChaseNew(arg1) {
return window['go']['main']['App']['SetChaseNew'](arg1);
}
export function SetChaseNewBands(arg1) {
return window['go']['main']['App']['SetChaseNewBands'](arg1);
}
export function SetChaseNewGrids(arg1) {
return window['go']['main']['App']['SetChaseNewGrids'](arg1);
}
@@ -2346,6 +2474,10 @@ export function SetOpsLogQSLReceived(arg1, arg2) {
return window['go']['main']['App']['SetOpsLogQSLReceived'](arg1, arg2);
}
export function SetPSKTarget(arg1, arg2, arg3) {
return window['go']['main']['App']['SetPSKTarget'](arg1, arg2, arg3);
}
export function SetPSUOutput(arg1) {
return window['go']['main']['App']['SetPSUOutput'](arg1);
}
@@ -2458,6 +2590,10 @@ export function SetUltrabeamDirection(arg1) {
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
}
export function SetWatchlistContestCalls(arg1) {
return window['go']['main']['App']['SetWatchlistContestCalls'](arg1);
}
export function SetWatchlistContestPattern(arg1) {
return window['go']['main']['App']['SetWatchlistContestPattern'](arg1);
}
@@ -2478,6 +2614,14 @@ export function SetWsjtHighlight(arg1) {
return window['go']['main']['App']['SetWsjtHighlight'](arg1);
}
export function SetWsjtHighlightColours(arg1) {
return window['go']['main']['App']['SetWsjtHighlightColours'](arg1);
}
export function SetWsjtHighlightWorked(arg1) {
return window['go']['main']['App']['SetWsjtHighlightWorked'](arg1);
}
export function SetYaesuAFGain(arg1) {
return window['go']['main']['App']['SetYaesuAFGain'](arg1);
}
@@ -2562,6 +2706,10 @@ export function StartCWDecoder() {
return window['go']['main']['App']['StartCWDecoder']();
}
export function StartSatelliteTracking(arg1, arg2) {
return window['go']['main']['App']['StartSatelliteTracking'](arg1, arg2);
}
export function StationSetRelay(arg1, arg2, arg3) {
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
}
@@ -2570,6 +2718,10 @@ export function StopCWDecoder() {
return window['go']['main']['App']['StopCWDecoder']();
}
export function StopSatelliteTracking() {
return window['go']['main']['App']['StopSatelliteTracking']();
}
export function SwitchCATRig(arg1) {
return window['go']['main']['App']['SwitchCATRig'](arg1);
}
@@ -2598,6 +2750,10 @@ export function TailLogFile(arg1) {
return window['go']['main']['App']['TailLogFile'](arg1);
}
export function TakeAutoCallTarget(arg1, arg2, arg3) {
return window['go']['main']['App']['TakeAutoCallTarget'](arg1, arg2, arg3);
}
export function TestCloudlogUpload() {
return window['go']['main']['App']['TestCloudlogUpload']();
}
@@ -2646,6 +2802,10 @@ export function TestRotatorDevice(arg1, arg2) {
return window['go']['main']['App']['TestRotatorDevice'](arg1, arg2);
}
export function TestSatelliteRotator() {
return window['go']['main']['App']['TestSatelliteRotator']();
}
export function TestStationDevice(arg1) {
return window['go']['main']['App']['TestStationDevice'](arg1);
}
+686
View File
@@ -1924,6 +1924,7 @@ export namespace main {
preroll_seconds: number;
ptt_method: string;
ptt_port: string;
ptt_data: boolean;
format: string;
from_gain: number;
mic_gain: number;
@@ -1945,6 +1946,7 @@ export namespace main {
this.preroll_seconds = source["preroll_seconds"];
this.ptt_method = source["ptt_method"];
this.ptt_port = source["ptt_port"];
this.ptt_data = source["ptt_data"];
this.format = source["format"];
this.from_gain = source["from_gain"];
this.mic_gain = source["mic_gain"];
@@ -1952,6 +1954,66 @@ export namespace main {
this.qso_play_gain = source["qso_play_gain"];
}
}
export class AutoCallSettings {
enabled: boolean;
only: string;
attempts: number;
watched_attempts: number;
misses: number;
max_rounds: number;
rest_periods: number;
on_screen_only: boolean;
trace: boolean;
static createFrom(source: any = {}) {
return new AutoCallSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.only = source["only"];
this.attempts = source["attempts"];
this.watched_attempts = source["watched_attempts"];
this.misses = source["misses"];
this.max_rounds = source["max_rounds"];
this.rest_periods = source["rest_periods"];
this.on_screen_only = source["on_screen_only"];
this.trace = source["trace"];
}
}
export class AutoCallStatus {
enabled: boolean;
only: string;
target: string;
waiting: string;
calls: number;
max: number;
misses: number;
max_miss: number;
stopped: boolean;
greylisted: number;
reason: string;
static createFrom(source: any = {}) {
return new AutoCallStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.only = source["only"];
this.target = source["target"];
this.waiting = source["waiting"];
this.calls = source["calls"];
this.max = source["max"];
this.misses = source["misses"];
this.max_miss = source["max_miss"];
this.stopped = source["stopped"];
this.greylisted = source["greylisted"];
this.reason = source["reason"];
}
}
export class AutostartLaunchResult {
id: string;
name: string;
@@ -2317,6 +2379,7 @@ export namespace main {
kenwood_baud: number;
kenwood_data_mode: string;
yaesu_low_lines: boolean;
yaesu_rtty_usb: boolean;
kenwood_low_lines: boolean;
icom_port: string;
icom_baud: number;
@@ -2370,6 +2433,7 @@ export namespace main {
this.kenwood_baud = source["kenwood_baud"];
this.kenwood_data_mode = source["kenwood_data_mode"];
this.yaesu_low_lines = source["yaesu_low_lines"];
this.yaesu_rtty_usb = source["yaesu_rtty_usb"];
this.kenwood_low_lines = source["kenwood_low_lines"];
this.icom_port = source["icom_port"];
this.icom_baud = source["icom_baud"];
@@ -3315,6 +3379,20 @@ export namespace main {
}
}
export class PSKTargetSettings {
enabled: boolean;
scope: string;
static createFrom(source: any = {}) {
return new PSKTargetSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.scope = source["scope"];
}
}
export class PSUSettings {
enabled: boolean;
com_port: string;
@@ -3925,6 +4003,363 @@ export namespace main {
return a;
}
}
export class SatTransponder {
label: string;
mode: string;
down_lo: number;
down_hi: number;
up_lo: number;
up_hi: number;
inverting: boolean;
ctcss: number;
linear: boolean;
static createFrom(source: any = {}) {
return new SatTransponder(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.label = source["label"];
this.mode = source["mode"];
this.down_lo = source["down_lo"];
this.down_hi = source["down_hi"];
this.up_lo = source["up_lo"];
this.up_hi = source["up_hi"];
this.inverting = source["inverting"];
this.ctcss = source["ctcss"];
this.linear = source["linear"];
}
}
export class SatBird {
name: string;
norad: number;
geostationary: boolean;
favorite: boolean;
has_elements: boolean;
element_name: string;
epoch_age_h: number;
transponders: SatTransponder[];
static createFrom(source: any = {}) {
return new SatBird(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.norad = source["norad"];
this.geostationary = source["geostationary"];
this.favorite = source["favorite"];
this.has_elements = source["has_elements"];
this.element_name = source["element_name"];
this.epoch_age_h = source["epoch_age_h"];
this.transponders = this.convertValues(source["transponders"], SatTransponder);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class SatPassInfo {
name: string;
has_pass: boolean;
in_pass: boolean;
// Go type: time
aos: any;
// Go type: time
los: any;
aos_az: number;
los_az: number;
max_el: number;
max_el_az: number;
// Go type: time
max_el_at: any;
duration_s: number;
static createFrom(source: any = {}) {
return new SatPassInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.has_pass = source["has_pass"];
this.in_pass = source["in_pass"];
this.aos = this.convertValues(source["aos"], null);
this.los = this.convertValues(source["los"], null);
this.aos_az = source["aos_az"];
this.los_az = source["los_az"];
this.max_el = source["max_el"];
this.max_el_az = source["max_el_az"];
this.max_el_at = this.convertValues(source["max_el_at"], null);
this.duration_s = source["duration_s"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class SatSettings {
favorites: string[];
min_el: number;
window_h: number;
auto_tle: boolean;
grid: string;
alt_m: number;
rot_on: boolean;
rot_type: string;
rot_pst_port: number;
rot_transport: string;
rot_host: string;
rot_port: number;
rot_com: string;
rot_baud: number;
rot_max_az: number;
rot_min_el: number;
rot_step: number;
rot_park: boolean;
static createFrom(source: any = {}) {
return new SatSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.favorites = source["favorites"];
this.min_el = source["min_el"];
this.window_h = source["window_h"];
this.auto_tle = source["auto_tle"];
this.grid = source["grid"];
this.alt_m = source["alt_m"];
this.rot_on = source["rot_on"];
this.rot_type = source["rot_type"];
this.rot_pst_port = source["rot_pst_port"];
this.rot_transport = source["rot_transport"];
this.rot_host = source["rot_host"];
this.rot_port = source["rot_port"];
this.rot_com = source["rot_com"];
this.rot_baud = source["rot_baud"];
this.rot_max_az = source["rot_max_az"];
this.rot_min_el = source["rot_min_el"];
this.rot_step = source["rot_step"];
this.rot_park = source["rot_park"];
}
}
export class SatSkyPoint {
// Go type: time
at: any;
az: number;
el: number;
static createFrom(source: any = {}) {
return new SatSkyPoint(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.at = this.convertValues(source["at"], null);
this.az = source["az"];
this.el = source["el"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class SatTLEInfo {
count: number;
// Go type: time
fetched_at: any;
age_h: number;
stale: boolean;
custom: number;
static createFrom(source: any = {}) {
return new SatTLEInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.count = source["count"];
this.fetched_at = this.convertValues(source["fetched_at"], null);
this.age_h = source["age_h"];
this.stale = source["stale"];
this.custom = source["custom"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class SatTrackStatus {
on: boolean;
name: string;
transponder: string;
mode: string;
nominal_down: number;
nominal_up: number;
down_hz: number;
up_hz: number;
az: number;
el: number;
visible: boolean;
radio: string;
error: string;
rot_on: boolean;
rot_az: number;
rot_el: number;
rot_live: boolean;
static createFrom(source: any = {}) {
return new SatTrackStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.on = source["on"];
this.name = source["name"];
this.transponder = source["transponder"];
this.mode = source["mode"];
this.nominal_down = source["nominal_down"];
this.nominal_up = source["nominal_up"];
this.down_hz = source["down_hz"];
this.up_hz = source["up_hz"];
this.az = source["az"];
this.el = source["el"];
this.visible = source["visible"];
this.radio = source["radio"];
this.error = source["error"];
this.rot_on = source["rot_on"];
this.rot_az = source["rot_az"];
this.rot_el = source["rot_el"];
this.rot_live = source["rot_live"];
}
}
export class SatTuning {
name: string;
transponder: string;
mode: string;
nominal_down: number;
nominal_up: number;
down_hz: number;
up_hz: number;
ctcss: number;
inverting: boolean;
az: number;
el: number;
range_km: number;
range_rate: number;
visible: boolean;
// Go type: time
at: any;
lat: number;
lon: number;
alt_km: number;
footprint_km: number;
static createFrom(source: any = {}) {
return new SatTuning(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.transponder = source["transponder"];
this.mode = source["mode"];
this.nominal_down = source["nominal_down"];
this.nominal_up = source["nominal_up"];
this.down_hz = source["down_hz"];
this.up_hz = source["up_hz"];
this.ctcss = source["ctcss"];
this.inverting = source["inverting"];
this.az = source["az"];
this.el = source["el"];
this.range_km = source["range_km"];
this.range_rate = source["range_rate"];
this.visible = source["visible"];
this.at = this.convertValues(source["at"], null);
this.lat = source["lat"];
this.lon = source["lon"];
this.alt_km = source["alt_km"];
this.footprint_km = source["footprint_km"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ScpStatus {
enabled: boolean;
count: number;
@@ -4516,6 +4951,24 @@ export namespace main {
return a;
}
}
export class WsjtHighlightColours {
watchlist: string;
new_dxcc: string;
new_band: string;
worked: string;
static createFrom(source: any = {}) {
return new WsjtHighlightColours(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.watchlist = source["watchlist"];
this.new_dxcc = source["new_dxcc"];
this.new_band = source["new_band"];
this.worked = source["worked"];
}
}
}
@@ -4894,6 +5347,8 @@ export namespace pskr {
last_err?: string;
broker: string;
bands: string[];
near_km: number;
squares: number;
static createFrom(source: any = {}) {
return new Status(source);
@@ -4907,6 +5362,8 @@ export namespace pskr {
this.last_err = source["last_err"];
this.broker = source["broker"];
this.bands = source["bands"];
this.near_km = source["near_km"];
this.squares = source["squares"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -4930,6 +5387,132 @@ export namespace pskr {
}
export namespace pskrtgt {
export class Bin {
offset_hz: number;
count: number;
avg_snr: number;
static createFrom(source: any = {}) {
return new Bin(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.offset_hz = source["offset_hz"];
this.count = source["count"];
this.avg_snr = source["avg_snr"];
}
}
export class Entry {
call: string;
grid: string;
snr: number;
offset_hz: number;
age_sec: number;
static createFrom(source: any = {}) {
return new Entry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.call = source["call"];
this.grid = source["grid"];
this.snr = source["snr"];
this.offset_hz = source["offset_hz"];
this.age_sec = source["age_sec"];
}
}
export class Analysis {
target: string;
mode?: string;
enabled: boolean;
online: boolean;
spots: number;
he_me: boolean;
he_me_seconds: number;
he_me_snr: number;
he_me_offset_hz: number;
target_uploads: boolean;
target_grid?: string;
near_him_count: number;
near_him_top: Entry[];
from_my_area_count: number;
from_my_area_top: Entry[];
path_open: boolean;
heard_by_count: number;
heard_near_me: number;
heard_near_me_top: Entry[];
decoded_by_count: number;
decoded_by_top: Entry[];
decoded_by_calls: string[];
pileup_count: number;
dial_hz: number;
ceiling_hz: number;
decodes_in_window: number;
bins: Bin[];
suggested_offset: number;
static createFrom(source: any = {}) {
return new Analysis(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.target = source["target"];
this.mode = source["mode"];
this.enabled = source["enabled"];
this.online = source["online"];
this.spots = source["spots"];
this.he_me = source["he_me"];
this.he_me_seconds = source["he_me_seconds"];
this.he_me_snr = source["he_me_snr"];
this.he_me_offset_hz = source["he_me_offset_hz"];
this.target_uploads = source["target_uploads"];
this.target_grid = source["target_grid"];
this.near_him_count = source["near_him_count"];
this.near_him_top = this.convertValues(source["near_him_top"], Entry);
this.from_my_area_count = source["from_my_area_count"];
this.from_my_area_top = this.convertValues(source["from_my_area_top"], Entry);
this.path_open = source["path_open"];
this.heard_by_count = source["heard_by_count"];
this.heard_near_me = source["heard_near_me"];
this.heard_near_me_top = this.convertValues(source["heard_near_me_top"], Entry);
this.decoded_by_count = source["decoded_by_count"];
this.decoded_by_top = this.convertValues(source["decoded_by_top"], Entry);
this.decoded_by_calls = source["decoded_by_calls"];
this.pileup_count = source["pileup_count"];
this.dial_hz = source["dial_hz"];
this.ceiling_hz = source["ceiling_hz"];
this.decodes_in_window = source["decodes_in_window"];
this.bins = this.convertValues(source["bins"], Bin);
this.suggested_offset = source["suggested_offset"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace psu {
export class Status {
@@ -5847,6 +6430,109 @@ export namespace qso {
}
export namespace sat {
export class Pass {
name: string;
// Go type: time
aos: any;
// Go type: time
los: any;
aos_az: number;
los_az: number;
max_el: number;
max_el_az: number;
// Go type: time
max_el_at: any;
duration_s: number;
static createFrom(source: any = {}) {
return new Pass(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.aos = this.convertValues(source["aos"], null);
this.los = this.convertValues(source["los"], null);
this.aos_az = source["aos_az"];
this.los_az = source["los_az"];
this.max_el = source["max_el"];
this.max_el_az = source["max_el_az"];
this.max_el_at = this.convertValues(source["max_el_at"], null);
this.duration_s = source["duration_s"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Position {
name: string;
// Go type: time
at: any;
lat: number;
lon: number;
alt_km: number;
footprint_km: number;
az: number;
el: number;
range_km: number;
range_rate: number;
static createFrom(source: any = {}) {
return new Position(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.at = this.convertValues(source["at"], null);
this.lat = source["lat"];
this.lon = source["lon"];
this.alt_km = source["alt_km"];
this.footprint_km = source["footprint_km"];
this.az = source["az"];
this.el = source["el"];
this.range_km = source["range_km"];
this.range_rate = source["range_rate"];
}
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 scp {
export class Result {
+1
View File
@@ -3,6 +3,7 @@ module hamlog
go 1.25.0
require (
github.com/akhenakh/sgp4 v0.0.0-20260314155803-8ee03fc877eb
github.com/braheezy/shine-mp3 v0.1.0
github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/go-ole/go-ole v1.3.0
+2
View File
@@ -1,5 +1,7 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/akhenakh/sgp4 v0.0.0-20260314155803-8ee03fc877eb h1:d9tZ7tJrssgs7Va9j8iu9vl7BlK2rmIs5RiOU7WQJrs=
github.com/akhenakh/sgp4 v0.0.0-20260314155803-8ee03fc877eb/go.mod h1:JfAepWD223Cel6uRpzYdip/xijWZ2FT457YFLWy8Md4=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/braheezy/shine-mp3 v0.1.0 h1:N2wZhv6ipCFduTSftaPNdDgZ5xFmQAPvB7JcqA4sSi8=
+69 -5
View File
@@ -120,12 +120,36 @@ func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (in
func SingleRecordADIF(q qso.QSO) string {
var b strings.Builder
bw := bufio.NewWriter(&b)
// Uploads target other services — keep it standard (no app-specific tags).
// Uploads target other services — keep it standard (no app-specific tags),
// and say nothing about the receive side when there is nothing to say: in
// ADIF an absent BAND_RX/FREQ_RX means "same as transmit", and a logger
// given both draws both. Wavelog reads a simplex FT8 contact uploaded with
// BAND_RX filled in as split and shows it as "17m/17m".
writeRecord(bw, q, false, nil)
bw.Flush()
return b.String()
}
// ForwardRecordADIF is the record sent to ANOTHER LOGGER on the UDP link.
//
// The receive side is written even when it repeats the transmit side, which is
// the opposite of the upload rule above and is deliberate: Log4OM reads BAND_RX
// and found nothing there for contacts logged by a path that left it blank. A
// logger on the same desk is being handed a copy of our record, not published
// to a service that will draw conclusions from every tag present.
func ForwardRecordADIF(q qso.QSO) string {
var b strings.Builder
bw := bufio.NewWriter(&b)
writeRecord(bw, q, false, nil, keepRX)
bw.Flush()
return b.String()
}
// keepRX marks a record whose receive side must be written out in full.
type rxMode int
const keepRX rxMode = 1
// FullRecordADIF serialises one QSO LOSSLESSLY — including the APP_* extras —
// so it can be written out and read back with nothing dropped. Used by the
// offline queue: a QSO parked in the safety file must come back identical
@@ -161,7 +185,18 @@ func BatchRecordsADIF(records []string) string {
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
// mode (e.g. FT4 stored without a parent) is exported as the canonical
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]bool) {
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]bool, rx ...rxMode) {
// The receive side, unless it merely repeats the transmit side. See
// SingleRecordADIF and ForwardRecordADIF.
bandRX, freqRX := q.BandRX, q.FreqRXHz
if len(rx) == 0 || rx[0] != keepRX {
if strings.EqualFold(strings.TrimSpace(bandRX), strings.TrimSpace(q.Band)) {
bandRX = ""
}
if freqRX != nil && q.FreqHz != nil && *freqRX == *q.FreqHz {
freqRX = nil
}
}
// allow == nil → write every promoted field (standard/full behaviour).
// Otherwise a promoted tag is written only when it's in the chosen set.
// w/wi/wf wrap the raw writers with that gate so the ~150 field lines below
@@ -194,7 +229,7 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
w("TIME_OFF", q.QSODateOff.UTC().Format("150405"))
}
w("BAND", q.Band)
w("BAND_RX", q.BandRX)
w("BAND_RX", bandRX)
mode, submode := modeForExport(q.Mode, q.Submode)
w("MODE", mode)
@@ -203,8 +238,8 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
if q.FreqHz != nil && *q.FreqHz > 0 {
w("FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
}
if q.FreqRXHz != nil && *q.FreqRXHz > 0 {
w("FREQ_RX", strconv.FormatFloat(float64(*q.FreqRXHz)/1_000_000, 'f', 6, 64))
if freqRX != nil && *freqRX > 0 {
w("FREQ_RX", strconv.FormatFloat(float64(*freqRX)/1_000_000, 'f', 6, 64))
}
w("RST_SENT", q.RSTSent)
@@ -372,12 +407,41 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
// length is the byte count (ADIF spec), which matches len(v) in Go since v is
// already a UTF-8 byte string.
func writeField(bw *bufio.Writer, tag, v string) {
v = oneLine(v)
if v == "" {
return
}
fmt.Fprintf(bw, "<%s:%d>%s ", tag, len(v), v)
}
// oneLine flattens a value onto a single line.
//
// ADIF counts bytes, so a value carrying line breaks is still read correctly —
// and it turns the file into something nobody can read. ADDRESS is a multi-line
// field by the standard, and callbooks and other loggers fill it that way: a
// value of "Kabul" followed by four blank lines and "Afghanistan" came out of
// OpsLog as one record spread down a dozen lines, with the next record
// apparently starting in the middle of the page.
//
// The breaks are dropped rather than escaped: the parts are trimmed and joined
// with a comma, which is how an address reads on one line anyway, and empty
// fragments go. The length prefix is computed after this, so the record stays
// exact.
func oneLine(v string) string {
if !strings.ContainsAny(v, "\r\n\t") {
return v
}
parts := strings.FieldsFunc(v, func(r rune) bool { return r == '\r' || r == '\n' })
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(strings.ReplaceAll(part, "\t", " "))
if part != "" {
out = append(out, part)
}
}
return strings.Join(out, ", ")
}
func writeIntPtr(bw *bufio.Writer, tag string, p *int) {
if p == nil {
return
+46
View File
@@ -0,0 +1,46 @@
package adif
import (
"bufio"
"strings"
"testing"
"hamlog/internal/qso"
)
// An exported record has to fit on its own line. ADDRESS is a multi-line field
// by the standard and callbooks fill it that way, so an OpsLog export was one
// record spread down a dozen lines with the next apparently starting in the
// middle of the page.
func TestAMultiLineValueIsWrittenOnOneLine(t *testing.T) {
var b strings.Builder
bw := bufio.NewWriter(&b)
writeField(bw, "ADDRESS", "Kabul\r\n\r\n\r\n\r\nAfghanistan\r\n")
bw.Flush()
got := b.String()
if strings.ContainsAny(got, "\r\n") {
t.Fatalf("the record still breaks across lines: %q", got)
}
if want := "<ADDRESS:18>Kabul, Afghanistan "; got != want {
t.Errorf("got %q, want %q", got, want)
}
}
// And the whole record, the way an operator reads the file.
func TestARecordIsOneLine(t *testing.T) {
hz := int64(28555000)
rec := SingleRecordADIF(qso.QSO{
Callsign: "T6T", Band: "10m", Mode: "SSB", FreqHz: &hz,
Address: "Kabul\n\n\nAfghanistan", Name: "Shuravi\t(Vyacheslav)",
})
if n := strings.Count(strings.TrimRight(rec, "\r\n"), "\n"); n != 0 {
t.Errorf("the record spans %d extra lines:\n%s", n, rec)
}
if !strings.Contains(rec, "Kabul, Afghanistan") {
t.Errorf("the address lost its parts:\n%s", rec)
}
if !strings.Contains(rec, "Shuravi (Vyacheslav)") {
t.Errorf("a tab was left in the value:\n%s", rec)
}
}
+32 -3
View File
@@ -15,6 +15,12 @@ type Manager struct {
mu sync.Mutex
recStop chan struct{}
recDone chan recResult
// Said once each: "the audio is reaching the speakers" and "it is arriving
// with nobody listening". Both are answers to the same evening — sound
// switched on, nothing out of the speakers — and neither is worth a line per
// packet at fifty packets a second.
gotAudioOnce sync.Once
noSinkOnce sync.Once
// monGainPct scales what the RX monitor plays, 100 = as captured.
//
// The "From radio" slider used to reach only the QSO recorder, so an
@@ -224,6 +230,10 @@ func (m *Manager) StartMonitor(inputDev, outputDev string) error {
return m.startMonitor(inputDev, outputDev, true)
}
// Logf receives this package's diagnostic lines. Set to applog.Printf by the
// app; a no-op in tests and for anything that vendors the package alone.
var Logf = func(string, ...any) {}
// StartMonitorSink starts ONLY the render side (no USB capture) so an external
// producer — the network 50003 stream — can feed decoded RX PCM via
// PushMonitorAudio. Same output path as StartMonitor, minus the capture goroutine.
@@ -255,8 +265,17 @@ func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
}()
}
// Consumer: render the ring to the output device at the internal 16 kHz mono.
//
// The error was thrown away, and that is the whole of "I turned the sound on
// and nothing comes out": a Listening device that has been unplugged, renamed
// by Windows or cannot open at 16 kHz fails here, silently, while everything
// upstream reports success — the stream is up, the packets arrive, the
// monitor says it started. Said out loud, the operator knows to look at the
// device rather than at the radio.
go func() {
_ = renderStream(outputDev, sampleRate, channels, bitsPerSample, stop, ring)
if err := renderStream(outputDev, sampleRate, channels, bitsPerSample, stop, ring); err != nil {
Logf("audio: the Listening device could not be opened (%q): %v", outputDev, err)
}
}()
m.notify()
return nil
@@ -321,9 +340,19 @@ func (m *Manager) PushMonitorAudio(pcm []byte) {
m.mu.Lock()
ring := m.monRing
m.mu.Unlock()
if ring != nil {
ring.Push(pcm)
if ring == nil {
// Nothing is listening: the stream is feeding the recorder and the voice
// keyer only. Said ONCE, because the alternative — silence in the log for
// silence in the speakers — is what makes this take an evening to find.
m.noSinkOnce.Do(func() {
Logf("audio: network RX audio is arriving but no monitor is running — the speakers are off (Listening)")
})
return
}
m.gotAudioOnce.Do(func() {
Logf("audio: network RX audio reaching the Listening device (%d-byte chunks)", len(pcm))
})
ring.Push(pcm)
}
// ---- TX audio passthrough (Phase 3: live mic → rig over USB) --------------
File diff suppressed because it is too large Load Diff
+978
View File
@@ -0,0 +1,978 @@
package autocall
import (
"fmt"
"strings"
"testing"
"time"
)
const me = "F4BPO"
// base is a slot boundary, so slot parity in the tests is the real arithmetic.
var base = time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC)
func at(period int) time.Time { return base.Add(time.Duration(period) * 15 * time.Second) }
func cq(call string, need Need, snr int, opts ...func(*Candidate)) Candidate {
c := Candidate{
Decode: Decode{Call: call, Band: "20m", Mode: "FT8", SNR: snr, CQ: true,
Msg: "CQ " + call + " JN36", TRPeriod: 15, IsNew: true},
Need: need,
}
for _, o := range opts {
o(&c)
}
return c
}
func watched(c *Candidate) { c.Watched = true }
func worked(c *Candidate) { c.Worked = true }
// busy is a station in the middle of an exchange with somebody else.
func busy(call string, need Need, snr int) Candidate {
c := cq(call, need, snr)
c.CQ = false
c.Msg = "VP6D " + call + " JN36"
return c
}
// callsMe is a station answering us.
func callsMe(call string, need Need, snr int) Candidate {
c := cq(call, need, snr)
c.CQ = false
c.Msg = me + " " + call + " -12"
return c
}
func period(n int, decodes ...Candidate) Period {
for i := range decodes {
decodes[i].At = at(n)
}
return Period{Key: fmt.Sprintf("p%d", n), At: at(n), TRPeriod: 15, Decodes: decodes, MyCall: me}
}
func on() *Engine { return New(Settings{Enabled: true}) }
// ── The ladder ────────────────────────────────────────────────────────────
func TestLadderOrder(t *testing.T) {
// Every rung. The watched ones come FIRST, ordered among themselves by what
// is needed — the list is the operator's answer, not a tie-breaker.
order := []Candidate{
cq("A", NeedDXCC, 0, watched), cq("C", NeedBand, 0, watched),
cq("E", NeedMode, 0, watched), cq("G", NeedSlot, 0, watched),
cq("I", NeedNone, 0, watched),
cq("B", NeedDXCC, 0), cq("D", NeedBand, 0),
cq("F", NeedMode, 0), cq("H", NeedSlot, 0),
// The orthogonal markers sit at the foot of the ladder: worth a call
// when nothing better is on the air, never worth leaving a band for.
cq("J", NeedExtra, 0),
}
for i := 1; i < len(order); i++ {
if rank(order[i-1]) <= rank(order[i]) {
t.Errorf("%s (%d) does not outrank %s (%d)",
order[i-1].Call, rank(order[i-1]), order[i].Call, rank(order[i]))
}
}
// A station with nothing needed and not watched is not called at all.
if rank(cq("Z", NeedNone, 0)) != 0 {
t.Error("a station with nothing to gain from it ranks above zero")
}
// And the pick agrees with the ladder, whatever order the period lists them.
e := on()
a := e.OnPeriod(period(0, order[8], order[5], order[0], order[6]))
if a.Kind != DoReply || a.Decode.Call != "A" {
t.Fatalf("picked %+v, want the watched new entity", a)
}
// A watched station with NOTHING needed still beats a new entity that is not
// watched — the case that sent an operator hunting: a watched DXpedition sat
// on the band all evening while the engine worked what the log wanted.
e = on()
if a := e.OnPeriod(period(2, cq("RARE", NeedDXCC, 0), cq("WATCHED", NeedNone, -20, watched))); a.Decode.Call != "WATCHED" {
t.Errorf("picked %q, want the watched callsign", a.Decode.Call)
}
}
func TestStrongestWinsBetweenEquals(t *testing.T) {
e := on()
a := e.OnPeriod(period(0, cq("WEAK", NeedBand, -20), cq("LOUD", NeedBand, -5)))
if a.Decode.Call != "LOUD" {
t.Errorf("picked %q, want the strongest of two equal needs", a.Decode.Call)
}
}
// ── The busy station ──────────────────────────────────────────────────────
func TestBusyStationIsNeverCalled(t *testing.T) {
e := on()
// The new entity is answering a DXpedition; the new band is calling CQ.
a := e.OnPeriod(period(0, busy("RARE", NeedDXCC, -3), cq("DL1XX", NeedBand, -15)))
if a.Kind != DoReply || a.Decode.Call != "DL1XX" {
t.Fatalf("called %+v — a station in mid-QSO cannot answer and must not be called", a)
}
// It is not banned: the moment it calls CQ it takes the slot back, once the
// QSO in hand is over.
e2 := on()
if a := e2.OnPeriod(period(0, cq("RARE", NeedDXCC, -3))); a.Decode.Call != "RARE" {
t.Errorf("the same station calling CQ was not called: %+v", a)
}
}
func TestFinalFrameIsCallable(t *testing.T) {
e := on()
c := busy("RARE", NeedDXCC, -3)
c.Msg = "IK2AAA RARE RR73" // one frame from being free
if a := e.OnPeriod(period(0, c)); a.Kind != DoReply {
t.Errorf("a station sending its last frame is free next period: %+v", a)
}
}
// ── The brakes ────────────────────────────────────────────────────────────
func TestAttemptsCapAtSevenAndFifteen(t *testing.T) {
for _, tc := range []struct {
name string
opt func(*Candidate)
want int
}{{"plain", func(*Candidate) {}, 7}, {"watched", watched, 15}} {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5, tc.opt)))
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
for i := 1; i < tc.want; i++ {
if a := e.NoteTX(tx); a.Kind != DoNothing {
t.Fatalf("%s: gave up at call %d of %d", tc.name, i, tc.want)
}
}
a := e.NoteTX(tx)
if a.Kind != DoHalt {
t.Errorf("%s: still calling after %d attempts", tc.name, tc.want)
}
if e.Target() != "" {
t.Errorf("%s: target still held after giving up", tc.name)
}
}
}
func TestOnlyFreshCallsCount(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
// The rest of the exchange is not a call: without this the seven were spent
// on one QSO in progress.
for _, msg := range []string{"DX " + me + " -12", "DX " + me + " R-12", "DX " + me + " RR73"} {
if a := e.NoteTX(TXState{Transmitting: true, Msg: msg}); a.Kind != DoNothing {
t.Fatalf("%q ended the series", msg)
}
}
if e.Status().Attempts != 0 {
t.Errorf("attempts = %d after three QSO frames, want 0", e.Status().Attempts)
}
// A transmission aimed at somebody else counts for nothing either.
e.NoteTX(TXState{Transmitting: true, Msg: "OTHER " + me + " JN36"})
if e.Status().Attempts != 0 {
t.Errorf("a call to another station was counted against the target")
}
}
func TestMissesOnlyCountTheStationsOwnPeriods(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))) // learns nothing yet
e.OnPeriod(period(2, cq("DX", NeedDXCC, -5))) // seen: it transmits on even periods
// Its listening periods say nothing about it. Ten of them must not add up
// to a give-up.
for _, p := range []int{3, 5, 7, 9, 11} {
if a := e.OnPeriod(period(p)); a.Kind != DoNothing {
t.Fatalf("gave up during the station's own listening period %d", p)
}
}
if e.Status().Misses != 0 {
t.Errorf("misses = %d over five listening periods, want 0", e.Status().Misses)
}
// Absent from two of its transmit periods: still holding.
e.OnPeriod(period(4))
e.OnPeriod(period(6))
if e.Target() == "" {
t.Fatal("gave up after two misses, the limit is three")
}
if a := e.OnPeriod(period(8)); a.Kind != DoHalt {
t.Errorf("still holding after three missed transmit periods: %+v", a)
}
}
func TestOneMissPerPeriodEvenIfTheHandlerRunsTwice(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
e.OnPeriod(period(2, cq("DX", NeedDXCC, -5)))
p := period(4)
e.OnPeriod(p)
e.OnPeriod(p)
e.OnPeriod(p)
if e.Status().Misses != 1 {
t.Errorf("misses = %d after one period handled three times, want 1", e.Status().Misses)
}
}
func TestClockBackstopReleasesAStuckTarget(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
// Decoded every one of its periods and never answering, with the decoder
// never reporting a transmission: no counter can advance.
for p := 2; p <= 16; p += 2 {
e.OnPeriod(period(p, cq("DX", NeedDXCC, -5)))
}
if a := e.OnPeriod(period(18, cq("DX", NeedDXCC, -5))); a.Kind != DoHalt {
t.Errorf("a target held past MaxHold with no counter moving was never released: %+v", a)
}
}
// ── After a series ────────────────────────────────────────────────────────
func TestAReleasedStationYieldsToAnythingBetter(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedBand, -5)))
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
for i := 0; i < 7; i++ {
e.NoteTX(tx)
}
if e.Target() != "" {
t.Fatal("still holding after seven calls")
}
// It is still there, and so is a new entity: the entity takes the slot.
a := e.OnPeriod(period(2, cq("DX", NeedBand, -5), cq("RARE", NeedDXCC, -20)))
if a.Decode.Call != "RARE" {
t.Errorf("picked %q, want the higher priority over the station just released", a.Decode.Call)
}
}
// A series that ended in a brake is followed by ONE of the station's own overs
// passed over — long enough to be a pause, short enough that the DX everybody
// is chasing is still there when the calling resumes. It used to be two
// minutes, which on FT8 is four overs: by then the DX has worked four other
// callers, and half the time it has gone.
func TestAReleasedStationSitsOutOneOfItsOvers(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedBand, -5)))
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
for i := 0; i < 7; i++ {
e.NoteTX(tx)
}
// Its very next over is passed over, whatever else is on the air: seven
// calls, a halt, and the same station called again four seconds later is not
// a rest, it is a stutter.
if a := e.OnPeriod(period(2, cq("DX", NeedBand, -5))); a.Kind != DoNothing {
t.Fatalf("called again during the rest: %+v", a)
}
// And the one after that goes: still there, still wanted, and the operator
// has listened through an over.
if a := e.OnPeriod(period(4, cq("DX", NeedBand, -5))); a.Kind != DoReply {
t.Errorf("after one over: %+v, want the station called again", a)
}
}
func TestTheAttemptsCapLetsTheOverFinish(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
var a Action
for i := 0; i < 7; i++ {
a = e.NoteTX(tx)
}
if a.Kind != DoHalt {
t.Fatalf("no halt after seven calls: %+v", a)
}
// The cap trips WHILE the seventh call is going out — cutting the carrier
// there sends half a call. It asks the decoder to finish the over first.
if !a.Soft {
t.Error("the attempts cap cut the transmission that counted it")
}
}
func TestAStationIsParkedAfterItsRounds(t *testing.T) {
e := on()
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
// Rounds are spaced past the rest (2 min = 8 periods) or the station would
// simply be resting rather than parked.
for round := 1; round <= 3; round++ {
a := e.OnPeriod(period(round*10, cq("DX", NeedBand, -5)))
if a.Kind != DoReply {
t.Fatalf("round %d: not called (%+v)", round, a)
}
for i := 0; i < 7; i++ {
e.NoteTX(tx)
}
}
// Three series of seven is twenty-one calls. That is the end of it for this
// session — the whole point of the exercise is that it cannot reach fifty.
if a := e.OnPeriod(period(60, cq("DX", NeedBand, -5))); a.Kind != DoNothing {
t.Errorf("a fourth series was started: %+v", a)
}
}
// ── Handing over ──────────────────────────────────────────────────────────
func TestFinishedQSOMovesToTheNextPriority(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
done := callsMe("DX", NeedDXCC, -5)
done.Msg = me + " DX RR73"
// The period the QSO ends in belongs to our own 73 — nothing else is called.
if a := e.OnPeriod(period(2, done, cq("NEXT", NeedBand, -10))); a.Kind != DoNothing {
t.Fatalf("took the slot our 73 goes out in: %+v", a)
}
a := e.OnPeriod(period(4, cq("NEXT", NeedBand, -10)))
if a.Kind != DoReply || a.Decode.Call != "NEXT" {
t.Errorf("next period: %+v, want the next priority", a)
}
}
func TestFinishedQSOWithNoPriorityAnswersWhoeverIsCallingUs(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
done := callsMe("DX", NeedDXCC, -5)
done.Msg = me + " DX RR73"
// Two stations calling us, nothing needed from either: the strongest wins —
// on the period after the one our 73 goes out in.
weak := callsMe("WEAK", NeedNone, -18)
weak.Watched = true
loud := callsMe("LOUD", NeedNone, -4)
loud.Watched = true
e.OnPeriod(period(2, done))
a := e.OnPeriod(period(4, weak, loud))
if a.Kind != DoReply || a.Decode.Call != "LOUD" {
t.Errorf("answered %+v, want the strongest of the stations calling us", a)
}
}
func TestAlreadyWorkedIsNeverCalled(t *testing.T) {
e := on()
a := e.OnPeriod(period(0, cq("DX", NeedDXCC, -5, worked)))
if a.Kind != DoNothing {
t.Errorf("called a station already in the log on this band and mode: %+v", a)
}
}
func TestReplayedHistoryIsNeverCalled(t *testing.T) {
e := on()
old := cq("DX", NeedDXCC, -5)
old.IsNew = false
if a := e.OnPeriod(period(0, old)); a.Kind != DoNothing {
t.Errorf("answered a replayed decode: %+v", a)
}
}
func TestNoCallStartsOverATransmissionInProgress(t *testing.T) {
e := on()
p := period(0, cq("DX", NeedDXCC, -5))
p.TX = TXState{Transmitting: true}
if a := e.OnPeriod(p); a.Kind != DoNothing {
t.Errorf("started a call while the decoder was transmitting: %+v", a)
}
}
// ── The "call this station" field ─────────────────────────────────────────
func TestOnlyCallsThatStation(t *testing.T) {
e := New(Settings{Enabled: true, Only: "VP6D"})
a := e.OnPeriod(period(0, cq("RARE", NeedDXCC, -5), cq("VP6D", NeedSlot, -20)))
if a.Kind != DoReply || a.Decode.Call != "VP6D" {
t.Fatalf("picked %+v, want the station named in the field", a)
}
// Same brakes, then a hard stop: there is nothing else it was asked to do.
tx := TXState{Transmitting: true, Msg: "VP6D " + me + " JN36"}
for i := 0; i < 7; i++ {
e.NoteTX(tx)
}
if !e.Status().Stopped {
t.Error("an explicit target ran out of attempts and the feature did not stop")
}
if a := e.OnPeriod(period(2, cq("VP6D", NeedSlot, -20))); a.Kind != DoNothing {
t.Errorf("kept calling after the stop: %+v", a)
}
e.Reset()
if a := e.OnPeriod(period(4, cq("VP6D", NeedSlot, -20))); a.Kind != DoReply {
t.Errorf("the operator restarted it and nothing happened: %+v", a)
}
}
func TestDisabledDoesNothingAtAll(t *testing.T) {
e := New(Settings{})
if a := e.OnPeriod(period(0, cq("DX", NeedDXCC, 0))); a.Kind != DoNothing {
t.Errorf("switched off and still calling: %+v", a)
}
if a := e.NoteTX(TXState{Transmitting: true, Msg: "DX " + me + " JN36"}); a.Kind != DoNothing {
t.Errorf("switched off and still counting: %+v", a)
}
}
func TestOnlyStopsOnceThatStationIsWorked(t *testing.T) {
e := New(Settings{Enabled: true, Only: "VP6D"})
e.OnPeriod(period(0, cq("VP6D", NeedDXCC, -10)))
done := callsMe("VP6D", NeedDXCC, -10)
done.Msg = me + " VP6D RR73"
e.OnPeriod(period(2, done))
// The station is still on the air calling CQ. It has been worked: an
// explicit request is for one QSO, not for the whole evening.
if a := e.OnPeriod(period(4, cq("VP6D", NeedDXCC, -10))); a.Kind != DoNothing {
t.Errorf("called the named station again after working it: %+v", a)
}
}
func TestAStationJustWorkedIsNotCalledBackWhileTheLogCatchesUp(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
done := callsMe("DX", NeedDXCC, -5)
done.Msg = me + " DX RR73"
e.OnPeriod(period(2, done))
// Still flagged as a new entity — the QSO is not in the log yet — and still
// calling CQ. It must not be answered again.
if a := e.OnPeriod(period(4, cq("DX", NeedDXCC, -5))); a.Kind != DoNothing {
t.Errorf("called back a station worked two periods ago: %+v", a)
}
}
func TestChaseListTakesSeveralCallsigns(t *testing.T) {
// Typed the way an operator types a list: commas, spaces, or both.
for _, field := range []string{"VP6D 3Y0J", "vp6d,3y0j", "VP6D, 3Y0J", " VP6D ;3Y0J "} {
if got := onlyList(field); len(got) != 2 || got[0] != "VP6D" || got[1] != "3Y0J" {
t.Errorf("%q parsed as %v, want [VP6D 3Y0J]", field, got)
}
}
e := New(Settings{Enabled: true, Only: "VP6D, 3Y0J"})
// Nothing off the list is called, however rare it is.
if a := e.OnPeriod(period(0, cq("RARE", NeedDXCC, -1))); a.Kind != DoNothing {
t.Errorf("called a station that is not on the chase list: %+v", a)
}
// Between two listed stations the ladder still decides: the new entity over
// the new slot, whatever their order in the period.
a := e.OnPeriod(period(2, cq("3Y0J", NeedSlot, -1), cq("VP6D", NeedDXCC, -22)))
if a.Kind != DoReply || a.Decode.Call != "VP6D" {
t.Fatalf("picked %+v, want the new entity of the two listed", a)
}
// Working one of them leaves the other callable — the list is a hunt, not a
// single request.
done := callsMe("VP6D", NeedDXCC, -22)
done.Msg = me + " VP6D RR73"
e.OnPeriod(period(4, done, cq("3Y0J", NeedSlot, -1)))
a = e.OnPeriod(period(6, cq("3Y0J", NeedSlot, -1)))
if a.Kind != DoReply || a.Decode.Call != "3Y0J" {
t.Errorf("after working VP6D: %+v, want the other station on the list", a)
}
}
// ── Two decoders at once (the split view) ─────────────────────────────────
func onInst(inst string, p Period) Period {
p.Instance = inst
for i := range p.Decodes {
p.Decodes[i].Instance = inst
}
return p
}
func TestTheOtherReceiverCannotBreakTheQSOInProgress(t *testing.T) {
e := on()
// Calling a new band on receiver A.
if a := e.OnPeriod(onInst("A", period(0, cq("DL1XX", NeedBand, -10)))); a.Decode.Call != "DL1XX" {
t.Fatalf("first call went to %+v", a)
}
// Receiver B now hears a new ENTITY — a better catch by every rule. It must
// still not be called: one station at a time, and the QSO in hand is the
// one already under way.
if a := e.OnPeriod(onInst("B", period(1, cq("RARE", NeedDXCC, -1)))); a.Kind != DoNothing {
t.Errorf("the other receiver started a second QSO: %+v", a)
}
// And B's periods count no misses against A's target: the station is not
// absent from B, it was never on that band.
e.OnPeriod(onInst("B", period(3, cq("RARE", NeedDXCC, -1))))
e.OnPeriod(onInst("B", period(5, cq("RARE", NeedDXCC, -1))))
e.OnPeriod(onInst("B", period(7, cq("RARE", NeedDXCC, -1))))
if e.Status().Misses != 0 || e.Target() != "DL1XX" {
t.Errorf("misses = %d, target = %q — the other receiver's periods were counted",
e.Status().Misses, e.Target())
}
// B transmitting its own QSO is not us calling DL1XX either.
for i := 0; i < 9; i++ {
e.NoteTX(TXState{Transmitting: true, Instance: "B", Msg: "DL1XX " + me + " JN36"})
}
if e.Status().Attempts != 0 {
t.Errorf("attempts = %d from the other receiver's transmissions, want 0", e.Status().Attempts)
}
// A's own transmissions do count.
e.NoteTX(TXState{Transmitting: true, Instance: "A", Msg: "DL1XX " + me + " JN36"})
if e.Status().Attempts != 1 {
t.Errorf("attempts = %d after one call from the calling receiver, want 1", e.Status().Attempts)
}
// Once the QSO is over, the other receiver's station is free to be taken.
done := callsMe("DL1XX", NeedBand, -10)
done.Msg = me + " DL1XX RR73"
e.OnPeriod(onInst("A", period(9, done)))
if a := e.OnPeriod(onInst("B", period(11, cq("RARE", NeedDXCC, -1)))); a.Decode.Call != "RARE" {
t.Errorf("after the QSO ended, the other receiver was still locked out: %+v", a)
}
}
// The one reported from the air: his RRR arrives, we are sending our 73, and
// the engine picked the next station in the same instant. WSJT-X acts on a
// reply at once — it dropped the exchange and started calling the new station,
// cutting the 73 a second in.
func TestNothingIsCalledOverOurOwnTransmission(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
done := callsMe("DX", NeedDXCC, -5)
done.Msg = me + " DX RRR"
p := period(2, done, cq("NEXT", NeedBand, -10))
p.TX = TXState{Transmitting: true, Msg: "DX " + me + " 73"}
if a := e.OnPeriod(p); a.Kind != DoNothing {
t.Fatalf("called %+v while our own over was still going out", a)
}
// The QSO is still released — only the next call waits.
if e.Target() != "" {
t.Errorf("target = %q after the QSO finished, want none", e.Target())
}
// Next period, carrier down: now it may take the next station.
if a := e.OnPeriod(period(4, cq("NEXT", NeedBand, -10))); a.Kind != DoReply || a.Decode.Call != "NEXT" {
t.Errorf("once the transmission ended: %+v, want the next station called", a)
}
}
func TestTheHoldClockRestartsWhenTheStationAnswers(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
// Called for a long time — nearly the backstop — and then he answers.
for p := 2; p <= 14; p += 2 {
e.OnPeriod(period(p, cq("DX", NeedDXCC, -5)))
}
e.OnPeriod(period(16, callsMe("DX", NeedDXCC, -5)))
// The exchange must not be halted by a backstop that was counting the wait.
for _, p := range []int{18, 20} {
if a := e.OnPeriod(period(p, callsMe("DX", NeedDXCC, -5))); a.Kind == DoHalt {
t.Fatalf("period %d: halted an exchange in progress (%s)", p, a.Reason)
}
}
}
func TestARealNeedOutranksAnUnconfirmedOne(t *testing.T) {
real := cq("REAL", NeedBand, -20)
unconf := cq("UNCONF", NeedBand, -2)
unconf.Unconfirmed = true
// Same need, and the unconfirmed one is 18 dB louder. The band never worked
// is still the catch: the other is a QSL to chase, not a QSO to make.
e := on()
if a := e.OnPeriod(period(0, unconf, real)); a.Decode.Call != "REAL" {
t.Errorf("picked %q, want the band never worked", a.Decode.Call)
}
// Watched changes that, and is meant to: the list is the operator's answer.
unconf.Watched = true
e = on()
if a := e.OnPeriod(period(0, unconf, real)); a.Decode.Call != "UNCONF" {
t.Errorf("picked %q, want the watched station", a.Decode.Call)
}
unconf.Watched = false
// Between two unwatched stations, an unconfirmed BAND still beats a real
// SLOT: the band is the bigger prize whatever state it is in.
slot := cq("SLOT", NeedSlot, 0)
e = on()
if a := e.OnPeriod(period(0, slot, unconf)); a.Decode.Call != "UNCONF" {
t.Errorf("picked %q, want the unconfirmed band over a real slot", a.Decode.Call)
}
}
// ── The operator's Halt ───────────────────────────────────────────────────
func TestHaltSetsTheStationAsideForTheSession(t *testing.T) {
e := on()
if a := e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))); a.Decode.Call != "DX" {
t.Fatalf("not called: %+v", a)
}
// The operator stops it mid-call.
if got := e.Halt(); got != "DX" {
t.Fatalf("Halt returned %q, want the station being called", got)
}
if e.Target() != "" {
t.Error("the target survived a halt")
}
// Still the loudest new entity on the air, period after period. It is the
// operator's verdict, so it is not called again — not next period, not in
// ten minutes.
for _, n := range []int{2, 4, 40} {
if a := e.OnPeriod(period(n, cq("DX", NeedDXCC, -5))); a.Kind != DoNothing {
t.Errorf("period %d: called a station the operator had stopped: %+v", n, a)
}
}
// Naming it explicitly does not override the operator either.
e.SetSettings(Settings{Enabled: true, Only: "DX"})
if a := e.OnPeriod(period(42, cq("DX", NeedDXCC, -5))); a.Kind != DoNothing {
t.Errorf("the chase list overrode a halt: %+v", a)
}
// Switching auto-call off and on is what starts over.
e.Reset()
e.SetSettings(Settings{Enabled: true})
if a := e.OnPeriod(period(44, cq("DX", NeedDXCC, -5))); a.Kind != DoReply {
t.Errorf("after a restart the station is fair game again: %+v", a)
}
}
func TestHaltWithNothingBeingCalledIsJustAHalt(t *testing.T) {
e := on()
if got := e.Halt(); got != "" {
t.Errorf("Halt returned %q with no target", got)
}
if e.Greylisted() != 0 {
t.Errorf("greylisted %d stations from an idle halt", e.Greylisted())
}
}
func TestTraceSaysWhyNobodyWasCalled(t *testing.T) {
var lines []string
e := on()
e.SetTrace(func(f string, args ...any) { lines = append(lines, fmt.Sprintf(f, args...)) })
worked := cq("A", NeedDXCC, -5, worked)
nothing := cq("B", NeedNone, -5)
busyOne := busy("C", NeedBand, -5)
e.Halt() // nothing held: no-op, keeps the set empty
e.OnPeriod(period(0, worked, nothing, busyOne))
if len(lines) == 0 {
t.Fatal("tracing on and nothing was written")
}
got := lines[len(lines)-1]
for _, want := range []string{"decodes=3", "callable=0", "worked=1", "nothing-needed=1", "busy=1"} {
if !strings.Contains(got, want) {
t.Errorf("trace %q does not say %q", got, want)
}
}
// And when it DOES call, the line names the candidates it ranked.
lines = nil
e.OnPeriod(period(2, cq("DX", NeedBand, -9, watched)))
if len(lines) == 0 || !strings.Contains(lines[0], "DX(watched band,-9 dB,r118)") {
t.Errorf("trace %v does not describe the station it called", lines)
}
}
func TestAStationCallingUsIsAnsweredEvenWithNothingToGain(t *testing.T) {
e := on()
// A QSO has just ended and two stations are calling us. Neither is worth
// anything to the log — and both are worth answering: they have heard us,
// they are waiting, and the contact is one over away.
weak := callsMe("WEAK", NeedNone, -18)
loud := callsMe("LOUD", NeedNone, -3)
a := e.OnPeriod(period(0, weak, loud))
if a.Kind != DoReply || a.Decode.Call != "LOUD" {
t.Fatalf("answered %+v, want the strongest of the stations calling us", a)
}
// A caller already worked on this band and mode is a duplicate, not a QSO.
e = on()
done := callsMe("DUPE", NeedNone, -1)
done.Worked = true
if a := e.OnPeriod(period(2, done)); a.Kind != DoNothing {
t.Errorf("answered a station already in the log on this band and mode: %+v", a)
}
// And one the operator halted stays halted, however politely it calls.
e = on()
e.OnPeriod(period(4, cq("STOP", NeedDXCC, -5)))
e.Halt()
if a := e.OnPeriod(period(6, callsMe("STOP", NeedDXCC, -5))); a.Kind != DoNothing {
t.Errorf("answered a station the operator had stopped: %+v", a)
}
// A needed station still comes first: a caller with nothing to gain is
// answered when nothing better is on the air, which is what was asked for.
e = on()
if a := e.OnPeriod(period(8, callsMe("CALLER", NeedNone, 0), cq("RARE", NeedDXCC, -20))); a.Decode.Call != "RARE" {
t.Errorf("picked %q, want the new entity ahead of a caller with nothing needed", a.Decode.Call)
}
}
// TestAStationWorkingSomebodyElseIsNotCHOSEN — the test above is about a target
// already being called; this is about picking one. A reply to a decode in
// mid-exchange is a reply WSJT-X and JTDX may refuse outright, so it never
// starts a call there.
func TestAStationWorkingSomebodyElseIsNotCHOSEN(t *testing.T) {
e := on()
busyNow := busy("ON7GB", NeedSlot, +5)
busyNow.Msg = "PY2SAD ON7GB JO21"
if a := e.OnPeriod(period(0, busyNow)); a.Kind == DoReply {
t.Fatalf("called %+v — it is in a QSO with PY2SAD", a)
}
if e.Target() != "" {
t.Errorf("target is %q", e.Target())
}
// Its final frame is different: one period from free is the best moment
// there is to be calling it.
last := busy("ON7GB", NeedSlot, +5)
last.Msg = "PY2SAD ON7GB RR73"
if a := e.OnPeriod(period(2, last)); a.Kind != DoReply {
t.Errorf("%+v — a station on its last frame was not called", a)
}
}
func TestAFinalFrameToSomebodyElseIsNotABusyTarget(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedSlot, 0)))
// Sending RR73 to another station: one period from being free, and the best
// moment there is to be calling it.
wrap := busy("DX", NeedSlot, 0)
wrap.Msg = "PY2SAD DX RR73"
if a := e.OnPeriod(period(2, wrap)); a.Kind != DoNothing || e.Target() != "DX" {
t.Errorf("dropped a station that was finishing: %+v (target %q)", a, e.Target())
}
}
func TestNothingHiddenByThePanelIsCalled(t *testing.T) {
e := New(Settings{Enabled: true, OnScreenOnly: true})
// The operator has filtered the list down to CQs: what is not on the screen
// is not called, whatever the log makes of it.
hidden := cq("RARE", NeedDXCC, 0)
hidden.Hidden = true
shown := cq("PLAIN", NeedSlot, -20)
if a := e.OnPeriod(period(0, hidden, shown)); a.Decode.Call != "PLAIN" {
t.Errorf("picked %q, want the station the panel is showing", a.Decode.Call)
}
// With the panel publishing nothing, nothing is hidden and the ladder
// decides alone.
e = New(Settings{Enabled: true, OnScreenOnly: true})
free := cq("RARE", NeedDXCC, 0)
if a := e.OnPeriod(period(2, free, shown)); a.Decode.Call != "RARE" {
t.Errorf("picked %q with no filtering in force, want the new entity", a.Decode.Call)
}
}
// MSHV answers two stations in one transmission and the decoder prints them as
// one line. Reported from the air: the DX was answering us in the second half
// and the engine, reading only the first, dropped it as busy.
func TestAMultiAnswerLineIsReadWhole(t *testing.T) {
fox := cq("HK0/PY8WW", NeedDXCC, -17, watched)
fox.CQ = false
fox.Msg = "YV5ALI RR73; " + me + " <HK0/PY8WW> -08"
if !callingUs(fox, me) {
t.Error("the second half is a report to us and was not read as one")
}
if !callable(fox, me) {
t.Error("a station answering us was judged uncallable")
}
// Held as the target, that line must not release it — it is the answer we
// were waiting for, not a station working somebody else.
e := on()
e.OnPeriod(period(0, cq("HK0/PY8WW", NeedDXCC, -17, watched)))
if a := e.OnPeriod(period(2, fox)); a.Kind != DoNothing || e.Target() != "HK0/PY8WW" {
t.Errorf("dropped the DX as it answered us: %+v (target %q)", a, e.Target())
}
// The exchange is under way, so the attempt counter has done its job.
if e.Status().Attempts != 0 {
t.Errorf("attempts = %d while in QSO, want 0", e.Status().Attempts)
}
// And its final frame to us, in the same shape, ends the QSO.
done := fox
done.Msg = "IK2ABC RR73; " + me + " <HK0/PY8WW> RR73"
if !finished([]Candidate{done}, "HK0/PY8WW", me) {
t.Error("a 73 to us inside a multi-answer line did not end the QSO")
}
}
// Reported from the air twice: the DX sends RR73, our own 73 is about to go
// out, and the engine answered somebody else in that very slot — two
// transmissions in one period, and the station at the other end never got the
// frame that closes the contact.
func TestTheSlotAfterAQSOBelongsToOur73(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("HP/WE9G", NeedDXCC, -6)))
bye := callsMe("HP/WE9G", NeedDXCC, -6)
bye.Msg = "<" + me + "> HP/WE9G RR73"
// A new band is decoding in the same period and is not called.
a := e.OnPeriod(period(2, bye, cq("GW8DX", NeedBand, +2)))
if a.Kind != DoNothing {
t.Fatalf("called %+v in the slot our 73 goes out in", a)
}
if !strings.Contains(a.Reason, "73") {
t.Errorf("reason %q does not say why the slot was left alone", a.Reason)
}
// It is still there fifteen seconds later, which is the whole cost.
if a := e.OnPeriod(period(4, cq("GW8DX", NeedBand, +2))); a.Kind != DoReply || a.Decode.Call != "GW8DX" {
t.Errorf("next period: %+v, want the new band called", a)
}
}
func TestABetterStationTakesOverBeforeAnybodyHasAnswered(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("PLAIN", NeedSlot, +10)))
// A watched station comes on the air: nothing has answered us yet, so the
// call in progress is worth less than the one now possible.
a := e.OnPeriod(period(2, cq("PLAIN", NeedSlot, +10), cq("WATCHED", NeedNone, -15, watched)))
if a.Kind != DoReply || a.Decode.Call != "WATCHED" {
t.Fatalf("%+v — a better station did not take over", a)
}
// Equal rungs do NOT take over while the station being called is on the air.
e = on()
e.OnPeriod(period(4, cq("A", NeedBand, 0)))
if a := e.OnPeriod(period(6, cq("A", NeedBand, 0), cq("B", NeedBand, +20))); a.Kind != DoNothing {
t.Errorf("%+v — swapped between two stations of equal value", a)
}
// But they do when it is absent: seven calls into the void while a station
// of the same value is calling CQ is what the operator was watching.
if a := e.OnPeriod(period(8, cq("B", NeedBand, +20))); a.Kind != DoReply || a.Decode.Call != "B" {
t.Errorf("%+v — kept calling a station that was not on the air", a)
}
// Once the station has answered, nothing takes its place.
e = on()
e.OnPeriod(period(10, cq("DX", NeedSlot, 0)))
e.OnPeriod(period(12, callsMe("DX", NeedSlot, 0)))
if a := e.OnPeriod(period(14, callsMe("DX", NeedSlot, 0), cq("RARE", NeedDXCC, 0, watched))); a.Kind != DoNothing {
t.Errorf("%+v — abandoned an exchange in progress", a)
}
}
// TestAnExchangeInProgressIsNeverAbandoned is the shack report: mid-QSO with
// V31MA — its report decoded, our RR73 going out — and a station of a higher
// rung called us from the other side of the screen. The engine switched.
func TestAnExchangeInProgressIsNeverAbandoned(t *testing.T) {
e := on()
// V31MA is worth nothing on the ladder: worked before, nothing needed.
if a := e.OnPeriod(period(0, callsMe("V31MA", NeedNone, -12))); a.Kind != DoReply {
t.Fatalf("%+v — a station calling us was not answered", a)
}
// Its report arrives in the same period a better station calls us. That
// period used to be judged before the reply was taken into account.
a := e.OnPeriod(period(2,
callsMe("V31MA", NeedNone, -12),
callsMe("F5NNN", NeedSlot, -10)))
if a.Kind != DoNothing {
t.Fatalf("%+v — left V31MA mid-exchange", a)
}
if e.target == nil || e.target.Call != "V31MA" {
t.Fatalf("target is %v — the QSO in progress lost its place", e.target)
}
// Our own transmission settles it too: a report is not an opening call, so
// even a QSO the operator started by hand is protected.
e = on()
e.OnPeriod(period(4, cq("V31MA", NeedNone, -12, watched)))
e.NoteTX(TXState{Transmitting: true, Msg: "V31MA F4BPO RR73"})
if !e.answered {
t.Error("sending a report did not count as being inside the exchange")
}
if a := e.OnPeriod(period(6, cq("V31MA", NeedNone, -12, watched), callsMe("F5NNN", NeedDXCC, -10))); a.Kind != DoNothing {
t.Errorf("%+v — abandoned a QSO we were in the middle of", a)
}
if e.attempts != 0 {
t.Errorf("attempts=%d — an exchange frame was counted as a call", e.attempts)
}
}
// TestAPileupIsWorkedByCallingThroughIt is the shack report: D44TWO finishing a
// contact, the call started, the DX coming back to somebody else — and the
// engine giving up on a station that was one period from being free.
func TestAPileupIsWorkedByCallingThroughIt(t *testing.T) {
e := on()
if a := e.OnPeriod(period(0, cq("D44TWO", NeedDXCC, -7))); a.Kind != DoReply {
t.Fatalf("%+v", a)
}
// It answers three other callers in a row — which is what a DX with a queue
// does, and calling through it is how the queue is joined.
does := func(n int) Action { return e.OnPeriod(period(n, busy("D44TWO", NeedDXCC, -7))) }
for _, n := range []int{2, 4, 6} {
if a := does(n); a.Kind != DoNothing {
t.Fatalf("%+v — stopped calling a station working the pileup", a)
}
if e.Target() != "D44TWO" {
t.Fatalf("target is %q — the station was released while it worked others", e.Target())
}
}
// And when it comes to us, the exchange starts as usual.
if a := e.OnPeriod(period(8, callsMe("D44TWO", NeedDXCC, -7))); a.Kind != DoNothing {
t.Errorf("%+v", a)
}
if !e.answered {
t.Error("the reply was not taken as the start of the exchange")
}
// A BETTER station may still take the slot while it is busy: nothing has
// been answered, so nothing is lost by moving.
e = on()
e.OnPeriod(period(10, cq("D44TWO", NeedSlot, -7)))
if a := e.OnPeriod(period(12, busy("D44TWO", NeedSlot, -7), cq("RARE", NeedDXCC, -20, watched))); a.Kind != DoReply || a.Decode.Call != "RARE" {
t.Errorf("%+v — a watched new one did not take the slot from a busy station", a)
}
}
// TestTheFirstCallStartsWithNoMisses is the shack report: a CQ answered for the
// first time, and the toolbar already reading two misses out of three.
func TestTheFirstCallStartsWithNoMisses(t *testing.T) {
e := on()
// It calls CQ on an odd slot; we answer.
if a := e.OnPeriod(period(1, cq("ER1CW", NeedBand, -2))); a.Kind != DoReply {
t.Fatalf("%+v", a)
}
// The next period is OURS: we are transmitting to it, and of course it is
// not decoded. That is not a miss.
pp := period(2)
pp.TX = TXState{Transmitting: true}
e.OnPeriod(pp)
if e.misses != 0 {
t.Fatalf("misses=%d after our own transmit period", e.misses)
}
// Nor is a period of the wrong parity with nothing in it.
e.OnPeriod(period(4))
if e.misses != 0 {
t.Fatalf("misses=%d — counted a period the station never transmits in", e.misses)
}
// ITS period, and it is not there: that is a miss.
e.OnPeriod(period(3))
if e.misses != 1 {
t.Fatalf("misses=%d — the station's own silent period was not counted", e.misses)
}
}
// Parking answers "it will not answer, stop wasting the evening on it" — which
// is a verdict about a station the LOG picked out, not about one the OPERATOR
// did. From the air: a watched ZD8GB, six streams a period, refused for the
// rest of the session after two series of unanswered calls.
func TestAWatchedCallsignIsNeverParked(t *testing.T) {
spent := func(c Candidate) *Engine {
e := New(Settings{Enabled: true, MaxRounds: 2})
e.OnPeriod(period(0)) // the engine's clock, so the rest below is period time
for i := 0; i < 2; i++ {
e.giveUp(strings.ToUpper(c.Call), c) // a series ended by a brake
}
return e
}
// Period 8, well past the rest that follows a series: what is left is the
// parking, and only the parking.
plain := cq("PLAIN", NeedDXCC, 0)
if a := spent(plain).OnPeriod(period(8, plain)); a.Kind == DoReply {
t.Errorf("%+v — a station whose series are spent was called again", a)
}
dx := cq("ZD8GB", NeedDXCC, 0, watched)
if a := spent(dx).OnPeriod(period(8, dx)); a.Kind != DoReply {
t.Errorf("%+v — a watched DXpedition was parked for the session", a)
}
}
// A period is judged more than once — the decodes arrive in a burst and
// stragglers follow — and a later judgement of it holds a partial view, not
// evidence that the station has gone. From the air: D2ACE answered in the very
// period the counter then read as a miss.
func TestAPeriodTheTargetWasSeenInIsNeverAMiss(t *testing.T) {
e := on()
if a := e.OnPeriod(period(1, cq("D2ACE", NeedBand, 13))); a.Kind != DoReply {
t.Fatalf("%+v", a)
}
// Its own next period: decoded, so the exchange is under way.
p := period(3, callsMe("D2ACE", NeedBand, 13))
e.OnPeriod(p)
if e.Status().Misses != 0 {
t.Fatalf("misses=%d after being decoded", e.Status().Misses)
}
// The same period again, this time from a flush that carries only the
// stragglers — the target is not among them.
e.OnPeriod(period(3))
if got := e.Status().Misses; got != 0 {
t.Errorf("misses=%d — a second flush of a period it was seen in counted against it", got)
}
}
+144 -2
View File
@@ -9,6 +9,7 @@ package cat
import (
"fmt"
"runtime"
"strings"
"sync"
"time"
)
@@ -240,15 +241,81 @@ func (m *Manager) freqOffsetHz() int64 {
// display trick: the readout would say 144 and every spot click, band change and
// memory recall would send the rig somewhere 116 MHz away.
func (m *Manager) SetFrequency(hz int64) error {
real := hz
if off := m.freqOffsetHz(); off != 0 && hz > off {
hz -= off
}
return m.exec(func(b Backend) error { return b.SetFrequency(hz) })
err := m.exec(func(b Backend) error { return b.SetFrequency(hz) })
if err == nil {
m.noteCommandedFreq(real)
}
return err
}
// noteCommandedFreq publishes a frequency the radio has just acknowledged,
// without waiting for the next poll to come round and read it back.
//
// The wait is what this is about. A rigctl client — WSJT-X above all — sets a
// frequency and then READS it back before it believes it is there, and until
// then it will not decode, transmit or even update its own dial. Everything
// answering "f" here comes from the last poll, so the answer was the OLD
// frequency for as long as a poll cycle takes; on a rig reached over the
// internet, where one cycle is many round trips, a band change from WSJT-X took
// ten seconds to be believed while the radio itself had moved instantly.
//
// Only when NOT split. In split the two frequencies mean different VFOs and a
// guess about which one just moved is how a client ends up writing the transmit
// frequency onto the dial — the poll is left to settle that case.
func (m *Manager) noteCommandedFreq(hz int64) {
if hz <= 0 {
return
}
m.mu.Lock()
st := m.state
if !st.Connected || st.Split || st.FreqHz == hz {
m.mu.Unlock()
return
}
st.FreqHz = hz
st.Band = BandFromHz(hz)
st.UpdatedAt = time.Now()
m.state = st
m.mu.Unlock()
m.emitState()
}
// SetMode dispatches a SetMode call to the CAT goroutine.
func (m *Manager) SetMode(mode string) error {
return m.exec(func(b Backend) error { return b.SetMode(mode) })
err := m.exec(func(b Backend) error { return b.SetMode(mode) })
if err == nil {
m.noteCommandedMode(mode)
}
return err
}
// noteCommandedMode is the mode half of noteCommandedFreq, and exists for the
// same client readback.
//
// "DATA" is deliberately not published. A backend reports data mode under the
// operator's own digital mode (FT8, JS8, RTTY…), and that name is what a QSO is
// logged with — a plain "DATA" standing in for a poll cycle is a mode nobody
// works, in a field that ends up in an ADIF file. The poll is a fraction of a
// second away and knows the real name.
func (m *Manager) noteCommandedMode(mode string) {
if mode == "" || strings.EqualFold(mode, "DATA") {
return
}
m.mu.Lock()
st := m.state
if !st.Connected || st.Mode == mode {
m.mu.Unlock()
return
}
st.Mode = mode
st.UpdatedAt = time.Now()
m.state = st
m.mu.Unlock()
m.emitState()
}
// SetPTT dispatches a transmit on/off request to the CAT goroutine.
@@ -256,6 +323,33 @@ func (m *Manager) SetPTT(on bool) error {
return m.exec(func(b Backend) error { return b.SetPTT(on) })
}
// dataPTTSetter is implemented by a backend that can key the DATA input rather
// than the microphone. A Kenwood TS-590 has two transmit commands and takes its
// audio from a different socket for each: TX (or TX0) opens the front mic, TX1
// the rear ACC2/USB. Send the wrong one and the radio transmits in silence,
// because the audio arriving on USB is simply not the input it is listening to.
type dataPTTSetter interface {
SetPTTData(on bool) error
}
// SetPTTSource keys the transmitter, saying WHERE the audio is coming from.
//
// data=true means "the audio reaches the radio on its data/USB input" — what a
// voice keyer playing through the rig's own sound card needs. A backend that
// draws no distinction (every rig where one PTT is all there is) falls back to
// the ordinary key, so nothing changes for it.
func (m *Manager) SetPTTSource(on, data bool) error {
if !data {
return m.SetPTT(on)
}
return m.exec(func(b Backend) error {
if d, ok := b.(dataPTTSetter); ok {
return d.SetPTTData(on)
}
return b.SetPTT(on)
})
}
// splitSetter is implemented by the backends that can arm split AND place the
// transmit frequency. Both together: arming without setting the dial transmits
// on whatever the transmit VFO happened to hold, which is worse than refusing.
@@ -677,6 +771,10 @@ type IcomController interface {
SetVOXGain(int) error
SetAntiVOX(int) error
SetPower(bool) error // turn the transceiver on/off (manual — never auto on connect)
// RecallBandStack moves the VFO to what the radio's own band stacking
// register holds — the operator's last frequency and mode on that band.
// Returns the frequency landed on.
RecallBandStack(band, reg int) (int64, error)
}
// ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's
@@ -734,6 +832,50 @@ func (m *Manager) IcomDo(fn func(IcomController) error) error {
})
}
// SatTuner is a backend that can be put on a satellite: a receiver on one band
// and a transmitter on another, both moving under Doppler, at the same time.
//
// It is a separate interface from the per-manufacturer ones because what a
// satellite needs is not a manufacturer's feature — it is a shape of operating
// that a FlexRadio and an IC-9700 both provide and reach in completely
// different ways. A backend that cannot do it simply does not implement this,
// and the caller falls back to tuning the downlink alone rather than pretending.
type SatTuner interface {
// SetSatellite arms or disarms satellite operation: the rig's own satellite
// mode where it has one, two slices where it has those. Disarming must leave
// the radio somewhere an operator can work from, not half-configured.
SetSatellite(on bool) error
// TuneSatellite points the receiver at downHz and the transmitter at upHz,
// both already Doppler-corrected. Modes are ADIF names ("SSB", "FM", "CW");
// an empty one leaves that side's mode alone.
TuneSatellite(downHz, upHz int64, downMode, upMode string) error
// SatReceiveHz is where the receiver actually is. The operator tunes it to
// follow a station across a linear transponder, and that dial movement is
// the input the whole tracker works from — without reading it back, a
// tracker fights the operator instead of helping them.
SatReceiveHz() (int64, error)
}
// SatCapable reports whether the active backend can hold a satellite pair.
func (m *Manager) SatCapable() bool {
m.mu.RLock()
b := m.backend
m.mu.RUnlock()
_, ok := b.(SatTuner)
return ok
}
// SatDo dispatches a satellite control onto the CAT goroutine.
func (m *Manager) SatDo(fn func(SatTuner) error) error {
return m.exec(func(b Backend) error {
st, ok := b.(SatTuner)
if !ok {
return fmt.Errorf("this radio cannot hold a satellite pair from OpsLog")
}
return fn(st)
})
}
// exec marshals a backend operation onto the CAT goroutine. Returns the
// operation's error or a "busy"/"not running" error if dispatch failed.
func (m *Manager) exec(fn func(Backend) error) error {
+62
View File
@@ -49,6 +49,11 @@ const (
CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off)
CmdRIT = 0x21 // RIT/ΔTX: sub 0x00 offset freq, 0x01 RIT on/off, 0x02 ΔTX(XIT) on/off
CmdSendCW = 0x17 // send a CW message (ASCII, ≤30 chars) via the rig's keyer; data 0xFF = stop
// CmdVFO selects which receiver subsequent commands address. On the two-band
// satellite rigs (IC-9700, IC-9100) the MAIN band is the downlink and the SUB
// band the uplink, so every satellite frequency set is "point at a band, then
// tune it".
CmdVFO = 0x07
SubLevelKeySpeed = 0x0C // CmdLevel: CW keying speed (0-255 → KeyMinWPM..KeyMaxWPM)
@@ -112,6 +117,17 @@ const (
SubSwBreakIn = 0x47 // CW break-in: 0=OFF, 1=SEMI, 2=FULL (needed so 0x17 CW keys TX)
SubSwMN = 0x48 // manual notch on/off
SubSwAPF = 0x32 // audio peak filter on/off (CW only)
// Satellite mode (IC-9700 / IC-9100). The rig's OWN satellite mode, not an
// imitation of one: it pairs main and sub, gives full duplex, and keeps the
// two dials linked the way the radio's designers meant. Asking it to do that
// is always better than building the same thing out of split.
SubSwSatellite = 0x5A
// CmdVFO sub-commands: which of a two-receiver rig's bands the next command
// addresses.
SubVFOMain = 0xD0 // MAIN band — the downlink in satellite mode
SubVFOSub = 0xD1 // SUB band — the uplink
SubVFOExchange = 0xB0 // swap main and sub
)
// CW break-in modes (CmdSwitch 0x47).
@@ -425,3 +441,49 @@ func indexPreamble(buf []byte, from int) int {
}
return -1
}
// ── Band stacking registers (CI-V 0x1A sub 0x01) ──────────────────────────
//
// Every modern Icom remembers the last few frequency/mode pairs used on each
// band, and its front-panel band key walks through them. That is why pressing
// [14] on the radio lands on the FT8 watering hole rather than on a number
// somebody chose in software: the register is the operator's OWN last visit.
//
// The frame is a READ — it asks the rig what a register holds and changes
// nothing — so a rig that does not know the command answers NG and the caller
// is exactly where it started.
//
// → 1A 01 <band> <reg>
// ← 1A 01 <band> <reg> <freq 5 BCD, LE> <mode> <filter> <data mode> …
//
// Anything past the data-mode byte (duplex, tone, DV squelch on the VHF rigs)
// is not read here: the question is where the operator last was, and the answer
// to that is the frequency and the mode.
const SubBandStack = 0x01
// BandStack is one register's contents.
type BandStack struct {
FreqHz int64
Mode byte
Data bool // the data-mode flag that goes with Mode
}
// DecodeBandStack reads the payload of a 0x1A 0x01 reply, i.e. everything after
// the command byte. ok is false for a frame that is not the register asked for,
// which is what a desynchronised read looks like.
func DecodeBandStack(data []byte, band, reg byte) (BandStack, bool) {
if len(data) < 9 || data[0] != SubBandStack || data[1] != band || data[2] != reg {
return BandStack{}, false
}
hz, ok := BCDToFreq(data[3:8])
if !ok || hz <= 0 {
return BandStack{}, false
}
bs := BandStack{FreqHz: hz, Mode: data[8]}
// Filter then data mode. A rig that stops at the filter byte is not an
// error — it is a register without a data flag, so the flag stays false.
if len(data) >= 11 {
bs.Data = data[10] != 0
}
return bs, true
}
+33
View File
@@ -204,3 +204,36 @@ func TestBCDToFreqRejectsNonDecimal(t *testing.T) {
t.Error("a short but valid BCD frame must still decode")
}
}
// A band stacking register reply, byte for byte as the rigs send it:
// 1A 01 <band> <reg> <freq 5 LE-BCD> <mode> <filter> <data>. The payload here
// is everything after the command byte, which is what Decoded.Data holds.
func TestDecodeBandStack(t *testing.T) {
// 14.074.000 MHz, USB, FIL1, data mode on — the FT8 stack on 20 m.
frame := []byte{0x01, 0x05, 0x03, 0x00, 0x40, 0x07, 0x14, 0x00, ModeUSB, 0x01, 0x01}
bs, ok := DecodeBandStack(frame, 0x05, 0x03)
if !ok {
t.Fatal("a well-formed register was rejected")
}
if bs.FreqHz != 14_074_000 {
t.Errorf("freq = %d, want 14074000", bs.FreqHz)
}
if bs.Mode != ModeUSB || !bs.Data {
t.Errorf("mode = 0x%02X data = %v, want USB + data", bs.Mode, bs.Data)
}
// The register ASKED FOR is part of the answer: a reply about another one is
// a desynchronised read, not a frequency to send the radio to.
if _, ok := DecodeBandStack(frame, 0x05, 0x01); ok {
t.Error("a reply for register 3 was accepted as register 1")
}
if _, ok := DecodeBandStack(frame, 0x03, 0x03); ok {
t.Error("a reply about 40 m was accepted as 20 m")
}
// A register without the data-mode byte is a shorter frame, not a bad one.
if bs, ok := DecodeBandStack(frame[:10], 0x05, 0x03); !ok || bs.FreqHz != 14_074_000 || bs.Data {
t.Errorf("short register = %+v ok=%v, want the frequency with no data flag", bs, ok)
}
if _, ok := DecodeBandStack([]byte{0x01, 0x05, 0x03}, 0x05, 0x03); ok {
t.Error("a truncated frame was accepted")
}
}
+20 -1
View File
@@ -67,6 +67,14 @@ type Flex struct {
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
pendingSpotMode map[int]string // seq → ADIF mode, paired with pendingSpot
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
pendingSat map[int]string // seq → "rx"/"tx", awaiting a satellite slice's index
// Satellite pair: slice A is the downlink, slice B the uplink. -1 when not
// armed. satCreatedTX marks an uplink slice OpsLog opened, and is the only
// one it will close again.
satOn bool
satRX int
satTX int
satCreatedTX bool
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
spotFreq map[int]int64 // spot index → Hz, so a click can report where it was (the trigger message carries only the index)
@@ -227,7 +235,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
return &Flex{
host: strings.TrimSpace(host), port: port,
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotFreq: map[int]int64{}, pendingSpotFreq: map[int]int64{}, panWindow: map[string]panView{}, spotSig: map[string]string{}, spotSent: map[string]time.Time{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotFreq: map[int]int64{}, pendingSpotFreq: map[int]int64{}, panWindow: map[string]panView{}, spotSig: map[string]string{}, spotSent: map[string]time.Time{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{}, pendingSat: map[int]string{}, satRX: -1, satTX: -1,
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
pinnedSlice: -1,
@@ -458,12 +466,23 @@ func (f *Flex) reader(conn net.Conn) {
if splitSeq {
delete(f.pendingSplit, seq)
}
// The same reply carries the index of a slice created for a satellite
// pair; which of the two it is was recorded when it was asked for.
satRole := f.pendingSat[seq]
if satRole != "" {
delete(f.pendingSat, seq)
}
f.mu.Unlock()
if splitSeq && ok && len(parts) >= 3 {
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
f.send(fmt.Sprintf("slice s %d tx=1", idx))
}
}
if satRole != "" && ok && len(parts) >= 3 {
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
f.adoptSatSlice(satRole, idx)
}
}
}
}
// Connection ended.
+202
View File
@@ -0,0 +1,202 @@
package cat
import (
"fmt"
"strings"
"hamlog/internal/applog"
)
// Satellite operation on a FlexRadio.
//
// A Flex has no satellite mode, and does not need one: it has slices. Slice A
// is the downlink and slice B the uplink — the arrangement every Flex satellite
// operator already uses by hand — with the transmitter on B and full duplex on,
// so the operator hears their own signal come back through the transponder.
// The transverters that put 145 and 435 MHz within the radio's reach are
// configured in SmartSDR, and their offsets are the radio's business: OpsLog
// sends the real satellite frequency and SmartSDR does the arithmetic.
//
// The two slices are CREATED when they are missing, because "slice B does not
// exist" is not a thing to make the operator fix at the start of a ten-minute
// pass. Only what OpsLog created is taken away again on disarming: a slice the
// operator opened is theirs.
// SetSatellite arranges (or unwinds) the two-slice satellite pair.
func (f *Flex) SetSatellite(on bool) error {
f.mu.Lock()
connected := f.conn != nil
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
if !on {
return f.satDisarm()
}
// The downlink slice is the one the operator is already on: taking the
// active slice rather than insisting on index 0 means arming the satellite
// does not move them off the receiver they were listening to.
f.mu.Lock()
rxIdx, _ := f.mainSliceLocked()
var txIdx = -1
for _, idx := range f.sortedSliceIdxLocked() {
if s := f.slices[idx]; s != nil && s.inUse && idx != rxIdx {
txIdx = idx
break
}
}
f.satRX, f.satTX = rxIdx, txIdx
f.satOn = true
f.mu.Unlock()
// Full duplex before anything else: without it the radio mutes the receiver
// on transmit, and an operator who cannot hear their own downlink has no way
// to know they are in the passband at all.
f.send("radio set full_duplex_enabled=1")
if rxIdx < 0 {
// A radio with no slice at all. One is created; the status that comes
// back adopts it as the downlink.
f.satCreate("rx", 145.900, "USB")
}
if txIdx < 0 {
f.satCreate("tx", 435.100, "USB")
} else {
f.send(fmt.Sprintf("slice s %d tx=1", txIdx))
}
applog.Printf("flex: satellite armed (rx slice %d, tx slice %d)", rxIdx, txIdx)
return nil
}
func (f *Flex) satDisarm() error {
f.mu.Lock()
rx, tx, created := f.satRX, f.satTX, f.satCreatedTX
f.satOn, f.satRX, f.satTX, f.satCreatedTX = false, -1, -1, false
f.mu.Unlock()
f.send("radio set full_duplex_enabled=0")
if created && tx >= 0 {
f.send(fmt.Sprintf("slice remove %d", tx))
}
// Transmit goes back where the operator is listening. A radio left
// transmitting on a slice that no longer exists — or on the uplink band with
// the satellite gone — is not somewhere anyone should be handed back.
if rx >= 0 {
f.send(fmt.Sprintf("slice s %d tx=1", rx))
}
applog.Printf("flex: satellite disarmed")
return nil
}
// satCreate asks for a slice and remembers what it is for; the index arrives in
// the reply (see the R-line handler), which is where the role is applied.
func (f *Flex) satCreate(role string, freqMHz float64, mode string) {
seq := f.send(fmt.Sprintf("slice create freq=%.6f mode=%s", freqMHz, mode))
if seq <= 0 {
return
}
f.mu.Lock()
if f.pendingSat == nil {
f.pendingSat = map[int]string{}
}
f.pendingSat[seq] = role
f.mu.Unlock()
}
// adoptSatSlice records a freshly created slice in its role. Called from the
// reply handler with the index the radio assigned.
func (f *Flex) adoptSatSlice(role string, idx int) {
f.mu.Lock()
switch role {
case "rx":
f.satRX = idx
case "tx":
f.satTX = idx
f.satCreatedTX = true
}
f.mu.Unlock()
if role == "tx" {
f.send(fmt.Sprintf("slice s %d tx=1", idx))
}
applog.Printf("flex: satellite %s slice is %d", role, idx)
}
// TuneSatellite moves the two slices.
func (f *Flex) TuneSatellite(downHz, upHz int64, downMode, upMode string) error {
f.mu.Lock()
rx, tx := f.satRX, f.satTX
connected := f.conn != nil
if rx >= 0 && f.slices[rx] != nil && downHz > 0 {
f.slices[rx].freqHz = downHz // optimistic, as SetFrequency is
}
if tx >= 0 && f.slices[tx] != nil && upHz > 0 {
f.slices[tx].freqHz = upHz
}
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
if rx < 0 {
// The slice was asked for and its index has not come back yet. Nothing is
// wrong — the next Doppler step, a second later, will find it.
return nil
}
if downHz > 0 {
f.send(fmt.Sprintf("slice t %d %.6f", rx, float64(downHz)/1e6))
f.satMode(rx, downMode, downHz)
}
if tx >= 0 && upHz > 0 {
f.send(fmt.Sprintf("slice t %d %.6f", tx, float64(upHz)/1e6))
f.satMode(tx, upMode, upHz)
}
return nil
}
// satMode sets a slice's mode only when it is not already there. A mode command
// on every Doppler step is a command a second per slice for a whole pass, and
// SmartSDR redraws the filter each time.
func (f *Flex) satMode(idx int, mode string, freqHz int64) {
mode = strings.TrimSpace(mode)
if mode == "" {
return
}
// USB on both sides above 30 MHz, which is every satellite worth the name —
// including the parts of a passband that would be an LSB band down on HF.
if strings.EqualFold(mode, "SSB") && freqHz > 30_000_000 {
mode = "USB"
}
fm := adifModeToFlex(mode, freqHz)
if fm == "" {
return
}
f.mu.Lock()
s := f.slices[idx]
same := s != nil && strings.EqualFold(s.mode, fm)
if s != nil {
s.mode = fm
}
f.mu.Unlock()
if same {
return
}
f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm))
}
// SatReceiveHz is where the downlink slice sits.
//
// From the cache, not from a read: SmartSDR pushes every slice change as it
// happens, so the cached value is what the radio said, and there is no round
// trip to pay for once a second.
func (f *Flex) SatReceiveHz() (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.satRX < 0 {
return 0, fmt.Errorf("flex: no downlink slice")
}
s := f.slices[f.satRX]
if s == nil || !s.inUse {
return 0, fmt.Errorf("flex: the downlink slice has gone")
}
return s.freqHz, nil
}
+30
View File
@@ -60,3 +60,33 @@ func TestIcomSilenceBackoff(t *testing.T) {
t.Errorf("backoff overshot the ceiling: %s", g)
}
}
// A new session must not be judged on the previous one's silence.
//
// From an operator's log: a rig switched to standby, then a dial-and-drop loop
// every forty seconds for as long as it was left there. lastGoodAt bounds "the
// link answers but no CI-V comes back"; it belongs to a session, and it was
// never cleared when a new one opened, so every fresh session started already
// past the grace — and the Icom console, where the power-ON button lives,
// blinked away on every pass.
func TestAFreshSessionStartsWithACleanSilenceClock(t *testing.T) {
b := &IcomSerial{
lastGoodAt: time.Now().Add(-30 * time.Minute), // a session from before standby
readFails: 9,
silentGrace: icomSilentGraceMax,
}
// What Connect does once the transport is open, before anything is sent.
b.lastGoodAt = time.Time{}
b.readFails = 0
b.silentGrace = icomSilentGrace
if !b.lastGoodAt.IsZero() {
t.Fatal("the previous session's last good read survived into this one")
}
tolerate := func(lastGood time.Time, silentFor, grace time.Duration) bool {
return lastGood.IsZero() || silentFor < grace
}
if !tolerate(b.lastGoodAt, time.Hour, b.silentGrace) {
t.Error("a session silent since connect was torn down — that is a rig in standby, and where the ON button has to work")
}
}
+15 -1
View File
@@ -67,12 +67,26 @@ type icomAudio struct {
txOuter uint16
txSend uint16
lastRx atomic.Int64 // UnixNano of last packet (liveness)
rxCount atomic.Int64 // packets delivered on this stream
done chan struct{}
closeOnce sync.Once
}
func (a *icomAudio) markRx() { a.lastRx.Store(time.Now().UnixNano()) }
func (a *icomAudio) markRx() {
a.lastRx.Store(time.Now().UnixNano())
a.rxCount.Add(1)
}
// packets reports whether the stream is actually delivering — the difference
// between "audio is on" and "audio is arriving", which is what makes it worth
// naming as a suspect when CI-V has gone quiet on the same session.
func (a *icomAudio) packets() int64 {
if a == nil {
return 0
}
return a.rxCount.Load()
}
// Close tears the audio stream down (disconnect a few times; UDP is lossy).
func (a *icomAudio) Close() {
+79 -4
View File
@@ -43,7 +43,7 @@ var icnBE = binary.BigEndian
// = CI-V only (the proven default). The audio stream is fully separate from CAT,
// so enabling it can't affect freq/mode/DSP control.
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string, audioSink func([]byte)) *IcomSerial {
if civAddr <= 0 || civAddr > 0xFF {
if civAddr < 0 || civAddr > 0xFF {
civAddr = 0x98 // IC-7610
}
if digitalDefault == "" {
@@ -132,6 +132,13 @@ type icomNet struct {
// loop, not the rig — and RS-BA1 showing no such dropouts points that way.
txCiv atomic.Uint32
txAtData atomic.Uint32
// WHAT was asked, not just how much. A rig that answers at connect and then
// never again has usually been sent something it does not like, and counting
// the unanswered commands says nothing about which one that was. The last few
// command headers are kept so the silence report can name them — the only way
// to find a poison command on a radio nobody here has.
cmdMu sync.Mutex
lastCmd []string
rx chan []byte // CI-V byte chunks from civPump → Read (control replies)
scopeRx chan []byte // scope (0x27) frames, kept off rx so the panadapter
@@ -268,6 +275,7 @@ func (n *icomNet) Write(p []byte) (int, error) {
n.vCivSeq++
n.seqMu.Unlock()
n.txCiv.Add(1)
n.noteCmd(p)
pkt := icnCivData(seq, n.vID, n.vRemote, civSeq, p)
n.sentMu.Lock()
n.sentBuf[seq] = pkt
@@ -280,6 +288,35 @@ func (n *icomNet) Write(p []byte) (int, error) {
return len(p), nil
}
// noteCmd remembers the command bytes of a CI-V frame — everything after the
// preamble and the two addresses, up to four bytes, which is command,
// sub-command and the first of the data.
func (n *icomNet) noteCmd(p []byte) {
if len(p) < 5 {
return
}
body := p[4:]
if len(body) > 4 {
body = body[:4]
}
n.cmdMu.Lock()
n.lastCmd = append(n.lastCmd, fmt.Sprintf("% X", body))
if len(n.lastCmd) > 8 {
n.lastCmd = n.lastCmd[len(n.lastCmd)-8:]
}
n.cmdMu.Unlock()
}
// recentCmds is what noteCmd collected, oldest first.
func (n *icomNet) recentCmds() string {
n.cmdMu.Lock()
defer n.cmdMu.Unlock()
if len(n.lastCmd) == 0 {
return "none"
}
return strings.Join(n.lastCmd, " | ")
}
// icnTrace toggles verbose per-frame CI-V request/reply logging for diagnosing
// the network transport. Off by default (the connect-step logs stay); flip to
// true to trace every TX/RX again.
@@ -496,6 +533,18 @@ func (n *icomNet) civPump() {
}
debugLog.Printf("icom net: no CI-V DATA for 10 s (transport last heard %s ago; last scope frame %s ago; last socket error: %v; missing-seq backlog: %d; CI-V commands SENT since the last answer: %d)",
time.Since(lastPkt).Round(time.Second), scopeAge, lastErr, len(n.rxMissing), n.txCiv.Load()-n.txAtData.Load())
debugLog.Printf("icom net: the last CI-V commands sent, oldest first: %s", n.recentCmds())
// THE AUDIO STREAM IS THE FIRST SUSPECT, AND ONLY THE LOG CAN SAY SO.
//
// The RX audio stream is experimental and shares the rig's session with
// CI-V. The shape seen in the field is exactly this one: audio packets
// arriving by the hundred while not one CI-V reply comes back, the
// watchdog tearing the session down, and the whole thing beginning
// again — a loop an operator reads as "the Icom keeps disconnecting",
// with nothing pointing at the switch that would end it.
if n.audio != nil && n.audio.packets() > 0 {
debugLog.Printf("icom net: the RX audio stream is running and still delivering while CI-V has gone quiet — that option is experimental and shares this session. If the drops continue, turn OFF \"Stream RX audio over the network\" in Settings → CAT and see whether control steadies.")
}
// And try the gentle repair before the 30 s watchdog tears the whole
// session down: if the rig quietly closed the CI-V data flow (the
// transport is still chatting, so the session itself stands), saying
@@ -808,20 +857,46 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan
n.vTracked++
n.vCivSeq++
go n.ctrlPump()
go n.civPump()
// Optional RX audio stream (50003). The rig was told (conninfo rxEnable=1) to
// stream audio; open the socket + handshake now. A failure here is NON-fatal:
// CAT works without audio, so we log and continue rather than tear down a
// perfectly good control/CI-V session.
//
// BEFORE the pumps start, because the conninfo below touches the control-
// stream auth state, and after ctrlPump is running that state belongs to it.
if wantAudio {
if a, err := dialIcomAudio(host, audioSink, cancel); err != nil {
debugLog.Printf("icom net: audio stream FAILED (CAT unaffected): %v", err)
} else {
n.audio = a
// AND THE CONNINFO AGAIN, NOW THAT SOMEBODY IS LISTENING ON 50003.
//
// The first one goes out during the login, before this socket exists —
// it has to, since it is what authorises the stream. So the rig is told
// to send audio to a port nothing is bound to yet, and what comes back
// is an ICMP port-unreachable; it then backs off, and the audio appears
// only when its own retry timer comes round. An operator timed that at
// twenty to thirty seconds of silence after switching the speakers on,
// and one log here shows a minute and a half.
//
// Re-sent once the port is open, so the rig starts streaming into a
// socket that is ready for it. Idempotent — the same message the session
// already carries, which is why RS-BA1 repeats it too.
pkt := icnConnInfo(n.cTracked, n.cAuthSeq, n.cTokReq, n.cID, n.cRemote, n.cToken, user, rigMAC, 50002, 50003, 0x01)
n.cSentBuf[n.cTracked] = pkt
n.cTracked++
n.cAuthSeq++
if _, err := ctrl.Write(pkt); err != nil {
debugLog.Printf("icom net: could not re-send the conninfo after opening the audio port: %v", err)
} else {
debugLog.Printf("icom net: conninfo re-sent now that :50003 is listening — the rig can start the audio at once")
}
}
}
go n.ctrlPump()
go n.civPump()
return n, nil
}
+181
View File
@@ -0,0 +1,181 @@
package cat
import (
"errors"
"fmt"
"strings"
"hamlog/internal/applog"
"hamlog/internal/cat/civ"
)
// Satellite operation on an Icom.
//
// Two rigs in the range have a satellite mode of their own — the IC-9700 and
// the IC-9100 — and on those the right thing to do is ask the radio for it
// rather than build an imitation out of split. Their satellite mode pairs the
// MAIN band (the downlink) with the SUB band (the uplink), gives full duplex,
// and keeps the two dials linked the way the designers meant. Every other Icom
// has one receiver on one band: it can be tuned to the downlink, and that is
// the whole truth about what it can do on a cross-band satellite.
//
// UNTESTED ON HARDWARE. Built from the IC-9700 CI-V reference: 0x16 0x5A arms
// satellite mode, 0x07 0xD0 / 0xD1 select MAIN and SUB, and once a band is
// selected the ordinary 0x05 / 0x06 tune it. If an IC-9700 owner reports it
// misbehaving, the log lines below name every frame sent.
// ErrSatUplinkUnreachable says the downlink was tuned and the uplink was not,
// because the radio has no second receiver and the two are on different bands.
//
// A distinct error rather than a silent half-success: a tracker that quietly
// stops transmitting where the operator expects it to is worse than one that
// says it cannot. The caller reports it once, not once per Doppler step.
var ErrSatUplinkUnreachable = errors.New("cat: this radio has one receiver — the uplink is on another band and cannot be set")
// SetSatellite arms the rig's own satellite mode.
func (b *IcomSerial) SetSatellite(on bool) error {
if !b.satNative {
// Nothing to arm and nothing to break: the tuning path below does what
// this radio can do without any mode change. Refusing here would deny an
// operator the downlink, which is most of the value on a receive-heavy
// pass.
b.satOn = on
return nil
}
if err := b.exec(civ.CmdSwitch, civ.SubSwSatellite, boolByte(on)); err != nil {
return fmt.Errorf("icom: satellite mode %v refused: %w", on, err)
}
b.satOn = on
applog.Printf("icom: satellite mode %v (%s)", on, b.model)
if on {
// Leave the radio pointing at MAIN. Everything else in OpsLog — the poll
// loop, the logged frequency, the operator's dial — reads the selected
// band, and on a satellite the band worth reading is the one carrying the
// downlink.
_ = b.exec(civ.CmdVFO, civ.SubVFOMain)
}
return nil
}
// TuneSatellite puts the receiver on downHz and the transmitter on upHz.
func (b *IcomSerial) TuneSatellite(downHz, upHz int64, downMode, upMode string) error {
if downHz <= 0 {
return fmt.Errorf("icom: no downlink frequency")
}
if !b.satNative {
return b.tuneSatSingleBand(downHz, upHz, downMode, upMode)
}
// MAIN — the downlink.
if err := b.exec(civ.CmdVFO, civ.SubVFOMain); err != nil {
return fmt.Errorf("icom: could not select the main band: %w", err)
}
if err := b.SetFrequency(downHz); err != nil {
return err
}
if err := b.satSetMode(downMode, downHz); err != nil {
return err
}
// SUB — the uplink.
if upHz > 0 {
if err := b.exec(civ.CmdVFO, civ.SubVFOSub); err != nil {
return fmt.Errorf("icom: could not select the sub band: %w", err)
}
uerr := b.execIdempotent(fmt.Sprintf("set uplink %d Hz", upHz),
append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(upHz)...)...)
merr := b.satSetMode(upMode, upHz)
// Back to MAIN whatever happened. A rig left pointing at SUB reports the
// uplink as its frequency, and every band-dependent thing in OpsLog —
// the log, the antenna, the amplifier — would follow the transmitter
// onto the wrong band.
if err := b.exec(civ.CmdVFO, civ.SubVFOMain); err != nil {
applog.Printf("icom: could not return to the main band: %v", err)
}
if uerr != nil {
return uerr
}
if merr != nil {
return merr
}
}
return nil
}
// satSetMode sets the mode of whichever band is currently selected. An empty
// mode leaves it alone — a linear transponder is worked in one mode for a whole
// pass, and re-sending it every second is traffic for nothing.
func (b *IcomSerial) satSetMode(mode string, freqHz int64) error {
mode = strings.TrimSpace(mode)
if mode == "" {
return nil
}
// modeCode resolves "SSB" against the CURRENT dial to pick a sideband, which
// is wrong here twice over: the dial may still be on the other band, and on
// satellites USB is the convention on both sides whatever the frequency.
code, data, err := b.modeCode(satSideband(mode))
if err != nil {
return err
}
return b.setModeBytes(mode, code, data)
}
// satSideband is the sideband convention above 30 MHz: USB, on both the uplink
// and the downlink, including the parts of a linear transponder that fall in
// what would be an LSB band on HF. The exceptions — AO-7's mode A downlink on
// 29 MHz among them — are still USB by convention, so there is no exception to
// make.
func satSideband(mode string) string {
if strings.EqualFold(strings.TrimSpace(mode), "SSB") {
return "USB"
}
return mode
}
// SatReceiveHz is where the receiver is now.
func (b *IcomSerial) SatReceiveHz() (int64, error) {
if b.satNative {
// The selected band is MAIN — see TuneSatellite, which always returns to
// it — so the ordinary frequency read is the downlink.
if err := b.exec(civ.CmdVFO, civ.SubVFOMain); err != nil {
applog.Printf("icom: sat readback could not select main: %v", err)
}
}
return b.readFreq()
}
// tuneSatSingleBand is every other Icom: one receiver, one band.
//
// The downlink is set, because that is what the operator is listening to. The
// uplink is set through split only when it is close enough to be on the same
// band — QO-100 behind transverters, AO-7's mode A — and otherwise reported as
// out of reach rather than quietly skipped.
func (b *IcomSerial) tuneSatSingleBand(downHz, upHz int64, downMode, _ string) error {
if err := b.SetFrequency(downHz); err != nil {
return err
}
if err := b.satSetMode(downMode, downHz); err != nil {
return err
}
if upHz <= 0 {
return nil
}
// One megahertz apart is the working definition of "the same band" here: it
// covers a transponder's own passband and any sensible transverter pairing,
// and excludes every real cross-band satellite (145 / 435 MHz).
if abs64(upHz-downHz) > 1_000_000 {
return ErrSatUplinkUnreachable
}
if err := b.exec(append([]byte{civ.CmdVfoFreq, civ.SubVfoUnselected}, civ.FreqToBCD(upHz)...)...); err != nil {
return err
}
if !b.satOn {
return nil
}
return b.exec(civ.CmdSplit, boolByte(true))
}
func abs64(v int64) int64 {
if v < 0 {
return -v
}
return v
}
+94 -2
View File
@@ -94,6 +94,13 @@ type IcomSerial struct {
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read
// via ScopeData from the binding goroutine).
dualScope bool
// satNative marks the two-band satellite rigs — the IC-9700 and the IC-9100 —
// which have a real satellite mode of their own. Everything else gets the
// downlink and, where the uplink is reachable, split.
satNative bool
// satOn tracks what we last told the rig, so TuneSatellite can arm the mode
// once rather than on every Doppler step.
satOn bool
// Set when the rig rejects the waveform-output command in both shapes: it has
// no stream to give, and asking again on every enable is noise.
scopeUnsupported bool
@@ -172,12 +179,13 @@ const (
)
// NewIcomSerial builds an (unconnected) Icom serial backend. baud defaults to
// 115200, rig address to the IC-7610's 0x98 when out of range.
// 115200, rig address to the IC-7610's 0x98 when out of range — and 0x00 is in
// range: it is a valid CI-V address that some rigs and interfaces are set to.
func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *IcomSerial {
if baud <= 0 {
baud = 115200
}
if civAddr <= 0 || civAddr > 0xFF {
if civAddr < 0 || civAddr > 0xFF {
civAddr = 0x98 // IC-7610
}
if digitalDefault == "" {
@@ -231,6 +239,21 @@ func (b *IcomSerial) Connect() error {
_ = port.SetRTS(false)
b.port = port
b.model = civ.ModelName(b.rigAddr)
// A NEW SESSION IS NOT JUDGED ON THE OLD ONE'S SILENCE.
//
// lastGoodAt bounds "the control link answers but no CI-V comes back". It
// belongs to a session, and it was never cleared when a new one opened —
// so a rig that went to standby half an hour ago handed every fresh session
// a half-hour-old "last good read", which is past the grace before the first
// command is even sent. The session was torn down at once, redialled twenty
// seconds later, and torn down again: a loop with no way out, and the Icom
// console (with its power-ON button) blinking away on every pass.
//
// Cleared, the rule reads as it was written: silent since connect is a rig in
// standby, and the session is kept so the operator can wake it.
b.lastGoodAt = time.Time{}
b.readFails = 0
b.silentGrace = icomSilentGrace
// Start the reader before any request: recv() now waits on respCh, which only
// the reader feeds. respCh is buffered so a burst (or the scope stream) never
@@ -268,6 +291,10 @@ func (b *IcomSerial) Connect() error {
// non-default address still RENDERS; this flag only drives the SET/read commands
// (mode, span, edges), which need the 0x00 selector to be accepted on the 7300.
b.dualScope = idAddr == 0x98 || idAddr == 0xA2 || idAddr == 0x94
// The satellite rigs: IC-9700 and IC-9100. Both carry two receivers on two
// bands and a satellite mode that pairs them; no other Icom in this table
// does, and asking one that does not is a rejected frame per Doppler step.
b.satNative = idAddr == 0xA2 || idAddr == 0x7C
// Silence any LEFTOVER waveform stream, BLIND, before anything else. The
// 0x27 output flag lives in the RADIO and survives sessions; its flood is
// what makes the IC-7760 stop answering CI-V — so waiting for CI-V to
@@ -594,6 +621,15 @@ func (b *IcomSerial) SetMode(mode string) error {
if err != nil {
return err
}
return b.setModeBytes(mode, code, data)
}
// setModeBytes is SetMode once the mode is already a CI-V byte and a data flag.
// Split out for the band-stacking recall, which gets both FROM the radio and
// must not go back through an ADIF name to reach them: a register holding CW-R
// or LSB would come back as plain CW or as whatever the band convention says,
// i.e. not the mode the operator left there.
func (b *IcomSerial) setModeBytes(mode string, code byte, data bool) error {
// Set the base mode (keeping the rig's current filter by sending only the
// mode byte), then set the data-mode flag for digital modes.
if err := b.execIdempotent("set mode "+mode, civ.CmdSetMode, code); err != nil {
@@ -2188,3 +2224,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")
}
// ── Band stacking registers ───────────────────────────────────────────────
// RecallBandStack puts the VFO where the operator last was on a band, by asking
// the radio rather than by holding an opinion about it.
//
// The console's band buttons used to send a frequency chosen in software — a
// reasonable middle-of-the-band number, and never where anybody actually
// operates. The radio already knows better: every band key press it has ever
// had is remembered in that band's stacking registers, so register 1 is the
// last place used on that band, and cycling through 2 and 3 walks back through
// the ones before it — CW where CW was worked, and the FT8 frequency where FT8
// was worked, without either being written down anywhere.
//
// Reads the register, then sets frequency and mode from it. Returns the
// frequency it landed on, so the caller can say where it went; a register the
// rig will not read leaves the radio untouched and returns an error, which is
// what makes the caller's fallback to a plain frequency safe.
func (b *IcomSerial) RecallBandStack(band, reg int) (int64, error) {
if b.port == nil {
return 0, fmt.Errorf("not connected")
}
if band <= 0 || reg < 1 || reg > 3 {
return 0, fmt.Errorf("icom: band stack %d/%d is not a register", band, reg)
}
bb, rb := civ.ByteToBCD(band), civ.ByteToBCD(reg)
if err := b.write(civ.CmdExtra, civ.SubBandStack, bb, rb); err != nil {
return 0, err
}
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdExtra && len(d.Data) >= 2 && d.Data[0] == civ.SubBandStack
})
if err != nil {
return 0, err
}
bs, ok := civ.DecodeBandStack(f.Data, bb, rb)
if !ok {
// Logged with the raw frame: the register layout has a tail that differs
// between models, and a rig that answers something we cannot read is the
// one thing worth seeing here.
applog.Printf("icom: band stack %d/%d — cannot read the register from % X", band, reg, f.Data)
return 0, fmt.Errorf("icom: band stacking register %d/%d not understood", band, reg)
}
if err := b.SetFrequency(bs.FreqHz); err != nil {
return 0, err
}
// The mode is best-effort. Landing on the right frequency in the wrong mode
// is a nuisance; refusing the whole recall over it would send the operator
// back to a button that does less.
if bs.Mode != 0 {
if err := b.setModeBytes(civ.ModeToADIF(bs.Mode, bs.Data), bs.Mode, bs.Data); err != nil {
applog.Printf("icom: band stack %d/%d — frequency set, mode 0x%02X refused: %v", band, reg, bs.Mode, err)
}
}
return bs.FreqHz, nil
}
+22
View File
@@ -632,6 +632,28 @@ func (k *Kenwood) SetPTT(on bool) error {
return k.write("RX;")
}
// SetPTTData keys the transmitter on the DATA input: TX1 on a TS-590, which is
// ACC2/USB rather than the front microphone. The radio's own manual is explicit
// that the parameter chooses the input — "0: SEND (normal transmission using
// the MIC input), 1: DATA SEND (ACC2/USB input)" — so a voice keyer playing
// into the rig's USB codec has to say TX1 or it transmits dead air while the
// radio listens to a microphone nobody is speaking into.
//
// Unkeying is the same RX either way; there is no data-flavoured stop.
func (k *Kenwood) SetPTTData(on bool) error {
k.mu.Lock()
defer k.mu.Unlock()
if k.port == nil {
return fmt.Errorf("kenwood: not connected")
}
k.tx = on
if on {
k.txAt = time.Now()
return k.write("TX1;")
}
return k.write("RX;")
}
func (k *Kenwood) write(cmd string) error {
if k.port == nil {
return fmt.Errorf("kenwood: not connected")
+79 -5
View File
@@ -59,6 +59,13 @@ var yaesuModels = map[string]string{
"0650": "FT-891",
"0670": "FT-DX3000",
"0460": "FT-450D",
// The eight-digit family. Named for the console; the frequency format is
// learned from the rig either way (see learnFreqWidth).
"0251": "FT-2000",
"0310": "FT-950",
"0583": "FTDX1200",
"0462": "FTDX3000",
"0101": "FTDX5000",
}
// yaesuModeToADIF maps the MD digit to an ADIF mode. The DATA and RTTY variants
@@ -100,6 +107,11 @@ type Yaesu struct {
// rxVFOCmd is "FR" when the rig reports its receive VFO that way, else empty
// and VS is used — see ReadState.
rxVFOCmd string
// freqDigits is how many digits this rig writes a frequency in, LEARNED from
// its own replies. See learnFreqWidth.
freqDigits int
// rttyUpper picks RTTY-U over RTTY-L — see SetRTTYUpper.
rttyUpper bool
curFreq int64
curRXFreq int64
@@ -130,6 +142,18 @@ func NewYaesu(portName string, baud int, digital string) *Yaesu {
return &Yaesu{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
}
// SetRTTYUpper chooses which sideband RTTY is set on.
//
// Yaesu has both — MD06 is RTTY-L, MD09 is RTTY-U — and ADIF has neither: it
// says "RTTY" and stops there, so the rig cannot be driven from the logged mode
// alone. LSB is the older convention and stays the default; an operator whose
// FSK controller or decoder wants the other one says so once here.
func (y *Yaesu) SetRTTYUpper(v bool) {
y.mu.Lock()
defer y.mu.Unlock()
y.rttyUpper = v
}
// SetLowerLines chooses whether DTR and RTS are deasserted on connect. Set
// before Connect.
func (y *Yaesu) SetLowerLines(v bool) {
@@ -275,6 +299,7 @@ func (y *Yaesu) ReadState() (RigState, error) {
if err != nil {
return RigState{}, err // the rig stopped answering — let the Manager reconnect
}
y.learnFreqWidth(faRaw, "FA")
freqA, ok := parseYaesuFreq(faRaw, "FA")
if !ok {
return RigState{}, fmt.Errorf("yaesu: unparsable FA reply %q", faRaw)
@@ -355,7 +380,7 @@ func (y *Yaesu) SetFrequency(hz int64) error {
if y.curVFO == "B" {
cmd = "FB"
}
return y.write(fmt.Sprintf("%s%09d;", cmd, hz))
return y.write(fmt.Sprintf("%s%0*d;", cmd, y.freqWidth(), hz))
}
func (y *Yaesu) SetMode(mode string) error {
@@ -364,7 +389,7 @@ func (y *Yaesu) SetMode(mode string) error {
if y.port == nil {
return fmt.Errorf("yaesu: not connected")
}
d := yaesuModeDigit(mode, y.curFreq)
d := yaesuModeDigit(mode, y.curFreq, y.rttyUpper)
if d == 0 {
return fmt.Errorf("yaesu: no CAT mode for %q", mode)
}
@@ -481,7 +506,53 @@ func cmdPrefix(cmd string) string {
return c
}
// parseYaesuFreq reads "FA014074000;" into Hz.
// EIGHT DIGITS OR NINE — the rig says which, and it is not a matter of taste.
//
// The FTDX10, FT-991A, FT-891 and FT-710 write a frequency in nine digits;
// everything before them — FTDX3000, FTDX5000, FTDX1200, FT-2000, FT-950,
// FT-450 — writes eight, and answers a nine-digit SET with "?;". An operator
// with an FTDX3000 saw exactly that: every FA command rejected, a radio that
// would not follow, and nothing to say why.
//
// The width is LEARNED rather than tabulated: the rig announces it in every
// reply to "FA;", so the answer comes from the radio in front of the operator
// instead of from a list of models that will always be one release behind. Nine
// until the first reply lands, which is what the modern rigs use and what this
// backend was written against.
const yaesuFreqDigitsDefault = 9
func (y *Yaesu) freqWidth() int {
if y.freqDigits >= 8 && y.freqDigits <= 11 {
return y.freqDigits
}
return yaesuFreqDigitsDefault
}
// learnFreqWidth takes the width from a frequency reply. Only a reply that
// parses as a frequency teaches anything — a "?;" or a stray frame says nothing
// about the format, and a width learned from one would be worse than the
// default.
func (y *Yaesu) learnFreqWidth(reply, prefix string) {
r := strings.TrimSpace(reply)
if !strings.HasPrefix(r, prefix) {
return
}
digits := strings.TrimSuffix(strings.TrimPrefix(r, prefix), ";")
if len(digits) < 8 || len(digits) > 11 {
return
}
for _, c := range digits {
if c < '0' || c > '9' {
return
}
}
if y.freqDigits != len(digits) {
debugLog.Printf("yaesu: this rig writes frequencies in %d digits — commands will match", len(digits))
y.freqDigits = len(digits)
}
}
// parseYaesuFreq reads "FA014074000;" (or "FA14074000;") into Hz.
func parseYaesuFreq(reply, prefix string) (int64, bool) {
r := strings.TrimSpace(reply)
if !strings.HasPrefix(r, prefix) {
@@ -551,7 +622,7 @@ func resolveYaesuVFOs(freqA, freqB int64, vfo string, split bool) (tx, rx int64,
// yaesuModeDigit maps an ADIF mode to the MD digit. SSB has no single digit —
// the sideband follows the worldwide convention (LSB below 10 MHz, USB above),
// which is why the current frequency is part of the decision.
func yaesuModeDigit(mode string, freqHz int64) byte {
func yaesuModeDigit(mode string, freqHz int64, rttyUpper bool) byte {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "SSB":
if freqHz > 0 && freqHz < 10_000_000 {
@@ -569,7 +640,10 @@ func yaesuModeDigit(mode string, freqHz int64) byte {
case "AM":
return '5'
case "RTTY":
return '6'
if rttyUpper {
return '9' // RTTY-U
}
return '6' // RTTY-L, the older convention
case "":
return 0
default:
+38
View File
@@ -0,0 +1,38 @@
package cat
import "testing"
// The FTDX10 family writes a frequency in nine digits; everything before it —
// FTDX3000, FTDX5000, FTDX1200, FT-2000, FT-950, FT-450 — writes eight and
// answers a nine-digit SET with "?;". Reported from an FTDX3000: every FA
// command rejected, a radio that would not follow.
//
// The width is taken from the rig's own reply, so a model this backend has
// never heard of is right on the first read.
func TestYaesuFrequencyWidthIsLearnedFromTheRig(t *testing.T) {
y := &Yaesu{}
if got := y.freqWidth(); got != 9 {
t.Errorf("before any reply the width is %d, want the modern 9", got)
}
y.learnFreqWidth("FA14074000;", "FA") // an FTDX3000
if got := y.freqWidth(); got != 8 {
t.Errorf("width %d after an eight-digit reply, want 8", got)
}
y.learnFreqWidth("FA014074000;", "FA") // and an FTDX10 on the next session
if got := y.freqWidth(); got != 9 {
t.Errorf("width %d after a nine-digit reply, want 9", got)
}
// Nothing that is not a frequency teaches anything: a rejection, a stray
// frame or a reply from another command would otherwise set the format for
// every command that follows.
for _, junk := range []string{"?;", "FA;", "FB014074000;", "FA1407400X;", "FA1234567;", "FA123456789012;"} {
before := y.freqWidth()
y.learnFreqWidth(junk, "FA")
if after := y.freqWidth(); after != before {
t.Errorf("%q changed the width from %d to %d", junk, before, after)
}
}
}
+13 -1
View File
@@ -84,6 +84,11 @@ type YaesuTXState struct {
// same shape as FlexController and IcomController.
type YaesuController interface {
YaesuState() YaesuTXState
// SetRTTYUpper is a preference, not a command — see Yaesu.SetRTTYUpper. It
// belongs here so a change of mind reaches the RUNNING rig: the link is not
// rebuilt for it, and until it was reachable this way the setting only took
// effect on the next launch.
SetRTTYUpper(bool)
RefreshYaesu() error
SetYaesuPower(int) error
SetYaesuMicGain(int) error
@@ -388,7 +393,14 @@ func (y *Yaesu) RefreshYaesu() error {
}
func (y *Yaesu) SetYaesuPower(w int) error {
return y.setAndRefresh(fmt.Sprintf("PC%03d;", clampInt(w, 5, 100)))
// The SAME ceiling the console draws its slider to. It was hard-coded at 100
// here while yaesuMaxPower already answered 200 for an FTDX101MP: asking for
// 200 W sent PC100, the rig obeyed, and the slider sprang back to 100 on the
// next poll — which is how the operator discovered it.
y.mu.Lock()
max := yaesuMaxPower(y.model, y.panel.RFPower)
y.mu.Unlock()
return y.setAndRefresh(fmt.Sprintf("PC%03d;", clampInt(w, 5, max)))
}
func (y *Yaesu) SetYaesuMicGain(p int) error {
+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)
}
}
+36 -1
View File
@@ -123,7 +123,7 @@ func TestYaesuModeDigit(t *testing.T) {
{"", 14074000, 0}, // nothing to set
}
for _, c := range cases {
if got := yaesuModeDigit(c.mode, c.hz); got != c.want {
if got := yaesuModeDigit(c.mode, c.hz, false); got != c.want {
t.Errorf("yaesuModeDigit(%q, %d) = %q, want %q", c.mode, c.hz, got, c.want)
}
}
@@ -406,3 +406,38 @@ func TestYaesuAntennaCommand(t *testing.T) {
}
}
}
// ADIF says "RTTY" and stops there, but Yaesu has both sidebands and the rig
// has to be told one. LSB is the older convention and the default; the other is
// a station's own choice, made once.
func TestYaesuRTTYSideband(t *testing.T) {
if got := yaesuModeDigit("RTTY", 14_080_000, false); got != '6' {
t.Errorf("RTTY = %q, want RTTY-L", got)
}
if got := yaesuModeDigit("RTTY", 14_080_000, true); got != '9' {
t.Errorf("RTTY (upper) = %q, want RTTY-U", got)
}
// The switch is about RTTY and nothing else.
if got := yaesuModeDigit("FT8", 28_074_000, true); got != 'C' {
t.Errorf("FT8 = %q, want DATA-U", got)
}
if got := yaesuModeDigit("CW", 14_030_000, true); got != '3' {
t.Errorf("CW = %q, want CW-U", got)
}
}
// The RTTY sideband is a preference the LINK does not depend on, so it is not
// in catLinkSig and the link is not rebuilt for it — which means the running
// client has to accept it. It did not, and the setting waited for the next
// launch while the rig went on choosing LSB.
func TestYaesuAcceptsTheRTTYSidebandWhileConnected(t *testing.T) {
var y YaesuController = &Yaesu{}
y.SetRTTYUpper(true)
if got := y.(*Yaesu).rttyUpper; !got {
t.Error("a running Yaesu ignored the RTTY sideband")
}
y.SetRTTYUpper(false)
if got := y.(*Yaesu).rttyUpper; got {
t.Error("it could not be turned back")
}
}
+11 -1
View File
@@ -40,6 +40,14 @@ type Match struct {
Continent string `json:"continent"`
Lat float64 `json:"lat"`
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 {
@@ -149,7 +157,9 @@ func (db *DB) Lookup(callsign string) (Match, bool) {
return Match{}, false
}
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
// two characters (KG4XX). "KG4", "KG4X", "KG4XYZ"… are continental USA.
+79
View File
@@ -0,0 +1,79 @@
package extsvc
import (
"fmt"
"strings"
)
// Configured reports what a service still needs before it can be uploaded to.
//
// It exists so the answer lives NEXT TO THE UPLOADERS that enforce it. Written
// once in the app instead, it drifted immediately: Club Log was refused for a
// missing API key, which nobody has ever set — OpsLog carries its own
// application key (see clublogAppAPIKey) and the account is an email, a password
// and the logbook callsign. An operator whose live upload had been working for
// months was told his service was not configured.
//
// Each case mirrors the guard at the top of the matching Upload* function. It
// answers "can this be attempted", not "are these credentials right": only the
// service can say that, and it says it by refusing the upload.
func Configured(svc Service, cfg ExternalServices) error {
missing := func(service string, fields ...string) error {
return fmt.Errorf("%s is not configured — %s", service, strings.Join(fields, ", "))
}
set := func(v string) bool { return strings.TrimSpace(v) != "" }
var need []string
add := func(ok bool, what string) {
if !ok {
need = append(need, what)
}
}
switch svc {
case ServiceQRZ:
add(set(cfg.QRZ.APIKey), "the logbook API key")
if len(need) > 0 {
return missing("QRZ.com", need...)
}
case ServiceClublog:
// No API key: OpsLog's own application key is embedded.
add(set(cfg.Clublog.Email), "the account email")
add(set(cfg.Clublog.Password), "the password")
add(set(cfg.Clublog.Callsign), "the logbook callsign")
if len(need) > 0 {
return missing("Club Log", need...)
}
case ServiceHRDLog:
add(set(cfg.HRDLog.Callsign), "the station callsign")
add(set(cfg.HRDLog.Code), "the upload code")
if len(need) > 0 {
return missing("HRDLog.net", need...)
}
case ServiceEQSL:
add(set(cfg.EQSL.Username), "the username (callsign)")
add(set(cfg.EQSL.Password), "the password")
if len(need) > 0 {
return missing("eQSL.cc", need...)
}
case ServiceHamQTH:
add(set(cfg.HamQTH.Username), "the username")
add(set(cfg.HamQTH.Password), "the password")
if len(need) > 0 {
return missing("HamQTH", need...)
}
case ServiceCloudlog:
add(set(cfg.Cloudlog.URL), "the instance URL")
add(set(cfg.Cloudlog.APIKey), "the API key")
add(set(cfg.Cloudlog.StationID), "the station profile")
if len(need) > 0 {
return missing("Cloudlog / Wavelog", need...)
}
case ServiceLoTW:
add(set(cfg.LoTW.TQSLPath), "the path to tqsl.exe")
add(set(cfg.LoTW.StationLocation), "the TQSL station location")
if len(need) > 0 {
return missing("LoTW", need...)
}
}
return nil
}
+20
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -104,7 +105,26 @@ func UploadHamlog(ctx context.Context, client *http.Client, cfg ServiceConfig, a
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) {
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)
if key == "" {
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 (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
@@ -28,7 +29,7 @@ func TestUploadHamlogRequestShape(t *testing.T) {
}))
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 {
t.Fatal(err)
}
@@ -64,7 +65,7 @@ func TestHamlogFailureIsNotSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = 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()
if err != nil {
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.
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) {
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")
}
}
+10 -12
View File
@@ -87,6 +87,7 @@ type Manager struct {
mu sync.Mutex
cfg ExternalServices
rnd *rand.Rand
hamlogClosedOnce sync.Once
}
// maxUploadAttempts bounds retries of a transient upload failure.
@@ -230,13 +231,13 @@ func (m *Manager) OnQSOLogged(id int64) {
m.route(ServiceCloudlog, id, c)
}
}
// HAMLOG.online — one API key and nothing else to get wrong.
if h := cfg.Hamlog; h.AutoUpload {
if h.APIKey == "" {
m.logf("extsvc: hamlog auto-upload is ON but no API key is set (QSO %d not sent)", id)
} else {
m.route(ServiceHamlog, id, h)
}
// HAMLOG.online is closed to uploads — see ErrHamlogClosed. Said once per
// session rather than per QSO, because an operator who left the switch on
// deserves to know why nothing leaves, and does not deserve it every minute.
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 {
@@ -296,9 +297,7 @@ func (m *Manager) onCloseServices() []Service {
if c := cfg.Cloudlog; c.AutoUpload && c.UploadMode == ModeOnClose && c.URL != "" && c.APIKey != "" && c.StationID != "" {
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)
}
@@ -348,8 +347,7 @@ func (m *Manager) FlushOnClose() int {
uploaded += m.flushOneByOne(svc, ids, cfg.HRDLog)
case ServiceCloudlog:
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)
}
+63
View File
@@ -11,6 +11,7 @@ package geo
import (
"math"
"sort"
"strings"
)
@@ -118,3 +119,65 @@ func NeighbourGrids(lat, lon float64, ring int) []string {
}
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, "~")
}
}
// 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)
}
}
}
+54
View File
@@ -0,0 +1,54 @@
package udp
import "testing"
// Three decoders on one multicast group, which is the ordinary setup. MSHV is
// working a station; WSJT-X and JTDX are idle and say so once a second each.
//
// Read across the listener rather than per program, every one of those idle
// Status packets was a "the operator cleared the DX Call" — so OpsLog emptied
// the entry field, MSHV's next Status refilled it, and the entry blinked and
// the map zoomed at 1 Hz for as long as all three were running.
func TestDXClearIsPerProgram(t *testing.T) {
s := &Server{}
if s.noteDXCall("MSHV", "F5NNN") {
t.Fatal("taking up a station is not a clear")
}
// The idle ones, interleaved, as they arrive on the wire.
for i := 0; i < 3; i++ {
if s.noteDXCall("WSJT-X", "") {
t.Fatal("an idle WSJT-X was read as MSHV clearing its call")
}
if s.noteDXCall("JTDX", "") {
t.Fatal("an idle JTDX was read as MSHV clearing its call")
}
if s.noteDXCall("MSHV", "F5NNN") {
t.Fatal("MSHV repeating the same station is not a clear")
}
}
// MSHV's own clear is still an edge, and only once: the Status that follows
// is just as empty and must not re-clear a field the operator may have
// typed into since.
if !s.noteDXCall("MSHV", "") {
t.Error("MSHV clearing its own DX Call was not reported")
}
if s.noteDXCall("MSHV", "") {
t.Error("the clear repeated on the next identical Status")
}
}
// Each program's edge is its own: WSJT-X letting go says nothing about MSHV.
func TestDXClearOfOneProgramLeavesTheOthers(t *testing.T) {
s := &Server{}
s.noteDXCall("MSHV", "F5NNN")
s.noteDXCall("WSJT-X", "DL1ABC")
if !s.noteDXCall("WSJT-X", "") {
t.Error("WSJT-X clearing its own call should be reported")
}
if s.noteDXCall("MSHV", "F5NNN") {
t.Error("MSHV's unchanged call was disturbed by WSJT-X's clear")
}
}
+31 -6
View File
@@ -235,7 +235,16 @@ type Server struct {
// lastMode is the mode NAME from each program's last Status, used to resolve
// a Decode's one-character mode marker.
lastMode map[string]string
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
// lastDX is each program's last DX Call, to spot the moment it is cleared.
//
// PER PROGRAM, and that is the whole point of the map. Two or three decoders
// commonly share one listener — the multicast group on 2237 is the usual
// setup — and a single value meant WSJT-X's empty DX Call was read as MSHV
// clearing the station it was calling. One "cleared" per second, alternating
// with MSHV re-announcing the call: the entry field emptied and refilled at
// 1 Hz and the map zoomed in and out with it. "The operator cleared the DX
// call" is a statement about ONE program, never about a socket.
lastDX map[string]string
// badPkts counts datagrams this listener could not parse, so the diagnostic
// dump below stays bounded. A misconfigured port is not a one-off: the
@@ -403,6 +412,25 @@ func (s *Server) run() {
// radios on different bands. The port is included — a program keeps its socket
// for as long as it runs, which is exactly the lifetime this has to be stable
// over.
// noteDXCall records a program's current DX Call and reports whether THIS
// program has just cleared one.
//
// A decoder sends Status every second whether anything changed or not, so the
// clear is an edge — a call, then none — and it is an edge in ONE program's
// stream. Several decoders commonly share a listener, and reading the edge
// across all of them made an idle WSJT-X look like MSHV abandoning the station
// it was calling, once a second, for as long as both were running.
func (s *Server) noteDXCall(inst, dx string) (cleared bool) {
s.mu.Lock()
defer s.mu.Unlock()
if s.lastDX == nil {
s.lastDX = map[string]string{}
}
prev := s.lastDX[inst]
s.lastDX[inst] = dx
return dx == "" && prev != ""
}
func (s *Server) instanceLabel(id string, remote *net.UDPAddr) string {
if id == "" {
return ""
@@ -623,12 +651,9 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
// operator cleared it in WSJT-X / JTDX / MSHV. Fire ONE clear (tracked per
// server) — an idle app sends empty Status every second, and we must not
// re-clear (which would fight a manual entry) on each of those.
s.mu.Lock()
prev := s.lastDX
s.lastDX = w.DXCall
s.mu.Unlock()
if w.DXCall == "" && prev != "" {
if s.noteDXCall(inst, w.DXCall) {
ev.ClearCall = true
ev.ProgramID = inst // whose clear it is — the app filters on it
}
case ServiceADIF:
// JTAlert / GridTracker forward a text ADIF record after a QSO is
+22 -1
View File
@@ -523,10 +523,24 @@ var decodeModeChar = map[string]string{
"#": "JT65",
"@": "JT9",
"&": "MSK144",
":": "Q65",
"`": "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
// is the mode from the same program's last Status, used when the field is a
// marker we do not know, or empty.
@@ -535,6 +549,13 @@ func DecodeModeName(raw, statusMode string) string {
if m, ok := decodeModeChar[raw]; ok {
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").
// Anything shorter, or carrying punctuation, is a marker rather than a name.
if len(raw) >= 2 {
+75 -5
View File
@@ -167,9 +167,14 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
r.Callsign = call
r.Source = p.Name()
r.FetchedAt = time.Now().UTC()
fillFromDXCC(&r, dxcc)
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)
fillFromDXCC(&r, dxcc)
return r, nil
}
if errors.Is(err, ErrNotFound) {
@@ -208,9 +213,9 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
if !saysNothingAboutLocation(call) {
clearHomeLocation(&r)
}
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
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
}
}
@@ -390,6 +395,47 @@ func titleCase(s string) string {
// lives, and keeps the ones that say WHO they are — name, address, QSL route.
// A portable operator's cards still go to the home address, so that address is
// not wrong; their county is.
// sameEntityName reports whether two country names denote the same entity.
//
// The two come from different vocabularies — the callbook writes "Germany"
// where cty.dat writes "Fed. Rep. of Germany", "United States" where the ADIF
// list says "United States of America" — so they are compared on their
// significant words with the boilerplate of officialdom removed, and one
// containing the other counts as a match.
//
// Deliberately generous. Getting it wrong in the strict direction discards a
// correct grid, which is the fault this exists to fix; getting it wrong in the
// generous direction keeps a location from a neighbouring entity, which is the
// state of every callbook record that has no page for the portable call anyway.
func sameEntityName(a, b string) bool {
na, nb := entityKey(a), entityKey(b)
if na == "" || nb == "" {
return false
}
return na == nb || strings.Contains(na, nb) || strings.Contains(nb, na)
}
var entityNoise = map[string]bool{
"THE": true, "OF": true, "FED": true, "REP": true, "REPUBLIC": true,
"FEDERAL": true, "FEDERATION": true, "DEM": true, "DEMOCRATIC": true,
"STATE": true, "KINGDOM": true, "AMERICA": true,
}
// entityKey reduces a country name to its significant letters.
func entityKey(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
var b strings.Builder
for _, tok := range strings.FieldsFunc(s, func(r rune) bool {
return r == ' ' || r == '.' || r == ',' || r == '-'
}) {
if entityNoise[tok] {
continue
}
b.WriteString(tok)
}
return b.String()
}
func clearHomeLocation(r *Result) {
r.Country, r.Continent = "", ""
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
@@ -425,14 +471,26 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
//
// Same-entity portables (F4BPO/P, W2RE/2) are untouched: the entities match,
// and there the home details ARE where the operator is.
// UNLESS THE RECORD IS ABOUT THE OPERATION ITSELF.
//
// Some compound calls have a callbook page of their own, filed under the
// slashed form and describing where the station actually is: QRZ's HP/WE9G
// carries Altos del Maria, Panama, square EJ98xq. Clearing that threw away
// the one field the lookup existed to find, and the QSO was logged with no
// grid at all while the page plainly showed one.
//
// The record's OWN country is what tells the two apart: a page for the
// operation names the entity being operated from, a home page names home.
if dxccNum != 0 && strings.ContainsRune(r.Callsign, '/') && !saysNothingAboutLocation(r.Callsign) {
if home := homeCall(r.Callsign); home != "" && home != r.Callsign {
if homeNum, _, _, _, _, _, _, homeOK := dxcc.Resolve(home); homeOK && homeNum != 0 && homeNum != dxccNum {
if !sameEntityName(r.Country, country) {
clearHomeLocation(r)
filled = true
}
}
}
}
if country != "" {
r.Country = country
filled = true
@@ -441,11 +499,23 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
r.Continent = cont
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
filled = true
}
if ituz != 0 {
if ituz != 0 && r.ITUZ == 0 {
r.ITUZ = ituz
filled = true
}
+57
View File
@@ -91,3 +91,60 @@ func TestSameEntityPortableKeepsItsLocation(t *testing.T) {
t.Errorf("lat/lon = %v/%v — the precise home position was replaced by the entity centroid", r.Lat, r.Lon)
}
}
// The other half again, and the one an operator reported: a compound call with
// a callbook page OF ITS OWN. QRZ files HP/WE9G under that exact form, with the
// Panama address and square the station is actually operating from — and the
// QSO was being logged with no grid at all while the page plainly showed one.
func TestACompoundCallWithItsOwnPageKeepsThatPagesLocation(t *testing.T) {
dxcc := testDXCC()
dxcc["HP/WE9G"] = struct {
num int
country string
cont string
cqz, ituz int
lat, lon float64
}{num: 88, country: "Panama", cont: "NA", cqz: 7, ituz: 11, lat: 8.5, lon: -80.0}
dxcc["WE9G"] = struct {
num int
country string
cont string
cqz, ituz int
lat, lon float64
}{num: 291, country: "United States", cont: "NA", cqz: 5, ituz: 8, lat: 39.8, lon: -98.5}
r := Result{
Callsign: "HP/WE9G",
Name: "Richard B",
Country: "Panama", // the RECORD's own country: this page is the operation
Grid: "EJ98xq",
Lat: 8.686667, Lon: -80.043333,
}
fillFromDXCC(&r, dxcc)
if r.Grid != "EJ98xq" {
t.Errorf("grid = %q, want EJ98xq — the page describes the operation, not a home address", r.Grid)
}
if r.Lat != 8.686667 || r.Lon != -80.043333 {
t.Errorf("lat/lon = %v/%v — the station's own position was replaced by the entity centroid", r.Lat, r.Lon)
}
}
func TestSameEntityNameAcrossVocabularies(t *testing.T) {
same := [][2]string{
{"Germany", "Fed. Rep. of Germany"},
{"United States", "United States of America"},
{"Panama", "Panama"},
{"Kosovo", "Republic of Kosovo"},
}
for _, p := range same {
if !sameEntityName(p[0], p[1]) {
t.Errorf("%q and %q read as different entities", p[0], p[1])
}
}
for _, p := range [][2]string{{"Costa Rica", "United States"}, {"France", "Belgium"}, {"", "Panama"}} {
if sameEntityName(p[0], p[1]) {
t.Errorf("%q and %q read as the same entity", p[0], p[1])
}
}
}
+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)
}
}
+38 -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
// feed is actually about, and it is the same for every operator.
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{}
for _, b := range w.cfg.Bands {
for _, b := range bands {
if len(w.cfg.RxGrids) == 0 {
out = append(out, "pskr/filter/v2/"+b+"/#")
continue
@@ -181,13 +190,30 @@ func (w *Watcher) Start() error {
opts.OnConnect = func(c mqtt.Client) {
w.cfg.Logf("pskr: connected to %s", w.cfg.Broker)
for _, topic := range w.topics() {
if tok := c.Subscribe(topic, 0, w.handle); tok.Wait() && tok.Error() != nil {
w.cfg.Logf("pskr: subscribe %s failed: %v", topic, tok.Error())
topics := w.topics()
// In batches, not one at a time. Each Subscribe waits for its own
// 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
}
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) {
w.mu.Lock()
@@ -293,6 +319,12 @@ type Status struct {
LastErr string `json:"last_err,omitempty"`
Broker string `json:"broker"`
Bands []string `json:"bands"`
// What the feed is actually filtered on, so a panel showing nothing can say
// WHY instead of leaving the operator to guess: the radius in force, and how
// many receiver squares it came to. A radius raised in Preferences that
// never reached the subscription is invisible without these two.
NearKm int `json:"near_km"`
Squares int `json:"squares"`
}
func (w *Watcher) Status() Status {
@@ -301,6 +333,7 @@ func (w *Watcher) Status() Status {
st := Status{
Running: w.running, Received: w.received, LastAt: w.lastAt,
LastErr: w.lastErr, Broker: w.cfg.Broker,
NearKm: w.cfg.NearKm, Squares: len(w.cfg.RxGrids),
}
st.Bands = append(st.Bands, w.cfg.Bands...)
return st
+936
View File
@@ -0,0 +1,936 @@
// 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
// backfillEvery is how often the history query is asked again for a target
// that is still being watched. Five minutes is PSK Reporter's own courtesy
// interval for repeating a query, and it happens to be the period most
// uploaders batch on.
backfillEvery = 5 * 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 and when,
// so the panel's polling cannot re-fetch it every second — PSK Reporter's
// query API answers that with a rate limit, and rightly — while a target
// held for a while still gets a fresh look every few minutes.
backfilled string
backfilledAt time.Time
}
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
}
if changed {
w.dropWindowOfPreviousTarget()
}
if err := w.resubscribe(client); err != nil {
return err
}
if changed {
// In BOTH scopes. The band-wide subscription was assumed to arrive with
// the target's reports already in the window — true only once it has been
// running a while, and false in the case that matters: the operator picks
// a station a minute after opening the panel and sees one decode where
// another program, running for an hour, shows four.
go w.backfill(target, mode)
}
return nil
}
// dropWindowOfPreviousTarget empties the collected window when it belonged to
// the station being left behind.
//
// WHOSE WINDOW IS IT? Under the narrow scope the subscription IS the target —
// three filters about one station — so the window is his, and keeping it across
// a change would answer the new question with the old station's evidence.
//
// Under the band-wide scope it is the BAND's: every FTx report on it, filtered
// by target only when the analysis is drawn. Clearing it there threw away an
// hour of accumulated evidence on every click, and a panel that had been showing
// hundreds of reports read zero — the operator clicking a decode to ask "can he
// hear me" is the very moment that history is worth something, and it was being
// deleted in order to answer the question.
func (w *Watcher) dropWindowOfPreviousTarget() {
w.mu.Lock()
defer w.mu.Unlock()
if w.cfg.Scope == ScopeBand {
return
}
w.spots = w.spots[:0]
}
// 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 != "" {
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 {
// Due another look at the history? Checked here because this is what the
// panel calls every second; the query itself is rate-limited inside
// backfill, so this cannot turn into a request per poll.
w.mu.Lock()
if t, m := w.target, w.mode; t != "" && time.Since(w.backfilledAt) >= backfillEvery {
w.mu.Unlock()
go w.backfill(t, m)
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.
//
// 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.
//
// Two passes, and the second is the one that matters on a busy DX. Looking for
// an empty run alone answered "nowhere" exactly when the answer was most
// wanted: a hundred decodes across a 2800 Hz passband leave no run of clear
// bins at all, and the panel drew a full histogram with no advice under it.
// Failing a real gap, the quietest slot is still better than the one the
// operator would have picked by eye.
func suggestOffset(bins []Bin, ceiling int) int {
if ceiling < 1000 {
return 0
}
count := make(map[int]int, len(bins))
busy := make(map[int]bool, len(bins)*3)
for _, b := range bins {
count[b.OffsetHz] = b.Count
if b.Count > 0 {
// 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.
busy[b.OffsetHz], busy[b.OffsetHz-binHz], busy[b.OffsetHz+binHz] = true, true, true
}
}
// 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.
const low = 1020
high := (ceiling / binHz) * binHz
// Pass 1 — the widest clear run, and call from its middle.
bestStart, bestLen, start, run := -1, 0, -1, 0
for edge := low; edge <= high; edge += binHz {
if busy[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 bestStart + bestLen*binHz/2
}
// Pass 2 — no clear run: the least busy slot. Walked from the top down, so
// a tie goes to the higher offset, which is the less crowded half of any
// passband and the half a pile-up leaves alone.
quietest, fewest := -1, 1<<30
for edge := high; edge >= low; edge -= binHz {
if c := count[edge]; c < fewest {
quietest, fewest = edge, c
}
}
if quietest < 0 {
return 0
}
// Never past the ceiling: the top bin CONTAINS it, so its middle can sit
// beyond the highest offset he has been shown to decode — which is the one
// thing this function exists to avoid.
if quietest+binHz/2 > ceiling {
quietest -= binHz
}
if quietest < low {
return 0
}
return quietest + 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()
// Once per target, then no more often than the refresh interval.
//
// The live feed alone lags by design: PSK Reporter's uploaders batch their
// reports, most of them every five minutes, so between two batches the
// window only holds what happened to have been sent. Asking the history
// again at that same cadence keeps it as full as a program that has been
// subscribed for an hour — which is the whole of the difference an operator
// sees when comparing the two side by side.
if w.backfilled == target && time.Since(w.backfilledAt) < backfillEvery {
w.mu.Unlock()
return
}
w.backfilled, w.backfilledAt = target, time.Now()
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
}
+181
View File
@@ -0,0 +1,181 @@
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 with no gap at all still gets an answer — the quietest slot,
// which is what an operator would look for by eye. What it must never do is
// advise ABOVE the ceiling: transmitting past the DX's filter is the one
// outcome worse than picking a busy slot.
if got := suggestOffset(bins, 1500); got <= 1000 || got > 1500 {
t.Errorf("suggested %d Hz with a full 1500 Hz passband, want a slot inside it", 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)
}
}
// The case reported from the air: a busy DX, 103 decodes across a 2805 Hz
// passband, and the panel drew the whole histogram with no advice under it.
func TestACrowdedPassbandStillGetsAnAnswer(t *testing.T) {
// Every bin from 1020 to 2800 occupied — no clear run anywhere, and the
// quietest slot is the answer.
bins := []Bin{}
for hz := 1020; hz <= 2760; hz += binHz {
n := 5
if hz == 2400 { // one slot noticeably quieter than the rest
n = 1
}
bins = append(bins, Bin{OffsetHz: hz, Count: n})
}
got := suggestOffset(bins, 2805)
if got == 0 {
t.Fatal("no advice on a full passband — this is exactly when it is wanted")
}
if got < 2400 || got > 2460 {
t.Errorf("suggested %d Hz, want the quietest slot around 2400", got)
}
}
// Under the band-wide scope the window is the BAND's, not the target's: it is
// every FTx report on it, filtered by target only when the analysis is drawn.
// Clearing it on a target change threw away an hour of accumulated evidence at
// the exact moment it was worth something — the operator clicking a decode to
// ask "can he hear me" saw the report count drop to zero.
func TestBandScopeKeepsItsWindowAcrossATargetChange(t *testing.T) {
for _, tc := range []struct {
scope Scope
keep bool
}{{ScopeBand, true}, {ScopeTarget, false}} {
w := &Watcher{cfg: Config{Scope: tc.scope, Logf: func(string, ...any) {}}}
w.target, w.mode = "OLD", "FT8"
w.spots = []spot{{TxCall: "OLD", RxCall: "F4BPO", at: time.Now()}}
w.dropWindowOfPreviousTarget()
w.target = "NEW"
if got := len(w.spots) > 0; got != tc.keep {
t.Errorf("%s scope: window kept = %v, want %v", tc.scope, got, tc.keep)
}
}
}
+26
View File
@@ -262,7 +262,18 @@ func (s *Server) serve(c net.Conn) {
return
}
req := strings.TrimSpace(line)
started := time.Now()
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.
//
// 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 {
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 {
s.log("rigctld: set_mode %q failed: %v", args[0], err)
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)
}
}
}
+369
View File
@@ -0,0 +1,369 @@
// Package easycomm drives azimuth/elevation rotator controllers that speak
// EasyComm II, over a raw TCP socket or a serial port.
//
// EasyComm is what satellite rotator controllers agreed on: SatPC32, Gpredict
// and Hamlib all speak it, so a controller that works with any of those works
// here. The dialect matters less than it looks — every command is a two-letter
// name with a number stuck to it, on one line, and a controller that does not
// recognise one ignores it.
//
// The subset used:
//
// AZ123.4 EL45.0<LF> point there
// AZ EL<LF> ask where it is — the reply is the same shape
// SA SE<LF> stop both axes
//
// Not every controller ANSWERS. A great many EasyComm boxes — the Arduino
// trackers above all — accept commands and never say a word back, which is
// perfectly legal in EasyComm I and common in II. So a silent controller is not
// treated as a broken one: the last commanded position is reported instead, and
// the rotator keeps being driven. Refusing to work with a write-only controller
// would rule out half the satellite stations in the hobby.
package easycomm
import (
"fmt"
"io"
"math"
"net"
"strconv"
"strings"
"sync"
"time"
"go.bug.st/serial"
)
const (
dialTimeout = 3 * time.Second
ioTimeout = 1500 * time.Millisecond
// replyWait is how long a query waits before deciding the controller is one
// of the silent ones. Short: this runs once a second inside a pass, and a
// controller that is going to answer answers in milliseconds.
replyWait = 400 * time.Millisecond
)
// Client is one rotator controller. Exactly one of (Host, Port) or ComPort is
// used.
type Client struct {
Host string
Port int
ComPort string
Baud int
// MaxAz is how far the rotator turns: 360 or 450. A 450° rotator can follow
// a pass straight through north without unwinding, which is the difference
// between hearing the whole of an overhead pass and losing the middle of it.
MaxAz int
mu sync.Mutex
// lastAz/lastEl are what was last commanded — the answer for a controller
// that does not talk back.
lastAz, lastEl float64
commanded bool
// silent latches once a query has gone unanswered. Without it, a write-only
// controller costs a 400 ms wait on every single poll of a pass.
silent bool
}
// New builds a TCP client. There is no standard port; 4533 is Hamlib's rotctld
// convention and the usual default in the controllers' own setup screens.
func New(host string, port int, maxAz int) *Client {
if strings.TrimSpace(host) == "" {
host = "127.0.0.1"
}
if port <= 0 || port > 65535 {
port = 4533
}
return &Client{Host: host, Port: port, MaxAz: normMaxAz(maxAz)}
}
// NewSerial builds a serial client.
func NewSerial(comPort string, baud int, maxAz int) *Client {
if baud <= 0 {
baud = 9600
}
return &Client{ComPort: comPort, Baud: baud, MaxAz: normMaxAz(maxAz)}
}
func normMaxAz(v int) int {
if v == 450 {
return 450
}
return 360
}
// Point commands the rotator to an azimuth and elevation.
//
// The azimuth is given in the rotator's own terms: on a 450° machine an
// azimuth past 360 is a real, reachable position, and asking for 010 when the
// rotator is sitting at 370 would send it the long way round through the whole
// scale — three quarters of a turn, in the middle of a pass, with the antenna
// pointing at the ground for most of it.
func (c *Client) Point(az, el float64) error {
az = c.wrapAz(az)
el = clamp(el, 0, 180)
if err := c.send(fmt.Sprintf("AZ%.1f EL%.1f", az, el), false); err != nil {
return err
}
c.mu.Lock()
c.lastAz, c.lastEl, c.commanded = az, el, true
c.mu.Unlock()
return nil
}
// Stop halts both axes.
func (c *Client) Stop() error { return c.send("SA SE", false) }
// Heading is where the rotator says it is.
//
// live is false when the answer is the last commanded position rather than a
// reading — the caller shows that differently, because "where I told it to go"
// and "where it is" are not the same claim and a stuck rotator must not be able
// to hide behind the first.
func (c *Client) Heading() (az, el float64, live bool, err error) {
c.mu.Lock()
silent, la, le, commanded := c.silent, c.lastAz, c.lastEl, c.commanded
c.mu.Unlock()
if silent {
if !commanded {
return 0, 0, false, fmt.Errorf("easycomm: the controller does not report its position")
}
return la, le, false, nil
}
line, err := c.query("AZ EL")
if err != nil {
// One silence is enough: a controller either answers or it does not, and
// this runs every second for the length of a pass.
c.mu.Lock()
c.silent = true
c.mu.Unlock()
if commanded {
return la, le, false, nil
}
return 0, 0, false, err
}
a, e, ok := parseHeading(line)
if !ok {
c.mu.Lock()
c.silent = true
c.mu.Unlock()
if commanded {
return la, le, false, nil
}
return 0, 0, false, fmt.Errorf("easycomm: could not read %q", line)
}
return a, e, true, nil
}
// wrapAz brings an azimuth into what this rotator can reach.
//
// On a 360° machine that is a plain modulo. On a 450° one the extra 90° is an
// OVERLAP — 370 and 10 are the same direction — and which of the two to use is
// decided by whichever is nearer where the rotator already is, so a pass
// crossing north continues instead of unwinding.
func (c *Client) wrapAz(az float64) float64 {
az = math.Mod(az, 360)
if az < 0 {
az += 360
}
if c.MaxAz != 450 {
return az
}
c.mu.Lock()
cur, known := c.lastAz, c.commanded
c.mu.Unlock()
if !known {
return az
}
alt := az + 360
if alt > 450 {
return az
}
if math.Abs(alt-cur) < math.Abs(az-cur) {
return alt
}
return az
}
// ── Transport ───────────────────────────────────────────────────────────────
type heldPort struct {
p serial.Port
openedAt time.Time
}
var (
portsMu sync.Mutex
openPorts = map[string]*heldPort{}
)
// bootSettle: an Arduino-based controller resets when its serial port is
// opened, and its bootloader then holds the processor for a second or more. A
// command sent into that window is simply lost — which is how a controller that
// answers a terminal perfectly reports nothing here.
const bootSettle = 2 * time.Second
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 {
baud = 9600
}
sp, err := serial.Open(com, &serial.Mode{BaudRate: baud})
if err != nil {
return nil, fmt.Errorf("open rotator %s @ %d baud: %w", com, baud, err)
}
_ = sp.SetReadTimeout(150 * time.Millisecond)
h := &heldPort{p: sp, openedAt: time.Now()}
openPorts[com] = h
return h, nil
}
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)
}
}
// Close releases the serial port. TCP dials per command and holds nothing.
func (c *Client) Close() {
if c.ComPort != "" {
drop(c.ComPort)
}
}
func (c *Client) send(cmd string, wantReply bool) error {
_, err := c.exchange(cmd, wantReply)
return err
}
func (c *Client) query(cmd string) (string, error) { return c.exchange(cmd, true) }
func (c *Client) exchange(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
}
if wait := bootSettle - time.Since(h.openedAt); wait > 0 {
time.Sleep(wait)
}
conn = h.p
drain(h.p)
} else {
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
if err != nil {
return "", fmt.Errorf("connect rotator %s:%d: %w", c.Host, c.Port, err)
}
_ = nc.SetDeadline(time.Now().Add(ioTimeout))
defer nc.Close()
conn = nc
}
// LF, not CR: EasyComm's own documents use a line feed, and the controllers
// that want CR accept either. The reverse is not true of every Arduino
// sketch out there.
if _, err := conn.Write([]byte(cmd + "\n")); err != nil {
if c.ComPort != "" {
drop(c.ComPort)
}
return "", fmt.Errorf("send %q: %w", cmd, err)
}
if !wantReply {
return "", nil
}
buf := make([]byte, 128)
var sb strings.Builder
deadline := time.Now().Add(replyWait)
for time.Now().Before(deadline) {
n, err := conn.Read(buf)
if n > 0 {
sb.Write(buf[:n])
if strings.ContainsAny(sb.String(), "\r\n") {
break
}
}
if err != nil {
break
}
}
line := strings.TrimSpace(sb.String())
if line == "" {
return "", fmt.Errorf("no reply to %q", cmd)
}
return line, nil
}
func drain(sp serial.Port) {
buf := make([]byte, 256)
for {
n, err := sp.Read(buf)
if n == 0 || err != nil {
return
}
}
}
// parseHeading reads a controller's answer.
//
// The shapes in the wild differ more than the specification suggests —
// "AZ123.4 EL45.0", "AZ=123.4 EL=45.0", "+123.4+045.0", lower case, tabs — so
// this looks for the two labels and takes the number attached to each rather
// than trying to match a whole line.
func parseHeading(line string) (az, el float64, ok bool) {
up := strings.ToUpper(line)
az, aok := numberAfter(up, "AZ")
el, eok := numberAfter(up, "EL")
if !aok {
return 0, 0, false
}
// Elevation missing is not a broken reply: an azimuth-only controller
// answering an AZ EL query says what it has.
if !eok {
el = 0
}
return az, el, true
}
func numberAfter(s, label string) (float64, bool) {
i := strings.Index(s, label)
if i < 0 {
return 0, false
}
rest := strings.TrimLeft(s[i+len(label):], " \t=:")
end := 0
for end < len(rest) {
ch := rest[end]
if (ch >= '0' && ch <= '9') || ch == '.' || ((ch == '-' || ch == '+') && end == 0) {
end++
continue
}
break
}
if end == 0 {
return 0, false
}
v, err := strconv.ParseFloat(strings.TrimSuffix(rest[:end], "."), 64)
if err != nil {
return 0, false
}
return v, true
}
func clamp(v, lo, hi float64) float64 {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
@@ -0,0 +1,92 @@
package easycomm
import "testing"
// Every one of these is a shape a real controller has been seen to answer with.
// The point of the parser is that none of them is special-cased.
func TestParseHeading(t *testing.T) {
for _, tc := range []struct {
line string
az, el float64
ok bool
}{
{"AZ123.4 EL45.0", 123.4, 45, true},
{"AZ=123.4 EL=45.0", 123.4, 45, true},
{"az 123.4 el 45.0", 123.4, 45, true},
{"AZ123.4\tEL45.0\r\n", 123.4, 45, true},
{"AZ012.0 EL000.0", 12, 0, true},
{"AZ370.5 EL05.5", 370.5, 5.5, true},
{"AZ123.4", 123.4, 0, true}, // azimuth-only controller
{"RPRT 0", 0, 0, false},
{"", 0, 0, false},
} {
az, el, ok := parseHeading(tc.line)
if ok != tc.ok {
t.Errorf("%q: ok=%v, wanted %v", tc.line, ok, tc.ok)
continue
}
if ok && (az != tc.az || el != tc.el) {
t.Errorf("%q: got %.1f/%.1f, wanted %.1f/%.1f", tc.line, az, el, tc.az, tc.el)
}
}
}
// The 450° overlap is the whole reason a satellite rotator is worth having: a
// pass crossing north must continue past 360 instead of unwinding through the
// entire scale with the antenna sweeping the ground.
func TestWrapAz450(t *testing.T) {
c := &Client{MaxAz: 450}
// Nothing commanded yet: no history to be near, so the plain bearing.
if got := c.wrapAz(10); got != 10 {
t.Errorf("first move: got %.1f, wanted 10", got)
}
c.lastAz, c.commanded = 350, true
// Crossing north: 370 is 20° away, 10 is 340° away.
if got := c.wrapAz(10); got != 370 {
t.Errorf("crossing north from 350: got %.1f, wanted 370", got)
}
// Coming back down the same way, the overlap stays the near answer.
c.lastAz = 370
if got := c.wrapAz(350); got != 350 {
t.Errorf("back from 370: got %.1f, wanted 350", got)
}
// Beyond the rotator's reach there is no overlap to use.
c.lastAz = 440
if got := c.wrapAz(100); got != 100 {
t.Errorf("past the end of the scale: got %.1f, wanted 100", got)
}
}
func TestWrapAz360(t *testing.T) {
c := &Client{MaxAz: 360}
c.lastAz, c.commanded = 350, true
if got := c.wrapAz(10); got != 10 {
t.Errorf("a 360 rotator has no overlap: got %.1f, wanted 10", got)
}
if got := c.wrapAz(-10); got != 350 {
t.Errorf("negative bearing: got %.1f, wanted 350", got)
}
if got := c.wrapAz(725); got != 5 {
t.Errorf("two turns and five degrees: got %.1f, wanted 5", got)
}
}
// A controller that never answers must not be treated as a broken one: the last
// commanded position is reported, marked as not live.
func TestSilentControllerReportsCommanded(t *testing.T) {
c := &Client{MaxAz: 360, silent: true}
if _, _, _, err := c.Heading(); err == nil {
t.Error("a silent controller with nothing commanded should say it cannot report")
}
c.lastAz, c.lastEl, c.commanded = 120, 30, true
az, el, live, err := c.Heading()
if err != nil {
t.Fatalf("after a command: %v", err)
}
if live {
t.Error("a commanded position must not be reported as a live reading")
}
if az != 120 || el != 30 {
t.Errorf("got %.1f/%.1f, wanted 120/30", az, el)
}
}
+97 -11
View File
@@ -28,6 +28,7 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"time"
"go.bug.st/serial"
@@ -71,32 +72,99 @@ func NewSerial(comPort string, baud int) *Client {
return &Client{ComPort: comPort, Baud: baud}
}
// roundTrip opens a connection (TCP or serial per the client's config), sends
// one CR-terminated command and (when wantReply) reads one CR/LF-terminated
// reply line.
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
var conn io.ReadWriteCloser
if c.ComPort != "" {
baud := c.Baud
// bootSettle is how long a freshly opened serial port is left alone before the
// first command.
//
// An Arduino-based controller — K3NG's firmware, the ERC family — RESETS when
// the serial port is opened: the DTR line pulses its reset pin, and the
// bootloader then holds the processor for a second or more. A command sent into
// 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 {
baud = 9600
}
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: baud})
sp, err := serial.Open(com, &serial.Mode{BaudRate: baud})
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)
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 {
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
if err != nil {
return "", fmt.Errorf("connect ARCO %s:%d: %w", c.Host, c.Port, err)
}
_ = nc.SetDeadline(time.Now().Add(ioTimeout))
defer nc.Close()
conn = nc
}
defer conn.Close()
if _, err := conn.Write([]byte(cmd + "\r")); err != nil {
if c.ComPort != "" {
drop(c.ComPort)
}
return "", fmt.Errorf("send %q: %w", cmd, err)
}
if !wantReply {
@@ -121,11 +189,29 @@ func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
}
line := strings.TrimSpace(sb.String())
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 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
// (overlap rotators accept >360); we normalise to [0,360).
func (c *Client) GoTo(az int) error {

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