Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions can/io/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,14 +136,20 @@ def __init__(
timestamps: bool = True,
gap: float = 0.0001,
skip: float = 60.0,
*,
skip_gaps: bool = False,
) -> None:
"""Creates an new **MessageSync** instance.

:param messages: An iterable of :class:`can.Message` instances.
: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::

Expand All @@ -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()
Expand All @@ -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)
Expand Down
11 changes: 9 additions & 2 deletions can/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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))

Expand Down
3 changes: 3 additions & 0 deletions doc/changelog.d/1646.added.rst
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions doc/scripts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand Down
130 changes: 130 additions & 0 deletions test/test_skip_gaps.py
Original file line number Diff line number Diff line change
@@ -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