qso-graph-sim 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ """qso-graph-sim: replays what ham radio programs send on the network, for testing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+ from typing import Final
7
+
8
+ try:
9
+ _pkg_version = version("qso-graph-sim")
10
+ except PackageNotFoundError: # source tree without dist metadata
11
+ _pkg_version = "0.0.0-dev"
12
+
13
+ # Read from the installed distribution, so pyproject.toml is the only place
14
+ # the version is written.
15
+ __version__: Final[str] = _pkg_version
@@ -0,0 +1,103 @@
1
+ """Reading WSJT-X's ALL.TXT, the log of every decode it has made.
2
+
3
+ One line per decode, in the format WSJT-X has written since 2.x::
4
+
5
+ 260407_233915 14.074 Rx FT8 -12 0.2 1234 CQ K7ABC DN13
6
+
7
+ date_time (UTC), dial frequency in MHz, Rx or Tx, mode, SNR in dB, DT in
8
+ seconds, audio offset in Hz, then the message.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timezone
16
+
17
+ _LINE = re.compile(
18
+ r"^(\d{6}_\d{6})\s+(\d+(?:\.\d+)?)\s+(Rx|Tx)\s+(\S+)\s+(-?\d+)\s+(-?\d+(?:\.\d+)?)"
19
+ r"\s+(\d+)\s+(.+?)\s*$"
20
+ )
21
+ # WSJT-X marks a decode it has low confidence in with a trailing "?"; it is
22
+ # not part of the message that was sent.
23
+ _LOW_CONFIDENCE = re.compile(r"\s\?$")
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Line:
28
+ stamp: str # as written: YYMMDD_HHMMSS
29
+ utc: datetime
30
+ dial_hz: int
31
+ direction: str # "Rx" or "Tx"
32
+ mode: str # the mode's name, e.g. "FT8"
33
+ snr: int
34
+ dt: float
35
+ df: int
36
+ message: str
37
+ low_confidence: bool
38
+
39
+
40
+ def parse_line(text: str) -> Line | None:
41
+ """One ALL.TXT line, or None if it is not one."""
42
+ m = _LINE.match(text)
43
+ if not m:
44
+ return None
45
+ stamp, mhz, direction, mode, snr, dt, df, message = m.groups()
46
+ try:
47
+ utc = datetime.strptime(stamp, "%y%m%d_%H%M%S").replace(tzinfo=timezone.utc)
48
+ except ValueError:
49
+ return None
50
+ low = bool(_LOW_CONFIDENCE.search(message))
51
+ return Line(
52
+ stamp=stamp,
53
+ utc=utc,
54
+ dial_hz=round(float(mhz) * 1_000_000),
55
+ direction=direction,
56
+ mode=mode,
57
+ snr=int(snr),
58
+ dt=float(dt),
59
+ df=int(df),
60
+ message=_LOW_CONFIDENCE.sub("", message) if low else message,
61
+ low_confidence=low,
62
+ )
63
+
64
+
65
+ @dataclass
66
+ class Cycle:
67
+ """The decodes of one T/R period on one dial frequency."""
68
+
69
+ stamp: str
70
+ dial_hz: int
71
+ mode: str
72
+ decodes: list[Line] = field(default_factory=list)
73
+
74
+
75
+ @dataclass
76
+ class Log:
77
+ cycles: list[Cycle]
78
+ skipped: int # Tx lines and lines that are not decodes
79
+
80
+ @property
81
+ def decodes(self) -> int:
82
+ return sum(len(c.decodes) for c in self.cycles)
83
+
84
+
85
+ def read(text: str, start: str = "") -> Log:
86
+ """Group received decodes into cycles, in file order, from ``start`` on."""
87
+ cycles: list[Cycle] = []
88
+ skipped = 0
89
+ for raw in text.splitlines():
90
+ if not raw.strip():
91
+ continue
92
+ line = parse_line(raw)
93
+ if line is None or line.direction != "Rx":
94
+ skipped += 1
95
+ continue
96
+ if start and line.stamp < start:
97
+ continue
98
+ last = cycles[-1] if cycles else None
99
+ if last and last.stamp == line.stamp and last.dial_hz == line.dial_hz:
100
+ last.decodes.append(line)
101
+ else:
102
+ cycles.append(Cycle(line.stamp, line.dial_hz, line.mode, [line]))
103
+ return Log(cycles, skipped)
qso_graph_sim/cli.py ADDED
@@ -0,0 +1,138 @@
1
+ """The qso-graph-sim command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import ipaddress
7
+ import re
8
+ import signal
9
+ import socket
10
+ import sys
11
+ from collections.abc import Callable, Sequence
12
+ from importlib.resources import files
13
+ from pathlib import Path
14
+
15
+ from . import __version__, alltxt
16
+ from .player import Player, Station
17
+
18
+ SAMPLE = "ALL-sample.TXT"
19
+ _STAMP = re.compile(r"^\d{6}_\d{6}$")
20
+ _CALL = re.compile(r"^[A-Z0-9/]{0,13}$")
21
+ _GRID = re.compile(r"^([A-R]{2}\d{2}([A-X]{2})?)?$")
22
+
23
+
24
+ def target(text: str) -> tuple[str, int]:
25
+ """IP:PORT, or [IPv6]:PORT. An address, not a name: nothing is looked up."""
26
+ m = re.match(r"^\[?([^\]]+?)\]?:(\d{1,5})$", text)
27
+ if not m:
28
+ raise argparse.ArgumentTypeError(
29
+ "use IP:PORT, the address QSO Graph Desktop listens on"
30
+ )
31
+ host, port = m.group(1), int(m.group(2))
32
+ try:
33
+ ipaddress.ip_address(host)
34
+ except ValueError:
35
+ raise argparse.ArgumentTypeError(f"{host} is not an IP address") from None
36
+ if not 0 < port < 65536:
37
+ raise argparse.ArgumentTypeError(f"{port} is not a port")
38
+ return host, port
39
+
40
+
41
+ def _matching(
42
+ pattern: re.Pattern[str], what: str, upper: bool = False
43
+ ) -> Callable[[str], str]:
44
+ def check(text: str) -> str:
45
+ if text == "":
46
+ return text # the default: not given
47
+ value = text.upper() if upper else text
48
+ if not pattern.match(value):
49
+ raise argparse.ArgumentTypeError(f"{text!r} is not {what}")
50
+ return value
51
+ return check
52
+
53
+
54
+ def _speed(text: str) -> float:
55
+ try:
56
+ v = float(text)
57
+ except ValueError:
58
+ raise argparse.ArgumentTypeError(f"{text!r} is not a number") from None
59
+ if not 0 < v <= 1000:
60
+ raise argparse.ArgumentTypeError("speed is more than 0 and at most 1000")
61
+ return v
62
+
63
+
64
+ def build_parser() -> argparse.ArgumentParser:
65
+ p = argparse.ArgumentParser(
66
+ prog="qso-graph-sim",
67
+ description=(
68
+ "Replays what a ham radio program sends on the network, from its own log, "
69
+ "so a receiver can be tested without a radio."
70
+ ),
71
+ )
72
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
73
+ app = p.add_mutually_exclusive_group(required=True)
74
+ app.add_argument("--wsjtx", action="store_true",
75
+ help="WSJT-X: play an ALL.TXT as its UDP messages")
76
+ p.add_argument("--to", type=target, required=True, metavar="IP:PORT",
77
+ help="where to send: the receiver's address and port (WSJT-X uses 2237)")
78
+ p.add_argument("--file", type=Path, metavar="PATH",
79
+ help="the log to play (default: a sample of 20 m FT8, 120 decodes)")
80
+ p.add_argument("--from", dest="start", type=_matching(_STAMP, "YYMMDD_HHMMSS"),
81
+ default="", metavar="YYMMDD_HHMMSS", help="start at this time in the log")
82
+ p.add_argument("--id", default="WSJT-X", metavar="NAME",
83
+ type=_matching(re.compile(r"^.{1,64}$"), "an Id"),
84
+ help="the instance name to send as (WSJT-X's default is WSJT-X)")
85
+ p.add_argument("--call", default="", type=_matching(_CALL, "a callsign", upper=True),
86
+ help="your callsign, sent in Status")
87
+ p.add_argument("--grid", default="", type=_matching(_GRID, "a 4- or 6-character grid"),
88
+ help="your grid, sent in Status")
89
+ p.add_argument("--speed", type=_speed, default=1.0, metavar="N",
90
+ help="play faster than real time, for testing (default 1)")
91
+ p.add_argument("--loop", action="store_true", help="start again at the end")
92
+ return p
93
+
94
+
95
+ def _read(path: Path | None) -> tuple[str, str]:
96
+ if path is None:
97
+ sample = files("qso_graph_sim").joinpath("samples", SAMPLE)
98
+ return sample.read_text("utf-8"), "the sample"
99
+ return path.read_text("utf-8", errors="replace"), str(path)
100
+
101
+
102
+ def main(argv: Sequence[str] | None = None) -> int:
103
+ args = build_parser().parse_args(argv)
104
+ try:
105
+ text, name = _read(args.file)
106
+ except OSError as e:
107
+ print(f"qso-graph-sim: cannot read {args.file}: {e.strerror}", file=sys.stderr)
108
+ return 1
109
+ log = alltxt.read(text, args.start)
110
+ if not log.cycles:
111
+ print(f"qso-graph-sim: no received decodes in {name}"
112
+ + (f" from {args.start}" if args.start else ""), file=sys.stderr)
113
+ return 1
114
+ print(f"{len(log.cycles)} cycles, {log.decodes} decodes from {name}"
115
+ + (f" ({log.skipped} lines skipped: Tx or not a decode)" if log.skipped else ""))
116
+ host, port = args.to
117
+ family = socket.AF_INET6 if ipaddress.ip_address(host).version == 6 else socket.AF_INET
118
+ with socket.socket(family, socket.SOCK_DGRAM) as sock:
119
+ print(f"sending to {host}:{port} as {args.id}; Ctrl+C to stop")
120
+ player = Player(
121
+ sock=sock, target=(host, port), log=log,
122
+ station=Station(sender=args.id, call=args.call, grid=args.grid),
123
+ speed=args.speed, loop=args.loop,
124
+ )
125
+ signal.signal(signal.SIGTERM, lambda *_: player.stop())
126
+ try:
127
+ player.run()
128
+ except KeyboardInterrupt:
129
+ player.stop()
130
+ except OSError as e:
131
+ print(f"qso-graph-sim: cannot send to {host}:{port}: {e.strerror}",
132
+ file=sys.stderr)
133
+ return 1
134
+ return 0
135
+
136
+
137
+ if __name__ == "__main__":
138
+ sys.exit(main())
@@ -0,0 +1,148 @@
1
+ """Playing an ALL.TXT back as WSJT-X would have sent it.
2
+
3
+ One cycle every 15 seconds, at the real pace unless sped up for a test. Per
4
+ cycle: a Heartbeat (WSJT-X sends one every 15 seconds), a Status as decoding
5
+ starts, the cycle's Decodes, and a Status as it ends. The Status carries the
6
+ dial frequency, so a band change in the log shows there; WSJT-X does not send
7
+ Clear for a band change, because its Band Activity window keeps the earlier
8
+ decodes, so neither does this. On the way out it sends Clear then Close, as
9
+ WSJT-X does when it shuts down normally.
10
+
11
+ Decodes are stamped with the cycle they are played in, not the one they were
12
+ logged in, so a receiver sees a live band rather than one from last April.
13
+
14
+ It answers Replay the way WSJT-X does: every decode still in its window,
15
+ marked not new, then a Status. Datagrams from anywhere but the address it was
16
+ pointed at are ignored, and so is anything addressed to a different Id.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import select
22
+ import socket
23
+ import time
24
+ from collections.abc import Callable
25
+ from dataclasses import dataclass, field, replace
26
+
27
+ from . import wsjtx
28
+ from .alltxt import Cycle, Log
29
+
30
+ CYCLE_SECONDS = 15
31
+
32
+
33
+ @dataclass
34
+ class Station:
35
+ sender: str = "WSJT-X"
36
+ call: str = ""
37
+ grid: str = ""
38
+
39
+
40
+ @dataclass
41
+ class Player:
42
+ sock: socket.socket
43
+ target: tuple[str, int]
44
+ log: Log
45
+ station: Station = field(default_factory=Station)
46
+ speed: float = 1.0
47
+ loop: bool = False
48
+ say: Callable[[str], None] = print
49
+ clock: Callable[[], float] = time.time
50
+ _window: list[wsjtx.Decode] = field(default_factory=list, init=False)
51
+ _status: wsjtx.Status | None = field(default=None, init=False)
52
+ _stop: bool = field(default=False, init=False)
53
+
54
+ def stop(self) -> None:
55
+ self._stop = True
56
+
57
+ def _send(self, data: bytes) -> None:
58
+ self.sock.sendto(data, self.target)
59
+
60
+ def _send_status(self) -> None:
61
+ if self._status is not None:
62
+ self._send(wsjtx.status(self.station.sender, self._status))
63
+
64
+ # ------------------------------------------------------------ incoming
65
+
66
+ def accepts(self, address: str) -> bool:
67
+ """Only the receiver this was pointed at may talk to it."""
68
+ return address.removeprefix("::ffff:") == self.target[0]
69
+
70
+ def _handle(self, data: bytes, address: str) -> None:
71
+ if not self.accepts(address):
72
+ return
73
+ try:
74
+ msg = wsjtx.parse(data)
75
+ except ValueError:
76
+ return
77
+ if msg.sender != self.station.sender:
78
+ return
79
+ if msg.kind == wsjtx.MessageType.REPLAY:
80
+ self.say(f"replay asked for: sending {len(self._window)} decodes")
81
+ for d in self._window:
82
+ self._send(wsjtx.decode(self.station.sender, replace(d, is_new=False)))
83
+ self._send_status()
84
+ # Reply, Halt Tx, Free Text, Close and the rest would act on a real
85
+ # WSJT-X. A monitor never sends them, so there is nothing to do.
86
+
87
+ def _wait(self, until: float) -> None:
88
+ """Sleep until ``until`` (real time), answering anything that arrives."""
89
+ while not self._stop:
90
+ left = until - time.monotonic()
91
+ if left <= 0:
92
+ return
93
+ ready, _, _ = select.select([self.sock], [], [], min(left, 0.5))
94
+ if ready:
95
+ try:
96
+ data, (address, *_rest) = self.sock.recvfrom(65_535)
97
+ except OSError:
98
+ continue
99
+ self._handle(data, address)
100
+
101
+ # ------------------------------------------------------------ playback
102
+
103
+ def _play(self, cycle: Cycle, played_at: float) -> None:
104
+ ms_of_day = int((played_at % 86_400) * 1000)
105
+ self._status = wsjtx.Status(
106
+ dial_hz=cycle.dial_hz, mode=cycle.mode, de_call=self.station.call,
107
+ de_grid=self.station.grid, decoding=True,
108
+ )
109
+ self._send(wsjtx.heartbeat(self.station.sender))
110
+ self._send_status()
111
+ for line in cycle.decodes:
112
+ d = wsjtx.Decode(
113
+ is_new=True, ms_of_day=ms_of_day, snr=line.snr, dt=line.dt, df=line.df,
114
+ mode=wsjtx.mode_character(line.mode), message=line.message,
115
+ low_confidence=line.low_confidence,
116
+ )
117
+ self._send(wsjtx.decode(self.station.sender, d))
118
+ self._window.append(d)
119
+ self._status = replace(self._status, decoding=False)
120
+ self._send_status()
121
+
122
+ def run(self) -> None:
123
+ if not self.log.cycles:
124
+ raise ValueError("nothing to play: no received decodes")
125
+ # The first cycle is stamped with the T/R period now under way; each
126
+ # one after it, 15 seconds later.
127
+ first = self.clock() // CYCLE_SECONDS * CYCLE_SECONDS
128
+ started = time.monotonic()
129
+ n = 0
130
+ try:
131
+ while not self._stop:
132
+ for cycle in self.log.cycles:
133
+ if self._stop:
134
+ break
135
+ played_at = first + n * CYCLE_SECONDS
136
+ self._play(cycle, played_at)
137
+ stamp = time.strftime("%H:%M:%S", time.gmtime(played_at))
138
+ self.say(
139
+ f"{stamp} {cycle.dial_hz / 1e6:.3f} {cycle.mode} "
140
+ f"{len(cycle.decodes):2d} decodes (logged {cycle.stamp})"
141
+ )
142
+ n += 1
143
+ self._wait(started + n * CYCLE_SECONDS / self.speed)
144
+ if not self.loop:
145
+ break
146
+ finally:
147
+ self._send(wsjtx.clear(self.station.sender))
148
+ self._send(wsjtx.close(self.station.sender))
@@ -0,0 +1,120 @@
1
+ 260407_233830 14.074 Rx FT8 0 0.3 2544 N5EMM KE0QEQ EN04
2
+ 260407_233830 14.074 Rx FT8 -4 0.2 1662 CQ N5VAN DN91
3
+ 260407_233830 14.074 Rx FT8 -7 0.3 1498 CQ KW7E DM33
4
+ 260407_233830 14.074 Rx FT8 1 0.2 1940 PY2VZ KJ4KFJ 73
5
+ 260407_233830 14.074 Rx FT8 -13 0.5 2221 CQ N8RAF EN90
6
+ 260407_233830 14.074 Rx FT8 -11 0.3 1085 KB2KBC KY4JDC +21
7
+ 260407_233830 14.074 Rx FT8 -18 0.4 1851 PP5TG KD8VAX -17
8
+ 260407_233830 14.074 Rx FT8 -13 0.3 913 CQ NN8M EM79
9
+ 260407_233830 14.074 Rx FT8 -15 0.3 1149 CQ PP2BO GH61
10
+ 260407_233830 14.074 Rx FT8 -14 0.3 500 KC0IUY N8PFK EM80
11
+ 260407_233830 14.074 Rx FT8 -22 0.2 1343 PY2MM AE1JR 73
12
+ 260407_233830 14.074 Rx FT8 -14 0.3 2230 LW5DR AK8A RR73
13
+ 260407_233830 14.074 Rx FT8 -19 0.4 1513 N5EMM KS5X EM15
14
+ 260407_233830 14.074 Rx FT8 -15 0.5 897 VA2PM W6RJR DM13
15
+ 260407_233830 14.074 Rx FT8 -17 0.3 2550 N5EMM K8TJM EN91
16
+ 260407_233845 14.074 Rx FT8 10 1.1 1003 CX3JY N6PE DM13
17
+ 260407_233845 14.074 Rx FT8 1 0.3 2221 N8RAF KI6BWO DM04
18
+ 260407_233845 14.074 Rx FT8 3 0.6 497 KD2VRL W6DAD DM33
19
+ 260407_233845 14.074 Rx FT8 4 1.2 1237 CQ NL7D DM42
20
+ 260407_233845 14.074 Rx FT8 3 0.2 1940 K6VVK KJ4KFJ EN74
21
+ 260407_233845 14.074 Rx FT8 1 0.6 1071 AF0DB K8IG EN91
22
+ 260407_233845 14.074 Rx FT8 -8 0.3 191 TN8GD AB4OB EM70
23
+ 260407_233845 14.074 Rx FT8 -11 0.3 2006 CQ KD6UYK DM13
24
+ 260407_233845 14.074 Rx FT8 -10 0.9 1166 NM2V KA9EKJ R+15
25
+ 260407_233845 14.074 Rx FT8 -8 0.5 1895 KA3PMW KG5FDT R-19
26
+ 260407_233845 14.074 Rx FT8 -9 0.2 667 PY4STO K8RWM EN82
27
+ 260407_233845 14.074 Rx FT8 -16 0.9 254 VE3BQ KJ5W -15
28
+ 260407_233845 14.074 Rx FT8 -16 0.5 2543 K8ZI N5EMM RR73
29
+ 260407_233845 14.074 Rx FT8 -13 0.6 774 TN8GD N8IBW EM89
30
+ 260407_233845 14.074 Rx FT8 -12 0.2 400 PY4KU AD9AR EM69
31
+ 260407_233845 14.074 Rx FT8 -19 0.3 1620 KQ4SRN <W1AW/5> R+05
32
+ 260407_233845 14.074 Rx FT8 -16 0.8 754 CQ K4GBI EM70
33
+ 260407_233845 14.074 Rx FT8 -12 0.3 498 N8PFK KC0IUY +06
34
+ 260407_233845 14.074 Rx FT8 -22 0.4 1488 W7JZI V31DL EK57
35
+ 260407_233845 14.074 Rx FT8 -21 0.3 1118 K4SYT AC3PN R+23
36
+ 260407_233900 14.074 Rx FT8 5 0.4 2616 KE8VWF AD4VE DM12
37
+ 260407_233900 14.074 Rx FT8 -5 0.2 1662 CQ N5VAN DN91
38
+ 260407_233900 14.074 Rx FT8 -5 0.3 2036 KG5FDT KQ6RN -01
39
+ 260407_233900 14.074 Rx FT8 -11 0.3 1497 WB8BCU KW7E +00
40
+ 260407_233900 14.074 Rx FT8 -16 0.3 1566 WG4P PP2BO +04
41
+ 260407_233900 14.074 Rx FT8 -15 0.3 1851 CQ KD8VAX EN82
42
+ 260407_233900 14.074 Rx FT8 -7 0.3 1086 PY5AQ KY4JDC R-14
43
+ 260407_233900 14.074 Rx FT8 -18 0.4 1957 N5EMM N7VBN -15
44
+ 260407_233900 14.074 Rx FT8 -8 0.2 2886 TL8GD AA1ZZ EM16
45
+ 260407_233900 14.074 Rx FT8 -10 0.3 912 CQ NN8M EM79
46
+ 260407_233900 14.074 Rx FT8 -16 0.5 2221 CQ N8RAF EN90
47
+ 260407_233900 14.074 Rx FT8 -14 0.3 499 KC0IUY N8PFK R-11
48
+ 260407_233900 14.074 Rx FT8 -23 0.3 1725 VA3WL WD4T EM75
49
+ 260407_233900 14.074 Rx FT8 -16 0.5 898 VA2PM W6RJR DM13
50
+ 260407_233915 14.074 Rx FT8 3 1.1 1003 CX3JY N6PE DM13
51
+ 260407_233915 14.074 Rx FT8 5 0.3 2221 N8RAF KI6BWO DM04
52
+ 260407_233915 14.074 Rx FT8 4 1.3 1237 N6BBQ NL7D +00
53
+ 260407_233915 14.074 Rx FT8 -2 0.2 1940 K6VVK KJ4KFJ R-01
54
+ 260407_233915 14.074 Rx FT8 0 0.5 497 KD2VRL W6DAD DM33
55
+ 260407_233915 14.074 Rx FT8 -8 0.3 192 TN8GD AB4OB EM70
56
+ 260407_233915 14.074 Rx FT8 -14 0.3 1118 K4SYT AC3PN R+23
57
+ 260407_233915 14.074 Rx FT8 -14 0.3 2006 CQ KD6UYK DM13
58
+ 260407_233915 14.074 Rx FT8 -7 0.2 667 PY4STO K8RWM R-18
59
+ 260407_233915 14.074 Rx FT8 -5 0.6 1071 AF0DB K8IG EN91
60
+ 260407_233915 14.074 Rx FT8 -10 0.3 582 TN8GD KF8EFV -15
61
+ 260407_233915 14.074 Rx FT8 -11 0.2 1400 CQ NL8F AO63
62
+ 260407_233915 14.074 Rx FT8 -14 -0.1 857 PY4KU K8SAW -15
63
+ 260407_233915 14.074 Rx FT8 -15 0.9 1166 NM2V KA9EKJ 73
64
+ 260407_233915 14.074 Rx FT8 -14 0.2 2715 TN8GD N0STL EN35
65
+ 260407_233915 14.074 Rx FT8 -22 0.3 1620 <...> W1AW/5 73
66
+ 260407_233915 14.074 Rx FT8 -18 0.5 2544 K8TJM N5EMM -06
67
+ 260407_233915 14.074 Rx FT8 -15 0.9 254 VE3BQ KJ5W -15
68
+ 260407_233915 14.074 Rx FT8 -16 0.3 2042 KB3DHF KM7MGM DM42
69
+ 260407_233915 14.074 Rx FT8 -5 0.3 472 TN8GD W4PSX EM65
70
+ 260407_233915 14.074 Rx FT8 -13 0.4 679 W5ERX KA3E -02
71
+ 260407_233915 14.074 Rx FT8 -16 0.4 1405 TN8GD K9XJ EN63
72
+ 260407_233915 14.074 Rx FT8 -17 0.2 826 W1JKN KJ5KYB EL09
73
+ 260407_233915 14.074 Rx FT8 -19 0.5 1895 KA3PMW KG5FDT R-19
74
+ 260407_233915 14.074 Rx FT8 -12 0.3 499 N8PFK KC0IUY RR73
75
+ 260407_233915 14.074 Rx FT8 -16 0.2 401 PY4KU AD9AR EM69
76
+ 260407_233945 14.074 Rx FT8 7 1.1 1003 KD5QJQ N6PE DM13
77
+ 260407_233945 14.074 Rx FT8 5 0.3 2221 N8RAF KI6BWO R+02
78
+ 260407_233945 14.074 Rx FT8 -9 0.3 666 PY4STO K8RWM 73
79
+ 260407_233945 14.074 Rx FT8 -3 1.5 1237 N6BBQ NL7D RRR
80
+ 260407_233945 14.074 Rx FT8 -19 0.3 2007 CQ KD6UYK DM13
81
+ 260407_233945 14.074 Rx FT8 -13 0.5 2544 K8TJM N5EMM -06
82
+ 260407_233945 14.074 Rx FT8 -18 0.5 1895 KA3PMW KG5FDT R-19
83
+ 260407_233945 14.074 Rx FT8 -15 -0.1 856 KD5QJQ K8SAW +07
84
+ 260407_233945 14.074 Rx FT8 -13 0.3 472 TN8GD W4PSX EM65
85
+ 260407_233945 14.074 Rx FT8 -16 0.3 303 KB3DHF AD0AR EN41
86
+ 260407_233945 14.074 Rx FT8 -19 0.3 1118 K4SYT AC3PN 73
87
+ 260407_233945 14.074 Rx FT8 -17 0.6 774 TN8GD N8IBW EM89
88
+ 260407_233945 14.074 Rx FT8 -14 0.3 2655 CQ DX KO6IDY DM03
89
+ 260407_233945 14.074 Rx FT8 -17 0.5 1405 TN8GD K9XJ EN63
90
+ 260407_233945 14.074 Rx FT8 -18 0.2 1400 CQ NL8F AO63
91
+ 260407_233945 14.074 Rx FT8 -14 0.3 498 N8PFK KC0IUY RR73
92
+ 260407_233945 14.074 Rx FT8 -18 0.2 827 W1JKN KJ5KYB R-20
93
+ 260407_233945 14.074 Rx FT8 -16 0.3 192 TN8GD AB4OB EM70
94
+ 260407_233945 14.074 Rx FT8 -18 0.4 2602 CQ KJ5NUD EM44
95
+ 260407_234000 14.074 Rx FT8 -4 0.4 2616 KE8VWF AD4VE 73
96
+ 260407_234000 14.074 Rx FT8 -7 0.2 1662 CQ N5VAN DN91
97
+ 260407_234000 14.074 Rx FT8 -7 0.3 912 VO1DNK NN8M RR73
98
+ 260407_234000 14.074 Rx FT8 -9 0.3 1498 <W1AW/5> KW7E -11
99
+ 260407_234000 14.074 Rx FT8 -9 0.3 1086 KB2KBC KY4JDC RR73
100
+ 260407_234000 14.074 Rx FT8 -12 0.3 2036 KG5FDT KQ6RN -01
101
+ 260407_234000 14.074 Rx FT8 -22 0.4 1852 CQ KD8VAX EN82
102
+ 260407_234000 14.074 Rx FT8 -24 0.3 1725 VA3WL WD4T 73
103
+ 260407_234000 14.074 Rx FT8 -21 0.3 1451 N6PE KD5QJQ +14
104
+ 260407_234015 14.074 Rx FT8 10 1.1 1003 KD5QJQ N6PE R-02
105
+ 260407_234015 14.074 Rx FT8 7 0.3 2221 N8RAF KI6BWO 73
106
+ 260407_234015 14.074 Rx FT8 -11 0.1 1711 TN8GD K1CPT EN22
107
+ 260407_234015 14.074 Rx FT8 -17 0.3 1165 XQ5OS AD9AR EM69
108
+ 260407_234015 14.074 Rx FT8 -7 0.9 749 CQ KA9EKJ EM64
109
+ 260407_234015 14.074 Rx FT8 -10 0.4 1405 TN8GD K9XJ EN63
110
+ 260407_234015 14.074 Rx FT8 -14 0.3 2006 CQ KD6UYK DM13
111
+ 260407_234015 14.074 Rx FT8 -11 0.3 499 N8PFK KC0IUY RR73
112
+ 260407_234015 14.074 Rx FT8 -20 0.2 1940 KI7SKT KJ4KFJ 73
113
+ 260407_234015 14.074 Rx FT8 -13 0.3 2655 CQ DX KO6IDY DM03
114
+ 260407_234015 14.074 Rx FT8 -14 0.5 2714 TN8GD N0STL R-12
115
+ 260407_234015 14.074 Rx FT8 -16 0.3 303 KB3DHF AD0AR R-05
116
+ 260407_234015 14.074 Rx FT8 -16 0.5 2543 K8TJM N5EMM RR73
117
+ 260407_234015 14.074 Rx FT8 -17 0.7 2402 W5ERX W8XP EM79
118
+ 260407_234015 14.074 Rx FT8 -11 0.3 472 TN8GD W4PSX EM65
119
+ 260407_234015 14.074 Rx FT8 -17 0.2 1399 CQ NL8F AO63
120
+ 260407_234015 14.074 Rx FT8 -18 0.5 775 TN8GD N8IBW EM89