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.
Files changed (106) hide show
  1. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/CHANGELOG.md +52 -0
  2. {ka9q_python-3.14.0/ka9q_python.egg-info → ka9q_python-3.14.2}/PKG-INFO +1 -1
  3. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/docs/API_REFERENCE.md +16 -1
  4. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/__init__.py +1 -1
  5. ka9q_python-3.14.2/ka9q/_multicast.py +90 -0
  6. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/multi_stream.py +22 -5
  7. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/rtp_recorder.py +22 -6
  8. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/stream.py +26 -13
  9. {ka9q_python-3.14.0 → ka9q_python-3.14.2/ka9q_python.egg-info}/PKG-INFO +1 -1
  10. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q_python.egg-info/SOURCES.txt +2 -0
  11. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/pyproject.toml +1 -1
  12. ka9q_python-3.14.2/tests/test_multicast_helpers.py +135 -0
  13. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_rtp_recorder.py +64 -0
  14. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/LICENSE +0 -0
  15. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/MANIFEST.in +0 -0
  16. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/README.md +0 -0
  17. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/docs/ARCHITECTURE.md +0 -0
  18. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/docs/CLI_GUIDE.md +0 -0
  19. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/docs/GETTING_STARTED.md +0 -0
  20. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/docs/INSTALLATION.md +0 -0
  21. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/docs/MULTI_STREAM.md +0 -0
  22. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/docs/RECIPES.md +0 -0
  23. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/docs/RTP_TIMING_SUPPORT.md +0 -0
  24. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/docs/SECURITY.md +0 -0
  25. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/docs/TESTING_GUIDE.md +0 -0
  26. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/docs/TUI_GUIDE.md +0 -0
  27. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/advanced_features_demo.py +0 -0
  28. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/channel_cleanup_example.py +0 -0
  29. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/codar_oceanography.py +0 -0
  30. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/diagnostics/diagnose_packets.py +0 -0
  31. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/diagnostics/repro_utc_bug.py +0 -0
  32. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/discover_example.py +0 -0
  33. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/grape_integration_example.py +0 -0
  34. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/hf_band_scanner.py +0 -0
  35. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/multi_stream_smoke.py +0 -0
  36. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/rtp_recorder_example.py +0 -0
  37. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/simple_am_radio.py +0 -0
  38. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/spectrum_example.py +0 -0
  39. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/stream_example.py +0 -0
  40. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/superdarn_recorder.py +0 -0
  41. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/test_channel_operations.py +0 -0
  42. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/test_improvements.py +0 -0
  43. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/test_timing_fields.py +0 -0
  44. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/tune.py +0 -0
  45. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/examples/tune_example.py +0 -0
  46. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/addressing.py +0 -0
  47. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/cli.py +0 -0
  48. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/compat.py +0 -0
  49. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/control.py +0 -0
  50. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/discovery.py +0 -0
  51. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/exceptions.py +0 -0
  52. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/managed_stream.py +0 -0
  53. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/monitor.py +0 -0
  54. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/pps_calibrator.py +0 -0
  55. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/resequencer.py +0 -0
  56. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/spectrum_stream.py +0 -0
  57. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/status.py +0 -0
  58. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/stream_quality.py +0 -0
  59. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/tui.py +0 -0
  60. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/types.py +0 -0
  61. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q/utils.py +0 -0
  62. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q_python.egg-info/dependency_links.txt +0 -0
  63. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q_python.egg-info/entry_points.txt +0 -0
  64. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q_python.egg-info/requires.txt +0 -0
  65. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q_python.egg-info/top_level.txt +0 -0
  66. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/ka9q_radio_compat +0 -0
  67. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/scripts/check_upstream_drift.py +0 -0
  68. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/scripts/sync_types.py +0 -0
  69. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/setup.cfg +0 -0
  70. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/setup.py +0 -0
  71. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/__init__.py +0 -0
  72. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/conftest.py +0 -0
  73. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_addressing.py +0 -0
  74. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_channel_verification.py +0 -0
  75. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_client_id_destination.py +0 -0
  76. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_create_split_encoding.py +0 -0
  77. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_decode_description.py +0 -0
  78. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_decode_functions.py +0 -0
  79. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_encode_functions.py +0 -0
  80. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_encode_socket.py +0 -0
  81. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_ensure_channel_encoding.py +0 -0
  82. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_filter_edges.py +0 -0
  83. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_integration.py +0 -0
  84. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_iq_20khz_f32.py +0 -0
  85. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_lifetime.py +0 -0
  86. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_listen_multicast.py +0 -0
  87. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_managed_stream_recovery.py +0 -0
  88. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_monitor.py +0 -0
  89. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_multihomed.py +0 -0
  90. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_native_discovery.py +0 -0
  91. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_performance_fixes.py +0 -0
  92. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_protocol_compat.py +0 -0
  93. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_remove_channel.py +0 -0
  94. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_security_features.py +0 -0
  95. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_spectrum.py +0 -0
  96. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_ssrc_dest_unit.py +0 -0
  97. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_ssrc_encoding_unit.py +0 -0
  98. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_ssrc_radiod_host_unit.py +0 -0
  99. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_status_decoder.py +0 -0
  100. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_ttl_warning.py +0 -0
  101. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_tune.py +0 -0
  102. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_tune_cli.py +0 -0
  103. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_tune_debug.py +0 -0
  104. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_tune_live.py +0 -0
  105. {ka9q_python-3.14.0 → ka9q_python-3.14.2}/tests/test_tune_method.py +0 -0
  106. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ka9q-python
3
- Version: 3.14.0
3
+ Version: 3.14.2
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
@@ -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(rtp_timestamp: int, channel: ChannelInfo) -> Optional[float]
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
@@ -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.2'
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
 
@@ -124,7 +124,11 @@ def parse_rtp_header(data: bytes) -> Optional[RTPHeader]:
124
124
  )
125
125
 
126
126
 
127
- def rtp_to_wallclock(rtp_timestamp: int, channel: ChannelInfo) -> Optional[float]:
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 the local system
153
- clock. System clock stays within seconds of true UTC even
154
- under hostile conditions, and the wrap ambiguity period is
155
- hours, so this is robust without tight NTP discipline.
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
- sys_now_ns = int(time.time() * BILLION)
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
- 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.2
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.2"
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()
@@ -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