feat: Enhance PLC status reporting with actual CPU cycle time

- Updated PLCClient to retrieve and report actual CPU cycle time using SZL methods.
- Renamed `cycle_time_ms` to `comm_time_ms` to clarify that it represents communication latency.
- Added detailed cycle time statistics (current, min, max) to the PLC status response.
- Modified Dashboard component to display new cycle time information alongside communication time.
- Documented the new implementation and troubleshooting steps for SZL access in the CPU Cycle Time Implementation Guide.
This commit is contained in:
Miguel 2025-08-30 22:59:16 +02:00
parent 5f73f77618
commit 71746fa326
6 changed files with 21192 additions and 19791 deletions

View File

@ -0,0 +1,152 @@
# CPU Cycle Time Implementation Guide
## Problem Identified
The previous implementation was showing **communication latency** instead of actual **CPU cycle time**:
- **Snap7's `get_exec_time()`** → Returns communication time (ping-like latency)
- **Actual CPU cycle time** → PLC internal scan cycle time (what Simatic Manager shows)
## Current Implementation (UPDATED)
### Dashboard Display
- **📡 Comm**: Communication latency from Snap7 `get_exec_time()` (ms)
- **⏱️ Current**: Current CPU cycle time via SZL (ms)
- **📊 Min/Max**: Minimum and Maximum cycle times via SZL (ms)
### Backend Changes
- `comm_time_ms`: Communication latency (renamed from `cycle_time_ms`)
- `cycle_time`: Object containing SZL-based cycle time statistics:
- `current_ms`: Current cycle time
- `min_ms`: Minimum recorded cycle time
- `max_ms`: Maximum recorded cycle time
- `method`: SZL method used (e.g., "SZL_0x0132_0x0004")
## SZL (System Status List) Implementation
### Current Method: SZL-Based Reading ✅
```python
def _read_actual_cpu_cycle_time(self) -> Optional[Dict[str, Any]]:
"""Read CPU cycle time using SZL (System Status List)"""
try:
# SZL ID 0x0132, Index 0x0004 - Basic cycle time information
szl_data = self.plc.read_szl(0x0132, 0x0004)
if szl_data and len(szl_data) >= 16:
import struct
# Parse SZL data structure:
# Bytes 0-3: Current cycle time (microseconds)
# Bytes 4-7: Minimum cycle time (microseconds)
# Bytes 8-11: Maximum cycle time (microseconds)
current_us = struct.unpack(">I", szl_data[0:4])[0]
min_us = struct.unpack(">I", szl_data[4:8])[0]
max_us = struct.unpack(">I", szl_data[8:12])[0]
return {
"current_ms": current_us / 1000.0,
"min_ms": min_us / 1000.0,
"max_ms": max_us / 1000.0,
"method": "SZL_0x0132_0x0004"
}
except:
# Fallback to alternative SZL index
szl_data = self.plc.read_szl(0x0132, 0x000C)
# ... similar parsing
```
### SZL Benefits
- **Universal**: Works with most S7-300/400 PLCs
- **No PLC program modification required**
- **Comprehensive data**: Min/Current/Max cycle times
- **Real-time**: Direct from CPU diagnostics
## Display Examples
### Frontend Display
```
🖥️ CPU Status: RUN (25.4ms)
📡 Comm: 27ms
⏱️ Current: 25.4ms
📊 Min: 12.3ms | Max: 45.2ms
📟 CPU 315F-2 PN/DP
```
### API Response
```json
{
"status": {
"connected": true,
"state": "RUN",
"comm_time_ms": 27,
"cycle_time": {
"current_ms": 25.4,
"min_ms": 12.3,
"max_ms": 45.2,
"method": "SZL_0x0132_0x0004"
},
"cpu_info": { ... }
}
}
```
## Troubleshooting
### Common SZL Issues
1. **Access denied**: Some PLCs require specific access rights
2. **Different data format**: CPU model variations in SZL structure
3. **Unsupported SZL**: Older PLCs may not support these SZL IDs
### Alternative SZL Indices
- `0x0132, 0x0004`: Basic cycle time information
- `0x0132, 0x000C`: 32-bit execution time meter
- `0x013C, 0x0000`: Cycle time monitoring (some models)
### Manual Testing
```python
import snap7
client = snap7.client.Client()
client.connect('10.1.33.11', 0, 2)
# Test SZL access
try:
data = client.read_szl(0x0132, 0x0004)
print(f"SZL data length: {len(data)} bytes")
print(f"Raw data: {data.hex()}")
except Exception as e:
print(f"SZL error: {e}")
```
## Migration from Old Method
### Before (Communication Time Only)
```json
{
"cycle_time_ms": 27 // This was communication time!
}
```
### After (Comprehensive Cycle Data)
```json
{
"comm_time_ms": 27, // Communication latency
"cycle_time": { // Real CPU cycle times
"current_ms": 25.4,
"min_ms": 12.3,
"max_ms": 45.2
}
}
```
## Notes
- **Communication Time**: Network latency (5-50ms typical)
- **CPU Cycle Time**: Internal scan time (10-100ms typical for S7-315)
- **SZL Method**: Standardized Siemens diagnostic interface
- **Real-time**: Updates with each status request
## References
- Siemens S7-300/400 System Manual: SZL Documentation
- Snap7 Documentation: `read_szl()` function
- SZL ID Reference: 0x0132 - Cycle time statistics

