diff --git a/Makefile b/Makefile index 8f728dc..76390cd 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ # VARIABLES {{{1 -LATEST_VERSION = 1.1.3 +LATEST_VERSION = 1.2.0 MININET = sudo mn PYTHON = sudo python @@ -66,6 +66,7 @@ tests-travis: $(TESTER_TRAVIS) $(TESTER_OPTS) tests/protocols_tests.py $(TESTER_TRAVIS) $(TESTER_OPTS) tests/devices_tests.py $(TESTER_TRAVIS) $(TESTER_OPTS) tests/states_tests.py + $(TESTER_TRAVIS) $(TESTER_OPTS) tests/ui_tests.py tests: $(TESTER) $(TESTER_OPTS) tests @@ -111,6 +112,11 @@ test-devices: test-device: $(TESTER) $(TESTER_OPTS) tests/devices_tests.py:TestDevice + + +test-ui: + $(TESTER) $(TESTER_OPTS) tests/ui_tests.py + # }}} # }}} diff --git a/RELEASES.md b/RELEASES.md index 6958af6..a635890 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,5 +1,12 @@ # Releases and Changelog +## Version 1.2.0 (2017-09-18) + +### UI + +* Added CLI tool `mcps`. + * Generate default files for simulation using a single command. + ## Version 1.1.3 (2017-05-14) ### Misc diff --git a/bin/mcps b/bin/mcps new file mode 100644 index 0000000..6468028 --- /dev/null +++ b/bin/mcps @@ -0,0 +1,45 @@ +#!/usr/bin/python2 + +import click + +import errno +import traceback + +import minicps.ui.commands as mcps + +# vim: ft=python + +@click.group() +def main(): + """ A command line tool for MINICPS.""" + pass + +@main.command('init', short_help="Initialize a directory with default files for MINICPS simulation.") +@click.option('--config', help="Configuration file for generating template.") +@click.option('--path', help="Path to the scaffold directory.") +@click.argument('name', default="myproject") +def init(config, path, name): + """ + Initialize directory NAME with optional custom configuration. + + Default dir name: `myproject`. + """ + init = mcps.Init() + + path = path if (path is not None) else "." + full_path = "{path}/{folder}".format(path=path.rstrip('/'), folder=name) + + try: + init.make(path=full_path) + click.echo(click.style("\n... Success: directory generated.", fg="green", bold=True)) + except OSError as e: + if e.errno == errno.EEXIST: + click.echo(click.style("Warning: Directory ('{}') already exists.".format(path), fg="yellow")) + elif e.errno == errno.EACCES: + click.echo(click.style("Error: Permission Denied. Cannot write to {path}.".format(path), fg="red")) + else: + click.echo(click.style(traceback.format_exc(), fg="red")) + click.echo("... init aborted.") + +if __name__=="__main__": + main() diff --git a/docs/Makefile b/docs/Makefile index 530989a..7cccf28 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -152,6 +152,7 @@ text: man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + cp $(BUILDDIR)/man/mcps.1 . @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." diff --git a/docs/conf.py b/docs/conf.py index 3bd66f6..b2f597b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -66,9 +66,9 @@ # built documents. # # The short X.Y version. -version = '1.1' +version = '1.2' # The full version, including alpha/beta/rc tags. -release = '1.1.3' +release = '1.2.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -264,8 +264,16 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). + +# NOTE: we don't want one man page for each document in the toc +# man_pages = [ +# (master_doc, 'minicps', u'minicps Documentation', +# [author], 1) +# ] + +# NOTE: mcps-man is not included in the TOC man_pages = [ - (master_doc, 'minicps', u'minicps Documentation', + ('mcps-man', 'mcps', u'mcps man page', [author], 1) ] diff --git a/docs/index.rst b/docs/index.rst index 727ca18..209d625 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -16,10 +16,9 @@ Contents: swat-tutorial contributing tests + ui misc - - Indices and tables ================== diff --git a/docs/mcps-man.rst b/docs/mcps-man.rst new file mode 100644 index 0000000..611eb4d --- /dev/null +++ b/docs/mcps-man.rst @@ -0,0 +1,49 @@ +.. MCPS-MAN {{{1 +.. _mcps-man: + +**************************************** +mcps man page (not included in the docs) +**************************************** + +======== +Synopsis +======== + +``mcps`` [``--help``] + +``mcps init`` [NAME] [``--path`` path] [``--config`` file] + +=========== +Description +=========== + +The command line utility is called ``mcps``. It generates a scaffold directory which has +the minimum files necessary to set-up a simulation environment. + +==================== +Command Line Options +==================== + +``init`` +-------- + +This generates a default scaffold directory (``myproject``) using two PLCs in the current working directory. + +``init [NAME] [OPTIONS]`` +------------------------- +``[NAME]`` + Custom name of the directory to be generated. + +``--path`` + Provide a custom path for generating the scaffold. + +``--config`` + Provide a configuration file to generate template with custom devices and values. + + Note: This is **NOT** implemented yet. + +``--help`` +---------- + +This lists the available commands. + diff --git a/docs/mcps.1 b/docs/mcps.1 new file mode 100644 index 0000000..020cf0e --- /dev/null +++ b/docs/mcps.1 @@ -0,0 +1,68 @@ +.\" Man page generated from reStructuredText. +. +.TH "MCPS" "1" "Sep 18, 2017" "1.2" "minicps" +.SH NAME +mcps \- mcps man page +. +.nr rst2man-indent-level 0 +. +.de1 rstReportMargin +\\$1 \\n[an-margin] +level \\n[rst2man-indent-level] +level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] +- +\\n[rst2man-indent0] +\\n[rst2man-indent1] +\\n[rst2man-indent2] +.. +.de1 INDENT +.\" .rstReportMargin pre: +. RS \\$1 +. nr rst2man-indent\\n[rst2man-indent-level] \\n[an-margin] +. nr rst2man-indent-level +1 +.\" .rstReportMargin post: +.. +.de UNINDENT +. RE +.\" indent \\n[an-margin] +.\" old: \\n[rst2man-indent\\n[rst2man-indent-level]] +.nr rst2man-indent-level -1 +.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] +.in \\n[rst2man-indent\\n[rst2man-indent-level]]u +.. +.SH SYNOPSIS +.sp +\fBmcps\fP [\fB\-\-help\fP] +.sp +\fBmcps init\fP [NAME] [\fB\-\-path\fP path] [\fB\-\-config\fP file] +.SH DESCRIPTION +.sp +The command line utility is called \fBmcps\fP\&. It generates a scaffold directory which has +the minimum files necessary to set\-up a simulation environment. +.SH COMMAND LINE OPTIONS +.SS \fBinit\fP +.sp +This generates a default scaffold directory (\fBmyproject\fP) using two PLCs in the current working directory. +.SS \fBinit [NAME] [OPTIONS]\fP +.INDENT 0.0 +.TP +.B \fB[NAME]\fP +Custom name of the directory to be generated. +.TP +.B \fB\-\-path\fP +Provide a custom path for generating the scaffold. +.TP +.B \fB\-\-config\fP +Provide a configuration file to generate template with custom devices and values. +.sp +Note: This is \fBNOT\fP implemented yet. +.UNINDENT +.SS \fB\-\-help\fP +.sp +This lists the available commands. +.SH AUTHOR +scy-phy +.SH COPYRIGHT +2017, Daniele Antonioli +.\" Generated by docutils manpage writer. +. diff --git a/docs/ui.rst b/docs/ui.rst new file mode 100644 index 0000000..4104fe6 --- /dev/null +++ b/docs/ui.rst @@ -0,0 +1,51 @@ +.. UI {{{1 +.. _ui: + +*************** +User Interface +*************** + +.. CLI {{{2 + +This section documents the CLI for minicps. + +======================= +Command Line Interface +======================= + +The command line utility is called ``mcps``. To get more information on the usage: + +.. code-block:: console + + mcps --help + +Default +-------- + +The base command generates a default scaffold directory which has the minimum files necessary +to set-up a simulation environment with two ``PLC``'s as the default device. + +.. code-block:: console + + mcps init + +Custom +------- + +The command has additional options. + +.. code-block:: console + + mcps init [NAME] [OPTIONS] + +``[NAME]`` + Custom name of the directory to be generated. + +``path`` + Provide a custom path for generating the scaffold. + +``config`` + Provide a configuration file to generate template with custom devices and values. + + Note: This is **NOT** implemented yet. + diff --git a/minicps/ui/__init__.py b/minicps/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/minicps/ui/commands.py b/minicps/ui/commands.py new file mode 100644 index 0000000..37da605 --- /dev/null +++ b/minicps/ui/commands.py @@ -0,0 +1,47 @@ +""" +This file contains the commands that can be used (with parameters) both by cli +and gui packages. For example the mcps click cli is using them as subcommands. + +This is the list of supported commands: + + - init +""" +import os +from template import TemplateFactory + +class Init(object): + + """Docstring for Init. """ + + def __init__(self): + self._template = None + + def make(self, path, _type="default"): + os.mkdir(path) + + self._template = TemplateFactory.get_template(_type) + if _type == "default": + self._default(path) + else: + pass + + def _default(self, path): + devices = self._template.devices(number=2) + for idx in range(len(devices)): + self._create('{}/plc{}.py'.format(path, (idx+1)), devices[idx]) + self._display("{folder}/plc{idx}.py".format(folder=path, idx=idx+1)) + + self._create('{}/run.py'.format(path), self._template.run()) + self._create('{}/state.py'.format(path), self._template.state()) + self._create('{}/topo.py'.format(path), self._template.topology()) + + self._display("{folder}/state.py".format(folder=path)) + self._display("{folder}/run.py".format(folder=path)) + self._display("{folder}/topo.py".format(folder=path)) + + def _create(self, path, message): + with open(path, 'w') as f: + f.write(message) + + def _display(self, message): + print "{:<5} create {}".format("", message) diff --git a/minicps/ui/template.py b/minicps/ui/template.py new file mode 100644 index 0000000..73d80ea --- /dev/null +++ b/minicps/ui/template.py @@ -0,0 +1,149 @@ +class BaseTemplate(object): + + @staticmethod + def devices(number, _type): + pass + + @staticmethod + def topology(): + pass + + @staticmethod + def run(): + pass + + @staticmethod + def state(): + pass + +class Device(object): + + @staticmethod + def plc(suffix): + return """# PLC Template +from minicps.devices import PLC + +PLC_DATA = {{ + 'SENSOR1': '0', + 'SENSOR2': '0.0', + 'SENSOR3': '0', # interlock with PLC2 + 'ACTUATOR1': '1', # 0 means OFF and 1 means ON + 'ACTUATOR2': '0', +}} + +PLC_TAGS = ( + ('SENSOR1', {suffix}, 'INT'), + ('SENSOR2', {suffix}, 'REAL'), + ('SENSOR3', {suffix}, 'INT'), # interlock with PLC2 + ('ACTUATOR1', {suffix}, 'INT'), # 0 means OFF and 1 means ON + ('ACTUATOR2', {suffix}, 'INT')) + +PLC_ADDR = '10.0.0.{suffix}' + +PLC_SERVER = {{ + 'address': PLC_ADDR, + 'tags': PLC_TAGS +}} + +PLC_PROTOCOL = {{ + 'name': 'enip', + 'mode': 1, + 'server': PLC_SERVER +}} + +class PLC{suffix}(PLC): + + def pre_loop(self, sleep=0.0): + pass + + def main_loop(self, sleep=0.0): + pass""".format(suffix=suffix) + +class TemplateFactory(object): + + @staticmethod + def get_template(_type): + if _type == "default": + return DefaultTemplate + else: + raise NotImplementedError() + +class DefaultTemplate(BaseTemplate): + + @staticmethod + def devices(number=1, _type="PLC"): + return [Device.plc(suffix) for suffix in range(1, number+1)] # always PLC + + @staticmethod + def topology(): + return """#Topology Template + +from mininet.topo import Topo + +NETMASK = '/24' + +PLC1_MAC = '00:00:00:00:00:01' +PLC2_MAC = '00:00:00:00:00:02' + +class ExampleTopo(Topo): + + def build(self): + pass""" + + @staticmethod + def run(): + return """# Run Script Template +from mininet.net import Mininet +from mininet.cli import CLI +from minicps.mcps import MiniCPS +from topo import ExampleTopo + +class ExampleCPS(MiniCPS): + + '''Main container used to run the simulation.''' + + def __init__(self, name, net): + pass""" + + @staticmethod + def state(): + return """# state +from minicps.states import SQLiteState + +def example_state(): + PATH = 'example_db.sqlite' + NAME = 'example_table' + + STATE = { + 'name': NAME, + 'path': PATH + } + + SCHEMA = ''' + CREATE TABLE example_table ( + name TEXT NOT NULL, + datatype TEXT NOT NULL, + value TEXT, + pid INTEGER NOT NULL, + PRIMARY KEY (name, pid) + ); + ''' + + SCHEMA_INIT = ''' + INSERT INTO example_table VALUES ('SENSOR1', 'int', '0', 1); + INSERT INTO example_table VALUES ('SENSOR2', 'float', '0.0', 1); + INSERT INTO example_table VALUES ('SENSOR3', 'int', '1', 1); + INSERT INTO example_table VALUES ('ACTUATOR1', 'int', '1', 1); + INSERT INTO example_table VALUES ('ACTUATOR2', 'int', '0', 1); + INSERT INTO example_table VALUES ('SENSOR3', 'int', '2', 2); + ''' + return STATE, SCHEMA, SCHEMA_INIT + +def init_db(path, schema, schema_init): + SQLiteState._create(path, schema) + SQLiteState._init(path, schema_init) + +if __name__=="__main__": + state, schema, init = example_state() + init_db(state['path'], schema, init) +""" diff --git a/requirements-dev.txt b/requirements-dev.txt index 1788721..1a39632 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,4 +6,5 @@ cryptography pyasn1 pymodbus cpppo +click diff --git a/requirements.txt b/requirements.txt index d7aa635..57d4729 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ cryptography pyasn1 pymodbus cpppo +click diff --git a/setup.py b/setup.py index 3d14dd7..6c99ceb 100755 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ # NOTE: https://packaging.python.org/ setup( name='minicps', - version='1.1.3', + version='1.2.0', description='MiniCPS: a framework for Cyber-Physical Systems \ real-time simulation, built on top of mininet.', # NOTE: long_description displayed on PyPi @@ -39,16 +39,17 @@ # packages=find_packages(exclude=['docs', 'tests*', 'examples', 'temp', # 'bin']), # NOTE: for the uses, see requirements for the developer + scripts = ['bin/mcps'], install_requires=[ 'cryptography', 'pyasn1', 'pymodbus', 'cpppo', + 'click', ], # NOTE: specify files relative to the module path package_data={}, # NOTE: specify files with absolute paths - data_files=None, - scripts=[], + data_files=[('/usr/local/man/man1', ['docs/mcps.1'])], ) diff --git a/tests/ui_tests.py b/tests/ui_tests.py new file mode 100644 index 0000000..5faad38 --- /dev/null +++ b/tests/ui_tests.py @@ -0,0 +1,95 @@ +import os +import shutil + +from minicps.ui.commands import Init +from minicps.ui.template import TemplateFactory, DefaultTemplate, Device + +from nose.tools import eq_ +from nose.plugins.skip import SkipTest + +class TestInit(): + + def test_initialization(self): + init = Init() + eq_(init._template, None) + + def test_make_creates_directory_with_default_files(self): + init = Init() + init.make("../scaffold") + + eq_(init._template, DefaultTemplate) + assert os.path.isdir("../scaffold".format(os.getcwd())) + + shutil.rmtree('../scaffold') + + def test_make_raises_NotImplementedError_when_type_is_not_default(self): + init = Init() + try: + init.make("./scaffold", _type="[placholder]") + assert False + except NotImplementedError as e: + assert True + shutil.rmtree('./scaffold') + + def test__default_creates_default_files(self): + os.mkdir('./default') + + init = Init() + init._template = TemplateFactory.get_template('default') + init._default('./default') + + assert os.path.exists("./default/plc1.py") + assert os.path.exists("./default/plc2.py") + assert os.path.exists("./default/run.py") + assert os.path.exists("./default/topo.py") + assert os.path.exists("./default/state.py") + + shutil.rmtree('./default') + + def test__create_makes_a_new_file(self): + init = Init() + init._create('test.py', "test") + + assert os.path.exists('./test.py') + + os.remove('./test.py') + +class TestTemplateFactory(): + + def test_factory_template_generator_for_default_path(self): + eq_(TemplateFactory.get_template("default"), DefaultTemplate) + + def test_factory_template_generator_raises_error_for_other_path(self): + try: + TemplateFactory.get_template("[placeholder]") + assert False + except NotImplementedError as e: + assert True + +class TestDefaultTemplate(): + + def test_default_devices_with_argument_number_greater_than_3(self): + test = DefaultTemplate.devices(number=3) + eq_(len(test), 3) + + def test_default_devices_with_default_argument(self): + test = DefaultTemplate.devices() + eq_(len(test), 1) + + def test_default_topology(self): + res = "#Topology Template\n\nfrom mininet.topo import Topo\n\nNETMASK = '/24'\n\nPLC1_MAC = '00:00:00:00:00:01'\nPLC2_MAC = '00:00:00:00:00:02'\n\nclass ExampleTopo(Topo):\n\n def build(self):\n pass" + eq_(DefaultTemplate.topology(), res) + + def test_default_run(self): + res = "# Run Script Template\nfrom mininet.net import Mininet\nfrom mininet.cli import CLI\nfrom minicps.mcps import MiniCPS\nfrom topo import ExampleTopo\n\nclass ExampleCPS(MiniCPS):\n\n '''Main container used to run the simulation.'''\n\n def __init__(self, name, net):\n pass" + eq_(DefaultTemplate.run(), res) + + def test_default_state(self): + res = '# state\nfrom minicps.states import SQLiteState\n\ndef example_state():\n PATH = \'example_db.sqlite\'\n NAME = \'example_table\'\n\n STATE = {\n \'name\': NAME,\n \'path\': PATH\n }\n\n SCHEMA = \'\'\'\n CREATE TABLE example_table (\n name TEXT NOT NULL,\n datatype TEXT NOT NULL,\n value TEXT,\n pid INTEGER NOT NULL,\n PRIMARY KEY (name, pid)\n );\n \'\'\'\n\n SCHEMA_INIT = \'\'\'\n INSERT INTO example_table VALUES (\'SENSOR1\', \'int\', \'0\', 1);\n INSERT INTO example_table VALUES (\'SENSOR2\', \'float\', \'0.0\', 1);\n INSERT INTO example_table VALUES (\'SENSOR3\', \'int\', \'1\', 1);\n INSERT INTO example_table VALUES (\'ACTUATOR1\', \'int\', \'1\', 1);\n INSERT INTO example_table VALUES (\'ACTUATOR2\', \'int\', \'0\', 1);\n INSERT INTO example_table VALUES (\'SENSOR3\', \'int\', \'2\', 2);\n \'\'\'\n return STATE, SCHEMA, SCHEMA_INIT\n\ndef init_db(path, schema, schema_init):\n SQLiteState._create(path, schema)\n SQLiteState._init(path, schema_init)\n\nif __name__=="__main__":\n state, schema, init = example_state()\n init_db(state[\'path\'], schema, init)\n' + eq_(DefaultTemplate.state(), res) + +class TestDevice(): + + def test_plc(self): + res = "# PLC Template\nfrom minicps.devices import PLC\n\nPLC_DATA = {\n 'SENSOR1': '0',\n 'SENSOR2': '0.0',\n 'SENSOR3': '0', # interlock with PLC2\n 'ACTUATOR1': '1', # 0 means OFF and 1 means ON\n 'ACTUATOR2': '0',\n}\n\nPLC_TAGS = (\n ('SENSOR1', 1, 'INT'),\n ('SENSOR2', 1, 'REAL'),\n ('SENSOR3', 1, 'INT'), # interlock with PLC2\n ('ACTUATOR1', 1, 'INT'), # 0 means OFF and 1 means ON\n ('ACTUATOR2', 1, 'INT'))\n\nPLC_ADDR = '10.0.0.1'\n\nPLC_SERVER = {\n 'address': PLC_ADDR,\n 'tags': PLC_TAGS\n}\n\nPLC_PROTOCOL = {\n 'name': 'enip',\n 'mode': 1,\n 'server': PLC_SERVER\n}\n\nclass PLC1(PLC):\n\n def pre_loop(self, sleep=0.0):\n pass\n\n def main_loop(self, sleep=0.0):\n pass" + eq_(Device.plc(1), res)