ka9q-python 3.14.0__tar.gz → 3.14.1__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.1}/CHANGELOG.md +31 -0
- {ka9q_python-3.14.0/ka9q_python.egg-info → ka9q_python-3.14.1}/PKG-INFO +1 -1
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/__init__.py +1 -1
- ka9q_python-3.14.1/ka9q/_multicast.py +90 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/multi_stream.py +22 -5
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/stream.py +26 -13
- {ka9q_python-3.14.0 → ka9q_python-3.14.1/ka9q_python.egg-info}/PKG-INFO +1 -1
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q_python.egg-info/SOURCES.txt +2 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/pyproject.toml +1 -1
- ka9q_python-3.14.1/tests/test_multicast_helpers.py +135 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/LICENSE +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/MANIFEST.in +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/README.md +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/docs/API_REFERENCE.md +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/docs/ARCHITECTURE.md +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/docs/CLI_GUIDE.md +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/docs/GETTING_STARTED.md +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/docs/INSTALLATION.md +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/docs/MULTI_STREAM.md +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/docs/RECIPES.md +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/docs/RTP_TIMING_SUPPORT.md +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/docs/SECURITY.md +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/docs/TESTING_GUIDE.md +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/docs/TUI_GUIDE.md +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/advanced_features_demo.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/channel_cleanup_example.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/codar_oceanography.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/diagnostics/diagnose_packets.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/diagnostics/repro_utc_bug.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/discover_example.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/grape_integration_example.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/hf_band_scanner.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/multi_stream_smoke.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/rtp_recorder_example.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/simple_am_radio.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/spectrum_example.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/stream_example.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/superdarn_recorder.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/test_channel_operations.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/test_improvements.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/test_timing_fields.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/tune.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/tune_example.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/addressing.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/cli.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/compat.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/control.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/discovery.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/exceptions.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/managed_stream.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/monitor.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/pps_calibrator.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/resequencer.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/rtp_recorder.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/spectrum_stream.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/status.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/stream_quality.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/tui.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/types.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/utils.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q_python.egg-info/dependency_links.txt +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q_python.egg-info/entry_points.txt +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q_python.egg-info/requires.txt +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q_python.egg-info/top_level.txt +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q_radio_compat +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/scripts/check_upstream_drift.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/scripts/sync_types.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/setup.cfg +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/setup.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/__init__.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/conftest.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_addressing.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_channel_verification.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_client_id_destination.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_create_split_encoding.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_decode_description.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_decode_functions.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_encode_functions.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_encode_socket.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_ensure_channel_encoding.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_filter_edges.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_integration.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_iq_20khz_f32.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_lifetime.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_listen_multicast.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_managed_stream_recovery.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_monitor.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_multihomed.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_native_discovery.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_performance_fixes.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_protocol_compat.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_remove_channel.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_rtp_recorder.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_security_features.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_spectrum.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_ssrc_dest_unit.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_ssrc_encoding_unit.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_ssrc_radiod_host_unit.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_status_decoder.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_ttl_warning.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_tune.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_tune_cli.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_tune_debug.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_tune_live.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_tune_method.py +0 -0
- {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_upstream_drift.py +0 -0
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.14.1] - 2026-05-14
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **`RadiodStream`: multi-interface multicast join (parity with
|
|
8
|
+
`MultiStream`).** Extends Rob Robinett's `e3acb6a` fix from
|
|
9
|
+
`multi_stream.py` to `stream.py` so the per-stream `RadiodStream`
|
|
10
|
+
socket also joins on every local IPv4 interface instead of the
|
|
11
|
+
single one the kernel picks via `INADDR_ANY`. Without this, a
|
|
12
|
+
`RadiodStream` consumer of a co-located radiod with `ttl=0` (or any
|
|
13
|
+
output that arrives on a non-default-route interface) silently
|
|
14
|
+
received zero packets. Most visible to clients that use the
|
|
15
|
+
per-stream API directly (codar-sounder, hf-timestd's legacy T6
|
|
16
|
+
path) rather than the shared `MultiStream` socket.
|
|
17
|
+
|
|
18
|
+
### Refactor
|
|
19
|
+
|
|
20
|
+
- The helper that enumerates IPv4 interfaces and calls
|
|
21
|
+
`IP_ADD_MEMBERSHIP` on each is factored into a new package-private
|
|
22
|
+
`ka9q/_multicast.py` module. Both `multi_stream.py` and `stream.py`
|
|
23
|
+
now import from it. Same behaviour, one implementation.
|
|
24
|
+
`rtp_recorder.RtpRecorder` still uses the single-interface join —
|
|
25
|
+
separate fix.
|
|
26
|
+
|
|
27
|
+
### Tests
|
|
28
|
+
|
|
29
|
+
- 8 new in `tests/test_multicast_helpers.py`: enumerator behaviour,
|
|
30
|
+
per-interface join failure handling (one interface failing doesn't
|
|
31
|
+
abort the loop), empty-enumeration safety, both stream classes
|
|
32
|
+
pulling the helper from the shared module.
|
|
33
|
+
|
|
3
34
|
## [3.14.0] - 2026-05-13
|
|
4
35
|
|
|
5
36
|
### Added
|
|
@@ -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
|
|
|
@@ -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()
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|