File diff suppressed because it is too large Load Diff

View File

@ -1124,8 +1124,11 @@ class PLCClient:
# Get CPU state (RUN, STOP, etc.)
cpu_state = self.plc.get_cpu_state()
# Get execution time (cycle time)
exec_time = self.plc.get_exec_time()
# Get communication time (NOT the actual CPU cycle time)
comm_time = self.plc.get_exec_time()
# Try to read actual CPU cycle time using SZL
cycle_time_data = self._read_actual_cpu_cycle_time()
# Get additional CPU info
cpu_info = self.plc.get_cpu_info()
@ -1147,11 +1150,11 @@ class PLCClient:
if self.logger:
self.logger.warning(f"Error parsing CPU state {cpu_state}: {e}")
return {
result = {
"connected": True,
"state": state_name,
"state_code": cpu_state,
"cycle_time_ms": exec_time,
"comm_time_ms": comm_time, # Communication latency
"cpu_info": {
"module_type_name": cpu_info.ModuleTypeName.decode(
"ascii", errors="ignore"
@ -1170,8 +1173,114 @@ class PLCClient:
"timestamp": time.time(),
}
# Add cycle time statistics if available from SZL
if cycle_time_data is not None:
result["cycle_time"] = {
"current_ms": cycle_time_data.get("current_ms"),
"min_ms": cycle_time_data.get("min_ms"),
"max_ms": cycle_time_data.get("max_ms"),
"method": cycle_time_data.get("method", "SZL"),
}
return result
except Exception as e:
error_msg = f"Error reading CPU status: {str(e)}"
if self.logger:
self.logger.error(error_msg)
return {"connected": True, "error": error_msg, "timestamp": time.time()}
def _read_actual_cpu_cycle_time(self) -> Optional[Dict[str, Any]]:
"""
Read actual CPU cycle time from Siemens PLC using SZL (System Status List).
Uses SZL 0x0222, INDEX 0x0001 to get OB1 cycle time information.
Returns cycle time statistics in milliseconds, or None if not available.
"""
try:
# Use SZL 0x0222, INDEX 0x0001 for OB1 cycle time information
# This provides the exact values shown in Simatic Manager
szl_result = self.plc.read_szl(0x0222, 0x0001)
if szl_result and hasattr(szl_result, "Data"):
raw_data = bytes(szl_result.Data)
if len(raw_data) >= 12:
# Parse OB1 cycle time data according to Siemens documentation:
# Offset 6-7: Previous cycle time (OB1_PREV_CYCLE) in ms
# Offset 8-9: Minimum cycle time (OB1_MIN_CYCLE) in ms
# Offset 10-11: Maximum cycle time (OB1_MAX_CYCLE) in ms
prev_ms = int.from_bytes(raw_data[6:8], "big", signed=True)
min_ms = int.from_bytes(raw_data[8:10], "big", signed=True)
max_ms = int.from_bytes(raw_data[10:12], "big", signed=True)
# Validate values are in reasonable range
if (
5 <= prev_ms <= 200
and 5 <= min_ms <= 200
and 5 <= max_ms <= 200
):
cycle_times = {
"current_ms": float(prev_ms), # Use previous as current
"min_ms": float(min_ms),
"max_ms": float(max_ms),
"method": "SZL_0x0222_0x0001_OB1",
}
if self.logger:
self.logger.debug(
f"OB1 cycle times: Current={prev_ms}ms, "
f"Min={min_ms}ms, Max={max_ms}ms"
)
return cycle_times
else:
if self.logger:
self.logger.debug(
f"OB1 values out of range: "
f"Prev={prev_ms}, Min={min_ms}, Max={max_ms}"
)
except Exception as e:
if self.logger:
self.logger.debug(f"SZL 0x0222/0x0001 method failed: {e}")
# Fallback: Try the original SZL that worked partially
try:
szl_result = self.plc.read_szl(0x0132, 0x0004)
if szl_result and hasattr(szl_result, "Data"):
data_bytes = bytes(szl_result.Data)
if len(data_bytes) >= 16:
import struct
# This gives us a static reference value
current_us = struct.unpack(">I", data_bytes[12:16])[0]
if 5000 <= current_us <= 200000:
cycle_times = {
"current_ms": current_us / 1000.0,
"min_ms": None, # Not available in this SZL
"max_ms": None, # Not available in this SZL
"method": "SZL_0x0132_0x0004_fallback",
}
if self.logger:
self.logger.debug(
f"Fallback SZL cycle time: {current_us/1000.0:.1f}ms"
)
return cycle_times
except Exception as e:
if self.logger:
self.logger.debug(f"Fallback SZL method also failed: {e}")
if self.logger:
self.logger.debug(
"Could not read CPU cycle time via any SZL method. "
"PLC may not support cycle time diagnostics."
)
return None

View File

@ -1015,11 +1015,24 @@ function StatusBar({ status, isConnected, isLeader, connectionError }) {
<StatLabel fontSize="sm">🖥 CPU Status</StatLabel>
<StatNumber fontSize="sm" color={cpuStatus.state === 'RUN' || cpuStatus.state?.includes('Run') ? 'green.500' : 'orange.500'}>
{cpuStatus.state?.replace('STATE_S7CpuStatus', '').toUpperCase() || 'UNKNOWN'}
{cpuStatus.cycle_time?.current_ms && (
<Text as="span" fontSize="xs" color="gray.500" ml={2}>
({cpuStatus.cycle_time.current_ms.toFixed(1)}ms)
</Text>
)}
</StatNumber>
<StatHelpText fontSize="xs" minHeight="32px">
{cpuStatus.cycle_time_ms !== undefined && (
{cpuStatus.comm_time_ms !== undefined && (
<>
Cycle: {cpuStatus.cycle_time_ms}ms
📡 Comm: {cpuStatus.comm_time_ms}ms
{cpuStatus.cycle_time && (
<>
<br /> Cycle: {cpuStatus.cycle_time.current_ms?.toFixed(1)}ms
{cpuStatus.cycle_time.min_ms && cpuStatus.cycle_time.max_ms && (
<><br />📊 Min: {cpuStatus.cycle_time.min_ms?.toFixed(1)}ms | Max: {cpuStatus.cycle_time.max_ms?.toFixed(1)}ms</>
)}
</>
)}
{cpuStatus.cpu_info?.module_type_name && (
<><br />📟 {cpuStatus.cpu_info.module_type_name}</>
)}

View File

@ -2999,7 +2999,7 @@ def disable_plc_reconnection():
@app.route("/api/plc/status")
def get_plc_status():
"""Get current PLC CPU status including state and cycle time"""
"""Get current PLC CPU status including state, communication time and actual cycle time"""
error_response = check_streamer_initialized()
if error_response:
return error_response

View File

@ -8,6 +8,5 @@
]
},
"auto_recovery_enabled": true,
"last_update": "2025-08-29T20:13:41.644405",
"plotjuggler_path": "C:\\Program Files\\PlotJuggler\\plotjuggler.exe"
"last_update": "2025-08-30T22:54:50.154570"
}