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.
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/CHANGELOG.md +79 -0
- {ka9q_python-3.13.0/ka9q_python.egg-info → ka9q_python-3.14.1}/PKG-INFO +1 -1
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/__init__.py +1 -1
- ka9q_python-3.14.1/ka9q/_multicast.py +90 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/compat.py +1 -1
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/control.py +48 -6
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/multi_stream.py +36 -5
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/rtp_recorder.py +49 -11
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/stream.py +26 -13
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/types.py +1 -1
- {ka9q_python-3.13.0 → ka9q_python-3.14.1/ka9q_python.egg-info}/PKG-INFO +1 -1
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q_python.egg-info/SOURCES.txt +3 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q_radio_compat +1 -1
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/pyproject.toml +1 -1
- ka9q_python-3.14.1/tests/test_client_id_destination.py +150 -0
- ka9q_python-3.14.1/tests/test_multicast_helpers.py +135 -0
- ka9q_python-3.14.1/tests/test_rtp_recorder.py +120 -0
- ka9q_python-3.13.0/tests/test_rtp_recorder.py +0 -41
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/LICENSE +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/MANIFEST.in +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/README.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/docs/API_REFERENCE.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/docs/ARCHITECTURE.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/docs/CLI_GUIDE.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/docs/GETTING_STARTED.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/docs/INSTALLATION.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/docs/MULTI_STREAM.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/docs/RECIPES.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/docs/RTP_TIMING_SUPPORT.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/docs/SECURITY.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/docs/TESTING_GUIDE.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/docs/TUI_GUIDE.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/advanced_features_demo.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/channel_cleanup_example.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/codar_oceanography.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/diagnostics/diagnose_packets.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/diagnostics/repro_utc_bug.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/discover_example.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/grape_integration_example.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/hf_band_scanner.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/multi_stream_smoke.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/rtp_recorder_example.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/simple_am_radio.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/spectrum_example.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/stream_example.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/superdarn_recorder.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/test_channel_operations.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/test_improvements.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/test_timing_fields.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/tune.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/examples/tune_example.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/addressing.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/cli.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/discovery.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/exceptions.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/managed_stream.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/monitor.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/pps_calibrator.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/resequencer.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/spectrum_stream.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/status.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/stream_quality.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/tui.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q/utils.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q_python.egg-info/dependency_links.txt +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q_python.egg-info/entry_points.txt +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q_python.egg-info/requires.txt +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/ka9q_python.egg-info/top_level.txt +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/scripts/check_upstream_drift.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/scripts/sync_types.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/setup.cfg +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/setup.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/__init__.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/conftest.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_addressing.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_channel_verification.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_create_split_encoding.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_decode_description.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_decode_functions.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_encode_functions.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_encode_socket.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_ensure_channel_encoding.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_filter_edges.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_integration.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_iq_20khz_f32.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_lifetime.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_listen_multicast.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_managed_stream_recovery.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_monitor.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_multihomed.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_native_discovery.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_performance_fixes.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_protocol_compat.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_remove_channel.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_security_features.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_spectrum.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_ssrc_dest_unit.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_ssrc_encoding_unit.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_ssrc_radiod_host_unit.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_status_decoder.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_ttl_warning.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_tune.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_tune_cli.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_tune_debug.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_tune_live.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.1}/tests/test_tune_method.py +0 -0
- {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
|
|
@@ -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
|
|
@@ -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
|
-
|
|
1367
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
150
|
-
#
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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):
|
|
@@ -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:
|
|
5
|
+
Validated against ka9q-radio commit: f78cff9cc8c7
|
|
6
6
|
|
|
7
7
|
DO NOT EDIT MANUALLY — run: python scripts/sync_types.py --apply
|
|
8
8
|
"""
|
|
@@ -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
|