"Enhance PLC data monitoring and error handling"

Key changes include:

Improved error handling and logging in PLC data reading
Added detailed debug information with pprint
Restructured logging format for better readability
Removed redundant docstrings
Fixed PLC data reading logic with proper value extraction
Added support for extra properties in logger setup
Code cleanup and formatting improvements
This commit is contained in:
Igor Barcik 2024-12-10 07:46:40 +01:00
parent 7447dd8e43
commit 660ea7666f
4 changed files with 54 additions and 71 deletions

View File

@ -1,3 +1,4 @@
# IndustrialEnergyTracker # IndustrialEnergyTracker
Periodically read values of air and energy consumption from Siemens PLC and PACs meters Periodically read values of air and energy consumption from Siemens PLC and PACs meters.
Yes

View File

@ -1,5 +1,6 @@
import os import os
import platform import platform
import pprint
import socket import socket
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -8,12 +9,12 @@ from typing import List, Tuple
import pyodbc import pyodbc
import snap7 import snap7
from dotenv import load_dotenv from dotenv import load_dotenv
from snap7.util.getters import get_lreal, get_ulint from snap7.util.getters import get_lreal, get_bool
load_dotenv() load_dotenv()
# Determine the correct driver based on OS # Determine the correct driver based on OS
SQL_DRIVER = "ODBC Driver 18 for SQL Server" if platform.system() == "Linux" else "SQL Server" SQL_DRIVER = (
"ODBC Driver 18 for SQL Server" if platform.system() == "Linux" else "SQL Server"
)
# Configuration # Configuration
CONN_STR = ( CONN_STR = (
f"Driver={{{SQL_DRIVER}}};" f"Driver={{{SQL_DRIVER}}};"
@ -26,21 +27,6 @@ CONN_STR = (
@dataclass @dataclass
class PlcConfig: class PlcConfig:
"""
Represents the configuration for a Programmable Logic Controller (PLC) device.
This class holds the necessary information to connect to and read data from a PLC,
including its IP address, database number, and offsets for air and energy data.
Attributes:
`id` (int): The unique identifier for the PLC.
`ip` (str): The IP address of the PLC.
`db_number` (int): The database number to read from the PLC.
`air_offset` (int): The offset within the database for the air data.
`energy_offset` (int): The offset within the database for the energy data.
`is_enabled` (bool): Whether the PLC is enabled and should be monitored.
"""
id: int id: int
ip: str ip: str
db_number: int db_number: int
@ -52,24 +38,15 @@ class PlcConfig:
last_air_read: float last_air_read: float
runstop_status_offset: int runstop_status_offset: int
@dataclass @dataclass
class SchedulerConfig: class SchedulerConfig:
interval: int interval: int
next_read: datetime next_read: datetime
class DatabaseManager: class DatabaseManager:
def __init__(self, connection_string: str): def __init__(self, connection_string: str):
self.conn_str = connection_string self.conn_str = connection_string
def get_plc_configs(self) -> List[PlcConfig]: def get_plc_configs(self) -> List[PlcConfig]:
"""
Retrieves a list of enabled PLC configurations from the database.
Returns:
List[PlcConfig]: A list of PlcConfig objects representing the enabled PLCs.
"""
with pyodbc.connect(self.conn_str) as conn: with pyodbc.connect(self.conn_str) as conn:
with conn.cursor() as cursor: with conn.cursor() as cursor:
cursor.execute(""" cursor.execute("""
@ -78,14 +55,7 @@ class DatabaseManager:
WHERE IsEnable = 1 WHERE IsEnable = 1
""") """)
return [PlcConfig(*row) for row in cursor.fetchall()] return [PlcConfig(*row) for row in cursor.fetchall()]
def get_scheduler_config(self) -> SchedulerConfig: def get_scheduler_config(self) -> SchedulerConfig:
"""
Retrieves the scheduler configuration from the database.
Returns:
SchedulerConfig: The scheduler configuration, including the interval and the next read time.
"""
with pyodbc.connect(self.conn_str) as conn: with pyodbc.connect(self.conn_str) as conn:
with conn.cursor() as cursor: with conn.cursor() as cursor:
cursor.execute(""" cursor.execute("""
@ -99,7 +69,6 @@ class DatabaseManager:
if row if row
else SchedulerConfig(30, datetime.now()) else SchedulerConfig(30, datetime.now())
) )
def save_energy_data(self, plc_id: int, energy: float, air: float, state: bool): def save_energy_data(self, plc_id: int, energy: float, air: float, state: bool):
with pyodbc.connect(self.conn_str) as conn: with pyodbc.connect(self.conn_str) as conn:
with conn.cursor() as cursor: with conn.cursor() as cursor:
@ -111,7 +80,6 @@ class DatabaseManager:
(energy, plc_id, air, state, datetime.now()), (energy, plc_id, air, state, datetime.now()),
) )
conn.commit() conn.commit()
def update_next_read(self, interval_seconds: int): def update_next_read(self, interval_seconds: int):
next_read = datetime.now() + timedelta(seconds=interval_seconds) next_read = datetime.now() + timedelta(seconds=interval_seconds)
with pyodbc.connect(self.conn_str) as conn: with pyodbc.connect(self.conn_str) as conn:
@ -126,25 +94,41 @@ class DatabaseManager:
) )
conn.commit() conn.commit()
class PlcManager: class PlcManager:
@staticmethod def check_connection(self, ip: str, port: int = 102, timeout: int = 1) -> bool:
def check_connection(ip: str, port: int = 102, timeout: int = 1) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(timeout) sock.settimeout(timeout)
return sock.connect_ex((ip, port)) == 0 return sock.connect_ex((ip, port)) == 0
def read_plc_data(self, config: PlcConfig) -> Tuple[float, float, bool]: def read_plc_data(self, config: PlcConfig) -> Tuple[float, float, bool]:
plc = snap7.client.Client() plc = snap7.client.Client()
try: try:
plc.connect(config.ip, 0, 1) plc.connect(config.ip, 0, 1)
db_data = plc.db_read( data = plc.db_read(config.db_number, 0, config.runstop_status_offset) # Define range of bytes to read
config.db_number, 0, config.runstop_status_offset energy_value = get_lreal(data, config.energy_offset) # Read energy value
) # Read up to energy offset + 8 bytes air_value = get_lreal(data, config.air_offset) # Read air value
air_value = get_lreal(db_data, config.air_offset) # run_status_value = get_bool(data, config.runstop_status_offset, 1) # Read run start value
# TODO if (air_value < prev_air_val): update in plc return air_value, energy_value, True
energy_value = get_lreal(db_data, config.energy_offset) except Exception as e:
stauts_value = getattr(db_data, config.runstatus_offset) pprint.pp(
return air_value, energy_value, stauts_value {
"msg": "Failed to read PLC data",
"plc_config": {
"id": config.id,
"ip": config.ip,
"db_number": config.db_number,
"air_offset": config.air_offset,
"energy_offset": config.energy_offset,
"runstop_status_offset": config.runstop_status_offset,
"location": config.location,
"ene_val": energy_value,
"air_val": air_value,
"run_status_val": run_status_value,
"timestamp": datetime.now().isoformat()
},
"error": str(e),
"timestamp": datetime.now().isoformat()
}
)
raise
finally: finally:
plc.disconnect() plc.disconnect()

