├── .clabot ├── .github └── workflows │ └── unit_tests.yml ├── .gitignore ├── .gitmodules ├── .pylintrc ├── LICENSE ├── README.rst ├── requirements.txt ├── scripts ├── __init__.py └── suns.py ├── setup.py ├── sunspec2 ├── __init__.py ├── device.py ├── docs │ └── pysunspec.rst ├── file │ ├── __init__.py │ └── client.py ├── mb.py ├── mdef.py ├── modbus │ ├── __init__.py │ ├── client.py │ └── modbus.py ├── smdx.py ├── spreadsheet.py ├── tests │ ├── __init__.py │ ├── mock_port.py │ ├── mock_socket.py │ ├── test_data │ │ ├── __init__.py │ │ ├── device_1547.json │ │ ├── inverter_123.json │ │ ├── smdx_304.csv │ │ └── wb_701-705.xlsx │ ├── test_device.py │ ├── test_file_client.py │ ├── test_mb.py │ ├── test_mdef.py │ ├── test_modbus_client.py │ ├── test_modbus_modbus.py │ ├── test_smdx.py │ ├── test_spreadsheet.py │ └── test_xlsx.py └── xlsx.py └── tox.ini /.clabot: -------------------------------------------------------------------------------- 1 | { 2 | "contributors": ["bobfox", "shelcrow", "dersecure"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/unit_tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: 4 | pull_request: 5 | branches: ["master"] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | unit_tests: 13 | 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 10 16 | 17 | steps: 18 | - name: Set up Python 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: "3.12" 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | with: 25 | submodules: recursive 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install -r requirements.txt 30 | - name: Test with pytest 31 | run: | 32 | pytest . -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .coverage 3 | venv 4 | .pytest_cache/ 5 | __pycache__/ 6 | 7 | .tox/ 8 | *.egg-info/ 9 | build/ 10 | 11 | dist/ -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "sunspec2/models"] 2 | path = sunspec2/models 3 | url = https://github.com/sunspec/models.git 4 | branch = master 5 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | # This Pylint rcfile contains a best-effort configuration to uphold the 2 | # best-practices and style described in the Google Python style guide: 3 | # https://google.github.io/styleguide/pyguide.html 4 | # 5 | # Its canonical open-source location is: 6 | # https://google.github.io/styleguide/pylintrc 7 | 8 | [MASTER] 9 | 10 | # Files or directories to be skipped. They should be base names, not paths. 11 | ignore= 12 | 13 | # Files or directories matching the regex patterns are skipped. The regex 14 | # matches against base names, not paths. 15 | ignore-path= 16 | 17 | # Pickle collected data for later comparisons. 18 | persistent=no 19 | 20 | # List of plugins (as comma separated values of python modules names) to load, 21 | # usually to register additional checkers. 22 | load-plugins= 23 | 24 | # Use multiple processes to speed up Pylint. 25 | jobs=0 # auto-detect 26 | 27 | # Allow loading of arbitrary C extensions. Extensions are imported into the 28 | # active Python interpreter and may run arbitrary code. 29 | unsafe-load-any-extension=no 30 | 31 | 32 | [MESSAGES CONTROL] 33 | 34 | # Only show warnings with the listed confidence levels. Leave empty to show 35 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 36 | confidence= 37 | 38 | # Enable the message, report, category or checker with the given id(s). You can 39 | # either give multiple identifier separated by comma (,) or put this option 40 | # multiple time (only on the command line, not in the configuration file where 41 | # it should appear only once). See also the "--disable" option for examples. 42 | #enable= 43 | 44 | # Disable the message, report, category or checker with the given id(s). You 45 | # can either give multiple identifiers separated by comma (,) or put this 46 | # option multiple times (only on the command line, not in the configuration 47 | # file where it should appear only once).You can also use "--disable=all" to 48 | # disable everything first and then reenable specific checks. For example, if 49 | # you want to run only the similarities checker, you can use "--disable=all 50 | # --enable=similarities". If you want to run only the classes checker, but have 51 | # no Warning level messages displayed, use"--disable=all --enable=classes 52 | # --disable=W" 53 | disable=abstract-method, 54 | apply-builtin, 55 | arguments-differ, 56 | attribute-defined-outside-init, 57 | backtick, 58 | bad-option-value, 59 | basestring-builtin, 60 | buffer-builtin, 61 | c-extension-no-member, 62 | consider-using-enumerate, 63 | cmp-builtin, 64 | cmp-method, 65 | coerce-builtin, 66 | coerce-method, 67 | delslice-method, 68 | div-method, 69 | duplicate-code, 70 | eq-without-hash, 71 | execfile-builtin, 72 | file-builtin, 73 | filter-builtin-not-iterating, 74 | fixme, 75 | getslice-method, 76 | global-statement, 77 | hex-method, 78 | idiv-method, 79 | implicit-str-concat, 80 | import-error, 81 | import-self, 82 | import-star-module-level, 83 | inconsistent-return-statements, 84 | input-builtin, 85 | intern-builtin, 86 | invalid-str-codec, 87 | locally-disabled, 88 | long-builtin, 89 | long-suffix, 90 | map-builtin-not-iterating, 91 | misplaced-comparison-constant, 92 | missing-function-docstring, 93 | metaclass-assignment, 94 | next-method-called, 95 | next-method-defined, 96 | no-absolute-import, 97 | no-else-break, 98 | no-else-continue, 99 | no-else-raise, 100 | no-else-return, 101 | no-init, # added 102 | no-member, 103 | no-name-in-module, 104 | no-self-use, 105 | nonzero-method, 106 | oct-method, 107 | old-division, 108 | old-ne-operator, 109 | old-octal-literal, 110 | old-raise-syntax, 111 | parameter-unpacking, 112 | print-statement, 113 | pointless-string-statement, 114 | raising-string, 115 | range-builtin-not-iterating, 116 | raw_input-builtin, 117 | rdiv-method, 118 | reduce-builtin, 119 | relative-import, 120 | reload-builtin, 121 | round-builtin, 122 | setslice-method, 123 | signature-differs, 124 | standarderror-builtin, 125 | suppressed-message, 126 | sys-max-int, 127 | too-few-public-methods, 128 | too-many-ancestors, 129 | too-many-arguments, 130 | too-many-boolean-expressions, 131 | too-many-branches, 132 | too-many-instance-attributes, 133 | too-many-locals, 134 | too-many-nested-blocks, 135 | too-many-public-methods, 136 | too-many-return-statements, 137 | too-many-statements, 138 | trailing-newlines, 139 | unichr-builtin, 140 | unicode-builtin, 141 | unnecessary-pass, 142 | unpacking-in-except, 143 | useless-else-on-loop, 144 | useless-object-inheritance, 145 | useless-suppression, 146 | using-cmp-argument, 147 | wrong-import-order, 148 | xrange-builtin, 149 | zip-builtin-not-iterating, 150 | broad-exception-caught, 151 | bare-except, 152 | try-except-raise, 153 | arguments-out-of-order, 154 | raise-missing-from, 155 | singleton-comparison, 156 | raising-format-tuple, 157 | unidiomatic-typecheck, 158 | 159 | # Coding convention 160 | consider-using-f-string, 161 | consider-using-max-builtin, 162 | consider-using-with, 163 | consider-using-in, 164 | consider-merging-isinstance, 165 | consider-using-min-builtin, 166 | consider-using-dict-items, 167 | 168 | # Stylistic / Misc. 169 | unspecified-encoding, 170 | simplifiable-if-statement, 171 | superfluous-parens, 172 | unnecessary-semicolon, 173 | chained-comparison, 174 | inconsistent-quotes, 175 | bad-indentation, 176 | line-too-long, 177 | trailing-whitespace, 178 | missing-final-newline, 179 | missing-module-docstring, 180 | missing-class-docstring, 181 | 182 | ########################################################## 183 | 184 | [REPORTS] 185 | 186 | # Set the output format. Available formats are text, parseable, colorized, msvs 187 | # (visual studio) and html. You can also give a reporter class, eg 188 | # mypackage.mymodule.MyReporterClass. 189 | output-format=text 190 | 191 | # Tells whether to display a full report or only the messages 192 | reports=no 193 | 194 | # Python expression which should return a note less than 10 (10 is the highest 195 | # note). You have access to the variables errors warning, statement which 196 | # respectively contain the number of errors / warnings messages and the total 197 | # number of statements analyzed. This is used by the global evaluation report 198 | # (RP0004). 199 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 200 | 201 | # Template used to display messages. This is a python new-style format string 202 | # used to format the message information. See doc for all details 203 | #msg-template= 204 | 205 | 206 | [BASIC] 207 | 208 | # Good variable names which should always be accepted, separated by a comma 209 | good-names=main,_ 210 | 211 | # Bad variable names which should always be refused, separated by a comma 212 | bad-names= 213 | 214 | # Colon-delimited sets of names that determine each other's naming style when 215 | # the name regexes allow several styles. 216 | name-group= 217 | 218 | # Include a hint for the correct naming format with invalid-name 219 | include-naming-hint=no 220 | 221 | # List of decorators that produce properties, such as abc.abstractproperty. Add 222 | # to this list to register other decorators that produce valid properties. 223 | property-classes=abc.abstractproperty,cached_property.cached_property,cached_property.threaded_cached_property,cached_property.cached_property_with_ttl,cached_property.threaded_cached_property_with_ttl 224 | 225 | # Regular expression matching correct function names 226 | function-rgx=^(?:(?PsetUp|tearDown|setUpModule|tearDownModule)|(?P_?[A-Z][a-zA-Z0-9]*)|(?P_?[a-z][a-z0-9_]*))$ 227 | 228 | # Regular expression matching correct variable names 229 | variable-rgx=^[a-z][a-z0-9_]*$ 230 | 231 | # Regular expression matching correct constant names 232 | const-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ 233 | 234 | # Regular expression matching correct attribute names 235 | attr-rgx=^_{0,2}[a-z][a-z0-9_]*$ 236 | 237 | # Regular expression matching correct argument names 238 | argument-rgx=^[a-z][a-z0-9_]*$ 239 | 240 | # Regular expression matching correct class attribute names 241 | class-attribute-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ 242 | 243 | # Regular expression matching correct inline iteration names 244 | inlinevar-rgx=^[a-z][a-z0-9_]*$ 245 | 246 | # Regular expression matching correct class names 247 | class-rgx=^_?[A-Z][a-zA-Z0-9]*$ 248 | 249 | # Regular expression matching correct module names 250 | module-rgx=^(_?[a-z][a-z0-9_]*|__init__)$ 251 | 252 | # Regular expression matching correct method names 253 | method-rgx=(?x)^(?:(?P_[a-z0-9_]+__|runTest|setUp|tearDown|setUpTestCase|tearDownTestCase|setupSelf|tearDownClass|setUpClass|(test|assert)_*[A-Z0-9][a-zA-Z0-9_]*|next)|(?P_{0,2}[A-Z][a-zA-Z0-9_]*)|(?P_{0,2}[a-z][a-z0-9_]*))$ 254 | 255 | # Regular expression which should only match function or class names that do 256 | # not require a docstring. 257 | no-docstring-rgx=(__.*__|main|test.*|.*test|.*Test)$ 258 | 259 | # Minimum line length for functions/classes that require docstrings, shorter 260 | # ones are exempt. 261 | docstring-min-length=10 262 | 263 | 264 | [TYPECHECK] 265 | 266 | # List of decorators that produce context managers, such as 267 | # contextlib.contextmanager. Add to this list to register other decorators that 268 | # produce valid context managers. 269 | contextmanager-decorators=contextlib.contextmanager,contextlib2.contextmanager 270 | 271 | # Tells whether missing members accessed in mixin class should be ignored. A 272 | # mixin class is detected if its name ends with "mixin" (case insensitive). 273 | ignore-mixin-members=yes 274 | 275 | # List of module names for which member attributes should not be checked 276 | # (useful for modules/projects where namespaces are manipulated during runtime 277 | # and thus existing member attributes cannot be deduced by static analysis. It 278 | # supports qualified module names, as well as Unix pattern matching. 279 | ignored-modules= 280 | 281 | # List of class names for which member attributes should not be checked (useful 282 | # for classes with dynamically set attributes). This supports the use of 283 | # qualified names. 284 | ignored-classes=optparse.Values,thread._local,_thread._local 285 | 286 | # List of members which are set dynamically and missed by pylint inference 287 | # system, and so shouldn't trigger E1101 when accessed. Python regular 288 | # expressions are accepted. 289 | generated-members= 290 | 291 | 292 | [FORMAT] 293 | 294 | # Maximum number of characters on a single line. 295 | max-line-length=120 296 | 297 | # Regexp for a line that is allowed to be longer than the limit. 298 | ignore-long-lines=(?x)( 299 | ^\s*(\#\ )??$| 300 | ^\s*(from\s+\S+\s+)?import\s+.+$) 301 | 302 | # Allow the body of an if to be on the same line as the test if there is no 303 | # else. 304 | single-line-if-stmt=yes 305 | 306 | # Maximum number of lines in a module 307 | max-module-lines=99999 308 | 309 | # String used as indentation unit. The internal Google style guide mandates 2 310 | # spaces. Google's externaly-published style guide says 4, consistent with 311 | # PEP 8. Here, we use 2 spaces, for conformity with many open-sourced Google 312 | # projects (like TensorFlow). 313 | # We use 4 spaces to follow PEP 8 and to be consistent with the external Google style guide. 314 | indent-string=' ' 315 | 316 | # Number of spaces of indent required inside a hanging or continued line. 317 | indent-after-paren=4 318 | 319 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 320 | expected-line-ending-format= 321 | 322 | 323 | [MISCELLANEOUS] 324 | 325 | # List of note tags to take in consideration, separated by a comma. 326 | notes=TODO 327 | 328 | 329 | [STRING] 330 | 331 | # This flag controls whether inconsistent-quotes generates a warning when the 332 | # character used as a quote delimiter is used inconsistently within a module. 333 | check-quote-consistency=yes 334 | 335 | 336 | [VARIABLES] 337 | 338 | # Tells whether we should check for unused import in __init__ files. 339 | init-import=no 340 | 341 | # A regular expression matching the name of dummy variables (i.e. expectedly 342 | # not used). 343 | dummy-variables-rgx=^\*{0,2}(_$|unused_|dummy_) 344 | 345 | # List of additional names supposed to be defined in builtins. Remember that 346 | # you should avoid to define new builtins when possible. 347 | additional-builtins= 348 | 349 | # List of strings which can identify a callback function by name. A callback 350 | # name must start or end with one of those strings. 351 | callbacks=cb_,_cb 352 | 353 | # List of qualified module names which can have objects that can redefine 354 | # builtins. 355 | redefining-builtins-modules=six,six.moves,past.builtins,future.builtins,functools 356 | 357 | 358 | [LOGGING] 359 | 360 | # Logging modules to check that the string format arguments are in logging 361 | # function parameter format 362 | logging-modules=logging,absl.logging,tensorflow.io.logging 363 | 364 | 365 | [SIMILARITIES] 366 | 367 | # Minimum lines number of a similarity. 368 | min-similarity-lines=4 369 | 370 | # Ignore comments when computing similarities. 371 | ignore-comments=yes 372 | 373 | # Ignore docstrings when computing similarities. 374 | ignore-docstrings=yes 375 | 376 | # Ignore imports when computing similarities. 377 | ignore-imports=no 378 | 379 | 380 | [SPELLING] 381 | 382 | # Spelling dictionary name. Available dictionaries: none. To make it working 383 | # install python-enchant package. 384 | spelling-dict= 385 | 386 | # List of comma separated words that should not be checked. 387 | spelling-ignore-words= 388 | 389 | # A path to a file that contains private dictionary; one word per line. 390 | spelling-private-dict-file= 391 | 392 | # Tells whether to store unknown words to indicated private dictionary in 393 | # --spelling-private-dict-file option instead of raising a message. 394 | spelling-store-unknown-words=no 395 | 396 | 397 | [IMPORTS] 398 | 399 | # Deprecated modules which should not be used, separated by a comma 400 | deprecated-modules=regsub, 401 | TERMIOS, 402 | Bastion, 403 | rexec, 404 | sets 405 | 406 | # Create a graph of every (i.e. internal and external) dependencies in the 407 | # given file (report RP0402 must not be disabled) 408 | import-graph= 409 | 410 | # Create a graph of external dependencies in the given file (report RP0402 must 411 | # not be disabled) 412 | ext-import-graph= 413 | 414 | # Create a graph of internal dependencies in the given file (report RP0402 must 415 | # not be disabled) 416 | int-import-graph= 417 | 418 | # Force import order to recognize a module as part of the standard 419 | # compatibility libraries. 420 | known-standard-library= 421 | 422 | # Force import order to recognize a module as part of a third party library. 423 | known-third-party=enchant, absl 424 | 425 | # Analyse import fallback blocks. This can be used to support both Python 2 and 426 | # 3 compatible code, which means that the block might have code that exists 427 | # only in one or another interpreter, leading to false positives when analysed. 428 | analyse-fallback-blocks=no 429 | 430 | 431 | [CLASSES] 432 | 433 | # List of method names used to declare (i.e. assign) instance attributes. 434 | defining-attr-methods=__init__, 435 | __new__, 436 | setUp 437 | 438 | # List of member names, which should be excluded from the protected access 439 | # warning. 440 | exclude-protected=_asdict, 441 | _fields, 442 | _replace, 443 | _source, 444 | _make 445 | 446 | # List of valid names for the first argument in a class method. 447 | valid-classmethod-first-arg=cls, 448 | class_ 449 | 450 | # List of valid names for the first argument in a metaclass class method. 451 | valid-metaclass-classmethod-first-arg=mcs 452 | 453 | 454 | [EXCEPTIONS] 455 | 456 | # Exceptions that will emit a warning when being caught. Defaults to 457 | # "Exception" 458 | overgeneral-exceptions=builtins.StandardError, 459 | builtins.Exception, 460 | builtins.BaseException 461 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | pySunSpec2 2 | ######### 3 | 4 | Overview 5 | ======== 6 | pySunSpec is a python package that provides objects and applications that support interaction with SunSpec compliant 7 | devices and documents. pySunSpec runs in most environments that support Python and is tested on Windows 7, Windows 10, 8 | and Windows 11. 9 | 10 | This is the next generation of pySunSpec tools. It supports all SunSpec infomation model definitions and formats including smdx and 11 | json. The Python objects used for interacting with devices have some differences from version 1 and it is not backward 12 | compatable with version 1. This version of pySunSpec must be used with devices implementing the 7xx series of 13 | information models as they use updated modeling concepts. 14 | 15 | Copyright (c) 2020 SunSpec Alliance 16 | 17 | Features 18 | ======== 19 | - Provides access to SunSpec Modbus RTU and TCP devices, and device image files 20 | - High level object model allowing easy device scripting 21 | - Minimal dependencies for core package allowing it to run in more constrained Python environments 22 | - Runs on Windows, and other environments that support Python 23 | 24 | 25 | Requirements 26 | ============ 27 | - Python >= 3.5 28 | - pySerial (if using Modbus RTU) 29 | - openpyxl (if using Excel spreadsheet) 30 | - pytest (if running tests) 31 | 32 | Installation 33 | ================================= 34 | Installing using pip: 35 | 36 | pip install pysunspec2 37 | 38 | Installation using pip installs the models as well 39 | 40 | Installing from the setup.py file: 41 | 42 | C:\\pysunspec2> python -m pip install . 43 | 44 | or if the python path isn't configured: 45 | 46 | C:\\pysunspec2> c:\\Python37\\python.exe -m pip install . 47 | 48 | Note: To install the models while cloning the repository, make sure to add the recursive tag: 49 | 50 | git clone https://github.com/sunspec/pysunspec2.git --recursive 51 | 52 | Interacting with a SunSpec Device 53 | ================================= 54 | 55 | The SunSpecModbusClientDeviceRTU, SunSpecModbusClientDeviceTCP, and FileClientDevice classes are used for high level 56 | access to a SunSpec device. These three classes represent the three contexts which you can operate in: RTU, TCP 57 | and device image. The three classes support the same interface, thus operating in the same way in regards to scripting 58 | and functionality. The three classes are wrappers around the Device object, which provides the user with the easiest 59 | syntax for basic operations. Because these context specific objects are a wrapper for the Device object, 60 | functions and patterns are shared across the contexts, but are adapted to their specific context. This means that all 61 | device types have the same device interaction. 62 | 63 | The Device object contains Model, Group, and Point objects, all dynamically created from the models in the device. 64 | 65 | All of the examples in this guide use commands in the Python command interpreter for illustrative purposes but the 66 | common use case would be to use Python scritps/programs to interact with a SunSpec device. 67 | 68 | pySunSpec Objects 69 | ----------------- 70 | 71 | The Device, Model, Group and Point objects provide the interface for interacting with device instances. 72 | 73 | These objects provide a Python representation of the physical device and contain the current values associated with the 74 | points implemented in the device. The point values represent a snapshot of the device at time the point were last 75 | explicitly read from or written to the device. Values are transferred from the physical device to the object when a 76 | read() is performed on an object and from the object to the device when a write is performed. Models, groups, and 77 | points can all be read and written. When models or groups are written, only point values that have been set in the 78 | object since the last read or write are written. A read will overwrite values that have been set and not written. 79 | 80 | Device 81 | ^^^^^^ 82 | The Device object is an interface for different device types, allowing all device types to have the same device 83 | interaction. The three classes which instantiate a device object are: SunSpecModbusClientDeviceRTU, 84 | SunSpecModbusClientDeviceTCP, and FileClientDevice. 85 | 86 | Model 87 | ^^^^^ 88 | A Model object is created for each model found on the device during the model discovery process. The Model object 89 | dynamically creates Point and Group objects based on the model definition and points and groups found in the model. 90 | 91 | In the model, group, point hierarchy, a model is a single top-level group. When groups and points are added in the 92 | discovery process they are placed as object attributes using their definition name. This allows the groups and points 93 | to be accessed hierarchically as object attributes allowing an efficient reference syntax. 94 | 95 | Group 96 | ^^^^^ 97 | A Group object represents a group in a model. The object can contain both points and groups. The Group object 98 | dynamically creates Points and Group objects based on points and groups in its group. 99 | 100 | Point 101 | ^^^^^ 102 | The Point object represents a point in a model. There are three key attributes for each point: value, sf_value, and 103 | cvalue. 104 | 105 | The value attribute represents the base value for the point. The sf_value attribute represents the scale factor value 106 | for the point, if applicable. The cvalue attribute represents the computed value for the point. The computed value is 107 | created by applying the scale factor value to the base value. Value can be get or set using either their value or 108 | cvalue. 109 | 110 | Here are some get examples, where "d" is the Device object, "DERMeasureAC[0]" is the Model object , and "LLV" is the 111 | Point object. 112 | 113 | Get value on point: :: 114 | 115 | >>> d.DERMeasureAC[0].LLV.value 116 | 2403 117 | 118 | Get scale factor value on point: :: 119 | 120 | >>> d.DERMeasureAC[0].LLV.sf_value 121 | -1 122 | 123 | Get the computed value on the point, where the scale factor is -1: :: 124 | 125 | >>> d.DERMeasureAC[0].LLV.cvalue 126 | 240.3 127 | 128 | And some set examples, where where "d" is the Device object, "DEREnterService[0]" is the Model object , and "ESVHi" is 129 | the Point object. 130 | 131 | Set value on point: :: 132 | 133 | >>> d.DEREnterService[0].ESVHi.value = 2450 134 | 135 | Get the point as cvalue: :: 136 | 137 | >>> d.DEREnterService[0].ESVHi.cvalue 138 | 245.0 139 | 140 | Set computed value on point, where the computed value is calculated from the scale factor, and the scale factor is 141 | -1: :: 142 | 143 | >>> d.DEREnterService[0].ESVHi.cvalue = 245.1 144 | 145 | Get the point as value: :: 146 | 147 | >>> d.DEREnterService[0].ESVHi.value 148 | 2451 149 | 150 | Remember, getting and setting the points only updates the Python object and does not read or write the values to the 151 | physical device. 152 | 153 | Accessing a SunSpec Device 154 | -------------------------- 155 | Accessing with a SunSpec device involves the following steps: 156 | 157 | 1. Create a device object using one of the device classes (Modbus TCP, Modbus RTU, or Device File). 158 | 2. Perform device model discovery using the scan() method on the device. 159 | 3. Read and write contents of the device as needed. 160 | 161 | Creating a Device Object 162 | ------------------------ 163 | The following are examples of how to initialize a Device objects using one of the device classes based on the device 164 | type. 165 | 166 | TCP 167 | ^^^ 168 | The following is how to open and initialize a TCP Device, where the slave ID is set to 1, the IP address of the TCP 169 | device is 170 | 127.0.0.1, and the port is 8502:: 171 | 172 | >>> import sunspec2.modbus.client as client 173 | >>> d = client.SunSpecModbusClientDeviceTCP(slave_id=1, ipaddr='127.0.0.1', ipport=8502) 174 | 175 | RTU 176 | ^^^ 177 | The following to open and initialize a RTU Device, where the slave ID is set to 1, and the name of the serial port is 178 | COM2:: 179 | 180 | >>> import sunspec2.modbus.client as client 181 | >>> d = client.SunSpecModbusClientDeviceRTU(slave_id=1, name="COM2") 182 | 183 | Device Image 184 | ^^^^^^^^^^^^ 185 | The following is how to open a Device Image file named "model_702_data.json":: 186 | 187 | >>> import sunspec2.file.client as client 188 | >>> d = client.FileClientDevice('model_702_data.json') 189 | 190 | Closing a device 191 | ---------------- 192 | When done with a device, close it: 193 | 194 | >>> d.close() 195 | 196 | Device Model Discovery 197 | ---------------------- 198 | The scan() method must be called after initialization of the device. Scan invokes the device model discovery 199 | process. For different device types, scan may or may not be necessary, but it can be called on any device type. 200 | Depending on the type, scan may either go through the device Modbus map, or it may go through the device image file. 201 | For Modbus, scan searches three device addresses (0, 40000, 50000), looking for the 'SunS' identifier. Upon discovery 202 | of the SunS identifier, scan uses the model ID and model length to find each model present in the device. Model 203 | definitions are used during the discovery process to create the Model, Group, and Points objects associated with the 204 | model. If a model is encountered that does not have a model definition, it is noted but its contents are not interpreted. 205 | The scan is performed until the end model is encountered. 206 | 207 | The scan produces a dictionary containing entries for each model ID found. Two dictionary keys are created for each 208 | model ID. The first key is the model ID as an int, the second key is the model name as a string. Since it is possible 209 | that a device may contain more than one model with the same model ID, the dictionary keys refer to a list of model 210 | objects with that ID. Both keys refer to the same model list for a model ID. 211 | 212 | >>> d = client.SunSpecModbusClientDeviceTCP(slave_id=1, ipaddr='127.0.0.1', ipport=8502) 213 | >>> d.scan() 214 | 215 | 216 | Determine which models are present in the device: :: 217 | 218 | >>> d.models 219 | {1: [<__main__.SunSpecModbusClientModel object at 0x000001FD7A6082B0>], 220 | 'common': [<__main__.SunSpecModbusClientModel object at 0x000001FD7A6082B0>], 221 | 705: [<__main__.SunSpecModbusClientModel object at 0x000001FD7A8B28B0>], 222 | 'DERVoltVar': [<__main__.SunSpecModbusClientModel object at 0x000001FD7A8B28B0>]} 223 | 224 | Models are stored in a dictionary using the key for the model ID, and the model name. In this case, the device has two 225 | models: common (model 1), DERVoltVar (model 705). 226 | 227 | Reading from a Device 228 | --------------------- 229 | To acquire the values from the physical device, an explicit read operation must be performed with the read() method 230 | on a device, model, group, or point within the device. 231 | 232 | To perform a read() for the common model contents: :: 233 | 234 | >>> d.common[0].read() 235 | 236 | The model, group, and point objects, in the common model, have been updated to the latest values on the device. 237 | 238 | Writing to a Device 239 | ------------------- 240 | To update the physical device with values that have been set in the device, an explict write() operation must be done on 241 | a device, model, group, or point. Only the fields that have been set since the last read or write in the model are 242 | actually written to the physical device. 243 | 244 | Get the value on the point "Ena" in the "DERVoltVar" model: :: 245 | 246 | >>> d.DERVoltVar[0].Ena.value 247 | 0 248 | 249 | Set the value for the point and write to the device: :: 250 | 251 | >>> d.DERVoltVar[0].Ena.value = 1 252 | >>> d.DERVoltVar[0].write() 253 | >>> d.DERVoltVar[0].read() 254 | 255 | Get the value on the point "Ena" in the "DERVoltVar" model: :: 256 | 257 | >>> print(d.DERVoltVar[0].Ena.value) 258 | 1 259 | 260 | After assigning the value on the point object, "Ena", write() must be called in order to update the device. Many 261 | consider it a good Modbus practice to read after every write to check if the operation was successful, but it is not 262 | required. In this example, we perform a read() after a write(). 263 | 264 | Additional Information 265 | ---------------------- 266 | The groups and points in a group are contained in ordered groups and points dictionaries if needed. Repeating groups are 267 | represented as a list of groups. 268 | 269 | Get the groups present in the model 705 on the device: :: 270 | 271 | >>> d.DERVoltVar[0].groups 272 | OrderedDict([('Crv', [<__main__.SunSpecModbusClientGroup object at 0x000001FD7A58EFA0>, 273 | <__main__.SunSpecModbusClientGroup object at 0x000001FD7A58EF40>, 274 | <__main__.SunSpecModbusClientGroup object at 0x000001FD7A58EEE0>])]) 275 | 276 | Get the points present in the model 705 on the device: :: 277 | 278 | >>> d.DERVoltVar[0].points 279 | OrderedDict([('ID', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C2E0>), 280 | ('L', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C2B0>), 281 | ('Ena', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C280>), 282 | ('CrvSt', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C250>), 283 | ('AdptCrvReq', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C220>), 284 | ('AdptCrvRslt', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C0A0>), 285 | ('NPt', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C0D0>), 286 | ('NCrv', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C100>), 287 | ('RvrtTms', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C130>), 288 | ('RvrtRem', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C160>), 289 | ('RvrtCrv', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C190>), 290 | ('V_SF', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C1C0>), 291 | ('DeptRef_SF', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C1F0>)]) 292 | 293 | Full Example of a Device Interaction 294 | ------------------------------------ 295 | This section will go over the full steps on how to set a volt-var curve. 296 | 297 | Initialize device, and run device discovery with scan(): :: 298 | 299 | >>> d = client.SunSpecModbusClientDeviceRTU(slave_id=1, name="COM2") 300 | >>> d.scan() 301 | 302 | Confirm that model 705 (DERVoltVar) is on the device: :: 303 | 304 | >>> d.models 305 | {1: [<__main__.SunSpecModbusClientModel object at 0x000001FD7A6082B0>], 306 | 'common': [<__main__.SunSpecModbusClientModel object at 0x000001FD7A6082B0>], 307 | 705: [<__main__.SunSpecModbusClientModel object at 0x000001FD7A8B28B0>], 308 | 'DERVoltVar': [<__main__.SunSpecModbusClientModel object at 0x000001FD7A8B28B0>]} 309 | 310 | Read the volt-var model from the device to ensure the latest values: 311 | 312 | >>> vv = d.DERVoltVar[0] 313 | >>> vv.read() 314 | 315 | Display the current curve values (the first curve). Curve 1 is a read-only curve indicating the current curve settings: 316 | 317 | >>> print(vv.Crv[0]) 318 | Crv(1): 319 | ActPt: 4 320 | DeptRef: 1 321 | Pri: 1 322 | VRef: 100 323 | VRefAuto: 0 324 | VRefAutoEna: None 325 | VRefTms: 5 326 | RspTms: 0 327 | ReadOnly: 1 328 | Pt(1): 329 | V: 9200 330 | Var: 3000 331 | Pt(2): 332 | V: 9670 333 | Var: 0 334 | Pt(3): 335 | V: 10300 336 | Var: 0 337 | Pt(4): 338 | V: 10700 339 | Var: -3000 340 | 341 | Note that, by convention, SunSpec repeating elements, such as curves, are labeled with an index of 1 for the first 342 | element, but when accessing in the Python objects, the index of the first element is 0. Here we see the first curve 343 | being accessed with the 0 index but labeled as curve 1 in the output. Parentheses are used with the index of 1 to 344 | indicate it is a SunSpec 1-based index. 345 | 346 | Use the second curve to hold the new curve settings and write to the device: 347 | 348 | >>> c = vv.Crv[1] 349 | >>> c.ActPt = 4 350 | >>> c.DeptRef = 1 351 | >>> c.VRef = 100 352 | >>> c.VRefAutoEna = 0 353 | >>> c.Pt[0].V = 9300 354 | >>> c.Pt[0].Var = 2000 355 | >>> c.Pt[1].V = 9700 356 | >>> c.Pt[1].Var = 0 357 | >>> c.Pt[2].V = 10350 358 | >>> c.Pt[2].Var = 0 359 | >>> c.Pt[3].V = 10680 360 | >>> c.Pt[3].Var = -2000 361 | >>> c.write() 362 | 363 | Write point adopt curve request point to adopt the curve 2 values: 364 | 365 | >>> vv.AdptCrvReq = 2 366 | >>> vv.write() 367 | 368 | Read the adopt curve result and contents of the curves: 369 | 370 | >>> vv.read() 371 | >>> print(vv.AdptCrvRslt) 372 | 1 373 | 374 | The result indicates completed. The first curve should now contain the updated values reflecting the current active curve settings: 375 | 376 | >>> print(vv.Crv[0]) 377 | Crv(1): 378 | ActPt: 4 379 | DeptRef: 1 380 | Pri: 1 381 | VRef: 100 382 | VRefAuto: 0 383 | VRefAutoEna: None 384 | VRefTms: 5 385 | RspTms: 0 386 | ReadOnly: 1 387 | Pt(1): 388 | V: 9300 389 | Var: 2000 390 | Pt(2): 391 | V: 9700 392 | Var: 0 393 | Pt(3): 394 | V: 10350 395 | Var: 0 396 | Pt(4): 397 | V: 10680 398 | Var: -2000 399 | 400 | Check to see if the function is enabled by checking the Ena point. :: 401 | 402 | >>> print(vv.Ena.value) 403 | 0 404 | 405 | The function is disabled, set the value to 1, and write to device, in order to enable the function. :: 406 | 407 | >>> vv.Ena.value = 1 408 | >>> d.write() 409 | 410 | It is considered a best practice with Modbus to verify values written to the device by reading them back to ensure they 411 | were set properly. That step has been omitted to here to focus on the update sequence. 412 | 413 | Development 414 | ============ 415 | 416 | Executing the unit tests 417 | ------------------------ 418 | 419 | Make sure `tox` is installed on your computer (see [tox documentation](https://tox.wiki/) for details). 420 | 421 | The following command will let `tox` create a virtual environment with all necessary dependencies for each python 422 | version supported by PySunspec and use it to execute the unit tests. 423 | Each python version must already be installed on the host 424 | 425 | ```sh 426 | tox 427 | ``` 428 | 429 | We can also run the test for one specific python version only: 430 | 431 | ```sh 432 | tox -e py39 433 | ``` 434 | 435 | Contribution 436 | ============ 437 | If you wish to contribute to the project, please contact support@sunspec.org to sign a CLA. 438 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytest>=8.3.3 2 | pyserial>=3.5 3 | openpyxl>=3.1.5 -------------------------------------------------------------------------------- /scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunspec/pysunspec2/d42ed2d1cc9647d2591a9a75caba1e1fa05c437e/scripts/__init__.py -------------------------------------------------------------------------------- /scripts/suns.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Copyright (c) 2021, SunSpec Alliance 5 | All Rights Reserved 6 | 7 | """ 8 | 9 | import sys 10 | import time 11 | import sunspec2.modbus.client as client 12 | import sunspec2.file.client as file_client 13 | from optparse import OptionParser 14 | 15 | """ 16 | Original suns options: 17 | 18 | -o: output mode for data (text, xml) 19 | -x: export model description (slang, xml) 20 | -t: transport type: tcp or rtu (default: tcp) 21 | -a: modbus slave address (default: 1) 22 | -i: ip address to use for modbus tcp (default: localhost) 23 | -P: port number for modbus tcp (default: 502) 24 | -p: serial port for modbus rtu (default: /dev/ttyUSB0) 25 | -R: parity for modbus rtu: None, E (default: None) 26 | -b: baud rate for modbus rtu (default: 9600) 27 | -T: timeout, in seconds (can be fractional, such as 1.5; default: 2.0) 28 | -r: number of retries attempted for each modbus read 29 | -m: specify model file 30 | -M: specify directory containing model files 31 | -s: run as a test server 32 | -I: logger id (for sunspec logger xml output) 33 | -N: logger id namespace (for sunspec logger xml output, defaults to 'mac') 34 | -l: limit number of registers requested in a single read (max is 125) 35 | -c: check models for internal consistency then exit 36 | -v: verbose level (up to -vvvv for most verbose) 37 | -V: print current release number and exit 38 | """ 39 | 40 | if __name__ == "__main__": 41 | 42 | usage = 'usage: %prog [options]' 43 | parser = OptionParser(usage=usage) 44 | parser.add_option('-t', metavar=' ', 45 | default='tcp', 46 | help='transport type: rtu, tcp, file [default: tcp]') 47 | parser.add_option('-a', metavar=' ', type='int', 48 | default=1, 49 | help='modbus slave address [default: 1]') 50 | parser.add_option('-i', metavar=' ', 51 | default='localhost', 52 | help='ip address to use for modbus tcp [default: localhost]') 53 | parser.add_option('-P', metavar=' ', type='int', 54 | default=502, 55 | help='port number for modbus tcp [default: 502]') 56 | parser.add_option('-p', metavar=' ', 57 | default='/dev/ttyUSB0', 58 | help='serial port for modbus rtu [default: /dev/ttyUSB0]') 59 | parser.add_option('-b', metavar=' ', 60 | default=9600, 61 | help='baud rate for modbus rtu [default: 9600]') 62 | parser.add_option('-R', metavar=' ', 63 | default=None, 64 | help='parity for modbus rtu: None, E [default: None]') 65 | parser.add_option('-T', metavar=' ', type='float', 66 | default=2.0, 67 | help='timeout, in seconds (can be fractional, such as 1.5) [default: 2.0]') 68 | parser.add_option('-m', metavar=' ', 69 | help='modbus map file') 70 | 71 | options, args = parser.parse_args() 72 | 73 | try: 74 | if options.t == 'tcp': 75 | sd = client.SunSpecModbusClientDeviceTCP(slave_id=options.a, ipaddr=options.i, ipport=options.P, 76 | timeout=options.T) 77 | elif options.t == 'rtu': 78 | sd = client.SunSpecModbusClientDeviceRTU(slave_id=options.a, name=options.p, baudrate=options.b, 79 | parity=options.R, timeout=options.T) 80 | elif options.t == 'file': 81 | sd = file_client.FileClientDevice(filename=options.m) 82 | else: 83 | print('Unknown -t option: %s' % (options.t)) 84 | sys.exit(1) 85 | 86 | except client.SunSpecModbusClientError as e: 87 | print('Error: %s' % e) 88 | sys.exit(1) 89 | except file_client.FileClientError as e: 90 | print('Error: %s' % e) 91 | sys.exit(1) 92 | 93 | if sd is not None: 94 | print( '\nTimestamp: %s' % (time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()))) 95 | 96 | # read all models in the device 97 | sd.scan() 98 | 99 | print(sd.get_text()) 100 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Copyright (c) 2025, SunSpec Alliance 5 | All Rights Reserved 6 | """ 7 | 8 | from setuptools import setup 9 | 10 | setup( 11 | name='pysunspec2', 12 | version='1.2.0', 13 | description='Python SunSpec Tools', 14 | license="Apache Software License, Version 2.0", 15 | author='SunSpec Alliance', 16 | author_email='support@sunspec.org', 17 | url='https://sunspec.org/', 18 | packages=['sunspec2', 'sunspec2.modbus', 'sunspec2.file', 'sunspec2.tests'], 19 | package_data={'sunspec2.tests': ['test_data/*'], 'sunspec2': ['models/json/*']}, 20 | scripts=['scripts/suns.py'], 21 | python_requires='>=3.5', 22 | extras_require={ 23 | 'serial': ['pyserial'], 24 | 'excel': ['openpyxl'], 25 | 'test': ['pytest'], 26 | }, 27 | classifiers=[ 28 | "Development Status :: 4 - Beta", 29 | "Intended Audience :: Developers", 30 | "Topic :: Software Development :: Libraries :: Python Modules", 31 | "License :: OSI Approved :: Apache Software License", 32 | "Programming Language :: Python :: 3.5", 33 | "Programming Language :: Python :: 3.6", 34 | "Programming Language :: Python :: 3.7", 35 | "Programming Language :: Python :: 3.8", 36 | "Programming Language :: Python :: 3.9", 37 | "Programming Language :: Python :: 3.10", 38 | "Programming Language :: Python :: 3.11", 39 | "Programming Language :: Python :: 3.12", 40 | ], 41 | ) 42 | -------------------------------------------------------------------------------- /sunspec2/__init__.py: -------------------------------------------------------------------------------- 1 | # pySunSpec version 2 | VERSION = '1.1.5' 3 | -------------------------------------------------------------------------------- /sunspec2/docs/pysunspec.rst: -------------------------------------------------------------------------------- 1 | pySunSpec 2 | ######### 3 | 4 | Overview 5 | ======== 6 | pySunSpec is a python package that provides objects and applications that support interaction with SunSpec compliant 7 | devices and documents. pySunSpec runs in most environments that support Python and is tested on Windows 7 and 8 | Windows 10. 9 | 10 | This is version 2 of pySunSpec. It supports all SunSpec infomation model definitions and formats including smdx and 11 | json. The Python objects used for interacting with devices have some differences from version 1 and it is not backward 12 | compatable with version 1. This version of pySunSpec must be used with devices implementing the 7xx series of 13 | information models as they use updated modeling concepts. 14 | 15 | Copyright (c) 2020 SunSpec Alliance 16 | 17 | Features 18 | ======== 19 | - Provides access to SunSpec Modbus RTU and TCP devices, and device image files 20 | - High level object model allowing easy device scripting 21 | - Minimal dependencies for core package allowing it to run in more constrained Python environments 22 | - Runs on Windows, and other environments that support Python 23 | 24 | 25 | Requirements 26 | ============ 27 | - Python 3.5-3.8 28 | - pySerial (if using Modbus RTU) 29 | - openpyxl (if using Excel spreadsheet) 30 | - pytest (if running tests) 31 | 32 | 33 | Interacting with a SunSpec Device 34 | ================================= 35 | 36 | The SunSpecModbusClientDeviceRTU, SunSpecModbusClientDeviceTCP, and FileClientDevice classes are used for high level 37 | access to a SunSpec device. These three classes represent the three contexts which you can operate in: RTU, TCP 38 | and device image. The three classes support the same interface, thus operating in the same way in regards to scripting 39 | and functionality. The three classes are wrappers around the Device object, which provides the user with the easiest 40 | syntax for basic operations. Because these context specific objects are a wrapper for the Device object, 41 | functions and patterns are shared across the contexts, but are adapted to their specific context. This means that all 42 | device types have the same device interaction. 43 | 44 | The Device object contains Model, Group, and Point objects, all dynamically created from the models in the device. 45 | 46 | All of the examples in this guide use commands in the Python command interpreter for illustrative purposes but the 47 | common use case would be to use Python scritps/programs to interact with a SunSpec device. 48 | 49 | pySunSpec Objects 50 | ----------------- 51 | 52 | The Device, Model, Group and Point objects provide the interface for interacting with device instances. 53 | 54 | These objects provide a Python representation of the physical device and contain the current values associated with the 55 | points implemented in the device. The point values represent a snapshot of the device at time the point were last 56 | explicitly read from or written to the device. Values are transferred from the physical device to the object when a 57 | read() is performed on an object and from the object to the device when a write is performed. Models, groups, and 58 | points can all be read and written. When models or groups are written, only point values that have been set in the 59 | object since the last read or write are written. A read will overwrite values that have been set and not written. 60 | 61 | Device 62 | ^^^^^^ 63 | The Device object is an interface for different device types, allowing all device types to have the same device 64 | interaction. The three classes which instantiate a device object are: SunSpecModbusClientDeviceRTU, 65 | SunSpecModbusClientDeviceTCP, and FileClientDevice. 66 | 67 | Model 68 | ^^^^^ 69 | A Model object is created for each model found on the device during the model discovery process. The Model object 70 | dynamically creates Point and Group objects based on the model definition and points and groups found in the model. 71 | 72 | In the model, group, point hierarchy, a model is a single top-level group. When groups and points are added in the 73 | discovery process they are placed as object attributes using their definition name. This allows the groups and points 74 | to be accessed hierarchically as object attributes allowing an efficient reference syntax. 75 | 76 | Group 77 | ^^^^^ 78 | A Group object represents a group in a model. The object can contain both points and groups. The Group object 79 | dynamically creates Points and Group objects based on points and groups in its group. 80 | 81 | Point 82 | ^^^^^ 83 | The Point object represents a point in a model. There are three key attributes for each point: value, sf_value, and 84 | cvalue. 85 | 86 | The value attribute represents the base value for the point. The sf_value attribute represents the scale factor value 87 | for the point, if applicable. The cvalue attribute represents the computed value for the point. The computed value is 88 | created by applying the scale factor value to the base value. Value can be get or set using either their value or 89 | cvalue. 90 | 91 | Here are some get examples, where "d" is the Device object, "DERMeasureAC[0]" is the Model object , and "LLV" is the 92 | Point object. 93 | 94 | Get value on point: :: 95 | 96 | >>> d.DERMeasureAC[0].LLV.value 97 | 2403 98 | 99 | Get scale factor value on point: :: 100 | 101 | >>> d.DERMeasureAC[0].LLV.sf_value 102 | -1 103 | 104 | Get the computed value on the point, where the scale factor is -1: :: 105 | 106 | >>> d.DERMeasureAC[0].LLV.cvalue 107 | 240.3 108 | 109 | And some set examples, where where "d" is the Device object, "DEREnterService[0]" is the Model object , and "ESVHi" is 110 | the Point object. 111 | 112 | Set value on point: :: 113 | 114 | >>> d.DEREnterService[0].ESVHi.value = 2450 115 | 116 | Get the point as cvalue: :: 117 | 118 | >>> d.DEREnterService[0].ESVHi.cvalue 119 | 245.0 120 | 121 | Set computed value on point, where the computed value is calculated from the scale factor, and the scale factor is 122 | -1: :: 123 | 124 | >>> d.DEREnterService[0].ESVHi.cvalue = 245.1 125 | 126 | Get the point as value: :: 127 | 128 | >>> d.DEREnterService[0].ESVHi.value 129 | 2451 130 | 131 | Remember, getting and setting the points only updates the Python object and does not read or write the values to the 132 | physical device. 133 | 134 | Accessing a SunSpec Device 135 | -------------------------- 136 | Accessing with a SunSpec device involves the following steps: 137 | 138 | 1. Create a device object using one of the device classes (Modbus TCP, Modbus RTU, or Device File). 139 | 2. Perform device model discovery using the scan() method on the device. 140 | 3. Read and write contents of the device as needed. 141 | 142 | Creating a Device Object 143 | ------------------------ 144 | The following are examples of how to initialize a Device objects using one of the device classes based on the device 145 | type. 146 | 147 | TCP 148 | ^^^ 149 | The following is how to open and initialize a TCP Device, where the slave ID is set to 1, the IP address of the TCP 150 | device is 151 | 127.0.0.1, and the port is 8502:: 152 | 153 | >>> import sunspec2.modbus.client as client 154 | >>> d = client.SunSpecModbusClientDeviceTCP(slave_id=1, ipaddr='127.0.0.1', ipport=8502) 155 | 156 | RTU 157 | ^^^ 158 | The following to open and initialize a RTU Device, where the slave ID is set to 1, and the name of the serial port is 159 | COM2:: 160 | 161 | >>> import sunspec2.modbus.client as client 162 | >>> d = client.SunSpecModbusClientDeviceRTU(slave_id=1, name="COM2") 163 | 164 | Device Image 165 | ^^^^^^^^^^^^ 166 | The following is how to open a Device Image file named "model_702_data.json":: 167 | 168 | >>> import sunspec2.file.client as client 169 | >>> d = client.FileClientDevice('model_702_data.json') 170 | 171 | Closing a device 172 | ---------------- 173 | When done with a device, close it: 174 | 175 | >>> d.close() 176 | 177 | Device Model Discovery 178 | ---------------------- 179 | The scan() method must be called after initialization of the device. Scan invokes the device model discovery 180 | process. For different device types, scan may or may not be necessary, but it can be called on any device type. 181 | Depending on the type, scan may either go through the device Modbus map, or it may go through the device image file. 182 | For Modbus, scan searches three device addresses (0, 40000, 50000), looking for the 'SunS' identifier. Upon discovery 183 | of the SunS identifier, scan uses the model ID and model length to find each model present in the device. Model 184 | definitions are used during the discovery process to create the Model, Group, and Points objects associated with the 185 | model. If a model is encountered that does not have a model definition, it is noted but its contents are not interpreted. 186 | The scan is performed until the end model is encountered. 187 | 188 | The scan produces a dictionary containing entries for each model ID found. Two dictionary keys are created for each 189 | model ID. The first key is the model ID as an int, the second key is the model name as a string. Since it is possible 190 | that a device may contain more than one model with the same model ID, the dictionary keys refer to a list of model 191 | objects with that ID. Both keys refer to the same model list for a model ID. 192 | 193 | >>> d = client.SunSpecModbusClientDeviceTCP(slave_id=1, ipaddr='127.0.0.1', ipport=8502) 194 | >>> d.scan() 195 | 196 | 197 | Determine which models are present in the device: :: 198 | 199 | >>> d.models 200 | {1: [<__main__.SunSpecModbusClientModel object at 0x000001FD7A6082B0>], 201 | 'common': [<__main__.SunSpecModbusClientModel object at 0x000001FD7A6082B0>], 202 | 705: [<__main__.SunSpecModbusClientModel object at 0x000001FD7A8B28B0>], 203 | 'DERVoltVar': [<__main__.SunSpecModbusClientModel object at 0x000001FD7A8B28B0>]} 204 | 205 | Models are stored in a dictionary using the key for the model ID, and the model name. In this case, the device has two 206 | models: common (model 1), DERVoltVar (model 705). 207 | 208 | Reading from a Device 209 | --------------------- 210 | To acquire the values from the physical device, an explicit read operation must be performed with the read() method 211 | on a device, model, group, or point within the device. 212 | 213 | To perform a read() for the common model contents: :: 214 | 215 | >>> d.common[0].read() 216 | 217 | The model, group, and point objects, in the common model, have been updated to the latest values on the device. 218 | 219 | Writing to a Device 220 | ------------------- 221 | To update the physical device with values that have been set in the device, an explict write() operation must be done on 222 | a device, model, group, or point. Only the fields that have been set since the last read or write in the model are 223 | actually written to the physical device. 224 | 225 | Get the value on the point "Ena" in the "DERVoltVar" model: :: 226 | 227 | >>> d.DERVoltVar[0].Ena.value 228 | 0 229 | 230 | Set the value for the point and write to the device: :: 231 | 232 | >>> d.DERVoltVar[0].Ena.value = 1 233 | >>> d.DERVoltVar[0].write() 234 | >>> d.DERVoltVar[0].read() 235 | 236 | Get the value on the point "Ena" in the "DERVoltVar" model: :: 237 | 238 | >>> print(d.DERVoltVar[0].Ena.value) 239 | 1 240 | 241 | After assigning the value on the point object, "Ena", write() must be called in order to update the device. Many 242 | consider it a good Modbus practice to read after every write to check if the operation was successful, but it is not 243 | required. In this example, we perform a read() after a write(). 244 | 245 | Additional Information 246 | ---------------------- 247 | The groups and points in a group are contained in ordered groups and points dictionaries if needed. Repeating groups are 248 | represented as a list of groups. 249 | 250 | Get the groups present in the model 705 on the device: :: 251 | 252 | >>> d.DERVoltVar[0].groups 253 | OrderedDict([('Crv', [<__main__.SunSpecModbusClientGroup object at 0x000001FD7A58EFA0>, 254 | <__main__.SunSpecModbusClientGroup object at 0x000001FD7A58EF40>, 255 | <__main__.SunSpecModbusClientGroup object at 0x000001FD7A58EEE0>])]) 256 | 257 | Get the points present in the model 705 on the device: :: 258 | 259 | >>> d.DERVoltVar[0].points 260 | OrderedDict([('ID', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C2E0>), 261 | ('L', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C2B0>), 262 | ('Ena', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C280>), 263 | ('CrvSt', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C250>), 264 | ('AdptCrvReq', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C220>), 265 | ('AdptCrvRslt', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C0A0>), 266 | ('NPt', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C0D0>), 267 | ('NCrv', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C100>), 268 | ('RvrtTms', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C130>), 269 | ('RvrtRem', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C160>), 270 | ('RvrtCrv', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C190>), 271 | ('V_SF', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C1C0>), 272 | ('DeptRef_SF', <__main__.SunSpecModbusClientPoint object at 0x000001FD7A59C1F0>)]) 273 | 274 | Full Example of a Device Interaction 275 | ------------------------------------ 276 | This section will go over the full steps on how to set a volt-var curve. 277 | 278 | Initialize device, and run device discovery with scan(): :: 279 | 280 | >>> d = client.SunSpecModbusClientDeviceRTU(slave_id=1, name="COM2") 281 | >>> d.scan() 282 | 283 | Confirm that model 705 (DERVoltVar) is on the device: :: 284 | 285 | >>> d.models 286 | {1: [<__main__.SunSpecModbusClientModel object at 0x000001FD7A6082B0>], 287 | 'common': [<__main__.SunSpecModbusClientModel object at 0x000001FD7A6082B0>], 288 | 705: [<__main__.SunSpecModbusClientModel object at 0x000001FD7A8B28B0>], 289 | 'DERVoltVar': [<__main__.SunSpecModbusClientModel object at 0x000001FD7A8B28B0>]} 290 | 291 | Read the volt-var model from the device to ensure the latest values: 292 | 293 | >>> vv = d.DERVoltVar[0] 294 | >>> vv.read() 295 | 296 | Display the current curve values (the first curve). Curve 1 is a read-only curve indicating the current curve settings: 297 | 298 | >>> print(vv.Crv[0]) 299 | Crv(1): 300 | ActPt: 4 301 | DeptRef: 1 302 | Pri: 1 303 | VRef: 100 304 | VRefAuto: 0 305 | VRefAutoEna: None 306 | VRefTms: 5 307 | RspTms: 0 308 | ReadOnly: 1 309 | Pt(1): 310 | V: 9200 311 | Var: 3000 312 | Pt(2): 313 | V: 9670 314 | Var: 0 315 | Pt(3): 316 | V: 10300 317 | Var: 0 318 | Pt(4): 319 | V: 10700 320 | Var: -3000 321 | 322 | Note that, by convention, SunSpec repeating elements, such as curves, are labeled with an index of 1 for the first 323 | element, but when accessing in the Python objects, the index of the first element is 0. Here we see the first curve 324 | being accessed with the 0 index but labeled as curve 1 in the output. Parentheses are used with the index of 1 to 325 | indicate it is a SunSpec 1-based index. 326 | 327 | Use the second curve to hold the new curve settings and write to the device: 328 | 329 | >>> c = vv.Crv[1] 330 | >>> c.ActPt = 4 331 | >>> c.DeptRef = 1 332 | >>> c.VRef = 100 333 | >>> c.VRefAutoEna = 0 334 | >>> c.Pt[0].V = 9300 335 | >>> c.Pt[0].Var = 2000 336 | >>> c.Pt[1].V = 9700 337 | >>> c.Pt[1].Var = 0 338 | >>> c.Pt[2].V = 10350 339 | >>> c.Pt[2].Var = 0 340 | >>> c.Pt[3].V = 10680 341 | >>> c.Pt[3].Var = -2000 342 | >>> c.write() 343 | 344 | Write point adopt curve request point to adopt the curve 2 values: 345 | 346 | >>> vv.AdptCrvReq = 2 347 | >>> vv.write() 348 | 349 | Read the adopt curve result and contents of the curves: 350 | 351 | >>> vv.read() 352 | >>> print(vv.AdptCrvRslt) 353 | 1 354 | 355 | The result indicates completed. The first curve should now contain the updated values reflecting the current active curve settings: 356 | 357 | >>> print(vv.Crv[0]) 358 | Crv(1): 359 | ActPt: 4 360 | DeptRef: 1 361 | Pri: 1 362 | VRef: 100 363 | VRefAuto: 0 364 | VRefAutoEna: None 365 | VRefTms: 5 366 | RspTms: 0 367 | ReadOnly: 1 368 | Pt(1): 369 | V: 9300 370 | Var: 2000 371 | Pt(2): 372 | V: 9700 373 | Var: 0 374 | Pt(3): 375 | V: 10350 376 | Var: 0 377 | Pt(4): 378 | V: 10680 379 | Var: -2000 380 | 381 | Check to see if the function is enabled by checking the Ena point. :: 382 | 383 | >>> print(vv.Ena.value) 384 | 0 385 | 386 | The function is disabled, set the value to 1, and write to device, in order to enable the function. :: 387 | 388 | >>> vv.Ena.value = 1 389 | >>> d.write() 390 | 391 | It is considered a best practice with Modbus to verify values written to the device by reading them back to ensure they 392 | were set properly. That step has been omitted to here to focus on the update sequence. 393 | -------------------------------------------------------------------------------- /sunspec2/file/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunspec/pysunspec2/d42ed2d1cc9647d2591a9a75caba1e1fa05c437e/sunspec2/file/__init__.py -------------------------------------------------------------------------------- /sunspec2/file/client.py: -------------------------------------------------------------------------------- 1 | import json 2 | import uuid 3 | import sunspec2.mdef as mdef 4 | import sunspec2.device as device 5 | import sunspec2.mb as mb 6 | 7 | 8 | class FileClientError(Exception): 9 | pass 10 | 11 | 12 | class FileClientPoint(device.Point): 13 | 14 | def read(self): 15 | pass 16 | 17 | def write(self): 18 | pass 19 | 20 | 21 | class FileClientGroup(device.Group): 22 | 23 | def read(self): 24 | pass 25 | 26 | def write(self): 27 | pass 28 | 29 | 30 | class FileClientModel(FileClientGroup): 31 | def __init__(self, model_id=None, model_addr=0, model_len=0, model_def=None, data=None, 32 | group_class=FileClientGroup, point_class=FileClientPoint): 33 | self.model_id = model_id 34 | self.model_addr = model_addr 35 | if model_len is None: 36 | self.model_len = 0 37 | else: 38 | self.model_len = model_len 39 | self.model_def = model_def 40 | self.error_info = '' 41 | self.mid = None 42 | self.device = None 43 | self.model = self 44 | 45 | gdef = None 46 | try: 47 | if self.model_def is None and model_id is not None: 48 | self.model_def = device.get_model_def(model_id) 49 | if self.model_def is not None: 50 | gdef = self.model_def.get(mdef.GROUP) 51 | except Exception as e: 52 | self.add_error(str(e)) 53 | 54 | FileClientGroup.__init__(self, gdef=gdef, model=self, model_offset=0, group_len=self.model_len, data=data, 55 | data_offset=0, group_class=group_class, point_class=point_class) 56 | 57 | def add_error(self, error_info): 58 | self.error_info = '%s%s\n' % (self.error_info, error_info) 59 | 60 | 61 | class FileClientDevice(device.Device): 62 | def __init__(self, filename=None, addr=40002, model_class=FileClientModel): 63 | device.Device.__init__(self, model_class=model_class) 64 | self.did = str(uuid.uuid4()) 65 | self.filename = filename 66 | self.addr = addr 67 | 68 | def scan(self, data=None): 69 | try: 70 | if self.filename: 71 | f = open(self.filename) 72 | data = json.load(f) 73 | 74 | mid = 0 75 | addr = self.addr 76 | for m in data.get('models'): 77 | model_id = m.get('ID') 78 | model_len = m.get('L') 79 | if model_id != mb.SUNS_END_MODEL_ID: 80 | model = self.model_class(model_id=model_id, model_addr=addr, model_len=model_len, 81 | model_def=None, data=m) 82 | model.mid = '%s_%s' % (self.did, mid) 83 | mid += 1 84 | self.add_model(model) 85 | addr += model.len 86 | except Exception as e: 87 | raise FileClientError(str(e)) 88 | 89 | def read(self): 90 | return '' 91 | 92 | def write(self): 93 | return 94 | 95 | def close(self): 96 | return 97 | 98 | 99 | class FileClient(FileClientDevice): 100 | pass 101 | -------------------------------------------------------------------------------- /sunspec2/mb.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2020 SunSpec Alliance 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a 5 | copy of this software and associated documentation files (the "Software"), 6 | to deal in the Software without restriction, including without limitation 7 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 8 | and/or sell copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included 12 | in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 17 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 19 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 20 | IN THE SOFTWARE. 21 | """ 22 | 23 | import struct 24 | import base64 25 | import collections 26 | 27 | import sunspec2.mdef as mdef 28 | 29 | SUNS_BASE_ADDR_DEFAULT = 40000 30 | SUNS_SUNS_LEN = 2 31 | 32 | SUNS_UNIMPL_INT16 = -32768 33 | SUNS_UNIMPL_UINT16 = 0xffff 34 | SUNS_UNIMPL_ACC16 = 0 35 | SUNS_UNIMPL_ENUM16 = 0xffff 36 | SUNS_UNIMPL_BITFIELD16 = 0xffff 37 | SUNS_UNIMPL_INT32 = -2147483648 38 | SUNS_UNIMPL_UINT32 = 0xffffffff 39 | SUNS_UNIMPL_ACC32 = 0 40 | SUNS_UNIMPL_ENUM32 = 0xffffffff 41 | SUNS_UNIMPL_BITFIELD32 = 0xffffffff 42 | SUNS_UNIMPL_IPADDR = 0 43 | SUNS_UNIMPL_INT64 = -9223372036854775808 44 | SUNS_UNIMPL_UINT64 = 0xffffffffffffffff 45 | SUNS_UNIMPL_ACC64 = 0 46 | SUNS_UNIMPL_IPV6ADDR = 0 47 | SUNS_UNIMPL_FLOAT32 = 0x7fc00000 48 | SUNS_UNIMPL_FLOAT64 = 0x7ff8000000000000 49 | SUNS_UNIMPL_STRING = '\0' 50 | SUNS_UNIMPL_SUNSSF = -32768 51 | SUNS_UNIMPL_EUI48 = 'FF:FF:FF:FF:FF:FF' 52 | SUNS_UNIMPL_PAD = 0 53 | 54 | SUNS_BLOCK_FIXED = 'fixed' 55 | SUNS_BLOCK_REPEATING = 'repeating' 56 | 57 | SUNS_END_MODEL_ID = 0xffff 58 | 59 | unimpl_value = { 60 | mdef.TYPE_INT16: SUNS_UNIMPL_INT16, 61 | mdef.TYPE_UINT16: SUNS_UNIMPL_UINT16, 62 | mdef.TYPE_ACC16: SUNS_UNIMPL_ACC16, 63 | mdef.TYPE_ENUM16: SUNS_UNIMPL_ENUM16, 64 | mdef.TYPE_BITFIELD16: SUNS_UNIMPL_BITFIELD16, 65 | mdef.TYPE_INT32: SUNS_UNIMPL_INT32, 66 | mdef.TYPE_UINT32: SUNS_UNIMPL_UINT32, 67 | mdef.TYPE_ACC32: SUNS_UNIMPL_ACC32, 68 | mdef.TYPE_ENUM32: SUNS_UNIMPL_ENUM32, 69 | mdef.TYPE_BITFIELD32: SUNS_UNIMPL_BITFIELD32, 70 | mdef.TYPE_IPADDR: SUNS_UNIMPL_IPADDR, 71 | mdef.TYPE_INT64: SUNS_UNIMPL_INT64, 72 | mdef.TYPE_UINT64: SUNS_UNIMPL_UINT64, 73 | mdef.TYPE_ACC64: SUNS_UNIMPL_ACC64, 74 | mdef.TYPE_IPV6ADDR: SUNS_UNIMPL_IPV6ADDR, 75 | mdef.TYPE_FLOAT32: SUNS_UNIMPL_FLOAT32, 76 | mdef.TYPE_STRING: SUNS_UNIMPL_STRING, 77 | mdef.TYPE_SUNSSF: SUNS_UNIMPL_SUNSSF, 78 | mdef.TYPE_EUI48: SUNS_UNIMPL_EUI48, 79 | mdef.TYPE_PAD: SUNS_UNIMPL_PAD 80 | } 81 | 82 | 83 | def create_unimpl_value(vtype, len=None): 84 | value = unimpl_value.get(vtype) 85 | if vtype is None: 86 | raise ValueError('Unknown SunSpec value type: %s' % vtype) 87 | if vtype == mdef.TYPE_STRING: 88 | if len is not None: 89 | return b'\0' * len 90 | else: 91 | raise ValueError('Unimplemented value creation for string requires a length') 92 | elif vtype == mdef.TYPE_IPV6ADDR: 93 | return b'\0' * 16 94 | return point_type_info[vtype][3](value) 95 | 96 | 97 | class SunSpecError(Exception): 98 | pass 99 | 100 | 101 | """ 102 | Functions to pack and unpack data string values 103 | """ 104 | 105 | 106 | def data_to_s16(data): 107 | s16 = struct.unpack('>h', data[:2]) 108 | return s16[0] 109 | 110 | 111 | def data_to_u16(data): 112 | u16 = struct.unpack('>H', data[:2]) 113 | return u16[0] 114 | 115 | 116 | def data_to_s32(data): 117 | s32 = struct.unpack('>l', data[:4]) 118 | return s32[0] 119 | 120 | 121 | def data_to_u32(data): 122 | u32 = struct.unpack('>L', data[:4]) 123 | return u32[0] 124 | 125 | 126 | def data_to_s64(data): 127 | s64 = struct.unpack('>q', data[:8]) 128 | return s64[0] 129 | 130 | 131 | def data_to_u64(data): 132 | u64 = struct.unpack('>Q', data[:8]) 133 | return u64[0] 134 | 135 | 136 | def data_to_ipv6addr(data): 137 | value = False 138 | for i in data: 139 | if i != 0: 140 | value = True 141 | break 142 | if value and len(data) == 16: 143 | return '%02X%02X%02X%02X:%02X%02X%02X%02X:%02X%02X%02X%02X:%02X%02X%02X%02X' % ( 144 | data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7], 145 | data[8], data[9], data[10], data[11], data[12], data[13], data[14], data[15]) 146 | 147 | 148 | def data_to_eui48(data): 149 | value = False 150 | for i in data: 151 | if i != 0: 152 | value = True 153 | break 154 | if value and len(data) == 8: 155 | return '%02X:%02X:%02X:%02X:%02X:%02X' % ( 156 | data[2], data[3], data[4], data[5], data[6], data[7]) 157 | 158 | 159 | def data_to_f32(data): 160 | f = struct.unpack('>f', data[:4]) 161 | if str(f[0]) != str(float('nan')): 162 | return f[0] 163 | 164 | 165 | def data_to_f64(data): 166 | d = struct.unpack('>d', data[:8]) 167 | if str(d[0]) != str(float('nan')): 168 | return d[0] 169 | 170 | 171 | def data_to_str(data): 172 | data = str(data, 'utf-8') 173 | 174 | if len(data) > 1: 175 | data = data[0] + data[1:].rstrip('\0') 176 | return data 177 | 178 | 179 | def s16_to_data(s16, len=None): 180 | return struct.pack('>h', s16) 181 | 182 | 183 | def u16_to_data(u16, len=None): 184 | return struct.pack('>H', u16) 185 | 186 | 187 | def s32_to_data(s32, len=None): 188 | return struct.pack('>l', s32) 189 | 190 | 191 | def u32_to_data(u32, len=None): 192 | return struct.pack('>L', u32) 193 | 194 | 195 | def s64_to_data(s64, len=None): 196 | return struct.pack('>q', s64) 197 | 198 | 199 | def u64_to_data(u64, len=None): 200 | return struct.pack('>Q', u64) 201 | 202 | 203 | def ipv6addr_to_data(addr, slen=None): 204 | s = base64.b16decode(addr.replace(':', '')) 205 | if slen is None: 206 | slen = len(s) 207 | return struct.pack(str(slen) + 's', s) 208 | 209 | 210 | def f32_to_data(f, len=None): 211 | return struct.pack('>f', f) 212 | 213 | 214 | def f64_to_data(f, len=None): 215 | return struct.pack('>d', f) 216 | 217 | 218 | def str_to_data(s, slen=None): 219 | if slen is None: 220 | slen = len(s) 221 | s = bytes(s, 'utf-8') 222 | return struct.pack(str(slen) + 's', s) 223 | 224 | 225 | def eui48_to_data(eui48): 226 | return (b'\x00\x00' + base64.b16decode(eui48.replace(':', ''))) 227 | 228 | 229 | def is_impl_int16(value): 230 | return not value == SUNS_UNIMPL_INT16 231 | 232 | 233 | def is_impl_uint16(value): 234 | return not value == SUNS_UNIMPL_UINT16 235 | 236 | 237 | def is_impl_acc16(value): 238 | return not value == SUNS_UNIMPL_ACC16 239 | 240 | 241 | def is_impl_enum16(value): 242 | return not value == SUNS_UNIMPL_ENUM16 243 | 244 | 245 | def is_impl_bitfield16(value): 246 | return not value == SUNS_UNIMPL_BITFIELD16 247 | 248 | 249 | def is_impl_int32(value): 250 | return not value == SUNS_UNIMPL_INT32 251 | 252 | 253 | def is_impl_uint32(value): 254 | return not value == SUNS_UNIMPL_UINT32 255 | 256 | 257 | def is_impl_acc32(value): 258 | return not value == SUNS_UNIMPL_ACC32 259 | 260 | 261 | def is_impl_enum32(value): 262 | return not value == SUNS_UNIMPL_ENUM32 263 | 264 | 265 | def is_impl_bitfield32(value): 266 | return not value == SUNS_UNIMPL_BITFIELD32 267 | 268 | 269 | def is_impl_ipaddr(value): 270 | return not value == SUNS_UNIMPL_IPADDR 271 | 272 | 273 | def is_impl_int64(value): 274 | return not value == SUNS_UNIMPL_INT64 275 | 276 | 277 | def is_impl_uint64(value): 278 | return not value == SUNS_UNIMPL_UINT64 279 | 280 | 281 | def is_impl_acc64(value): 282 | return not value == SUNS_UNIMPL_ACC64 283 | 284 | 285 | def is_impl_ipv6addr(value): 286 | if value: 287 | return not value[0] == '\0' 288 | return False 289 | 290 | 291 | def is_impl_float32(value): 292 | return value is not None and value != SUNS_UNIMPL_FLOAT32 293 | 294 | 295 | def is_impl_float64(value): 296 | return value is not None and value != SUNS_UNIMPL_FLOAT64 297 | 298 | 299 | def is_impl_string(value): 300 | if value: 301 | return not value[0] == '\0' 302 | return False 303 | 304 | 305 | def is_impl_sunssf(value): 306 | return not value == SUNS_UNIMPL_SUNSSF 307 | 308 | 309 | def is_impl_eui48(value): 310 | return not value == SUNS_UNIMPL_EUI48 311 | 312 | 313 | def is_impl_pad(value): 314 | return True 315 | 316 | 317 | PointInfo = collections.namedtuple('PointInfo', 'len is_impl data_to to_data to_type default') 318 | point_type_info = { 319 | mdef.TYPE_INT16: PointInfo(1, is_impl_int16, data_to_s16, s16_to_data, mdef.to_int, 0), 320 | mdef.TYPE_UINT16: PointInfo(1, is_impl_uint16, data_to_u16, u16_to_data, mdef.to_int, 0), 321 | mdef.TYPE_COUNT: PointInfo(1, is_impl_uint16, data_to_u16, u16_to_data, mdef.to_int, 0), 322 | mdef.TYPE_ACC16: PointInfo(1, is_impl_acc16, data_to_u16, u16_to_data, mdef.to_int, 0), 323 | mdef.TYPE_ENUM16: PointInfo(1, is_impl_enum16, data_to_u16, u16_to_data, mdef.to_int, 0), 324 | mdef.TYPE_BITFIELD16: PointInfo(1, is_impl_bitfield16, data_to_u16, u16_to_data, mdef.to_int, 0), 325 | mdef.TYPE_PAD: PointInfo(1, is_impl_pad, data_to_u16, u16_to_data, mdef.to_int, 0), 326 | mdef.TYPE_INT32: PointInfo(2, is_impl_int32, data_to_s32, s32_to_data, mdef.to_int, 0), 327 | mdef.TYPE_UINT32: PointInfo(2, is_impl_uint32, data_to_u32, u32_to_data, mdef.to_int, 0), 328 | mdef.TYPE_ACC32: PointInfo(2, is_impl_acc32, data_to_u32, u32_to_data, mdef.to_int, 0), 329 | mdef.TYPE_ENUM32: PointInfo(2, is_impl_enum32, data_to_u32, u32_to_data, mdef.to_int, 0), 330 | mdef.TYPE_BITFIELD32: PointInfo(2, is_impl_bitfield32, data_to_u32, u32_to_data, mdef.to_int, 0), 331 | mdef.TYPE_IPADDR: PointInfo(2, is_impl_ipaddr, data_to_u32, u32_to_data, mdef.to_int, 0), 332 | mdef.TYPE_INT64: PointInfo(4, is_impl_int64, data_to_s64, s64_to_data, mdef.to_int, 0), 333 | mdef.TYPE_UINT64: PointInfo(4, is_impl_uint64, data_to_u64, u64_to_data, mdef.to_int, 0), 334 | mdef.TYPE_ACC64: PointInfo(4, is_impl_acc64, data_to_u64, u64_to_data, mdef.to_int, 0), 335 | mdef.TYPE_IPV6ADDR: PointInfo(8, is_impl_ipv6addr, data_to_ipv6addr, ipv6addr_to_data, mdef.to_str, 0), 336 | mdef.TYPE_FLOAT32: PointInfo(2, is_impl_float32, data_to_f32, f32_to_data, mdef.to_float, 0), 337 | mdef.TYPE_FLOAT64: PointInfo(4, is_impl_float64, data_to_f64, f64_to_data, mdef.to_float, 0), 338 | mdef.TYPE_STRING: PointInfo(None, is_impl_string, data_to_str, str_to_data, mdef.to_str, ''), 339 | mdef.TYPE_SUNSSF: PointInfo(1, is_impl_sunssf, data_to_s16, s16_to_data, mdef.to_int, 0), 340 | mdef.TYPE_EUI48: PointInfo(4, is_impl_eui48, data_to_eui48, eui48_to_data, mdef.to_str, 0) 341 | } 342 | -------------------------------------------------------------------------------- /sunspec2/mdef.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | ''' 5 | 1. JSON is used for the native encoding of information model definitions. 6 | 2. JSON can be used to represent the values associated with information model points at a specific point in time. 7 | 8 | Python support for information models: 9 | - Python model support is based on dictionaries and their afinity with JSON objects. 10 | 11 | Model instance notes: 12 | 13 | - If a model contains repeating groups, the group counts must be known to fully initialized the model. If fields are 14 | accessed that depend on group counts that have not been initialized, a ModelError exception is generated. 15 | - Points that have not been read or written contain a value of None. 16 | - If a point that can not be changed from the initialized value is changed, a ModelError exception is generated. 17 | 18 | A model definition is represented as a dictionary using the constants defined in this file as the entry keys. 19 | 20 | A model definition is required to have a single top level group. 21 | 22 | A model dict 23 | - must contain: 'id' and 'group' 24 | 25 | A group dict 26 | - must contain: 'name', 'type', 'points' 27 | - may contain: 'count', 'groups', 'label', 'description', 'notes', 'comments' 28 | 29 | A point dict 30 | - must contain: 'name', 'type' 31 | - may contain: 'count', 'size', 'sf', 'units', 'mandatory', 'access', 'symbols', 'label', 'description', 'notes', 32 | 'comments', 'standards' 33 | 34 | A symbol dict 35 | - must contain: 'name', 'value' 36 | - may contain: 'label', 'description', 'notes', 'comments' 37 | 38 | Example: 39 | model_def = { 40 | 'id': 123, 41 | 'group': { 42 | 'id': model_name, 43 | 'groups': [], 44 | 'points': [] 45 | } 46 | ''' 47 | 48 | DEVICE = 'device' # device (device dict) ### currently not in the spec 49 | MODEL = 'model' # model (model dict) ### currently not in the spec 50 | GROUP = 'group' # top level model group (group dict) 51 | GROUPS = 'groups' # groups in group (list of group dicts) 52 | POINTS = 'points' # points in group (list of point dicts) 53 | 54 | ID = 'id' # id (int or str) 55 | NAME = 'name' # name (str) 56 | VALUE = 'value' # value (int, float, str) 57 | COUNT = 'count' # instance count (int or str) 58 | 59 | TYPE = 'type' # point type (str of TYPE_XXX) 60 | MANDATORY = 'mandatory' # point mandatory (str of MANDATORY_XXX) 61 | ACCESS = 'access' # point access (str of ACCESS_XXX) 62 | STATIC = 'static' # point value is static (str of STATIC_XXX) 63 | SF = 'sf' # point scale factor (int) 64 | UNITS = 'units' # point units (str) 65 | SIZE = 'size' # point string length (int) 66 | 67 | LABEL = 'label' # label (str) 68 | DESCRIPTION = 'desc' # description (str) 69 | NOTES = 'notes' # notes (str) 70 | DETAIL = 'detail' # detailed description (str) 71 | SYMBOLS = 'symbols' # symbols (list of symbol dicts) 72 | COMMENTS = 'comments' # comments (list of str) 73 | STANDARDS = 'standards' # standards (list of str) 74 | 75 | TYPE_GROUP = 'group' 76 | TYPE_SYNC_GROUP = 'sync' 77 | 78 | TYPE_INT16 = 'int16' 79 | TYPE_UINT16 = 'uint16' 80 | TYPE_COUNT = 'count' 81 | TYPE_ACC16 = 'acc16' 82 | TYPE_ENUM16 = 'enum16' 83 | TYPE_BITFIELD16 = 'bitfield16' 84 | TYPE_PAD = 'pad' 85 | TYPE_INT32 = 'int32' 86 | TYPE_UINT32 = 'uint32' 87 | TYPE_ACC32 = 'acc32' 88 | TYPE_ENUM32 = 'enum32' 89 | TYPE_BITFIELD32 = 'bitfield32' 90 | TYPE_IPADDR = 'ipaddr' 91 | TYPE_INT64 = 'int64' 92 | TYPE_UINT64 = 'uint64' 93 | TYPE_ACC64 = 'acc64' 94 | TYPE_IPV6ADDR = 'ipv6addr' 95 | TYPE_FLOAT32 = 'float32' 96 | TYPE_FLOAT64 = 'float64' 97 | TYPE_STRING = 'string' 98 | TYPE_SUNSSF = 'sunssf' 99 | TYPE_EUI48 = 'eui48' 100 | 101 | ACCESS_R = 'R' 102 | ACCESS_RW = 'RW' 103 | 104 | MANDATORY_FALSE = 'O' 105 | MANDATORY_TRUE = 'M' 106 | 107 | STATIC_FALSE = 'D' 108 | STATIC_TRUE = 'S' 109 | 110 | MODEL_ID_POINT_NAME = 'ID' 111 | MODEL_LEN_POINT_NAME = 'L' 112 | 113 | END_MODEL_ID = 65535 114 | 115 | MODEL_DEF_EXT = '.json' 116 | 117 | 118 | def to_int(x): 119 | try: 120 | return int(x, 0) 121 | except TypeError: 122 | return int(x) 123 | 124 | 125 | def to_str(s): 126 | return str(s) 127 | 128 | 129 | def to_float(f): 130 | try: 131 | return float(f) 132 | except ValueError: 133 | return None 134 | 135 | 136 | # valid model attributes 137 | model_attr = {ID: {'type': int, 'mand': True}, GROUP: {'mand': True}, COMMENTS: {}} 138 | 139 | # valid group attributes 140 | group_attr = {NAME: {'type': str, 'mand': True}, COUNT: {'type': [int, str]}, TYPE: {'mand': True}, 141 | GROUPS: {}, POINTS: {'mand': True}, LABEL: {'type': str}, 142 | DESCRIPTION: {'type': str}, NOTES: {'type': str}, COMMENTS: {}, DETAIL: {'type': str}} 143 | 144 | # valid point attributes 145 | point_attr = {NAME: {'type': str, 'mand': True}, COUNT: {'type': int}, VALUE: {}, TYPE: {'mand': True}, 146 | SIZE: {'type': int}, SF: {}, UNITS: {'type': str}, 147 | ACCESS: {'type': str, 'values': ['R', 'RW'], 'default': 'R'}, 148 | MANDATORY: {'type': str, 'values': ['O', 'M'], 'default': 'O'}, 149 | STATIC: {'type': str, 'values': ['D', 'S'], 'default': 'D'}, 150 | LABEL: {'type': str}, DESCRIPTION: {'type': str}, NOTES: {'type': str}, SYMBOLS: {}, COMMENTS: {}, 151 | DETAIL: {'type': str}, STANDARDS: {'type': list}} 152 | 153 | # valid symbol attributes 154 | symbol_attr = {NAME: {'type': str, 'mand': True}, VALUE: {'mand': True}, LABEL: {'type': str}, 155 | DESCRIPTION: {'type': str}, NOTES: {'type': str}, COMMENTS: {}, DETAIL: {'type': str}} 156 | 157 | group_types = [TYPE_GROUP, TYPE_SYNC_GROUP] 158 | 159 | point_type_info = { 160 | TYPE_INT16: {'len': 1, 'to_type': to_int, 'default': 0}, 161 | TYPE_UINT16: {'len': 1, 'to_type': to_int, 'default': 0}, 162 | TYPE_COUNT: {'len': 1, 'to_type': to_int, 'default': 0}, 163 | TYPE_ACC16: {'len': 1, 'to_type': to_int, 'default': 0}, 164 | TYPE_ENUM16: {'len': 1, 'to_type': to_int, 'default': 0}, 165 | TYPE_BITFIELD16: {'len': 1, 'to_type': to_int, 'default': 0}, 166 | TYPE_PAD: {'len': 1, 'to_type': to_int, 'default': 0}, 167 | TYPE_INT32: {'len': 2, 'to_type': to_int, 'default': 0}, 168 | TYPE_UINT32: {'len': 2, 'to_type': to_int, 'default': 0}, 169 | TYPE_ACC32: {'len': 2, 'to_type': to_int, 'default': 0}, 170 | TYPE_ENUM32: {'len': 2, 'to_type': to_int, 'default': 0}, 171 | TYPE_BITFIELD32: {'len': 2, 'to_type': to_int, 'default': 0}, 172 | TYPE_IPADDR: {'len': 2, 'to_type': to_int, 'default': 0}, 173 | TYPE_INT64: {'len': 4, 'to_type': to_int, 'default': 0}, 174 | TYPE_UINT64: {'len': 4, 'to_type': to_int, 'default': 0}, 175 | TYPE_ACC64: {'len': 4, 'to_type': to_int, 'default': 0}, 176 | TYPE_IPV6ADDR: {'len': 8, 'to_type': to_str, 'default': 0}, 177 | TYPE_FLOAT32: {'len': 2, 'to_type': to_float, 'default': 0}, 178 | TYPE_FLOAT64: {'len': 4, 'to_type': to_float, 'default': 0}, 179 | TYPE_STRING: {'len': None, 'to_type': to_str, 'default': ''}, 180 | TYPE_SUNSSF: {'len': 1, 'to_type': to_int, 'default': 0}, 181 | TYPE_EUI48: {'len': 4, 'to_type': to_str, 'default': 0} 182 | } 183 | 184 | 185 | class ModelDefinitionError(Exception): 186 | pass 187 | 188 | 189 | def to_number_type(n): 190 | if isinstance(n, str): 191 | try: 192 | n = int(n) 193 | except ValueError: 194 | try: 195 | n = float(n) 196 | except ValueError: 197 | pass 198 | return n 199 | 200 | 201 | def validate_find_point(group, pname): 202 | points = group.get(POINTS, []) 203 | for p in points: 204 | pxname = p.get(NAME) 205 | if pxname: 206 | if p[NAME] == pname: 207 | return p 208 | 209 | 210 | def validate_attrs(element, attrs, result=''): 211 | # check for unexpected attributes 212 | for k in element: 213 | if k not in attrs: 214 | result += 'Unexpected model definition attribute: %s in %s\n' % (k, element.get(NAME)) 215 | # check for missing attributes 216 | for k, a in attrs.items(): 217 | if k in element and element[k] is not None: 218 | # check type if specified 219 | t = a.get('type') 220 | if isinstance(t, list): 221 | if t and type(element[k]) not in t: 222 | result += 'Unexpected type for model attribute %s, expected %s, found %s\n' % \ 223 | (k, t, type(element[k])) 224 | else: 225 | if t and not isinstance(element[k], t): 226 | result += ('Unexpected type for model attribute %s, expected %s, found %s\n' % 227 | (k, t, type(element[k]))) 228 | values = a.get('values') 229 | if values and element[k] not in values: 230 | result += 'Unexpected value for model attribute %s: %s\n' % (k, element[k]) 231 | elif a.get('mand', False): 232 | result += 'Mandatory attribute missing from model definition: %s\n' % k 233 | return result 234 | 235 | 236 | def validate_group_point_dup(group, result=''): 237 | groups = group.get(GROUPS, []) 238 | for g in groups: 239 | gname = g.get(NAME) 240 | if gname: 241 | count = 0 242 | for gx in groups: 243 | gxname = gx.get(NAME) 244 | if gxname: 245 | if gx[NAME] == gname: 246 | count += 1 247 | if count > 1: 248 | result += 'Duplicate group id %s in group %s' % (gname, group[NAME]) 249 | if validate_find_point(group, gname): 250 | result += 'Duplicate group and point id %s in group %s' % (gname, group[NAME]) 251 | else: 252 | result += 'Mandatory %s attribute missing in group definition element\n' % (NAME) 253 | points = group.get(POINTS, []) 254 | for p in points: 255 | pname = p.get(NAME) 256 | if pname: 257 | count = 0 258 | for px in points: 259 | pxname = px.get(NAME) 260 | if pxname: 261 | if px[NAME] == pname: 262 | count += 1 263 | if count > 1: 264 | result += 'Duplicate point id %s in group %s' % (pname, group[NAME]) 265 | else: 266 | result += 'Mandatory attribute missing in point definition element: %s\n' % (NAME) 267 | return result 268 | 269 | 270 | def validate_symbols(symbols, model_group, result=''): 271 | for symbol in symbols: 272 | result = validate_attrs(symbol, model_group, result) 273 | return result 274 | 275 | 276 | def validate_sf(point, sf, sf_groups, result=''): 277 | found = False 278 | if isinstance(sf, str): 279 | for group in sf_groups: 280 | p = validate_find_point(group, sf) 281 | if p: 282 | found = True 283 | if p[TYPE] != TYPE_SUNSSF: 284 | result += 'Scale factor %s for point %s is not scale factor type: %s\n' % (sf, point[NAME], p[TYPE]) 285 | break 286 | if not found: 287 | result += 'Scale factor %s for point %s not found\n' % (sf, point[NAME]) 288 | elif isinstance(sf, int): 289 | if sf < - 10 or sf > 10: 290 | result += 'Scale factor %s for point %s out of range\n' % (sf, point[NAME]) 291 | else: 292 | result += 'Scale factor %s for point %s has invalid type %s\n' % (sf, point[NAME], type(sf)) 293 | return result 294 | 295 | 296 | def validate_point_def(point, model_group, group, result=''): 297 | # validate general point attributes 298 | result = validate_attrs(point, point_attr, result) 299 | # validate point type 300 | ptype = point.get(TYPE) 301 | if ptype not in point_type_info: 302 | result += 'Unknown point type %s for point %s\n' % (ptype, point[NAME]) 303 | # validate scale factor, if present 304 | sf = point.get(SF) 305 | if sf: 306 | result = validate_sf(point, sf, [model_group, group], result) 307 | # validate symbols 308 | symbols = point.get(SYMBOLS, []) 309 | result = validate_symbols(symbols, symbol_attr, result) 310 | # check for duplicate symbols 311 | for s in symbols: 312 | sname = s.get(NAME) 313 | if sname: 314 | count = 0 315 | for sx in symbols: 316 | if sx[NAME] == sname: 317 | count += 1 318 | if count > 1: 319 | result += 'Duplicate symbol id %s in point %s\n' % (sname, point[NAME]) 320 | else: 321 | result += 'Mandatory attribute missing in symbol definition element: %s\n' % (NAME) 322 | return result 323 | 324 | 325 | def validate_group_def(group, model_group, result=''): 326 | # validate general group attributes 327 | result = validate_attrs(group, group_attr, result) 328 | # validate points 329 | points = group.get(POINTS, []) 330 | for p in points: 331 | result = validate_point_def(p, model_group, group, result) 332 | # validate groups 333 | groups = group.get(GROUPS, []) 334 | for g in groups: 335 | result = validate_group_def(g, model_group, result) 336 | # check for group and point duplicates 337 | result = validate_group_point_dup(group, result) 338 | return result 339 | 340 | 341 | def validate_model_group_def(model_def, group, result=''): 342 | # must contain ID and length points 343 | points = group.get(POINTS) 344 | if points: 345 | if len(points) >= 2: 346 | pname = points[0].get(NAME) 347 | if pname != MODEL_ID_POINT_NAME: 348 | result += "First point in top-level group must be %s, found: %s\n" % (MODEL_ID_POINT_NAME, pname) 349 | if points[0].get(VALUE) != model_def.get(ID): 350 | result += 'Model ID does not match top-level group ID: %s %s %s %s\n' % ( 351 | model_def.get(ID), type(model_def.get(ID)), points[0].get(VALUE), type(points[0].get(VALUE))) 352 | pname = points[1].get(NAME) 353 | if pname != MODEL_LEN_POINT_NAME: 354 | result += "Second point in top-level group must be %s, found: %s\n" % (MODEL_LEN_POINT_NAME, pname) 355 | else: 356 | result += "Top-level group must contain at least two points: %s and %s\n" % (MODEL_ID_POINT_NAME, 357 | MODEL_LEN_POINT_NAME) 358 | else: 359 | result += 'Top-level group missing point definitions\n' 360 | # perform normal group validation 361 | result = validate_group_def(group, group, result) 362 | return result 363 | 364 | 365 | def validate_model_def(model_def, result=''): 366 | result = validate_attrs(model_def, model_attr, result) 367 | group = model_def.get(GROUP) 368 | result = validate_model_group_def(model_def, group, result) 369 | return result 370 | 371 | 372 | def from_json_str(s): 373 | return json.loads(s) 374 | 375 | 376 | def from_json_file(filename): 377 | f = open(filename) 378 | model_def = json.load(f) 379 | f.close() 380 | return model_def 381 | 382 | 383 | def to_json_str(model_def, indent=4): 384 | return json.dumps(model_def, indent=indent, sort_keys=True) 385 | 386 | 387 | def to_json_filename(model_id): 388 | return 'model_%s%s' % (model_id, MODEL_DEF_EXT) 389 | 390 | 391 | def model_filename_to_id(filename): 392 | f = filename 393 | if '.' in f: 394 | f = os.path.splitext(f)[0] 395 | try: 396 | mid = int(f.rsplit('_', 1)[1]) 397 | except ValueError: 398 | raise ModelDefinitionError('Error extracting model id from filename') 399 | 400 | return mid 401 | 402 | 403 | def to_json_file(model_def, filename=None, filedir=None, indent=4): 404 | if filename is None: 405 | filename = to_json_filename(model_def[ID]) 406 | if filedir is not None: 407 | filename = os.path.join(filedir, filename) 408 | f = open(filename, 'w') 409 | json.dump(model_def, f, indent=indent, sort_keys=True) 410 | 411 | 412 | def get_group_len_points(group_def, points=None): 413 | if points is None: 414 | points = [] 415 | groups = group_def.get(GROUPS) 416 | if groups: 417 | for g in groups: 418 | count = g.get(COUNT) 419 | if count: 420 | try: 421 | count = int(count) 422 | except: 423 | if count not in points: 424 | points.append(count) 425 | points = get_group_len_points(g, points) 426 | return points 427 | 428 | 429 | def get_group_len_points_index(group_def): 430 | index = pindex = 0 431 | if group_def: 432 | len_points = get_group_len_points(group_def) 433 | if len_points: 434 | points = group_def.get(POINTS, []) 435 | for p in points: 436 | name = p.get(NAME) 437 | plen = point_type_info.get(p.get(TYPE)).get('len') 438 | if plen is None: 439 | plen = point_type_info.get(p.get(SIZE)) 440 | if not plen: 441 | raise ModelDefinitionError('Unable to get size of point %s' % name) 442 | pindex += plen 443 | if name in len_points: 444 | index = pindex 445 | len_points.remove(name) 446 | if not len_points: 447 | break 448 | if len_points: 449 | raise ModelDefinitionError('Expected points not found in group definition: %s' % len_points) 450 | return index 451 | 452 | 453 | if __name__ == "__main__": 454 | pass 455 | -------------------------------------------------------------------------------- /sunspec2/modbus/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunspec/pysunspec2/d42ed2d1cc9647d2591a9a75caba1e1fa05c437e/sunspec2/modbus/__init__.py -------------------------------------------------------------------------------- /sunspec2/modbus/client.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2020 SunSpec Alliance 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a 5 | copy of this software and associated documentation files (the "Software"), 6 | to deal in the Software without restriction, including without limitation 7 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 8 | and/or sell copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included 12 | in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 17 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 19 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 20 | IN THE SOFTWARE. 21 | """ 22 | 23 | import time 24 | import uuid 25 | from sunspec2 import mdef, device, mb 26 | import sunspec2.modbus.modbus as modbus_client 27 | 28 | TEST_NAME = 'test_name' 29 | 30 | modbus_rtu_clients = {} 31 | 32 | 33 | class SunSpecModbusClientError(Exception): 34 | pass 35 | 36 | 37 | class SunSpecModbusValueError(Exception): 38 | pass 39 | 40 | 41 | class SunSpecModbusClientTimeout(SunSpecModbusClientError): 42 | pass 43 | 44 | 45 | class SunSpecModbusClientException(SunSpecModbusClientError): 46 | pass 47 | 48 | 49 | class SunSpecModbusClientPoint(device.Point): 50 | 51 | def read(self): 52 | data = self.model.device.read(self.model.model_addr + self.offset, self.len) 53 | self.set_mb(data=data, dirty=False) 54 | 55 | def write(self): 56 | """Write the point to the physical device""" 57 | try: 58 | data = self.info.to_data(self.value, int(self.len) * 2) 59 | except Exception as e: 60 | raise SunSpecModbusValueError('Point value error for %s %s: %s' % (self.pdef.get(mdef.NAME), self.value, 61 | str(e))) 62 | model_addr = self.model.model_addr 63 | point_offset = self.offset 64 | addr = model_addr + point_offset 65 | self.model.device.write(addr, data) 66 | self.dirty = False 67 | 68 | 69 | class SunSpecModbusClientGroup(device.Group): 70 | 71 | def __init__(self, gdef=None, model=None, model_offset=0, group_len=0, data=None, data_offset=0, group_class=None, 72 | point_class=None, index=None): 73 | 74 | device.Group.__init__(self, gdef=gdef, model=model, model_offset=model_offset, group_len=group_len, 75 | data=data, data_offset=data_offset, group_class=group_class, point_class=point_class, 76 | index=index) 77 | 78 | def read(self, len=None): 79 | if len is None: 80 | len = self.len 81 | # check if currently connected 82 | connected = self.model.device.is_connected() 83 | if not connected: 84 | self.model.device.connect() 85 | 86 | if self.access_regions: 87 | data = bytearray() 88 | for region in self.access_regions: 89 | data += self.model.device.read(self.model.model_addr + self.offset + region[0], region[1]) 90 | data = bytes(data) 91 | else: 92 | data = self.model.device.read(self.model.model_addr + self.offset, len) 93 | self.set_mb(data=data, dirty=False) 94 | 95 | # disconnect if was not connected 96 | if not connected: 97 | self.model.device.disconnect() 98 | 99 | def write(self): 100 | start_addr = next_addr = self.model.model_addr + self.offset 101 | data = b'' 102 | start_addr, next_addr, data = self.write_points(start_addr, next_addr, data) 103 | if data: 104 | self.model.device.write(start_addr, data) 105 | 106 | def write_points(self, start_addr=None, next_addr=None, data=None): 107 | """ 108 | Write all points that have been modified since the last write operation to the physical device 109 | """ 110 | 111 | for name, point in self.points.items(): 112 | model_addr = self.model.model_addr 113 | point_offset = point.offset 114 | point_addr = model_addr + point_offset 115 | if data and (not point.dirty or point_addr != next_addr): 116 | self.model.device.write(start_addr, data) 117 | data = b'' 118 | if point.dirty: 119 | point_len = point.len 120 | try: 121 | point_data = point.info.to_data(point.value, int(point_len) * 2) 122 | except Exception as e: 123 | raise SunSpecModbusValueError('Point value error for %s %s: %s' % (name, point.value, str(e))) 124 | if not data: 125 | start_addr = point_addr 126 | next_addr = point_addr + point_len 127 | data += point_data 128 | point.dirty = False 129 | 130 | for name, group in self.groups.items(): 131 | if isinstance(group, list): 132 | for g in group: 133 | start_addr, next_addr, data = g.write_points(start_addr, next_addr, data) 134 | else: 135 | start_addr, next_addr, data = group.write_points(start_addr, next_addr, data) 136 | 137 | return start_addr, next_addr, data 138 | 139 | 140 | class SunSpecModbusClientModel(SunSpecModbusClientGroup): 141 | def __init__(self, model_id=None, model_addr=0, model_len=0, model_def=None, data=None, mb_device=None, 142 | group_class=SunSpecModbusClientGroup, point_class=SunSpecModbusClientPoint): 143 | self.model_id = model_id 144 | self.model_addr = model_addr 145 | self.model_len = model_len 146 | self.model_def = model_def 147 | self.error_info = '' 148 | self.mid = None 149 | self.device = mb_device 150 | self.model = self 151 | 152 | gdef = None 153 | try: 154 | if self.model_def is None: 155 | self.model_def = device.get_model_def(model_id) 156 | if self.model_def is not None: 157 | gdef = self.model_def.get(mdef.GROUP) 158 | except Exception as e: 159 | self.add_error(str(e)) 160 | 161 | # determine largest point index that contains a group len 162 | group_len_points_index = mdef.get_group_len_points_index(gdef) 163 | # if data len < largest point index that contains a group len, read the rest of the point data 164 | data_regs = len(data)/2 165 | remaining = group_len_points_index - data_regs 166 | if remaining > 0: 167 | points_data = self.device.read(self.model_addr + data_regs, remaining) 168 | data += points_data 169 | 170 | SunSpecModbusClientGroup.__init__(self, gdef=gdef, model=self.model, model_offset=0, group_len=self.model_len, 171 | data=data, data_offset=0, group_class=group_class, point_class=point_class) 172 | 173 | if self.model_len is not None: 174 | self.len = self.model_len 175 | 176 | if self.model_len and self.len: 177 | if self.model_len != self.len: 178 | self.add_error('Model error: Discovered length %s does not match computed length %s' % 179 | (self.model_len, self.len)) 180 | 181 | def add_error(self, error_info): 182 | self.error_info = '%s%s\n' % (self.error_info, error_info) 183 | 184 | def read(self, len=None): 185 | SunSpecModbusClientGroup.read(self, len=self.len + 2) 186 | 187 | 188 | class SunSpecModbusClientDevice(device.Device): 189 | def __init__(self, model_class=SunSpecModbusClientModel): 190 | device.Device.__init__(self, model_class=model_class) 191 | self.did = str(uuid.uuid4()) 192 | self.retry_count = 2 193 | self.base_addr_list = [40000, 0, 50000] 194 | self.base_addr = None 195 | 196 | def connect(self): 197 | pass 198 | 199 | def disconnect(self): 200 | pass 201 | 202 | def is_connected(self): 203 | return True 204 | 205 | def close(self): 206 | pass 207 | 208 | # must be overridden by Modbus protocol implementation 209 | def read(self, addr, count): 210 | return '' 211 | 212 | # must be overridden by Modbus protocol implementation 213 | def write(self, addr, data): 214 | return 215 | 216 | def scan(self, progress=None, delay=None, connect=True, full_model_read=True): 217 | """Scan all the models of the physical device and create the 218 | corresponding model objects within the device object based on the 219 | SunSpec model definitions. 220 | """ 221 | self.base_addr = None 222 | self.delete_models() 223 | 224 | data = '' 225 | error = '' 226 | connected = False 227 | 228 | if connect: 229 | self.connect() 230 | connected = True 231 | 232 | if delay is not None: 233 | time.sleep(delay) 234 | 235 | error_dict = {} 236 | if self.base_addr is None: 237 | for addr in self.base_addr_list: 238 | error_dict[addr] = '' 239 | try: 240 | data = self.read(addr, 3) 241 | if data: 242 | if data[:4] == b'SunS': 243 | self.base_addr = addr 244 | break 245 | else: 246 | error_dict[addr] = 'Device responded - not SunSpec register map' 247 | else: 248 | error_dict[addr] = 'Data time out' 249 | except SunSpecModbusClientError as e: 250 | error_dict[addr] = str(e) 251 | except modbus_client.ModbusClientTimeout as e: 252 | error_dict[addr] = str(e) 253 | except modbus_client.ModbusClientException as e: 254 | error_dict[addr] = str(e) 255 | except Exception as e: 256 | error_dict[addr] = str(e) 257 | 258 | if delay is not None: 259 | time.sleep(delay) 260 | 261 | error = 'Error scanning SunSpec base addresses. \n' 262 | for k, v in error_dict.items(): 263 | error += 'Base address %s error = %s. \n' % (k, v) 264 | 265 | if self.base_addr is not None: 266 | model_id_data = data[4:6] 267 | model_id = mb.data_to_u16(model_id_data) 268 | addr = self.base_addr + 2 269 | 270 | mid = 0 271 | while model_id != mb.SUNS_END_MODEL_ID: 272 | # read model and model len separately due to some devices not supplying 273 | # count for the end model id 274 | model_len_data = self.read(addr + 1, 1) 275 | if model_len_data and len(model_len_data) == 2: 276 | if progress is not None: 277 | cont = progress('Scanning model %s' % model_id) 278 | if not cont: 279 | raise SunSpecModbusClientError('Device scan terminated') 280 | model_len = mb.data_to_u16(model_len_data) 281 | 282 | # read model data 283 | ### model_data = self.read(addr, model_len + 2) 284 | model_data = model_id_data + model_len_data 285 | model = self.model_class(model_id=model_id, model_addr=addr, model_len=model_len, data=model_data, 286 | mb_device=self) 287 | if full_model_read and model.model_def: 288 | model.read() 289 | model.mid = '%s_%s' % (self.did, mid) 290 | mid += 1 291 | self.add_model(model) 292 | 293 | addr += model_len + 2 294 | model_id_data = self.read(addr, 1) 295 | if model_id_data and len(model_id_data) == 2: 296 | model_id = mb.data_to_u16(model_id_data) 297 | else: 298 | break 299 | else: 300 | break 301 | 302 | if delay is not None: 303 | time.sleep(delay) 304 | 305 | else: 306 | raise SunSpecModbusClientError(error) 307 | 308 | if connected: 309 | self.disconnect() 310 | 311 | 312 | class SunSpecModbusClientDeviceTCP(SunSpecModbusClientDevice): 313 | def __init__(self, slave_id=1, ipaddr='127.0.0.1', ipport=502, timeout=None, ctx=None, trace_func=None, 314 | max_count=modbus_client.REQ_COUNT_MAX, max_write_count=modbus_client.REQ_WRITE_COUNT_MAX, 315 | model_class=SunSpecModbusClientModel): 316 | SunSpecModbusClientDevice.__init__(self, model_class=model_class) 317 | self.slave_id = slave_id 318 | self.ipaddr = ipaddr 319 | self.ipport = ipport 320 | self.timeout = timeout 321 | self.ctx = ctx 322 | self.socket = None 323 | self.trace_func = trace_func 324 | self.max_count = max_count 325 | self.max_write_count = max_write_count 326 | 327 | self.client = modbus_client.ModbusClientTCP(slave_id=slave_id, ipaddr=ipaddr, ipport=ipport, timeout=timeout, 328 | ctx=ctx, trace_func=trace_func, 329 | max_count=modbus_client.REQ_COUNT_MAX, 330 | max_write_count=modbus_client.REQ_WRITE_COUNT_MAX) 331 | 332 | if self.client is None: 333 | raise SunSpecModbusClientError('No modbus tcp client set for device') 334 | 335 | def connect(self, timeout=None): 336 | self.client.connect(timeout) 337 | 338 | def disconnect(self): 339 | self.client.disconnect() 340 | 341 | def is_connected(self): 342 | return self.client.is_connected() 343 | 344 | def read(self, addr, count, op=modbus_client.FUNC_READ_HOLDING): 345 | return self.client.read(addr, count, op) 346 | 347 | def write(self, addr, data): 348 | return self.client.write(addr, data) 349 | 350 | 351 | class SunSpecModbusClientDeviceRTU(SunSpecModbusClientDevice): 352 | """Provides access to a Modbus RTU device. 353 | Parameters: 354 | slave_id : 355 | Modbus slave id. 356 | name : 357 | Name of the serial port such as 'com4' or '/dev/ttyUSB0'. 358 | baudrate : 359 | Baud rate such as 9600 or 19200. Default is 9600 if not specified. 360 | parity : 361 | Parity. Possible values: 362 | :const:`sunspec.core.modbus.client.PARITY_NONE`, 363 | :const:`sunspec.core.modbus.client.PARITY_EVEN` Defaulted to 364 | :const:`PARITY_NONE`. 365 | timeout : 366 | Modbus request timeout in seconds. Fractional seconds are permitted 367 | such as .5. 368 | ctx : 369 | Context variable to be used by the object creator. Not used by the 370 | modbus module. 371 | trace_func : 372 | Trace function to use for detailed logging. No detailed logging is 373 | perform is a trace function is not supplied. 374 | max_count : 375 | Maximum register count for a single Modbus request. 376 | Raises: 377 | SunSpecModbusClientError: Raised for any general modbus client error. 378 | SunSpecModbusClientTimeoutError: Raised for a modbus client request timeout. 379 | SunSpecModbusClientException: Raised for an exception response to a modbus 380 | client request. 381 | """ 382 | 383 | def __init__(self, slave_id, name, baudrate=None, parity=None, timeout=None, ctx=None, trace_func=None, 384 | max_count=modbus_client.REQ_COUNT_MAX, max_write_count=modbus_client.REQ_WRITE_COUNT_MAX, 385 | model_class=SunSpecModbusClientModel): 386 | # test if this super class init is needed 387 | SunSpecModbusClientDevice.__init__(self, model_class=model_class) 388 | self.slave_id = slave_id 389 | self.name = name 390 | self.client = None 391 | self.ctx = ctx 392 | self.trace_func = trace_func 393 | self.max_count = max_count 394 | self.max_write_count = max_write_count 395 | 396 | self.client = modbus_client.modbus_rtu_client(name, baudrate, parity, timeout) 397 | if self.client is None: 398 | raise SunSpecModbusClientError('No modbus rtu client set for device') 399 | self.client.add_device(self.slave_id, self) 400 | 401 | def open(self): 402 | self.client.open() 403 | 404 | def close(self): 405 | """Close the device. Called when device is no longer in use. 406 | """ 407 | 408 | if self.client: 409 | self.client.remove_device(self.slave_id) 410 | 411 | def read(self, addr, count, op=modbus_client.FUNC_READ_HOLDING): 412 | """Read Modbus device registers. 413 | Parameters: 414 | addr : 415 | Starting Modbus address. 416 | count : 417 | Read length in Modbus registers. 418 | op : 419 | Modbus function code for request. 420 | Returns: 421 | Byte string containing register contents. 422 | """ 423 | 424 | return self.client.read(self.slave_id, addr, count, op=op, max_count=self.max_count) 425 | 426 | def write(self, addr, data): 427 | """Write Modbus device registers. 428 | Parameters: 429 | addr : 430 | Starting Modbus address. 431 | count : 432 | Byte string containing register contents. 433 | """ 434 | 435 | return self.client.write(self.slave_id, addr, data, max_write_count=self.max_write_count) 436 | -------------------------------------------------------------------------------- /sunspec2/smdx.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | Copyright (C) 2020 SunSpec Alliance 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the "Software"), 7 | to deal in the Software without restriction, including without limitation 8 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | and/or sell copies of the Software, and to permit persons to whom the 10 | Software is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included 13 | in all 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 18 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 21 | IN THE SOFTWARE. 22 | """ 23 | 24 | import os 25 | import xml.etree.ElementTree as ET 26 | 27 | import sunspec2.mdef as mdef 28 | 29 | SMDX_ROOT = 'sunSpecModels' 30 | SMDX_MODEL = mdef.MODEL 31 | SMDX_BLOCK = 'block' 32 | SMDX_POINT = 'point' 33 | SMDX_ATTR_VERS = 'v' 34 | SMDX_ATTR_ID = 'id' 35 | SMDX_ATTR_LEN = 'len' 36 | SMDX_ATTR_NAME = mdef.NAME 37 | SMDX_ATTR_TYPE = mdef.TYPE 38 | SMDX_ATTR_COUNT = mdef.COUNT 39 | SMDX_ATTR_VALUE = mdef.VALUE 40 | SMDX_ATTR_TYPE_FIXED = 'fixed' 41 | SMDX_ATTR_TYPE_REPEATING = 'repeating' 42 | SMDX_ATTR_OFFSET = 'offset' 43 | SMDX_ATTR_MANDATORY = mdef.MANDATORY 44 | SMDX_ATTR_ACCESS = mdef.ACCESS 45 | SMDX_ATTR_SF = mdef.SF 46 | SMDX_ATTR_UNITS = mdef.UNITS 47 | 48 | SMDX_SYMBOL = 'symbol' 49 | SMDX_COMMENT = 'comment' 50 | 51 | SMDX_STRINGS = 'strings' 52 | SMDX_ATTR_LOCALE = 'locale' 53 | SMDX_LABEL = mdef.LABEL 54 | SMDX_DESCRIPTION = 'description' 55 | SMDX_NOTES = 'notes' 56 | SMDX_DETAIL = mdef.DETAIL 57 | 58 | SMDX_TYPE_INT16 = mdef.TYPE_INT16 59 | SMDX_TYPE_UINT16 = mdef.TYPE_UINT16 60 | SMDX_TYPE_COUNT = mdef.TYPE_COUNT 61 | SMDX_TYPE_ACC16 = mdef.TYPE_ACC16 62 | SMDX_TYPE_ENUM16 = mdef.TYPE_ENUM16 63 | SMDX_TYPE_BITFIELD16 = mdef.TYPE_BITFIELD16 64 | SMDX_TYPE_PAD = mdef.TYPE_PAD 65 | SMDX_TYPE_INT32 = mdef.TYPE_INT32 66 | SMDX_TYPE_UINT32 = mdef.TYPE_UINT32 67 | SMDX_TYPE_ACC32 = mdef.TYPE_ACC32 68 | SMDX_TYPE_ENUM32 = mdef.TYPE_ENUM32 69 | SMDX_TYPE_BITFIELD32 = mdef.TYPE_BITFIELD32 70 | SMDX_TYPE_IPADDR = mdef.TYPE_IPADDR 71 | SMDX_TYPE_INT64 = mdef.TYPE_INT64 72 | SMDX_TYPE_UINT64 = mdef.TYPE_UINT64 73 | SMDX_TYPE_ACC64 = mdef.TYPE_ACC64 74 | SMDX_TYPE_IPV6ADDR = mdef.TYPE_IPV6ADDR 75 | SMDX_TYPE_FLOAT32 = mdef.TYPE_FLOAT32 76 | SMDX_TYPE_STRING = mdef.TYPE_STRING 77 | SMDX_TYPE_SUNSSF = mdef.TYPE_SUNSSF 78 | SMDX_TYPE_EUI48 = mdef.TYPE_EUI48 79 | 80 | SMDX_ACCESS_R = 'r' 81 | SMDX_ACCESS_RW = 'rw' 82 | 83 | SMDX_MANDATORY_FALSE = 'false' 84 | SMDX_MANDATORY_TRUE = 'true' 85 | 86 | smdx_access_types = {SMDX_ACCESS_R: mdef.ACCESS_R, SMDX_ACCESS_RW: mdef.ACCESS_RW} 87 | 88 | smdx_mandatory_types = {SMDX_MANDATORY_FALSE: mdef.MANDATORY_FALSE, SMDX_MANDATORY_TRUE: mdef.MANDATORY_TRUE} 89 | 90 | smdx_type_types = [ 91 | SMDX_TYPE_INT16, 92 | SMDX_TYPE_UINT16, 93 | SMDX_TYPE_COUNT, 94 | SMDX_TYPE_ACC16, 95 | SMDX_TYPE_ENUM16, 96 | SMDX_TYPE_BITFIELD16, 97 | SMDX_TYPE_PAD, 98 | SMDX_TYPE_INT32, 99 | SMDX_TYPE_UINT32, 100 | SMDX_TYPE_ACC32, 101 | SMDX_TYPE_ENUM32, 102 | SMDX_TYPE_BITFIELD32, 103 | SMDX_TYPE_IPADDR, 104 | SMDX_TYPE_INT64, 105 | SMDX_TYPE_UINT64, 106 | SMDX_TYPE_ACC64, 107 | SMDX_TYPE_IPV6ADDR, 108 | SMDX_TYPE_FLOAT32, 109 | SMDX_TYPE_STRING, 110 | SMDX_TYPE_SUNSSF, 111 | SMDX_TYPE_EUI48 112 | ] 113 | 114 | SMDX_PREFIX = 'smdx_' 115 | SMDX_EXT = '.xml' 116 | 117 | 118 | def to_smdx_filename(model_id): 119 | return '%s%05d%s' % (SMDX_PREFIX, int(model_id), SMDX_EXT) 120 | 121 | 122 | def model_filename_to_id(filename): 123 | f = filename 124 | if '.' in f: 125 | f = os.path.splitext(f)[0] 126 | try: 127 | mid = int(f.rsplit('_', 1)[1]) 128 | except ValueError: 129 | raise mdef.ModelDefinitionError('Error extracting model id from filename') 130 | 131 | return mid 132 | 133 | ''' 134 | smdx to json mapping: 135 | 136 | fixed block -> top level group 137 | model 'name' attribute -> group 'name' 138 | ID point is created for model ID and 'value' is the model ID value as a number 139 | L point is created for model len - model len has no value specified in the model definition 140 | fixed block points are placed in top level group 141 | repeating block -> group with count = 0 (indicates model len shoud be used to determine number of groups) 142 | repeating block 'name' -> group 'name', if no 'name' is defined 'name' = 'repeating' 143 | 144 | points: 145 | all type, access, and mandatory attributes are preserved 146 | point symbol map to the symbol object and placed in the symbols list for the point 147 | symbol 'name' attribute -> symbol object 'name' 148 | symbol element content -> symbol object 'value' 149 | strings 'label', 'description', 'notes' elements map to point attributes 'label', 'desc', 'detail' 150 | ''' 151 | 152 | 153 | def from_smdx_file(filename): 154 | tree = ET.parse(filename) 155 | root = tree.getroot() 156 | return(from_smdx(root)) 157 | 158 | 159 | def from_smdx(element): 160 | """ Sets the model type attributes based on an element tree model type 161 | element contained in an SMDX model definition. 162 | 163 | Parameters: 164 | 165 | element : 166 | Element Tree model type element. 167 | """ 168 | 169 | model_def = {} 170 | 171 | m = element.find(SMDX_MODEL) 172 | if m is None: 173 | raise mdef.ModelDefinitionError('Model definition not found') 174 | try: 175 | mid = mdef.to_number_type(m.attrib.get(SMDX_ATTR_ID)) 176 | except ValueError: 177 | raise mdef.ModelDefinitionError('Invalid model id: %s' % m.attrib.get(SMDX_ATTR_ID)) 178 | 179 | name = m.attrib.get(SMDX_ATTR_NAME) 180 | if name is None: 181 | name = 'model_' + str(mid) 182 | model_def[mdef.NAME] = name 183 | 184 | strings = element.find(SMDX_STRINGS) 185 | 186 | # create top level group with ID and L points 187 | fixed_def = {mdef.NAME: name, 188 | mdef.TYPE: mdef.TYPE_GROUP, 189 | mdef.POINTS: [ 190 | {mdef.NAME: 'ID', mdef.VALUE: mid, 191 | mdef.DESCRIPTION: 'Model identifier', mdef.LABEL: 'Model ID', mdef.SIZE: 1, 192 | mdef.MANDATORY: mdef.MANDATORY_TRUE, mdef.STATIC: mdef.STATIC_TRUE, mdef.TYPE: mdef.TYPE_UINT16}, 193 | {mdef.NAME: 'L', 194 | mdef.DESCRIPTION: 'Model length', mdef.LABEL: 'Model Length', mdef.SIZE: 1, 195 | mdef.MANDATORY: mdef.MANDATORY_TRUE, mdef.STATIC: mdef.STATIC_TRUE, mdef.TYPE: mdef.TYPE_UINT16} 196 | ] 197 | } 198 | 199 | repeating_def = None 200 | 201 | fixed = None 202 | repeating = None 203 | for b in m.findall(SMDX_BLOCK): 204 | btype = b.attrib.get(SMDX_ATTR_TYPE, SMDX_ATTR_TYPE_FIXED) 205 | if btype == SMDX_ATTR_TYPE_FIXED: 206 | if fixed is not None: 207 | raise mdef.ModelDefinitionError('Duplicate fixed block type definition') 208 | fixed = b 209 | elif btype == SMDX_ATTR_TYPE_REPEATING: 210 | if repeating is not None: 211 | raise mdef.ModelDefinitionError('Duplicate repeating block type definition') 212 | repeating = b 213 | else: 214 | raise mdef.ModelDefinitionError('Invalid block type: %s' % btype) 215 | 216 | fixed_points_map = {} 217 | if fixed is not None: 218 | points = [] 219 | for e in fixed.findall(SMDX_POINT): 220 | point_def = from_smdx_point(e) 221 | if point_def[mdef.NAME] not in fixed_points_map: 222 | fixed_points_map[point_def[mdef.NAME]] = point_def 223 | points.append(point_def) 224 | else: 225 | raise mdef.ModelDefinitionError('Duplicate point definition: %s' % point_def[mdef.NAME]) 226 | if points: 227 | fixed_def[mdef.POINTS].extend(points) 228 | 229 | repeating_points_map = {} 230 | if repeating is not None: 231 | name = repeating.attrib.get(SMDX_ATTR_NAME) 232 | if name is None: 233 | name = 'repeating' 234 | repeating_def = {mdef.NAME: name, mdef.TYPE: mdef.TYPE_GROUP, mdef.COUNT: 0} 235 | points = [] 236 | for e in repeating.findall(SMDX_POINT): 237 | point_def = from_smdx_point(e) 238 | if point_def[mdef.NAME] not in repeating_points_map: 239 | repeating_points_map[point_def[mdef.NAME]] = point_def 240 | points.append(point_def) 241 | else: 242 | raise mdef.ModelDefinitionError('Duplicate point definition: %s' % point_def[mdef.NAME]) 243 | if points: 244 | repeating_def[mdef.POINTS] = points 245 | fixed_def[mdef.GROUPS] = [repeating_def] 246 | 247 | e = element.find(SMDX_STRINGS) 248 | if e.attrib.get(SMDX_ATTR_ID) == str(mid): 249 | m = e.find(SMDX_MODEL) 250 | if m is not None: 251 | for a in m.findall('*'): 252 | if a.tag == SMDX_LABEL and a.text: 253 | fixed_def[mdef.LABEL] = a.text 254 | elif a.tag == SMDX_DESCRIPTION and a.text: 255 | fixed_def[mdef.DESCRIPTION] = a.text 256 | elif a.tag == SMDX_NOTES and a.text: 257 | fixed_def[mdef.DETAIL] = a.text 258 | 259 | # Assign point info to point definitions 260 | for p in e.findall(SMDX_POINT): 261 | pid = p.attrib.get(SMDX_ATTR_ID) 262 | label = desc = notes = None 263 | for a in p.findall('*'): 264 | if a.tag == SMDX_LABEL and a.text: 265 | label = a.text 266 | elif a.tag == SMDX_DESCRIPTION and a.text: 267 | desc = a.text 268 | elif a.tag == SMDX_NOTES and a.text: 269 | notes = a.text 270 | 271 | for points_map in [fixed_points_map, repeating_points_map]: 272 | point_def = points_map.get(pid) 273 | if point_def is None: 274 | continue 275 | 276 | if label: 277 | point_def[mdef.LABEL] = label 278 | if desc: 279 | point_def[mdef.DESCRIPTION] = desc 280 | if notes: 281 | point_def[mdef.DETAIL] = notes 282 | 283 | # Assign symbol info to the point's symbol definitions 284 | for s in p.findall(SMDX_SYMBOL): 285 | sid = s.attrib.get(SMDX_ATTR_ID) 286 | s_label = s_desc = s_notes = None 287 | for a in s.findall('*'): 288 | if a.tag == SMDX_LABEL and a.text: 289 | s_label = a.text 290 | elif a.tag == SMDX_DESCRIPTION and a.text: 291 | s_desc = a.text 292 | elif a.tag == SMDX_NOTES and a.text: 293 | s_notes = a.text 294 | 295 | for s in point_def.get(mdef.SYMBOLS): 296 | if s[mdef.NAME] != sid: 297 | continue 298 | 299 | if s_label: 300 | s[mdef.LABEL] = s_label 301 | if s_desc: 302 | s[mdef.DESCRIPTION] = s_desc 303 | if s_notes: 304 | s[mdef.DETAIL] = s_notes 305 | break 306 | 307 | model_def = {'id': mid, 'group': fixed_def} 308 | return model_def 309 | 310 | 311 | def from_smdx_point(element): 312 | """ Sets the point attributes based on an element tree point element 313 | contained in an SMDX model definition. 314 | 315 | Parameters: 316 | 317 | element : 318 | Element Tree point type element. 319 | 320 | strings : 321 | Indicates if *element* is a subelement of the 'strings' 322 | definintion within the model definition. 323 | """ 324 | point_def = {} 325 | pid = element.attrib.get(SMDX_ATTR_ID) 326 | if pid is None: 327 | raise mdef.ModelDefinitionError('Missing point id attribute') 328 | point_def[mdef.NAME] = pid 329 | ptype = element.attrib.get(SMDX_ATTR_TYPE) 330 | if ptype is None: 331 | raise mdef.ModelDefinitionError('Missing type attribute for point: %s' % pid) 332 | elif ptype not in smdx_type_types: 333 | raise mdef.ModelDefinitionError('Unknown point type %s for point %s' % (ptype, pid)) 334 | point_def[mdef.TYPE] = ptype 335 | plen = mdef.to_number_type(element.attrib.get(SMDX_ATTR_LEN)) 336 | if ptype == SMDX_TYPE_STRING: 337 | if plen is None: 338 | raise mdef.ModelDefinitionError('Missing len attribute for point: %s' % pid) 339 | point_def[mdef.SIZE] = plen 340 | else: 341 | point_def[mdef.SIZE] = mdef.point_type_info.get(ptype)['len'] 342 | mandatory = element.attrib.get(SMDX_ATTR_MANDATORY, SMDX_MANDATORY_FALSE) 343 | if mandatory not in smdx_mandatory_types: 344 | raise mdef.ModelDefinitionError('Unknown mandatory type: %s' % mandatory) 345 | if mandatory == SMDX_MANDATORY_TRUE: 346 | point_def[mdef.MANDATORY] = smdx_mandatory_types.get(mandatory) 347 | access = element.attrib.get(SMDX_ATTR_ACCESS, SMDX_ACCESS_R) 348 | if access not in smdx_access_types: 349 | raise mdef.ModelDefinitionError('Unknown access type: %s' % access) 350 | if access == SMDX_ACCESS_RW: 351 | point_def[mdef.ACCESS] = smdx_access_types.get(access) 352 | units = element.attrib.get(SMDX_ATTR_UNITS) 353 | if units: 354 | point_def[mdef.UNITS] = units 355 | # if scale factor is an number, convert to correct type 356 | sf = mdef.to_number_type(element.attrib.get(SMDX_ATTR_SF)) 357 | if sf is not None: 358 | point_def[mdef.SF] = sf 359 | # if scale factor is an number, convert to correct type 360 | value = mdef.to_number_type(element.attrib.get(SMDX_ATTR_VALUE)) 361 | if value is not None: 362 | point_def[mdef.VALUE] = value 363 | 364 | symbols = [] 365 | for e in element.findall('*'): 366 | if e.tag == SMDX_SYMBOL: 367 | sid = e.attrib.get(SMDX_ATTR_ID) 368 | value = e.text 369 | try: 370 | value = int(value) 371 | except ValueError: 372 | pass 373 | symbols.append({mdef.NAME: sid, mdef.VALUE: value}) 374 | if symbols: 375 | point_def[mdef.SYMBOLS] = symbols 376 | 377 | return point_def 378 | 379 | 380 | def indent(elem, level=0): 381 | i = os.linesep + level*" " 382 | if len(elem): 383 | if not elem.text or not elem.text.strip(): 384 | elem.text = i + " " 385 | if not elem.tail or not elem.tail.strip(): 386 | elem.tail = i 387 | for elem in elem: 388 | indent(elem, level+1) 389 | if not elem.tail or not elem.tail.strip(): 390 | elem.tail = i 391 | else: 392 | if level and (not elem.tail or not elem.tail.strip()): 393 | elem.tail = i 394 | -------------------------------------------------------------------------------- /sunspec2/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunspec/pysunspec2/d42ed2d1cc9647d2591a9a75caba1e1fa05c437e/sunspec2/tests/__init__.py -------------------------------------------------------------------------------- /sunspec2/tests/mock_port.py: -------------------------------------------------------------------------------- 1 | class MockPort(object): 2 | PARITY_NONE = 'N' 3 | PARITY_EVEN = 'E' 4 | 5 | def __init__(self, port, baudrate, bytesize, parity, stopbits, xonxoff, timeout): 6 | self.connected = True 7 | self.port = port 8 | self.baudrate = baudrate 9 | self.bytesize = bytesize 10 | self.parity = parity 11 | self.stopbits = stopbits 12 | self.xonxoff = xonxoff 13 | self.timeout = timeout 14 | 15 | self.buffer = [] 16 | self.request = [] 17 | 18 | def open(self): 19 | pass 20 | 21 | def close(self): 22 | self.connected = False 23 | 24 | def read(self, count): 25 | if len(self.buffer) == 0: 26 | return b'' 27 | print(f"MockPort.read: count={count}. Message: {self.buffer[0]}") 28 | return self.buffer.pop(0) # get the first element 29 | 30 | def write(self, data): 31 | self.request.append(data) 32 | 33 | def flushInput(self): 34 | pass 35 | 36 | def _set_buffer(self, resp_list): 37 | for bs in resp_list: 38 | self.buffer.append(bs) 39 | 40 | def clear_buffer(self): 41 | self.buffer = [] 42 | 43 | 44 | def mock_port(port, baudrate, bytesize, parity, stopbits, xonxoff, timeout): 45 | return MockPort(port, baudrate, bytesize, parity, stopbits, xonxoff, timeout) 46 | -------------------------------------------------------------------------------- /sunspec2/tests/mock_socket.py: -------------------------------------------------------------------------------- 1 | class MockSocket(object): 2 | def __init__(self): 3 | self.connected = False 4 | self.timeout = 0 5 | self.ipaddr = None 6 | self.ipport = None 7 | self.buffer = [] 8 | 9 | self.request = [] 10 | 11 | def settimeout(self, timeout): 12 | self.timeout = timeout 13 | 14 | def connect(self, ipaddrAndipportTup): 15 | self.connected = True 16 | self.ipaddr = ipaddrAndipportTup[0] 17 | self.ipport = ipaddrAndipportTup[1] 18 | 19 | def close(self): 20 | self.connected = False 21 | 22 | def recv(self, size): 23 | if len(self.buffer) == 0: 24 | return b'' 25 | print(f"MockSocket.recv: size={size}. Message: {self.buffer[0]}") 26 | return self.buffer.pop(0) 27 | 28 | def sendall(self, data): 29 | self.request.append(data) 30 | 31 | def _set_buffer(self, resp_list): 32 | for bs in resp_list: 33 | self.buffer.append(bs) 34 | 35 | def clear_buffer(self): 36 | self.buffer = [] 37 | 38 | 39 | def mock_socket(AF_INET, SOCK_STREAM): 40 | return MockSocket() 41 | 42 | 43 | def mock_tcp_connect(self): 44 | if self.client.socket is None: 45 | self.client.socket = mock_socket('foo', 'bar') 46 | self.client.socket.settimeout(999) 47 | self.client.socket.connect((999, 999)) 48 | pass 49 | 50 | 51 | def mock_tcp_disconnect(self): 52 | pass -------------------------------------------------------------------------------- /sunspec2/tests/test_data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunspec/pysunspec2/d42ed2d1cc9647d2591a9a75caba1e1fa05c437e/sunspec2/tests/test_data/__init__.py -------------------------------------------------------------------------------- /sunspec2/tests/test_data/device_1547.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "device_1547", 3 | "models": [ 4 | { 5 | "ID": 1, 6 | "Mn": "SunSpecTest", 7 | "Md": "Test-1547-1", 8 | "Opt": "opt_a_b_c", 9 | "Vr": "1.2.3", 10 | "SN": "sn-123456789", 11 | "DA": 1, 12 | "Pad": 0 13 | }, 14 | { 15 | "ID": 701, 16 | "L": null, 17 | "ACType": 3, 18 | "St": 2, 19 | "Alrm": 0, 20 | "W": 9800, 21 | "VA": 10000, 22 | "Var": 200, 23 | "PF": 985, 24 | "A": 411, 25 | "LLV": 2400, 26 | "LNV": 2400, 27 | "Hz": 60010, 28 | "TotWhInj": 150, 29 | "TotWhAbs": 0, 30 | "TotVarhInj": 9, 31 | "TotVarhAbs": 0, 32 | "TmpAmb": 450, 33 | "TmpCab": 550, 34 | "TmpSnk": 650, 35 | "TmpTrns": 500, 36 | "TmpSw": 400, 37 | "TmpOt": 420, 38 | "WL1": 3200, 39 | "VAL1": 3333, 40 | "VarL1": 80, 41 | "PFL1": 984, 42 | "AL1": 137, 43 | "VL1L2": 120, 44 | "VL1": 120, 45 | "TotWhInjL1": 49, 46 | "TotWhAbsL1": 0, 47 | "TotVarhInjL1": 2, 48 | "TotVarhAbsL1": 0, 49 | "WL2": 3300, 50 | "VAL2": 3333, 51 | "VarL2": 80, 52 | "PFL2": 986, 53 | "AL2": 136, 54 | "VL2L3": 120, 55 | "VL2": 120, 56 | "TotWhInjL2": 50, 57 | "TotWhAbsL2": 0, 58 | "TotVarhInjL2": 3, 59 | "TotVarhAbsL2": 0, 60 | "WL3": 3500, 61 | "VAL3": 3333, 62 | "VarL3": 40, 63 | "PFL3": 987, 64 | "AL3": 138, 65 | "VL3L1": 120, 66 | "VL3N": 120, 67 | "TotWhInjL3": 51, 68 | "TotWhAbsL3": 0, 69 | "TotVarhInjL3": 4, 70 | "TotVarhAbsL3": 0, 71 | "A_SF": -1, 72 | "V_SF": -1, 73 | "Hz_SF": -3, 74 | "W_SF": 0, 75 | "PF_SF": -3, 76 | "VA_SF": 0, 77 | "Var_SF": 0, 78 | "TotWh_SF": 3, 79 | "TotVarh_SF": 3, 80 | "Tmp_SF": -1 81 | }, 82 | { 83 | "ID": 702, 84 | "L": null, 85 | "WMaxRtg": 10000, 86 | "WOvrExtRtg": 10000, 87 | "WOvrExtRtgPF": 1000, 88 | "WUndExtRtg": 10000, 89 | "WUndExtRtgPF": 1000, 90 | "VAMaxRtg": 11000, 91 | "VarMaxInjRtg": 2500, 92 | "VarMaxAbsRtg": 0, 93 | "WChaRteMaxRtg": 0, 94 | "WDisChaRteMaxRtg": 0, 95 | "VAChaRteMaxRtg": 0, 96 | "VADisChaRteMaxRtg": 0, 97 | "VNomRtg": 240, 98 | "VMaxRtg": 270, 99 | "VMinRtg": 210, 100 | "AMaxRtg": 50, 101 | "PFOvrExtRtg": 850, 102 | "PFUndExtRtg": 850, 103 | "ReactSusceptRtg": null, 104 | "NorOpCatRtg": 2, 105 | "AbnOpCatRtg": 3, 106 | "CtrlModes": null, 107 | "IntIslandCatRtg": null, 108 | "WMax": 10000, 109 | "WMaxOvrExt": null, 110 | "WOvrExtPF": null, 111 | "WMaxUndExt": null, 112 | "WUndExtPF": null, 113 | "VAMax": 10000, 114 | "AMax": null, 115 | "Vnom": null, 116 | "VRefOfs": null, 117 | "VMax": null, 118 | "VMin": null, 119 | "VarMaxInj": null, 120 | "VarMaxAbs": null, 121 | "WChaRteMax": null, 122 | "WDisChaRteMax": null, 123 | "VAChaRteMax": null, 124 | "VADisChaRteMax": null, 125 | "IntIslandCat": null, 126 | "W_SF": 0, 127 | "PF_SF": -3, 128 | "VA_SF": 0, 129 | "Var_SF": 0, 130 | "V_SF": 0, 131 | "A_SF": 0, 132 | "S_SF": 0 133 | }, 134 | { 135 | "ID": 703, 136 | "ES": 1, 137 | "ESVHi": 1050, 138 | "ESVLo": 917, 139 | "ESHzHi": 6010, 140 | "ESHzLo": 5950, 141 | "ESDlyTms": 300, 142 | "ESRndTms": 100, 143 | "ESRmpTms": 60, 144 | "V_SF": -3, 145 | "Hz_SF": -2 146 | }, 147 | { 148 | "ID": 704, 149 | "L": null, 150 | "PFWInjEna": 0, 151 | "PFWInjEnaRvrt": null, 152 | "PFWInjRvrtTms": null, 153 | "PFWInjRvrtRem": null, 154 | "PFWAbsEna": 0, 155 | "PFWAbsEnaRvrt": null, 156 | "PFWAbsRvrtTms": null, 157 | "PFWAbsRvrtRem": null, 158 | "WMaxLimEna": 0, 159 | "WMaxLim": 1000, 160 | "WMaxLimRvrt": null, 161 | "WMaxLimEnaRvrt": null, 162 | "WMaxLimRvrtTms": null, 163 | "WMaxLimRvrtRem": null, 164 | "WSetEna": null, 165 | "WSetMod": null, 166 | "WSet": null, 167 | "WSetRvrt": null, 168 | "WSetPct": null, 169 | "WSetPctRvrt": null, 170 | "WSetEnaRvrt": null, 171 | "WSetRvrtTms": null, 172 | "WSetRvrtRem": null, 173 | "VarSetEna": null, 174 | "VarSetMod": null, 175 | "VarSetPri": null, 176 | "VarSet": null, 177 | "VarSetRvrt": null, 178 | "VarSetPct": null, 179 | "VarSetPctRvrt": null, 180 | "VarSetRvrtTms": null, 181 | "VarSetRvrtRem": null, 182 | "RGra": null, 183 | "PF_SF": -3, 184 | "WMaxLim_SF": -1, 185 | "WSet_SF": null, 186 | "WSetPct_SF": null, 187 | "VarSet_SF": null, 188 | "VarSetPct_SF": null, 189 | "PFWInj": { 190 | "PF": 950, 191 | "Ext": 1 192 | }, 193 | "PFWInjRvrt": { 194 | "PF": null, 195 | "Ext": null 196 | }, 197 | "PFWAbs": { 198 | "PF": null, 199 | "Ext": null 200 | }, 201 | "PFWAbsRvrt": { 202 | "PF": null, 203 | "Ext": null 204 | } 205 | }, 206 | { 207 | "ID": 705, 208 | "Ena": 1, 209 | "CrvSt": 1, 210 | "AdptCrvReq": 0, 211 | "AdptCrvRslt": 0, 212 | "NPt": 4, 213 | "NCrv": 3, 214 | "RvrtTms": 0, 215 | "RvrtRem": 0, 216 | "RvrtCrv": 0, 217 | "V_SF": -2, 218 | "DeptRef_SF": -2, 219 | "Crv": [ 220 | { 221 | "ActPt": 4, 222 | "DeptRef": 1, 223 | "Pri": 1, 224 | "VRef": 1, 225 | "VRefAuto": 0, 226 | "VRefTms": 5, 227 | "RspTms": 6, 228 | "ReadOnly": 1, 229 | "Pt": [ 230 | { 231 | "V": 9200, 232 | "Var": 3000 233 | }, 234 | { 235 | "V": 9670, 236 | "Var": 0 237 | }, 238 | { 239 | "V": 10300, 240 | "Var": 0 241 | }, 242 | { 243 | "V": 10700, 244 | "Var": -3000 245 | } 246 | ] 247 | }, 248 | { 249 | "ActPt": 4, 250 | "DeptRef": 1, 251 | "Pri": 1, 252 | "VRef": 1, 253 | "VRefAuto": 0, 254 | "VRefTms": 5, 255 | "RspTms": 6, 256 | "ReadOnly": 0, 257 | "Pt": [ 258 | { 259 | "V": 9300, 260 | "Var": 3000 261 | }, 262 | { 263 | "V": 9570, 264 | "Var": 0 265 | }, 266 | { 267 | "V": 10200, 268 | "Var": 0 269 | }, 270 | { 271 | "V": 10600, 272 | "Var": -4000 273 | } 274 | ] 275 | }, 276 | { 277 | "ActPt": 4, 278 | "DeptRef": 1, 279 | "Pri": 1, 280 | "VRef": 1, 281 | "VRefAuto": 0, 282 | "VRefTms": 5, 283 | "RspTms": 6, 284 | "ReadOnly": 0, 285 | "Pt": [ 286 | { 287 | "V": 9400, 288 | "Var": 2000 289 | }, 290 | { 291 | "V": 9570, 292 | "Var": 0 293 | }, 294 | { 295 | "V": 10500, 296 | "Var": 0 297 | }, 298 | { 299 | "V": 10800, 300 | "Var": -2000 301 | } 302 | ] 303 | } 304 | ] 305 | }, 306 | { 307 | "ID": 706, 308 | "Ena": 0, 309 | "CrvSt": 1, 310 | "AdptCrvReq": 0, 311 | "AdptCrvRslt": 0, 312 | "NPt": 2, 313 | "NCrv": 2, 314 | "RvrtTms": null, 315 | "RvrtRem": null, 316 | "RvrtCrv": null, 317 | "V_SF": 0, 318 | "DeptRef_SF": 0, 319 | "Crv": [ 320 | { 321 | "ActPt": 2, 322 | "DeptRef": 1, 323 | "RspTms": 10, 324 | "ReadOnly": 1, 325 | "Pt": [ 326 | { 327 | "V": 106, 328 | "W": 100 329 | }, 330 | { 331 | "V": 110, 332 | "W": 0 333 | } 334 | ] 335 | }, 336 | { 337 | "ActPt": 2, 338 | "DeptRef": 1, 339 | "RspTms": 5, 340 | "ReadOnly": 0, 341 | "Pt": [ 342 | { 343 | "V": 105, 344 | "W": 100 345 | }, 346 | { 347 | "V": 109, 348 | "W": 0 349 | } 350 | ] 351 | } 352 | ] 353 | }, 354 | { 355 | "ID": 707, 356 | "L": null, 357 | "Ena": 1, 358 | "CrvSt": null, 359 | "AdptCrvReq": null, 360 | "AdptCrvRslt": null, 361 | "NPt": 1, 362 | "NCrvSet": 1, 363 | "V_SF": -2, 364 | "Tms_SF": 0, 365 | "Crv": [ 366 | { 367 | "MustTrip": { 368 | "ActPt": 1, 369 | "Pt": [ 370 | { 371 | "V": 5000, 372 | "Tms": 5 373 | } 374 | ] 375 | }, 376 | "MayTrip": { 377 | "ActPt": 1, 378 | "Pt": [ 379 | { 380 | "V": 7000, 381 | "Tms": 5 382 | } 383 | ] 384 | }, 385 | "MomCess": { 386 | "ActPt": 1, 387 | "Pt": [ 388 | { 389 | "V": 6000, 390 | "Tms": 5 391 | } 392 | ] 393 | } 394 | } 395 | ] 396 | }, 397 | { 398 | "ID": 708, 399 | "L": null, 400 | "Ena": 1, 401 | "CrvSt": null, 402 | "AdptCrvReq": null, 403 | "AdptCrvRslt": null, 404 | "NPt": 1, 405 | "NCrvSet": 1, 406 | "V_SF": -2, 407 | "Tms_SF": 0, 408 | "Crv": [ 409 | { 410 | "MustTrip": { 411 | "ActPt": 1, 412 | "Pt": [ 413 | { 414 | "V": 12000, 415 | "Tms": 5 416 | } 417 | ] 418 | }, 419 | "MayTrip": { 420 | "ActPt": 1, 421 | "Pt": [ 422 | { 423 | "V": 10000, 424 | "Tms": 5 425 | } 426 | ] 427 | }, 428 | "MomCess": { 429 | "ActPt": 1, 430 | "Pt": [ 431 | { 432 | "V": 10000, 433 | "Tms": 5 434 | } 435 | ] 436 | } 437 | } 438 | ] 439 | }, 440 | { 441 | "ID": 709, 442 | "L": null, 443 | "Ena": 1, 444 | "CrvSt": null, 445 | "AdptCrvReq": null, 446 | "AdptCrvRslt": null, 447 | "NPt": 1, 448 | "NCrvSet": 1, 449 | "Freq_SF": null, 450 | "Tms_SF": -2, 451 | "Crv": [ 452 | { 453 | "MustTrip": { 454 | "ActPt": 1, 455 | "Pt": [ 456 | { 457 | "Freq": 5300, 458 | "Tms": 5 459 | } 460 | ] 461 | }, 462 | "MayTrip": { 463 | "ActPt": 1, 464 | "Pt": [ 465 | { 466 | "Freq": 5850, 467 | "Tms": 5 468 | } 469 | ] 470 | }, 471 | "MomCess": { 472 | "ActPt": 1, 473 | "Pt": [ 474 | { 475 | "Freq": 5850, 476 | "Tms": 5 477 | } 478 | ] 479 | } 480 | } 481 | ] 482 | }, 483 | { 484 | "ID": 710, 485 | "L": null, 486 | "Ena": null, 487 | "CrvSt": null, 488 | "AdptCrvReq": null, 489 | "AdptCrvRslt": null, 490 | "NPt": 1, 491 | "NCrvSet": 1, 492 | "Freq_SF": null, 493 | "Tms_SF": -2, 494 | "Crv": [ 495 | { 496 | "MustTrip": { 497 | "ActPt": 1, 498 | "Pt": [ 499 | { 500 | "Freq": 6500, 501 | "Tms": 5 502 | } 503 | ] 504 | }, 505 | "MayTrip": { 506 | "ActPt": 1, 507 | "Pt": [ 508 | { 509 | "Freq": 6050, 510 | "Tms": 5 511 | } 512 | ] 513 | }, 514 | "MomCess": { 515 | "ActPt": 1, 516 | "Pt": [ 517 | { 518 | "Freq": 6050, 519 | "Tms": 5 520 | } 521 | ] 522 | } 523 | } 524 | ] 525 | }, 526 | { 527 | "ID": 711, 528 | "L": null, 529 | "Ena": null, 530 | "CrvSt": null, 531 | "AdptCrvReq": null, 532 | "AdptCrvRslt": null, 533 | "NCtl": 1, 534 | "RvrtTms": 0, 535 | "RvrtRem": 0, 536 | "RvrtCrv": 0, 537 | "Db_SF": -2, 538 | "K_SF": -2, 539 | "RspTms_SF": 0, 540 | "Ctl": [ 541 | { 542 | "DbOf": 60030, 543 | "DbUf": 59970, 544 | "KOf": 40, 545 | "KUf": 40, 546 | "RspTms": 600 547 | } 548 | ] 549 | }, 550 | { 551 | "ID": 712, 552 | "L": null, 553 | "Ena": null, 554 | "CrvSt": null, 555 | "AdptCrvReq": null, 556 | "AdptCrvRslt": null, 557 | "NPt": 1, 558 | "NCrv": 1, 559 | "RvrtTms": 0, 560 | "RvrtRem": 0, 561 | "RvrtCrv": 0, 562 | "DeptRef_SF": -2, 563 | "Crv": [ 564 | { 565 | "ActPt": 1, 566 | "DeptRef": null, 567 | "Pri": null, 568 | "ReadOnly": null, 569 | "Pt": [ 570 | { 571 | "W": null, 572 | "Var": null 573 | } 574 | ] 575 | } 576 | ] 577 | }, 578 | { 579 | "ID": 713, 580 | "L": null, 581 | "PrtAlrms": null, 582 | "NPrt": 1, 583 | "DCA": null, 584 | "DCW": null, 585 | "DCWhInj": null, 586 | "DCWhAbs": null, 587 | "DCA_SF": null, 588 | "DCV_SF": null, 589 | "DCW_SF": null, 590 | "DCWH_SF": null, 591 | "Prt": [ 592 | { 593 | "PrtTyp": null, 594 | "ID": null, 595 | "IDStr": null, 596 | "DCA": null, 597 | "DCV": null, 598 | "DCW": null, 599 | "DCWhInj": null, 600 | "DCWhAbs": null, 601 | "Tmp": null, 602 | "DCSt": null, 603 | "DCAlrm": null 604 | } 605 | ] 606 | }, 607 | { 608 | "ID": 65535, 609 | "L": 0 610 | } 611 | ] 612 | } -------------------------------------------------------------------------------- /sunspec2/tests/test_data/inverter_123.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": null, 3 | "did": "fa0a9f2d-d503-470e-8ce8-ec9c51428829", 4 | "models": [ 5 | { 6 | "ID": 1, 7 | "L": 66, 8 | "Mn": "Device Manufacturer", 9 | "Md": "Inverter 123", 10 | "Opt": null, 11 | "Vr": "v0.0.1", 12 | "SN": "9999abcd", 13 | "DA": null, 14 | "Pad": 32768 15 | }, 16 | { 17 | "ID": 129, 18 | "L": 210, 19 | "ActCrv": 1, 20 | "ModEna": 0, 21 | "WinTms": null, 22 | "RvrtTms": null, 23 | "RmpTms": null, 24 | "NCrv": 4, 25 | "NPt": 10, 26 | "Tms_SF": -2, 27 | "V_SF": -1, 28 | "Pad": 32768, 29 | "curve": [ 30 | { 31 | "ActPt": 4, 32 | "Tms1": 200, 33 | "V1": 880, 34 | "Tms2": 71, 35 | "V2": 650, 36 | "Tms3": 20, 37 | "V3": 450, 38 | "Tms4": 0, 39 | "V4": 300, 40 | "Tms5": 0, 41 | "V5": 0, 42 | "Tms6": 0, 43 | "V6": 0, 44 | "Tms7": 0, 45 | "V7": 0, 46 | "Tms8": 0, 47 | "V8": 0, 48 | "Tms9": 0, 49 | "V9": 0, 50 | "Tms10": 0, 51 | "V10": 0, 52 | "Tms11": null, 53 | "V11": null, 54 | "Tms12": null, 55 | "V12": null, 56 | "Tms13": null, 57 | "V13": null, 58 | "Tms14": null, 59 | "V14": null, 60 | "Tms15": null, 61 | "V15": null, 62 | "Tms16": null, 63 | "V16": null, 64 | "Tms17": null, 65 | "V17": null, 66 | "Tms18": null, 67 | "V18": null, 68 | "Tms19": null, 69 | "V19": null, 70 | "Tms20": null, 71 | "V20": null, 72 | "CrvNam": null, 73 | "ReadOnly": 1 74 | }, 75 | { 76 | "ActPt": 0, 77 | "Tms1": 0, 78 | "V1": 0, 79 | "Tms2": 0, 80 | "V2": 0, 81 | "Tms3": 0, 82 | "V3": 0, 83 | "Tms4": 0, 84 | "V4": 0, 85 | "Tms5": 0, 86 | "V5": 0, 87 | "Tms6": 0, 88 | "V6": 0, 89 | "Tms7": 0, 90 | "V7": 0, 91 | "Tms8": 0, 92 | "V8": 0, 93 | "Tms9": 0, 94 | "V9": 0, 95 | "Tms10": 0, 96 | "V10": 0, 97 | "Tms11": null, 98 | "V11": null, 99 | "Tms12": null, 100 | "V12": null, 101 | "Tms13": null, 102 | "V13": null, 103 | "Tms14": null, 104 | "V14": null, 105 | "Tms15": null, 106 | "V15": null, 107 | "Tms16": null, 108 | "V16": null, 109 | "Tms17": null, 110 | "V17": null, 111 | "Tms18": null, 112 | "V18": null, 113 | "Tms19": null, 114 | "V19": null, 115 | "Tms20": null, 116 | "V20": null, 117 | "CrvNam": null, 118 | "ReadOnly": 0 119 | }, 120 | { 121 | "ActPt": 0, 122 | "Tms1": 0, 123 | "V1": 0, 124 | "Tms2": 0, 125 | "V2": 0, 126 | "Tms3": 0, 127 | "V3": 0, 128 | "Tms4": 0, 129 | "V4": 0, 130 | "Tms5": 0, 131 | "V5": 0, 132 | "Tms6": 0, 133 | "V6": 0, 134 | "Tms7": 0, 135 | "V7": 0, 136 | "Tms8": 0, 137 | "V8": 0, 138 | "Tms9": 0, 139 | "V9": 0, 140 | "Tms10": 0, 141 | "V10": 0, 142 | "Tms11": null, 143 | "V11": null, 144 | "Tms12": null, 145 | "V12": null, 146 | "Tms13": null, 147 | "V13": null, 148 | "Tms14": null, 149 | "V14": null, 150 | "Tms15": null, 151 | "V15": null, 152 | "Tms16": null, 153 | "V16": null, 154 | "Tms17": null, 155 | "V17": null, 156 | "Tms18": null, 157 | "V18": null, 158 | "Tms19": null, 159 | "V19": null, 160 | "Tms20": null, 161 | "V20": null, 162 | "CrvNam": null, 163 | "ReadOnly": 0 164 | }, 165 | { 166 | "ActPt": 0, 167 | "Tms1": 0, 168 | "V1": 0, 169 | "Tms2": 0, 170 | "V2": 0, 171 | "Tms3": 0, 172 | "V3": 0, 173 | "Tms4": 0, 174 | "V4": 0, 175 | "Tms5": 0, 176 | "V5": 0, 177 | "Tms6": 0, 178 | "V6": 0, 179 | "Tms7": 0, 180 | "V7": 0, 181 | "Tms8": 0, 182 | "V8": 0, 183 | "Tms9": 0, 184 | "V9": 0, 185 | "Tms10": 0, 186 | "V10": 0, 187 | "Tms11": null, 188 | "V11": null, 189 | "Tms12": null, 190 | "V12": null, 191 | "Tms13": null, 192 | "V13": null, 193 | "Tms14": null, 194 | "V14": null, 195 | "Tms15": null, 196 | "V15": null, 197 | "Tms16": null, 198 | "V16": null, 199 | "Tms17": null, 200 | "V17": null, 201 | "Tms18": null, 202 | "V18": null, 203 | "Tms19": null, 204 | "V19": null, 205 | "Tms20": null, 206 | "V20": null, 207 | "CrvNam": null, 208 | "ReadOnly": 0 209 | } 210 | ] 211 | } 212 | ] 213 | } 214 | -------------------------------------------------------------------------------- /sunspec2/tests/test_data/smdx_304.csv: -------------------------------------------------------------------------------- 1 | Address Offset,Group Offset,Name,Value,Count,Type,Size,Scale Factor,Units,RW Access (RW),Mandatory (M),Static (S),Label,Description,Detailed Description,Standards 2 | ,,inclinometer,,,group,,,,,,,Inclinometer Model,Include to support orientation measurements,, 3 | 0,,ID,304,,uint16,,,,,M,S,Model ID,Model identifier,, 4 | 1,,L,,,uint16,,,,,M,S,Model Length,Model length,, 5 | ,,inclinometer.incl,,0,group,,,,,,,,,, 6 | ,0,Inclx,,,int32,,-2,Degrees,,M,,X,X-Axis inclination,, 7 | ,2,Incly,,,int32,,-2,Degrees,,,,Y,Y-Axis inclination,, 8 | ,4,Inclz,,,int32,,-2,Degrees,,,,Z,Z-Axis inclination,, 9 | -------------------------------------------------------------------------------- /sunspec2/tests/test_data/wb_701-705.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunspec/pysunspec2/d42ed2d1cc9647d2591a9a75caba1e1fa05c437e/sunspec2/tests/test_data/wb_701-705.xlsx -------------------------------------------------------------------------------- /sunspec2/tests/test_mb.py: -------------------------------------------------------------------------------- 1 | import sunspec2.mb as mb 2 | import pytest 3 | 4 | 5 | def test_create_unimpl_value(): 6 | with pytest.raises(ValueError): 7 | mb.create_unimpl_value(None) 8 | 9 | with pytest.raises(ValueError): 10 | mb.create_unimpl_value('string') 11 | 12 | assert mb.create_unimpl_value('string', len=8) == b'\x00\x00\x00\x00\x00\x00\x00\x00' 13 | assert mb.create_unimpl_value('ipv6addr') == b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' 14 | assert mb.create_unimpl_value('int16') == b'\x80\x00' 15 | assert mb.create_unimpl_value('uint16') == b'\xff\xff' 16 | assert mb.create_unimpl_value('acc16') == b'\x00\x00' 17 | assert mb.create_unimpl_value('enum16') == b'\xff\xff' 18 | assert mb.create_unimpl_value('bitfield16') == b'\xff\xff' 19 | assert mb.create_unimpl_value('int32') == b'\x80\x00\x00\x00' 20 | assert mb.create_unimpl_value('uint32') == b'\xff\xff\xff\xff' 21 | assert mb.create_unimpl_value('acc32') == b'\x00\x00\x00\x00' 22 | assert mb.create_unimpl_value('enum32') == b'\xff\xff\xff\xff' 23 | assert mb.create_unimpl_value('bitfield32') == b'\xff\xff\xff\xff' 24 | assert mb.create_unimpl_value('ipaddr') == b'\x00\x00\x00\x00' 25 | assert mb.create_unimpl_value('int64') == b'\x80\x00\x00\x00\x00\x00\x00\x00' 26 | assert mb.create_unimpl_value('uint64') == b'\xff\xff\xff\xff\xff\xff\xff\xff' 27 | assert mb.create_unimpl_value('acc64') == b'\x00\x00\x00\x00\x00\x00\x00\x00' 28 | assert mb.create_unimpl_value('float32') == b'N\xff\x80\x00' 29 | assert mb.create_unimpl_value('sunssf') == b'\x80\x00' 30 | assert mb.create_unimpl_value('eui48') == b'\x00\x00\xff\xff\xff\xff\xff\xff' 31 | assert mb.create_unimpl_value('pad') == b'\x00\x00' 32 | 33 | 34 | def test_data_to_s16(): 35 | assert mb.data_to_s16(b'\x13\x88') == 5000 36 | 37 | 38 | def test_data_to_u16(): 39 | assert mb.data_to_u16(b'\x27\x10') == 10000 40 | 41 | 42 | def test_data_to_s32(): 43 | assert mb.data_to_s32(b'\x12\x34\x56\x78') == 305419896 44 | assert mb.data_to_s32(b'\xED\xCB\xA9\x88') == -305419896 45 | 46 | 47 | def test_data_to_u32(): 48 | assert mb.data_to_u32(b'\x12\x34\x56\x78') == 305419896 49 | 50 | 51 | def test_data_to_s64(): 52 | assert mb.data_to_s64(b'\x12\x34\x56\x78\x12\x34\x56\x78') == 1311768465173141112 53 | assert mb.data_to_s64(b'\xED\xCB\xA9\x87\xED\xCB\xA9\x88') == -1311768465173141112 54 | 55 | 56 | def test_data_to_u64(): 57 | assert mb.data_to_u64(b'\xff\xff\xff\xff\xff\xff\xff\xff') == 18446744073709551615 58 | 59 | 60 | def test_data_to_ipv6addr(): 61 | assert mb.data_to_ipv6addr(b'\x20\x01\x0d\xb8\x85\xa3\x00\x00\x00\x00\x8a\x2e\x03\x70\x73\x34') == '20010DB8:85A30000:00008A2E:03707334' 62 | 63 | 64 | def test_data_to_eui48(): 65 | # need test to test for python 2 66 | assert mb.data_to_eui48(b'\x00\x00\x12\x34\x56\x78\x90\xAB') == '12:34:56:78:90:AB' 67 | 68 | 69 | def test_data_to_f64(): 70 | assert mb.data_to_f64(b'\x44\x9a\x43\xf3\x00\x00\x00\x00') == 3.1008742600725133e+22 71 | 72 | 73 | def test_data_to_str(): 74 | assert mb.data_to_str(b'test') == 'test' 75 | assert mb.data_to_str(b'444444') == '444444' 76 | 77 | 78 | def test_s16_to_data(): 79 | assert mb.s16_to_data(5000) == b'\x13\x88' 80 | 81 | 82 | def test_u16_to_data(): 83 | assert mb.u16_to_data(10000) == b'\x27\x10' 84 | 85 | 86 | def test_s32_to_data(): 87 | assert mb.s32_to_data(305419896) == b'\x12\x34\x56\x78' 88 | assert mb.s32_to_data(-305419896) == b'\xED\xCB\xA9\x88' 89 | 90 | 91 | def test_u32_to_data(): 92 | assert mb.u32_to_data(305419896) == b'\x12\x34\x56\x78' 93 | 94 | 95 | def test_s64_to_data(): 96 | assert mb.s64_to_data(1311768465173141112) == b'\x12\x34\x56\x78\x12\x34\x56\x78' 97 | assert mb.s64_to_data(-1311768465173141112) == b'\xED\xCB\xA9\x87\xED\xCB\xA9\x88' 98 | 99 | 100 | def test_u64_to_data(): 101 | assert mb.u64_to_data(18446744073709551615) == b'\xff\xff\xff\xff\xff\xff\xff\xff' 102 | 103 | 104 | def test_ipv6addr_to_data(): 105 | assert mb.ipv6addr_to_data('20010DB8:85A30000:00008A2E:03707334') == \ 106 | b'\x20\x01\x0d\xb8\x85\xa3\x00\x00\x00\x00\x8a\x2e\x03\x70\x73\x34' 107 | # need additional test to test for python 2 108 | 109 | 110 | def test_f32_to_data(): 111 | assert mb.f32_to_data(32500.43359375) == b'F\xfd\xe8\xde' 112 | 113 | 114 | def test_f64_to_data(): 115 | assert mb.f64_to_data(3.1008742600725133e+22) == b'\x44\x9a\x43\xf3\x00\x00\x00\x00' 116 | 117 | 118 | def test_str_to_data(): 119 | assert mb.str_to_data('test') == b'test' 120 | assert mb.str_to_data('444444') == b'444444' 121 | assert mb.str_to_data('test', 5) == b'test\x00' 122 | 123 | 124 | def test_eui48_to_data(): 125 | assert mb.eui48_to_data('12:34:56:78:90:AB') == b'\x00\x00\x12\x34\x56\x78\x90\xAB' 126 | 127 | 128 | def test_is_impl_int16(): 129 | assert not mb.is_impl_int16(-32768) 130 | assert mb.is_impl_int16(1111) 131 | assert mb.is_impl_int16(None) 132 | 133 | 134 | def test_is_impl_uint16(): 135 | assert not mb.is_impl_uint16(0xffff) 136 | assert mb.is_impl_uint16(0x1111) 137 | 138 | 139 | def test_is_impl_acc16(): 140 | assert not mb.is_impl_acc16(0) 141 | assert mb.is_impl_acc16(1111) 142 | 143 | 144 | def test_is_impl_enum16(): 145 | assert not mb.is_impl_enum16(0xffff) 146 | assert mb.is_impl_enum16(0x1111) 147 | 148 | 149 | def test_is_impl_bitfield16(): 150 | assert not mb.is_impl_bitfield16(0xffff) 151 | assert mb.is_impl_bitfield16(0x1111) 152 | 153 | 154 | def test_is_impl_int32(): 155 | assert not mb.is_impl_int32(-2147483648) 156 | assert mb.is_impl_int32(1111111) 157 | 158 | 159 | def test_is_impl_uint32(): 160 | assert not mb.is_impl_uint32(0xffffffff) 161 | assert mb.is_impl_uint32(0x11111111) 162 | 163 | 164 | def test_is_impl_acc32(): 165 | assert not mb.is_impl_acc32(0) 166 | assert mb.is_impl_acc32(1) 167 | 168 | 169 | def test_is_impl_enum32(): 170 | assert not mb.is_impl_enum32(0xffffffff) 171 | assert mb.is_impl_enum32(0x11111111) 172 | 173 | 174 | def test_is_impl_bitfield32(): 175 | assert not mb.is_impl_bitfield32(0xffffffff) 176 | assert mb.is_impl_bitfield32(0x11111111) 177 | 178 | 179 | def test_is_impl_ipaddr(): 180 | assert not mb.is_impl_ipaddr(0) 181 | assert mb.is_impl_ipaddr('192.168.0.1') 182 | 183 | 184 | def test_is_impl_int64(): 185 | assert not mb.is_impl_int64(-9223372036854775808) 186 | assert mb.is_impl_int64(111111111111111) 187 | 188 | 189 | def test_is_impl_uint64(): 190 | assert not mb.is_impl_uint64(0xffffffffffffffff) 191 | assert mb.is_impl_uint64(0x1111111111111111) 192 | 193 | 194 | def test_is_impl_acc64(): 195 | assert not mb.is_impl_acc64(0) 196 | assert mb.is_impl_acc64(1) 197 | 198 | 199 | def test_is_impl_ipv6addr(): 200 | assert not mb.is_impl_ipv6addr('\0') 201 | assert mb.is_impl_ipv6addr(b'\x20\x01\x0d\xb8\x85\xa3\x00\x00\x00\x00\x8a\x2e\x03\x70\x73\x34') 202 | 203 | 204 | def test_is_impl_float32(): 205 | assert not mb.is_impl_float32(None) 206 | assert mb.is_impl_float32(0x123456) 207 | 208 | 209 | def test_is_impl_string(): 210 | assert not mb.is_impl_string('\0') 211 | assert mb.is_impl_string(b'\x74\x65\x73\x74') 212 | 213 | 214 | def test_is_impl_sunssf(): 215 | assert not mb.is_impl_sunssf(-32768) 216 | assert mb.is_impl_sunssf(30000) 217 | 218 | 219 | def test_is_impl_eui48(): 220 | assert not mb.is_impl_eui48('FF:FF:FF:FF:FF:FF') 221 | assert mb.is_impl_eui48('00:00:00:00:00:00') 222 | -------------------------------------------------------------------------------- /sunspec2/tests/test_mdef.py: -------------------------------------------------------------------------------- 1 | import sunspec2.mdef as mdef 2 | import json 3 | import copy 4 | import pytest 5 | 6 | 7 | def test_to_int(): 8 | assert mdef.to_int('4') == 4 9 | assert isinstance(mdef.to_int('4'), int) 10 | assert isinstance(mdef.to_int(4.0), int) 11 | 12 | 13 | def test_to_str(): 14 | assert mdef.to_str(4) == '4' 15 | assert isinstance(mdef.to_str('4'), str) 16 | 17 | 18 | def test_to_float(): 19 | assert mdef.to_float('4') == 4.0 20 | assert isinstance(mdef.to_float('4'), float) 21 | assert mdef.to_float('z') is None 22 | 23 | 24 | def test_to_number_type(): 25 | assert mdef.to_number_type('4') == 4 26 | assert mdef.to_number_type('4.0') == 4.0 27 | assert mdef.to_number_type('z') == 'z' 28 | 29 | 30 | def test_validate_find_point(): 31 | with open('./sunspec2/models/json/model_702.json') as f: 32 | model_json = json.load(f) 33 | 34 | assert mdef.validate_find_point(model_json['group'], 'ID') == model_json['group']['points'][0] 35 | assert mdef.validate_find_point(model_json['group'], 'abc') is None 36 | 37 | 38 | def test_validate_attrs(): 39 | with open('./sunspec2/models/json/model_701.json') as f: 40 | model_json = json.load(f) 41 | 42 | # model 43 | assert mdef.validate_attrs(model_json, mdef.model_attr) == '' 44 | 45 | model_unexp_attr_err = copy.deepcopy(model_json) 46 | model_unexp_attr_err['abc'] = 'def' 47 | assert mdef.validate_attrs(model_unexp_attr_err, mdef.model_attr)[0:37] == 'Unexpected model definition attribute' 48 | 49 | model_unexp_type_err = copy.deepcopy(model_json) 50 | model_unexp_type_err['id'] = '701' 51 | assert mdef.validate_attrs(model_unexp_type_err, mdef.model_attr)[0:15] == 'Unexpected type' 52 | 53 | model_attr_missing = copy.deepcopy(model_json) 54 | del model_attr_missing['id'] 55 | assert mdef.validate_attrs(model_attr_missing, mdef.model_attr)[0:27] == 'Mandatory attribute missing' 56 | 57 | # group 58 | assert mdef.validate_attrs(model_json['group'], mdef.group_attr) == '' 59 | group_unexp_attr_err = copy.deepcopy(model_json)['group'] 60 | group_unexp_attr_err['abc'] = 'def' 61 | assert mdef.validate_attrs(group_unexp_attr_err, mdef.group_attr)[0:37] == 'Unexpected model definition attribute' 62 | 63 | group_unexp_type_err = copy.deepcopy(model_json)['group'] 64 | group_unexp_type_err['name'] = 1 65 | assert mdef.validate_attrs(group_unexp_type_err, mdef.group_attr)[0:15] == 'Unexpected type' 66 | 67 | group_attr_missing = copy.deepcopy(model_json)['group'] 68 | del group_attr_missing['name'] 69 | assert mdef.validate_attrs(group_attr_missing, mdef.group_attr)[0:27] == 'Mandatory attribute missing' 70 | 71 | # point 72 | assert mdef.validate_attrs(model_json['group']['points'][0], mdef.point_attr) == '' 73 | 74 | point_unexp_attr_err = copy.deepcopy(model_json)['group']['points'][0] 75 | point_unexp_attr_err['abc'] = 'def' 76 | assert mdef.validate_attrs(point_unexp_attr_err, mdef.point_attr)[0:37] == 'Unexpected model definition attribute' 77 | 78 | point_unexp_type_err = copy.deepcopy(model_json)['group']['points'][0] 79 | point_unexp_type_err['name'] = 1 80 | assert mdef.validate_attrs(point_unexp_type_err, mdef.point_attr)[0:15] == 'Unexpected type' 81 | 82 | point_unexp_value_err = copy.deepcopy(model_json)['group']['points'][1] 83 | point_unexp_value_err['access'] = 'z' 84 | assert mdef.validate_attrs(point_unexp_value_err, mdef.point_attr)[0:16] == 'Unexpected value' 85 | 86 | point_attr_missing = copy.deepcopy(model_json)['group']['points'][0] 87 | del point_attr_missing['name'] 88 | assert mdef.validate_attrs(point_attr_missing, mdef.point_attr)[0:27] == 'Mandatory attribute missing' 89 | 90 | # symbol 91 | assert mdef.validate_attrs(model_json['group']['points'][2]['symbols'][0], mdef.symbol_attr) == '' 92 | 93 | symbol_unexp_attr_err = copy.deepcopy(model_json)['group']['points'][2]['symbols'][0] 94 | symbol_unexp_attr_err['abc'] = 'def' 95 | assert mdef.validate_attrs(symbol_unexp_attr_err, mdef.symbol_attr)[0:37] == 'Unexpected model definition attribute' 96 | 97 | symbol_unexp_type_err = copy.deepcopy(model_json)['group']['points'][2]['symbols'][0] 98 | symbol_unexp_type_err['name'] = 1 99 | assert mdef.validate_attrs(symbol_unexp_type_err, mdef.symbol_attr)[0:15] == 'Unexpected type' 100 | 101 | symbol_attr_missing = copy.deepcopy(model_json)['group']['points'][2]['symbols'][0] 102 | del symbol_attr_missing['name'] 103 | assert mdef.validate_attrs(symbol_attr_missing, mdef.symbol_attr)[0:27] == 'Mandatory attribute missing' 104 | 105 | 106 | def test_validate_group_point_dup(): 107 | with open('./sunspec2/models/json/model_704.json') as f: 108 | model_json = json.load(f) 109 | 110 | assert mdef.validate_group_point_dup(model_json['group']) == '' 111 | 112 | dup_group_id_model = copy.deepcopy(model_json) 113 | dup_group_id_group = dup_group_id_model['group'] 114 | dup_group_id_group['groups'][0]['name'] = 'PFWInjRvrt' 115 | assert mdef.validate_group_point_dup(dup_group_id_group)[0:18] == 'Duplicate group id' 116 | 117 | dup_group_point_id_model = copy.deepcopy(model_json) 118 | dup_group_point_id_group = dup_group_point_id_model['group'] 119 | dup_group_point_id_group['groups'][0]['name'] = 'PFWInjEna' 120 | assert mdef.validate_group_point_dup(dup_group_point_id_group)[0:28] == 'Duplicate group and point id' 121 | 122 | mand_attr_miss_model = copy.deepcopy(model_json) 123 | mand_attr_miss_group = mand_attr_miss_model['group'] 124 | del mand_attr_miss_group['groups'][0]['name'] 125 | assert mdef.validate_group_point_dup(mand_attr_miss_group)[0:32] == 'Mandatory name attribute missing' 126 | 127 | dup_point_id_model = copy.deepcopy(model_json) 128 | dup_point_id_group = dup_point_id_model['group'] 129 | dup_point_id_group['points'][1]['name'] = 'ID' 130 | assert mdef.validate_group_point_dup(dup_point_id_group)[0:30] == 'Duplicate point id ID in group' 131 | 132 | mand_attr_miss_point_model = copy.deepcopy(model_json) 133 | mand_attr_miss_point_group = mand_attr_miss_point_model['group'] 134 | del mand_attr_miss_point_group['points'][1]['name'] 135 | assert mdef.validate_group_point_dup(mand_attr_miss_point_group)[0:55] == 'Mandatory attribute missing in point ' \ 136 | 'definition element' 137 | 138 | 139 | def test_validate_symbols(): 140 | symbols = [ 141 | {'name': 'CAT_A', 'value': 1}, 142 | {'name': 'CAT_B', 'value': 2} 143 | ] 144 | assert mdef.validate_symbols(symbols, mdef.symbol_attr) == '' 145 | 146 | 147 | def test_validate_sf(): 148 | with open('./sunspec2/models/json/model_702.json') as f: 149 | model_json = json.load(f) 150 | 151 | model_point = model_json['group']['points'][2] 152 | model_group = model_json['group'] 153 | model_group_arr = [model_group, model_group] 154 | assert mdef.validate_sf(model_point, 'W_SF', model_group_arr) == '' 155 | 156 | not_sf_type_model = copy.deepcopy(model_json) 157 | not_sf_type_point = not_sf_type_model['group']['points'][2] 158 | not_sf_type_group = not_sf_type_model['group'] 159 | not_sf_type_group_arr = [not_sf_type_group, not_sf_type_group] 160 | for point in not_sf_type_model['group']['points']: 161 | if point['name'] == 'W_SF': 162 | point['type'] = 'abc' 163 | assert mdef.validate_sf(not_sf_type_point, 'W_SF', not_sf_type_group_arr)[0:60] == 'Scale factor W_SF for point ' \ 164 | 'WMaxRtg is not scale factor ' \ 165 | 'type' 166 | 167 | sf_not_found_model = copy.deepcopy(model_json) 168 | sf_not_found_point = sf_not_found_model['group']['points'][2] 169 | sf_not_found_group = sf_not_found_model['group'] 170 | sf_not_found_group_arr = [sf_not_found_group, sf_not_found_group] 171 | assert mdef.validate_sf(sf_not_found_point, 'ABC', sf_not_found_group_arr)[0:44] == 'Scale factor ABC for point ' \ 172 | 'WMaxRtg not found' 173 | 174 | sf_out_range_model = copy.deepcopy(model_json) 175 | sf_out_range_point = sf_out_range_model['group']['points'][2] 176 | sf_out_range_group = sf_out_range_model['group'] 177 | sf_out_range_group_arr = [sf_out_range_group, sf_out_range_group] 178 | assert mdef.validate_sf(sf_out_range_point, 11, sf_out_range_group_arr)[0:46] == 'Scale factor 11 for point ' \ 179 | 'WMaxRtg out of range' 180 | 181 | sf_invalid_type_model = copy.deepcopy(model_json) 182 | sf_invalid_type_point = sf_invalid_type_model['group']['points'][2] 183 | sf_invalid_type_group = sf_invalid_type_model['group'] 184 | sf_invalid_type_group_arr = [sf_invalid_type_group, sf_invalid_type_group] 185 | assert mdef.validate_sf(sf_invalid_type_point, 4.0, sf_invalid_type_group_arr)[0:51] == 'Scale factor 4.0 for' \ 186 | ' point WMaxRtg has ' \ 187 | 'invalid type' 188 | 189 | 190 | def test_validate_point_def(): 191 | with open('./sunspec2/models/json/model_702.json') as f: 192 | model_json = json.load(f) 193 | 194 | model_group = model_json['group'] 195 | group = model_json['group'] 196 | point = model_json['group']['points'][0] 197 | assert mdef.validate_point_def(point, model_group, group) == '' 198 | 199 | unk_point_type_model = copy.deepcopy(model_json) 200 | unk_point_type_model_group = unk_point_type_model['group'] 201 | unk_point_type_group = unk_point_type_model['group'] 202 | unk_point_type_point = unk_point_type_model['group']['points'][0] 203 | unk_point_type_point['type'] = 'abc' 204 | assert mdef.validate_point_def(unk_point_type_point, unk_point_type_model_group, 205 | unk_point_type_group)[0:35] == 'Unknown point type abc for point ID' 206 | 207 | dup_symbol_model = copy.deepcopy(model_json) 208 | dup_symbol_model_group = dup_symbol_model['group'] 209 | dup_symbol_group = dup_symbol_model['group'] 210 | dup_symbol_point = dup_symbol_model['group']['points'][21] 211 | dup_symbol_point['symbols'][0]['name'] = 'CAT_B' 212 | assert mdef.validate_point_def(dup_symbol_point, dup_symbol_model_group, 213 | dup_symbol_group)[0:19] == 'Duplicate symbol id' 214 | 215 | mand_attr_missing = copy.deepcopy(model_json) 216 | mand_attr_missing_model_group = mand_attr_missing['group'] 217 | mand_attr_missing_group = mand_attr_missing['group'] 218 | mand_attr_missing_point = mand_attr_missing['group']['points'][0] 219 | del mand_attr_missing_point['name'] 220 | assert mdef.validate_point_def(mand_attr_missing_point, mand_attr_missing_model_group, 221 | mand_attr_missing_group)[0:27] == 'Mandatory attribute missing' 222 | 223 | 224 | def test_validate_group_def(): 225 | with open('./sunspec2/models/json/model_702.json') as f: 226 | model_json = json.load(f) 227 | 228 | assert mdef.validate_group_def(model_json['group'], model_json['group']) == '' 229 | 230 | 231 | def test_validate_model_group_def(): 232 | with open('./sunspec2/models/json/model_702.json') as f: 233 | model_json = json.load(f) 234 | 235 | assert mdef.validate_model_group_def(model_json, model_json['group']) == '' 236 | 237 | missing_id_model = copy.deepcopy(model_json) 238 | missing_id_group = missing_id_model['group'] 239 | missing_id_group['points'][0]['name'] = 'abc' 240 | assert mdef.validate_model_group_def(missing_id_model, missing_id_group)[0:41] == 'First point in top-level' \ 241 | ' group must be ID' 242 | 243 | wrong_model_id_model = copy.deepcopy(model_json) 244 | wrong_model_id_group = wrong_model_id_model['group'] 245 | wrong_model_id_group['points'][0]['value'] = 0 246 | assert mdef.validate_model_group_def(wrong_model_id_model, wrong_model_id_group)[0:42] == 'Model ID does not ' \ 247 | 'match top-level group ID' 248 | 249 | missing_len_model = copy.deepcopy(model_json) 250 | missing_len_group = missing_len_model['group'] 251 | missing_len_group['points'][1]['name'] = 'abc' 252 | assert mdef.validate_model_group_def(missing_len_model, missing_len_group)[0:41] == 'Second point in top-level ' \ 253 | 'group must be L' 254 | 255 | missing_two_p_model = copy.deepcopy(model_json) 256 | missing_two_p_group = missing_two_p_model['group'] 257 | missing_two_p_point = missing_two_p_group['points'][0] 258 | del missing_two_p_group['points'] 259 | missing_two_p_group['points'] = [missing_two_p_point] 260 | assert mdef.validate_model_group_def(missing_two_p_model, missing_two_p_group)[0:48] == 'Top-level group must' \ 261 | ' contain at least two ' \ 262 | 'points' 263 | 264 | missing_p_def_model = copy.deepcopy(model_json) 265 | missing_p_def_group = missing_p_def_model['group'] 266 | del missing_p_def_group['points'] 267 | assert mdef.validate_model_group_def(missing_p_def_model, missing_p_def_group)[0:41] == 'Top-level group' \ 268 | ' missing point definitions' 269 | 270 | 271 | def test_validate_model_def(): 272 | with open('./sunspec2/models/json/model_702.json') as f: 273 | model_json = json.load(f) 274 | 275 | assert mdef.validate_model_def(model_json) == '' 276 | 277 | 278 | def test_from_json_str(): 279 | with open('./sunspec2/models/json/model_63001.json') as f: 280 | model_json = json.load(f) 281 | model_json_str = json.dumps(model_json) 282 | assert isinstance(mdef.from_json_str(model_json_str), dict) 283 | 284 | 285 | def test_from_json_file(): 286 | assert isinstance(mdef.from_json_file('./sunspec2/models/json/model_63001.json'), dict) 287 | 288 | 289 | def test_to_json_str(): 290 | with open('./sunspec2/models/json/model_63001.json') as f: 291 | model_json = json.load(f) 292 | assert isinstance(mdef.to_json_str(model_json), str) 293 | 294 | 295 | def test_to_json_filename(): 296 | assert mdef.to_json_filename('63001') == 'model_63001.json' 297 | 298 | 299 | def test_to_json_file(tmp_path): 300 | with open('./sunspec2/models/json/model_63001.json') as f: 301 | model_json = json.load(f) 302 | mdef.to_json_file(model_json, filedir=tmp_path) 303 | 304 | with open(tmp_path / 'model_63001.json') as f: 305 | model_json = json.load(f) 306 | assert isinstance(model_json, dict) 307 | 308 | 309 | def test_model_filename_to_id(): 310 | assert mdef.model_filename_to_id('model_00077.json') == 77 311 | with pytest.raises(Exception) as exc: 312 | mdef.model_filename_to_id('model_abc.json') 313 | assert 'Error extracting model id from filename' in str(exc.value) 314 | -------------------------------------------------------------------------------- /sunspec2/tests/test_modbus_modbus.py: -------------------------------------------------------------------------------- 1 | import sunspec2.modbus.modbus as modbus_client 2 | import pytest 3 | import socket 4 | import serial 5 | import sunspec2.tests.mock_socket as MockSocket 6 | import sunspec2.tests.mock_port as MockPort 7 | 8 | 9 | def test_modbus_rtu_client(monkeypatch): 10 | monkeypatch.setattr(serial, 'Serial', MockPort.mock_port) 11 | c = modbus_client.modbus_rtu_client('COMM2') 12 | assert c.baudrate == 9600 13 | assert c.parity == "N" 14 | assert modbus_client.modbus_rtu_clients['COMM2'] 15 | 16 | with pytest.raises(modbus_client.ModbusClientError) as exc1: 17 | c2 = modbus_client.modbus_rtu_client('COMM2', baudrate=99) 18 | assert 'Modbus client baudrate mismatch' in str(exc1.value) 19 | 20 | with pytest.raises(modbus_client.ModbusClientError) as exc2: 21 | c2 = modbus_client.modbus_rtu_client('COMM2', parity='E') 22 | assert 'Modbus client parity mismatch' in str(exc2.value) 23 | 24 | 25 | def test_modbus_rtu_client_remove(monkeypatch): 26 | monkeypatch.setattr(serial, 'Serial', MockPort.mock_port) 27 | c = modbus_client.modbus_rtu_client('COMM2') 28 | assert modbus_client.modbus_rtu_clients['COMM2'] 29 | modbus_client.modbus_rtu_client_remove('COMM2') 30 | assert modbus_client.modbus_rtu_clients.get('COMM2') is None 31 | 32 | 33 | def test___generate_crc16_table(): 34 | pass 35 | 36 | 37 | def test_computeCRC(): 38 | pass 39 | 40 | 41 | def test_checkCRC(): 42 | pass 43 | 44 | 45 | class TestModbusClientRTU: 46 | def test___init__(self, monkeypatch): 47 | monkeypatch.setattr(serial, 'Serial', MockPort.mock_port) 48 | c = modbus_client.ModbusClientRTU(name="COM2") 49 | assert c.name == "COM2" 50 | assert c.baudrate == 9600 51 | assert c.parity is None 52 | assert c.serial is not None 53 | assert c.timeout == .5 54 | assert c.write_timeout == .5 55 | assert not c.devices 56 | 57 | def test_open(self, monkeypatch): 58 | monkeypatch.setattr(serial, 'Serial', MockPort.mock_port) 59 | c = modbus_client.ModbusClientRTU(name="COM2") 60 | c.open() 61 | assert c.serial.connected 62 | 63 | def test_close(self, monkeypatch): 64 | monkeypatch.setattr(serial, 'Serial', MockPort.mock_port) 65 | c = modbus_client.ModbusClientRTU(name="COM2") 66 | c.open() 67 | c.close() 68 | assert not c.serial.connected 69 | 70 | def test_add_device(self, monkeypatch): 71 | monkeypatch.setattr(serial, 'Serial', MockPort.mock_port) 72 | c = modbus_client.ModbusClientRTU(name="COM2") 73 | c.add_device(1, "1") 74 | assert c.devices.get(1) is not None 75 | assert c.devices[1] == "1" 76 | 77 | def test_remove_device(self, monkeypatch): 78 | monkeypatch.setattr(serial, 'Serial', MockPort.mock_port) 79 | c = modbus_client.ModbusClientRTU(name="COM2") 80 | c.add_device(1, "1") 81 | assert c.devices.get(1) is not None 82 | assert c.devices[1] == "1" 83 | c.remove_device(1) 84 | assert c.devices.get(1) is None 85 | 86 | def test__read(self): 87 | pass 88 | 89 | def test_read(self, monkeypatch): 90 | monkeypatch.setattr(serial, 'Serial', MockPort.mock_port) 91 | c = modbus_client.ModbusClientRTU(name="COM2") 92 | in_buff = [b'\x01\x03\x8cSu', b'nS\x00\x01\x00BSunSpecTest\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' 93 | b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00TestDevice-1\x00\x00\x00\x00\x00' 94 | b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00opt_a_b_c\x00' 95 | b'\x00\x00\x00\x00\x00\x001.2.3\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' 96 | b'sn-123456789\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' 97 | b'\x00\x00\x00\x00\x00\x00\x01\x00\x00\xb7d'] 98 | check_req = b'\x01\x03\x9c@\x00F\xeb\xbc' 99 | c.open() 100 | c.serial._set_buffer(in_buff) 101 | 102 | check_read = in_buff[0] + in_buff[1] 103 | assert c.read(1, 40000, 70) == check_read[3:-2] 104 | assert c.serial.request[0] == check_req 105 | 106 | def test__write(self): 107 | pass 108 | 109 | def test_write(self, monkeypatch): 110 | monkeypatch.setattr(serial, 'Serial', MockPort.mock_port) 111 | c = modbus_client.ModbusClientRTU(name="COM2") 112 | c.open() 113 | data_to_write = b'v0.0.0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00sn-000\x00\x00\x00\x00\x00\x00\x00' \ 114 | b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' 115 | 116 | buffer = [b'\x01\x10\x9cl\x00', b'\x18.N'] 117 | c.serial._set_buffer(buffer) 118 | c.write(1, 40044, data_to_write) 119 | 120 | check_req = b'\x01\x10\x9cl\x00\x180v0.0.0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00sn-000\x00' \ 121 | b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' \ 122 | b'\x00\x00\x00\x00\x00\xad\xff' 123 | assert c.serial.request[0] == check_req 124 | 125 | 126 | class TestModbusClientTCP: 127 | def test___init__(self): 128 | c = modbus_client.ModbusClientTCP() 129 | assert c.slave_id == 1 130 | assert c.ipaddr == '127.0.0.1' 131 | assert c.ipport == 502 132 | assert c.timeout == 2 133 | assert c.ctx is None 134 | assert c.trace_func is None 135 | assert c.max_count == 125 136 | 137 | def test_close(self, monkeypatch): 138 | monkeypatch.setattr(socket, 'socket', MockSocket.mock_socket) 139 | 140 | c = modbus_client.ModbusClientTCP() 141 | c.connect() 142 | assert c.socket 143 | c.disconnect() 144 | assert c.socket is None 145 | 146 | def test_connect(self, monkeypatch): 147 | c = modbus_client.ModbusClientTCP() 148 | 149 | with pytest.raises(Exception) as exc: 150 | c.connect() 151 | assert 'Connection error' in str(exc.value) 152 | 153 | monkeypatch.setattr(socket, 'socket', MockSocket.mock_socket) 154 | c.connect() 155 | assert c.socket is not None 156 | assert c.socket.connected is True 157 | assert c.socket.ipaddr == '127.0.0.1' 158 | assert c.socket.ipport == 502 159 | assert c.socket.timeout == 2 160 | 161 | def test_disconnect(self, monkeypatch): 162 | monkeypatch.setattr(socket, 'socket', MockSocket.mock_socket) 163 | 164 | c = modbus_client.ModbusClientTCP() 165 | c.connect() 166 | assert c.socket 167 | c.disconnect() 168 | assert c.socket is None 169 | 170 | def test__read(self, monkeypatch): 171 | pass 172 | 173 | def test_read(self, monkeypatch): 174 | c = modbus_client.ModbusClientTCP() 175 | monkeypatch.setattr(socket, 'socket', MockSocket.mock_socket) 176 | in_buff = [b'\x00\x00\x00\x00\x00\x8f\x01\x03\x8c', b'SunS\x00\x01\x00BSunSpecTest\x00\x00\x00\x00\x00\x00' 177 | b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' 178 | b'\x00\x00TestDevice-1\x00\x00\x00\x00\x00\x00\x00\x00' 179 | b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00opt_a_b_c' 180 | b'\x00\x00\x00\x00\x00\x00\x001.2.3\x00\x00\x00\x00\x00' 181 | b'\x00\x00\x00\x00\x00\x00sn-123456789\x00\x00\x00\x00' 182 | b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' 183 | b'\x00\x00\x00\x00\x01\x00\x00'] 184 | check_req = b'\x00\x00\x00\x00\x00\x06\x01\x03\x9c@\x00F' 185 | c.connect() 186 | c.socket._set_buffer(in_buff) 187 | assert c.read(40000, 70) == in_buff[1] 188 | assert c.socket.request[0] == check_req 189 | 190 | def test__write(self, monkeypatch): 191 | pass 192 | 193 | def test_write(self, monkeypatch): 194 | c = modbus_client.ModbusClientTCP() 195 | monkeypatch.setattr(socket, 'socket', MockSocket.mock_socket) 196 | c.connect() 197 | data_to_write = b'sn-000\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' \ 198 | b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' 199 | 200 | buffer = [b'\x00\x00\x00\x00\x00\x06\x01\x10\x9c', b't\x00\x10'] 201 | c.socket._set_buffer(buffer) 202 | c.write(40052, data_to_write) 203 | 204 | check_req = b"\x00\x00\x00\x00\x00'\x01\x10\x9ct\x00\x10 sn-000\x00\x00\x00\x00\x00\x00\x00" \ 205 | b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" 206 | assert c.socket.request[0] == check_req 207 | 208 | def test_write_over_max_size(self, monkeypatch): 209 | c = modbus_client.ModbusClientTCP() 210 | monkeypatch.setattr(socket, 'socket', MockSocket.mock_socket) 211 | c.connect() 212 | data_to_write = bytearray((c.max_write_count+1)*2) 213 | data_to_write[:6] = b'sn-000' 214 | 215 | buffer = [b'\x00\x00\x00\x00\x00\x06\x01\x10\x9ct\x00\x7b', 216 | b'\x00\x00\x00\x00\x00\x06\x01\x10\x9c\xef\x00\x01'] 217 | c.socket._set_buffer(buffer) 218 | c.write(40052, data_to_write) 219 | 220 | check_req0 = b"\x00\x00\x00\x00\x00\xfd\x01" + b"\x10\x9ct\x00{\xf6" + data_to_write[:(c.max_write_count*2)] 221 | check_req1 = b"\x00\x00\x00\x00\x00\x09\x01" + b"\x10\x9c\xef\x00\x01\x02\x00\x00" 222 | assert c.socket.request[0] == check_req0 223 | assert c.socket.request[1] == check_req1 224 | -------------------------------------------------------------------------------- /sunspec2/tests/test_smdx.py: -------------------------------------------------------------------------------- 1 | import sunspec2.smdx as smdx 2 | import sunspec2.mdef as mdef 3 | import xml.etree.ElementTree as ET 4 | import pytest 5 | import copy 6 | 7 | 8 | def test_to_smdx_filename(): 9 | assert smdx.to_smdx_filename(77) == 'smdx_00077.xml' 10 | 11 | 12 | def test_model_filename_to_id(): 13 | assert smdx.model_filename_to_id('smdx_00077.xml') == 77 14 | with pytest.raises(Exception) as exc: 15 | smdx.model_filename_to_id('smdx_abc.xml') 16 | assert 'Error extracting model id from filename' in str(exc.value) 17 | 18 | 19 | def test_from_smdx_file(): 20 | smdx_304 = {'id': 304, 'group': {'name': 'inclinometer', 'type': 'group', 'points': [ 21 | {'name': 'ID', 'value': 304, 'desc': 'Model identifier', 'label': 'Model ID', 'size': 1, 'mandatory': 'M', 22 | 'static': 'S', 'type': 'uint16'}, 23 | {'name': 'L', 'desc': 'Model length', 'label': 'Model Length', 'size': 1, 'mandatory': 'M', 'static': 'S', 24 | 'type': 'uint16'}], 'groups': [{'name': 'incl', 'type': 'group', 'count': 0, 'points': [ 25 | {'name': 'Inclx', 'type': 'int32', 'size': 2, 'mandatory': 'M', 'units': 'Degrees', 'sf': -2, 'label': 'X', 26 | 'desc': 'X-Axis inclination'}, 27 | {'name': 'Incly', 'type': 'int32', 'size': 2, 'units': 'Degrees', 'sf': -2, 'label': 'Y', 28 | 'desc': 'Y-Axis inclination'}, 29 | {'name': 'Inclz', 'type': 'int32', 'size': 2, 'units': 'Degrees', 'sf': -2, 'label': 'Z', 30 | 'desc': 'Z-Axis inclination'}]}], 'label': 'Inclinometer Model', 31 | 'desc': 'Include to support orientation measurements'}} 32 | assert smdx.from_smdx_file('./sunspec2/models/smdx/smdx_00304.xml') == smdx_304 33 | 34 | 35 | def test_from_smdx_file_symbols(): 36 | mdef = smdx.from_smdx_file('sunspec2/models/smdx/smdx_00803.xml') 37 | for point_def in mdef["group"]["groups"][0]["points"]: 38 | if point_def["name"] != "StrSt": 39 | continue 40 | symbol = point_def["symbols"][1] 41 | assert symbol["name"] == "CONTACTOR_STATUS" 42 | assert symbol["label"] == "Contactor Status" 43 | assert symbol["desc"].startswith("String") 44 | assert symbol["detail"] 45 | break 46 | else: 47 | pytest.fail("Point not found") 48 | 49 | 50 | def test_from_smdx(): 51 | tree = ET.parse('./sunspec2/models/smdx/smdx_00304.xml') 52 | root = tree.getroot() 53 | 54 | mdef_not_found = copy.deepcopy(root) 55 | mdef_not_found.remove(mdef_not_found.find('model')) 56 | with pytest.raises(mdef.ModelDefinitionError): 57 | smdx.from_smdx(mdef_not_found) 58 | 59 | duplicate_fixed_btype_str = ''' 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | ''' 90 | duplicate_fixed_btype_xml = ET.fromstring(duplicate_fixed_btype_str) 91 | with pytest.raises(mdef.ModelDefinitionError): 92 | smdx.from_smdx(duplicate_fixed_btype_xml) 93 | 94 | dup_repeating_btype_str = ''' 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | ''' 123 | dup_repeating_btype_xml = ET.fromstring(dup_repeating_btype_str) 124 | with pytest.raises(mdef.ModelDefinitionError): 125 | smdx.from_smdx(dup_repeating_btype_xml) 126 | 127 | invalid_btype_root = copy.deepcopy(root) 128 | invalid_btype_root.find('model').find('block').set('type', 'abc') 129 | with pytest.raises(mdef.ModelDefinitionError): 130 | smdx.from_smdx(invalid_btype_root) 131 | 132 | dup_fixed_p_def_str = ''' 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | ''' 149 | dup_fixed_p_def_xml = ET.fromstring(dup_fixed_p_def_str) 150 | with pytest.raises(mdef.ModelDefinitionError): 151 | smdx.from_smdx(dup_fixed_p_def_xml) 152 | 153 | dup_repeating_p_def_str = ''' 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | ''' 175 | dup_repeating_p_def_xml = ET.fromstring(dup_repeating_p_def_str) 176 | with pytest.raises(mdef.ModelDefinitionError): 177 | smdx.from_smdx(dup_repeating_p_def_xml) 178 | 179 | 180 | def test_from_smdx_point(): 181 | smdx_point_str = """""" 182 | smdx_point_xml = ET.fromstring(smdx_point_str) 183 | assert smdx.from_smdx_point(smdx_point_xml) == {'name': 'Mn', 'type': 'string', 'size': 16, 'mandatory': 'M'} 184 | 185 | missing_pid_xml = copy.deepcopy(smdx_point_xml) 186 | del missing_pid_xml.attrib['id'] 187 | with pytest.raises(mdef.ModelDefinitionError): 188 | smdx.from_smdx_point(missing_pid_xml) 189 | 190 | missing_ptype = copy.deepcopy(smdx_point_xml) 191 | del missing_ptype.attrib['type'] 192 | with pytest.raises(mdef.ModelDefinitionError): 193 | smdx.from_smdx_point(missing_ptype) 194 | 195 | unk_ptype = copy.deepcopy(smdx_point_xml) 196 | unk_ptype.attrib['type'] = 'abc' 197 | with pytest.raises(mdef.ModelDefinitionError): 198 | smdx.from_smdx_point(unk_ptype) 199 | 200 | missing_len = copy.deepcopy(smdx_point_xml) 201 | del missing_len.attrib['len'] 202 | with pytest.raises(mdef.ModelDefinitionError): 203 | smdx.from_smdx_point(missing_len) 204 | 205 | unk_mand_type = copy.deepcopy(smdx_point_xml) 206 | unk_mand_type.attrib['mandatory'] = 'abc' 207 | with pytest.raises(mdef.ModelDefinitionError): 208 | smdx.from_smdx_point(unk_mand_type) 209 | 210 | unk_access_type = copy.deepcopy(smdx_point_xml) 211 | unk_access_type.attrib['access'] = 'abc' 212 | with pytest.raises(mdef.ModelDefinitionError): 213 | smdx.from_smdx_point(unk_access_type) 214 | 215 | 216 | def test_indent(): 217 | pass 218 | -------------------------------------------------------------------------------- /sunspec2/tests/test_xlsx.py: -------------------------------------------------------------------------------- 1 | import sunspec2.xlsx as xlsx 2 | import pytest 3 | import openpyxl 4 | import openpyxl.styles as styles 5 | import json 6 | 7 | 8 | def test___init__(): 9 | wb = xlsx.ModelWorkbook(filename='./sunspec2/tests/test_data/wb_701-705.xlsx') 10 | assert wb.filename == './sunspec2/tests/test_data/wb_701-705.xlsx' 11 | assert wb.params == {} 12 | 13 | wb2 = xlsx.ModelWorkbook() 14 | assert wb2.filename is None 15 | assert wb2.params == {} 16 | 17 | 18 | def test_get_models(): 19 | wb = xlsx.ModelWorkbook(filename='./sunspec2/tests/test_data/wb_701-705.xlsx') 20 | assert wb.get_models() == [701, 702, 703, 704, 705] 21 | wb2 = xlsx.ModelWorkbook() 22 | assert wb2.get_models() == [] 23 | 24 | 25 | def test_save(tmp_path): 26 | wb = xlsx.ModelWorkbook() 27 | wb.save(tmp_path / 'test.xlsx') 28 | wb2 = xlsx.ModelWorkbook(filename=tmp_path / 'test.xlsx') 29 | iter_rows = wb2.xlsx_iter_rows(wb.wb['Index']) 30 | assert next(iter_rows) == ['Model', 'Label', 'Description'] 31 | 32 | 33 | def test_xlsx_iter_rows(): 34 | wb = xlsx.ModelWorkbook(filename='./sunspec2/tests/test_data/wb_701-705.xlsx') 35 | iter_rows = wb.xlsx_iter_rows(wb.wb['704']) 36 | assert next(iter_rows) == ['Address Offset', 'Group Offset', 'Name', 37 | 'Value', 'Count', 'Type', 'Size', 'Scale Factor', 38 | 'Units', 'RW Access (RW)', 'Mandatory (M)', 'Static (S)', 39 | 'Label', 'Description', 'Detailed Description', 'Standards'] 40 | assert next(iter_rows) == [None, None, 'DERCtlAC', None, None, 'group', 41 | None, None, None, None, None, None, 'DER AC Controls', 42 | 'DER AC controls model.', None, None] 43 | 44 | 45 | def test_spreadsheet_from_xlsx(): 46 | wb = xlsx.ModelWorkbook(filename='./sunspec2/tests/test_data/wb_701-705.xlsx') 47 | assert wb.spreadsheet_from_xlsx(704)[0:2] == [['Address Offset', 'Group Offset', 'Name', 'Value', 'Count', 48 | 'Type', 'Size', 'Scale Factor', 'Units', 'RW Access (RW)', 49 | 'Mandatory (M)', 'Static (S)', 'Label', 'Description', 50 | 'Detailed Description', 'Standards'], 51 | ['', '', 'DERCtlAC', None, None, 'group', None, None, None, 52 | None, None, None, 'DER AC Controls', 'DER AC controls model.', None, 53 | None]] 54 | 55 | 56 | def sort_nested_dicts(d): 57 | for key, value in d.items(): 58 | if isinstance(value, dict): 59 | d[key] = sort_nested_dicts(value) # Sort nested dictionaries 60 | elif key == 'points' and isinstance(value, list): 61 | d[key] = sorted(value, key=lambda x: x['name']) 62 | elif isinstance(value, list) and len(value) > 1: 63 | d[key] = sorted(value, key=lambda x: sorted(x.items()) if isinstance(x, dict) else x) 64 | return dict(sorted(d.items())) 65 | 66 | 67 | # need deep diff to compare from_xlsx to json file, right now just compares with its own output 68 | def test_from_xlsx(): 69 | wb = xlsx.ModelWorkbook(filename='./sunspec2/tests/test_data/wb_701-705.xlsx') 70 | with open('./sunspec2/models/json/model_704.json') as f: 71 | from_xlsx_output = json.load(f) 72 | 73 | a = sort_nested_dicts(wb.from_xlsx(704)) 74 | b = sort_nested_dicts(from_xlsx_output) 75 | assert a == b 76 | 77 | 78 | def test_set_cell(): 79 | wb = xlsx.ModelWorkbook(filename='./sunspec2/tests/test_data/wb_701-705.xlsx') 80 | with pytest.raises(ValueError) as exc: 81 | wb.set_cell(wb.wb['704'], 1, 2, 3) 82 | assert 'Workbooks opened with existing file are read only' in str(exc.value) 83 | 84 | wb2 = xlsx.ModelWorkbook() 85 | assert wb2.set_cell(wb2.wb['Index'], 2, 1, 3, style='suns_comment').value == 3 86 | 87 | 88 | def test_set_info(): 89 | wb = xlsx.ModelWorkbook() 90 | values = [''] * 14 91 | values[13] = 'description' 92 | values[12] = 'label' 93 | wb.set_info(wb.wb['Index'], 2, values) 94 | iter_rows = wb.xlsx_iter_rows(wb.wb['Index']) 95 | next(iter_rows) 96 | assert next(iter_rows) == [None, None, None, None, None, None, 97 | None, None, None, None, None, None, 'label', 'description'] 98 | 99 | 100 | def test_set_group(): 101 | wb = xlsx.ModelWorkbook() 102 | values = [''] * 16 103 | values[2] = 'name' 104 | values[5] = 'type' 105 | values[4] = 'count' 106 | values[13] = 'description' 107 | values[12] = 'label' 108 | wb.set_group(wb.wb['Index'], 2, values, 2) 109 | iter_rows = wb.xlsx_iter_rows(wb.wb['Index']) 110 | next(iter_rows) 111 | assert next(iter_rows) == ['', '', 'name', '', 'count', 'type', '', '', '', '', '', '', 112 | 'label', 'description', '', ''] 113 | 114 | 115 | def test_set_point(): 116 | wb = xlsx.ModelWorkbook() 117 | values = [''] * 16 118 | values[0] = 'addr_offset' 119 | values[1] = 'group_offset' 120 | values[2] = 'name' 121 | values[3] = 'value' 122 | values[4] = 'count' 123 | values[5] = 'type' 124 | values[6] = 'size' 125 | values[7] = 'sf' 126 | values[8] = 'units' 127 | values[9] = 'access' 128 | values[10] = 'mandatory' 129 | values[11] = 'static' 130 | wb.set_point(wb.wb['Index'], 2, values, 1) 131 | iter_rows = wb.xlsx_iter_rows(wb.wb['Index']) 132 | next(iter_rows) 133 | assert next(iter_rows) == ['addr_offset', 'group_offset', 'name', 'value', 'count', 'type', '', 134 | 'sf', 'units', 'access', 'mandatory', 'static', '', '', '', ''] 135 | 136 | 137 | def test_set_symbol(): 138 | wb = xlsx.ModelWorkbook() 139 | values = [''] * 16 140 | values[2] = 'name' 141 | values[3] = 'value' 142 | values[12] = 'label' 143 | values[13] = 'description' 144 | wb.set_symbol(wb.wb['Index'], 2, values) 145 | iter_rows = wb.xlsx_iter_rows(wb.wb['Index']) 146 | next(iter_rows) # skip header (['Model', 'Label', 'Description', None, ...]) 147 | assert next(iter_rows) == ['', '', 'name', 'value', '', '', '', 148 | '', '', '', '', '', 'label', 'description', '', ''] 149 | 150 | 151 | def test_set_comment(): 152 | wb = xlsx.ModelWorkbook() 153 | wb.set_comment(wb.wb['Index'], 2, ['This is a comment']) 154 | iter_rows = wb.xlsx_iter_rows(wb.wb['Index']) 155 | next(iter_rows) 156 | assert next(iter_rows)[0] == 'This is a comment' 157 | 158 | 159 | def test_set_hdr(): 160 | wb = xlsx.ModelWorkbook() 161 | wb.set_hdr(wb.wb['Index'], ['This', 'is', 'a', 'test', 'header']) 162 | iter_rows = wb.xlsx_iter_rows(wb.wb['Index']) 163 | assert next(iter_rows) == ['This', 'is', 'a', 'test', 'header'] 164 | 165 | 166 | def test_spreadsheet_to_xlsx(): 167 | wb = xlsx.ModelWorkbook(filename='./sunspec2/tests/test_data/wb_701-705.xlsx') 168 | with pytest.raises(ValueError) as exc: 169 | wb.spreadsheet_to_xlsx(702, []) 170 | assert 'Workbooks opened with existing file are read only' in str(exc.value) 171 | 172 | spreadsheet_smdx_304 = [ 173 | ['Address Offset', 'Group Offset', 'Name', 'Value', 'Count', 'Type', 'Size', 'Scale Factor', 'Units', 174 | 'RW Access (RW)', 'Mandatory (M)', 'Static (S)', 'Label', 'Description', 'Detailed Description', 'Standards'], 175 | ['', '', 'inclinometer', '', '', 'group', '', '', '', '', '', '', 'Inclinometer Model', 176 | 'Include to support orientation measurements', '', ''], 177 | [0, '', 'ID', 304, '', 'uint16', '', '', '', '', 'M', 'S', 'Model ID', 'Model identifier', '', ''], 178 | [1, '', 'L', '', '', 'uint16', '', '', '', '', 'M', 'S', 'Model Length', 'Model length', '', ''], 179 | ['', '', 'inclinometer.incl', '', 0, 'group', '', '', '', '', '', '', '', '', '', ''], 180 | ['', 0, 'Inclx', '', '', 'int32', '', -2, 'Degrees', '', 'M', '', 'X', 'X-Axis inclination', '', ''], 181 | ['', 2, 'Incly', '', '', 'int32', '', -2, 'Degrees', '', '', '', 'Y', 'Y-Axis inclination', '', ''], 182 | ['', 4, 'Inclz', '', '', 'int32', '', -2, 'Degrees', '', '', '', 'Z', 'Z-Axis inclination', '', ''] 183 | ] 184 | wb2 = xlsx.ModelWorkbook() 185 | wb2.spreadsheet_to_xlsx(304, spreadsheet_smdx_304) 186 | iter_rows = wb2.xlsx_iter_rows(wb2.wb['304']) 187 | for row in spreadsheet_smdx_304: 188 | assert next(iter_rows) == row 189 | 190 | 191 | def test_to_xlsx(tmp_path): 192 | spreadsheet_smdx_304 = [ 193 | ['Address Offset', 'Group Offset', 'Name', 'Value', 'Count', 'Type', 'Size', 'Scale Factor', 'Units', 194 | 'RW Access (RW)', 'Mandatory (M)', 'Static (S)', 'Label', 'Description', 'Detailed Description', 'Standards'], 195 | ['', '', 'inclinometer', '', '', 'group', '', '', '', '', '', '', 'Inclinometer Model', 196 | 'Include to support orientation measurements', '', ''], 197 | [0, '', 'ID', 304, '', 'uint16', '', '', '', '', 'M', 'S', 'Model ID', 'Model identifier', '', ''], 198 | [1, '', 'L', '', '', 'uint16', '', '', '', '', 'M', 'S', 'Model Length', 'Model length', '', ''], 199 | ['', '', 'inclinometer.incl', '', 0, 'group', '', '', '', '', '', '', '', '', '', ''], 200 | ['', 0, 'Inclx', '', '', 'int32', '', -2, 'Degrees', '', 'M', '', 'X', 'X-Axis inclination', '', ''], 201 | ['', 2, 'Incly', '', '', 'int32', '', -2, 'Degrees', '', '', '', 'Y', 'Y-Axis inclination', '', ''], 202 | ['', 4, 'Inclz', '', '', 'int32', '', -2, 'Degrees', '', '', '', 'Z', 'Z-Axis inclination', '', ''] 203 | ] 204 | with open('./sunspec2/models/json/model_304.json') as f: 205 | m_703 = json.load(f) 206 | wb = xlsx.ModelWorkbook() 207 | wb.to_xlsx(m_703) 208 | iter_rows = wb.xlsx_iter_rows(wb.wb['304']) 209 | for row in spreadsheet_smdx_304: 210 | assert next(iter_rows) == row 211 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>=4 4 | env_list = py{37,38,39,310,311} 5 | 6 | [testenv] 7 | description = run unit tests 8 | deps = 9 | # see extra_requires in setup.cfg 10 | .[serial,excel,test] 11 | commands = 12 | pytest -rA --show-capture=all {posargs} 13 | 14 | --------------------------------------------------------------------------------