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.
Files changed (106) hide show
  1. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/CHANGELOG.md +31 -0
  2. {ka9q_python-3.14.0/ka9q_python.egg-info → ka9q_python-3.14.1}/PKG-INFO +1 -1
  3. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/__init__.py +1 -1
  4. ka9q_python-3.14.1/ka9q/_multicast.py +90 -0
  5. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/multi_stream.py +22 -5
  6. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/stream.py +26 -13
  7. {ka9q_python-3.14.0 → ka9q_python-3.14.1/ka9q_python.egg-info}/PKG-INFO +1 -1
  8. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q_python.egg-info/SOURCES.txt +2 -0
  9. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/pyproject.toml +1 -1
  10. ka9q_python-3.14.1/tests/test_multicast_helpers.py +135 -0
  11. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/LICENSE +0 -0
  12. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/MANIFEST.in +0 -0
  13. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/README.md +0 -0
  14. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/docs/API_REFERENCE.md +0 -0
  15. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/docs/ARCHITECTURE.md +0 -0
  16. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/docs/CLI_GUIDE.md +0 -0
  17. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/docs/GETTING_STARTED.md +0 -0
  18. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/docs/INSTALLATION.md +0 -0
  19. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/docs/MULTI_STREAM.md +0 -0
  20. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/docs/RECIPES.md +0 -0
  21. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/docs/RTP_TIMING_SUPPORT.md +0 -0
  22. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/docs/SECURITY.md +0 -0
  23. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/docs/TESTING_GUIDE.md +0 -0
  24. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/docs/TUI_GUIDE.md +0 -0
  25. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/advanced_features_demo.py +0 -0
  26. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/channel_cleanup_example.py +0 -0
  27. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/codar_oceanography.py +0 -0
  28. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/diagnostics/diagnose_packets.py +0 -0
  29. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/diagnostics/repro_utc_bug.py +0 -0
  30. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/discover_example.py +0 -0
  31. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/grape_integration_example.py +0 -0
  32. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/hf_band_scanner.py +0 -0
  33. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/multi_stream_smoke.py +0 -0
  34. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/rtp_recorder_example.py +0 -0
  35. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/simple_am_radio.py +0 -0
  36. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/spectrum_example.py +0 -0
  37. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/stream_example.py +0 -0
  38. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/superdarn_recorder.py +0 -0
  39. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/test_channel_operations.py +0 -0
  40. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/test_improvements.py +0 -0
  41. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/test_timing_fields.py +0 -0
  42. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/tune.py +0 -0
  43. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/examples/tune_example.py +0 -0
  44. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/addressing.py +0 -0
  45. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/cli.py +0 -0
  46. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/compat.py +0 -0
  47. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/control.py +0 -0
  48. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/discovery.py +0 -0
  49. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/exceptions.py +0 -0
  50. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/managed_stream.py +0 -0
  51. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/monitor.py +0 -0
  52. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/pps_calibrator.py +0 -0
  53. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/resequencer.py +0 -0
  54. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/rtp_recorder.py +0 -0
  55. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/spectrum_stream.py +0 -0
  56. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/status.py +0 -0
  57. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/stream_quality.py +0 -0
  58. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/tui.py +0 -0
  59. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/types.py +0 -0
  60. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q/utils.py +0 -0
  61. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q_python.egg-info/dependency_links.txt +0 -0
  62. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q_python.egg-info/entry_points.txt +0 -0
  63. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q_python.egg-info/requires.txt +0 -0
  64. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q_python.egg-info/top_level.txt +0 -0
  65. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/ka9q_radio_compat +0 -0
  66. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/scripts/check_upstream_drift.py +0 -0
  67. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/scripts/sync_types.py +0 -0
  68. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/setup.cfg +0 -0
  69. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/setup.py +0 -0
  70. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/__init__.py +0 -0
  71. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/conftest.py +0 -0
  72. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_addressing.py +0 -0
  73. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_channel_verification.py +0 -0
  74. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_client_id_destination.py +0 -0
  75. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_create_split_encoding.py +0 -0
  76. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_decode_description.py +0 -0
  77. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_decode_functions.py +0 -0
  78. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_encode_functions.py +0 -0
  79. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_encode_socket.py +0 -0
  80. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_ensure_channel_encoding.py +0 -0
  81. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_filter_edges.py +0 -0
  82. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_integration.py +0 -0
  83. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_iq_20khz_f32.py +0 -0
  84. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_lifetime.py +0 -0
  85. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_listen_multicast.py +0 -0
  86. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_managed_stream_recovery.py +0 -0
  87. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_monitor.py +0 -0
  88. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_multihomed.py +0 -0
  89. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_native_discovery.py +0 -0
  90. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_performance_fixes.py +0 -0
  91. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_protocol_compat.py +0 -0
  92. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_remove_channel.py +0 -0
  93. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_rtp_recorder.py +0 -0
  94. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_security_features.py +0 -0
  95. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_spectrum.py +0 -0
  96. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_ssrc_dest_unit.py +0 -0
  97. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_ssrc_encoding_unit.py +0 -0
  98. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_ssrc_radiod_host_unit.py +0 -0
  99. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_status_decoder.py +0 -0
  100. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_ttl_warning.py +0 -0
  101. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_tune.py +0 -0
  102. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_tune_cli.py +0 -0
  103. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_tune_debug.py +0 -0
  104. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_tune_live.py +0 -0
  105. {ka9q_python-3.14.0 → ka9q_python-3.14.1}/tests/test_tune_method.py +0 -0
  106. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ka9q-python
3
- Version: 3.14.0
3
+ Version: 3.14.1
4
4
  Summary: Python interface for ka9q-radio control and monitoring
5
5
  Home-page: https://github.com/mijahauan/ka9q-python
6
6
  Author: Michael Hauan AC0G
@@ -56,7 +56,7 @@ Lower-level usage (explicit control):
56
56
  )
57
57
  print(f"Created channel with SSRC: {ssrc}")
58
58
  """
59
- __version__ = '3.14.0'
59
+ __version__ = '3.14.1'
60
60
  __author__ = 'Michael Hauan AC0G'
61
61
 
62
62
  from .control import RadiodControl, allocate_ssrc
@@ -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
- mreq = struct.pack(
326
- "=4s4s",
327
- socket.inet_aton(self._multicast_address),
328
- socket.inet_aton("0.0.0.0"),
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
- sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
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
- mreq = struct.pack(
238
- '=4s4s',
239
- socket.inet_aton(self.channel.multicast_address),
240
- socket.inet_aton('0.0.0.0')
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
- sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
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):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ka9q-python
3
- Version: 3.14.0
3
+ Version: 3.14.1
4
4
  Summary: Python interface for ka9q-radio control and monitoring
5
5
  Home-page: https://github.com/mijahauan/ka9q-python
6
6
  Author: Michael Hauan AC0G
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ka9q-python"
7
- version = "3.14.0"
7
+ version = "3.14.1"
8
8
  description = "Python interface for ka9q-radio control and monitoring"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -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