ka9q-python 3.13.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 (107) hide show
  1. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/CHANGELOG.md +79 -0
  2. {ka9q_python-3.13.0/ka9q_python.egg-info → ka9q_python-3.14.1}/PKG-INFO +1 -1
  3. {ka9q_python-3.13.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.13.0 → ka9q_python-3.14.1}/ka9q/compat.py +1 -1
  6. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/control.py +48 -6
  7. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/multi_stream.py +36 -5
  8. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/rtp_recorder.py +49 -11
  9. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/stream.py +26 -13
  10. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/types.py +1 -1
  11. {ka9q_python-3.13.0 → ka9q_python-3.14.1/ka9q_python.egg-info}/PKG-INFO +1 -1
  12. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q_python.egg-info/SOURCES.txt +3 -0
  13. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q_radio_compat +1 -1
  14. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/pyproject.toml +1 -1
  15. ka9q_python-3.14.1/tests/test_client_id_destination.py +150 -0
  16. ka9q_python-3.14.1/tests/test_multicast_helpers.py +135 -0
  17. ka9q_python-3.14.1/tests/test_rtp_recorder.py +120 -0
  18. ka9q_python-3.13.0/tests/test_rtp_recorder.py +0 -41
  19. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/LICENSE +0 -0
  20. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/MANIFEST.in +0 -0
  21. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/README.md +0 -0
  22. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/docs/API_REFERENCE.md +0 -0
  23. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/docs/ARCHITECTURE.md +0 -0
  24. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/docs/CLI_GUIDE.md +0 -0
  25. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/docs/GETTING_STARTED.md +0 -0
  26. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/docs/INSTALLATION.md +0 -0
  27. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/docs/MULTI_STREAM.md +0 -0
  28. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/docs/RECIPES.md +0 -0
  29. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/docs/RTP_TIMING_SUPPORT.md +0 -0
  30. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/docs/SECURITY.md +0 -0
  31. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/docs/TESTING_GUIDE.md +0 -0
  32. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/docs/TUI_GUIDE.md +0 -0
  33. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/advanced_features_demo.py +0 -0
  34. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/channel_cleanup_example.py +0 -0
  35. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/codar_oceanography.py +0 -0
  36. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/diagnostics/diagnose_packets.py +0 -0
  37. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/diagnostics/repro_utc_bug.py +0 -0
  38. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/discover_example.py +0 -0
  39. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/grape_integration_example.py +0 -0
  40. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/hf_band_scanner.py +0 -0
  41. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/multi_stream_smoke.py +0 -0
  42. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/rtp_recorder_example.py +0 -0
  43. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/simple_am_radio.py +0 -0
  44. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/spectrum_example.py +0 -0
  45. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/stream_example.py +0 -0
  46. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/superdarn_recorder.py +0 -0
  47. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/test_channel_operations.py +0 -0
  48. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/test_improvements.py +0 -0
  49. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/test_timing_fields.py +0 -0
  50. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/tune.py +0 -0
  51. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/tune_example.py +0 -0
  52. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/addressing.py +0 -0
  53. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/cli.py +0 -0
  54. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/discovery.py +0 -0
  55. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/exceptions.py +0 -0
  56. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/managed_stream.py +0 -0
  57. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/monitor.py +0 -0
  58. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/pps_calibrator.py +0 -0
  59. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/resequencer.py +0 -0
  60. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/spectrum_stream.py +0 -0
  61. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/status.py +0 -0
  62. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/stream_quality.py +0 -0
  63. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/tui.py +0 -0
  64. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/utils.py +0 -0
  65. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q_python.egg-info/dependency_links.txt +0 -0
  66. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q_python.egg-info/entry_points.txt +0 -0
  67. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q_python.egg-info/requires.txt +0 -0
  68. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q_python.egg-info/top_level.txt +0 -0
  69. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/scripts/check_upstream_drift.py +0 -0
  70. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/scripts/sync_types.py +0 -0
  71. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/setup.cfg +0 -0
  72. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/setup.py +0 -0
  73. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/__init__.py +0 -0
  74. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/conftest.py +0 -0
  75. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_addressing.py +0 -0
  76. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_channel_verification.py +0 -0
  77. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_create_split_encoding.py +0 -0
  78. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_decode_description.py +0 -0
  79. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_decode_functions.py +0 -0
  80. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_encode_functions.py +0 -0
  81. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_encode_socket.py +0 -0
  82. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_ensure_channel_encoding.py +0 -0
  83. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_filter_edges.py +0 -0
  84. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_integration.py +0 -0
  85. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_iq_20khz_f32.py +0 -0
  86. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_lifetime.py +0 -0
  87. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_listen_multicast.py +0 -0
  88. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_managed_stream_recovery.py +0 -0
  89. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_monitor.py +0 -0
  90. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_multihomed.py +0 -0
  91. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_native_discovery.py +0 -0
  92. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_performance_fixes.py +0 -0
  93. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_protocol_compat.py +0 -0
  94. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_remove_channel.py +0 -0
  95. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_security_features.py +0 -0
  96. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_spectrum.py +0 -0
  97. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_ssrc_dest_unit.py +0 -0
  98. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_ssrc_encoding_unit.py +0 -0
  99. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_ssrc_radiod_host_unit.py +0 -0
  100. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_status_decoder.py +0 -0
  101. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_ttl_warning.py +0 -0
  102. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_tune.py +0 -0
  103. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_tune_cli.py +0 -0
  104. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_tune_debug.py +0 -0
  105. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_tune_live.py +0 -0
  106. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_tune_method.py +0 -0
  107. {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_upstream_drift.py +0 -0
@@ -1,5 +1,84 @@
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
+
34
+ ## [3.14.0] - 2026-05-13
35
+
36
+ ### Added
37
+
38
+ - **`RadiodControl(client_id=...)` for per-client deterministic multicast
39
+ destinations.** Closes the CONTRACT v0.3 §7 gap where the spec said
40
+ "ka9q-python derives the multicast destination" but the implementation
41
+ never did, so every client on a given radiod landed on radiod's
42
+ config-file default group. Two clients with no operator action are now
43
+ guaranteed to use distinct multicast addresses without per-client
44
+ derivation code.
45
+ - New `client_id: Optional[str] = None` kwarg on
46
+ `RadiodControl.__init__`. When set, `ensure_channel(destination=None)`
47
+ auto-derives a `239.x.y.z` address via
48
+ `generate_multicast_ip(client_id, radiod_host=self.status_address)`.
49
+ - Destination precedence inside `ensure_channel`:
50
+ `(1) explicit destination=` >
51
+ `(2) derived from (client_id, status_address)` >
52
+ `(3) None → radiod's config default`.
53
+ - Multi-radiod handling falls out of the hash: a `psk-recorder` instance
54
+ pointing at `bee1-hf-status.local` derives a different address from
55
+ one pointing at `bee3-hf-status.local`.
56
+ - Multi-client handling falls out of the hash: `psk-recorder` and
57
+ `wspr-recorder` on the same radiod derive different addresses.
58
+ - `MultiStream._attempt_restore` inherits the behavior because it reuses
59
+ the same `RadiodControl` instance (and thus the same `client_id`).
60
+ Restoration after a radiod restart re-creates each channel on the
61
+ same per-client multicast group it was on before.
62
+ - Because `allocate_ssrc` already hashes `destination` into the SSRC,
63
+ per-client destinations produce per-client SSRCs — radiod's channel
64
+ table cleanly separates concurrent clients.
65
+
66
+ ### Backward Compatibility
67
+
68
+ - Default behavior unchanged: `RadiodControl(status)` without `client_id`
69
+ preserves pre-3.14 semantics — `destination=None` flows through, radiod
70
+ uses its config-file default, every channel from that radiod shares one
71
+ multicast group. Clients opt in by passing `client_id="<name>"`.
72
+
73
+ ### Tests
74
+
75
+ - 9 new unit tests in `tests/test_client_id_destination.py` cover:
76
+ client_id default-None / set / stored; precedence (explicit wins over
77
+ derived, no client_id → None); uniqueness invariants (same client on
78
+ two radiods → distinct; two clients on one radiod → distinct;
79
+ same client + same radiod → repeatable); SSRC divergence per client.
80
+ 134 offline unit tests pass.
81
+
3
82
  ## [3.13.0] - 2026-05-08
4
83
 
5
84
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ka9q-python
3
- Version: 3.13.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.13.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
@@ -12,4 +12,4 @@ Usage:
12
12
  print(f"Compatible with ka9q-radio at {KA9Q_RADIO_COMMIT}")
13
13
  """
14
14
 
15
- KA9Q_RADIO_COMMIT: str = "5498aefd6fd4be7d4ff2f5e33c9b310ecd3b8574"
15
+ KA9Q_RADIO_COMMIT: str = "f78cff9cc8c7fa33ea49aac9176d9263c535a332"
@@ -691,18 +691,35 @@ class RadiodControl:
691
691
  """
692
692
 
693
693
  def __init__(self, status_address: str, max_commands_per_sec: int = 100,
694
- interface: Optional[str] = None):
694
+ interface: Optional[str] = None,
695
+ client_id: Optional[str] = None):
695
696
  """
696
697
  Initialize radiod control
697
-
698
+
698
699
  Args:
699
700
  status_address: mDNS name or IP:port of radiod status stream
700
701
  max_commands_per_sec: Maximum commands per second (rate limiting)
701
702
  interface: IP address of network interface for multicast (e.g., '192.168.1.100').
702
703
  Required on multi-homed systems. If None, uses INADDR_ANY (0.0.0.0).
704
+ client_id: Identity string for this client application
705
+ (e.g. ``"psk-recorder"``, ``"hf-timestd"``). When set,
706
+ :py:meth:`ensure_channel` derives a per-(client, radiod)
707
+ multicast destination via
708
+ :py:func:`ka9q.generate_multicast_ip` whenever the
709
+ caller does not supply an explicit ``destination=``.
710
+ Same client_id + same radiod -> same destination
711
+ (all of that client's channels share one multicast
712
+ group); different client_id OR different radiod ->
713
+ distinct destinations (peer clients on one station
714
+ never collide; one client targeting two radiods
715
+ sees one destination per radiod). ``None`` (default)
716
+ preserves pre-3.14 behavior: when no ``destination=``
717
+ is passed, radiod uses its config-file default and
718
+ every client lands on the same group.
703
719
  """