View File

@ -32,6 +32,7 @@ def setup_logger():
level=logging.DEBUG, level=logging.DEBUG,
batch_size=50, # Increased batch size batch_size=50, # Increased batch size
auto_flush_timeout=10, # Increased flush timeout to 10 seconds auto_flush_timeout=10, # Increased flush timeout to 10 seconds
support_extra_properties=True,
) )
return logger return logger

37
main.py
View File

@ -1,7 +1,7 @@
import os import os
import pprint
import time import time
from datetime import datetime from datetime import datetime
import pprint
from energy_monitor import CONN_STR, DatabaseManager, PlcManager from energy_monitor import CONN_STR, DatabaseManager, PlcManager
from logger_setup import setup_logger from logger_setup import setup_logger
@ -11,7 +11,6 @@ DEVICE_DELAY = 1 # Delay between device data fetches
POOL_RATE = 2 # 2s polling rate reading of NextRead value from database POOL_RATE = 2 # 2s polling rate reading of NextRead value from database
FALLBACK_INTERVAL = 20 # 1h = 3600s interval in case db read issue FALLBACK_INTERVAL = 20 # 1h = 3600s interval in case db read issue
# Logger setup # Logger setup
logger = setup_logger() logger = setup_logger()
@ -20,8 +19,8 @@ def process_plc_devices(db_manager: DatabaseManager, plc_manager: PlcManager):
plc_configs = db_manager.get_plc_configs() plc_configs = db_manager.get_plc_configs()
# If debug print out plc_configs to logs # If debug print out plc_configs to logs
logger.debug( logger.debug(
"Retrieved PLC configurations", {
extra={ "msg": "Retrieved PLC configurations",
"plc_configs": [ "plc_configs": [
{ {
"id": plc.id, "id": plc.id,
@ -39,21 +38,21 @@ def process_plc_devices(db_manager: DatabaseManager, plc_manager: PlcManager):
try: try:
if not plc_manager.check_connection(plc_config.ip): if not plc_manager.check_connection(plc_config.ip):
logger.error( logger.error(
"PLC connection failed", {
extra={ "msg": "PLC connection failed",
"PlcId": plc_config.id, "PlcId": plc_config.id,
"PlcIp": plc_config.ip, "PlcIp": plc_config.ip,
"timestamp": datetime.now().isoformat(), "timestamp": datetime.now().isoformat(),
}, }
) )
continue continue
air_value, energy_value = plc_manager.read_plc_data(plc_config) air_value, energy_value, run_status = plc_manager.read_plc_data(plc_config)
db_manager.save_energy_data(plc_config.id, energy_value, air_value, True) db_manager.save_energy_data(plc_config.id, energy_value, air_value, True)
logger.info( logger.info(
"✅ Data successfully read and saved", {
extra={ "msg": "✅ Data successfully read and saved",
"PlcId": plc_config.id, "PlcId": plc_config.id,
"PlcIp": plc_config.ip, "PlcIp": plc_config.ip,
"energy_value": energy_value, "energy_value": energy_value,
@ -66,8 +65,8 @@ def process_plc_devices(db_manager: DatabaseManager, plc_manager: PlcManager):
except Exception as e: except Exception as e:
logger.error( logger.error(
"Error processing PLC", {
extra={ "msg": "Error reading PLC data",
"PlcId": plc_config.id, "PlcId": plc_config.id,
"PlcIp": plc_config.ip, "PlcIp": plc_config.ip,
"error": str(e), "error": str(e),
@ -77,6 +76,9 @@ def process_plc_devices(db_manager: DatabaseManager, plc_manager: PlcManager):
def main(): def main():
db_manager = DatabaseManager(CONN_STR)
plc_manager = PlcManager()
pprint.pp( pprint.pp(
{ {
"SQL_Config": { "SQL_Config": {
@ -94,8 +96,6 @@ def main():
"timestamp": datetime.now().isoformat(), "timestamp": datetime.now().isoformat(),
} }
) )
db_manager = DatabaseManager(CONN_STR)
plc_manager = PlcManager()
while True: while True:
try: try:
@ -108,15 +108,12 @@ def main():
time.sleep(POOL_RATE) time.sleep(POOL_RATE)
except Exception as e: except Exception as e:
pprint.pp( logger.error(
{ {
"msg": "Main loop error! Falling back to 1h interval.",
"error": str(e), "error": str(e),
"timestamp": datetime.now().isoformat(), "timestamp": datetime.now().isoformat(),
} },
)
logger.error(
"Main loop error! Falling back to 1h interval.",
extra={"error": str(e), "timestamp": datetime.now().isoformat()},
) )
time.sleep(FALLBACK_INTERVAL) # Default fallback interval time.sleep(FALLBACK_INTERVAL) # Default fallback interval