diff --git a/.github/workflows/deploy-pre-release.yml b/.github/workflows/deploy-pre-release.yml index 1cb4f83..942c6b8 100644 --- a/.github/workflows/deploy-pre-release.yml +++ b/.github/workflows/deploy-pre-release.yml @@ -20,7 +20,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [ '3.8', '3.9', '3.10' , '3.11'] + python-version: [ '3.8', '3.9', '3.10' ] # 3.11+ not suppport steps: - run: echo "🎉 The job was automatically triggered by a ${{ github.event_name }} event." - run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by GitHub!" diff --git a/.github/workflows/install-package.yml b/.github/workflows/install-package.yml new file mode 100644 index 0000000..8b150b7 --- /dev/null +++ b/.github/workflows/install-package.yml @@ -0,0 +1,47 @@ +name: Install Package Test + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10"] + + steps: + - uses: actions/checkout@v2 + name: Check out repository code + with: + submodules: recursive # Ensures submodules are checked out + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m venv env + source env/bin/activate + + - name: Install and Test Package + run: | + pip install cmake # Install CMake + python setup.py install + python -c "import dnp3_python; print(dnp3_python)" + env: + PYTHONPATH: ${{ github.workspace }} + + - name: Clean up + if: always() + run: rm -rf env diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..7696c43 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,54 @@ +name: Pytests + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10"] + + steps: + - uses: actions/checkout@v2 + name: Check out repository code + with: + submodules: recursive # Ensures submodules are checked out + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m venv env + source env/bin/activate + + - name: Install and Test Package + run: | + pip install cmake # Install CMake + python setup.py install + python -c "import dnp3_python; print(dnp3_python)" + env: + PYTHONPATH: ${{ github.workspace }} + + - name: Run Tests + run: | + pip install pytest + for file in tests/test_dnp3_python/test_*.py; do echo "Running pytest on $file"; pytest -s -vv $file; done # Note: the file needs to run separately. + env: + PYTHONPATH: ${{ github.workspace }} + + - name: Clean up + if: always() + run: rm -rf env diff --git a/.gitignore b/.gitignore index 8812264..e789002 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ __pycache__/ # Distribution / packaging .Python -env/ +env*/ build/ develop-eggs/ dist/ diff --git a/deps/pybind11 b/deps/pybind11 index 7722db1..338d615 160000 --- a/deps/pybind11 +++ b/deps/pybind11 @@ -1 +1 @@ -Subproject commit 7722db1674dff4d9d04d803b92c0b05c9b10363d +Subproject commit 338d615e12ce41ee021724551841de3cbe0bc1df diff --git a/setup.py b/setup.py index 9be420c..8e0e6f9 100644 --- a/setup.py +++ b/setup.py @@ -36,24 +36,37 @@ # under Contract DE-AC05-76RL01830 # }}} -import sys import os -import subprocess -import re import platform +import re +import subprocess +import sys +from distutils.version import LooseVersion +from pathlib import Path -from setuptools import setup, Extension +from setuptools import Extension, find_namespace_packages, find_packages, setup from setuptools.command.build_ext import build_ext -from distutils.version import LooseVersion -from setuptools import find_packages, find_namespace_packages -from pathlib import Path +module_name = "dnp3_python" + -__version__ = '0.3.0b1' +# Function to extract version from the __init__.py file at /src +def find_version(): + here = os.path.abspath(os.path.dirname(__file__)) + with open(os.path.join(here, "src", module_name, "__init__.py"), "r") as f: + contents = f.read() + version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", contents, re.M) + if version_match: + return version_match.group(1) + raise RuntimeError("Unable to find version string.") + + +__version__ = find_version() +print(f"{__version__=}") class CMakeExtension(Extension): - def __init__(self, name, sourcedir=''): + def __init__(self, name, sourcedir=""): Extension.__init__(self, name, sources=[]) self.sourcedir = os.path.abspath(sourcedir) @@ -61,14 +74,18 @@ def __init__(self, name, sourcedir=''): class CMakeBuild(build_ext): def run(self): try: - out = subprocess.check_output(['cmake', '--version']) + out = subprocess.check_output(["cmake", "--version"]) except OSError: - raise RuntimeError("CMake must be installed to build the following extensions: " + - ", ".join(e.name for e in self.extensions)) + raise RuntimeError( + "CMake must be installed to build the following extensions: " + + ", ".join(e.name for e in self.extensions) + ) if platform.system() == "Windows": - cmake_version = LooseVersion(re.search(r'version\s*([\d.]+)', out.decode()).group(1)) - if cmake_version < '3.1.0': + cmake_version = LooseVersion( + re.search(r"version\s*([\d.]+)", out.decode()).group(1) + ) + if cmake_version < "3.1.0": raise RuntimeError("CMake >= 3.1.0 is required on Windows") for ext in self.extensions: @@ -76,25 +93,30 @@ def run(self): def build_extension(self, ext): extdir = os.path.abspath(os.path.dirname(self.get_ext_fullpath(ext.name))) - cmake_args = ['-DCMAKE_LIBRARY_OUTPUT_DIRECTORY=' + extdir, - '-DPYTHON_EXECUTABLE=' + sys.executable] + cmake_args = [ + "-DCMAKE_LIBRARY_OUTPUT_DIRECTORY=" + extdir, + "-DPYTHON_EXECUTABLE=" + sys.executable, + ] - cfg = 'Debug' if self.debug else 'Release' - build_args = ['--config', cfg] + cfg = "Debug" if self.debug else "Release" + build_args = ["--config", cfg] if platform.system() == "Windows": - cmake_args += ['-DCMAKE_LIBRARY_OUTPUT_DIRECTORY_{}={}'.format(cfg.upper(), extdir)] + cmake_args += [ + "-DCMAKE_LIBRARY_OUTPUT_DIRECTORY_{}={}".format(cfg.upper(), extdir) + ] if sys.maxsize > 2**32: - cmake_args += ['-A', 'x64'] - build_args += ['--', '/m'] + cmake_args += ["-A", "x64"] + build_args += ["--", "/m"] else: - cmake_args += ['-DCMAKE_BUILD_TYPE=' + cfg] - cmake_args += ['-DSTATICLIBS=ON'] - build_args += ['--', '-j2'] + cmake_args += ["-DCMAKE_BUILD_TYPE=" + cfg] + cmake_args += ["-DSTATICLIBS=ON"] + build_args += ["--", "-j2"] env = os.environ.copy() - env['CXXFLAGS'] = '{} -DVERSION_INFO=\\"{}\\"'.format(env.get('CXXFLAGS', ''), - self.distribution.get_version()) + env["CXXFLAGS"] = '{} -DVERSION_INFO=\\"{}\\"'.format( + env.get("CXXFLAGS", ""), self.distribution.get_version() + ) if not os.path.exists(self.build_temp): os.makedirs(self.build_temp) @@ -106,37 +128,41 @@ def build_extension(self, ext): # # subprocess.check_call(['git', 'apply', patch_path], cwd=dnp3_path) - subprocess.check_call(['cmake', ext.sourcedir] + cmake_args, cwd=self.build_temp, env=env) - subprocess.check_call(['cmake', '--build', '.'] + build_args, cwd=self.build_temp) + subprocess.check_call( + ["cmake", ext.sourcedir] + cmake_args, cwd=self.build_temp, env=env + ) + subprocess.check_call( + ["cmake", "--build", "."] + build_args, cwd=self.build_temp + ) this_directory = Path(__file__).parent long_description = (this_directory / "README.md").read_text() setup( - name='dnp3-python', + name="dnp3-python", version=__version__, - author='Volttron Team', - author_email='volttron@pnnl.gov', - url='https://github.com/VOLTTRON/dnp3-python', - description='pydnp3 -- python binding for opendnp3', + author="Volttron Team", + author_email="volttron@pnnl.gov", + url="https://github.com/VOLTTRON/dnp3-python", + description="pydnp3 -- python binding for opendnp3", long_description=long_description, - long_description_content_type='text/markdown', + long_description_content_type="text/markdown", install_requires=[ # 'pybind11>=2.2', - 'argcomplete'], - ext_modules=[CMakeExtension('pydnp3')], + "argcomplete" + ], + ext_modules=[CMakeExtension("pydnp3")], cmdclass=dict(build_ext=CMakeBuild), zip_safe=False, - packages=find_namespace_packages( - where='src', - include=['dnp3_python*', 'dnp3demo'] # to include sub-packages as well. + where="src", + include=[f"{module_name}*", "dnp3demo"], # to include sub-packages as well. ), package_dir={"": "src"}, entry_points={ - 'console_scripts': [ - 'dnp3demo = dnp3demo.__main__:main', - ] - }, + "console_scripts": [ + "dnp3demo = dnp3demo.__main__:main", + ] + }, ) diff --git a/src/dnp3_python/__init__.py b/src/dnp3_python/__init__.py index e69de29..417e2e8 100644 --- a/src/dnp3_python/__init__.py +++ b/src/dnp3_python/__init__.py @@ -0,0 +1 @@ +__version__ = "0.3.0b1" diff --git a/src/dnp3_python/dnp3station/master.py b/src/dnp3_python/dnp3station/master.py index 3de013f..e032349 100644 --- a/src/dnp3_python/dnp3station/master.py +++ b/src/dnp3_python/dnp3station/master.py @@ -1,21 +1,36 @@ +from __future__ import annotations + +import datetime import logging import sys import time +from typing import Callable, Dict, List, Optional, Tuple, Union -from pydnp3 import opendnp3, openpal, asiopal, asiodnp3 -# from visitors import * -from .visitors import * -from typing import Callable, Union, Dict, List, Optional, Tuple +from pydnp3 import asiodnp3, asiopal, opendnp3, openpal from pydnp3.opendnp3 import GroupVariation, GroupVariationID +from .station_utils import ( + AppChannelListener, + Dnp3Database, + MyLogger, + SOEHandler, + collection_callback, + command_callback, + parsing_gv_to_mastercmdtype, + parsing_gvid_to_gvcls, + restart_callback, +) +from .visitors import * # noqa: F403 + FILTERS = opendnp3.levels.NORMAL | opendnp3.levels.ALL_COMMS HOST = "127.0.0.1" # remote outstation LOCAL = "0.0.0.0" # local masterstation -# HOST = "192.168.1.14" PORT = 20000 stdout_stream = logging.StreamHandler(sys.stdout) -stdout_stream.setFormatter(logging.Formatter('%(asctime)s\t%(name)s\t%(levelname)s\t%(message)s')) +stdout_stream.setFormatter( + logging.Formatter("%(asctime)s\t%(name)s\t%(levelname)s\t%(message)s") +) _log = logging.getLogger(__name__) # _log.addHandler(stdout_stream) @@ -23,58 +38,54 @@ # _log.setLevel(logging.ERROR) _log.setLevel(logging.INFO) -from .station_utils import MyLogger, AppChannelListener, SOEHandler -from .station_utils import parsing_gvid_to_gvcls, parsing_gv_to_mastercmdtype -from .station_utils import collection_callback, command_callback, restart_callback -import datetime - # alias DbPointVal DbPointVal = Union[float, int, bool, None] -DbStorage = Dict[opendnp3.GroupVariation, Dict[ - int, DbPointVal]] # e.g., {GroupVariation.Group30Var6: {0: 4.8, 1: 14.1, 2: 27.2, 3: 0.0, 4: 0.0} +DbStorage = Dict[ + opendnp3.GroupVariation, Dict[int, DbPointVal] +] # e.g., {GroupVariation.Group30Var6: {0: 4.8, 1: 14.1, 2: 27.2, 3: 0.0, 4: 0.0} class MyMaster: """ - DNP3 spec section 5.1.6.1: - The Application Layer provides the following services for the DNP3 User Layer in a master: - - Formats requests directed to one or more outstations. - - Notifies the DNP3 User Layer when new data or information arrives from an outstation. - - DNP spec section 5.1.6.3: - The Application Layer requires specific services from the layers beneath it. - - Partitioning of fragments into smaller portions for transport reliability. - - Knowledge of which device(s) were the source of received messages. - - Transmission of messages to specific devices or to all devices. - - Message integrity (i.e., error-free reception and transmission of messages). - - Knowledge of the time when messages arrive. - - Either precise times of transmission or the ability to set time values - into outgoing messages. + DNP3 spec section 5.1.6.1: + The Application Layer provides the following services for the DNP3 User Layer in a master: + - Formats requests directed to one or more outstations. + - Notifies the DNP3 User Layer when new data or information arrives from an outstation. + + DNP spec section 5.1.6.3: + The Application Layer requires specific services from the layers beneath it. + - Partitioning of fragments into smaller portions for transport reliability. + - Knowledge of which device(s) were the source of received messages. + - Transmission of messages to specific devices or to all devices. + - Message integrity (i.e., error-free reception and transmission of messages). + - Knowledge of the time when messages arrive. + - Either precise times of transmission or the ability to set time values + into outgoing messages. """ - def __init__(self, - master_ip: str = "0.0.0.0", - outstation_ip: str = "127.0.0.1", - port: int = 20000, - master_id: int = 2, - outstation_id: int = 1, - concurrency_hint: int = 1, - log_handler=asiodnp3.ConsoleLogger().Create(), - listener=asiodnp3.PrintingChannelListener().Create(), - - soe_handler=SOEHandler(), - master_application=asiodnp3.DefaultMasterApplication().Create(), - channel_log_level=opendnp3.levels.NORMAL, - master_log_level=7, # wild guess, 7: warning, 15 (opendnp3.levels.NORMAL): info - num_polling_retry: int = 2, - delay_polling_retry: float = 0.2, # in seconds - stale_if_longer_than: float = 2, # in seconds - - stack_config=None, - - # manager = asiodnp3.DNP3Manager(2, asiodnp3.ConsoleLogger().Create()) - *args, **kwargs): + def __init__( + self, + master_ip: str | None = "0.0.0.0", + outstation_ip: str | None = "127.0.0.1", + port: int | None = 20000, + master_id: int | None = 2, + outstation_id: int | None = 1, + concurrency_hint: int | None = 1, + log_handler=asiodnp3.ConsoleLogger().Create(), + listener=asiodnp3.PrintingChannelListener().Create(), + soe_handler=SOEHandler(), + master_application=asiodnp3.DefaultMasterApplication().Create(), + channel_log_level=opendnp3.levels.NORMAL, + master_log_level=7, # wild guess, 7: warning, 15 (opendnp3.levels.NORMAL): info + num_polling_retry: int = 2, + delay_polling_retry: float = 0.2, # in seconds + stale_if_longer_than: float = 2, # in seconds + stack_config=None, + # manager = asiodnp3.DNP3Manager(2, asiodnp3.ConsoleLogger().Create()) + *args, + **kwargs, + ): """ TODO: docstring here """ @@ -98,49 +109,63 @@ def __init__(self, self.delay_polling_retry = delay_polling_retry # in seconds self.stale_if_longer_than = stale_if_longer_than # in seconds - _log.debug('Configuring the DNP3 stack.') + _log.debug("Configuring the DNP3 stack.") self.stack_config = stack_config if not self.stack_config: self.stack_config = asiodnp3.MasterStackConfig() self.stack_config.master.responseTimeout = openpal.TimeDuration().Seconds(2) - self.stack_config.link.RemoteAddr = outstation_id # meant for outstation, use 1 as default - self.stack_config.link.LocalAddr = master_id # meant for master station, use 2 as default + self.stack_config.link.RemoteAddr = ( + outstation_id # meant for outstation, use 1 as default + ) + self.stack_config.link.LocalAddr = ( + master_id # meant for master station, use 2 as default + ) # init steps: DNP3Manager(manager) -> TCPClient(channel) -> Master(master) # init DNP3Manager(manager) - _log.debug('Creating a DNP3Manager.') + _log.debug("Creating a DNP3Manager.") self.manager = asiodnp3.DNP3Manager(concurrency_hint, self.log_handler) # self.manager = manager # init TCPClient(channel) - _log.debug('Creating the DNP3 channel, a TCP client.') + _log.debug("Creating the DNP3 channel, a TCP client.") self.retry = asiopal.ChannelRetry().Default() - level = opendnp3.levels.NORMAL | opendnp3.levels.ALL_COMMS # TODO: check why this seems not working - self.channel = self.manager.AddTCPClient(id="tcpclient", - levels=level, - retry=self.retry, - host=outstation_ip, - local=master_ip, - port=port, - listener=self.listener) + level = ( + opendnp3.levels.NORMAL | opendnp3.levels.ALL_COMMS + ) # TODO: check why this seems not working + self.channel = self.manager.AddTCPClient( + id="tcpclient", + levels=level, + retry=self.retry, + host=outstation_ip, + local=master_ip, + port=port, + listener=self.listener, + ) # init Master(master) - _log.debug('Adding the master to the channel.') - self.master = self.channel.AddMaster(id="master", - SOEHandler=self.soe_handler, - # SOEHandler=asiodnp3.PrintingSOEHandler().Create(), - application=self.master_application, - config=self.stack_config) - - _log.debug('Configuring some scans (periodic reads).') + _log.debug("Adding the master to the channel.") + self.master: asiodnp3.IMaster = self.channel.AddMaster( + id="master", + SOEHandler=self.soe_handler, + # SOEHandler=asiodnp3.PrintingSOEHandler().Create(), + application=self.master_application, + config=self.stack_config, + ) + + _log.debug("Configuring some scans (periodic reads).") # Set up a "slow scan", an infrequent integrity poll that requests events and static data for all classes. - self.slow_scan = self.master.AddClassScan(opendnp3.ClassField().AllClasses(), # TODO: add interface entrypoint - openpal.TimeDuration().Minutes(30), - opendnp3.TaskConfig().Default()) + self.slow_scan = self.master.AddClassScan( + opendnp3.ClassField().AllClasses(), # TODO: add interface entrypoint + openpal.TimeDuration().Minutes(30), + opendnp3.TaskConfig().Default(), + ) # Set up a "fast scan", a relatively-frequent exception poll that requests events and class 1 static data. - self.fast_scan = self.master.AddClassScan(opendnp3.ClassField(opendnp3.ClassField.CLASS_1), - openpal.TimeDuration().Minutes(1), - opendnp3.TaskConfig().Default()) + self.fast_scan = self.master.AddClassScan( + opendnp3.ClassField(opendnp3.ClassField.CLASS_1), + openpal.TimeDuration().Minutes(1), + opendnp3.TaskConfig().Default(), + ) # Configure log level for channel(server) and master # note: one of the following @@ -187,7 +212,8 @@ def channel_statistic(self): return { "numOpen": self.channel.GetStatistics().channel.numOpen, "numOpenFail": self.channel.GetStatistics().channel.numOpenFail, - "numClose": self.channel.GetStatistics().channel.numClose} + "numClose": self.channel.GetStatistics().channel.numClose, + } @property def is_connected(self): @@ -196,7 +222,7 @@ def is_connected(self): numOpen - numClose == 1 => SUCCESS numOpen - numClose == 0 => FAIL """ - if self.channel_statistic.get("numOpen") - self.channel_statistic.get("numClose") == 1: + if self.channel_statistic["numOpen"] - self.channel_statistic["numClose"] == 1: return True else: return False @@ -206,15 +232,19 @@ def get_config(self): example""" return self._comm_conifg - def send_direct_operate_command(self, - command: Union[opendnp3.ControlRelayOutputBlock, - opendnp3.AnalogOutputInt16, - opendnp3.AnalogOutputInt32, - opendnp3.AnalogOutputFloat32, - opendnp3.AnalogOutputDouble64], - index: int, - callback: Callable[[opendnp3.ICommandTaskResult], None] = command_callback, - config: opendnp3.TaskConfig = opendnp3.TaskConfig().Default()): + def send_direct_operate_command( + self, + command: Union[ + opendnp3.ControlRelayOutputBlock, + opendnp3.AnalogOutputInt16, + opendnp3.AnalogOutputInt32, + opendnp3.AnalogOutputFloat32, + opendnp3.AnalogOutputDouble64, + ], + index: int, + callback: Callable[[opendnp3.ICommandTaskResult], None] = command_callback, + config: opendnp3.TaskConfig = opendnp3.TaskConfig().Default(), + ): """ Direct operate a single command Note: send_direct_operate_command will evoke outstation side def process_point_value once as side effect @@ -223,10 +253,16 @@ def send_direct_operate_command(self, :param callback: callback that will be invoked upon completion or failure. :param config: optional configuration that controls normal callbacks and allows the user to be specified for SA """ - self.master.DirectOperate(command, index, callback, config) # real signature unknown; restored from __doc__ - - def send_direct_operate_command_set(self, command_set, callback=asiodnp3.PrintingCommandCallback.Get(), - config=opendnp3.TaskConfig().Default()): + self.master.DirectOperate( + command, index, callback, config + ) # real signature unknown; restored from __doc__ + + def send_direct_operate_command_set( + self, + command_set, + callback=asiodnp3.PrintingCommandCallback.Get(), + config=opendnp3.TaskConfig().Default(), + ): """ Direct operate a set of commands @@ -236,8 +272,13 @@ def send_direct_operate_command_set(self, command_set, callback=asiodnp3.Printin """ self.master.DirectOperate(command_set, callback, config) - def send_select_and_operate_command(self, command, index, callback=asiodnp3.PrintingCommandCallback.Get(), - config=opendnp3.TaskConfig().Default()): + def send_select_and_operate_command( + self, + command, + index, + callback=asiodnp3.PrintingCommandCallback.Get(), + config=opendnp3.TaskConfig().Default(), + ): """ Select and operate a single command Note: send_direct_operate_command will evoke outstation side def process_point_value TWICE as side effect @@ -249,8 +290,12 @@ def send_select_and_operate_command(self, command, index, callback=asiodnp3.Prin """ self.master.SelectAndOperate(command, index, callback, config) - def send_select_and_operate_command_set(self, command_set, callback=asiodnp3.PrintingCommandCallback.Get(), - config=opendnp3.TaskConfig().Default()): + def send_select_and_operate_command_set( + self, + command_set, + callback=asiodnp3.PrintingCommandCallback.Get(), + config=opendnp3.TaskConfig().Default(), + ): """ Select and operate a set of commands @@ -260,10 +305,11 @@ def send_select_and_operate_command_set(self, command_set, callback=asiodnp3.Pri """ self.master.SelectAndOperate(command_set, callback, config) - def _retrieve_all_obj_by_gvids_w_ts(self, - gv_ids: Optional[List[opendnp3.GroupVariationID]] = None, - config=opendnp3.TaskConfig().Default() - ): + def _retrieve_all_obj_by_gvids_w_ts( + self, + gv_ids: Optional[List[opendnp3.GroupVariationID]] = None, + config=opendnp3.TaskConfig().Default(), + ): """Retrieve point value (from an outstation databse) based on gvId (Group Variation ID). :param opendnp3.GroupVariationID gv_ids: list of group-variance Id @@ -273,25 +319,32 @@ def _retrieve_all_obj_by_gvids_w_ts(self, :rtype: Dict[opendnp3.GroupVariation, Dict[int, DbPointVal]] """ - gv_ids: Optional[List[opendnp3.GroupVariationID]] + # gv_ids: Optional[List[opendnp3.GroupVariationID]] if gv_ids is None: # using default - gv_ids = [GroupVariationID(30, 6), - GroupVariationID(1, 2), - GroupVariationID(40, 4), - GroupVariationID(10, 2), - # GroupVariationID(32, 4), - # GroupVariationID(2, 2), - # GroupVariationID(42, 8), - # GroupVariationID(11, 2), - ] + gv_ids = [ + GroupVariationID(30, 6), + GroupVariationID(1, 2), + GroupVariationID(40, 4), + GroupVariationID(10, 2), + # GroupVariationID(32, 4), + # GroupVariationID(2, 2), + # GroupVariationID(42, 8), + # GroupVariationID(11, 2), + ] filtered_db_w_ts = {} for gv_id in gv_ids: # self.retrieve_all_obj_by_gvid(gv_id=gv_id, config=config) self.retrieve_db_by_gvid(gv_id=gv_id) gv_cls: opendnp3.GroupVariation = parsing_gvid_to_gvcls(gv_id) # filtered_db_w_ts.update({gv_cls: self.soe_handler.gv_ts_ind_val_dict.get(gv_cls)}) - filtered_db_w_ts.update({gv_cls: (self.soe_handler.gv_last_poll_dict.get(gv_cls), - self.soe_handler.gv_index_value_nested_dict.get(gv_cls))}) + filtered_db_w_ts.update( + { + gv_cls: ( + self.soe_handler.gv_last_poll_dict.get(gv_cls), + self.soe_handler.gv_index_value_nested_dict.get(gv_cls), + ) + } + ) return filtered_db_w_ts @@ -324,7 +377,7 @@ def retrieve_db_by_gvid(self, gv_id: opendnp3.GroupVariationID) -> DbStorage: # val_storage: ValStorage = self.soe_handler.gv_ts_ind_val_dict.get(gv_cls) # val_storage = self.soe_handler.gv_last_poll_dict.get(gv_cls), self.soe_handler.gv_index_value_nested_dict.get( # gv_cls) - ret_val: {opendnp3.GroupVariation: Dict[int, DbPointVal]} + ret_val: Dict[opendnp3.GroupVariation, Dict[int, DbPointVal]] ts = self.soe_handler.gv_last_poll_dict.get(gv_cls) stale_if_longer_than = self.stale_if_longer_than @@ -340,6 +393,25 @@ def retrieve_db_by_gvid(self, gv_id: opendnp3.GroupVariationID) -> DbStorage: return ret_val + def ScanAllObjects( + self, + master_session: asiodnp3.IMaster, + gvId: opendnp3.GroupVariationID, + config: opendnp3.TaskConfig, + ): + """ + Note: ScanAllObjects implements in deps/dnp3/cpp/libs/src/asiodnp3/MasterSessionStack.cpp + + void MasterSessionStack::ScanAllObjects(GroupVariationID gvId, const TaskConfig& config) + { + auto action = [self = shared_from_this(), gvId, config]() -> void { self->context.ScanAllObjects(gvId, config); }; + return executor->strand.post(action); + } + + """ + return master_session.ScanAllObjects(gvId, config) + # return "dfds" + def _get_updated_val_storage(self, gv_id: opendnp3.GroupVariationID) -> DbStorage: """ Wrap on self.master.ScanAllObjects with retry logic @@ -354,8 +426,7 @@ def _get_updated_val_storage(self, gv_id: opendnp3.GroupVariationID) -> DbStorag # perform scan config = opendnp3.TaskConfig().Default() # TODO: "prettify" the following while loop workflow. e.g., helper function + recurrent function - self.master.ScanAllObjects(gvId=gv_id, - config=config) + self.ScanAllObjects(self.master, gv_id, config) # gv_cls: opendnp3.GroupVariation = parsing_gvid_to_gvcls(gv_id) # # update stale logic to improve performance # self.soe_handler.update_stale_db() @@ -366,14 +437,15 @@ def _get_updated_val_storage(self, gv_id: opendnp3.GroupVariationID) -> DbStorag n_retry = 0 sleep_delay = self.delay_polling_retry # in seconds while gv_db_val is None and n_retry < retry_max: - self.master.ScanAllObjects(gvId=gv_id, - config=config) + self.ScanAllObjects(self.master, gv_id, config) # gv_cls: opendnp3.GroupVariation = parsing_gvid_to_gvcls(gv_id) time.sleep(sleep_delay) gv_db_val = self.soe_handler.gv_index_value_nested_dict.get(gv_cls) if gv_db_val is None: - _log.debug(f"No value returned when polling {gv_cls}. " - f"Starting retry No. {n_retry + 1} (of {retry_max}) after {sleep_delay} sec.") + _log.debug( + f"No value returned when polling {gv_cls}. " + f"Starting retry No. {n_retry + 1} (of {retry_max}) after {sleep_delay} sec." + ) n_retry += 1 # print("=======n_retry, gv_db_val, gv_cls", n_retry, gv_db_val, gv_cls) # print("=======self.soe_handler", self.soe_handler) @@ -382,7 +454,11 @@ def _get_updated_val_storage(self, gv_id: opendnp3.GroupVariationID) -> DbStorag # print("======gv_db_val", gv_db_val) if n_retry >= retry_max: - _log.warning("==Retry numbers hit retry limit {}, when polling {}==".format(retry_max, gv_cls)) + _log.warning( + "==Retry numbers hit retry limit {}, when polling {}==".format( + retry_max, gv_cls + ) + ) # info_gv: GroupVariation = gv_cls # Note: this is method is penetrating (intuitively it should belong to SOEHandler instead) # but SOEHandler is blind to retry logic at current version @@ -391,8 +467,9 @@ def _get_updated_val_storage(self, gv_id: opendnp3.GroupVariationID) -> DbStorag # Action: set polling attempt timestamp, set db value associated to gv_cls to None. self.soe_handler.gv_last_poll_dict[gv_cls] = datetime.datetime.now() - self.soe_handler.gv_index_value_nested_dict[ - gv_cls] = None # Note: redundant, but explicitly define again. + self.soe_handler.gv_index_value_nested_dict[gv_cls] = ( + None # Note: redundant, but explicitly define again. + ) return {gv_cls: gv_db_val} @@ -430,15 +507,17 @@ def get_db_by_group_variation(self, group: int, variation: int) -> DbStorage: gv_id = opendnp3.GroupVariationID(group, variation) return self.retrieve_db_by_gvid(gv_id=gv_id) - def get_db_by_group_variation_index(self, group: int, variation: int, index: int) -> DbStorage: + def get_db_by_group_variation_index( + self, group: int, variation: int, index: int + ) -> DbStorage: """Retrieve point value based on group-variation id, e.g., GroupVariationID(30, 6), and index - Return ret_val: DbStorage (return_meta=True, default), DbPointVal(return_meta=False) + Return ret_val: DbStorage (return_meta=True, default), DbPointVal(return_meta=False) - EXAMPLE: - >>> # prerequisite: outstation db properly configured and updated, master_application properly initialized - >>> master_application.get_db_by_group_variation_index(group=6, variation=30, index=0) - ({GroupVariation.Group30Var6: {0: 7.8, 1: 14.1, 2: 22.2, 3: 0.0, 4: 0.0, 5: 0.0, 6: 0.0, 7: 0.0}}, datetime.datetime(2022, 9, 8, 22, 3, 50, 591742), 'init') + EXAMPLE: + >>> # prerequisite: outstation db properly configured and updated, master_application properly initialized + >>> master_application.get_db_by_group_variation_index(group=6, variation=30, index=0) + ({GroupVariation.Group30Var6: {0: 7.8, 1: 14.1, 2: 22.2, 3: 0.0, 4: 0.0, 5: 0.0, 6: 0.0, 7: 0.0}}, datetime.datetime(2022, 9, 8, 22, 3, 50, 591742), 'init') """ @@ -447,17 +526,25 @@ def get_db_by_group_variation_index(self, group: int, variation: int, index: int ts = self.soe_handler.gv_last_poll_dict.get(gv_cls) stale_if_longer_than = self.stale_if_longer_than - if ts and (datetime.datetime.now() - ts).total_seconds() < stale_if_longer_than: # Use aggressive caching - vals: Dict[int, DbPointVal] = self.soe_handler.gv_index_value_nested_dict.get(gv_cls) + if ( + ts and (datetime.datetime.now() - ts).total_seconds() < stale_if_longer_than + ): # Use aggressive caching + vals: Dict[int, DbPointVal] = ( + self.soe_handler.gv_index_value_nested_dict.get(gv_cls) + ) else: # Use normal routine - vals: Dict[int, DbPointVal] = self.get_db_by_group_variation(group, variation).get(gv_cls) + vals: Dict[int, DbPointVal] = self.get_db_by_group_variation( + group, variation + ).get(gv_cls) if vals: return {gv_cls: {index: vals.get(index)}} else: return {gv_cls: {index: None}} - def get_val_by_group_variation_index(self, group: int, variation: int, index: int) -> DbPointVal: + def get_val_by_group_variation_index( + self, group: int, variation: int, index: int + ) -> DbPointVal: val_w_meta = self.get_db_by_group_variation_index(group, variation, index) gv_id = opendnp3.GroupVariationID(group, variation) gv_cls: opendnp3.GroupVariation = parsing_gvid_to_gvcls(gv_id) @@ -466,10 +553,15 @@ def get_val_by_group_variation_index(self, group: int, variation: int, index: in else: return None - def send_direct_point_command(self, group: int, variation: int, index: int, val_to_set: DbPointVal, - call_back: Callable[[opendnp3.ICommandTaskResult], None] = None, - config: opendnp3.TaskConfig = None - ) -> None: + def send_direct_point_command( + self, + group: int, + variation: int, + index: int, + val_to_set: DbPointVal, + call_back: Callable[[opendnp3.ICommandTaskResult], None] | None = None, + config: opendnp3.TaskConfig = None, + ) -> None: """ Wrapper on send_direct_operate_command with flatten argumensts @@ -481,25 +573,29 @@ def send_direct_point_command(self, group: int, variation: int, index: int, val_ if config is None: config = opendnp3.TaskConfig().Default() - master_cmd: opendnp3.AnalogOutputDouble64 = parsing_gv_to_mastercmdtype(group=group, - variation=variation, - val_to_set=val_to_set) - self.send_direct_operate_command(command=master_cmd, - index=index, - callback=call_back, - config=config) - - def send_select_and_operate_point_command(self, group: int, variation: int, index: int, val_to_set: DbPointVal, - call_back: Callable[[opendnp3.ICommandTaskResult], None] = None, - config: opendnp3.TaskConfig = None - ) -> None: + master_cmd: opendnp3.AnalogOutputDouble64 = parsing_gv_to_mastercmdtype( + group=group, variation=variation, val_to_set=val_to_set + ) + self.send_direct_operate_command( + command=master_cmd, index=index, callback=call_back, config=config + ) + + def send_select_and_operate_point_command( + self, + group: int, + variation: int, + index: int, + val_to_set: DbPointVal, + call_back: Callable[[opendnp3.ICommandTaskResult], None] = None, + config: opendnp3.TaskConfig = None, + ) -> None: """ TODO: mimic send_direct_point_command """ pass def start(self): - _log.debug('Enabling the master.') + _log.debug("Enabling the master.") self.master.Enable() def shutdown(self): @@ -520,8 +616,12 @@ def shutdown(self): """ sleep_before_master_shutdown = 2 - _log.info(f"Master station shutting down in {sleep_before_master_shutdown} seconds...") - time.sleep(sleep_before_master_shutdown) # Note: hard-coded sleep to avoid hanging process + _log.info( + f"Master station shutting down in {sleep_before_master_shutdown} seconds..." + ) + time.sleep( + sleep_before_master_shutdown + ) # Note: hard-coded sleep to avoid hanging process # del self.master # self.master.Shutdown() # self.channel.Shutdown() @@ -546,14 +646,213 @@ def __del__(self): except AttributeError: pass - def send_scan_all_request(self, gv_ids: List[opendnp3.GroupVariationID] = None): - """send requests to retrieve all point values, if gv_ids not provided then use default """ + def send_scan_all_request( + self, gv_ids: List[opendnp3.GroupVariationID] | None = None + ): + """send requests to retrieve all point values, if gv_ids not provided then use default""" config = opendnp3.TaskConfig().Default() if gv_ids is None: - gv_ids = [GroupVariationID(group=30, variation=6), - GroupVariationID(group=40, variation=4), - GroupVariationID(group=1, variation=2), - GroupVariationID(group=10, variation=2)] + gv_ids = [ + GroupVariationID(group=30, variation=6), + GroupVariationID(group=40, variation=4), + GroupVariationID(group=1, variation=2), + GroupVariationID(group=10, variation=2), + ] for gv_id in gv_ids: - self.master.ScanAllObjects(gvId=gv_id, - config=config) + self.ScanAllObjects(self.master, gv_id, config) + + +class MasterApplication: + """ + Public interface wrapper on MyMaster.outstation_application. + + This class provides a high-level interface for interacting with the DNP3 master. + It encapsulates the functionality of the underlying `MyMaster` object and provides + convenient methods for starting, shutting down, and sending scan requests to the master. + + Args: + master_ip (str | None, optional): The IP address of the DNP3 master. Defaults to "0.0.0.0". + outstation_ip (str | None, optional): The IP address of the DNP3 outstation. Defaults to "127.0.0.1". + port (int | None, optional): The port number for communication. Defaults to 20000. + master_id (int | None, optional): The ID of the DNP3 master. Defaults to 2. + outstation_id (int | None, optional): The ID of the DNP3 outstation. Defaults to 1. + concurrency_hint (int | None, optional): A hint for the number of threads to use for processing. Defaults to 1. + log_handler (object, optional): The log handler for the DNP3 master. Defaults to `asiodnp3.ConsoleLogger().Create()`. + listener (object, optional): The channel listener for the DNP3 master. Defaults to `asiodnp3.PrintingChannelListener().Create()`. + soe_handler (object, optional): The SOE (Sequence of Events) handler for the DNP3 master. Defaults to `SOEHandler()`. + master_application (object, optional): The master application for the DNP3 master. Defaults to `asiodnp3.DefaultMasterApplication().Create()`. + channel_log_level (int, optional): The log level for the channel. Defaults to `opendnp3.levels.NORMAL`. + master_log_level (int, optional): The log level for the master. Defaults to 7 (warning level). + num_polling_retry (int, optional): The number of retries for polling requests. Defaults to 2. + delay_polling_retry (float, optional): The delay between polling retries in seconds. Defaults to 0.2. + stale_if_longer_than (float, optional): The time in seconds after which data is considered stale. Defaults to 2. + stack_config (object, optional): The stack configuration for the DNP3 master. Defaults to None. + *args: Variable length argument list. + **kwargs: Arbitrary keyword arguments. + + Attributes: + my_master (MyMaster): The underlying `MyMaster` object. + + """ + + def __init__( + self, + master_ip: str = "0.0.0.0", + outstation_ip: str = "127.0.0.1", + port: int = 20000, + master_id: int = 2, + outstation_id: int = 1, + concurrency_hint: int = 1, + log_handler=asiodnp3.ConsoleLogger().Create(), + listener=asiodnp3.PrintingChannelListener().Create(), + soe_handler=SOEHandler(), + master_application=asiodnp3.DefaultMasterApplication().Create(), + channel_log_level=opendnp3.levels.NORMAL, + master_log_level=7, # wild guess, 7: warning, 15 (opendnp3.levels.NORMAL): info + num_polling_retry: int = 2, + delay_polling_retry: float = 0.2, # in seconds + stale_if_longer_than: float = 2, # in seconds + stack_config=None, + # manager = asiodnp3.DNP3Manager(2, asiodnp3.ConsoleLogger().Create()) + *args, + **kwargs, + ): + self.my_master = MyMaster( + master_ip=master_ip, + outstation_ip=outstation_ip, + port=port, + master_id=master_id, + outstation_id=outstation_id, + concurrency_hint=concurrency_hint, + log_handler=log_handler, + listener=listener, + soe_handler=soe_handler, + master_application=master_application, + channel_log_level=channel_log_level, + master_log_level=master_log_level, + num_polling_retry=num_polling_retry, + delay_polling_retry=delay_polling_retry, + stale_if_longer_than=stale_if_longer_than, + stack_config=stack_config, + ) + + def start(self) -> None: + """ + Starts the DNP3 master. + + This method initiates the start of the DNP3 master, allowing it to begin communication with the outstation(s). + """ + return self.my_master.start() + + def shutdown(self) -> None: + """ + Shuts down the DNP3 master. + + This method calls the `shutdown` method of the underlying `my_master` object. + + Returns: + None + """ + return self.my_master.shutdown() + + @property + def is_connected(self) -> bool: + return self.my_master.is_connected + + @property + def db(self) -> Dnp3Database: + return Dnp3Database(self.my_master.soe_handler.db) + + def get_config(self): + return self.my_master.get_config() + + def send_scan_all_request( + self, gv_ids: List[opendnp3.GroupVariationID] | None = None + ): + """ + Sends a scan all request to the DNP3 master. + + Args: + gv_ids (List[opendnp3.GroupVariationID] | None, optional): A list of GroupVariationIDs to scan. Defaults to None. + + Returns: + The result of the scan all request. + """ + return self.my_master.send_scan_all_request(gv_ids=gv_ids) + + def get_db_by_group_variation(self, group: int, variation: int) -> DbStorage: + """ + Retrieves the database by group and variation. + + Args: + group (int): The group number. + variation (int): The variation number. + + Returns: + DbStorage: The database storage. + """ + return self.my_master.get_db_by_group_variation(group, variation) + + def send_direct_point_command( + self, + group: int, + variation: int, + index: int, + val_to_set: DbPointVal, + call_back: Callable[[opendnp3.ICommandTaskResult], None] | None = None, + config: opendnp3.TaskConfig = None, + ) -> None: + """ + Sends a direct point command to the DNP3 master. + + Args: + group (int): The group number for the command. + variation (int): The variation number for the command. + index (int): The index of the command. + val_to_set (DbPointVal): The value to set for the command. + + Returns: + None + """ + return self.my_master.send_direct_point_command( + group=group, + variation=variation, + index=index, + val_to_set=val_to_set, + call_back=call_back, + config=config, + ) + + def send_direct_analog_output_point_command( + self, index: int, val_to_set: float + ) -> None: + """ + Sends a direct point command to the DNP3 master. + + Args: + index (int): The index of the command. + val_to_set (float): The value to set for the command. + + Returns: + None + """ + return self.my_master.send_direct_point_command( + group=40, variation=4, index=index, val_to_set=val_to_set + ) + + def send_direct_binary_output_point_command( + self, index: int, val_to_set: bool + ) -> None: + """ + Sends a direct point command to the DNP3 master. + + Args: + index (int): The index of the command. + val_to_set (bool): The value to set for the command. + + Returns: + None + """ + return self.my_master.send_direct_point_command( + group=10, variation=2, index=index, val_to_set=val_to_set + ) diff --git a/src/dnp3_python/dnp3station/outstation.py b/src/dnp3_python/dnp3station/outstation.py index fc6b4ef..b922119 100644 --- a/src/dnp3_python/dnp3station/outstation.py +++ b/src/dnp3_python/dnp3station/outstation.py @@ -1,18 +1,23 @@ from __future__ import annotations import logging +import random import sys - -import pydnp3.asiopal -from pydnp3 import opendnp3, openpal, asiopal, asiodnp3 import time +from datetime import datetime +from typing import Dict, Type, Union -from typing import Union, Type, Dict +import pydnp3.asiopal +from pydnp3 import asiodnp3, asiopal, opendnp3, openpal -from .station_utils import master_to_outstation_command_parser -from .station_utils import OutstationCmdType, MasterCmdType # from .outstation_utils import MeasurementType -from .station_utils import DBHandler +from .station_utils import ( + DBHandler, + Dnp3Database, + MasterCmdType, + OutstationCmdType, + master_to_outstation_command_parser, +) LOG_LEVELS = opendnp3.levels.NORMAL | opendnp3.levels.ALL_COMMS LOCAL_IP = "0.0.0.0" @@ -20,7 +25,9 @@ # PORT = 20001 stdout_stream = logging.StreamHandler(sys.stdout) -stdout_stream.setFormatter(logging.Formatter('%(asctime)s\t%(name)s\t%(levelname)s\t%(message)s')) +stdout_stream.setFormatter( + logging.Formatter("%(asctime)s\t%(name)s\t%(levelname)s\t%(message)s") +) _log = logging.getLogger(__name__) _log.addHandler(stdout_stream) @@ -29,54 +36,67 @@ _log.setLevel(logging.INFO) # alias -PointValueType = Union[opendnp3.Analog, opendnp3.Binary, opendnp3.AnalogOutputStatus, opendnp3.BinaryOutputStatus] +PointValueType = Union[ + opendnp3.Analog, + opendnp3.Binary, + opendnp3.AnalogOutputStatus, + opendnp3.BinaryOutputStatus, +] class MyOutStation(opendnp3.IOutstationApplication): """ - Interface for all outstation callback info except for control requests. - - DNP3 spec section 5.1.6.2: - The Application Layer provides the following services for the DNP3 User Layer in an outstation: - - Notifies the DNP3 User Layer when action requests, such as control output, - analog output, freeze and file operations, arrive from a master. - - Requests data and information from the outstation that is wanted by a master - and formats the responses returned to a master. - - Assures that event data is successfully conveyed to a master (using - Application Layer confirmation). - - Sends notifications to the master when the outstation restarts, has queued events, - and requires time synchronization. - - DNP3 spec section 5.1.6.3: - The Application Layer requires specific services from the layers beneath it. - - Partitioning of fragments into smaller portions for transport reliability. - - Knowledge of which device(s) were the source of received messages. - - Transmission of messages to specific devices or to all devices. - - Message integrity (i.e., error-free reception and transmission of messages). - - Knowledge of the time when messages arrive. - - Either precise times of transmission or the ability to set time values - into outgoing messages. - """ + Interface for all outstation callback info except for control requests. + + DNP3 spec section 5.1.6.2: + The Application Layer provides the following services for the DNP3 User Layer in an outstation: + - Notifies the DNP3 User Layer when action requests, such as control output, + analog output, freeze and file operations, arrive from a master. + - Requests data and information from the outstation that is wanted by a master + and formats the responses returned to a master. + - Assures that event data is successfully conveyed to a master (using + Application Layer confirmation). + - Sends notifications to the master when the outstation restarts, has queued events, + and requires time synchronization. + + DNP3 spec section 5.1.6.3: + The Application Layer requires specific services from the layers beneath it. + - Partitioning of fragments into smaller portions for transport reliability. + - Knowledge of which device(s) were the source of received messages. + - Transmission of messages to specific devices or to all devices. + - Message integrity (i.e., error-free reception and transmission of messages). + - Knowledge of the time when messages arrive. + - Either precise times of transmission or the ability to set time values + into outgoing messages. + """ # outstation = None # db_handler = None outstation_application = None # outstation_pool = {} # a pool of outstations - outstation_application_pool: Dict[str, MyOutStation] = {} # a pool of outstation applications - - def __init__(self, - outstation_ip: str = "0.0.0.0", - port: int = 20000, - master_id: int = 2, - outstation_id: int = 1, - concurrency_hint: int = 1, - - channel_log_level=opendnp3.levels.NORMAL, - outstation_log_level=opendnp3.levels.NORMAL, - - db_sizes: opendnp3.DatabaseSizes = None, - event_buffer_config: opendnp3.EventBufferConfig = None - ): + outstation_application_pool: Dict[ + str, MyOutStation + ] = {} # a pool of outstation applications + + def __init__( + self, + outstation_ip: str | None = "0.0.0.0", + port: int | None = 20000, + master_id: int | None = 2, + outstation_id: int | None = 1, + concurrency_hint: int | None = 1, + channel_log_level=opendnp3.levels.NORMAL, + outstation_log_level=opendnp3.levels.NORMAL, + db_sizes: opendnp3.DatabaseSizes = None, + event_buffer_config: opendnp3.EventBufferConfig = None, + numBinary: int | None = None, + numBinaryOutputStatus: int | None = None, + numAnalog: int | None = None, + numAnalogOutputStatus: int | None = None, + is_allowUnsolicited: bool = True, + *args, + **kwargs, + ): super().__init__() # Note: @@ -94,18 +114,43 @@ def __init__(self, self.master_id: int = master_id self.outstation_id: int = outstation_id - # Set to default - if db_sizes is None: - db_sizes = opendnp3.DatabaseSizes.AllTypes(count=5) + # Set database sizes based on input parameters + DEFAULT_DB_SIZE = 5 + if db_sizes is not None: + self.db_sizes = db_sizes + else: + if ( + numAnalog is None + and numAnalogOutputStatus is None + and numBinary is None + and numBinaryOutputStatus is None + ): + # All specific DB size parameters are None, use a default for all types + self.db_sizes = opendnp3.DatabaseSizes.AllTypes(count=DEFAULT_DB_SIZE) + else: + # Set DB sizes based on provided parameters, default others to 0 + self.db_sizes = opendnp3.DatabaseSizes( + numBinary=numBinary, + numBinaryOutputStatus=numBinaryOutputStatus, + numDoubleBinary=0, # use numBinary instead of numDoubleBinary + numAnalog=numAnalog, + numAnalogOutputStatus=numAnalogOutputStatus, + numCounter=0, + numFrozenCounter=0, + numTimeAndInterval=0, + ) + if event_buffer_config is None: event_buffer_config = opendnp3.EventBufferConfig().AllTypes(sizes=10) - self.db_sizes = db_sizes self.event_buffer_config = event_buffer_config - _log.debug('Configuring the DNP3 stack.') - _log.debug('Configuring the outstation database.') - self.stack_config = self.configure_stack(db_sizes=db_sizes, - event_buffer_config=event_buffer_config) + _log.debug("Configuring the DNP3 stack.") + _log.debug("Configuring the outstation database.") + self.stack_config = self.configure_stack( + db_sizes=self.db_sizes, + event_buffer_config=event_buffer_config, + is_allowUnsolicited=is_allowUnsolicited, + ) # TODO: Justify if this is is really working? (Not sure if it really takes effect yet.) # but needs to add docstring. Search for "intriguing" in "data_retrieval_demo.py" @@ -115,29 +160,35 @@ def __init__(self, self.configure_database(self.stack_config.dbConfig) # self.log_handler = MyLogger() - self.log_handler = asiodnp3.ConsoleLogger().Create() # (or use this during regression testing) + self.log_handler = ( + asiodnp3.ConsoleLogger().Create() + ) # (or use this during regression testing) # self.manager = asiodnp3.DNP3Manager(threads_to_allocate, self.log_handler) # print("====outstation self.log_handler = log_handler", self.log_handler) # init steps: DNP3Manager(manager) -> TCPClient(channel) -> Master(master) # init DNP3Manager(manager) - _log.debug('Creating a DNP3Manager.') - self.manager = asiodnp3.DNP3Manager(concurrency_hint, self.log_handler) # TODO: play with concurrencyHint + _log.debug("Creating a DNP3Manager.") + self.manager = asiodnp3.DNP3Manager( + concurrency_hint, self.log_handler + ) # TODO: play with concurrencyHint # init TCPClient(channel) - _log.debug('Creating the DNP3 channel, a TCP server.') + _log.debug("Creating the DNP3 channel, a TCP server.") self.retry_parameters = asiopal.ChannelRetry().Default() self.listener = AppChannelListener() # self.listener = asiodnp3.PrintingChannelListener().Create() # (or use this during regression testing) level = opendnp3.levels.NORMAL | opendnp3.levels.ALL_COMMS # seems not working - self.channel = self.manager.AddTCPServer(id="server", - levels=level, - retry=self.retry_parameters, - endpoint=outstation_ip, - port=port, - listener=self.listener) - - _log.debug('Adding the outstation to the channel.') + self.channel = self.manager.AddTCPServer( + id="server", + levels=level, + retry=self.retry_parameters, + endpoint=outstation_ip, + port=port, + listener=self.listener, + ) + + _log.debug("Adding the outstation to the channel.") self.outstation_app_id = outstation_ip + "-" + str(port) # self.command_handler = OutstationCommandHandler() self.command_handler = MyOutstationCommandHandler() @@ -148,13 +199,17 @@ def __init__(self, MyOutStation.set_outstation_application(outstation_application=self) # finally, init outstation - self.outstation = self.channel.AddOutstation(id="outstation-" + self.outstation_app_id, - commandHandler=self.command_handler, - application=MyOutStation.outstation_application, - config=self.stack_config) - - MyOutStation.add_outstation_app(outstation_id=self.outstation_app_id, - outstation_app=self.outstation_application) + self.outstation = self.channel.AddOutstation( + id="outstation-" + self.outstation_app_id, + commandHandler=self.command_handler, + application=MyOutStation.outstation_application, + config=self.stack_config, + ) + + MyOutStation.add_outstation_app( + outstation_id=self.outstation_app_id, + outstation_app=self.outstation_application, + ) # Configure log level for channel(tcpclient) and outstation # note: one of the following @@ -164,7 +219,7 @@ def __init__(self, # NORMAL = 15 # NOTHING = 0 - _log.debug('Configuring log level') + _log.debug("Configuring log level") self.channel_log_level: opendnp3.levels = channel_log_level self.outstation_log_level: opendnp3.levels = outstation_log_level @@ -202,7 +257,8 @@ def channel_statistic(self): return { "numOpen": self.channel.GetStatistics().channel.numOpen, "numOpenFail": self.channel.GetStatistics().channel.numOpenFail, - "numClose": self.channel.GetStatistics().channel.numClose} + "numClose": self.channel.GetStatistics().channel.numClose, + } @property def is_connected(self): @@ -211,7 +267,7 @@ def is_connected(self): numOpen - numClose == 1 => SUCCESS numOpen - numClose == 0 => FAIL """ - if self.channel_statistic.get("numOpen") - self.channel_statistic.get("numClose") == 1: + if self.channel_statistic["numOpen"] - self.channel_statistic["numClose"] == 1: return True else: return False @@ -236,43 +292,55 @@ def get_outstation_app(cls, outstation_id: str) -> MyOutStation: @classmethod def set_outstation_application(cls, outstation_application): """ - use singleton - Note: at this version,needs to keep this function + use singleton + Note: at this version,needs to keep this function """ if cls.outstation_application: pass else: cls.outstation_application = outstation_application - def configure_stack(self, db_sizes: opendnp3.DatabaseSizes = None, - event_buffer_config: opendnp3.EventBufferConfig = None, - **kwargs) -> asiodnp3.OutstationStackConfig: + def configure_stack( + self, + db_sizes: opendnp3.DatabaseSizes = None, + event_buffer_config: opendnp3.EventBufferConfig = None, + is_allowUnsolicited: bool = True, + **kwargs, + ) -> asiodnp3.OutstationStackConfig: """Set up the OpenDNP3 configuration.""" stack_config = asiodnp3.OutstationStackConfig(dbSizes=db_sizes) stack_config.outstation.eventBufferConfig = event_buffer_config - stack_config.outstation.params.allowUnsolicited = True # TODO: create interface for this - stack_config.link.LocalAddr = self.outstation_id # meaning for outstation, use 1 to follow simulator's default - stack_config.link.RemoteAddr = self.master_id # meaning for master station, use 2 to follow simulator's default + stack_config.outstation.params.allowUnsolicited = ( + is_allowUnsolicited # TODO: doesn't seem to take effect + ) + stack_config.link.LocalAddr = ( + self.outstation_id + ) # meaning for outstation, use 1 to follow simulator's default + stack_config.link.RemoteAddr = ( + self.master_id + ) # meaning for master station, use 2 to follow simulator's default stack_config.link.KeepAliveTimeout = openpal.TimeDuration().Max() return stack_config @staticmethod def configure_database(db_config): """ - Configure the Outstation's database of input point definitions. + Configure the Outstation's database of input point definitions. - # Configure two Analog points (group/variation 30.1) at indexes 1 and 2. - Configure two Analog points (group/variation 30.1) at indexes 0, 1. - Configure two Binary points (group/variation 1.2) at indexes 1 and 2. + # Configure two Analog points (group/variation 30.1) at indexes 1 and 2. + Configure two Analog points (group/variation 30.1) at indexes 0, 1. + Configure two Binary points (group/variation 1.2) at indexes 1 and 2. """ + # _log.info("======== configure_database") # AnalogInput db_config.analog[0].clazz = opendnp3.PointClass.Class2 # db_config.analog[0].svariation = opendnp3.StaticAnalogVariation.Group30Var1 - db_config.analog[ - 0].svariation = opendnp3.StaticAnalogVariation.Group30Var5 # note: experiment, Analog input - double-precision, floating-point with flag ref: https://docs.stepfunc.io/dnp3/0.9.0/dotnet/namespacednp3.html#aa326dc3592a41ae60222051044fb084f + # note: experiment, Analog input - double-precision, floating-point with flag + # ref: https://docs.stepfunc.io/dnp3/0.9.0/dotnet/namespacednp3.html#aa326dc3592a41ae60222051044fb084f + db_config.analog[0].svariation = opendnp3.StaticAnalogVariation.Group30Var5 db_config.analog[0].evariation = opendnp3.EventAnalogVariation.Group32Var7 db_config.analog[1].clazz = opendnp3.PointClass.Class2 db_config.analog[1].svariation = opendnp3.StaticAnalogVariation.Group30Var1 @@ -294,14 +362,18 @@ def configure_database(db_config): # Kefei's wild guess for analog output config db_config.aoStatus[0].clazz = opendnp3.PointClass.Class2 - db_config.aoStatus[0].svariation = opendnp3.StaticAnalogOutputStatusVariation.Group40Var1 + db_config.aoStatus[ + 0 + ].svariation = opendnp3.StaticAnalogOutputStatusVariation.Group40Var1 # db_config.aoStatus[0].evariation = opendnp3.StaticAnalogOutputStatusVariation.Group40Var1 db_config.boStatus[0].clazz = opendnp3.PointClass.Class2 - db_config.boStatus[0].svariation = opendnp3.StaticBinaryOutputStatusVariation.Group10Var2 + db_config.boStatus[ + 0 + ].svariation = opendnp3.StaticBinaryOutputStatusVariation.Group10Var2 # db_config.boStatus[0].evariation = opendnp3.StaticBinaryOutputStatusVariation.Group10Var2 def start(self): - _log.debug('Enabling the outstation.') + _log.debug("Enabling the outstation.") self.outstation.Enable() def shutdown(self): @@ -338,7 +410,9 @@ def process_point_value(self, command_type, command, index, op_type): # TODO: add control logic in scenarios 'Select' or 'Operate' (to allow more sophisticated control behavior) # print("command __getattribute__ ", command.__getattribute__) - _log.debug('Processing received point value for index {}: {}'.format(index, command)) + _log.debug( + "Processing received point value for index {}: {}".format(index, command) + ) # parse master operation command to outstation update command # Note: print("command rawCode ", command.rawCode) for BinaryOutput/ControlRelayOutputBlock @@ -349,9 +423,7 @@ def process_point_value(self, command_type, command, index, op_type): self.apply_update(outstation_cmd, index) # @classmethod - def apply_update(self, - measurement: OutstationCmdType, - index): + def apply_update(self, measurement: OutstationCmdType, index): """ Record an opendnp3 data value (Analog, Binary, etc.) in the outstation's database. Note: measurement based on asiodnp3.UpdateBuilder.Update(**args) @@ -361,14 +433,23 @@ def apply_update(self, :param measurement: An instance of Analog, Binary, or another opendnp3 data value. :param index: (integer) Index of the data definition in the opendnp3 database. """ - _log.debug('Recording {} measurement, index={}, ' - 'value={}, flag={}, time={}' - .format(type(measurement), index, measurement.value, measurement.flags.value, - measurement.time.value)) + _log.debug( + "Recording {} measurement, index={}, " "value={}, flag={}, time={}".format( + type(measurement), + index, + measurement.value, + measurement.flags.value, + measurement.time.value, + ) + ) # builder = asiodnp3.UpdateBuilder() # builder.Update(measurement, index) # update = builder.Build() - update = asiodnp3.UpdateBuilder().Update(measurement, index).Build() + update = ( + asiodnp3.UpdateBuilder() + .Update(measurement, index, opendnp3.EventMode.Force) + .Build() + ) # cls.get_outstation().Apply(update) self.outstation.Apply(update) @@ -384,12 +465,12 @@ def __del__(self): class MyOutstationCommandHandler(opendnp3.ICommandHandler): """ - Override ICommandHandler in this manner to implement application-specific command handling. + Override ICommandHandler in this manner to implement application-specific command handling. - ICommandHandler implements the Outstation's handling of Select and Operate, - which relay commands and data from the Master to the Outstation. + ICommandHandler implements the Outstation's handling of Select and Operate, + which relay commands and data from the Master to the Outstation. - Note: this class CANNOT implement init + Note: this class CANNOT implement init """ # outstation_application = MyOutStationNew @@ -403,10 +484,10 @@ def post_init(self, outstation_id, **kwargs): self.outstation_id = outstation_id def Start(self): - _log.debug('In OutstationCommandHandler.Start') + _log.debug("In OutstationCommandHandler.Start") def End(self): - _log.debug('In OutstationCommandHandler.End') + _log.debug("In OutstationCommandHandler.End") def Select(self, command, index): """ @@ -419,10 +500,10 @@ def Select(self, command, index): :return: CommandStatus """ outstation_application_pool = MyOutStation.outstation_application_pool - outstation_app = outstation_application_pool.get(self.outstation_id) + outstation_app = outstation_application_pool[self.outstation_id] try: - outstation_app.process_point_value('Select', command, index, None) + outstation_app.process_point_value("Select", command, index, None) return opendnp3.CommandStatus.SUCCESS except Exception as e: _log.error(e) @@ -442,10 +523,10 @@ def Operate(self, command, index, op_type): """ outstation_application_pool = MyOutStation.outstation_application_pool - outstation_app = outstation_application_pool.get(self.outstation_id) + outstation_app = outstation_application_pool[self.outstation_id] try: # self.outstation_application.process_point_value('Operate', command, index, op_type) - outstation_app.process_point_value('Operate', command, index, op_type) + outstation_app.process_point_value("Operate", command, index, op_type) return opendnp3.CommandStatus.SUCCESS except Exception as e: _log.error(e) @@ -454,11 +535,147 @@ def Operate(self, command, index, op_type): class AppChannelListener(asiodnp3.IChannelListener): """ - Override IChannelListener in this manner to implement application-specific channel behavior. + Override IChannelListener in this manner to implement application-specific channel behavior. """ def __init__(self): super(AppChannelListener, self).__init__() def OnStateChange(self, state): - _log.debug('In AppChannelListener.OnStateChange: state={}'.format(state)) + _log.debug("In AppChannelListener.OnStateChange: state={}".format(state)) + + +class OutStationApplication: + """Public interface wrapper on MyOutstation.outstation_application""" + + def __init__( + self, + outstation_ip: str = "0.0.0.0", + port: int = 20000, + master_id: int = 2, + outstation_id: int = 1, + concurrency_hint: int = 1, + channel_log_level=opendnp3.levels.NORMAL, + outstation_log_level=opendnp3.levels.NORMAL, + db_sizes: opendnp3.DatabaseSizes = None, + event_buffer_config: opendnp3.EventBufferConfig = None, + numBinary: int | None = None, + numBinaryOutputStatus: int | None = None, + numAnalog: int | None = None, + numAnalogOutputStatus: int | None = None, + is_allowUnsolicited: bool = True, # TODO: doesn't seem to take effect + *args, + **kwargs, + ): + self.my_outstation = MyOutStation( + outstation_ip=outstation_ip, + port=port, + master_id=master_id, + outstation_id=outstation_id, + concurrency_hint=concurrency_hint, + channel_log_level=channel_log_level, + outstation_log_level=outstation_log_level, + db_sizes=db_sizes, + event_buffer_config=event_buffer_config, + numBinary=numBinary, + numBinaryOutputStatus=numBinaryOutputStatus, + numAnalog=numAnalog, + numAnalogOutputStatus=numAnalogOutputStatus, + is_allowUnsolicited=is_allowUnsolicited, + *args, + **kwargs, + ) + + def start(self) -> None: + return self.my_outstation.start() + + def shutdown(self) -> None: + return self.my_outstation.shutdown() + + @property + def is_connected(self) -> bool: + return self.my_outstation.is_connected + + def get_config(self): + return self.my_outstation.get_config() + + def apply_update(self, measurement: OutstationCmdType, index: int) -> None: + """ + Record an opendnp3 data value (Analog, Binary, etc.) in the outstation's database. + Note: measurement based on asiodnp3.UpdateBuilder.Update(**args) + + The data value gets sent to the Master as a side effect. + + :param measurement: An instance of Analog, Binary, or another opendnp3 data value. + :param index: (integer) Index of the data definition in the opendnp3 database. + """ + return self.my_outstation.apply_update(measurement, index) + + def apply_update_analog_input(self, analog_input_value: float, index: int) -> None: + """ + wrapper on apply_update for AnalogInput + """ + measurement = opendnp3.Analog(value=analog_input_value) + + return self.my_outstation.apply_update(measurement, index) + + def apply_update_analog_output( + self, analog_output_value: float, index: int + ) -> None: + """ + wrapper on apply_update for AnalogOutput + """ + measurement = opendnp3.AnalogOutputStatus(value=analog_output_value) + + return self.my_outstation.apply_update(measurement, index) + + def apply_update_binary_input(self, binary_input_value: bool, index: int) -> None: + """ + wrapper on apply_update for BinaryInput + """ + measurement = opendnp3.Binary(value=binary_input_value) + + return self.my_outstation.apply_update(measurement, index) + + def apply_update_binary_output(self, binary_output_value: bool, index: int) -> None: + """ + wrapper on apply_update for BinaryOutput + """ + measurement = opendnp3.BinaryOutputStatus(value=binary_output_value) + + return self.my_outstation.apply_update(measurement, index) + + @property + def db(self) -> Dnp3Database: + return Dnp3Database(self.my_outstation.db_handler.db) + + def update_db_with_random(self) -> None: + """ + Update a random point in the outstation's database with a random value. + + This method iterates over the different types of points in the outstation's database + (binary, binary output status, analog, analog output status) and updates a random point + in each category with a random value. For binary points, a random boolean value is chosen. + For analog points, a random float value is generated within a range based on the index of + the point. + + Note: This method assumes that the `apply_update` method is defined elsewhere in the class. + + Returns: + None + """ + db_sizes = self.my_outstation.db_sizes + for n in range(db_sizes.numBinary): + val = random.choice([True, False]) + self.apply_update(opendnp3.Binary(val), n) + for n in range(db_sizes.numBinaryOutputStatus): + val = random.choice([True, False]) + self.apply_update(opendnp3.BinaryOutputStatus(val), n) + for n in range(db_sizes.numAnalog): + val = random.random() * pow(2, n) + val = round(val, 4) + self.apply_update(opendnp3.Analog(val), n) + for n in range(db_sizes.numAnalogOutputStatus): + val = random.random() * pow(2, n) + val = round(val, 4) + self.apply_update(opendnp3.AnalogOutputStatus(val), n) diff --git a/src/dnp3_python/dnp3station/station_utils.py b/src/dnp3_python/dnp3station/station_utils.py index aa48c16..629bace 100644 --- a/src/dnp3_python/dnp3station/station_utils.py +++ b/src/dnp3_python/dnp3station/station_utils.py @@ -1,9 +1,16 @@ +from __future__ import annotations + +import csv import datetime +import io import logging +import os import sys import time +from dataclasses import asdict, dataclass, field from typing import Callable, Dict, List, Optional, Tuple, Type, TypeVar, Union +# import pandas as pd from pydnp3 import asiodnp3, asiopal, opendnp3, openpal from pydnp3.opendnp3 import GroupVariation, GroupVariationID @@ -117,6 +124,12 @@ def __init__(self, soehandler_log_level=logging.INFO, *args, **kwargs): # db self._db = self.init_db() + # temp: TODO get rid of them + self.visitor_dict: Dict[str, "VisitorClass"] = {} + self.value_visitor_dict: Dict[str, ICollectionIndexedVal] = {} + self.info_visitor_dict: Dict[str, ICollectionIndexedVal] = {"ds": "dfs"} + self.temp_values = None + def config_logger(self, log_level=logging.INFO): self.logger.addHandler(stdout_stream) self.logger.setLevel(log_level) @@ -152,10 +165,11 @@ def Process(self, info, values: ICollectionIndexedVal, *args, **kwargs): GroupVariation.Group30Var3, GroupVariation.Group30Var4, # GroupVariation.Group32Var0, - GroupVariation.Group32Var1, + # GroupVariation.Group32Var1, GroupVariation.Group32Var2, GroupVariation.Group32Var3, GroupVariation.Group32Var4, + GroupVariation.Group32Var5, ]: visitor = VisitorIndexedAnalogInt() elif visitor_class == VisitorIndexedAnalogOutputStatus: @@ -173,6 +187,14 @@ def Process(self, info, values: ICollectionIndexedVal, *args, **kwargs): # Note: mystery method, magic side effect to update visitor.index_and_value values.Foreach(visitor) + self.visitor_dict[str(visitor_class)] = visitor + self.value_visitor_dict[str(visitor_class)] = visitor + # info.Foreach(visitor) + self.info_visitor_dict[str(visitor_class)] = info + # self.info_ = info + + # values.Foreach(visitor) + # visitor.index_and_value: List[Tuple[int, DbPointVal]] for index, value in visitor.index_and_value: log_string = "SOEHandler.Process {0}\theaderIndex={1}\tdata_type={2}\tindex={3}\tvalue={4}" @@ -187,8 +209,10 @@ def Process(self, info, values: ICollectionIndexedVal, *args, **kwargs): visitor_ind_val: List[Tuple[int, DbPointVal]] = visitor.index_and_value # _log.info("======== SOEHandler.Process") - # _log.info(f"info_gv {info_gv}") + # _log.info(f"{info_gv=}") # _log.info(f"visitor_ind_val {visitor_ind_val}") + # _log.info(f"{values=}") + self.temp_values = values self._post_process(info_gv=info_gv, visitor_ind_val=visitor_ind_val) def _post_process( @@ -259,7 +283,7 @@ def _consolidate_db(self): "Binary", "BinaryOutputStatus", "Analog", "AnalogOutputStatus" """ pass - # for Analog + # for Analog (TODO: why get GroupVariation.Group30Var6) _db = { "Analog": self._gv_index_value_nested_dict.get(GroupVariation.Group30Var6) } @@ -457,10 +481,13 @@ def __init__( **kwargs, ): self.stack_config = stack_config - self._db: dict = self.config_db(stack_config) + self._db: dict = self.config_db( + stack_config + ) # TODO: ideally, this should be a dataclass self.logger = logging.getLogger(self.__class__.__name__) self.config_logger(log_level=dbhandler_log_level) + # self.dnp3_database: Dnp3Database = Dnp3Database() def config_logger(self, log_level=logging.INFO): self.logger.addHandler(stdout_stream) @@ -487,9 +514,13 @@ def config_db(stack_config): def db(self) -> dict: return self._db - def process(self, command, index): - pass - # _log.info(f"command {command}") + def process(self, command: OutstationCmdType, index: int): + """ + Note: this method would be magically evoked when outstation apply_update, + or receive command from master. + command.__class__.__name__ is in ["Analog", "AnalogOutputStatus", "Binary", "BinaryOutputStatus"] + """ + # _log.info(f"{command=}, {type(command)=}") # _log.info(f"index {index}") update_body: dict = {index: command.value} if self.db.get(command.__class__.__name__): @@ -497,6 +528,12 @@ def process(self, command, index): else: self.db[command.__class__.__name__] = update_body # _log.info(f"========= self.db {self.db}") + command_to_dataclass_type = { + "Analog": "AnalogInput", + "AnalogOutputStatus": "AnalogOutput", + "Binary": "BinaryInput", + "BinaryOutputStatus": "BinaryOutput", + } class MyLogger(openpal.ILogHandler): @@ -535,3 +572,80 @@ def to_flat_db(db: dict) -> dict: db_flat["Value"] += values db_flat["Type"] += types return db_flat + + +import datetime +from dataclasses import dataclass, field +from typing import List + + +@dataclass(frozen=True) +class Dnp3DatabaseRecord: + """ + Represents a record in a DNP3 database. + + Attributes: + point_type (str): The type of point, e.g., AnalogInput, AnalogOutput. + index (int): The index of the point in the database. + value (str | float | bool | None): The value of the point. + updated_at (datetime.datetime): The timestamp when the point was last updated. + received_at (datetime.datetime): The timestamp when the point was last received. + + Example: + >>> record = Dnp3DatabaseRecord( + point_type="AnalogInput", + index=1, + value=123.45, + updated_at=datetime.datetime.now(), + received_at=datetime.datetime.now() + ) + """ + + point_type: str + index: int + value: str | float | bool | None + updated_at: str | datetime.datetime + # received_at: datetime.datetime + + +class Dnp3Database: + """ + DNP3 database representation + """ + + def __init__(self, db: dict, *args, **kwargs): + self._db = db + self = db + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self._db})" + + @property + def Analog(self) -> dict[int, float | None]: + return self._db["Analog"] + + @property + def AnalogOutputStatus(self) -> dict[int, float | None]: + return self._db["AnalogOutputStatus"] + + @property + def Binary(self) -> dict[int, bool | None]: + return self._db["Binary"] + + @property + def BinaryOutputStatus(self) -> dict[int, bool | None]: + return self._db["BinaryOutputStatus"] + + def to_csv(self, file_path: str | None = None) -> None: + flat_dict = to_flat_db(self._db) + if file_path is None: + file_path = f"dnp3-database-{datetime.datetime.now().isoformat()}.csv" + # Open the CSV file for writing + with open(file_path, mode="w", newline="") as file: + # Create a CSV writer object + writer = csv.writer(file) + # Write the header (the keys of the dictionary) + writer.writerow(flat_dict.keys()) + # Write the data rows + # Transpose the values from the dictionary to rows in CSV + writer.writerows(zip(*flat_dict.values())) diff --git a/src/dnp3_python/dnp3station/visitors.py b/src/dnp3_python/dnp3station/visitors.py index 3d8f2a5..1db8fb8 100644 --- a/src/dnp3_python/dnp3station/visitors.py +++ b/src/dnp3_python/dnp3station/visitors.py @@ -1,7 +1,8 @@ """ - The master uses these data-type-specific Visitor class definitions - when it processes measurements received from the outstation. +The master uses these data-type-specific Visitor class definitions +when it processes measurements received from the outstation. """ + from pydnp3 import opendnp3 @@ -11,7 +12,9 @@ def __init__(self): self.index_and_value = [] def OnValue(self, indexed_instance): - self.index_and_value.append((indexed_instance.index, indexed_instance.value.value)) + self.index_and_value.append( + (indexed_instance.index, indexed_instance.value.value) + ) class VisitorIndexedDoubleBitBinary(opendnp3.IVisitorIndexedDoubleBitBinary): @@ -20,7 +23,9 @@ def __init__(self): self.index_and_value = [] def OnValue(self, indexed_instance): - self.index_and_value.append((indexed_instance.index, indexed_instance.value.value)) + self.index_and_value.append( + (indexed_instance.index, indexed_instance.value.value) + ) class VisitorIndexedCounter(opendnp3.IVisitorIndexedCounter): @@ -29,7 +34,9 @@ def __init__(self): self.index_and_value = [] def OnValue(self, indexed_instance): - self.index_and_value.append((indexed_instance.index, indexed_instance.value.value)) + self.index_and_value.append( + (indexed_instance.index, indexed_instance.value.value) + ) class VisitorIndexedFrozenCounter(opendnp3.IVisitorIndexedFrozenCounter): @@ -38,25 +45,35 @@ def __init__(self): self.index_and_value = [] def OnValue(self, indexed_instance): - self.index_and_value.append((indexed_instance.index, indexed_instance.value.value)) + self.index_and_value.append( + (indexed_instance.index, indexed_instance.value.value) + ) class VisitorIndexedAnalog(opendnp3.IVisitorIndexedAnalog): def __init__(self): super(VisitorIndexedAnalog, self).__init__() self.index_and_value = [] + self.index_and_instance = [] def OnValue(self, indexed_instance): - self.index_and_value.append((indexed_instance.index, indexed_instance.value.value)) + self.index_and_value.append( + (indexed_instance.index, indexed_instance.value.value) + ) + self.index_and_instance.append((indexed_instance.index, indexed_instance)) class VisitorIndexedAnalogInt(VisitorIndexedAnalog): def __init__(self): super(VisitorIndexedAnalogInt, self).__init__() self.index_and_value = [] + self.index_and_instance = [] def OnValue(self, indexed_instance): - self.index_and_value.append((indexed_instance.index, int(indexed_instance.value.value))) + self.index_and_value.append( + (indexed_instance.index, int(indexed_instance.value.value)) + ) + self.index_and_instance.append((indexed_instance.index, indexed_instance)) class VisitorIndexedBinaryOutputStatus(opendnp3.IVisitorIndexedBinaryOutputStatus): @@ -65,7 +82,9 @@ def __init__(self): self.index_and_value = [] def OnValue(self, indexed_instance): - self.index_and_value.append((indexed_instance.index, indexed_instance.value.value)) + self.index_and_value.append( + (indexed_instance.index, indexed_instance.value.value) + ) class VisitorIndexedAnalogOutputStatus(opendnp3.IVisitorIndexedAnalogOutputStatus): @@ -74,7 +93,9 @@ def __init__(self): self.index_and_value = [] def OnValue(self, indexed_instance): - self.index_and_value.append((indexed_instance.index, indexed_instance.value.value)) + self.index_and_value.append( + (indexed_instance.index, indexed_instance.value.value) + ) class VisitorIndexedAnalogOutputStatusInt(VisitorIndexedAnalogOutputStatus): @@ -83,7 +104,9 @@ def __init__(self): self.index_and_value = [] def OnValue(self, indexed_instance): - self.index_and_value.append((indexed_instance.index, int(indexed_instance.value.value))) + self.index_and_value.append( + (indexed_instance.index, int(indexed_instance.value.value)) + ) class VisitorIndexedTimeAndInterval(opendnp3.IVisitorIndexedTimeAndInterval): @@ -96,4 +119,6 @@ def OnValue(self, indexed_instance): ti_instance = indexed_instance.value ti_dnptime = ti_instance.time ti_interval = ti_instance.interval - self.index_and_value.append((indexed_instance.index, (ti_dnptime.value, ti_interval))) + self.index_and_value.append( + (indexed_instance.index, (ti_dnptime.value, ti_interval)) + ) diff --git a/src/dnp3demo/control_workflow_demo.py b/src/dnp3demo/control_workflow_demo.py index 66db714..aad429f 100644 --- a/src/dnp3demo/control_workflow_demo.py +++ b/src/dnp3demo/control_workflow_demo.py @@ -1,17 +1,19 @@ +import datetime import logging import random import sys +from time import sleep from pydnp3 import opendnp3 -from dnp3_python.dnp3station.station_utils import command_callback + from dnp3_python.dnp3station.master import MyMaster from dnp3_python.dnp3station.outstation import MyOutStation - -from time import sleep -import datetime +from dnp3_python.dnp3station.station_utils import command_callback stdout_stream = logging.StreamHandler(sys.stdout) -stdout_stream.setFormatter(logging.Formatter('%(asctime)s\t%(name)s\t%(levelname)s\t%(message)s')) +stdout_stream.setFormatter( + logging.Formatter("%(asctime)s\t%(name)s\t%(levelname)s\t%(message)s") +) _log = logging.getLogger(__name__) _log = logging.getLogger("control_workflow_demo") @@ -25,23 +27,27 @@ def main(): # channel_log_level=opendnp3.levels.ALL_COMMS, # master_log_level=opendnp3.levels.ALL_COMMS # soe_handler=SOEHandler(soehandler_log_level=logging.DEBUG) - ) + ) master_application.start() - _log.debug('Initialization complete. Master Station in command loop.') + _log.debug("Initialization complete. Master Station in command loop.") # cmd_interface_outstation = OutstationCmd() outstation_application = MyOutStation( # channel_log_level=opendnp3.levels.ALL_COMMS, # outstation_log_level=opendnp3.levels.ALL_COMMS ) outstation_application.start() - _log.debug('Initialization complete. OutStation in command loop.') + _log.debug("Initialization complete. OutStation in command loop.") count = 0 while count < 10: sleep(2) # Note: hard-coded, master station query every 1 sec. count += 1 - print(datetime.datetime.now(), "============count ", count, ) + print( + datetime.datetime.now(), + "============count ", + count, + ) # plan: there are 3 AnalogInput Points, # outstation will randomly pick from @@ -58,34 +64,47 @@ def main(): for i, pts in enumerate([point_values_0, point_values_1, point_values_2]): p_val = random.choice(pts) print(f"====== Master send command index {i} with {p_val}") - master_application.send_direct_operate_command(opendnp3.AnalogOutputDouble64(float(p_val)), - i, - command_callback) + master_application.send_direct_operate_command( + opendnp3.AnalogOutputDouble64(float(p_val)), i, command_callback + ) # update binaryInput value as well master_application.send_direct_operate_command( opendnp3.ControlRelayOutputBlock(opendnp3.ControlCode.LATCH_ON), 0, - command_callback) + command_callback, + ) master_application.send_direct_operate_command( opendnp3.ControlRelayOutputBlock(opendnp3.ControlCode.LATCH_ON), 2, - command_callback) - p_val = random.choice([opendnp3.ControlCode.LATCH_ON, opendnp3.ControlCode.LATCH_OFF]) + command_callback, + ) + p_val = random.choice( + [opendnp3.ControlCode.LATCH_ON, opendnp3.ControlCode.LATCH_OFF] + ) master_application.send_direct_operate_command( - opendnp3.ControlRelayOutputBlock(p_val), - 1, - command_callback) + opendnp3.ControlRelayOutputBlock(p_val), 1, command_callback + ) # demo send_direct_operate_command_set command_set = opendnp3.CommandSet() - command_set.Add([ - opendnp3.WithIndex(opendnp3.ControlRelayOutputBlock(opendnp3.ControlCode.LATCH_ON), 3), - opendnp3.WithIndex(opendnp3.ControlRelayOutputBlock(opendnp3.ControlCode.LATCH_OFF), 4), - opendnp3.WithIndex(opendnp3.ControlRelayOutputBlock(opendnp3.ControlCode.LATCH_ON), 5) - ]) - master_application.send_direct_operate_command_set(command_set, command_callback) + command_set.Add( + [ + opendnp3.WithIndex( + opendnp3.ControlRelayOutputBlock(opendnp3.ControlCode.LATCH_ON), 3 + ), + opendnp3.WithIndex( + opendnp3.ControlRelayOutputBlock(opendnp3.ControlCode.LATCH_OFF), 4 + ), + opendnp3.WithIndex( + opendnp3.ControlRelayOutputBlock(opendnp3.ControlCode.LATCH_ON), 5 + ), + ] + ) + master_application.send_direct_operate_command_set( + command_set, command_callback + ) # demo using send_direct_point_command (with consistent parameter than send_direct_operate_command) # operate on 4,5,6 (or # operate on 8,9,10) @@ -100,35 +119,50 @@ def main(): # i, # command_callback) # for group40var4 AnalogOutputDouble - master_application.send_direct_point_command(group=40, variation=4, index=i + 4, - val_to_set=p_val + 0.0025) # operate on 4,5,6 + master_application.send_direct_point_command( + group=40, variation=4, index=i + 4, val_to_set=p_val + 0.0025 + ) # operate on 4,5,6 # for group40var2 AnalogOutputInt32 - master_application.send_direct_point_command(group=40, variation=2, index=i + 4 + 4, - val_to_set=int(p_val)) # operate on 8,9,10 + master_application.send_direct_point_command( + group=40, variation=2, index=i + 4 + 4, val_to_set=int(p_val) + ) # operate on 8,9,10 # for group10var2 BinaryOutput - master_application.send_direct_point_command(group=10, variation=2, index=i + 4 + 4, - val_to_set=True) # operate on 8,9,10 + master_application.send_direct_point_command( + group=10, variation=2, index=i + 4 + 4, val_to_set=True + ) # operate on 8,9,10 # master station retrieve value # use case 6: retrieve point values specified by single GroupVariationIDs and index. # demo float AnalogOutput, result = master_application.get_db_by_group_variation(group=40, variation=4) - print(f"===important log: case6 get_db_by_group_variation ==== {count}", "\n", datetime.datetime.now(), - result) + print( + f"===important log: case6 get_db_by_group_variation ==== {count}", + "\n", + datetime.datetime.now(), + result, + ) result = master_application.get_db_by_group_variation(group=40, variation=2) - print(f"===important log: case6b get_db_by_group_variation ==== {count}", "\n", datetime.datetime.now(), - result) + print( + f"===important log: case6b get_db_by_group_variation ==== {count}", + "\n", + datetime.datetime.now(), + result, + ) result = master_application.get_db_by_group_variation(group=10, variation=2) - print(f"===important log: case6c get_db_by_group_variation ==== {count}", "\n", datetime.datetime.now(), - result) - - _log.debug('Exiting.') + print( + f"===important log: case6c get_db_by_group_variation ==== {count}", + "\n", + datetime.datetime.now(), + result, + ) + + _log.debug("Exiting.") master_application.shutdown() outstation_application.shutdown() -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/src/dnp3demo/data_retrieval_demo.py b/src/dnp3demo/data_retrieval_demo.py index e4132a4..c895da5 100644 --- a/src/dnp3demo/data_retrieval_demo.py +++ b/src/dnp3demo/data_retrieval_demo.py @@ -1,17 +1,18 @@ +import datetime import logging import random import sys +from time import sleep from pydnp3 import opendnp3 from dnp3_python.dnp3station.master import MyMaster from dnp3_python.dnp3station.outstation import MyOutStation -import datetime -from time import sleep - stdout_stream = logging.StreamHandler(sys.stdout) -stdout_stream.setFormatter(logging.Formatter('%(asctime)s\t%(name)s\t%(levelname)s\t%(message)s')) +stdout_stream.setFormatter( + logging.Formatter("%(asctime)s\t%(name)s\t%(levelname)s\t%(message)s") +) _log = logging.getLogger(__name__) # _log = logging.getLogger("data_retrieval_demo") @@ -23,19 +24,23 @@ def main(): # init an outstation using default configuration, e.g., port=20000. Then start. outstation_application = MyOutStation() outstation_application.start() - _log.debug('Initialization complete. OutStation in command loop.') + _log.debug("Initialization complete. OutStation in command loop.") # init a master using default configuration, e.g., port=20000. Then start. master_application = MyMaster() master_application.start() - _log.debug('Initialization complete. Master Station in command loop.') + _log.debug("Initialization complete. Master Station in command loop.") count = 0 while count < 10: sleep(2) # Note: hard-coded, master station query every 1 sec. count += 1 - print(datetime.datetime.now(), "============count ", count, ) + print( + datetime.datetime.now(), + "============count ", + count, + ) # plan: there are 3 AnalogInput Points, # outstation will randomly pick from @@ -54,7 +59,9 @@ def main(): for i, pts in enumerate([point_values_0, point_values_1, point_values_2]): p_val = random.choice(pts) print(f"====== Outstation update index {i} with {p_val}") - outstation_application.apply_update(opendnp3.Analog(value=float(p_val)), i) + outstation_application.apply_update( + opendnp3.Analog(value=float(p_val)), i + ) if count % 2 == 1: point_values_0 = [True, False] @@ -80,17 +87,26 @@ def main(): # opendnp3.GroupVariationID(1, 2)]) # result = master_application.retrieve_val_by_gv(gv_id=opendnp3.GroupVariationID(30, 6),) result = master_application.get_db_by_group_variation(group=30, variation=6) - print(f"===important log: case6 get_db_by_group_variation(group=30, variation=6) ==== {count}", "\n", - datetime.datetime.now(), - result) + print( + f"===important log: case6 get_db_by_group_variation(group=30, variation=6) ==== {count}", + "\n", + datetime.datetime.now(), + result, + ) result = master_application.get_db_by_group_variation(group=1, variation=2) - print(f"===important log: case6b get_db_by_group_variation(group=1, variation=2) ==== {count}", "\n", - datetime.datetime.now(), - result) + print( + f"===important log: case6b get_db_by_group_variation(group=1, variation=2) ==== {count}", + "\n", + datetime.datetime.now(), + result, + ) result = master_application.get_db_by_group_variation(group=30, variation=1) - print(f"===important log: case6c get_db_by_group_variation(group=30, variation=1) ==== {count}", "\n", - datetime.datetime.now(), - result) + print( + f"===important log: case6c get_db_by_group_variation(group=30, variation=1) ==== {count}", + "\n", + datetime.datetime.now(), + result, + ) # # use case 7: retrieve point values specified by single GroupVariationIDs and index. # # demo float AnalogInput, @@ -106,10 +122,10 @@ def main(): # datetime.datetime.now(), # result) - _log.debug('Exiting.') + _log.debug("Exiting.") master_application.shutdown() outstation_application.shutdown() -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/src/dnp3demo/multi_stations_demo.py b/src/dnp3demo/multi_stations_demo.py index 0e35cd1..2bcd2dd 100644 --- a/src/dnp3demo/multi_stations_demo.py +++ b/src/dnp3demo/multi_stations_demo.py @@ -1,17 +1,18 @@ +import datetime import logging import random import sys +from time import sleep +from dnp3station.master import MyMaster from pydnp3 import opendnp3 -from dnp3_python.dnp3station.master import MyMaster from dnp3_python.dnp3station.outstation import MyOutStation -import datetime -from time import sleep - stdout_stream = logging.StreamHandler(sys.stdout) -stdout_stream.setFormatter(logging.Formatter('%(asctime)s\t%(name)s\t%(levelname)s\t%(message)s')) +stdout_stream.setFormatter( + logging.Formatter("%(asctime)s\t%(name)s\t%(levelname)s\t%(message)s") +) _log = logging.getLogger(__name__) # _log = logging.getLogger("data_retrieval_demo") @@ -20,29 +21,32 @@ def main(): - outstation_application = MyOutStation() outstation_application.start() - _log.debug('Initialization complete. OutStation in command loop.') + _log.debug("Initialization complete. OutStation in command loop.") master_application = MyMaster() master_application.start() - _log.debug('Initialization complete. Master Station in command loop.') + _log.debug("Initialization complete. Master Station in command loop.") outstation_application_20001 = MyOutStation(port=20001) outstation_application_20001.start() - _log.debug('Initialization complete. OutStation p20001 in command loop.') + _log.debug("Initialization complete. OutStation p20001 in command loop.") master_application_20001 = MyMaster(port=20001) master_application_20001.start() - _log.debug('Initialization complete. Master p20001 Station in command loop.') + _log.debug("Initialization complete. Master p20001 Station in command loop.") count = 0 while count < 10: sleep(1) # Note: hard-coded, master station query every 1 sec. count += 1 - print(datetime.datetime.now(), "============count ", count, ) + print( + datetime.datetime.now(), + "============count ", + count, + ) # plan: there are 3 AnalogInput Points, # outstation will randomly pick from @@ -61,9 +65,14 @@ def main(): for i, pts in enumerate([point_values_0, point_values_1, point_values_2]): p_val = random.choice(pts) print(f"====== Outstation update index {i} with {p_val}") - outstation_application.apply_update(opendnp3.Analog(value=float(p_val), - flags=opendnp3.Flags(24), - time=opendnp3.DNPTime(3094)), i) + outstation_application.apply_update( + opendnp3.Analog( + value=float(p_val), + flags=opendnp3.Flags(24), + time=opendnp3.DNPTime(3094), + ), + i, + ) # outstation_application.apply_update(opendnp3.AnalogIn(value=float(p_val), # flags=opendnp3.Flags(24), # time=opendnp3.DNPTime(3094)), i) @@ -111,8 +120,9 @@ def main(): # # use case 5: (for debugging purposes) retrieve point values specified by a list of GroupVariationIDs. # demo float AnalogInput, BinaryInput, - result = master_application._retrieve_all_obj_by_gvids_w_ts(gv_ids=[opendnp3.GroupVariationID(30, 6), - opendnp3.GroupVariationID(1, 2)]) + result = master_application._retrieve_all_obj_by_gvids_w_ts( + gv_ids=[opendnp3.GroupVariationID(30, 6), opendnp3.GroupVariationID(1, 2)] + ) # print(f"===important log: case5 _retrieve_all_obj_by_gvids_w_ts default ==== {count}", datetime.datetime.now(), # result) # @@ -146,10 +156,10 @@ def main(): # print(f"===important log: case7c get_db_by_group_variation_index ==== {count}", datetime.datetime.now(), # result) - _log.debug('Exiting.') + _log.debug("Exiting.") master_application.shutdown() outstation_application.shutdown() -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/src/dnp3demo/run_master.py b/src/dnp3demo/run_master.py index 3cbbdf1..2b18fad 100644 --- a/src/dnp3demo/run_master.py +++ b/src/dnp3demo/run_master.py @@ -1,12 +1,17 @@ +import argparse +import csv import logging import sys -import argparse -from dnp3_python.dnp3station.master import MyMaster +from datetime import datetime from time import sleep +from dnp3_python.dnp3station.master import MasterApplication +from dnp3_python.dnp3station.station_utils import to_flat_db stdout_stream = logging.StreamHandler(sys.stdout) -stdout_stream.setFormatter(logging.Formatter('%(asctime)s\t%(name)s\t%(levelname)s\t%(message)s')) +stdout_stream.setFormatter( + logging.Formatter("%(asctime)s\t%(name)s\t%(levelname)s\t%(message)s") +) _log = logging.getLogger(f"{__file__}, {__name__}") _log.addHandler(stdout_stream) @@ -23,23 +28,47 @@ def input_prompt(display_str=None, prefix="", menu_indicator="") -> str: def setup_args(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: - # Adding optional argument - parser.add_argument("--master-ip=", action="store", default="0.0.0.0", type=str, - metavar="", - help="master ip, default: 0.0.0.0") - parser.add_argument("--outstation-ip=", action="store", default="127.0.0.1", type=str, - metavar="", - help="outstation ip, default: 127.0.0.1") - parser.add_argument("--port=", action="store", default=20000, type=int, - metavar="", - help="port, default: 20000") - parser.add_argument("--master-id=", action="store", default=2, type=int, - metavar="", - help="master id, default: 2") - parser.add_argument("--outstation-id=", action="store", default=1, type=int, - metavar="", - help="master id, default: 1") + parser.add_argument( + "--master-ip", + action="store", + default="0.0.0.0", + type=str, + metavar="", + help="master ip, default: 0.0.0.0", + ) + parser.add_argument( + "--outstation-ip", + action="store", + default="127.0.0.1", + type=str, + metavar="", + help="outstation ip, default: 127.0.0.1", + ) + parser.add_argument( + "--port", + action="store", + default=20000, + type=int, + metavar="", + help="port, default: 20000", + ) + parser.add_argument( + "--master-id", + action="store", + default=2, + type=int, + metavar="", + help="master id, default: 2", + ) + parser.add_argument( + "--outstation-id", + action="store", + default=1, + type=int, + metavar="", + help="master id, default: 1", + ) return parser @@ -51,13 +80,14 @@ def print_menu(): - set binary-output point value (for remote control)
- display/polling (outstation) database - display configuration + - take a screenshot of the current database point values - quit the program =================================================================\ """ print(welcome_str) -def main(parser=None, *args, **kwargs): +def main(parser=None, *args, **kwargs): if parser is None: # Initialize parser parser = argparse.ArgumentParser( @@ -68,26 +98,26 @@ def main(parser=None, *args, **kwargs): parser = setup_args(parser) # Read arguments from command line - args = parser.parse_args() + parse_args = parser.parse_args() # dict to store args.Namespace - d_args = vars(args) - print(__name__, d_args) + # d_args = vars(parse_args) + # print(__name__, d_args) + print(f"{parse_args=}") # print(args.__dir__()) - master_application = MyMaster( - master_ip=d_args.get("master_ip="), - outstation_ip=d_args.get("outstation_ip="), - port=d_args.get("port="), - master_id=d_args.get("master_id="), - outstation_id=d_args.get("outstation_id="), - + master_application = MasterApplication( + master_ip=parse_args.master_ip, + outstation_ip=parse_args.outstation_ip, + port=parse_args.port, + master_id=parse_args.master_id, + outstation_id=parse_args.outstation_id, # channel_log_level=opendnp3.levels.ALL_COMMS, # master_log_level=opendnp3.levels.ALL_COMMS # soe_handler=SOEHandler(soehandler_log_level=logging.DEBUG) ) _log.info("Connection Config", master_application.get_config()) master_application.start() - _log.debug('Initialization complete. Master Station in command loop.') + _log.debug("Initialization complete. Master Station in command loop.") sleep(3) # Note: if without sleep(2) there will be a glitch when first send_select_and_operate_command @@ -103,14 +133,20 @@ def main(parser=None, *args, **kwargs): print_menu() print() if master_application.is_connected: - option = input_prompt(menu_indicator="Main Menu") # Note: one of ["ai", "ao", "bi", "bo", "dd", "dc"] + option = input_prompt( + menu_indicator="Main Menu" + ) # Note: one of ["ai", "ao", "bi", "bo", "dd", "dc"] else: - option = input_prompt(prefix="!!!!!!!!! WARNING: The Master is NOT connected !!!!!!!!!\n", - menu_indicator="Main Menu") + option = input_prompt( + prefix="!!!!!!!!! WARNING: The Master is NOT connected !!!!!!!!!\n", + menu_indicator="Main Menu", + ) while True: if option == "ao": print("You chose - set analog-output point value") - print("Type in and . Separate with space, then hit ENTER. e.g., `1.4321, 1`.") + print( + "Type in and . Separate with space, then hit ENTER. e.g., `1.4321, 1`." + ) print("Type 'q', 'quit', 'exit' to main menu.") input_str = input_prompt() if input_str in ["q", "quit", "exit"]: @@ -118,8 +154,12 @@ def main(parser=None, *args, **kwargs): try: p_val = float(input_str.split(" ")[0]) index = int(input_str.split(" ")[1]) - master_application.send_direct_point_command(group=40, variation=4, index=index, val_to_set=p_val) - result: dict = master_application.get_db_by_group_variation(group=40, variation=4) + master_application.send_direct_point_command( + group=40, variation=4, index=index, val_to_set=p_val + ) + result: dict = master_application.get_db_by_group_variation( + group=40, variation=4 + ) print("SUCCESS", {"AnalogOutputStatus": list(result.values())[0]}) sleep(2) except Exception as e: @@ -127,7 +167,9 @@ def main(parser=None, *args, **kwargs): print(e) elif option == "bo": print("You chose - set binary-output point value") - print("Type in <[1/0]> and . Separate with space, then hit ENTER. e.g., `1 0`.") + print( + "Type in <[1/0]> and . Separate with space, then hit ENTER. e.g., `1 0`." + ) input_str = input_prompt() if input_str in ["q", "quit", "exit"]: break @@ -138,8 +180,12 @@ def main(parser=None, *args, **kwargs): else: p_val = True if p_val_input == "1" else False index = int(input_str.split(" ")[1]) - master_application.send_direct_point_command(group=10, variation=2, index=index, val_to_set=p_val) - result = master_application.get_db_by_group_variation(group=10, variation=2) + master_application.send_direct_point_command( + group=10, variation=2, index=index, val_to_set=p_val + ) + result = master_application.get_db_by_group_variation( + group=10, variation=2 + ) print("SUCCESS", {"BinaryOutputStatus": list(result.values())[0]}) sleep(2) except Exception as e: @@ -149,7 +195,7 @@ def main(parser=None, *args, **kwargs): print("You chose < dd > - display database") master_application.send_scan_all_request() sleep(1) - db_print = master_application.soe_handler.db + db_print = master_application.db print(db_print) sleep(2) break @@ -158,9 +204,19 @@ def main(parser=None, *args, **kwargs): print(master_application.get_config()) sleep(3) break + elif option == "sc": + print( + "You chose < sc > - take a screenshot of the current database point values" + ) + db_print = master_application.db + p_save = f'/tmp/dnp3_db_screenshot_{datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}.csv' + db_print.to_csv(p_save) + print(f"The database screenshot has been saved to {p_save}.") + sleep(3) + break elif option == "q": print("Stopping Master") - _log.debug('Exiting.') + _log.debug("Exiting.") master_application.shutdown() sys.exit(0) else: @@ -173,5 +229,5 @@ def main(parser=None, *args, **kwargs): # outstation_application.shutdown() -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/src/dnp3demo/run_outstation.py b/src/dnp3demo/run_outstation.py index bd5f4d2..2537bb4 100644 --- a/src/dnp3demo/run_outstation.py +++ b/src/dnp3demo/run_outstation.py @@ -1,12 +1,17 @@ import argparse +import csv import logging import random import sys +from datetime import datetime from time import sleep -from dnp3_python.dnp3station.outstation import MyOutStation from pydnp3 import opendnp3 +# from tabulate import tabulate +from dnp3_python.dnp3station.outstation import OutStationApplication +from dnp3_python.dnp3station.station_utils import Dnp3Database + stdout_stream = logging.StreamHandler(sys.stdout) stdout_stream.setFormatter( logging.Formatter("%(asctime)s\t%(name)s\t%(levelname)s\t%(message)s") @@ -31,7 +36,7 @@ def setup_args(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: # parser.add_argument("-mip", "--master-ip", action="store", default="0.0.0.0", type=str, # metavar="") parser.add_argument( - "--outstation-ip=", + "--outstation-ip", action="store", default="0.0.0.0", type=str, @@ -39,7 +44,7 @@ def setup_args(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: help="outstation ip, default: 0.0.0.0", ) parser.add_argument( - "--port=", + "--port", action="store", default=20000, type=int, @@ -47,7 +52,7 @@ def setup_args(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: help="port, default: 20000", ) parser.add_argument( - "--master-id=", + "--master-id", action="store", default=2, type=int, @@ -55,7 +60,7 @@ def setup_args(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: help="master id, default: 2", ) parser.add_argument( - "--outstation-id=", + "--outstation-id", action="store", default=1, type=int, @@ -63,7 +68,42 @@ def setup_args(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: help="master id, default: 1", ) parser.add_argument( - "--init-random", action="store_true", help="if appears, init with random values" + "--init-random", + action="store_true", + default=False, + help="if appears, init with random values, default: False", + ) + parser.add_argument( + "--n-ai", + action="store", + default=5, + type=int, + metavar="", + help="number of AnalogInput, default: 5", + ) + parser.add_argument( + "--n-ao", + action="store", + default=5, + type=int, + metavar="", + help="number of AnalogOutput, default: 5", + ) + parser.add_argument( + "--n-bi", + action="store", + default=5, + type=int, + metavar="", + help="number of BinaryInput, default: 5", + ) + parser.add_argument( + "--n-bo", + action="store", + default=5, + type=int, + metavar="", + help="number of BinaryOutput, default: 5", ) return parser @@ -78,6 +118,7 @@ def print_menu(): - update binary-output point value (for local control)
- display database - display configuration + - take a screenshot of the current database point values - quit the program =================================================================""" print(welcome_str) @@ -94,46 +135,39 @@ def main(parser=None, *args, **kwargs): parser = setup_args(parser) # Read arguments from command line - args = parser.parse_args() + parse_args: argparse.Namespace = parser.parse_args() # dict to store args.Namespace - d_args = vars(args) - print(__name__, d_args) - - from pydnp3 import opendnp3 - - db_sizes = opendnp3.DatabaseSizes.AllTypes(count=5) - # db_sizes = opendnp3.DatabaseSizes(numBinary=1, - # numDoubleBinary=2, - - # numAnalog=3, - # numCounter=4, - # numFrozenCounter=5, - # numBinaryOutputStatus=6, - # numAnalogOutputStatus=7, - - # numTimeAndInterval=8) - # db_sizes = opendnp3.DatabaseSizes(1, - # 2, - - # 3, - # 4, - # 5, - # 6, - # 7, - - # 8) - - outstation_application = MyOutStation( + # d_args = vars(parse_args) + # print(__name__, f"{d_args=}") + print(f"{parse_args=}") + + # db_sizes = opendnp3.DatabaseSizes.AllTypes(count=5) + # db_sizes = opendnp3.DatabaseSizes( + # numBinary=1, + # numBinaryOutputStatus=6, + # numDoubleBinary=2, + # numAnalog=3, + # numAnalogOutputStatus=7, + # numCounter=4, + # numFrozenCounter=5, + # numTimeAndInterval=8, + # ) + + outstation_application = OutStationApplication( # masterstation_ip_str=args.master_ip, - outstation_ip=d_args.get("outstation_ip="), - port=d_args.get("port="), - master_id=d_args.get("master_id="), - outstation_id=d_args.get("outstation_id="), - db_sizes=db_sizes, + outstation_ip=parse_args.outstation_ip, + port=parse_args.port, + master_id=parse_args.master_id, + outstation_id=parse_args.outstation_id, + # db_sizes=db_sizes, # channel_log_level=opendnp3.levels.ALL_COMMS, # master_log_level=opendnp3.levels.ALL_COMMS # soe_handler=SOEHandler(soehandler_log_level=logging.DEBUG) + numAnalog=parse_args.n_ai, + numAnalogOutputStatus=parse_args.n_ao, + numBinary=parse_args.n_bi, + numBinaryOutputStatus=parse_args.n_bo, ) _log.info("Connection Config", outstation_application.get_config()) outstation_application.start() @@ -146,40 +180,14 @@ def main(parser=None, *args, **kwargs): # Additional init for demo purposes # if d_args.get("init_random")==True, init with random values - if d_args.get("init_random"): - db_sizes = outstation_application.db_sizes - for n in range(db_sizes.numBinary): - val = random.choice([True, False]) - outstation_application.apply_update(opendnp3.Binary(val), n) - for n in range(db_sizes.numBinaryOutputStatus): - val = random.choice([True, False]) - outstation_application.apply_update(opendnp3.BinaryOutputStatus(val), n) - for n in range(db_sizes.numAnalog): - val = random.random() * pow(10, n) - outstation_application.apply_update(opendnp3.Analog(val), n) - for n in range(db_sizes.numAnalogOutputStatus): - val = random.random() * pow(10, n) - outstation_application.apply_update(opendnp3.AnalogOutputStatus(val), n) + if parse_args.init_random: + outstation_application.update_db_with_random() count = 0 while count < 1000: # sleep(1) # Note: hard-coded, master station query every 1 sec. count += 1 - # print(f"=========== Count {count}") - - # if outstation_application.is_connected: - # # print("Communication Config", master_application.get_config()) - # print_menu() - # else: - # # Note: even not connected, still allow the CLI enter the main menu. - # print("Connection error.") - # print("Connection Config", outstation_application.get_config()) - # # print("Start retry...") - # # sleep(2) - # # continue - # print_menu() - # # print("!!!!!!!!! WARNING: The outstation is NOT connected !!!!!!!!!") print_menu() print() @@ -211,9 +219,7 @@ def main(parser=None, *args, **kwargs): outstation_application.apply_update( opendnp3.Analog(value=p_val), index ) - result = { - "Analog": outstation_application.db_handler.db.get("Analog") - } + result = {"Analog": outstation_application.db.Analog} print(result) sleep(2) except Exception as e: @@ -237,9 +243,7 @@ def main(parser=None, *args, **kwargs): opendnp3.AnalogOutputStatus(value=p_val), index ) result = { - "AnalogOutputStatus": outstation_application.db_handler.db.get( - "AnalogOutputStatus" - ) + "AnalogOutputStatus": outstation_application.db.AnalogOutputStatus } print(result) sleep(2) @@ -266,9 +270,7 @@ def main(parser=None, *args, **kwargs): outstation_application.apply_update( opendnp3.Binary(value=p_val), index ) - result = { - "Binary": outstation_application.db_handler.db.get("Binary") - } + result = {"Binary": outstation_application.db.Binary} print(result) sleep(2) except Exception as e: @@ -295,9 +297,7 @@ def main(parser=None, *args, **kwargs): opendnp3.BinaryOutputStatus(value=p_val), index ) result = { - "BinaryOutputStatus": outstation_application.db_handler.db.get( - "BinaryOutputStatus" - ) + "BinaryOutputStatus": outstation_application.db.BinaryOutputStatus } print(result) sleep(2) @@ -306,19 +306,27 @@ def main(parser=None, *args, **kwargs): print(e) elif option == "dd": print("You chose < dd > - display database") - db_print = outstation_application.db_handler.db - # print(db_print) - from dnp3_python.dnp3station.station_utils import to_flat_db - from tabulate import tabulate + db_print = outstation_application.db + print(db_print) - print(tabulate(to_flat_db(db_print), headers="keys", tablefmt="grid")) sleep(2) break elif option == "dc": - print("You chose < dc> - display configuration") + print("You chose < dc > - display configuration") print(outstation_application.get_config()) sleep(3) break + elif option == "sc": + print( + "You chose < sc > - take a screenshot of the current database point values" + ) + p_save = f'/tmp/dnp3_db_screenshot_{datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}.csv' + outstation_application.db.to_csv(p_save) + + # df_save.to_csv(p_save) + print(f"The database screenshot has been saved to {p_save}.") + sleep(3) + break elif option == "q": print("Stopping Outstation") _log.debug("Exiting.") diff --git a/tests/test_dnp3_python/test_fixtures.py b/tests/test_dnp3_python/test_fixtures.py new file mode 100644 index 0000000..3bc72cd --- /dev/null +++ b/tests/test_dnp3_python/test_fixtures.py @@ -0,0 +1,88 @@ +from time import sleep +from typing import Generator + +import pytest +from utils import get_free_port + +from dnp3_python.dnp3station.master import MasterApplication, MyMaster +from dnp3_python.dnp3station.outstation import MyOutStation, OutStationApplication + +PORT = get_free_port() + + +@pytest.fixture(scope="module") +def master_new() -> Generator[MyMaster, None, None]: + master = MyMaster( + master_ip="0.0.0.0", + outstation_ip="127.0.0.1", + port=PORT, + master_id=2, + outstation_id=1, + ) + master.start() + yield master + master.shutdown() + + +@pytest.fixture(scope="module") +def outstation_new() -> Generator[MyOutStation, None, None]: + outstation = MyOutStation( + outstation_ip="0.0.0.0", + port=PORT, + master_id=2, + outstation_id=1, + concurrency_hint=1, + ) + outstation.start() + yield outstation + outstation.shutdown() + + +def test_station_new_initialization(master_new, outstation_new): + assert master_new is not None + assert outstation_new is not None + + for i in range(10): + print(f"{i=}, {outstation_new.is_connected=}, {master_new.is_connected=}") + if outstation_new.is_connected and master_new.is_connected: + break + sleep(1) + + +# @pytest.fixture(scope="function") +# def master_app() -> Generator[MasterApplication, None, None]: +# master = MasterApplication( +# master_ip="0.0.0.0", +# outstation_ip="127.0.0.1", +# port=PORT, +# master_id=2, +# outstation_id=1, +# ) +# master.start() +# yield master +# master.shutdown() + + +# @pytest.fixture(scope="function") +# def outstation_app() -> Generator[OutStationApplication, None, None]: +# outstation = OutStationApplication( +# outstation_ip="0.0.0.0", +# port=PORT, +# master_id=2, +# outstation_id=1, +# concurrency_hint=1, +# ) +# outstation.start() +# yield outstation +# outstation.shutdown() + + +# def test_station_application_new_initialization(master_app, outstation_app): +# assert master_app is not None +# assert outstation_app is not None + +# for i in range(10): +# print(f"{i=}, {outstation_app.is_connected=}, {master_app.is_connected=}") +# if outstation_app.is_connected and master_app.is_connected: +# break +# sleep(1) diff --git a/tests/test_dnp3_python/test_master.py b/tests/test_dnp3_python/test_master.py new file mode 100644 index 0000000..752b7d3 --- /dev/null +++ b/tests/test_dnp3_python/test_master.py @@ -0,0 +1,75 @@ +from time import sleep +from typing import Generator + +import pytest +from pydnp3 import opendnp3 +from utils import get_free_port + +from dnp3_python.dnp3station.master import MyMaster +from dnp3_python.dnp3station.outstation import MyOutStation + +PORT = get_free_port() + + +@pytest.fixture(scope="function") +def master_new() -> Generator[MyMaster, None, None]: + master = MyMaster( + master_ip="0.0.0.0", + outstation_ip="127.0.0.1", + port=PORT, + master_id=2, + outstation_id=1, + ) + master.start() + yield master + master.shutdown() + + +@pytest.fixture(scope="module") +def outstation_new() -> Generator[MyOutStation, None, None]: + outstation = MyOutStation( + outstation_ip="0.0.0.0", + port=PORT, + master_id=2, + outstation_id=1, + concurrency_hint=1, + ) + outstation.start() + + yield outstation + outstation.shutdown() + + +def test_send_scan_all_request(master_new, outstation_new): + value = 0.1234 + index = 0 + outstation_new.apply_update(opendnp3.Analog(value=value), index) + + for i in range(10): + master_new.send_scan_all_request() + sleep(1) + result = master_new.soe_handler.db + print(f"{i=}, {result=}") + if result["Analog"][index] is not None: + break + sleep(1) + + +def test_send_direct_point_command(master_new, outstation_new): + group = 40 + variation = 4 + index = 1 + value_to_set = 12.34 + + for i in range(10): + master_new.send_direct_point_command( + group=group, variation=variation, index=index, val_to_set=value_to_set + ) + sleep(1) + master_new.get_db_by_group_variation(group=group, variation=variation) + sleep(1) + result = master_new.soe_handler.db["AnalogOutputStatus"] + print(f"{i=}, {result=}") + if result[index] is not None: + break + sleep(1) diff --git a/tests/test_dnp3_python/test_master_app.py b/tests/test_dnp3_python/test_master_app.py new file mode 100644 index 0000000..42ed3e3 --- /dev/null +++ b/tests/test_dnp3_python/test_master_app.py @@ -0,0 +1,121 @@ +import random +from time import sleep +from typing import Generator + +import pytest +from pydnp3 import opendnp3 +from utils import get_free_port + +from dnp3_python.dnp3station.master import MasterApplication +from dnp3_python.dnp3station.outstation import OutStationApplication + +PORT = get_free_port() +NUMBER_OF_DB_POINTS = 10 + + +@pytest.fixture(scope="function") +def master_new() -> Generator[MasterApplication, None, None]: + master = MasterApplication( + master_ip="0.0.0.0", + outstation_ip="127.0.0.1", + port=PORT, + master_id=2, + outstation_id=1, + ) + master.start() + yield master + master.shutdown() + + +@pytest.fixture(scope="module") +def outstation_new() -> Generator[OutStationApplication, None, None]: + outstation = OutStationApplication( + outstation_ip="0.0.0.0", + port=PORT, + master_id=2, + outstation_id=1, + concurrency_hint=1, + numAnalog=NUMBER_OF_DB_POINTS, + numAnalogOutputStatus=NUMBER_OF_DB_POINTS, + numBinary=NUMBER_OF_DB_POINTS, + numBinaryOutputStatus=NUMBER_OF_DB_POINTS, + ) + outstation.start() + # outstation.update_db_with_random() + + yield outstation + outstation.shutdown() + + +def test_send_scan_all_request(master_new, outstation_new): + value = random.random() + index = random.randint(0, NUMBER_OF_DB_POINTS - 1) + outstation_new.apply_update(opendnp3.Analog(value=value), index) + print(f"=== {index=}, {value=}") + + for i in range(10): + master_new.send_scan_all_request() + sleep(1) + result = master_new.my_master.soe_handler.db + print(f"{i=}, {result=}") + if result["Analog"][index] is not None: + break + sleep(1) + + +def test_send_direct_point_command(master_new, outstation_new): + group = 40 + variation = 4 + value = random.random() + index = random.randint(0, NUMBER_OF_DB_POINTS - 1) + print(f"=== {index=}, {value=}") + + for i in range(10): + master_new.send_direct_point_command( + group=group, variation=variation, index=index, val_to_set=value + ) + sleep(1) + master_new.get_db_by_group_variation(group=group, variation=variation) + sleep(1) + result = master_new.my_master.soe_handler.db["AnalogOutputStatus"] + print(f"{i=}, {result=}") + if result[index] == value: + break + sleep(1) + assert result[index] == value + + +def test_send_direct_analog_output_point_command(master_new, outstation_new): + value = random.random() + index = random.randint(0, NUMBER_OF_DB_POINTS - 1) + print(f"=== {index=}, {value=}") + + for i in range(10): + master_new.send_direct_analog_output_point_command(index, value) + sleep(1) + master_new.send_scan_all_request() + sleep(1) + result = master_new.db.AnalogOutputStatus + print(f"{i=}, {result=}") + if result[index] == value: + break + sleep(1) + assert result[index] == value + + +def test_send_direct_binary_output_point_command(master_new, outstation_new): + value = random.choice([True, False]) + index = random.randint(0, NUMBER_OF_DB_POINTS - 1) + print(f"=== {index=}, {value=}") + + for i in range(10): + master_new.send_direct_binary_output_point_command(index, value) + sleep(1) + master_new.send_scan_all_request() + sleep(1) + result = master_new.db.BinaryOutputStatus + print(f"{i=}, {result=}") + if result[index] == value: + break + sleep(1) + assert result[index] == value diff --git a/tests/test_dnp3_python/test_outstation.py b/tests/test_dnp3_python/test_outstation.py new file mode 100644 index 0000000..f8b7474 --- /dev/null +++ b/tests/test_dnp3_python/test_outstation.py @@ -0,0 +1,75 @@ +# test_master.py +from time import sleep +from typing import Generator + +import pytest +from pydnp3 import opendnp3 +from utils import get_free_port + +from dnp3_python.dnp3station.master import MyMaster +from dnp3_python.dnp3station.outstation import MyOutStation + +PORT = get_free_port() + + +@pytest.fixture(scope="module") +def master_new() -> Generator[MyMaster, None, None]: + # master = MyMasterNew() + master = MyMaster( + master_ip="0.0.0.0", + outstation_ip="127.0.0.1", + port=PORT, + master_id=2, + outstation_id=1, + ) + master.start() + yield master + master.shutdown() + + +@pytest.fixture(scope="function") +def outstation_new() -> Generator[MyOutStation, None, None]: + # outstation = MyOutStationNew() + outstation = MyOutStation( + outstation_ip="0.0.0.0", + port=PORT, + master_id=2, + outstation_id=1, + concurrency_hint=1, + ) + outstation.start() + + yield outstation + outstation.shutdown() + + +# Test function to verify that sending a direct point command works as expected +def test_apply_update(master_new, outstation_new): + # Setup the conditions for the test (e.g., known state of the outstation) + value = 0.1234 + index = 1 + outstation_new.apply_update(opendnp3.Analog(value=value), index) + + for i in range(10): + result = outstation_new.db_handler.db + print(f"{i=}, {result=}") + if result["Analog"][index] != 0: + break + sleep(1) + + +# Test function to verify that sending a direct point command works as expected +def test_send_scan_all_request_passive(master_new, outstation_new): + # Setup the conditions for the test (e.g., known state of the outstation) + value = 0.1234 + index = 0 + outstation_new.apply_update(opendnp3.Analog(value=value), index) + + for i in range(10): + master_new.send_scan_all_request() + sleep(1) + result_master = master_new.soe_handler.db + print(f"{i=}, {result_master=}") + if result_master["Analog"][index] is not None: + break + sleep(1) diff --git a/tests/test_dnp3_python/test_outstation_app.py b/tests/test_dnp3_python/test_outstation_app.py new file mode 100644 index 0000000..829e104 --- /dev/null +++ b/tests/test_dnp3_python/test_outstation_app.py @@ -0,0 +1,147 @@ +import random +from time import sleep +from typing import Generator + +import pytest +from pydnp3 import opendnp3 +from utils import get_free_port + +from dnp3_python.dnp3station.master import MasterApplication +from dnp3_python.dnp3station.outstation import OutStationApplication + +PORT = get_free_port() +NUMBER_OF_DB_POINTS = 10 + + +@pytest.fixture(scope="module") +def master_new() -> Generator[MasterApplication, None, None]: + master = MasterApplication( + master_ip="0.0.0.0", + outstation_ip="127.0.0.1", + port=PORT, + master_id=2, + outstation_id=1, + ) + master.start() + yield master + master.shutdown() + + +@pytest.fixture(scope="function") +def outstation_app() -> Generator[OutStationApplication, None, None]: + outstation = OutStationApplication( + outstation_ip="0.0.0.0", + port=PORT, + master_id=2, + outstation_id=1, + concurrency_hint=1, + numAnalog=NUMBER_OF_DB_POINTS, + numAnalogOutputStatus=NUMBER_OF_DB_POINTS, + numBinary=NUMBER_OF_DB_POINTS, + numBinaryOutputStatus=NUMBER_OF_DB_POINTS, + ) + outstation.start() + + yield outstation + outstation.shutdown() + + +def test_apply_update(master_new, outstation_app): + value = random.random() + index = random.randint(0, NUMBER_OF_DB_POINTS - 1) + outstation_app.apply_update(opendnp3.Analog(value=value), index) + print(f"=== {index=}, {value=}") + + for i in range(10): + result = outstation_app.db + print(f"{i=}, {result=}") + if result.Analog[index] != 0: + break + sleep(1) + + +def test_update_db_with_random(master_new, outstation_app): + outstation_app.update_db_with_random() + + for i in range(10): + result = outstation_app.db + print(f"{i=}, {result=}") + if result.Analog[0] != 0: + break + sleep(1) + + +def test_send_scan_all_request_passive(master_new, outstation_app): + value = random.random() + index = random.randint(0, NUMBER_OF_DB_POINTS - 1) + outstation_app.apply_update(opendnp3.Analog(value=value), index) + print(f"=== {index=}, {value=}") + + for i in range(10): + master_new.send_scan_all_request() + sleep(1) + result_master = master_new.db + print(f"{i=}, {result_master=}") + if result_master.Analog[index] is not None: + break + sleep(1) + + +def test_apply_update_analog_input(master_new, outstation_app): + value = random.random() + index = random.randint(0, NUMBER_OF_DB_POINTS - 1) + outstation_app.apply_update_analog_input(value, index) + print(f"=== {index=}, {value=}") + + for i in range(10): + result = outstation_app.db + print(f"{i=}, {result=}") + if result.Analog[index] == value: + break + sleep(1) + assert result.Analog[index] == value + + +def test_apply_update_analog_output(master_new, outstation_app): + value = random.random() + index = random.randint(0, NUMBER_OF_DB_POINTS - 1) + outstation_app.apply_update_analog_output(value, index) + print(f"=== {index=}, {value=}") + + for i in range(10): + result = outstation_app.db + print(f"{i=}, {result=}") + if result.AnalogOutputStatus[index] == value: + break + sleep(1) + assert result.AnalogOutputStatus[index] == value + + +def test_apply_update_binary_input(master_new, outstation_app): + value = random.choice([True, False]) + index = random.randint(0, NUMBER_OF_DB_POINTS - 1) + outstation_app.apply_update_binary_input(value, index) + print(f"=== {index=}, {value=}") + + for i in range(10): + result = outstation_app.db + print(f"{i=}, {result=}") + if result.Binary[index] == value: + break + sleep(1) + assert result.Binary[index] == value + + +def test_apply_update_binary_output(master_new, outstation_app): + value = random.choice([True, False]) + index = random.randint(0, NUMBER_OF_DB_POINTS - 1) + outstation_app.apply_update_binary_output(value, index) + print(f"=== {index=}, {value=}") + + for i in range(10): + result = outstation_app.db + print(f"{i=}, {result=}") + if result.BinaryOutputStatus[index] == value: + break + sleep(1) + assert result.BinaryOutputStatus[index] == value diff --git a/tests/test_dnp3_python/utils.py b/tests/test_dnp3_python/utils.py new file mode 100644 index 0000000..1f25cad --- /dev/null +++ b/tests/test_dnp3_python/utils.py @@ -0,0 +1,43 @@ +import random +import socket +from time import sleep + + +def check_port_in_use(port, host="127.0.0.1"): + """ + # Example usage: + port = 8080 + if check_port_in_use(port): + print(f"Port {port} is in use.") + else: + print(f"Port {port} is available.") + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind((host, port)) + # If bind is successful, port is not in use + return False + except socket.error as e: + # If bind fails, port is in use + return True + + +class PortUnavailableError(Exception): + pass + + +def get_free_port(host="127.0.0.1", start_port=20000, end_port=30000): + """ + # Example usage: + port = get_free_port() + print(f"Port {port} is available.") + """ + ports = list(range(start_port, end_port + 1)) + random.shuffle(ports) + for port in ports: + if not check_port_in_use(port, host): + return port + sleep(1) + + exception_msg = f"No available port found between {start_port} and {end_port}." + raise PortUnavailableError(exception_msg)