704
720
  self.status_address = status_address
705
721
  self.interface = interface
722
+ self.client_id = client_id
706
723
  self.socket = None
707
724
  self._status_sock = None # Cached status listener socket for tune()
708
725
  self._status_sock_lock = None # Will be initialized when needed
@@ -1363,8 +1380,16 @@ class RadiodControl:
1363
1380
  agc_enable: Enable automatic gain control (0=off, 1=on, default: 0)
1364
1381
  gain: Manual gain in dB (default: 0.0). Only used when agc_enable=0
1365
1382
  destination: RTP destination multicast address (optional).
1366
- If not specified, uses radiod's default from config file.
1367
- If specified, becomes part of the channel identity (SSRC).
1383
+ Precedence: (1) explicit ``destination=`` wins;
1384
+ (2) otherwise if the RadiodControl was constructed
1385
+ with ``client_id=``, this method derives a
1386
+ deterministic ``239.x.y.z`` address from the
1387
+ (client_id, status_address) pair so each peer
1388
+ client on a station gets its own multicast group;
1389
+ (3) otherwise (``destination=None`` and no
1390
+ ``client_id``), radiod's config-file default is
1391
+ used. The resolved address becomes part of the
1392
+ channel identity (SSRC).
1368
1393
  encoding: Output encoding (0=none, 4=F32, etc.) - see Encoding class
