From f5e2c15a8792fd84193c90191384c222fd322230 Mon Sep 17 00:00:00 2001 From: Gijs Molenaar Date: Wed, 7 Oct 2026 10:19:58 +0200 Subject: [PATCH] fix: accept the ten-byte S7 clock reply and emit it from the server --- CHANGES.md | 3 +++ snap7/s7protocol.py | 37 +++++++++++++++++++++++++------------ snap7/server/__init__.py | 25 +++++++++++++++---------- tests/test_s7protocol.py | 19 ++++++++++++++++++- 4 files changed, 61 insertions(+), 23 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 7c55940a..fa361cb2 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -21,6 +21,9 @@ Unreleased day was off by one position. The bundled server's clock reply uses the same convention now (#932). * `set_plc_datetime` now raises `ValueError` for years outside 1990-2089 instead of silently sending a different year, since the two-digit S7 year cannot represent them (#933). +* `parse_get_clock_response` now accepts the ten-byte S7 clock reply (reserved, century, then the time with milliseconds) + that native Snap7 and PLCs send, and still accepts the legacy eight-byte form. The bundled server emits the + ten-byte form (#934). 3.2.1 ----- diff --git a/snap7/s7protocol.py b/snap7/s7protocol.py index d5ceac7a..b3098d52 100644 --- a/snap7/s7protocol.py +++ b/snap7/s7protocol.py @@ -1341,26 +1341,39 @@ def parse_get_clock_response(self, response: Dict[str, Any]) -> "datetime": data_info = response.get("data", {}) raw_data = data_info.get("data", b"") - if len(raw_data) != 8: - raise S7ProtocolError("Clock response must contain exactly eight bytes") + # The S7 clock data is ten bytes: reserved, century (0x19/0x20), then year, month, day, + # hour, minute, second and milliseconds/weekday. The older eight-byte form (no century byte, + # no milliseconds) is still accepted. + if len(raw_data) == 10: + time_bytes = raw_data[2:] + elif len(raw_data) == 8: + time_bytes = raw_data[1:] + else: + raise S7ProtocolError("Clock response must contain ten bytes (or eight in the legacy form)") - # Parse BCD time def from_bcd(value: int) -> int: + if value >> 4 > 9 or value & 0x0F > 9: + raise S7ProtocolError("Clock response contains an invalid BCD timestamp") return ((value >> 4) * 10) + (value & 0x0F) - # Skip first byte (reserved) - year = from_bcd(raw_data[1]) - month = from_bcd(raw_data[2]) - day = from_bcd(raw_data[3]) - hour = from_bcd(raw_data[4]) - minute = from_bcd(raw_data[5]) - second = from_bcd(raw_data[6]) + year = from_bcd(time_bytes[0]) + month = from_bcd(time_bytes[1]) + day = from_bcd(time_bytes[2]) + hour = from_bcd(time_bytes[3]) + minute = from_bcd(time_bytes[4]) + second = from_bcd(time_bytes[5]) + microsecond = 0 + if len(raw_data) == 10: + # Byte 8: milliseconds hundreds/tens (BCD); high nibble of byte 9: milliseconds ones + microsecond = (from_bcd(time_bytes[6]) * 10 + (time_bytes[7] >> 4)) * 1000 + if microsecond > 999000: + raise S7ProtocolError("Clock response contains an invalid BCD timestamp") - # Determine century (assume 2000s for years 0-99) + # The two-digit S7 year maps 90-99 to 1990-1999 and 00-89 to 2000-2089 full_year = 2000 + year if year < 90 else 1900 + year try: - return dt_class(full_year, month, day, hour, minute, second) + return dt_class(full_year, month, day, hour, minute, second, microsecond) except ValueError as exc: raise S7ProtocolError("Clock response contains an invalid BCD timestamp") from exc diff --git a/snap7/server/__init__.py b/snap7/server/__init__.py index fe35d14b..da6fb92c 100644 --- a/snap7/server/__init__.py +++ b/snap7/server/__init__.py @@ -1867,15 +1867,17 @@ def _handle_get_clock( """ Handle get clock request - returns current server time. - Returns time in BCD format (8 bytes): + Returns time in BCD format (10 bytes, as native Snap7 ``TResDataGetTime``): - Byte 0: Reserved (0x00) - - Byte 1: Year (BCD, 0-99) - - Byte 2: Month (BCD, 1-12) - - Byte 3: Day (BCD, 1-31) - - Byte 4: Hour (BCD, 0-23) - - Byte 5: Minute (BCD, 0-59) - - Byte 6: Second (BCD, 0-59) - - Byte 7: Day of week (1=Sunday .. 7=Saturday) + - Byte 1: Century (0x19 for years 1990-1999, 0x20 for 2000-2089) + - Byte 2: Year (BCD, 0-99) + - Byte 3: Month (BCD, 1-12) + - Byte 4: Day (BCD, 1-31) + - Byte 5: Hour (BCD, 0-23) + - Byte 6: Minute (BCD, 0-59) + - Byte 7: Second (BCD, 0-59) + - Byte 8: Milliseconds hundreds/tens (BCD) + - Byte 9: Milliseconds ones (high nibble) and day of week (low nibble, 1=Sunday .. 7=Saturday) """ from datetime import datetime @@ -1885,16 +1887,19 @@ def to_bcd(value: int) -> int: return ((value // 10) << 4) | (value % 10) year = now.year % 100 + millisecond = now.microsecond // 1000 bcd_time = struct.pack( - ">BBBBBBBB", + ">BBBBBBBBBB", 0x00, # Reserved + 0x19 if now.year < 2000 else 0x20, # Century to_bcd(year), # Year (BCD) to_bcd(now.month), # Month (BCD) to_bcd(now.day), # Day (BCD) to_bcd(now.hour), # Hour (BCD) to_bcd(now.minute), # Minute (BCD) to_bcd(now.second), # Second (BCD) - now.isoweekday() % 7 + 1, # Day of week (1=Sunday .. 7=Saturday) + to_bcd(millisecond // 10), # Milliseconds hundreds/tens (BCD) + ((millisecond % 10) << 4) | (now.isoweekday() % 7 + 1), # Milliseconds ones, day of week (1=Sunday) ) logger.debug(f"Get clock from {client_address}: returning {now}") diff --git a/tests/test_s7protocol.py b/tests/test_s7protocol.py index dc7456ed..d488f8c5 100644 --- a/tests/test_s7protocol.py +++ b/tests/test_s7protocol.py @@ -17,6 +17,7 @@ _COLD_START_PARAMS, ) from snap7.error import S7ProtocolError +from datetime import datetime class TestGetReturnCodeDescription: @@ -526,9 +527,25 @@ def test_year_90_is_1990(self) -> None: result = self.proto.parse_get_clock_response(response) assert result.year == 1990 + def test_ten_byte_native_layout(self) -> None: + # reserved, century, then 1990-01-01 00:00:00 (year, month, day, hour, minute, second, ms/weekday) + raw_data = bytes([0x00, 0x19, 0x90, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01]) + result = self.proto.parse_get_clock_response({"data": {"data": raw_data}}) + assert result == datetime(1990, 1, 1, 0, 0, 0) + + def test_ten_byte_with_milliseconds_and_century(self) -> None: + raw_data = bytes([0x00, 0x20, 0x26, 0x10, 0x07, 0x12, 0x30, 0x45, 0x12, 0x34]) + result = self.proto.parse_get_clock_response({"data": {"data": raw_data}}) + assert result == datetime(2026, 10, 7, 12, 30, 45, 123000) + + def test_ten_byte_invalid_milliseconds_rejected(self) -> None: + raw_data = bytes([0x00, 0x20, 0x26, 0x10, 0x07, 0x12, 0x30, 0x45, 0xFF, 0x04]) + with pytest.raises(S7ProtocolError, match="invalid BCD"): + self.proto.parse_get_clock_response({"data": {"data": raw_data}}) + def test_short_data_rejected(self) -> None: response = {"data": {"data": b"\x00\x01"}} - with pytest.raises(S7ProtocolError, match="eight bytes"): + with pytest.raises(S7ProtocolError, match="ten bytes"): self.proto.parse_get_clock_response(response) def test_invalid_bcd_date_rejected(self) -> None: