Implementadas mejoras significativas en el sistema de streaming y grabación de CSV. Se añadió un control independiente para la grabación de CSV, permitiendo la organización automática de archivos por hora. Se implementó un sistema de persistencia del estado del sistema y recuperación automática, mejorando la fiabilidad en entornos industriales. Además, se integró el logo de SIDEL en la interfaz y se realizaron ajustes en el diseño para una mejor experiencia de usuario.

This commit is contained in:
Miguel 2025-07-17 14:32:45 +02:00
parent c1d258fbf3
commit cc729d8f82
8 changed files with 37073 additions and 12 deletions

View File

@ -43,6 +43,26 @@
**Impact**: The interface now has a more professional, industrial appearance suitable for PLC monitoring applications.
#### SIDEL Corporate Logo Integration
**Decision**: Integrated the SIDEL company logo into the main application header alongside the existing factory icon.
**Rationale**: Brand visibility and corporate identity integration for customized industrial applications deployed in SIDEL facilities.
**Implementation**:
- Added Flask static file serving route `/images/<filename>` to serve images from `.images` directory
- Implemented responsive CSS styling with flexbox layout for proper logo positioning
- Applied visual effects (drop-shadow) consistent with existing header text styling
- Added mobile-responsive design with smaller logo size and vertical layout for mobile devices
- Logo positioned before the factory emoji icon maintaining visual hierarchy
**Technical Details**:
- Logo served from `.images/SIDEL.png` through dedicated Flask route
- CSS styling includes 60px height (45px on mobile) with automatic width scaling
- Flexbox implementation ensures proper alignment and spacing
- Drop-shadow filter maintains visual consistency with text shadows
**Impact**: The application now displays clear corporate branding while maintaining professional appearance and responsive behavior across all device types.
### Technical Architecture Decisions
#### Class-Based Streamer Design
@ -61,6 +81,27 @@ Configuration handling is separated into distinct methods for PLC settings, UDP
**Technical Impact**: Ensured proper error handling and logging throughout the application lifecycle from the very first startup.
#### CSV Recording System Implementation
**Decision**: Added comprehensive CSV recording system with hourly file organization and selective streaming capability.
**Rationale**: Industrial applications require both real-time visualization (PlotJuggler) and historical data storage (CSV) with different variable sets for each purpose.
**Implementation**:
- Automatic directory structure creation: `records/dd-mm-yyyy/hour.csv`
- Hourly file rotation with timestamp-based organization
- Complete variable set recording to CSV regardless of streaming selection
- Selective variable streaming to PlotJuggler through checkbox interface
- Independent control for CSV recording and UDP streaming
- Automatic CSV header management and file handling
**Architecture Impact**:
- Separated data flow: All variables → CSV, Selected variables → PlotJuggler
- Thread-safe file operations with automatic directory creation
- Real-time file path updates in the interface
- Dual recording modes: Combined (streaming + CSV) and Independent (CSV only)
**User Experience**: Users can now record all process data for historical analysis while sending only relevant variables to real-time visualization tools, reducing network traffic and improving PlotJuggler performance.
### Future Considerations
The persistent configuration system provides a foundation for more advanced features like configuration profiles, backup/restore functionality, and remote configuration management.

BIN
.images/SIDEL.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

225
main.py
View File

