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
41 changes: 31 additions & 10 deletions can/io/blf.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,15 @@ class BLFParseError(Exception):
TIME_ONE_NANS_FACTOR = Decimal("1e-9")


def timestamp_to_systemtime(timestamp: float | None) -> TSystemTime:
def timestamp_to_systemtime(
timestamp: float | None, tz: datetime.tzinfo | None = None
) -> TSystemTime:
if timestamp is None or timestamp < 631152000:
# Probably not a Unix timestamp
return 0, 0, 0, 0, 0, 0, 0, 0
t = datetime.datetime.fromtimestamp(round(timestamp, 3), tz=datetime.timezone.utc)
t = datetime.datetime.fromtimestamp(
round(timestamp, 3), tz=tz or datetime.timezone.utc
)
return (
t.year,
t.month,
Expand All @@ -121,7 +125,9 @@ def timestamp_to_systemtime(timestamp: float | None) -> TSystemTime:
)


def systemtime_to_timestamp(systemtime: TSystemTime) -> float:
def systemtime_to_timestamp(
systemtime: TSystemTime, tz: datetime.tzinfo | None = None
) -> float:
try:
t = datetime.datetime(
systemtime[0],
Expand All @@ -131,7 +137,7 @@ def systemtime_to_timestamp(systemtime: TSystemTime) -> float:
systemtime[5],
systemtime[6],
systemtime[7] * 1000,
tzinfo=datetime.timezone.utc,
tzinfo=tz or datetime.timezone.utc,
)
return t.timestamp()
except ValueError:
Expand All @@ -149,12 +155,19 @@ class BLFReader(BinaryIOMessageReader):
def __init__(
self,
file: StringPathLike | BinaryIO,
tz: datetime.tzinfo | None = None,
**kwargs: Any,
) -> None:
"""
:param file: a path-like object or as file-like object to read from
If this is a file-like object, is has to opened in binary
read mode, not text read mode.
:param tz: the timezone the start and stop timestamps in the file
header are interpreted in. Vector tools like CANoe write
these in the local timezone of the recording computer, so
pass the matching timezone to get correct (absolute)
message timestamps for such files.
Defaults to UTC if not given.
"""
super().__init__(file, mode="rb")
data = self.file.read(FILE_HEADER_STRUCT.size)
Expand All @@ -165,10 +178,10 @@ def __init__(
self.uncompressed_size = header[11]
self.object_count = header[12]
self.start_timestamp = systemtime_to_timestamp(
cast("TSystemTime", header[14:22])
cast("TSystemTime", header[14:22]), tz
)
self.stop_timestamp = systemtime_to_timestamp(
cast("TSystemTime", header[22:30])
cast("TSystemTime", header[22:30]), tz
)
# Read rest of header
self.file.read(header[1] - FILE_HEADER_STRUCT.size)
Expand Down Expand Up @@ -390,6 +403,7 @@ def __init__(
append: bool = False,
channel: int = 1,
compression_level: int = -1,
tz: datetime.tzinfo | None = None,
**kwargs: Any,
) -> None:
"""
Expand All @@ -399,6 +413,12 @@ def __init__(
Default channel to log as if not specified by the interface.
:param append:
Append messages to an existing log file.
:param tz:
The timezone the start and stop timestamps in the file header
are written in (and interpreted in when appending to an
existing file). Vector tools like CANoe expect these in the
local timezone of the recording computer.
Defaults to UTC if not given.
:param compression_level:
An integer from 0 to 9 or -1 controlling the level of compression.
1 (Z_BEST_SPEED) is fastest and produces the least compression.
Expand All @@ -417,6 +437,7 @@ def __init__(
assert self.file is not None
self.channel = channel
self.compression_level = compression_level
self.tz = tz
self._buffer: list[bytes] = []
self._buffer_size = 0
# If max container size is located in kwargs, then update the instance
Expand All @@ -431,10 +452,10 @@ def __init__(
self.uncompressed_size = header[11]
self.object_count = header[12]
self.start_timestamp: float | None = systemtime_to_timestamp(
cast("TSystemTime", header[14:22])
cast("TSystemTime", header[14:22]), self.tz
)
self.stop_timestamp: float | None = systemtime_to_timestamp(
cast("TSystemTime", header[22:30])
cast("TSystemTime", header[22:30]), self.tz
)
# Jump to the end of the file
self.file.seek(0, 2)
Expand All @@ -450,8 +471,8 @@ def _write_header(self, filesize: int) -> None:
header = [b"LOGG", FILE_HEADER_SIZE, self.application_id, 0, 0, 0, 2, 6, 8, 1]
# The meaning of "count of objects read" is unknown
header.extend([filesize, self.uncompressed_size, self.object_count, 0])
header.extend(timestamp_to_systemtime(self.start_timestamp))
header.extend(timestamp_to_systemtime(self.stop_timestamp))
header.extend(timestamp_to_systemtime(self.start_timestamp, self.tz))
header.extend(timestamp_to_systemtime(self.stop_timestamp, self.tz))
self.file.write(FILE_HEADER_STRUCT.pack(*header))
# Pad to header size
self.file.write(b"\x00" * (FILE_HEADER_SIZE - FILE_HEADER_STRUCT.size))
Expand Down
1 change: 1 addition & 0 deletions doc/changelog.d/2010.added.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added an optional ``tz`` argument to :class:`can.BLFReader` and :class:`can.BLFWriter` to interpret the timezone-less start/stop timestamps in the file header in a given timezone (e.g. the local timezone of the computer that recorded the file with a Vector tool) instead of always assuming UTC.
89 changes: 89 additions & 0 deletions test/logformats_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -959,6 +959,95 @@ def test_issue_1905(self):
self.assertMessageEqual(expected, msgs[0])


class TestBlfTimezone(unittest.TestCase):
"""Tests the ``tz`` argument of BLFReader and BLFWriter.

The BLF header stores the start/stop timestamps as a broken-down
SYSTEMTIME without any timezone information. Vector tools write it in
the local timezone of the recording computer, so interpreting it in
another timezone shifts all message timestamps.
"""

def test_systemtime_conversion_with_timezone(self):
tz = timezone(timedelta(hours=2))
# 2024-01-15 was a Monday (weekday 1 in SYSTEMTIME, Sunday is 0)
systemtime = (2024, 1, 1, 15, 12, 30, 15, 123)
as_utc = blf.systemtime_to_timestamp(systemtime)
as_plus2 = blf.systemtime_to_timestamp(systemtime, tz)
# the same wall-clock time at UTC+2 is two hours earlier in UTC
self.assertEqual(as_utc - as_plus2, 2 * 3600)
# and converting back with the same timezone is lossless
self.assertEqual(blf.timestamp_to_systemtime(as_plus2, tz), systemtime)
self.assertEqual(blf.timestamp_to_systemtime(as_utc), systemtime)

def test_writer_reader_roundtrip_with_timezone(self):
tz = timezone(timedelta(hours=-5))
messages = [
can.Message(
timestamp=1700000000.123,
arbitration_id=0x123,
data=[1, 2, 3],
is_extended_id=False,
),
can.Message(
timestamp=1700000000.623,
arbitration_id=0x124,
data=[4, 5],
is_extended_id=False,
),
]
with tempfile.NamedTemporaryFile(suffix=".blf", delete=False) as tmp:
filename = tmp.name
try:
with can.BLFWriter(filename, tz=tz) as writer:
for message in messages:
writer.on_message_received(message)

# reading with the same timezone reproduces the timestamps
with can.BLFReader(filename, tz=tz) as reader:
self.assertAlmostEqual(
reader.start_timestamp, messages[0].timestamp, delta=0.002
)
read_messages = list(reader)
self.assertEqual(len(read_messages), len(messages))
for index, expected in enumerate(messages):
self.assertAlmostEqual(
read_messages[index].timestamp, expected.timestamp, delta=0.002
)

# reading without a timezone (UTC) shifts them by the offset
with can.BLFReader(filename) as reader:
shifted = list(reader)
for index, expected in enumerate(read_messages):
self.assertAlmostEqual(
shifted[index].timestamp - expected.timestamp,
-5 * 3600,
delta=0.002,
)
finally:
os.unlink(filename)

def test_append_with_timezone_keeps_start_timestamp(self):
tz = timezone(timedelta(hours=3))
message = can.Message(
timestamp=1700000000.0,
arbitration_id=0x123,
data=[1],
is_extended_id=False,
)
with tempfile.NamedTemporaryFile(suffix=".blf", delete=False) as tmp:
filename = tmp.name
try:
with can.BLFWriter(filename, tz=tz) as writer:
writer.on_message_received(message)
with can.BLFWriter(filename, append=True, tz=tz) as writer:
self.assertAlmostEqual(
writer.start_timestamp, message.timestamp, delta=0.002
)
finally:
os.unlink(filename)


class TestCanutilsFileFormat(ReaderWriterTest):
"""Tests can.CanutilsLogWriter and can.CanutilsLogReader"""

Expand Down