From df9baaa9a003c990c9ac4777a96b75750fd67a59 Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Thu, 19 Dec 2024 07:57:14 +0100 Subject: [PATCH] Developing new version, more advanced and more flexible --- .archive/old_version/.gitignore | 162 ++++++++++++++++++ README.md => .archive/old_version/README.md | 0 .../old_version/energy_monitor.py | 3 +- .../old_version/logger_setup.py | 0 main.py => .archive/old_version/main.py | 0 .archive/old_version/pyS7_test.py | 20 +++ .../old_version/snap7_test.py | 10 +- .env.template | 9 - .gitignore | 161 +---------------- chat-memory.md | 0 classes.py | 131 -------------- config.ini.template | 16 ++ notes.md | 94 ++++++++++ pyS7_test.py | 29 ---- requirements-dev.txt | 3 + requirements.txt | 20 +-- src/models/__init__.py | 11 ++ src/models/device.py | 28 +++ src/models/pac.py | 15 ++ src/models/plc.py | 19 ++ src/models/reading.py | 19 ++ src/models/scheduler.py | 12 ++ src/readers/__init__.py | 5 + src/readers/device_reader.py | 20 +++ src/readers/pac_reader.py | 16 ++ src/readers/plc_reader.py | 16 ++ src/scripts/test_db.py | 46 +++++ src/services/__init__.py | 0 src/services/database_service.py | 79 +++++++++ src/services/scheduler_service.py | 28 +++ src/utils/config.py | 34 ++++ src/utils/helpers.py | 0 src/utils/logger.py | 0 test-server.sh | 2 + 34 files changed, 665 insertions(+), 343 deletions(-) create mode 100644 .archive/old_version/.gitignore rename README.md => .archive/old_version/README.md (100%) rename energy_monitor.py => .archive/old_version/energy_monitor.py (96%) rename logger_setup.py => .archive/old_version/logger_setup.py (100%) rename main.py => .archive/old_version/main.py (100%) create mode 100644 .archive/old_version/pyS7_test.py rename snap7_test.py => .archive/old_version/snap7_test.py (75%) delete mode 100644 .env.template create mode 100644 chat-memory.md delete mode 100644 classes.py create mode 100644 config.ini.template create mode 100644 notes.md delete mode 100644 pyS7_test.py create mode 100644 requirements-dev.txt create mode 100644 src/models/__init__.py create mode 100644 src/models/device.py create mode 100644 src/models/pac.py create mode 100644 src/models/plc.py create mode 100644 src/models/reading.py create mode 100644 src/models/scheduler.py create mode 100644 src/readers/__init__.py create mode 100644 src/readers/device_reader.py create mode 100644 src/readers/pac_reader.py create mode 100644 src/readers/plc_reader.py create mode 100644 src/scripts/test_db.py create mode 100644 src/services/__init__.py create mode 100644 src/services/database_service.py create mode 100644 src/services/scheduler_service.py create mode 100644 src/utils/config.py create mode 100644 src/utils/helpers.py create mode 100644 src/utils/logger.py create mode 100644 test-server.sh diff --git a/.archive/old_version/.gitignore b/.archive/old_version/.gitignore new file mode 100644 index 0000000..5d381cc --- /dev/null +++ b/.archive/old_version/.gitignore @@ -0,0 +1,162 @@ +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + diff --git a/README.md b/.archive/old_version/README.md similarity index 100% rename from README.md rename to .archive/old_version/README.md diff --git a/energy_monitor.py b/.archive/old_version/energy_monitor.py similarity index 96% rename from energy_monitor.py rename to .archive/old_version/energy_monitor.py index e766455..76fa581 100644 --- a/energy_monitor.py +++ b/.archive/old_version/energy_monitor.py @@ -9,7 +9,8 @@ from typing import List, Tuple import pyodbc import snap7 from dotenv import load_dotenv -from snap7.util.getters import get_lreal, get_bool +from snap7.util.getters import get_bool, get_lreal + load_dotenv() # Determine the correct driver based on OS SQL_DRIVER = ( diff --git a/logger_setup.py b/.archive/old_version/logger_setup.py similarity index 100% rename from logger_setup.py rename to .archive/old_version/logger_setup.py diff --git a/main.py b/.archive/old_version/main.py similarity index 100% rename from main.py rename to .archive/old_version/main.py diff --git a/.archive/old_version/pyS7_test.py b/.archive/old_version/pyS7_test.py new file mode 100644 index 0000000..8e4f057 --- /dev/null +++ b/.archive/old_version/pyS7_test.py @@ -0,0 +1,20 @@ +import pprint +import time + +from pyS7 import S7Client + +LOOPS = 3 +counter = 0 +client = S7Client(address="172.16.3.231", rack=0, slot=2) +while counter < LOOPS: + try: + client.connect() + tags = ["DB9,DBD0"] + data = client.read(tags=tags) + print(data) + except Exception as e: + pprint.pprint(e) + finally: + client.disconnect() + counter += 1 + time.sleep(1) diff --git a/snap7_test.py b/.archive/old_version/snap7_test.py similarity index 75% rename from snap7_test.py rename to .archive/old_version/snap7_test.py index 15a9d1b..f02ccca 100644 --- a/snap7_test.py +++ b/.archive/old_version/snap7_test.py @@ -1,14 +1,16 @@ -import time -import snap7 -from snap7.util.getters import get_lreal, get_dint import pprint +import time + +import snap7 +from snap7.util.getters import get_dint, get_lreal LOOPS = 20 counter = 0 while counter < LOOPS: try: plc = snap7.client.Client() - plc.connect("172.16.3.231", 0, 2) + plc.connect("172.16.3.231", 0, 2) # fagor 6 + plc.connect("172.16.3.230", 0, 2) # fagor 5 # pprint.pprint(plc.get_cpu_state()) data = plc.db_read(9, 0, 4) # Define range of bytes to read energy_value = get_dint(data, 0) # Read energy value diff --git a/.env.template b/.env.template deleted file mode 100644 index 833b9e6..0000000 --- a/.env.template +++ /dev/null @@ -1,9 +0,0 @@ -# Database credentials -DB_SERVER= -DB_NAME= -DB_USER= -DB_PASSWORD= - -# Seq logging credentials -SEQ_URL= -SEQ_API_KEY= diff --git a/.gitignore b/.gitignore index 5d381cc..7d078be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,162 +1,11 @@ -# ---> Python -# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments +*.class .env .venv -env/ venv/ ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - +*.log +.pytest_cache/ +.coverage +config.ini \ No newline at end of file diff --git a/chat-memory.md b/chat-memory.md new file mode 100644 index 0000000..e69de29 diff --git a/classes.py b/classes.py deleted file mode 100644 index 4ea6af5..0000000 --- a/classes.py +++ /dev/null @@ -1,131 +0,0 @@ -import os -import platform -import socket -from datetime import datetime -from typing import Optional - -import pyodbc -import snap7 -from dotenv import load_dotenv - -load_dotenv() - - -# Entire scheduler config class -class SchedulerConfig: - interval: int - next_read: datetime - - -# PLC properties class (energy, air) -class Plc: - id: int - name: str - ip: str - db_number: int # Where the data is stored in the PLC - is_enabled: bool # True if PLC will be read from - air_db_offset: int - energy_db_offset: int - state_db_offset: int - location: str # H1 or H2 - last_energy_read: float - last_air_read: float - last_state_read: bool - last_read_timestamp: datetime - - def __init__(self, id: int, name: str, ip: str, db_number: int, - air_db_offset: int, energy_db_offset: int, - state_db_offset: int, location: str) -> None: - self.id = id - self.name = name - self.ip = ip - self.db_number = db_number - self.is_enabled = True # default value - self.air_db_offset = air_db_offset - self.energy_db_offset = energy_db_offset - self.state_db_offset = state_db_offset - self.location = location - self.last_energy_read = 0.0 - self.last_air_read = 0.0 - self.last_state_read = False - self.last_read_timestamp = None - - - def check_connection_snap(self) -> bool: - """Check if the PLC is reachable.""" - try: - client = snap7.client.Client() - client.connect(self.ip, 0, 1) - client.disconnect() - return True - except Exception as e: - # logger.error(f"❌ Error checking PLC connection: {e}") - return False - - def check_connection_socket(self) -> bool: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.settimeout(1) - return sock.connect_ex((self.ip, 102)) == 0 - - -# PAC properties class (energy) -class Pac: - id: int - name: str - ip: str - port: int - is_enabled: bool - location: str # H1 or H2 - last_energy_read: float - last_read_timestamp: datetime - - -class Database: - # Private class attributes (internal use) - _sql_driver = ( - "ODBC Driver 18 for SQL Server" - if platform.system() == "Linux" - else "SQL Server" - ) - _instance: Optional["Database"] = None - _connection = None - - def __new__(cls): - if cls._instance is None: - cls._instance = super(Database, cls).__new__(cls) - return cls._instance - - # Public methods (external interface) - def __init__(self): - if self._connection is None: - self._connection = self._create_connection() - - def execute_query(self, query: str, params: tuple = ()) -> pyodbc.Cursor: - """Execute SQL query and return cursor.""" - cursor = self.get_connection().cursor() - cursor.execute(query, params) - return cursor - - def get_connection(self) -> pyodbc.Connection: - """Get database connection, create new if needed.""" - if not self._connection or not self._connection.connected: - self._connection = self._create_connection() - return self._connection - - def close(self) -> None: - """Close database connection.""" - if self._connection: - self._connection.close() - self._connection = None - - # Private methods (internal use) - def _create_connection(self) -> pyodbc.Connection: - """Create new database connection.""" - connection_string = ( - "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')}" - ) - return pyodbc.connect(connection_string) diff --git a/config.ini.template b/config.ini.template new file mode 100644 index 0000000..97ccc59 --- /dev/null +++ b/config.ini.template @@ -0,0 +1,16 @@ +[mssql] +host = +name = +password = +user = + +[postgres] +host = +name = +password = +port = +user = + +[seq] +api_key = +url = \ No newline at end of file diff --git a/notes.md b/notes.md new file mode 100644 index 0000000..ea9ea28 --- /dev/null +++ b/notes.md @@ -0,0 +1,94 @@ +# IndustrialTracker + +## Databse model + +### Device +Main device object used as base for dependency injection +- id - number +- name - string +- type - (Relation)->DeviceType +- enabled - boolean + +### DeviceType +Device type definition +- id - number +- name - string + +### Reading +Reading object used to store data from Device +- id - number +- device - (Relation)->Device +- reading_time - date +- energy - number +- air - number +- running - boolean +- ... place for more data eventually + +### PLC +PLC object used to store configuration of PLC +- id - number +- ip - string +- port - number +- db_number - number +- energy_offset - number +- air_offset - number +- running_offset - number +- ... place for more offsets definition + +### PAC +PAC object used to store configuration of PAC +- id - number +- ip - string +- port - number +... something I missed + +### Scheduler +- id - number +- name - string +- interval_seconds - number +- next_run - date +... something I missed + +## Workflow + +### Main process +`main.py` + +Is infinite loop that: +- checks scheduler settings from database (Scheduler) +- checks if it's time to run scheduler +- if yes, runs scheduler then updates next_run date following schema date.now() + interval_seconds + +### Scheduler process/service +`scheduler_service.py` + +When scheduler is run, it: +- gets all devices from database (Device) +- for each device that is enable, at first, check if it is accessible (by pinging) +- if yes, then it gets data from device (via read_service) and saves it to database (Reading) + +### Read process/service +`read_service.py` +> Read process should be unified for all devices and handled by dependency injection. +Abstract class for dependency injection is declared in `readers/device_reader.py`. + +Each device type has it's own reader class that implements pinging function and reading function. + +* PLC reader is implemented in `readers/plc_reader.py` +* PAC reader is implemented in `readers/pac_reader.py` + +#### PLC reader + +Utilizes `python-snap7` library to communicate with PLC. + +#### PAC reader +> TODO: PAC reader is not implemented yet. + +Utilizes `---` library to communicate with PAC. + +### Database process/service +`database_service.py` + +Singleton class that implements all database operations. + +> Most use cases should be in scheduler process where readed data are going to be saved to database. \ No newline at end of file diff --git a/pyS7_test.py b/pyS7_test.py deleted file mode 100644 index c813cd4..0000000 --- a/pyS7_test.py +++ /dev/null @@ -1,29 +0,0 @@ -import time -from pyS7 import S7Client -import pprint - -LOOPS = 3 -counter = 0 -client = S7Client(address="172.16.3.231", rack=0, slot=2) -while counter < LOOPS: - try: - # Create a new 'S7Client' object to connect to S7-300/400/1200/1500 PLC. - # Provide the PLC's IP address and slot/rack information - - # client = S7Client(address="172.16.4.220", rack=0, slot=2) - # Establish connection with the PLC - client.connect() - - # Define area tags to read - tags = ["DB9,DBD0"] - - # Read the data from the PLC using the specified tag list - data = client.read(tags=tags) - - print(data) # [True, False, 123, True, 10, -2.54943805634653e-12, 'Hello'] - except Exception as e: - pprint.pprint(e) - finally: - client.disconnect() - counter += 1 - time.sleep(1) diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..e782696 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest +ruff \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index e4fce18..b5a4189 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,7 @@ -certifi==2024.8.30 -charset-normalizer==3.4.0 -idna==3.10 -pyodbc==5.2.0 -pyS7 @ git+https://github.com/FiloCara/pyS7@761c785799106a04ccbc9e19d6201f728165231d -python-dateutil==2.9.0.post0 -python-dotenv==1.0.1 -python-snap7==2.0.2 -PyYAML==6.0.2 -requests==2.32.3 -seqlog==0.3.31 -six==1.16.0 -urllib3==2.2.3 +sqlalchemy +python-snap7 +pyodbc +python-dotenv +seqlog +pymodbus +pyS7 @ git+https://github.com/FiloCara/pyS7@761c785799106a04ccbc9e19d6201f728165231d \ No newline at end of file diff --git a/src/models/__init__.py b/src/models/__init__.py new file mode 100644 index 0000000..c4b3840 --- /dev/null +++ b/src/models/__init__.py @@ -0,0 +1,11 @@ +from sqlalchemy.ext.declarative import declarative_base + +from .device import Device, DeviceType +from .pac import PAC +from .plc import PLC +from .reading import Reading +from .scheduler import Scheduler + +Base = declarative_base() + +__all__ = ["Base", "Device", "DeviceType", "PLC", "PAC", "Reading", "Scheduler"] diff --git a/src/models/device.py b/src/models/device.py new file mode 100644 index 0000000..f2a020b --- /dev/null +++ b/src/models/device.py @@ -0,0 +1,28 @@ +from sqlalchemy import Column, ForeignKey, Integer, String +from sqlalchemy.orm import relationship + +from models import Base + + +class DeviceType(Base): + __tablename__ = "device_type" + + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + + devices = relationship("Device", back_populates="type") + + +class Device(Base): + __tablename__ = "device" + + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + type_id = Column(Integer, ForeignKey("device_type.id"), nullable=False) + enabled = Column(Integer, default=True, nullable=False) + + # Relationships + type = relationship("DeviceType", back_populates="devices") + plc_config = relationship("PLC", back_populates="device", uselist=False) + pac_config = relationship("PAC", back_populates="device", uselist=False) + readings = relationship("Reading", back_populates="device") diff --git a/src/models/pac.py b/src/models/pac.py new file mode 100644 index 0000000..a2f5d1c --- /dev/null +++ b/src/models/pac.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, ForeignKey, Integer, String +from sqlalchemy.orm import relationship + +from models import Base + + +class PAC(Base): + __tablename__ = "pac" + + id = Column(Integer, primary_key=True) + device_id = Column(Integer, ForeignKey("device.id"), nullable=False) + ip = Column(String(15), nullable=False) + port = Column(Integer, default=102) + + device = relationship("Device", back_populates="pac_config") diff --git a/src/models/plc.py b/src/models/plc.py new file mode 100644 index 0000000..d37cb2c --- /dev/null +++ b/src/models/plc.py @@ -0,0 +1,19 @@ +from sqlalchemy import Column, ForeignKey, Integer, String +from sqlalchemy.orm import relationship + +from models import Base + + +class PLC(Base): + __tablename__ = "plc" + + id = Column(Integer, primary_key=True) + device_id = Column(Integer, ForeignKey("device.id"), nullable=False) + ip = Column(String(15), nullable=False) + port = Column(Integer, default=102) + db_number = Column(Integer, nullable=False) + energy_offset = Column(Integer) + air_offset = Column(Integer) + running_offset = Column(Integer) + + device = relationship("Device", back_populates="plc_config") diff --git a/src/models/reading.py b/src/models/reading.py new file mode 100644 index 0000000..094c1df --- /dev/null +++ b/src/models/reading.py @@ -0,0 +1,19 @@ +import datetime + +from sqlalchemy import Boolean, Column, DateTime, Float, ForeignKey, Integer +from sqlalchemy.orm import relationship + +from models import Base + + +class Reading(Base): + __tablename__ = "reading" + + id = Column(Integer, primary_key=True) + device_id = Column(Integer, ForeignKey("device.id"), nullable=False) + reading_time = Column(DateTime, default=datetime.now(datetime.timezone.utc)) + energy = Column(Float) + air = Column(Float) + running = Column(Boolean) + + device = relationship("Device", back_populates="readings") diff --git a/src/models/scheduler.py b/src/models/scheduler.py new file mode 100644 index 0000000..dd1c505 --- /dev/null +++ b/src/models/scheduler.py @@ -0,0 +1,12 @@ +from sqlalchemy import Column, DateTime, Integer, String + +from models import Base + + +class Scheduler(Base): + __tablename__ = "scheduler" + + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + interval_seconds = Column(Integer, nullable=False) + next_run = Column(DateTime, nullable=False) diff --git a/src/readers/__init__.py b/src/readers/__init__.py new file mode 100644 index 0000000..ab001a6 --- /dev/null +++ b/src/readers/__init__.py @@ -0,0 +1,5 @@ +from .device_reader import DeviceReader +from .pac_reader import PACReader +from .plc_reader import PLCReader + +__all__ = ["DeviceReader", "PLCReader", "PACReader"] diff --git a/src/readers/device_reader.py b/src/readers/device_reader.py new file mode 100644 index 0000000..dc902ca --- /dev/null +++ b/src/readers/device_reader.py @@ -0,0 +1,20 @@ +from abc import ABC, abstractmethod + +from src.models import Device, Reading + + +# Abstract base class for device readers +# This class defines a common interface that all device readers must implement +# Using abstract classes ensures consistency across different device types +class DeviceReader(ABC): + @abstractmethod + def is_accessible(self, device: Device) -> bool: + # This abstract method forces all child classes to implement their own + # device accessibility check logic + pass + + @abstractmethod + def collect_reading(self, device: Device) -> Reading: + # This abstract method forces all child classes to implement their own + # data collection logic specific to the device type + pass diff --git a/src/readers/pac_reader.py b/src/readers/pac_reader.py new file mode 100644 index 0000000..5d29c61 --- /dev/null +++ b/src/readers/pac_reader.py @@ -0,0 +1,16 @@ +from .device_reader import DeviceReader +from src.models import Device, Reading + + +# Concrete implementation for PAC (Programmable Automation Controller) devices +# Inherits from DeviceReader and must implement all abstract methods +class PACReader(DeviceReader): + def is_accessible(self, device: Device) -> bool: + # Implement PAC ping check + # This method will contain specific logic for checking PAC connectivity + pass + + def collect_reading(self, device: Device) -> Reading: + # Implement PAC data collection + # This method will contain specific logic for reading data from PAC devices + pass diff --git a/src/readers/plc_reader.py b/src/readers/plc_reader.py new file mode 100644 index 0000000..cfe2303 --- /dev/null +++ b/src/readers/plc_reader.py @@ -0,0 +1,16 @@ +from .device_reader import DeviceReader +from src.models import Device, Reading + + +# Concrete implementation for PLC (Programmable Logic Controller) devices +# Inherits from DeviceReader and must implement all abstract methods +class PLCReader(DeviceReader): + def is_accessible(self, device: Device) -> bool: + # Implement PLC ping check + # This method will contain specific logic for checking PLC connectivity + pass + + def collect_reading(self, device: Device) -> Reading: + # Implement PLC data collection + # This method will contain specific logic for reading data from PLC devices + pass diff --git a/src/scripts/test_db.py b/src/scripts/test_db.py new file mode 100644 index 0000000..38542b5 --- /dev/null +++ b/src/scripts/test_db.py @@ -0,0 +1,46 @@ +from datetime import datetime, timedelta +from services.database_service import DatabaseService + +def test_database_operations(): + # Initialize database service + db = DatabaseService() + + # Test getting device types + print("\n=== Testing Device Types ===") + device_types = db.get_device_types() + print(f"Found {len(device_types)} device types") + for dt in device_types: + print(f"- {dt.name}") + + # Test getting enabled devices + print("\n=== Testing Enabled Devices ===") + enabled_devices = db.get_enabled_devices() + print(f"Found {len(enabled_devices)} enabled devices") + for device in enabled_devices: + print(f"- {device.name} (Type: {device.type.name})") + + # Test scheduler operations + print("\n=== Testing Scheduler ===") + scheduler = db.get_scheduler("main") + if scheduler: + print(f"Current scheduler: {scheduler.name}") + print(f"Current next_run: {scheduler.next_run}") + + # Test updating scheduler + new_next_run = datetime.now() + timedelta(minutes=5) + db.update_scheduler_next_run(scheduler, new_next_run) + print(f"Updated next_run to: {new_next_run}") + + # Test getting readings for a device + print("\n=== Testing Readings ===") + if enabled_devices: + test_device = enabled_devices[0] + from_date = datetime.now() - timedelta(days=1) + to_date = datetime.now() + readings = db.get_readings_by_device(test_device.id, from_date, to_date) + print(f"Found {len(readings)} readings for device {test_device.name}") + for reading in readings[:5]: # Show first 5 readings + print(f"- Time: {reading.reading_time}, Energy: {reading.energy}") + +if __name__ == "__main__": + test_database_operations() \ No newline at end of file diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/database_service.py b/src/services/database_service.py new file mode 100644 index 0000000..47875f9 --- /dev/null +++ b/src/services/database_service.py @@ -0,0 +1,79 @@ +from datetime import datetime +from typing import List, Optional + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from src.models import PAC, PLC, Device, DeviceType, Reading, Scheduler +from utils.config import Config + + +class SingletonMeta(type): + _instances = {} + + def __call__(cls, *args, **kwargs): + """ + This is a singleton metaclass implementation that ensures only one instance of a class is created. + When a class with this metaclass is instantiated: + 1. It checks if the class already has an instance in _instances dictionary + 2. If no instance exists, it creates one using super().__call__ and stores it + 3. If instance exists, it returns the stored instance + This way, multiple calls to create an instance will always return the same object + """ + if cls not in cls._instances: + cls._instances[cls] = super().__call__(*args, **kwargs) + return cls._instances[cls] + +class DatabaseService(metaclass=SingletonMeta): + def __init__(self): + config = Config() + engine = create_engine(config.get_database_url()) + self.session = Session(engine) + + def get_enabled_devices(self) -> List[Device]: + return self.session.query(Device).filter(Device.enabled.is_(True)).all() + + def get_scheduler(self, name: str) -> Optional[Scheduler]: + return self.session.query(Scheduler).filter(Scheduler.name == name).first() + + def update_scheduler_next_run( + self, scheduler: Scheduler, next_run: datetime + ) -> None: + scheduler.next_run = next_run + self.session.commit() + + def save_reading(self, reading: Reading) -> None: + self.session.add(reading) + self.session.commit() + + def save_readings(self, readings: List[Reading]) -> None: + self.session.add_all(readings) + self.session.commit() + + def get_device_types(self) -> List[DeviceType]: + return self.session.query(DeviceType).all() + + def get_plc_config(self, device_id: int) -> Optional[PLC]: + return self.session.query(PLC).filter(PLC.id == device_id).first() + + def get_pac_config(self, device_id: int) -> Optional[PAC]: + return self.session.query(PAC).filter(PAC.id == device_id).first() + + def get_device(self, device_id: int) -> Optional[Device]: + return self.session.query(Device).filter(Device.id == device_id).first() + + def get_device_by_name(self, name: str) -> Optional[Device]: + return self.session.query(Device).filter(Device.name == name).first() + + def get_readings_by_device( + self, device_id: int, from_date: datetime, to_date: datetime + ) -> List[Reading]: + return ( + self.session.query(Reading) + .filter( + Reading.device_id == device_id, + Reading.reading_time >= from_date, + Reading.reading_time <= to_date, + ) + .all() + ) diff --git a/src/services/scheduler_service.py b/src/services/scheduler_service.py new file mode 100644 index 0000000..d29348b --- /dev/null +++ b/src/services/scheduler_service.py @@ -0,0 +1,28 @@ +from datetime import datetime, timedelta + +from sqlalchemy.orm import Session + +from src.models import Scheduler + + +class SchedulerService: + def __init__(self, db_session: Session): + # Initialize the scheduler service with a database session + self.session = db_session + + def run_main_loop(self): + # Main loop that continuously checks and executes scheduled tasks + while True: + # Get all scheduler entries from the database + schedulers = self.session.query(Scheduler).all() + for scheduler in schedulers: + # Check if it's time to execute the scheduler + if datetime.now(datetime.timezone.utc) >= scheduler.next_run: + # Execute the scheduled task + self.execute_scheduler(scheduler) + # Calculate and set the next run time based on the interval + scheduler.next_run = datetime.now( + datetime.timezone.utc + ) + timedelta(seconds=scheduler.interval_seconds) + # Save changes to the database + self.session.commit() diff --git a/src/utils/config.py b/src/utils/config.py new file mode 100644 index 0000000..de6fe11 --- /dev/null +++ b/src/utils/config.py @@ -0,0 +1,34 @@ +from configparser import ConfigParser +from pathlib import Path +import platform + + +class Config: + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + config = ConfigParser() + config_path = Path("config.ini") + config.read(config_path) + cls._instance.config = config + return cls._instance + + def get_mssql_url(self) -> str: + mssql = self.config["mssql"] + driver = "ODBC Driver 18 for SQL Server" if platform.platform.system() == "Linux" else "SQL Server" + driver = driver.replace(" ", "+") + return f"mssql+pyodbc://{mssql['user']}:{mssql['password']}@{mssql['host']}/{mssql['name']}?driver={driver}" + + def get_postgres_url(self) -> str: + db = self.config["postgres"] + return f"postgresql://{db['user']}:{db['password']}@{db['host']}/{db['name']}" + + def get_seq_url(self) -> str: + seq = self.config["seq"] + return seq["url"] + + def get_seq_api_key(self) -> str: + seq = self.config["seq"] + return seq["api_key"] diff --git a/src/utils/helpers.py b/src/utils/helpers.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/logger.py b/src/utils/logger.py new file mode 100644 index 0000000..e69de29 diff --git a/test-server.sh b/test-server.sh new file mode 100644 index 0000000..de0d95b --- /dev/null +++ b/test-server.sh @@ -0,0 +1,2 @@ +#!.venv/bin/python +python -m snap7.server \ No newline at end of file