1369
1394
  timeout: Maximum time to wait for channel verification (default: 5.0s)
1370
1395
  frequency_tolerance: Acceptable frequency deviation in Hz (default: 1.0)
@@ -1418,15 +1443,32 @@ class RadiodControl:
1418
1443
  applications requesting the same parameters will share the same
1419
1444
  channel, reducing radiod resource usage.
1420
1445
  """
1446
+ from .addressing import generate_multicast_ip
1421
1447
  from .discovery import ChannelInfo, discover_channels
1422
-
1448
+
1423
1449
  # Validate inputs
1424
1450
  _validate_frequency(frequency_hz)
1425
1451
  _validate_sample_rate(sample_rate)
1426
1452
  _validate_gain(gain)
1427
1453
  _validate_preset(preset)
1428
1454
  _validate_timeout(timeout)
1429
-
1455
+
1456
+ # CONTRACT v0.3 §7: when the RadiodControl was constructed with a
1457
+ # client_id and the caller didn't pass an explicit destination,
1458
+ # derive a deterministic per-(client, radiod) multicast address.
1459
+ # This makes peer clients on the same host land on distinct
1460
+ # multicast groups without per-client derivation code. An
1461
+ # explicit destination= still wins (e.g. operator override).
1462
+ if destination is None and self.client_id:
1463
+ destination = generate_multicast_ip(
1464
+ unique_id=self.client_id,
1465
+ radiod_host=self.status_address,
1466
+ )
1467
+ logger.debug(
1468
+ "ensure_channel: derived destination=%s for client_id=%r "
1469
+ "radiod=%r", destination, self.client_id, self.status_address,
1470
+ )
1471
+
1430
1472
  # Compute deterministic SSRC from parameters (including radiod identity)
1431
1473
  ssrc = allocate_ssrc(
1432
1474
  frequency_hz=frequency_hz,
@@ -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."""
@@ -134,6 +137,7 @@ class MultiStream:
134
137
  high_edge: Optional[float] = None,
