├── .gitchangelog.rc ├── .github └── workflows │ ├── pydocstyle.yml │ ├── pylint.yml │ └── sphinx-build.yml ├── .gitignore ├── .readthedocs.yaml ├── LICENSE ├── README.md ├── docsource ├── client_examples │ └── simple_autoconfiguration.md ├── conf.py ├── examples.md ├── exceptions.rst ├── index.md ├── requirements.txt └── selprotopy.md ├── logo ├── SELProtoPyLogo.png ├── SELProtoPyLogo.svg ├── relay.png └── selprotopy.png ├── pyproject.toml ├── selprotopy ├── __init__.py ├── client │ ├── __init__.py │ ├── base.py │ ├── ethernet.py │ └── serial.py ├── common.py ├── exceptions.py ├── protocol │ ├── __init__.py │ ├── commands.py │ └── parser │ │ ├── __init__.py │ │ └── common.py └── support │ ├── __init__.py │ ├── socket.py │ └── telnet.py └── setup.py /.gitchangelog.rc: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8; mode: python -*- 2 | ## 3 | ## Format 4 | ## 5 | ## ACTION: [AUDIENCE:] COMMIT_MSG [!TAG ...] 6 | ## 7 | ## Description 8 | ## 9 | ## ACTION is one of 'chg', 'fix', 'new' 10 | ## 11 | ## Is WHAT the change is about. 12 | ## 13 | ## 'chg' is for refactor, small improvement, cosmetic changes... 14 | ## 'fix' is for bug fixes 15 | ## 'new' is for new features, big improvement 16 | ## 17 | ## AUDIENCE is optional and one of 'dev', 'usr', 'pkg', 'test', 'doc' 18 | ## 19 | ## Is WHO is concerned by the change. 20 | ## 21 | ## 'dev' is for developpers (API changes, refactors...) 22 | ## 'usr' is for final users (UI changes) 23 | ## 'pkg' is for packagers (packaging changes) 24 | ## 'test' is for testers (test only related changes) 25 | ## 'doc' is for doc guys (doc only changes) 26 | ## 27 | ## COMMIT_MSG is ... well ... the commit message itself. 28 | ## 29 | ## TAGs are additionnal adjective as 'refactor' 'minor' 'cosmetic' 30 | ## 31 | ## They are preceded with a '!' or a '@' (prefer the former, as the 32 | ## latter is wrongly interpreted in github.) Commonly used tags are: 33 | ## 34 | ## 'refactor' is obviously for refactoring code only 35 | ## 'minor' is for a very meaningless change (a typo, adding a comment) 36 | ## 'cosmetic' is for cosmetic driven change (re-indentation, 80-col...) 37 | ## 'wip' is for partial functionality but complete subfunctionality. 38 | ## 39 | ## Example: 40 | ## 41 | ## new: usr: support of bazaar implemented 42 | ## chg: re-indentend some lines !cosmetic 43 | ## new: dev: updated code to be compatible with last version of killer lib. 44 | ## fix: pkg: updated year of licence coverage. 45 | ## new: test: added a bunch of test around user usability of feature X. 46 | ## fix: typo in spelling my name in comment. !minor 47 | ## 48 | ## Please note that multi-line commit message are supported, and only the 49 | ## first line will be considered as the "summary" of the commit message. So 50 | ## tags, and other rules only applies to the summary. The body of the commit 51 | ## message will be displayed in the changelog without reformatting. 52 | def writefile(lines): 53 | # Develop Full String of Log 54 | log = '' 55 | for line in lines: 56 | log += line 57 | import re 58 | log = re.sub(r'\[\w{1,20}\n? {0,8}\w{1,20}\]','', log) 59 | print(log) 60 | with open('source/changelog.rst','w') as changelog: 61 | changelog.write(log) 62 | print("Update CHANGELOG.rst Complete.") 63 | 64 | ## 65 | ## ``ignore_regexps`` is a line of regexps 66 | ## 67 | ## Any commit having its full commit message matching any regexp listed here 68 | ## will be ignored and won't be reported in the changelog. 69 | ## 70 | ignore_regexps = [ 71 | r'\(private\)', 72 | r'@minor', r'!minor', 73 | r'@cosmetic', r'!cosmetic', 74 | r'@refactor', r'!refactor', 75 | r'@wip', r'!wip', 76 | r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*[p|P]kg:', 77 | r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*[d|D]ev:', 78 | r'^(.{3,3}\s*:)?\s*[fF]irst commit.?\s*$', 79 | r'^$', ## ignore commits with empty messages 80 | ] 81 | 82 | 83 | ## ``section_regexps`` is a list of 2-tuples associating a string label and a 84 | ## list of regexp 85 | ## 86 | ## Commit messages will be classified in sections thanks to this. Section 87 | ## titles are the label, and a commit is classified under this section if any 88 | ## of the regexps associated is matching. 89 | ## 90 | ## Please note that ``section_regexps`` will only classify commits and won't 91 | ## make any changes to the contents. So you'll probably want to go check 92 | ## ``subject_process`` (or ``body_process``) to do some changes to the subject, 93 | ## whenever you are tweaking this variable. 94 | ## 95 | section_regexps = [ 96 | ('New', [ 97 | r'^[nN]ew\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 98 | ]), 99 | ('Changes', [ 100 | r'^[cC]hg\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 101 | ]), 102 | ('Fix', [ 103 | r'^[fF]ix\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 104 | ]), 105 | 106 | ('Other', None ## Match all lines 107 | ), 108 | 109 | ] 110 | 111 | 112 | ## ``body_process`` is a callable 113 | ## 114 | ## This callable will be given the original body and result will 115 | ## be used in the changelog. 116 | ## 117 | ## Available constructs are: 118 | ## 119 | ## - any python callable that take one txt argument and return txt argument. 120 | ## 121 | ## - ReSub(pattern, replacement): will apply regexp substitution. 122 | ## 123 | ## - Indent(chars=" "): will indent the text with the prefix 124 | ## Please remember that template engines gets also to modify the text and 125 | ## will usually indent themselves the text if needed. 126 | ## 127 | ## - Wrap(regexp=r"\n\n"): re-wrap text in separate paragraph to fill 80-Columns 128 | ## 129 | ## - noop: do nothing 130 | ## 131 | ## - ucfirst: ensure the first letter is uppercase. 132 | ## (usually used in the ``subject_process`` pipeline) 133 | ## 134 | ## - final_dot: ensure text finishes with a dot 135 | ## (usually used in the ``subject_process`` pipeline) 136 | ## 137 | ## - strip: remove any spaces before or after the content of the string 138 | ## 139 | ## - SetIfEmpty(msg="No commit message."): will set the text to 140 | ## whatever given ``msg`` if the current text is empty. 141 | ## 142 | ## Additionally, you can `pipe` the provided filters, for instance: 143 | #body_process = Wrap(regexp=r'\n(?=\w+\s*:)') | Indent(chars=" ") 144 | #body_process = Wrap(regexp=r'\n(?=\w+\s*:)') 145 | #body_process = noop 146 | body_process = ReSub(r'((^|\n)[A-Z]\w+(-\w+)*: .*(\n\s+.*)*)+$', r'') | strip 147 | 148 | 149 | ## ``subject_process`` is a callable 150 | ## 151 | ## This callable will be given the original subject and result will 152 | ## be used in the changelog. 153 | ## 154 | ## Available constructs are those listed in ``body_process`` doc. 155 | subject_process = (strip | 156 | ReSub(r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n@]*)(@[a-z]+\s+)*$', r'\4') | 157 | SetIfEmpty("No commit message.") | ucfirst | final_dot) 158 | 159 | 160 | ## ``tag_filter_regexp`` is a regexp 161 | ## 162 | ## Tags that will be used for the changelog must match this regexp. 163 | ## 164 | tag_filter_regexp = r'^[0-9]+(\.[0-9]+)?$' 165 | 166 | 167 | ## ``unreleased_version_label`` is a string or a callable that outputs a string 168 | ## 169 | ## This label will be used as the changelog Title of the last set of changes 170 | ## between last valid tag and HEAD if any. 171 | unreleased_version_label = "(unreleased)" 172 | 173 | 174 | ## ``output_engine`` is a callable 175 | ## 176 | ## This will change the output format of the generated changelog file 177 | ## 178 | ## Available choices are: 179 | ## 180 | ## - rest_py 181 | ## 182 | ## Legacy pure python engine, outputs ReSTructured text. 183 | ## This is the default. 184 | ## 185 | ## - mustache() 186 | ## 187 | ## Template name could be any of the available templates in 188 | ## ``templates/mustache/*.tpl``. 189 | ## Requires python package ``pystache``. 190 | ## Examples: 191 | ## - mustache("markdown") 192 | ## - mustache("restructuredtext") 193 | ## 194 | ## - makotemplate() 195 | ## 196 | ## Template name could be any of the available templates in 197 | ## ``templates/mako/*.tpl``. 198 | ## Requires python package ``mako``. 199 | ## Examples: 200 | ## - makotemplate("restructuredtext") 201 | ## 202 | output_engine = rest_py 203 | #output_engine = mustache("restructuredtext") 204 | #output_engine = mustache("markdown") 205 | #output_engine = makotemplate("restructuredtext") 206 | 207 | 208 | ## ``include_merge`` is a boolean 209 | ## 210 | ## This option tells git-log whether to include merge commits in the log. 211 | ## The default is to include them. 212 | include_merge = True 213 | 214 | 215 | ## ``log_encoding`` is a string identifier 216 | ## 217 | ## This option tells gitchangelog what encoding is outputed by ``git log``. 218 | ## The default is to be clever about it: it checks ``git config`` for 219 | ## ``i18n.logOutputEncoding``, and if not found will default to git's own 220 | ## default: ``utf-8``. 221 | #log_encoding = 'utf-8' 222 | 223 | 224 | ## ``publish`` is a callable 225 | ## 226 | ## Sets what ``gitchangelog`` should do with the output generated by 227 | ## the output engine. ``publish`` is a callable taking one argument 228 | ## that is an interator on lines from the output engine. 229 | ## 230 | ## Some helper callable are provided: 231 | ## 232 | ## Available choices are: 233 | ## 234 | ## - stdout 235 | ## 236 | ## Outputs directly to standard output 237 | ## (This is the default) 238 | ## 239 | ## - FileInsertAtFirstRegexMatch(file, pattern, idx=lamda m: m.start()) 240 | ## 241 | ## Creates a callable that will parse given file for the given 242 | ## regex pattern and will insert the output in the file. 243 | ## ``idx`` is a callable that receive the matching object and 244 | ## must return a integer index point where to insert the 245 | ## the output in the file. Default is to return the position of 246 | ## the start of the matched string. 247 | ## 248 | ## - FileRegexSubst(file, pattern, replace, flags) 249 | ## 250 | ## Apply a replace inplace in the given file. Your regex pattern must 251 | ## take care of everything and might be more complex. Check the README 252 | ## for a complete copy-pastable example. 253 | ## 254 | publish = writefile 255 | #publish = stdout 256 | 257 | 258 | ## ``revs`` is a list of callable or a list of string 259 | ## 260 | ## callable will be called to resolve as strings and allow dynamical 261 | ## computation of these. The result will be used as revisions for 262 | ## gitchangelog (as if directly stated on the command line). This allows 263 | ## to filter exaclty which commits will be read by gitchangelog. 264 | ## 265 | ## To get a full documentation on the format of these strings, please 266 | ## refer to the ``git rev-list`` arguments. There are many examples. 267 | ## 268 | ## Using callables is especially useful, for instance, if you 269 | ## are using gitchangelog to generate incrementally your changelog. 270 | ## 271 | ## Some helpers are provided, you can use them:: 272 | ## 273 | ## - FileFirstRegexMatch(file, pattern): will return a callable that will 274 | ## return the first string match for the given pattern in the given file. 275 | ## If you use named sub-patterns in your regex pattern, it'll output only 276 | ## the string matching the regex pattern named "rev". 277 | ## 278 | ## - Caret(rev): will return the rev prefixed by a "^", which is a 279 | ## way to remove the given revision and all its ancestor. 280 | ## 281 | ## Please note that if you provide a rev-list on the command line, it'll 282 | ## replace this value (which will then be ignored). 283 | ## 284 | ## If empty, then ``gitchangelog`` will act as it had to generate a full 285 | ## changelog. 286 | ## 287 | ## The default is to use all commits to make the changelog. 288 | #revs = ["^1.0.3", ] 289 | #revs = [ 290 | # Caret( 291 | # FileFirstRegexMatch( 292 | # "CHANGELOG.rst", 293 | # r"(?P[0-9]+\.[0-9]+(\.[0-9]+)?)\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)\n--+\n")), 294 | # "HEAD" 295 | #] 296 | revs = [] 297 | -------------------------------------------------------------------------------- /.github/workflows/pydocstyle.yml: -------------------------------------------------------------------------------- 1 | name: pydocstyle 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: [3.8, 3.9] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 23 | pip install pydocstyle 24 | pip install .[full] 25 | python3 -c "import selprotopy; print('selprotopy.__file__')" 26 | - name: Test NumpyDoc Style 27 | run: | 28 | cd selprotopy 29 | pydocstyle --convention=numpy 30 | -------------------------------------------------------------------------------- /.github/workflows/pylint.yml: -------------------------------------------------------------------------------- 1 | name: pylint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: [3.8, 3.9] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 23 | pip install pylint 24 | pip install .[full] 25 | python3 -c "import selprotopy; print('selprotopy.__file__')" 26 | - name: Test NumpyDoc Style 27 | run: | 28 | pylint ./selprotopy -E 29 | -------------------------------------------------------------------------------- /.github/workflows/sphinx-build.yml: -------------------------------------------------------------------------------- 1 | # Syntax reference for this file: 2 | # https://help.github.com/en/articles/workflow-syntax-for-github-actions 3 | 4 | name: Sphinx Documentation Builder 5 | on: [push, pull_request] 6 | 7 | # https://gist.github.com/c-bata/ed5e7b7f8015502ee5092a3e77937c99 8 | jobs: 9 | build-and-delpoy: 10 | name: Build 11 | runs-on: ubuntu-latest 12 | steps: 13 | # https://github.com/marketplace/actions/checkout 14 | - uses: actions/checkout@v2 15 | # https://github.com/marketplace/actions/setup-python 16 | # ^-- This gives info on matrix testing. 17 | - name: Install Python 18 | uses: actions/setup-python@v1 19 | with: 20 | python-version: 3.9 21 | # I don't know where the "run" thing is documented. 22 | - name: Install dependencies 23 | run: | 24 | #pip install -r requirements.txt 25 | pip install -r docsource/requirements.txt 26 | - name: Build Sphinx docs 27 | if: success() 28 | run: | 29 | pip install .[full] 30 | python3 -c "import selprotopy; print('selprotopy.__file__')" 31 | sphinx-build -M html docsource docs 32 | 33 | # https://github.com/marketplace/actions/github-pages 34 | #- if: success() 35 | # uses: crazy-max/ghaction-github-pages@master 36 | # env: 37 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | # with: 39 | # target_branch: gh-pages 40 | # build_dir: _build/html/ 41 | 42 | # https://github.com/peaceiris/actions-gh-pages 43 | - name: Deploy 44 | if: success() 45 | uses: peaceiris/actions-gh-pages@v3 46 | with: 47 | publish_branch: gh-pages 48 | github_token: ${{ secrets.GITHUB_TOKEN }} 49 | publish_dir: docs/html/ 50 | 51 | 52 | # This action probably does everything for you: 53 | # https://github.com/marketplace/actions/sphinx-build 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Built Documentation Reference Pages 2 | docs/ 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # Ignore VSCode 135 | /.vscode -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.11" 13 | # You can also specify other tool versions: 14 | # nodejs: "19" 15 | # rust: "1.64" 16 | # golang: "1.19" 17 | 18 | # Build documentation in the docs/ directory with Sphinx 19 | sphinx: 20 | configuration: docsource/conf.py 21 | 22 | # If using Sphinx, optionally build your docs in additional formats such as PDF 23 | # formats: 24 | # - pdf 25 | 26 | # Optionally declare the Python requirements required to build your docs 27 | python: 28 | install: 29 | - requirements: docsource/requirements.txt -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Joe Stanley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SELProtoPy logo 2 | Schweitzer Engineering Laboratories (SEL) Protocol Bindings in Python 3 | 4 | [![PyPI Version](https://img.shields.io/pypi/v/selprotopy.svg?color=blue&logo=pypi&logoColor=white)](https://pypi.org/project/selprotopy/) 5 | [![Downloads](https://pepy.tech/badge/selprotopy)](https://pepy.tech/project/selprotopy) 6 | [![Stars](https://img.shields.io/github/stars/engineerjoe440/selprotopy?logo=github)](https://github.com/engineerjoe440/selprotopy/) 7 | [![License](https://img.shields.io/pypi/l/selprotopy.svg?color=blue)](https://github.com/engineerjoe440/selprotopy/blob/master/LICENSE.txt) 8 | 9 | 10 | 11 | [![pydocstyle](https://github.com/engineerjoe440/selprotopy/actions/workflows/pydocstyle.yml/badge.svg?branch=master)](https://github.com/engineerjoe440/selprotopy/actions/workflows/pydocstyle.yml) 12 | [![pylint](https://github.com/engineerjoe440/selprotopy/actions/workflows/pylint.yml/badge.svg)](https://github.com/engineerjoe440/selprotopy/actions/workflows/pylint.yml) 13 | 14 | GitHub Pages (development documentation): https://engineerjoe440.github.io/selprotopy/ 15 | 16 | ReadTheDocs (production documentation): https://selprotopy.readthedocs.io/ 17 | 18 | ***This project is still in early stages, much is still to come.*** 19 | 20 | ## Description 21 | 22 | `selprotopy` is intended to be used as a protocol binding suite for the SEL Protocol 23 | suite which includes SEL Fast Meter, SEL Fast Message, and SEL Fast Operate; each of 24 | which are proprietary protocols designed by 25 | [Schweitzer Engineering Laboratories](https://selinc.com/) for use primarily with 26 | protective electric relays, and other intelligent electronic devices. 27 | 28 | # ⚠️ Caution 29 | 30 | ***This project, although binding SEL Protocol, is not sponsored, tested, or vetted in any 31 | way by Schweitzer Engineering Laboratories (SEL). This project is authored and maintained 32 | as an open-source project. Testing is performed on a very small set of hardware running 33 | in the author's basement. In short, this project has no association with SEL.*** 34 | 35 | *Since this project is not rigorously tested across all SEL devices or in a wide variety 36 | of use-cases, any time this project is used, it should first be thoroughly tested. This 37 | project is not intended to serve protection-class systems in any capacity. It should 38 | primarily be used for research, exploration, and other learning objectives.* 39 | 40 | ## :books: Protocol Documentation 41 | 42 | SEL Protocol was introduced in the early 1990s to support various communications and 43 | control of SEL protective relays. SEL provided a very supportive 44 | [application guide](https://selinc.com/api/download/5026/?lang=en) which provides great 45 | detail about the protocol's implementation. This application guide is a great resource 46 | and thoroughly documents the core framework of SEL Protocol. This guide is the basis of 47 | the bindings provided here. The guide can be accessed with a free account on the SEL 48 | website: [](https://selinc.com/) 49 | 50 | ## Installation 51 | 52 | **From PyPI as a Python Package** 53 | 54 | Just go ahead and issue: `pip install selprotopy` 55 | 56 | **From Source on Github** 57 | 58 | To install `selprotopy` from GitHub: 59 | 60 | 1. Download the repository as a zipped package. 61 | 2. Unzip the repository. 62 | 3. Open a terminal (command-prompt) and navigate to the new folder that's been unzipped. 63 | (*Hint:* Use `cd /selprotopy`) 64 | 4. Use `pip` or `python` to install with the following commands, respectively: 65 | - `$> pip install .` 66 | - `$> python setup.py install` 67 | 5. Verify that it's been installed by opening a Python instance and importing: 68 | `>>> import selprotopy` If no errors arise, the package has been installed. 69 | 70 | ## Contributing 71 | 72 | Want to get involved? We'd love to have your help! 73 | 74 | Please help us by identifying any issues that you come across. If you find an error, 75 | bug, or just have questions, jump over to the 76 | [issue](https://github.com/engineerjoe440/selprotopy/issues) page. 77 | 78 | If you want to add features, or contribute yourself, feel free to open a pull-request. 79 | 80 | ### Contact Info 81 | :information_source: *As mentioned in the 82 | [caution](https://github.com/engineerjoe440/selprotopy#warning-caution) above, this 83 | project is not associated with Schweitzer Engineering Laboratories (SEL) in any 84 | way, and as such, all contacts for questions, support, or other items should be 85 | directed to the resources listed here.* 86 | 87 | For issues found in the source code itself, please feel free to open an 88 | [issue](https://github.com/engineerjoe440/selprotopy/issues), but for general inquiries 89 | and other contact, feel free to address Joe Stanley, the project maintainer. 90 | 91 | - [engineerjoe440@yahoo.com](mailto:engineerjoe440@yahoo.com) 92 | -------------------------------------------------------------------------------- /docsource/client_examples/simple_autoconfiguration.md: -------------------------------------------------------------------------------- 1 | # Performing Automatic Configuration with an SEL Relay 2 | 3 | SEL Protocol operates in a self-describing manner wherein the relay or intelligent electronic 4 | device provides a standard interface for binary messages to describe the layout of specific 5 | data regions. This makes it possible for the relay to describe the methods in which a user may 6 | query the device for data, send control operations, or otherwise interact with the relay. 7 | 8 | SELProtoPy provides mechanisms for the client objects to negotiate the automatic configuration 9 | process with a device to establish the devices capabilities. 10 | 11 | ## Autoconfiguration with a Serially-Connected Device 12 | 13 | The following example assumes a Linux environment using a USB-to-serial adapter (thus the 14 | `/dev/ttyUSB1`) in use. 15 | 16 | ```python 17 | # Import the serial client object from SELProtoPy 18 | from selprotopy.client import SerialSELClient 19 | 20 | # Define the connection parameters 21 | PORT = '/dev/ttyUSB1' 22 | BAUD = 9600 23 | 24 | # Establish the connection - this will NOT start the autoconfiguration 25 | client = SerialSELClient(port=PORT, baudrate=BAUD) 26 | 27 | # Start the Automatic Configuration process 28 | client.autoconfigure() 29 | 30 | # If no exceptions are raised, the configuration process has succeeded 31 | 32 | # Poll the relay using fast-meter 33 | client.poll_fast_meter() 34 | ``` 35 | 36 | ## Autoconfiguration with a Ethernet-Connected Device 37 | 38 | The following example uses a raw TCP socket connection (does not use `telnetlib`) to 39 | establish a connection with the relay. 40 | 41 | ```python 42 | # Import the TCP client object from SELProtoPy 43 | from selprotopy.client import TCPSELClient 44 | 45 | # Define the connection parameters 46 | IP = '192.168.1.100' 47 | TCP_PORT = 23 48 | 49 | # Establish the connection - this will NOT start the autoconfiguration 50 | client = TCPSELClient(ip_address=IP, port=TCP_PORT) 51 | 52 | # Start the Automatic Configuration process 53 | client.autoconfigure() 54 | 55 | # If no exceptions are raised, the configuration process has succeeded 56 | 57 | # Poll the relay using fast-meter 58 | client.poll_fast_meter() 59 | ``` -------------------------------------------------------------------------------- /docsource/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | print("Build with:", sys.version) 16 | parent_dir = os.path.dirname(os.path.dirname(__file__)) 17 | sys.path.insert(0,parent_dir) 18 | print(parent_dir) 19 | 20 | 21 | # Verify Import 22 | try: 23 | import selprotopy 24 | except: 25 | print("Couldn't import `selprotopy` module!") 26 | sys.exit(9) 27 | 28 | 29 | # -- Project information ----------------------------------------------------- 30 | 31 | project = 'selprotopy' 32 | copyright = '2023, Joe Stanley' 33 | author = 'Joe Stanley' 34 | 35 | 36 | # -- General configuration --------------------------------------------------- 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 40 | # ones. 41 | extensions = [ 42 | 'sphinx.ext.autodoc', 43 | 'sphinx.ext.napoleon', 44 | 'sphinx.ext.autosummary', 45 | 'numpydoc', 46 | #'sphinx_sitemap', 47 | 'myst_parser', 48 | 'sphinx_immaterial', 49 | ] 50 | autosummary_generate = True 51 | numpydoc_show_class_members = True 52 | 53 | 54 | # List of patterns, relative to source directory, that match files and 55 | # directories to ignore when looking for source files. 56 | # This pattern also affects html_static_path and html_extra_path. 57 | exclude_patterns = [] 58 | 59 | 60 | # -- Options for HTML output ------------------------------------------------- 61 | 62 | # The theme to use for HTML and HTML Help pages. See the documentation for 63 | # a list of builtin themes. 64 | # 65 | html_theme = 'sphinx_immaterial' 66 | html_title = 'selprotopy' 67 | html_logo = '../logo/selprotopy.png' 68 | html_favicon = '../logo/relay.png' 69 | html_theme_options = { 70 | 71 | # Specify a base_url used to generate sitemap.xml. If not 72 | # specified, then no sitemap will be built. 73 | 'site_url': 'https://selprotopy.readthedocs.io/en/latest/', 74 | 75 | # Set the color and the accent color 76 | "palette": [ 77 | { 78 | "primary": "light-blue", 79 | "accent": "blue", 80 | "media": "(prefers-color-scheme: light)", 81 | "scheme": "default", 82 | "toggle": { 83 | "icon": "material/toggle-switch-off-outline", 84 | "name": "Switch to dark mode", 85 | } 86 | }, 87 | { 88 | "primary": "blue", 89 | "accent": "light-blue", 90 | "media": "(prefers-color-scheme: dark)", 91 | "scheme": "slate", 92 | "toggle": { 93 | "icon": "material/toggle-switch", 94 | "name": "Switch to light mode", 95 | } 96 | }, 97 | ], 98 | 99 | # Set the repo location to get a badge with stats 100 | 'repo_url': 'https://github.com/engineerjoe440/selprotopy/', 101 | 'repo_name': 'selprotopy', 102 | 103 | "icon": { 104 | "repo": "fontawesome/brands/github", 105 | "logo": "material/library", 106 | }, 107 | } 108 | 109 | 110 | 111 | 112 | 113 | # END -------------------------------------------------------------------------------- /docsource/examples.md: -------------------------------------------------------------------------------- 1 | # Examples of SELProtoPy in Action 2 | 3 | SELProtoPy may be used for a variety of standard interactions with SEL relays. The following 4 | references are a variety of example use-cases or implementations of the SELProtoPy package. 5 | Do not mistake them as recommendations, they are provided here as a support for education 6 | surrounding this tool. 7 | 8 | ```{toctree} 9 | --- 10 | maxdepth: 1 11 | glob: 12 | --- 13 | 14 | client_examples/* 15 | ``` 16 | 17 | ```{note} 18 | It's important to acknowledge that although SEL Protocol is largely standardized, this library 19 | has only been used on, or tested against a limited set of SEL intelligent electronic devices. 20 | As such you may encounter errors where such devices do not behave in an expected fashion. 21 | 22 | In such cases, it is requested that an issue is opened on 23 | [GitHub](https://github.com/engineerjoe440/selprotopy/issues) to make maintainers aware of the 24 | problem. 25 | ``` 26 | -------------------------------------------------------------------------------- /docsource/exceptions.rst: -------------------------------------------------------------------------------- 1 | .. _selprotopy.exceptions.py: 2 | 3 | Base Exceptions 4 | =============== 5 | 6 | .. autoexception:: selprotopy.exceptions.CommError 7 | 8 | .. autoexception:: selprotopy.exceptions.ParseError 9 | 10 | .. autoexception:: selprotopy.exceptions.ProtoError 11 | 12 | 13 | Communications Exceptions 14 | ========================= 15 | 16 | .. autoexception:: selprotopy.exceptions.MalformedByteArray 17 | 18 | .. autoexception:: selprotopy.exceptions.ChecksumFail 19 | 20 | .. autoexception:: selprotopy.exceptions.ConnVerificationFail 21 | 22 | 23 | Parse Errors 24 | ~~~~~~~~~~~~ 25 | 26 | .. autoexception:: selprotopy.exceptions.MissingA5Head 27 | 28 | .. autoexception:: selprotopy.exceptions.DnaDigitalsMisMatch 29 | 30 | 31 | Protocol Exceptions 32 | =================== 33 | 34 | .. autoexception:: selprotopy.exceptions.InvalidCommandType 35 | 36 | .. autoexception:: selprotopy.exceptions.InvalidControlType 37 | 38 | -------------------------------------------------------------------------------- /docsource/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | :hero: Python Bindings for the SEL Protocol Suite 3 | --- 4 | 5 | # SELPROTOPY: Python Bindings for the SEL Protocol Suite 6 | 7 | `selprotopy` is intended to be used as a protocol binding suite for the SEL 8 | Protocol suite which includes SEL Fast Meter, SEL Fast Message, and SEL Fast 9 | Operate; each of which are proprietary protocols designed by 10 | [Schweitzer Engineering Laboratories](https://selinc.com/) for use primarily 11 | with protective electric relays, and other intelligent electronic devices. 12 | 13 | ```{note} 14 | This project, although binding SEL Protocol, is not sponsored, tested, or 15 | vetted in any way by Schweitzer Engineering Laboratories (SEL). This project 16 | is authored and maintained as an open-source project. Testing is performed 17 | on a very small set of hardware running in the author's basement. In short, 18 | this project has no association with SEL. 19 | ``` 20 | 21 | ## Protocol Documentation 22 | 23 | SEL Protocol was introduced in the early 1990s to support various communications 24 | and control of SEL protective relays. SEL provided a very supportive 25 | [application guide](https://selinc.com/api/download/5026/) which provides great 26 | detail about the protocol's implementation. 27 | 28 | ```{toctree} 29 | --- 30 | maxdepth: 1 31 | --- 32 | 33 | selprotopy 34 | examples 35 | ``` 36 | 37 | ```{warning} 38 | *Since this project is not rigorously tested across all SEL devices or in a 39 | wide variety of use-cases, any time this project is used, it should first be 40 | thoroughly tested. This project is not intended to serve protection-class 41 | systems in any capacity. It should primarily be used for research, 42 | exploration, and other learning objectives.* 43 | ``` 44 | 45 | ### Installation 46 | 47 | #### Typical Installation (From PyPI) 48 | 49 | ```shell 50 | pip install selprotopy[full] 51 | ``` 52 | 53 | #### From Source (Development) 54 | 55 | To install `selprotopy` from GitHub: 56 | 57 | 1. Download the repository as a zipped package. 58 | 2. Unzip the repository. 59 | 3. Open a terminal (command-prompt) and navigate to the new folder that's been unzipped. 60 | 61 | ```{hint} 62 | Use `cd /selprotopy` 63 | ``` 64 | 65 | 4. Use `pip` or `python` to install with the following commands, respectively: 66 | 67 | ```shell 68 | pip install -e . 69 | ``` 70 | 71 | 5. Verify that it's been installed by opening a Python instance and importing: 72 | 73 | ```python 74 | >>> import selprotopy 75 | ``` 76 | 77 | If no errors arise, the package has been installed. 78 | 79 | ## Project Information 80 | 81 | For additional information related to this project, please refer to the links 82 | and materials linked below. 83 | 84 | ### Contact Info 85 | 86 | As mentioned in the 87 | [caution](https://engineerjoe440.github.io/selprotopy/#selprotopy-python-bindings-for-the-sel-protocol-suite) 88 | above, this project is not associated with Schweitzer Engineering Laboratories 89 | (SEL) in any way, and as such, all contacts for questions, support, or other 90 | items should be directed to the resources listed here. 91 | 92 | For issues found in the source code itself, please feel free to open an 93 | [issue](https://github.com/engineerjoe440/selprotopy/issues), but for general 94 | inquiries and other contact, feel free to address 95 | [Joe Stanley](mailto:engineerjoe440@yahoo.com). 96 | -------------------------------------------------------------------------------- /docsource/requirements.txt: -------------------------------------------------------------------------------- 1 | wheel 2 | sphinx 3 | pyserial 4 | numpydoc 5 | myst-parser 6 | sphinx-sitemap 7 | sphinx-immaterial 8 | coverage-badge -------------------------------------------------------------------------------- /docsource/selprotopy.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ```{eval-rst} 4 | .. autoclass:: selprotopy.client.base.SELClient 5 | :members: 6 | ``` 7 | 8 | 9 | # Sub-Modules and Other Package API References 10 | 11 | ```{eval-rst} 12 | .. toctree:: 13 | :maxdepth: 1 14 | 15 | exceptions 16 | ``` -------------------------------------------------------------------------------- /logo/SELProtoPyLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/engineerjoe440/selprotopy/c983ce883aa2d2102dc0b56932bb994ff087abe9/logo/SELProtoPyLogo.png -------------------------------------------------------------------------------- /logo/SELProtoPyLogo.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 25 | 31 | 32 | 51 | 53 | 54 | 56 | image/svg+xml 57 | 59 | 60 | 61 | 62 | 63 | 67 | 74 | 81 | 88 | 93 | 100 | 107 | 114 | 121 | 128 | 135 | 142 | 149 | 156 | 158 | 163 | 168 | 173 | 178 | 179 | 182 | 187 | 194 | 195 | 198 | 203 | 210 | 211 | 214 | 219 | 226 | 227 | 230 | 235 | 242 | 243 | 248 | 251 | 256 | 261 | 262 | SEL Protocol Bindings for Python 274 | 275 | 276 | -------------------------------------------------------------------------------- /logo/relay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/engineerjoe440/selprotopy/c983ce883aa2d2102dc0b56932bb994ff087abe9/logo/relay.png -------------------------------------------------------------------------------- /logo/selprotopy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/engineerjoe440/selprotopy/c983ce883aa2d2102dc0b56932bb994ff087abe9/logo/selprotopy.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "selprotopy" 7 | description = "Schweitzer Engineering Laboratories (SEL) Protocol Bindings in Python" 8 | authors = [{name = "Joe Stanley", email = "engineerjoe440@yahoo.com"}] 9 | readme = "README.md" 10 | license = {file = "LICENSE"} 11 | classifiers = [ 12 | "Programming Language :: Python :: 3", 13 | "License :: OSI Approved :: MIT License", 14 | "Operating System :: OS Independent", 15 | ] 16 | dynamic = ["version"] 17 | 18 | [project.urls] 19 | Home = "https://selprotopy.readthedocs.io/en/latest/" 20 | Repository = "https://github.com/engineerjoe440/selprotopy/" 21 | PyPI = "https://pypi.org/project/selprotopy/" 22 | Issues = "https://github.com/engineerjoe440/selprotopy/issues" 23 | 24 | [project.optional-dependencies] 25 | serial = [ 26 | "pyserial", 27 | ] 28 | full = [ 29 | "pyserial", 30 | ] 31 | -------------------------------------------------------------------------------- /selprotopy/__init__.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | """ 3 | selprotopy: A Protocol Binding Suite for the SEL Protocol Suite. 4 | 5 | Supports: 6 | - SEL Fast Meter 7 | - SEL Fast Message 8 | - SEL Fast Operate 9 | 10 | Author(s): 11 | - Joe Stanley: engineerjoe440@yahoo.com 12 | 13 | Homepage: https://github.com/engineerjoe440/sel-proto-py 14 | 15 | SEL Protocol Application Guide: https://selinc.com/api/download/5026/ 16 | """ 17 | ################################################################################ 18 | 19 | # Standard Imports 20 | import telnetlib 21 | 22 | # Local Imports 23 | from selprotopy.support import telnet 24 | 25 | # Describe Package for External Interpretation 26 | __version__ = "0.1.4" 27 | 28 | # `telnetlib` Discards Null Characters, but SEL Protocol Requires them 29 | telnetlib.Telnet.process_rawq = telnet.process_rawq 30 | -------------------------------------------------------------------------------- /selprotopy/client/__init__.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | """ 3 | selprotopy: A Protocol Binding Suite for the SEL Protocol Suite. 4 | 5 | Supports: 6 | - SEL Fast Meter 7 | - SEL Fast Message 8 | - SEL Fast Operate 9 | 10 | Author(s): 11 | - Joe Stanley: engineerjoe440@yahoo.com 12 | 13 | Homepage: https://github.com/engineerjoe440/sel-proto-py 14 | 15 | SEL Protocol Application Guide: https://selinc.com/api/download/5026/ 16 | """ 17 | ################################################################################ 18 | 19 | try: 20 | from selprotopy.client.serial import * 21 | except ImportError: 22 | pass 23 | 24 | try: 25 | from selprotopy.client.ethernet import * 26 | except ImportError: 27 | pass 28 | -------------------------------------------------------------------------------- /selprotopy/client/base.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | """ 3 | selprotopy: A Protocol Binding Suite for the SEL Protocol Suite. 4 | 5 | Supports: 6 | - SEL Fast Meter 7 | - SEL Fast Message 8 | - SEL Fast Operate 9 | 10 | Author(s): 11 | - Joe Stanley: engineerjoe440@yahoo.com 12 | 13 | Homepage: https://github.com/engineerjoe440/sel-proto-py 14 | 15 | SEL Protocol Application Guide: https://selinc.com/api/download/5026/ 16 | """ 17 | ################################################################################ 18 | 19 | # Standard Imports 20 | import time 21 | import logging 22 | 23 | # Local Imports 24 | from selprotopy.common import ( 25 | retry, INVALID_COMMAND_STR, RemoteBitControlType, BreakerBitControlType 26 | ) 27 | from selprotopy import exceptions 28 | from selprotopy.protocol import commands, parser 29 | from selprotopy.support import socket 30 | 31 | 32 | # Define Simple Polling Client 33 | class SELClient(): 34 | """ 35 | `SELClient` Class for Polling an SEL Relay/Intelligent Electronic Device. 36 | 37 | The basic polling class intended to interact with an SEL relay which has 38 | already been connected to by way of a Telnet or Serial connection using one 39 | of the following Python libraries: 40 | 41 | - telnetlib https://docs.python.org/3/library/telnetlib.html 42 | - pyserial https://pyserial.readthedocs.io/en/latest/pyserial.html 43 | 44 | Parameters 45 | ---------- 46 | connApi: [telnetlib.Telnet, serial.Serial] 47 | Telnet or Serial API which will be used to communicate 48 | with the SEL relay. 49 | autoconfig_now: bool, optional 50 | Control to activate automatic configuration with the 51 | connected relay at time of class initialization, this 52 | should normally be set to True to allow auto-config. 53 | Defaults to True 54 | validConnChecks: int, optional 55 | Integer control to indicate maximum number of 56 | connection attempts should be issued to relay in the 57 | process of verifying established connection(s). 58 | Defaults to 5 59 | interdelay: float, optional 60 | Floating control which describes the amount of time in 61 | seconds between iterative connection verification 62 | attempts. Defaults to 0.025 (seconds) 63 | logger: logging.logger 64 | Logging object to record communications messages. 65 | verbose: bool, optional 66 | Control to dictate whether verbose printing operations 67 | should be used (often for debugging and learning 68 | purposes). Defaults to False 69 | 70 | Attributes 71 | ---------- 72 | conn: [telnetlib.Telnet, serial.Serial] 73 | Connection API 74 | verbose: bool 75 | Verbose information printing record (set by `verbose`) 76 | check: int 77 | Number of connection attempts before indicating failure 78 | (set by `validConnChecks`) 79 | delay: float 80 | Time (in seconds) to delay between connection attempts 81 | (set by `interdelay`) 82 | fid: str 83 | Relay's described Firmware ID string (set by connection with 84 | relay) 85 | bfid: str 86 | Relay's described BFID string (set by connection with relay) 87 | cid: str 88 | Relay's described CID string (set by connection with relay) 89 | devid: str 90 | Relay's described DEVID string (set by connection with relay) 91 | partno: str 92 | Relay's described part number string (set by connection with 93 | relay) 94 | config: str 95 | Relay's described configuration string (set by connection with 96 | relay) 97 | """ 98 | 99 | def __init__(self, connApi, logger: logging.Logger = None, 100 | verbose: bool = False, debug: bool = False, **kwargs): 101 | """Prepare SELClient.""" 102 | # Initialize Inputs 103 | self.conn = connApi 104 | self.verbose = verbose 105 | self.logger = logger 106 | self.debug = debug 107 | 108 | # Initialize Class Options 109 | self.timeout = 60 110 | self.__num_con_check__ = 5 111 | self.__inter_cmd_delay__ = 0.025 112 | 113 | # Initialize Additional Options that May be Made Available 114 | kwarg_keys = kwargs.keys() 115 | if 'timeout' in kwarg_keys: 116 | self.timeout = kwargs['timeout'] 117 | if 'conn_check' in kwarg_keys: 118 | self.__num_con_check__ = kwargs['conn_check'] 119 | if 'cmd_delay' in kwarg_keys: 120 | self.__inter_cmd_delay__ = kwargs['cmd_delay'] 121 | 122 | # Define Basic Parameter Defaults 123 | self.fid = '' 124 | self.bfid = '' 125 | self.cid = '' 126 | self.devid = '' 127 | self.partno = '' 128 | self.config = '' 129 | 130 | # Define Parameters to Indicate Whether Specific Commands are Supported 131 | self.fast_meter_supported = False 132 | self.fast_meter_demand_supported = False 133 | self.fast_meter_peak_demand_supported = False 134 | self.fast_operate_supported = False 135 | 136 | # Define the Various Command Defaults 137 | self.fm_config_command_1 = commands.FM_CONFIG_BLOCK 138 | self.fm_command_1 = commands.FM_DEMAND_CONFIG_BLOCK 139 | self.fm_config_command_2 = commands.FM_PEAK_CONFIG_BLOCK 140 | self.fm_command_2 = commands.FAST_METER_REGULAR 141 | self.fm_config_command_3 = commands.FAST_METER_DEMAND 142 | self.fm_command_3 = commands.FAST_METER_PEAK_DEMAND 143 | self.fop_command_info = commands.FO_CONFIG_BLOCK 144 | self.fmsg_command_info = commands.FAST_MSG_CONFIG_BLOCK 145 | 146 | # Allocate Space for Relay Definition Responses 147 | self.fast_meter_definition = None 148 | self.fast_demand_definition = None 149 | self.fast_peak_demand_definition = None 150 | 151 | if hasattr(self.conn, 'settimeout'): 152 | self.conn.settimeout(self.timeout) 153 | 154 | # Verify Connection by Searching for Prompt 155 | if 'noverify' not in kwarg_keys: 156 | if verbose: 157 | print('Verifying Connection...') 158 | if not self._verify_connection(): 159 | raise exceptions.ConnVerificationFail("Verification Failed.") 160 | if verbose: 161 | print('Connection Verified.') 162 | self.quit() 163 | if 'autoconfig' in kwargs: 164 | # Run Auto-Configuration 165 | if not isinstance(kwargs['autoconfig'], bool): 166 | self.autoconfig(verbose=verbose) 167 | elif bool(kwargs['autoconfig']): 168 | self.autoconfig(verbose=verbose) 169 | 170 | # Define Connectivity Check Method 171 | def _verify_connection(self): 172 | # Set Default Indication 173 | connected = False 174 | # Iteratively attempt to see relay's response 175 | for _ in range(self.__num_con_check__): 176 | self._write( commands.CR + commands.CR + commands.CR ) 177 | if hasattr(self.conn, "read_until"): 178 | response = self.conn.read_until( commands.CR ) 179 | elif hasattr(self.conn, 'socket_read'): 180 | response = socket.socket_read(self.conn) 181 | else: 182 | # pySerial Method 183 | response = self.conn.read_until( commands.CR ) 184 | if self.debug: 185 | print(response) 186 | if commands.LEVEL_0 in response: 187 | # Relay Responded 188 | connected = True 189 | break 190 | time.sleep( self.__inter_cmd_delay__ ) 191 | # Return Status 192 | return connected 193 | 194 | # Define Method to Handle Eager Reading Between Connection Methods 195 | def _read_eager(self): 196 | # Switch on Connection Type 197 | if hasattr(self.conn, "read_until"): 198 | return self.conn.read_very_eager() 199 | elif hasattr(self.conn, 'socket_read'): 200 | return socket.socket_read(self.conn) 201 | # pySerial 202 | return self.conn.read_very_eager() 203 | 204 | # Define Method to "Clear" the Buffer 205 | def _clear_input_buffer(self): 206 | try: 207 | resp = self._read_eager() 208 | if self.logger: 209 | self.logger.info(f'Rx: {resp}') 210 | if self.debug: 211 | print('Clearing buffer:', resp) 212 | while b'' != resp: 213 | time.sleep(self.__inter_cmd_delay__ * 10) 214 | resp = self._read_eager() 215 | if self.logger: 216 | self.logger.info(f'Rx: {resp}') 217 | if self.debug: 218 | print('Clearing buffer:', resp) 219 | except Exception: 220 | # pySerial Method 221 | self.conn.reset_input_buffer() 222 | 223 | # Define Method to Handle Writing for telnetlib-vs-socket 224 | def _write(self, data): 225 | # Switch on Writing Mechanism 226 | if hasattr(self.conn, "read_until"): 227 | self.conn.write(data) 228 | elif hasattr(self.conn, 'sendall'): 229 | self.conn.sendall(data) 230 | else: 231 | # pySerial 232 | self.conn.write(data) 233 | 234 | # Define Method to Read All Data to Next Relay Prompt 235 | def _read_to_prompt(self, prompt_str=commands.PROMPT): 236 | if hasattr(self.conn, "read_until"): 237 | try: 238 | # Telnetlib Supports a Timeout 239 | response = self.conn.read_until( 240 | prompt_str, 241 | timeout=self.timeout 242 | ) 243 | # PySerial Does not Support Timeout 244 | except TypeError: 245 | response = self.conn.read_until(prompt_str) 246 | elif hasattr(self.conn, 'socket_read'): 247 | response = socket.socket_read(self.conn) 248 | if self.logger: 249 | self.logger.debug(f'Rx: {response}') 250 | if self.debug: 251 | print(response) 252 | return response 253 | 254 | # Define Method to Read All Data After a Command (and to next relay prompt) 255 | def _read_command_response(self, command, prompt_str=commands.PROMPT): 256 | if isinstance(command, bytes): 257 | command = command.replace(b'\n', b'') 258 | command = command.replace(b'\r', b'') 259 | elif isinstance(command, str): 260 | command = command.replace('\n', '') 261 | command = command.replace('\r', '') 262 | response = b'' 263 | i = 0 264 | while (response.find(command) == -1) and (i < 10): 265 | sz = len(response) # Capture Previous Size 266 | response += self._read_to_prompt( prompt_str=prompt_str ) 267 | i = 0 if len(response) != sz else i + 1 268 | # Check for Invalid Command Response from Relay 269 | if INVALID_COMMAND_STR in response: 270 | raise exceptions.InvalidCommand( 271 | f"Relay Reports Invalid Command: '{response}'" 272 | ) 273 | return response 274 | 275 | # Define Method to Read Until a "Clean" Prompt is Viewed 276 | def _read_clean_prompt(self): 277 | """ 278 | Read and Send Carriage Return Characters to Clean Prompt. 279 | 280 | Strategy 281 | -------- 282 | 283 | Continue to send until the counted "clean" responses reaches 3 284 | or more. 285 | """ 286 | count = 0 287 | response = b'' 288 | while count < 3: 289 | self._write( commands.CR ) # Write 290 | response += self._read_to_prompt() # Read 291 | if self.debug: 292 | print('Clean prompt response:', response) 293 | # Count the Number of Clean Prompt Responses 294 | if parser.clean_prompt(response): 295 | count += 1 296 | else: 297 | count = 0 298 | time.sleep(self.__inter_cmd_delay__) 299 | self._clear_input_buffer() # Empty anything left in the buffer 300 | time.sleep(self.__inter_cmd_delay__) 301 | 302 | # Define Method to Attempt Reading Everything (only for telnetlib) 303 | def _read_everything(self): 304 | response = self._read_eager() 305 | if self.logger: 306 | self.logger.info(f'Rx: {response}') 307 | if self.debug: 308 | print(response) 309 | return response 310 | 311 | # Define Method to Identify Current Access Level 312 | def access_level(self): 313 | """ 314 | Identify Current Access Level. 315 | 316 | Simple method to identify what the current access level 317 | is for the connected relay. Provides an integer and 318 | string. 319 | 320 | Returns 321 | ------- 322 | int: Integer representing the access level 323 | as a value in the range of [0, 1, 2, 3] 324 | desc: String describing the access level, 325 | will return empty string for level-0. 326 | """ 327 | # Retrieve Prompt Twice 328 | self._write( commands.CR ) 329 | resp = self._read_to_prompt() 330 | self._write( commands.CR ) 331 | resp += self._read_to_prompt() 332 | # Look for Each Level, Return Highest Found 333 | if commands.LEVEL_C in resp: 334 | return (3, 'CAL') 335 | elif commands.LEVEL_2 in resp: 336 | return (2, '2AC') 337 | elif commands.LEVEL_1 in resp: 338 | return (1, 'ACC') 339 | else: 340 | return (0, '') 341 | 342 | # Define Method to Return to Access Level 0 343 | def quit(self): 344 | """ 345 | Quit Method. 346 | 347 | Simple method to send the QUIT command to an 348 | actively connected relay. 349 | 350 | See Also 351 | -------- 352 | access_level_1 : Elevate permission to ACC 353 | access_level_2 : Elevate permission to 2AC 354 | """ 355 | self._write( commands.QUIT ) 356 | self._read_to_prompt( commands.LEVEL_0 ) 357 | self._read_clean_prompt() 358 | 359 | # Define Method to Access Level 1 360 | def access_level_1(self, level_1_pass: str = commands.PASS_ACC, **kwargs): 361 | """ 362 | Go to Access Level 1. 363 | 364 | Used to elevate connection privileges with the connected 365 | relay to ACC with the appropriate password specified. If 366 | called when current access level is greater than ACC, this 367 | method will deescalate the permission level to ACC. 368 | 369 | See Also 370 | -------- 371 | quit : Relinquish all permission with relay 372 | access_level_2 : Elevate permission to 2AC 373 | 374 | Parameters 375 | ---------- 376 | level_1_pass: str, optional 377 | Password necessary to access the ACC 378 | level, only required if accessing ACC 379 | from level 0 (i.e. logging in). 380 | 381 | Returns 382 | ------- 383 | success: bool 384 | Indicator of whether the login failed. 385 | """ 386 | # Identify Current Access Level 387 | time.sleep(self.__inter_cmd_delay__) 388 | level, _ = self.access_level() 389 | if self.debug: 390 | print("Logging in to ACC") 391 | self._write( commands.GO_ACC ) 392 | # Provide Password 393 | if level == 0: 394 | time.sleep( int(self.__inter_cmd_delay__ * 3) ) 395 | self._write( level_1_pass + commands.CR ) 396 | time.sleep( self.__inter_cmd_delay__ ) 397 | resp = self._read_to_prompt( commands.LEVEL_0 ) 398 | if b'Invalid' in resp: 399 | if self.debug: 400 | print("Log-In Failed") 401 | return False 402 | else: 403 | if self.debug: 404 | print("Log-In Succeeded") 405 | return True 406 | 407 | # Define Method to Access Level 2 408 | def access_level_2(self, level_2_pass: str = commands.PASS_2AC, **kwargs): 409 | """ 410 | Go To Access Level 2. 411 | 412 | Used to elevate connection privileges with the connected 413 | relay to 2AC with the appropriate password specified. If 414 | called when current access level is greater than 2AC, this 415 | method will deescalate the permission level to 2AC. 416 | 417 | See Also 418 | -------- 419 | quit : Relinquish all permission with relay 420 | access_level_1 : Elevate permission to ACC 421 | 422 | Parameters 423 | ---------- 424 | level_2_pass: str, optional 425 | Password necessary to access the 2AC 426 | level, only required if accessing 2AC 427 | from level 1 (i.e. logging in). 428 | 429 | Returns 430 | ------- 431 | success: bool 432 | Indicator of whether the login failed. 433 | """ 434 | # Identify Current Access Level 435 | level, _ = self.access_level() 436 | # Provide Password 437 | if level == 0: 438 | if not self.access_level_1( **kwargs ): 439 | return False 440 | if self.debug: 441 | print("Logging in to 2AC") 442 | self._write( commands.GO_2AC ) 443 | if level in [0, 1]: 444 | time.sleep( int(self.__inter_cmd_delay__ * 3) ) 445 | self._write( level_2_pass + commands.CR ) 446 | time.sleep( self.__inter_cmd_delay__ ) 447 | resp = self._read_to_prompt( commands.LEVEL_0 ) 448 | if b'Invalid' in resp: 449 | if self.debug: 450 | print("Log-In Failed") 451 | return False 452 | else: 453 | if self.debug: 454 | print("Log-In Succeeded") 455 | return True 456 | 457 | # Define Method to Perform Auto-Configuration Process 458 | def autoconfig( self, attempts: int = 0, verbose: bool = False, **kwargs ): 459 | """ 460 | Auto-Configure SELClient Instance. 461 | 462 | Method to operate the standard auto-configuration process 463 | with a connected relay to identify the system parameters of 464 | the relay. This includes: 465 | 466 | - FID 467 | - BFID 468 | - CID 469 | - DEVID 470 | - PARTNO 471 | - CONFIG 472 | - Relay Definition Block 473 | 474 | This method also automatically interprets the following fast 475 | meter blocks by way of separate method calls. 476 | 477 | - Fast Meter Configuration Block 478 | - Fast Meter Demand Configuration Block 479 | - Fast Meter Peak Demand Configuration Block 480 | - Fast Operate Configuration Block 481 | 482 | See Also 483 | -------- 484 | autoconfig_fastmeter : Auto Configuration for Fast Meter 485 | autoconfig_fastmeter_demand : Auto Configuration for Fast Meter 486 | Demand 487 | autoconfig_fastmeter_peakdemand : Auto Configuration for Fast Meter 488 | Peak Demand 489 | autoconfig_fastoperate : Auto Configuration for Fast Operate 490 | autoconfig_relay_definition : Auto Configuration for Relay 491 | Definition Block 492 | 493 | Parameters 494 | ---------- 495 | attempts: int, optional 496 | Number of auto-configuration attempts, setting to `0` 497 | will allow for repeated auto-configuration until all 498 | stages succeed. Defaults to 0 499 | verbose: bool, optional 500 | Control to dictate whether verbose printing operations 501 | should be used (often for debugging and learning 502 | purposes). Defaults to False 503 | """ 504 | self.quit() 505 | # Determine Command Strings and Relay Information 506 | self.autoconfig_relay_definition(attempts=attempts, verbose=self.debug) 507 | if self.fast_meter_supported: 508 | self.autoconfig_fastmeter( verbose=self.debug ) 509 | if self.fast_meter_demand_supported: 510 | self.autoconfig_fastmeter_demand( verbose=self.debug ) 511 | if self.fast_meter_peak_demand_supported: 512 | self.autoconfig_fastmeter_peakdemand( verbose=self.debug ) 513 | if self.fast_operate_supported: 514 | self.autoconfig_fastoperate( verbose=self.debug ) 515 | # Determine if Level 0, and Escalate Accordingly 516 | if self.access_level()[0] == 0: 517 | # Access Level 1 Required to Request DNA 518 | self.access_level_1( **kwargs ) 519 | # Request Relay ENA Block 520 | # TODO 521 | # Request Relay DNA Block 522 | self._read_clean_prompt() 523 | if verbose: 524 | print("Reading Relay DNA Block...") 525 | self._write( commands.DNA ) 526 | self.dnaDef = parser.relay_dna_block( 527 | self._read_command_response(commands.DNA), 528 | encoding='utf-8', 529 | verbose=self.debug 530 | ) 531 | # Request Relay BNA Block 532 | # TODO 533 | # Request Relay ID Block 534 | if verbose: 535 | print("Reading Relay ID Block...") 536 | self._write( commands.ID ) 537 | id_block = parser.relay_id_block( 538 | self._read_command_response(commands.ID), 539 | encoding='utf-8', 540 | verbose=self.debug 541 | ) 542 | # Store Relay Information 543 | self.fid = id_block['FID'] 544 | self.bfid = id_block['BFID'] 545 | self.cid = id_block['CID'] 546 | self.devid = id_block['DEVID'] 547 | self.partno = id_block['PARTNO'] 548 | self.config = id_block['CONFIG'] 549 | 550 | # Define Method to Pack the Config Messages 551 | @retry(fail_msg="Relay Definition Parsing Failed.") 552 | def autoconfig_relay_definition(self, attempts: int = 0, 553 | verbose: bool = False): 554 | """ 555 | Autoconfigure SEL Client for Relay Definition Block. 556 | 557 | Method to operate the standard auto-configuration process 558 | with a connected relay to identify the standard messages required to 559 | interact with the device and poll specific datasets. 560 | 561 | See Also 562 | -------- 563 | autoconfig : Relay Auto Configuration 564 | autoconfig_fastmeter_demand : Auto Config for Fast Meter Demand 565 | autoconfig_fastmeter_peakdemand : Auto Config for Fast Meter Pk Demand 566 | autoconfig_fastoperate : Auto Config for Fast Operate 567 | 568 | Parameters 569 | ---------- 570 | attempts: int, optional 571 | Number of auto-configuration attempts, setting to `0` 572 | will allow for repeated auto-configuration until all 573 | stages succeed. Defaults to 0 574 | verbose: bool, optional 575 | Control to dictate whether verbose printing operations 576 | should be used (often for debugging purposes). 577 | Defaults to False 578 | """ 579 | # Request Relay Definition 580 | if verbose: 581 | print("Reading Relay Definition Block...") 582 | verbose = verbose or self.debug 583 | self._write(commands.RELAY_DEFINITION + commands.CR ) 584 | definition = parser.relay_definition_block( 585 | self._read_command_response(commands.RELAY_DEFINITION), 586 | verbose=verbose 587 | ) 588 | # Load the Relay Definition Information and Request the Meter Blocks 589 | if definition['fmmessagesup'] >= 1: 590 | if verbose: 591 | print("Reading Fast Meter Definition Block...") 592 | self.fm_config_command_1 = \ 593 | definition['fmcommandinfo'][0]['configcommand'] 594 | self.fm_command_1 = \ 595 | definition['fmcommandinfo'][0]['command'] 596 | self.fast_meter_supported = True 597 | if definition['fmmessagesup'] >= 2: 598 | if verbose: 599 | print("Reading Fast Meter Demand Definition Block...") 600 | self.fm_config_command_2 = \ 601 | definition['fmcommandinfo'][1]['configcommand'] 602 | self.fm_command_2 = \ 603 | definition['fmcommandinfo'][1]['command'] 604 | self.fast_meter_demand_supported = True 605 | if definition['fmmessagesup'] >= 3: 606 | if verbose: 607 | print("Reading Fast Meter Peak Demand Definition Block...") 608 | self.fm_config_command_3 = \ 609 | definition['fmcommandinfo'][2]['configcommand'] 610 | self.fm_command_3 = \ 611 | definition['fmcommandinfo'][2]['command'] 612 | self.fast_meter_peak_demand_supported = True 613 | # Interpret the Fast Operate Information if Present 614 | if definition['fopcommandinfo'] != '': 615 | if verbose: 616 | print("Reading Fast Operate Definition Block...") 617 | self.fop_command_info = definition['fopcommandinfo'] 618 | self.fast_operate_supported = True 619 | # Interpret the Fast Message Information if Present 620 | if definition['fmsgcommandinfo'] != '': 621 | if verbose: 622 | print("Reading Fast Message Definition Block...") 623 | self.fmsg_command_info = definition['fmsgcommandinfo'] 624 | 625 | 626 | # Define Method to Run the Fast Meter Configuration 627 | @retry(fail_msg="Fast Meter Autoconfig Failed.") 628 | def autoconfig_fastmeter(self, attempts: int = 0, verbose: bool = False): 629 | """ 630 | Autoconfigure Fast Meter for SEL Client. 631 | 632 | Method to operate the standard auto-configuration process 633 | with a connected relay to identify the standard fast meter 634 | parameters of the relay. 635 | 636 | See Also 637 | -------- 638 | autoconfig : Relay Auto Configuration 639 | autoconfig_fastmeter_demand : Auto Config for Fast Meter Demand 640 | autoconfig_fastmeter_peakdemand : Auto Config for Fast Meter Pk Demand 641 | autoconfig_fastoperate : Auto Config for Fast Operate 642 | 643 | Parameters 644 | ---------- 645 | attempts: int, optional 646 | Number of auto-configuration attempts, setting to `0` 647 | will allow for repeated auto-configuration until all 648 | stages succeed. Defaults to 0 649 | verbose: bool, optional 650 | Control to dictate whether verbose printing operations 651 | should be used (often for debugging purposes). 652 | Defaults to False 653 | """ 654 | # Fast Meter 655 | self._read_clean_prompt() 656 | self._write( self.fm_config_command_1 + commands.CR ) 657 | self.fast_meter_definition = parser.fast_meter_configuration_block( 658 | self._read_to_prompt(), 659 | verbose=verbose, 660 | ) 661 | 662 | # Define Method to Run the Fast Meter Demand Configuration 663 | @retry(fail_msg="Fast Meter Demand Autoconfig Failed.") 664 | def autoconfig_fastmeter_demand(self, attempts: int = 0, 665 | verbose: bool = False): 666 | """ 667 | Autoconfigure Fast Meter Demand for SEL Client. 668 | 669 | Method to operate the standard auto-configuration process 670 | with a connected relay to identify the fast meter demand 671 | parameters of the relay. 672 | 673 | See Also 674 | -------- 675 | autoconfig : Relay Auto Configuration 676 | autoconfig_fastmeter : Auto Config for Fast Meter 677 | autoconfig_fastmeter_peakdemand : Auto Config for Fast Meter Pk Demand 678 | autoconfig_fastoperate : Auto Config for Fast Operate 679 | 680 | Parameters 681 | ---------- 682 | attempts: int, optional 683 | Number of auto-configuration attempts, setting to `0` 684 | will allow for repeated auto-configuration until all 685 | stages succeed. Defaults to 0 686 | verbose: bool, optional 687 | Control to dictate whether verbose printing operations 688 | should be used (often for debugging purposes). 689 | Defaults to False 690 | """ 691 | # Fast Meter Demand 692 | self._read_clean_prompt() 693 | self._write( self.fm_config_command_2 + commands.CR ) 694 | self.fast_demand_definition = parser.fast_meter_configuration_block( 695 | self._read_to_prompt(), 696 | verbose=verbose, 697 | ) 698 | 699 | # Define Method to Run the Fast Meter Peak Demand Configuration 700 | @retry(fail_msg="Fast Meter Peak Demand Autoconfig Failed.") 701 | def autoconfig_fastmeter_peakdemand(self, attempts: int = 0, 702 | verbose: bool = False): 703 | """ 704 | Autoconfigure Fast Meter Peak Demand for SEL Client. 705 | 706 | Method to operate the standard auto-configuration process 707 | with a connected relay to identify the fast meter peak demand 708 | parameters of the relay. 709 | 710 | See Also 711 | -------- 712 | autoconfig : Relay Auto Configuration 713 | autoconfig_fastmeter : Auto Config for Fast Meter 714 | autoconfig_fastmeter_demand : Auto Config for Fast Meter Demand 715 | autoconfig_fastoperate : Auto Config for Fast Operate 716 | 717 | Parameters 718 | ---------- 719 | attempts: int, optional 720 | Number of auto-configuration attempts, setting to `0` 721 | will allow for repeated auto-configuration until all 722 | stages succeed. Defaults to 0 723 | verbose: bool, optional 724 | Control to dictate whether verbose printing operations 725 | should be used (often for debugging purposes). 726 | Defaults to False 727 | """ 728 | # Fast Meter Peak Demand 729 | self._read_clean_prompt() 730 | self._write( self.fm_config_command_3 + commands.CR ) 731 | self.fast_peak_demand_definition = parser.fast_meter_configuration_block( 732 | self._read_to_prompt(), 733 | verbose=verbose, 734 | ) 735 | 736 | # Define Method to Run the Fast Operate Configuration 737 | @retry(fail_msg="Fast Operate Autoconfig Failed.") 738 | def autoconfig_fastoperate(self, attempts: int = 0, verbose: bool = False): 739 | """ 740 | Autoconfigure Fast Operate for SEL Client. 741 | 742 | Method to operate the standard auto-configuration process 743 | with a connected relay to identify the fast operate parameters 744 | of the relay. 745 | 746 | See Also 747 | -------- 748 | autoconfig : Relay Auto Configuration 749 | autoconfig_fastmeter : Auto Config for Fast Meter 750 | autoconfig_fastmeter_demand : Auto Config for Fast Meter Demand 751 | autoconfig_fastmeter_peakdemand : Auto Config for Fast Meter Pk Demand 752 | 753 | Parameters 754 | ---------- 755 | attempts: int, optional 756 | Number of auto-configuration attempts, setting to `0` 757 | will allow for repeated auto-configuration until all 758 | stages succeed. Defaults to 0 759 | verbose: bool, optional 760 | Control to dictate whether verbose printing operations 761 | should be used (often for debugging purposes). 762 | Defaults to False 763 | """ 764 | # Fast Meter Peak Demand 765 | self._read_clean_prompt() 766 | self._write( self.fop_command_info + commands.CR ) 767 | self.fastOpDef = parser.fast_op_configuration_block( 768 | self._read_to_prompt(), 769 | verbose=verbose, 770 | ) 771 | 772 | # Define Method to Perform Fast Meter Polling 773 | def poll_fast_meter(self, minAccLevel: bool = 0, verbose: bool = False, 774 | **kwargs): 775 | """ 776 | Poll Fast Meter Data from SEL Relay/IED. 777 | 778 | Method to poll the connected relay with the configured protocol 779 | settings (use `autoconfig` method to configure protocol settings). 780 | 781 | See Also 782 | -------- 783 | autoconfig : Relay Auto Configuration 784 | 785 | Parameters 786 | ---------- 787 | minAccLevel: int, optional 788 | Control to specify whether a minimum access level must 789 | be obtained before polling should be performed. 790 | verbose: bool, optional 791 | Control to dictate whether verbose printing operations 792 | should be used (often for debugging purposes). 793 | Defaults to False 794 | """ 795 | # Verify that Configuration is Valid 796 | if self.fast_meter_definition is None: 797 | # TODO: Add Custom Exception to be More Explicit 798 | raise ValueError("Client has not been auto-configured yet!") 799 | # Raise to Appropriate Access Level if Needed 800 | if minAccLevel == 1: 801 | self.access_level_1( **kwargs ) 802 | if minAccLevel == 2: 803 | self.access_level_2( **kwargs ) 804 | # Poll Client for Data 805 | self._read_clean_prompt() 806 | self._write( self.fm_command_1 + commands.CR ) 807 | response = parser.fast_meter_block( 808 | self._read_command_response( 809 | self.fm_command_1 810 | ), 811 | self.fast_meter_definition, 812 | self.dnaDef, 813 | verbose=verbose, 814 | ) 815 | # Return the Response 816 | return response 817 | 818 | # Define Method to Send Fast Operate Command for Breaker Bit 819 | def send_breaker_bit_fast_op( 820 | self, 821 | control_point: str, 822 | command: BreakerBitControlType = BreakerBitControlType.TRIP 823 | ): 824 | """ 825 | Send a Fast Operate Breaker Bit Control. 826 | 827 | Send a breaker bit using the Fast Operate protocol by describing 828 | the control point and the control type. 829 | 830 | See Also 831 | -------- 832 | send_remote_bit_fast_op : Send Remote Bit Control 833 | 834 | Parameters 835 | ---------- 836 | control_point: str 837 | Particular Remote Bit point which should be 838 | controlled, should be of format 'RBxx' where 839 | 'xx' represents the remote bit number. 840 | command: BreakerBitControlType, optional 841 | Command type which will be sent, must be of: 842 | ['TRIP', 'CLOSE']. 843 | Defaults to 'trip' 844 | """ 845 | # Write the Command 846 | command_str = commands.prepare_fastop_command( 847 | control_type='breaker_bit', control_point=control_point, 848 | command=command, fastop_def=self.fastOpDef 849 | ) 850 | if self.verbose: 851 | print(command_str) 852 | self._write( command_str ) 853 | 854 | # Define Method to Send Fast Operate Command for Remote Bit 855 | def send_remote_bit_fast_op( 856 | self, 857 | control_point: str, 858 | command: RemoteBitControlType = RemoteBitControlType.PULSE 859 | ): 860 | """ 861 | Send a Fast Operate Remote Bit Control. 862 | 863 | Send a remote bit using the Fast Operate protocol by describing 864 | the control point and the control type. 865 | 866 | See Also 867 | -------- 868 | send_breaker_bit_fast_op : Send Breaker Bit Control 869 | 870 | Parameters 871 | ---------- 872 | control_point: str 873 | Particular Remote Bit point which should be 874 | controlled, should be of format 'RBxx' where 875 | 'xx' represents the remote bit number. 876 | command: RemoteBitControlType, optional 877 | Command type which will be sent, must be of: 878 | ['SET', 'CLEAR', 'PULSE', 'OPEN', 'CLOSE']. 879 | Defaults to 'pulse' 880 | """ 881 | # Write the Command 882 | command_str = commands.prepare_fastop_command( 883 | control_type='remote_bit', control_point=control_point, 884 | command=command, fastop_def=self.fastOpDef 885 | ) 886 | if self.verbose: 887 | print(command_str) 888 | self._write( command_str ) 889 | 890 | 891 | # Define Builtin Test Mechanism 892 | if __name__ == '__main__': 893 | logging.basicConfig(filename='traffic.log', level=logging.DEBUG) 894 | logger_obj = logging.getLogger(__name__) 895 | print('Establishing Connection...') 896 | # with telnetlib.Telnet('192.168.2.210', 23) as tn: 897 | # print('Initializing Client...') 898 | # poller = SELClient( tn, logger=logger_obj, verbose=True, debug=True, noverify=True ) 899 | # poller.autoconfig_relay_definition(verbose=True) 900 | # poller.autoconfig(verbose=True) 901 | # poller.send_remote_bit_fast_op('RB1', 'pulse') 902 | # d = None 903 | # for _ in range(10): 904 | # d = poller.poll_fast_meter() # verbose=True) 905 | # for name, value in d['analogs'].items(): 906 | # print(name, value) 907 | # time.sleep(1) 908 | # poller.send_remote_bit_fast_op('RB1', 'pulse') 909 | sock = socket.create_connection(('192.168.2.210', 23)) 910 | print('Initializing Client...') 911 | poller = SELClient( sock, logger=logger_obj, verbose=True, debug=True ) 912 | poller.autoconfig_relay_definition(verbose=True) 913 | poller.autoconfig(verbose=True) 914 | 915 | 916 | # END 917 | -------------------------------------------------------------------------------- /selprotopy/client/ethernet.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | """ 3 | selprotopy: A Protocol Binding Suite for the SEL Protocol Suite. 4 | 5 | Supports: 6 | - SEL Fast Meter 7 | - SEL Fast Message 8 | - SEL Fast Operate 9 | 10 | Author(s): 11 | - Joe Stanley: engineerjoe440@yahoo.com 12 | 13 | Homepage: https://github.com/engineerjoe440/sel-proto-py 14 | 15 | SEL Protocol Application Guide: https://selinc.com/api/download/5026/ 16 | """ 17 | ################################################################################ 18 | 19 | from typing import Optional 20 | 21 | from selprotopy.client.base import SELClient 22 | from selprotopy.support import socket 23 | 24 | __all__ = ["TCPSELClient"] 25 | 26 | class TCPSELClient(SELClient): 27 | """ 28 | `SELClient` Class for Polling an SEL Relay/Intelligent Electronic Device. 29 | 30 | The basic polling class intended to interact with an SEL relay which has 31 | already been connected to by way of a TCP socket connection. 32 | 33 | Parameters 34 | ---------- 35 | autoconfig_now: bool, optional 36 | Control to activate automatic configuration with the 37 | connected relay at time of class initialization, this 38 | should normally be set to True to allow auto-config. 39 | Defaults to True 40 | validConnChecks: int, optional 41 | Integer control to indicate maximum number of 42 | connection attempts should be issued to relay in the 43 | process of verifying established connection(s). 44 | Defaults to 5 45 | interdelay: float, optional 46 | Floating control which describes the amount of time in 47 | seconds between iterative connection verification 48 | attempts. Defaults to 0.025 (seconds) 49 | logger: logging.logger 50 | Logging object to record communications messages. 51 | verbose: bool, optional 52 | Control to dictate whether verbose printing operations 53 | should be used (often for debugging and learning 54 | purposes). Defaults to False 55 | 56 | Attributes 57 | ---------- 58 | conn: [telnetlib.Telnet, serial.Serial] 59 | Connection API 60 | verbose: bool 61 | Verbose information printing record (set by `verbose`) 62 | check: int 63 | Number of connection attempts before indicating failure 64 | (set by `validConnChecks`) 65 | delay: float 66 | Time (in seconds) to delay between connection attempts 67 | (set by `interdelay`) 68 | fid: str 69 | Relay's described Firmware ID string (set by connection with 70 | relay) 71 | bfid: str 72 | Relay's described BFID string (set by connection with relay) 73 | cid: str 74 | Relay's described CID string (set by connection with relay) 75 | devid: str 76 | Relay's described DEVID string (set by connection with relay) 77 | partno: str 78 | Relay's described part number string (set by connection with 79 | relay) 80 | config: str 81 | Relay's described configuration string (set by connection with 82 | relay) 83 | """ 84 | 85 | def __init__( 86 | self, 87 | ip_address: str, 88 | port: Optional[int] = 23, 89 | **kwargs 90 | ): 91 | """Connect over Serial to the SEL Protocol Device.""" 92 | # Establish a TCP Connection 93 | connection = socket.socket.create_connection((ip_address, port)) 94 | # Attach Super Object 95 | super().__init__(connApi=connection, **kwargs) 96 | -------------------------------------------------------------------------------- /selprotopy/client/serial.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | """ 3 | selprotopy: A Protocol Binding Suite for the SEL Protocol Suite. 4 | 5 | Supports: 6 | - SEL Fast Meter 7 | - SEL Fast Message 8 | - SEL Fast Operate 9 | 10 | Author(s): 11 | - Joe Stanley: engineerjoe440@yahoo.com 12 | 13 | Homepage: https://github.com/engineerjoe440/sel-proto-py 14 | 15 | SEL Protocol Application Guide: https://selinc.com/api/download/5026/ 16 | """ 17 | ################################################################################ 18 | 19 | import serial 20 | 21 | from selprotopy.client.base import SELClient 22 | 23 | __all__ = ["SerialSELClient"] 24 | 25 | class SerialSELClient(SELClient): 26 | """ 27 | `SELClient` Class for Polling an SEL Relay/Intelligent Electronic Device. 28 | 29 | The basic polling class intended to interact with an SEL relay which has 30 | already been connected to by way of a Serial connection. 31 | 32 | Parameters 33 | ---------- 34 | autoconfig_now: bool, optional 35 | Control to activate automatic configuration with the 36 | connected relay at time of class initialization, this 37 | should normally be set to True to allow auto-config. 38 | Defaults to True 39 | validConnChecks: int, optional 40 | Integer control to indicate maximum number of 41 | connection attempts should be issued to relay in the 42 | process of verifying established connection(s). 43 | Defaults to 5 44 | interdelay: float, optional 45 | Floating control which describes the amount of time in 46 | seconds between iterative connection verification 47 | attempts. Defaults to 0.025 (seconds) 48 | logger: logging.logger 49 | Logging object to record communications messages. 50 | verbose: bool, optional 51 | Control to dictate whether verbose printing operations 52 | should be used (often for debugging and learning 53 | purposes). Defaults to False 54 | 55 | Attributes 56 | ---------- 57 | conn: [telnetlib.Telnet, serial.Serial] 58 | Connection API 59 | verbose: bool 60 | Verbose information printing record (set by `verbose`) 61 | check: int 62 | Number of connection attempts before indicating failure 63 | (set by `validConnChecks`) 64 | delay: float 65 | Time (in seconds) to delay between connection attempts 66 | (set by `interdelay`) 67 | fid: str 68 | Relay's described Firmware ID string (set by connection with 69 | relay) 70 | bfid: str 71 | Relay's described BFID string (set by connection with relay) 72 | cid: str 73 | Relay's described CID string (set by connection with relay) 74 | devid: str 75 | Relay's described DEVID string (set by connection with relay) 76 | partno: str 77 | Relay's described part number string (set by connection with 78 | relay) 79 | config: str 80 | Relay's described configuration string (set by connection with 81 | relay) 82 | """ 83 | 84 | def __init__( 85 | self, 86 | port=None, 87 | baudrate=9600, 88 | bytesize=serial.EIGHTBITS, 89 | parity=serial.PARITY_NONE, 90 | stopbits=serial.STOPBITS_ONE, 91 | timeout=None, 92 | xonxoff=False, 93 | rtscts=False, 94 | write_timeout=None, 95 | **kwargs 96 | ): 97 | """Connect over Serial to the SEL Protocol Device.""" 98 | # Establish Serial Connection 99 | connection = serial.Serial( 100 | port=port, 101 | baudrate=baudrate, 102 | bytesize=bytesize, 103 | parity=parity, 104 | xonxoff=xonxoff, 105 | rtscts=rtscts, 106 | write_timeout=write_timeout 107 | ) 108 | # Attach Super Object 109 | super().__init__(connApi=connection, **kwargs) 110 | -------------------------------------------------------------------------------- /selprotopy/common.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | """ 3 | selprotopy: A Protocol Binding Suite for the SEL Protocol Suite. 4 | 5 | Supports: 6 | - SEL Fast Meter 7 | - SEL Fast Message 8 | - SEL Fast Operate 9 | """ 10 | ################################################################################ 11 | 12 | # Import Requirements 13 | import time 14 | import math 15 | import struct 16 | from typing import AnyStr 17 | from enum import Enum 18 | 19 | from selprotopy import exceptions 20 | 21 | INVALID_COMMAND_STR = b"Invalid Command" 22 | 23 | 24 | class BreakerBitControlType(str, Enum): 25 | """Control Type for Remote Bits.""" 26 | 27 | CLOSE = "CLOSE" 28 | TRIP = "TRIP" 29 | 30 | class RemoteBitControlType(str, Enum): 31 | """Control Type for Remote Bits.""" 32 | 33 | SET = "SET" 34 | CLEAR = "CLEAR" 35 | PULSE = "PULSE" 36 | OPEN = "OPEN" 37 | CLOSE = "CLOSE" 38 | 39 | 40 | # Define Simple Function to Cast Binary Integer to List of Bools 41 | def int_to_bool_list(number: int, byte_like: bool = False, 42 | reverse: bool = False): 43 | """ 44 | Convert Integer to List of Booleans. 45 | 46 | This function converts an integer to a list of boolean values, 47 | where the most significant value is stored in the highest point 48 | of the list. That is, a binary number: 8 would be represented as 49 | [False, False, False, True] 50 | 51 | Parameters 52 | ---------- 53 | number: int 54 | Integer to be converted to list of boolean values 55 | according to binary representation. 56 | byte_like: bool, optional 57 | Control to verify that the integer is broken into a 58 | list of booleans that could be composed into a byte 59 | string object (i.e. length of list is divisible by 8), 60 | defaults to False. 61 | reverse: bool, optional 62 | Control to reverse the order of the binary/boolean 63 | points. 64 | 65 | Returns 66 | ------- 67 | bin_list: list of bool 68 | List of boolean values cast from the binary 69 | representation of the integer passed to the 70 | function. 71 | """ 72 | bin_string = format(number, '04b') 73 | bin_list = [x == '1' for x in bin_string[::-1]] 74 | # Extend List of Bytes if Needed 75 | if byte_like and ((len(bin_list) % 8) != 0): 76 | len_needed = ((len(bin_list)//8)+1) * 8 77 | apnd_list = [False] * (len_needed - len(bin_list)) 78 | bin_list.extend(apnd_list) 79 | # Reverse if Desired 80 | if reverse: 81 | bin_list.reverse() 82 | return bin_list 83 | 84 | # Define Simple Function to Cast Binary Representation of IEEE 4-Byte FPS 85 | def ieee_4_byte_fps(binary_bytes: bytes, total_digits: int = 7): 86 | """ 87 | Convert 4-Bytes to IEEE Floating Point Value. 88 | 89 | This function accepts a bytestring of 4 bytes and evaluates the 90 | IEEE Floating-Point value represented by the bytestring. 91 | 92 | Parameters 93 | ---------- 94 | binary_bytes: bytes 95 | The 4-byte bytestring which should be cast 96 | to a float using the IEEE floating-point standard. 97 | total_digits: int, optional 98 | Number of digits (i.e. decimal accuracy) which 99 | should be evaluated, defaults to 7. 100 | 101 | Returns 102 | ------- 103 | float: IEEE floating-point representation of the 4-byte 104 | bytestring passed as an argument. 105 | """ 106 | # Define the Internal Functions to Utilize 107 | def magnitude(x): 108 | return 0 if x==0 else int(math.floor(math.log10(abs(x)))) + 1 109 | def round_total_digits(x, digits=7): 110 | return round(x, digits - magnitude(x)) 111 | # Perform Calculation 112 | return round_total_digits( x=struct.unpack('>f', binary_bytes)[0], 113 | digits=total_digits ) 114 | 115 | # Define Function to Evaluate Checksum 116 | def eval_checksum(data: AnyStr, constrain: bool = False ): 117 | """ 118 | Evaluate Checksum from Data Row. 119 | 120 | This function accepts a byte-string, and calculates the checksum 121 | of the bytes provided. 122 | 123 | Parameters 124 | ---------- 125 | data: [str, bytes] 126 | The bytestring which should be evaluated for the 127 | checksum. 128 | constrain: bool, optional 129 | Control to specify whether the value should be 130 | constrained to an 8-bit representation, defaults 131 | to False. 132 | 133 | Returns 134 | ------- 135 | checksum: int 136 | The fully evaluated checksum. 137 | """ 138 | # Evaluate the sum 139 | if isinstance(data, str): 140 | checksum = sum(map(ord, data)) 141 | else: 142 | checksum = sum(data) 143 | # Cap the Value if Needed 144 | if constrain: 145 | checksum = checksum & 0xff # Bit-wise and with 8-bit maximum 146 | return checksum 147 | 148 | def retry(delay=0, fail_msg="Automatic Configuration Failed.", 149 | log_msg="Malformed response received during auto-configuration."): 150 | """Decorate Functions and Methods to Handle Retrying Specific Operations.""" 151 | def decorator(decor_method): 152 | def wrapper(cls, *args, **kwargs): 153 | attempts = 0 154 | if 'attempts' in kwargs.keys(): 155 | attempts = int(kwargs['attempts']) 156 | cnt = 0 157 | while (cnt < attempts) or (attempts == 0): 158 | try: 159 | return_values = decor_method(cls, *args, **kwargs) 160 | return return_values 161 | except exceptions.MalformedByteArray as error: 162 | if 'verbose' in kwargs.keys(): 163 | if bool(kwargs['verbose']): 164 | print(log_msg) 165 | print(error) 166 | # On exception, retry till count is exhausted 167 | if cls.logger: 168 | cls.logger.exception(log_msg, exc_info=error) 169 | time.sleep(delay) 170 | # Failed Beyond Retry Attempts 171 | raise exceptions.AutoConfigurationFailure(fail_msg) 172 | return wrapper 173 | return decorator 174 | 175 | # END 176 | -------------------------------------------------------------------------------- /selprotopy/exceptions.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | """ 3 | selprotopy: A Protocol Binding Suite for the SEL Protocol Suite. 4 | 5 | Supports: 6 | - SEL Fast Meter 7 | - SEL Fast Message 8 | - SEL Fast Operate 9 | """ 10 | ################################################################################ 11 | 12 | # Define Various Custom Exception Types 13 | class CommError(Exception): 14 | """Base Class for Communications Errors.""" 15 | 16 | class ParseError(CommError): 17 | """Base Class for Parsing Errors.""" 18 | 19 | class ProtoError(Exception): 20 | """Base Class for Protocol Errors.""" 21 | 22 | 23 | ############################################################################### 24 | 25 | # Define Custom Exception for Array Extraction Error 26 | class MalformedByteArray(CommError): 27 | """ 28 | Malformed Byte Array. 29 | 30 | Byte array does not appear in expected format, likely due to truncated 31 | communications caused by a communications failure. 32 | """ 33 | 34 | 35 | # Define Custom Exception for Invalid Checksum 36 | class ChecksumFail(CommError): 37 | """ 38 | Checksum Comparison Failure. 39 | 40 | Checksum validation has failed for the captured message, likely due to 41 | communications failure. 42 | """ 43 | 44 | 45 | # Define Custom Exception to Indicate Connection Verification Failure 46 | class ConnVerificationFail(CommError): 47 | """Communications could not be verified.""" 48 | 49 | ############################################################################### 50 | 51 | 52 | # Define Custom Exception for SEL Protocol Message Response Extraction 53 | class MissingA5Head(ParseError): 54 | """ 55 | Response Message Missing A5 Byte Heading. 56 | 57 | Parsing of returned binary SEL Protocol message failed due to lack of 58 | required A5[C1] heading. 59 | """ 60 | 61 | 62 | # Define Custom Exception for SEL Protocol DNA/Digitals Count Mismatch 63 | class DnaDigitalsMisMatch(ParseError): 64 | """ 65 | DNA Digital Sequence Doesn't Match Definition. 66 | 67 | Dereferencing of digitals in response does not match the relay's DNA 68 | definition, may be caused by a communications error, or failed parse. 69 | """ 70 | 71 | ############################################################################### 72 | 73 | 74 | # Define Custom Exception for Generic Invalid Command Response 75 | class InvalidCommand(ProtoError): 76 | """Invalid Command Reported by Relay.""" 77 | 78 | 79 | # Define Custom Exception for Invalid Command Type 80 | class InvalidCommandType(ProtoError): 81 | """ 82 | Invalid CommandType. 83 | 84 | Invalid command type provided for Fast Operate, must be member of: 85 | ['SET', 'CLEAR', 'PULSE', 'OPEN', 'CLOSE']. 86 | """ 87 | 88 | 89 | # Define Custom Exception for Invalid Control Type 90 | class InvalidControlType(ProtoError): 91 | """ 92 | Invalid Control Type. 93 | 94 | Invalid control type provided for Fast Operate, must be member of: 95 | ['REMOTE_BIT', 'BREAKER_BIT']. 96 | """ 97 | 98 | # Define Generic Auto-configuration Failure Exception 99 | class AutoConfigurationFailure(CommError): 100 | """ 101 | Automatic Configuration Failure. 102 | 103 | Failed to complete the auto-config process with the connected relay. 104 | """ 105 | 106 | # END 107 | -------------------------------------------------------------------------------- /selprotopy/protocol/__init__.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | """ 3 | selprotopy: A Protocol Binding Suite for the SEL Protocol Suite. 4 | 5 | Supports: 6 | - SEL Fast Meter 7 | - SEL Fast Message 8 | - SEL Fast Operate 9 | 10 | Author(s): 11 | - Joe Stanley: engineerjoe440@yahoo.com 12 | 13 | Homepage: https://github.com/engineerjoe440/sel-proto-py 14 | 15 | SEL Protocol Application Guide: https://selinc.com/api/download/5026/ 16 | """ 17 | ################################################################################ 18 | -------------------------------------------------------------------------------- /selprotopy/protocol/commands.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | """ 3 | selprotopy: A Protocol Binding Suite for the SEL Protocol Suite. 4 | 5 | Supports: 6 | - SEL Fast Meter 7 | - SEL Fast Message 8 | - SEL Fast Operate 9 | """ 10 | ################################################################################ 11 | 12 | # Standard Package Imports 13 | from typing import Union 14 | import re 15 | 16 | # Local Imports 17 | from selprotopy.common import ( 18 | eval_checksum, BreakerBitControlType, RemoteBitControlType 19 | ) 20 | from selprotopy.exceptions import InvalidControlType 21 | 22 | # Define Various Binary Requests 23 | RELAY_DEFINITION = bytes.fromhex('A5C0') # The relay definition block. 24 | FM_CONFIG_BLOCK = bytes.fromhex('A5C1') # A configuration block for regular Fast Meter command if available. 25 | FM_DEMAND_CONFIG_BLOCK = bytes.fromhex('A5C2') # A configuration block for demand Fast Meter if available. 26 | FM_PEAK_CONFIG_BLOCK = bytes.fromhex('A5C3') # A configuration block for peak demand Fast Meter if available. 27 | FO_CONFIG_BLOCK = bytes.fromhex('A5CE') # A configuration block for Fast Operate if available. 28 | FO_CONFIG_BLOCK_ALT = bytes.fromhex('A5CF') # Alternate configuration block for Fast Operate if available. 29 | FM_OLD_STD_BLOCK = bytes.fromhex('A5DC') # One of the old standard Fast Meter blocks if available. 30 | FM_OLD_EXT_BLOCK = bytes.fromhex('A5DA') # One of the old extended Fast Meter blocks if available. 31 | FAST_METER_REGULAR = bytes.fromhex('A5D1') # Regular Fast Meter defined by configuration block. 32 | FAST_METER_DEMAND = bytes.fromhex('A5D2') # Demand Fast Meter defined by configuration block. 33 | FAST_METER_PEAK_DEMAND = bytes.fromhex('A5D3') # Peak demand Fast Meter defined by configuration block. 34 | FAST_OP_REMOTE_BIT = bytes.fromhex('A5E0') # Fast Operate command for remote bit operation. 35 | FAST_OP_BREAKER_BIT = bytes.fromhex('A5E3') # Fast Operate command for breaker operation. 36 | FAST_OP_OPEN_COMMAND = bytes.fromhex('A5E5') # Fast Operate OPEN Command. 37 | FAST_OP_CLOSE_COMMAND = bytes.fromhex('A5E6') # Fast Operate CLOSE Command. 38 | FAST_OP_SET_COMMAND = bytes.fromhex('A5E7') # Fast Operate SET Command. 39 | FAST_OP_CLEAR_COMMAND = bytes.fromhex('A5E8') # Fast Operate CLEAR Command. 40 | FAST_OP_PULSE_COMMAND = bytes.fromhex('A5E9') # Fast Operate PULSE Command. 41 | OLDEST_UNAK_EVNT_REP = bytes.fromhex('A5B2') # Oldest unacknowledged event report packet. 42 | ACK_RECNT_SENT_EVNT_REP = bytes.fromhex('A5B5') # Acknowledge event report most recently sent. 43 | CLEAR_STA_POWER_SETTING = bytes.fromhex('A5B9') # Clear status bits: power-up, setting change. 44 | MOST_RECENT_EVNT_REP = bytes.fromhex('A560') # Most recent event report packet. 45 | FAST_MSG_CONFIG_BLOCK = bytes.fromhex('A546') # A configuration block for Fast Message command. 46 | 47 | # Define Various ASCII Requests 48 | CR = b"\r\n" # Carriage Return to be Used Throughout 49 | ID = b"ID" + CR 50 | ENA = b"ENA" + CR 51 | DNA = b"DNA" + CR 52 | BNA = b"BNA" + CR 53 | QUIT = b"QUI" + CR 54 | GO_ACC = b"ACC" + CR 55 | GO_2AC = b"2AC" + CR 56 | 57 | # Define Default SEL Relay Passwords 58 | PASS_ACC = b"OTTER" 59 | PASS_2AC = b"TAIL" 60 | 61 | # Define Access Level Indicators 62 | LEVEL_0 = b"=" 63 | LEVEL_1 = b"=>" 64 | LEVEL_2 = b"=>>" 65 | LEVEL_C = b"==>>" 66 | PROMPT = CR + LEVEL_0 67 | PASS_PROMPT = b"Password:" 68 | 69 | 70 | ################################################################################ 71 | # Define Simple Function to Evaluate Request String for Numbered Event Record 72 | def event_record_request(event_number: int): 73 | """ 74 | Evaluate Byte-String to Form Event Record Request. 75 | 76 | A simple function to evaluate the request byte-string 77 | to request a numbered event (zero-based) from the relay's 78 | event history. 79 | 80 | Parameters 81 | ---------- 82 | event_number: int 83 | The zero-based event number index. Zero (0) 84 | is the most recent event. 85 | 86 | Returns 87 | ------- 88 | request: bytes 89 | The byte-string request which to provide the 90 | indexed event desired. 91 | 92 | Raises 93 | ------ 94 | ValueError: Raised when the requested event number is 95 | greater than 64. 96 | """ 97 | if event_number <= 64: 98 | # Prepare leading portion of request 99 | request = bytes.fromhex('A5') 100 | # Append the event number plus the minimum event command 101 | request += hex(96 + event_number) 102 | return request 103 | else: 104 | raise ValueError("Event number may not be greater than 64.") 105 | 106 | ################################################################################ 107 | # Define Function to Prepare Fast Operate Command 108 | def prepare_fastop_command( 109 | control_type: str, 110 | control_point: str, 111 | command: Union[BreakerBitControlType, RemoteBitControlType], 112 | fastop_def: dict 113 | ): 114 | """ 115 | Prepare a fast operate command for a relay. 116 | 117 | Prepare the binary message required to set/clear/pulse/open/close 118 | the respective control point using fast operate. 119 | 120 | Parameters 121 | ---------- 122 | control_type: ['remote_bit', 'breaker_bit'] 123 | Option for whether the control should be for a remote bit or 124 | a breaker bit. 125 | control_point: str 126 | The specific point which should be controlled. 127 | command: ['set', 'clear', 'pulse', 'open', 'close'] 128 | Control option which should be sent to the device. 129 | fastop_def: dict 130 | The FastOp definition dictionary to be used. 131 | """ 132 | # Prepare the Point Number 133 | if isinstance(control_point, str): 134 | control_point = int(re.findall(r'(\d+)',control_point)[0]) 135 | # Verify the Command Type 136 | if command.lower() not in ['set', 'clear', 'pulse', 'open', 'close', 'trip']: 137 | # Indicate invalid command type 138 | raise ValueError("Invalid command type") 139 | # Set up Breaker or Remote Control 140 | if control_type.lower() == 'remote_bit': 141 | command_string = FAST_OP_REMOTE_BIT 142 | elif control_type.lower() == 'breaker_bit': 143 | command_string = FAST_OP_BREAKER_BIT 144 | else: 145 | # Indicate invalid control type 146 | raise InvalidControlType("Invalid control type described.") 147 | command_string += bytes([6]) # Length (in bytes) 148 | try: 149 | try: 150 | command_opt = command.value 151 | except AttributeError: 152 | command_opt = str(command) 153 | command_opt = command_opt.lower() 154 | remote_bit_def = fastop_def['remotebitconfig'] 155 | control_point_def = remote_bit_def[control_point - 1] 156 | control = control_point_def[command_opt] 157 | print("control", control) 158 | except KeyError as err: 159 | raise ValueError("Improper command type for control point.") from err 160 | op_validation = (control * 4 + 1) & 0xff 161 | # Clean Control and Validation (format as hex) 162 | command_string += bytes([control, op_validation]) 163 | command_string += bytes([eval_checksum(command_string, constrain=True)]) 164 | # Return the Configured Command 165 | return command_string 166 | 167 | 168 | # END 169 | -------------------------------------------------------------------------------- /selprotopy/protocol/parser/__init__.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | """ 3 | selprotopy: A Protocol Binding Suite for the SEL Protocol Suite. 4 | 5 | Supports: 6 | - SEL Fast Meter 7 | - SEL Fast Message 8 | - SEL Fast Operate 9 | """ 10 | ################################################################################ 11 | 12 | from typing import AnyStr, List 13 | 14 | # Local Imports 15 | from selprotopy.protocol import commands 16 | from selprotopy.common import int_to_bool_list, eval_checksum 17 | from selprotopy.exceptions import MalformedByteArray, ChecksumFail 18 | from selprotopy.exceptions import MissingA5Head, DnaDigitalsMisMatch 19 | from selprotopy.protocol.parser import common 20 | 21 | 22 | # Define Simple Function to Validate Checksum for Byte Array 23 | def _validate_checksum(byte_array: bytearray): 24 | """Use last byte in a byte array as checksum to verify preceding bytes.""" 25 | # Assume Valid Message, and Find the Length of the Data 26 | data_length = byte_array[2] # Third Byte, Message Length 27 | # Collect the Checksum from the Data 28 | try: 29 | checksum_byte = byte_array[data_length - 1] # Extract checksum byte 30 | except IndexError as err: 31 | # Indicate Malformed Byte Array 32 | raise MalformedByteArray( 33 | f"Length of byte array extracted ({data_length}) appears invalid. " 34 | f"Attempted extracting byte at position {data_length-1} but length " 35 | f"is {len(byte_array)}." 36 | ) from err 37 | data = byte_array[:data_length - 1] # Don't include last byte 38 | confirmed = eval_checksum(data, constrain=True) 39 | if checksum_byte != confirmed: 40 | raise ChecksumFail( 41 | "Invalid Checksum Found for Data Stream. "+ 42 | f"Found: '{checksum_byte}'; Expected: '{confirmed}' \n{byte_array}" 43 | ) 44 | 45 | # Simple Function to Cast Byte Array and Clean Ordering 46 | def _cast_bytearray(data: AnyStr, debug: bool = True): 47 | """Cast the data to a byte-array.""" 48 | offset = data.find(b'\xa5') 49 | # Determine Invalid Criteria 50 | if offset == -1: 51 | # Indicate that response is missing 'A5' binary heading 52 | if debug: 53 | print("Debug Cast Data:", data ) 54 | raise MissingA5Head( 55 | "Invalid response request; missing 'A5' binary heading." 56 | ) 57 | byte_array = bytearray(data)[offset:] 58 | # Strip any Trailing Characters that are Not Needed 59 | if commands.LEVEL_0 in byte_array: 60 | byte_array = byte_array.split(commands.LEVEL_0)[0] 61 | if byte_array.endswith(commands.CR): 62 | byte_array = byte_array[:-2] 63 | _validate_checksum( byte_array=byte_array ) 64 | return byte_array 65 | 66 | 67 | ################################################################################ 68 | # Define Clear Prompt Interpreter 69 | def clean_prompt(data: AnyStr, encoding: str = 'utf-8' ): 70 | """Repeatedly use Carriage-Returns to Clear the Prompt for new Commands.""" 71 | if encoding: 72 | # Decode Bytes 73 | try: 74 | data = data.decode(encoding) 75 | except UnicodeDecodeError: 76 | pass 77 | return common.RE_CLEAN_PROMPT_CHARS.search(data) is not None 78 | 79 | ################################################################################ 80 | # Define Relay ID Block Parser 81 | def relay_id_block(data: AnyStr, encoding: str = '', byteorder: str = 'big', 82 | signed: bool = True, verbose: bool = False): 83 | """ 84 | Parse Relay ID Block. 85 | 86 | Parser for a relay ID/Firmware ID block to describe the relay's 87 | firmware version, part number, and configuration. 88 | 89 | Parameters 90 | ---------- 91 | data: str 92 | The full ID string returned from the relay, may 93 | optionally be passed as a byte-string if the 94 | encoding is also described. 95 | encoding: str, optional 96 | Optional encoding format to describe which encoding 97 | method should be used to decode the data passed. 98 | byteorder: ['big', 'little'], optional 99 | Control of how bytes are interpreted as 100 | integers, using big-endian or little-endian 101 | operations. Defaults to 'big' 102 | signed: bool, optional 103 | Control to specify whether the bytes should 104 | be interpreted as signed or unsigned integers. 105 | Defaults to True 106 | verbose: bool, optional 107 | Control to optionally utilize verbose printing. 108 | 109 | Returns 110 | ------- 111 | results: dict of str 112 | Dictionary containing each of the string results 113 | from the various ID/FID fields. 114 | """ 115 | if encoding: 116 | # Decode Bytes 117 | id_string = data.decode(encoding) 118 | else: 119 | # Pass Data Directly 120 | id_string = data 121 | # Iteratively Identify ID Parameters 122 | results = {} 123 | for id_key, re_param in common.RE_ID_BLOCKS.items(): 124 | try: 125 | # Capture the Important Pieces from the Input 126 | key_result, checksum_chars = re_param.findall(id_string)[0] 127 | # Validate Checksum 128 | calc_checksum = eval_checksum( f'"{id_key}={key_result}",' ) 129 | checksum = int.from_bytes( bytes.fromhex(checksum_chars), 130 | byteorder=byteorder, 131 | signed=signed ) 132 | if checksum != calc_checksum: 133 | # Indicate Checksum Failure 134 | raise ChecksumFail(f"Invalid Checksum Found for {id_key}") 135 | # Store the Results 136 | results[id_key] = key_result 137 | except KeyError: 138 | # Store Empty Results 139 | results[id_key] = '' 140 | if verbose: 141 | print(f'Unable to determine {id_key} parameter from relay ID.') 142 | except Exception: 143 | print(id_string, re_param.findall(id_string)) 144 | # Return Parsed ID Components 145 | return results 146 | 147 | # Define Relay DNA Block Parser 148 | def relay_dna_block(data: AnyStr, encoding: str = '', byteorder: str = 'big', 149 | signed: bool = True, verbose: bool = False): 150 | """ 151 | Parse Relay DNA Response Block. 152 | 153 | Parser for a relay digital names block to describe the configured 154 | digital names for a relay. 155 | 156 | Parameters 157 | ---------- 158 | data: str 159 | The full DNA string returned from the relay, may 160 | optionally be passed as a byte-string if the 161 | encoding is also described. 162 | encoding: str, optional 163 | Optional encoding format to describe which encoding 164 | method should be used to decode the data passed. 165 | byteorder: ['big', 'little'], optional 166 | Control of how bytes are interpreted as 167 | integers, using big-endian or little-endian 168 | operations. Defaults to 'big' 169 | signed: bool, optional 170 | Control to specify whether the bytes should 171 | be interpreted as signed or unsigned integers. 172 | Defaults to True 173 | verbose: bool, optional 174 | Control to optionally utilize verbose printing. 175 | 176 | Returns 177 | ------- 178 | binaries: list of list 179 | List of the target rows with each element's label. 180 | """ 181 | if encoding: 182 | # Decode Bytes 183 | dna_string = data.decode(encoding) 184 | else: 185 | # Pass Data Directly 186 | dna_string = data 187 | dna_string = dna_string.upper() 188 | # Remove the Leading Command if Present 189 | if common.RE_DNA_CONTROL.search(dna_string) is not None: 190 | dna_string = common.RE_DNA_CONTROL.split(dna_string)[1] 191 | # Remove Double Quotes 192 | dna_string = dna_string.replace('"','') 193 | # Format the List of Lists 194 | binaries = [] 195 | for line in dna_string.split('\n'): 196 | # Verify that Comma is Present 197 | if ',' in line: 198 | columns = line.split(',') 199 | # Attempt Generating Binaries List 200 | try: 201 | # Capture and Clean the Columns 202 | row = [ 203 | common.RE_HEX_CHAR.sub('', target) for target in columns[0:8] 204 | ] 205 | # Verify Checksum 206 | calc_checksum = eval_checksum( 207 | '"{}",'.format('","'.join(row)) 208 | ) 209 | checksum = int.from_bytes( 210 | bytes.fromhex(columns[8]), 211 | byteorder=byteorder, 212 | signed=signed 213 | ) 214 | # Indicate Failed Checksum Validation 215 | if calc_checksum != checksum: 216 | # Indicate Checksum Failure 217 | raise ChecksumFail(f"Invalid Checksum Found for {line}") 218 | binaries.append( row ) 219 | except Exception: 220 | if verbose: print(f"Couldn't parse line: {line}") 221 | return binaries 222 | 223 | # Define Relay Status Bit Name Parser 224 | def RelayBnaBlock(data: AnyStr, encoding: str = '', verbose: bool = False): 225 | """ 226 | Parse Relay BNA Response Block. 227 | 228 | Parser for a relay bit names block to describe the configured 229 | bit names for a relay. 230 | 231 | Parameters 232 | ---------- 233 | data: str 234 | The full BNA string returned from the relay, may 235 | optionally be passed as a byte-string if the 236 | encoding is also described. 237 | encoding: str, optional 238 | Optional encoding format to describe which encoding 239 | method should be used to decode the data passed. 240 | verbose: bool, optional 241 | Control to optionally utilize verbose printing. 242 | 243 | Returns 244 | ------- 245 | list[list[str]]: List of the target rows with each element's label. 246 | """ 247 | if encoding: 248 | # Decode Bytes 249 | bna_string = data.decode(encoding) 250 | else: 251 | # Pass Data Directly 252 | bna_string = data 253 | # Remove Double Quotes 254 | bna_string = bna_string.replace('"','') 255 | # Iteratively Process Lines 256 | bit_names = [] 257 | for line in bna_string.split('\n'): 258 | # Verify that Comma is Present 259 | if ',' in line: 260 | entries = line.split(',') 261 | # Attempt Generating Binaries List 262 | try: 263 | names = entries[0:8] 264 | names.append( [entries[8]] ) 265 | bit_names.append( names ) 266 | except Exception: 267 | if verbose: 268 | print(f"Couldn't parse line: {line}") 269 | else: 270 | break 271 | return bit_names 272 | ################################################################################ 273 | 274 | 275 | ################################################################################ 276 | # Define Relay Definition Block Parser 277 | def relay_definition_block(data: AnyStr, verbose: bool = False): 278 | """ 279 | Parse Relay Definition Block. 280 | 281 | Parser for a relay definition block to describe the relay's available 282 | functional message, control, and event blocks. 283 | 284 | Parameters 285 | ---------- 286 | data: bytes 287 | The full byte-string returned from a 288 | relay using SEL protocol. 289 | verbose: bool, optional 290 | Control to optionally utilize verbose printing. 291 | 292 | Returns 293 | ------- 294 | struct: dict 295 | Dictionary of key-value pairs describing 296 | the relay's definition block. 297 | """ 298 | # Capture Byte Array for Parsing 299 | byre_array = _cast_bytearray(data, verbose) 300 | struct = {} 301 | try: 302 | # Parse Data, Load Attributes 303 | struct['command'] = bytes(byre_array[:2]) 304 | struct['length'] = byre_array[2] 305 | struct['numprotocolsup']= byre_array[3] 306 | struct['fmmessagesup'] = byre_array[4] 307 | struct['statusflagssup']= byre_array[5] 308 | struct['protocolinfo'] = [] 309 | struct['fmcommandinfo'] = [] 310 | struct['statusflaginfo']= [] 311 | if verbose: 312 | print("Generic Relay Definition Block Information") 313 | print("Command:",struct['command']) 314 | print("Length:",struct['length']) 315 | print("Number of Supported Protocols:",struct['numprotocolsup']) 316 | print("Fast Meter Message Support:",struct['fmmessagesup']) 317 | print("Status Flag Support:",struct['statusflagssup']) 318 | # Iterate over the Fast Meter Commands 319 | ind = 6 320 | for _ in range(struct['fmmessagesup']): 321 | data_dict = {} 322 | data_dict['configcommand'] = bytes(byre_array[ind:ind+2]) 323 | data_dict['command'] = bytes(byre_array[ind+2:ind+4]) 324 | struct['fmcommandinfo'].append(data_dict) 325 | ind += 4 326 | struct['fmtype'] = byre_array[ind] 327 | if verbose: 328 | print("Fast Meter Command Information") 329 | print(struct['fmcommandinfo'],'\n',struct['fmtype']) 330 | ind += 1 331 | # Iterate Over the Status Flag Commands 332 | for _ in range(struct['statusflagssup']): 333 | data_dict = {} 334 | data_dict['statusbit'] = bytes(byre_array[ind:ind+2]) 335 | data_dict['affectedcommand'] = bytes(byre_array[ind+2:ind+8]) 336 | struct['statusflaginfo'].append(data_dict) 337 | ind += 8 338 | if verbose: 339 | print("Status Flag Information") 340 | print(struct['statusflaginfo']) 341 | # Manage Protocol Specific Data 342 | struct['protocols'] = [] 343 | struct['fopcommandinfo'] = '' 344 | struct['fmsgcommandinfo'] = '' 345 | for _ in range(struct['numprotocolsup']): 346 | data = int_to_bool_list( byre_array[ind] ) 347 | data.extend([False,False]) # This should be at least two bits 348 | prot = byre_array[ind+1] 349 | # Manage SEL-Protocol Types 350 | if prot == 0: 351 | proto_desc = { 352 | 'type' : 'SEL_STANDARD', 353 | 'fast_op_en' : data[0], 354 | 'fast_msg_en' : data[1] 355 | } 356 | if data[0]: 357 | struct['fopcommandinfo'] = commands.FO_CONFIG_BLOCK 358 | if data[1]: 359 | struct['fmsgcommandinfo'] = commands.FAST_MSG_CONFIG_BLOCK 360 | elif prot == 1: 361 | proto_desc = { 362 | 'type' : 'SEL_LMD', 363 | 'fast_op_en' : data[0], 364 | 'fast_msg_en' : data[1] 365 | } 366 | if data[0]: 367 | struct['fopcommandinfo'] = commands.FO_CONFIG_BLOCK 368 | if data[1]: 369 | struct['fmsgcommandinfo'] = commands.FAST_MSG_CONFIG_BLOCK 370 | elif prot == 2: 371 | proto_desc = { 372 | 'type' : 'MODBUS' 373 | } 374 | elif prot == 3: 375 | proto_desc = { 376 | 'type' : 'SY_MAX' 377 | } 378 | elif prot == 4: 379 | proto_desc = { 380 | 'type' : 'R_SEL' 381 | } 382 | elif prot == 5: 383 | proto_desc = { 384 | 'type' : 'DNP3' 385 | } 386 | elif prot == 6: 387 | proto_desc = { 388 | 'type' : 'R6_SEL' 389 | } 390 | if verbose: 391 | print('Protocol Type:',proto_desc['type']) 392 | if 'fast_op_en' in proto_desc.keys(): 393 | print('Fast Operate Enable:',proto_desc['fast_op_en']) 394 | print('Fast Message Enable:',proto_desc['fast_msg_en']) 395 | struct['protocols'].append(proto_desc) 396 | ind += 2 397 | # Return Resultant Structure 398 | return struct 399 | except IndexError as err: 400 | raise ValueError("Invalid data string response") from err 401 | 402 | # Define Relay Definition Block Parser 403 | def fast_meter_configuration_block(data: AnyStr, byteorder: str = 'big', 404 | signed: bool = True, verbose: bool = False): 405 | """ 406 | Parse Relay Fast Meter Configuration Block. 407 | 408 | Parser for a relay's fast meter block to describe 409 | the relay's available functional message, control, 410 | and event blocks 411 | 412 | Parameters 413 | ---------- 414 | data: bytes 415 | The full byte-string returned from a 416 | relay using SEL protocol. 417 | byteorder: ['big', 'little'], optional 418 | Control of how bytes are interpreted as 419 | integers, using big-endian or little-endian 420 | operations. Defaults to 'big' 421 | signed: bool, optional 422 | Control to specify whether the bytes should 423 | be interpreted as signed or unsigned integers. 424 | Defaults to True 425 | verbose: bool, optional 426 | Control to optionally utilize verbose printing. 427 | 428 | Returns 429 | ------- 430 | struct: dict 431 | Dictionary of key-value pairs describing 432 | the relay's fast meter configuration block. 433 | """ 434 | # Capture Byte Array for Parsing 435 | byte_arr = _cast_bytearray(data) 436 | struct = {} 437 | try: 438 | # Parse Data, Load Attributes 439 | struct['command'] = bytes(byte_arr[:2]) 440 | struct['length'] = byte_arr[2] 441 | struct['numstatusflags']= byte_arr[3] 442 | struct['scalefactloc'] = byte_arr[4] 443 | struct['numscalefact'] = byte_arr[5] 444 | struct['numanalogins'] = byte_arr[6] 445 | struct['numsampperchan']= byte_arr[7] 446 | struct['numdigitalbank']= byte_arr[8] 447 | struct['numcalcblocks'] = byte_arr[9] 448 | # Determine Offsets 449 | struct['analogchanoff'] = int.from_bytes( 450 | byte_arr[10:12], 451 | byteorder=byteorder, 452 | signed=signed 453 | ) 454 | struct['timestmpoffset']= int.from_bytes( 455 | byte_arr[12:14], 456 | byteorder=byteorder, 457 | signed=signed 458 | ) 459 | struct['digitaloffset'] = int.from_bytes( 460 | byte_arr[14:16], 461 | byteorder=byteorder, 462 | signed=signed 463 | ) 464 | # Iteratively Interpret the Analog Channels 465 | ind = 16 466 | struct['analogchannels'] = [] 467 | for _ in range(struct['numanalogins']): 468 | data_dict = {} 469 | bytstr = bytes(byte_arr[ind:ind+6]) 470 | data_dict['name'] = '' 471 | for byte in bytstr: 472 | char = chr(byte) 473 | if byte != 0: 474 | data_dict['name'] += char 475 | ind += 6 476 | data_dict['channeltype'] = byte_arr[ind] 477 | data_dict['factortype'] = byte_arr[ind+1] 478 | data_dict['scaleoffset'] = int.from_bytes( 479 | byte_arr[ind:ind+2], 480 | byteorder=byteorder, 481 | signed=signed 482 | ) 483 | ind += 4 484 | # Append the Analog Channel Description: 485 | struct['analogchannels'].append( data_dict ) 486 | # Iteratively Interpret the Calculation Blocks 487 | struct['calcblocks'] = [] 488 | for _ in range(struct['numcalcblocks']): 489 | data_dict = {} 490 | # Determine the Line Configuration 491 | # rot: Rotation 492 | # vConDP: Delta Connected, Positive Sequence 493 | # vConDN: Delta Connected, Negative Sequence 494 | # iConDP: Delta Connected, Positive Sequence 495 | # iConDN: Delta Connected, Negative Sequence 496 | val = byte_arr[ind] 497 | [rot, vConDP, vConDN, iConDP, iConDN, _, _, _] = int_to_bool_list( 498 | val, 499 | byte_like=True 500 | ) 501 | # Evaluate Rotation 502 | data_dict['line'] = val 503 | data_dict['rotation'] = 'ACB' if rot else 'ABC' 504 | # Evaluate Voltage Connection 505 | if vConDN: 506 | data_dict['voltage'] = 'AC-BA-CB' 507 | elif vConDP: 508 | data_dict['voltage'] = 'AB-BC-CA' 509 | else: 510 | data_dict['voltage'] = 'Y' 511 | # Evaluate Current Connection 512 | if iConDN: 513 | data_dict['current'] = 'AC-BA-CB' 514 | elif iConDP: 515 | data_dict['current'] = 'AB-BC-CA' 516 | else: 517 | data_dict['current'] = 'Y' 518 | ind += 1 519 | # Determine the Calculation Type 520 | val = byte_arr[ind] 521 | data_dict['type'] = val 522 | ind += 1 523 | if val == 0: 524 | data_dict['typedesc'] = 'standard-power' 525 | elif val == 1: 526 | data_dict['typedesc'] = '2-1/2 element Δ power' 527 | elif val == 2: 528 | data_dict['typedesc'] = 'voltages only' 529 | elif val == 3: 530 | data_dict['typedesc'] = 'currents only' 531 | elif val == 4: 532 | data_dict['typedesc'] = 'single-phase IA and VA only' 533 | elif val == 5: 534 | data_dict['typedesc'] = \ 535 | 'standard-power with two sets of currents' 536 | else: 537 | data_dict['typedesc'] = ( 538 | '2-1/2 element Δ power with two sets of currents' 539 | ) 540 | # Determine Skew Correction offset, Rs offset, and Xs offset 541 | data_dict['skewoffset'] = bytes(byte_arr[ind:ind+2]) 542 | data_dict['rsoffset'] = bytes(byte_arr[ind+2:ind+4]) 543 | data_dict['xsoffset'] = bytes(byte_arr[ind+4:ind+6]) 544 | # Determine Current Indicies 545 | ind += 1 546 | data_dict['iaindex'] = byte_arr[ind+0] 547 | data_dict['ibindex'] = byte_arr[ind+1] 548 | data_dict['icindex'] = byte_arr[ind+2] 549 | data_dict['vaindex'] = byte_arr[ind+3] 550 | data_dict['vbindex'] = byte_arr[ind+4] 551 | data_dict['vcindex'] = byte_arr[ind+5] 552 | # Store Dictionary 553 | struct['calcblocks'].append(data_dict) 554 | if verbose: 555 | print("Generic Fast Meter Configuration Block Information") 556 | print("Command:", struct['command']) 557 | print("Message Length:",struct['length']) 558 | print("Number of Status Flags:",struct['numstatusflags']) 559 | print("Scale Factor Location:",struct['scalefactloc']) 560 | print("Number of Scale Factors:",struct['numscalefact']) 561 | print("Number of Analog Inputs:",struct['numanalogins']) 562 | print("Number of Samples per Channel:",struct['numsampperchan']) 563 | print("Number of Digital Banks:",struct['numdigitalbank']) 564 | print("Number of Calculation Blocks:",struct['numcalcblocks']) 565 | print("Analog Channel Offset:",struct['analogchanoff']) 566 | print("Time Stamp Offset:",struct['timestmpoffset']) 567 | print("Digital Channel Offset:",struct['digitaloffset']) 568 | # Return the Generated Structure 569 | return struct 570 | except IndexError as err: 571 | raise ValueError("Invalid data string response") from err 572 | 573 | # Define Function to Parse a Fast Operate Configuration Block 574 | def fast_op_configuration_block(data: AnyStr, byteorder: str = 'big', 575 | signed: bool = True, verbose: bool = False): 576 | """ 577 | Parse Fast Operate Configuration Block. 578 | 579 | Parser for a fast operate configuration block to describe 580 | the relay's available fast operate options. 581 | 582 | Parameters 583 | ---------- 584 | data: bytes 585 | The full byte-string returned from a 586 | relay using SEL protocol. 587 | byteorder: ['big', 'little'], optional 588 | Control of how bytes are interpreted as 589 | integers, using big-endian or little-endian 590 | operations. Defaults to 'big' 591 | signed: bool, optional 592 | Control to specify whether the bytes should 593 | be interpreted as signed or unsigned integers. 594 | Defaults to True 595 | verbose: bool, optional 596 | Control to optionally utilize verbose printing. 597 | 598 | Returns 599 | ------- 600 | struct: dict 601 | Dictionary of key-value pairs describing 602 | the relay's fast meter configuration block. 603 | """ 604 | # Capture Byte Array for Parsing 605 | byte_array = _cast_bytearray(data) 606 | struct = {} 607 | try: 608 | # Parse Data, Load Attributes 609 | struct['command'] = bytes(byte_array[:2]) 610 | struct['length'] = byte_array[2] 611 | struct['numbreakers'] = byte_array[3] 612 | struct['numremotebits'] = int.from_bytes( byte_array[4:6], 613 | byteorder=byteorder, 614 | signed=signed ) 615 | struct['pulsesupported']= byte_array[6] 616 | _ = byte_array[7] # reservedpoint 617 | # Iterate Over Breaker Bits 618 | ind = 8 619 | struct['breakerconfig'] = [] 620 | for _ in range(struct['numbreakers']): 621 | struct['breakerconfig'].append({ 622 | 'open' : byte_array[ind], 623 | 'close' : byte_array[ind+1], 624 | }) 625 | ind += 2 626 | # Iterate Over Remote Bits 627 | struct['remotebitconfig'] = [] 628 | for _ in range(struct['numremotebits']): 629 | remotebitstruct = { 630 | 'clear' : byte_array[ind], 631 | 'set' : byte_array[ind+1], 632 | } 633 | ind += 2 634 | if struct['pulsesupported'] == 1: 635 | remotebitstruct['pulse'] = byte_array[ind] 636 | ind += 1 637 | # Append Structure 638 | struct['remotebitconfig'].append(remotebitstruct) 639 | if verbose: 640 | print("Generic Fast Operate Configuration Block Information") 641 | print("Command:", struct['command']) 642 | print("Message Length:",struct['length']) 643 | print("Number of Breakers:",struct['breakerconfig']) 644 | print("Number of Remote Bits:",struct['numremotebits']) 645 | print("Pulse Command Supported:", int(struct['pulsesupported'])==1) 646 | # Return Structure 647 | return struct 648 | except IndexError as err: 649 | raise ValueError("Invalid data string response") from err 650 | ################################################################################ 651 | 652 | ################################################################################ 653 | # Define Function to Parse a Fast Meter Response Given the Configuration 654 | def fast_meter_block(data: AnyStr, definition: dict, dna_def: List[List[str]], 655 | byteorder: str = 'big', signed: str = True, 656 | verbose: bool = False ): 657 | """ 658 | Parse Fast Meter Response Block. 659 | 660 | Parser for a relay's fast meter block for the various analog 661 | and digital points. 662 | 663 | Parameters 664 | ---------- 665 | data: bytes 666 | The full byte-string returned from a 667 | relay using SEL protocol. 668 | definition: struct 669 | The previously defined relay definition 670 | for the applicable fast meter block. 671 | dna_def: list of list of str 672 | The previously defined relay digital name 673 | alias reference. 674 | byteorder: ['big', 'little'], optional 675 | Control of how bytes are interpreted as 676 | integers, using big-endian or little-endian 677 | operations. Defaults to 'big' 678 | signed: bool, optional 679 | Control to specify whether the bytes should 680 | be interpreted as signed or unsigned integers. 681 | Defaults to True 682 | verbose: bool, optional 683 | Control to optionally utilize verbose printing. 684 | 685 | Returns 686 | ------- 687 | struct: dict 688 | Dictionary of key-value pairs describing 689 | the relay's fast meter data points. 690 | """ 691 | # Capture Byte Array for Parsing 692 | byte_array = _cast_bytearray( data ) 693 | struct = {} 694 | try: 695 | # Parse Data, Load Attributes 696 | struct['command'] = bytes(byte_array[:2]) 697 | struct['length'] = byte_array[2] 698 | struct['statusflag'] = byte_array[3:3+definition['numstatusflags']] 699 | if verbose: 700 | print("Generic Fast Meter Block Information") 701 | print("Command:", struct['command']) 702 | print("Message Length:",struct['length']) 703 | print("Status Flag:",struct['statusflag']) 704 | # Handle Analog Points 705 | struct['analogs'] = {} 706 | ind = definition['analogchanoff'] 707 | samples = definition['numsampperchan'] 708 | # Iterate over Samples 709 | for samp_n in range(samples): 710 | # Iterate over Analogs 711 | for analog_desc in definition['analogchannels']: 712 | name = analog_desc['name'] 713 | type = analog_desc['channeltype'] 714 | size = common.ANALOG_SIZE_LOOKUP[type] 715 | scale_type = analog_desc['factortype'] 716 | # Handle Non-Scaling Values 717 | if scale_type == 255: 718 | scale = 1 719 | # Extract Value to be Interpreted 720 | value = bytes(byte_array[ind:ind+size]) 721 | # Apply Formatting 722 | value = common.ANALOG_TYPE_FORMATTERS[type]( value ) 723 | # Evaluate Result 724 | analog_data = value*scale 725 | # Handle Different Analog Sample Types 726 | if samples == 1: # Each is Magnitude 727 | # Record Magnitude Directly 728 | struct['analogs'][name] = analog_data 729 | elif samples == 2: # Set of Imaginary, Real Group 730 | # Manage Real/Imaginary Quantities 731 | if samp_n == 0: # Imaginary 732 | if analog_data > 1e-8: 733 | struct['analogs'][name] = analog_data * 1j 734 | else: 735 | struct['analogs'][name] = 0 736 | else: # Real 737 | struct['analogs'][name] += analog_data 738 | else: # 1st, 2nd, 5th, 6th qtr cycle 739 | if samp_n == 0: 740 | struct['analogs'][name] = [analog_data] 741 | else: 742 | struct['analogs'][name].append(analog_data) 743 | ind += size 744 | if verbose: print("Analog {}: {}".format(name,analog_data)) 745 | # Iteratively Handle Digital Points 746 | struct['digitals'] = {} 747 | ind = definition['digitaloffset'] 748 | for target_row_index in range(definition['numdigitalbank']): 749 | # Verify Length of Points 750 | if definition['numdigitalbank'] != len(dna_def): 751 | # Indicate number of digitals in definition does not match DNA 752 | raise DnaDigitalsMisMatch( 753 | 'Number of digital banks does not match DNA definition.' 754 | ) 755 | # Grab the Applicable Names for this Target Row (byte) 756 | point_names = dna_def[target_row_index][:8] # grab first 8 entries 757 | # Grab the list of binary statuses from the target row info 758 | target_data = int_to_bool_list(byte_array[ind+target_row_index], 759 | byte_like=True, reverse=True) 760 | target_row = dict(zip(point_names,target_data)) 761 | # Load the Digital Dictionary with new Points 762 | struct['digitals'].update(target_row) 763 | # Remove the '*' Key from Dictionary 764 | struct['digitals'].pop('*') 765 | # Return the Resultant Structure 766 | return struct 767 | except IndexError: 768 | raise ValueError("Invalid data string response") 769 | ################################################################################ 770 | 771 | # END 772 | -------------------------------------------------------------------------------- /selprotopy/protocol/parser/common.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | """ 3 | selprotopy: A Protocol Binding Suite for the SEL Protocol Suite. 4 | 5 | Supports: 6 | - SEL Fast Meter 7 | - SEL Fast Message 8 | - SEL Fast Operate 9 | """ 10 | ################################################################################ 11 | 12 | # Import Requirements 13 | import re 14 | 15 | from selprotopy.common import ieee_4_byte_fps 16 | 17 | # Define Clean Prompt Characters for RegEx 18 | RE_CLEAN_PROMPT_CHARS = re.compile( 19 | r'\=\>{0,2}\r\n|\>{0,2}\r\n\=|\r\n\=\>{0,2}|\n\=\>{0,2}\r' 20 | ) 21 | 22 | # Define DNA Control Character String for RegEx 23 | RE_DNA_CONTROL = re.compile(r'\>?.*DNA') 24 | 25 | # Define ID Block for RegEx 26 | RE_ID_BLOCK_1 = re.compile(r'''"FID\=(SEL.*)","(\w*)"''') 27 | RE_ID_BLOCK_2 = re.compile(r'''"BFID\=(\w.*)","(\w*)"''') 28 | RE_ID_BLOCK_3 = re.compile(r'''"CID\=(\w*)","(\w*)"''') 29 | RE_ID_BLOCK_4 = re.compile(r'''"DEVID\=(.*)","(\w*)"''') 30 | RE_ID_BLOCK_5 = re.compile(r'''"DEVCODE\=(\w*)","(\w*)"''') 31 | RE_ID_BLOCK_6 = re.compile(r'''"PARTNO\=(\w*)","(\w*)"''') 32 | RE_ID_BLOCK_7 = re.compile(r'''"CONFIG\=(\d*)","(\w*)"''') 33 | RE_ID_BLOCK_8 = re.compile(r'''"SPECIAL\=(\d*)","(\w*)"''') 34 | 35 | RE_ID_BLOCKS = { 36 | 'FID': RE_ID_BLOCK_1, 37 | 'BFID': RE_ID_BLOCK_2, 38 | 'CID': RE_ID_BLOCK_3, 39 | 'DEVID': RE_ID_BLOCK_4, 40 | 'DEVCODE': RE_ID_BLOCK_5, 41 | 'PARTNO': RE_ID_BLOCK_6, 42 | 'CONFIG': RE_ID_BLOCK_7, 43 | 'SPECIAL': RE_ID_BLOCK_8, 44 | } 45 | 46 | # Define RegEx for Hex Character Replacement 47 | RE_HEX_CHAR = re.compile(r'[^\x20-\x7F]+') 48 | 49 | # Define Look-Up-Table for Number of Bytes Associated with an Analog Type 50 | ANALOG_SIZE_LOOKUP = { 51 | 0: 2, 52 | 1: 4, 53 | 2: 8, 54 | 3: 8, 55 | } 56 | 57 | # Define Look-Up-Table for Formatting Functions for Various Analog Channel Types 58 | ANALOG_TYPE_FORMATTERS = { 59 | 0: int.from_bytes, # 2-Byte Integer 60 | 1: ieee_4_byte_fps, # 4-Byte IEEE FPS 61 | 2: None, # 8-Byte IEEE FPS 62 | 3: None, # 8-Byte Time Stamp 63 | } -------------------------------------------------------------------------------- /selprotopy/support/__init__.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | """ 3 | selprotopy: A Protocol Binding Suite for the SEL Protocol Suite. 4 | 5 | Supports: 6 | - SEL Fast Meter 7 | - SEL Fast Message 8 | - SEL Fast Operate 9 | 10 | Author(s): 11 | - Joe Stanley: engineerjoe440@yahoo.com 12 | 13 | Homepage: https://github.com/engineerjoe440/sel-proto-py 14 | 15 | SEL Protocol Application Guide: https://selinc.com/api/download/5026/ 16 | """ 17 | ################################################################################ 18 | -------------------------------------------------------------------------------- /selprotopy/support/socket.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | """ 3 | selprotopy: A Protocol Binding Suite for the SEL Protocol Suite. 4 | 5 | Supports: 6 | - SEL Fast Meter 7 | - SEL Fast Message 8 | - SEL Fast Operate 9 | 10 | To use `telnetlib_support`, without importing `selprotopy` directly, use: 11 | 12 | ``` 13 | telnetlib.Telnet.process_rawq = process_rawq 14 | ``` 15 | """ 16 | ################################################################################ 17 | 18 | # Socket Support: We need to read without blocking forever! 19 | import socket 20 | 21 | def socket_read(sock: socket.socket): 22 | """Read from the socket without blocking indefinitely.""" 23 | data = b'' 24 | old_data = b'' 25 | timeout = sock.gettimeout() 26 | sock.settimeout(0.1) 27 | while True: 28 | try: 29 | try: 30 | # Unix Systems 31 | data += sock.recv(1024, socket.MSG_DONTWAIT) 32 | except AttributeError: 33 | # Windows 34 | data += sock.recv(1024) 35 | if data == old_data: 36 | break 37 | except socket.timeout: 38 | break 39 | old_data = data 40 | # Finished Collecting 41 | sock.settimeout(timeout) 42 | return data 43 | -------------------------------------------------------------------------------- /selprotopy/support/telnet.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | """ 3 | selprotopy: A Protocol Binding Suite for the SEL Protocol Suite. 4 | 5 | Supports: 6 | - SEL Fast Meter 7 | - SEL Fast Message 8 | - SEL Fast Operate 9 | 10 | To use `telnetlib_support`, without importing `selprotopy` directly, use: 11 | 12 | ``` 13 | telnetlib.Telnet.process_rawq = process_rawq 14 | ``` 15 | """ 16 | ################################################################################ 17 | 18 | # Telnetlib Support: Don't eat the Null Characters, They're Important! 19 | import telnetlib 20 | from telnetlib import IAC, DO, DONT, WILL, WONT, SE, NOOPT, SB 21 | 22 | def process_rawq(self): 23 | """Transfer from raw queue to cooked queue. 24 | 25 | Set self.eof when connection is closed. Don't block unless in 26 | the midst of an IAC sequence. 27 | 28 | """ 29 | buf = [b'', b''] 30 | try: 31 | while self.rawq: 32 | c = self.rawq_getchar() 33 | if not self.iacseq: 34 | # Retain the Null Character 35 | # if c == theNULL: 36 | # continue 37 | # if c == b"\021": 38 | # continue 39 | if c != IAC: 40 | buf[self.sb] = buf[self.sb] + c 41 | continue 42 | else: 43 | self.iacseq += c 44 | elif len(self.iacseq) == 1: 45 | # 'IAC: IAC CMD [OPTION only for WILL/WONT/DO/DONT]' 46 | if c in (DO, DONT, WILL, WONT): 47 | self.iacseq += c 48 | continue 49 | 50 | self.iacseq = b'' 51 | if c == IAC: 52 | buf[self.sb] = buf[self.sb] + c 53 | else: 54 | if c == SB: # SB ... SE start. 55 | self.sb = 1 56 | self.sbdataq = b'' 57 | elif c == SE: 58 | self.sb = 0 59 | self.sbdataq = self.sbdataq + buf[1] 60 | buf[1] = b'' 61 | if self.option_callback: 62 | # Callback is supposed to look into 63 | # the sbdataq 64 | self.option_callback(self.sock, c, NOOPT) 65 | else: 66 | # We can't offer automatic processing of 67 | # suboptions. Alas, we should not get any 68 | # unless we did a WILL/DO before. 69 | self.msg('IAC %d not recognized' % ord(c)) 70 | elif len(self.iacseq) == 2: 71 | cmd = self.iacseq[1:2] 72 | self.iacseq = b'' 73 | opt = c 74 | if cmd in (DO, DONT): 75 | self.msg('IAC %s %d', 76 | cmd == DO and 'DO' or 'DONT', ord(opt)) 77 | if self.option_callback: 78 | self.option_callback(self.sock, cmd, opt) 79 | else: 80 | self.sock.sendall(IAC + WONT + opt) 81 | elif cmd in (WILL, WONT): 82 | self.msg('IAC %s %d', 83 | cmd == WILL and 'WILL' or 'WONT', ord(opt)) 84 | if self.option_callback: 85 | self.option_callback(self.sock, cmd, opt) 86 | else: 87 | self.sock.sendall(IAC + DONT + opt) 88 | except EOFError: # raised by self.rawq_getchar() 89 | self.iacseq = b'' # Reset on EOF 90 | self.sb = 0 91 | pass 92 | self.cookedq = self.cookedq + buf[0] 93 | self.sbdataq = self.sbdataq + buf[1] 94 | 95 | # END 96 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | --------------------------------------------------------------------------------