ka9q-python 3.12.0__tar.gz → 3.14.0__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.12.0 → ka9q_python-3.14.0}/CHANGELOG.md +85 -0
- {ka9q_python-3.12.0/ka9q_python.egg-info → ka9q_python-3.14.0}/PKG-INFO +1 -1
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q/__init__.py +1 -1
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q/compat.py +1 -1
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q/control.py +48 -6
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q/multi_stream.py +46 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q/rtp_recorder.py +49 -11
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q/types.py +1 -1
- {ka9q_python-3.12.0 → ka9q_python-3.14.0/ka9q_python.egg-info}/PKG-INFO +1 -1
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q_python.egg-info/SOURCES.txt +1 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q_radio_compat +1 -1
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/pyproject.toml +1 -1
- ka9q_python-3.14.0/tests/test_client_id_destination.py +150 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_lifetime.py +88 -0
- ka9q_python-3.14.0/tests/test_rtp_recorder.py +120 -0
- ka9q_python-3.12.0/tests/test_rtp_recorder.py +0 -41
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/LICENSE +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/MANIFEST.in +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/README.md +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/docs/API_REFERENCE.md +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/docs/ARCHITECTURE.md +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/docs/CLI_GUIDE.md +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/docs/GETTING_STARTED.md +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/docs/INSTALLATION.md +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/docs/MULTI_STREAM.md +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/docs/RECIPES.md +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/docs/RTP_TIMING_SUPPORT.md +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/docs/SECURITY.md +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/docs/TESTING_GUIDE.md +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/docs/TUI_GUIDE.md +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/examples/advanced_features_demo.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/examples/channel_cleanup_example.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/examples/codar_oceanography.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/examples/diagnostics/diagnose_packets.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/examples/diagnostics/repro_utc_bug.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/examples/discover_example.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/examples/grape_integration_example.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/examples/hf_band_scanner.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/examples/multi_stream_smoke.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/examples/rtp_recorder_example.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/examples/simple_am_radio.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/examples/spectrum_example.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/examples/stream_example.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/examples/superdarn_recorder.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/examples/test_channel_operations.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/examples/test_improvements.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/examples/test_timing_fields.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/examples/tune.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/examples/tune_example.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q/addressing.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q/cli.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q/discovery.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q/exceptions.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q/managed_stream.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q/monitor.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q/pps_calibrator.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q/resequencer.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q/spectrum_stream.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q/status.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q/stream.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q/stream_quality.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q/tui.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q/utils.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q_python.egg-info/dependency_links.txt +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q_python.egg-info/entry_points.txt +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q_python.egg-info/requires.txt +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/ka9q_python.egg-info/top_level.txt +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/scripts/check_upstream_drift.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/scripts/sync_types.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/setup.cfg +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/setup.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/__init__.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/conftest.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_addressing.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_channel_verification.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_create_split_encoding.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_decode_description.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_decode_functions.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_encode_functions.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_encode_socket.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_ensure_channel_encoding.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_filter_edges.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_integration.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_iq_20khz_f32.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_listen_multicast.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_managed_stream_recovery.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_monitor.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_multihomed.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_native_discovery.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_performance_fixes.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_protocol_compat.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_remove_channel.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_security_features.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_spectrum.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_ssrc_dest_unit.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_ssrc_encoding_unit.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_ssrc_radiod_host_unit.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_status_decoder.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_ttl_warning.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_tune.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_tune_cli.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_tune_debug.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_tune_live.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_tune_method.py +0 -0
- {ka9q_python-3.12.0 → ka9q_python-3.14.0}/tests/test_upstream_drift.py +0 -0
|
@@ -1,5 +1,90 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.14.0] - 2026-05-13
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`RadiodControl(client_id=...)` for per-client deterministic multicast
|
|
8
|
+
destinations.** Closes the CONTRACT v0.3 §7 gap where the spec said
|
|
9
|
+
"ka9q-python derives the multicast destination" but the implementation
|
|
10
|
+
never did, so every client on a given radiod landed on radiod's
|
|
11
|
+
config-file default group. Two clients with no operator action are now
|
|
12
|
+
guaranteed to use distinct multicast addresses without per-client
|
|
13
|
+
derivation code.
|
|
14
|
+
- New `client_id: Optional[str] = None` kwarg on
|
|
15
|
+
`RadiodControl.__init__`. When set, `ensure_channel(destination=None)`
|
|
16
|
+
auto-derives a `239.x.y.z` address via
|
|
17
|
+
`generate_multicast_ip(client_id, radiod_host=self.status_address)`.
|
|
18
|
+
- Destination precedence inside `ensure_channel`:
|
|
19
|
+
`(1) explicit destination=` >
|
|
20
|
+
`(2) derived from (client_id, status_address)` >
|
|
21
|
+
`(3) None → radiod's config default`.
|
|
22
|
+
- Multi-radiod handling falls out of the hash: a `psk-recorder` instance
|
|
23
|
+
pointing at `bee1-hf-status.local` derives a different address from
|
|
24
|
+
one pointing at `bee3-hf-status.local`.
|
|
25
|
+
- Multi-client handling falls out of the hash: `psk-recorder` and
|
|
26
|
+
`wspr-recorder` on the same radiod derive different addresses.
|
|
27
|
+
- `MultiStream._attempt_restore` inherits the behavior because it reuses
|
|
28
|
+
the same `RadiodControl` instance (and thus the same `client_id`).
|
|
29
|
+
Restoration after a radiod restart re-creates each channel on the
|
|
30
|
+
same per-client multicast group it was on before.
|
|
31
|
+
- Because `allocate_ssrc` already hashes `destination` into the SSRC,
|
|
32
|
+
per-client destinations produce per-client SSRCs — radiod's channel
|
|
33
|
+
table cleanly separates concurrent clients.
|
|
34
|
+
|
|
35
|
+
### Backward Compatibility
|
|
36
|
+
|
|
37
|
+
- Default behavior unchanged: `RadiodControl(status)` without `client_id`
|
|
38
|
+
preserves pre-3.14 semantics — `destination=None` flows through, radiod
|
|
39
|
+
uses its config-file default, every channel from that radiod shares one
|
|
40
|
+
multicast group. Clients opt in by passing `client_id="<name>"`.
|
|
41
|
+
|
|
42
|
+
### Tests
|
|
43
|
+
|
|
44
|
+
- 9 new unit tests in `tests/test_client_id_destination.py` cover:
|
|
45
|
+
client_id default-None / set / stored; precedence (explicit wins over
|
|
46
|
+
derived, no client_id → None); uniqueness invariants (same client on
|
|
47
|
+
two radiods → distinct; two clients on one radiod → distinct;
|
|
48
|
+
same client + same radiod → repeatable); SSRC divergence per client.
|
|
49
|
+
134 offline unit tests pass.
|
|
50
|
+
|
|
51
|
+
## [3.13.0] - 2026-05-08
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
|
|
55
|
+
- **`MultiStream` channel-lifetime support**: closes the gap from 3.10.0 where
|
|
56
|
+
`RadiodControl.{create_channel,ensure_channel,tune}` accepted a `lifetime=`
|
|
57
|
+
kwarg but `MultiStream.add_channel()` did not, leaving MultiStream-based
|
|
58
|
+
clients (psk-recorder, hfdl-recorder, hf-timestd) unable to opt into
|
|
59
|
+
radiod's channel self-destruct timer for crash-resilient cleanup.
|
|
60
|
+
- `MultiStream.add_channel(..., lifetime=None)` — optional kwarg, forwarded
|
|
61
|
+
to the internal `ensure_channel` call. Stored per-slot.
|
|
62
|
+
- **Drop/restore path now re-applies lifetime**: `_attempt_restore` reads
|
|
63
|
+
the stored slot lifetime and passes it to `ensure_channel`. Previously,
|
|
64
|
+
a channel that radiod self-destructed and MultiStream restored would
|
|
65
|
+
silently lose its LIFETIME until the next external keep-alive — the
|
|
66
|
+
most dangerous failure mode now closed.
|
|
67
|
+
- `MultiStream.set_channel_lifetime(ssrc, lifetime)` — keep-alive method
|
|
68
|
+
that updates both the wire (via `RadiodControl.set_channel_lifetime`)
|
|
69
|
+
and the slot's stored lifetime, so the value survives subsequent
|
|
70
|
+
drop/restore cycles.
|
|
71
|
+
|
|
72
|
+
### Backward Compatibility
|
|
73
|
+
|
|
74
|
+
- Default behavior unchanged: omitting `lifetime` produces a packet with no
|
|
75
|
+
LIFETIME tag (ChannelSlot.lifetime defaults to None). Existing MultiStream
|
|
76
|
+
callers see no change in wire behavior.
|
|
77
|
+
|
|
78
|
+
### Tests
|
|
79
|
+
|
|
80
|
+
- 5 new unit tests in `tests/test_lifetime.py::TestMultiStreamLifetime` cover:
|
|
81
|
+
forward-on-add, lifetime=None default, restore-reapplies-lifetime,
|
|
82
|
+
set_channel_lifetime updates slot+wire, set_channel_lifetime is a no-op for
|
|
83
|
+
unknown SSRC. 258 unit tests still green.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
|
|
3
88
|
## [3.12.0] - 2026-05-07
|
|
4
89
|
|
|
5
90
|
### Added
|
|
@@ -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,
|
|
@@ -81,6 +81,7 @@ class _ChannelSlot:
|
|
|
81
81
|
last_packet_time: float = 0.0
|
|
82
82
|
dropped: bool = False
|
|
83
83
|
first_rtp_timestamp: Optional[int] = None
|
|
84
|
+
lifetime: Optional[int] = None
|
|
84
85
|
|
|
85
86
|
|
|
86
87
|
class MultiStream:
|
|
@@ -132,6 +133,8 @@ class MultiStream:
|
|
|
132
133
|
low_edge: Optional[float] = None,
|
|
133
134
|
high_edge: Optional[float] = None,
|
|
134
135
|
kaiser_beta: Optional[float] = None,
|
|
136
|
+
lifetime: Optional[int] = None,
|
|
137
|
+
timeout: float = 5.0,
|
|
135
138
|
) -> ChannelInfo:
|
|
136
139
|
"""Provision a channel and register it for reception.
|
|
137
140
|
|
|
@@ -142,6 +145,27 @@ class MultiStream:
|
|
|
142
145
|
preset's default passband. None = use preset defaults. See
|
|
143
146
|
RadiodControl.ensure_channel for the full semantics.
|
|
144
147
|
|
|
148
|
+
``lifetime`` opts the channel into radiod's self-destruct timer
|
|
149
|
+
(radiod commit 0f8b622+, see RadiodControl.set_channel_lifetime).
|
|
150
|
+
The value is stored per-slot so the drop/restore path re-applies
|
|
151
|
+
it: a channel that radiod self-destructs and we then restore
|
|
152
|
+
won't silently lose its lifetime. The caller is still
|
|
153
|
+
responsible for periodic keep-alive via
|
|
154
|
+
``set_channel_lifetime()`` (or by calling
|
|
155
|
+
RadiodControl.set_channel_lifetime directly on the SSRC).
|
|
156
|
+
|
|
157
|
+
``timeout`` is forwarded to ``ensure_channel`` and bounds the
|
|
158
|
+
wait for radiod's status-message ACK confirming the channel
|
|
159
|
+
was created. The default 5 s is fine for an idle radiod, but
|
|
160
|
+
when many producers register channels in quick succession
|
|
161
|
+
(e.g. a multi-client deployment restarting) radiod can stall
|
|
162
|
+
long enough that 5 s isn't enough — observed on bee1 2026-05-08
|
|
163
|
+
as the T6 BPSK PPS channel timing out and crashing
|
|
164
|
+
timestd-core-recorder, which in turn left TSL3 SHM stale.
|
|
165
|
+
Callers in latency-sensitive setups should pass a longer
|
|
166
|
+
value (10-30 s) so a brief radiod-busy window doesn't fail
|
|
167
|
+
the whole channel registration.
|
|
168
|
+
|
|
145
169
|
Returns the ChannelInfo from ensure_channel().
|
|
146
170
|
"""
|
|
147
171
|
channel_info = self._control.ensure_channel(
|
|
@@ -154,6 +178,8 @@ class MultiStream:
|
|
|
154
178
|
low_edge=low_edge,
|
|
155
179
|
high_edge=high_edge,
|
|
156
180
|
kaiser_beta=kaiser_beta,
|
|
181
|
+
lifetime=lifetime,
|
|
182
|
+
timeout=timeout,
|
|
157
183
|
)
|
|
158
184
|
|
|
159
185
|
addr = channel_info.multicast_address
|
|
@@ -189,6 +215,7 @@ class MultiStream:
|
|
|
189
215
|
on_stream_dropped=on_stream_dropped,
|
|
190
216
|
on_stream_restored=on_stream_restored,
|
|
191
217
|
deliver_interval=self._deliver_interval,
|
|
218
|
+
lifetime=lifetime,
|
|
192
219
|
)
|
|
193
220
|
self._slots[ssrc] = slot
|
|
194
221
|
|
|
@@ -224,6 +251,24 @@ class MultiStream:
|
|
|
224
251
|
f"{self._multicast_address}:{self._port}"
|
|
225
252
|
)
|
|
226
253
|
|
|
254
|
+
def set_channel_lifetime(self, ssrc: int, lifetime: int) -> None:
|
|
255
|
+
"""Refresh the LIFETIME tag on one channel and update slot state.
|
|
256
|
+
|
|
257
|
+
Suitable as a periodic keep-alive: callers should invoke this on
|
|
258
|
+
every active SSRC at a cadence shorter than the lifetime so the
|
|
259
|
+
radiod self-destruct counter never reaches zero. The new value
|
|
260
|
+
is also stored in the slot, so a subsequent drop/restore will
|
|
261
|
+
re-apply this value rather than the original ``add_channel``
|
|
262
|
+
argument.
|
|
263
|
+
|
|
264
|
+
No-op if ``ssrc`` is not in this MultiStream.
|
|
265
|
+
"""
|
|
266
|
+
slot = self._slots.get(ssrc)
|
|
267
|
+
if slot is None:
|
|
268
|
+
return
|
|
269
|
+
self._control.set_channel_lifetime(ssrc, lifetime)
|
|
270
|
+
slot.lifetime = lifetime
|
|
271
|
+
|
|
227
272
|
def stop(self) -> None:
|
|
228
273
|
"""Stop threads and close socket."""
|
|
229
274
|
if not self._running:
|
|
@@ -426,6 +471,7 @@ class MultiStream:
|
|
|
426
471
|
preset=slot.preset,
|
|
427
472
|
sample_rate=slot.sample_rate,
|
|
428
473
|
encoding=slot.encoding,
|
|
474
|
+
lifetime=slot.lifetime,
|
|
429
475
|
)
|
|
430
476
|
new_ssrc = channel_info.ssrc
|
|
431
477
|
if new_ssrc != ssrc:
|
|
@@ -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
|
|
@@ -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
|
"""
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""RadiodControl(client_id=...) destination-derivation tests.
|
|
2
|
+
|
|
3
|
+
When ``client_id`` is set, ``ensure_channel(destination=None)`` should
|
|
4
|
+
auto-derive a per-(client, radiod) multicast address so peer clients on
|
|
5
|
+
the same host land on distinct multicast groups without per-client
|
|
6
|
+
derivation code. Tests mock ``_connect`` and short-circuit
|
|
7
|
+
``create_channel`` to capture the resolved destination without
|
|
8
|
+
requiring a live radiod or running the verify loop.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import unittest
|
|
14
|
+
from unittest.mock import patch
|
|
15
|
+
|
|
16
|
+
from ka9q.addressing import generate_multicast_ip
|
|
17
|
+
from ka9q.control import RadiodControl
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _ensure_dest(ctrl: RadiodControl, *, destination=None) -> object:
|
|
21
|
+
"""Drive ensure_channel to ``create_channel`` and return the
|
|
22
|
+
destination it would have received. ``create_channel`` raises to
|
|
23
|
+
short-circuit the post-create verify loop (which would otherwise
|
|
24
|
+
spin discover_channels for ``timeout`` seconds)."""
|
|
25
|
+
captured: dict = {}
|
|
26
|
+
|
|
27
|
+
def _capture(*_args, **kwargs):
|
|
28
|
+
captured['destination'] = kwargs.get('destination')
|
|
29
|
+
raise _ShortCircuit()
|
|
30
|
+
|
|
31
|
+
# ensure_channel re-imports discover_channels from ka9q.discovery
|
|
32
|
+
# *inside* the function body, so the patch target is the
|
|
33
|
+
# discovery module, not the control module's alias.
|
|
34
|
+
with patch('ka9q.discovery.discover_channels', return_value={}), \
|
|
35
|
+
patch.object(ctrl, 'create_channel', side_effect=_capture):
|
|
36
|
+
try:
|
|
37
|
+
ctrl.ensure_channel(
|
|
38
|
+
frequency_hz=14_074_000.0,
|
|
39
|
+
preset="iq",
|
|
40
|
+
sample_rate=16000,
|
|
41
|
+
destination=destination,
|
|
42
|
+
)
|
|
43
|
+
except _ShortCircuit:
|
|
44
|
+
pass
|
|
45
|
+
return captured.get('destination')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _ShortCircuit(Exception):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TestClientIdStored(unittest.TestCase):
|
|
53
|
+
@patch('ka9q.control.RadiodControl._connect')
|
|
54
|
+
def test_client_id_default_none(self, _c):
|
|
55
|
+
ctrl = RadiodControl("radiod.local")
|
|
56
|
+
self.assertIsNone(ctrl.client_id)
|
|
57
|
+
|
|
58
|
+
@patch('ka9q.control.RadiodControl._connect')
|
|
59
|
+
def test_client_id_stored(self, _c):
|
|
60
|
+
ctrl = RadiodControl("radiod.local", client_id="psk-recorder")
|
|
61
|
+
self.assertEqual(ctrl.client_id, "psk-recorder")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestDestinationPrecedence(unittest.TestCase):
|
|
65
|
+
"""ensure_channel's destination= resolution order:
|
|
66
|
+
explicit > derived (from client_id+status) > None.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
@patch('ka9q.control.RadiodControl._connect')
|
|
70
|
+
def test_explicit_destination_wins_over_client_id(self, _c):
|
|
71
|
+
ctrl = RadiodControl("radiod.local", client_id="psk-recorder")
|
|
72
|
+
dest = _ensure_dest(ctrl, destination="239.1.2.3")
|
|
73
|
+
self.assertEqual(dest, "239.1.2.3")
|
|
74
|
+
|
|
75
|
+
@patch('ka9q.control.RadiodControl._connect')
|
|
76
|
+
def test_no_client_id_keeps_destination_none(self, _c):
|
|
77
|
+
"""Pre-3.14 behavior preserved: no client_id, no explicit
|
|
78
|
+
destination -> destination=None flows through so radiod uses
|
|
79
|
+
its config-file default."""
|
|
80
|
+
ctrl = RadiodControl("radiod.local")
|
|
81
|
+
dest = _ensure_dest(ctrl, destination=None)
|
|
82
|
+
self.assertIsNone(dest)
|
|
83
|
+
|
|
84
|
+
@patch('ka9q.control.RadiodControl._connect')
|
|
85
|
+
def test_client_id_derives_destination(self, _c):
|
|
86
|
+
ctrl = RadiodControl("bee1-hf-status.local", client_id="psk-recorder")
|
|
87
|
+
expected = generate_multicast_ip(
|
|
88
|
+
"psk-recorder", radiod_host="bee1-hf-status.local",
|
|
89
|
+
)
|
|
90
|
+
dest = _ensure_dest(ctrl, destination=None)
|
|
91
|
+
self.assertEqual(dest, expected)
|
|
92
|
+
self.assertTrue(dest.startswith("239."))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TestDestinationUniqueness(unittest.TestCase):
|
|
96
|
+
"""The two invariants the operator-facing design promises:
|
|
97
|
+
(a) same client on two radiods -> two distinct destinations
|
|
98
|
+
(b) two clients on one radiod -> two distinct destinations
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
@patch('ka9q.control.RadiodControl._connect')
|
|
102
|
+
def test_same_client_different_radiods(self, _c):
|
|
103
|
+
a = RadiodControl("bee1-hf-status.local", client_id="psk-recorder")
|
|
104
|
+
b = RadiodControl("bee3-hf-status.local", client_id="psk-recorder")
|
|
105
|
+
self.assertNotEqual(_ensure_dest(a), _ensure_dest(b),
|
|
106
|
+
"psk-recorder on radiod-0 vs radiod-1 must use "
|
|
107
|
+
"distinct multicast groups")
|
|
108
|
+
|
|
109
|
+
@patch('ka9q.control.RadiodControl._connect')
|
|
110
|
+
def test_different_clients_same_radiod(self, _c):
|
|
111
|
+
a = RadiodControl("bee1-hf-status.local", client_id="psk-recorder")
|
|
112
|
+
b = RadiodControl("bee1-hf-status.local", client_id="wspr-recorder")
|
|
113
|
+
self.assertNotEqual(_ensure_dest(a), _ensure_dest(b),
|
|
114
|
+
"psk-recorder and wspr-recorder on one radiod "
|
|
115
|
+
"must use distinct multicast groups")
|
|
116
|
+
|
|
117
|
+
@patch('ka9q.control.RadiodControl._connect')
|
|
118
|
+
def test_same_client_same_radiod_repeatable(self, _c):
|
|
119
|
+
"""Restart must bind to the same multicast group."""
|
|
120
|
+
a = RadiodControl("bee1-hf-status.local", client_id="psk-recorder")
|
|
121
|
+
b = RadiodControl("bee1-hf-status.local", client_id="psk-recorder")
|
|
122
|
+
self.assertEqual(_ensure_dest(a), _ensure_dest(b))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TestDestinationParticipatesInSsrc(unittest.TestCase):
|
|
126
|
+
"""allocate_ssrc already hashes destination into the SSRC, so two
|
|
127
|
+
clients with derived destinations must produce different SSRCs
|
|
128
|
+
even for identical channel parameters."""
|
|
129
|
+
|
|
130
|
+
@patch('ka9q.control.RadiodControl._connect')
|
|
131
|
+
def test_ssrcs_diverge_per_client(self, _c):
|
|
132
|
+
from ka9q.control import allocate_ssrc
|
|
133
|
+
|
|
134
|
+
params = dict(frequency_hz=14_074_000.0, preset="iq",
|
|
135
|
+
sample_rate=16000, agc=False, gain=0.0)
|
|
136
|
+
psk_dest = generate_multicast_ip("psk-recorder",
|
|
137
|
+
radiod_host="bee1-hf-status.local")
|
|
138
|
+
wspr_dest = generate_multicast_ip("wspr-recorder",
|
|
139
|
+
radiod_host="bee1-hf-status.local")
|
|
140
|
+
psk_ssrc = allocate_ssrc(**params, destination=psk_dest,
|
|
141
|
+
radiod_host="bee1-hf-status.local")
|
|
142
|
+
wspr_ssrc = allocate_ssrc(**params, destination=wspr_dest,
|
|
143
|
+
radiod_host="bee1-hf-status.local")
|
|
144
|
+
self.assertNotEqual(psk_ssrc, wspr_ssrc,
|
|
145
|
+
"Per-client destination must produce per-client "
|
|
146
|
+
"SSRC, so radiod's channel table separates them")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
unittest.main()
|
|
@@ -173,3 +173,91 @@ class TestCreateChannelLifetime:
|
|
|
173
173
|
)
|
|
174
174
|
assert sent
|
|
175
175
|
assert not _has_lifetime_tag(sent[0])
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class TestMultiStreamLifetime:
|
|
179
|
+
"""MultiStream stores ``lifetime`` per-slot and forwards it to
|
|
180
|
+
ensure_channel on both the initial add and the drop/restore path,
|
|
181
|
+
plus exposes a set_channel_lifetime() keep-alive method.
|
|
182
|
+
|
|
183
|
+
Asserted at the ``RadiodControl.ensure_channel`` boundary — these
|
|
184
|
+
tests don't exercise the wire encoding (covered above), they verify
|
|
185
|
+
that MultiStream wires the kwarg through correctly.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def _make_multi_with_mock_control(self, ssrc=12345):
|
|
189
|
+
from ka9q.multi_stream import MultiStream
|
|
190
|
+
from ka9q.discovery import ChannelInfo
|
|
191
|
+
|
|
192
|
+
control = MagicMock()
|
|
193
|
+
control.ensure_channel.return_value = ChannelInfo(
|
|
194
|
+
ssrc=ssrc,
|
|
195
|
+
preset="iq",
|
|
196
|
+
sample_rate=12000,
|
|
197
|
+
frequency=14_074_000.0,
|
|
198
|
+
snr=0.0,
|
|
199
|
+
multicast_address="239.1.2.3",
|
|
200
|
+
port=5004,
|
|
201
|
+
)
|
|
202
|
+
multi = MultiStream(control=control)
|
|
203
|
+
return multi, control
|
|
204
|
+
|
|
205
|
+
def test_add_channel_forwards_lifetime(self):
|
|
206
|
+
multi, control = self._make_multi_with_mock_control()
|
|
207
|
+
multi.add_channel(
|
|
208
|
+
frequency_hz=14_074_000.0,
|
|
209
|
+
preset="iq",
|
|
210
|
+
sample_rate=12000,
|
|
211
|
+
lifetime=6000,
|
|
212
|
+
)
|
|
213
|
+
kwargs = control.ensure_channel.call_args.kwargs
|
|
214
|
+
assert kwargs["lifetime"] == 6000
|
|
215
|
+
|
|
216
|
+
def test_add_channel_lifetime_none_when_omitted(self):
|
|
217
|
+
multi, control = self._make_multi_with_mock_control()
|
|
218
|
+
multi.add_channel(
|
|
219
|
+
frequency_hz=14_074_000.0,
|
|
220
|
+
preset="iq",
|
|
221
|
+
sample_rate=12000,
|
|
222
|
+
)
|
|
223
|
+
kwargs = control.ensure_channel.call_args.kwargs
|
|
224
|
+
assert kwargs["lifetime"] is None
|
|
225
|
+
|
|
226
|
+
def test_restore_reapplies_stored_lifetime(self):
|
|
227
|
+
"""A slot added with lifetime=N must re-pass N on _attempt_restore."""
|
|
228
|
+
multi, control = self._make_multi_with_mock_control()
|
|
229
|
+
multi.add_channel(
|
|
230
|
+
frequency_hz=14_074_000.0,
|
|
231
|
+
preset="iq",
|
|
232
|
+
sample_rate=12000,
|
|
233
|
+
lifetime=6000,
|
|
234
|
+
)
|
|
235
|
+
ssrc = next(iter(multi._slots))
|
|
236
|
+
slot = multi._slots[ssrc]
|
|
237
|
+
slot.dropped = True
|
|
238
|
+
control.ensure_channel.reset_mock()
|
|
239
|
+
|
|
240
|
+
multi._attempt_restore(ssrc, slot)
|
|
241
|
+
|
|
242
|
+
kwargs = control.ensure_channel.call_args.kwargs
|
|
243
|
+
assert kwargs["lifetime"] == 6000
|
|
244
|
+
|
|
245
|
+
def test_set_channel_lifetime_updates_slot_and_wire(self):
|
|
246
|
+
multi, control = self._make_multi_with_mock_control()
|
|
247
|
+
multi.add_channel(
|
|
248
|
+
frequency_hz=14_074_000.0,
|
|
249
|
+
preset="iq",
|
|
250
|
+
sample_rate=12000,
|
|
251
|
+
lifetime=6000,
|
|
252
|
+
)
|
|
253
|
+
ssrc = next(iter(multi._slots))
|
|
254
|
+
|
|
255
|
+
multi.set_channel_lifetime(ssrc, 9000)
|
|
256
|
+
|
|
257
|
+
control.set_channel_lifetime.assert_called_once_with(ssrc, 9000)
|
|
258
|
+
assert multi._slots[ssrc].lifetime == 9000
|
|
259
|
+
|
|
260
|
+
def test_set_channel_lifetime_unknown_ssrc_is_noop(self):
|
|
261
|
+
multi, control = self._make_multi_with_mock_control()
|
|
262
|
+
multi.set_channel_lifetime(99999, 6000)
|
|
263
|
+
control.set_channel_lifetime.assert_not_called()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for RTP recorder functionality
|
|
3
|
+
"""
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from ka9q.rtp_recorder import rtp_to_wallclock
|
|
9
|
+
from ka9q.discovery import ChannelInfo
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
GPS_UTC_OFFSET = 315964800
|
|
13
|
+
BILLION = 1_000_000_000
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _channel(sample_rate, gps_time_ns, rtp_timesnap):
|
|
17
|
+
return ChannelInfo(
|
|
18
|
+
ssrc=1234,
|
|
19
|
+
preset="test",
|
|
20
|
+
sample_rate=sample_rate,
|
|
21
|
+
frequency=100.0,
|
|
22
|
+
snr=0.0,
|
|
23
|
+
multicast_address="239.1.2.3",
|
|
24
|
+
port=5004,
|
|
25
|
+
gps_time=gps_time_ns,
|
|
26
|
+
rtp_timesnap=rtp_timesnap,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_rtp_to_wallclock_basic():
|
|
31
|
+
"""Same RTP as snapshot → exactly GPS+offset; +N samples → +N/sample_rate s."""
|
|
32
|
+
gps_time_ns = 1234567890000000000 # ~2019
|
|
33
|
+
channel = _channel(48000, gps_time_ns, 1000)
|
|
34
|
+
expected_sec = (gps_time_ns + BILLION * (GPS_UTC_OFFSET - 18)) / BILLION
|
|
35
|
+
|
|
36
|
+
# Pin system clock close to the channel's wall-clock so the
|
|
37
|
+
# wrap-disambiguation picks epoch k=0 deterministically.
|
|
38
|
+
with patch("ka9q.rtp_recorder.time.time", return_value=expected_sec):
|
|
39
|
+
assert rtp_to_wallclock(1000, channel) == pytest.approx(expected_sec)
|
|
40
|
+
assert rtp_to_wallclock(1000 + 48000, channel) == pytest.approx(expected_sec + 1.0)
|
|
41
|
+
# One sample later → +1/48000 s
|
|
42
|
+
assert rtp_to_wallclock(1001, channel) == pytest.approx(expected_sec + 1 / 48000.0)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_rtp_to_wallclock_signed_32bit_window():
|
|
46
|
+
"""RTP within ±2**31 samples of snapshot (the natively-correct window)
|
|
47
|
+
→ no wrap adjustment needed regardless of system clock."""
|
|
48
|
+
gps_time_ns = 1234567890000000000
|
|
49
|
+
channel = _channel(96000, gps_time_ns, 1000)
|
|
50
|
+
base_wall = (gps_time_ns + BILLION * (GPS_UTC_OFFSET - 18)) / BILLION
|
|
51
|
+
|
|
52
|
+
# 30 minutes ahead at 96 kHz = +30*60*96000 = 172_800_000 samples
|
|
53
|
+
target_rtp = (1000 + 172_800_000) & 0xFFFFFFFF
|
|
54
|
+
expected = base_wall + 30 * 60.0
|
|
55
|
+
with patch("ka9q.rtp_recorder.time.time", return_value=expected):
|
|
56
|
+
assert rtp_to_wallclock(target_rtp, channel) == pytest.approx(expected)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_rtp_to_wallclock_wraps_correctly_after_one_period():
|
|
60
|
+
"""After one full RTP wrap (2**32 samples), naive subtraction aliases
|
|
61
|
+
to ~snapshot. System-clock disambiguation must return the right
|
|
62
|
+
wall-clock value one wrap later."""
|
|
63
|
+
sample_rate = 96000
|
|
64
|
+
gps_time_ns = 1234567890000000000
|
|
65
|
+
channel = _channel(sample_rate, gps_time_ns, 1000)
|
|
66
|
+
snapshot_wall = (gps_time_ns + BILLION * (GPS_UTC_OFFSET - 18)) / BILLION
|
|
67
|
+
|
|
68
|
+
period_sec = 0x100000000 / sample_rate # 12.43 h
|
|
69
|
+
target_rtp = (1000 + 0x100000000) & 0xFFFFFFFF # exactly 1 wrap later
|
|
70
|
+
expected = snapshot_wall + period_sec
|
|
71
|
+
|
|
72
|
+
# System clock at the *expected* time — wrap counter k must be 1.
|
|
73
|
+
with patch("ka9q.rtp_recorder.time.time", return_value=expected):
|
|
74
|
+
result = rtp_to_wallclock(target_rtp, channel)
|
|
75
|
+
assert result == pytest.approx(expected, abs=1e-3)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_rtp_to_wallclock_wraps_correctly_after_two_periods():
|
|
79
|
+
"""Same idea, two full wraps (~24.86 h after snapshot at 96 kHz)."""
|
|
80
|
+
sample_rate = 96000
|
|
81
|
+
gps_time_ns = 1234567890000000000
|
|
82
|
+
channel = _channel(sample_rate, gps_time_ns, 1000)
|
|
83
|
+
snapshot_wall = (gps_time_ns + BILLION * (GPS_UTC_OFFSET - 18)) / BILLION
|
|
84
|
+
|
|
85
|
+
period_sec = 0x100000000 / sample_rate
|
|
86
|
+
target_rtp = (1000 + 2 * 0x100000000 + 100) & 0xFFFFFFFF # 2 wraps + 100 samples
|
|
87
|
+
expected = snapshot_wall + 2 * period_sec + 100 / sample_rate
|
|
88
|
+
|
|
89
|
+
with patch("ka9q.rtp_recorder.time.time", return_value=expected):
|
|
90
|
+
result = rtp_to_wallclock(target_rtp, channel)
|
|
91
|
+
assert result == pytest.approx(expected, abs=1e-3)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_rtp_to_wallclock_disambiguator_tolerates_clock_skew():
|
|
95
|
+
"""System clock can be off by hours (NTP not yet locked, holdover,
|
|
96
|
+
operator skew) and the wrap disambiguator should still pick the
|
|
97
|
+
right epoch as long as skew is < period/2 (~6 h at 96 kHz)."""
|
|
98
|
+
sample_rate = 96000
|
|
99
|
+
gps_time_ns = 1234567890000000000
|
|
100
|
+
channel = _channel(sample_rate, gps_time_ns, 1000)
|
|
101
|
+
snapshot_wall = (gps_time_ns + BILLION * (GPS_UTC_OFFSET - 18)) / BILLION
|
|
102
|
+
|
|
103
|
+
period_sec = 0x100000000 / sample_rate
|
|
104
|
+
target_rtp = (1000 + 0x100000000 + 50_000_000) & 0xFFFFFFFF
|
|
105
|
+
true_wall = snapshot_wall + period_sec + 50_000_000 / sample_rate # 12.43 h + ~520 s
|
|
106
|
+
|
|
107
|
+
# System clock 4 hours off (within ±6 h tolerance window).
|
|
108
|
+
skewed_now = true_wall - 4 * 3600.0
|
|
109
|
+
with patch("ka9q.rtp_recorder.time.time", return_value=skewed_now):
|
|
110
|
+
result = rtp_to_wallclock(target_rtp, channel)
|
|
111
|
+
assert result == pytest.approx(true_wall, abs=1e-3)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_rtp_to_wallclock_returns_none_when_timing_missing():
|
|
115
|
+
channel = _channel(48000, None, None)
|
|
116
|
+
assert rtp_to_wallclock(1000, channel) is None
|
|
117
|
+
channel = _channel(48000, 1234567890000000000, None)
|
|
118
|
+
assert rtp_to_wallclock(1000, channel) is None
|
|
119
|
+
channel = _channel(48000, None, 1000)
|
|
120
|
+
assert rtp_to_wallclock(1000, channel) is None
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Tests for RTP recorder functionality
|
|
3
|
-
"""
|
|
4
|
-
import pytest
|
|
5
|
-
from ka9q.rtp_recorder import rtp_to_wallclock
|
|
6
|
-
from ka9q.discovery import ChannelInfo
|
|
7
|
-
|
|
8
|
-
def test_rtp_to_wallclock():
|
|
9
|
-
"""Test GPS time to Unix time conversion"""
|
|
10
|
-
# Channel info with mocked timing
|
|
11
|
-
# GPS Time: 1234567890000000000 ns (random recent-ish GPS time)
|
|
12
|
-
# This corresponds to some time around 2019
|
|
13
|
-
gps_time_ns = 1234567890000000000
|
|
14
|
-
|
|
15
|
-
# Constants from code
|
|
16
|
-
GPS_UTC_OFFSET = 315964800
|
|
17
|
-
BILLION = 1_000_000_000
|
|
18
|
-
|
|
19
|
-
channel = ChannelInfo(
|
|
20
|
-
ssrc=1234,
|
|
21
|
-
preset="test",
|
|
22
|
-
sample_rate=48000,
|
|
23
|
-
frequency=100.0,
|
|
24
|
-
snr=0.0,
|
|
25
|
-
multicast_address="239.1.2.3",
|
|
26
|
-
port=5004,
|
|
27
|
-
gps_time=gps_time_ns,
|
|
28
|
-
rtp_timesnap=1000
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
# Case 1: Same RTP timestamp as snapshot
|
|
32
|
-
# Result should be exactly GPS time + offset
|
|
33
|
-
# Expected Unix time = GPS time + Offset - Leap Seconds
|
|
34
|
-
expected_unix_ns = gps_time_ns + (BILLION * (GPS_UTC_OFFSET - 18))
|
|
35
|
-
expected_unix_sec = expected_unix_ns / BILLION
|
|
36
|
-
|
|
37
|
-
assert rtp_to_wallclock(1000, channel) == pytest.approx(expected_unix_sec)
|
|
38
|
-
|
|
39
|
-
# Case 2: One second later
|
|
40
|
-
# 48000 samples later
|
|
41
|
-
assert rtp_to_wallclock(1000 + 48000, channel) == pytest.approx(expected_unix_sec + 1.0)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|