135
138
  kaiser_beta: Optional[float] = None,
136
139
  lifetime: Optional[int] = None,
140
+ timeout: float = 5.0,
137
141
  ) -> ChannelInfo:
138
142
  """Provision a channel and register it for reception.
139
143
 
@@ -153,6 +157,18 @@ class MultiStream:
153
157
  ``set_channel_lifetime()`` (or by calling
154
158
  RadiodControl.set_channel_lifetime directly on the SSRC).
155
159
 
160
+ ``timeout`` is forwarded to ``ensure_channel`` and bounds the
161
+ wait for radiod's status-message ACK confirming the channel
162
+ was created. The default 5 s is fine for an idle radiod, but
163
+ when many producers register channels in quick succession
164
+ (e.g. a multi-client deployment restarting) radiod can stall
165
+ long enough that 5 s isn't enough — observed on bee1 2026-05-08
166
+ as the T6 BPSK PPS channel timing out and crashing
167
+ timestd-core-recorder, which in turn left TSL3 SHM stale.
168
+ Callers in latency-sensitive setups should pass a longer
169
+ value (10-30 s) so a brief radiod-busy window doesn't fail
170
+ the whole channel registration.
171
+
156
172
  Returns the ChannelInfo from ensure_channel().
157
173
  """
158
174
  channel_info = self._control.ensure_channel(
@@ -166,6 +182,7 @@ class MultiStream:
166
182
  high_edge=high_edge,
167
183
  kaiser_beta=kaiser_beta,
168
184
  lifetime=lifetime,
185
+ timeout=timeout,
169
186
  )
170
187
 
171
188
  addr = channel_info.multicast_address
@@ -308,12 +325,26 @@ class MultiStream:
308
325
 
309
326
  sock.bind(("0.0.0.0", self._port))
310
327
 
311
- mreq = struct.pack(
312
- "=4s4s",
313
- socket.inet_aton(self._multicast_address),
314
- 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,
315
336
  )
316
- 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
+ )
317
348
  sock.settimeout(1.0)
318
349
  return sock
319
350
 
@@ -127,34 +127,72 @@ def parse_rtp_header(data: bytes) -> Optional[RTPHeader]:
127
127
  def rtp_to_wallclock(rtp_timestamp: int, channel: ChannelInfo) -> Optional[float]:
128
128
  """
129
129
  Convert RTP timestamp to Unix wall-clock time
130
-
130
+
131
131
  Uses the GPS_TIME/RTP_TIMESNAP timing information from radiod.
132
-
132
+
133
133
  Args:
134
134
  rtp_timestamp: RTP timestamp from packet header
135
135
  channel: ChannelInfo with gps_time, rtp_timesnap, sample_rate
136
-
136
+
137
137
  Returns:
138
138
  Unix timestamp (seconds) or None if timing info unavailable
139
+
140
+ Wraparound handling:
141
+ RTP timestamps are 32-bit and wrap every 2**32 samples. At
142
+ 96 kHz that's 12.43 hours; at 16 kHz it's 74.6 hours. The
143
+ ``rtp_timesnap``/``gps_time`` pair is captured once at SSRC
144
+ discovery, so an SSRC alive longer than one wrap period is
145
+ in a *later* RTP epoch than the snapshot — the naive signed
146
+ 32-bit subtraction is correct only within ±2**31 samples
147
+ (~6 hours at 96 kHz) of the snapshot, beyond which it
148
+ silently aliases.
149
+
150
+ We disambiguate by picking the wrap-epoch count ``k`` (full
151
+ 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.
156
+
157
+ Observed on bee1 2026-05-08: long-running SSRCs caused TSL3
158
+ SHM samples stuck at the snapshot's wall-clock time, ~12.4 h
159
+ behind. Chrony filtered every sample and reach fell to 0.
139
160
  """
