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