From d01e41398dfd15a6ce1820ee65d99ee6760a1da4 Mon Sep 17 00:00:00 2001 From: Kefei Mo Date: Fri, 17 Feb 2023 12:15:55 -0600 Subject: [PATCH 1/7] developed dnp3 agent --- services/core/DNP3OutstationAgent/config | 4 + .../installation-script-notes.txt | 5 + .../demo-scripts/rpc_example.py | 81 ++++ .../run_dnp3_outstation_agent_script.py | 278 ++++++++++++ .../dnp3_outstation_agent/__init__.py | 0 .../dnp3_outstation_agent/agent.py | 408 ++++++++++++++++++ services/core/DNP3OutstationAgent/setup.py | 29 ++ .../tests/test_dnp3_agent.py | 310 +++++++++++++ 8 files changed, 1115 insertions(+) create mode 100644 services/core/DNP3OutstationAgent/config create mode 100644 services/core/DNP3OutstationAgent/demo-scripts/installation-script-notes.txt create mode 100644 services/core/DNP3OutstationAgent/demo-scripts/rpc_example.py create mode 100644 services/core/DNP3OutstationAgent/demo-scripts/run_dnp3_outstation_agent_script.py create mode 100644 services/core/DNP3OutstationAgent/dnp3_outstation_agent/__init__.py create mode 100644 services/core/DNP3OutstationAgent/dnp3_outstation_agent/agent.py create mode 100644 services/core/DNP3OutstationAgent/setup.py create mode 100644 services/core/DNP3OutstationAgent/tests/test_dnp3_agent.py diff --git a/services/core/DNP3OutstationAgent/config b/services/core/DNP3OutstationAgent/config new file mode 100644 index 0000000000..96fc8d2fc3 --- /dev/null +++ b/services/core/DNP3OutstationAgent/config @@ -0,0 +1,4 @@ +{'outstation_ip_str': '0.0.0.0', +'port': 20000, +'masterstation_id_int': 2, +'outstation_id_int': 1} diff --git a/services/core/DNP3OutstationAgent/demo-scripts/installation-script-notes.txt b/services/core/DNP3OutstationAgent/demo-scripts/installation-script-notes.txt new file mode 100644 index 0000000000..232c7b0e36 --- /dev/null +++ b/services/core/DNP3OutstationAgent/demo-scripts/installation-script-notes.txt @@ -0,0 +1,5 @@ +python scripts/install-agent.py -s services/core/DNP3OutstationAgent/ \ + -c services/core/DNP3OutstationAgent/config \ + -t dnp3-outstation-agent \ + -i dnp3-outstation-agent \ + -f diff --git a/services/core/DNP3OutstationAgent/demo-scripts/rpc_example.py b/services/core/DNP3OutstationAgent/demo-scripts/rpc_example.py new file mode 100644 index 0000000000..471f19b3ba --- /dev/null +++ b/services/core/DNP3OutstationAgent/demo-scripts/rpc_example.py @@ -0,0 +1,81 @@ +""" +A demo to test dnp3-driver get_point method using rpc call. +Pre-requisite: +- install platform-driver +- configure dnp3-driver +- a dnp3 outstation/server is up and running +- platform-driver is up and running +""" + +import random +from volttron.platform.vip.agent.utils import build_agent +from time import sleep +import datetime + + +def main(): + a = build_agent() + + # peer = "test-agent" + # peer_method = "outstation_get_config" + # + # rs = a.vip.rpc.call(peer, peer_method, ).get(timeout=10) + # print(datetime.datetime.now(), "rs: ", rs) + + peer = "dnp3-agent" + + peer_method = "get_volttron_config" + rs = a.vip.rpc.call(peer, peer_method).get(timeout=10) + print(datetime.datetime.now(), "rs: ", rs) + + # peer_method = "set_volttron_config" + # rs = a.vip.rpc.call(peer, peer_method, port=100, unused_key="unused").get(timeout=10) + # print(datetime.datetime.now(), "rs: ", rs) + # + # peer_method = "demo_config_store" + # rs = a.vip.rpc.call(peer, peer_method).get(timeout=10) + # print(datetime.datetime.now(), "rs: ", rs) + + peer_method = "set_volttron_config" + rs = a.vip.rpc.call(peer, peer_method, port=31000).get(timeout=10) + print(datetime.datetime.now(), "rs: ", rs) + + # peer_method = "outstation_get_is_connected" + # rs = a.vip.rpc.call(peer, peer_method).get(timeout=10) + # print(datetime.datetime.now(), "rs: ", rs) + + peer_method = "outstation_reset" + rs = a.vip.rpc.call(peer, peer_method).get(timeout=10) + print(datetime.datetime.now(), "rs: ", rs) + + + + # while True: + # sleep(5) + # print("============") + # try: + # peer = "test-agent" + # peer_method = "outstation_display_db" + # + # rs = a.vip.rpc.call(peer, peer_method).get(timeout=10) + # print(datetime.datetime.now(), "rs: ", rs) + # + # # rs = a.vip.rpc.call(peer, peer_method, arg1="173", arg2="arg2222", + # # something="something-else" + # # ).get(timeout=10) + # + # # rs = a.vip.rpc.call(peer, peer_method, "173", "arg2222", + # # "something-else" + # # ).get(timeout=10) + # # print(datetime.datetime.now(), "rs: ", rs) + # # reg_pt_name = "AnalogInput_index1" + # # rs = a.vip.rpc.call("platform.driver", rpc_method, + # # device_name, + # # reg_pt_name).get(timeout=10) + # # print(datetime.datetime.now(), "point_name: ", reg_pt_name, "value: ", rs) + # except Exception as e: + # print(e) + + +if __name__ == "__main__": + main() diff --git a/services/core/DNP3OutstationAgent/demo-scripts/run_dnp3_outstation_agent_script.py b/services/core/DNP3OutstationAgent/demo-scripts/run_dnp3_outstation_agent_script.py new file mode 100644 index 0000000000..5895520840 --- /dev/null +++ b/services/core/DNP3OutstationAgent/demo-scripts/run_dnp3_outstation_agent_script.py @@ -0,0 +1,278 @@ +import logging +import sys +import argparse + +from pydnp3 import opendnp3 +from dnp3_python.dnp3station.outstation_new import MyOutStationNew + +from time import sleep +from volttron.platform.vip.agent.utils import build_agent +from services.core.DNP3OutstationAgent.dnp3_outstation_agent import agent # agent + +stdout_stream = logging.StreamHandler(sys.stdout) +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") +_log.addHandler(stdout_stream) +_log.setLevel(logging.DEBUG) + + +def input_prompt(display_str=None) -> str: + if display_str is None: + display_str = """ +======== Your Input Here: ==(DNP3 OutStation Agent)====== +""" + return input(display_str) + + +def setup_args(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: + + # Adding optional argument + # parser.add_argument("-mip", "--master-ip", action="store", default="0.0.0.0", type=str, + # metavar="") + # note: volttron agent require post-configuration + # parser.add_argument("-oip", "--outstation-ip", action="store", default="0.0.0.0", type=str, + # metavar="") + # parser.add_argument("-p", "--port", action="store", default=20000, type=int, + # metavar="") + # parser.add_argument("-mid", "--master-id", action="store", default=2, type=int, + # metavar="") + # parser.add_argument("-oid", "--outstation-id", action="store", default=1, type=int, + # metavar="") + parser.add_argument("-aid", "--agent-identity", action="store", default="dnp3-outstation-agent", type=str, + metavar="", help="specify agent identity (parsed as peer-name for rpc call), default 'dnp3-outstation-agent'.") + + return parser + + +def print_menu(): + welcome_str = """\ +========================= MENU ================================== + - set analog-input point value + - set analog-output point value + - set binary-input point value + - set binary-output point value + +
- display database + - display (outstation) info + - config then restart outstation +=================================================================\ +""" + print(welcome_str) + +def main(parser=None, *args, **kwargs): + + if parser is None: + # Initialize parser + parser = argparse.ArgumentParser( + prog="dnp3-outstation", + description="Run a dnp3 outstation agent. Specify agent identity, by default `dnp3-outstation-agent`", + # epilog="Thanks for using %(prog)s! :)", + ) + parser = setup_args(parser) + + # Read arguments from command line + args = parser.parse_args() + + # dict to store args.Namespace + # d_args = vars(args) + # print(__name__, d_args) + + # create volttron vip agent to evoke dnp3-agent rpc calls + a = build_agent() + peer = args.agent_identity # note: default "dnp3-outstation-agent" or "test-agent" + # peer_method = "outstation_apply_update_analog_input" + + def get_db_helper(): + _peer_method = "outstation_get_db" + _db_print = a.vip.rpc.call(peer, _peer_method).get(timeout=10) + return _db_print + + def get_config_helper(): + _peer_method = "outstation_get_config" + _config_print = a.vip.rpc.call(peer, _peer_method).get(timeout=10) + _config_print.update({"peer": peer}) + return _config_print + + # outstation_application = MyOutStationNew( + # # masterstation_ip_str=args.master_ip, + # outstation_ip_str=args.outstation_ip, + # port=args.port, + # masterstation_id_int=args.master_id, + # outstation_id_int=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("Communication Config", outstation_application.get_config()) + # outstation_application.start() + # _log.debug('Initialization complete. Outstation in command loop.') + + sleep(3) + # Note: if without sleep(2) there will be a glitch when first send_select_and_operate_command + # (i.e., all the values are zero, [(0, 0.0), (1, 0.0), (2, 0.0), (3, 0.0)])) + # since it would not update immediately + + count = 0 + while count < 1000: + # sleep(1) # Note: hard-coded, master station query every 1 sec. + + count += 1 + # print(f"=========== Count {count}") + + + + # TODO: figure out how to list existing agents, e.g., the following code block cannot be captured + # try: + # x = a.vip.rpc.call(agent_not_exist, "outstation_get_is_connectedsddsdf", ).get(timeout=10) + # print(x) + # except Exception as e: + # print(f"++++++++++++++ e {e}") + + if a.vip.rpc.call(peer, "outstation_get_is_connected",).get(timeout=10): + # print("Communication Config", master_application.get_config()) + print_menu() + else: + print("Communication error.") + # print("Communication Config", outstation_application.get_config()) + print(get_config_helper()) + print("Start retry...") + sleep(2) + continue + + + + option = input_prompt() # Note: one of ["ai", "ao", "bi", "bo", "dd", "dc"] + while True: + if option == "ai": + print("You chose - set analog-input point value") + print("Type in and . Separate with space, then hit ENTER.") + print("Type 'q', 'quit', 'exit' to main menu.") + input_str = input_prompt() + if input_str in ["q", "quit", "exit"]: + break + try: + p_val = float(input_str.split(" ")[0]) + index = int(input_str.split(" ")[1]) + # outstation_application.apply_update(opendnp3.Analog(value=p_val), index) + # result = {"Analog": outstation_application.db_handler.db.get("Analog")} + method = agent.Dnp3Agent.outstation_apply_update_analog_input + peer_method = method.__name__ # i.e., "outstation_apply_update_analog_input" + response = a.vip.rpc.call(peer, peer_method, p_val, index).get(timeout=10) + result = {"Analog": get_db_helper().get("Analog")} + print(result) + sleep(2) + except Exception as e: + print(f"your input string '{input_str}'") + print(e) + elif option == "ao": + print("You chose - set analog-output point value") + print("Type in and . Separate with space, then hit ENTER.") + print("Type 'q', 'quit', 'exit' to main menu.") + input_str = input_prompt() + if input_str in ["q", "quit", "exit"]: + break + try: + p_val = float(input_str.split(" ")[0]) + index = int(input_str.split(" ")[1]) + method = agent.Dnp3Agent.outstation_apply_update_analog_output + peer_method = method.__name__ # i.e., "outstation_apply_update_analog_input" + response = a.vip.rpc.call(peer, peer_method, p_val, index).get(timeout=10) + result = {"AnalogOutputStatus": get_db_helper().get("AnalogOutputStatus")} + print(result) + sleep(2) + except Exception as e: + print(f"your input string '{input_str}'") + print(e) + elif option == "bi": + print("You chose - set binary-input point value") + print("Type in <[1/0]> and . Separate with space, then hit ENTER.") + input_str = input_prompt() + if input_str in ["q", "quit", "exit"]: + break + try: + p_val_input = input_str.split(" ")[0] + if p_val_input not in ["0", "1"]: + raise ValueError("binary-output value only takes '0' or '1'.") + else: + p_val = True if p_val_input == "1" else False + index = int(input_str.split(" ")[1]) + method = agent.Dnp3Agent.outstation_apply_update_binary_input + peer_method = method.__name__ + response = a.vip.rpc.call(peer, peer_method, p_val, index).get(timeout=10) + result = {"Binary": get_db_helper().get("Binary")} + print(result) + sleep(2) + except Exception as e: + print(f"your input string '{input_str}'") + 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.") + input_str = input_prompt() + if input_str in ["q", "quit", "exit"]: + break + try: + p_val_input = input_str.split(" ")[0] + if p_val_input not in ["0", "1"]: + raise ValueError("binary-output value only takes '0' or '1'.") + else: + p_val = True if p_val_input == "1" else False + index = int(input_str.split(" ")[1]) + method = agent.Dnp3Agent.outstation_apply_update_binary_output + peer_method = method.__name__ + response = a.vip.rpc.call(peer, peer_method, p_val, index).get(timeout=10) + result = {"BinaryOutputStatus": get_db_helper().get("BinaryOutputStatus")} + print(result) + sleep(2) + except Exception as e: + print(f"your input string '{input_str}'") + print(e) + elif option == "dd": + print("You chose
- display database") + # db_print = outstation_application.db_handler.db + # peer_method = "outstation_get_db" + # db_print = a.vip.rpc.call(peer, peer_method).get(timeout=10) + # print(db_print) + print(get_db_helper()) + sleep(2) + break + elif option == "di": + print("You chose - display (outstation) info") + # print(outstation_application.get_config()) + # peer_method = "outstation_get_config" + # config_print = a.vip.rpc.call(peer, peer_method).get(timeout=10) + print(get_config_helper()) + sleep(3) + break + elif option == "cr": + print("You chose - config then restart outstation") + print(f"current self.volttron_config is {get_config_helper()}") + print("Type in , then hit ENTER. (Note: In this script, only support port configuration.)") + input_str = input_prompt() + try: + # set_volttron_config + port_val = int(input_str) + method = agent.Dnp3Agent.outstation_reset + peer_method = method.__name__ + response = a.vip.rpc.call(peer, peer_method, port=port_val).get(timeout=10) + print("SUCCESS.", get_config_helper()) + sleep(2) + except Exception as e: + print(f"your input string '{input_str}'") + print(e) + break + else: + print(f"ERROR- your input `{option}` is not one of the following.") + sleep(1) + break + + _log.debug('Exiting.') + # outstation_application.shutdown() + # outstation_application.shutdown() + + +if __name__ == '__main__': + main() diff --git a/services/core/DNP3OutstationAgent/dnp3_outstation_agent/__init__.py b/services/core/DNP3OutstationAgent/dnp3_outstation_agent/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/services/core/DNP3OutstationAgent/dnp3_outstation_agent/agent.py b/services/core/DNP3OutstationAgent/dnp3_outstation_agent/agent.py new file mode 100644 index 0000000000..5807c5770a --- /dev/null +++ b/services/core/DNP3OutstationAgent/dnp3_outstation_agent/agent.py @@ -0,0 +1,408 @@ +""" +Agent documentation goes here. +""" + +__docformat__ = 'reStructuredText' + +import logging +import sys +from volttron.platform.agent import utils +from volttron.platform.vip.agent import Agent, Core, RPC + +from dnp3_python.dnp3station.outstation_new import MyOutStationNew +from pydnp3 import opendnp3 + + +_log = logging.getLogger("Dnp3-agent") +utils.setup_logging() +__version__ = "0.2.0" + +_log.level=logging.DEBUG +_log.addHandler(logging.StreamHandler(sys.stdout)) # Note: redirect stdout from dnp3 lib + + +def agent_main(config_path, **kwargs): + """ + Parses the Agent configuration and returns an instance of + the agent created using that configuration. + + Note: config_path is by convention under .volttron home path, called config, e.g. + /home/kefei/.volttron/agents/6745e0ef-b500-495a-a6e8-120ec0ead4fd/testeragent-0.5/testeragent-0.5.dist-info/config + + :param config_path: Path to a configuration file. + :type config_path: str + :returns: Tester + :rtype: Dnp3Agent + """ + # _log.info(f"======config_path {config_path}") + # Note: config_path is by convention under .volttron home path, called config, e.g. + # /home/kefei/.volttron/agents/6745e0ef-b500-495a-a6e8-120ec0ead4fd/testeragent-0.5/testeragent-0.5.dist-info/config + # Note: the config file is attached when running `python scripts/install-agent.py -c TestAgent/config` + # NOte: the config file attached in this way will not appear in the config store. + # (Need to explicitly using `vctl config store`) + try: + config: dict = utils.load_config(config_path) + except Exception as e: + _log.info(e) + config = {} + + if not config: + _log.info("Using Agent defaults for starting configuration.") + + setting1 = int(config.get('setting1', 1)) + setting2 = config.get('setting2', "some/random/topic") + + return Dnp3Agent(config, setting2, **kwargs) + + +class Dnp3Agent(Agent): + """ + Dnp3 agent mainly to represent a dnp3 outstation + """ + + def __init__(self, setting1={}, setting2="some/random/topic", **kwargs): + # TODO: clean-up the bizarre signature. Note: may need to reinstall the agent for testing. + super(Dnp3Agent, self).__init__(**kwargs) + _log.debug("vip_identity: " + self.core.identity) # Note: consistent with IDENTITY in `vctl status` + + + # self.setting1 = setting1 + # self.setting2 = setting2 + config_when_installed = setting1 + # TODO: new-feature: load_config from config store + # config_at_configstore = + + self.default_config = {'outstation_ip_str': '0.0.0.0', 'port': 21000, + 'masterstation_id_int': 2, 'outstation_id_int': 1} + # agent configuration using volttron config framework + # get_volttron_cofig, set_volltron_config + self._volttron_config: dict + + # for dnp3 features + try: + self.outstation_application = MyOutStationNew(**config_when_installed) + _log.info(f"init dnp3 outstation with {config_when_installed}") + self._volttron_config = config_when_installed + except Exception as e: + _log.error(e) + self.outstation_application = MyOutStationNew(**self.default_config) + _log.info(f"init dnp3 outstation with {self.default_config}") + self._volttron_config = self.default_config + # self.outstation_application.start() # moved to onstart + + # Set a default configuration to ensure that self.configure is called immediately to setup + # the agent. + self.vip.config.set_default(config_name="default-config", contents=self.default_config) + self.vip.config.set_default(config_name="_volttron_config", contents=self._volttron_config) + # Hook self.configure up to changes to the configuration file "config". + self.vip.config.subscribe(self.configure, actions=["NEW", "UPDATE"], pattern="config") + + def _get_volttron_config(self): + return self._volttron_config + + def _set_volttron_config(self, **kwargs): + """set self._volttron_config using **kwargs. + EXAMPLE + self.default_config = {'outstation_ip_str': '0.0.0.0', 'port': 21000, + 'masterstation_id_int': 2, 'outstation_id_int': 1} + set_volttron_config(port=30000, unused_key="unused") + # outcome + self.default_config = {'outstation_ip_str': '0.0.0.0', 'port': 30000, + 'masterstation_id_int': 2, 'outstation_id_int': 1, + 'unused_key': 'unused'} + """ + self._volttron_config.update(kwargs) + _log.info(f"Updated self._volttron_config to {self._volttron_config}") + return {"_volttron_config": self._get_volttron_config()} + + @RPC.export + def outstation_reset(self, **kwargs): + """update`self._volttron_config`, then init a new outstation. + + For post-configuration and immediately take effect. + Note: will start a new outstation instance and the old database data will lose""" + self._set_volttron_config(**kwargs) + try: + outstation_app_new = MyOutStationNew(**self._volttron_config) + self.outstation_application.shutdown() + self.outstation_application = outstation_app_new + self.outstation_application.start() + except Exception as e: + _log.error(e) + + @RPC.export + def outstation_get_db(self): + """expose db""" + return self.outstation_application.db_handler.db + + @RPC.export + def outstation_get_config(self): + """expose get_config""" + return self.outstation_application.get_config() + + @RPC.export + def outstation_get_is_connected(self): + """expose is_connected, note: status, property""" + return self.outstation_application.is_connected + + # @RPC.export + # def demo_config_store(self): + # """ + # Example return + # {'config_list': "['config', 'testagent.config']", + # 'config': "{'setting1': 2, 'setting2': 'some/random/topic2'}", + # 'testagent.config': "{'setting1': 2, 'setting2': 'some/random/topic2', + # 'setting3': True, 'setting4': False, 'setting5': 5.1, 'setting6': [1, 2, 3, 4], + # 'setting7': {'setting7a': 'a', 'setting7b': 'b'}}"} + # + # on command line + # vctl config store test-agent testagent.config /home/kefei/project-local/volttron/services/core/DNP3OutstationAgent/config + # vctl config get test-agent testagent.config + # """ + # + # msg_dict = dict() + # # vip.config.set() + # # config_demo = {"set1": "setting1-xxxxxxxxx", + # # "set2": "setting2-xxxxxxxxx"} + # # # Set a default configuration to ensure that self.configure is called immediately to setup + # # # the agent. + # # # self.vip.config.set_default("config", default_config) # set_default can only be used before onstart + # # self.vip.config.set(config_name="config_2", contents=config_demo, + # # trigger_callback=False, send_update=True) + # + # # vip.config.list() + # config_list = self.vip.config.list() + # msg_dict["config_list"] = str(config_list) + # + # # vip.config.get() + # if config_list: + # for config_name in config_list: + # config = self.vip.config.get(config_name) + # msg_dict[config_name] = str(config) + # + # return msg_dict + + @RPC.export + def outstation_apply_update_analog_input(self, val, index): + """public interface to update analog-input point value + + val: float + index: int, point index + """ + if not isinstance(val, float): + raise f"val of type(val) should be float" + self.outstation_application.apply_update(opendnp3.Analog(value=val), index) + _log.debug(f"Updated outstation analog-input index: {index}, val: {val}") + + return self.outstation_application.db_handler.db + + @RPC.export + def outstation_apply_update_analog_output(self, val, index): + """public interface to update analog-output point value + + val: float + index: int, point index + """ + + if not isinstance(val, float): + raise f"val of type(val) should be float" + self.outstation_application.apply_update(opendnp3.AnalogOutputStatus(value=val), index) + _log.debug(f"Updated outstation analog-output index: {index}, val: {val}") + + return self.outstation_application.db_handler.db + + @RPC.export + def outstation_apply_update_binary_input(self, val, index): + """public interface to update binary-input point value + + val: bool + index: int, point index + """ + if not isinstance(val, bool): + raise f"val of type(val) should be bool" + self.outstation_application.apply_update(opendnp3.Binary(value=val), index) + _log.debug(f"Updated outstation binary-input index: {index}, val: {val}") + + return self.outstation_application.db_handler.db + + @RPC.export + def outstation_apply_update_binary_output(self, val, index): + """public interface to update binary-output point value + + val: bool + index: int, point index + """ + if not isinstance(val, bool): + raise f"val of type(val) should be bool" + self.outstation_application.apply_update(opendnp3.BinaryOutputStatus(value=val), index) + _log.debug(f"Updated outstation binary-output index: {index}, val: {val}") + + return self.outstation_application.db_handler.db + + @RPC.export + def outstation_display_db(self): + return self.outstation_application.db_handler.db + + # @RPC.export + # def playground(self, val, index): + # pass + # + # + # + # _log.debug("====================") + # + # return self.outstation_display_db() + + def configure(self, config_name, action, contents): + """ + # TODO: clean-up this bizarre method + """ + config = self.default_config.copy() + config.update(contents) + + _log.debug("Configuring Agent") + + try: + setting1 = int(config["setting1"]) + setting2 = str(config["setting2"]) + except ValueError as e: + _log.error("ERROR PROCESSING CONFIGURATION: {}".format(e)) + return + + self.setting1 = setting1 + self.setting2 = setting2 + + self._create_subscriptions(self.setting2) + + def _create_subscriptions(self, topic): + """ + Unsubscribe from all pub/sub topics and create a subscription to a topic in the configuration which triggers + the _handle_publish callback + """ + self.vip.pubsub.unsubscribe("pubsub", None, None) + + topic = "some/topic" + self.vip.pubsub.subscribe(peer='pubsub', + prefix=topic, + callback=self._handle_publish) + + def _handle_publish(self, peer, sender, bus, topic, headers, message): + """ + Callback triggered by the subscription setup using the topic from the agent's config file + """ + _log.debug(f" ++++++handleer++++++++++++++++++++++++++" + f"peer {peer}, sender {sender}, bus {bus}, topic {topic}, " + f"headers {headers}, message {message}") + + @Core.receiver("onstart") + def onstart(self, sender, **kwargs): + """ + This is method is called once the Agent has successfully connected to the platform. + This is a good place to setup subscriptions if they are not dynamic or + do any other startup activities that require a connection to the message bus. + Called after any configurations methods that are called at startup. + + Usually not needed if using the configuration store. + """ + + # for dnp3 outstation + self.outstation_application.start() + + # Example publish to pubsub + # self.vip.pubsub.publish('pubsub', "some/random/topic", message="HI!") + # + # # Example RPC call + # # self.vip.rpc.call("some_agent", "some_method", arg1, arg2) + # pass + # self._create_subscriptions(self.setting2) + + + @Core.receiver("onstop") + def onstop(self, sender, **kwargs): + """ + This method is called when the Agent is about to shutdown, but before it disconnects from + the message bus. + """ + pass + self.outstation_application.shutdown() + + # @RPC.export + # def rpc_demo_load_config(self): + # """ + # RPC method + # + # May be called from another agent via self.core.rpc.call + # """ + # try: + # config = utils.load_config("/home/kefei/project-local/volttron/TestAgent/config") + # except Exception: + # config = {} + # return config + + # @RPC.export + # def rpc_demo_config_list_set_get(self): + # """ + # RPC method + # + # May be called from another agent via self.core.rpc.call + # """ + # default_config = {"setting1": "setting1-xxxxxxxxx", + # "setting2": "setting2-xxxxxxxxx"} + # + # # Set a default configuration to ensure that self.configure is called immediately to setup + # # the agent. + # # self.vip.config.set_default("config", default_config) # set_default can only be used before onstart + # self.vip.config.set(config_name="config_2", contents=default_config, + # trigger_callback=False, send_update=True) + # get_result = [ + # self.vip.config.get(config) for config in self.vip.config.list() + # ] + # return self.vip.config.list(), get_result + + # @RPC.export + # def rpc_demo_config_set_default(self): + # """ + # RPC method + # + # May be called from another agent via self.core.rpc.call + # """ + # default_config = {"setting1": "setting1-xxxxxxxxx", + # "setting2": "setting2-xxxxxxxxx"} + # + # # Set a default configuration to ensure that self.configure is called immediately to setup + # # the agent. + # self.vip.config.set_default("config", default_config) + # return self.vip.config.list() + # # # Hook self.configure up to changes to the configuration file "config". + # # self.vip.config.subscribe(self.configure, actions=["NEW", "UPDATE"], pattern="config") + + # @RPC.export + # def rpc_demo_pubsub(self): + # """ + # RPC method + # + # May be called from another agent via self.core.rpc.call + # """ + # + # # pubsub_list = self.vip.pubsub.list('pubsub', 'some/') + # # list(self, peer, prefix='', bus='', subscribed=True, reverse=False, all_platforms=False) + # # # return pubsub_list + # self.vip.pubsub.publish('pubsub', 'some/topic/', message="+++++++++++++++++++++++++ something something") + # # self.vip.pubsub.subscribe('pubsub', 'some/topic/', callable=self._handle_publish) + # # return pubsub_list + # # # Hook self.configure up to changes to the configuration file "config". + # # self.vip.config.subscribe(self.configure, actions=["NEW", "UPDATE"], pattern="config") + + +def main(): + """Main method called to start the agent.""" + utils.vip_main(agent_main, + version=__version__) + + +if __name__ == '__main__': + # Entry point for script + try: + sys.exit(main()) + except KeyboardInterrupt: + pass diff --git a/services/core/DNP3OutstationAgent/setup.py b/services/core/DNP3OutstationAgent/setup.py new file mode 100644 index 0000000000..b96cd42d10 --- /dev/null +++ b/services/core/DNP3OutstationAgent/setup.py @@ -0,0 +1,29 @@ +from setuptools import setup, find_packages + +MAIN_MODULE = 'agent' + +# Find the agent package that contains the main module +packages = find_packages('.') +agent_package = 'dnp3_outstation_agent' + +# Find the version number from the main module +agent_module = agent_package + '.' + MAIN_MODULE +_temp = __import__(agent_module, globals(), locals(), ['__version__'], 0) +__version__ = _temp.__version__ + +# Setup +setup( + name=agent_package + 'agent', + version=__version__, + author="VOLTTRON team", + author_email="volttron@pnl.gov", + url="http:something", + description="Dnp3 agent as an outstation", + install_requires=['volttron'], + packages=packages, + entry_points={ + 'setuptools.installation': [ + 'eggsecutable = ' + agent_module + ':main', + ] + } +) diff --git a/services/core/DNP3OutstationAgent/tests/test_dnp3_agent.py b/services/core/DNP3OutstationAgent/tests/test_dnp3_agent.py new file mode 100644 index 0000000000..b5ec934c4b --- /dev/null +++ b/services/core/DNP3OutstationAgent/tests/test_dnp3_agent.py @@ -0,0 +1,310 @@ +# -*- coding: utf-8 -*- {{{ +# vim: set fenc=utf-8 ft=python sw=4 ts=4 sts=4 et: +# +# Copyright 2018, SLAC / Kisensum. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# This material was prepared as an account of work sponsored by an agency of +# the United States Government. Neither the United States Government nor the +# United States Department of Energy, nor SLAC, nor Kisensum, nor any of their +# employees, nor any jurisdiction or organization that has cooperated in the +# development of these materials, makes any warranty, express or +# implied, or assumes any legal liability or responsibility for the accuracy, +# completeness, or usefulness or any information, apparatus, product, +# software, or process disclosed, or represents that its use would not infringe +# privately owned rights. Reference herein to any specific commercial product, +# process, or service by trade name, trademark, manufacturer, or otherwise +# does not necessarily constitute or imply its endorsement, recommendation, or +# favoring by the United States Government or any agency thereof, or +# SLAC, or Kisensum. The views and opinions of authors expressed +# herein do not necessarily state or reflect those of the +# United States Government or any agency thereof. +# }}} + +import pytest +# try: +# import dnp3 +# except ImportError: +# pytest.skip("pydnp3 not found!", allow_module_level=True) +# +# import gevent +# import os +# import pytest +# +# from volttron.platform import get_services_core, jsonapi +# from volttron.platform.agent.utils import strip_comments +# +# # from dnp3.points import PointDefinitions +# # from mesa_master_test import MesaMasterTest +# +# from pydnp3 import asiodnp3, asiopal, opendnp3, openpal + +FILTERS = opendnp3.levels.NORMAL | opendnp3.levels.ALL_COMMS +HOST = "127.0.0.1" +LOCAL = "0.0.0.0" +PORT = 20000 + +DNP3_AGENT_ID = 'dnp3_outstation_agent' +POINT_TOPIC = "dnp3/point" +TEST_GET_POINT_NAME = 'DCTE.WinTms.AO11' +TEST_SET_POINT_NAME = 'DCTE.WinTms.AI55' + +input_group_map = { + 1: "Binary", + 2: "Binary", + 30: "Analog", + 31: "Analog", + 32: "Analog", + 33: "Analog", + 34: "Analog" +} + +DNP3_AGENT_CONFIG = { + "points": "config://mesa_points.config", + "point_topic": POINT_TOPIC, + "outstation_config": { + "log_levels": ["NORMAL", "ALL_APP_COMMS"] + }, + "local_ip": "0.0.0.0", + "port": 20000 +} + +# Get point definitions from the files in the test directory. +POINT_DEFINITIONS_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), 'data', 'mesa_points.config')) + +pdefs = PointDefinitions(point_definitions_path=POINT_DEFINITIONS_PATH) + +AGENT_CONFIG = { + "points": "config://mesa_points.config", + "outstation_config": { + "database_sizes": 700, + "log_levels": ["NORMAL"] + }, + "local_ip": "0.0.0.0", + "port": 20000 +} + +messages = {} + + +def onmessage(peer, sender, bus, topic, headers, message): + """Callback: As DNP3Agent publishes mesa/point messages, store them in a multi-level global dictionary.""" + global messages + messages[topic] = {'headers': headers, 'message': message} + + +def dict_compare(source_dict, target_dict): + """Assert that the value for each key in source_dict matches the corresponding value in target_dict. + + Ignores keys in target_dict that are not in source_dict. + """ + for name, source_val in source_dict.items(): + target_val = target_dict.get(name, None) + assert source_val == target_val, "Source value of {}={}, target value={}".format(name, source_val, target_val) + + +def add_definitions_to_config_store(test_agent): + """Add PointDefinitions to the mesaagent's config store.""" + with open(POINT_DEFINITIONS_PATH, 'r') as f: + points_json = jsonapi.loads(strip_comments(f.read())) + test_agent.vip.rpc.call('config.store', 'manage_store', DNP3_AGENT_ID, + 'mesa_points.config', points_json, config_type='raw') + + +@pytest.fixture(scope="module") +def agent(request, volttron_instance): + """Build the test agent for rpc call.""" + + test_agent = volttron_instance.build_agent(identity="test_agent") + capabilities = {'edit_config_store': {'identity': 'dnp3_outstation_agent'}} + volttron_instance.add_capabilities(test_agent.core.publickey, capabilities) + add_definitions_to_config_store(test_agent) + + print('Installing DNP3Agent') + os.environ['AGENT_MODULE'] = 'dnp3.agent' + agent_id = volttron_instance.install_agent(agent_dir=get_services_core("DNP3Agent"), + config_file=AGENT_CONFIG, + vip_identity=DNP3_AGENT_ID, + start=True) + + # Subscribe to DNP3 point publication + test_agent.vip.pubsub.subscribe(peer='pubsub', prefix=POINT_TOPIC, callback=onmessage) + + def stop(): + """Stop test agent.""" + if volttron_instance.is_running(): + volttron_instance.stop_agent(agent_id) + volttron_instance.remove_agent(agent_id) + test_agent.core.stop() + + gevent.sleep(12) # wait for agents and devices to start + + request.addfinalizer(stop) + + return test_agent + +def test_agent(agent): + print(agent) + +def test_agent(): + print("agent") + + +@pytest.fixture(scope="module") +def run_master(request): + """Run Mesa master application.""" + master = MesaMasterTest(local_ip=AGENT_CONFIG['local_ip'], port=AGENT_CONFIG['port']) + master.connect() + + def stop(): + master.shutdown() + + request.addfinalizer(stop) + + return master + + +@pytest.fixture(scope="function") +def reset(agent): + """Reset agent and global variable messages before running every test.""" + global messages + messages = {} + agent.vip.rpc.call(DNP3_AGENT_ID, 'reset').get() + + +class TestDummy: + + @staticmethod + def get_point_definitions(agent, point_names): + """Ask DNP3Agent for a list of point definitions.""" + return agent.vip.rpc.call(DNP3_AGENT_ID, 'get_point_definitions', point_names).get(timeout=10) + + def get_point_definition(self, agent, point_name): + """Confirm that the agent has a point definition named point_name. Return the definition.""" + point_defs = self.get_point_definitions(agent, [point_name]) + point_def = point_defs.get(point_name, None) + assert point_def is not None, "Agent has no point definition for {}".format(TEST_GET_POINT_NAME) + return point_def + + def test_fixture_run_master(self, run_master): + pass + print(f"=====run_master {run_master}") + + def test_fixture_agent(self, agent): + pass + print(f"=====agent {agent}") + + def test_fixture_reset(self, reset): + pass + print(f"=====agent {reset}") + + def test_get_point_definition(self, run_master, agent, reset): + """Ask the agent whether it has a point definition for a point name.""" + self.get_point_definition(agent, TEST_GET_POINT_NAME) + + +class TestDNP3Agent: + """Regression tests for (non-MESA) DNP3Agent.""" + + @staticmethod + def get_point(agent, point_name): + """Ask DNP3Agent for a point value for a DNP3 resource.""" + return agent.vip.rpc.call(DNP3_AGENT_ID, 'get_point', point_name).get(timeout=10) + + @staticmethod + def get_point_definitions(agent, point_names): + """Ask DNP3Agent for a list of point definitions.""" + return agent.vip.rpc.call(DNP3_AGENT_ID, 'get_point_definitions', point_names).get(timeout=10) + + @staticmethod + def get_point_by_index(agent, data_type, index): + """Ask DNP3Agent for a point value for a DNP3 resource.""" + return agent.vip.rpc.call(DNP3_AGENT_ID, 'get_point_by_index', data_type, index).get(timeout=10) + + @staticmethod + def set_point(agent, point_name, value): + """Use DNP3Agent to set a point value for a DNP3 resource.""" + response = agent.vip.rpc.call(DNP3_AGENT_ID, 'set_point', point_name, value).get(timeout=10) + gevent.sleep(5) # Give the Master time to receive an echoed point value back from the Outstation. + return response + + @staticmethod + def set_points(agent, point_dict): + """Use DNP3Agent to set point values for a DNP3 resource.""" + return agent.vip.rpc.call(DNP3_AGENT_ID, 'set_points', point_dict).get(timeout=10) + + @staticmethod + def send_single_point(master, point_name, point_value): + """ + Send a point name and value from the Master to DNP3Agent. + + Return a dictionary with an exception key and error, empty if successful. + """ + try: + master.send_single_point(pdefs, point_name, point_value) + return {} + except Exception as err: + exception = {'key': type(err).__name__, 'error': str(err)} + print("Exception sending point from master: {}".format(exception)) + return exception + + @staticmethod + def get_value_from_master(master, point_name): + """Get value of the point from master after being set by test agent.""" + try: + pdef = pdefs.point_named(point_name) + group = input_group_map[pdef.group] + index = pdef.index + return master.soe_handler.result[group][index] + except KeyError: + return None + + def get_point_definition(self, agent, point_name): + """Confirm that the agent has a point definition named point_name. Return the definition.""" + point_defs = self.get_point_definitions(agent, [point_name]) + point_def = point_defs.get(point_name, None) + assert point_def is not None, "Agent has no point definition for {}".format(TEST_GET_POINT_NAME) + return point_def + + @staticmethod + def subscribed_points(): + """Return point values published by DNP3Agent using the dnp3/point topic.""" + return messages[POINT_TOPIC].get('message', {}) + + # ********** + # ********** OUTPUT TESTS (send data from Master to Agent to ControlAgent) ************ + # ********** + + def test_get_point_definition(self, run_master, agent, reset): + """Ask the agent whether it has a point definition for a point name.""" + self.get_point_definition(agent, TEST_GET_POINT_NAME) + + def test_send_point(self, run_master, agent, reset): + """Send a point from the master and get its value from DNP3Agent.""" + exceptions = self.send_single_point(run_master, TEST_GET_POINT_NAME, 45) + assert exceptions == {} + received_point = self.get_point(agent, TEST_GET_POINT_NAME) + # Confirm that the agent's received point value matches the value that was sent. + assert received_point == 45, "Expected {} = {}, got {}".format(TEST_GET_POINT_NAME, 45, received_point) + dict_compare({TEST_GET_POINT_NAME: 45}, self.subscribed_points()) + + # ********** + # ********** INPUT TESTS (send data from ControlAgent to Agent to Master) ************ + # ********** + + def test_set_point(self, run_master, agent, reset): + """Test set an input point and confirm getting the same value for that point.""" + self.set_point(agent, TEST_SET_POINT_NAME, 45) + received_val = self.get_value_from_master(run_master, TEST_SET_POINT_NAME) + assert received_val == 45, "Expected {} = {}, got {}".format(TEST_SET_POINT_NAME, 45, received_val) From d652a2b90bba73397390e78013c03877463b1373 Mon Sep 17 00:00:00 2001 From: Kefei Mo Date: Fri, 17 Feb 2023 16:05:31 -0600 Subject: [PATCH 2/7] updated DNP3 outstation README --- services/core/DNP3OutstationAgent/README.md | 307 ++++++++++++++++++ .../{config => example-config.json} | 0 2 files changed, 307 insertions(+) create mode 100644 services/core/DNP3OutstationAgent/README.md rename services/core/DNP3OutstationAgent/{config => example-config.json} (100%) diff --git a/services/core/DNP3OutstationAgent/README.md b/services/core/DNP3OutstationAgent/README.md new file mode 100644 index 0000000000..f283375d9e --- /dev/null +++ b/services/core/DNP3OutstationAgent/README.md @@ -0,0 +1,307 @@ +# DNP3 Outstation Agent + +Distributed Network Protocol (DNP or DNP3) has achieved a large-scale acceptance since its introduction in 1993. This +protocol is an immediately deployable solution for monitoring remote sites because it was developed for communication of +critical infrastructure status, allowing for reliable remote control. + +GE-Harris Canada (formerly Westronic, Inc.) is generally credited with the seminal work on the protocol. This protocol +is, however, currently implemented by an extensive range of manufacturers in a variety of industrial applications, such +as electric utilities. + +DNP3 is composed of three layers of the OSI seven-layer functions model. These layers are application layer, data link +layer, and transport layer. Also, DNP3 can be transmitted over a serial bus connection or over a TCP/IP network. + +# Prerequisites + +* > Python 3.8, < Python 4.0 + +# Installation + +1. Install volttron and start the platform. + + Refer to the [VOLTTRON Quick Start](https://volttron.readthedocs.io/en/main/tutorials/quick-start.html) to install + the VOLTTRON platform. + + ```shell + ... + # Activate the virtual enviornment + $ source env/bin/activate + + # Start the platform + (volttron) $ ./start-volttron + + # Check (installed) agent status + (volttron) $ vctl status + UUID AGENT IDENTITY TAG STATUS HEALTH + 75 listeneragent-3.3 listeneragent-3.3_1 listener + 2f platform_driveragent-4.0 platform.driver platform_driver + ``` + +1. Install and start the DNP3 Outstation Agent. + + Install the DNP3 Outstation agent with the following command: + + ```shell + (volttron) $ vctl install -s services/core/DNP3OutstationAgent/ \ + --agent-config \ + --tag \ + --vip-identity \ + -f \ + --start + ``` + + Assuming at the package root path, installing a dnp3-agent with [example-config.json](example-config.json), called " + dnp3-outstation-agent". + + ```shell + (volttron) $ vctl install services/core/DNP3OutstationAgent/ \ + --agent-config services/core/DNP3OutstationAgent/example-config.json \ + --tag dnp3-outstation-agent \ + --vip-identity dnp3-outstation-agent \ + -f \ + --start + + # >> + Agent 2e37a3bc-4438-4d52-8e05-cb6703cf3760 installed and started [11074] + ``` + + Please see more details about agent installation with `vctl install -h`. + +1. View the status of the installed agent (and notice a new dnp3 outstation agent is installed and running.) + + ```shell + (volttron) $ vctl status + UUID AGENT IDENTITY TAG STATUS HEALTH + 2e dnp3_outstation_agentagent-0.2.0 dnp3-outstation-agent dnp3-outstation-agent running [11074] GOOD + 75 listeneragent-3.3 listeneragent-3.3_1 listener + 2f platform_driveragent-4.0 platform.driver platform_driver + ``` + +1. Verification + + The dnp3 outstation agent acts as a server, and we will demonstrate a typical use case in the "Demonstration" + session. + +# Agent Configuration + +The required parameters for this agent are "outstation_ip_str", "port", "masterstation_id_int", and "outstation_id_int". +Below is an example configuration can be found at [example-config.json](example-config.json). + +```json + { + 'outstation_ip_str': '0.0.0.0', + 'port': 20000, + 'masterstation_id_int': 2, + 'outstation_id_int': 1 +} +``` + +Note: as part of the Volttron configuration framework, this file will be added to +the `$VOLTTRON_HOME/agents////` as `config`, +e.g. `~/.volttron/agents/94e54843-4bd4-45d7-9a92-3d18588b5682/dnp3_outstation_agentagent-0.2.0/dnp3_outstation_agentagent-0.2.0.dist-info/config` + +# Demonstration + +If you don't have a dedicated DNP3 Master to test the DNP3 outstation agent against, you can setup a local DNP3 Master +instead. This DNP3 Master will +be hosted at localhost on a specific port (port 20000 by default, i.e. 127.0.0.1:20000). +This Master will communicate with the DNP3 outstation agent. + +To setup a local master, we can utilize the dnp3demo module from the dnp3-python dependency. For more information about +the dnp3demo module, please refer +to [dnp3demo-Module.md](https://github.com/VOLTTRON/dnp3-python/blob/develop/docs/dnp3demo-Module.md) + +## Setup DNP3 Master + +1. Clone this repo. + + +1. Verify the dnp3demo module is installed and working properly: + + Note that the dnp3demo module is part of the dnp3-python dependency and should be available once the DNP3 Outstation + Agent is installed. To verify, run the following commands and expect similar output (note that the version might be + different) : + + ```shell + python -m venv env + source env/bin/activate + ``` + +1. Install [openleadr](https://pypi.org/project/openleadr/): + + ```shell + (volttron) $ pip list | grep dnp3 + dnp3-python 0.2.3b2 + + (volttron) $ dnp3demo + ms(1676667858612) INFO manager - Starting thread (0) + ms(1676667858612) WARN server - Address already in use + 2023-02-17 15:04:18,612 dnp3demo.data_retrieval_demo DEBUG Initialization complete. OutStation in command loop. + ms(1676667858613) INFO manager - Starting thread (0) + channel state change: OPENING + ms(1676667858613) INFO tcpclient - Connecting to: 127.0.0.1 + 2023-02-17 15:04:18,613 dnp3demo.data_retrieval_demo DEBUG Initialization complete. Master Station in command loop. + ms(1676667858613) INFO tcpclient - Connected to: 127.0.0.1 + channel state change: OPEN + 2023-02-17 15:04:19.615457 ============count 1 + ====== Outstation update index 0 with 8.465443888876885 + ====== Outstation update index 1 with 17.77180643225464 + ====== Outstation update index 2 with 27.730343174887107 + ====== Outstation update index 0 with False + ====== Outstation update index 1 with True + + ... + + 2023-02-17 15:04:22,839 dnp3demo.data_retrieval_demo DEBUG Exiting. + channel state change: CLOSED + channel state change: SHUTDOWN + ms(1676667864841) INFO manager - Exiting thread (0) + ms(1676667870850) INFO manager - Exiting thread (0) + ``` + +1. Run a DNP3 Master at local (with the default parameters) + + Assuming the DNP3 outstation agent is running, run the following commands and expect the similar output. + ```shell + (volttron) $ dnp3demo master + dnp3demo.run_master {'command': 'master', 'master_ip': '0.0.0.0', 'outstation_ip': '127.0.0.1', 'port': 20000, 'master_id': 2, 'outstation_id': 1} + ms(1676668214630) INFO manager - Starting thread (0) + 2023-02-17 15:10:14,630 control_workflow_demo INFO Communication Config + 2023-02-17 15:10:14,630 control_workflow_demo INFO Communication Config + 2023-02-17 15:10:14,630 control_workflow_demo INFO Communication Config + channel state change: OPENING + ms(1676668214630) INFO tcpclient - Connecting to: 127.0.0.1 + ms(1676668214630) INFO tcpclient - Connected to: 127.0.0.1 + channel state change: OPEN + 2023-02-17 15:10:14,630 control_workflow_demo DEBUG Initialization complete. Master Station in command loop. + 2023-02-17 15:10:14,630 control_workflow_demo DEBUG Initialization complete. Master Station in command loop. + 2023-02-17 15:10:14,630 control_workflow_demo DEBUG Initialization complete. Master Station in command loop. + ==== Master Operation MENU ================================== + - set analog-output point value (for remote control) + - set binary-output point value (for remote control) +
- display/polling (outstation) database + - display configuration + ================================================================= + + ======== Your Input Here: ==(master)====== + + ``` + + Note: if the dnp3 agent is not running, you might observe the following output instead + ``` + Start retry... + Communication error. + Communication Config {'masterstation_ip_str': '0.0.0.0', 'outstation_ip_str': '127.0.0.1', 'port': 20000, 'masterstation_id_int': 2, 'outstation_id_int': 1} + ... + ``` + + This Master station runs at port 20000 by default. Please see `dnp3demo master -h` for configuration options. + Note: If using customized master parameter, please make sure the DNP3 Outstation Agent is configured accordingly. + Please refer to [DNP3-Primer.md](https://github.com/VOLTTRON/dnp3-python/blob/develop/docs/DNP3-Primer.md) for DNP3 + protocol fundamentals including connection settings. + +## Basic operation demo + +The dnp3demo master submodule is an interactive CLI tool to communicate with an outstation. The available options are +shown in the "Master Operation MENU" and should be self-explanatory. Here we can demonstrate
and commands. + +1.
- display/polling (outstation) database + + ```shell + ======== Your Input Here: ==(master)====== + dd + You chose < dd > - display database + {'Analog': {0: 0.0, 1: 0.0, 2: 0.0, 3: 0.0, 4: 0.0, 5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0, 9: 0.0}, 'AnalogOutputStatus': {0: 0.0, 1: 0.0, 2: 0.0, 3: 0.0, 4: 0.0, 5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0, 9: 0.0}, 'Binary': {0: False, 1: False, 2: False, 3: False, 4: False, 5: False, 6: False, 7: False, 8: False, 9: False}, 'BinaryOutputStatus': {0: False, 1: False, 2: False, 3: False, 4: False, 5: False, 6: False, 7: False, 8: False, 9: False}} + ==== Master Operation MENU ================================== + - set analog-output point value (for remote control) + - set binary-output point value (for remote control) +
- display/polling (outstation) database + - display configuration + ================================================================= + + ``` + + Note that an outstation is initialed with "0.0" for Analog-type points, and "False" for Binary-type points, hence the + output displayed above. + +1. - set analog-output point value (for remote control) + + ```shell + ======== Your Input Here: ==(master)====== + ao + You chose - set analog-output point value + Type in and . Separate with space, then hit ENTER. + Type 'q', 'quit', 'exit' to main menu. + + ======== Your Input Here: ==(master)====== + 0.1233 0 + SUCCESS {'AnalogOutputStatus': {0: 0.1233, 1: 0.0, 2: 0.0, 3: 0.0, 4: 0.0, 5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0, 9: 0.0}} + You chose - set analog-output point value + Type in and . Separate with space, then hit ENTER. + Type 'q', 'quit', 'exit' to main menu. + + ======== Your Input Here: ==(master)====== + 1.3223 1 + SUCCESS {'AnalogOutputStatus': {0: 0.1233, 1: 1.3223, 2: 0.0, 3: 0.0, 4: 0.0, 5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0, 9: 0.0}} + You chose - set analog-output point value + Type in and . Separate with space, then hit ENTER. + Type 'q', 'quit', 'exit' to main menu. + + ======== Your Input Here: ==(master)====== + q + ==== Master Operation MENU ================================== + - set analog-output point value (for remote control) + - set binary-output point value (for remote control) +
- display/polling (outstation) database + - display configuration + ================================================================= + + ======== Your Input Here: ==(master)====== + dd + You chose < dd > - display database + {'Analog': {0: 0.0, 1: 0.0, 2: 0.0, 3: 0.0, 4: 0.0, 5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0, 9: 0.0}, 'AnalogOutputStatus': {0: 0.1233, 1: 1.3223, 2: 0.0, 3: 0.0, 4: 0.0, 5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0, 9: 0.0}, 'Binary': {0: False, 1: False, 2: False, 3: False, 4: False, 5: False, 6: False, 7: False, 8: False, 9: False}, 'BinaryOutputStatus': {0: False, 1: False, 2: False, 3: False, 4: False, 5: False, 6: False, 7: False, 8: False, 9: False}} + + ``` + + Explain of the operation and expected output + * We type "ao" (stands for "analog output") to enter the set point dialog. + * We set AnalogOut index0==0.1233, (the prompt indicates the operation is successful.) + * Then, we set AnalogOut index1==1.3223, (again, the prompt indicates the operation is successful.) + * We type "q" (stands for "quit") to exit the set point dialog. + * We use "dd" command and verified that AnalogOutput values are consistent to what we set ealier. + +1. Bonus script for running DNP3 outstation agent interactively + + Similar to the interactive dnp3demo master submodule, we can run the dnp3 outstation agent interactively from the + command line using [run_dnp3_outstation_agent_script.py](demo-scripts/run_dnp3_outstation_agent_script.py). + + ```shell + (volttron) $ python services/core/DNP3OutstationAgent/demo-scripts/run_dnp3_outstation_agent_script.py + ... + 2023-02-17 15:58:04,123 volttron.platform.vip.agent.core INFO: Connected to platform: router: a2ae7a58-6ce7-4386-b5eb-71e386075c15 version: 1.0 identity: e91d54f6-d4ff-4fe5-afcb-cf8f360e84af + 2023-02-17 15:58:04,123 volttron.platform.vip.agent.core DEBUG: Running onstart methods. + 2023-02-17 15:58:07,137 volttron.platform.vip.agent.subsystems.auth WARNING: Auth entry not found for e91d54f6-d4ff-4fe5-afcb-cf8f360e84af: rpc_method_authorizations not updated. If this agent does have an auth entry, verify that the 'identity' field has been included in the auth entry. This should be set to the identity of the agent + ========================= MENU ================================== + - set analog-input point value + - set analog-output point value + - set binary-input point value + - set binary-output point value + +
- display database + - display (outstation) info + - config then restart outstation + + ``` + + dd command + ```shell + ======== Your Input Here: ==(DNP3 OutStation Agent)====== + dd + You chose
- display database + {'Analog': {'0': None, '1': None, '2': None, '3': None, '4': None, '5': None, '6': None, '7': None, '8': None, '9': None}, 'AnalogOutputStatus': {'0': 0.1233, '1': 1.3223, '2': None, '3': None, '4': None, '5': None, '6': None, '7': None, '8': None, '9': None}, 'Binary': {'0': None, '1': None, '2': None, '3': None, '4': None, '5': None, '6': None, '7': None, '8': None, '9': None}, 'BinaryOutputStatus': {'0': None, '1': None, '2': None, '3': None, '4': None, '5': None, '6': None, '7': None, '8': None, '9': None}} + + ``` + + Note: [run_dnp3_outstation_agent_script.py](demo-scripts/run_dnp3_outstation_agent_script.py) script is a wrapper on + the dnp3demo outstation submodle. For details about the interactive dnp3 station operations, please refer + to [dnp3demo-Module.md](https://github.com/VOLTTRON/dnp3-python/blob/develop/docs/dnp3demo-Module.md) diff --git a/services/core/DNP3OutstationAgent/config b/services/core/DNP3OutstationAgent/example-config.json similarity index 100% rename from services/core/DNP3OutstationAgent/config rename to services/core/DNP3OutstationAgent/example-config.json From 33de510f7534e29d908bf02dea67f3ea07b74344 Mon Sep 17 00:00:00 2001 From: Kefei Mo Date: Fri, 17 Feb 2023 16:10:28 -0600 Subject: [PATCH 3/7] minor cleanup README --- services/core/DNP3OutstationAgent/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/core/DNP3OutstationAgent/README.md b/services/core/DNP3OutstationAgent/README.md index f283375d9e..b45fe2ef6b 100644 --- a/services/core/DNP3OutstationAgent/README.md +++ b/services/core/DNP3OutstationAgent/README.md @@ -13,7 +13,7 @@ layer, and transport layer. Also, DNP3 can be transmitted over a serial bus conn # Prerequisites -* > Python 3.8, < Python 4.0 +* Python 3.8 + # Installation @@ -93,7 +93,7 @@ Below is an example configuration can be found at [example-config.json](example- 'port': 20000, 'masterstation_id_int': 2, 'outstation_id_int': 1 -} + } ``` Note: as part of the Volttron configuration framework, this file will be added to From ba42907e140141813447eb8c522f563026a05e66 Mon Sep 17 00:00:00 2001 From: Kefei Mo Date: Fri, 17 Feb 2023 16:11:13 -0600 Subject: [PATCH 4/7] minor cleanup README --- services/core/DNP3OutstationAgent/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/core/DNP3OutstationAgent/README.md b/services/core/DNP3OutstationAgent/README.md index b45fe2ef6b..b3bc7cc744 100644 --- a/services/core/DNP3OutstationAgent/README.md +++ b/services/core/DNP3OutstationAgent/README.md @@ -87,7 +87,7 @@ layer, and transport layer. Also, DNP3 can be transmitted over a serial bus conn The required parameters for this agent are "outstation_ip_str", "port", "masterstation_id_int", and "outstation_id_int". Below is an example configuration can be found at [example-config.json](example-config.json). -```json +``` { 'outstation_ip_str': '0.0.0.0', 'port': 20000, From 752a391370439d898ddab05a49f1186880705f34 Mon Sep 17 00:00:00 2001 From: Kefei Mo Date: Thu, 11 May 2023 22:30:07 -0500 Subject: [PATCH 5/7] updated dnp3 README, updated to comply dnp3-python 0.3.0 --- services/core/DNP3OutstationAgent/README.md | 28 ++------ .../dnp3_outstation_agent/agent.py | 67 +++---------------- .../DNP3OutstationAgent/example-config.json | 6 +- 3 files changed, 20 insertions(+), 81 deletions(-) diff --git a/services/core/DNP3OutstationAgent/README.md b/services/core/DNP3OutstationAgent/README.md index b3bc7cc744..5aee8765a1 100644 --- a/services/core/DNP3OutstationAgent/README.md +++ b/services/core/DNP3OutstationAgent/README.md @@ -42,7 +42,7 @@ layer, and transport layer. Also, DNP3 can be transmitted over a serial bus conn Install the DNP3 Outstation agent with the following command: ```shell - (volttron) $ vctl install -s services/core/DNP3OutstationAgent/ \ + (volttron) $ vctl install services/core/DNP3OutstationAgent/ \ --agent-config \ --tag \ --vip-identity \ @@ -54,7 +54,7 @@ layer, and transport layer. Also, DNP3 can be transmitted over a serial bus conn dnp3-outstation-agent". ```shell - (volttron) $ vctl install services/core/DNP3OutstationAgent/ \ + (volttron) $ vctl install ./services/core/DNP3OutstationAgent/ \ --agent-config services/core/DNP3OutstationAgent/example-config.json \ --tag dnp3-outstation-agent \ --vip-identity dnp3-outstation-agent \ @@ -84,15 +84,15 @@ layer, and transport layer. Also, DNP3 can be transmitted over a serial bus conn # Agent Configuration -The required parameters for this agent are "outstation_ip_str", "port", "masterstation_id_int", and "outstation_id_int". +The required parameters for this agent are "outstation_ip", "port", "master_id", and "outstation_id". Below is an example configuration can be found at [example-config.json](example-config.json). ``` { - 'outstation_ip_str': '0.0.0.0', + 'outstation_ip': '0.0.0.0', 'port': 20000, - 'masterstation_id_int': 2, - 'outstation_id_int': 1 + 'master_id': 2, + 'outstation_id': 1 } ``` @@ -113,21 +113,7 @@ to [dnp3demo-Module.md](https://github.com/VOLTTRON/dnp3-python/blob/develop/doc ## Setup DNP3 Master -1. Clone this repo. - - -1. Verify the dnp3demo module is installed and working properly: - - Note that the dnp3demo module is part of the dnp3-python dependency and should be available once the DNP3 Outstation - Agent is installed. To verify, run the following commands and expect similar output (note that the version might be - different) : - - ```shell - python -m venv env - source env/bin/activate - ``` - -1. Install [openleadr](https://pypi.org/project/openleadr/): +1. Verify [dnp3-python](https://pypi.org/project/dnp3-python/) is installed properly: ```shell (volttron) $ pip list | grep dnp3 diff --git a/services/core/DNP3OutstationAgent/dnp3_outstation_agent/agent.py b/services/core/DNP3OutstationAgent/dnp3_outstation_agent/agent.py index 5807c5770a..da4431ce06 100644 --- a/services/core/DNP3OutstationAgent/dnp3_outstation_agent/agent.py +++ b/services/core/DNP3OutstationAgent/dnp3_outstation_agent/agent.py @@ -9,7 +9,7 @@ from volttron.platform.agent import utils from volttron.platform.vip.agent import Agent, Core, RPC -from dnp3_python.dnp3station.outstation_new import MyOutStationNew +from dnp3_python.dnp3station.outstation import MyOutStation from pydnp3 import opendnp3 @@ -72,20 +72,20 @@ def __init__(self, setting1={}, setting2="some/random/topic", **kwargs): # TODO: new-feature: load_config from config store # config_at_configstore = - self.default_config = {'outstation_ip_str': '0.0.0.0', 'port': 21000, - 'masterstation_id_int': 2, 'outstation_id_int': 1} + self.default_config = {'outstation_ip': '0.0.0.0', 'port': 20000, + 'master_id': 2, 'outstation_id': 1} # agent configuration using volttron config framework # get_volttron_cofig, set_volltron_config self._volttron_config: dict # for dnp3 features try: - self.outstation_application = MyOutStationNew(**config_when_installed) + self.outstation_application = MyOutStation(**config_when_installed) _log.info(f"init dnp3 outstation with {config_when_installed}") self._volttron_config = config_when_installed except Exception as e: _log.error(e) - self.outstation_application = MyOutStationNew(**self.default_config) + self.outstation_application = MyOutStation(**self.default_config) _log.info(f"init dnp3 outstation with {self.default_config}") self._volttron_config = self.default_config # self.outstation_application.start() # moved to onstart @@ -103,12 +103,12 @@ def _get_volttron_config(self): def _set_volttron_config(self, **kwargs): """set self._volttron_config using **kwargs. EXAMPLE - self.default_config = {'outstation_ip_str': '0.0.0.0', 'port': 21000, - 'masterstation_id_int': 2, 'outstation_id_int': 1} + self.default_config = {'outstation_ip': '0.0.0.0', 'port': 21000, + 'master_id': 2, 'outstation_id': 1} set_volttron_config(port=30000, unused_key="unused") # outcome - self.default_config = {'outstation_ip_str': '0.0.0.0', 'port': 30000, - 'masterstation_id_int': 2, 'outstation_id_int': 1, + self.default_config = {'outstation_ip': '0.0.0.0', 'port': 30000, + 'master_id': 2, 'outstation_id': 1, 'unused_key': 'unused'} """ self._volttron_config.update(kwargs) @@ -123,7 +123,7 @@ def outstation_reset(self, **kwargs): Note: will start a new outstation instance and the old database data will lose""" self._set_volttron_config(**kwargs) try: - outstation_app_new = MyOutStationNew(**self._volttron_config) + outstation_app_new = MyOutStation(**self._volttron_config) self.outstation_application.shutdown() self.outstation_application = outstation_app_new self.outstation_application.start() @@ -145,43 +145,6 @@ def outstation_get_is_connected(self): """expose is_connected, note: status, property""" return self.outstation_application.is_connected - # @RPC.export - # def demo_config_store(self): - # """ - # Example return - # {'config_list': "['config', 'testagent.config']", - # 'config': "{'setting1': 2, 'setting2': 'some/random/topic2'}", - # 'testagent.config': "{'setting1': 2, 'setting2': 'some/random/topic2', - # 'setting3': True, 'setting4': False, 'setting5': 5.1, 'setting6': [1, 2, 3, 4], - # 'setting7': {'setting7a': 'a', 'setting7b': 'b'}}"} - # - # on command line - # vctl config store test-agent testagent.config /home/kefei/project-local/volttron/services/core/DNP3OutstationAgent/config - # vctl config get test-agent testagent.config - # """ - # - # msg_dict = dict() - # # vip.config.set() - # # config_demo = {"set1": "setting1-xxxxxxxxx", - # # "set2": "setting2-xxxxxxxxx"} - # # # Set a default configuration to ensure that self.configure is called immediately to setup - # # # the agent. - # # # self.vip.config.set_default("config", default_config) # set_default can only be used before onstart - # # self.vip.config.set(config_name="config_2", contents=config_demo, - # # trigger_callback=False, send_update=True) - # - # # vip.config.list() - # config_list = self.vip.config.list() - # msg_dict["config_list"] = str(config_list) - # - # # vip.config.get() - # if config_list: - # for config_name in config_list: - # config = self.vip.config.get(config_name) - # msg_dict[config_name] = str(config) - # - # return msg_dict - @RPC.export def outstation_apply_update_analog_input(self, val, index): """public interface to update analog-input point value @@ -243,16 +206,6 @@ def outstation_apply_update_binary_output(self, val, index): def outstation_display_db(self): return self.outstation_application.db_handler.db - # @RPC.export - # def playground(self, val, index): - # pass - # - # - # - # _log.debug("====================") - # - # return self.outstation_display_db() - def configure(self, config_name, action, contents): """ # TODO: clean-up this bizarre method diff --git a/services/core/DNP3OutstationAgent/example-config.json b/services/core/DNP3OutstationAgent/example-config.json index 96fc8d2fc3..567306a8e3 100644 --- a/services/core/DNP3OutstationAgent/example-config.json +++ b/services/core/DNP3OutstationAgent/example-config.json @@ -1,4 +1,4 @@ -{'outstation_ip_str': '0.0.0.0', +{'outstation_ip': '0.0.0.0', 'port': 20000, -'masterstation_id_int': 2, -'outstation_id_int': 1} +'master_id': 2, +'outstation_id': 1} From f9698a131861b7baa4250e15ecd65b5afae3ffcd Mon Sep 17 00:00:00 2001 From: Kefei Mo Date: Fri, 12 May 2023 12:52:39 -0500 Subject: [PATCH 6/7] pin to dnp3-python=0.2.3b3 to walkaround pybind11 issue, in-sync to use modular dnp3-agent stucture --- services/core/DNP3OutstationAgent/README.md | 8 +- .../run_dnp3_outstation_agent_script.py | 161 ++--- .../dnp3_outstation_agent/agent.py | 684 ++++++++++++------ .../tests/test_dnp3_agent.py | 525 ++++++-------- 4 files changed, 757 insertions(+), 621 deletions(-) diff --git a/services/core/DNP3OutstationAgent/README.md b/services/core/DNP3OutstationAgent/README.md index 5aee8765a1..048ef93416 100644 --- a/services/core/DNP3OutstationAgent/README.md +++ b/services/core/DNP3OutstationAgent/README.md @@ -37,12 +37,18 @@ layer, and transport layer. Also, DNP3 can be transmitted over a serial bus conn 2f platform_driveragent-4.0 platform.driver platform_driver ``` +1. (If not satisfied yet,) install [dnp3-python](https://pypi.org/project/dnp3-python/) dependency. + + ```shell + (volttron) $ pip install dnp3-python==0.3.0b1 + ``` + 1. Install and start the DNP3 Outstation Agent. Install the DNP3 Outstation agent with the following command: ```shell - (volttron) $ vctl install services/core/DNP3OutstationAgent/ \ + (volttron) $ vctl install \ --agent-config \ --tag \ --vip-identity \ diff --git a/services/core/DNP3OutstationAgent/demo-scripts/run_dnp3_outstation_agent_script.py b/services/core/DNP3OutstationAgent/demo-scripts/run_dnp3_outstation_agent_script.py index 5895520840..c061c9a666 100644 --- a/services/core/DNP3OutstationAgent/demo-scripts/run_dnp3_outstation_agent_script.py +++ b/services/core/DNP3OutstationAgent/demo-scripts/run_dnp3_outstation_agent_script.py @@ -3,51 +3,55 @@ import argparse from pydnp3 import opendnp3 -from dnp3_python.dnp3station.outstation_new import MyOutStationNew +# from dnp3_python.dnp3station.outstation import MyOutStation from time import sleep from volttron.platform.vip.agent.utils import build_agent -from services.core.DNP3OutstationAgent.dnp3_outstation_agent import agent # agent +from services.core.DNP3OutstationAgent.dnp3_outstation_agent.agent import Dnp3Agent as Dnp3OutstationAgent # agent +from volttron.platform.vip.agent import Agent + +import logging +import sys +import argparse + +# from pydnp3 import opendnp3 +# from dnp3_python.dnp3station.outstation_new import MyOutStationNew + +from time import sleep + +# from volttron.client.vip.agent import build_agent +# from dnp3_outstation.agent import Dnp3OutstationAgent +# from volttron.client.vip.agent import Agent + +DNP3_AGENT_ID = "dnp3_outstation" stdout_stream = logging.StreamHandler(sys.stdout) 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") +# _log = logging.getLogger("control_workflow_demo") _log.addHandler(stdout_stream) -_log.setLevel(logging.DEBUG) +_log.setLevel(logging.INFO) def input_prompt(display_str=None) -> str: if display_str is None: - display_str = """ + display_str = f""" ======== Your Input Here: ==(DNP3 OutStation Agent)====== """ return input(display_str) def setup_args(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: - - # Adding optional argument - # parser.add_argument("-mip", "--master-ip", action="store", default="0.0.0.0", type=str, - # metavar="") - # note: volttron agent require post-configuration - # parser.add_argument("-oip", "--outstation-ip", action="store", default="0.0.0.0", type=str, - # metavar="") - # parser.add_argument("-p", "--port", action="store", default=20000, type=int, - # metavar="") - # parser.add_argument("-mid", "--master-id", action="store", default=2, type=int, - # metavar="") - # parser.add_argument("-oid", "--outstation-id", action="store", default=1, type=int, - # metavar="") - parser.add_argument("-aid", "--agent-identity", action="store", default="dnp3-outstation-agent", type=str, - metavar="", help="specify agent identity (parsed as peer-name for rpc call), default 'dnp3-outstation-agent'.") + parser.add_argument("-aid", "--agent-identity", action="store", default=DNP3_AGENT_ID, type=str, + metavar="", + help=f"specify agent identity (parsed as peer-name for rpc call), default '{DNP3_AGENT_ID}'.") return parser def print_menu(): - welcome_str = """\ + welcome_str = rf""" ========================= MENU ================================== - set analog-input point value - set analog-output point value @@ -57,60 +61,50 @@ def print_menu():
- display database - display (outstation) info - config then restart outstation -=================================================================\ +================================================================= """ print(welcome_str) -def main(parser=None, *args, **kwargs): +def check_agent_id_existence(agent_id: str, vip_agent: Agent): + rs = vip_agent.vip.peerlist.list().get(5) + if agent_id not in rs: + raise ValueError(f"There is no agent named `{agent_id}` available on the message bus." + f"Available peers are {rs}") + # _log.warning(f"There is no agent named `{agent_id}` available on the message bus." + # f"Available peers are {rs}") + + +def main(parser=None, *args, **kwargs): if parser is None: # Initialize parser parser = argparse.ArgumentParser( prog="dnp3-outstation", - description="Run a dnp3 outstation agent. Specify agent identity, by default `dnp3-outstation-agent`", + description=f"Run a dnp3 outstation agent. Specify agent identity, by default `{DNP3_AGENT_ID}`", # epilog="Thanks for using %(prog)s! :)", ) parser = setup_args(parser) # Read arguments from command line args = parser.parse_args() - - # dict to store args.Namespace - # d_args = vars(args) - # print(__name__, d_args) - # create volttron vip agent to evoke dnp3-agent rpc calls a = build_agent() - peer = args.agent_identity # note: default "dnp3-outstation-agent" or "test-agent" - # peer_method = "outstation_apply_update_analog_input" + peer = args.agent_identity # note: default {DNP3_AGENT_ID} or "test-agent" + # print(f"========= peer {peer}") + check_agent_id_existence(peer, a) def get_db_helper(): - _peer_method = "outstation_get_db" + _peer_method = Dnp3OutstationAgent.display_outstation_db.__name__ _db_print = a.vip.rpc.call(peer, _peer_method).get(timeout=10) return _db_print def get_config_helper(): - _peer_method = "outstation_get_config" + _peer_method = Dnp3OutstationAgent.get_outstation_config.__name__ _config_print = a.vip.rpc.call(peer, _peer_method).get(timeout=10) _config_print.update({"peer": peer}) return _config_print - # outstation_application = MyOutStationNew( - # # masterstation_ip_str=args.master_ip, - # outstation_ip_str=args.outstation_ip, - # port=args.port, - # masterstation_id_int=args.master_id, - # outstation_id_int=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("Communication Config", outstation_application.get_config()) - # outstation_application.start() - # _log.debug('Initialization complete. Outstation in command loop.') - - sleep(3) + sleep(2) # Note: if without sleep(2) there will be a glitch when first send_select_and_operate_command # (i.e., all the values are zero, [(0, 0.0), (1, 0.0), (2, 0.0), (3, 0.0)])) # since it would not update immediately @@ -118,37 +112,30 @@ def get_config_helper(): count = 0 while count < 1000: # sleep(1) # Note: hard-coded, master station query every 1 sec. - count += 1 # print(f"=========== Count {count}") - - - - # TODO: figure out how to list existing agents, e.g., the following code block cannot be captured - # try: - # x = a.vip.rpc.call(agent_not_exist, "outstation_get_is_connectedsddsdf", ).get(timeout=10) - # print(x) - # except Exception as e: - # print(f"++++++++++++++ e {e}") - - if a.vip.rpc.call(peer, "outstation_get_is_connected",).get(timeout=10): + peer_method = Dnp3OutstationAgent.is_outstation_connected + if a.vip.rpc.call(peer, peer_method.__name__, ).get(timeout=10): # print("Communication Config", master_application.get_config()) print_menu() else: - print("Communication error.") - # print("Communication Config", outstation_application.get_config()) + print_menu() + print("!!!!!!!!! WARNING: The outstation is NOT connected !!!!!!!!!") print(get_config_helper()) - print("Start retry...") - sleep(2) - continue - - - + # else: + # print("Communication error.") + # # print("Communication Config", outstation_application.get_config()) + # print(get_config_helper()) + # print("Start retry...") + # sleep(2) + # continue + + # print_menu() option = input_prompt() # Note: one of ["ai", "ao", "bi", "bo", "dd", "dc"] while True: if option == "ai": print("You chose - set analog-input point value") - print("Type in and . Separate with space, then hit ENTER.") + 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"]: @@ -158,8 +145,8 @@ def get_config_helper(): index = int(input_str.split(" ")[1]) # outstation_application.apply_update(opendnp3.Analog(value=p_val), index) # result = {"Analog": outstation_application.db_handler.db.get("Analog")} - method = agent.Dnp3Agent.outstation_apply_update_analog_input - peer_method = method.__name__ # i.e., "outstation_apply_update_analog_input" + method = Dnp3OutstationAgent.apply_update_analog_input + peer_method = method.__name__ # i.e., "apply_update_analog_input" response = a.vip.rpc.call(peer, peer_method, p_val, index).get(timeout=10) result = {"Analog": get_db_helper().get("Analog")} print(result) @@ -169,7 +156,7 @@ def get_config_helper(): print(e) elif option == "ao": print("You chose - set analog-output point value") - print("Type in and . Separate with space, then hit ENTER.") + print("Type in and . Separate with space, then hit ENTER. e.g., `0.1234, 0`.") print("Type 'q', 'quit', 'exit' to main menu.") input_str = input_prompt() if input_str in ["q", "quit", "exit"]: @@ -177,8 +164,8 @@ def get_config_helper(): try: p_val = float(input_str.split(" ")[0]) index = int(input_str.split(" ")[1]) - method = agent.Dnp3Agent.outstation_apply_update_analog_output - peer_method = method.__name__ # i.e., "outstation_apply_update_analog_input" + method = Dnp3OutstationAgent.apply_update_analog_output + peer_method = method.__name__ # i.e., "apply_update_analog_input" response = a.vip.rpc.call(peer, peer_method, p_val, index).get(timeout=10) result = {"AnalogOutputStatus": get_db_helper().get("AnalogOutputStatus")} print(result) @@ -188,7 +175,7 @@ def get_config_helper(): print(e) elif option == "bi": print("You chose - set binary-input point value") - print("Type in <[1/0]> and . Separate with space, then hit ENTER.") + 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 @@ -199,7 +186,7 @@ def get_config_helper(): else: p_val = True if p_val_input == "1" else False index = int(input_str.split(" ")[1]) - method = agent.Dnp3Agent.outstation_apply_update_binary_input + method = Dnp3OutstationAgent.apply_update_binary_input peer_method = method.__name__ response = a.vip.rpc.call(peer, peer_method, p_val, index).get(timeout=10) result = {"Binary": get_db_helper().get("Binary")} @@ -210,7 +197,7 @@ def get_config_helper(): 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.") + 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 @@ -221,7 +208,7 @@ def get_config_helper(): else: p_val = True if p_val_input == "1" else False index = int(input_str.split(" ")[1]) - method = agent.Dnp3Agent.outstation_apply_update_binary_output + method = Dnp3OutstationAgent.apply_update_binary_output peer_method = method.__name__ response = a.vip.rpc.call(peer, peer_method, p_val, index).get(timeout=10) result = {"BinaryOutputStatus": get_db_helper().get("BinaryOutputStatus")} @@ -232,30 +219,26 @@ def get_config_helper(): print(e) elif option == "dd": print("You chose
- display database") - # db_print = outstation_application.db_handler.db - # peer_method = "outstation_get_db" - # db_print = a.vip.rpc.call(peer, peer_method).get(timeout=10) - # print(db_print) print(get_db_helper()) sleep(2) break elif option == "di": print("You chose - display (outstation) info") - # print(outstation_application.get_config()) - # peer_method = "outstation_get_config" - # config_print = a.vip.rpc.call(peer, peer_method).get(timeout=10) print(get_config_helper()) sleep(3) break elif option == "cr": print("You chose - config then restart outstation") print(f"current self.volttron_config is {get_config_helper()}") - print("Type in , then hit ENTER. (Note: In this script, only support port configuration.)") - input_str = input_prompt() + print( + "Type in , then hit ENTER. e.g., `20000`." + "(Note: In this script, only support port configuration.)") + # input_str = input_prompt() + input_str = input() try: # set_volttron_config port_val = int(input_str) - method = agent.Dnp3Agent.outstation_reset + method = Dnp3OutstationAgent.update_outstation peer_method = method.__name__ response = a.vip.rpc.call(peer, peer_method, port=port_val).get(timeout=10) print("SUCCESS.", get_config_helper()) diff --git a/services/core/DNP3OutstationAgent/dnp3_outstation_agent/agent.py b/services/core/DNP3OutstationAgent/dnp3_outstation_agent/agent.py index da4431ce06..02f2520f51 100644 --- a/services/core/DNP3OutstationAgent/dnp3_outstation_agent/agent.py +++ b/services/core/DNP3OutstationAgent/dnp3_outstation_agent/agent.py @@ -9,8 +9,11 @@ from volttron.platform.agent import utils from volttron.platform.vip.agent import Agent, Core, RPC -from dnp3_python.dnp3station.outstation import MyOutStation +# from dnp3_python.dnp3station.outstation import MyOutStation as MyOutStationNew +from dnp3_python.dnp3station.outstation_new import MyOutStationNew from pydnp3 import opendnp3 +from typing import Callable, Dict + _log = logging.getLogger("Dnp3-agent") @@ -21,134 +24,173 @@ _log.addHandler(logging.StreamHandler(sys.stdout)) # Note: redirect stdout from dnp3 lib -def agent_main(config_path, **kwargs): - """ - Parses the Agent configuration and returns an instance of - the agent created using that configuration. - - Note: config_path is by convention under .volttron home path, called config, e.g. - /home/kefei/.volttron/agents/6745e0ef-b500-495a-a6e8-120ec0ead4fd/testeragent-0.5/testeragent-0.5.dist-info/config - - :param config_path: Path to a configuration file. - :type config_path: str - :returns: Tester - :rtype: Dnp3Agent - """ - # _log.info(f"======config_path {config_path}") - # Note: config_path is by convention under .volttron home path, called config, e.g. - # /home/kefei/.volttron/agents/6745e0ef-b500-495a-a6e8-120ec0ead4fd/testeragent-0.5/testeragent-0.5.dist-info/config - # Note: the config file is attached when running `python scripts/install-agent.py -c TestAgent/config` - # NOte: the config file attached in this way will not appear in the config store. - # (Need to explicitly using `vctl config store`) - try: - config: dict = utils.load_config(config_path) - except Exception as e: - _log.info(e) - config = {} - - if not config: - _log.info("Using Agent defaults for starting configuration.") - - setting1 = int(config.get('setting1', 1)) - setting2 = config.get('setting2', "some/random/topic") - - return Dnp3Agent(config, setting2, **kwargs) +# def agent_main(config_path, **kwargs): +# """ +# Parses the Agent configuration and returns an instance of +# the agent created using that configuration. +# +# Note: config_path is by convention under .volttron home path, called config, e.g. +# /home/kefei/.volttron/agents/6745e0ef-b500-495a-a6e8-120ec0ead4fd/testeragent-0.5/testeragent-0.5.dist-info/config +# +# :param config_path: Path to a configuration file. +# :type config_path: str +# :returns: Tester +# :rtype: Dnp3Agent +# """ +# # _log.info(f"======config_path {config_path}") +# # Note: config_path is by convention under .volttron home path, called config, e.g. +# # /home/kefei/.volttron/agents/6745e0ef-b500-495a-a6e8-120ec0ead4fd/testeragent-0.5/testeragent-0.5.dist-info/config +# # Note: the config file is attached when running `python scripts/install-agent.py -c TestAgent/config` +# # NOte: the config file attached in this way will not appear in the config store. +# # (Need to explicitly using `vctl config store`) +# try: +# config: dict = utils.load_config(config_path) +# except Exception as e: +# _log.info(e) +# config = {} +# +# if not config: +# _log.info("Using Agent defaults for starting configuration.") +# +# setting1 = int(config.get('setting1', 1)) +# setting2 = config.get('setting2', "some/random/topic") +# +# return Dnp3Agent(config, **kwargs) class Dnp3Agent(Agent): - """ - Dnp3 agent mainly to represent a dnp3 outstation - """ + """This is class is a subclass of the Volttron Agent; + This agent is an implementation of a DNP3 outstation; + The agent overrides @Core.receiver methods to modify agent life cycle behavior; + The agent exposes @RPC.export as public interface utilizing RPC calls. + """ - def __init__(self, setting1={}, setting2="some/random/topic", **kwargs): - # TODO: clean-up the bizarre signature. Note: may need to reinstall the agent for testing. + def __init__(self, config_path: str, **kwargs) -> None: super(Dnp3Agent, self).__init__(**kwargs) - _log.debug("vip_identity: " + self.core.identity) # Note: consistent with IDENTITY in `vctl status` - - # self.setting1 = setting1 - # self.setting2 = setting2 - config_when_installed = setting1 - # TODO: new-feature: load_config from config store - # config_at_configstore = - - self.default_config = {'outstation_ip': '0.0.0.0', 'port': 20000, - 'master_id': 2, 'outstation_id': 1} + # default_config, mainly for developing and testing purposes. + default_config: dict = {'outstation_ip': '0.0.0.0', 'port': 20000, 'master_id': 2, 'outstation_id': 1} # agent configuration using volttron config framework - # get_volttron_cofig, set_volltron_config - self._volttron_config: dict + # self._dnp3_outstation_config = default_config + config_from_path = self._parse_config(config_path) - # for dnp3 features + # TODO: improve this logic by refactoring out the MyOutstationNew init, + # and add config from "config store" try: - self.outstation_application = MyOutStation(**config_when_installed) - _log.info(f"init dnp3 outstation with {config_when_installed}") - self._volttron_config = config_when_installed + _log.info("Using config_from_path {config_from_path}") + self._dnp3_outstation_config = config_from_path + self.outstation_application = MyOutStationNew(**self._dnp3_outstation_config) except Exception as e: _log.error(e) - self.outstation_application = MyOutStation(**self.default_config) - _log.info(f"init dnp3 outstation with {self.default_config}") - self._volttron_config = self.default_config - # self.outstation_application.start() # moved to onstart - - # Set a default configuration to ensure that self.configure is called immediately to setup - # the agent. - self.vip.config.set_default(config_name="default-config", contents=self.default_config) - self.vip.config.set_default(config_name="_volttron_config", contents=self._volttron_config) - # Hook self.configure up to changes to the configuration file "config". - self.vip.config.subscribe(self.configure, actions=["NEW", "UPDATE"], pattern="config") - - def _get_volttron_config(self): - return self._volttron_config - - def _set_volttron_config(self, **kwargs): - """set self._volttron_config using **kwargs. - EXAMPLE - self.default_config = {'outstation_ip': '0.0.0.0', 'port': 21000, - 'master_id': 2, 'outstation_id': 1} - set_volttron_config(port=30000, unused_key="unused") - # outcome - self.default_config = {'outstation_ip': '0.0.0.0', 'port': 30000, - 'master_id': 2, 'outstation_id': 1, - 'unused_key': 'unused'} - """ - self._volttron_config.update(kwargs) - _log.info(f"Updated self._volttron_config to {self._volttron_config}") - return {"_volttron_config": self._get_volttron_config()} + _log.info(f"Failed to use config_from_path {config_from_path}" + f"Using default_config {default_config}") + self._dnp3_outstation_config = default_config + self.outstation_application = MyOutStationNew(**self._dnp3_outstation_config) + + # SubSystem/ConfigStore + self.vip.config.set_default("config", default_config) + self.vip.config.subscribe( + self._config_callback_dummy, # TODO: cleanup: used to be _configure_ven_client + actions=["NEW", "UPDATE"], + pattern="config", + ) # TODO: understand what vip.config.subscribe does + + @property + def dnp3_outstation_config(self): + return self._dnp3_outstation_config + + @dnp3_outstation_config.setter + def dnp3_outstation_config(self, config: dict): + # TODO: add validation + self._dnp3_outstation_config = config + + def _config_callback_dummy(self, config_name: str, action: str, + contents: Dict) -> None: + pass + + @Core.receiver("onstart") + def onstart(self, sender, **kwargs): + """ + This is method is called once the Agent has successfully connected to the platform. + This is a good place to setup subscriptions if they are not dynamic or + do any other startup activities that require a connection to the message bus. + Called after any configurations methods that are called at startup. + Usually not needed if using the configuration store. + """ + + # for dnp3 outstation + self.outstation_application.start() + + # Example publish to pubsub + # self.vip.pubsub.publish('pubsub', "some/random/topic", message="HI!") + # + # # Example RPC call + # # self.vip.rpc.call("some_agent", "some_method", arg1, arg2) + # pass + # self._create_subscriptions(self.setting2) + + # ***************** Helper methods ******************** + def _parse_config(self, config_path: str) -> Dict: + """Parses the agent's configuration file. + + :param config_path: The path to the configuration file + :return: The configuration + """ + # TODO: added capability to configuration based on tabular config file (e.g., csv) + try: + config = utils.load_config(config_path) + except NameError as err: + _log.exception(err) + raise err + except Exception as err: + _log.error("Error loading configuration: {}".format(err)) + config = {} + # print(f"============= def _parse_config config {config}") + if not config: + raise Exception("Configuration cannot be empty.") + return config @RPC.export - def outstation_reset(self, **kwargs): - """update`self._volttron_config`, then init a new outstation. + def rpc_dummy(self) -> str: + """ + For testing rpc call + """ + return "This is a dummy rpc call" + @RPC.export + def reset_outstation(self): + """update`self._dnp3_outstation_config`, then init a new outstation. For post-configuration and immediately take effect. Note: will start a new outstation instance and the old database data will lose""" - self._set_volttron_config(**kwargs) + # self.dnp3_outstation_config(**kwargs) + # TODO: this method might be refactored as internal helper method for `update_outstation` try: - outstation_app_new = MyOutStation(**self._volttron_config) self.outstation_application.shutdown() + outstation_app_new = MyOutStationNew(**self.dnp3_outstation_config) self.outstation_application = outstation_app_new self.outstation_application.start() + _log.info(f"Outstation has restarted") except Exception as e: _log.error(e) @RPC.export - def outstation_get_db(self): + def display_outstation_db(self) -> dict: """expose db""" return self.outstation_application.db_handler.db @RPC.export - def outstation_get_config(self): + def get_outstation_config(self) -> dict: """expose get_config""" return self.outstation_application.get_config() @RPC.export - def outstation_get_is_connected(self): + def is_outstation_connected(self) -> bool: """expose is_connected, note: status, property""" return self.outstation_application.is_connected @RPC.export - def outstation_apply_update_analog_input(self, val, index): + def apply_update_analog_input(self, val: float, index: int) -> dict: """public interface to update analog-input point value - val: float index: int, point index """ @@ -160,9 +202,8 @@ def outstation_apply_update_analog_input(self, val, index): return self.outstation_application.db_handler.db @RPC.export - def outstation_apply_update_analog_output(self, val, index): + def apply_update_analog_output(self, val: float, index: int) -> dict: """public interface to update analog-output point value - val: float index: int, point index """ @@ -175,9 +216,8 @@ def outstation_apply_update_analog_output(self, val, index): return self.outstation_application.db_handler.db @RPC.export - def outstation_apply_update_binary_input(self, val, index): + def apply_update_binary_input(self, val: bool, index: int): """public interface to update binary-input point value - val: bool index: int, point index """ @@ -189,9 +229,8 @@ def outstation_apply_update_binary_input(self, val, index): return self.outstation_application.db_handler.db @RPC.export - def outstation_apply_update_binary_output(self, val, index): + def apply_update_binary_output(self, val: bool, index: int): """public interface to update binary-output point value - val: bool index: int, point index """ @@ -203,153 +242,320 @@ def outstation_apply_update_binary_output(self, val, index): return self.outstation_application.db_handler.db @RPC.export - def outstation_display_db(self): - return self.outstation_application.db_handler.db - - def configure(self, config_name, action, contents): + def update_outstation(self, + outstation_ip: str = None, + port: int = None, + master_id: int = None, + outstation_id: int = None, + **kwargs): """ - # TODO: clean-up this bizarre method + Update dnp3 outstation config and restart the application to take effect. By default, + {'outstation_ip': '0.0.0.0', 'port': 20000, 'master_id': 2, 'outstation_id': 1} """ - config = self.default_config.copy() - config.update(contents) - - _log.debug("Configuring Agent") - - try: - setting1 = int(config["setting1"]) - setting2 = str(config["setting2"]) - except ValueError as e: - _log.error("ERROR PROCESSING CONFIGURATION: {}".format(e)) - return - - self.setting1 = setting1 - self.setting2 = setting2 - - self._create_subscriptions(self.setting2) - - def _create_subscriptions(self, topic): - """ - Unsubscribe from all pub/sub topics and create a subscription to a topic in the configuration which triggers - the _handle_publish callback - """ - self.vip.pubsub.unsubscribe("pubsub", None, None) - - topic = "some/topic" - self.vip.pubsub.subscribe(peer='pubsub', - prefix=topic, - callback=self._handle_publish) - - def _handle_publish(self, peer, sender, bus, topic, headers, message): - """ - Callback triggered by the subscription setup using the topic from the agent's config file - """ - _log.debug(f" ++++++handleer++++++++++++++++++++++++++" - f"peer {peer}, sender {sender}, bus {bus}, topic {topic}, " - f"headers {headers}, message {message}") - - @Core.receiver("onstart") - def onstart(self, sender, **kwargs): - """ - This is method is called once the Agent has successfully connected to the platform. - This is a good place to setup subscriptions if they are not dynamic or - do any other startup activities that require a connection to the message bus. - Called after any configurations methods that are called at startup. - - Usually not needed if using the configuration store. - """ - - # for dnp3 outstation - self.outstation_application.start() - - # Example publish to pubsub - # self.vip.pubsub.publish('pubsub', "some/random/topic", message="HI!") - # - # # Example RPC call - # # self.vip.rpc.call("some_agent", "some_method", arg1, arg2) - # pass - # self._create_subscriptions(self.setting2) - - - @Core.receiver("onstop") - def onstop(self, sender, **kwargs): - """ - This method is called when the Agent is about to shutdown, but before it disconnects from - the message bus. - """ - pass - self.outstation_application.shutdown() - - # @RPC.export - # def rpc_demo_load_config(self): - # """ - # RPC method - # - # May be called from another agent via self.core.rpc.call - # """ - # try: - # config = utils.load_config("/home/kefei/project-local/volttron/TestAgent/config") - # except Exception: - # config = {} - # return config - - # @RPC.export - # def rpc_demo_config_list_set_get(self): - # """ - # RPC method - # - # May be called from another agent via self.core.rpc.call - # """ - # default_config = {"setting1": "setting1-xxxxxxxxx", - # "setting2": "setting2-xxxxxxxxx"} - # - # # Set a default configuration to ensure that self.configure is called immediately to setup - # # the agent. - # # self.vip.config.set_default("config", default_config) # set_default can only be used before onstart - # self.vip.config.set(config_name="config_2", contents=default_config, - # trigger_callback=False, send_update=True) - # get_result = [ - # self.vip.config.get(config) for config in self.vip.config.list() - # ] - # return self.vip.config.list(), get_result - - # @RPC.export - # def rpc_demo_config_set_default(self): - # """ - # RPC method - # - # May be called from another agent via self.core.rpc.call - # """ - # default_config = {"setting1": "setting1-xxxxxxxxx", - # "setting2": "setting2-xxxxxxxxx"} - # - # # Set a default configuration to ensure that self.configure is called immediately to setup - # # the agent. - # self.vip.config.set_default("config", default_config) - # return self.vip.config.list() - # # # Hook self.configure up to changes to the configuration file "config". - # # self.vip.config.subscribe(self.configure, actions=["NEW", "UPDATE"], pattern="config") - - # @RPC.export - # def rpc_demo_pubsub(self): - # """ - # RPC method - # - # May be called from another agent via self.core.rpc.call - # """ - # - # # pubsub_list = self.vip.pubsub.list('pubsub', 'some/') - # # list(self, peer, prefix='', bus='', subscribed=True, reverse=False, all_platforms=False) - # # # return pubsub_list - # self.vip.pubsub.publish('pubsub', 'some/topic/', message="+++++++++++++++++++++++++ something something") - # # self.vip.pubsub.subscribe('pubsub', 'some/topic/', callable=self._handle_publish) - # # return pubsub_list - # # # Hook self.configure up to changes to the configuration file "config". - # # self.vip.config.subscribe(self.configure, actions=["NEW", "UPDATE"], pattern="config") + config = self._dnp3_outstation_config.copy() + for kwarg in [{"outstation_ip": outstation_ip}, + {"port": port}, + {"master_id": master_id}, {"outstation_id": outstation_id}]: + if list(kwarg.values())[0] is not None: + config.update(kwarg) + self._dnp3_outstation_config = config + self.reset_outstation() + +# class Dnp3Agent(Agent): +# """ +# Dnp3 agent mainly to represent a dnp3 outstation +# """ +# +# def __init__(self, setting1={}, setting2="some/random/topic", **kwargs): +# # TODO: clean-up the bizarre signature. Note: may need to reinstall the agent for testing. +# super(Dnp3Agent, self).__init__(**kwargs) +# _log.debug("vip_identity: " + self.core.identity) # Note: consistent with IDENTITY in `vctl status` +# +# +# # self.setting1 = setting1 +# # self.setting2 = setting2 +# config_when_installed = setting1 +# # TODO: new-feature: load_config from config store +# # config_at_configstore = +# +# self.default_config = {'outstation_ip': '0.0.0.0', 'port': 20000, +# 'master_id': 2, 'outstation_id': 1} +# # agent configuration using volttron config framework +# # get_volttron_cofig, set_volltron_config +# self._volttron_config: dict +# +# # for dnp3 features +# try: +# self.outstation_application = MyOutStation(**config_when_installed) +# _log.info(f"init dnp3 outstation with {config_when_installed}") +# self._volttron_config = config_when_installed +# except Exception as e: +# _log.error(e) +# self.outstation_application = MyOutStation(**self.default_config) +# _log.info(f"init dnp3 outstation with {self.default_config}") +# self._volttron_config = self.default_config +# # self.outstation_application.start() # moved to onstart +# +# # Set a default configuration to ensure that self.configure is called immediately to setup +# # the agent. +# self.vip.config.set_default(config_name="default-config", contents=self.default_config) +# self.vip.config.set_default(config_name="_volttron_config", contents=self._volttron_config) +# # Hook self.configure up to changes to the configuration file "config". +# self.vip.config.subscribe(self.configure, actions=["NEW", "UPDATE"], pattern="config") +# +# def _get_volttron_config(self): +# return self._volttron_config +# +# def _set_volttron_config(self, **kwargs): +# """set self._volttron_config using **kwargs. +# EXAMPLE +# self.default_config = {'outstation_ip': '0.0.0.0', 'port': 21000, +# 'master_id': 2, 'outstation_id': 1} +# set_volttron_config(port=30000, unused_key="unused") +# # outcome +# self.default_config = {'outstation_ip': '0.0.0.0', 'port': 30000, +# 'master_id': 2, 'outstation_id': 1, +# 'unused_key': 'unused'} +# """ +# self._volttron_config.update(kwargs) +# _log.info(f"Updated self._volttron_config to {self._volttron_config}") +# return {"_volttron_config": self._get_volttron_config()} +# +# @RPC.export +# def outstation_reset(self, **kwargs): +# """update`self._volttron_config`, then init a new outstation. +# +# For post-configuration and immediately take effect. +# Note: will start a new outstation instance and the old database data will lose""" +# self._set_volttron_config(**kwargs) +# try: +# outstation_app_new = MyOutStation(**self._volttron_config) +# self.outstation_application.shutdown() +# self.outstation_application = outstation_app_new +# self.outstation_application.start() +# except Exception as e: +# _log.error(e) +# +# @RPC.export +# def outstation_get_db(self): +# """expose db""" +# return self.outstation_application.db_handler.db +# +# @RPC.export +# def outstation_get_config(self): +# """expose get_config""" +# return self.outstation_application.get_config() +# +# @RPC.export +# def outstation_get_is_connected(self): +# """expose is_connected, note: status, property""" +# return self.outstation_application.is_connected +# +# @RPC.export +# def outstation_apply_update_analog_input(self, val, index): +# """public interface to update analog-input point value +# +# val: float +# index: int, point index +# """ +# if not isinstance(val, float): +# raise f"val of type(val) should be float" +# self.outstation_application.apply_update(opendnp3.Analog(value=val), index) +# _log.debug(f"Updated outstation analog-input index: {index}, val: {val}") +# +# return self.outstation_application.db_handler.db +# +# @RPC.export +# def outstation_apply_update_analog_output(self, val, index): +# """public interface to update analog-output point value +# +# val: float +# index: int, point index +# """ +# +# if not isinstance(val, float): +# raise f"val of type(val) should be float" +# self.outstation_application.apply_update(opendnp3.AnalogOutputStatus(value=val), index) +# _log.debug(f"Updated outstation analog-output index: {index}, val: {val}") +# +# return self.outstation_application.db_handler.db +# +# @RPC.export +# def outstation_apply_update_binary_input(self, val, index): +# """public interface to update binary-input point value +# +# val: bool +# index: int, point index +# """ +# if not isinstance(val, bool): +# raise f"val of type(val) should be bool" +# self.outstation_application.apply_update(opendnp3.Binary(value=val), index) +# _log.debug(f"Updated outstation binary-input index: {index}, val: {val}") +# +# return self.outstation_application.db_handler.db +# +# @RPC.export +# def outstation_apply_update_binary_output(self, val, index): +# """public interface to update binary-output point value +# +# val: bool +# index: int, point index +# """ +# if not isinstance(val, bool): +# raise f"val of type(val) should be bool" +# self.outstation_application.apply_update(opendnp3.BinaryOutputStatus(value=val), index) +# _log.debug(f"Updated outstation binary-output index: {index}, val: {val}") +# +# return self.outstation_application.db_handler.db +# +# @RPC.export +# def outstation_display_db(self): +# return self.outstation_application.db_handler.db +# +# def configure(self, config_name, action, contents): +# """ +# # TODO: clean-up this bizarre method +# """ +# config = self.default_config.copy() +# config.update(contents) +# +# _log.debug("Configuring Agent") +# +# try: +# setting1 = int(config["setting1"]) +# setting2 = str(config["setting2"]) +# except ValueError as e: +# _log.error("ERROR PROCESSING CONFIGURATION: {}".format(e)) +# return +# +# self.setting1 = setting1 +# self.setting2 = setting2 +# +# self._create_subscriptions(self.setting2) +# +# def _create_subscriptions(self, topic): +# """ +# Unsubscribe from all pub/sub topics and create a subscription to a topic in the configuration which triggers +# the _handle_publish callback +# """ +# self.vip.pubsub.unsubscribe("pubsub", None, None) +# +# topic = "some/topic" +# self.vip.pubsub.subscribe(peer='pubsub', +# prefix=topic, +# callback=self._handle_publish) +# +# def _handle_publish(self, peer, sender, bus, topic, headers, message): +# """ +# Callback triggered by the subscription setup using the topic from the agent's config file +# """ +# _log.debug(f" ++++++handleer++++++++++++++++++++++++++" +# f"peer {peer}, sender {sender}, bus {bus}, topic {topic}, " +# f"headers {headers}, message {message}") +# +# @Core.receiver("onstart") +# def onstart(self, sender, **kwargs): +# """ +# This is method is called once the Agent has successfully connected to the platform. +# This is a good place to setup subscriptions if they are not dynamic or +# do any other startup activities that require a connection to the message bus. +# Called after any configurations methods that are called at startup. +# +# Usually not needed if using the configuration store. +# """ +# +# # for dnp3 outstation +# self.outstation_application.start() +# +# # Example publish to pubsub +# # self.vip.pubsub.publish('pubsub', "some/random/topic", message="HI!") +# # +# # # Example RPC call +# # # self.vip.rpc.call("some_agent", "some_method", arg1, arg2) +# # pass +# # self._create_subscriptions(self.setting2) +# +# +# @Core.receiver("onstop") +# def onstop(self, sender, **kwargs): +# """ +# This method is called when the Agent is about to shutdown, but before it disconnects from +# the message bus. +# """ +# pass +# self.outstation_application.shutdown() +# +# # @RPC.export +# # def rpc_demo_load_config(self): +# # """ +# # RPC method +# # +# # May be called from another agent via self.core.rpc.call +# # """ +# # try: +# # config = utils.load_config("/home/kefei/project-local/volttron/TestAgent/config") +# # except Exception: +# # config = {} +# # return config +# +# # @RPC.export +# # def rpc_demo_config_list_set_get(self): +# # """ +# # RPC method +# # +# # May be called from another agent via self.core.rpc.call +# # """ +# # default_config = {"setting1": "setting1-xxxxxxxxx", +# # "setting2": "setting2-xxxxxxxxx"} +# # +# # # Set a default configuration to ensure that self.configure is called immediately to setup +# # # the agent. +# # # self.vip.config.set_default("config", default_config) # set_default can only be used before onstart +# # self.vip.config.set(config_name="config_2", contents=default_config, +# # trigger_callback=False, send_update=True) +# # get_result = [ +# # self.vip.config.get(config) for config in self.vip.config.list() +# # ] +# # return self.vip.config.list(), get_result +# +# # @RPC.export +# # def rpc_demo_config_set_default(self): +# # """ +# # RPC method +# # +# # May be called from another agent via self.core.rpc.call +# # """ +# # default_config = {"setting1": "setting1-xxxxxxxxx", +# # "setting2": "setting2-xxxxxxxxx"} +# # +# # # Set a default configuration to ensure that self.configure is called immediately to setup +# # # the agent. +# # self.vip.config.set_default("config", default_config) +# # return self.vip.config.list() +# # # # Hook self.configure up to changes to the configuration file "config". +# # # self.vip.config.subscribe(self.configure, actions=["NEW", "UPDATE"], pattern="config") +# +# # @RPC.export +# # def rpc_demo_pubsub(self): +# # """ +# # RPC method +# # +# # May be called from another agent via self.core.rpc.call +# # """ +# # +# # # pubsub_list = self.vip.pubsub.list('pubsub', 'some/') +# # # list(self, peer, prefix='', bus='', subscribed=True, reverse=False, all_platforms=False) +# # # # return pubsub_list +# # self.vip.pubsub.publish('pubsub', 'some/topic/', message="+++++++++++++++++++++++++ something something") +# # # self.vip.pubsub.subscribe('pubsub', 'some/topic/', callable=self._handle_publish) +# # # return pubsub_list +# # # # Hook self.configure up to changes to the configuration file "config". +# # # self.vip.config.subscribe(self.configure, actions=["NEW", "UPDATE"], pattern="config") def main(): """Main method called to start the agent.""" - utils.vip_main(agent_main, + utils.vip_main(Dnp3Agent, version=__version__) diff --git a/services/core/DNP3OutstationAgent/tests/test_dnp3_agent.py b/services/core/DNP3OutstationAgent/tests/test_dnp3_agent.py index b5ec934c4b..668de98832 100644 --- a/services/core/DNP3OutstationAgent/tests/test_dnp3_agent.py +++ b/services/core/DNP3OutstationAgent/tests/test_dnp3_agent.py @@ -1,310 +1,251 @@ -# -*- coding: utf-8 -*- {{{ -# vim: set fenc=utf-8 ft=python sw=4 ts=4 sts=4 et: -# -# Copyright 2018, SLAC / Kisensum. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# This material was prepared as an account of work sponsored by an agency of -# the United States Government. Neither the United States Government nor the -# United States Department of Energy, nor SLAC, nor Kisensum, nor any of their -# employees, nor any jurisdiction or organization that has cooperated in the -# development of these materials, makes any warranty, express or -# implied, or assumes any legal liability or responsibility for the accuracy, -# completeness, or usefulness or any information, apparatus, product, -# software, or process disclosed, or represents that its use would not infringe -# privately owned rights. Reference herein to any specific commercial product, -# process, or service by trade name, trademark, manufacturer, or otherwise -# does not necessarily constitute or imply its endorsement, recommendation, or -# favoring by the United States Government or any agency thereof, or -# SLAC, or Kisensum. The views and opinions of authors expressed -# herein do not necessarily state or reflect those of the -# United States Government or any agency thereof. -# }}} - +""" +This test suits focus on the exposed RPC calls. +It utilizes a vip agent to evoke the RPC calls. +The volltron instance and dnp3-agent is start manually. +Note: several fixtures are used + volttron_platform_wrapper + vip_agent + dnp3_outstation_agent +""" +import pathlib + +import gevent import pytest -# try: -# import dnp3 -# except ImportError: -# pytest.skip("pydnp3 not found!", allow_module_level=True) -# -# import gevent -# import os -# import pytest -# -# from volttron.platform import get_services_core, jsonapi -# from volttron.platform.agent.utils import strip_comments -# -# # from dnp3.points import PointDefinitions -# # from mesa_master_test import MesaMasterTest -# -# from pydnp3 import asiodnp3, asiopal, opendnp3, openpal - -FILTERS = opendnp3.levels.NORMAL | opendnp3.levels.ALL_COMMS -HOST = "127.0.0.1" -LOCAL = "0.0.0.0" -PORT = 20000 - -DNP3_AGENT_ID = 'dnp3_outstation_agent' -POINT_TOPIC = "dnp3/point" -TEST_GET_POINT_NAME = 'DCTE.WinTms.AO11' -TEST_SET_POINT_NAME = 'DCTE.WinTms.AI55' - -input_group_map = { - 1: "Binary", - 2: "Binary", - 30: "Analog", - 31: "Analog", - 32: "Analog", - 33: "Analog", - 34: "Analog" -} - -DNP3_AGENT_CONFIG = { - "points": "config://mesa_points.config", - "point_topic": POINT_TOPIC, - "outstation_config": { - "log_levels": ["NORMAL", "ALL_APP_COMMS"] - }, - "local_ip": "0.0.0.0", - "port": 20000 -} - -# Get point definitions from the files in the test directory. -POINT_DEFINITIONS_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), 'data', 'mesa_points.config')) - -pdefs = PointDefinitions(point_definitions_path=POINT_DEFINITIONS_PATH) - -AGENT_CONFIG = { - "points": "config://mesa_points.config", - "outstation_config": { - "database_sizes": 700, - "log_levels": ["NORMAL"] - }, - "local_ip": "0.0.0.0", - "port": 20000 -} - -messages = {} - - -def onmessage(peer, sender, bus, topic, headers, message): - """Callback: As DNP3Agent publishes mesa/point messages, store them in a multi-level global dictionary.""" - global messages - messages[topic] = {'headers': headers, 'message': message} - - -def dict_compare(source_dict, target_dict): - """Assert that the value for each key in source_dict matches the corresponding value in target_dict. - - Ignores keys in target_dict that are not in source_dict. - """ - for name, source_val in source_dict.items(): - target_val = target_dict.get(name, None) - assert source_val == target_val, "Source value of {}={}, target value={}".format(name, source_val, target_val) +import os +# from volttron.client.vip.agent import build_agent +from volttron.platform.vip.agent.utils import build_agent +from time import sleep +import datetime +from dnp3_outstation.agent import Dnp3OutstationAgent +from dnp3_python.dnp3station.outstation_new import MyOutStationNew +import random +import subprocess +from volttron.utils import is_volttron_running +import json +# from utils.testing_utils import * +from volttrontesting.fixtures.volttron_platform_fixtures import volttron_instance + +import logging +logging_logger = logging.getLogger(__name__) -def add_definitions_to_config_store(test_agent): - """Add PointDefinitions to the mesaagent's config store.""" - with open(POINT_DEFINITIONS_PATH, 'r') as f: - points_json = jsonapi.loads(strip_comments(f.read())) - test_agent.vip.rpc.call('config.store', 'manage_store', DNP3_AGENT_ID, - 'mesa_points.config', points_json, config_type='raw') +dnp3_vip_identity = "dnp3_outstation" @pytest.fixture(scope="module") -def agent(request, volttron_instance): - """Build the test agent for rpc call.""" +def volttron_home(): + """ + VOLTTRON_HOME environment variable suggested to setup at pytest.ini [env] + """ + volttron_home: str = os.getenv("VOLTTRON_HOME") + assert volttron_home + return volttron_home - test_agent = volttron_instance.build_agent(identity="test_agent") - capabilities = {'edit_config_store': {'identity': 'dnp3_outstation_agent'}} - volttron_instance.add_capabilities(test_agent.core.publickey, capabilities) - add_definitions_to_config_store(test_agent) - print('Installing DNP3Agent') - os.environ['AGENT_MODULE'] = 'dnp3.agent' - agent_id = volttron_instance.install_agent(agent_dir=get_services_core("DNP3Agent"), - config_file=AGENT_CONFIG, - vip_identity=DNP3_AGENT_ID, - start=True) +def test_volttron_home_fixture(volttron_home): + assert volttron_home + print(volttron_home) - # Subscribe to DNP3 point publication - test_agent.vip.pubsub.subscribe(peer='pubsub', prefix=POINT_TOPIC, callback=onmessage) - def stop(): - """Stop test agent.""" - if volttron_instance.is_running(): - volttron_instance.stop_agent(agent_id) - volttron_instance.remove_agent(agent_id) - test_agent.core.stop() +def test_testing_file_path(): + parent_path = os.getcwd() + dnp3_agent_config_path = os.path.join(parent_path, "dnp3-outstation-config.json") + # print(dnp3_agent_config_path) + logging_logger.info(f"test_testing_file_path {dnp3_agent_config_path}") - gevent.sleep(12) # wait for agents and devices to start - request.addfinalizer(stop) +def test_volttron_instance_fixture(volttron_instance): + print(volttron_instance) + logging_logger.info(f"=========== volttron_instance_new.volttron_home: {volttron_instance.volttron_home}") + logging_logger.info(f"=========== volttron_instance_new.skip_cleanup: {volttron_instance.skip_cleanup}") + logging_logger.info(f"=========== volttron_instance_new.vip_address: {volttron_instance.vip_address}") - return test_agent -def test_agent(agent): - print(agent) +@pytest.fixture(scope="module") +def vip_agent(volttron_instance): + # build a vip agent + a = volttron_instance.build_agent() + print(a) + return a + -def test_agent(): - print("agent") +def test_vip_agent_fixture(vip_agent): + print(vip_agent) + logging_logger.info(f"=========== vip_agent: {vip_agent}") + logging_logger.info(f"=========== vip_agent.core.identity: {vip_agent.core.identity}") + logging_logger.info(f"=========== vip_agent.vip.peerlist().get(): {vip_agent.vip.peerlist().get()}") @pytest.fixture(scope="module") -def run_master(request): - """Run Mesa master application.""" - master = MesaMasterTest(local_ip=AGENT_CONFIG['local_ip'], port=AGENT_CONFIG['port']) - master.connect() - - def stop(): - master.shutdown() - - request.addfinalizer(stop) - - return master - - -@pytest.fixture(scope="function") -def reset(agent): - """Reset agent and global variable messages before running every test.""" - global messages - messages = {} - agent.vip.rpc.call(DNP3_AGENT_ID, 'reset').get() - - -class TestDummy: - - @staticmethod - def get_point_definitions(agent, point_names): - """Ask DNP3Agent for a list of point definitions.""" - return agent.vip.rpc.call(DNP3_AGENT_ID, 'get_point_definitions', point_names).get(timeout=10) - - def get_point_definition(self, agent, point_name): - """Confirm that the agent has a point definition named point_name. Return the definition.""" - point_defs = self.get_point_definitions(agent, [point_name]) - point_def = point_defs.get(point_name, None) - assert point_def is not None, "Agent has no point definition for {}".format(TEST_GET_POINT_NAME) - return point_def - - def test_fixture_run_master(self, run_master): - pass - print(f"=====run_master {run_master}") - - def test_fixture_agent(self, agent): - pass - print(f"=====agent {agent}") - - def test_fixture_reset(self, reset): - pass - print(f"=====agent {reset}") - - def test_get_point_definition(self, run_master, agent, reset): - """Ask the agent whether it has a point definition for a point name.""" - self.get_point_definition(agent, TEST_GET_POINT_NAME) - - -class TestDNP3Agent: - """Regression tests for (non-MESA) DNP3Agent.""" - - @staticmethod - def get_point(agent, point_name): - """Ask DNP3Agent for a point value for a DNP3 resource.""" - return agent.vip.rpc.call(DNP3_AGENT_ID, 'get_point', point_name).get(timeout=10) - - @staticmethod - def get_point_definitions(agent, point_names): - """Ask DNP3Agent for a list of point definitions.""" - return agent.vip.rpc.call(DNP3_AGENT_ID, 'get_point_definitions', point_names).get(timeout=10) - - @staticmethod - def get_point_by_index(agent, data_type, index): - """Ask DNP3Agent for a point value for a DNP3 resource.""" - return agent.vip.rpc.call(DNP3_AGENT_ID, 'get_point_by_index', data_type, index).get(timeout=10) - - @staticmethod - def set_point(agent, point_name, value): - """Use DNP3Agent to set a point value for a DNP3 resource.""" - response = agent.vip.rpc.call(DNP3_AGENT_ID, 'set_point', point_name, value).get(timeout=10) - gevent.sleep(5) # Give the Master time to receive an echoed point value back from the Outstation. - return response - - @staticmethod - def set_points(agent, point_dict): - """Use DNP3Agent to set point values for a DNP3 resource.""" - return agent.vip.rpc.call(DNP3_AGENT_ID, 'set_points', point_dict).get(timeout=10) - - @staticmethod - def send_single_point(master, point_name, point_value): - """ - Send a point name and value from the Master to DNP3Agent. - - Return a dictionary with an exception key and error, empty if successful. - """ - try: - master.send_single_point(pdefs, point_name, point_value) - return {} - except Exception as err: - exception = {'key': type(err).__name__, 'error': str(err)} - print("Exception sending point from master: {}".format(exception)) - return exception - - @staticmethod - def get_value_from_master(master, point_name): - """Get value of the point from master after being set by test agent.""" - try: - pdef = pdefs.point_named(point_name) - group = input_group_map[pdef.group] - index = pdef.index - return master.soe_handler.result[group][index] - except KeyError: - return None - - def get_point_definition(self, agent, point_name): - """Confirm that the agent has a point definition named point_name. Return the definition.""" - point_defs = self.get_point_definitions(agent, [point_name]) - point_def = point_defs.get(point_name, None) - assert point_def is not None, "Agent has no point definition for {}".format(TEST_GET_POINT_NAME) - return point_def - - @staticmethod - def subscribed_points(): - """Return point values published by DNP3Agent using the dnp3/point topic.""" - return messages[POINT_TOPIC].get('message', {}) - - # ********** - # ********** OUTPUT TESTS (send data from Master to Agent to ControlAgent) ************ - # ********** - - def test_get_point_definition(self, run_master, agent, reset): - """Ask the agent whether it has a point definition for a point name.""" - self.get_point_definition(agent, TEST_GET_POINT_NAME) - - def test_send_point(self, run_master, agent, reset): - """Send a point from the master and get its value from DNP3Agent.""" - exceptions = self.send_single_point(run_master, TEST_GET_POINT_NAME, 45) - assert exceptions == {} - received_point = self.get_point(agent, TEST_GET_POINT_NAME) - # Confirm that the agent's received point value matches the value that was sent. - assert received_point == 45, "Expected {} = {}, got {}".format(TEST_GET_POINT_NAME, 45, received_point) - dict_compare({TEST_GET_POINT_NAME: 45}, self.subscribed_points()) - - # ********** - # ********** INPUT TESTS (send data from ControlAgent to Agent to Master) ************ - # ********** - - def test_set_point(self, run_master, agent, reset): - """Test set an input point and confirm getting the same value for that point.""" - self.set_point(agent, TEST_SET_POINT_NAME, 45) - received_val = self.get_value_from_master(run_master, TEST_SET_POINT_NAME) - assert received_val == 45, "Expected {} = {}, got {}".format(TEST_SET_POINT_NAME, 45, received_val) +def dnp3_outstation_agent(volttron_instance) -> dict: + """ + Install and start a dnp3-outstation-agent, return its vip-identity + """ + # install a dnp3-outstation-agent + # TODO: improve the following hacky path resolver + parent_path = pathlib.Path(__file__) + dnp3_outstation_package_path = pathlib.Path(parent_path).parent.parent + dnp3_agent_config_path = str(os.path.join(parent_path, "dnp3-outstation-config.json")) + config = { + "outstation_ip": "0.0.0.0", + "master_id": 2, + "outstation_id": 1, + "port": 20000 + } + agent_vip_id = dnp3_vip_identity + uuid = volttron_instance.install_agent( + agent_dir=dnp3_outstation_package_path, + # agent_dir="volttron-dnp3-outastion", + config_file=config, + start=False, # Note: for some reason, need to set to False, then start + vip_identity=agent_vip_id) + # start agent with retry + # pid = retry_call(volttron_instance.start_agent, f_kwargs=dict(agent_uuid=uuid), max_retries=5, delay_s=2, + # wait_before_call_s=2) + + # # check if running with retry + # retry_call(volttron_instance.is_agent_running, f_kwargs=dict(agent_uuid=uuid), max_retries=5, delay_s=2, + # wait_before_call_s=2) + gevent.sleep(5) + pid = volttron_instance.start_agent(uuid) + gevent.sleep(5) + logging_logger.info( + f"=========== volttron_instance.is_agent_running(uuid): {volttron_instance.is_agent_running(uuid)}") + # TODO: get retry_call back + return {"uuid": uuid, "pid": pid} + + +def test_install_dnp3_outstation_agent_fixture(dnp3_outstation_agent, vip_agent, volttron_instance): + puid = dnp3_outstation_agent + print(puid) + logging_logger.info(f"=========== dnp3_outstation_agent ids: {dnp3_outstation_agent}") + logging_logger.info(f"=========== vip_agent.vip.peerlist().get(): {vip_agent.vip.peerlist().get()}") + logging_logger.info(f"=========== volttron_instance_new.is_agent_running(puid): " + f"{volttron_instance.is_agent_running(dnp3_outstation_agent['uuid'])}") + + +def test_dummy(vip_agent, dnp3_outstation_agent): + peer = dnp3_vip_identity + method = Dnp3OutstationAgent.rpc_dummy + peer_method = method.__name__ # "rpc_dummy" + rs = vip_agent.vip.rpc.call(peer, peer_method).get(timeout=5) + print(datetime.datetime.now(), "rs: ", rs) + + +def test_outstation_reset(vip_agent, dnp3_outstation_agent): + + peer = dnp3_vip_identity + method = Dnp3OutstationAgent.reset_outstation + peer_method = method.__name__ # "reset_outstation" + # note: reset_outstation returns None, check if raise or time out instead + try: + rs = vip_agent.vip.rpc.call(peer, peer_method).get(timeout=5) + print(datetime.datetime.now(), "rs: ", rs) + except BaseException as e: + assert False + + +def test_outstation_get_db(vip_agent, dnp3_outstation_agent): + peer = dnp3_vip_identity + method = Dnp3OutstationAgent.display_outstation_db + peer_method = method.__name__ # "display_outstation_db" + rs = vip_agent.vip.rpc.call(peer, peer_method).get(timeout=5) + print(datetime.datetime.now(), "rs: ", rs) + assert rs == { + 'Analog': {'0': None, '1': None, '2': None, '3': None, '4': None, '5': None, '6': None, '7': None, '8': None, + '9': None}, + 'AnalogOutputStatus': {'0': None, '1': None, '2': None, '3': None, '4': None, '5': None, '6': None, '7': None, + '8': None, '9': None}, + 'Binary': {'0': None, '1': None, '2': None, '3': None, '4': None, '5': None, '6': None, '7': None, '8': None, + '9': None}, + 'BinaryOutputStatus': {'0': None, '1': None, '2': None, '3': None, '4': None, '5': None, '6': None, '7': None, + '8': None, '9': None}} + + +def test_outstation_get_config(vip_agent, dnp3_outstation_agent): + peer = dnp3_vip_identity + method = Dnp3OutstationAgent.get_outstation_config + peer_method = method.__name__ # "get_outstation_config" + rs = vip_agent.vip.rpc.call(peer, peer_method).get(timeout=5) + print(datetime.datetime.now(), "rs: ", rs) + assert rs == {'outstation_ip_str': '0.0.0.0', 'port': 20000, 'masterstation_id_int': 2, 'outstation_id_int': 1} + + +def test_outstation_is_connected(vip_agent, dnp3_outstation_agent): + peer = dnp3_vip_identity + method = Dnp3OutstationAgent.is_outstation_connected + peer_method = method.__name__ # "is_outstation_connected" + rs = vip_agent.vip.rpc.call(peer, peer_method).get(timeout=5) + print(datetime.datetime.now(), "rs: ", rs) + assert rs in [True, False] + + +def test_outstation_apply_update_analog_input(vip_agent, dnp3_outstation_agent): + peer = dnp3_vip_identity + method = Dnp3OutstationAgent.apply_update_analog_input + peer_method = method.__name__ # "apply_update_analog_input" + val, index = random.random(), random.choice(range(5)) + print(f"val: {val}, index: {index}") + rs = vip_agent.vip.rpc.call(peer, peer_method, val, index).get(timeout=5) + print(datetime.datetime.now(), "rs: ", rs) + + # verify + val_new = rs.get("Analog").get(str(index)) + assert val_new == val + + +def test_outstation_apply_update_analog_output(vip_agent, dnp3_outstation_agent): + peer = dnp3_vip_identity + method = Dnp3OutstationAgent.apply_update_analog_output + peer_method = method.__name__ # "apply_update_analog_output" + val, index = random.random(), random.choice(range(5)) + print(f"val: {val}, index: {index}") + rs = vip_agent.vip.rpc.call(peer, peer_method, val, index).get(timeout=5) + print(datetime.datetime.now(), "rs: ", rs) + + # verify + val_new = rs.get("AnalogOutputStatus").get(str(index)) + assert val_new == val + + +def test_outstation_apply_update_binary_input(vip_agent, dnp3_outstation_agent): + peer = dnp3_vip_identity + method = Dnp3OutstationAgent.apply_update_binary_input + peer_method = method.__name__ # "apply_update_binary_input" + val, index = random.choice([True, False]), random.choice(range(5)) + print(f"val: {val}, index: {index}") + rs = vip_agent.vip.rpc.call(peer, peer_method, val, index).get(timeout=5) + print(datetime.datetime.now(), "rs: ", rs) + + # verify + val_new = rs.get("Binary").get(str(index)) + assert val_new == val + + +def test_outstation_apply_update_binary_output(vip_agent, dnp3_outstation_agent): + peer = dnp3_vip_identity + method = Dnp3OutstationAgent.apply_update_binary_output + peer_method = method.__name__ # "apply_update_binary_output" + val, index = random.choice([True, False]), random.choice(range(5)) + print(f"val: {val}, index: {index}") + rs = vip_agent.vip.rpc.call(peer, peer_method, val, index).get(timeout=5) + print(datetime.datetime.now(), "rs: ", rs) + + # verify + val_new = rs.get("BinaryOutputStatus").get(str(index)) + assert val_new == val + + +def test_outstation_update_config_with_restart(vip_agent, dnp3_outstation_agent): + peer = dnp3_vip_identity + method = Dnp3OutstationAgent.update_outstation + peer_method = method.__name__ # "update_outstation" + port_to_set = 20001 + rs = vip_agent.vip.rpc.call(peer, peer_method, port=port_to_set).get(timeout=5) + print(datetime.datetime.now(), "rs: ", rs) + + # verify + rs = vip_agent.vip.rpc.call(peer, "get_outstation_config").get(timeout=5) + port_new = rs.get("port") + # print(f"========= port_new {port_new}") + assert port_new == port_to_set From 2007e71b86df52ad0333142be00cf98856cb6031 Mon Sep 17 00:00:00 2001 From: Kefei Mo Date: Fri, 12 May 2023 14:15:41 -0500 Subject: [PATCH 7/7] updated testing and testing session in README --- services/core/DNP3OutstationAgent/README.md | 29 +++++++++++++++++ .../tests/test_dnp3_agent.py | 31 ++++++++++--------- 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/services/core/DNP3OutstationAgent/README.md b/services/core/DNP3OutstationAgent/README.md index 048ef93416..5938674930 100644 --- a/services/core/DNP3OutstationAgent/README.md +++ b/services/core/DNP3OutstationAgent/README.md @@ -297,3 +297,32 @@ shown in the "Master Operation MENU" and should be self-explanatory. Here we can Note: [run_dnp3_outstation_agent_script.py](demo-scripts/run_dnp3_outstation_agent_script.py) script is a wrapper on the dnp3demo outstation submodle. For details about the interactive dnp3 station operations, please refer to [dnp3demo-Module.md](https://github.com/VOLTTRON/dnp3-python/blob/develop/docs/dnp3demo-Module.md) + +# Run Tests + +1. Install volttron testing dependencies + ```shell + (volttron) $ python bootstrap.py --testing + UPDATE: ['testing'] + Installing required packages + + pip install --upgrade --no-deps wheel==0.30 + Requirement already satisfied: wheel==0.30 in ./env/lib/python3.10/site-packages (0.30.0) + + pip install --upgrade --install-option --zmq=bundled --no-deps pyzmq==22.2.1 + WARNING: Disabling all use of wheels due to the use of --build-option / --global-option / --install-option. + ... + ``` + +1. Run pytest + ```shell + (volttron) $ pytest services/core/DNP3OutstationAgent/tests/. + ===================================================================================================== test session starts ===================================================================================================== + platform linux -- Python 3.10.6, pytest-7.1.2, pluggy-1.0.0 -- /home/kefei/project/volttron/env/bin/python + cachedir: .pytest_cache + rootdir: /home/kefei/project/volttron, configfile: pytest.ini + plugins: rerunfailures-10.2, asyncio-0.19.0, timeout-2.1.0 + asyncio: mode=auto + timeout: 300.0s + timeout method: signal + timeout func_only: False + collected 40 items + ``` diff --git a/services/core/DNP3OutstationAgent/tests/test_dnp3_agent.py b/services/core/DNP3OutstationAgent/tests/test_dnp3_agent.py index 668de98832..74bd58a034 100644 --- a/services/core/DNP3OutstationAgent/tests/test_dnp3_agent.py +++ b/services/core/DNP3OutstationAgent/tests/test_dnp3_agent.py @@ -16,11 +16,12 @@ from volttron.platform.vip.agent.utils import build_agent from time import sleep import datetime -from dnp3_outstation.agent import Dnp3OutstationAgent +# from dnp3_outstation.agent import Dnp3OutstationAgent +from services.core.DNP3OutstationAgent.dnp3_outstation_agent.agent import Dnp3Agent as Dnp3OutstationAgent from dnp3_python.dnp3station.outstation_new import MyOutStationNew import random import subprocess -from volttron.utils import is_volttron_running +# from volttron.utils import is_volttron_running import json # from utils.testing_utils import * from volttrontesting.fixtures.volttron_platform_fixtures import volttron_instance @@ -32,19 +33,19 @@ dnp3_vip_identity = "dnp3_outstation" -@pytest.fixture(scope="module") -def volttron_home(): - """ - VOLTTRON_HOME environment variable suggested to setup at pytest.ini [env] - """ - volttron_home: str = os.getenv("VOLTTRON_HOME") - assert volttron_home - return volttron_home - - -def test_volttron_home_fixture(volttron_home): - assert volttron_home - print(volttron_home) +# @pytest.fixture(scope="module") +# def volttron_home(): +# """ +# VOLTTRON_HOME environment variable suggested to setup at pytest.ini [env] +# """ +# volttron_home: str = os.getenv("VOLTTRON_HOME") +# assert volttron_home +# return volttron_home +# +# +# def test_volttron_home_fixture(volttron_home): +# assert volttron_home +# print(volttron_home) def test_testing_file_path():