@ -1,4 +1,12 @@
from flask import Flask, render_template, request, jsonify, redirect, url_for
from flask import (
Flask,
render_template,
request,
jsonify,
redirect,
url_for,
send_from_directory,
)
import snap7
import json
import socket
@ -9,6 +17,8 @@ from datetime import datetime
from typing import Dict, Any, Optional
import struct
import os
import csv
from pathlib import Path
app = Flask(__name__)
app.secret_key = "plc_streamer_secret_key"
@ -27,6 +37,15 @@ class PLCDataStreamer:
# Configurable variables
self.variables = {}
self.streaming_variables = set() # Variables selected for streaming
# CSV recording settings
self.csv_recording = False
self.csv_record_thread = None
self.current_csv_file = None
self.current_csv_writer = None
self.current_hour = None
self.csv_headers_written = False
# System states
self.plc = None
@ -136,9 +155,129 @@ class PLCDataStreamer:
"""Remove a variable from polling"""
if name in self.variables:
del self.variables[name]
# Also remove from streaming variables if present
self.streaming_variables.discard(name)
self.save_variables()
self.logger.info(f"Variable removed: {name}")
def toggle_streaming_variable(self, name: str, enabled: bool):
"""Enable or disable a variable for streaming"""
if name in self.variables:
if enabled:
self.streaming_variables.add(name)
else:
self.streaming_variables.discard(name)
self.logger.info(
f"Variable {name} streaming: {'enabled' if enabled else 'disabled'}"
)
def get_csv_directory_path(self) -> str:
"""Get the directory path for current day's CSV files"""
now = datetime.now()
day_folder = now.strftime("%d-%m-%Y")
return os.path.join("records", day_folder)
def get_csv_file_path(self) -> str:
"""Get the complete file path for current hour's CSV file"""
now = datetime.now()
hour = now.strftime("%H")
directory = self.get_csv_directory_path()
return os.path.join(directory, f"{hour}.csv")
def ensure_csv_directory(self):
"""Create CSV directory structure if it doesn't exist"""
directory = self.get_csv_directory_path()
Path(directory).mkdir(parents=True, exist_ok=True)
def setup_csv_file(self):
"""Setup CSV file for the current hour"""
current_hour = datetime.now().hour
# Check if we need to create a new file
if self.current_hour != current_hour or self.current_csv_file is None:
# Close previous file if open
if self.current_csv_file:
self.current_csv_file.close()
# Create directory and file for current hour
self.ensure_csv_directory()
csv_path = self.get_csv_file_path()
# Check if file exists to determine if we need headers
file_exists = os.path.exists(csv_path)
self.current_csv_file = open(csv_path, "a", newline="", encoding="utf-8")
self.current_csv_writer = csv.writer(self.current_csv_file)
self.current_hour = current_hour
# Write headers if it's a new file
if not file_exists and self.variables:
headers = ["timestamp"] + list(self.variables.keys())
self.current_csv_writer.writerow(headers)
self.current_csv_file.flush()
self.csv_headers_written = True
self.logger.info(f"CSV file created: {csv_path}")
def write_csv_data(self, data: Dict[str, Any]):
"""Write data to CSV file"""
if not self.csv_recording or not self.variables:
return
try:
self.setup_csv_file()
if self.current_csv_writer:
# Create timestamp
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
# Create row with all variables (use None for missing values)
row = [timestamp]
for var_name in self.variables.keys():
row.append(data.get(var_name, None))
self.current_csv_writer.writerow(row)
self.current_csv_file.flush()
except Exception as e:
self.logger.error(f"Error writing CSV data: {e}")
def get_streaming_data(self, all_data: Dict[str, Any]) -> Dict[str, Any]:
"""Filter data for streaming based on selected variables"""
if not self.streaming_variables:
return all_data
return {
name: value
for name, value in all_data.items()
if name in self.streaming_variables
}
def start_csv_recording(self) -> bool:
"""Start CSV recording"""
if not self.connected:
self.logger.error("PLC not connected")
return False
if not self.variables:
self.logger.error("No variables configured")
return False
self.csv_recording = True
self.logger.info("CSV recording started")
return True
def stop_csv_recording(self):
"""Stop CSV recording"""
self.csv_recording = False
if self.current_csv_file:
self.current_csv_file.close()
self.current_csv_file = None
self.current_csv_writer = None
self.current_hour = None
self.logger.info("CSV recording stopped")
def connect_plc(self) -> bool:
"""Connect to S7-315 PLC"""
try:
@ -254,15 +393,24 @@ class PLCDataStreamer:
start_time = time.time()
# Read all variables
data = self.read_all_variables()
all_data = self.read_all_variables()
if data:
# Send to PlotJuggler
self.send_to_plotjuggler(data)
if all_data:
# Write to CSV (all variables)
self.write_csv_data(all_data)
# Get filtered data for streaming
streaming_data = self.get_streaming_data(all_data)
# Send filtered data to PlotJuggler
if streaming_data:
self.send_to_plotjuggler(streaming_data)
# Log data
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
self.logger.info(f"[{timestamp}] {data}")
self.logger.info(
f"[{timestamp}] CSV: {len(all_data)} vars, Streaming: {len(streaming_data)} vars"
)
# Maintain sampling interval
elapsed = time.time() - start_time
@ -286,12 +434,15 @@ class PLCDataStreamer:
if not self.setup_udp_socket():
return False
# Start CSV recording automatically
self.start_csv_recording()
self.streaming = True
self.stream_thread = threading.Thread(target=self.streaming_loop)
self.stream_thread.daemon = True
self.stream_thread.start()
self.logger.info("Streaming started")
self.logger.info("Streaming and CSV recording started")
return True
def stop_streaming(self):
@ -300,21 +451,29 @@ class PLCDataStreamer:
if self.stream_thread:
self.stream_thread.join(timeout=2)
# Stop CSV recording
self.stop_csv_recording()
if self.udp_socket:
self.udp_socket.close()
self.udp_socket = None
self.logger.info("Streaming stopped")
self.logger.info("Streaming and CSV recording stopped")
def get_status(self) -> Dict[str, Any]:
"""Get current system status"""
return {
"plc_connected": self.connected,
"streaming": self.streaming,
"csv_recording": self.csv_recording,
"plc_config": self.plc_config,
"udp_config": self.udp_config,
"variables_count": len(self.variables),
"streaming_variables_count": len(self.streaming_variables),
"sampling_interval": self.sampling_interval,
"current_csv_file": (
self.get_csv_file_path() if self.csv_recording else None
),
}
@ -322,6 +481,12 @@ class PLCDataStreamer:
streamer = PLCDataStreamer()
@app.route("/images/<filename>")
def serve_image(filename):
"""Serve images from .images directory"""
return send_from_directory(".images", filename)
@app.route("/")
def index():
"""Main page"""
@ -405,6 +570,31 @@ def remove_variable(name):
return jsonify({"success": True, "message": f"Variable {name} removed"})
@app.route("/api/variables/<name>/streaming", methods=["POST"])
def toggle_variable_streaming(name):
"""Toggle streaming for a specific variable"""
try:
data = request.get_json()
enabled = data.get("enabled", False)
streamer.toggle_streaming_variable(name, enabled)
status = "enabled" if enabled else "disabled"
return jsonify(
{"success": True, "message": f"Variable {name} streaming {status}"}
)
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 400
@app.route("/api/variables/streaming", methods=["GET"])
def get_streaming_variables():
"""Get list of variables enabled for streaming"""
return jsonify(
{"success": True, "streaming_variables": list(streamer.streaming_variables)}
)
@app.route("/api/streaming/start", methods=["POST"])
def start_streaming():
"""Start streaming"""
@ -438,6 +628,25 @@ def update_sampling():
return jsonify({"success": False, "message": str(e)}), 400
@app.route("/api/csv/start", methods=["POST"])
def start_csv_recording():
"""Start CSV recording independently"""
if streamer.start_csv_recording():
return jsonify({"success": True, "message": "CSV recording started"})
else:
return (
jsonify({"success": False, "message": "Error starting CSV recording"}),
500,
)
@app.route("/api/csv/stop", methods=["POST"])
def stop_csv_recording():
"""Stop CSV recording independently"""
streamer.stop_csv_recording()
return jsonify({"success": True, "message": "CSV recording stopped"})
@app.route("/api/status")
def get_status():
"""Get current status"""

