From fc3aeb9a14738f324a8ba9c02d4b302974579605 Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Mon, 18 Nov 2024 14:37:54 +0100 Subject: [PATCH] WIP --- .env.template | 9 +++ energy_monitor.py | 156 ++++++++++++++++++++++++++++++++++++++++++++++ main.py | 79 +++++++++++++++++++++++ 3 files changed, 244 insertions(+) create mode 100644 .env.template create mode 100644 energy_monitor.py create mode 100644 main.py diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..833b9e6 --- /dev/null +++ b/.env.template @@ -0,0 +1,9 @@ +# Database credentials +DB_SERVER= +DB_NAME= +DB_USER= +DB_PASSWORD= + +# Seq logging credentials +SEQ_URL= +SEQ_API_KEY= diff --git a/energy_monitor.py b/energy_monitor.py new file mode 100644 index 0000000..40ea40f --- /dev/null +++ b/energy_monitor.py @@ -0,0 +1,156 @@ +import logging +import os +import socket +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import List, Tuple + +import pyodbc +import seqlog +import snap7 +from dotenv import load_dotenv +from snap7.util.getters import get_lreal, get_ulint + +load_dotenv() + +# Configuration +CONN_STR = ( + f"Driver={{SQL Server}};" + f"Server={os.getenv('DB_SERVER')};" + f"Database={os.getenv('DB_NAME')};" + f"UID={os.getenv('DB_USER')};" + f"PWD={os.getenv('DB_PASSWORD')};" +) + +# Logger setup +root_logger = logging.getLogger() +# Logger setup +seq_logger = seqlog.log_to_seq( + server_url=os.getenv("SEQ_URL"), + api_key=os.getenv("SEQ_API_KEY"), + level=logging.INFO, + support_extra_properties=True, +) + + +@dataclass +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 + ip: str + db_number: int + air_offset: int + energy_offset: int + runstatus_offset: int + is_enabled: bool + + +@dataclass +class SchedulerConfig: + interval: int + next_read: datetime + + +class DatabaseManager: + def __init__(self, connection_string: str): + self.conn_str = connection_string + + 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 conn.cursor() as cursor: + cursor.execute(""" + SELECT Id, Ip, DbNumber, AirDbOffset, EnergyDbOffset, RunStatusDbOffset,IsEnable + FROM sch.Plc + WHERE IsEnable = 1 + """) + return [PlcConfig(*row) for row in cursor.fetchall()] + + 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 conn.cursor() as cursor: + cursor.execute(""" + SELECT Interval, NextRead + FROM sch.SchedulerParameters + WHERE Name = 'Python_Energy_Scheduler' + """) + row = cursor.fetchone() + return ( + SchedulerConfig(row[0], row[1]) + if row + else SchedulerConfig(30, datetime.now()) + ) + + def save_energy_data(self, plc_id: int, energy: float, air: float, state: bool): + with pyodbc.connect(self.conn_str) as conn: + with conn.cursor() as cursor: + cursor.execute( + """ + INSERT INTO sch.Energy (Energy, PlcId, Air, State, CreatedAt) + VALUES (?, ?, ?, ?, ?) + """, + (energy, plc_id, air, state, datetime.now()), + ) + conn.commit() + + def update_next_read(self, interval_seconds: int): + next_read = datetime.now() + timedelta(seconds=interval_seconds) + with pyodbc.connect(self.conn_str) as conn: + with conn.cursor() as cursor: + cursor.execute( + """ + UPDATE sch.SchedulerParameters + SET NextRead = ? + WHERE Name = 'Python_Energy_Scheduler' + """, + (next_read,), + ) + conn.commit() + + +class PlcManager: + @staticmethod + def check_connection(ip: str, port: int = 102, timeout: int = 1) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(timeout) + return sock.connect_ex((ip, port)) == 0 + + def read_plc_data(self, config: PlcConfig) -> Tuple[float, float, bool]: + plc = snap7.client.Client() + try: + plc.connect(config.ip, 0, 1) + db_data = plc.db_read( + config.db_number, config.air_offset, config.energy_offset + 10 + ) # Read up to energy offset + 8 bytes + air_value = get_ulint(db_data, config.air_offset) + # TODO if (air_value < prev_air_val): update in plc + energy_value = get_lreal(db_data, config.energy_offset) + stauts_value = getattr(db_data, config.runstatus_offset) + return air_value, energy_value, stauts_value + finally: + plc.disconnect() diff --git a/main.py b/main.py new file mode 100644 index 0000000..37fb56b --- /dev/null +++ b/main.py @@ -0,0 +1,79 @@ +import time +from datetime import datetime + +from energy_monitor import CONN_STR, DatabaseManager, PlcManager, root_logger + +# Globals +DEVICE_DELAY = 1 # Delay between device data fetches +POOL_RATE = 2 # 2s polling rate reading of NextRead value from database +FALLBACK_INTERVAL = 3600 # 1h interval in case db read issue + + +def process_plc_devices(db_manager: DatabaseManager, plc_manager: PlcManager): + plc_configs = db_manager.get_plc_configs() + + for plc_config in plc_configs: + try: + if not plc_manager.check_connection(plc_config.ip): + root_logger.error( + "PLC connection failed", + extra={ + "PlcId": plc_config.id, + "PlcIp": plc_config.ip, + "timestamp": datetime.now().isoformat(), + }, + ) + continue + + air_value, energy_value = plc_manager.read_plc_data(plc_config) + db_manager.save_energy_data(plc_config.id, energy_value, air_value, True) + + root_logger.info( + "✅ Data successfully read and saved", + extra={ + "PlcId": plc_config.id, + "PlcIp": plc_config.ip, + "energy_value": energy_value, + "air_value": air_value, + "timestamp": datetime.now().isoformat(), + }, + ) + + time.sleep(DEVICE_DELAY) # n-second delay between devices + + except Exception as e: + root_logger.error( + "Error processing PLC", + extra={ + "PlcId": plc_config.id, + "PlcIp": plc_config.ip, + "error": str(e), + "timestamp": datetime.now().isoformat(), + }, + ) + + +def main(): + db_manager = DatabaseManager(CONN_STR) + plc_manager = PlcManager() + + while True: + try: + scheduler_config = db_manager.get_scheduler_config() + + if datetime.now() >= scheduler_config.next_read: + process_plc_devices(db_manager, plc_manager) + db_manager.update_next_read(scheduler_config.interval) + + time.sleep(POOL_RATE) + + except Exception as e: + root_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 + + +if __name__ == "__main__": + main()