Files
OpsLog/internal/integrations/udp/decodetime_test.go
T
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

53 lines
2.1 KiB
Go

package udp
import (
"testing"
"time"
)
// 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.
func TestDecodeTimeCrossesMidnight(t *testing.T) {
const ms = 1000
sec := func(h, m, s int) uint32 { return uint32((h*3600 + m*60 + s) * ms) }
got := decodeTime(sec(23, 59, 58))
now := time.Now().UTC()
// Whatever the clock says, a decode must never land in the future beyond the
// slack of a single period, nor more than a day in the past.
if d := got.Sub(now); d > time.Minute {
t.Errorf("decode at 23:59:58 resolved to %s, %s in the FUTURE", got.Format(time.RFC3339), d)
}
if d := now.Sub(got); d > 24*time.Hour {
t.Errorf("decode at 23:59:58 resolved to %s, %s in the past", got.Format(time.RFC3339), d)
}
// And the ordinary case: a stamp close to now stays on today.
near := decodeTime(sec(now.Hour(), now.Minute(), now.Second()))
if diff := near.Sub(now); diff > 2*time.Second || diff < -2*time.Second {
t.Errorf("a decode stamped at the current time resolved to %s (%s off)", near.Format(time.RFC3339), diff)
}
}
// 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) {
const ms = 1000
at := func(h, m, s int) time.Time { return decodeTime(uint32((h*3600 + m*60 + s) * ms)) }
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("12:30:00 and 12:30:14 fell in different periods (%d vs %d)", floor(a), floor(b))
}
c := at(12, 30, 15)
if floor(a) == floor(c) {
t.Error("12:30:00 and 12:30:15 shared a period — the slot boundary was not honoured")
}
}