12
plc_config.json Normal file
View File

@ -0,0 +1,12 @@
{
"plc_config": {
"ip": "10.1.33.11",
"rack": 0,
"slot": 2
},
"udp_config": {
"host": "127.0.0.1",
"port": 9870
},
"sampling_interval": 0.1
}

12
plc_variables.json Normal file
View File

@ -0,0 +1,12 @@
{
"UR29_Brix": {
"db": 2121,
"offset": 18,
"type": "real"
},
"UR62_Brix": {
"db": 2122,
"offset": 18,
"type": "real"
}
}

32694
records/17-07-2025/13.csv Normal file

File diff suppressed because it is too large Load Diff

3955
records/17-07-2025/14.csv Normal file

File diff suppressed because it is too large Load Diff

View File

@ -35,6 +35,16 @@
font-size: 2.5rem;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
}
.header-logo {
height: 60px;
width: auto;
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.3));
}
.status-bar {
@ -223,9 +233,32 @@
margin-top: 15px;
}
.info-section {
background-color: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
}
.info-section p {
margin: 5px 0;
color: #374151;
}
.info-section strong {
color: #1f2937;
}
@media (max-width: 768px) {
.header h1 {
font-size: 2rem;
flex-direction: column;
gap: 10px;
}
.header-logo {
height: 45px;
}
.form-row {
@ -242,7 +275,10 @@
<body>
<div class="container">
<div class="header">
<h1>🏭 PLC S7-315 Streamer & Logger</h1>
<h1>
<img src="/images/SIDEL.png" alt="SIDEL Logo" class="header-logo">
🏭 PLC S7-315 Streamer & Logger
</h1>
<p>Real-time monitoring and streaming system</p>
</div>
@ -261,6 +297,9 @@
<div class="status-item status-idle">
⏱️ Interval: {{ status.sampling_interval }}s
</div>
<div class="status-item" id="csv-status">
💾 CSV: Inactive
</div>
</div>
</div>
@ -359,6 +398,7 @@
<th>Data Block</th>
<th>Offset</th>
<th>Type</th>
<th>Stream to PlotJuggler</th>
<th>Actions</th>
</tr>
</thead>
@ -369,6 +409,11 @@
<td>DB{{ var.db }}</td>
<td>{{ var.offset }}</td>
<td>{{ var.type.upper() }}</td>
<td>
<input type="checkbox" id="stream-{{ name }}"
onchange="toggleStreaming('{{ name }}', this.checked)">
<label for="stream-{{ name }}">Enable</label>
</td>
<td>
<button class="btn btn-danger" onclick="removeVariable('{{ name }}')">🗑️ Remove</button>
</td>
@ -378,12 +423,32 @@
</table>
</div>
<!-- CSV Recording Control -->
<div class="card">
<h2>💾 CSV Recording Control</h2>
<div class="info-section">
<p><strong>📁 Recording Location:</strong> records/[dd-mm-yyyy]/[hour].csv</p>
<p><strong>📊 Recording Mode:</strong> All defined variables are automatically saved to CSV</p>
<p><strong>📅 File Organization:</strong> One file per hour, automatic directory creation</p>
<p id="current-csv-file"></p>
</div>
<div class="controls">
<button class="btn btn-success" id="start-csv-btn">💾 Start CSV Recording</button>
<button class="btn btn-danger" id="stop-csv-btn">⏹️ Stop CSV Recording</button>
</div>
</div>
<!-- Streaming Control -->
<div class="card">
<h2>🚀 Streaming Control</h2>
<div class="info-section">
<p><strong>📡 Streaming Mode:</strong> Only variables marked for streaming are sent to PlotJuggler</p>
<p><strong>🔄 Combined Operation:</strong> Starting streaming also starts CSV recording automatically
</p>
</div>
<div class="controls">
<button class="btn btn-success" id="start-streaming-btn">▶️ Start Streaming</button>
<button class="btn btn-danger" id="stop-streaming-btn">⏹️ Stop Streaming</button>
<button class="btn btn-success" id="start-streaming-btn">▶️ Start Streaming & CSV</button>
<button class="btn btn-danger" id="stop-streaming-btn">⏹️ Stop Streaming & CSV</button>
<button class="btn" onclick="location.reload()">🔄 Refresh Status</button>
</div>
</div>
@ -407,6 +472,7 @@
.then(data => {
const plcStatus = document.getElementById('plc-status');
const streamStatus = document.getElementById('stream-status');
const csvStatus = document.getElementById('csv-status');
if (data.plc_connected) {
plcStatus.textContent = '🔌 PLC: Connected';
@ -417,12 +483,28 @@
}
if (data.streaming) {
streamStatus.textContent = '📡 Streaming: Active';
streamStatus.textContent = `📡 Streaming: Active (${data.streaming_variables_count} vars)`;
streamStatus.className = 'status-item status-streaming';
} else {
streamStatus.textContent = '📡 Streaming: Inactive';
streamStatus.className = 'status-item status-idle';
}
if (data.csv_recording) {
csvStatus.textContent = '💾 CSV: Recording';
csvStatus.className = 'status-item status-streaming';
} else {
csvStatus.textContent = '💾 CSV: Inactive';
csvStatus.className = 'status-item status-idle';
}
// Update current CSV file info
const csvFileInfo = document.getElementById('current-csv-file');
if (data.current_csv_file && data.csv_recording) {
csvFileInfo.innerHTML = `<strong>📁 Current File:</strong> ${data.current_csv_file}`;
} else {
csvFileInfo.innerHTML = '';
}
})
.catch(error => console.error('Error updating status:', error));
}
@ -510,6 +592,41 @@
});
});
// Toggle streaming for variable
function toggleStreaming(varName, enabled) {
fetch(`/api/variables/${varName}/streaming`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: enabled })
})
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus(); // Update streaming variable count
})
.catch(error => {
console.error('Error toggling streaming:', error);
showMessage('Error updating streaming setting', 'error');
});
}
// Load streaming variables status
function loadStreamingStatus() {
fetch('/api/variables/streaming')
.then(response => response.json())
.then(data => {
if (data.success) {
data.streaming_variables.forEach(varName => {
const checkbox = document.getElementById(`stream-${varName}`);
if (checkbox) {
checkbox.checked = true;
}
});
}
})
.catch(error => console.error('Error loading streaming status:', error));
}
// Remove variable
function removeVariable(name) {
if (confirm(`Are you sure you want to remove the variable "${name}"?`)) {
@ -558,11 +675,32 @@
});
});
// Start CSV recording
document.getElementById('start-csv-btn').addEventListener('click', function () {
fetch('/api/csv/start', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
// Stop CSV recording
document.getElementById('stop-csv-btn').addEventListener('click', function () {
fetch('/api/csv/stop', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
// Update status every 5 seconds
setInterval(updateStatus, 5000);
// Initial update
updateStatus();
loadStreamingStatus();
</script>
</body>