From c7850ea4a1d6594aa31c60eae2801af8b1193c38 Mon Sep 17 00:00:00 2001 From: TIM ANDERSON Date: Tue, 27 Oct 2026 14:36:24 +1000 Subject: [PATCH] feat(player): add opt-in removal of long recorded gaps --- can/io/player.py | 14 +++- can/player.py | 11 ++- doc/changelog.d/1646.added.rst | 3 + doc/scripts.rst | 10 +++ test/test_skip_gaps.py | 130 +++++++++++++++++++++++++++++++++ 5 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 doc/changelog.d/1646.added.rst create mode 100644 test/test_skip_gaps.py diff --git a/can/io/player.py b/can/io/player.py index c0015b185..02bfe3651 100644 --- a/can/io/player.py +++ b/can/io/player.py @@ -136,6 +136,8 @@ def __init__( timestamps: bool = True, gap: float = 0.0001, skip: float = 60.0, + *, + skip_gaps: bool = False, ) -> None: """Creates an new **MessageSync** instance. @@ -143,7 +145,11 @@ def __init__( :param timestamps: Use the messages' timestamps. If False, uses the *gap* parameter as the time between messages. :param gap: Minimum time between sent messages in seconds - :param skip: Skip periods of inactivity greater than this (in seconds). + :param skip: Cap remaining playback waits at this many seconds. Zero + disables capping and preserves the recorded delays. + :param skip_gaps: With recorded timestamps, remove remaining waits greater + than *skip* instead of capping them. Defaults to False. + Has no effect when *skip* is zero or *timestamps* is False. Example:: @@ -159,6 +165,7 @@ def __init__( self.timestamps = timestamps self.gap = gap self.skip = skip + self.skip_gaps = skip_gaps def __iter__(self) -> Generator[Message, None, None]: t_wakeup = playback_start_time = time.perf_counter() @@ -180,8 +187,9 @@ def __iter__(self) -> Generator[Message, None, None]: sleep_period = t_wakeup - time.perf_counter() if self.skip and sleep_period > self.skip: - t_skipped += sleep_period - self.skip - sleep_period = self.skip + delay = 0.0 if self.timestamps and self.skip_gaps else self.skip + t_skipped += sleep_period - delay + sleep_period = delay if sleep_period > 1e-4: time.sleep(sleep_period) diff --git a/can/player.py b/can/player.py index 6190a58d8..632370fad 100644 --- a/can/player.py +++ b/can/player.py @@ -96,12 +96,18 @@ def main() -> None: "--skip", type=float, default=60 * 60 * 24, - help="Skip gaps greater than 's' seconds between messages. " - "Default is 86400 (24 hours), meaning only very large gaps are skipped. " + help="Cap playback waits at 's' seconds (default: 86400, or 24 hours). " "Set to 0 to never skip any gaps (all delays are preserved). " "Set to a very small value (e.g., 1e-4) " "to skip all gaps and send messages as fast as possible.", ) + player_group.add_argument( + "--remove-gaps", + dest="skip_gaps", + action="store_true", + help="Remove recorded-timestamp waits greater than --skip instead of " + "capping them. Has no effect with --skip 0 or --ignore-timestamps.", + ) player_group.add_argument( "-l", "--loop", @@ -147,6 +153,7 @@ def main() -> None: timestamps=results.timestamps, gap=results.gap, skip=results.skip, + skip_gaps=results.skip_gaps, ) print(_format_player_start_message(iteration, loop_count)) diff --git a/doc/changelog.d/1646.added.rst b/doc/changelog.d/1646.added.rst new file mode 100644 index 000000000..d1056d179 --- /dev/null +++ b/doc/changelog.d/1646.added.rst @@ -0,0 +1,3 @@ +Add opt-in ``MessageSync(skip_gaps=True)`` and player ``--remove-gaps`` to remove +long recorded-timestamp waits instead of capping them at ``skip``. Existing +defaults, ``skip=0`` and fixed-gap playback retain their behavior. diff --git a/doc/scripts.rst b/doc/scripts.rst index 1d730a74b..4f6404053 100644 --- a/doc/scripts.rst +++ b/doc/scripts.rst @@ -21,6 +21,16 @@ can.player .. command-output:: python -m can.player -h :shell: +By default, ``--skip S`` caps each remaining playback wait at ``S`` seconds; +it does not remove the whole wait. Add ``--remove-gaps`` to remove waits greater +than that threshold when replaying recorded timestamps. A wait equal to the +threshold is retained. For example, ``--skip 1 --remove-gaps`` removes a five-second +wait entirely, whereas ``--skip 1`` retains one second. Later recorded intervals +are preserved relative to the shortened playback timeline. + +``--skip 0`` disables both behaviors. ``--remove-gaps`` has no effect with +``--ignore-timestamps``, which keeps the existing fixed-gap playback behavior. + can.viewer ---------- diff --git a/test/test_skip_gaps.py b/test/test_skip_gaps.py new file mode 100644 index 000000000..e1aaf6943 --- /dev/null +++ b/test/test_skip_gaps.py @@ -0,0 +1,130 @@ +"""Deterministic playback schedules, without real sleeps or CAN hardware.""" + +from unittest.mock import MagicMock, patch + +import pytest + +import can +import can.player + + +class Clock: + def __init__(self): + self.now = 100.0 + + def perf_counter(self): + return self.now + + def sleep(self, delay): + assert delay > 0 + self.now += delay + + +@pytest.mark.parametrize("remove", [False, True]) +@pytest.mark.parametrize( + "timestamps,skip,capped,removed", + [ + ([0, 0.25, 5.25, 5.5], 1, [0, 0.25, 1.25, 1.5], [0, 0.25, 0.25, 0.5]), + ([0, 1, 2], 1, [0, 1, 2], [0, 1, 2]), + ([0, 5, 10, 10.25], 1, [0, 1, 2, 2.25], [0, 0, 0, 0.25]), + ([0, 0, -1, 0.25], 1, [0, 0, 0, 0.25], [0, 0, 0, 0.25]), + ([0, 0.25, 5.25, 5.5], 0, [0, 0.25, 5.25, 5.5], [0, 0.25, 5.25, 5.5]), + ], +) +def test_recorded_schedule(remove, timestamps, skip, capped, removed): + clock = Clock() + messages = [ + can.Message(timestamp=t, arbitration_id=i) for i, t in enumerate(timestamps) + ] + options = {"skip_gaps": True} if remove else {} + observed = [] + collected = [] + with ( + patch("can.io.player.time.perf_counter", clock.perf_counter), + patch("can.io.player.time.sleep", clock.sleep), + ): + for message in can.MessageSync(iter(messages), skip=skip, **options): + observed.append(round(clock.now - 100, 6)) + collected.append(message) + assert observed == (removed if remove else capped) + assert len(collected) == len(messages) + assert all(a is b for a, b in zip(collected, messages)) + + +@pytest.mark.parametrize("remove", [False, True]) +@pytest.mark.parametrize("gap,expected", [(0.25, [0.25, 0.5]), (2, [1, 2])]) +def test_fixed_gap_unchanged(remove, gap, expected): + clock = Clock() + options = {"skip_gaps": True} if remove else {} + observed = [] + with ( + patch("can.io.player.time.perf_counter", clock.perf_counter), + patch("can.io.player.time.sleep", clock.sleep), + ): + for _ in can.MessageSync( + [can.Message(), can.Message()], + timestamps=False, + gap=gap, + skip=1, + **options, + ): + observed.append(clock.now - 100) + assert observed == expected + + +@pytest.mark.parametrize("remove", [False, True]) +@pytest.mark.parametrize("loops", [1, 2]) +def test_player_cli(remove, loops): + clock = Clock() + bus = MagicMock() + observed = [] + bus.send.side_effect = lambda message: observed.append( + (round(clock.now - 100, 6), message.arbitration_id) + ) + argv = [ + "can_player", + "--sk", + "1", + "--loop", + str(loops), + "test/data/test_CanMessage.asc", + ] + if remove: + argv.insert(1, "--remove-gaps") + with ( + patch("sys.argv", argv), + patch("can.player.create_bus_from_namespace") as create_bus, + patch("can.io.player.time.perf_counter", clock.perf_counter), + patch("can.io.player.time.sleep", clock.sleep), + ): + create_bus.return_value.__enter__.return_value = bus + can.player.main() + expected = [] + for iteration in range(loops): + expected.extend( + [ + (0 if remove else iteration, 0xC8), + (0 if remove else iteration + 1, 0x6F9), + ] + ) + assert observed == expected + create_bus.return_value.__exit__.assert_called_once() + + +@pytest.mark.parametrize( + "remove,expected", [(False, [0, 3, 3.25]), (True, [0, 2, 2.25])] +) +def test_processing_time_counts_towards_wait(remove, expected): + clock = Clock() + messages = [can.Message(timestamp=t) for t in [0, 5, 5.25]] + options = {"skip_gaps": True} if remove else {} + observed = [] + with ( + patch("can.io.player.time.perf_counter", clock.perf_counter), + patch("can.io.player.time.sleep", clock.sleep), + ): + for index, _ in enumerate(can.MessageSync(messages, skip=1, **options)): + observed.append(clock.now - 100) + if index == 0: + clock.now += 2 + assert observed == expected