diff --git a/can/io/blf.py b/can/io/blf.py index 77bd02fae..8ea91f70c 100644 --- a/can/io/blf.py +++ b/can/io/blf.py @@ -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, @@ -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], @@ -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: @@ -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) @@ -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) @@ -390,6 +403,7 @@ def __init__( append: bool = False, channel: int = 1, compression_level: int = -1, + tz: datetime.tzinfo | None = None, **kwargs: Any, ) -> None: """ @@ -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. @@ -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 @@ -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) @@ -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)) diff --git a/doc/changelog.d/2010.added.rst b/doc/changelog.d/2010.added.rst new file mode 100644 index 000000000..4a77e4241 --- /dev/null +++ b/doc/changelog.d/2010.added.rst @@ -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. diff --git a/test/logformats_test.py b/test/logformats_test.py index 42ed29b13..1b7353fd4 100644 --- a/test/logformats_test.py +++ b/test/logformats_test.py @@ -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"""