ka9q-python 3.14.0__tar.gz → 3.14.2__tar.gz
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_python-3.14.0 → ka9q_python-3.14.2}/CHANGELOG.md +52 -0
- {ka9q_python-3.14.0/ka9q_python.egg-info → ka9q_python-3.14.2}/PKG-INFO +1 -1
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/docs/API_REFERENCE.md +16 -1
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/__init__.py +1 -1
- ka9q_python-3.14.2/ka9q/_multicast.py +90 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/multi_stream.py +22 -5
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/rtp_recorder.py +22 -6
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/stream.py +26 -13
- {ka9q_python-3.14.0 → ka9q_python-3.14.2/ka9q_python.egg-info}/PKG-INFO +1 -1
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q_python.egg-info/SOURCES.txt +2 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/pyproject.toml +1 -1
- ka9q_python-3.14.2/tests/test_multicast_helpers.py +135 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_rtp_recorder.py +64 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/LICENSE +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/MANIFEST.in +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/README.md +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/docs/ARCHITECTURE.md +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/docs/CLI_GUIDE.md +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/docs/GETTING_STARTED.md +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/docs/INSTALLATION.md +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/docs/MULTI_STREAM.md +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/docs/RECIPES.md +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/docs/RTP_TIMING_SUPPORT.md +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/docs/SECURITY.md +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/docs/TESTING_GUIDE.md +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/docs/TUI_GUIDE.md +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/advanced_features_demo.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/channel_cleanup_example.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/codar_oceanography.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/diagnostics/diagnose_packets.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/diagnostics/repro_utc_bug.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/discover_example.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/grape_integration_example.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/hf_band_scanner.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/multi_stream_smoke.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/rtp_recorder_example.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/simple_am_radio.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/spectrum_example.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/stream_example.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/superdarn_recorder.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/test_channel_operations.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/test_improvements.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/test_timing_fields.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/tune.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/tune_example.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/addressing.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/cli.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/compat.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/control.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/discovery.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/exceptions.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/managed_stream.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/monitor.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/pps_calibrator.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/resequencer.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/spectrum_stream.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/status.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/stream_quality.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/tui.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/types.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/utils.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q_python.egg-info/dependency_links.txt +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q_python.egg-info/entry_points.txt +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q_python.egg-info/requires.txt +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q_python.egg-info/top_level.txt +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q_radio_compat +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/scripts/check_upstream_drift.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/scripts/sync_types.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/setup.cfg +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/setup.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/__init__.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/conftest.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_addressing.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_channel_verification.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_client_id_destination.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_create_split_encoding.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_decode_description.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_decode_functions.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_encode_functions.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_encode_socket.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_ensure_channel_encoding.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_filter_edges.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_integration.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_iq_20khz_f32.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_lifetime.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_listen_multicast.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_managed_stream_recovery.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_monitor.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_multihomed.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_native_discovery.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_performance_fixes.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_protocol_compat.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_remove_channel.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_security_features.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_spectrum.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_ssrc_dest_unit.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_ssrc_encoding_unit.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_ssrc_radiod_host_unit.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_status_decoder.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_ttl_warning.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_tune.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_tune_cli.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_tune_debug.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_tune_live.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_tune_method.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_upstream_drift.py +0 -0
|
@@ -1,5 +1,57 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.14.2] - 2026-05-14
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`rtp_to_wallclock()` gains optional `wallclock_hint_sec` parameter.**
|
|
8
|
+
When supplied, the function uses the hint to disambiguate the 32-bit
|
|
9
|
+
RTP wrap epoch instead of calling `time.time()`. Authority-aware
|
|
10
|
+
callers (those with access to an hf-timestd `rtp_to_utc_offset_ns`)
|
|
11
|
+
can now keep the labeling path off the chrony-disciplined system
|
|
12
|
+
clock, per the METROLOGY.md §4.5 RTP-reference invariant. The hint
|
|
13
|
+
only needs ±period/2 accuracy (≥6 hours at typical sample rates).
|
|
14
|
+
When omitted, the function falls back to `time.time()` for backward
|
|
15
|
+
compatibility — existing callers are unaffected.
|
|
16
|
+
|
|
17
|
+
### Tests
|
|
18
|
+
|
|
19
|
+
- 3 new in `tests/test_rtp_recorder.py`: hint bypasses `time.time()`
|
|
20
|
+
entirely; hint at a different wrap epoch correctly overrides system
|
|
21
|
+
clock; default path still consults `time.time()` when no hint is
|
|
22
|
+
given.
|
|
23
|
+
|
|
24
|
+
## [3.14.1] - 2026-05-14
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **`RadiodStream`: multi-interface multicast join (parity with
|
|
29
|
+
`MultiStream`).** Extends Rob Robinett's `e3acb6a` fix from
|
|
30
|
+
`multi_stream.py` to `stream.py` so the per-stream `RadiodStream`
|
|
31
|
+
socket also joins on every local IPv4 interface instead of the
|
|
32
|
+
single one the kernel picks via `INADDR_ANY`. Without this, a
|
|
33
|
+
`RadiodStream` consumer of a co-located radiod with `ttl=0` (or any
|
|
34
|
+
output that arrives on a non-default-route interface) silently
|
|
35
|
+
received zero packets. Most visible to clients that use the
|
|
36
|
+
per-stream API directly (codar-sounder, hf-timestd's legacy T6
|
|
37
|
+
path) rather than the shared `MultiStream` socket.
|
|
38
|
+
|
|
39
|
+
### Refactor
|
|
40
|
+
|
|
41
|
+
- The helper that enumerates IPv4 interfaces and calls
|
|
42
|
+
`IP_ADD_MEMBERSHIP` on each is factored into a new package-private
|
|
43
|
+
`ka9q/_multicast.py` module. Both `multi_stream.py` and `stream.py`
|
|
44
|
+
now import from it. Same behaviour, one implementation.
|
|
45
|
+
`rtp_recorder.RtpRecorder` still uses the single-interface join —
|
|
46
|
+
separate fix.
|
|
47
|
+
|
|
48
|
+
### Tests
|
|
49
|
+
|
|
50
|
+
- 8 new in `tests/test_multicast_helpers.py`: enumerator behaviour,
|
|
51
|
+
per-interface join failure handling (one interface failing doesn't
|
|
52
|
+
abort the loop), empty-enumeration safety, both stream classes
|
|
53
|
+
pulling the helper from the shared module.
|
|
54
|
+
|
|
3
55
|
## [3.14.0] - 2026-05-13
|
|
4
56
|
|
|
5
57
|
### Added
|
|
@@ -727,7 +727,11 @@ Dataclass: `packets_received`, `packets_dropped`,
|
|
|
727
727
|
|
|
728
728
|
```python
|
|
729
729
|
parse_rtp_header(data: bytes) -> Optional[RTPHeader]
|
|
730
|
-
rtp_to_wallclock(
|
|
730
|
+
rtp_to_wallclock(
|
|
731
|
+
rtp_timestamp: int,
|
|
732
|
+
channel: ChannelInfo,
|
|
733
|
+
wallclock_hint_sec: Optional[float] = None,
|
|
734
|
+
) -> Optional[float]
|
|
731
735
|
```
|
|
732
736
|
|
|
733
737
|
`rtp_to_wallclock()` returns `None` unless `channel.gps_time` and
|
|
@@ -735,6 +739,17 @@ rtp_to_wallclock(rtp_timestamp: int, channel: ChannelInfo) -> Optional[float]
|
|
|
735
739
|
`channel.chain_delay_correction_ns` is set (see below), it is
|
|
736
740
|
subtracted from the computed wallclock.
|
|
737
741
|
|
|
742
|
+
The `wallclock_hint_sec` parameter (v3.14.2+) lets callers supply an
|
|
743
|
+
approximate UTC reference for 32-bit RTP wrap-epoch disambiguation
|
|
744
|
+
without falling back to `time.time()`. Authority-aware callers
|
|
745
|
+
(e.g. those reading `rtp_to_utc_offset_ns` from an `hf-timestd`
|
|
746
|
+
authority.json) should pass this hint to keep the labeling path off
|
|
747
|
+
the chrony-disciplined system clock (METROLOGY.md §4.5 RTP-reference
|
|
748
|
+
invariant). The hint only needs ±period/2 accuracy (≥6 hours at
|
|
749
|
+
typical sample rates), so even a coarse value is sufficient. When
|
|
750
|
+
omitted, the function falls back to `time.time()` for backward
|
|
751
|
+
compatibility.
|
|
752
|
+
|
|
738
753
|
---
|
|
739
754
|
|
|
740
755
|
## L6 BPSK PPS Calibration
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Shared multicast helpers — multi-interface IP_ADD_MEMBERSHIP.
|
|
2
|
+
|
|
3
|
+
Factored out of ``multi_stream.py`` (Rob Robinett's e3acb6a) so the
|
|
4
|
+
same join-on-every-interface logic can also be used by
|
|
5
|
+
``stream.RadiodStream`` and ``rtp_recorder.RtpRecorder`` without
|
|
6
|
+
duplicating the SIOCGIFADDR enumeration code.
|
|
7
|
+
|
|
8
|
+
This module is package-private — callers inside ``ka9q`` import the
|
|
9
|
+
two public helpers below; nothing outside the package should rely on
|
|
10
|
+
the symbol names.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import fcntl
|
|
16
|
+
import logging
|
|
17
|
+
import socket
|
|
18
|
+
import struct
|
|
19
|
+
from typing import Iterator, List, Tuple
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Linux SIOCGIFADDR — fetch IPv4 of an interface by name. Used to enumerate
|
|
25
|
+
# every UP IPv4 interface so the multicast group join can be made on each
|
|
26
|
+
# of them. Without this, joining with INADDR_ANY lets the kernel pick a
|
|
27
|
+
# single interface (typically the default-route one), which misses:
|
|
28
|
+
#
|
|
29
|
+
# * Loopback-only multicast emitted by a co-located radiod with TTL=0
|
|
30
|
+
# (packets sit on `lo`; the kernel won't deliver them to a socket
|
|
31
|
+
# joined on `ens0`).
|
|
32
|
+
# * Multi-homed stations where one radiod streams on lo and another
|
|
33
|
+
# on eth: a single receiver should consume both.
|
|
34
|
+
#
|
|
35
|
+
# Joining on EVERY local IPv4 interface lets one socket receive from any
|
|
36
|
+
# radiod source on any path.
|
|
37
|
+
_SIOCGIFADDR = 0x8915
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def iter_local_ipv4_interfaces() -> Iterator[Tuple[str, str]]:
|
|
41
|
+
"""Yield (ifname, ipv4_addr_str) for every local interface with an IPv4.
|
|
42
|
+
|
|
43
|
+
Order is ``socket.if_nameindex()`` order — typically ``'lo'`` first,
|
|
44
|
+
then ``ens0``/``eth0``/``wlan0``/etc. Interfaces without an IPv4
|
|
45
|
+
(IPv6-only, or freshly-created with no addr) are skipped silently.
|
|
46
|
+
Stays stdlib-only on Linux (no ``netifaces``/``psutil`` dependency).
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
names = socket.if_nameindex()
|
|
50
|
+
except OSError:
|
|
51
|
+
return
|
|
52
|
+
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
53
|
+
try:
|
|
54
|
+
for _idx, ifname in names:
|
|
55
|
+
try:
|
|
56
|
+
raw = fcntl.ioctl(
|
|
57
|
+
probe.fileno(),
|
|
58
|
+
_SIOCGIFADDR,
|
|
59
|
+
struct.pack("256s", ifname.encode()[:15]),
|
|
60
|
+
)
|
|
61
|
+
addr = socket.inet_ntoa(raw[20:24])
|
|
62
|
+
except OSError:
|
|
63
|
+
continue
|
|
64
|
+
yield ifname, addr
|
|
65
|
+
finally:
|
|
66
|
+
probe.close()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def join_multicast_all_interfaces(sock: socket.socket,
|
|
70
|
+
multicast_address: str) -> List[str]:
|
|
71
|
+
"""Join ``multicast_address`` on every local IPv4 interface.
|
|
72
|
+
|
|
73
|
+
Returns the list of interface names where the join succeeded. Empty
|
|
74
|
+
list means no interface was usable (extremely rare — even a freshly-
|
|
75
|
+
booted box has ``lo``). Per-interface failures are logged at DEBUG
|
|
76
|
+
and skipped (e.g., a virtual interface without an IPv4).
|
|
77
|
+
"""
|
|
78
|
+
joined: List[str] = []
|
|
79
|
+
group = socket.inet_aton(multicast_address)
|
|
80
|
+
for ifname, ifaddr in iter_local_ipv4_interfaces():
|
|
81
|
+
mreq = struct.pack("=4s4s", group, socket.inet_aton(ifaddr))
|
|
82
|
+
try:
|
|
83
|
+
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
|
|
84
|
+
joined.append(ifname)
|
|
85
|
+
except OSError as exc:
|
|
86
|
+
logger.debug(
|
|
87
|
+
"multicast join on %s (%s) failed: %s",
|
|
88
|
+
ifname, ifaddr, exc,
|
|
89
|
+
)
|
|
90
|
+
return joined
|
|
@@ -59,6 +59,9 @@ from .stream_quality import GapEvent, GapSource, StreamQuality
|
|
|
59
59
|
logger = logging.getLogger(__name__)
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
from ._multicast import join_multicast_all_interfaces
|
|
63
|
+
|
|
64
|
+
|
|
62
65
|
@dataclass
|
|
63
66
|
class _ChannelSlot:
|
|
64
67
|
"""Per-SSRC state within a MultiStream."""
|
|
@@ -322,12 +325,26 @@ class MultiStream:
|
|
|
322
325
|
|
|
323
326
|
sock.bind(("0.0.0.0", self._port))
|
|
324
327
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
328
|
+
# Join the multicast group on EVERY local IPv4 interface, not
|
|
329
|
+
# via INADDR_ANY (which lets the kernel pick a single interface
|
|
330
|
+
# — typically the default route — and silently misses radiod
|
|
331
|
+
# outputs on other paths, e.g. TTL=0 loopback packets from a
|
|
332
|
+
# co-located radiod). Helper lives in ka9q._multicast so
|
|
333
|
+
# stream.RadiodStream uses identical logic.
|
|
334
|
+
joined = join_multicast_all_interfaces(
|
|
335
|
+
sock, self._multicast_address,
|
|
329
336
|
)
|
|
330
|
-
|
|
337
|
+
if not joined:
|
|
338
|
+
logger.warning(
|
|
339
|
+
"MultiStream: no interface accepted the multicast "
|
|
340
|
+
"join for %s — recvfrom() will return nothing",
|
|
341
|
+
self._multicast_address,
|
|
342
|
+
)
|
|
343
|
+
else:
|
|
344
|
+
logger.debug(
|
|
345
|
+
"MultiStream: joined %s on interfaces: %s",
|
|
346
|
+
self._multicast_address, ", ".join(joined),
|
|
347
|
+
)
|
|
331
348
|
sock.settimeout(1.0)
|
|
332
349
|
return sock
|
|
333
350
|
|
|
@@ -124,7 +124,11 @@ def parse_rtp_header(data: bytes) -> Optional[RTPHeader]:
|
|
|
124
124
|
)
|
|
125
125
|
|
|
126
126
|
|
|
127
|
-
def rtp_to_wallclock(
|
|
127
|
+
def rtp_to_wallclock(
|
|
128
|
+
rtp_timestamp: int,
|
|
129
|
+
channel: ChannelInfo,
|
|
130
|
+
wallclock_hint_sec: Optional[float] = None,
|
|
131
|
+
) -> Optional[float]:
|
|
128
132
|
"""
|
|
129
133
|
Convert RTP timestamp to Unix wall-clock time
|
|
130
134
|
|
|
@@ -133,6 +137,15 @@ def rtp_to_wallclock(rtp_timestamp: int, channel: ChannelInfo) -> Optional[float
|
|
|
133
137
|
Args:
|
|
134
138
|
rtp_timestamp: RTP timestamp from packet header
|
|
135
139
|
channel: ChannelInfo with gps_time, rtp_timesnap, sample_rate
|
|
140
|
+
wallclock_hint_sec: Approximate UTC seconds, used solely to
|
|
141
|
+
disambiguate the 32-bit RTP wrap epoch (see below). Must be
|
|
142
|
+
within ±period/2 of true UTC (period = 2**32 / sample_rate
|
|
143
|
+
seconds, ≥6 hours for typical sample rates). When omitted,
|
|
144
|
+
falls back to ``time.time()`` — convenient but couples the
|
|
145
|
+
result to the host system clock. Callers that have an
|
|
146
|
+
hf-timestd authority offset available should pass it
|
|
147
|
+
explicitly to keep the labeling path off the chrony-disciplined
|
|
148
|
+
system clock (METROLOGY.md §4.5 RTP-reference invariant).
|
|
136
149
|
|
|
137
150
|
Returns:
|
|
138
151
|
Unix timestamp (seconds) or None if timing info unavailable
|
|
@@ -149,10 +162,10 @@ def rtp_to_wallclock(rtp_timestamp: int, channel: ChannelInfo) -> Optional[float
|
|
|
149
162
|
|
|
150
163
|
We disambiguate by picking the wrap-epoch count ``k`` (full
|
|
151
164
|
2**32-sample periods elapsed since the snapshot) that places
|
|
152
|
-
the resulting wall-clock time closest to
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
165
|
+
the resulting wall-clock time closest to ``wallclock_hint_sec``
|
|
166
|
+
(or ``time.time()`` if no hint was given). Either source needs
|
|
167
|
+
only ±period/2 accuracy, so this stays robust even when the
|
|
168
|
+
hinting reference is loose.
|
|
156
169
|
|
|
157
170
|
Observed on bee1 2026-05-08: long-running SSRCs caused TSL3
|
|
158
171
|
SHM samples stuck at the snapshot's wall-clock time, ~12.4 h
|
|
@@ -181,7 +194,10 @@ def rtp_to_wallclock(rtp_timestamp: int, channel: ChannelInfo) -> Optional[float
|
|
|
181
194
|
# value closest to the system clock. Exact when sys clock is
|
|
182
195
|
# within ±period/2 of true UTC.
|
|
183
196
|
period_ns = BILLION * 0x100000000 // channel.sample_rate
|
|
184
|
-
|
|
197
|
+
if wallclock_hint_sec is not None:
|
|
198
|
+
sys_now_ns = int(wallclock_hint_sec * BILLION)
|
|
199
|
+
else:
|
|
200
|
+
sys_now_ns = int(time.time() * BILLION)
|
|
185
201
|
diff_ns = sys_now_ns - base_wall_ns
|
|
186
202
|
if period_ns > 0:
|
|
187
203
|
# Round-to-nearest of diff_ns / period_ns (Python `//` is
|
|
@@ -36,6 +36,7 @@ import numpy as np
|
|
|
36
36
|
from datetime import datetime, timezone
|
|
37
37
|
from typing import Optional, Callable, List
|
|
38
38
|
|
|
39
|
+
from ._multicast import join_multicast_all_interfaces
|
|
39
40
|
from .discovery import ChannelInfo
|
|
40
41
|
from .rtp_recorder import RTPHeader, parse_rtp_header, rtp_to_wallclock
|
|
41
42
|
from .resequencer import PacketResequencer, RTPPacket
|
|
@@ -232,22 +233,34 @@ class RadiodStream:
|
|
|
232
233
|
|
|
233
234
|
# Bind to port
|
|
234
235
|
sock.bind(('0.0.0.0', self.channel.port))
|
|
235
|
-
|
|
236
|
-
# Join multicast group
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
236
|
+
|
|
237
|
+
# Join the multicast group on EVERY local IPv4 interface (lo,
|
|
238
|
+
# ens0, etc.) — not via INADDR_ANY, which leaves the choice to
|
|
239
|
+
# the kernel's routing table and silently misses radiod outputs
|
|
240
|
+
# that arrive on a non-default interface. Most notably, a
|
|
241
|
+
# co-located radiod with TTL=0 emits only on `lo`; an
|
|
242
|
+
# INADDR_ANY join typically resolves to `ens0` and never sees
|
|
243
|
+
# those packets. Same helper used by MultiStream (the shared-
|
|
244
|
+
# socket abstraction) so both classes have identical behaviour.
|
|
245
|
+
joined = join_multicast_all_interfaces(
|
|
246
|
+
sock, self.channel.multicast_address,
|
|
241
247
|
)
|
|
242
|
-
|
|
243
|
-
|
|
248
|
+
if not joined:
|
|
249
|
+
logger.warning(
|
|
250
|
+
"RadiodStream: no interface accepted the multicast "
|
|
251
|
+
"join for %s — recvfrom() will return nothing",
|
|
252
|
+
self.channel.multicast_address,
|
|
253
|
+
)
|
|
254
|
+
else:
|
|
255
|
+
logger.debug(
|
|
256
|
+
"RadiodStream: joined %s:%d on interfaces: %s",
|
|
257
|
+
self.channel.multicast_address, self.channel.port,
|
|
258
|
+
", ".join(joined),
|
|
259
|
+
)
|
|
260
|
+
|
|
244
261
|
# Timeout for periodic running check
|
|
245
262
|
sock.settimeout(1.0)
|
|
246
|
-
|
|
247
|
-
logger.debug(
|
|
248
|
-
f"Joined multicast {self.channel.multicast_address}:{self.channel.port}"
|
|
249
|
-
)
|
|
250
|
-
|
|
263
|
+
|
|
251
264
|
return sock
|
|
252
265
|
|
|
253
266
|
def _receive_loop(self):
|
|
@@ -36,6 +36,7 @@ examples/tune_example.py
|
|
|
36
36
|
examples/diagnostics/diagnose_packets.py
|
|
37
37
|
examples/diagnostics/repro_utc_bug.py
|
|
38
38
|
ka9q/__init__.py
|
|
39
|
+
ka9q/_multicast.py
|
|
39
40
|
ka9q/addressing.py
|
|
40
41
|
ka9q/cli.py
|
|
41
42
|
ka9q/compat.py
|
|
@@ -81,6 +82,7 @@ tests/test_lifetime.py
|
|
|
81
82
|
tests/test_listen_multicast.py
|
|
82
83
|
tests/test_managed_stream_recovery.py
|
|
83
84
|
tests/test_monitor.py
|
|
85
|
+
tests/test_multicast_helpers.py
|
|
84
86
|
tests/test_multihomed.py
|
|
85
87
|
tests/test_native_discovery.py
|
|
86
88
|
tests/test_performance_fixes.py
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Unit tests for ka9q._multicast helpers.
|
|
2
|
+
|
|
3
|
+
Locks in the multi-interface IP_ADD_MEMBERSHIP behaviour that both
|
|
4
|
+
MultiStream and RadiodStream rely on for TTL=0 / multi-homed reception.
|
|
5
|
+
Rob Robinett (e3acb6a) introduced the logic inside multi_stream.py;
|
|
6
|
+
this refactor moved it to ka9q._multicast and extended RadiodStream to
|
|
7
|
+
use it too.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import socket
|
|
13
|
+
import struct
|
|
14
|
+
import unittest
|
|
15
|
+
from unittest.mock import MagicMock, patch
|
|
16
|
+
|
|
17
|
+
from ka9q._multicast import (
|
|
18
|
+
join_multicast_all_interfaces,
|
|
19
|
+
iter_local_ipv4_interfaces,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestIterLocalIPv4Interfaces(unittest.TestCase):
|
|
24
|
+
"""`iter_local_ipv4_interfaces` should walk `if_nameindex` and skip
|
|
25
|
+
interfaces SIOCGIFADDR doesn't answer for."""
|
|
26
|
+
|
|
27
|
+
def test_enumerates_via_if_nameindex(self):
|
|
28
|
+
"""Smoke-test: at least one interface must come back on a real host."""
|
|
29
|
+
ifaces = list(iter_local_ipv4_interfaces())
|
|
30
|
+
# Every Linux box has 'lo' with 127.0.0.1. If this list is
|
|
31
|
+
# empty something is very wrong (privileged container with no
|
|
32
|
+
# network? broken socket()?).
|
|
33
|
+
self.assertGreater(len(ifaces), 0)
|
|
34
|
+
names = [n for n, _ in ifaces]
|
|
35
|
+
self.assertIn('lo', names)
|
|
36
|
+
# Find lo and verify it has the expected loopback address.
|
|
37
|
+
for name, addr in ifaces:
|
|
38
|
+
if name == 'lo':
|
|
39
|
+
self.assertEqual(addr, '127.0.0.1')
|
|
40
|
+
break
|
|
41
|
+
else:
|
|
42
|
+
self.fail("lo not present in enumeration")
|
|
43
|
+
|
|
44
|
+
def test_handles_if_nameindex_failure(self):
|
|
45
|
+
"""If if_nameindex() raises, we get an empty iterator (not a crash)."""
|
|
46
|
+
with patch('socket.if_nameindex', side_effect=OSError("no /proc/net")):
|
|
47
|
+
self.assertEqual(list(iter_local_ipv4_interfaces()), [])
|
|
48
|
+
|
|
49
|
+
def test_skips_interfaces_without_ipv4(self):
|
|
50
|
+
"""An interface that has no IPv4 (SIOCGIFADDR errors) is silently
|
|
51
|
+
skipped — we just don't see it in the output."""
|
|
52
|
+
# Real-host probe should not include made-up interfaces.
|
|
53
|
+
ifaces = list(iter_local_ipv4_interfaces())
|
|
54
|
+
names = [n for n, _ in ifaces]
|
|
55
|
+
self.assertNotIn('this-interface-does-not-exist', names)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestJoinMulticastAllInterfaces(unittest.TestCase):
|
|
59
|
+
"""`join_multicast_all_interfaces` must call setsockopt once per
|
|
60
|
+
enumerated interface and return the names where the join succeeded."""
|
|
61
|
+
|
|
62
|
+
def test_joins_each_enumerated_interface(self):
|
|
63
|
+
sock = MagicMock(spec=socket.socket)
|
|
64
|
+
sock.setsockopt = MagicMock()
|
|
65
|
+
|
|
66
|
+
fake_ifaces = [('lo', '127.0.0.1'),
|
|
67
|
+
('ens0', '192.168.1.50'),
|
|
68
|
+
('tailscale0', '100.64.0.5')]
|
|
69
|
+
with patch('ka9q._multicast.iter_local_ipv4_interfaces',
|
|
70
|
+
return_value=iter(fake_ifaces)):
|
|
71
|
+
joined = join_multicast_all_interfaces(sock, '239.1.2.3')
|
|
72
|
+
|
|
73
|
+
self.assertEqual(joined, ['lo', 'ens0', 'tailscale0'])
|
|
74
|
+
self.assertEqual(sock.setsockopt.call_count, 3)
|
|
75
|
+
|
|
76
|
+
# Verify each setsockopt got the right (group, iface) mreq.
|
|
77
|
+
for call, (_ifname, ifaddr) in zip(sock.setsockopt.call_args_list, fake_ifaces):
|
|
78
|
+
args = call.args
|
|
79
|
+
self.assertEqual(args[0], socket.IPPROTO_IP)
|
|
80
|
+
self.assertEqual(args[1], socket.IP_ADD_MEMBERSHIP)
|
|
81
|
+
expected_mreq = struct.pack(
|
|
82
|
+
"=4s4s",
|
|
83
|
+
socket.inet_aton('239.1.2.3'),
|
|
84
|
+
socket.inet_aton(ifaddr),
|
|
85
|
+
)
|
|
86
|
+
self.assertEqual(args[2], expected_mreq)
|
|
87
|
+
|
|
88
|
+
def test_per_interface_failure_skipped(self):
|
|
89
|
+
"""A setsockopt OSError on one interface skips it but doesn't
|
|
90
|
+
abort the loop or raise — the other interfaces still join."""
|
|
91
|
+
sock = MagicMock(spec=socket.socket)
|
|
92
|
+
# ens0 fails, lo + tailscale0 succeed.
|
|
93
|
+
def setsockopt_side_effect(level, opt, value):
|
|
94
|
+
mreq_iface = socket.inet_ntoa(value[4:8])
|
|
95
|
+
if mreq_iface == '192.168.1.50':
|
|
96
|
+
raise OSError(99, "Cannot assign requested address")
|
|
97
|
+
return None
|
|
98
|
+
sock.setsockopt.side_effect = setsockopt_side_effect
|
|
99
|
+
|
|
100
|
+
fake_ifaces = [('lo', '127.0.0.1'),
|
|
101
|
+
('ens0', '192.168.1.50'),
|
|
102
|
+
('tailscale0', '100.64.0.5')]
|
|
103
|
+
with patch('ka9q._multicast.iter_local_ipv4_interfaces',
|
|
104
|
+
return_value=iter(fake_ifaces)):
|
|
105
|
+
joined = join_multicast_all_interfaces(sock, '239.1.2.3')
|
|
106
|
+
|
|
107
|
+
self.assertEqual(joined, ['lo', 'tailscale0']) # ens0 dropped
|
|
108
|
+
|
|
109
|
+
def test_empty_enumeration_returns_empty(self):
|
|
110
|
+
"""No interfaces enumerated → empty join list, no exception."""
|
|
111
|
+
sock = MagicMock(spec=socket.socket)
|
|
112
|
+
with patch('ka9q._multicast.iter_local_ipv4_interfaces',
|
|
113
|
+
return_value=iter([])):
|
|
114
|
+
joined = join_multicast_all_interfaces(sock, '239.1.2.3')
|
|
115
|
+
self.assertEqual(joined, [])
|
|
116
|
+
sock.setsockopt.assert_not_called()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TestStreamClassesUseHelper(unittest.TestCase):
|
|
120
|
+
"""RadiodStream and MultiStream must both call the shared helper, so a
|
|
121
|
+
future change to join semantics applies uniformly."""
|
|
122
|
+
|
|
123
|
+
def test_radiodstream_imports_helper(self):
|
|
124
|
+
# Ensure the import succeeded — would raise ImportError at
|
|
125
|
+
# collection time if the symbol moved.
|
|
126
|
+
from ka9q import stream
|
|
127
|
+
self.assertTrue(hasattr(stream, 'join_multicast_all_interfaces'))
|
|
128
|
+
|
|
129
|
+
def test_multistream_imports_helper(self):
|
|
130
|
+
from ka9q import multi_stream
|
|
131
|
+
self.assertTrue(hasattr(multi_stream, 'join_multicast_all_interfaces'))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == '__main__':
|
|
135
|
+
unittest.main()
|
|
@@ -118,3 +118,67 @@ def test_rtp_to_wallclock_returns_none_when_timing_missing():
|
|
|
118
118
|
assert rtp_to_wallclock(1000, channel) is None
|
|
119
119
|
channel = _channel(48000, None, 1000)
|
|
120
120
|
assert rtp_to_wallclock(1000, channel) is None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_rtp_to_wallclock_hint_bypasses_system_clock():
|
|
124
|
+
"""When `wallclock_hint_sec` is provided, time.time() must NOT be
|
|
125
|
+
consulted. This is the RTP-reference invariant: authority-aware
|
|
126
|
+
callers can disambiguate the wrap epoch without coupling to the
|
|
127
|
+
host system clock."""
|
|
128
|
+
sample_rate = 96000
|
|
129
|
+
gps_time_ns = 1234567890000000000
|
|
130
|
+
channel = _channel(sample_rate, gps_time_ns, 1000)
|
|
131
|
+
snapshot_wall = (gps_time_ns + BILLION * (GPS_UTC_OFFSET - 18)) / BILLION
|
|
132
|
+
|
|
133
|
+
period_sec = 0x100000000 / sample_rate
|
|
134
|
+
target_rtp = (1000 + 0x100000000) & 0xFFFFFFFF
|
|
135
|
+
expected = snapshot_wall + period_sec
|
|
136
|
+
|
|
137
|
+
# Pin time.time() to something WAY OFF (1 year earlier) — if the
|
|
138
|
+
# function ignores the hint and consults the system clock anyway,
|
|
139
|
+
# the wrap-epoch math will pick k=0 and the result will be wrong.
|
|
140
|
+
bogus_now = expected - 365 * 86400.0
|
|
141
|
+
with patch("ka9q.rtp_recorder.time.time", return_value=bogus_now) as mock_time:
|
|
142
|
+
result = rtp_to_wallclock(target_rtp, channel, wallclock_hint_sec=expected)
|
|
143
|
+
assert result == pytest.approx(expected, abs=1e-3)
|
|
144
|
+
# Strict: hint path must not call time.time() at all.
|
|
145
|
+
mock_time.assert_not_called()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_rtp_to_wallclock_hint_picks_correct_epoch():
|
|
149
|
+
"""Hint at a different wrap epoch from the system clock → result
|
|
150
|
+
follows the hint, proving the function uses the hint for k-selection."""
|
|
151
|
+
sample_rate = 12000 # WSPR-band rate; period ≈ 99 hours
|
|
152
|
+
gps_time_ns = 1234567890000000000
|
|
153
|
+
channel = _channel(sample_rate, gps_time_ns, 1000)
|
|
154
|
+
snapshot_wall = (gps_time_ns + BILLION * (GPS_UTC_OFFSET - 18)) / BILLION
|
|
155
|
+
period_sec = 0x100000000 / sample_rate
|
|
156
|
+
|
|
157
|
+
# RTP value aliased to ~snapshot, but actually 2 wraps later.
|
|
158
|
+
target_rtp = (1000 + 2 * 0x100000000) & 0xFFFFFFFF
|
|
159
|
+
expected = snapshot_wall + 2 * period_sec
|
|
160
|
+
|
|
161
|
+
# System clock at the snapshot wall (would pick k=0); hint says
|
|
162
|
+
# ~2 periods later (correct k=2).
|
|
163
|
+
with patch("ka9q.rtp_recorder.time.time", return_value=snapshot_wall):
|
|
164
|
+
result = rtp_to_wallclock(target_rtp, channel, wallclock_hint_sec=expected)
|
|
165
|
+
assert result == pytest.approx(expected, abs=1e-3)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_rtp_to_wallclock_hint_omitted_falls_back_to_system_clock():
|
|
169
|
+
"""Default path (no hint) preserves legacy behavior for
|
|
170
|
+
backwards compatibility — the hint parameter is purely additive."""
|
|
171
|
+
sample_rate = 48000
|
|
172
|
+
gps_time_ns = 1234567890000000000
|
|
173
|
+
channel = _channel(sample_rate, gps_time_ns, 1000)
|
|
174
|
+
snapshot_wall = (gps_time_ns + BILLION * (GPS_UTC_OFFSET - 18)) / BILLION
|
|
175
|
+
period_sec = 0x100000000 / sample_rate
|
|
176
|
+
|
|
177
|
+
target_rtp = (1000 + 0x100000000) & 0xFFFFFFFF
|
|
178
|
+
expected = snapshot_wall + period_sec
|
|
179
|
+
|
|
180
|
+
# No hint → must consult time.time() to land on k=1.
|
|
181
|
+
with patch("ka9q.rtp_recorder.time.time", return_value=expected) as mock_time:
|
|
182
|
+
result = rtp_to_wallclock(target_rtp, channel)
|
|
183
|
+
assert result == pytest.approx(expected, abs=1e-3)
|
|
184
|
+
mock_time.assert_called()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|