Compare commits

..
24 Commits
Author SHA1 Message Date
rouggy e6889caae9 chore: release v0.26.1 2026-08-21 01:43:14 +02:00
rouggy e3b7a35e2c fix: stop losing decodes, hanging on exit, and wedging the rig link
Three faults an operator's log finally made visible, plus the interface
work that came out of the same session.

Reliability:

- UDP events were dropped on backpressure without a word. A period hands
  over twenty-odd decodes at once, and one slow write to the radio was
  enough to fill the queue — so a decode simply never appeared, and the
  only detector was the operator comparing the panel with JTDX. The drop
  is now counted and logged, panadapter spots went to their own goroutine
  so the radio can no longer hold the decode stream up, and the queue is
  deep enough for a full period.

- The CAT manager waited for its poll loop with a bare <-done. A loop
  wedged in a serial read then blocked every later restart inside Start,
  before it could even try to connect: the rig stayed dead, no line was
  written anywhere, and only killing the process recovered it. The wait
  is bounded at ten seconds and says what it abandoned and why the next
  connect may fail.

- Shutdown had no logging at all, so a hang left nothing to go on and a
  process the operator had to kill — which then blocked the restart after
  an update. Every step is logged, and a watchdog forces the exit if one
  of them never returns.

Auto-call:

- A QSO in progress is now held by OpsLog itself rather than inferred
  from the sender's Status. The moment WSJT-X/JTDX dropped the DX call or
  the Enable-Tx flag between overs, the exchange looked finished and the
  next CQ was answered, interleaving two and then three QSOs on one
  slice. Released on log, on halt, on taking over, and by a watchdog.

Cluster console:

- Replies to a command were buried under the spot flood; a Replies
  toggle hides the DX spots, which the list above already shows.
- Twelve named command buttons beside the input, configured in
  Settings -> Cluster; a button with no command is not drawn.
- Following the tail is now an explicit switch, and sending a command
  re-arms it. It used to measure "am I at the bottom" AFTER committing
  the new lines, so a ten-line reply looked like the operator had
  scrolled up and was never followed — the one case it exists for.

Awards:

- An award can name NO field. The matching controls disappear with it
  and only hand-assigned references count, which is the only thing that
  can feed a reference like WWBOTA. A test pins that nothing else is
  scanned.
- WWBOTA added to the catalogue with its 31 342 references.

Elsewhere: the rotor widget's Stop button acknowledges the press like
the direction presets already did, and the docked band map can be
switched to fit-to-band from its own header.
2026-08-21 01:07:10 +02:00
rouggy 1e507225dd chore: release v0.26.0 2026-08-20 17:53:55 +02:00
rouggy 47992b5f03 fix(decodes): a decode's mode is a marker, not a mode name
Every station on an already-worked band was flagged NEW MODE.

A WSJT-X Decode does not carry the mode's name. It carries the
one-character marker from the decode line - "~" for FT8, "+" for FT4 - and
that character was passed straight through as though it were a mode. The
status resolver then compared "~" against the modes worked for the entity,
matched nothing, and concluded the mode had never been worked. Same cause
put "~ -07" in the comment of every decode spot pushed to the FlexRadio
panadapter, which nobody had traced back.

Resolved through a marker table, with the mode from the sender's last
Status as the fallback - Status is the message that carries the real name.
So an unlisted or future marker degrades to correct rather than to
nonsense, and a sender that puts the name in the field directly is believed
as-is. With neither available the mode is left empty, which makes the
resolver answer "worked": the safe side, since a wrong mode invents a
new-mode flag exactly as the marker did.
2026-08-18 11:40:13 +02:00
rouggy 453e0df27b Merge branch 'feature/ftx-decodes'
An FT decodes panel fed by the inbound UDP link: every decode from WSJT-X,
JTDX or MSHV, grouped by T/R period, with the new-entity flags the cluster
already computes and the operator's own transmissions threaded into the
slots they went out in. Clicking a line answers the station through a
WSJT-X Reply, routed to the instance that heard it.

Available both as a closable tab and as a Main-view pane.
2026-08-18 11:18:22 +02:00
rouggy 6ed38014ed chore(decodes): changelog entries for the FT decodes panel 2026-08-18 11:17:30 +02:00
rouggy f832d1ba07 Merge main: station callsign on import 2026-08-18 11:08:56 +02:00
rouggy f0e00c63a3 fix(import): "fill my station fields" fills the station callsign too
STATION_CALLSIGN was the one field the option deliberately skipped, on the
grounds that stamping the active call could re-route a mixed-call log.

That protected nothing. The same option already writes this profile's grid,
rig, antenna, city and postal address onto every record it finds blank — it
has assumed "this log is mine" long before it reaches the callsign. And the
thing that actually keeps a multi-op log intact is that a record CARRYING a
station callsign is never touched, which has always been true and still is.
Withholding it only meant the option quietly failed on the one field an
operator goes and checks afterwards, leaving imported QSOs with no station
call at all — invisible in the ADIF they export, and unroutable for LoTW
and Club Log.

Counted and logged: "did it fill the callsign?" is the first question after
an import, and the log is where it gets answered.
2026-08-18 11:08:45 +02:00
rouggy 40ecfb9fa9 refactor(decodes): read the list the way DXHunter's reads
Compared side by side, DXHunter's decode list is plainly easier to read,
and three things account for the gap.

The Call column repeated what the message already said. Every FT8 line
opens with the callsigns — "CQ A93MO LL56", "PG5FRL JH3CUL PM74" — so a
column in front of it printed the same token twice and pushed the line
everyone actually reads to the middle of the row. It is gone; the message
is the identity, and the tokens worth finding are picked out inside it:
green for CQ and for our own call when someone answers, red for the station
we are calling. The grid went with it, being the message's last token.

Time is on every row now, compact, no separators. A decode belongs to a
period and the section heading names it — but once a slot runs past a
screenful the heading is somewhere above, and an instant you cannot read
where the decode is is not an instant you have.

Band and mode became their own columns, band as a chip, and the status
flags moved into one column at the right edge with LoTW and a "worked"
marker beside them. Rows are tighter: 13 px for the message and the report,
11 px for the figures, 10 px for the badges, and the column rules run
through the lot.
2026-08-18 10:14:40 +02:00
rouggy 8c68a7d711 Merge main: OpsLog's own Club Log API key 2026-08-18 10:10:42 +02:00
rouggy 628d1e8490 fix(clublog): use OpsLog's own application API key
The embedded key was registered to XV9Q, not to OpsLog. Not a cosmetic
detail: Club Log identifies the client software by that key, so every
OpsLog upload in the world was attributed to that callsign. Its owner
received the abuse warning OpsLog earned when the on-close sweep was still
posting hundreds of QSOs through the realtime endpoint - and a revocation
aimed at them would have cut Club Log uploads for every user of this
program at once.

G7VJR issued a key for "OpsLog" on request. Same mechanism, same UX: the
key identifies the software, the operator still supplies their own e-mail
and password, so it authorises nothing on its own.

Club Log asks that it not be published in source code. The source is on a
private remote and only the built exe is released, but it remains
recoverable from that binary by anyone who looks - as it is for every
logger that embeds one. It is an identifier that can be attributed, not a
secret.
2026-08-18 10:10:33 +02:00
rouggy 0881c72c0f feat(decodes): offer the panel as a Main-view pane
It joins the left/right dropdowns in Settings -> General alongside the maps,
the cluster and the rest, so decodes can sit beside the entry strip instead
of only behind a tab. Per-profile like the other pane choices.

The panel itself is now built in ONE place and rendered from both: two
copies of that call would be two sets of props to keep in step, and the
click handler in particular is not something to duplicate.

The status refresh follows. Its "is anything showing a spot status?" test
decides whether a logged QSO refreshes the NEW badges now or only marks
them dirty, and a panel that had become a pane would have gone on wearing
stale badges whenever it was shown that way rather than as a tab.
2026-08-18 10:01:30 +02:00
rouggy 6da30f91c4 feat(decodes): slot clock, and the QSO in progress stands out
A countdown for the T/R slot, against the UTC clock — a bar, the seconds
left, and the mode. Slots are anchored to UTC rather than to when OpsLog
started, so it is computed from the wall clock alone and keeps running when
the band is dead and there is nothing to group. The last fifth of a slot
turns amber: that is when a decode is imminent and an operator deciding
whether to answer has run out of time to think.

Which exposed a real fault in the grouping. Status reports the T/R period
as a whole number of seconds, so FT4 arrives as 7 or 8 depending on which
way the sender rounded, and the code floored to whole seconds on top of
that — two FT4 periods landed in one heading and others were split down the
middle. The slot length now comes from the MODE, which knows the exact
figure and the whole halving family (FT8 15, FT4 7.5, FT2 3.75), with the
sender's number only as a fallback; and the arithmetic is in milliseconds.
Sub-second slots get a decimal in their heading, or two FT4 periods inside
one second would print the same time twice.

Two rows now stand out from the band behind them, because they are not
about the band at all but about the QSO in progress:

  - the station being called is red, taken from the transmit state, so it
    can be found in a slot holding thirty others;
  - anyone ANSWERING is green and labelled, which outranks everything else
    on the screen. A reply is a decoded line that opens with our own
    callsign — bracketed too, since a non-standard call comes back
    compressed.
2026-08-18 07:14:17 +02:00
rouggy f833ff6d04 fix(decodes): a station just worked drops its NEW badge
EY35S went on showing NEW SLOT for the rest of the half hour it stayed in
the list, five minutes after the QSO was in the log.

A decode's verdict is resolved once, when it arrives, and then read from
the shared status cache for ever. The cache IS refreshed after a QSO is
logged - but refreshSpotStatuses only ever re-queried the cluster spots, so
no decoded callsign was in the batch. And the decodes tab was not in the
"is anything showing a status?" test, so logging a QSO while looking at
this very panel only marked the cache dirty and waited for the cluster or
the band map to be opened.

Both fixed: decoded stations join the refresh batch, deduplicated by
call+band+mode so half an hour of a busy band is a few hundred queries the
backend answers with one pass of the log, and the tab counts as visible.

Second, worse bug found on the way. The cache is pruned back to the live
spots once it outgrows twice the spot cap - keeping only keys present in
the cluster list. Decodes share that cache and are exactly what pushes it
past the cap, so on a busy band the panel would have wiped every one of its
own badges the moment it filled up. Decoded stations now count as live.
2026-08-18 07:03:09 +02:00
rouggy fba7e79a1c feat(decodes): answer a station on click, DT and Freq, badge filters
Clicking a decode now ANSWERS it. It sends WSJT-X/MSHV a Reply message
(type 4), which is the same thing as double-clicking the line in their own
Band Activity window: the application looks the decode up, sets its
transmit frequency to the caller's and starts the exchange.

It deliberately does not tune the radio, which is what it did before and
why nothing happened. On FT8 the whole band sits inside one passband, so
moving the dial changes nothing about who gets answered - the decision
belongs to the decoding application, and the Reply is the only way to hand
it over. Tuning would also just fight it for the VFO. The entry is still
filled so the QSO can be logged here.

The reply is routed by PROGRAM ID, not by listener: two receivers can share
one multicast group, and answering a station heard on the 6 m instance by
talking to the 20 m one would start a call on the wrong band. It goes to
the address that instance's packets actually arrive from - a multicast
listener must answer the sender, never the group. WSJT-X matches the reply
against its own decode list, so the payload replays the decode field for
field: time, snr, delta time, audio offset, mode and message text.

Two columns added, DT and Freq - the audio offset inside the passband, not
the RF frequency, which is the same for every station in the list and says
nothing. Past about two seconds DT takes a warning tint: that station is
drifting out of the window.

The transmit strip. "You cannot see what you are sending, or who you are
calling" - two separate faults. The message was only ever threaded into its
period, and in FT8 you transmit in the slots you are NOT receiving in, so
its period had no decodes and the whole line was dropped; a transmit slot
now creates its period. And the state is a strip of its own at the top,
because it is the one thing on the screen that is about the operator rather
than the band. It is fed by every Status rather than only by one carrying
transmit text, so it can still name the station being called on MSHV and
older JTDX builds, which stop before tx_message in the Status payload.

"New only" became per-category badges, in the colours and the vocabulary of
the Chase New panel. None lit shows the whole band - this is a decode log
first, and a panel that opened by hiding most of the traffic would be lying
about what is on the air.
2026-08-18 06:53:10 +02:00
rouggy d829726679 Merge main: the 0.26.0 changelog block 2026-08-18 06:13:44 +02:00
rouggy c48edd7898 chore: open the 0.26.0 changelog block 2026-08-18 06:13:32 +02:00
rouggy ffaf6fc869 refactor(decodes): column rules, left-aligned grid, no CQ stutter
Three things from reading it on a real screen.

Column rules. The grid alone was not enough to follow a line across: cells
now carry a right border and the row stretches, so the rules run unbroken
from the header to the bottom of the list. That is what turns rows of text
into a table.

Left-aligned. The previous pass centred the grid inside a maximum width,
which on a wide screen opened a dead margin down the left before the first
callsign - trading the hole in the middle for a bigger one at the edge. Now
it fills the width and the slack lands in the message column, which is the
one that can use it and the one bounded by rules on both sides, so it reads
as a cell rather than a gap.

"CQ CQ PE1NAO JO32" - a green CQ badge in front of a message whose own
first word is CQ. The badge is gone; the word already in the line is picked
out instead, which scans the same and stutters not at all.
2026-08-18 06:12:21 +02:00
rouggy 4f77d51ffe refactor(decodes): real columns, spelled-out flags, bigger type
First pass on the panel from operating feedback.

Columns are a grid template shared by the header row and every data row, so
the two cannot drift and the eye has a rail to follow. It is capped at
1500 px and centred: free-flowing, a 2500 px window put the country a foot
from the callsign it belonged to and left a hole in the middle of every
line.

"New" gets a COLUMN. It was only a coloured edge before, which says
something is special without saying what — and every one of these is a
reason to break off what you are doing and call. The entity verdict is a
solid badge, the orthogonal ones (park, grid, prefix, county) are outlined
in the colours markerColour already gives the cluster list and the band
map, so a new park is the same green in all three. Applied inline because
those are categorical --chart-* custom properties, which the theme does not
expose as Tailwind colour utilities: written as border-chart-7 the badge
would simply have had no colour.

Band and mode selectors now appear only when the feed actually carries more
than one of each. One MSHV is one band and one mode, so for most operators
they were furniture; they show up the day a second instance puts a second
band on the link, which is the only day they mean anything. Same rule for
continent, and a receiver count when more than one instance is feeding.

Added a LoTW-only filter, and raised the type throughout (call and message
to 14 px, secondary to 12 px, badges to 11 px) with more room per row.

The decode payload now carries the sending application's own id. It tells
two receivers apart on one multicast group — and it is the address a
WSJT-X Reply message would have to go back to, so it is carried now rather
than requiring another trip through the parser later.
2026-08-18 06:04:28 +02:00
rouggy a197d124dc feat(decodes): an FT decodes tab fed by the inbound UDP link
Every FTx decode WSJT-X, JTDX or MSHV puts on the wire, grouped by T/R
period. Optional and closable, from Tools -> FT decodes; its open state is
remembered, because an operator running digital modes leaves it open for
the session rather than consulting and closing it.

The period is the point, and what separates this from the cluster list.
FT8 is a sequence of fifteen-second slots and a band is read by watching
them go by: who called CQ this slot, who answered, what I was sending while
they did. A flat list sorted by time loses exactly that, so the list is
grouped one section per period, newest first, with the operator's own
transmission shown inside the slot it went out in.

Three fields had to be carried up from the wire to make it possible:

  - the decode's OWN timestamp, which the parser read and threw away. It is
    what assigns a slot: a period's decodes arrive in one burst a second or
    two after it closes, so arrival time piles a whole period into the next
    one. Rebuilt to UTC from milliseconds-since-midnight, with the
    day-boundary case handled - a decode stamped 23:59:58 arriving at
    00:00:01 would otherwise be dated a day ahead and sit at the top of the
    list for the rest of the session.
  - the decoded line itself. The exchange is what says where a station is in
    a QSO, and no set of extracted fields reads like "R-09" does.
  - tx_message and transmitting from Status, which nothing parsed before.
    Recorded once per message rather than on every Status, which repeats it
    about once a second for the whole over.

Also picked up on the way: is_new, low_confidence, off_air, the operator's
own call and grid, and the T/R period itself - better authority on slot
length than the mode name, which says nothing about a custom period. The
Status tail is read defensively: those fields were appended over successive
schema versions and JTDX and MSHV each stop at their own point, so a short
packet is normal and keeps whatever parsed.

Status flags come from ClusterSpotStatuses, the resolver the cluster list
and band map already use, filling the same cache. One verdict per call:
"new band" in this panel and plain worked in the cluster two seconds later
would be worse than no flag at all. Clicking a call goes through the same
handler as a cluster spot, so answering a station is one gesture whether it
came off telnet or off the receiver.

Filters: CQ only, new-anything only, band, mode, continent, an SNR floor
and a free search. The band, mode and continent choices are built from what
is actually on the feed - offering 160 m to a station whose receivers are
all on 6 m is noise.

Decodes are held in the frontend and pruned to a rolling half hour: they
are a live view, not data, nothing outside the panel reads them, and a
night of FT8 on 20 m would otherwise grow a list no filter can rescue.
Arrivals are staged on a 300 ms timer so a period landing as fifty packets
costs one status lookup and one render.
2026-08-18 05:51:34 +02:00
rouggy 9599c3e0b9 chore: release v0.25.9 2026-08-18 05:09:04 +02:00
rouggy a81125eab1 fix(icom): a set whose acknowledgement is lost is sent once more
Extends to frequency and mode what PTT already had. A missing FB is not a
missing command: the rig acts on the frame as it decodes it, and what
expires is our wait for the answer, on a bus shared with the rig's own
transceive updates. JTDX in "Split: Fake It" moves the dial and the mode
immediately before every key-down, so those acks queue behind each other.

Losing one is fatal to the client rather than merely untidy: rigctld answers
RPRT -9, JTDX reads that as losing rig control and tears the connection down
mid-over. An operator's log shows three set_freq failures and one set_mode,
each followed within 300 ms by a fresh rigctld client -- and shows the PTT
resend rescuing an over that would otherwise have ended there.

Opt-in per caller rather than folded into exec: only a command that says
"be in this state" can be repeated safely, and a relative one must never
come through here.

The acknowledgement loss itself is still unexplained. Every failure in that
log is preceded by a state read reporting SSB on a rig in DATA, which points
at CI-V frame desync rather than a slow rig, and needs a trace to pin down.
2026-08-17 16:23:54 +02:00
rouggy bbe1b3ce80 fix(tci): a refused un-key no longer leaves the rig keyed for good
The trx handler stamped its PTT cache BEFORE commanding the radio and left
it in place when the command failed. An operator running JTDX over TCI with
an Icom on CI-V lost an un-key to a lost acknowledgement: the cache recorded
"off" regardless, and from then on every trx:0,false was dismissed as a
repeat of a state the radio had never reached. The cache is per-server, not
per-connection, so reconnecting JTDX changed nothing either -- the
transmitter stayed keyed into the amplifier, with no drive, until the radio
was switched off by hand.

The cache is now written only on success, and a failure clears "known"
outright so the next command reaches the radio whatever it is.

Second guard: releasePTT drops a PTT this server asserted when the client
disconnects, and when the server stops -- before the CAT backend goes down,
while the rig is still reachable. rigctld has had that since a K3 sat in
transmit for 29 s; the TCI server was written without it, so an operator
moving from Hamlib to TCI silently lost the protection. A later log shows
the rig keyed for 40 s across a JTDX reconnect for exactly that reason.
2026-08-17 16:23:53 +02:00
rouggy 5e80c27f61 feat(appearance): the band/mode matrix colours can be chosen
The PH/CW/DIG grid in the Stats panel is the fastest read in the app and its
palette was fixed per theme. Settings -> Appearance now offers the six: the
four status fills, the never-worked fill, and the ring on the cell being
entered.

Stored as OVERRIDES, not as a palette. Each of the twelve themes ships an
--mx-* ramp tuned to its own background, so an operator who only wants a
different green must not thereby freeze the other four to the theme they
happened to be using that day. An empty value means "whatever the theme
says"; the chosen ones are stamped inline on <html>, where they win over
every theme; switching the feature off hands the colours straight back.

The pickers are seeded from what the matrix is painting at that moment
rather than from a fixed palette, so the choice starts from the colours in
front of the operator. A new --mx-cur token carries the current-entry ring:
it follows --warning by default, so it stays theme-correct on all twelve,
but can be recoloured without dragging every other warning in the app along.

