S7_snap7_Stremer_n_Recorder/main.py

820 lines
25 KiB
Python

from flask import (
Flask,
render_template,
request,
jsonify,
redirect,
url_for,
send_from_directory,
)
import snap7
import snap7.util
import json
import socket
import time
import logging
import threading
from datetime import datetime
from typing import Dict, Any, Optional, List
import struct
import os
import csv
from pathlib import Path
import atexit
import psutil
import sys
from core.plc_client import PLCDataStreamer
app = Flask(__name__)
app.secret_key = "plc_streamer_secret_key"
def resource_path(relative_path):
""" Get absolute path to resource, works for dev and for PyInstaller """
try:
# PyInstaller creates a temp folder and stores path in _MEIPASS
base_path = sys._MEIPASS
except Exception:
# Not running in a bundle
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path)
# Global streamer instance (will be initialized in main)
streamer = None
def check_streamer_initialized():
"""Check if streamer is initialized, return error response if not"""
if streamer is None:
return jsonify({"error": "Application not initialized"}), 503
return None
@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"""
if streamer is None:
return "Application not initialized", 503
# Get variables for the current dataset or empty dict if no current dataset
current_variables = {}
if streamer.current_dataset_id and streamer.current_dataset_id in streamer.datasets:
current_variables = streamer.datasets[streamer.current_dataset_id].get(
"variables", {}
)
return render_template(
"index.html",
status=streamer.get_status(),
variables=current_variables,
datasets=streamer.datasets,
current_dataset_id=streamer.current_dataset_id,
)
@app.route("/api/plc/config", methods=["POST"])
def update_plc_config():
"""Update PLC configuration"""
error_response = check_streamer_initialized()
if error_response:
return error_response
try:
data = request.get_json()
ip = data.get("ip", "10.1.33.11")
rack = int(data.get("rack", 0))
slot = int(data.get("slot", 2))
streamer.update_plc_config(ip, rack, slot)
return jsonify({"success": True, "message": "PLC configuration updated"})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 400
@app.route("/api/udp/config", methods=["POST"])
def update_udp_config():
"""Update UDP configuration"""
try:
data = request.get_json()
host = data.get("host", "127.0.0.1")
port = int(data.get("port", 9870))
streamer.update_udp_config(host, port)
return jsonify({"success": True, "message": "UDP configuration updated"})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 400
@app.route("/api/plc/connect", methods=["POST"])
def connect_plc():
"""Connect to PLC"""
error_response = check_streamer_initialized()
if error_response:
return error_response
if streamer.connect_plc():
return jsonify({"success": True, "message": "Connected to PLC"})
else:
return jsonify({"success": False, "message": "Error connecting to PLC"}), 500
@app.route("/api/plc/disconnect", methods=["POST"])
def disconnect_plc():
"""Disconnect from PLC"""
streamer.stop_streaming()
streamer.disconnect_plc()
return jsonify({"success": True, "message": "Disconnected from PLC"})
@app.route("/api/variables", methods=["POST"])
def add_variable():
"""Add a new variable"""
try:
data = request.get_json()
name = data.get("name")
area = data.get("area")
db = int(data.get("db"))
offset = int(data.get("offset"))
var_type = data.get("type")
bit = data.get("bit") # Added bit parameter
valid_types = [
"real",
"int",
"bool",
"dint",
"word",
"byte",
"uint",
"udint",
"sint",
"usint",
]
valid_areas = ["db", "mw", "m", "pew", "pe", "paw", "pa", "e", "a", "mb"]
if (
not name
or not area
or var_type not in valid_types
or area.lower() not in valid_areas
):
return jsonify({"success": False, "message": "Invalid data"}), 400
if area.lower() in ["e", "a", "mb"] and bit is None:
return (
jsonify(
{
"success": False,
"message": "Bit position must be specified for bit areas",
}
),
400,
)
if area.lower() in ["e", "a", "mb"] and (bit < 0 or bit > 7):
return (
jsonify(
{
"success": False,
"message": "Bit position must be between 0 and 7",
}
),
400,
)
streamer.add_variable(name, area, db, offset, var_type, bit)
return jsonify({"success": True, "message": f"Variable {name} added"})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 400
@app.route("/api/variables/<name>", methods=["DELETE"])
def remove_variable(name):
"""Remove a variable"""
streamer.remove_variable(name)
return jsonify({"success": True, "message": f"Variable {name} removed"})
@app.route("/api/variables/<name>", methods=["GET"])
def get_variable(name):
"""Get a specific variable configuration from current dataset"""
try:
if not streamer.current_dataset_id:
return (
jsonify({"success": False, "message": "No dataset selected"}),
400,
)
current_variables = streamer.get_dataset_variables(streamer.current_dataset_id)
if name not in current_variables:
return (
jsonify({"success": False, "message": f"Variable {name} not found"}),
404,
)
var_config = current_variables[name].copy()
var_config["name"] = name
# Check if variable is in streaming list for current dataset
streaming_vars = streamer.datasets[streamer.current_dataset_id].get(
"streaming_variables", []
)
var_config["streaming"] = name in streaming_vars
return jsonify({"success": True, "variable": var_config})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 400
@app.route("/api/variables/<name>", methods=["PUT"])
def update_variable(name):
"""Update an existing variable in current dataset"""
try:
if not streamer.current_dataset_id:
return jsonify({"success": False, "message": "No dataset selected"}), 400
data = request.get_json()
new_name = data.get("name", name)
area = data.get("area")
db = int(data.get("db", 1))
offset = int(data.get("offset"))
var_type = data.get("type")
bit = data.get("bit")
valid_types = [
"real",
"int",
"bool",
"dint",
"word",
"byte",
"uint",
"udint",
"sint",
"usint",
]
valid_areas = ["db", "mw", "m", "pew", "pe", "paw", "pa", "e", "a", "mb"]
if (
not new_name
or not area
or var_type not in valid_types
or area.lower() not in valid_areas
):
return jsonify({"success": False, "message": "Invalid data"}), 400
if area.lower() in ["e", "a", "mb"] and bit is None:
return (
jsonify(
{
"success": False,
"message": "Bit position must be specified for bit areas",
}
),
400,
)
if area.lower() in ["e", "a", "mb"] and (bit < 0 or bit > 7):
return (
jsonify(
{
"success": False,
"message": "Bit position must be between 0 and 7",
}
),
400,
)
current_dataset_id = streamer.current_dataset_id
current_variables = streamer.get_dataset_variables(current_dataset_id)
# Check if variable exists in current dataset
if name not in current_variables:
return (
jsonify({"success": False, "message": f"Variable {name} not found"}),
404,
)
# Check if new name already exists (if name is changing)
if name != new_name and new_name in current_variables:
return (
jsonify(
{
"success": False,
"message": f"Variable {new_name} already exists",
}
),
400,
)
# Preserve streaming state
streaming_vars = streamer.datasets[current_dataset_id].get(
"streaming_variables", []
)
was_streaming = name in streaming_vars
# Remove old variable
streamer.remove_variable_from_dataset(current_dataset_id, name)
# Add updated variable
streamer.add_variable_to_dataset(
current_dataset_id, new_name, area, db, offset, var_type, bit, was_streaming
)
return jsonify({"success": True, "message": f"Variable updated successfully"})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 400
@app.route("/api/variables/<name>/streaming", methods=["POST"])
def toggle_variable_streaming(name):
"""Toggle streaming for a specific variable in current dataset"""
try:
if not streamer.current_dataset_id:
return jsonify({"success": False, "message": "No dataset selected"}), 400
data = request.get_json()
enabled = data.get("enabled", False)
# Use the new dataset-specific method
streamer.toggle_variable_streaming(streamer.current_dataset_id, 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 in current dataset"""
if streamer is None:
return jsonify({"success": False, "error": "Streamer not initialized"}), 503
# Get streaming variables from current dataset
streaming_vars = []
if streamer.current_dataset_id and streamer.current_dataset_id in streamer.datasets:
streaming_vars = streamer.datasets[streamer.current_dataset_id].get(
"streaming_variables", []
)
return jsonify({"success": True, "streaming_variables": streaming_vars})
# Dataset Management API Endpoints
@app.route("/api/datasets", methods=["GET"])
def get_datasets():
"""Get all datasets"""
error_response = check_streamer_initialized()
if error_response:
return error_response
return jsonify(
{
"success": True,
"datasets": streamer.datasets,
"active_datasets": list(streamer.active_datasets),
"current_dataset_id": streamer.current_dataset_id,
}
)
@app.route("/api/datasets", methods=["POST"])
def create_dataset():
"""Create a new dataset"""
error_response = check_streamer_initialized()
if error_response:
return error_response
try:
data = request.get_json()
dataset_id = data.get("dataset_id", "").strip()
name = data.get("name", "").strip()
prefix = data.get("prefix", "").strip()
sampling_interval = data.get("sampling_interval")
if not dataset_id or not name or not prefix:
return (
jsonify(
{
"success": False,
"message": "Dataset ID, name, and prefix are required",
}
),
400,
)
# Validate sampling interval if provided
if sampling_interval is not None:
sampling_interval = float(sampling_interval)
if sampling_interval < 0.01:
return (
jsonify(
{
"success": False,
"message": "Sampling interval must be at least 0.01 seconds",
}
),
400,
)
streamer.create_dataset(dataset_id, name, prefix, sampling_interval)
return jsonify(
{
"success": True,
"message": f"Dataset '{name}' created successfully",
"dataset_id": dataset_id,
}
)
except ValueError as e:
return jsonify({"success": False, "message": str(e)}), 400
except Exception as e:
return (
jsonify({"success": False, "message": f"Error creating dataset: {str(e)}"}),
500,
)
@app.route("/api/datasets/<dataset_id>", methods=["DELETE"])
def delete_dataset(dataset_id):
"""Delete a dataset"""
error_response = check_streamer_initialized()
if error_response:
return error_response
try:
streamer.delete_dataset(dataset_id)
return jsonify({"success": True, "message": "Dataset deleted successfully"})
except ValueError as e:
return jsonify({"success": False, "message": str(e)}), 404
except Exception as e:
return (
jsonify({"success": False, "message": f"Error deleting dataset: {str(e)}"}),
500,
)
@app.route("/api/datasets/<dataset_id>/activate", methods=["POST"])
def activate_dataset(dataset_id):
"""Activate a dataset for streaming"""
error_response = check_streamer_initialized()
if error_response:
return error_response
try:
streamer.activate_dataset(dataset_id)
return jsonify({"success": True, "message": "Dataset activated successfully"})
except ValueError as e:
return jsonify({"success": False, "message": str(e)}), 404
except RuntimeError as e:
return jsonify({"success": False, "message": str(e)}), 400
except Exception as e:
return (
jsonify(
{"success": False, "message": f"Error activating dataset: {str(e)}"}
),
500,
)
@app.route("/api/datasets/<dataset_id>/deactivate", methods=["POST"])
def deactivate_dataset(dataset_id):
"""Deactivate a dataset"""
error_response = check_streamer_initialized()
if error_response:
return error_response
try:
streamer.deactivate_dataset(dataset_id)
return jsonify({"success": True, "message": "Dataset deactivated successfully"})
except ValueError as e:
return jsonify({"success": False, "message": str(e)}), 404
except Exception as e:
return (
jsonify(
{"success": False, "message": f"Error deactivating dataset: {str(e)}"}
),
500,
)
@app.route("/api/datasets/<dataset_id>/variables", methods=["POST"])
def add_variable_to_dataset(dataset_id):
"""Add a variable to a dataset"""
error_response = check_streamer_initialized()
if error_response:
return error_response
try:
data = request.get_json()
name = data.get("name", "").strip()
area = data.get("area", "").strip()
db = data.get("db", 1)
offset = data.get("offset", 0)
var_type = data.get("type", "").strip()
bit = data.get("bit")
streaming = data.get("streaming", False)
if not name or not area or not var_type:
return (
jsonify(
{
"success": False,
"message": "Variable name, area, and type are required",
}
),
400,
)
streamer.add_variable_to_dataset(
dataset_id, name, area, db, offset, var_type, bit, streaming
)
return jsonify(
{
"success": True,
"message": f"Variable '{name}' added to dataset successfully",
}
)
except ValueError as e:
return jsonify({"success": False, "message": str(e)}), 400
except Exception as e:
return (
jsonify({"success": False, "message": f"Error adding variable: {str(e)}"}),
500,
)
@app.route("/api/datasets/<dataset_id>/variables/<variable_name>", methods=["DELETE"])
def remove_variable_from_dataset(dataset_id, variable_name):
"""Remove a variable from a dataset"""
error_response = check_streamer_initialized()
if error_response:
return error_response
try:
streamer.remove_variable_from_dataset(dataset_id, variable_name)
return jsonify(
{
"success": True,
"message": f"Variable '{variable_name}' removed from dataset successfully",
}
)
except ValueError as e:
return jsonify({"success": False, "message": str(e)}), 404
except Exception as e:
return (
jsonify(
{"success": False, "message": f"Error removing variable: {str(e)}"}
),
500,
)
@app.route(
"/api/datasets/<dataset_id>/variables/<variable_name>/streaming", methods=["POST"]
)
def toggle_variable_streaming_in_dataset(dataset_id, variable_name):
"""Toggle streaming for a variable in a dataset"""
error_response = check_streamer_initialized()
if error_response:
return error_response
try:
data = request.get_json()
enabled = data.get("enabled", False)
streamer.toggle_variable_streaming(dataset_id, variable_name, enabled)
return jsonify(
{
"success": True,
"message": f"Variable '{variable_name}' streaming {'enabled' if enabled else 'disabled'}",
}
)
except ValueError as e:
return jsonify({"success": False, "message": str(e)}), 404
except Exception as e:
return (
jsonify(
{"success": False, "message": f"Error toggling streaming: {str(e)}"}
),
500,
)
@app.route("/api/datasets/current", methods=["POST"])
def set_current_dataset():
"""Set the current dataset for editing"""
error_response = check_streamer_initialized()
if error_response:
return error_response
try:
data = request.get_json()
dataset_id = data.get("dataset_id")
if dataset_id and dataset_id in streamer.datasets:
streamer.current_dataset_id = dataset_id
streamer.save_datasets()
return jsonify(
{
"success": True,
"message": "Current dataset updated successfully",
"current_dataset_id": dataset_id,
}
)
else:
return jsonify({"success": False, "message": "Invalid dataset ID"}), 400
except Exception as e:
return (
jsonify(
{
"success": False,
"message": f"Error setting current dataset: {str(e)}",
}
),
500,
)
@app.route("/api/streaming/start", methods=["POST"])
def start_streaming():
"""Start streaming"""
error_response = check_streamer_initialized()
if error_response:
return error_response
if streamer.start_streaming():
return jsonify({"success": True, "message": "Streaming started"})
else:
return jsonify({"success": False, "message": "Error starting streaming"}), 500
@app.route("/api/streaming/stop", methods=["POST"])
def stop_streaming():
"""Stop streaming"""
streamer.stop_streaming()
return jsonify({"success": True, "message": "Streaming stopped"})
@app.route("/api/sampling", methods=["POST"])
def update_sampling():
"""Update sampling interval"""
try:
data = request.get_json()
interval = float(data.get("interval", 0.1))
if interval < 0.01:
interval = 0.01
streamer.update_sampling_interval(interval)
return jsonify({"success": True, "message": f"Interval updated to {interval}s"})
except Exception as e:
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"""
if streamer is None:
return jsonify({"error": "Application not initialized"}), 503
return jsonify(streamer.get_status())
@app.route("/api/events")
def get_events():
"""Get recent events from the application log"""
error_response = check_streamer_initialized()
if error_response:
return error_response
try:
limit = request.args.get("limit", 50, type=int)
limit = min(limit, 200) # Maximum 200 events per request
events = streamer.get_recent_events(limit)
return jsonify(
{
"success": True,
"events": events,
"total_events": len(streamer.events_log),
"showing": len(events),
}
)
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
def graceful_shutdown():
"""Perform graceful shutdown"""
print("\n⏹️ Performing graceful shutdown...")
try:
streamer.stop_streaming()
streamer.disconnect_plc()
streamer.release_instance_lock()
print("✅ Shutdown completed successfully")
except Exception as e:
print(f"⚠️ Error during shutdown: {e}")
def main():
"""Main application entry point with error handling and recovery"""
max_retries = 3
retry_count = 0
while retry_count < max_retries:
try:
# Create templates directory if it doesn't exist
os.makedirs("templates", exist_ok=True)
print("🚀 Starting Flask server for PLC S7-315 Streamer")
print("📊 Web interface available at: http://localhost:5050")
print("🔧 Configure your PLC and variables through the web interface")
# Initialize streamer (this will handle instance locking and auto-recovery)
global streamer
# Start Flask application
app.run(debug=False, host="0.0.0.0", port=5050, use_reloader=False)
# If we reach here, the server stopped normally
break
except RuntimeError as e:
if "Another instance" in str(e):
print(f"{e}")
print("💡 Tip: Stop the other instance or wait for it to finish")
sys.exit(1)
else:
print(f"⚠️ Runtime error: {e}")
retry_count += 1
except KeyboardInterrupt:
print("\n⏸️ Received interrupt signal...")
graceful_shutdown()
break
except Exception as e:
print(f"💥 Unexpected error: {e}")
retry_count += 1
if retry_count < max_retries:
print(f"🔄 Attempting restart ({retry_count}/{max_retries})...")
time.sleep(2) # Wait before retry
else:
print("❌ Maximum retries reached. Exiting...")
graceful_shutdown()
sys.exit(1)
if __name__ == "__main__":
try:
# Initialize streamer instance
streamer = PLCDataStreamer()
main()
except Exception as e:
print(f"💥 Critical error during initialization: {e}")
sys.exit(1)