Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand Down
37 changes: 25 additions & 12 deletions snap7/s7protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 15 additions & 10 deletions snap7/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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}")
Expand Down
19 changes: 18 additions & 1 deletion tests/test_s7protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
_COLD_START_PARAMS,
)
from snap7.error import S7ProtocolError
from datetime import datetime


class TestGetReturnCodeDescription:
Expand Down Expand Up @@ -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:
Expand Down
Loading