ka9q-python 3.22.0__py3-none-any.whl → 3.26.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.
- ka9q/__init__.py +4 -0
- ka9q/compat.py +1 -1
- ka9q/control.py +260 -79
- ka9q/discovery.py +28 -1
- ka9q/managed_stream.py +7 -0
- ka9q/multi_stream.py +31 -4
- ka9q/recovery_ladder.py +139 -0
- ka9q/resequencer.py +37 -1
- ka9q/rtp_recorder.py +20 -4
- ka9q/status_listener.py +9 -0
- ka9q/stream.py +124 -24
- ka9q/stream_quality.py +13 -0
- ka9q/types.py +4 -3
- {ka9q_python-3.22.0.dist-info → ka9q_python-3.26.0.dist-info}/METADATA +1 -1
- ka9q_python-3.26.0.dist-info/RECORD +30 -0
- ka9q_python-3.22.0.dist-info/RECORD +0 -29
- {ka9q_python-3.22.0.dist-info → ka9q_python-3.26.0.dist-info}/WHEEL +0 -0
- {ka9q_python-3.22.0.dist-info → ka9q_python-3.26.0.dist-info}/entry_points.txt +0 -0
- {ka9q_python-3.22.0.dist-info → ka9q_python-3.26.0.dist-info}/licenses/LICENSE +0 -0
- {ka9q_python-3.22.0.dist-info → ka9q_python-3.26.0.dist-info}/top_level.txt +0 -0
ka9q/__init__.py
CHANGED
|
@@ -119,7 +119,11 @@ from .slot_clock import SlotClock, Slot, SlotClockDesyncError, rtp_diff
|
|
|
119
119
|
from .spectrum_stream import SpectrumStream
|
|
120
120
|
from .status_listener import StatusListener, StatusListenerStats
|
|
121
121
|
|
|
122
|
+
from .recovery_ladder import RecoveryAction, RecoveryLadder # noqa: E402
|
|
123
|
+
|
|
122
124
|
__all__ = [
|
|
125
|
+
"RecoveryAction",
|
|
126
|
+
"RecoveryLadder",
|
|
123
127
|
# Control
|
|
124
128
|
'RadiodControl',
|
|
125
129
|
'allocate_ssrc',
|
ka9q/compat.py
CHANGED
ka9q/control.py
CHANGED
|
@@ -65,6 +65,32 @@ class Metrics:
|
|
|
65
65
|
|
|
66
66
|
|
|
67
67
|
# SSRC allocation function - compatible with signal-recorder
|
|
68
|
+
# Preset -> demodulator, mirroring the `demod =` line of each preset in
|
|
69
|
+
# ka9q-radio's share/presets.conf. LINEAR is the default because radio.h
|
|
70
|
+
# defines it as "everything else"; defaulting to FM (as this once did) puts
|
|
71
|
+
# an FM demodulator behind sam/ame/dsb/cwu/cwl/wspr/nam/amsq as well as wfm.
|
|
72
|
+
_FM_PRESETS = frozenset({"fm", "nfm", "pm", "npm"})
|
|
73
|
+
_WFM_PRESETS = frozenset({"wfm"})
|
|
74
|
+
_SPECTRUM_PRESETS = frozenset({"spectrum"})
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def demod_type_for_preset(preset: str) -> int:
|
|
78
|
+
"""Return the DEMOD_TYPE that matches a radiod preset name.
|
|
79
|
+
|
|
80
|
+
radiod sets the demodulator from the preset, but clients that also send
|
|
81
|
+
DEMOD_TYPE override it -- so the value has to agree with the preset or
|
|
82
|
+
the channel runs a demodulator the caller never asked for.
|
|
83
|
+
"""
|
|
84
|
+
name = (preset or "").strip().lower()
|
|
85
|
+
if name in _WFM_PRESETS:
|
|
86
|
+
return DemodType.WFM_DEMOD
|
|
87
|
+
if name in _FM_PRESETS:
|
|
88
|
+
return DemodType.FM_DEMOD
|
|
89
|
+
if name in _SPECTRUM_PRESETS:
|
|
90
|
+
return DemodType.SPECT_DEMOD
|
|
91
|
+
return DemodType.LINEAR_DEMOD
|
|
92
|
+
|
|
93
|
+
|
|
68
94
|
def allocate_ssrc(
|
|
69
95
|
frequency_hz: float,
|
|
70
96
|
preset: str = "iq",
|
|
@@ -764,6 +790,19 @@ def decode_status_dict(buffer: bytes) -> dict:
|
|
|
764
790
|
status['rf_agc'] = decode_int(data, optlen)
|
|
765
791
|
elif type_val == StatusType.PRESET:
|
|
766
792
|
status['preset'] = decode_string(data, optlen)
|
|
793
|
+
# ⛔ The two fields that say how to READ the samples. status.py has
|
|
794
|
+
# always decoded them; this decoder -- the one discovery.py builds
|
|
795
|
+
# ChannelInfo from -- did not, so every consumer holding a
|
|
796
|
+
# ChannelInfo had to fall back to the preset STRING to decide
|
|
797
|
+
# complex-versus-real. Phil Karn, 2026-09-21: "the individual
|
|
798
|
+
# parameters [are] definitive, with a preset just being a convenient
|
|
799
|
+
# shorthand"; a preset can be tweaked out of truth (PRESET USB then
|
|
800
|
+
# filters -3000,-50 yields LSB) and, from the nopreset branch on,
|
|
801
|
+
# may not arrive at all.
|
|
802
|
+
elif type_val == StatusType.DEMOD_TYPE:
|
|
803
|
+
status['demod_type'] = decode_int(data, optlen)
|
|
804
|
+
elif type_val == StatusType.OUTPUT_CHANNELS:
|
|
805
|
+
status['output_channels'] = decode_int(data, optlen)
|
|
767
806
|
elif type_val == StatusType.LOW_EDGE:
|
|
768
807
|
status['low_edge'] = decode_float(data, optlen)
|
|
769
808
|
elif type_val == StatusType.HIGH_EDGE:
|
|
@@ -799,6 +838,21 @@ def decode_status_dict(buffer: bytes) -> dict:
|
|
|
799
838
|
else:
|
|
800
839
|
_TTL_ZERO_WARNED.add(ssrc)
|
|
801
840
|
logger.warning(msg)
|
|
841
|
+
elif type_val == StatusType.FILTER_DROPS:
|
|
842
|
+
# radiod's count of output blocks this channel's demod thread
|
|
843
|
+
# lapped. Since ka9q-radio 55d9048d each lapped block is
|
|
844
|
+
# counted here AND emitted as a block of zeros, so the sample
|
|
845
|
+
# timeline stays intact and the loss shows up in the data --
|
|
846
|
+
# which makes this counter cross-checkable against
|
|
847
|
+
# ``PacketResequencer``'s zero-block detection rather than
|
|
848
|
+
# something a client has to take on trust.
|
|
849
|
+
#
|
|
850
|
+
# ``decode_status_packet`` has decoded it into
|
|
851
|
+
# ``ChannelStatus.filter_drops`` all along; this lightweight
|
|
852
|
+
# decoder -- the one ``StatusListener`` runs on every
|
|
853
|
+
# broadcast -- silently skipped it, so the per-channel drop
|
|
854
|
+
# count was invisible to every long-running client.
|
|
855
|
+
status['filter_drops'] = decode_int(data, optlen)
|
|
802
856
|
elif type_val == StatusType.LIFETIME:
|
|
803
857
|
status['lifetime'] = decode_int(data, optlen)
|
|
804
858
|
elif type_val == StatusType.DESCRIPTION:
|
|
@@ -841,6 +895,14 @@ def _encoding_name(value: Optional[int]) -> str:
|
|
|
841
895
|
return f"UNKNOWN({value})"
|
|
842
896
|
|
|
843
897
|
|
|
898
|
+
#: Bounds on `RadiodControl._drain_status_socket`. Comfortably above a full
|
|
899
|
+
#: receive buffer's worth of status packets (~26k for a 5 MB queue), while
|
|
900
|
+
#: guaranteeing the drain terminates even if the group refills as fast as it
|
|
901
|
+
#: is read -- or if the socket is a test double that never raises.
|
|
902
|
+
_DRAIN_MAX_PACKETS = 50000
|
|
903
|
+
_DRAIN_MAX_SEC = 0.25
|
|
904
|
+
|
|
905
|
+
|
|
844
906
|
class RadiodControl:
|
|
845
907
|
"""
|
|
846
908
|
Control interface for radiod
|
|
@@ -1475,8 +1537,13 @@ class RadiodControl:
|
|
|
1475
1537
|
encode_string(cmdbuffer, StatusType.PRESET, preset)
|
|
1476
1538
|
logger.info(f"Setting preset for SSRC {ssrc} to {preset}")
|
|
1477
1539
|
|
|
1478
|
-
# DEMOD_TYPE
|
|
1479
|
-
|
|
1540
|
+
# DEMOD_TYPE must agree with what the preset just selected, because
|
|
1541
|
+
# radiod applies whichever arrives last and this one follows PRESET
|
|
1542
|
+
# in the same packet. Getting it wrong silently substitutes a
|
|
1543
|
+
# different demodulator: asking for "wfm" and sending FM_DEMOD gets
|
|
1544
|
+
# the narrowband FM demod running behind a +/-110 kHz filter, which
|
|
1545
|
+
# produces no output at all and reports snr=-inf forever.
|
|
1546
|
+
demod_type = demod_type_for_preset(preset)
|
|
1480
1547
|
encode_int(cmdbuffer, StatusType.DEMOD_TYPE, demod_type)
|
|
1481
1548
|
logger.info(f"Setting DEMOD_TYPE for SSRC {ssrc} to {demod_type}")
|
|
1482
1549
|
|
|
@@ -1629,7 +1696,8 @@ class RadiodControl:
|
|
|
1629
1696
|
return True
|
|
1630
1697
|
|
|
1631
1698
|
def poll_channel(self, ssrc: int, expected_freq: Optional[float] = None,
|
|
1632
|
-
timeout: float = 2.0, frequency_tolerance: float = 1.0
|
|
1699
|
+
timeout: float = 2.0, frequency_tolerance: float = 1.0,
|
|
1700
|
+
allow_idle: bool = False):
|
|
1633
1701
|
"""Targeted status poll for a single channel — O(1), not O(channels).
|
|
1634
1702
|
|
|
1635
1703
|
Sends a poll naming exactly this ``ssrc`` and returns the ChannelInfo
|
|
@@ -1646,6 +1714,23 @@ class RadiodControl:
|
|
|
1646
1714
|
status appears or the timeout elapses. With no ``expected_freq`` an
|
|
1647
1715
|
obviously-empty reply (frequency 0) is rejected.
|
|
1648
1716
|
|
|
1717
|
+
**The 0 Hz blind spot (issue #6).** That empty reply is byte-for-byte
|
|
1718
|
+
what radiod also sends for a channel it *holds* at 0 Hz: one being
|
|
1719
|
+
reaped after :meth:`remove_channel` (radiod tunes it to 0 Hz and lets
|
|
1720
|
+
its lifetime run out), one parked at 0 Hz, or -- on builds that create
|
|
1721
|
+
channels dynamically -- one this very poll just minted. So with the
|
|
1722
|
+
default ``allow_idle=False`` a purging SSRC is indistinguishable from a
|
|
1723
|
+
free one: both return ``None``. Re-creating such an SSRC hands back
|
|
1724
|
+
the dying channel, which answers status and never emits RTP.
|
|
1725
|
+
|
|
1726
|
+
Pass ``allow_idle=True`` to receive that reply instead: the returned
|
|
1727
|
+
``ChannelInfo`` has ``frequency == 0.0`` and proves that radiod
|
|
1728
|
+
currently answers for this SSRC at 0 Hz -- i.e. the SSRC is **not**
|
|
1729
|
+
free to reuse right now. It does *not* tell you why (reaping vs
|
|
1730
|
+
parked vs freshly minted), and ``None`` then means only "no reply".
|
|
1731
|
+
``expected_freq`` always wins: a 0 Hz reply never matches a non-zero
|
|
1732
|
+
expectation, whatever ``allow_idle`` says.
|
|
1733
|
+
|
|
1649
1734
|
This is the establishment/verify probe: after ``create_channel`` sends
|
|
1650
1735
|
the create, one ``poll_channel(ssrc, expected_freq=f)`` confirms radiod
|
|
1651
1736
|
accepted it and yields the *granted* encoding + RTP_TIMESNAP/GPS_TIME
|
|
@@ -1708,8 +1793,9 @@ class RadiodControl:
|
|
|
1708
1793
|
if expected_freq is not None:
|
|
1709
1794
|
if abs(freq - expected_freq) > frequency_tolerance:
|
|
1710
1795
|
continue # empty or stale reply — keep waiting
|
|
1711
|
-
elif not freq:
|
|
1796
|
+
elif not freq and not allow_idle:
|
|
1712
1797
|
continue # radiod's "no such channel" empty status
|
|
1798
|
+
# -- or a channel held at 0 Hz; see #6
|
|
1713
1799
|
dest = status.get("destination", {})
|
|
1714
1800
|
mcast = dest.get("address", "") if isinstance(dest, dict) else ""
|
|
1715
1801
|
port = dest.get("port", 0) if isinstance(dest, dict) else 0
|
|
@@ -1990,8 +2076,14 @@ class RadiodControl:
|
|
|
1990
2076
|
# configs); the returned ChannelInfo carries the granted value, which
|
|
1991
2077
|
# consumers use authoritatively. A rate/preset divergence is logged
|
|
1992
2078
|
# but not fatal — the channel exists at the requested frequency.
|
|
1993
|
-
|
|
1994
|
-
|
|
2079
|
+
# An ABSENT preset is not a divergence. radiod may decline to echo
|
|
2080
|
+
# PRESET at all (write-only from the nopreset branch on), and a label
|
|
2081
|
+
# nobody sent cannot disagree with the one we asked for. Warning on
|
|
2082
|
+
# that would fire on every single ensure_channel call and bury the
|
|
2083
|
+
# real rate divergences underneath it.
|
|
2084
|
+
granted_preset = (getattr(channel, 'preset', None) or "").lower()
|
|
2085
|
+
preset_diverged = bool(granted_preset) and granted_preset != preset.lower()
|
|
2086
|
+
if channel.sample_rate != sample_rate or preset_diverged:
|
|
1995
2087
|
logger.warning(
|
|
1996
2088
|
"ensure_channel: SSRC %s granted rate=%s preset=%s "
|
|
1997
2089
|
"(requested rate=%s preset=%s)",
|
|
@@ -2033,6 +2125,13 @@ class RadiodControl:
|
|
|
2033
2125
|
- Always call this when your application is done with a channel
|
|
2034
2126
|
- Especially important for long-running applications that create temporary channels
|
|
2035
2127
|
- The channel may still appear in discovery for a brief time after calling this
|
|
2128
|
+
- Removal is asynchronous: radiod tunes the channel to 0 Hz and only
|
|
2129
|
+
reaps it when its lifetime runs out (build/config dependent; ~20 s
|
|
2130
|
+
observed). Until then a poll for this SSRC gets a 0 Hz reply that
|
|
2131
|
+
:meth:`poll_channel` drops by default, so the SSRC *looks* free
|
|
2132
|
+
while re-creating it would hand back the dying channel. Use
|
|
2133
|
+
``poll_channel(ssrc, allow_idle=True)`` to see that state
|
|
2134
|
+
(issue #6).
|
|
2036
2135
|
"""
|
|
2037
2136
|
_validate_ssrc(ssrc)
|
|
2038
2137
|
|
|
@@ -2162,31 +2261,81 @@ class RadiodControl:
|
|
|
2162
2261
|
|
|
2163
2262
|
return status_sock
|
|
2164
2263
|
|
|
2264
|
+
@staticmethod
|
|
2265
|
+
def _drain_status_socket(sock) -> int:
|
|
2266
|
+
"""Discard whatever is already queued. Returns how many were dropped.
|
|
2267
|
+
|
|
2268
|
+
The cached poll socket is joined to radiod's status group but read
|
|
2269
|
+
ONLY during a poll, so between polls the kernel queues the entire
|
|
2270
|
+
continuous status broadcast into it. Left alone it reaches the
|
|
2271
|
+
receive-buffer ceiling and stays there: on B4, wspr-recorder's copy
|
|
2272
|
+
sat permanently full and had discarded 1,254,234 packets, ~24/s.
|
|
2273
|
+
|
|
2274
|
+
Two costs, and the second is the one that matters. The socket pins
|
|
2275
|
+
its whole buffer in kernel memory; and every poll then has to
|
|
2276
|
+
recvfrom() and decode its way through thousands of stale packets
|
|
2277
|
+
before reaching the reply it wants, inside a 2 s timeout. Draining
|
|
2278
|
+
first makes a poll read only post-command traffic, which is what a
|
|
2279
|
+
request/response exchange on a shared multicast group requires
|
|
2280
|
+
anyway.
|
|
2281
|
+
|
|
2282
|
+
Correctness does not depend on this -- `poll_status` matches on
|
|
2283
|
+
`command_tag` and would reject stale packets regardless -- but
|
|
2284
|
+
latency and the drop counter do.
|
|
2285
|
+
|
|
2286
|
+
Bounded on purpose. An unbounded "read until it raises" loop becomes
|
|
2287
|
+
the stall it exists to prevent: on a busy group the socket can be
|
|
2288
|
+
refilled as fast as it is emptied, and the drain never returns. The
|
|
2289
|
+
caps below are far above a full buffer's worth (a 5 MB queue of
|
|
2290
|
+
~200-byte status packets is ~26k) yet still terminate. This was not
|
|
2291
|
+
theoretical -- the first version hung the tune() test suite outright,
|
|
2292
|
+
because a mocked socket never raises.
|
|
2293
|
+
"""
|
|
2294
|
+
n = 0
|
|
2295
|
+
deadline = time.monotonic() + _DRAIN_MAX_SEC
|
|
2296
|
+
sock.setblocking(False)
|
|
2297
|
+
try:
|
|
2298
|
+
for _ in range(_DRAIN_MAX_PACKETS):
|
|
2299
|
+
try:
|
|
2300
|
+
sock.recv(65536)
|
|
2301
|
+
n += 1
|
|
2302
|
+
except (BlockingIOError, InterruptedError):
|
|
2303
|
+
break
|
|
2304
|
+
except OSError:
|
|
2305
|
+
break
|
|
2306
|
+
if not n % 256 and time.monotonic() > deadline:
|
|
2307
|
+
logger.warning(
|
|
2308
|
+
"status drain hit its %.2fs budget after %d packets; "
|
|
2309
|
+
"the group is busier than the drain can empty",
|
|
2310
|
+
_DRAIN_MAX_SEC, n)
|
|
2311
|
+
break
|
|
2312
|
+
else:
|
|
2313
|
+
logger.warning(
|
|
2314
|
+
"status drain hit its %d-packet cap; some stale status "
|
|
2315
|
+
"remains queued", _DRAIN_MAX_PACKETS)
|
|
2316
|
+
finally:
|
|
2317
|
+
sock.setblocking(True)
|
|
2318
|
+
sock.settimeout(0.1)
|
|
2319
|
+
if n:
|
|
2320
|
+
logger.debug("drained %d stale status packet(s) before poll", n)
|
|
2321
|
+
return n
|
|
2322
|
+
|
|
2165
2323
|
def _get_or_create_status_listener(self):
|
|
2324
|
+
"""Removed: the cached status socket leaked.
|
|
2325
|
+
|
|
2326
|
+
It stayed joined to radiod's status group and unread between polls,
|
|
2327
|
+
so the kernel queued the continuous status broadcast into it until
|
|
2328
|
+
the receive buffer capped and every further packet was discarded --
|
|
2329
|
+
1,254,234 of them on B4, ~24/s, with the queue permanently full.
|
|
2330
|
+
Draining at poll time did not help, because in steady state nothing
|
|
2331
|
+
polls. Each exchange now creates and closes its own socket via
|
|
2332
|
+
`_setup_status_listener`; `close()` still tolerates the old
|
|
2333
|
+
attribute so existing callers are unaffected.
|
|
2166
2334
|
"""
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
socket exhaustion.
|
|
2172
|
-
|
|
2173
|
-
Returns:
|
|
2174
|
-
Cached or newly created status listener socket
|
|
2175
|
-
"""
|
|
2176
|
-
import threading
|
|
2177
|
-
|
|
2178
|
-
# Lazy initialization of lock (avoid threading overhead if never used)
|
|
2179
|
-
if self._status_sock_lock is None:
|
|
2180
|
-
self._status_sock_lock = threading.Lock()
|
|
2181
|
-
|
|
2182
|
-
with self._status_sock_lock:
|
|
2183
|
-
if self._status_sock is None:
|
|
2184
|
-
logger.debug("Creating cached status listener socket")
|
|
2185
|
-
self._status_sock = self._setup_status_listener()
|
|
2186
|
-
else:
|
|
2187
|
-
logger.debug("Reusing cached status listener socket")
|
|
2188
|
-
return self._status_sock
|
|
2189
|
-
|
|
2335
|
+
raise NotImplementedError(
|
|
2336
|
+
"the cached status socket was removed (it leaked); call "
|
|
2337
|
+
"_setup_status_listener() per exchange and close it")
|
|
2338
|
+
|
|
2190
2339
|
def tune(self, ssrc: int, frequency_hz: Optional[float] = None,
|
|
2191
2340
|
preset: Optional[str] = None, sample_rate: Optional[int] = None,
|
|
2192
2341
|
low_edge: Optional[float] = None, high_edge: Optional[float] = None,
|
|
@@ -2326,9 +2475,16 @@ class RadiodControl:
|
|
|
2326
2475
|
|
|
2327
2476
|
encode_eol(cmdbuffer)
|
|
2328
2477
|
|
|
2329
|
-
#
|
|
2330
|
-
#
|
|
2331
|
-
|
|
2478
|
+
# One socket per exchange (see poll_status for why caching it leaked).
|
|
2479
|
+
# The old comment claimed reuse avoided creation overhead; the cost it
|
|
2480
|
+
# actually avoided was ~1 ms of setup per tune, against a socket that
|
|
2481
|
+
# stayed joined to the status group for the process lifetime and
|
|
2482
|
+
# queued the whole broadcast into a buffer nobody read.
|
|
2483
|
+
status_sock = self._setup_status_listener()
|
|
2484
|
+
# Same request/response contract as poll_status: only packets that
|
|
2485
|
+
# arrive after the tune command can be its reply, and wading through
|
|
2486
|
+
# a full buffer's worth of stale ones eats the timeout.
|
|
2487
|
+
self._drain_status_socket(status_sock)
|
|
2332
2488
|
|
|
2333
2489
|
try:
|
|
2334
2490
|
start_time = time.time()
|
|
@@ -2385,9 +2541,10 @@ class RadiodControl:
|
|
|
2385
2541
|
raise TimeoutError(f"No status response received for SSRC {ssrc} within {timeout}s")
|
|
2386
2542
|
|
|
2387
2543
|
finally:
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2544
|
+
try:
|
|
2545
|
+
status_sock.close()
|
|
2546
|
+
except OSError:
|
|
2547
|
+
pass
|
|
2391
2548
|
|
|
2392
2549
|
def _decode_status_response(self, buffer: bytes) -> dict:
|
|
2393
2550
|
"""
|
|
@@ -3318,30 +3475,44 @@ class RadiodControl:
|
|
|
3318
3475
|
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
3319
3476
|
encode_eol(cmdbuffer)
|
|
3320
3477
|
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3478
|
+
# One socket per exchange, closed on the way out. Caching it for
|
|
3479
|
+
# the process lifetime left it joined to the status group and
|
|
3480
|
+
# unread between polls, so the kernel queued radiod's continuous
|
|
3481
|
+
# broadcast into it until the buffer capped: on B4 that socket sat
|
|
3482
|
+
# permanently full having discarded 1.25M packets. Draining at poll
|
|
3483
|
+
# time cannot fix that, because in steady state nothing polls.
|
|
3484
|
+
status_sock = self._setup_status_listener()
|
|
3485
|
+
try:
|
|
3486
|
+
# Only traffic that arrives AFTER the command can be its reply.
|
|
3487
|
+
self._drain_status_socket(status_sock)
|
|
3488
|
+
start = _time.time()
|
|
3489
|
+
last_send = 0.0
|
|
3490
|
+
retry = 0.1
|
|
3491
|
+
while _time.time() - start < timeout:
|
|
3492
|
+
now = _time.time()
|
|
3493
|
+
if now - last_send >= retry:
|
|
3494
|
+
self.send_command(cmdbuffer)
|
|
3495
|
+
last_send = now
|
|
3496
|
+
retry = min(retry * 2, 1.0)
|
|
3497
|
+
remaining = timeout - (now - start)
|
|
3498
|
+
ready = select.select([status_sock], [], [], min(retry, remaining, 0.5))
|
|
3499
|
+
if not ready[0]:
|
|
3500
|
+
continue
|
|
3501
|
+
try:
|
|
3502
|
+
buf, _addr = status_sock.recvfrom(8192)
|
|
3503
|
+
except socket.timeout:
|
|
3504
|
+
continue
|
|
3505
|
+
st = decode_status_packet(buf)
|
|
3506
|
+
if st is None:
|
|
3507
|
+
continue
|
|
3508
|
+
if st.ssrc == ssrc and st.command_tag == command_tag:
|
|
3509
|
+
return st
|
|
3510
|
+
raise TimeoutError(f"No status response received for SSRC {ssrc} within {timeout}s")
|
|
3511
|
+
finally:
|
|
3335
3512
|
try:
|
|
3336
|
-
|
|
3337
|
-
except
|
|
3338
|
-
|
|
3339
|
-
st = decode_status_packet(buf)
|
|
3340
|
-
if st is None:
|
|
3341
|
-
continue
|
|
3342
|
-
if st.ssrc == ssrc and st.command_tag == command_tag:
|
|
3343
|
-
return st
|
|
3344
|
-
raise TimeoutError(f"No status response received for SSRC {ssrc} within {timeout}s")
|
|
3513
|
+
status_sock.close()
|
|
3514
|
+
except OSError:
|
|
3515
|
+
pass
|
|
3345
3516
|
|
|
3346
3517
|
def listen_status(self, callback, duration: Optional[float] = None,
|
|
3347
3518
|
ssrcs: Optional[set] = None):
|
|
@@ -3356,28 +3527,38 @@ class RadiodControl:
|
|
|
3356
3527
|
import time as _time
|
|
3357
3528
|
from .status import decode_status_packet
|
|
3358
3529
|
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3530
|
+
# Passive listener: its own socket for the life of the call, then
|
|
3531
|
+
# closed. Unlike the request/response paths this one is read
|
|
3532
|
+
# continuously, so it never accumulates -- but it must not share a
|
|
3533
|
+
# cached socket that outlives the call either.
|
|
3534
|
+
status_sock = self._setup_status_listener()
|
|
3535
|
+
try:
|
|
3536
|
+
start = _time.time()
|
|
3537
|
+
while True:
|
|
3538
|
+
if duration is not None and _time.time() - start >= duration:
|
|
3539
|
+
return
|
|
3540
|
+
remaining = None if duration is None else max(0.0, duration - (_time.time() - start))
|
|
3541
|
+
ready = select.select([status_sock], [], [], 0.5 if remaining is None else min(0.5, remaining))
|
|
3542
|
+
if not ready[0]:
|
|
3543
|
+
continue
|
|
3544
|
+
try:
|
|
3545
|
+
buf, _addr = status_sock.recvfrom(8192)
|
|
3546
|
+
except socket.timeout:
|
|
3547
|
+
continue
|
|
3548
|
+
st = decode_status_packet(buf)
|
|
3549
|
+
if st is None or st.ssrc is None:
|
|
3550
|
+
continue
|
|
3551
|
+
if ssrcs is not None and st.ssrc not in ssrcs:
|
|
3552
|
+
continue
|
|
3553
|
+
try:
|
|
3554
|
+
callback(st)
|
|
3555
|
+
except Exception as exc:
|
|
3556
|
+
logger.warning(f"listen_status callback raised: {exc}")
|
|
3557
|
+
finally:
|
|
3377
3558
|
try:
|
|
3378
|
-
|
|
3379
|
-
except
|
|
3380
|
-
|
|
3559
|
+
status_sock.close()
|
|
3560
|
+
except OSError:
|
|
3561
|
+
pass
|
|
3381
3562
|
|
|
3382
3563
|
# ── Continuous STATUS listener (v3.16.0+) ──────────────────────
|
|
3383
3564
|
|
ka9q/discovery.py
CHANGED
|
@@ -82,6 +82,31 @@ class ChannelInfo:
|
|
|
82
82
|
# per-band abs-divergence check remains the backstop for real sustained drift.
|
|
83
83
|
anchor_step_threshold_sec: float = 0.75 # |move| beyond this ⇒ a step (configurable)
|
|
84
84
|
|
|
85
|
+
# radiod's cumulative count of output blocks this channel's demod thread
|
|
86
|
+
# lapped (FILTER_DROPS). Appended last so the addition is purely
|
|
87
|
+
# additive — no existing positional construction shifts.
|
|
88
|
+
#
|
|
89
|
+
# CUMULATIVE since the radiod process started, so consumers must
|
|
90
|
+
# DIFFERENCE it; a level on its own says nothing about current loss.
|
|
91
|
+
# ``None`` until a STATUS broadcast carrying the tag has been seen —
|
|
92
|
+
# absent and "zero drops" are different claims and must not be
|
|
93
|
+
# conflated.
|
|
94
|
+
filter_drops: Optional[int] = None
|
|
95
|
+
|
|
96
|
+
# ⛔ APPENDED, deliberately. These belong logically beside `preset`, but
|
|
97
|
+
# inserting them there would shift the position of every field after it
|
|
98
|
+
# and break any client constructing a ChannelInfo positionally. The
|
|
99
|
+
# contract test caught exactly that. Logical grouping is not worth a
|
|
100
|
+
# silent argument shift in five downstream repos.
|
|
101
|
+
#
|
|
102
|
+
# The parameter vector radiod treats as definitive: demod_type and
|
|
103
|
+
# output_channels together say whether the payload carries complex or
|
|
104
|
+
# real samples. The preset string above is a convenience label that a
|
|
105
|
+
# later per-parameter tweak can leave stale, and which radiod may stop
|
|
106
|
+
# echoing entirely. None when the status packet did not carry them.
|
|
107
|
+
demod_type: Optional[int] = None
|
|
108
|
+
output_channels: Optional[int] = None
|
|
109
|
+
|
|
85
110
|
def __post_init__(self):
|
|
86
111
|
# Seed the atomic-pair snapshot from the constructor args if both
|
|
87
112
|
# were provided. Lets ``get_anchor`` return the construction-time
|
|
@@ -324,7 +349,9 @@ def discover_channels_native(status_address: str, listen_duration: float = 2.0,
|
|
|
324
349
|
port=port,
|
|
325
350
|
gps_time=status.get('gps_time'),
|
|
326
351
|
rtp_timesnap=status.get('rtp_timesnap'),
|
|
327
|
-
encoding=status.get('encoding', 0)
|
|
352
|
+
encoding=status.get('encoding', 0),
|
|
353
|
+
demod_type=status.get('demod_type'),
|
|
354
|
+
output_channels=status.get('output_channels'),
|
|
328
355
|
)
|
|
329
356
|
|
|
330
357
|
# Store or update channel info
|
ka9q/managed_stream.py
CHANGED
|
@@ -133,6 +133,7 @@ class ManagedStream:
|
|
|
133
133
|
samples_per_packet: int = 320,
|
|
134
134
|
resequence_buffer_size: int = 64,
|
|
135
135
|
deliver_interval_packets: int = 10,
|
|
136
|
+
raw_payloads: bool = False,
|
|
136
137
|
):
|
|
137
138
|
"""
|
|
138
139
|
Initialize ManagedStream.
|
|
@@ -154,6 +155,10 @@ class ManagedStream:
|
|
|
154
155
|
max_restore_attempts: Max restore attempts, 0=unlimited (default: 0)
|
|
155
156
|
samples_per_packet: Expected samples per RTP packet
|
|
156
157
|
resequence_buffer_size: Packets to buffer for resequencing
|
|
158
|
+
raw_payloads: Deliver undecoded RTP payloads (List[bytes], one per
|
|
159
|
+
packet) instead of samples — required for the framed encodings
|
|
160
|
+
OPUS/OPUS_VOIP/AX25, which have no raw-sample representation.
|
|
161
|
+
See :class:`ka9q.stream.RadiodStream` for the full contract.
|
|
157
162
|
deliver_interval_packets: Deliver to callback every N packets
|
|
158
163
|
"""
|
|
159
164
|
self._control = control
|
|
@@ -179,6 +184,7 @@ class ManagedStream:
|
|
|
179
184
|
self._samples_per_packet = samples_per_packet
|
|
180
185
|
self._resequence_buffer_size = resequence_buffer_size
|
|
181
186
|
self._deliver_interval_packets = deliver_interval_packets
|
|
187
|
+
self._raw_payloads = raw_payloads
|
|
182
188
|
|
|
183
189
|
# State
|
|
184
190
|
self._state = StreamState.STOPPED
|
|
@@ -305,6 +311,7 @@ class ManagedStream:
|
|
|
305
311
|
samples_per_packet=self._samples_per_packet,
|
|
306
312
|
resequence_buffer_size=self._resequence_buffer_size,
|
|
307
313
|
deliver_interval_packets=self._deliver_interval_packets,
|
|
314
|
+
raw_payloads=self._raw_payloads,
|
|
308
315
|
)
|
|
309
316
|
self._stream.start()
|
|
310
317
|
|
ka9q/multi_stream.py
CHANGED
|
@@ -52,7 +52,7 @@ import numpy as np
|
|
|
52
52
|
|
|
53
53
|
from .discovery import ChannelInfo
|
|
54
54
|
from .resequencer import PacketResequencer, RTPPacket
|
|
55
|
-
from .rtp_recorder import RTPHeader, parse_rtp_header,
|
|
55
|
+
from .rtp_recorder import RTPHeader, parse_rtp_header, rtp_to_utc
|
|
56
56
|
from .stream import ENCODING_SAMPLE_BYTES, SampleCallback, parse_rtp_samples
|
|
57
57
|
from .stream_quality import GapEvent, GapSource, StreamQuality
|
|
58
58
|
|
|
@@ -512,7 +512,7 @@ class MultiStream:
|
|
|
512
512
|
continue
|
|
513
513
|
|
|
514
514
|
# Wallclock
|
|
515
|
-
wallclock =
|
|
515
|
+
wallclock = rtp_to_utc(header.timestamp, slot.channel_info)
|
|
516
516
|
|
|
517
517
|
# Resequencer
|
|
518
518
|
packet = RTPPacket(
|
|
@@ -524,11 +524,17 @@ class MultiStream:
|
|
|
524
524
|
)
|
|
525
525
|
output, gaps = slot.resequencer.process_packet(packet)
|
|
526
526
|
|
|
527
|
+
# Harvest gaps unconditionally. A radiod zero-block marker
|
|
528
|
+
# can be reported for a packet that produced no output yet
|
|
529
|
+
# (arrived out of order and is still buffered); collecting
|
|
530
|
+
# only inside the branch below discarded it, and the stall
|
|
531
|
+
# went silent one layer further up.
|
|
532
|
+
slot.gap_buffer.extend(gaps)
|
|
533
|
+
|
|
527
534
|
if output is not None and len(output) > 0:
|
|
528
535
|
if not slot.sample_buffer:
|
|
529
536
|
slot.pending_rtp_start = slot.resequencer.last_chunk_rtp_start
|
|
530
537
|
slot.sample_buffer.append(output)
|
|
531
|
-
slot.gap_buffer.extend(gaps)
|
|
532
538
|
slot.packets_since_delivery += 1
|
|
533
539
|
|
|
534
540
|
if slot.packets_since_delivery >= slot.deliver_interval:
|
|
@@ -617,6 +623,27 @@ class MultiStream:
|
|
|
617
623
|
|
|
618
624
|
# ── delivery ──
|
|
619
625
|
|
|
626
|
+
@staticmethod
|
|
627
|
+
def _account_delivery(slot: _ChannelSlot, combined, gaps) -> None:
|
|
628
|
+
"""Book one delivered batch against the completeness counters.
|
|
629
|
+
|
|
630
|
+
`completeness_pct` computes
|
|
631
|
+
`(delivered - gaps_filled) / expected`, but MultiStream only ever
|
|
632
|
+
set `delivered`. With `expected` left at 0 the property took its
|
|
633
|
+
early return and reported a hardcoded 100.0 no matter what was
|
|
634
|
+
lost — on every recorder built on MultiStream, which is all the
|
|
635
|
+
multiplexing ones. AC0G-B4 2026-08-15 logged 42 gaps totalling
|
|
636
|
+
93,600 samples in 25 minutes while completeness read 100.0%.
|
|
637
|
+
|
|
638
|
+
Gap fills keep the sample count whole, so `expected` tracks
|
|
639
|
+
`delivered` and the loss is carried by `gaps_filled`.
|
|
640
|
+
"""
|
|
641
|
+
n = len(combined)
|
|
642
|
+
slot.quality.total_samples_delivered += n
|
|
643
|
+
slot.quality.total_samples_expected += n
|
|
644
|
+
slot.quality.total_gaps_filled += sum(
|
|
645
|
+
g.duration_samples for g in gaps)
|
|
646
|
+
|
|
620
647
|
def _deliver(self, slot: _ChannelSlot) -> None:
|
|
621
648
|
if not slot.sample_buffer or slot.on_samples is None:
|
|
622
649
|
slot.sample_buffer.clear()
|
|
@@ -628,7 +655,7 @@ class MultiStream:
|
|
|
628
655
|
combined = np.concatenate(slot.sample_buffer)
|
|
629
656
|
n = len(combined)
|
|
630
657
|
|
|
631
|
-
slot
|
|
658
|
+
self._account_delivery(slot, combined, slot.gap_buffer)
|
|
632
659
|
slot.quality.batch_samples_delivered = n
|
|
633
660
|
slot.quality.batch_gaps = list(slot.gap_buffer)
|
|
634
661
|
slot.quality.sample_rate = slot.sample_rate
|
ka9q/recovery_ladder.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Escalating recovery from a radiod restart.
|
|
2
|
+
|
|
3
|
+
``MultiStream`` restores a slot whose stream merely stopped. It cannot
|
|
4
|
+
help when radiod itself went away and came back, because the channel the
|
|
5
|
+
slot refers to no longer exists: radiod recreates SSRCs at Template
|
|
6
|
+
defaults with TTL=0 after a brief outage, the per-channel verify times
|
|
7
|
+
out, and the slot stays stale forever while the client goes on looking
|
|
8
|
+
healthy.
|
|
9
|
+
|
|
10
|
+
Recovering from that needs a second tier -- re-issue the channel
|
|
11
|
+
requests -- and a third when even that fails -- tear the source down and
|
|
12
|
+
reconnect. wspr-recorder worked this out and kept it in its own
|
|
13
|
+
repository. The cost of that showed on AC0G-B4 on 2026-08-30: after a
|
|
14
|
+
radiod outage, wspr re-provisioned 17 stale channels and recovered on
|
|
15
|
+
its own, while meteor-scatter, which has only the tier-1 callback, sat
|
|
16
|
+
dead for twenty-two hours until a human noticed. psk-recorder has the
|
|
17
|
+
same gap. One client in three solved it, and the other two failed
|
|
18
|
+
silently.
|
|
19
|
+
|
|
20
|
+
The POLICY belongs here, because recovering from a radiod restart is a
|
|
21
|
+
property of the clients<->radiod interface and that interface is what
|
|
22
|
+
this package is. The MECHANISMS stay with the client: what it means to
|
|
23
|
+
re-provision depends on that client's own configuration, which this
|
|
24
|
+
package should not know. So the ladder decides *what* should happen and
|
|
25
|
+
the caller decides *how*.
|
|
26
|
+
|
|
27
|
+
Typical use, from whatever loop the client already runs::
|
|
28
|
+
|
|
29
|
+
ladder = RecoveryLadder()
|
|
30
|
+
...
|
|
31
|
+
action = ladder.observe(healthy=(active == expected))
|
|
32
|
+
if action is RecoveryAction.REPROVISION:
|
|
33
|
+
rm.reprovision_stale()
|
|
34
|
+
elif action is RecoveryAction.FULL_RESET:
|
|
35
|
+
rm.full_reset()
|
|
36
|
+
elif action is RecoveryAction.RESTART_SELF:
|
|
37
|
+
log.critical("in-process repair exhausted after %d attempts; "
|
|
38
|
+
"exiting for systemd to replace us",
|
|
39
|
+
ladder.consecutive_degraded)
|
|
40
|
+
raise SystemExit(1) # Restart=always does the rest
|
|
41
|
+
|
|
42
|
+
One ladder per source. A client with two radiods keeps two, so a fault
|
|
43
|
+
on one never escalates the other.
|
|
44
|
+
"""
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import enum
|
|
48
|
+
|
|
49
|
+
__all__ = ["RecoveryAction", "RecoveryLadder"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RecoveryAction(enum.Enum):
|
|
53
|
+
"""What the ladder asks the caller to do about this observation."""
|
|
54
|
+
|
|
55
|
+
NONE = "none"
|
|
56
|
+
REPROVISION = "reprovision"
|
|
57
|
+
FULL_RESET = "full_reset"
|
|
58
|
+
#: In-process remedies are exhausted; the PROCESS must be replaced.
|
|
59
|
+
#:
|
|
60
|
+
#: ⛔ OPT-IN (`restart_after=None` by default). An action a caller does
|
|
61
|
+
#: not handle falls through its if/elif chain and does NOTHING, which is
|
|
62
|
+
#: silent inaction — the exact failure this ladder exists to prevent. So
|
|
63
|
+
#: a caller asks for this rung only once it can act on it.
|
|
64
|
+
RESTART_SELF = "restart_self"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RecoveryLadder:
|
|
68
|
+
"""Consecutive-degraded counter with two escalation thresholds.
|
|
69
|
+
|
|
70
|
+
The counter clears the instant a source returns to full health, so a
|
|
71
|
+
single bad check followed by recovery never ratchets a later,
|
|
72
|
+
unrelated incident toward a full reset.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
DEFAULT_REPROVISION_AFTER = 1
|
|
76
|
+
DEFAULT_FULL_RESET_AFTER = 3
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
reprovision_after: int = DEFAULT_REPROVISION_AFTER,
|
|
81
|
+
full_reset_after: int = DEFAULT_FULL_RESET_AFTER,
|
|
82
|
+
restart_after: int | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
if full_reset_after < reprovision_after:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"full_reset_after ({full_reset_after}) must not precede "
|
|
87
|
+
f"reprovision_after ({reprovision_after}): a ladder whose "
|
|
88
|
+
f"top rung comes first is not a ladder"
|
|
89
|
+
)
|
|
90
|
+
if restart_after is not None and restart_after < full_reset_after:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"restart_after ({restart_after}) must not precede "
|
|
93
|
+
f"full_reset_after ({full_reset_after}): replacing the "
|
|
94
|
+
f"process is the LAST resort, not an earlier one"
|
|
95
|
+
)
|
|
96
|
+
self.reprovision_after = int(reprovision_after)
|
|
97
|
+
self.full_reset_after = int(full_reset_after)
|
|
98
|
+
self.restart_after = (None if restart_after is None
|
|
99
|
+
else int(restart_after))
|
|
100
|
+
self.consecutive_degraded = 0
|
|
101
|
+
#: Totals since construction, so an operator can tell a station
|
|
102
|
+
#: that is quietly self-healing from one that is merely quiet.
|
|
103
|
+
self.reprovisions = 0
|
|
104
|
+
self.full_resets = 0
|
|
105
|
+
self.restarts_requested = 0
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def escalating(self) -> bool:
|
|
109
|
+
"""True while a fault is open (at least one degraded check)."""
|
|
110
|
+
return self.consecutive_degraded > 0
|
|
111
|
+
|
|
112
|
+
def observe(self, healthy: bool) -> RecoveryAction:
|
|
113
|
+
"""Record one health check and return the action it calls for."""
|
|
114
|
+
if healthy:
|
|
115
|
+
self.consecutive_degraded = 0
|
|
116
|
+
return RecoveryAction.NONE
|
|
117
|
+
|
|
118
|
+
self.consecutive_degraded += 1
|
|
119
|
+
n = self.consecutive_degraded
|
|
120
|
+
if self.restart_after is not None and n >= self.restart_after:
|
|
121
|
+
# Checked before FULL_RESET because it is the higher rung: once
|
|
122
|
+
# in-process repair has demonstrably failed this many times,
|
|
123
|
+
# repeating it is the behaviour we are fixing.
|
|
124
|
+
self.restarts_requested += 1
|
|
125
|
+
return RecoveryAction.RESTART_SELF
|
|
126
|
+
if n >= self.full_reset_after:
|
|
127
|
+
# Stays here rather than giving up. A client that cannot
|
|
128
|
+
# recover must keep trying: going quiet is the exact failure
|
|
129
|
+
# this ladder exists to prevent.
|
|
130
|
+
self.full_resets += 1
|
|
131
|
+
return RecoveryAction.FULL_RESET
|
|
132
|
+
if n >= self.reprovision_after:
|
|
133
|
+
self.reprovisions += 1
|
|
134
|
+
return RecoveryAction.REPROVISION
|
|
135
|
+
return RecoveryAction.NONE
|
|
136
|
+
|
|
137
|
+
def reset(self) -> None:
|
|
138
|
+
"""Forget the current fault (not the totals)."""
|
|
139
|
+
self.consecutive_degraded = 0
|
ka9q/resequencer.py
CHANGED
|
@@ -157,11 +157,47 @@ class PacketResequencer:
|
|
|
157
157
|
self.stats.packets_duplicate += 1
|
|
158
158
|
return None, []
|
|
159
159
|
|
|
160
|
+
# radiod zero-fills a dropped output block, so this packet may
|
|
161
|
+
# carry synthetic samples with a perfectly contiguous timestamp.
|
|
162
|
+
# Detect it here, before the resequencer -- which by definition
|
|
163
|
+
# cannot see it, there being no timestamp gap to find.
|
|
164
|
+
zero_gap = self._detect_zero_block(packet)
|
|
165
|
+
|
|
160
166
|
# Add to buffer
|
|
161
167
|
self._add_to_buffer(packet)
|
|
162
168
|
|
|
163
169
|
# Try to output packets in sequence order
|
|
164
|
-
|
|
170
|
+
samples, gaps = self._try_output()
|
|
171
|
+
if zero_gap is not None:
|
|
172
|
+
gaps = list(gaps) + [zero_gap]
|
|
173
|
+
return samples, gaps
|
|
174
|
+
|
|
175
|
+
def _detect_zero_block(self, packet: RTPPacket) -> Optional[GapEvent]:
|
|
176
|
+
"""A packet of nothing but exact zeros is radiod's drop marker.
|
|
177
|
+
|
|
178
|
+
Cheap on the hot path: real samples almost always have a
|
|
179
|
+
non-zero first element, so the full scan is only reached for
|
|
180
|
+
packets that are already suspect.
|
|
181
|
+
"""
|
|
182
|
+
samples = packet.samples
|
|
183
|
+
if samples is None or len(samples) == 0:
|
|
184
|
+
return None
|
|
185
|
+
if samples[0] != 0 or samples.any():
|
|
186
|
+
return None
|
|
187
|
+
n = len(samples)
|
|
188
|
+
self.stats.samples_filled += n
|
|
189
|
+
logger.warning(
|
|
190
|
+
f"radiod block drop: {n} zero samples "
|
|
191
|
+
f"({n / self.sample_rate * 1000:.1f} ms) at seq={packet.sequence} "
|
|
192
|
+
f"-- synthetic, not received signal"
|
|
193
|
+
)
|
|
194
|
+
return GapEvent(
|
|
195
|
+
source=GapSource.RADIOD_BLOCK_DROP,
|
|
196
|
+
position_samples=self.cumulative_samples,
|
|
197
|
+
duration_samples=n,
|
|
198
|
+
timestamp_utc=datetime.now(timezone.utc).isoformat(),
|
|
199
|
+
packets_affected=1,
|
|
200
|
+
)
|
|
165
201
|
|
|
166
202
|
def _initialize(self, packet: RTPPacket):
|
|
167
203
|
"""Initialize sequencer with first packet"""
|
ka9q/rtp_recorder.py
CHANGED
|
@@ -16,6 +16,7 @@ import socket
|
|
|
16
16
|
import struct
|
|
17
17
|
import logging
|
|
18
18
|
import time
|
|
19
|
+
import warnings
|
|
19
20
|
from enum import Enum
|
|
20
21
|
from typing import Optional, Callable, NamedTuple, Dict, Any
|
|
21
22
|
from dataclasses import dataclass
|
|
@@ -242,9 +243,24 @@ def rtp_to_utc(
|
|
|
242
243
|
return wall_time_ns / BILLION
|
|
243
244
|
|
|
244
245
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
246
|
+
def rtp_to_wallclock(
|
|
247
|
+
rtp_timestamp: int,
|
|
248
|
+
channel: ChannelInfo,
|
|
249
|
+
wallclock_hint_sec: Optional[float] = None,
|
|
250
|
+
) -> Optional[float]:
|
|
251
|
+
"""Deprecated alias for :func:`rtp_to_utc` (renamed 2026-06-27).
|
|
252
|
+
|
|
253
|
+
Identical signature and behavior. Emits ``DeprecationWarning`` on
|
|
254
|
+
every call (audit finding F16) — the in-tree sigmond clients migrated
|
|
255
|
+
2026-08; this is the visible signal for any remaining callers.
|
|
256
|
+
"""
|
|
257
|
+
warnings.warn(
|
|
258
|
+
"ka9q rtp_to_wallclock() is deprecated; use rtp_to_utc() "
|
|
259
|
+
"(same signature and behavior, renamed 2026-06-27)",
|
|
260
|
+
DeprecationWarning,
|
|
261
|
+
stacklevel=2,
|
|
262
|
+
)
|
|
263
|
+
return rtp_to_utc(rtp_timestamp, channel, wallclock_hint_sec)
|
|
248
264
|
|
|
249
265
|
|
|
250
266
|
class RTPRecorder:
|
|
@@ -479,7 +495,7 @@ class RTPRecorder:
|
|
|
479
495
|
payload = data[header_len:]
|
|
480
496
|
|
|
481
497
|
# Compute wall-clock time
|
|
482
|
-
wallclock =
|
|
498
|
+
wallclock = rtp_to_utc(header.timestamp, self.channel)
|
|
483
499
|
|
|
484
500
|
# Call packet callback
|
|
485
501
|
if self.on_packet:
|
ka9q/status_listener.py
CHANGED
|
@@ -475,6 +475,15 @@ class StatusListener:
|
|
|
475
475
|
if enc is not None:
|
|
476
476
|
ci.encoding = enc
|
|
477
477
|
|
|
478
|
+
# Cumulative per-channel output block drops. Refreshed here so a
|
|
479
|
+
# long-running client can watch radiod's own loss counter without
|
|
480
|
+
# opening a second status socket -- differencing it across two
|
|
481
|
+
# reads is the honest measurement; the level alone is meaningless
|
|
482
|
+
# because it accumulates from radiod start.
|
|
483
|
+
drops = status.get("filter_drops")
|
|
484
|
+
if drops is not None:
|
|
485
|
+
ci.filter_drops = drops
|
|
486
|
+
|
|
478
487
|
self.stats.updates_applied += 1
|
|
479
488
|
|
|
480
489
|
for cb in per_ssrc_callbacks:
|
ka9q/stream.py
CHANGED
|
@@ -36,9 +36,11 @@ import numpy as np
|
|
|
36
36
|
from datetime import datetime, timezone
|
|
37
37
|
from typing import Optional, Callable, List
|
|
38
38
|
|
|
39
|
+
from .types import DemodType
|
|
40
|
+
|
|
39
41
|
from ._multicast import join_multicast_all_interfaces
|
|
40
42
|
from .discovery import ChannelInfo
|
|
41
|
-
from .rtp_recorder import RTPHeader, parse_rtp_header,
|
|
43
|
+
from .rtp_recorder import RTPHeader, parse_rtp_header, rtp_to_utc
|
|
42
44
|
from .resequencer import PacketResequencer, RTPPacket
|
|
43
45
|
from .stream_quality import GapSource, GapEvent, StreamQuality
|
|
44
46
|
from .types import Encoding
|
|
@@ -181,6 +183,46 @@ def _decode_to_float32(payload: bytes, encoding: int, *, is_iq: bool = False) ->
|
|
|
181
183
|
return np.frombuffer(payload, dtype='<f4').astype(np.float32, copy=False)
|
|
182
184
|
|
|
183
185
|
|
|
186
|
+
def _derive_is_iq(demod_type, output_channels, preset) -> bool:
|
|
187
|
+
"""Does this channel's payload carry complex samples?
|
|
188
|
+
|
|
189
|
+
Decided from the parameter vector radiod treats as definitive, and only
|
|
190
|
+
from the preset label when the vector is absent.
|
|
191
|
+
|
|
192
|
+
Phil Karn, 2026-09-21: "the individual parameters [are] definitive, with
|
|
193
|
+
a preset just being a convenient shorthand for loading a specific
|
|
194
|
+
predefined set of them into the channel." A preset can be tweaked out of
|
|
195
|
+
truth -- send PRESET USB, retune the filters to -3000,-50, and you have
|
|
196
|
+
the lower sideband while the label still reads "usb" -- and from the
|
|
197
|
+
nopreset branch onward radiod may not echo it at all. Reading the label
|
|
198
|
+
then yields 'unknown', flips this flag false, and every complex stream is
|
|
199
|
+
parsed as audio at half the samples per packet, with nothing raised and
|
|
200
|
+
nothing logged.
|
|
201
|
+
|
|
202
|
+
⛔ Two output channels does NOT mean complex on its own. ka9q-radio
|
|
203
|
+
docs/table.csv: OUTPUT_CHANNELS is "1 or 2 in Linear and WFM, 1 in FM",
|
|
204
|
+
so a WFM channel with two of them is carrying stereo AUDIO. Only under
|
|
205
|
+
the linear demodulator do two channels mean I and Q.
|
|
206
|
+
"""
|
|
207
|
+
if demod_type is not None:
|
|
208
|
+
if demod_type in (int(DemodType.SPECT_DEMOD),
|
|
209
|
+
int(DemodType.SPECT2_DEMOD)):
|
|
210
|
+
# Unchanged from the preset-based behaviour. Whether spectrum
|
|
211
|
+
# framing truly matches IQ is a separate question; do not answer
|
|
212
|
+
# it silently while fixing something else.
|
|
213
|
+
return True
|
|
214
|
+
if demod_type == int(DemodType.LINEAR_DEMOD):
|
|
215
|
+
if output_channels is not None:
|
|
216
|
+
return output_channels == 2
|
|
217
|
+
# Linear with no channel count: fall through to the label rather
|
|
218
|
+
# than guess, since linear covers both IQ and every real mode.
|
|
219
|
+
else:
|
|
220
|
+
return False # FM, WFM -- never complex, whatever the count
|
|
221
|
+
|
|
222
|
+
label = (preset or "").lower()
|
|
223
|
+
return label in ('iq', 'spectrum')
|
|
224
|
+
|
|
225
|
+
|
|
184
226
|
class OpusDecoder:
|
|
185
227
|
"""Optional Opus → float32 decoder for radiod OPUS / OPUS_VOIP streams.
|
|
186
228
|
|
|
@@ -263,6 +305,7 @@ class RadiodStream:
|
|
|
263
305
|
samples_per_packet: int = 320,
|
|
264
306
|
resequence_buffer_size: int = 64,
|
|
265
307
|
deliver_interval_packets: int = 10,
|
|
308
|
+
raw_payloads: bool = False,
|
|
266
309
|
):
|
|
267
310
|
"""
|
|
268
311
|
Initialize RadiodStream.
|
|
@@ -273,11 +316,31 @@ class RadiodStream:
|
|
|
273
316
|
samples_per_packet: Expected samples per RTP packet (320 @ 16kHz)
|
|
274
317
|
resequence_buffer_size: Packets to buffer for resequencing (64 = ~2s)
|
|
275
318
|
deliver_interval_packets: Deliver to callback every N packets (batching)
|
|
319
|
+
raw_payloads: Deliver undecoded RTP payloads instead of samples.
|
|
320
|
+
``on_samples`` then receives a ``List[bytes]`` — one entry per
|
|
321
|
+
RTP packet, in arrival order — rather than an ``np.ndarray``.
|
|
322
|
+
|
|
323
|
+
This is the transport mode for the *framed* encodings that
|
|
324
|
+
``parse_rtp_samples()`` deliberately does not decode: OPUS,
|
|
325
|
+
OPUS_VOIP and AX25. Without it those payloads are dropped
|
|
326
|
+
(``_parse_samples`` returns None) and the callback never fires.
|
|
327
|
+
|
|
328
|
+
Use it when the consumer wants the codec frames themselves —
|
|
329
|
+
forwarding Opus to a browser's WebCodecs decoder, or writing
|
|
330
|
+
an Ogg/AX25 file — rather than PCM. To get PCM from an Opus
|
|
331
|
+
channel instead, decode the frames with :class:`OpusDecoder`.
|
|
332
|
+
|
|
333
|
+
The resequencer is bypassed: a codec frame is opaque, so it
|
|
334
|
+
can be neither concatenated nor zero-filled, and gap
|
|
335
|
+
concealment belongs to the decoder (Opus has built-in PLC).
|
|
336
|
+
Loss/reorder therefore surface to the consumer, which for a
|
|
337
|
+
sequenced transport like TCP/WebSocket is exactly right.
|
|
276
338
|
"""
|
|
277
339
|
self.channel = channel
|
|
278
340
|
self.on_samples = on_samples
|
|
279
341
|
self.samples_per_packet = samples_per_packet
|
|
280
342
|
self.deliver_interval_packets = deliver_interval_packets
|
|
343
|
+
self.raw_payloads = raw_payloads
|
|
281
344
|
|
|
282
345
|
# Resequencer
|
|
283
346
|
self.resequencer = PacketResequencer(
|
|
@@ -289,7 +352,8 @@ class RadiodStream:
|
|
|
289
352
|
# Quality tracking
|
|
290
353
|
self.quality = StreamQuality()
|
|
291
354
|
|
|
292
|
-
# Sample accumulator for batched delivery
|
|
355
|
+
# Sample accumulator for batched delivery. In raw_payloads mode the
|
|
356
|
+
# entries are `bytes` (one RTP payload each) instead of arrays.
|
|
293
357
|
self._sample_buffer: List[np.ndarray] = []
|
|
294
358
|
self._gap_buffer: List[GapEvent] = []
|
|
295
359
|
self._pending_rtp_start = None
|
|
@@ -305,10 +369,14 @@ class RadiodStream:
|
|
|
305
369
|
self._max_reconnect_backoff = 60.0 # Max backoff
|
|
306
370
|
self._consecutive_errors = 0
|
|
307
371
|
|
|
308
|
-
# Detect payload format from channel
|
|
372
|
+
# Detect payload format from the channel's PARAMETERS, not its label.
|
|
309
373
|
# IQ mode: complex64 (interleaved float32 I/Q)
|
|
310
374
|
# Audio modes: float32 (mono or stereo)
|
|
311
|
-
self._is_iq =
|
|
375
|
+
self._is_iq = _derive_is_iq(
|
|
376
|
+
getattr(channel, 'demod_type', None),
|
|
377
|
+
getattr(channel, 'output_channels', None),
|
|
378
|
+
getattr(channel, 'preset', None),
|
|
379
|
+
)
|
|
312
380
|
|
|
313
381
|
# Payload samples differ from RTP timestamp increment in IQ mode
|
|
314
382
|
# IQ: 160 complex samples per packet, but timestamp advances by 320
|
|
@@ -359,12 +427,17 @@ class RadiodStream:
|
|
|
359
427
|
self._thread.join(timeout=5.0)
|
|
360
428
|
self._thread = None
|
|
361
429
|
|
|
362
|
-
# Flush resequencer
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
self._gap_buffer.extend(final_gaps)
|
|
430
|
+
# Flush resequencer. In raw_payloads mode it never ran, and its
|
|
431
|
+
# ndarray output must not be mixed into a buffer of codec frames —
|
|
432
|
+
# just deliver whatever frames are still pending.
|
|
433
|
+
if self.raw_payloads:
|
|
367
434
|
self._deliver_samples()
|
|
435
|
+
else:
|
|
436
|
+
final_samples, final_gaps = self.resequencer.flush()
|
|
437
|
+
if len(final_samples) > 0 or final_gaps:
|
|
438
|
+
self._sample_buffer.append(final_samples)
|
|
439
|
+
self._gap_buffer.extend(final_gaps)
|
|
440
|
+
self._deliver_samples()
|
|
368
441
|
|
|
369
442
|
logger.info(
|
|
370
443
|
f"RadiodStream stopped. Completeness: {self.quality.completeness_pct:.1f}%, "
|
|
@@ -581,13 +654,28 @@ class RadiodStream:
|
|
|
581
654
|
self._record_empty_payload(header)
|
|
582
655
|
return
|
|
583
656
|
|
|
657
|
+
# Raw-payload mode: the payload is an opaque codec frame. Buffer it
|
|
658
|
+
# verbatim and skip both the sample parser and the resequencer, which
|
|
659
|
+
# can only operate on numeric samples.
|
|
660
|
+
if self.raw_payloads:
|
|
661
|
+
self._sample_buffer.append(payload)
|
|
662
|
+
self._packets_since_delivery += 1
|
|
663
|
+
if self._packets_since_delivery >= self.deliver_interval_packets:
|
|
664
|
+
self._deliver_samples()
|
|
665
|
+
wallclock = rtp_to_utc(header.timestamp, self.channel)
|
|
666
|
+
if wallclock:
|
|
667
|
+
self.quality.last_packet_utc = datetime.fromtimestamp(
|
|
668
|
+
wallclock, tz=timezone.utc
|
|
669
|
+
).isoformat()
|
|
670
|
+
return
|
|
671
|
+
|
|
584
672
|
# Parse samples from payload
|
|
585
673
|
samples = self._parse_samples(payload)
|
|
586
674
|
if samples is None:
|
|
587
675
|
return
|
|
588
676
|
|
|
589
677
|
# Get wallclock time
|
|
590
|
-
wallclock =
|
|
678
|
+
wallclock = rtp_to_utc(header.timestamp, self.channel)
|
|
591
679
|
|
|
592
680
|
# Create packet for resequencer
|
|
593
681
|
packet = RTPPacket(
|
|
@@ -649,28 +737,40 @@ class RadiodStream:
|
|
|
649
737
|
if not self._sample_buffer:
|
|
650
738
|
return
|
|
651
739
|
|
|
652
|
-
# Combine samples
|
|
653
|
-
|
|
740
|
+
# Combine samples. Opaque codec frames stay a list of bytes — they
|
|
741
|
+
# have no numeric representation to concatenate — and their "sample"
|
|
742
|
+
# count is a frame count.
|
|
743
|
+
if self.raw_payloads:
|
|
744
|
+
samples = list(self._sample_buffer)
|
|
745
|
+
n_units = len(samples)
|
|
746
|
+
else:
|
|
747
|
+
samples = np.concatenate(self._sample_buffer)
|
|
748
|
+
n_units = len(samples)
|
|
654
749
|
gaps = list(self._gap_buffer)
|
|
655
750
|
|
|
656
751
|
# Update quality for this batch
|
|
657
752
|
batch_start = self.quality.total_samples_delivered
|
|
658
753
|
self.quality.batch_start_sample = batch_start
|
|
659
754
|
self.quality.delivered_rtp_start = self._pending_rtp_start
|
|
660
|
-
self.quality.batch_samples_delivered =
|
|
755
|
+
self.quality.batch_samples_delivered = n_units
|
|
661
756
|
self.quality.batch_gaps = gaps
|
|
662
|
-
self.quality.total_samples_delivered +=
|
|
757
|
+
self.quality.total_samples_delivered += n_units
|
|
663
758
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
759
|
+
if self.raw_payloads:
|
|
760
|
+
# One delivered unit per RTP packet, so "expected" is the packet
|
|
761
|
+
# count. Resequencer stats stay zero: it never ran.
|
|
762
|
+
self.quality.total_samples_expected = self.quality.rtp_packets_received
|
|
763
|
+
else:
|
|
764
|
+
# Update expected samples (based on actual payload samples per packet)
|
|
765
|
+
self.quality.total_samples_expected = (
|
|
766
|
+
self.quality.rtp_packets_received * self._payload_samples_per_packet
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
# Update RTP loss stats from resequencer
|
|
770
|
+
reseq_stats = self.resequencer.get_stats()
|
|
771
|
+
self.quality.rtp_packets_lost = reseq_stats.get('gaps_detected', 0)
|
|
772
|
+
self.quality.rtp_packets_resequenced = reseq_stats.get('packets_resequenced', 0)
|
|
773
|
+
self.quality.rtp_packets_duplicate = reseq_stats.get('packets_duplicate', 0)
|
|
674
774
|
|
|
675
775
|
# Clear buffers
|
|
676
776
|
self._sample_buffer = []
|
ka9q/stream_quality.py
CHANGED
|
@@ -32,6 +32,19 @@ class GapSource(Enum):
|
|
|
32
32
|
|
|
33
33
|
RESEQUENCE_TIMEOUT = "resequence_timeout"
|
|
34
34
|
"""Packet arrived after resequence window expired"""
|
|
35
|
+
|
|
36
|
+
RADIOD_BLOCK_DROP = "radiod_block_drop"
|
|
37
|
+
"""radiod dropped a filter output block and emitted zeros in its place.
|
|
38
|
+
|
|
39
|
+
Since ka9q-radio 55d9048d (2026.08.15-1-trixie1) a lapped output
|
|
40
|
+
block is counted and zero-filled rather than skipped, which keeps
|
|
41
|
+
sample counts honest but leaves RTP timestamps CONTIGUOUS -- so no
|
|
42
|
+
timestamp gap exists for the resequencer to find. The zeros
|
|
43
|
+
themselves are the signal: a solid block of exact IEEE-754 zeros
|
|
44
|
+
across every sample does not occur in real RF, where ~144 dB of
|
|
45
|
+
instantaneous float dynamic range means noise always occupies the
|
|
46
|
+
low bits. Distinct from NETWORK_LOSS: the packets DID arrive, and
|
|
47
|
+
the samples they carry are synthetic rather than missing."""
|
|
35
48
|
|
|
36
49
|
EMPTY_PAYLOAD = "empty_payload"
|
|
37
50
|
"""RTP packet received but payload was empty/zeros"""
|
ka9q/types.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
ka9q-radio protocol types and constants
|
|
3
3
|
|
|
4
4
|
Auto-generated by scripts/sync_types.py from ka9q-radio C headers.
|
|
5
|
-
Validated against ka9q-radio commit:
|
|
5
|
+
Validated against ka9q-radio commit: 401992cdecaa
|
|
6
6
|
|
|
7
7
|
DO NOT EDIT MANUALLY — run: python scripts/sync_types.py --apply
|
|
8
8
|
"""
|
|
@@ -158,9 +158,9 @@ class Encoding:
|
|
|
158
158
|
F16 = F16LE
|
|
159
159
|
|
|
160
160
|
|
|
161
|
-
# Demodulator types — auto-generated from ka9q-radio
|
|
161
|
+
# Demodulator types — auto-generated from ka9q-radio (see ENUM_SEARCH_PATHS)
|
|
162
162
|
class DemodType:
|
|
163
|
-
"""Demodulator types — values must match ka9q-radio
|
|
163
|
+
"""Demodulator types — values must match ka9q-radio enum demod_type (status.h since 2026-09-19)"""
|
|
164
164
|
|
|
165
165
|
INVALID_DEMOD = -1 # used as sentinel
|
|
166
166
|
LINEAR_DEMOD = 0 # Linear demodulation, i.e., everything else: SSB, CW, DSB, CAM, IQ
|
|
@@ -176,6 +176,7 @@ class DemodType:
|
|
|
176
176
|
class WindowType:
|
|
177
177
|
"""FFT window types — values must match ka9q-radio/src/window.h enum window_type"""
|
|
178
178
|
|
|
179
|
+
INVALID_WINDOW = -1
|
|
179
180
|
KAISER_WINDOW = 0
|
|
180
181
|
RECT_WINDOW = 1 # essentially kaiser with beta = 0
|
|
181
182
|
BLACKMAN_WINDOW = 2
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
ka9q/__init__.py,sha256=P2glBiqPlHhJO7YN8TAMMg9SBtBcwtql_AF5MZxOBE0,5483
|
|
2
|
+
ka9q/_multicast.py,sha256=U6pCiH1ruy3i5yv7B_rRhkakokwnetu1Nd3009nAI5w,3343
|
|
3
|
+
ka9q/addressing.py,sha256=GaHLeTwRpsmJawRAROoj-AqALcVkqO6Jq4_th9u7fjE,2681
|
|
4
|
+
ka9q/cli.py,sha256=nfU4hqScqjJtuWBBraPxUb-37cvjY_qtZkNIxr9GzRU,15040
|
|
5
|
+
ka9q/compat.py,sha256=uiU-wkQ1p88fcfPYRdqU1QxHiunmQPIhM5CXkW_5GMs,436
|
|
6
|
+
ka9q/control.py,sha256=twKMbLjXmkAp2P2Pi51QKCj1W8zrv4wE-4RYSSeL7Zk,156426
|
|
7
|
+
ka9q/discovery.py,sha256=-jGcDczuwEQmmJRogc2hR22PUKT2qxWkPgy_ffd9Cdk,28615
|
|
8
|
+
ka9q/exceptions.py,sha256=_P8rok_tTmRMu-lLRhS95kv8FRK5PEY80_6jIux2mWM,474
|
|
9
|
+
ka9q/managed_stream.py,sha256=gHvGcQgJF5FCOzVp7V6vcIp75NLGnOHgClGR24i9_No,18842
|
|
10
|
+
ka9q/monitor.py,sha256=jw4UqoPpmKhX_xImQ31AvOmET6rsetCoefd7UBB9Tpo,5262
|
|
11
|
+
ka9q/multi_stream.py,sha256=0ztgMVPRh0bZTFbsCTdeejWe3SRJYaF8Pha67MV98t0,31774
|
|
12
|
+
ka9q/pps_calibrator.py,sha256=yaVEzr1zmXXbPNwnISGoqMq2HcuUWmVLLqYCfjj8avk,11656
|
|
13
|
+
ka9q/recovery_ladder.py,sha256=033U8r3E-ncS_hubKMcQHv5SZAot0gX5pQS5srA9y5M,5877
|
|
14
|
+
ka9q/resequencer.py,sha256=m_OWattFpqIugSqWr6aadsmUeoJIPkfINKfPi6gp_sg,17300
|
|
15
|
+
ka9q/rtp_recorder.py,sha256=-YpG9mleoGjEd5voPSxnGcdVsFvyxSgOJ8f7ZaXGcT0,23210
|
|
16
|
+
ka9q/slot_clock.py,sha256=9OuE1qMHJ5YBHfNPtQSguj_KHgkU9iAXfhOLEk-1B78,13468
|
|
17
|
+
ka9q/spectrum_stream.py,sha256=utMhpH8hkkwOSD1WpKb4r0YHm3isDF0TvYMq-jJJFwY,11458
|
|
18
|
+
ka9q/status.py,sha256=Tep1FW4bgg80v0qn18Biulg6g0S1F2LmbrDXVu2HXSg,28224
|
|
19
|
+
ka9q/status_listener.py,sha256=20l9aHii263myYlO8mmSgI_tUcchUwLwgh69Dx-1UQY,20045
|
|
20
|
+
ka9q/stream.py,sha256=s1Xl4Njia9DwXxsSF2XSon1plFjJoDXXXCGeLZdF734,33111
|
|
21
|
+
ka9q/stream_quality.py,sha256=YrKa07PzDi4xeF5tubwpDvSTs5eHu0G7mOa12iIINrs,8222
|
|
22
|
+
ka9q/tui.py,sha256=ryL5CxfYkVzXhsCy1hw9GP3ya8hDbZTccfkc_uJtrDQ,27182
|
|
23
|
+
ka9q/types.py,sha256=woWL_ShotZnYUYsR4RBL2wu13S1MTkkJC9_-9IVh14g,7514
|
|
24
|
+
ka9q/utils.py,sha256=fFR_sYIpeDyPaFlXzN6jCiGhYlssvrMMVx4z6vyhqcc,7107
|
|
25
|
+
ka9q_python-3.26.0.dist-info/licenses/LICENSE,sha256=mjz-l5TVsO9j7Jqt1gfLvTWtZK_HL9sZRRpsHaq4Qv8,1074
|
|
26
|
+
ka9q_python-3.26.0.dist-info/METADATA,sha256=6Bj7pdfXVJebxW24J-3bInOyKsh32qjMzz16_hirSq0,14637
|
|
27
|
+
ka9q_python-3.26.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
28
|
+
ka9q_python-3.26.0.dist-info/entry_points.txt,sha256=Wt5Q4OMUKHaf4KdsZXWsjSBpbHVuTtt3pypVIHi7eRk,39
|
|
29
|
+
ka9q_python-3.26.0.dist-info/top_level.txt,sha256=FUTLZDtQpJGenGPPlN34E5QpQi_oaiLB6LuzgHblBNE,5
|
|
30
|
+
ka9q_python-3.26.0.dist-info/RECORD,,
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
ka9q/__init__.py,sha256=VNGzyyvs0Lz9MG34Kts04BrOvzZS9G7Z4R2La3XNPlE,5364
|
|
2
|
-
ka9q/_multicast.py,sha256=U6pCiH1ruy3i5yv7B_rRhkakokwnetu1Nd3009nAI5w,3343
|
|
3
|
-
ka9q/addressing.py,sha256=GaHLeTwRpsmJawRAROoj-AqALcVkqO6Jq4_th9u7fjE,2681
|
|
4
|
-
ka9q/cli.py,sha256=nfU4hqScqjJtuWBBraPxUb-37cvjY_qtZkNIxr9GzRU,15040
|
|
5
|
-
ka9q/compat.py,sha256=YimJ1SzJOf7_TiJccun_aq_CgJ0Xtnexq_XsXY5ZHoQ,436
|
|
6
|
-
ka9q/control.py,sha256=fVvLMzSx3Tl6ZdfCal257TzCl5fGInwkwJ8BCpmlFu4,146362
|
|
7
|
-
ka9q/discovery.py,sha256=JjA-zKqHmib8W6KOMpCMX30IFoWrWiDj1ckbkzR-jqA,27131
|
|
8
|
-
ka9q/exceptions.py,sha256=_P8rok_tTmRMu-lLRhS95kv8FRK5PEY80_6jIux2mWM,474
|
|
9
|
-
ka9q/managed_stream.py,sha256=I3P7C74ssDKebfCzU4hD4HbnfYbJOeC39c-VaZqLlSU,18404
|
|
10
|
-
ka9q/monitor.py,sha256=jw4UqoPpmKhX_xImQ31AvOmET6rsetCoefd7UBB9Tpo,5262
|
|
11
|
-
ka9q/multi_stream.py,sha256=ConVJBgi_snqzG_VpbNh6dmaNJhQWb4GiRtmX3S-EK8,30449
|
|
12
|
-
ka9q/pps_calibrator.py,sha256=yaVEzr1zmXXbPNwnISGoqMq2HcuUWmVLLqYCfjj8avk,11656
|
|
13
|
-
ka9q/resequencer.py,sha256=EgYAj_s0sWr8gS2EG-8Rz5Jw7HYla82sPytQKqwrses,15797
|
|
14
|
-
ka9q/rtp_recorder.py,sha256=UBxurokkU9pECK2sfWw9I4iy7KquDxpkWb9XW3cm2zE,22667
|
|
15
|
-
ka9q/slot_clock.py,sha256=9OuE1qMHJ5YBHfNPtQSguj_KHgkU9iAXfhOLEk-1B78,13468
|
|
16
|
-
ka9q/spectrum_stream.py,sha256=utMhpH8hkkwOSD1WpKb4r0YHm3isDF0TvYMq-jJJFwY,11458
|
|
17
|
-
ka9q/status.py,sha256=Tep1FW4bgg80v0qn18Biulg6g0S1F2LmbrDXVu2HXSg,28224
|
|
18
|
-
ka9q/status_listener.py,sha256=oJ_aGhgDKea_j1-rbZFBBFFnw4FQW3CpCESYOrFev_0,19590
|
|
19
|
-
ka9q/stream.py,sha256=VnFQSjVD_WLobH8O9nl1vqqiaWzJdpP-XQc7vy5Ji4w,27905
|
|
20
|
-
ka9q/stream_quality.py,sha256=cuI_wnAuh3Dh5wpkI3ujXgbbh7xobdEgtYuZM1cKnOY,7482
|
|
21
|
-
ka9q/tui.py,sha256=ryL5CxfYkVzXhsCy1hw9GP3ya8hDbZTccfkc_uJtrDQ,27182
|
|
22
|
-
ka9q/types.py,sha256=E4y9wtLABNloL63y6aNsjvOpj_kdrUPiRKjAQcXzacQ,7462
|
|
23
|
-
ka9q/utils.py,sha256=fFR_sYIpeDyPaFlXzN6jCiGhYlssvrMMVx4z6vyhqcc,7107
|
|
24
|
-
ka9q_python-3.22.0.dist-info/licenses/LICENSE,sha256=mjz-l5TVsO9j7Jqt1gfLvTWtZK_HL9sZRRpsHaq4Qv8,1074
|
|
25
|
-
ka9q_python-3.22.0.dist-info/METADATA,sha256=hmTXtuhSSr2KJR1alVBE8M19QVLwi3unHJToVN5FnXU,14637
|
|
26
|
-
ka9q_python-3.22.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
27
|
-
ka9q_python-3.22.0.dist-info/entry_points.txt,sha256=Wt5Q4OMUKHaf4KdsZXWsjSBpbHVuTtt3pypVIHi7eRk,39
|
|
28
|
-
ka9q_python-3.22.0.dist-info/top_level.txt,sha256=FUTLZDtQpJGenGPPlN34E5QpQi_oaiLB6LuzgHblBNE,5
|
|
29
|
-
ka9q_python-3.22.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|