The legend under the matrix and its cell tooltips were hardcoded English.
They now go through t() with the same keys as the pickers, so the grid and
the settings cannot disagree about which green is which.
2026-08-17 16:23:39 +02:00
68 changed files with 257933 additions and 364 deletions
+742 -80
View File
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -716,7 +716,10 @@ func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error)
}
vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q)
if q.FreqHz != nil {
vars["qso.freq"] = strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64) + " MHz"
// Megahertz, without the unit. The column is labelled FREQ and the box is
// narrow — "10.136 MHz" ran into the MODE column beside it, and a card that
// says FREQ 10.136 has never confused anybody.
vars["qso.freq"] = strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64)
}
return vars, a.qslCountryInfo(), nil
}
@@ -749,12 +752,21 @@ func (a *App) qslCountryInfo() qslcard.CountryInfo {
}
// sampleQSO is the canned contact used by the editor's live preview.
//
// Every field the QSO box can show must be FILLED here, and filled with a value
// of realistic width. The preview is the only place an operator can see whether
// a column fits before printing a card — a blank one silently promises room it
// will not have. The frequency is the widest of them, hence a real 20 m SSB one
// rather than a round number.
func sampleQSO() qso.QSO {
hz := int64(14285000)
return qso.QSO{
Callsign: "DL1ABC",
QSODate: time.Date(2026, 5, 17, 14, 2, 0, 0, time.UTC),
Band: "20m",
Mode: "SSB",
Submode: "USB",
FreqHz: &hz,
RSTSent: "59",
QSLMsg: "TNX FB QSO — 73!",
}
+83 -2
View File
@@ -49,8 +49,18 @@ type RowColorSettings struct {
// Configured distinguishes "saved with this off" from "saved before the
// option existed", so a new marker can default ON without silently turning
// itself back on for an operator who switched it off.
Configured bool `json:"configured"`
Rules []RowColorRule `json:"rules"`
// RecentZebra decides whether the Recent QSOs grid alternates its row
// background. "" (or "on") keeps the banding, which is what the grid has
// always done; "off" makes every row the same colour.
//
// A string with an empty default rather than a bool: a bool would have to
// mean "true when absent", and every reader would have to remember that.
RecentZebra string `json:"recent_zebra,omitempty"`
// RecentZebraColor is the alternate row's colour. Empty follows the theme,
// which is the only answer that stays right across all four of them.
RecentZebraColor string `json:"recent_zebra_color,omitempty"`
Configured bool `json:"configured"`
Rules []RowColorRule `json:"rules"`
}
// The rule ids, in priority order. The frontend matches on these and holds the
@@ -94,6 +104,13 @@ func normRowColors(s RowColorSettings) RowColorSettings {
out := RowColorSettings{
Enabled: s.Enabled, Style: s.Style, Intensity: s.Intensity,
BandMapLotw: s.BandMapLotw, Configured: true,
RecentZebra: strings.ToLower(strings.TrimSpace(s.RecentZebra)),
}
if out.RecentZebra != "off" {
out.RecentZebra = "" // anything unrecognised means the default banding
}
if c := strings.TrimSpace(s.RecentZebraColor); hexColor.MatchString(c) {
out.RecentZebraColor = c
}
if !s.Configured {
out.BandMapLotw = true // new option, on unless the operator says otherwise
@@ -138,6 +155,70 @@ func normRowColors(s RowColorSettings) RowColorSettings {
return out
}
// MatrixColors recolours the band/mode matrix — the PH/CW/DIG grid in the Stats
// panel, whose five fills and current-entry ring are the fastest read in the
// whole app and the one an operator is most likely to want in their own colours.
//
// Every colour is OPTIONAL and an empty one keeps whatever the active theme
// paints. That is why this stores OVERRIDES rather than a palette: each of the
// twelve themes ships a matrix ramp tuned to its own background, and an operator
// who only wants a different green must not thereby freeze the other four to the
// theme they happened to be using the day they picked it.
type MatrixColors struct {
// Enabled off leaves the theme's own ramp untouched, so switching it off is a
// genuine revert and not "some other set of colours".
Enabled bool `json:"enabled"`
CallConfirmed string `json:"call_confirmed"`
CallWorked string `json:"call_worked"`
EntityConfirmed string `json:"entity_confirmed"`
EntityWorked string `json:"entity_worked"`
NotWorked string `json:"not_worked"`
CurrentEntry string `json:"current_entry"`
}
// normMatrixColors keeps only plain hex values. Anything else becomes "" — i.e.
// "use the theme's" — because these are written straight into a CSS custom
// property, and the same rule as hexColor's own comment applies: what cannot be
// trusted into a stylesheet is refused rather than passed through.
func normMatrixColors(c MatrixColors) MatrixColors {
clean := func(s string) string {
s = strings.TrimSpace(s)
if hexColor.MatchString(s) {
return strings.ToLower(s)
}
return ""
}
return MatrixColors{
Enabled: c.Enabled,
CallConfirmed: clean(c.CallConfirmed),
CallWorked: clean(c.CallWorked),
EntityConfirmed: clean(c.EntityConfirmed),
EntityWorked: clean(c.EntityWorked),
NotWorked: clean(c.NotWorked),
CurrentEntry: clean(c.CurrentEntry),
}
}
// GetMatrixColors returns the operator's matrix palette overrides. All-empty
// (the default) means "whatever the theme says".
func (a *App) GetMatrixColors() MatrixColors {
var c MatrixColors
if raw := a.settingOr(keyMatrixColors, ""); raw != "" {
_ = json.Unmarshal([]byte(raw), &c)
}
return normMatrixColors(c)
}
// SaveMatrixColors persists them.
func (a *App) SaveMatrixColors(c MatrixColors) error {
b, err := json.Marshal(normMatrixColors(c))
if err != nil {
return err
}
a.setSetting(keyMatrixColors, string(b))
return nil
}
// GetRowColors returns the row-colouring configuration, defaults included so the
// panel never has to invent one.
func (a *App) GetRowColors() RowColorSettings {
+37
View File
@@ -0,0 +1,37 @@
package main
import (
"testing"
"hamlog/internal/pskr"
)
// The receiver radius is what makes an opening report evidence about the
// OPERATOR's path rather than someone else's. Both ends of the range destroy
// that: too small and no receiver is ever near enough, too large and the
// reports are about a different part of the world. A stored value that would do
// either has to fall back rather than be honoured.
func TestBandOpenNearKmClamps(t *testing.T) {
for _, tc := range []struct {
name string
raw string
want int
}{
{"unset falls back", "", pskr.DefaultNearKm},
{"not a number falls back", "soon", pskr.DefaultNearKm},
{"zero falls back", "0", pskr.DefaultNearKm},
{"negative falls back", "-100", pskr.DefaultNearKm},
{"absurdly tight falls back", "5", pskr.DefaultNearKm},
{"absurdly wide falls back", "5000", pskr.DefaultNearKm},
{"a real choice is kept", "100", 100},
{"the lower bound is kept", "25", 25},
{"the upper bound is kept", "1000", 1000},
{"surrounding space is ignored", " 150 ", 150},
} {
t.Run(tc.name, func(t *testing.T) {
if got := bandOpenNearKm(tc.raw); got != tc.want {
t.Errorf("bandOpenNearKm(%q) = %d, want %d", tc.raw, got, tc.want)
}
})
}
}
+31 -1
View File
@@ -16,6 +16,7 @@ package main
// they no longer need.
import (
"strconv"
"strings"
"hamlog/internal/applog"
@@ -28,7 +29,8 @@ import (
const (
keyBandOpenEnabled = "bandopen.enabled"
keyBandOpenBands = "bandopen.bands" // comma-separated; empty = the default set
keyBandOpenBands = "bandopen.bands" // comma-separated; empty = the default set
keyBandOpenNearKm = "bandopen.nearkm" // receiver radius; empty = pskr.DefaultNearKm
)
// rbnNodes are the two Reverse Beacon Network endpoints the watch wants: CW and
@@ -45,6 +47,18 @@ type BandOpenSettings struct {
// Available is every band that can be watched, so the UI does not carry its
// own copy of a list that belongs to the detector.
Available []string `json:"available"`
// NearKm is how close a RECEIVER has to be for its report to count as
// evidence about the operator's own path. An opening on these bands reaches
// an AREA, and the right radius is a judgement about how big that area is —
// a 2 m tropo duct is not a 10 m Es footprint — so it is the operator's to
// make rather than a constant.
//
// Tightening it costs nothing in traffic: the broker subscription is one
// ring of squares whatever this says, and this only decides what survives
// once the messages are here. It costs SENSITIVITY — too small a radius in a
// thinly-populated region leaves no receivers at all, and a watch with
// nothing to look at reports nothing while appearing to work.
NearKm int `json:"near_km"`
}
func (a *App) GetBandOpenSettings() BandOpenSettings {
@@ -52,6 +66,7 @@ func (a *App) GetBandOpenSettings() BandOpenSettings {
Enabled: a.settingOr(keyBandOpenEnabled, "") == "1",
Bands: splitCSV(a.settingOr(keyBandOpenBands, "")),
Available: pskr.Bands,
NearKm: bandOpenNearKm(a.settingOr(keyBandOpenNearKm, "")),
}
// Keep only bands that are still offered. A saved selection outlives the code
// that made it: 12 m was dropped from the watched set, but every operator who
@@ -78,9 +93,23 @@ func keepKnownBands(want []string) []string {
return out
}
// 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.
func bandOpenNearKm(raw string) int {
n, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || n < 25 || n > 1000 {
return pskr.DefaultNearKm
}
return n
}
func (a *App) SaveBandOpenSettings(s BandOpenSettings) error {
a.setSetting(keyBandOpenEnabled, map[bool]string{true: "1", false: "0"}[s.Enabled])
a.setSetting(keyBandOpenBands, strings.Join(s.Bands, ","))
a.setSetting(keyBandOpenNearKm, strconv.Itoa(bandOpenNearKm(strconv.Itoa(s.NearKm))))
if s.Enabled {
a.ensureRBNNodes()
}
@@ -184,6 +213,7 @@ func (a *App) startBandOpenFeed() {
a.pskr = pskr.New(pskr.Config{
Bands: bands,
RxGrids: rxGrids,
NearKm: s.NearKm,
OpLat: a.opLat, OpLon: a.opLon,
Geo: func(grid string) (int, int, bool) {
lat, lon, ok := gridToLatLon(grid)
+108
View File
@@ -1,4 +1,112 @@
[
{
"version": "0.26.1",
"date": "",
"en": [
"Auto-call: a QSO in progress is now held by OpsLog itself, so a new station is no longer called in the middle of an exchange. The hold is released when that QSO is logged, when you halt or click another decode, and after four minutes without a contact.",
"Rotor widget: the Stop button now lights up when pressed, like the direction presets, so there is no doubt the order went out.",
"FT decodes: a decode could go missing from the panel when a period arrived as one burst — the internal queue discarded it in silence. It is now deep enough for a full period, panadapter spots no longer hold the stream up, and anything still lost is written to the log.",
"Closing OpsLog can no longer leave a process behind. Each shutdown step is written to the log, and if one of them hangs the application now forces its own exit instead of staying open — which also unblocks the restart after an update.",
"CAT: a rig link whose polling loop got stuck no longer freezes every later reconnection attempt in silence. The wait is now bounded and the problem is written to the log, instead of a radio that never comes back and no explanation anywhere.",
"Cluster console: the replies to your commands are no longer buried under the spot flood. A Replies button — on by default — hides the DX spots, which the list above already shows.",
"Cluster: twelve named command buttons beside the command box — name one \"Show filters\", give it SH/FILTER, and one click sends it. They are set up in Settings → Cluster; a button with no command is not shown.",
"Cluster console: a Follow button decides whether the view sticks to the newest line, and sending a command re-arms it — a long reply is now scrolled to instead of landing out of sight. Switch Follow off to read without being pulled back down; the arrow beside it returns to the end.",
"Band map: the docked map can be switched to fit-to-band from its own header — click the px/kHz readout. It is the same setting the Band Map tab drives, so the two always agree.",
"Awards: an award can now be set to search in NO field. The matching options disappear with it, and the award counts only the references you assign to a contact by hand — for a reference like WWBOTA, which has no ADIF field anywhere.",
"New award: WWBOTA (World Wide Bunkers on the Air), with its full reference list — 31 342 bunkers. References are assigned to a contact by hand, the award having no ADIF field of its own.",
"Grid squares map: the map now fills its panel. It measured its container once, while the tab was still hidden, and kept that size — leaving a blank band under it whatever the window size.",
"Grid squares map: the fill colours for confirmed and worked squares can be chosen, beside the basemap selector. Left alone they follow the theme, and one click puts them back.",
"QSL cards: the QSO box now carries the frequency in megahertz alongside the band, and the designer lets you choose which columns it shows — date, UTC, band, frequency, mode, submode, RST.",
"Main view: up to four panes instead of two. A third and a fourth can be chosen — or left empty, in which case they are not drawn — and with four you pick between quarters (2 × 2) and four columns. Every divider is draggable, each arrangement is remembered for its own pane count, and a double-click evens it out.",
"Appearance: the alternating row shading in Recent QSOs can be switched off — every row the same colour — or given a colour of its own. It is independent of the QSL row colours, which still take precedence on the rows they paint.",
"QSL row colours: the \"left stripe\" style no longer draws a coloured hairline along the top and bottom of every coloured row. The stripe is painted differently — it is a stripe again, and nothing else.",
"QSL designer: the preview contact now fills every column the QSO box can show, frequency and submode included. A blank column promised room the printed card would not have."
],
"fr": [
"Appel automatique : le QSO en cours est désormais verrouillé par OpsLog lui-même, si bien qu'une nouvelle station n'est plus appelée au milieu d'un échange. Le verrou est levé quand ce QSO est journalisé, quand tu fais Stop ou cliques un autre décodage, et au bout de quatre minutes sans contact.",
"Widget rotor : le bouton Stop s'allume maintenant quand on l'enfonce, comme les présélections de direction, pour ne plus douter que l'ordre est parti.",
"Décodages FT : un décodage pouvait manquer dans le panneau quand une période arrivait d'un bloc — la file interne le jetait sans rien dire. Elle est désormais dimensionnée pour une période entière, les spots panadapter ne retiennent plus le flux, et ce qui serait encore perdu est écrit dans le journal.",
"Fermer OpsLog ne peut plus laisser un processus derrière. Chaque étape d'arrêt est écrite dans le journal, et si l'une d'elles se bloque l'application force désormais sa propre sortie au lieu de rester ouverte — ce qui débloque aussi le redémarrage après une mise à jour.",
"CAT : une liaison radio dont la boucle d'interrogation se bloquait figeait en silence toutes les tentatives de reconnexion suivantes. L'attente est désormais bornée et le problème écrit dans le journal, au lieu d'une radio qui ne revient jamais sans la moindre explication.",
"Console cluster : les réponses à tes commandes ne sont plus noyées sous le flot de spots. Un bouton Réponses — actif par défaut — masque les spots DX, que la liste au-dessus affiche déjà.",
"Cluster : douze boutons de commande nommés à côté du champ de saisie — nomme-en un « Voir filtres », donne-lui SH/FILTER, et un clic l'envoie. Ils se règlent dans Réglages → Cluster ; un bouton sans commande n'est pas affiché.",
"Console cluster : un bouton Suivre décide si la vue reste collée à la dernière ligne, et envoyer une commande le réarme — une longue réponse défile maintenant jusqu'à elle au lieu d'arriver hors champ. Désactive Suivre pour lire sans être ramené en bas ; la flèche à côté revient à la fin.",
"Bandmap : la carte ancrée peut passer en ajustement à la bande depuis son propre en-tête — cliquer l'indicateur px/kHz. C'est le même réglage que celui de l'onglet Bandmap, les deux sont donc toujours d'accord.",
"Diplômes : un diplôme peut désormais ne chercher dans AUCUN champ. Les options de reconnaissance disparaissent avec lui, et le diplôme ne compte que les références attribuées à la main sur un contact — pour une référence comme WWBOTA, qui n'a de champ ADIF nulle part.",
"Nouveau diplôme : WWBOTA (World Wide Bunkers on the Air), avec sa liste de références complète — 31 342 bunkers. Les références s'attribuent à la main sur un contact, le diplôme n'ayant pas de champ ADIF propre.",
"Carte des carrés locator : la carte occupe enfin tout son panneau. Elle mesurait son conteneur une seule fois, alors que l'onglet était encore masqué, et gardait cette taille — d'où la bande vide en dessous quelle que soit la taille de la fenêtre.",
"Carte des carrés locator : les couleurs des carrés confirmés et contactés se choisissent, à côté du sélecteur de fond de carte. Laissées telles quelles, elles suivent le thème, et un clic les y ramène.",
"Cartes QSL : le tableau du QSO porte désormais la fréquence en mégahertz à côté de la bande, et le concepteur permet de choisir les colonnes affichées — date, UTC, bande, fréquence, mode, sous-mode, RST.",
"Vue principale : jusqu'à quatre volets au lieu de deux. Un troisième et un quatrième se choisissent — ou se laissent vides, auquel cas ils ne sont pas dessinés — et avec quatre tu choisis entre les quarts (2 × 2) et quatre colonnes. Chaque séparateur se glisse, chaque disposition est mémorisée pour son propre nombre de volets, et un double-clic la remet à égalité.",
"Apparence : l'alternance de teinte une ligne sur deux dans QSO récents peut être désactivée — toutes les lignes de la même couleur — ou recevoir sa propre couleur. Elle est indépendante des couleurs de ligne QSL, qui restent prioritaires sur les lignes qu'elles peignent.",
"Couleurs de ligne QSL : le style « barre à gauche » ne trace plus un filet coloré en haut et en bas de chaque ligne colorée. La barre est peinte autrement — c'est de nouveau une barre, et rien d'autre.",
"Concepteur QSL : le contact d'aperçu remplit maintenant toutes les colonnes que le tableau peut afficher, fréquence et sous-mode compris. Une colonne vide promettait une place que la carte imprimée n'avait pas."
]
},
{
"version": "0.26.0",
"date": "",
"en": [
"A new FT decodes tab: every decode from WSJT-X, JTDX or MSHV, grouped by transmit period, with what is new and what you are sending. Clicking a decode asks the application to call that station, exactly as double-clicking the line in its own window does. Columns are resizable and remembered, the filter bar with them, Clear empties the lists and Halt stops the transmission. With two receivers running the table splits into one column per band, each with its own transmit line, Halt and Clear, and the column lights up when that receiver is on the air. On a FlexRadio, answering a decode moves the transmitter to the slice listening on that band — unless that band already transmits, so an existing split is left alone.",
"FT decodes: auto-call. OpsLog can answer a decode without you clicking it — pick what qualifies (a new entity, band, mode, band+mode slot, grid, US county, POTA park or WPX prefix) and add a watch list of callsigns, with or without conditions of their own. It answers a CQ only, one station at a time across the whole station, never while any receiver is still mid-QSO, with a cooldown per callsign, and every call is written to the log with its reason. Armed from the decodes toolbar or Settings → FTx decodes, and off until you turn it on.",
"A grid-square map (Tools → Grid squares): every 4-character square in the log, confirmed squares solid and worked ones faint, scoped to digital or the FT modes, on the basemap of your choice. It answers the one question a decode list cannot — not who is on the band, but where the station has actually been reaching.",
"UDP: a new outbound type relays the WSJT-X stream. Every datagram received from WSJT-X, JTDX or MSHV is re-sent byte for byte to another program — JTAlert, GridTracker, a second logger — which each sender cannot do on its own, since they talk to one address only. Forwarded verbatim, with no origin header of the kind that has to be stripped back off at the far end, and a target naming one of OpsLogs own ports is refused rather than looped.",
"New-grid detection now reads the grid from a reply as well as from a CQ. \"C91RU IZ5EME JN52\" — a station answering with its locator — is the commonest grid-bearing message on a busy band, and only CQs were being read: any station whose CQ you happened to miss had no known grid at all and could never be flagged as a new one. This is why other tools showed far more wanted grids than OpsLog did.",
"New-grid chasing is now scoped the way GridTracker scopes it, so the two agree. Pick how a square counts as already worked — this band or any band, crossed with any digital mode, this exact mode, or any FT mode (FT8/FT4/FT2) — and whether to chase only squares never worked or unconfirmed ones too, which are still missing from the award. The two are shown differently: a square never worked is a QSO to make, one worked and awaiting a confirmation is a QSL to chase, and they no longer share a badge. Settings → DX Cluster. The decodes table also gained a Grid column.",
"Cluster: NEW badges now clear after a contact however it was logged. Only hand-logging refreshed the worked snapshot, so a station worked through WSJT-X / JTDX / MSHV — or replayed from the offline outbox, arriving from a multi-op partner, or imported from the POTA hunter log — kept its NEW, NEW BAND, NEW MODE, NEW SLOT, NEW GRID, NEW PFX or NEW POTA badge for the rest of the session.",
"DX cluster: the Spotter continent filter matches again. One callsign is spotted by skimmers all over the world within a minute, and they all shared a single cached entry belonging to whichever spotter arrived first — so picking AF left the Indian and American skimmers in the list. Each spot now carries its own spotter continent. Alert rules on the spotters continent or country were missing RBN spots for a related reason and now match too.",
"Recent QSOs: the callsign search takes wildcards. A plain word now matches the START of a call — 4S finds 4S7AB — while * is any run of characters and ? exactly one, so *4S ends with 4S and *4S* contains it anywhere. It was an unconditional contains-match before, which made asking for a prefix impossible.",
"QSL Manager: the right-click menu now carries the same actions as Recent QSOs — ADIF export of the selection, export with a chosen field set, Cabrillo, bulk edit, the callbook and cty.dat updates, e-mail and delete. Filtered exports are deliberately not offered here: \"the filter\" means the Recent-QSOs filter, not the rows on screen. Two entries that appeared in this tab and did nothing when clicked are fixed at the same time — the menu now shows an action only where it is actually wired.",
"Column pickers and the ADIF export field chooser gained a whole-dialog Select all / Clear. Only per-group links existed, so clearing a selection meant one click per group — a dozen of them before you could tick the handful you actually wanted. Applies to Recent QSOs, Worked before, the cluster list and the export dialog, and covers the award columns the per-group links never touched.",
"Rotator: a Compact mode option (Settings → Rotator) reduces the rotor widget to the dial and the short/long-path azimuths under it. Left off, the widget keeps its quick-turn buttons, azimuth box and Stop.",
"Band-opening watch: the radius that counts a receiver as \"around here\" is now adjustable (Settings → DX Cluster), 251000 km, still 300 km by default. An opening on 10 m reaches a whole region, a 2 m duct does not, and the right distance is a judgement only the operator can make. Tightening it costs no extra traffic — the subscription is unchanged — only sensitivity.",
"Club Log uploads are now identified as OpsLog. They were credited to another station's application key, which took the blame for them.",
"ADIF import: \"fill my station fields\" now fills the station callsign too, on records that carry none."
],
"fr": [
"Un onglet Décodages FT : tous les décodages de WSJT-X, JTDX ou MSHV, groupés par période d’émission, avec ce qui est nouveau et ce que tu émets. Cliquer un décodage demande au logiciel dappeler cette station, exactement comme un double-clic sur la ligne dans sa propre fenêtre. Les colonnes se redimensionnent et sont mémorisées, la barre de filtres avec elles, Vider efface les listes et Stop arrête l’émission. Avec deux récepteurs, le tableau se scinde en une colonne par bande, chacune avec sa ligne d’émission, son Stop et son Vider, et la colonne sallume quand cest ce récepteur qui émet. Sur un FlexRadio, répondre à un décodage déplace l’émetteur sur la slice qui écoute cette bande — sauf si cette bande émet déjà, pour ne pas défaire un split en place.",
"Décodages FT : appel automatique. OpsLog peut répondre à un décodage sans que tu cliques — choisis ce qui le déclenche (nouvelle entité, bande, mode, couple bande+mode, carré, comté US, parc POTA ou préfixe WPX) et ajoute une liste dindicatifs surveillés, avec ou sans conditions propres. Uniquement sur un CQ, une station à la fois pour toute la station, jamais tant quun récepteur est encore en QSO, avec un délai de garde par indicatif, et chaque appel est écrit dans le journal avec sa raison. Sarme depuis la barre doutils des décodages ou Réglages → Décodages FTx, et reste désactivé tant que tu ne lactives pas.",
"Une carte des carrés locator (Outils → Carrés locator) : tous les carrés de 4 caractères du journal, les confirmés en plein et les contactés en pâle, au choix en numérique ou en modes FT, sur le fond de carte de ton choix. Elle répond à la seule question quune liste de décodages ne peut pas traiter : non pas qui est sur la bande, mais jusquoù la station porte réellement.",
"UDP : un nouveau type sortant relaie le flux WSJT-X. Chaque datagramme reçu de WSJT-X, JTDX ou MSHV est réémis octet pour octet vers un autre logiciel — JTAlert, GridTracker, un second carnet — ce quaucun émetteur ne sait faire seul puisquil ne parle qu’à une adresse. Transmis tel quel, sans len-tête dorigine quil faut ensuite retirer à lautre bout, et une cible désignant un port d’écoute dOpsLog est refusée au lieu de boucler.",
"La détection des nouveaux carrés lit désormais le locator dans une réponse autant que dans un CQ. « C91RU IZ5EME JN52 » — une station qui répond avec son locator — est le message porteur de locator le plus fréquent sur une bande chargée, et seuls les CQ étaient lus : une station dont on avait manqué le CQ navait aucun locator connu et ne pouvait jamais être signalée comme nouvelle. Cest ce qui expliquait l’écart avec les autres logiciels sur le nombre de carrés recherchés.",
"La chasse aux nouveaux carrés se règle désormais comme dans GridTracker, pour que les deux soient daccord. Choisis ce qui fait quun carré est déjà fait — cette bande ou toutes bandes, croisé avec tout mode numérique, ce mode exact, ou tout mode FT (FT8/FT4/FT2) — et si tu chasses seulement les carrés jamais contactés ou aussi les non confirmés, qui manquent encore au diplôme. Les deux saffichent différemment : un carré jamais contacté est un QSO à faire, un carré contacté en attente de confirmation est une QSL à relancer, et ils ne partagent plus le même badge. Réglages → Cluster DX. Le tableau des décodages gagne aussi une colonne Locator.",
"Cluster : les badges NEW seffacent enfin après un contact, quel que soit le mode de journalisation. Seule la saisie manuelle rafraîchissait linstantané des contacts, si bien quune station travaillée via WSJT-X / JTDX / MSHV — ou rejouée depuis la file hors-ligne, reçue dun partenaire multi-op, ou importée du journal chasseur POTA — gardait son badge NEW, NEW BAND, NEW MODE, NEW SLOT, NEW GRID, NEW PFX ou NEW POTA jusqu’à la fin de la session.",
"Cluster DX : le filtre Continent du spotter fonctionne à nouveau. Un même indicatif est spotté par des skimmers du monde entier en une minute, et tous partageaient une seule entrée en cache appartenant au premier spotter arrivé — choisir AF laissait donc les skimmers indiens et américains dans la liste. Chaque spot porte désormais son propre continent de spotter. Les règles dalerte sur le continent ou le pays du spotter rataient les spots RBN pour une raison voisine et fonctionnent aussi.",
"QSO récents : la recherche dindicatif accepte les jokers. Un mot simple correspond désormais au DÉBUT de lindicatif — 4S trouve 4S7AB — tandis que * remplace nimporte quelle suite de caractères et ? exactement un : *4S se termine par 4S, *4S* le contient nimporte où. C’était auparavant une recherche « contient » systématique, qui rendait impossible la recherche par préfixe.",
"Gestionnaire QSL : le menu contextuel propose désormais les mêmes actions que les QSO récents — export ADIF de la sélection, export avec choix des champs, Cabrillo, édition en masse, mises à jour cty.dat et annuaires, e-mail et suppression. Les exports « filtrés » ny sont volontairement pas offerts : « le filtre » désigne celui des QSO récents, pas les lignes affichées ici. Deux entrées qui apparaissaient dans cet onglet sans rien faire sont corrigées au passage — le menu naffiche plus une action que là où elle est réellement branchée.",
"Les sélecteurs de colonnes et le choix des champs à lexport ADIF ont désormais un Tout sélectionner / Tout décocher global. Seuls les liens par groupe existaient, si bien que vider une sélection demandait un clic par groupe — une douzaine avant de pouvoir cocher les quelques champs voulus. Vaut pour les QSO récents, Déjà contacté, la liste cluster et la fenêtre dexport, et couvre les colonnes de diplômes que les liens par groupe ne touchaient pas.",
"Rotor : une option Mode compact (Réglages → Rotor) réduit le widget au cadran et aux azimuts courte/longue distance en dessous. Désactivée, le widget garde ses boutons de rotation rapide, sa case dazimut et Stop.",
"Veille douverture : le rayon qui fait quun récepteur compte comme « près dici » est désormais réglable (Réglages → Cluster DX), de 25 à 1000 km, toujours 300 km par défaut. Une ouverture sur 10 m couvre toute une région, un conduit sur 2 m non, et la bonne distance est un jugement qui nappartient qu’à lopérateur. Le resserrer ne coûte aucun trafic supplémentaire — labonnement ne change pas — seulement de la sensibilité.",
"Les envois Club Log sidentifient désormais comme OpsLog. Ils étaient attribués à la clé applicative dune autre station, qui en portait la responsabilité.",
"Import ADIF : « remplir mes champs station » renseigne aussi lindicatif de station, sur les enregistrements qui nen portent pas."
]
},
{
"version": "0.25.9",
"date": "",
"en": [
"The band/mode matrix colours can be chosen in Appearance, starting from the ones your theme already paints. Its legend is translated too.",
"TCI sharing: a refused un-key no longer leaves the rig stuck transmitting, and PTT is dropped if the client dies mid-over.",
"Icom: a frequency or mode change whose acknowledgement is lost is sent again, like PTT — losing one made JTDX drop the radio.",
"FlexRadio: transmit audio can switch to DAX for a voice message and back afterwards, so your microphone is not left off air.",
"Rotor widget: quick-turn buttons you can name and aim yourself, plus an azimuth box where Enter turns the antenna.",
"A station portable in another entity no longer inherits its home county, state and grid — TI8/W2RE was logged in New York.",
"LoTW confirmations for FT2, FT4 and FT8 match again: LoTW answers “DATA”, and the exact mode never lined up with the log.",
"CAT: a transverter offset, so a 28 MHz IF behind a 144 MHz transverter logs, spots and tunes on the band you are really on.",
"Band map: zoom goes down to 2 px/kHz for a whole band in one screen, and each band keeps the zoom you left it at."
],
"fr": [
"Les couleurs de la matrice bandes/modes se choisissent dans Apparence, à partir de celles du thème. Sa légende est traduite aussi.",
"Partage TCI : un retour en réception refusé ne laisse plus le poste bloqué en émission, et le PTT retombe si le logiciel meurt.",
"Icom : un changement de fréquence ou de mode dont laccusé se perd est renvoyé, comme le PTT — en perdre un faisait lâcher JTDX.",
"FlexRadio : laudio d’émission peut basculer sur DAX le temps dun message vocal et revenir après, pour ne pas perdre le micro.",
"Widget rotor : des boutons de rotation rapide que tu nommes et vises toi-même, et un champ azimut où Entrée lance lantenne.",
"Une station portable dans une autre entité nhérite plus de son comté, son état et son locator dorigine : TI8/W2RE sortait à New York.",
"Les confirmations LoTW des QSO FT2, FT4 et FT8 se retrouvent : LoTW répond « DATA », et le mode exact ne correspondait jamais au log.",
"CAT : un décalage transverter, pour quune FI 28 MHz derrière un transverter 144 MHz logue et accorde sur la vraie bande.",
"Carte de bande : le zoom descend à 2 px/kHz pour voir toute la bande dun coup, et chaque bande retient son zoom."
]
},
{
"version": "0.25.8",
"date": "",
+806 -62
View File
File diff suppressed because it is too large Load Diff
+137 -1
View File
@@ -1,9 +1,13 @@
import { useEffect, useState } from 'react';
import { GetRowColors, SaveRowColors } from '../../wailsjs/go/main/App';
import { GetMatrixColors, GetRowColors, SaveMatrixColors, SaveRowColors } from '../../wailsjs/go/main/App';
import { Checkbox } from '@/components/ui/checkbox';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import type { RowColorSettings } from '@/lib/rowColors';
import {
MATRIX_VARS, applyMatrixColors, effectiveMatrixColor, emptyMatrixColors,
type MatrixColors,
} from '@/lib/matrixColors';
// A fixed palette plus a free picker. Muted values on purpose: they are
// composited at low opacity over a dark grid, where a saturated colour reads as
@@ -30,6 +34,111 @@ const CHANNEL_LABELS: Record<string, string> = {
qsl: 'appr.chQsl', lotw: 'LoTW', eqsl: 'eQSL', qrz: 'QRZ.com',
};
// MatrixColorsSection recolours the band/mode matrix — the PH/CW/DIG grid in the
// Stats panel.
//
// The pickers are seeded from what the matrix is painting RIGHT NOW (the active
// theme's ramp, or an existing override), not from a fixed palette: the operator
// starts from the colours in front of them and moves one, instead of being
// handed six values that may belong to a theme they stopped using. Every change
// is applied to the live document at once, so the sample row below is the real
// thing rather than a mock-up of it.
function MatrixColorsSection() {
const { t } = useI18n();
const [cfg, setCfg] = useState<MatrixColors | null>(null);
useEffect(() => {
(async () => {
try {
setCfg((await GetMatrixColors()) as any);
} catch {
setCfg(emptyMatrixColors());
}
})();
}, []);
const save = (next: MatrixColors) => {
setCfg(next);
applyMatrixColors(next); // live, before the round trip — the panel must not lag the choice
SaveMatrixColors(next as any).catch(() => {});
};
// Turning it ON with nothing stored would change nothing at all and read as a
// broken switch, so the empty slots are filled from the theme's current ramp:
// the operator sees six swatches that match the grid and edits from there.
const enable = (on: boolean) => {
if (!cfg) return;
if (!on) {
save({ ...cfg, enabled: false });
return;
}
const seeded = { ...cfg, enabled: true };
for (const { key, cssVar } of MATRIX_VARS) {
if (!String(seeded[key] ?? '').trim()) seeded[key] = effectiveMatrixColor(cssVar);
}
save(seeded);
};
// Reset clears the overrides but keeps the section switched on, then re-seeds
// from the theme — "back to the theme's colours", which is what an operator
// means by reset here, rather than "switch the whole feature off".
const reset = () => {
if (!cfg) return;
applyMatrixColors({ ...emptyMatrixColors(), enabled: false });
const seeded = { ...emptyMatrixColors(), enabled: true };
for (const { key, cssVar } of MATRIX_VARS) seeded[key] = effectiveMatrixColor(cssVar);
save(seeded);
};
if (!cfg) return null;
return (
<div className="space-y-3 border-t border-border/60 pt-4">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={cfg.enabled} className="mt-0.5" onCheckedChange={(c) => enable(!!c)} />
<span>
{t('appr.matrixEnable')}{' '}
<span className="text-xs text-muted-foreground">{t('appr.matrixHint')}</span>
</span>
</label>
{cfg.enabled && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
{MATRIX_VARS.map(({ key, cssVar, label }) => (
<label key={key} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="color"
value={String(cfg[key] || '').trim() || effectiveMatrixColor(cssVar)}
onChange={(e) => save({ ...cfg, [key]: e.target.value })}
className="size-6 rounded-md border border-border bg-transparent p-0 cursor-pointer shrink-0"
/>
{t(label)}
</label>
))}
</div>
{/* The matrix as it will actually look: same tokens, same shapes. */}
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground w-10 shrink-0">{t('appr.matrixSample')}</span>
<span className="inline-block w-7 h-5 rounded bg-mx-call-conf" />
<span className="inline-block w-7 h-5 rounded bg-mx-call-work" />
<span className="inline-block w-7 h-5 rounded bg-mx-dx-conf" />
<span className="inline-block w-7 h-5 rounded bg-mx-dx-work" />
<span className="inline-block w-7 h-5 rounded bg-mx-none" />
<span className="inline-block w-7 h-5 rounded bg-mx-none ring-2 ring-mx-cur ring-inset" />
</div>
<button type="button" onClick={reset}
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground">
{t('appr.matrixReset')}
</button>
</div>
)}
</div>
);
}
export function AppearancePanel() {
const { t } = useI18n();
const [cfg, setCfg] = useState<RowColorSettings | null>(null);
@@ -70,6 +179,31 @@ export function AppearancePanel() {
<span>{t('appr.bandmapLotw')} <span className="text-xs text-muted-foreground">{t('appr.bandmapLotwHint')}</span></span>
</label>
{/* Banding is its own thing, above the QSL rules and outside them: it says
nothing about a contact, it only helps the eye keep its line across a
wide table. Turning the status colours off must not take it away. */}
<div className="space-y-2">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={(cfg as any).recent_zebra !== 'off'} className="mt-0.5"
onCheckedChange={(c) => save({ ...cfg, recent_zebra: c ? '' : 'off' } as any)} />
<span>{t('appr.zebra')} <span className="text-xs text-muted-foreground">{t('appr.zebraHint')}</span></span>
</label>
{(cfg as any).recent_zebra !== 'off' && (
<div className="flex items-center gap-2 pl-6">
<span className="text-xs text-muted-foreground">{t('appr.zebraColor')}</span>
<input type="color" className="size-7 rounded border border-border bg-transparent p-0 cursor-pointer"
value={/^#[0-9a-fA-F]{6}$/.test((cfg as any).recent_zebra_color ?? '') ? (cfg as any).recent_zebra_color : '#808080'}
onChange={(e) => save({ ...cfg, recent_zebra_color: e.target.value } as any)} />
{/* Empty means "follow the theme", and there has to be a way back to
it — a colour chosen under one theme is wrong under the other. */}
{(cfg as any).recent_zebra_color && (
<button type="button" className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => save({ ...cfg, recent_zebra_color: '' } as any)}>{t('appr.zebraAuto')}</button>
)}
</div>
)}
</div>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={cfg.enabled} className="mt-0.5"
onCheckedChange={(c) => save({ ...cfg, enabled: !!c })} />
@@ -148,6 +282,8 @@ export function AppearancePanel() {
))}
</div>
)}
<MatrixColorsSection />
</div>
);
}
+23 -2
View File
@@ -62,6 +62,10 @@ type Preset = { key: string; name: string; field: string; dxcc: number; refs: Aw
// organizational only; matching is driven by the field/pattern/dynamic options,
// so there's no need for separate GRID/DXCC types (use QSOFIELDS + the field).
const AWARD_TYPES = ['REFERENCE', 'QSOFIELDS', 'CALLSIGN'];
// Sentinel for "no QSO field at all". The definition stores an empty field, and
// the matcher already treats that as "nothing to scan" — this only gives the
// operator a way to ASK for it.
const NO_FIELD = '__none__';
const CONFIRM_SRC = [
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
@@ -277,6 +281,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
}
return groups;
}, [testRows]);
const noField = !String(cur?.field ?? '').trim();
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
const toggleIn = (key: keyof AwardDef, v: string) => {
const arr = ((cur?.[key] as string[]) ?? []);
@@ -566,11 +571,26 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<div className="border-t pt-2.5 mt-1 space-y-2.5">
<p className="text-[11px] text-muted-foreground">{t('awed.qsoParams')}</p>
<Field2 label={t('awed.searchInField')}>
<Select value={cur.field} onValueChange={(v) => patch({ field: v })}>
{/* NO_FIELD is a sentinel because the stored value is the
empty string and a Select cannot carry one — an empty
item value means "show the placeholder" to Radix. */}
<Select value={cur.field || NO_FIELD}
onValueChange={(v) => patch({ field: v === NO_FIELD ? '' : v })}>
<SelectTrigger className="h-8 text-xs w-56"><SelectValue /></SelectTrigger>
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
<SelectContent className="max-h-72">
<SelectItem value={NO_FIELD}>{t('awed.fieldNone')}</SelectItem>
{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}
</SelectContent>
</Select>
</Field2>
{/* An award whose references live in no ADIF field at all —
WWBOTA has no column anywhere — has nothing to match on,
and every control below would be a question with no
answer. Hiding them is the point: the references are the
ones the operator assigns to a QSO by hand. */}
{noField ? (
<p className="text-[11px] text-muted-foreground pl-[128px]">{t('awed.fieldNoneHint')}</p>
) : (<>
<Field2 label={t('awed.matchBy')}>
<div className="flex items-center gap-3 text-xs">
{['code', 'description', 'pattern'].map((m) => (
@@ -630,6 +650,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
);
})}
</div>
</>)}
</div>
</TabsContent>
+78 -11
View File
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
import { writeUiPref } from '@/lib/uiPref';
// BandMap — vertical spectrum panel inspired by Log4OM.
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
@@ -82,6 +83,11 @@ interface Props {
// globally from the band-map tab toolbar.
hideDigital?: boolean;
fitToBand?: boolean;
// onToggleFit turns the zoom readout into the fit-to-band switch. Only the
// docked map passes it: the Band Map tab has the same control in its own
// toolbar, above maps that all obey it at once, and a second switch inside
// each card would be four ways to change one setting.
onToggleFit?: () => void;
// Mark stations that upload to LoTW (Settings → Appearance).
showLotw?: boolean;
// keyNav enables Ctrl+↑ / Ctrl+↓ to hop to the next spot above / below the rig
@@ -217,7 +223,37 @@ function statusStyle(s: string): { pill: string; bar: string; line: string; dot:
// Pixels-per-kHz at each zoom step. Base 8 px/kHz means a stacked spot
// every 2.75 kHz fits without anti-overlap kicking in — comfortable for
// most bands. Higher levels are for fine inspection of crowded sub-bands.
const PX_PER_KHZ = [8, 16, 32, 64, 128, 256];
// Zoom steps, px per kHz. 2 and 4 sit below the old floor of 8 so a whole band
// fits without FIT taking the scale away from you: 20 m end to end is 700 px at
// 2 px/kHz, which scrolls in one screen on most displays.
const PX_PER_KHZ = [2, 4, 8, 16, 32, 64, 128, 256];
const DEFAULT_ZOOM_IDX = PX_PER_KHZ.indexOf(32);
// Zoom is remembered PER BAND, in ONE json key rather than one key per band:
// lib/uiPref keeps an explicit list of the preferences that travel with data/,
// and thirteen entries there to say the same thing would be thirteen chances to
// forget one.
const ZOOM_KEY = 'opslog.bandMapZoom';
function readZoomMap(): Record<string, number> {
try {
const m = JSON.parse(localStorage.getItem(ZOOM_KEY) || '{}');
return m && typeof m === 'object' ? m : {};
} catch {
return {};
}
}
function readZoom(band: string): number {
const n = readZoomMap()[band];
return Number.isInteger(n) && n >= 0 && n < PX_PER_KHZ.length ? n : DEFAULT_ZOOM_IDX;
}
function writeZoom(band: string, idx: number): void {
const m = readZoomMap();
m[band] = idx;
writeUiPref(ZOOM_KEY, JSON.stringify(m));
}
const SCALE_W = 56;
const PILL_H = 22; // px — height of each callsign pill
const PILL_GAP = 32; // px between scale border and first pill (room for leader)
@@ -232,7 +268,7 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
// last; ties broken by closeness to the rig freq).
const MAX_VISIBLE_SPOTS = 30;
export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, keyNav = false, showLotw = false }: Props) {
export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, onToggleFit, keyNav = false, showLotw = false }: Props) {
const { t } = useI18n();
// The two display options are applied ONCE here, on the whole map, so the
@@ -252,7 +288,22 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight]);
const range = BAND_RANGES[band];
const segments = SEGMENT_COLORS[band] ?? [];
const [zoomIdx, setZoomIdx] = useState(2); // default 32 px/kHz
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
// The docked map follows the rig, so a band change must bring up THAT band's
// remembered zoom.
useEffect(() => { setZoomIdx(readZoom(band)); }, [band]);
// Stored from the interaction, not from an effect on zoomIdx: an effect would
// also fire on the band change above, and in the same commit it would still be
// holding the PREVIOUS band's index — writing it over the new band's. Doing it
// where the operator actually turns the wheel keeps the two apart. (Running
// twice under StrictMode is harmless: the same value is stored.)
const changeZoom = useCallback((delta: number) => {
setZoomIdx((z) => {
const n = Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + delta));
if (n !== z) writeZoom(band, n);
return n;
});
}, [band]);
const scrollerRef = useRef<HTMLDivElement | null>(null);
const [containerH, setContainerH] = useState(400);
@@ -421,7 +472,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
if (!range) return;
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
setZoomIdx((z) => Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + (e.deltaY > 0 ? -1 : 1))));
changeZoom(e.deltaY > 0 ? -1 : 1);
}
};
el.addEventListener('wheel', onWheel, { passive: false });
@@ -476,8 +527,10 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
// Tick step (where small marks land) and label step (where the kHz
// number is printed) are decoupled so the scale shows ~10-20 numeric
// labels per viewport regardless of zoom — at base 8 px/kHz we want
// labels every 25 kHz, not every 250.
// labels per viewport regardless of zoom — at 8 px/kHz we want labels
// every 25 kHz, not every 250. The bare values below are the floor, and
// they are what the 2 px/kHz step lands on: a whole band in one screen
// wants a number every 100 kHz, not every 25.
let tickStep = 50;
let labelStep = 100;
if (pxPerKHz >= 4) { tickStep = 25; labelStep = 50; }
@@ -502,14 +555,28 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
return (
<div className="h-full w-full flex flex-col min-h-0 bg-card">
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex flex-nowrap items-center gap-0.5 shrink-0">
<span className="flex-1 min-w-0 truncate">{t('bmp.map')} · {band}</span>
<button type="button" onClick={() => setZoomIdx((z) => Math.max(0, z - 1))} disabled={fitToBand || zoomIdx === 0}
{/* The band alone. "Map · 20M" spent a third of a narrow header saying
what the panel obviously is, and with four band maps side by side it
was four times the same word. */}
<span className="flex-1 min-w-0 truncate">{band}</span>
<button type="button" onClick={() => changeZoom(-1)} disabled={fitToBand || zoomIdx === 0}
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
title={t('bmp.zoomOut')}>
<Minus className="size-3" />
</button>
<span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}</span>
<button type="button" onClick={() => setZoomIdx((z) => Math.min(PX_PER_KHZ.length - 1, z + 1))} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
{/* The readout IS the switch when the parent offers one — the header is
already tight with four maps side by side, and a separate chip would
cost width to say what this text says anyway. */}
{onToggleFit ? (
<button type="button" onClick={onToggleFit} title={t('bmp.fitTitle')}
className={cn('shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-1 rounded border transition-colors',
fitToBand ? 'border-primary bg-primary text-primary-foreground' : 'border-border hover:bg-muted')}>
{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}
</button>
) : (
<span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}</span>
)}
<button type="button" onClick={() => changeZoom(1)} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
title={t('bmp.zoomIn')}>
<Plus className="size-3" />
+23 -19
View File
@@ -4,6 +4,7 @@ import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { sunTimes } from '@/lib/sun';
import { isQSLConfirmed } from '@/lib/qsl';
import { useI18n } from '@/lib/i18n';
import { BandSlotQSOs } from '../../wailsjs/go/main/App';
import type { WorkedBeforeView } from '@/types';
@@ -76,28 +77,31 @@ const STATUS_CLASSES: Record<string, string> = {
dxcc_w: 'bg-mx-dx-work',
};
// Legend entries, in the same colour order as the cells. swatch = the
// background class (or a special ring marker for the current-entry cell).
// Legend entries, in the same colour order as the cells — and the same order and
// i18n keys the Appearance panel's colour pickers use, so the two can never
// disagree about which green is which. swatch = the background class (or a
// special ring marker for the current-entry cell).
const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [
{ swatch: 'bg-mx-call-conf', label: 'Call confirmed' },
{ swatch: 'bg-mx-call-work', label: 'Call worked' },
{ swatch: 'bg-mx-dx-conf', label: 'Entity confirmed' },
{ swatch: 'bg-mx-dx-work', label: 'Entity worked' },
{ swatch: 'bg-mx-none', label: 'Not worked' },
{ swatch: 'bg-mx-none', ring: true, label: 'Current entry' },
{ swatch: 'bg-mx-call-conf', label: 'mx.callConf' },
{ swatch: 'bg-mx-call-work', label: 'mx.callWork' },
{ swatch: 'bg-mx-dx-conf', label: 'mx.dxConf' },
{ swatch: 'bg-mx-dx-work', label: 'mx.dxWork' },
{ swatch: 'bg-mx-none', label: 'mx.none' },
{ swatch: 'bg-mx-none', ring: true, label: 'mx.current' },
];
function cellTitle(band: string, cls: string, status: string, current: boolean): string {
function cellTitle(t: (k: string) => string, band: string, cls: string, status: string, current: boolean): string {
const desc =
status === 'call_c' ? 'This callsign confirmed' :
status === 'call_w' ? 'This callsign worked (not confirmed)' :
status === 'dxcc_c' ? 'Entity confirmed (other callsign)' :
status === 'dxcc_w' ? 'Entity worked (other callsign)' :
'Never worked';
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
status === 'call_c' ? t('mx.tipCallConf') :
status === 'call_w' ? t('mx.tipCallWork') :
status === 'dxcc_c' ? t('mx.tipDxConf') :
status === 'dxcc_w' ? t('mx.tipDxWork') :
t('mx.tipNone');
return `${band} ${cls}: ${desc}${current ? ' — ' + t('mx.current') : ''}`;
}
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
const { t } = useI18n();
// Cell drill-down: which band+class the operator clicked, or null.
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
// Columns from the operator's configured bands (so the matrix shows only the
@@ -308,7 +312,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
return (
<td
key={b.tag}
title={cellTitle(b.tag, cls, st, isCurrent) + (st ? ' — click to list the QSOs' : '')}
title={cellTitle(t, b.tag, cls, st, isCurrent) + (st ? ' — ' + t('mx.tipClick') : '')}
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
className={cn(
'w-[28px] h-[24px] rounded transition-colors p-0',
@@ -316,7 +320,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
// Only a filled cell has anything to show — an empty one
// stays inert rather than opening a "no QSOs" dialog.
st && 'cursor-pointer hover:brightness-110',
isCurrent && 'ring-2 ring-warning ring-inset',
isCurrent && 'ring-2 ring-mx-cur ring-inset',
)}
/>
);
@@ -335,10 +339,10 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
className={cn(
'inline-block size-3 rounded shrink-0',
l.swatch,
l.ring && 'ring-2 ring-warning ring-inset',
l.ring && 'ring-2 ring-mx-cur ring-inset',
)}
/>
{l.label}
{t(l.label)}
</span>
))}
</div>
+23 -4
View File
@@ -69,6 +69,7 @@ export type SpotStatusEntry = {
spotter_continent?: string;
grid?: string;
new_grid?: boolean;
grid_state?: string;
};
type Props = {
@@ -266,12 +267,12 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
if (s?.new_county) parts.push(t('clg2.newCounty'));
if (s?.new_pota) parts.push(t('clg2.newPota'));
if (s?.new_pfx) parts.push(t('clg2.newPfx'));
if (s?.new_grid) parts.push(t('clg2.newGrid'));
if (s?.new_grid) parts.push(s.grid_state === 'unconf' ? t('clg2.newGridUnconf') : t('clg2.newGrid'));
return parts.join(' ');
},
cellRenderer: (p: any) => {
const s = statusFor(p);
const parts: { text: string; color: string }[] = [];
const parts: { text: string; color: string; dim?: boolean }[] = [];
const main = statusColor(s);
if (main) {
const label = s?.status === 'new' ? t('clg2.newDxcc')
@@ -287,12 +288,19 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county') });
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: markerColour('new_pota') });
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx') });
if (s?.new_grid) parts.push({ text: t('clg2.newGrid'), color: markerColour('new_grid') });
// Worked-but-unconfirmed is a QSL to chase, not a QSO to make. Same hue
// held back, so it reads as "less" of the same thing rather than a
// different fact — and the label says which.
if (s?.new_grid) {
parts.push(s.grid_state === 'unconf'
? { text: t('clg2.newGridUnconf'), color: markerColour('new_grid'), dim: true }
: { text: t('clg2.newGrid'), color: markerColour('new_grid') });
}
if (parts.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
return (
<span style={{ whiteSpace: 'nowrap' }}>
{parts.map((pt, i) => (
<span key={i} style={{ color: pt.color, fontWeight: 700 }}>
<span key={i} style={{ color: pt.color, fontWeight: 700, opacity: pt.dim ? 0.5 : 1 }}>
{i > 0 ? ' · ' : ''}{pt.text}
</span>
))}
@@ -638,6 +646,17 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
{t('clg2.pickerDesc')}
</DialogDescription>
</DialogHeader>
{/* Whole-dialog controls. showAll/hideAll with no group already mean
"every column"; only the buttons were missing, so hiding the lot
took one click per group. */}
<div className="flex items-center gap-1 px-1 text-[11px] text-muted-foreground">
<span>{t('clg2.allGroups')}</span>
<button type="button" className="text-[11px] text-primary hover:underline px-1"
onClick={() => showAll()}>{t('clg2.all')}</button>
<span className="opacity-40">·</span>
<button type="button" className="text-[11px] text-muted-foreground hover:underline px-1"
onClick={() => hideAll()}>{t('clg2.none')}</button>
</div>
<div className="max-h-[60vh] overflow-y-auto py-2">
{GROUP_ORDER.map((group) => {
const cols = COL_CATALOG.filter((c) => c.group === group);
File diff suppressed because it is too large Load Diff
+24 -3
View File
@@ -86,6 +86,13 @@ export function ExportFieldsDialog({ open, count, onExport, onClose }: {
return [...m.entries()];
}, [official]);
// Every tag the dialog can offer — the extras AND every group — so "select
// all" means what it says rather than "all the official ones".
const everyTag = useMemo(
() => [...extras, ...groups.flatMap(([, list]) => list.map((d) => d.name))],
[extras, groups],
);
const toggle = (name: string, on: boolean) =>
setSel((s) => { const n = new Set(s); if (on) n.add(name); else n.delete(name); return n; });
const setMany = (names: string[], on: boolean) =>
@@ -114,11 +121,25 @@ export function ExportFieldsDialog({ open, count, onExport, onClose }: {
<DialogDescription>{t('exf.desc')}</DialogDescription>
</DialogHeader>
{/* Whole-dialog select/clear, next to the count they change. Per-group
links alone meant clearing the selection was one click PER GROUP —
a dozen of them to get to "just the handful I want". */}
<div className="flex items-center gap-2 px-1 text-[11px] text-muted-foreground">
<span>{t('exf.chosen', { n: sel.size })}</span>
<Button variant="ghost" size="sm" className="ml-auto h-6 text-[11px]" onClick={() => setSel(defaultSet(official))}>
{t('exf.defaults')}
</Button>
<span className="ml-auto inline-flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-6 text-[11px]"
onClick={() => setSel(new Set(everyTag))}>
{t('exf.selectAll')}
</Button>
<Button variant="ghost" size="sm" className="h-6 text-[11px]"
disabled={sel.size === 0}
onClick={() => setSel(new Set())}>
{t('exf.selectNone')}
</Button>
<Button variant="ghost" size="sm" className="h-6 text-[11px]" onClick={() => setSel(defaultSet(official))}>
{t('exf.defaults')}
</Button>
</span>
</div>
<div className="grid grid-cols-3 gap-3 max-h-[56vh] overflow-y-auto pr-1">
+272
View File
@@ -0,0 +1,272 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { Loader2, RefreshCw } from 'lucide-react';
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 { writeUiPref } from '@/lib/uiPref';
// GridSquareMap — every Maidenhead square in the log, drawn on a world map.
//
// A square, not a marker. The question this answers is "where have I been
// heard", and the shape of the answer is an area: a wall of squares across the
// Atlantic and a bare Pacific says something about an antenna that a scatter of
// pins does not. Confirmed and merely worked are two fills of the same hue —
// the distinction is a STATE of one thing, not two categories, so it must not
// be two unrelated colours.
//
// 4 characters, never 6: at world zoom a 6-character square is sub-pixel, and
// aggregating to the big square is also how the count means "contacts in this
// square" rather than "contacts at this exact spot".
type Square = { grid: string; count: number; confirmed: boolean; band?: string; mode?: string };
// The SAME basemaps the Main-tab world map offers, imported rather than copied:
// two lists of tile servers is how one map ends up on a provider the other has
// already been blocked by. This map opened on a hardcoded dark Carto, which is
// the least legible of the four under translucent squares.
// Leaflet geometry is painted onto a CANVAS, and canvas takes a colour, not a
// stylesheet: "var(--success)" handed to fillStyle is simply invalid and the
// shape is silently not drawn — a map with a correct square count and nothing
// on it. So the token is RESOLVED to its literal value here, once per theme,
// which is also why every other map in this app passes hex.
function cssColour(token: string, fallback: string): string {
try {
const v = getComputedStyle(document.documentElement).getPropertyValue(token).trim();
return v || fallback;
} catch { return fallback; }
}
// Mode scope. Two, because the map lives beside a panel that decodes FTx: CW,
// phone and "everything" answer a question nobody is asking here, and a row of
// choices that are never the right one is just noise to read past.
//
// Digital is every digital mode; FTx is the FT family alone, which is the
// narrower and usually the honest one — a square worked on RTTY in a contest is
// not a square worked on FT8.
const SCOPES = [
{ key: 'DIGI', label: 'gsm.digital' },
{ key: 'FTX', label: 'gsm.ftx' },
] as const;
type ScopeKey = typeof SCOPES[number]['key'];
const SCOPE_KEY = 'opslog.gridMapScope';
// Chosen fill colours. Empty means "follow the theme", which is the default and
// stays the default: the tokens already track the four themes, and freezing a
// hex at first run would leave a dark-theme map painted in the light palette.
const COL_CONFIRMED_KEY = 'opslog.gridMapColorConfirmed';
const COL_WORKED_KEY = 'opslog.gridMapColorWorked';
// A colour input only accepts #rrggbb. The theme tokens ARE plain hex today, so
// this normally just passes them through — but a token that ever becomes oklch()
// or a named colour would silently drive the swatch to black, and a fallback is
// cheaper than that debugging session.
function asHex(v: string, fallback: string): string {
return /^#[0-9a-f]{6}$/i.test(v.trim()) ? v.trim() : fallback;
}
export function GridSquareMap({ myGrid, className }: { myGrid?: string; className?: string }) {
const { t } = useI18n();
const hostRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const layerRef = useRef<L.LayerGroup | null>(null);
const [squares, setSquares] = useState<Square[] | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const [scope, setScope] = useState<ScopeKey>(
() => (SCOPES.some((s) => s.key === localStorage.getItem(SCOPE_KEY))
? (localStorage.getItem(SCOPE_KEY) as ScopeKey) : 'DIGI'));
const [basemap, setBasemap] = useState<BasemapKey>(loadBasemap);
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
// from theme tokens resolved at draw time, so a theme switch leaves them on
// the old palette until something forces a redraw.
const [themeTick, setThemeTick] = useState(0);
useEffect(() => {
const obs = new MutationObserver(() => setThemeTick((n) => n + 1));
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => obs.disconnect();
}, []);
const load = async (sc: ScopeKey = scope) => {
setBusy(true); setErr('');
try {
const r = (await GridSquares(sc)) as any;
setSquares((Array.isArray(r) ? r : []) as Square[]);
} catch (e: any) {
setErr(String(e?.message ?? e));
setSquares([]);
} finally { setBusy(false); }
};
useEffect(() => { void load(scope); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [scope]);
// One-time map creation. preferCanvas: a busy digital log is a few thousand
// rectangles, and as SVG that is a few thousand DOM nodes to lay out on every
// pan.
useEffect(() => {
if (!hostRef.current || mapRef.current) return;
const m = L.map(hostRef.current, {
preferCanvas: true,
zoomControl: true,
attributionControl: true,
worldCopyJump: true,
minZoom: 1,
}).setView([25, 0], 2);
mapRef.current = m;
layerRef.current = L.layerGroup().addTo(m);
// Leaflet measures its container ONCE, when the map is created, and never
// looks again. Here that measurement happens while the panel is still
// laying out — so the map kept the height it had at that instant and the
// rest of the panel stayed blank underneath it, whatever the window size.
// Watching the host is the only fix that also survives a window resize, a
// split-pane drag and the tab being shown for the first time.
const ro = new ResizeObserver(() => m.invalidateSize({ animate: false }));
ro.observe(hostRef.current);
return () => { ro.disconnect(); m.remove(); mapRef.current = null; layerRef.current = null; };
}, []);
// The chosen basemap, shared with the Main-tab map so picking one there and
// finding another here cannot happen.
const baseRef = useRef<L.TileLayer | null>(null);
const labelsRef = useRef<L.TileLayer | null>(null);
useEffect(() => {
const m = mapRef.current;
if (!m) return;
addBasemap(m, basemap, baseRef, labelsRef);
baseRef.current?.bringToBack();
}, [basemap]);
// Redraw the squares.
useEffect(() => {
const layer = layerRef.current;
if (!layer) return;
layer.clearLayers();
// Resolved once for the whole redraw, not per square: getComputedStyle
// forces a style flush, and doing that a thousand times is a visible stall.
const confirmedColour = confColour || cssColour('--success', '#16a34a');
const workedFill = workedColour || cssColour('--chart-1', '#2a78d6');
const meColour = cssColour('--warning', '#f59e0b');
for (const sq of squares ?? []) {
const b = gridSquareBounds(sq.grid);
if (!b) continue;
// One hue, two states. Confirmed is the solid, saturated one; worked is
// the same colour held back — so the eye reads "more" and "less" of the
// same thing rather than two unrelated facts.
const colour = sq.confirmed ? confirmedColour : workedFill;
L.rectangle([[b.south, b.west], [b.north, b.east]], {
color: colour,
weight: 0.5,
opacity: sq.confirmed ? 0.9 : 0.5,
fillColor: colour,
fillOpacity: sq.confirmed ? 0.55 : 0.22,
})
.bindTooltip(
`${sq.grid}${sq.count} QSO${sq.count > 1 ? 's' : ''}` +
`${sq.band ? ` · ${sq.band}` : ''}${sq.mode ? ` ${sq.mode}` : ''}` +
`${sq.confirmed ? ` · ${t('gsm.confirmed')}` : ''}`,
{ sticky: true },
)
.addTo(layer);
}
// The operator's own square, so the pattern has an origin to be read from.
const me = myGrid ? gridToLatLon(myGrid) : null;
if (me) {
L.circleMarker([me.lat, me.lon], {
radius: 4, color: meColour, weight: 2,
fillColor: meColour, fillOpacity: 1,
}).bindTooltip(myGrid!.toUpperCase(), { sticky: true }).addTo(layer);
}
}, [squares, myGrid, t, themeTick, confColour, workedColour]);
const stats = useMemo(() => {
const list = squares ?? [];
return { total: list.length, confirmed: list.filter((s) => s.confirmed).length };
}, [squares]);
return (
// isolate is load-bearing, not tidiness. Leaflet puts its panes at z-index
// 400 and its zoom control at 1000, in the PAGE's stacking context — and the
// modals here are z-50. Without a stacking context of its own the map floats
// over Preferences and hides the Save and Close buttons. isolation:isolate
// confines every z-index Leaflet sets to this element.
<section className={cn('isolate relative flex flex-col min-h-0 rounded-lg border border-border bg-card overflow-hidden', className)}>
<header className="flex items-center gap-2 px-2.5 py-1.5 border-b border-border bg-muted/40 shrink-0 flex-wrap">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('gsm.title')}
</span>
<div className="inline-flex rounded-md border border-border overflow-hidden">
{SCOPES.map((s, i) => (
<button key={s.key} type="button"
onClick={() => { setScope(s.key); try { localStorage.setItem(SCOPE_KEY, s.key); } catch { /* quota */ } }}
className={cn('px-1.5 h-6 text-[11px] whitespace-nowrap', i > 0 && 'border-l border-border',
scope === s.key ? 'bg-primary text-primary-foreground' : 'hover:bg-muted text-muted-foreground')}>
{t(s.label)}
</button>
))}
</div>
<span className="text-[11px] text-muted-foreground tabular-nums">
{t('gsm.count', { n: stats.total, c: stats.confirmed })}
</span>
<select
value={basemap}
onChange={(e) => { const v = e.target.value as BasemapKey; setBasemap(v); writeUiPref('opslog.mapBasemap', v); }}
title={t('gsm.basemap')}
className="h-6 rounded border border-border bg-background px-1 text-[11px]"
>
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
<option key={k} value={k}>{BASEMAPS[k].label}</option>
))}
</select>
{/* Beside the basemap, because they answer the same question: what this
map looks like. Written straight through — there is no Save here. */}
<label className="inline-flex items-center gap-1 text-[11px] text-muted-foreground" title={t('gsm.colConfirmed')}>
<input type="color" className="size-5 rounded border border-border bg-transparent p-0 cursor-pointer"
value={asHex(confColour || cssColour('--success', '#16a34a'), '#16a34a')}
onChange={(e) => { setConfColour(e.target.value); writeUiPref(COL_CONFIRMED_KEY, e.target.value); }} />
{t('gsm.confirmed')}
</label>
<label className="inline-flex items-center gap-1 text-[11px] text-muted-foreground" title={t('gsm.colWorked')}>
<input type="color" className="size-5 rounded border border-border bg-transparent p-0 cursor-pointer"
value={asHex(workedColour || cssColour('--chart-1', '#2a78d6'), '#2a78d6')}
onChange={(e) => { setWorkedColour(e.target.value); writeUiPref(COL_WORKED_KEY, e.target.value); }} />
{t('gsm.worked')}
</label>
{(confColour || workedColour) && (
<button type="button" title={t('gsm.colReset')}
onClick={() => {
setConfColour(''); setWorkedColour('');
writeUiPref(COL_CONFIRMED_KEY, ''); writeUiPref(COL_WORKED_KEY, '');
}}
className="text-[11px] text-muted-foreground hover:text-foreground px-1"></button>
)}
<span className="flex-1" />
<button type="button" onClick={() => void load()} disabled={busy} title={t('gsm.refresh')}
className="inline-flex items-center justify-center size-6 rounded border border-border hover:bg-muted disabled:opacity-50">
{busy ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
</button>
</header>
{err && <p className="px-2.5 py-1 text-[11px] text-destructive shrink-0">{err}</p>}
{/* The map host must have a real height or Leaflet renders nothing at all
— flex-1 + min-h-0, never a percentage. */}
<div ref={hostRef} className="flex-1 min-h-0" />
<footer className="flex items-center gap-3 px-2.5 py-1 border-t border-border bg-muted/30 shrink-0 text-[10px] text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span className="inline-block size-2.5 rounded-[2px]"
style={{ background: confColour || 'var(--success)', opacity: 0.75 }} />
{t('gsm.confirmed')}
</span>
<span className="inline-flex items-center gap-1.5">
<span className="inline-block size-2.5 rounded-[2px]"
style={{ background: workedColour || 'var(--chart-1)', opacity: 0.35 }} />
{t('gsm.worked')}
</span>
</footer>
</section>
);
}
+4 -4
View File
@@ -61,8 +61,8 @@ const ESRI_STREET_ATTR = 'Tiles &copy; Esri — Source: Esri, HERE, Garmin, &cop
// Selectable basemaps for the world (great-circle) map. All key-free and all
// LABELLED (country/continent names). `labelsUrl` adds a transparent place-name
// overlay on top of an imagery basemap (so satellite keeps its names too).
type BasemapKey = 'light' | 'voyager' | 'street' | 'satellite';
const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: string; subdomains?: string; labelsUrl?: string }> = {
export type BasemapKey = 'light' | 'voyager' | 'street' | 'satellite';
export const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: string; subdomains?: string; labelsUrl?: string }> = {
light: { label: 'Light', url: CARTO_LIGHT, attr: CARTO_ATTR, subdomains: 'abcd' },
voyager: { label: 'Voyager', url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
attr: CARTO_ATTR, subdomains: 'abcd' },
@@ -71,7 +71,7 @@ const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: string; s
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}' },
};
function loadBasemap(): BasemapKey {
export function loadBasemap(): BasemapKey {
const v = localStorage.getItem('opslog.mapBasemap');
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
}
@@ -80,7 +80,7 @@ function loadBasemap(): BasemapKey {
// place-name overlay. updateWhenIdle/keepBuffer keep the number of live tiles
// down: satellite loads TWO tile layers, so its tile count — and the composited
// layers WebView2 has to hold — is double every other basemap's.
function addBasemap(
export function addBasemap(
m: L.Map,
key: BasemapKey,
base: React.MutableRefObject<L.TileLayer | null>,
+31 -1
View File
@@ -111,7 +111,33 @@ export function fmtQslDate(s?: string): string {
// QSL Manager as an in-app tab panel: upload logged QSOs to online logbooks
// and download confirmations, while the rest of the app stays usable.
export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
// GridActions are the row actions the context menu offers, forwarded straight to
// whichever grid this panel is showing.
//
// The FILTERED exports are deliberately absent. "Export everything the filter
// matches" means the Recent-QSOs filter, and the rows here come from a different
// query entirely — offering it would export something other than what is on the
// screen, which is the one thing an export must never do. Selection-based
// exports have no such ambiguity: the ids are the rows you ticked.
export type GridActions = {
onUpdateFromCty?: (ids: number[]) => void;
onUpdateFromQRZ?: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
onUpdateCountyFromULS?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void;
onBulkEdit?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void;
onExportSelectedFields?: (ids: number[]) => void;
onExportCabrilloSelected?: (ids: number[]) => void;
onDelete?: (ids: number[]) => void;
};
export function QSLManagerPanel({ onEditQSO, actions }: {
onEditQSO?: (id: number) => void;
actions?: GridActions;
} = {}) {
const { t } = useI18n();
const [service, setService] = useState('lotw');
// The callsign this profile signs/uploads/downloads as for the selected
@@ -443,6 +469,8 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
total={paperRows.length}
selectAllSignal={paperSelAllSig}
onRowSelected={(ids) => setPaperSel(new Set(ids))}
{...actions}
onRowDoubleClicked={onEditQSO ? (q) => onEditQSO(q.id as number) : undefined}
/>
</div>
)
@@ -549,6 +577,8 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
total={rows.length}
selectAllSignal={uploadSelAllSig}
onRowSelected={onUploadRowSelected}
{...actions}
onRowDoubleClicked={onEditQSO ? (q) => onEditQSO(q.id as number) : undefined}
/>
</div>
)}
+24 -16
View File
@@ -7,8 +7,8 @@ export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
type Props = {
menu: QSOMenuState;
onClose: () => void;
onUpdateFromCty: (ids: number[]) => void;
onUpdateFromQRZ: (ids: number[]) => void;
onUpdateFromCty?: (ids: number[]) => void;
onUpdateFromQRZ?: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
// Only passed once the offline US county database has been downloaded —
// an entry that can only ever answer "no database" is not worth a line here.
@@ -98,20 +98,28 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
{t('qctx.selected', { n })}
</div>
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onUpdateFromCty(menu.ids); onClose(); }}
>
<Globe2 className="size-4 text-primary" />
<span>{t('qctx.fixCountry')}</span>
</button>
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onUpdateFromQRZ(menu.ids); onClose(); }}
>
<RefreshCw className="size-4 text-info" />
<span>{t('qctx.updateQrz')}</span>
</button>
{/* Conditional like every other entry. These two were drawn
unconditionally, and the grid passes them through an arrow that calls
an optional handler — so in a grid that wires neither (the QSL
Manager) both appeared and did precisely nothing when clicked. */}
{onUpdateFromCty && (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onUpdateFromCty(menu.ids); onClose(); }}
>
<Globe2 className="size-4 text-primary" />
<span>{t('qctx.fixCountry')}</span>
</button>
)}
{onUpdateFromQRZ && (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onUpdateFromQRZ(menu.ids); onClose(); }}
>
<RefreshCw className="size-4 text-info" />
<span>{t('qctx.updateQrz')}</span>
</button>
)}
{onUpdateFromClublog && (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
+31 -3
View File
@@ -631,6 +631,20 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
api.setColumnsVisible(ids, false);
saveColumnState();
}
// Whole-dialog show/hide. showAll/hideAll cover the CATALOGUE only; the award
// columns live in their own persisted code set, so a global that skipped them
// would leave a row of columns behind and look broken.
function setEverything(visible: boolean) {
const api = gridRef.current?.api;
if (!api) return;
api.setColumnsVisible(COL_CATALOG.map((c) => c.colId!), visible);
if (awardCols && awardCols.length > 0) {
persistAwardShown(visible ? new Set(awardCols.map((a) => a.code.toUpperCase())) : new Set());
api.setColumnsVisible(awardCols.map((a) => `award_${a.code}`), visible);
}
saveColumnState();
}
function resetDefaults() {
const api = gridRef.current?.api;
if (!api) return;
@@ -706,16 +720,20 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
animateRows={false}
suppressCellFocus
getRowId={(p) => String((p.data as any).id)}
getRowStyle={(p) => rowStyleFor(p.data, rowColors ?? null)}
getRowStyle={(p) => rowStyleFor(p.data, rowColors ?? null, p.node.rowIndex ?? undefined)}
/>
</div>
</div>
{/* Every handler is passed STRAIGHT through, never wrapped in an arrow: a
wrapper is always truthy, so the menu drew "Fix country" and "Update
from the callsign databases" even in a grid that wired neither — and
clicking them did nothing at all. */}
<QSOContextMenu
menu={menu}
onClose={() => setMenu(null)}
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
onUpdateFromCty={onUpdateFromCty}
onUpdateFromQRZ={onUpdateFromQRZ}
onUpdateFromClublog={onUpdateFromClublog}
onUpdateCountyFromULS={onUpdateCountyFromULS}
onSendTo={onSendTo}
@@ -738,6 +756,16 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
{t('rqg.pickerDesc')}
</DialogDescription>
</DialogHeader>
{/* Whole-dialog controls. The per-group links alone meant hiding
everything was one click PER GROUP, and there are a dozen. */}
<div className="flex items-center gap-1 px-5 text-[11px] text-muted-foreground">
<span>{t('rqg.pickerAllGroups')}</span>
<button type="button" className="text-[11px] text-primary hover:underline px-1"
onClick={() => setEverything(true)}>{t('rqg.all')}</button>
<span className="opacity-40">·</span>
<button type="button" className="text-[11px] text-muted-foreground hover:underline px-1"
onClick={() => setEverything(false)}>{t('rqg.none')}</button>
</div>
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
{GROUP_ORDER.map((group) => {
const cols = COL_CATALOG.filter((c) => c.group === group);
+178 -26
View File
@@ -5,12 +5,13 @@
// 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 { useMemo } from 'react';
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 } from 'lucide-react';
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);
@@ -29,6 +30,11 @@ interface Props {
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;
@@ -41,7 +47,58 @@ function pt(az: number, radius: number): [number, number] {
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
}
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose }: Props) {
export function RotorCompass({ 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' } ],
@@ -72,6 +129,35 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
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. */}
@@ -128,9 +214,13 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
</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 p-2 min-h-0">
<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'}
@@ -191,30 +281,92 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
</svg>
{/* 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.
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. */}
<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 ' +
(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)}°`}
{/* 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>
);
+361 -37
View File
@@ -11,6 +11,7 @@ import {
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
GetAntGeniusSettings, SaveAntGeniusSettings,
GetTunerGeniusSettings, SaveTunerGeniusSettings,
@@ -54,7 +55,7 @@ import {
GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow,
GetRelayAuto, SaveRelayAuto, GetStationDevices,
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes,
GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -75,12 +76,14 @@ import {
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { writeUiPref } from '@/lib/uiPref';
import { loadAutoCall, autoCallKey, type AutoCallSettings, type AutoCallCriteria } from '@/lib/autocall';
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
import { OperatingPanel } from '@/components/OperatingPanel';
import { AppearancePanel } from '@/components/AppearancePanel';
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
import { loadClusterMacros, saveClusterMacros, type ClusterMacro } from '@/lib/clusterMacros';
type LookupSettings = LookupSettingsForm;
type StationSettings = StationSettingsForm;
@@ -165,7 +168,7 @@ interface Props {
initialSection?: string;
onClose: () => void;
onSaved: () => void;
onMainPaneChanged?: (side: 'left' | 'right', value: string) => void; // live Main-view layout update
onMainPaneChanged?: (side: 'left' | 'right' | 'p3' | 'p4' | 'layout', value: string) => void; // live Main-view layout update
flexAvailable?: boolean; // CAT backend is FlexRadio → offer it as a Main pane
icomAvailable?: boolean; // CAT backend is Icom → offer the Icom console as a Main pane
yaesuAvailable?: boolean; // CAT backend is Yaesu → offer the Yaesu console as a Main pane
@@ -201,6 +204,7 @@ type SectionId =
| 'uscounties'
| 'awards'
| 'cat'
| 'ftx'
| 'rotator'
| 'winkeyer'
| 'antenna'
@@ -305,6 +309,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
{ kind: 'item', label: t('sec.modes'), id: 'lists-modes' },
]},
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
{ kind: 'item', label: t('sec.ftx'), id: 'ftx' },
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
{ kind: 'item', label: t('sec.adifmon'), id: 'adifmon' },
{ kind: 'item', label: t('sec.foldersync'), id: 'foldersync' },
@@ -324,7 +329,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
const SECTION_KEY: Partial<Record<SectionId, string>> = {
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
'external-services': 'sec.external', appearance: 'sec.appearance', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
cluster: 'sec.cluster', ftx: 'sec.ftx', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
adifmon: 'sec.adifmon',
foldersync: 'sec.foldersync',
webpublish: 'sec.webpublish',
@@ -1024,15 +1029,16 @@ function RelayAutoPanel() {
);
}
// MainViewPanes lets the operator choose what the Main tab's left and right
// panes show, independently: the great-circle map, the locator street map, the
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
// which is profile-prefixed). Self-contained so it owns its async-loaded state.
const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol'];
function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean; yaesuAvailable?: boolean }) {
// MainViewPanes lets the operator choose what the Main tab shows, in up to four
// panes. The first two are the view; the third and fourth can be left empty, and
// an empty one is not drawn at all. Per-profile (stored via SetUIPref, which is
// profile-prefixed). Self-contained so it owns its async-loaded state.
const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol', 'decodes'];
const PANE_NONE = 'none';
function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable }: { onChanged?: (side: 'left' | 'right' | 'p3' | 'p4' | 'layout', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean; yaesuAvailable?: boolean }) {
const { t } = useI18n();
const [left, setLeft] = useState('map1');
const [right, setRight] = useState('map2');
const [panes, setPanes] = useState<Record<string, string>>({ left: 'map1', right: 'map2', p3: PANE_NONE, p4: PANE_NONE });
const [layout, setLayout] = useState('quad');
// Radio-control panes are only offered when that CAT backend is active. Sorted A→Z.
const options = [
...MAIN_PANE_VALUES,
@@ -1041,42 +1047,77 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable
...(yaesuAvailable ? ['yaesu'] : []),
].map((value) => ({ value, label: t(`settings.pane.${value}`) }))
.sort((a, b) => a.label.localeCompare(b.label));
const KEYS: Record<string, string> = { left: 'mainPaneLeft', right: 'mainPaneRight', p3: 'mainPane3', p4: 'mainPane4' };
useEffect(() => {
const valid = (v: string) => v === 'flex' || v === 'icom' || v === 'yaesu' || MAIN_PANE_VALUES.includes(v);
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
Promise.all([
...Object.values(KEYS).map((k) => GetUIPref(k).catch(() => '')),
GetUIPref('mainPaneLayout').catch(() => ''),
]).then(([l, r, p3, p4, lay]) => {
setPanes({
left: valid(l) ? l : 'map1',
right: valid(r) ? r : 'map2',
p3: valid(p3) ? p3 : PANE_NONE,
p4: valid(p4) ? p4 : PANE_NONE,
});
if (lay === 'cols' || lay === 'quad') setLayout(lay);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const pick = (side: 'left' | 'right', v: string) => {
if (side === 'left') setLeft(v); else setRight(v);
const pick = (side: string, v: string) => {
setPanes((p) => ({ ...p, [side]: v }));
// Persist (per-profile) AND tell the parent the new value directly, so the
// Main view updates from the chosen value — never a stale DB re-read.
SetUIPref(side === 'left' ? 'mainPaneLeft' : 'mainPaneRight', v).catch(() => {});
onChanged?.(side, v);
SetUIPref(KEYS[side], v).catch(() => {});
onChanged?.(side as any, v);
};
const pickLayout = (v: string) => {
setLayout(v);
SetUIPref('mainPaneLayout', v).catch(() => {});
onChanged?.('layout', v);
};
const filled = ['left', 'right', 'p3', 'p4'].filter((k) => panes[k] !== PANE_NONE).length;
const SIDES = [
{ key: 'left', label: t('settings.leftPane'), canBeEmpty: false },
{ key: 'right', label: t('settings.rightPane'), canBeEmpty: false },
{ key: 'p3', label: t('settings.thirdPane'), canBeEmpty: true },
{ key: 'p4', label: t('settings.fourthPane'), canBeEmpty: true },
];
return (
<div className="border-t border-border/60 pt-4 space-y-2">
<h4 className="text-sm font-semibold text-foreground">{t('settings.mainView')}</h4>
<p className="text-xs text-muted-foreground">{t('settings.mainViewHint')}</p>
<div className="grid grid-cols-2 gap-3 max-w-xl">
<label className="flex flex-col gap-1 text-xs">
<span className="text-muted-foreground">{t('settings.leftPane')}</span>
<Select value={left} onValueChange={(v) => pick('left', v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</label>
<label className="flex flex-col gap-1 text-xs">
<span className="text-muted-foreground">{t('settings.rightPane')}</span>
<Select value={right} onValueChange={(v) => pick('right', v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</label>
{SIDES.map((sd) => (
<label key={sd.key} className="flex flex-col gap-1 text-xs">
<span className="text-muted-foreground">{sd.label}</span>
<Select value={panes[sd.key]} onValueChange={(v) => pick(sd.key, v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{/* Only the third and fourth may be empty: the first two ARE the
Main view, and a tab with nothing in it is not a layout. */}
{sd.canBeEmpty && <SelectItem value={PANE_NONE}>{t('settings.paneNone')}</SelectItem>}
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</label>
))}
</div>
{/* Only asked when it can matter. Three panes are columns and nothing
else; four are the only case with two honest answers. */}
{filled === 4 && (
<div className="flex items-center gap-2 pt-1">
<span className="text-xs text-muted-foreground">{t('settings.paneLayout')}</span>
<div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
{[{ v: 'quad', l: t('settings.paneQuad') }, { v: 'cols', l: t('settings.paneCols') }].map((o) => (
<button key={o.v} type="button" onClick={() => pickLayout(o.v)}
className={cn('px-2.5 py-1', layout === o.v ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
{o.l}
</button>
))}
</div>
</div>
)}
</div>
);
}
@@ -1399,16 +1440,25 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [bandDraft, setBandDraft] = useState('');
const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false,
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001,
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false,
});
// While true, the next key press is captured as the PTT hotkey.
const [capturingPtt, setCapturingPtt] = useState(false);
const [rotors, setRotors] = useState<RotatorDevice[]>([]);
const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]);
// Whether the presets have actually been READ back yet.
//
// An empty list is a legitimate answer — an operator may want no quick-turn
// buttons at all — so the backend stores it as one. That makes "not loaded
// yet" and "deliberately none" the same value, and a Save landing in the first
// state wiped the buttons for good. Saving is gated on having read them, so
// the only empty list that can ever reach the store is one the operator made.
const [rotorPresetsLoaded, setRotorPresetsLoaded] = useState(false);
const [rotatorTesting, setRotatorTesting] = useState(false);
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
@@ -1509,10 +1559,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
const [checkUpdates, setCheckUpdates] = useState(() => localStorage.getItem('opslog.checkUpdates') !== '0');
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
// Compact rotor widget: the dial and the SP/LP readout only. Off by default —
// the full widget is what the operator has today, and a setting that changes
// a panel the moment you upgrade is a setting that gets blamed for it.
const [rotorCompact, setRotorCompact] = useState(() => localStorage.getItem('opslog.rotorCompact') === '1');
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
// Declared HERE and not in ClusterPanel: that renderer is called as a plain
// function by the PANELS map, so it must stay hooks-free.
const [clusterMacros, setClusterMacros] = useState<ClusterMacro[]>(loadClusterMacros);
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
// Password-encryption (secret vault) state.
@@ -1714,6 +1771,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
// has side effects there — adding the RBN nodes, bringing the PSK Reporter
// feed up or down — so the write has to go where those live.
const [bandOpen, setBandOpen] = useState<any>({ enabled: false, bands: [], available: [] });
// How a worked square is matched, and what counts as still wanted.
const [autoCall, setAutoCall] = useState<AutoCallSettings>(loadAutoCall);
const [gridScope, setGridScope] = useState<any>({ scope: 'mix_digi', hunt: 'new', scopes: [] });
useEffect(() => { GetGridScopeSettings().then((g) => setGridScope(g as any)).catch(() => {}); }, []);
const saveGridScope = async (next: any) => {
setGridScope(next);
try { await SaveGridScopeSettings(next); } catch { /* the panel keeps the choice either way */ }
};
const [chaseGrids, setChaseGrids] = useState(false);
const [chaseNew, setChaseNew] = useState(false);
const [spotTTL, setSpotTTL] = useState(0);
@@ -1825,6 +1890,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
await reloadClusterServers();
setCatCfg(c);
setRotors((r ?? []) as any);
// Loaded HERE, in the loader that runs on mount — not only in the
// event-driven one below. Missing from this one, the state stayed empty
// on a normal open and Save then wrote an empty list over the operator's
// buttons. See rotorPresetsLoaded for the belt to this brace.
try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
@@ -1868,6 +1938,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
try { setLookup(await GetLookupSettings() as any); } catch {}
try { setCatCfg(await GetCATSettings() as any); } catch {}
try { setRotors(((await GetRotators()) ?? []) as any); } catch {}
try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
@@ -2062,6 +2133,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
await SaveLookupSettings(lookup as any);
await SaveCATSettings(catCfg as any);
await SaveRotators(rotors as any);
// Only once they have been read back — see rotorPresetsLoaded.
if (rotorPresetsLoaded) await SaveRotorPresets(rotorPresets as any);
await SaveUltrabeamSettings(ultrabeam as any);
await SaveAntGeniusSettings(antgenius as any);
await SaveTunerGeniusSettings(tunergenius as any);
@@ -2862,6 +2935,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<span className="text-xs text-muted-foreground">{t('cat.flexDecodeSecsHint')}</span>
</div>
)}
<label className="col-span-2 flex items-start gap-2 text-sm cursor-pointer">
<Checkbox className="mt-0.5" checked={!!catCfg.flex_dvk_dax} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_dvk_dax: !!c }))} />
<span>{t('cat.flexDvkDax')} <span className="text-xs text-muted-foreground">{t('cat.flexDvkDaxHint')}</span></span>
</label>
</>
)}
{catCfg.backend === 'xiegu' && (
@@ -3142,6 +3219,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
</>
)}
{/* Transverter offset. OUTSIDE the per-backend blocks on purpose: a
transverter hangs off the IF of whatever radio you own, and the
offset is applied in the CAT manager, above every backend. Tucked
under the OmniRig/Icom section it would have been invisible to the
Yaesu, Kenwood, Xiegu, Flex and TCI operators who need it just as
much. */}
<label className="col-span-2 flex items-start gap-2 text-sm cursor-pointer">
<Checkbox className="mt-0.5" checked={!!catCfg.offset_on}
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, offset_on: !!c }))} />
<span>{t('cat.offsetOn')} <span className="text-xs text-muted-foreground">{t('cat.offsetHint')}</span></span>
</label>
{catCfg.offset_on && (
<div className="col-span-2 flex items-center gap-2 pl-6">
<Label className="text-sm">{t('cat.offsetMhz')}</Label>
<Input
type="number" step="0.000001" className="w-40"
value={(catCfg.offset_hz ?? 0) / 1e6}
onChange={(e) => setCatCfg((s) => ({ ...s, offset_hz: Math.round((parseFloat(e.target.value) || 0) * 1e6) }))}
/>
<span className="text-xs text-muted-foreground">{t('cat.offsetExample')}</span>
</div>
)}
<div className="space-y-1 col-span-2">
<Label>{t('cat.digitalDefault')}</Label>
<Select
@@ -4135,6 +4234,56 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
)}
</div>
)}
{/* How much of the rotor widget to draw. Compact keeps the dial and the
SP/LP azimuths and drops the controls for an operator who turns
the antenna from the bearing pill or from PstRotator and wants the
dial to take a column, not a panel. */}
<div className="border-t border-border/60 pt-3 space-y-2">
<div className="text-sm font-semibold">{t('rot.widget')}</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={rotorCompact}
onCheckedChange={(c) => { const v = !!c; setRotorCompact(v); writeUiPref('opslog.rotorCompact', v ? '1' : '0'); }} />
{t('rot.compact')}
</label>
<p className="text-xs text-muted-foreground">{t('rot.compactHint')}</p>
</div>
{/* Quick-turn buttons for the rotor widget. Their azimuths start out
computed from the station square, so they are right for THIS QTH
rather than copied from someone else's. Hidden in compact mode
editing buttons that are not drawn is a trap. */}
<div className={cn('border-t border-border/60 pt-3 space-y-2', rotorCompact && 'opacity-40')}>
<div className="text-sm font-semibold">{t('rot.presets')}</div>
<p className="text-xs text-muted-foreground">{t('rot.presetsHint')}</p>
<div className="space-y-1.5">
{rotorPresets.map((p, i) => (
<div key={i} className="flex items-center gap-2">
<Input className="w-24" maxLength={6} value={p.label}
placeholder={t('rot.presetName')}
onChange={(e) => setRotorPresets((list) => list.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)))} />
<Input className="w-24" type="number" min={0} max={359} value={p.azimuth}
onChange={(e) => setRotorPresets((list) => list.map((x, j) => (j === i ? { ...x, azimuth: Math.max(0, Math.min(359, parseInt(e.target.value, 10) || 0)) } : x)))} />
<span className="text-xs text-muted-foreground">°</span>
<Button variant="ghost" size="sm" title={t('rot.remove')}
onClick={() => setRotorPresets((list) => list.filter((_, j) => j !== i))}>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
</div>
<div className="flex items-center gap-2">
{rotorPresets.length < 8 && (
<Button variant="outline" size="sm" onClick={() => setRotorPresets((l) => [...l, { label: '', azimuth: 0 }])}>
<Plus className="size-3.5 mr-1" /> {t('rot.presetAdd')}
</Button>
)}
<Button variant="outline" size="sm"
onClick={() => { ResetRotorPresets().then((p) => setRotorPresets((p ?? []) as any)).catch((e) => setErr(String(e?.message ?? e))); }}>
{t('rot.presetsReset')}
</Button>
</div>
</div>
{rotatorTest && (
<div className={cn(
'text-xs rounded-md p-2.5 border',
@@ -4481,8 +4630,101 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
setEditingServer(next);
}
// FTx decodes: what OpsLog does on its own with the digital stream.
//
// Auto-call KEYS THE TRANSMITTER without anyone clicking, so the panel is
// deliberately explicit about it: off by default, every criterion opt-in, and
// a plain statement of what the machine will do once it is on.
function FtxPanel() {
const set = (patch: Partial<AutoCallSettings>) => {
const next = { ...autoCall, ...patch };
setAutoCall(next);
writeUiPref(autoCallKey, JSON.stringify(next));
};
const crit = (which: 'criteria' | 'watchCriteria', k: keyof AutoCallCriteria) => (
<label key={k} className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox
checked={!!autoCall[which][k]}
onCheckedChange={(v) => set({ [which]: { ...autoCall[which], [k]: !!v } } as any)}
/>
{t(`ftx.c_${k}`)}
</label>
);
const KEYS: (keyof AutoCallCriteria)[] = ['dxcc', 'band', 'mode', 'slot', 'grid', 'county', 'pota', 'pfx'];
return (
<>
<SectionHeader title={t('sec.ftx')} hint={t('ftx.hint')} />
<div className="space-y-4">
<div className="rounded-md border border-border p-3 space-y-3">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={autoCall.enabled} className="mt-0.5"
onCheckedChange={(v) => set({ enabled: !!v })} />
<span>
{t('ftx.enable')}{' '}
<span className="text-xs text-muted-foreground">{t('ftx.enableHint')}</span>
</span>
</label>
{autoCall.enabled && (
<div className="pl-6 space-y-3">
<div>
<p className="text-xs font-semibold text-muted-foreground mb-1.5">{t('ftx.callWhen')}</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1">{KEYS.map((k) => crit('criteria', k))}</div>
</div>
<div className="border-t border-border/60 pt-3">
<p className="text-xs font-semibold text-muted-foreground mb-1">{t('ftx.watch')}</p>
<p className="text-[11px] text-muted-foreground mb-1.5 leading-relaxed">{t('ftx.watchHint')}</p>
<Textarea
className="h-20 font-mono text-xs"
placeholder={"4S7*\nTM0HQ\n*/P"}
defaultValue={(autoCall.watch ?? []).join('\n')}
key={`w-${(autoCall.watch ?? []).length}`}
onBlur={(e) => set({
watch: e.target.value.split('\n').map((x) => x.trim().toUpperCase()).filter(Boolean),
})}
/>
{(autoCall.watch ?? []).length > 0 && (
<div className="mt-2">
<p className="text-[11px] text-muted-foreground mb-1">{t('ftx.watchOnlyIf')}</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1">{KEYS.map((k) => crit('watchCriteria', k))}</div>
</div>
)}
</div>
<div className="border-t border-border/60 pt-3 flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">{t('ftx.cooldown')}</span>
<Input type="number" min={10} max={3600} className="w-24 h-7 text-xs"
defaultValue={autoCall.cooldownSec}
key={`cd-${autoCall.cooldownSec}`}
onBlur={(e) => {
const v = parseInt(e.target.value, 10);
if (!isNaN(v) && v >= 10 && v <= 3600) set({ cooldownSec: v });
}} />
<span className="text-xs text-muted-foreground">s</span>
</div>
<p className="text-[11px] rounded border border-warning-border bg-warning-muted text-warning-muted-foreground px-2 py-1.5 leading-relaxed">
{t('ftx.warn')}
</p>
</div>
)}
</div>
</div>
</>
);
}
function ClusterPanel() {
const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
// Written on every keystroke. This panel has no Save button, and a pair of
// text boxes whose contents only take effect on some other button is how
// work gets lost.
const setMacro = (i: number, patch: Partial<ClusterMacro>) => {
const next = clusterMacros.map((m, j) => (j === i ? { ...m, ...patch } : m));
setClusterMacros(next);
saveClusterMacros(next);
};
return (
<>
<SectionHeader
@@ -4575,6 +4817,35 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
{t('clu.autoConnect')}
</label>
</div>
<div className="border-t border-border/60 pt-3 space-y-2">
<div>
<span className="text-sm font-medium">{t('clu.macros')}</span>
<p className="text-xs text-muted-foreground">{t('clu.macrosHint')}</p>
</div>
{/* Two columns of six: twelve rows stacked would push everything else
in this panel off the screen. */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1.5">
{clusterMacros.map((m, i) => (
<div key={i} className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground tabular-nums w-4 text-right shrink-0">{i + 1}</span>
<Input
className="h-8 w-28 shrink-0 text-xs"
placeholder={t('clu.macroLabel')}
value={m.label}
maxLength={24}
onChange={(e) => setMacro(i, { label: e.target.value })}
/>
<Input
className="h-8 flex-1 min-w-0 font-mono text-xs"
placeholder={t('clu.macroCmd')}
value={m.cmd}
maxLength={120}
onChange={(e) => setMacro(i, { cmd: e.target.value })}
/>
</div>
))}
</div>
</div>
<p className="text-xs text-muted-foreground">
{t('clu.freeNodes')} <span className="font-mono">dxc.k0xm.net:7300</span>,{' '}
<span className="font-mono">dx.maritimecontestclub.net:7300</span>,{' '}
@@ -4635,6 +4906,36 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
{pskrStatus?.running ? ` · ${t('bo.feedUp', { n: pskrStatus.received ?? 0 })}` : ''}
</p>
)}
{/* What counts as "I already have this square". There is no single
right answer a VUCC chaser counts a square per band, someone
filling a wall map counts it once so it is a choice, and the
same six GridTracker offers, because a square wanted in one and
not the other is a bug report every time. */}
<div className="pl-6 space-y-1.5 pt-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('gsc.scope')}</span>
<Select value={gridScope.scope} onValueChange={(v) => saveGridScope({ ...gridScope, scope: v })}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{(gridScope.scopes ?? []).map((s: any) => (
<SelectItem key={s.key} value={s.key}>{t(`gsc.scope_${s.key}`)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('gsc.hunt')}</span>
<Select value={gridScope.hunt} onValueChange={(v) => saveGridScope({ ...gridScope, hunt: v })}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="new">{t('gsc.huntNew')}</SelectItem>
<SelectItem value="new_unconfirmed">{t('gsc.huntUnconf')}</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('gsc.hint')}</p>
</div>
</div>
{/* Chase new its own option, NOT nested under grid chasing. Chasing
@@ -4675,6 +4976,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
);
})}
</div>
{/* How near a RECEIVER has to be. This is the whole premise of
the watch a report is evidence about YOUR path only if it
was collected near you and the right distance differs by
band, so it is the operator's call. Committed on blur/Enter,
not per keystroke: each save restarts the feed. */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">{t('bo.nearKm')}</span>
<Input
type="number" min={25} max={1000} step={25}
className="w-24 h-7 text-xs"
defaultValue={bandOpen.near_km ?? 300}
key={`nk-${bandOpen.near_km ?? 300}`}
onBlur={(e) => {
const v = parseInt(e.target.value, 10);
if (!isNaN(v) && v !== bandOpen.near_km) saveBandOpen({ ...bandOpen, near_km: v });
}}
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
/>
<span className="text-xs text-muted-foreground">km</span>
</div>
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('bo.nearKmHint')}</p>
{/* A live count, because a feed that is connected but silent looks
exactly like one that is broken until a number moves. */}
<p className="text-xs text-muted-foreground">
@@ -6591,6 +6914,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
'lists-bands': BandsPanel,
'lists-modes': ModesPanel,
cluster: ClusterPanel,
ftx: FtxPanel,
udp: UDPIntegrationsPanelWrapper,
// Module-scope components, wrapped so their props can be passed. The nested
// panels below go through PanelHost instead — which is what now lets either
@@ -24,7 +24,7 @@ type UDPConfig = {
direction: 'inbound' | 'outbound';
name: string;
port: number;
service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated' | 'pstrotator_freq' | 'n1mm_radioinfo' | 'wsjt_log' | 'custom';
service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated' | 'pstrotator_freq' | 'n1mm_radioinfo' | 'wsjt_log' | 'wsjt_relay' | 'custom';
// Custom rows only.
trigger?: string;
template?: string;
@@ -95,6 +95,19 @@ const SERVICE_TYPES: Array<{
hint: 'udpp.svcWsjtLogHint',
defaults: { port: 2250, destination_ip: '127.0.0.1' },
},
{
// Feeds a SECOND application the stream this OpsLog receives. WSJT-X, JTDX
// and MSHV each send to ONE address, so without a relay the choice is
// between OpsLog and JTAlert/GridTracker rather than both.
//
// 2238 by default: beside the 2237 the sender uses, and free. Pointing it AT
// 2237 would name an OpsLog listener, which the relay refuses.
id: 'wsjt_relay',
direction: 'outbound',
label: 'udpp.svcWsjtRelayLabel',
hint: 'udpp.svcWsjtRelayHint',
defaults: { port: 2238, destination_ip: '127.0.0.1' },
},
{
// The general case: the operator picks the moment and writes the message.
// Mainly for antenna switches, which are driven by a URL and want the band
@@ -298,6 +298,20 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
{t('wbg.pickerDesc')}
</DialogDescription>
</DialogHeader>
{/* Whole-dialog controls the award columns included, which the
catalogue-only helpers do not cover. */}
<div className="flex items-center gap-1 px-5 text-[11px] text-muted-foreground">
<span>{t('wbg.allGroups')}</span>
<button type="button" className="text-[11px] text-primary hover:underline px-1"
onClick={() => { showAll(); (awardCols ?? []).forEach((a) => setColVisible(`award_${a.code}`, true)); }}>
{t('wbg.all')}
</button>
<span className="opacity-40">·</span>
<button type="button" className="text-[11px] text-muted-foreground hover:underline px-1"
onClick={() => { hideAll(); (awardCols ?? []).forEach((a) => setColVisible(`award_${a.code}`, false)); }}>
{t('wbg.none')}
</button>
</div>
<div className="grid grid-cols-2 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
{GROUP_ORDER.map((group) => {
const cols = COL_CATALOG.filter((c) => c.group === group);
@@ -10,6 +10,7 @@ import {
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import { QSO_BOX_FIELDS, QSO_FIELD_LABELS } from './qslTypes';
import type { CardTemplate, CardElement, QSOBox, QSLPresetInfo, StyleParams } from './qslTypes';
import type { CardSelection } from './CardPreview';
import { StylePresetPicker, NumberField } from './StylePresetPicker';
@@ -199,6 +200,26 @@ export function EditorPanel({ template, sel, presets, fontFamilies, onPatchEleme
<Input className="h-7 w-44 font-mono text-xs" value={box.title ?? ''}
onChange={(ev) => onPatchBox({ title: ev.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Columns</Label>
{/* Rebuilt from the canonical order every time rather than
appended to: a box whose columns come out in the order they
were ticked reads as a bug, and the widths are tuned for this
order. */}
<div className="grid grid-cols-2 gap-x-2">
{QSO_BOX_FIELDS.map((f) => (
<label key={f} className="flex items-center gap-1.5 text-xs">
<Checkbox
checked={box.fields.includes(f)}
onCheckedChange={(v) => onPatchBox({
fields: QSO_BOX_FIELDS.filter((k) => (k === f ? v === true : box.fields.includes(k))),
})}
/>
{QSO_FIELD_LABELS[f] ?? f}
</label>
))}
</div>
</div>
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Footer</Label>
<Input className="h-7 w-44 font-mono text-xs" value={box.footer}
+6
View File
@@ -165,6 +165,12 @@ export interface QSLPresetInfo {
}
// Human labels for the QSO box fields the renderer knows.
// QSO_BOX_FIELDS is the canonical COLUMN ORDER of the confirmation box, and
// the whole list the designer offers. The renderer's column widths are tuned
// for it, so a box is always rebuilt from this order rather than from the
// order the operator happened to tick the boxes in.
export const QSO_BOX_FIELDS = ['qso_date', 'time_on', 'band', 'freq', 'mode', 'submode', 'rst_sent'] as const;
export const QSO_FIELD_LABELS: Record<string, string> = {
qso_date: 'Date',
time_on: 'UTC',
+176
View File
@@ -0,0 +1,176 @@
// Auto-call: let OpsLog answer a decode without the operator clicking it.
//
// 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
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
sota: boolean; // summit never worked (spot-tagged only)
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, band: false, mode: false, slot: false,
grid: false, county: false, pota: false, sota: 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);
return {
...defaultAutoCall,
...v,
criteria: { ...emptyCriteria, ...(v?.criteria ?? {}) },
watchCriteria: { ...emptyCriteria, ...(v?.watchCriteria ?? {}) },
watch: Array.isArray(v?.watch) ? v.watch : [],
};
} 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;
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');
// Only a CQ. Answering a station mid-QSO is both rude and futile — WSJT-X
// actions a Reply only for CQ and QRZ anyway.
if (!d.cq) return no('not a CQ');
// 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');
}
+57
View File
@@ -0,0 +1,57 @@
// Cluster command macros — a named button for a command you would otherwise
// retype.
//
// Twelve slots, fixed. A list you can grow needs add/remove/reorder controls and
// a decision about what an empty row means; twelve boxes you fill in need
// neither, and nobody has thirteen cluster commands they use daily. A slot with
// no command is simply not drawn, so the toolbar is as long as the operator made
// it and no longer.
//
// 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';
export type ClusterMacro = {
label: string; // what the button says
cmd: string; // what is sent to the master server
};
export const CLUSTER_MACRO_COUNT = 12;
export const clusterMacrosKey = 'opslog.clusterMacros';
export const emptyClusterMacros = (): ClusterMacro[] =>
Array.from({ length: CLUSTER_MACRO_COUNT }, () => ({ label: '', cmd: '' }));
// loadClusterMacros always returns exactly CLUSTER_MACRO_COUNT entries, whatever
// was stored: a saved list from a build with fewer slots must not leave the
// editor rendering undefined rows.
export function loadClusterMacros(): ClusterMacro[] {
const out = emptyClusterMacros();
try {
const raw = localStorage.getItem(clusterMacrosKey);
if (!raw) return out;
const v = JSON.parse(raw);
if (!Array.isArray(v)) return out;
for (let i = 0; i < CLUSTER_MACRO_COUNT && i < v.length; i++) {
out[i] = {
label: String(v[i]?.label ?? '').slice(0, 24),
cmd: String(v[i]?.cmd ?? '').slice(0, 120),
};
}
} catch { /* a corrupt preference is not worth failing the panel over */ }
return out;
}
export function saveClusterMacros(macros: ClusterMacro[]): void {
writeUiPref(clusterMacrosKey, JSON.stringify(macros));
}
// visibleClusterMacros drops the slots that would send nothing. The COMMAND is
// what decides: a slot with a label and no command is a button that lies, and a
// command with no label still has something to show — its own text.
export function visibleClusterMacros(macros: ClusterMacro[]): ClusterMacro[] {
return macros
.filter((m) => m.cmd.trim() !== '')
.map((m) => ({ label: m.label.trim() || m.cmd.trim(), cmd: m.cmd.trim() }));
}
+125 -25
View File
File diff suppressed because one or more lines are too long
+66
View File
@@ -0,0 +1,66 @@
// Operator overrides for the band/mode matrix palette.
//
// Applied as inline custom properties on <html>, which is what lets them be
// OVERRIDES rather than a palette: the twelve themes each define their own
// --mx-* ramp in style.css, an inline value wins over all of them, and removing
// it hands the colour straight back to the theme. Nothing has to know which
// theme is active, and switching theme with overrides off is a clean revert.
export type MatrixColors = {
enabled: boolean;
call_confirmed: string;
call_worked: string;
entity_confirmed: string;
entity_worked: string;
not_worked: string;
current_entry: string;
};
// The six settings fields and the CSS custom property each one drives. Also the
// display order — the same order the legend under the matrix reads in, so the
// settings panel and the grid can never disagree about which green is which.
export const MATRIX_VARS: { key: keyof Omit<MatrixColors, 'enabled'>; cssVar: string; label: string }[] = [
{ key: 'call_confirmed', cssVar: '--mx-call-conf', label: 'mx.callConf' },
{ key: 'call_worked', cssVar: '--mx-call-work', label: 'mx.callWork' },
{ key: 'entity_confirmed', cssVar: '--mx-dx-conf', label: 'mx.dxConf' },
{ key: 'entity_worked', cssVar: '--mx-dx-work', label: 'mx.dxWork' },
{ key: 'not_worked', cssVar: '--mx-none', label: 'mx.none' },
{ key: 'current_entry', cssVar: '--mx-cur', label: 'mx.current' },
];
export const emptyMatrixColors = (): MatrixColors => ({
enabled: false,
call_confirmed: '', call_worked: '', entity_confirmed: '',
entity_worked: '', not_worked: '', current_entry: '',
});
// applyMatrixColors stamps (or clears) the overrides on <html>. Safe to call as
// often as you like — it is the whole rendering path, so the settings panel uses
// it for a live preview and the app uses it once at startup.
export function applyMatrixColors(c?: MatrixColors | null): void {
const root = document.documentElement;
for (const { key, cssVar } of MATRIX_VARS) {
const v = c?.enabled ? String(c[key] ?? '').trim() : '';
if (v) root.style.setProperty(cssVar, v);
else root.style.removeProperty(cssVar);
}
}
// effectiveMatrixColor reads what the matrix is ACTUALLY painting right now —
// the override if there is one, else the active theme's value. It is what seeds
// the colour pickers, so the operator starts from the colours in front of them
// instead of from a hardcoded palette that may belong to a different theme.
//
// A custom property's computed value has its var() references substituted, so
// --mx-cur resolves to the theme's --warning rather than to the literal text.
export function effectiveMatrixColor(cssVar: string): string {
try {
const v = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim();
// <input type="color"> only accepts #rrggbb. Anything else (a theme that
// ever moves to oklch, an empty read during boot) falls back to mid grey
// rather than silently resetting the picker to black.
return /^#[0-9a-f]{6}$/i.test(v) ? v.toLowerCase() : '#808080';
} catch {
return '#808080';
}
}
+36 -7
View File
@@ -16,9 +16,16 @@ export type RowColorSettings = {
style?: 'bar' | 'tint' | 'both';
intensity?: number;
bandmap_lotw?: boolean;
recent_zebra?: string; // '' = alternate (default), 'off' = every row alike
recent_zebra_color?: string; // '' = follow the theme
rules: RowColorRule[];
};
// The banding the grid theme applies on its own. Repeated here because turning
// the option OFF means painting every row explicitly — the theme would
// otherwise keep striping underneath whatever we do per row.
const ZEBRA_DEFAULT = 'color-mix(in srgb, var(--muted) 40%, var(--card))';
export const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz'] as const;
// The two QSO fields behind each channel. QRZ.com and Club Log call theirs an
@@ -76,21 +83,43 @@ export function matchRowRule(q: any, cfg: RowColorSettings | null): RowColorRule
// A log where nearly every contact has SOME QSL state ends up with every row
// painted, and colour that is always present stops being information. The
// default is a stripe down the left edge; a tint is offered at a chosen strength.
export function rowStyleFor(q: any, cfg: RowColorSettings | null): Record<string, string> | undefined {
if (!cfg?.enabled) return undefined;
// rowIndex is optional: only the Recent QSOs grid bands its rows, and the other
// callers have nothing to say about odd and even.
export function rowStyleFor(q: any, cfg: RowColorSettings | null, rowIndex?: number): Record<string, string> | undefined {
const out: Record<string, string> = {};
// Banding first, and INDEPENDENT of cfg.enabled: it is legibility, not QSL
// status, and an operator who wants plain rows should not have to turn the
// status colours off to get them.
if (typeof rowIndex === 'number') {
if (cfg?.recent_zebra === 'off') {
out.backgroundColor = 'var(--card)';
} else if (rowIndex % 2 === 1) {
out.backgroundColor = cfg?.recent_zebra_color || ZEBRA_DEFAULT;
}
}
const empty = () => (Object.keys(out).length ? out : undefined);
if (!cfg?.enabled) return empty();
const rule = matchRowRule(q, cfg);
if (!rule?.color) return undefined;
if (!rule?.color) return empty();
const style = cfg.style ?? 'bar';
const pct = Math.max(5, Math.min(45, cfg.intensity ?? 12));
const out: Record<string, string> = {};
if (style === 'tint' || style === 'both') {
// Overrides the banding on purpose: the QSL colour is information, the
// banding is only there to help the eye keep its line.
out.backgroundColor = `color-mix(in srgb, ${rule.color} ${pct}%, transparent)`;
}
if (style === 'bar' || style === 'both') {
// inset shadow rather than a border: a border would shift the cells three
// pixels on coloured rows only, and the columns would stop lining up.
out.boxShadow = `inset 3px 0 0 ${rule.color}`;
// A background gradient, not a border and not a shadow.
//
// A border would shift the cells three pixels on coloured rows only, and
// the columns would stop lining up. An inset box-shadow was the first fix
// for that and drew the stripe correctly — but on the grid's transformed,
// absolutely-positioned rows it also bled a hairline of the same colour
// along the row edges, so every coloured row got a horizontal rule it was
// never meant to have. A gradient paints inside the box and nothing else:
// three pixels of colour, then transparent, and no edge to bleed from.
out.backgroundImage = `linear-gradient(to right, ${rule.color} 0, ${rule.color} 3px, transparent 3px)`;
}
return out;
}
+11 -1
View File
@@ -17,6 +17,8 @@ const PORTABLE_KEYS = [
'opslog.autofocusWB', // auto-focus Worked-before
'hamlog.filterPresets', // Filter Builder saved presets
'opslog.showRotor', // rotor compass shown next to the keyers
'opslog.rotorCompact', // rotor widget: dial + SP/LP only, no quick-turn buttons or Stop
'opslog.decodesGridMap',// FT decodes tab: grid-square map shown beside the table
'opslog.showChaseNew', // Chase New panel shown next to the keyers
'opslog.showAmpWidget', // amplifier widget shown next to the keyers
'opslog.ampSel.widget', // which amplifier that widget shows ("all" or an amp id)
@@ -39,11 +41,19 @@ const PORTABLE_KEYS = [
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
'opslog.activeTab', // last selected tab
'opslog.mainSplit', // Main tab: width share of the left pane (percent)
'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:[..]}
'opslog.mainQuad', // Main tab: the 2x2 cross, as {col,row} percentages
'opslog.clusterMuteWorked', // cluster/band map: no colour or badge on worked spots
'opslog.clusterSlotHighlight', // cluster/band map: colour calls not worked on this band+mode
'opslog.bandMapWidth', // docked band map: column width (px)
'opslog.bandMapTabWidth', // Band map tab: shared card width (px)
'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
'opslog.clusterMacros', // cluster console: the twelve named command buttons
'opslog.clusterHideSpots', // cluster console: hide the DX spot flood so replies are readable
'opslog.clusterConsoleFollow', // cluster console: keep the view pinned to the newest line
'opslog.gridMapColorConfirmed', 'opslog.gridMapColorWorked', // grid map: chosen fills (empty = follow the theme)
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
+7
View File
@@ -85,6 +85,12 @@
--mx-dx-conf: #3730a3; /* entity confirmed*/
--mx-dx-work: #a5b4fc; /* entity worked */
--mx-none: #e7e5e4; /* never worked */
/* Ring on the cell the operator is entering. Declared ONCE, on :root, and
deliberately not repeated per theme: it follows --warning, which every theme
already tunes to its own background. The token exists so the matrix ring can
be recoloured on its own without dragging every other warning in the app
with it (Appearance matrix colours). */
--mx-cur: var(--warning);
--scrollbar-thumb: #b8a880;
--scrollbar-thumb-hover: #968455;
@@ -974,6 +980,7 @@
--color-mx-dx-conf: var(--mx-dx-conf);
--color-mx-dx-work: var(--mx-dx-work);
--color-mx-none: var(--mx-none);
--color-mx-cur: var(--mx-cur);
--radius: 0.5rem;
+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.25.8';
export const APP_VERSION = '0.26.1';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+24
View File
@@ -53,6 +53,8 @@ export function AmpPower(arg1:string,arg2:boolean):Promise<void>;
export function AmpPowerLevel(arg1:string,arg2:string):Promise<void>;
export function AnswerDecode(arg1:string,arg2:number,arg3:number,arg4:number,arg5:number,arg6:string,arg7:string,arg8:boolean):Promise<void>;
export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
export function AntGeniusDeselect(arg1:number):Promise<void>;
@@ -355,6 +357,8 @@ export function FlexSetXITFreq(arg1:number):Promise<void>;
export function FlexStopCW():Promise<void>;
export function FlexTXOnBand(arg1:string):Promise<void>;
export function FlexTune(arg1:boolean):Promise<void>;
export function GetACOMStatus():Promise<acom.Status>;
@@ -457,6 +461,8 @@ export function GetFolderSyncStatus():Promise<main.FolderSyncStatus>;
export function GetGridCacheStatus():Promise<main.GridCacheStatus>;
export function GetGridScopeSettings():Promise<main.GridScopeSettings>;
export function GetIcomState():Promise<cat.IcomTXState>;
export function GetLinkedAmps():Promise<Array<string>>;
@@ -477,6 +483,8 @@ export function GetLogbookRevision():Promise<string>;
export function GetLookupSettings():Promise<main.LookupSettings>;
export function GetMatrixColors():Promise<main.MatrixColors>;
export function GetMySQLSettings():Promise<main.MySQLSettings>;
export function GetOfflineStatus():Promise<main.OfflineStatus>;
@@ -511,6 +519,8 @@ export function GetRotatorHeading():Promise<main.RotatorHeading>;
export function GetRotators():Promise<Array<main.RotatorDevice>>;
export function GetRotorPresets():Promise<Array<main.RotorPreset>>;
export function GetRowColors():Promise<main.RowColorSettings>;
export function GetSPEStatus():Promise<spe.Status>;
@@ -563,6 +573,10 @@ export function GetWorkedCallVariants():Promise<boolean>;
export function GetYaesuState():Promise<cat.YaesuTXState>;
export function GridSquares(arg1:string):Promise<Array<qso.GridSquare>>;
export function HaltDecodeTx(arg1:string,arg2:boolean):Promise<void>;
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
export function IcomRefresh():Promise<void>;
@@ -881,6 +895,8 @@ export function ResetAwardDefs():Promise<Array<award.Def>>;
export function ResetDatabaseToDefault():Promise<void>;
export function ResetRotorPresets():Promise<Array<main.RotorPreset>>;
export function RestartApp():Promise<void>;
export function RestartQSORecorder():Promise<void>;
@@ -945,10 +961,14 @@ export function SaveFlexBandPower(arg1:Record<string, main.FlexBandPower>):Promi
export function SaveFolderSync(arg1:main.FolderSyncConfig):Promise<void>;
export function SaveGridScopeSettings(arg1:main.GridScopeSettings):Promise<void>;
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
export function SaveMatrixColors(arg1:main.MatrixColors):Promise<void>;
export function SaveMySQLSettings(arg1:main.MySQLSettings):Promise<void>;
export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.Antenna>;
@@ -969,6 +989,8 @@ export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
export function SaveRotorPresets(arg1:Array<main.RotorPreset>):Promise<void>;
export function SaveRowColors(arg1:main.RowColorSettings):Promise<void>;
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
@@ -999,6 +1021,8 @@ export function SendClusterCommand(arg1:string):Promise<void>;
export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
export function SendDecodeFreeText(arg1:string,arg2:string,arg3:boolean):Promise<void>;
export function SendEQSL(arg1:number,arg2:number,arg3:string):Promise<void>;
export function SendLogToDeveloper():Promise<void>;
+48
View File
@@ -46,6 +46,10 @@ export function AmpPowerLevel(arg1, arg2) {
return window['go']['main']['App']['AmpPowerLevel'](arg1, arg2);
}
export function AnswerDecode(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8) {
return window['go']['main']['App']['AnswerDecode'](arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8);
}
export function AntGeniusActivate(arg1, arg2) {
return window['go']['main']['App']['AntGeniusActivate'](arg1, arg2);
}
@@ -650,6 +654,10 @@ export function FlexStopCW() {
return window['go']['main']['App']['FlexStopCW']();
}
export function FlexTXOnBand(arg1) {
return window['go']['main']['App']['FlexTXOnBand'](arg1);
}
export function FlexTune(arg1) {
return window['go']['main']['App']['FlexTune'](arg1);
}
@@ -854,6 +862,10 @@ export function GetGridCacheStatus() {
return window['go']['main']['App']['GetGridCacheStatus']();
}
export function GetGridScopeSettings() {
return window['go']['main']['App']['GetGridScopeSettings']();
}
export function GetIcomState() {
return window['go']['main']['App']['GetIcomState']();
}
@@ -894,6 +906,10 @@ export function GetLookupSettings() {
return window['go']['main']['App']['GetLookupSettings']();
}
export function GetMatrixColors() {
return window['go']['main']['App']['GetMatrixColors']();
}
export function GetMySQLSettings() {
return window['go']['main']['App']['GetMySQLSettings']();
}
@@ -962,6 +978,10 @@ export function GetRotators() {
return window['go']['main']['App']['GetRotators']();
}
export function GetRotorPresets() {
return window['go']['main']['App']['GetRotorPresets']();
}
export function GetRowColors() {
return window['go']['main']['App']['GetRowColors']();
}
@@ -1066,6 +1086,14 @@ export function GetYaesuState() {
return window['go']['main']['App']['GetYaesuState']();
}
export function GridSquares(arg1) {
return window['go']['main']['App']['GridSquares'](arg1);
}
export function HaltDecodeTx(arg1, arg2) {
return window['go']['main']['App']['HaltDecodeTx'](arg1, arg2);
}
export function HasBuiltinReferences(arg1) {
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
}
@@ -1702,6 +1730,10 @@ export function ResetDatabaseToDefault() {
return window['go']['main']['App']['ResetDatabaseToDefault']();
}
export function ResetRotorPresets() {
return window['go']['main']['App']['ResetRotorPresets']();
}
export function RestartApp() {
return window['go']['main']['App']['RestartApp']();
}
@@ -1830,6 +1862,10 @@ export function SaveFolderSync(arg1) {
return window['go']['main']['App']['SaveFolderSync'](arg1);
}
export function SaveGridScopeSettings(arg1) {
return window['go']['main']['App']['SaveGridScopeSettings'](arg1);
}
export function SaveListsSettings(arg1) {
return window['go']['main']['App']['SaveListsSettings'](arg1);
}
@@ -1838,6 +1874,10 @@ export function SaveLookupSettings(arg1) {
return window['go']['main']['App']['SaveLookupSettings'](arg1);
}
export function SaveMatrixColors(arg1) {
return window['go']['main']['App']['SaveMatrixColors'](arg1);
}
export function SaveMySQLSettings(arg1) {
return window['go']['main']['App']['SaveMySQLSettings'](arg1);
}
@@ -1878,6 +1918,10 @@ export function SaveRotators(arg1) {
return window['go']['main']['App']['SaveRotators'](arg1);
}
export function SaveRotorPresets(arg1) {
return window['go']['main']['App']['SaveRotorPresets'](arg1);
}
export function SaveRowColors(arg1) {
return window['go']['main']['App']['SaveRowColors'](arg1);
}
@@ -1938,6 +1982,10 @@ export function SendClusterSpot(arg1, arg2, arg3) {
return window['go']['main']['App']['SendClusterSpot'](arg1, arg2, arg3);
}
export function SendDecodeFreeText(arg1, arg2, arg3) {
return window['go']['main']['App']['SendDecodeFreeText'](arg1, arg2, arg3);
}
export function SendEQSL(arg1, arg2, arg3) {
return window['go']['main']['App']['SendEQSL'](arg1, arg2, arg3);
}
+122
View File
@@ -2001,6 +2001,7 @@ export namespace main {
enabled: boolean;
bands: string[];
available: string[];
near_km: number;
static createFrom(source: any = {}) {
return new BandOpenSettings(source);
@@ -2011,6 +2012,7 @@ export namespace main {
this.enabled = source["enabled"];
this.bands = source["bands"];
this.available = source["available"];
this.near_km = source["near_km"];
}
}
export class CATSettings {
@@ -2021,6 +2023,7 @@ export namespace main {
flex_host: string;
flex_port: number;
flex_spots: boolean;
flex_dvk_dax: boolean;
flex_decode_spots: boolean;
flex_decode_secs: number;
xiegu_port: string;
@@ -2047,6 +2050,8 @@ export namespace main {
tci_spots: boolean;
poll_ms: number;
delay_ms: number;
offset_on: boolean;
offset_hz: number;
digital_default: string;
share_enabled: boolean;
share_port: number;
@@ -2069,6 +2074,7 @@ export namespace main {
this.flex_host = source["flex_host"];
this.flex_port = source["flex_port"];
this.flex_spots = source["flex_spots"];
this.flex_dvk_dax = source["flex_dvk_dax"];
this.flex_decode_spots = source["flex_decode_spots"];
this.flex_decode_secs = source["flex_decode_secs"];
this.xiegu_port = source["xiegu_port"];
@@ -2095,6 +2101,8 @@ export namespace main {
this.tci_spots = source["tci_spots"];
this.poll_ms = source["poll_ms"];
this.delay_ms = source["delay_ms"];
this.offset_on = source["offset_on"];
this.offset_hz = source["offset_hz"];
this.digital_default = source["digital_default"];
this.share_enabled = source["share_enabled"];
this.share_port = source["share_port"];
@@ -2561,6 +2569,56 @@ export namespace main {
this.pending = source["pending"];
}
}
export class gridScope {
key: string;
band: boolean;
mode: string;
static createFrom(source: any = {}) {
return new gridScope(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.key = source["key"];
this.band = source["band"];
this.mode = source["mode"];
}
}
export class GridScopeSettings {
scope: string;
hunt: string;
scopes: gridScope[];
static createFrom(source: any = {}) {
return new GridScopeSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.scope = source["scope"];
this.hunt = source["hunt"];
this.scopes = this.convertValues(source["scopes"], gridScope);
}
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 ModePreset {
name: string;
default_rst_sent?: string;
@@ -2683,6 +2741,30 @@ export namespace main {
this.cache_ttl_days = source["cache_ttl_days"];
}
}
export class MatrixColors {
enabled: boolean;
call_confirmed: string;
call_worked: string;
entity_confirmed: string;
entity_worked: string;
not_worked: string;
current_entry: string;
static createFrom(source: any = {}) {
return new MatrixColors(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.call_confirmed = source["call_confirmed"];
this.call_worked = source["call_worked"];
this.entity_confirmed = source["entity_confirmed"];
this.entity_worked = source["entity_worked"];
this.not_worked = source["not_worked"];
this.current_entry = source["current_entry"];
}
}
export class MySQLSettings {
enabled: boolean;
@@ -3182,6 +3264,20 @@ export namespace main {
this.motorized = source["motorized"];
}
}
export class RotorPreset {
label: string;
azimuth: number;
static createFrom(source: any = {}) {
return new RotorPreset(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.label = source["label"];
this.azimuth = source["azimuth"];
}
}
export class RowColorRule {
id: string;
color: string;
@@ -3205,6 +3301,8 @@ export namespace main {
style: string;
intensity: number;
bandmap_lotw: boolean;
recent_zebra?: string;
recent_zebra_color?: string;
configured: boolean;
rules: RowColorRule[];
@@ -3218,6 +3316,8 @@ export namespace main {
this.style = source["style"];
this.intensity = source["intensity"];
this.bandmap_lotw = source["bandmap_lotw"];
this.recent_zebra = source["recent_zebra"];
this.recent_zebra_color = source["recent_zebra_color"];
this.configured = source["configured"];
this.rules = this.convertValues(source["rules"], RowColorRule);
}
@@ -3318,6 +3418,7 @@ export namespace main {
new_pota: boolean;
grid?: string;
new_grid: boolean;
grid_state?: string;
spotter_continent?: string;
lotw: boolean;
new_pfx: boolean;
@@ -3343,6 +3444,7 @@ export namespace main {
this.new_pota = source["new_pota"];
this.grid = source["grid"];
this.new_grid = source["new_grid"];
this.grid_state = source["grid_state"];
this.spotter_continent = source["spotter_continent"];
this.lotw = source["lotw"];
this.new_pfx = source["new_pfx"];
@@ -4475,6 +4577,26 @@ export namespace qso {
this.minutes = source["minutes"];
}
}
export class GridSquare {
grid: string;
count: number;
confirmed: boolean;
band?: string;
mode?: string;
static createFrom(source: any = {}) {
return new GridSquare(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.grid = source["grid"];
this.count = source["count"];
this.confirmed = source["confirmed"];
this.band = source["band"];
this.mode = source["mode"];
}
}
export class ListFilter {
callsign?: string;
band?: string;
+187
View File
@@ -0,0 +1,187 @@
package main
import (
"strings"
"hamlog/internal/award"
"hamlog/internal/qso"
)
// New-grid scoping: WHICH prior contacts count as "I already have this square".
//
// The question has no single right answer, which is why it is a setting rather
// than a rule. A VUCC chaser counts a square per band; someone filling in a
// wall map counts it once. GridTracker offers the same six combinations, and an
// operator running both alongside each other needs them to agree — a square
// "wanted" in one and not the other is a bug report every time.
const (
keyGridScope = "grid.scope" // band+mode combination — see gridScopes
keyGridHunt = "grid.hunt" // "new" | "new_unconfirmed"
)
// gridScope is one band × mode combination.
type gridScope struct {
Key string `json:"key"`
// Band is true when the square has to have been worked on THIS band. False
// is GridTracker's "Mix Bands": any band counts.
Band bool `json:"band"`
// Mode is how much of the mode matters:
// "digi" — any digital mode at all
// "ftx" — any of the FT family (FT8/FT4/FT2)
// "mode" — this exact mode and no other
Mode string `json:"mode"`
}
var gridScopes = []gridScope{
{Key: "band_digi", Band: true, Mode: "digi"},
{Key: "band_mode", Band: true, Mode: "mode"},
{Key: "band_ftx", Band: true, Mode: "ftx"},
{Key: "mix_digi", Band: false, Mode: "digi"},
{Key: "mix_mode", Band: false, Mode: "mode"},
{Key: "mix_ftx", Band: false, Mode: "ftx"},
}
// defaultGridScope matches what the panel showed before the setting existed:
// any band, any digital mode. Changing an operator's badges on upgrade is worse
// than offering them a choice they have not made yet.
const defaultGridScope = "mix_digi"
func lookupGridScope(key string) gridScope {
for _, s := range gridScopes {
if s.Key == key {
return s
}
}
for _, s := range gridScopes {
if s.Key == defaultGridScope {
return s
}
}
return gridScopes[0]
}
// ftxModes is the FT family, which is a narrower thing than "digital": an
// operator chasing squares on FT8 does not count the RTTY contest contact, and
// GridTracker draws the same line.
var ftxModes = map[string]bool{"FT8": true, "FT4": true, "FT2": true}
// gridClassesOf lists every bucket one logged mode belongs to. A single FT8
// contact counts under its own name, under FTx and under digital — the scopes
// overlap by construction, so the index stores all three and the lookup picks.
func gridClassesOf(mode string) []string {
m := strings.ToUpper(strings.TrimSpace(mode))
if m == "" {
return nil
}
out := []string{"m:" + m}
if ftxModes[m] {
out = append(out, "ftx")
}
if award.ModeClass(m) == "DIGI" {
out = append(out, "digi")
}
return out
}
// gridClassFor turns the scope plus the mode being heard into the one class to
// look up. Empty means "this scope cannot judge this mode" — a phone contact
// under an FTx scope — and the caller then flags nothing rather than guessing.
func gridClassFor(s gridScope, mode string) string {
m := strings.ToUpper(strings.TrimSpace(mode))
if m == "" {
return ""
}
switch s.Mode {
case "mode":
return "m:" + m
case "ftx":
if ftxModes[m] {
return "ftx"
}
return ""
default: // digi
if award.ModeClass(m) == "DIGI" {
return "digi"
}
return ""
}
}
// GridScopeSettings is the panel's shape.
type GridScopeSettings struct {
Scope string `json:"scope"`
Hunt string `json:"hunt"`
// Scopes is the list the UI renders, so it cannot drift from the one the
// lookup honours.
Scopes []gridScope `json:"scopes"`
}
func (a *App) GetGridScopeSettings() GridScopeSettings {
return GridScopeSettings{
Scope: lookupGridScope(a.settingOr(keyGridScope, "")).Key,
Hunt: gridHunt(a.settingOr(keyGridHunt, "")),
Scopes: gridScopes,
}
}
// gridHunt normalises the hunting mode. "new" is never-worked; "new_unconfirmed"
// also chases a square worked but not yet confirmed — the same square is still
// missing from the award either way.
func gridHunt(v string) string {
if strings.TrimSpace(v) == "new_unconfirmed" {
return "new_unconfirmed"
}
return "new"
}
func (a *App) SaveGridScopeSettings(s GridScopeSettings) error {
a.setSetting(keyGridScope, lookupGridScope(s.Scope).Key)
a.setSetting(keyGridHunt, gridHunt(s.Hunt))
// The worked-grid index is built under these rules, so it is now stale.
a.clusterStatusMu.Lock()
a.clusterStatusIdx = nil
a.clusterStatusMu.Unlock()
return nil
}
// Grid states a heard square can be in, from the operator's point of view.
//
// "wanted" is not one state but two, and they call for different decisions: a
// square never worked is a QSO to make, one worked and not yet confirmed is a
// QSL to chase. Reporting both as plain NEW GRID — which is what this did when
// the unconfirmed hunt was added — leaves an operator staring at a badge on a
// station the log plainly says they worked an hour ago.
const (
gridStateNew = "new" // never worked under this scope
gridStateUnconf = "unconf" // worked under this scope, not yet confirmed
)
// gridStateFor classifies one heard square. "" means nothing to chase: already
// confirmed, or a mode this scope cannot judge.
//
// band is where it was heard and mode what it was heard in — the SCOPE decides
// whether either is allowed to matter.
func gridStateFor(idx map[string]bool, s gridScope, hunt, grid, band, mode string) string {
cls := gridClassFor(s, mode)
if cls == "" || len(grid) < 4 {
return ""
}
key := qso.GridKey(strings.ToUpper(grid[:4]), "", cls)
if s.Band {
key = qso.GridKey(strings.ToUpper(grid[:4]), band, cls)
}
confirmed, worked := idx[key]
switch {
case !worked:
return gridStateNew
case !confirmed && hunt == "new_unconfirmed":
return gridStateUnconf
default:
return ""
}
}
// gridIsWanted is gridStateFor reduced to the yes/no the filters need.
func gridIsWanted(idx map[string]bool, s gridScope, hunt, grid, band, mode string) bool {
return gridStateFor(idx, s, hunt, grid, band, mode) != ""
}
+126
View File
@@ -0,0 +1,126 @@
package main
import (
"testing"
"hamlog/internal/qso"
)
// idx builds a worked-grid index the way GridWorkedIndex does, so the test
// exercises the real key shape rather than a convenient one.
func idx(entries ...struct {
grid, band, mode string
confirmed bool
}) map[string]bool {
m := map[string]bool{}
for _, e := range entries {
for _, cls := range gridClassesOf(e.mode) {
for _, k := range []string{qso.GridKey(e.grid, e.band, cls), qso.GridKey(e.grid, "", cls)} {
m[k] = m[k] || e.confirmed
}
}
}
return m
}
type entry = struct {
grid, band, mode string
confirmed bool
}
// The six scopes are six different questions, and the whole point of offering
// them is that the same square answers differently.
func TestGridScopes(t *testing.T) {
// JN36 worked once: 20 m, FT8, not confirmed.
i := idx(entry{"JN36", "20m", "FT8", false})
for _, tc := range []struct {
scope string
band string
mode string
want bool
why string
}{
{"band_ftx", "20m", "FT8", false, "same band, same family — already mine"},
{"band_ftx", "15m", "FT8", true, "another band, and the scope counts bands"},
{"mix_ftx", "15m", "FT8", false, "another band, but this scope does not care"},
{"band_mode", "20m", "FT4", true, "same band, but FT4 is not FT8 under an exact-mode scope"},
{"band_ftx", "20m", "FT4", false, "same band, and FT4 is in the same FT family"},
{"band_digi", "20m", "RTTY", false, "digital counts every digital mode"},
{"band_ftx", "20m", "RTTY", false, "RTTY is outside an FTx scope — it is not evidence either way, so nothing is flagged"},
{"mix_digi", "15m", "FT4", false, "the widest scope: worked anywhere, any digital"},
} {
got := gridIsWanted(i, lookupGridScope(tc.scope), "new", "JN36", tc.band, tc.mode)
if got != tc.want {
t.Errorf("scope %s, %s %s → wanted=%v, want %v — %s", tc.scope, tc.band, tc.mode, got, tc.want, tc.why)
}
}
}
// "New + unconfirmed" chases a square that is worked but not yet confirmed: it
// is still missing from the award, which is the only sense in which it is done.
func TestGridHuntUnconfirmed(t *testing.T) {
unconf := idx(entry{"JN36", "20m", "FT8", false})
conf := idx(entry{"IO91", "20m", "FT8", true})
if gridIsWanted(unconf, lookupGridScope("band_ftx"), "new", "JN36", "20m", "FT8") {
t.Error("worked-but-unconfirmed must NOT be wanted when hunting only what is new")
}
if !gridIsWanted(unconf, lookupGridScope("band_ftx"), "new_unconfirmed", "JN36", "20m", "FT8") {
t.Error("worked-but-unconfirmed must BE wanted when hunting unconfirmed too")
}
if gridIsWanted(conf, lookupGridScope("band_ftx"), "new_unconfirmed", "IO91", "20m", "FT8") {
t.Error("a confirmed square is finished under either hunt")
}
}
// A mode the scope cannot judge flags nothing rather than guessing: a phone
// contact under an FTx scope is not evidence either way.
func TestGridScopeIgnoresModesItCannotJudge(t *testing.T) {
i := idx(entry{"JN36", "20m", "FT8", false})
for _, mode := range []string{"SSB", "CW", ""} {
if gridIsWanted(i, lookupGridScope("band_ftx"), "new", "KO01", "20m", mode) {
t.Errorf("mode %q is outside an FTx scope — nothing should be flagged", mode)
}
}
}
// An unknown or unset scope falls back rather than answering "wanted" for
// everything, which is what an empty class would have done.
func TestGridScopeFallsBack(t *testing.T) {
if got := lookupGridScope("").Key; got != defaultGridScope {
t.Errorf("unset scope = %q, want the default %q", got, defaultGridScope)
}
if got := lookupGridScope("nonsense").Key; got != defaultGridScope {
t.Errorf("unknown scope = %q, want the default %q", got, defaultGridScope)
}
if got := gridHunt("nonsense"); got != "new" {
t.Errorf("unknown hunt = %q, want new", got)
}
}
// The two wanted states must be told apart, not merged. A square worked an hour
// ago and awaiting a QSL is a different job from one never worked, and reporting
// both as plain NEW GRID is what made a station the log plainly shows as worked
// still wear the badge.
func TestGridStateSeparatesNewFromUnconfirmed(t *testing.T) {
i := idx(
entry{"JN74", "20m", "FT8", false}, // worked, no confirmation yet
entry{"IO91", "20m", "FT8", true}, // worked and confirmed
)
s := lookupGridScope("band_ftx")
if got := gridStateFor(i, s, "new_unconfirmed", "KO99", "20m", "FT8"); got != gridStateNew {
t.Errorf("never worked → %q, want %q", got, gridStateNew)
}
if got := gridStateFor(i, s, "new_unconfirmed", "JN74", "20m", "FT8"); got != gridStateUnconf {
t.Errorf("worked but unconfirmed → %q, want %q", got, gridStateUnconf)
}
if got := gridStateFor(i, s, "new_unconfirmed", "IO91", "20m", "FT8"); got != "" {
t.Errorf("confirmed → %q, want nothing to chase", got)
}
// Under the plain "new" hunt an unconfirmed square is finished business.
if got := gridStateFor(i, s, "new", "JN74", "20m", "FT8"); got != "" {
t.Errorf("unconfirmed under the new-only hunt → %q, want nothing", got)
}
}
+23
View File
@@ -684,3 +684,26 @@ func TestCatalogForcesBuiltin(t *testing.T) {
t.Fatal("empty catalog")
}
}
// An award with NO QSO field at all counts only what the operator assigned by
// hand. WWBOTA is the case: bunker references have no ADIF field anywhere, so
// the definition names no field and the matcher must scan nothing rather than
// fall back on some default. The manual override still applies — it is the only
// thing that can feed such an award.
func TestComputeNoFieldManualOnly(t *testing.T) {
def := Def{Code: "WWBOTA", Type: TypeReference, Field: "", Dynamic: true, Valid: true}
qsos := []qso.QSO{
// Assigned by hand → counts.
{Callsign: "F4ABC", Band: "40m", Extras: map[string]string{ManualRefsKey: "WWBOTA@B/F-0123"}},
// Nothing assigned. The other fields are deliberately full: none of them
// may be scanned just because no field was named.
{Callsign: "F4DEF", Band: "20m", POTARef: "FF-0001", QTH: "B/F-0999", Comment: "B/F-0888"},
}
r := Compute([]Def{def}, qsos, nil, nil)[0]
if r.Worked != 1 {
t.Errorf("WWBOTA worked = %d, want 1 (%v)", r.Worked, refCodes(r))
}
if got := refCodes(r); len(got) != 1 || got[0] != "B/F-0123" {
t.Errorf("WWBOTA refs = %v, want [B/F-0123]", got)
}
}
File diff suppressed because it is too large Load Diff
+75 -1
View File
@@ -79,6 +79,9 @@ type Manager struct {
pollEvery time.Duration
cmdDelay time.Duration // pause after each command (some rigs need it)
// freqOffset is the transverter offset in Hz — see SetFreqOffset. Added to
// what the rig reports, taken off what it is told.
freqOffset int64
}
func NewManager(emit func(RigState)) *Manager {
@@ -181,12 +184,65 @@ func (m *Manager) stopLocked() {
iv.Interrupt()
}
if done != nil {
<-done
// Bounded, and loud when it expires.
//
// This used to be a bare <-done. A poll goroutine wedged in a serial read
// then held every caller for ever: each restart attempt blocked inside
// Start before it could even try to connect, so the rig stayed dead and
// NOTHING was written to the log — the operator saw a CAT link that would
// not come back and no reason anywhere. Shutdown blocked on the same wait.
//
// Ten seconds is far longer than any honest disconnect. Past that the old
// poller is not coming back, and carrying on without it — noisily — beats
// freezing the application around it. The abandoned goroutine still holds
// its port, which is exactly what the log line has to say, because the
// next connect will fail because of it.
select {
case <-done:
case <-time.After(10 * time.Second):
name := "cat"
if b != nil {
name = b.Name()
}
debugLog.Printf("%s: poll loop did not stop within 10s — abandoning it; "+
"its port stays open, so the next connect may fail until OpsLog is restarted", name)
}
}
}
// SetFreqOffset sets the transverter offset: the number of hertz between what
// the RIG is tuned to and where the station is actually on the air.
//
// A 28 MHz IF driving a 144 MHz transverter is an offset of +116 MHz. Everything
// above this layer — the entry form, the band, the log, the cluster, the shared
// CAT servers — then works in real frequencies, and the rig keeps seeing its own.
//
// Zero disables it, which is why it is a plain number and not a flag plus a
// number: "enabled with an offset of nothing" and "disabled" are the same thing
// to everyone downstream.
func (m *Manager) SetFreqOffset(hz int64) {
m.mu.Lock()
m.freqOffset = hz
m.mu.Unlock()
}
// freqOffsetHz reads the offset.
func (m *Manager) freqOffsetHz() int64 {
m.mu.RLock()
defer m.mu.RUnlock()
return m.freqOffset
}
// SetFrequency dispatches a SetFreq call to the CAT goroutine.
//
// The caller speaks in REAL frequencies (a 2 m spot is 144.300), so the offset
// comes back off before the rig hears it. Without this the offset would be a
// 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 {
if off := m.freqOffsetHz(); off != 0 && hz > off {
hz -= off
}
return m.exec(func(b Backend) error { return b.SetFrequency(hz) })
}
@@ -215,6 +271,10 @@ type splitSetter interface {
// band when it was transmitting on the DX's own frequency. A refusal WSJT-X can
// report is worth far more than a success it cannot check.
func (m *Manager) SetSplit(on bool, txHz int64) error {
// Real frequency in, IF frequency out — same as SetFrequency.
if off := m.freqOffsetHz(); off != 0 && txHz > off {
txHz -= off
}
return m.exec(func(b Backend) error {
s, ok := b.(splitSetter)
if !ok {
@@ -744,6 +804,20 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
ns.Enabled = true
ns.Backend = b.Name()
ns.UpdatedAt = time.Now()
// Transverter offset: the rig reports its IF, the operator is on the
// real band. Applied BEFORE the band is worked out, or a 28 MHz IF
// behind a 2 m transverter would log every contact on 10 m — and the
// band the backend may already have filled in is the IF's, so it is
// recomputed rather than trusted.
if off := m.freqOffsetHz(); off != 0 {
if ns.FreqHz != 0 {
ns.FreqHz += off
ns.Band = ""
}
if ns.RxFreqHz != 0 {
ns.RxFreqHz += off
}
}
if ns.FreqHz != 0 && ns.Band == "" {
ns.Band = BandFromHz(ns.FreqHz)
}
+25 -17
View File
@@ -514,7 +514,8 @@ func (b *IcomSerial) SetFrequency(hz int64) error {
return fmt.Errorf("invalid frequency")
}
b.lastSetFreq, b.lastSetFreqAt = hz, time.Now()
return b.exec(append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)...)
return b.execIdempotent(fmt.Sprintf("set frequency %d Hz", hz),
append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)...)
}
func (b *IcomSerial) SetMode(mode string) error {
@@ -524,7 +525,7 @@ func (b *IcomSerial) SetMode(mode string) 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.exec(civ.CmdSetMode, code); err != nil {
if err := b.execIdempotent("set mode "+mode, civ.CmdSetMode, code); err != nil {
return err
}
dataByte := byte(0)
@@ -532,7 +533,7 @@ func (b *IcomSerial) SetMode(mode string) error {
dataByte = 1
}
// Filter 0x01 (FIL1) is the conventional default for the data-mode set.
_ = b.exec(civ.CmdExtra, civ.SubDataMode, dataByte, 0x01)
_ = b.execIdempotent("set data mode", civ.CmdExtra, civ.SubDataMode, dataByte, 0x01)
return nil
}
@@ -542,31 +543,38 @@ func (b *IcomSerial) SetMode(mode string) error {
// a formatted string so callers can tell the two apart.
var errIcomAckLost = errors.New("icom: timeout waiting for response")
// SetPTT keys or unkeys the transmitter (CI-V 0x1C 0x00), retrying ONCE when the
// execIdempotent runs a SET command and sends it ONCE MORE if the
// acknowledgement is lost.
//
// A missing FB is not a missing command — the rig acts on the frame as soon as it
// decodes it, and what expires is our wait for the answer on a bus shared with
// the rig's own transceive updates. JTDX in "Split Operating: Fake It" moves the
// dial immediately before every key-down, so the PTT ack queues behind that
// traffic, and one lost ack was fatal: rigctld answered RPRT -9, JTDX read that
// as losing rig control and tore the connection down mid-over, reopening it a
// moment later (an operator's log shows exactly that, twice, a new rigctld client
// within 300 ms of each failure). The same session over TCI never failed, because
// TCI carries no CI-V and needs no Fake It.
// dial and the mode immediately before every key-down, so those acks queue behind
// each other, and losing one was fatal: rigctld answers RPRT -9, JTDX reads that
// as losing rig control and tears the connection down mid-over. An operator's log
// shows it happening on set_ptt, on set_freq and on set_mode alike, each failure
// followed within 300 ms by a fresh rigctld client — and shows this resend
// rescuing a PTT that would otherwise have ended the over.
//
// Re-sending is safe: asking for a state the rig is already in changes nothing.
// Only for commands that say "be in this state": re-sending one changes nothing
// if the first arrived. A relative or incremental command must not come through
// here, which is why this is opt-in per caller rather than folded into exec.
func (b *IcomSerial) execIdempotent(what string, payload ...byte) error {
err := b.exec(payload...)
if err == nil || !errors.Is(err, errIcomAckLost) {
return err
}
applog.Printf("icom: %s — no acknowledgement in %s, sending it once more", what, icomCmdTimeout)
return b.exec(payload...)
}
// SetPTT keys or unkeys the transmitter (CI-V 0x1C 0x00).
func (b *IcomSerial) SetPTT(on bool) error {
state := byte(0)
if on {
state = 1
}
err := b.exec(civ.CmdPTT, civ.SubPTT, state)
if err == nil || !errors.Is(err, errIcomAckLost) {
return err
}
applog.Printf("icom: PTT %v — no acknowledgement in %s, sending it once more", on, icomCmdTimeout)
return b.exec(civ.CmdPTT, civ.SubPTT, state)
return b.execIdempotent(fmt.Sprintf("PTT %v", on), civ.CmdPTT, civ.SubPTT, state)
}
// SetPower turns the transceiver on or off (CI-V 0x18). Power-ON is prefixed with
+6 -1
View File
@@ -47,7 +47,12 @@ type Spot struct {
SourceID int64 `json:"source_id"` // ID of the cluster server this came from
SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers)
Spotter string `json:"spotter"` // DE field
DXCall string `json:"dx_call"` // the DX station heard
// SpotterContinent belongs to the SPOT, not to the DX station: one call is
// spotted by dozens of skimmers on every continent within a minute. It is
// resolved per spot at ingest for exactly that reason — see the note on the
// spotter-continent filter in App.tsx.
SpotterContinent string `json:"spotter_continent,omitempty"`
DXCall string `json:"dx_call"` // the DX station heard
FreqKHz float64 `json:"freq_khz"`
FreqHz int64 `json:"freq_hz"`
Band string `json:"band,omitempty"`
+20 -6
View File
@@ -40,12 +40,26 @@ func looksLikeHTML(s string) bool {
// anything in the operator's log, which is what a test button must never do.
const clublogDownloadURL = "https://clublog.org/getadif.php"
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
// requires an api parameter that identifies the client software (not the
// user) — the same way Log4OM embeds its own key — so we ship it baked in
// rather than asking each user for one. It's an application identifier, not
// a user secret, but note it is visible in the source and the binary.
const clublogAppAPIKey = "5767f19333363a9ef432ee9cd4141fe76b8adf38"
// clublogAppAPIKey is OpsLog's own Club Log *application* API key, issued to
// "OpsLog" by G7VJR on 2026-08-18.
//
// Club Log requires an api parameter identifying the client SOFTWARE, not the
// user — the same way Log4OM embeds its own — so it ships baked in rather than
// asking every operator to request one.
//
// It replaces a key that was registered to XV9Q, not to OpsLog. That was not a
// cosmetic detail: every OpsLog upload in the world was attributed to that
// callsign, its owner received the abuse warnings OpsLog earned, and a
// revocation aimed at them would have cut Club Log uploads for every user of
// this program at once.
//
// Club Log asks that the key not be published in source code. The source lives
// on a private remote and only the built exe is released — but the key is still
// recoverable from that binary by anyone who looks, as it is for every logger
// that embeds one. Treat it as an identifier that can be attributed, never as a
// secret: it authorises nothing on its own, since every request also carries the
// operator's own e-mail and password.
const clublogAppAPIKey = "8df47807a412c586787c9401c96c10c135d6e580"
// UploadClublog pushes one ADIF record to Club Log in real time. The user
// supplies the account email + password and the logbook callsign; the
+13
View File
@@ -37,6 +37,19 @@ const (
ServiceDBUpdated ServiceType = "db_updated" // ADIF of each locally-logged QSO (on save)
ServicePstFreq ServiceType = "pstrotator_freq" // <PST><FREQUENCY> radio freq (on freq change)
ServiceN1MMRadio ServiceType = "n1mm_radioinfo" // N1MM RadioInfo XML: freq+mode (on freq/mode change)
// ServiceWSJTRelay re-sends every datagram an inbound WSJT listener receives,
// byte for byte, to somewhere else.
//
// WSJT-X, JTDX and MSHV send to ONE address. Anything else that wants the
// same stream — JTAlert, GridTracker, a second logger — has to be fed by a
// relay, and this is it, so OpsLog stops being the reason you cannot run the
// two together.
//
// VERBATIM, with no origin header prepended. The relays in the field add one
// ("127.0.0.1:2237|"), which is precisely why stripForwarderHeader exists on
// the receiving side here; inflicting the same thing on whatever is
// downstream would be repeating a mistake we had to write code to survive.
ServiceWSJTRelay ServiceType = "wsjt_relay"
// ServiceWSJTLog wraps the same ADIF in a WSJT-X "Logged ADIF" datagram.
// Logger32 and others listen on the WSJT-X interface, not for plain text, and
// discard a bare ADIF record without a word — so the two cannot be one row.
@@ -0,0 +1,75 @@
package udp
import (
"bytes"
"encoding/binary"
"testing"
)
// A grid reaches OpsLog in TWO kinds of message, not one. The reply that
// answers a CQ with a locator — "C91RU IZ5EME JN52" — is the commonest of them
// on a busy band, and reading only CQs meant any station whose CQ we happened
// to miss had no known grid at all, so it could never be flagged as a new one.
func TestDecodeGridFromBothMessageShapes(t *testing.T) {
for _, tc := range []struct {
msg string
call string
cq bool
grid string
why string
}{
{"CQ RW9MZ MO74", "RW9MZ", true, "MO74", "a plain CQ"},
{"CQ DX F4BPO JN36", "F4BPO", true, "JN36", "a CQ with a modifier"},
{"C91RU IZ5EME JN52", "IZ5EME", false, "JN52", "a reply answering with its grid"},
{"YB1EWD YO6GLT KN36", "YO6GLT", false, "KN36", "the same, other way round"},
// Everything else in the third slot is a report or a sign-off, and must
// never be mistaken for a locator.
{"C91RU IZ5EME -05", "IZ5EME", false, "", "a signal report is not a grid"},
{"C91RU IZ5EME R-05", "IZ5EME", false, "", "a rogered report is not a grid"},
{"C91RU IZ5EME RRR", "IZ5EME", false, "", "RRR is not a grid"},
{"C91RU IZ5EME RR73", "IZ5EME", false, "", "RR73 has the shape of one and is not"},
{"C91RU IZ5EME 73", "IZ5EME", false, "", "73 is not a grid"},
{"<YO7LBX> 8F81SU RR73", "8F81SU", false, "", "a hashed call, signing off"},
} {
call, cq, grid := wsjtSender(tc.msg)
if call != tc.call || cq != tc.cq || grid != tc.grid {
t.Errorf("wsjtSender(%q) = (%q, %v, %q), want (%q, %v, %q) — %s",
tc.msg, call, cq, grid, tc.call, tc.cq, tc.grid, tc.why)
}
}
}
// The Reply replays the decode field for field, so the message must survive the
// trip UNTRIMMED. DecodeMsg is trimmed for display; DecodeMsgRaw is what goes
// back, and a stripped trailing space is a mismatch the far end reports nowhere.
func TestDecodeKeepsTheUntrimmedMessage(t *testing.T) {
qstr := func(b *bytes.Buffer, v string) {
binary.Write(b, binary.BigEndian, int32(len(v)))
b.WriteString(v)
}
const padded = "CQ RW9MZ MO74 "
var p bytes.Buffer
binary.Write(&p, binary.BigEndian, uint32(wsjtMagic))
binary.Write(&p, binary.BigEndian, uint32(2))
binary.Write(&p, binary.BigEndian, uint32(wsjtMsgDecode))
qstr(&p, "JTDX")
binary.Write(&p, binary.BigEndian, uint8(1))
binary.Write(&p, binary.BigEndian, uint32(45_000_000))
binary.Write(&p, binary.BigEndian, int32(-6))
binary.Write(&p, binary.BigEndian, float64(0.2))
binary.Write(&p, binary.BigEndian, uint32(1500))
qstr(&p, "~")
qstr(&p, padded)
ev, ok, err := ParseWSJT(p.Bytes())
if err != nil || !ok {
t.Fatalf("parse: %v ok=%v", err, ok)
}
if ev.DecodeMsgRaw != padded {
t.Errorf("DecodeMsgRaw = %q, want %q — a Reply built from this will not match", ev.DecodeMsgRaw, padded)
}
if ev.DecodeMsg != "CQ RW9MZ MO74" {
t.Errorf("DecodeMsg = %q, want it trimmed for display", ev.DecodeMsg)
}
}
@@ -0,0 +1,115 @@
package udp
import (
"bytes"
"encoding/binary"
"net"
"testing"
)
// A Decode does not carry the mode's NAME. It carries the one-character marker
// from the decode line — "~" for FT8, "+" for FT4 — and that character used to
// be passed on as if it were a mode. Everything downstream compared it against
// the modes in the log, matched nothing, and reported every station on an
// already-worked band as a NEW MODE.
func TestDecodeModeNameResolvesTheMarker(t *testing.T) {
for raw, want := range map[string]string{
"~": "FT8",
"+": "FT4",
"#": "JT65",
"@": "JT9",
} {
if got := DecodeModeName(raw, "FT8"); got != want {
t.Errorf("DecodeModeName(%q) = %q, want %q", raw, got, want)
}
}
}
// A sender that puts the real name in the field is believed as-is — several do,
// and the marker table must not get in their way.
func TestDecodeModeNameKeepsARealName(t *testing.T) {
for _, raw := range []string{"FT8", "ft4", "JS8", "Q65"} {
if got := DecodeModeName(raw, ""); got == "" || got != upper(raw) {
t.Errorf("DecodeModeName(%q) = %q, want the name itself", raw, got)
}
}
}
// The safety net: an unknown marker falls back to the mode from the sender's
// last Status, which always carries the real name. This is what keeps a future
// or unlisted marker degrading to correct rather than to nonsense.
func TestDecodeModeNameFallsBackToStatus(t *testing.T) {
if got := DecodeModeName("%", "FT4"); got != "FT4" {
t.Errorf("unknown marker resolved to %q, want the Status mode FT4", got)
}
if got := DecodeModeName("", "FT8"); got != "FT8" {
t.Errorf("empty mode resolved to %q, want the Status mode FT8", got)
}
// Nothing known at all is empty rather than a guess: an empty mode makes the
// status resolver answer "worked", which is the safe side — a wrong mode
// would invent a new-mode flag exactly as the marker did.
if got := DecodeModeName("%", ""); got != "" {
t.Errorf("with no Status mode the result was %q, want empty", got)
}
}
func upper(s string) string {
out := []rune(s)
for i, r := range out {
if r >= 'a' && r <= 'z' {
out[i] = r - 32
}
}
return string(out)
}
// Resolving the marker must not DESTROY it. A Reply is matched by the receiving
// application against its own decode list field for field, and the mode field it
// compares is the one it sent — the marker. Sending the resolved name instead is
// accepted silently and simply never transmits: JTDX finds no matching decode,
// and no error is reported anywhere to say so. Reported as "clicking a CQ does
// nothing in JTDX".
//
// So a Decode has to carry BOTH: Mode for the log and the status resolver,
// DecodeModeRaw for the Reply.
func TestDecodeKeepsTheRawModeMarkerForReplies(t *testing.T) {
qstr := func(b *bytes.Buffer, v string) {
binary.Write(b, binary.BigEndian, int32(len(v)))
b.WriteString(v)
}
var p bytes.Buffer
binary.Write(&p, binary.BigEndian, uint32(wsjtMagic))
binary.Write(&p, binary.BigEndian, uint32(2)) // schema
binary.Write(&p, binary.BigEndian, uint32(wsjtMsgDecode)) // type 2
qstr(&p, "JTDX") // id
binary.Write(&p, binary.BigEndian, uint8(1)) // is_new
binary.Write(&p, binary.BigEndian, uint32(45_000_000)) // time (ms since midnight)
binary.Write(&p, binary.BigEndian, int32(-6)) // snr
binary.Write(&p, binary.BigEndian, float64(0.2)) // delta_time
binary.Write(&p, binary.BigEndian, uint32(1500)) // delta_frequency
qstr(&p, "~") // mode — the MARKER
qstr(&p, "CQ EN35UKR") // message
out := make(chan Event, 4)
s := &Server{
out: out,
cfg: Config{Name: "JTDX", ServiceType: ServiceWSJT},
lastMode: map[string]string{"JTDX": "FT8"},
dialHz: map[string]int64{"JTDX": 14074000},
}
s.handle(p.Bytes(), &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 2237})
var ev Event
select {
case ev = <-out:
default:
t.Fatal("no event emitted for the decode")
}
if ev.Mode != "FT8" {
t.Errorf("resolved Mode = %q, want FT8 (what the log and the status resolver need)", ev.Mode)
}
if ev.DecodeModeRaw != "~" {
t.Errorf("DecodeModeRaw = %q, want %q — a Reply built from this will not match and JTDX will not transmit",
ev.DecodeModeRaw, "~")
}
}
@@ -0,0 +1,70 @@
package udp
import (
"testing"
"time"
)
const ms = 1000
func sec(h, m, s int) uint32 { return uint32((h*3600 + m*60 + s) * ms) }
// WSJT-X stamps a decode with a time of DAY and no date, so the date has to come
// from our own clock — and around midnight the two disagree. A decode stamped
// 23:59:58 that reaches us at 00:00:01 would be dated the NEW day, putting it
// almost 24 hours in the future: it would sort to the top of the decodes panel
// and stay there for the rest of the session, and its period would never line up
// with the ones around it.
//
// The clock is passed IN. The previous version of this test read the real one
// and so failed every afternoon — a 23:59:58 stamp seen at 17:00 is genuinely
// hours in the future, and no rule can make it otherwise. A suite that is red
// for half the day is a suite nobody reads.
func TestDecodeTimeCrossesMidnight(t *testing.T) {
// Just after midnight, a stamp from the last seconds of yesterday.
now := time.Date(2026, 3, 14, 0, 0, 1, 0, time.UTC)
got := decodeTimeAt(sec(23, 59, 58), now)
if want := time.Date(2026, 3, 13, 23, 59, 58, 0, time.UTC); !got.Equal(want) {
t.Errorf("23:59:58 seen at 00:00:01 → %s, want %s (yesterday)", got, want)
}
if got.After(now) {
t.Errorf("%s is in the future — it would sort to the top of the list for the session", got)
}
}
// The mirror case: just before midnight, a stamp from the first seconds of
// tomorrow. Our clock is still on the old day.
func TestDecodeTimeCrossesMidnightBackwards(t *testing.T) {
now := time.Date(2026, 3, 13, 23, 59, 58, 0, time.UTC)
got := decodeTimeAt(sec(0, 0, 1), now)
if want := time.Date(2026, 3, 14, 0, 0, 1, 0, time.UTC); !got.Equal(want) {
t.Errorf("00:00:01 seen at 23:59:58 → %s, want %s (tomorrow)", got, want)
}
}
// The ordinary case, which is every decode that is not within a few seconds of
// midnight: the stamp belongs to today and is left alone.
func TestDecodeTimeOrdinary(t *testing.T) {
now := time.Date(2026, 3, 13, 14, 30, 0, 0, time.UTC)
got := decodeTimeAt(sec(14, 29, 45), now)
if want := time.Date(2026, 3, 13, 14, 29, 45, 0, time.UTC); !got.Equal(want) {
t.Errorf("a stamp close to now → %s, want %s", got, want)
}
}
// The whole point of the timestamp is grouping, so two decodes from the same
// fifteen-second slot must floor to the same period however far apart in the
// slot they were heard.
func TestDecodesInOneSlotShareAPeriod(t *testing.T) {
now := time.Date(2026, 3, 13, 12, 30, 7, 0, time.UTC)
at := func(h, m, s int) time.Time { return decodeTimeAt(sec(h, m, s), now) }
floor := func(x time.Time) int64 { return x.Unix() / 15 * 15 }
a, b := at(12, 30, 0), at(12, 30, 14)
if floor(a) != floor(b) {
t.Errorf("%s and %s fell in different periods", a, b)
}
if floor(at(12, 30, 14)) == floor(at(12, 30, 15)) {
t.Error("12:30:14 and 12:30:15 are different slots and must not share a period")
}
}
+92
View File
@@ -2,6 +2,7 @@ package udp
import (
"fmt"
"net"
"strings"
"hamlog/internal/applog"
@@ -185,3 +186,94 @@ func (m *Manager) sendTo(c Config, payload []byte) {
applog.Printf("udp: [%s] sent %d bytes to %s (%s)%s",
c.Name, len(payload), dst, c.ServiceType, sentPreview(c, payload))
}
// RelayInbound re-sends one received datagram, unchanged, to every configured
// relay destination.
//
// Called with the RAW bytes as they arrived — before parsing, and whatever the
// parse made of them. A packet this build cannot decode is still a packet the
// application downstream may understand perfectly well, and a relay that only
// forwards what it understood is a relay that silently drops the fields it has
// not learned about yet.
//
// Loop guard: a destination that is one of our OWN inbound ports on a local
// address would come straight back in and be relayed again, for ever, at line
// rate. Such a row is skipped and said so once — quietly dropping it would look
// like a relay that does not work.
func (m *Manager) RelayInbound(pkt []byte, fromPort int) {
if len(pkt) == 0 {
return
}
rows := m.Outbound(ServiceWSJTRelay)
if len(rows) == 0 {
return
}
m.mu.Lock()
ports := make(map[int]struct{}, len(m.inbound))
for _, s := range m.inbound {
ports[s.cfg.Port] = struct{}{}
}
m.mu.Unlock()
for _, c := range rows {
if isLoopback(c.DestinationIP) {
if _, mine := ports[c.Port]; mine {
m.relayLoopOnce(c)
continue
}
}
// Deliberately not sendTo: that logs a line per datagram, and this runs
// on every decode of every period — it would bury the log within minutes.
host := strings.TrimSpace(c.DestinationIP)
if host == "" {
host = "127.0.0.1"
}
dst := fmt.Sprintf("%s:%d", host, c.Port)
if err := SendUDP(dst, pkt); err != nil {
m.relayErrOnce(c, dst, err)
}
}
_ = fromPort
}
// isLoopback reports whether a destination names this machine. Empty counts:
// sendTo defaults it to 127.0.0.1.
func isLoopback(host string) bool {
h := strings.TrimSpace(host)
if h == "" || h == "localhost" {
return true
}
ip := net.ParseIP(h)
return ip != nil && ip.IsLoopback()
}
// relayLoopOnce and relayErrOnce keep a repeating relay complaint to one line a
// session. Both fire per datagram otherwise, which on a busy band is hundreds a
// minute and makes the log useless for anything else.
func (m *Manager) relayLoopOnce(c Config) {
m.relayWarnMu.Lock()
defer m.relayWarnMu.Unlock()
if m.relayWarned == nil {
m.relayWarned = map[int64]bool{}
}
if m.relayWarned[c.ID] {
return
}
m.relayWarned[c.ID] = true
applog.Printf("udp: [%s] relay target %s:%d is one of OpsLog's OWN listening ports — "+
"that would feed the stream back into itself for ever. Nothing is relayed on this row; "+
"point it at the other application's port.", c.Name, c.DestinationIP, c.Port)
}
func (m *Manager) relayErrOnce(c Config, dst string, err error) {
m.relayWarnMu.Lock()
defer m.relayWarnMu.Unlock()
if m.relayWarned == nil {
m.relayWarned = map[int64]bool{}
}
if m.relayWarned[-c.ID-1] {
return
}
m.relayWarned[-c.ID-1] = true
applog.Printf("udp: [%s] relay to %s failed: %v (said once)", c.Name, dst, err)
}
+77
View File
@@ -0,0 +1,77 @@
package udp
import (
"net"
"testing"
"time"
)
// The relay exists so a second application can be fed the same WSJT-X stream,
// and the ONE thing it must get right is that the bytes are unchanged. The
// relays in the field prepend an origin header ("127.0.0.1:2237|") — which is
// why stripForwarderHeader had to be written on the receiving side — so a
// header here would inflict on the next program the fault we had to survive.
func TestRelayForwardsVerbatim(t *testing.T) {
dst, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
if err != nil {
t.Fatal(err)
}
defer dst.Close()
port := dst.LocalAddr().(*net.UDPAddr).Port
m := &Manager{outbound: []Config{{
ID: 1, Name: "relay", ServiceType: ServiceWSJTRelay,
DestinationIP: "127.0.0.1", Port: port,
}}}
// A packet with bytes that no parser here understands, on purpose: the relay
// must not care what it is carrying.
sent := []byte{0xad, 0xbc, 0xcb, 0xda, 0x00, 0x00, 0x00, 0x63, 0xde, 0xad, 0xbe, 0xef}
m.RelayInbound(sent, 2237)
buf := make([]byte, 1024)
_ = dst.SetReadDeadline(time.Now().Add(2 * time.Second))
n, _, err := dst.ReadFromUDP(buf)
if err != nil {
t.Fatalf("nothing relayed: %v", err)
}
if got := buf[:n]; string(got) != string(sent) {
t.Errorf("relayed % X, want % X — the bytes must go out unchanged", got, sent)
}
}
// A relay aimed at one of OpsLog's OWN listening ports would come straight back
// in, be relayed again, and saturate the loopback within seconds. The row is
// skipped instead.
func TestRelayRefusesToFeedItself(t *testing.T) {
own, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
if err != nil {
t.Fatal(err)
}
defer own.Close()
port := own.LocalAddr().(*net.UDPAddr).Port
m := &Manager{
inbound: map[int64]*Server{
7: {cfg: Config{ID: 7, Port: port, ServiceType: ServiceWSJT}},
},
outbound: []Config{{
ID: 1, Name: "loop", ServiceType: ServiceWSJTRelay,
DestinationIP: "127.0.0.1", Port: port,
}},
}
m.RelayInbound([]byte{1, 2, 3, 4}, 2237)
buf := make([]byte, 64)
_ = own.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
if n, _, err := own.ReadFromUDP(buf); err == nil {
t.Fatalf("relayed %d bytes back into our own listener — that is the loop", n)
}
}
// No relay rows configured must cost nothing and send nothing.
func TestRelayWithNoRowsIsSilent(t *testing.T) {
m := &Manager{}
m.RelayInbound([]byte{1, 2, 3}, 2237) // must not panic
m.RelayInbound(nil, 2237)
}
+219 -10
View File
@@ -77,6 +77,37 @@ func reusingListenConfig() net.ListenConfig {
}
}
// decodeTime turns WSJT-X's milliseconds-since-midnight into a UTC instant.
//
// The sender gives a time of DAY with no date, so the date comes from our own
// clock — and the two can straddle midnight: a decode stamped 23:59:58 that
// reaches us at 00:00:01 would otherwise be dated a day late and sort to the top
// of the list for the rest of the session. More than half a day apart is read as
// the wrong side of midnight and moved.
func decodeTime(msSinceMidnight uint32) time.Time {
return decodeTimeAt(msSinceMidnight, time.Now().UTC())
}
// decodeTimeAt is decodeTime with the clock passed in.
//
// Split out so the midnight rule can be tested at midnight instead of at
// whatever time the suite happens to run: the test used to read the real clock
// and failed every afternoon, because a 23:59:58 stamp seen at 17:00 is hours in
// the future and no rule can make it otherwise. A test that fails for half the
// day teaches everyone to ignore the suite.
func decodeTimeAt(msSinceMidnight uint32, now time.Time) time.Time {
now = now.UTC()
midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
at := midnight.Add(time.Duration(msSinceMidnight) * time.Millisecond)
switch {
case at.Sub(now) > 12*time.Hour:
at = at.AddDate(0, 0, -1) // stamped late yesterday, arrived after midnight
case now.Sub(at) > 12*time.Hour:
at = at.AddDate(0, 0, 1) // stamped just after midnight, our clock still on the old day
}
return at
}
// Event is what a Server emits to its consumer for every parsed packet.
// At most one of the fields is populated per event.
type Event struct {
@@ -92,10 +123,55 @@ type Event struct {
// A WSJT-X Decode (heard station) to render on the panadapter.
DecodeCall string // transmitting (DE) callsign
DecodeGrid string // 4-char grid, CQ decodes only
DecodeGrid string // 4-char grid: from a CQ, or a reply answering with its locator
DecodeFreqHz int64 // RF frequency (dial + audio offset)
DecodeSNR int // reported SNR (dB)
DecodeCQ bool // the decode was a CQ
DecodeMsg string // the decoded line as printed ("CQ K1ABC FN42")
// DecodeAt is the decode's own UTC timestamp, rebuilt from the sender's
// milliseconds-since-midnight. It is what groups decodes into T/R periods:
// a period's worth arrives in one burst, so arrival time would put them all
// in whichever slot the burst happened to land in.
DecodeAt time.Time
// DecodeTRPeriod is the transmit/receive period in seconds, from the last
// Status of the same program (15 = FT8). 0 when the sender never said.
DecodeTRPeriod int
DecodeDial int64 // dial frequency the decode was heard on, for the band
DecodeOffAir bool // decoded from a file rather than off the air
// The three fields below are shown in the panel AND replayed verbatim when
// answering the station — WSJT-X matches a Reply against its own decode list,
// so every one has to go back exactly as it came.
DecodeDT float64 // seconds into the slot the transmission started
DecodeAudioHz int64 // audio offset inside the passband
DecodeMs uint32 // the decode's raw ms-since-midnight, as sent
DecodeLowConf bool
// DecodeModeRaw is the mode field EXACTLY as the Decode carried it — the
// one-character marker ("~", "+"), not the resolved name in Mode.
//
// Both are needed and they are not interchangeable. Mode is what the log and
// the status resolver want. This is what a Reply must echo: the receiving
// application matches a Reply against its own decode list field for field,
// and JTDX rejects one whose mode reads "FT8" where it decoded "~" — the
// click is accepted, nothing is transmitted, and nothing is reported.
DecodeModeRaw string
// DecodeMsgRaw is the message as sent, untrimmed — see DecodeModeRaw.
DecodeMsgRaw string
// ProgramID is the sending application's own id ("WSJT-X", "MSHV", or
// "WSJT-X - 2" for a second instance started with --rig-name). It is what
// tells two receivers apart on one multicast group — and it is the address a
// Reply message would have to be sent back to, so it is carried even though
// nothing replies yet.
ProgramID string
// TxMessage is what the operator's digital app is sending, with Transmitting
// true while the carrier is actually up. From Status, so ~1 Hz.
TxMessage string
Transmitting bool
// TxEnabled is the sender's Enable Tx toggle — see WSJTEvent.TxEnabled. The
// watchdog clears it, which is the only reliable sign an exchange was
// abandoned rather than merely paused between overs.
TxEnabled bool
DECall string // the operator's own call, as the digital app knows it
// ClearCall is set when a WSJT/JTDX/MSHV Status message reports an EMPTY DX
// Call after previously reporting one — i.e. the operator cleared the call in
@@ -112,7 +188,10 @@ type Event struct {
// Server is a single inbound UDP listener.
type Server struct {
cfg Config
cfg Config
// mgr is the owning Manager, so a listener can reach the outbound rows.
// The relay is the only thing that needs it.
mgr *Manager
conn *net.UDPConn
out chan<- Event
stop chan struct{}
@@ -127,7 +206,16 @@ type Server struct {
// 50.400 panadapter. WSJT-X requires --rig-name for a second instance, so the
// id is distinct whenever there is more than one.
dialHz map[string]int64
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
// trPeriod is the T/R period (seconds) from each program's last Status —
// what tells a decode which slot it belongs to.
trPeriod map[string]int
// lastFrom is the address each program's packets arrive from — where a Reply
// has to be sent. See SendReply.
lastFrom map[string]*net.UDPAddr
// 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
// badPkts counts datagrams this listener could not parse, so the diagnostic
// dump below stays bounded. A misconfigured port is not a one-off: the
@@ -135,6 +223,15 @@ type Server struct {
// which fills the whole rotating log with the same line and buries the
// evidence of anything else.
badPkts int
// drops counts events discarded because the consumer could not keep up —
// see the select at the foot of handle(). They used to vanish in silence,
// which made a missing decode indistinguishable from one the sender never
// broadcast: an operator comparing the panel against JTDX side by side was
// the only detector we had. A period's decodes arrive as one burst, so this
// is exactly when it happens.
drops int
lastDropL time.Time
}
// maxBadPktDumps is how many unparseable datagrams a listener describes in full
@@ -171,10 +268,11 @@ func describePacket(pkt []byte) string {
return fmt.Sprintf("%d bytes | text %q%s | hex %s%s", len(pkt), text.String(), more, strings.TrimSpace(hex.String()), more)
}
func newServer(cfg Config, out chan<- Event) *Server {
func newServer(cfg Config, out chan<- Event, mgr *Manager) *Server {
return &Server{
cfg: cfg,
out: out,
mgr: mgr,
stop: make(chan struct{}),
done: make(chan struct{}),
}
@@ -340,6 +438,17 @@ func (s *Server) noteIgnoredADIF(pkt []byte) {
}
func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
// Relay FIRST, and unconditionally: before parsing, and whatever the parse
// then makes of it. A datagram this build cannot decode is still one the
// application downstream may understand, and a relay that forwards only what
// it understood drops exactly the fields it has yet to learn about.
//
// WSJT listeners only. The other inbound services are text protocols with no
// second consumer to speak of, and relaying an ADIF record twice would log
// the contact twice.
if s.cfg.ServiceType == ServiceWSJT && s.mgr != nil {
s.mgr.RelayInbound(pkt, s.cfg.Port)
}
ev := Event{ConfigID: s.cfg.ID, Service: s.cfg.ServiceType, Source: remote.String()}
switch s.cfg.ServiceType {
case ServiceWSJT:
@@ -351,6 +460,18 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
if !ok {
return
}
// Where this application's packets come from, so a Reply can be sent back
// to it. Per PROGRAM, not per listener: two receivers share one multicast
// group, and a reply must reach the one that heard the station — and it
// must go to the sender's own address, never to the group.
if w.ProgramID != "" && remote != nil {
s.mu.Lock()
if s.lastFrom == nil {
s.lastFrom = map[string]*net.UDPAddr{}
}
s.lastFrom[w.ProgramID] = remote
s.mu.Unlock()
}
// Status carries the current dial frequency; remember it so Decode audio
// offsets can be turned into RF frequencies for the panadapter.
if w.FreqHz > 0 && !w.IsDecode {
@@ -359,11 +480,40 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
s.dialHz = map[string]int64{}
}
s.dialHz[w.ProgramID] = w.FreqHz
// The T/R period travels with Status, and a decode has to be told
// which slot it belongs to — so it is remembered per program the
// same way the dial is.
if w.TRPeriod > 0 {
if s.trPeriod == nil {
s.trPeriod = map[string]int{}
}
s.trPeriod[w.ProgramID] = w.TRPeriod
}
// The mode NAME, which only Status carries: a Decode gives the
// one-character marker instead. See DecodeModeName.
if w.Mode != "" {
if s.lastMode == nil {
s.lastMode = map[string]string{}
}
s.lastMode[w.ProgramID] = w.Mode
}
s.mu.Unlock()
}
if !w.IsDecode && (w.TxMessage != "" || w.DECall != "") {
// What the operator is sending, and to whom. Carried on every Status,
// so the consumer sees it change as the QSO progresses. The program id
// travels with it because a second receiver has a transmit state of
// its own.
ev.TxMessage = w.TxMessage
ev.Transmitting = w.Transmitting
ev.DECall = w.DECall
ev.ProgramID = w.ProgramID
}
if w.IsDecode {
s.mu.Lock()
dial := s.dialHz[w.ProgramID]
tr := s.trPeriod[w.ProgramID]
statusMode := s.lastMode[w.ProgramID]
s.mu.Unlock()
if dial <= 0 {
// No Status from THIS instance yet. Guessing with another
@@ -376,7 +526,19 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
ev.DecodeFreqHz = dial + w.DeltaFreqHz
ev.DecodeSNR = w.SNR
ev.DecodeCQ = w.IsCQ
ev.Mode = w.Mode
ev.Mode = DecodeModeName(w.Mode, statusMode)
ev.DecodeModeRaw = w.Mode
ev.DecodeMsg = w.DecodeMsg
ev.DecodeMsgRaw = w.DecodeMsgRaw
ev.DecodeAt = decodeTime(w.DecodeMsSinceMidnight)
ev.DecodeTRPeriod = tr
ev.DecodeDial = dial
ev.DecodeOffAir = w.OffAir
ev.ProgramID = w.ProgramID
ev.DecodeDT = w.DeltaTime
ev.DecodeAudioHz = w.DeltaFreqHz
ev.DecodeMs = w.DecodeMsSinceMidnight
ev.DecodeLowConf = w.LowConfidence
break
}
// Only a logged QSO is worth a line — WSJT-X/MSHV send a Status packet
@@ -501,16 +663,52 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
// Empty events are useless; skip — EXCEPT a clear signal, which is meant to be
// empty (the DX Call was cleared in the digital app), and a tune-only
// request (freq with no callsign).
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" && !ev.ClearCall && ev.TuneFreqHz == 0 {
// TxMessage rides on Status, which also carries the DX call — but a Status
// with an empty DX call and a live transmit message (calling CQ) used to be
// dropped here, and that is exactly the message the decodes panel needs.
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" && ev.TxMessage == "" && !ev.ClearCall && ev.TuneFreqHz == 0 {
return
}
select {
case s.out <- ev:
default:
// Drop on backpressure rather than block the read loop.
// Drop on backpressure rather than block the read loop — but say so.
s.noteDrop(ev)
}
}
// noteDrop reports events lost to a full queue, first one immediately and then
// at most one line a minute with the running total.
//
// Rate-limited because the condition is self-sustaining: a consumer that fell
// behind on one period is behind for the next, and a line per lost decode would
// bury the rest of the log under the symptom. The event's own description goes
// in, because "a decode was lost" and "a Status was lost" are different faults.
func (s *Server) noteDrop(ev Event) {
what := "event"
switch {
case ev.DecodeCall != "":
what = "decode from " + ev.DecodeCall
case ev.LoggedADIF != "":
what = "logged QSO"
case ev.TxMessage != "" || ev.DECall != "":
what = "transmit status"
}
s.mu.Lock()
s.drops++
n := s.drops
quiet := n > 1 && time.Since(s.lastDropL) < time.Minute
if !quiet {
s.lastDropL = time.Now()
}
s.mu.Unlock()
if quiet {
return
}
applog.Printf("udp: [%s] queue full — dropped %s (%d lost this session); "+
"the decodes panel is missing what the sender broadcast", s.cfg.Name, what, n)
}
func (s *Server) close() {
s.mu.Lock()
if s.stopped {
@@ -564,6 +762,13 @@ type Manager struct {
httpFailMu sync.Mutex
httpFailAt map[int64]time.Time
// relayWarned keeps a relay row's complaint (a loop target, a dead
// destination) to one line a session. Keyed by row id for the loop warning
// and by -id-1 for the send failure, so one row can say each once.
// Without this a busy band writes hundreds of identical lines a minute.
relayWarnMu sync.Mutex
relayWarned map[int64]bool
mu sync.Mutex
inbound map[int64]*Server
outbound []Config
@@ -571,8 +776,12 @@ type Manager struct {
func NewManager(repo *Repo) *Manager {
return &Manager{
repo: repo,
out: make(chan Event, 64),
repo: repo,
// 256, not 64: a period delivers twenty-odd decodes in one burst while
// Status keeps arriving, and the consumer does real work per event. The
// depth is headroom for that burst — the drop counter above is what says
// whether it was enough.
out: make(chan Event, 256),
inbound: map[int64]*Server{},
}
}
@@ -616,7 +825,7 @@ func (m *Manager) Reload(ctx context.Context) []string {
m.mu.Unlock()
continue
}
srv := newServer(c, m.out)
srv := newServer(c, m.out, m)
if err := srv.start(); err != nil {
applog.Printf("udp: start %q failed: %v", c.Name, err)
errs = append(errs, fmt.Sprintf("%s: %v", c.Name, err))
+170 -12
View File
@@ -51,10 +51,54 @@ type WSJTEvent struct {
// known dial frequency (from Status) to DeltaFreqHz to get the RF frequency.
IsDecode bool
DecodeCall string // the sender (DE) callsign extracted from the message text
DecodeGrid string // 4-char grid, CQ decodes only — the exchange carries none
DecodeGrid string // 4-char grid: from a CQ, or a reply that answers with its locator
DeltaFreqHz int64 // audio offset within the passband (Hz)
SNR int // reported signal-to-noise (dB)
IsCQ bool // the decode was a CQ call
// DeltaTime is how far into the slot the transmission started, in seconds —
// WSJT-X's "DT" column. Read and discarded before; kept now because it is
// shown, and because a Reply has to replay the decode field for field.
DeltaTime float64
// DecodeMsg is the decoded text as WSJT-X printed it ("CQ K1ABC FN42",
// "F4BPO K1ABC -07"). Kept whole rather than only its parsed pieces: the
// exchange is what tells an operator where a station is in a QSO, and no set
// of extracted fields says "R-09" the way the line itself does.
DecodeMsg string
// DecodeMsgRaw is the message EXACTLY as the datagram carried it, trailing
// spaces and all. DecodeMsg above is trimmed for display; a Reply has to
// send this one, because the receiving application matches a Reply against
// its own decode list field for field and a stripped space is a mismatch it
// reports nowhere.
DecodeMsgRaw string
// DecodeMsSinceMidnight is the decode's own timestamp, in milliseconds since
// 00:00 UTC, as the sender reported it. It is what groups decodes into T/R
// PERIODS — arrival time cannot, since a whole period's decodes land in one
// burst and a slow link shifts the lot into the next slot.
DecodeMsSinceMidnight uint32
DecodeIsNew bool // sender's "is_new": first time this line was decoded
LowConfidence bool // sender is unsure of the decode
OffAir bool // decoded from a file, not off the air
// ---- Status extras ----
// TxMessage is what the operator is sending right now ("CQ F4BPO JN18"),
// with Transmitting saying whether the carrier is actually up. Both come
// from Status, so they arrive about once a second.
TxMessage string
Transmitting bool
// TxEnabled is the sender's "Enable Tx" toggle. It is what the WATCHDOG
// turns off: the application stops transmitting but leaves the DX call set,
// so a caller that reads only the DX call believes the QSO is still running
// and waits for ever. This is the difference between "between overs" and
// "given up".
TxEnabled bool
DECall string // the operator's own callsign, as the digital app knows it
DEGrid string // and their square
// TRPeriod is the transmit/receive period in seconds (15 for FT8, 7 or 8 for
// FT4 depending on the sender's rounding). The authority on how long a slot
// is — better than inferring it from the mode name, which says nothing about
// a custom period.
TRPeriod int
}
// maxFwdHeader bounds how far into a packet the WSJT-X magic may sit behind a
@@ -166,40 +210,81 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
return WSJTEvent{}, false, err
}
ev.DXCall = strings.ToUpper(strings.TrimSpace(dxCall))
// Skip report, tx_mode (QUtf8), tx_enabled (bool), transmitting,
// decoding, rx_df (qint32), tx_df (qint32), de_call (QUtf8),
// de_grid (QUtf8) → then dx_grid.
// report, tx_mode → skipped.
for _, name := range []string{"report", "tx_mode"} {
if _, err := readQString(r); err != nil {
return ev, true, fmt.Errorf("read %s: %w", name, err)
}
}
// 3 booleans (each 1 byte)
for i := 0; i < 3; i++ {
var b uint8
if err := binary.Read(r, binary.BigEndian, &b); err != nil {
// tx_enabled, transmitting, decoding (1 byte each). The middle one is
// worth keeping: it says the carrier is up, which is what turns TxMessage
// from "what I would send" into "what is going out".
var txEnabled, transmitting, decoding uint8
for _, p := range []*uint8{&txEnabled, &transmitting, &decoding} {
if err := binary.Read(r, binary.BigEndian, p); err != nil {
return ev, true, err
}
}
// 2 int32
ev.Transmitting = transmitting != 0
ev.TxEnabled = txEnabled != 0
// rx_df, tx_df
var i32 int32
for i := 0; i < 2; i++ {
if err := binary.Read(r, binary.BigEndian, &i32); err != nil {
return ev, true, err
}
}
// de_call, de_grid, dx_grid
if _, err := readQString(r); err != nil {
deCall, err := readQString(r)
if err != nil {
return ev, true, err
}
if _, err := readQString(r); err != nil {
ev.DECall = strings.ToUpper(strings.TrimSpace(deCall))
deGrid, err := readQString(r)
if err != nil {
return ev, true, err
}
ev.DEGrid = strings.ToUpper(strings.TrimSpace(deGrid))
dxGrid, err := readQString(r)
if err != nil {
return ev, true, err
}
ev.DXGrid = strings.ToUpper(strings.TrimSpace(dxGrid))
// Everything past here was APPENDED to the schema over successive
// releases, and JTDX and MSHV each stop at their own point. A short
// packet is therefore normal, not an error: read as far as the sender
// went and keep what we got. That is why the tail below swallows its
// errors instead of reporting them — the fields already parsed are good.
var b uint8
if binary.Read(r, binary.BigEndian, &b) != nil { // tx_watchdog
return ev, true, nil
}
if _, err := readQString(r); err != nil { // sub_mode
return ev, true, nil
}
if binary.Read(r, binary.BigEndian, &b) != nil { // fast_mode
return ev, true, nil
}
if binary.Read(r, binary.BigEndian, &b) != nil { // special_operation_mode
return ev, true, nil
}
var u32 uint32
if binary.Read(r, binary.BigEndian, &u32) != nil { // frequency_tolerance
return ev, true, nil
}
if binary.Read(r, binary.BigEndian, &u32) != nil { // tr_period (seconds)
return ev, true, nil
}
// 0xFFFFFFFF is WSJT-X's "not applicable" for the quint32 fields.
if u32 > 0 && u32 < 3600 {
ev.TRPeriod = int(u32)
}
if _, err := readQString(r); err != nil { // configuration_name
return ev, true, nil
}
if txMsg, err := readQString(r); err == nil {
ev.TxMessage = strings.TrimSpace(txMsg)
}
return ev, true, nil
case wsjtMsgDecode:
@@ -217,6 +302,7 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
if err := binary.Read(r, binary.BigEndian, &b); err != nil { // is_new
return WSJTEvent{}, false, err
}
ev.DecodeIsNew = b != 0
var t32, df uint32
var snr int32
if err := binary.Read(r, binary.BigEndian, &t32); err != nil { // time
@@ -229,6 +315,7 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
if err := binary.Read(r, binary.BigEndian, &dt); err != nil { // delta_time
return WSJTEvent{}, false, err
}
ev.DeltaTime = dt
if err := binary.Read(r, binary.BigEndian, &df); err != nil { // delta_frequency
return WSJTEvent{}, false, err
}
@@ -240,6 +327,11 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
if err != nil {
return WSJTEvent{}, false, err
}
// low_confidence and off_air were appended later; absent on older senders.
var lowConf, offAir uint8
_ = binary.Read(r, binary.BigEndian, &lowConf)
_ = binary.Read(r, binary.BigEndian, &offAir)
call, isCQ, grid := wsjtSender(msg)
if call == "" {
return WSJTEvent{}, false, nil // free-text / telemetry / unparseable → ignore
@@ -251,6 +343,11 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
ev.DeltaFreqHz = int64(df)
ev.SNR = int(snr)
ev.Mode = strings.ToUpper(strings.TrimSpace(mode))
ev.DecodeMsg = strings.TrimSpace(msg)
ev.DecodeMsgRaw = msg
ev.DecodeMsSinceMidnight = t32
ev.LowConfidence = lowConf != 0
ev.OffAir = offAir != 0
return ev, true, nil
case wsjtMsgLoggedADIF:
@@ -303,7 +400,21 @@ func wsjtSender(message string) (call string, isCQ bool, grid string) {
return "", true, ""
}
// Standard exchange: the DE (sender) call is the second token.
//
// And the THIRD token is a grid whenever the station is answering with its
// locator — "C91RU IZ5EME JN52", the ordinary first reply to a CQ. This used
// to return no grid at all on the grounds that "the exchange carries none",
// which is simply not true: it is the commonest grid-bearing message on a
// busy band, and only a CQ being read meant a station whose CQ we missed had
// no known grid — so it could never be flagged as a new one. GridTracker
// reads both, which is why its wanted-grid count ran so far ahead of ours.
//
// isGridField is what keeps the reports and sign-offs out: -05, R-05, RRR,
// 73 and RR73 all fail it, on length, charset or by name.
if len(f) >= 2 && looksLikeCall(f[1]) {
if len(f) >= 3 && isGridField(f[2]) {
return f[1], false, f[2]
}
return f[1], false, ""
}
return "", false, ""
@@ -370,3 +481,50 @@ func readQString(r *bytes.Reader) (string, error) {
}
return string(buf), nil
}
// decodeModeChar maps the single character WSJT-X puts in a Decode's mode field
// to the mode it stands for.
//
// A Decode does NOT carry the mode's name. It carries the one-character marker
// that appears in the decode line and in ALL.TXT — "~" for FT8, "+" for FT4 —
// and that character was being passed straight through as if it were a mode.
// Everything downstream then compared "~" against the modes in the log, matched
// nothing, and called every station on an already-worked band a new MODE.
//
// The table covers what is common; anything missing falls back to the mode from
// the sender's last Status, which carries the real name — so an unlisted or
// future marker degrades to correct rather than to nonsense.
var decodeModeChar = map[string]string{
"~": "FT8",
"+": "FT4",
"#": "JT65",
"@": "JT9",
"&": "MSK144",
":": "Q65",
"`": "FST4",
}
// 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.
func DecodeModeName(raw, statusMode string) string {
raw = strings.TrimSpace(raw)
if m, ok := decodeModeChar[raw]; ok {
return m
}
// 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 {
named := true
for _, r := range raw {
if !(r >= 'A' && r <= 'Z') && !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') {
named = false
break
}
}
if named {
return strings.ToUpper(raw)
}
}
return strings.ToUpper(strings.TrimSpace(statusMode))
}
+78
View File
@@ -0,0 +1,78 @@
package udp
import (
"bytes"
"encoding/binary"
"fmt"
"strings"
"hamlog/internal/applog"
)
// WSJT-X Free Text (message type 9) — "put this in the free-text box, and send".
//
// Free Text type 9
// id utf8 the target application's own id
// text utf8 the message
// send bool true = transmit it now, false = only fill the box
//
// What this is FOR, and what it is not:
//
// It is the one "transmit this now" primitive that behaves the same on WSJT-X,
// WSJT-Z, JTDX, JTDX-Improved and MSHV, because it needs no decode to match
// against — unlike Reply, which the receiving application will only action if it
// can find the exact decode being answered.
//
// It is NOT a way to call a station. FT8 free text is 13 characters, so a
// standard "<DX> <ME> <GRID>" does not fit, and more importantly the text goes
// out AS free text without setting the DX Call — so the sequencer never takes
// over, and no report, RR73 or logging prompt follows. Use it for a 73, a short
// note or a CQ; use Reply to start a QSO.
const wsjtMsgFreeText = 9
// EncodeFreeText builds the datagram. send=false fills the box without keying.
func EncodeFreeText(programID, text string, send bool) []byte {
var b bytes.Buffer
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMagic))
_ = binary.Write(&b, binary.BigEndian, uint32(2)) // schema 2 — what every current sender speaks
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMsgFreeText))
writeQString(&b, programID)
writeQString(&b, text)
var s uint8
if send {
s = 1
}
_ = binary.Write(&b, binary.BigEndian, s)
return b.Bytes()
}
// SendFreeText hands a free-text message to one decoding application.
//
// Routed by program id to the address that instance's packets arrive from, for
// the same reason as SendReply and SendHaltTx: two receivers can share one
// multicast group, and transmitting from the wrong one is worse than not
// transmitting at all.
func (m *Manager) SendFreeText(programID, text string, send bool) error {
if strings.TrimSpace(programID) == "" {
return fmt.Errorf("no application id — cannot tell which receiver to send with")
}
m.mu.Lock()
servers := make([]*Server, 0, len(m.inbound))
for _, s := range m.inbound {
servers = append(servers, s)
}
m.mu.Unlock()
for _, s := range servers {
conn, addr := s.replyTarget(programID)
if conn == nil || addr == nil {
continue
}
if _, err := conn.WriteToUDP(EncodeFreeText(programID, text, send), addr); err != nil {
return fmt.Errorf("send free text to %s at %s: %w", programID, addr, err)
}
applog.Printf("udp: free text sent to %s at %s — %q (send=%v)", programID, addr, text, send)
return nil
}
return fmt.Errorf("no packet has arrived from %q yet — nothing to send to", programID)
}
@@ -0,0 +1,45 @@
package udp
import (
"bytes"
"testing"
)
// The wire format, byte for byte. A malformed Free Text is discarded in silence
// by the far end, exactly like a malformed Reply — so a wrong byte here shows up
// as a button that does nothing.
func TestEncodeFreeText(t *testing.T) {
got := EncodeFreeText("JTDX", "73 GL", true)
want := []byte{
0xad, 0xbc, 0xcb, 0xda, // magic
0x00, 0x00, 0x00, 0x02, // schema 2
0x00, 0x00, 0x00, 0x09, // type 9 — Free Text
0x00, 0x00, 0x00, 0x04, 'J', 'T', 'D', 'X',
0x00, 0x00, 0x00, 0x05, '7', '3', ' ', 'G', 'L',
0x01, // send now
}
if !bytes.Equal(got, want) {
t.Errorf("EncodeFreeText\n got %#v\nwant %#v", got, want)
}
}
// send=false fills the box without keying — the difference is one byte, and
// getting it backwards would transmit when the operator only meant to prepare.
func TestEncodeFreeTextWithoutSending(t *testing.T) {
got := EncodeFreeText("MSHV", "", false)
if got[len(got)-1] != 0x00 {
t.Errorf("send flag = %#x, want 0 — this would transmit unasked", got[len(got)-1])
}
// An empty text is a zero-length QString, never the -1 that means null:
// WSJT-X drops a packet with a null where it expects text.
if !bytes.Equal(got[len(got)-5:len(got)-1], []byte{0, 0, 0, 0}) {
t.Errorf("empty text was not encoded as a zero-length string: % X", got)
}
}
func TestSendFreeTextRejectsEmptyID(t *testing.T) {
m := &Manager{}
if err := m.SendFreeText(" ", "73", true); err == nil {
t.Fatal("expected an error for a blank program id")
}
}
+73
View File
@@ -0,0 +1,73 @@
package udp
import (
"bytes"
"encoding/binary"
"fmt"
"strings"
"hamlog/internal/applog"
)
// WSJT-X Halt Tx (message type 8) — "stop transmitting".
//
// The same two things the Halt Tx button and the Enable Tx toggle do in the
// application's own window:
//
// Halt Tx type 8
// id utf8 the target application's own id
// auto_tx_only bool false = stop the transmission NOW
// true = let this over finish, then stop auto-Tx
//
// Both are useful and they are not the same operation. Stopping mid-over is what
// you want when you have called the wrong station or realised the frequency is
// occupied; finishing the over first is what you want when the QSO is complete
// and cutting the transmission would leave the other end waiting for a report
// that never lands.
const wsjtMsgHaltTx = 8
// EncodeHaltTx builds the datagram. autoTxOnly false halts immediately.
func EncodeHaltTx(programID string, autoTxOnly bool) []byte {
var b bytes.Buffer
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMagic))
_ = binary.Write(&b, binary.BigEndian, uint32(2)) // schema 2 — what every current sender speaks
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMsgHaltTx))
writeQString(&b, programID)
var auto uint8
if autoTxOnly {
auto = 1
}
_ = binary.Write(&b, binary.BigEndian, auto)
return b.Bytes()
}
// SendHaltTx tells one decoding application to stop transmitting.
//
// Routed by PROGRAM ID and answered back to the address that instance's packets
// arrive from, exactly like SendReply: two receivers can share one multicast
// group, and halting the 20 m instance because the 6 m one is transmitting would
// be worse than doing nothing.
func (m *Manager) SendHaltTx(programID string, autoTxOnly bool) error {
if strings.TrimSpace(programID) == "" {
return fmt.Errorf("no application id — cannot tell which receiver to halt")
}
m.mu.Lock()
servers := make([]*Server, 0, len(m.inbound))
for _, s := range m.inbound {
servers = append(servers, s)
}
m.mu.Unlock()
for _, s := range servers {
conn, addr := s.replyTarget(programID)
if conn == nil || addr == nil {
continue
}
if _, err := conn.WriteToUDP(EncodeHaltTx(programID, autoTxOnly), addr); err != nil {
return fmt.Errorf("send halt to %s at %s: %w", programID, addr, err)
}
applog.Printf("udp: halt tx sent to %s at %s (auto_tx_only=%v)", programID, addr, autoTxOnly)
return nil
}
return fmt.Errorf("no packet has arrived from %q yet — nothing to halt", programID)
}
@@ -0,0 +1,75 @@
package udp
import (
"bytes"
"testing"
)
// The Halt Tx datagram, byte for byte. A wrong byte here does not fail loudly —
// WSJT-X drops a malformed packet in silence, and the operator sees a Halt
// button that simply does nothing.
func TestEncodeHaltTx(t *testing.T) {
tests := []struct {
name string
id string
autoTxOnly bool
want []byte
}{
{
name: "halt now",
id: "WSJT-X",
want: []byte{
0xad, 0xbc, 0xcb, 0xda, // magic
0x00, 0x00, 0x00, 0x02, // schema 2
0x00, 0x00, 0x00, 0x08, // type 8 — Halt Tx
0x00, 0x00, 0x00, 0x06, // id length
'W', 'S', 'J', 'T', '-', 'X',
0x00, // auto_tx_only = false → stop mid-over
},
},
{
name: "finish the over first",
id: "MSHV",
autoTxOnly: true,
want: []byte{
0xad, 0xbc, 0xcb, 0xda,
0x00, 0x00, 0x00, 0x02,
0x00, 0x00, 0x00, 0x08,
0x00, 0x00, 0x00, 0x04,
'M', 'S', 'H', 'V',
0x01,
},
},
{
// An empty id is length 0, never the -1 that means a null QString:
// a null where text is expected makes WSJT-X discard the packet.
name: "empty id is a zero-length string",
id: "",
want: []byte{
0xad, 0xbc, 0xcb, 0xda,
0x00, 0x00, 0x00, 0x02,
0x00, 0x00, 0x00, 0x08,
0x00, 0x00, 0x00, 0x00,
0x00,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := EncodeHaltTx(tc.id, tc.autoTxOnly)
if !bytes.Equal(got, tc.want) {
t.Errorf("EncodeHaltTx(%q, %v)\n got %#v\nwant %#v", tc.id, tc.autoTxOnly, got, tc.want)
}
})
}
}
// SendHaltTx must refuse an empty id rather than broadcast a halt at whatever
// application happens to answer: with two receivers sharing a multicast group,
// halting the wrong one is worse than doing nothing.
func TestSendHaltTxRejectsEmptyID(t *testing.T) {
m := &Manager{}
if err := m.SendHaltTx(" ", false); err == nil {
t.Fatal("expected an error for a blank program id")
}
}
+138
View File
@@ -0,0 +1,138 @@
package udp
import (
"bytes"
"encoding/binary"
"fmt"
"net"
"strings"
"hamlog/internal/applog"
)
// WSJT-X Reply (message type 4) — "answer this station".
//
// It is the same thing as double-clicking the line in WSJT-X's own Band
// Activity window: the application looks the decode up in its list, sets its
// transmit frequency to the caller's, fills the DX call and starts the exchange.
// Which is why OpsLog cannot do this by tuning the radio — on FT8 the whole band
// sits inside one passband, so moving the dial changes nothing about who gets
// answered. The decision belongs to the decoding application, and this is the
// only way to hand it over.
//
// The payload REPLAYS the decode being answered, and WSJT-X matches it against
// what it decoded. Every field has to come back exactly as it went out — which
// is why the parser now keeps the time, the delta time, the audio offset and the
// message text rather than only what the panadapter needed.
//
// Reply type 4
// id utf8 the target application's own id
// time quint32 ms since midnight, from the decode
// snr qint32
// delta_time double seconds
// delta_frequency quint32 audio offset in the passband, Hz
// mode utf8
// message utf8
// low_confidence bool
// modifiers quint8 keyboard modifiers (0 = a plain click)
const wsjtMsgReply = 4
// Reply is one "call this station" request, rebuilt from a decode.
type Reply struct {
ProgramID string // which application to talk to ("WSJT-X", "MSHV", "WSJT-X - 2")
MsSinceMidnig uint32
SNR int32
DeltaTime float64
DeltaFreqHz uint32
Mode string
Message string
LowConfidence bool
}
// writeQString writes a Qt QString/QUtf8: a big-endian int32 length then the
// bytes. An EMPTY string is length 0, not the -1 that means null — WSJT-X reads
// a null where it expects text as a malformed packet and drops the whole reply.
func writeQString(b *bytes.Buffer, s string) {
_ = binary.Write(b, binary.BigEndian, int32(len(s)))
b.WriteString(s)
}
// EncodeReply builds the datagram.
func EncodeReply(r Reply) []byte {
var b bytes.Buffer
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMagic))
_ = binary.Write(&b, binary.BigEndian, uint32(2)) // schema 2 — the one every current sender speaks
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMsgReply))
writeQString(&b, r.ProgramID)
_ = binary.Write(&b, binary.BigEndian, r.MsSinceMidnig)
_ = binary.Write(&b, binary.BigEndian, r.SNR)
_ = binary.Write(&b, binary.BigEndian, r.DeltaTime)
_ = binary.Write(&b, binary.BigEndian, r.DeltaFreqHz)
writeQString(&b, r.Mode)
writeQString(&b, r.Message)
var low uint8
if r.LowConfidence {
low = 1
}
_ = binary.Write(&b, binary.BigEndian, low)
_ = binary.Write(&b, binary.BigEndian, uint8(0)) // modifiers: a plain click
return b.Bytes()
}
// SendReply hands a Reply to the application that produced the decode.
//
// Routed by PROGRAM ID, not by listener: two receivers can share one multicast
// group, and answering a station heard on the 6 m instance by talking to the
// 20 m one would start a call on the wrong band. The id is what tells them
// apart, and the address the reply goes to is the one that instance's packets
// actually arrive from — a multicast listener must answer back to the sender,
// not to the group.
func (m *Manager) SendReply(r Reply) error {
if strings.TrimSpace(r.ProgramID) == "" {
return fmt.Errorf("no application id — cannot tell which receiver to answer with")
}
m.mu.Lock()
servers := make([]*Server, 0, len(m.inbound))
for _, s := range m.inbound {
servers = append(servers, s)
}
m.mu.Unlock()
for _, s := range servers {
conn, addr := s.replyTarget(r.ProgramID)
if conn == nil || addr == nil {
continue
}
pkt := EncodeReply(r)
if _, err := conn.WriteToUDP(pkt, addr); err != nil {
return fmt.Errorf("send reply to %s at %s: %w", r.ProgramID, addr, err)
}
// Says whether the decode being answered was a CQ, because that is what
// decides whether the far end will act on it at all: the protocol only
// requires a Reply to be honoured for a CQ or QRZ. Without this the log
// showed a reply going out and nothing happening, and the reason had to
// be deduced from the message text by eye.
kind := "not a CQ — the far end may ignore it"
if strings.HasPrefix(strings.TrimSpace(strings.ToUpper(r.Message)), "CQ ") {
kind = "CQ"
}
applog.Printf("udp: reply sent to %s at %s — %q (%s)", r.ProgramID, addr, r.Message, kind)
return nil
}
return fmt.Errorf("no packet has arrived from %q yet — nothing to answer to", r.ProgramID)
}
// replyTarget returns this listener's socket and the address the given program
// last sent from, or nils when it has never been heard here.
func (s *Server) replyTarget(programID string) (*net.UDPConn, *net.UDPAddr) {
s.mu.Lock()
defer s.mu.Unlock()
if s.conn == nil || s.lastFrom == nil {
return nil, nil
}
addr, ok := s.lastFrom[programID]
if !ok {
return nil, nil
}
return s.conn, addr
}
+39 -4
View File
@@ -195,10 +195,7 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
// operator was looking the call up for, and it came back empty while
// the same lookup without the suffix answered perfectly.
if !saysNothingAboutLocation(call) {
r.Country, r.Continent = "", ""
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
r.Lat, r.Lon = 0, 0
r.Grid, r.State, r.County = "", "", ""
clearHomeLocation(&r)
}
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
normalizeNames(&r)
@@ -378,6 +375,17 @@ func titleCase(s string) string {
// the DXCC number since QRZ's value is wrong and we don't have an entity
// → DXCC# table yet.
// Returns true if any field was filled.
// clearHomeLocation drops the fields that say WHERE a callbook record's operator
// 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.
func clearHomeLocation(r *Result) {
r.Country, r.Continent = "", ""
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
r.Lat, r.Lon = 0, 0
r.Grid, r.State, r.County = "", "", ""
}
func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
if dxcc == nil {
return false
@@ -387,6 +395,33 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
return false
}
filled := false
// A subdivision belongs to an ENTITY, so a record describing one entity has
// nothing to say about a station operating from another.
//
// TI8/W2RE came back as a Costa Rica contact in Dutchess County, New York, on
// square FN31 — W2RE's home details, from a QRZ page that carries the home
// mailing address as most portable pages do. CNTY is *defined* as a US
// county, so that is not a cosmetic slip: it is a US county award credit
// recorded against a Costa Rica QSO, and a distance and beam heading computed
// from a square 3,600 km from where the station actually is.
//
// The home-call fallback above already clears these, but it only runs when NO
// provider had the slashed form. An operator with a page for their portable
// call never reached it. Cleared here instead, where the operating entity is
// known — which also heals rows already in the cache, since every cache hit
// comes back through here.
//
// Same-entity portables (F4BPO/P, W2RE/2) are untouched: the entities match,
// and there the home details ARE where the operator is.
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 {
clearHomeLocation(r)
filled = true
}
}
}
if country != "" {
r.Country = country
filled = true
+93
View File
@@ -0,0 +1,93 @@
package lookup
import "testing"
// fakeDXCC resolves a handful of calls, enough to stand in for cty.dat.
type fakeDXCC map[string]struct {
num int
country string
cont string
cqz, ituz int
lat, lon float64
}
func (f fakeDXCC) Resolve(call string) (int, string, string, int, int, float64, float64, bool) {
e, ok := f[call]
if !ok {
return 0, "", "", 0, 0, 0, 0, false
}
return e.num, e.country, e.cont, e.cqz, e.ituz, e.lat, e.lon, true
}
func testDXCC() fakeDXCC {
return fakeDXCC{
// Costa Rica, as cty.dat reads the OPERATING call.
"TI8/W2RE": {num: 308, country: "Costa Rica", cont: "NA", cqz: 7, ituz: 11, lat: 9.9, lon: -84.1},
// The home call: United States.
"W2RE": {num: 291, country: "United States", cont: "NA", cqz: 5, ituz: 8, lat: 39.8, lon: -98.5},
// A same-entity portable: still France either way.
"F4BPO/P": {num: 227, country: "France", cont: "EU", cqz: 14, ituz: 27, lat: 46.2, lon: 2.2},
"F4BPO": {num: 227, country: "France", cont: "EU", cqz: 14, ituz: 27, lat: 46.2, lon: 2.2},
}
}
// A callbook record for a portable call routinely carries the operator's HOME
// address — most QRZ pages for a portable call do — and a subdivision belongs to
// an entity. Carrying the home county across an entity change is not cosmetic:
// CNTY is defined as a US county, so TI8/W2RE was coming out as a Costa Rica
// contact credited to Dutchess County, New York, with the distance and beam
// heading taken from a square 3,600 km from where the station actually was.
func TestPortableInAnotherEntityDropsTheHomeSubdivision(t *testing.T) {
r := Result{
Callsign: "TI8/W2RE",
Name: "Raymond", // who they are — kept
QTH: "Poughquag",
Address: "499 Pleasant Ridge Road", // cards still go there — kept
State: "NY",
County: "Dutchess",
Grid: "FN31",
Lat: 41.6, Lon: -73.7,
DXCC: 291,
}
fillFromDXCC(&r, testDXCC())
if r.County != "" {
t.Errorf("county = %q, want empty — a US county on a Costa Rica QSO is a false award credit", r.County)
}
if r.State != "" {
t.Errorf("state = %q, want empty — a subdivision of the entity that is not being worked", r.State)
}
if r.Grid != "" {
t.Errorf("grid = %q, want empty — it is the home square, 3,600 km from the operation", r.Grid)
}
// The entity and its centroid take over, so distance and bearing mean
// something again.
if r.DXCC != 308 || r.Country != "Costa Rica" {
t.Errorf("entity = %d %q, want 308 Costa Rica", r.DXCC, r.Country)
}
if r.Lat != 9.9 || r.Lon != -84.1 {
t.Errorf("lat/lon = %v/%v, want the Costa Rica centroid — the home coordinates must not survive", r.Lat, r.Lon)
}
// Who they are, and where their cards go, is unchanged.
if r.Name != "Raymond" || r.Address != "499 Pleasant Ridge Road" {
t.Errorf("name/address were cleared (%q / %q) — a portable operator's post still reaches home", r.Name, r.Address)
}
}
// The other half of the rule: a portable WITHIN the same entity is at home as
// far as the entity is concerned, and its details must survive untouched.
func TestSameEntityPortableKeepsItsLocation(t *testing.T) {
r := Result{
Callsign: "F4BPO/P",
State: "77", County: "Seine-et-Marne", Grid: "JN18cs",
Lat: 48.8, Lon: 2.4, DXCC: 227,
}
fillFromDXCC(&r, testDXCC())
if r.Grid != "JN18cs" || r.County != "Seine-et-Marne" || r.State != "77" {
t.Errorf("a same-entity portable lost its location: grid=%q county=%q state=%q", r.Grid, r.County, r.State)
}
if r.Lat != 48.8 || r.Lon != 2.4 {
t.Errorf("lat/lon = %v/%v — the precise home position was replaced by the entity centroid", r.Lat, r.Lon)
}
}
+5 -1
View File
@@ -733,7 +733,11 @@ func placeQSOBox(profile ProfileInfo, zone pxRect, occupied []pxRect) QSOBox {
Enabled: true, W: 760, H: 220,
BG: "#ffffff", BGOpacity: 0.88, Radius: 12,
Title: "Confirming QSO with {qso.callsign}",
Fields: []string{"qso_date", "time_on", "band", "mode", "rst_sent"},
// The frequency belongs on a card: "20m" says which band the QSL is for,
// the megahertz say where the contact actually was — and a card is often
// the only record the OTHER station keeps. Removable in the designer for
// anyone who wants the band alone.
Fields: []string{"qso_date", "time_on", "band", "freq", "mode", "rst_sent"},
// PSE/TNX stamp (auto: TNX if a QSL was received, else PSE QSL) leads the
// message. {qso.pse_tnx} is a normal token — it can be moved to its own
// element anywhere on the card in the designer.
+84
View File
@@ -0,0 +1,84 @@
package qso
import (
"context"
"testing"
"time"
)
// LoTW never hands back the submode it was given: every digital contact comes
// back as the mode GROUP, "DATA". Matched on the exact mode string that is
// simply never equal, so an operator downloading their confirmations was told
// their own QSOs were not in their log — reported for FT2 contacts confirmed as
// DATA, and true of FT4 and FT8 alike.
func TestClassKeyMatchesAConfirmationCarryingTheModeGroup(t *testing.T) {
r := openRepo(t)
ctx := context.Background()
when := time.Date(2026, 8, 16, 16, 29, 0, 0, time.UTC)
id, err := r.Add(ctx, QSO{Callsign: "F1NQP", QSODate: when, Band: "20m", Mode: "FT2"})
if err != nil {
t.Fatal(err)
}
minute := when.Format("2006-01-02T15:04")
exact, err := r.DedupeKeyIDs(ctx)
if err != nil {
t.Fatal(err)
}
if _, found := exact[DedupeKey("F1NQP", minute, "20m", "DATA")]; found {
t.Fatal("the exact index matched DATA against FT2 — the test proves nothing")
}
byClass, err := r.DedupeClassKeyIDs(ctx)
if err != nil {
t.Fatal(err)
}
got, found := byClass[DedupeClassKey("F1NQP", minute, "20m", "DATA")]
if !found || got != id {
t.Errorf("class match = (%d, %v), want (%d, true) — the confirmation would be reported as having no local QSO", got, found, id)
}
}
// Two digital contacts with the same station, same band, same minute is barely
// physical — but if it happens, stamping the confirmation on whichever row the
// map happened to keep is a silent error in an award credit. Ambiguity maps to
// 0 so the caller reports it unmatched instead of guessing.
func TestAnAmbiguousClassKeyIsRefusedRatherThanGuessed(t *testing.T) {
r := openRepo(t)
ctx := context.Background()
when := time.Date(2026, 8, 16, 16, 29, 0, 0, time.UTC)
for _, m := range []string{"FT8", "RTTY"} {
if _, err := r.Add(ctx, QSO{Callsign: "F1NQP", QSODate: when, Band: "20m", Mode: m}); err != nil {
t.Fatal(err)
}
}
byClass, err := r.DedupeClassKeyIDs(ctx)
if err != nil {
t.Fatal(err)
}
if got := byClass[DedupeClassKey("F1NQP", when.Format("2006-01-02T15:04"), "20m", "DATA")]; got != 0 {
t.Errorf("ambiguous class key resolved to %d — one of two QSOs was picked at random", got)
}
}
// Phone and CW keep their own classes: a CW confirmation must never land on an
// SSB contact just because the rest of the key agrees.
func TestClassMatchDoesNotCrossPhoneAndCW(t *testing.T) {
r := openRepo(t)
ctx := context.Background()
when := time.Date(2026, 8, 16, 16, 29, 0, 0, time.UTC)
if _, err := r.Add(ctx, QSO{Callsign: "F1NQP", QSODate: when, Band: "20m", Mode: "SSB"}); err != nil {
t.Fatal(err)
}
byClass, err := r.DedupeClassKeyIDs(ctx)
if err != nil {
t.Fatal(err)
}
minute := when.Format("2006-01-02T15:04")
if _, found := byClass[DedupeClassKey("F1NQP", minute, "20m", "CW")]; found {
t.Error("a CW confirmation matched an SSB contact")
}
if _, found := byClass[DedupeClassKey("F1NQP", minute, "20m", "USB")]; !found {
t.Error("USB did not match the SSB contact — the phone sidebands are one class")
}
}
+231 -2
View File
@@ -1475,13 +1475,67 @@ func conditionSQL(c Condition) (string, []any, error) {
}
}
// quickCallsignLike turns the Recent-QSOs search box into a SQL LIKE pattern.
//
// The rule, which is the one an operator already has in their head from the
// alert filters:
//
// 4S → starts with 4S (4S%)
// *4S → ends with 4S (%4S)
// *4S* → contains 4S (%4S%)
// 4S? → 4S and one more (4S_)
//
// So a plain word is a PREFIX — the common case, and what makes typing a call
// feel like a call sign lookup rather than a text search. The moment a wildcard
// appears the pattern is taken literally end to end, which is the only way
// "*4S" can mean "ends with" while "4S" means "starts with".
//
// This used to be an unconditional contains-match, so there was no way to ask
// for a prefix at all: searching 4S returned every call with 4S buried in it.
//
// % and _ typed by the operator are escaped to literals — otherwise a search
// for "_" would quietly match everything.
func quickCallsignLike(pattern string) string {
p := strings.TrimSpace(pattern)
var b strings.Builder
esc := func(r rune) {
switch r {
case '%', '_', '!':
b.WriteByte('!')
}
b.WriteRune(r)
}
if !strings.ContainsAny(p, "*?") {
for _, r := range p {
esc(r)
}
b.WriteByte('%') // plain text = prefix
return b.String()
}
for _, r := range p {
switch r {
case '*':
b.WriteByte('%')
case '?':
b.WriteByte('_')
default:
esc(r)
}
}
return b.String()
}
// buildWhere assembles the predicate (everything after WHERE) + args.
func buildWhere(f QueryFilter) (string, []any, error) {
pred := "1=1"
var args []any
if qc := strings.TrimSpace(f.QuickCallsign); qc != "" {
pred += " AND callsign LIKE ?"
args = append(args, "%"+qc+"%")
// ESCAPE '!' rather than the usual backslash: the clause is a literal in
// the SQL text, and '\\' is one character to MySQL but two to SQLite,
// which rejects it. '!' is one character to both and appears in no
// callsign.
pred += " AND callsign LIKE ? ESCAPE '!'"
args = append(args, quickCallsignLike(qc))
}
if len(f.Conditions) > 0 {
joiner := " AND "
@@ -1605,6 +1659,136 @@ func (r *Repo) IterateByIDs(ctx context.Context, ids []int64, fn func(QSO) error
return rows.Err()
}
// GridKey builds the lookup key for the worked-grid index.
//
// band is "" for a "mix bands" scope, which is why the index stores BOTH forms
// for every contact: asking "worked anywhere" must not mean walking the map.
func GridKey(grid, band, class string) string {
return grid + "|" + strings.ToLower(band) + "|" + class
}
// GridWorkedIndex maps every worked square to whether it is CONFIRMED, under
// every band-and-mode scope the operator can choose between.
//
// One QSO lands in several buckets, because the scopes overlap: an FT8 contact
// counts for "this exact mode", for "any FTx mode" and for "any digital mode",
// and each of those under both its own band and the any-band form. classesOf is
// supplied by the caller — the meaning of DIGI and FTx belongs upstairs with the
// rest of the mode vocabulary, not here.
//
// The value is "confirmed", not merely "present", so the same index answers both
// hunting modes: chase what has never been worked, or chase what is not yet
// confirmed. Confirmed means LoTW, a card or eQSL — the three the award engine
// counts, so a square cannot be confirmed here and unconfirmed there.
func (r *Repo) GridWorkedIndex(ctx context.Context, classesOf func(mode string) []string) (map[string]bool, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT COALESCE(grid,''), UPPER(COALESCE(mode,'')), LOWER(COALESCE(band,'')),
COALESCE(lotw_rcvd,''), COALESCE(qsl_rcvd,''), COALESCE(eqsl_rcvd,'')
FROM qso
WHERE grid IS NOT NULL AND grid != ''`)
if err != nil {
return nil, fmt.Errorf("query worked grids: %w", err)
}
defer rows.Close()
out := make(map[string]bool, 8192)
for rows.Next() {
var grid, mode, band, lotw, card, eqsl string
if err := rows.Scan(&grid, &mode, &band, &lotw, &card, &eqsl); err != nil {
return nil, err
}
g := strings.ToUpper(strings.TrimSpace(grid))
if len(g) < 4 {
continue
}
g = g[:4]
confirmed := lotw == "Y" || card == "Y" || eqsl == "Y"
for _, cls := range classesOf(mode) {
if cls == "" {
continue
}
// || is never a downgrade: one confirmed contact confirms the square
// for that scope, whatever the others say.
for _, k := range []string{GridKey(g, band, cls), GridKey(g, "", cls)} {
out[k] = out[k] || confirmed
}
}
}
return out, rows.Err()
}
// GridSquare is one 4-character Maidenhead square in the log.
type GridSquare struct {
Grid string `json:"grid"`
Count int `json:"count"`
Confirmed bool `json:"confirmed"`
// Band and Mode of the most recent contact in the square, for the tooltip.
// The square is the subject here, not the QSO, so one example is enough —
// listing every band a square was worked on turns a map into a table.
Band string `json:"band,omitempty"`
Mode string `json:"mode,omitempty"`
}
// GridSquares aggregates the log into 4-character squares, newest contact
// deciding the example band/mode.
//
// modeOf filters and is supplied by the caller (it owns what "digital" means):
// return false to drop a QSO. Aggregation to 4 characters happens HERE rather
// than in SQL — the column holds 4, 6 and 8-character grids, and lower(substr)
// in SQL would differ between SQLite and MySQL for no gain.
func (r *Repo) GridSquares(ctx context.Context, keep func(mode string) bool) ([]GridSquare, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT COALESCE(grid,''), UPPER(COALESCE(mode,'')), LOWER(COALESCE(band,'')),
COALESCE(lotw_rcvd,''), COALESCE(qsl_rcvd,''), COALESCE(eqsl_rcvd,'')
FROM qso
WHERE grid IS NOT NULL AND grid != ''
ORDER BY qso_date ASC, id ASC`)
if err != nil {
return nil, fmt.Errorf("query grid squares: %w", err)
}
defer rows.Close()
out := map[string]*GridSquare{}
for rows.Next() {
var grid, mode, band, lotw, card, eqsl string
if err := rows.Scan(&grid, &mode, &band, &lotw, &card, &eqsl); err != nil {
return nil, err
}
if keep != nil && !keep(mode) {
continue
}
g := strings.ToUpper(strings.TrimSpace(grid))
if len(g) < 4 {
continue
}
g = g[:4]
// Guard the shape: a malformed grid would draw a rectangle somewhere
// arbitrary, and a map with one square in the sea is a map nobody trusts.
if g[0] < 'A' || g[0] > 'R' || g[1] < 'A' || g[1] > 'R' ||
g[2] < '0' || g[2] > '9' || g[3] < '0' || g[3] > '9' {
continue
}
sq := out[g]
if sq == nil {
sq = &GridSquare{Grid: g}
out[g] = sq
}
sq.Count++
// Ascending order, so the last write wins = the most recent contact.
sq.Band, sq.Mode = band, mode
if lotw == "Y" || card == "Y" || eqsl == "Y" {
sq.Confirmed = true
}
}
if err := rows.Err(); err != nil {
return nil, err
}
list := make([]GridSquare, 0, len(out))
for _, sq := range out {
list = append(list, *sq)
}
sort.Slice(list, func(i, j int) bool { return list[i].Grid < list[j].Grid })
return list, nil
}
// BandSlotQSOs returns every contact on one band that belongs to a slot of the
// entry matrix: the exact callsign, or any callsign in the same DXCC entity.
// Mode is NOT filtered here — the class (phone / CW / digital) is a derived
@@ -2691,6 +2875,51 @@ func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {
return out, rows.Err()
}
// DedupeClassKey is DedupeKey with the mode collapsed to its CLASS (Phone / CW /
// Digital).
//
// For matching a downloaded confirmation whose mode is not the one you logged.
// LoTW does not hand back the submode it was given: a contact uploaded as FT4,
// FT8 or anything else digital comes back as the mode GROUP, "DATA". Matched on
// the exact string that is simply never equal, and the operator is told their
// own QSO is not in their log — which is how a confirmed contact goes unrecorded.
func DedupeClassKey(callsign, qsoDateMinute, band, mode string) string {
return strings.ToUpper(callsign) + "|" + qsoDateMinute + "|" + strings.ToLower(band) + "|" + modeClass(mode)
}
// DedupeClassKeyIDs is DedupeKeyIDs at mode-CLASS granularity, for the second
// pass when the exact key misses.
//
// A key shared by more than one QSO maps to 0 — AMBIGUOUS, not "pick one". Two
// contacts with the same station, on the same band, in the same minute, in two
// digital modes is barely physical; but if it ever happens, stamping the
// confirmation on whichever row the map happened to keep would be a silent
// error in someone's award credit. Better to report it unmatched.
func (r *Repo) DedupeClassKeyIDs(ctx context.Context) (map[string]int64, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, callsign, substr(qso_date, 1, 16), band, mode
FROM qso`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]int64, 1024)
for rows.Next() {
var id int64
var call, when, band, mode string
if err := rows.Scan(&id, &call, &when, &band, &mode); err != nil {
return nil, err
}
k := DedupeClassKey(call, when, band, mode)
if prev, seen := out[k]; seen && prev != id {
out[k] = 0
continue
}
out[k] = id
}
return out, rows.Err()
}
// matchRef is one local QSO's time + id, for time-window confirmation matching.
type matchRef struct {
when time.Time
+28
View File
@@ -0,0 +1,28 @@
package qso
import "testing"
// The search box is the one place an operator types a callsign fragment, and
// the three shapes below are what they expect from every other logger. A plain
// word is a PREFIX: it used to be an unconditional contains-match, so asking
// for "calls starting with 4S" was impossible.
func TestQuickCallsignLike(t *testing.T) {
for _, tc := range []struct{ in, want, why string }{
{"4S", "4S%", "plain text is a prefix"},
{"*4S", "%4S", "a leading star means ends-with"},
{"*4S*", "%4S%", "stars both ends mean contains"},
{"4S*", "4S%", "a trailing star is the same prefix, written out"},
{"4S?", "4S_", "? is exactly one character"},
{"*", "%", "a lone star matches everything"},
{" 4S ", "4S%", "surrounding space is not part of the call"},
// Escaping: a LIKE metacharacter the operator typed is a literal.
{"A_B", "A!_B%", "_ typed by hand is a literal underscore"},
{"50%", "50!%%", "% typed by hand is a literal percent"},
{"A!B", "A!!B%", "the escape character escapes itself"},
{"*A_B*", "%A!_B%", "escaping still applies inside a wildcard pattern"},
} {
if got := quickCallsignLike(tc.in); got != tc.want {
t.Errorf("quickCallsignLike(%q) = %q, want %q — %s", tc.in, got, tc.want, tc.why)
}
}
}
+54 -1
View File
@@ -178,7 +178,41 @@ func (s *Server) Start() error {
}
// Stop closes the listener and every client.
// releasePTT drops a PTT this server asserted, and does it once.
//
// A client that dies mid-over — or a settings save that closes the server —
// leaves the rig keyed with nobody left to un-key it, into an amplifier that has
// no idea the transmission ended. rigctld has had this guard for a while (a K3
// once sat in transmit for 29 s until the CAT link happened to be rebuilt); the
// TCI server was written without it, so an operator who moved from Hamlib to TCI
// silently lost the protection.
//
// pttKnown is cleared whatever happens: after an emergency unkey the radio's
// state is a guess, and the next command must reach it rather than be dismissed
// as a repeat.
func (s *Server) releasePTT(why string) {
s.mu.Lock()
keyed := s.ptt
s.ptt, s.pttKnown = false, false
s.mu.Unlock()
if !keyed {
return
}
s.log("tci server: %s while the rig was keyed — dropping PTT", why)
if err := s.rig.SetPTT(false); err != nil {
s.log("tci server: emergency unkey FAILED: %v", err)
return
}
// Any client still attached is told, so a second logger's transmit indicator
// does not stay lit over a rig that is back in receive.
s.broadcast("trx:0,false;")
}
func (s *Server) Stop() {
// Before anything is torn down: the CAT backend is still up here, so an unkey
// still lands. Same ordering as rigctld.Stop for the same reason.
s.releasePTT("TCI server stopped")
s.mu.Lock()
if s.closed {
s.mu.Unlock()
@@ -266,6 +300,8 @@ func (s *Server) serve(c *client, remote string) {
s.mu.Unlock()
_ = c.conn.Close()
s.log("tci server: %s disconnected", remote)
// A client that walks away mid-over must not leave the rig transmitting.
s.releasePTT("client " + remote + " left")
}
// initBlock is the initialisation set from §4.1 of the protocol document, in
@@ -484,16 +520,33 @@ func (s *Server) handle(c *client, cmd string) string {
// knowing how the radio was left.
s.mu.Lock()
known, prev := s.pttKnown, s.ptt
s.ptt, s.pttKnown = on, true
s.mu.Unlock()
if known && prev == on {
s.broadcast(fmt.Sprintf("trx:0,%t;", on))
return ""
}
if err := s.rig.SetPTT(on); err != nil {
// The cache is stamped ONLY on success, and a failure clears "known"
// outright so the NEXT command — whatever it is — reaches the radio.
//
// It used to be written before the radio was commanded and left in
// place when the command failed. That is how a rig got stuck keyed for
// good: the un-key failed on a lost CI-V acknowledgement, the cache
// recorded "off" regardless, and from then on every trx:0,false was
// dismissed as a repeat of a state the radio had never reached. Not
// even reconnecting the client cleared it — this cache is per-server,
// not per-connection — so the transmitter stayed keyed into the
// amplifier until the operator switched the radio off. A cache must
// never claim something the radio refused.
s.mu.Lock()
s.pttKnown = false
s.mu.Unlock()
s.log("tci server: PTT %v refused: %v", on, err)
return ""
}
s.mu.Lock()
s.ptt, s.pttKnown = on, true
s.mu.Unlock()
s.log("tci server: PTT %s", map[bool]string{true: "ON", false: "off"}[on])
s.broadcast(fmt.Sprintf("trx:0,%t;", on))
return ""
+56
View File
@@ -16,6 +16,7 @@ type fakeRig struct {
txHz int64
ptt bool
splitErr error
pttErr error
calls []string
}
@@ -34,6 +35,10 @@ func (r *fakeRig) SetMode(m string) error {
return nil
}
func (r *fakeRig) SetPTT(on bool) error {
// Refused BEFORE the state moves, like a radio that never got the frame.
if r.pttErr != nil {
return r.pttErr
}
r.calls = append(r.calls, fmt.Sprintf("ptt=%v", on))
r.ptt = on
return nil
@@ -328,3 +333,54 @@ func TestRepeatedPTTIsNotResentToTheRadio(t *testing.T) {
t.Errorf("the radio was told %v, want the change through and the repeat dropped", r.calls)
}
}
// A refused un-key must never be remembered as done.
//
// The failure an operator hit running JTDX over TCI with an Icom on CI-V: the
// rig went to transmit, the un-key was refused on a lost acknowledgement, and
// from then on NOTHING could take it out of transmit. The cache had stamped
// "off" before the radio was even commanded and kept it after the refusal, so
// every later trx:0,false was dismissed as a repeat of a state the radio had
// never reached. It is per-server, not per-connection, so reconnecting the
// client changed nothing either — the transmitter stayed keyed into the
// amplifier, with no drive, until the radio was switched off by hand.
func TestARefusedUnkeyIsNotRememberedAsDone(t *testing.T) {
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
s := srv(r)
ask(t, s, "trx:0,true")
if !r.ptt {
t.Fatal("the rig was never keyed — the test would prove nothing")
}
r.pttErr = fmt.Errorf("icom: timeout waiting for response")
ask(t, s, "trx:0,false")
if !r.ptt {
t.Fatal("the fake rig un-keyed on a refusal — the test would prove nothing")
}
// The client asks again, and this time the radio answers. It MUST be told.
r.pttErr = nil
ask(t, s, "trx:0,false")
if r.ptt {
t.Error("still keyed: the refused un-key was cached as done and the retry was dropped as a repeat")
}
}
// A client that walks away mid-over must not leave the rig transmitting, and
// the release must be once-only — a second call has nothing to un-key and must
// not re-command a radio that is already receiving.
func TestReleasePTTUnkeysOnceWhenTheClientLeaves(t *testing.T) {
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
s := srv(r)
ask(t, s, "trx:0,true")
s.releasePTT("client left")
if r.ptt {
t.Error("the rig is still keyed after the client left")
}
n := len(r.calls)
s.releasePTT("client left")
if len(r.calls) != n {
t.Errorf("released twice — the radio was told %v", r.calls)
}
}
+5
View File
@@ -161,6 +161,11 @@ func (a *App) replayOfflineQueue() (int, error) {
failed = append(failed, q)
continue
}
// These are contacts the operator MADE — the worked indexes have to learn
// them, or every one of them keeps its NEW badge in the cluster and decode
// lists until the next restart. a.qso.Add is the raw insert; only AddQSO
// does this for you, and the outbox replay does not go through it.
a.noteWorked(q.Callsign, q.Band, q.Mode)
imported++
}
+4
View File
@@ -507,6 +507,10 @@ func (a *App) applySyncRecord(rec syncfolder.Record) bool {
if err := a.qso.SetSyncUID(a.ctx, newID, rec.UID); err != nil {
applog.Printf("foldersync: stamping the new QSO %d: %v", newID, err)
}
// A partner's contact counts as worked for this station too — that is the
// point of a multi-op log — so the worked indexes must learn it exactly as
// they do for our own. Raw insert, so nothing else does it.
a.noteWorked(q.Callsign, q.Band, q.Mode)
return true
}
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.25.8"
appVersion = "0.26.1"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.