Problem Overview and Engineering Context
Engineers using a Siemens SIMATIC S7-1200 CPU 1212C (order number 6ES7 212-1AE40-0XB0) programmed in TIA Portal V15 frequently need to combine three on-time counters (hours, minutes, seconds) and the current calendar date into a single human-readable timestamp string. The PLC tracks the uptime of a field device and must hand the value off to a Python-based SCADA bridge, historian, or analytics application. The problem is technically constrained: (1) the S7-1200 firmware must support the chosen date/time data type, (2) the format must be consumable by a Python parser, and (3) ASCII conversion should not consume PLC scan time when a binary transfer is possible.
This tutorial covers three production-grade approaches — SCL string composition with VAL_STRG and CONCAT, Ladder with S_CONV plus manual padding, and the recommended DTL binary structure with Python-side parsing. Each method is benchmarked on memory footprint, OB1 execution cost, and downstream parser simplicity.
RD_SYS_T instruction, and the extended CONCAT/VAL_STRG instruction set all require S7-1200 firmware V4.0 or later. TIA Portal V15.1 (released March 2018) is the minimum engineering environment. For older firmware (V3.x), only the legacy DATE_AND_TIME (8-byte) type is available; the code in this article targets V4.x behavior.Prerequisites
- Installed TIA Portal V15 or V15.1 with the S7-1200 HSP (Hardware Support Package) for firmware V4.2.
- S7-1200 CPU 1212C, 6ES7 212-1AE40-0XB0, firmware V4.2 or later (check in the device's online > diagnostics > module information).
- A configured PROFINET or PROFIBUS interface to the Python host. The Python host in this example uses the
snap7library to exchange data via S7 communication (PUT/GET). - A populated PLC tag table with the source counters. Recommended types:
UINTfor hour/minute/second (range 0-65535), notSInt(range -128 to +127). The sign bit on a runtime counter is wasted memory and a future rollover bug waiting to happen. - For SCL paths: SCL source file or an SCL-enabled code block (FB/FC). SCL is part of the TIA Portal basic installation.
Data Type Selection Matrix
| Data type | Length (bytes) | Range | When to use |
|---|---|---|---|
SInt |
1 | -128 to +127 | Signed small values (avoid for time counters) |
USInt |
1 | 0 to 255 | Minutes, seconds, hours up to 255 |
UINT |
2 | 0 to 65535 | Recommended for uptime counters |
String |
2 + n (max 254) | ASCII string | HMI display, text export |
WString |
4 + n (max 16382) | Unicode (UTF-16) | Multi-byte or non-ASCII characters |
DTL |
12 | 1970-01-01 to 2554-12-31 | Date+time with sub-second precision; ideal for binary transport to Python |
DATE |
2 | D#1990-01-01 to D#2168-12-31 | Calendar day only (days since 1990-01-01) |
TOD (Time_Of_Day) |
4 | TOD#00:00:00.000 to TOD#23:59:59.999 | Time-of-day without calendar date |
Date (VB .NET / .NET) |
8 | 0001-01-01 to 9999-12-31 | Reference: how the consumer system represents the same value (Microsoft Learn: Date Data Type) |
Method 1 — SCL with VAL_STRG and CONCAT (string composition)
SCL (Structured Control Language) gives the cleanest control over string formatting. The two key instructions are:
-
VAL_STRG(SrcVal, Format, DecPoint, UseSep, Sep, Result)— converts a numeric value to a string with a configurable minimum width and decimal separator. -
CONCAT(Str1, Str2)— concatenates two strings. Chain multiple calls to join more than two.
Block layout (FC, call from OB1 on each scan):
FUNCTION "FC_BuildTimestamp" : Void
VAR
hrValue : UINT; // input from running counter, 0..65535
minValue : UINT; // 0..59
secValue : UINT; // 0..59
tmpHr : String[4]; // padded "0000".."9999"
tmpMin : String[2];
tmpSec : String[2];
timeStr : String[12]; // "HH:MM:SS"
col1, col2 : String[14]; // intermediate concat buffers
END_VAR
BEGIN
// 1) Convert with explicit width so leading zeros are written.
// Format = digit count, DecPoint = 0, UseSep = FALSE.
VAL_STRG(IN := hrValue, FORMAT := 4, DECIMAL := 0, USEPOINT := FALSE, Result => tmpHr);
VAL_STRG(IN := minValue, FORMAT := 2, DECIMAL := 0, USEPOINT := FALSE, Result => tmpMin);
VAL_STRG(IN := secValue, FORMAT := 2, DECIMAL := 0, USEPOINT := FALSE, Result => tmpSec);
// 2) Build "HH:MM:SS" by chaining CONCAT calls.
col1 := CONCAT(tmpHr, ':'); // "HHHH:"
col2 := CONCAT(col1, tmpMin); // "HHHH:MM"
timeStr := CONCAT(CONCAT(col2, ':'), tmpSec); // "HHHH:MM:SS"
// 3) Move the final string into a globally visible tag for HMI/Python.
"dbRuntime".timeAscii := timeStr;
END_FUNCTION
Parameters of VAL_STRG on S7-1200 / S7-1500:
| Parameter | Type | Meaning |
|---|---|---|
IN |
Input | Numeric value (INT, DINT, REAL, …) |
FORMAT |
WORD/INT | Minimum field width in characters (not interpreted as zero-pad count, but enforced as right-aligned width). For zero-pad behavior, see notes below. |
DECIMAL |
INT | Number of digits after the decimal point |
USEPOINT |
BOOL | TRUE = '.' decimal point, FALSE = ',' decimal point |
Result |
Output (String) | Output string |
VAL_STRG in S7-1200/S7-1500 right-aligns the value but does not automatically pad with leading zeros. The SCL workaround is to prefix the string with a literal '0' when the source value is below the format width, e.g. IF minValue < 10 THEN tmpMin := CONCAT('0', tmpMin); END_IF; or to use the DTL approach in Method 3 which does not need padding at all.Method 2 — Ladder with S_CONV and manual padding
For teams that cannot use SCL, the same result is achievable in Ladder using S_CONV (scalar conversion), MOVE_BLK, and CONCAT. The catch is that S_CONV from UINT to STRING produces an un-padded result: hour 9 becomes the single character "9" rather than "09". You must insert the colon and the leading zero yourself.
Network 1 — convert each value to STRING
| hrValue |---[ S_CONV (UINT -> STRING) ]--->| tmpHr |
| minValue |---[ S_CONV (UINT -> STRING) ]--->| tmpMin |
| secValue |---[ S_CONV (UINT -> STRING) ]--->| tmpSec |
Network 2 — pad minute and second when < 10
| minValue < 10 |--[ MOVE_BLK_VARIANT src:='0' dst:=tmpMin[1] len:=1 ]-->X
| |--[ S_CONV minValue -> tmpMin[2] ]---|
Network 3 — assemble the final string with CONCAT in four steps
| tmpHr |---[ CONCAT IN1: tmpHr IN2: ':' ]--->| col1 |
| col1 |---[ CONCAT IN1: col1 IN2: tmpMin ]--->| col2 |
| col2 |---[ CONCAT IN1: col2 IN2: ':' ]--->| col3 |
| col3 |---[ CONCAT IN1: col3 IN2: tmpSec ]--->| timeStr |
Common Ladder failure modes
| Symptom | Cause | Fix |
|---|---|---|
| String shows "9:5:3" with no zeros |
S_CONV does not pad |
Insert the leading-zero comparison network above |
CONCAT errors with ENO=0 |
Result buffer shorter than combined length | Increase the STRING length; default is 254, but intermediate buffers must be declared with enough capacity |
| Garbage in the last character | Old characters in the string header (current length byte) not reset | Reset the LEN field of the result with a MOVE of 0 to the second byte of the STRING header before re-using it |
Method 3 — Recommended: DTL data type with binary transport to Python
The DTL (Date and Time Long) type was introduced for S7-1200 firmware V4.0 and S7-1500 from launch. It is a 12-byte structure that carries the full date, time, and nanosecond resolution without any ASCII overhead.
DTL structure on S7-1200/S7-1500 (little-endian, 12 bytes total)
| Byte offset | Field | Type | Range / meaning |
|---|---|---|---|
| 0-1 | YEAR | UINT (2 bytes) | 1970 to 2554 |
| 2 | MONTH | USINT | 1 to 12 |
| 3 | DAY | USINT | 1 to 31 |
| 4 | WEEKDAY | USINT | 1 (Sunday) to 7 (Saturday) |
| 5 | HOUR | USINT | 0 to 23 |
| 6 | MINUTE | USINT | 0 to 59 |
| 7 | SECOND | USINT | 0 to 59 |
| 8-11 | NANOSECOND | UDINT (4 bytes) | 0 to 999_999_999 (sub-second precision) |
Step 1 — declare the tag in the data block
DATA_BLOCK "dbRuntime"
STRUCT
hrCounter : UINT; // uptime hours (0..65535)
minCounter : UINT; // uptime minutes (0..59)
secCounter : UINT; // uptime seconds (0..59)
runTime : DTL; // 12 bytes, populated by FC_BuildTimestamp
sysTime : DTL; // 12 bytes, populated by RD_SYS_T
END_STRUCT;
END_DATA_BLOCK
Step 2 — populate the runtime DTL from the three counters (SCL)
FUNCTION "FC_BuildRuntimeDTL" : Void
VAR_TEMP
tmpDTL : DTL;
END_VAR
BEGIN
// read the PLC's local time once so weekday and date are correct
RD_SYS_T(RET_VAL := #errTime, OUT := #tmpDTL);
// overwrite the time-of-day fields with the uptime counters
tmpDTL.HOUR := INT_TO_USINT(#hrValue);
tmpDTL.MINUTE := INT_TO_USINT(#minValue);
tmpDTL.SECOND := INT_TO_USINT(#secValue);
tmpDTL.NANOSECOND := 0;
// publish
"dbRuntime".runTime := tmpDTL;
END_FUNCTION
Step 3 — read the calendar date with RD_SYS_T
RD_SYS_T is in the Extended instructions > Date and time folder. It writes the current PLC time-of-day (driven by the internal hardware clock) into a DTL.
RD_SYS_T(RET_VAL := "dbRuntime".errSysTime,
OUT := "dbRuntime".sysTime);
Step 4 — combine uptime and date into one DTL
Both DTLs are concatenated by overriding the date fields of the system-time DTL with the year/month/day you want, leaving the time-of-day fields as the runtime values. In practice you typically do not need to merge them into a single DTL: hand both tags to Python and let the parser assemble a human-readable string.
CONCAT and S_CONV are CPU-bound on the S7-1200; on a 1212C with 75 KB of work memory, repeating those in a fast OB (e.g. OB35 cyclic interrupt) can chew 200-400 µs of scan time per block instance.Python-Side Parsing with snap7
The snap7 library implements the S7 communication protocol natively. It pulls the 12 bytes from dbRuntime.runTime and the 12 bytes from dbRuntime.sysTime in a single read request per DTL.
import snap7
from snap7.util import get_dtl
from datetime import datetime
client = snap7.client.Client()
client.connect('192.168.0.10', 0, 1, 102) # PLC IP, rack 0, slot 1, TCP port 102
# Read 24 bytes from DB 1 starting at byte 0
raw = client.db_read(1, 0, 24)
# Snap7 ships a DTL helper; if not available, unpack manually:
# YEAR (UINT, big-endian), MONTH, DAY, WEEKDAY, HOUR, MINUTE, SECOND,
# NANOSECOND (UDINT, big-endian). Format string = '> H B B B B B B I' (12 bytes)
import struct
run_unpacked = struct.unpack('>HBBBBBB I', raw[0:12])
sys_unpacked = struct.unpack('>HBBBBBB I', raw[12:24])
run_time = datetime(run_unpacked[0], # YEAR
run_unpacked[1], # MONTH
1, # placeholder day
run_unpacked[4], # HOUR
run_unpacked[5], # MINUTE
run_unpacked[6]) # SECOND
sys_date = datetime(sys_unpacked[0], # YEAR
sys_unpacked[1], # MONTH
sys_unpacked[2], # DAY
sys_unpacked[4], # HOUR
sys_unpacked[5], # MINUTE
sys_unpacked[6]) # SECOND
# final stamp matches the requested "dd/mm/yyyy HH:MM:SS" shape
timestamp = sys_date.strftime('%d/%m/%Y') + ' ' + run_time.strftime('%H:%M:%S')
print(timestamp) # >> 14/03/2025 09:35:42
The same datetime object is directly compatible with the .NET Date data type (8-byte floating point days-since-0001-01-01 in the lower layer) when the value is exchanged via OPC UA rather than raw S7 — useful if the Python host is a wrapper around a .NET industrial service.
Alternative: Format the String on the PLC and Ship the ASCII
For HMI display only, build the timestamp string in SCL and write it to a 32-byte STRING tag:
// "dd/mm/yyyy HH:MM:SS" = 19 characters
outStr.LEN := 19;
outStr[1] := tmpDay[1];
outStr[2] := tmpDay[2];
outStr[3] := '/';
outStr[4] := tmpMonth[1];
outStr[5] := tmpMonth[2];
outStr[6] := '/';
// ... and so on for the year, space, HH, :, MM, :, SS
// padding zeros for month < 10 / day < 10 is required
This approach is only recommended when there is no IT-side consumer and the value is human-readable on a Comfort Panel or KTP display. For OPC UA or S7 transport to Python, the DTL binary form is faster and safer.
Verification Steps
-
Online watch table — open the DB, force
hrValue = 9,minValue = 5,secValue = 3. Confirm the resulting STRING reads09:05:03in the SCL path, or the DTL fields readHOUR = 9, MINUTE = 5, SECOND = 3in the DTL path. - CPU diagnostic buffer — confirm no SF (system fault) entries. The DTL manipulation must not trigger an OB121 (programming error) when the source values are at the boundary (e.g. minute = 59 → increment hour).
-
Watch RD_SYS_T update — place a cross-reference watch on
dbRuntime.sysTime; the value must increment by one second per second against the system clock. -
Python round-trip — in the Python host, run a loop that calls
client.db_read(1, 0, 24)every 1 s and assert that the parsed seconds field increments monotonically. - Rollover test — force hour = 23, minute = 59, second = 59; pulse the block. Verify the DTL hour does not roll to 24 (the SCL is responsible for that if you are building a calendar date from runtime counters; for uptime counters, 9999:59:59 is a legitimate value).
-
Endianness check — log the raw bytes returned by
db_read. Year 2025 must appear as bytes0x07 0xE9(big-endian) — the S7 is little-endian on the wire, but snap7'sget_dtland yourstruct.unpackformat string must use the matching'>'or'<'prefix.
Troubleshooting Matrix
| Observed fault | Likely root cause | Fix |
|---|---|---|
| SF LED on, OB121 (programming error) | DTL field assigned an out-of-range value (e.g. MONTH = 13) | Clamp the input with a LIMIT instruction or IF check before assignment |
| String truncates at 254 characters | Standard STRING max length exceeded by chained CONCAT | Use WSTRING (max 16,382) or split the output into two tags |
| CONCAT ENO = FALSE | Result buffer too small for combined input | Declare the output STRING with sufficient declared length (default 254); the actual length byte is independent |
| RD_SYS_T returns 0x0001 in RET_VAL | Real-time clock not set or battery low | Online > diagnostics > set time, or use WR_SYS_T to write the time from an external source (e.g. NTP, GPS) |
| Python parser shows year 1970 or 1900 | Endian mismatch in struct.unpack | Try swapping '>' to '<' and vice versa; print raw bytes for confirmation |
| Hour/minute counters overflow SInt boundary | Counter declared as SInt (max 127) | Change to UINT in the data block; recompile and reload |
| VAL_STRG result string has trailing space | FORMAT is interpreted as minimum width, right-aligned | Use the IF-check / leading-zero padding pattern or switch to DTL |
| DIAG LED on PLC, DTL data length wrong | DB downloaded but not initialized | Perform an online > reset to factory settings, or use Initial values in the DB properties |
Performance and Memory Notes
- String composition with CONCAT allocates a new STRING every call. On the S7-1200 1212C, 3 × CONCAT per scan in OB1 is negligible; in OB35 at 100 ms cycle with 4 tags, reserve ~500 µs of CPU time.
- DTL manipulation is essentially pointer-based — 12 bytes copied, no heap. It is the cheapest approach for a high-frequency OB.
- String tag declared length includes the 2-byte header (max length word + current length word). A STRING[20] occupies 22 bytes of work memory and 22 bytes of load memory.
- WSTRING costs 4 + n bytes (UTF-16) and is the only correct type for characters outside the Latin-1 set.
Field-Commissioning Checklist
- Confirm firmware version of the S7-1200 (online > module information) is V4.0+ for DTL or V3.x-compatible legacy code only.
- Place
RD_SYS_Tin OB1, not in a startup OB, so the value is refreshed each cycle. - If the plant has more than one CPU, give each PLC a unique DTL tag location in its DB to avoid snap7 byte misalignment on the Python side.
- Use the PLC's battery-backed real-time clock for normal operation. For plants with NTP, drive
WR_SYS_Tfrom the network every 5 minutes to keep drift below 1 second. - Lock the data block against write access from the HMI to prevent operators from accidentally modifying the runtime counters.
FAQ
Can I concatenate the three time values into a single STRING in Ladder without SCL?
Yes. Use S_CONV to convert each UINT to STRING, then four sequential CONCAT blocks to assemble HH, the colon, MM, the second colon, and SS. Add a leading-zero MOVE of the literal '0' for any field less than 10. The DTL approach is recommended over this if a Python consumer exists.
Why does my S_CONV output show "9" instead of "09" for the minute field?
S_CONV from UINT to STRING does not pad with leading zeros — it produces the minimum digit count. Either prepend '0' manually with an IF/MOVE block, or use VAL_STRG in SCL and then add the leading zero explicitly because VAL_STRG also does not pad zeros automatically on S7-1200/S7-1500.
What is the difference between DTL and the older DATE_AND_TIME (DT) data type?
The legacy DT (8 bytes) is a BCD-encoded structure supported on S7-300/400 and S7-1200 firmware V3.x. DTL (12 bytes) is binary, supports nanosecond resolution, and is the recommended type on S7-1200 V4.x and S7-1500. Use DTL for new code.
How do I read the PLC clock and use it as the calendar date in the same DTL that holds my uptime counters?
Call RD_SYS_T to load the current date and time into a DTL, then overwrite the HOUR, MINUTE, and SECOND fields with the uptime counters. Leave YEAR, MONTH, and DAY untouched. The single 12-byte DTL now carries date + uptime.
What is the minimum TIA Portal and firmware combination that supports DTL on the S7-1200?
TIA Portal V13 SP1 with HSP for S7-1200 firmware V4.0 is the minimum. For the CPU 1212C with order number 6ES7 212-1AE40-0XB0, the matching firmware is V4.2.x. TIA Portal V15 (the version used in the source problem) fully supports DTL and the RD_SYS_T/WR_SYS_T/CONCAT/VAL_STRG instruction set.