WIP
This commit is contained in:
parent
86774c95d3
commit
fc3aeb9a14
9
.env.template
Normal file
9
.env.template
Normal file
@ -0,0 +1,9 @@
|
||||
# Database credentials
|
||||
DB_SERVER=
|
||||
DB_NAME=
|
||||
DB_USER=
|
||||
DB_PASSWORD=
|
||||
|
||||
# Seq logging credentials
|
||||
SEQ_URL=
|
||||
SEQ_API_KEY=
|
156
energy_monitor.py
Normal file
156
energy_monitor.py
Normal file
@ -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()
|
79
main.py
Normal file
79
main.py
Normal file
@ -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()
|
Loading…
x
Reference in New Issue
Block a user