140
161
  if channel.gps_time is None or channel.rtp_timesnap is None:
141
162
  return None
142
-
163
+
143
164
  # Convert GPS nanoseconds to Unix time
144
165
  # GPS epoch is Jan 6, 1980; Unix epoch is Jan 1, 1970
145
166
  # gps_time is nanoseconds since GPS epoch, so add GPS_UTC_OFFSET (in ns)
146
167
  # AND subtract current GPS_LEAP_SECONDS (18s) to align with UTC
147
168
  sender_time = channel.gps_time + BILLION * (GPS_UTC_OFFSET - GPS_LEAP_SECONDS)
148
-
149
- # Add offset from RTP timestamp difference
150
- # Cast to int32 for proper wrapping behavior
169
+
170
+ # Signed 32-bit RTP delta — correct within ±2**31 samples of
171
+ # the snapshot.
151
172
  rtp_delta = int((rtp_timestamp - channel.rtp_timesnap) & 0xFFFFFFFF)
152
173
  if rtp_delta > 0x7FFFFFFF:
153
174
  rtp_delta -= 0x100000000
154
-
155
- time_offset = BILLION * rtp_delta // channel.sample_rate
156
-
157
- wall_time_ns = sender_time + time_offset
175
+
176
+ base_wall_ns = sender_time + (BILLION * rtp_delta // channel.sample_rate)
177
+
178
+ # Adjust for wrap epochs. One period = 2**32 samples =
179
+ # BILLION * 2**32 // sample_rate ns. Pick the integer k that
180
+ # minimises |base_wall_ns + k*period - sys_now_ns| — i.e. the
181
+ # value closest to the system clock. Exact when sys clock is
182
+ # within ±period/2 of true UTC.
183
+ period_ns = BILLION * 0x100000000 // channel.sample_rate
184
+ sys_now_ns = int(time.time() * BILLION)
185
+ diff_ns = sys_now_ns - base_wall_ns
186
+ if period_ns > 0:
187
+ # Round-to-nearest of diff_ns / period_ns (Python `//` is
188
+ # floor; bias by half-period before flooring to round).
189
+ if diff_ns >= 0:
190
+ k = (diff_ns + period_ns // 2) // period_ns
191
+ else:
192
+ k = -(((-diff_ns) + period_ns // 2) // period_ns)
193
+ else:
194
+ k = 0
195
+ wall_time_ns = base_wall_ns + k * period_ns
158
196
 
159
197
  # Apply L6 BPSK PPS chain-delay calibration if available.
160
198
  # This corrects for the end-to-end RF→ADC→DSP→RTP latency
@@ -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):
@@ -2,7 +2,7 @@
2
2
  ka9q-radio protocol types and constants
3
3
 
4
4
  Auto-generated by scripts/sync_types.py from ka9q-radio C headers.
5
- Validated against ka9q-radio commit: 5498aefd6fd4
5
+ Validated against ka9q-radio commit: f78cff9cc8c7
6
6
 
7
7
  DO NOT EDIT MANUALLY — run: python scripts/sync_types.py --apply
8
8
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ka9q-python
3
- Version: 3.13.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
@@ -67,6 +68,7 @@ tests/__init__.py
67
68
  tests/conftest.py
68
69
  tests/test_addressing.py
69
70
  tests/test_channel_verification.py
71
+ tests/test_client_id_destination.py
70
72
  tests/test_create_split_encoding.py
71
73
  tests/test_decode_description.py
72
74
  tests/test_decode_functions.py
@@ -80,6 +82,7 @@ tests/test_lifetime.py
80
82
  tests/test_listen_multicast.py
81
83
  tests/test_managed_stream_recovery.py
82
84
  tests/test_monitor.py
85
+ tests/test_multicast_helpers.py
83
86
  tests/test_multihomed.py
84
87
  tests/test_native_discovery.py
85
88
  tests/test_performance_fixes.py
@@ -1,3 +1,3 @@
1
1
  # ka9q-radio commit that ka9q/types.py was last validated against
2
2
  # Updated by: scripts/sync_types.py --apply
3
- 5498aefd6fd4be7d4ff2f5e33c9b310ecd3b8574
3
+ f78cff9cc8c7fa33ea49aac9176d9263c535a332
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ka9q-python"
7
- version = "3.13.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 = [