sep2tools 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ name: Python package
2
+
3
+ on: ["pull_request"]
4
+ jobs:
5
+ build:
6
+ runs-on: ubuntu-latest
7
+ strategy:
8
+ fail-fast: false
9
+ matrix:
10
+ python-version: ["3.9", "3.10", "3.11", "3.12"]
11
+ steps:
12
+ - uses: actions/checkout@v3
13
+ - name: Set up Python ${{ matrix.python-version }}
14
+ uses: actions/setup-python@v4
15
+ with:
16
+ python-version: ${{ matrix.python-version }}
17
+ - name: Install dependencies
18
+ run: |
19
+ python -m pip install --upgrade pip wheel
20
+ if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
21
+ # install the local folder
22
+ python -m pip install -e .
23
+ - name: Test with ruff
24
+ run: |
25
+ pip install ruff
26
+ ruff check .
27
+ - name: Test with pytest
28
+ run: |
29
+ pip install pytest pytest-cov
30
+ pytest --cov-report=xml
31
+ - name: Coveralls
32
+ env:
33
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
34
+ COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
35
+ run: |
36
+ pip install coveralls
37
+ coverage run --source=sep2tools -m pytest tests/
38
+ coveralls
@@ -0,0 +1,165 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # poetry
98
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102
+ #poetry.lock
103
+
104
+ # pdm
105
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106
+ #pdm.lock
107
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108
+ # in version control.
109
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
110
+ .pdm.toml
111
+ .pdm-python
112
+ .pdm-build/
113
+
114
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
115
+ __pypackages__/
116
+
117
+ # Celery stuff
118
+ celerybeat-schedule
119
+ celerybeat.pid
120
+
121
+ # SageMath parsed files
122
+ *.sage.py
123
+
124
+ # Environments
125
+ .env
126
+ .venv
127
+ env/
128
+ venv/
129
+ ENV/
130
+ env.bak/
131
+ venv.bak/
132
+
133
+ # Spyder project settings
134
+ .spyderproject
135
+ .spyproject
136
+
137
+ # Rope project settings
138
+ .ropeproject
139
+
140
+ # mkdocs documentation
141
+ /site
142
+
143
+ # mypy
144
+ .mypy_cache/
145
+ .dmypy.json
146
+ dmypy.json
147
+
148
+ # Pyre type checker
149
+ .pyre/
150
+
151
+ # pytype static type analyzer
152
+ .pytype/
153
+
154
+ # Cython debug symbols
155
+ cython_debug/
156
+
157
+ # PyCharm
158
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
159
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
160
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
161
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
162
+ #.idea/
163
+
164
+
165
+ certs/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Alex Guinman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.1
2
+ Name: sep2tools
3
+ Version: 0.1.0
4
+ Summary: Useful functions for working with IEEE 2030.5 (SEP2)
5
+ Author-email: Alex Guinman <alex@guinman.id.au>
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Operating System :: OS Independent
15
+ Requires-Dist: cryptography
16
+ Requires-Dist: asn1
17
+ Requires-Dist: python-dateutil
18
+ Requires-Dist: ruff ; extra == "test"
19
+ Requires-Dist: pytest >=2.7.3 ; extra == "test"
20
+ Requires-Dist: pytest-cov ; extra == "test"
21
+ Requires-Dist: mypy ; extra == "test"
22
+ Project-URL: Source, https://github.com/aguinane/SEP2-Tools
23
+ Provides-Extra: test
24
+
25
+ # SEP2-Tools
26
+ This library provides some useful functions for working with IEEE 2030.5 (SEP2).
27
+
@@ -0,0 +1,2 @@
1
+ # SEP2-Tools
2
+ This library provides some useful functions for working with IEEE 2030.5 (SEP2).
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["flit_core >=3.2,<4"]
3
+ build-backend = "flit_core.buildapi"
4
+
5
+ [project]
6
+ name = "sep2tools"
7
+ authors = [{ name = "Alex Guinman", email = "alex@guinman.id.au" }]
8
+ readme = "README.md"
9
+ license = { file = "LICENSE" }
10
+ classifiers = [
11
+ "License :: OSI Approved :: MIT License",
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.9",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+ requires-python = ">=3.9"
20
+ dynamic = ["version", "description"]
21
+ dependencies = ["cryptography", "asn1", "python-dateutil"]
22
+
23
+ [project.optional-dependencies]
24
+ test = ["ruff", "pytest >=2.7.3", "pytest-cov", "mypy"]
25
+
26
+ [project.urls]
27
+ Source = "https://github.com/aguinane/SEP2-Tools"
28
+
29
+ [tool.pytest.ini_options]
30
+ addopts = "-ra --failed-first --showlocals --durations=3 --cov=sep2tools"
31
+
32
+ [tool.coverage.run]
33
+ omit = ["*/version.py", '*/__main__.py']
34
+
35
+ [tool.coverage.report]
36
+ show_missing = true
37
+ skip_empty = true
38
+ fail_under = 80
39
+
40
+ [tool.ruff.lint]
41
+ select = ["A", "B", "E", "F", "I", "N", "SIM", "UP"]
@@ -0,0 +1,13 @@
1
+ """ Useful functions for working with IEEE 2030.5 (SEP2)"""
2
+
3
+ from .cert_id import get_der_certificate_lfdi, get_pem_certificate_lfdi
4
+ from .ids import generate_mrid, proxy_device_lfdi
5
+ from .version import __version__
6
+
7
+ __all__ = [
8
+ "__version__",
9
+ "get_pem_certificate_lfdi",
10
+ "get_der_certificate_lfdi",
11
+ "proxy_device_lfdi",
12
+ "generate_mrid",
13
+ ]
@@ -0,0 +1,175 @@
1
+ import logging
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import asn1
7
+ from cryptography import x509
8
+ from cryptography.hazmat.primitives import serialization
9
+ from cryptography.hazmat.primitives.asymmetric import ec
10
+ from cryptography.hazmat.primitives.hashes import SHA256
11
+ from cryptography.x509.oid import ObjectIdentifier
12
+ from dateutil import tz
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+ DEFAULT_KEY_PATH = Path("key.pem")
17
+ DEFAULT_CSR_PATH = Path("csr.pem")
18
+
19
+ # IEEE 2030.5 device type assignments (Section 6.11.7.2)
20
+ SEP2_DEV_GENERIC = ObjectIdentifier("1.3.6.1.4.1.40732.1.1")
21
+ SEP2_DEV_MOBILE = ObjectIdentifier("1.3.6.1.4.1.40732.1.2")
22
+ SEP2_DEV_POSTMANUF = ObjectIdentifier("1.3.6.1.4.1.40732.1.3")
23
+
24
+ # IEEE 2030.5 policy assignments (Section 6.11.7.3)
25
+ SEP2_TEST_CERT = ObjectIdentifier("1.3.6.1.4.1.40732.2.1")
26
+ SEP2_TEST_SELFSIGNED = ObjectIdentifier("1.3.6.1.4.1.40732.2.2")
27
+ SEP2_TEST_SERVPROV = ObjectIdentifier("1.3.6.1.4.1.40732.2.3")
28
+ SEP2_TEST_BULK_CERT = ObjectIdentifier("1.3.6.1.4.1.40732.2.4")
29
+
30
+ # HardwareModuleName (Section 6.11.7.4)
31
+ SEP2_HARDWARE_MODULE_NAME = ObjectIdentifier("1.3.6.1.5.5.7.8.4")
32
+
33
+
34
+ def generate_key_and_csr(
35
+ key_file: Path = DEFAULT_KEY_PATH, csr_file: Path = DEFAULT_CSR_PATH
36
+ ) -> tuple[Path, Path]:
37
+ """Generate a Private Key and Certificate Signing Request (CSR)"""
38
+ key = ec.generate_private_key(ec.SECP256R1)
39
+ key_pem = key.private_bytes(
40
+ encoding=serialization.Encoding.PEM,
41
+ format=serialization.PrivateFormat.PKCS8,
42
+ encryption_algorithm=serialization.NoEncryption(),
43
+ )
44
+ with open(key_file, "wb") as fh:
45
+ fh.write(key_pem)
46
+ log.info("Created Key at %s", key_file)
47
+
48
+ subject_name = ""
49
+ csr = (
50
+ x509.CertificateSigningRequestBuilder()
51
+ .subject_name(x509.Name(subject_name))
52
+ .sign(key, SHA256())
53
+ )
54
+ csr_pem = csr.public_bytes(serialization.Encoding.PEM)
55
+ with open(csr_file, "wb") as fh:
56
+ fh.write(csr_pem)
57
+ log.info("Created CSR at %s", csr_file)
58
+ return key_file, csr_file
59
+
60
+
61
+ def convert_pem_to_der(filename: Path) -> Path:
62
+ """Convert a PEM file to DER Certificate"""
63
+ with open(filename, "rb") as pem_file:
64
+ cert_data = pem_file.read()
65
+ cert = x509.load_pem_x509_certificate(cert_data)
66
+ der_data = cert.public_bytes(encoding=serialization.Encoding.DER)
67
+ output_der_path = filename.with_suffix(".der")
68
+ with open(output_der_path, "wb") as fh:
69
+ fh.write(der_data)
70
+ return output_der_path
71
+
72
+
73
+ def generate_device_certificate(
74
+ filename: Path,
75
+ csr_path: Path,
76
+ mica_cert_path: Path,
77
+ mica_key_path: Path,
78
+ hardware_type_oid: str,
79
+ hardware_serial_number: str,
80
+ policy_oids: list[ObjectIdentifier],
81
+ serca_cert_path: Optional[Path] = None,
82
+ ) -> Path:
83
+ """Use a CSR and MICA key pair to generate a SEP2 Certificate"""
84
+
85
+ with open(csr_path, "rb") as pem_file:
86
+ csr_data = pem_file.read()
87
+ csr = x509.load_pem_x509_csr(csr_data)
88
+ with open(mica_cert_path, "rb") as pem_file:
89
+ mica_pem_data = pem_file.read()
90
+ mica_cert = x509.load_pem_x509_certificate(mica_pem_data)
91
+ with open(mica_key_path, "rb") as pem_file:
92
+ pem_data = pem_file.read()
93
+ mica_key = serialization.load_pem_private_key(pem_data, password=None)
94
+ valid_from = datetime.now(tz=tz.UTC)
95
+ valid_to = datetime(9999, 12, 31, 23, 59, 59, 0) # as per standard
96
+
97
+ policies = [x509.PolicyInformation(oid, None) for oid in policy_oids]
98
+
99
+ # SAN OtherName encoder
100
+ encoder = asn1.Encoder()
101
+ encoder.start()
102
+ encoder.enter(asn1.Numbers.Sequence)
103
+ encoder.write(str(hardware_type_oid), asn1.Numbers.ObjectIdentifier)
104
+ encoder.write(str(hardware_serial_number), asn1.Numbers.OctetString)
105
+ encoder.leave()
106
+ hw_module_name = encoder.output()
107
+
108
+ sname = x509.Name("") # SubjectName should be blank
109
+
110
+ issuer_name = mica_cert.subject
111
+ iname = x509.Name(issuer_name)
112
+ cert = (
113
+ x509.CertificateBuilder()
114
+ .subject_name(sname)
115
+ .issuer_name(iname)
116
+ .public_key(csr.public_key())
117
+ .serial_number(x509.random_serial_number())
118
+ .not_valid_before(valid_from)
119
+ .not_valid_after(valid_to)
120
+ .add_extension(
121
+ x509.BasicConstraints(ca=False, path_length=None),
122
+ critical=True,
123
+ )
124
+ .add_extension(
125
+ x509.KeyUsage(
126
+ key_agreement=True,
127
+ key_cert_sign=False,
128
+ crl_sign=False,
129
+ digital_signature=True,
130
+ content_commitment=False,
131
+ key_encipherment=False,
132
+ data_encipherment=False,
133
+ encipher_only=False,
134
+ decipher_only=False,
135
+ ),
136
+ critical=True,
137
+ )
138
+ .add_extension(
139
+ x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(
140
+ mica_cert.extensions.get_extension_for_class(
141
+ x509.SubjectKeyIdentifier
142
+ ).value
143
+ ),
144
+ critical=False,
145
+ )
146
+ .add_extension(
147
+ x509.SubjectAlternativeName(
148
+ general_names=[
149
+ x509.OtherName(SEP2_HARDWARE_MODULE_NAME, hw_module_name)
150
+ ]
151
+ ),
152
+ critical=True,
153
+ )
154
+ .add_extension(
155
+ x509.CertificatePolicies(policies=policies),
156
+ critical=True,
157
+ )
158
+ .sign(mica_key, SHA256())
159
+ )
160
+
161
+ serca_pem_data = ""
162
+ if serca_cert_path:
163
+ with open(serca_cert_path, "rb") as pem_file:
164
+ serca_pem_data = pem_file.read()
165
+
166
+ cert_pem = cert.public_bytes(encoding=serialization.Encoding.PEM)
167
+ with open(filename, "wb") as fh:
168
+ fh.write(cert_pem)
169
+ # Append the intermediate certificate
170
+ fh.write(mica_pem_data)
171
+ # Append the root certificate
172
+ if serca_pem_data:
173
+ fh.write(serca_pem_data)
174
+ log.info("Created Cert %s", filename)
175
+ return filename
@@ -0,0 +1,87 @@
1
+ import hashlib
2
+ import logging
3
+ from itertools import zip_longest
4
+ from pathlib import Path
5
+
6
+ from cryptography import x509
7
+ from cryptography.hazmat.primitives import serialization
8
+
9
+ log = logging.getLogger(__name__)
10
+
11
+
12
+ def group_hex(item: str, group_size: int = 4) -> str:
13
+ """Group into groups of four"""
14
+
15
+ def grouper(iterable, n, fillvalue=None):
16
+ args = [iter(iterable)] * n
17
+ return zip_longest(*args, fillvalue=fillvalue)
18
+
19
+ return "-".join("".join(x) for x in grouper(item, n=group_size))
20
+
21
+
22
+ def get_lfdi(fingerprint: str, group: bool = True) -> str:
23
+ """Long-form device identifier (LFDI)
24
+ The LFDI SHALL be the certificate fingerprint left-truncated to 160 bits.
25
+ For display purposes, this SHALL be expressed as 40 hexadecimal digits in
26
+ groups of four.
27
+ """
28
+ lfdi = fingerprint.replace("-", "")[0:40].upper()
29
+ if not group:
30
+ return lfdi
31
+ return group_hex(lfdi)
32
+
33
+
34
+ def get_sfdi(fingerprint, group: bool = True) -> str:
35
+ """Short-form device identifier (SFDI)
36
+ The SFDI SHALL be the certificate fingerprint left-truncated to 36 bits.
37
+ For display purposes, this SHALL be expressed as 11 decimal (base 10) digits,
38
+ with an additional sum-of-digits checksum digit right concatenated.
39
+ """
40
+ trunc = fingerprint.replace("-", "")[0:9]
41
+ sfdi = int(trunc, base=16) # Convert from Base 16 to Base 10
42
+ digits = map(int, str(sfdi))
43
+ checksum = 10 - sum(digits) % 10
44
+ if checksum == 10:
45
+ checksum = 0
46
+
47
+ full_sfdi = str(sfdi) + str(checksum)
48
+ if not group:
49
+ return full_sfdi
50
+ return group_hex(full_sfdi, 3)
51
+
52
+
53
+ def get_fingerprint(cert_path: Path) -> str:
54
+ """Certificate fingerprint
55
+ The certificate fingerprint is the result of performing a SHA256 operation
56
+ over the whole DER-encoded certificate and is used to derive the SFDI and LFDI.
57
+ """
58
+ with open(cert_path, "rb") as f:
59
+ fbytes = f.read() # read entire file as bytes
60
+ readable_hash = hashlib.sha256(fbytes).hexdigest().upper()
61
+ return readable_hash
62
+
63
+
64
+ def get_der_certificate_lfdi(cert_path: Path, group: bool = True):
65
+ """Load X.509 DER Certificate and return LFDI"""
66
+
67
+ if not cert_path.exists():
68
+ raise ValueError(f"Cert not found at {cert_path}")
69
+
70
+ fingerprint = get_fingerprint(cert_path)
71
+ return get_lfdi(fingerprint, group=group)
72
+
73
+
74
+ def get_pem_certificate_lfdi(cert_path: Path, group: bool = True):
75
+ """Load X.509 DER Certificate in PEM format and return LFDI"""
76
+
77
+ if not cert_path.exists():
78
+ raise ValueError(f"Cert not found at {cert_path}")
79
+
80
+ with open(cert_path, "rb") as pem_file:
81
+ cert_data = pem_file.read()
82
+
83
+ cert = x509.load_pem_x509_certificate(cert_data)
84
+ der_bytes = cert.public_bytes(serialization.Encoding.DER)
85
+ fingerprint = hashlib.sha256(der_bytes).hexdigest().upper()
86
+ lfdi = get_lfdi(fingerprint, group=group)
87
+ return lfdi
@@ -0,0 +1,177 @@
1
+ def get_role_flag(
2
+ is_mirror=0, is_premise=0, is_pev=0, is_der=0, is_revenue=0, is_dc=0, is_submeter=0
3
+ ) -> tuple[str, str]:
4
+ """
5
+ RoleFlagsType object (HexBinary16)
6
+ Specifies the roles that apply to a usage point.
7
+ Bit 0—isMirror—SHALL be set if the server is not the measurement device
8
+ Bit 1—isPremisesAggregationPoint—SHALL be set if the UsagePoint is the
9
+ point of delivery for a premises
10
+ Bit 2—isPEV—SHALL be set if the usage applies to an electric vehicle
11
+ Bit 3—isDER—SHALL be set if the usage applies to a distributed energy resource,
12
+ capable of delivering
13
+ power to the grid
14
+ Bit 4—isRevenueQuality—SHALL be set if usage was measured by a device certified
15
+ as revenue quality
16
+ Bit 5—isDC—SHALL be set if the usage point measures direct current
17
+ Bit 6—isSubmeter—SHALL be set if the usage point is not a premises aggregation point
18
+ Bit 7 to 15—Reserved
19
+ """
20
+
21
+ a = is_mirror
22
+ b = is_premise
23
+ c = is_pev
24
+ d = is_der
25
+ e = is_revenue
26
+ f = is_dc
27
+ g = is_submeter
28
+
29
+ binary_str = f"000000000{g}{f}{e}{d}{c}{b}{a}"
30
+ val = int(binary_str, 2)
31
+ hex_str = str(hex(val))[2:].zfill(2).upper()
32
+ return binary_str, hex_str
33
+
34
+
35
+ def get_quality_flag(
36
+ valid=0, manual=0, estimate=0, interpolated=0, questionable=0, derived=0, future=0
37
+ ) -> tuple[str, str]:
38
+ """
39
+ qualityFlags (HexBinary16)
40
+ Bit 0 - valid: data that has gone through all required validation
41
+ checks and either passed them all or has been verified
42
+ Bit 1 - manually edited: Replaced or approved by a human
43
+ Bit 2 - estimated using reference day: data value was replaced
44
+ by a machine computed value based on analysis of historical
45
+ data using the same type of measurement.
46
+ Bit 3 - estimated using linear interpolation: data value was
47
+ computed using linear interpolation based on the readings
48
+ before and after it
49
+ Bit 4 - questionable: data that has failed one or more checks
50
+ Bit 5 - derived: data that has been calculated (using logic or
51
+ mathematical operations), not necessarily measured directly
52
+ Bit 6 - projected (forecast): data that has been calculated as a
53
+ projection or forecast of future reading
54
+ """
55
+
56
+ a = valid
57
+ b = manual
58
+ c = estimate
59
+ d = interpolated
60
+ e = questionable
61
+ f = derived
62
+ g = future
63
+
64
+ binary_str = f"000000000{g}{f}{e}{d}{c}{b}{a}"
65
+ val = int(binary_str, 2)
66
+ hex_str = str(hex(val))[2:].zfill(2).upper()
67
+ return binary_str, hex_str
68
+
69
+
70
+ def get_connect_status(
71
+ connected=0, available=0, operating=0, test=0, fault=0
72
+ ) -> tuple[str, str]:
73
+ """
74
+ ConnectStatusType object (HexBinary8)
75
+ 0 = Connected
76
+ 1 = Available
77
+ 2 = Operating
78
+ 3 = Test
79
+ 4 = Fault/Error
80
+ """
81
+
82
+ a = connected
83
+ b = available
84
+ c = operating
85
+ d = test
86
+ e = fault
87
+
88
+ binary_str = f"000{e}{d}{c}{b}{a}"
89
+ val = int(binary_str, 2)
90
+ hex_str = str(hex(val))[2:].zfill(2).upper()
91
+ return binary_str, hex_str
92
+
93
+
94
+ def get_modes_supported(modes: list[str]) -> tuple[str, str]:
95
+ """DERControlType object (HexBinary32)
96
+ Control modes supported by the DER. Bit positions SHALL be defined as follows:
97
+ 0 = Charge mode
98
+ 1 = Discharge mode
99
+ 2 = opModConnect (connect/disconnect—implies galvanic isolation)
100
+ 3 = opModEnergize (energize/de-energize)
101
+ 4 = opModFixedPFAbsorbW (fixed power factor setpoint when absorbing active power)
102
+ 5 = opModFixedPFInjectW (fixed power factor setpoint when injecting active power)
103
+ 6 = opModFixedVar (reactive power setpoint)
104
+ 7 = opModFixedW (charge/discharge setpoint)
105
+ 8 = opModFreqDroop (Frequency-Watt Parameterized mode)
106
+ 9 = opModFreqWatt (Frequency-Watt Curve mode)
107
+ 10 = opModHFRTMayTrip (High Frequency Ride-Through, May Trip mode)
108
+ 11 = opModHFRTMustTrip (High Frequency Ride-Through, Must Trip mode)
109
+ 12 = opModHVRTMayTrip (High Voltage Ride-Through, May Trip mode)
110
+ 13 = opModHVRTMomentaryCessation (HV Ride-Through, Momentary Cessation mode)
111
+ 14 = opModHVRTMustTrip (High Voltage Ride-Through, Must Trip mode)
112
+ 15 = opModLFRTMayTrip (Low Frequency Ride-Through, May Trip mode)
113
+ 16 = opModLFRTMustTrip (Low Frequency Ride-Through, Must Trip mode)
114
+ 17 = opModLVRTMayTrip (Low Voltage Ride-Through, May Trip mode)
115
+ 18 = opModLVRTMomentaryCessation (LV Ride-Through, Momentary Cessation mode)
116
+ 19 = opModLVRTMustTrip (Low Voltage Ride-Through, Must Trip mode)
117
+ 20 = opModMaxLimW (maximum active power)
118
+ 21 = opModTargetVar (target reactive power)
119
+ 22 = opModTargetW (target active power)
120
+ 23 = opModVoltVar (Volt-Var mode)
121
+ 24 = opModVoltWatt (Volt-Watt mode)
122
+ 25 = opModWattPF (Watt-Powerfactor mode)
123
+ 26 = opModWattVar (Watt-Var mode)
124
+ """
125
+
126
+ a = 1 if "Charge mode" in modes else 0
127
+ b = 1 if "Discharge mode" in modes else 0
128
+ c = 1 if "opModConnect" in modes else 0
129
+ d = 1 if "opModEnergize" in modes else 0
130
+ e = 1 if "opModFixedPFAbsorbW" in modes else 0
131
+ f = 1 if "opModFixedPFInjectW" in modes else 0
132
+ g = 1 if "opModFixedVar" in modes else 0
133
+ h = 1 if "opModFixedW" in modes else 0
134
+ i = 1 if "opModFreqDroop" in modes else 0
135
+ j = 1 if "opModFreqWatt" in modes else 0
136
+ k = 1 if "opModHFRTMayTrip" in modes else 0
137
+ l = 1 if "opModHFRTMustTrip" in modes else 0 # noqa: E741
138
+ m = 1 if "opModHVRTMayTrip" in modes else 0
139
+ n = 1 if "opModHVRTMomentaryCessation" in modes else 0
140
+ o = 1 if "opModHVRTMustTrip" in modes else 0
141
+ p = 1 if "opModLFRTMayTrip" in modes else 0
142
+ q = 1 if "opModLFRTMustTrip" in modes else 0
143
+ r = 1 if "opModLVRTMayTrip" in modes else 0
144
+ s = 1 if "opModLVRTMomentaryCessation" in modes else 0
145
+ t = 1 if "opModLVRTMustTrip" in modes else 0
146
+ u = 1 if "opModMaxLimW" in modes else 0
147
+ v = 1 if "opModTargetVar" in modes else 0
148
+ w = 1 if "opModTargetW" in modes else 0
149
+ x = 1 if "opModVoltVar" in modes else 0
150
+ y = 1 if "opModVoltWatt" in modes else 0
151
+ z = 1 if "opModWattPF" in modes else 0
152
+ aa = 1 if "opModWattVar" in modes else 0
153
+
154
+ binary_str = f"00000{aa}{z}{y}{x}{w}{v}{u}{t}{s}{r}{q}"
155
+ binary_str += f"{p}{o}{n}{m}{l}{k}{j}{i}{h}{g}{f}{e}{d}{c}{b}{a}"
156
+ val = int(binary_str, 2)
157
+ hex_str = str(hex(val))[2:].zfill(8).upper()
158
+ return binary_str, hex_str
159
+
160
+
161
+ def get_doe_modes_supported(modes: list[str]) -> tuple[str, str]:
162
+ """DERControlType object (HexBinary32)
163
+ Control modes supported by the DER. Bit positions SHALL be defined as follows:
164
+ 0 = opModExpLimW
165
+ 1 = opModImpLimW
166
+ 2 = opModGenLimW
167
+ 3 = opModLoadLimW
168
+ """
169
+ a = 1 if "opModExpLimW" in modes else 0
170
+ b = 1 if "opModImpLimW" in modes else 0
171
+ c = 1 if "opModGenLimW" in modes else 0
172
+ d = 1 if "opModLoadLimW" in modes else 0
173
+
174
+ binary_str = f"000000000000{d}{c}{b}{a}"
175
+ val = int(binary_str, 2)
176
+ hex_str = str(hex(val))[2:].zfill(8).upper()
177
+ return binary_str, hex_str
@@ -0,0 +1,37 @@
1
+ import hashlib
2
+ import logging
3
+ from itertools import zip_longest
4
+ from uuid import uuid4
5
+
6
+ log = logging.getLogger(__name__)
7
+
8
+ # When generatating IDs, the PEN should always be used at the end
9
+ # to ensure that there are no conflicts between entities
10
+
11
+
12
+ def group_hex(item: str, group_size: int = 4) -> str:
13
+ """Group into groups of four"""
14
+
15
+ def grouper(iterable, n, fillvalue=None):
16
+ args = [iter(iterable)] * n
17
+ return zip_longest(*args, fillvalue=fillvalue)
18
+
19
+ return "-".join("".join(x) for x in grouper(item, n=group_size))
20
+
21
+
22
+ def generate_mrid(pen: int, group: bool = True) -> str:
23
+ """Generate an random mRID"""
24
+ random_hex = uuid4().hex
25
+ mrid = f"{random_hex[0:24]}{pen:08}".upper()
26
+ if not group:
27
+ return mrid
28
+ return group_hex(mrid)
29
+
30
+
31
+ def proxy_device_lfdi(pen: int, con_id: str, group: bool = True) -> str:
32
+ """Generate a LFDI for use by a cloud proxy"""
33
+ id_hash = hashlib.sha256(con_id.encode("utf-8")).hexdigest().upper()
34
+ lfdi = f"{id_hash[0:32]}{pen:08}"
35
+ if not group:
36
+ return lfdi
37
+ return group_hex(lfdi)
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,24 @@
1
+ from pathlib import Path
2
+
3
+ from sep2tools.cert_create import generate_key_and_csr
4
+
5
+ CERT_DIR = Path("certs")
6
+ CERT_DIR.mkdir(exist_ok=True)
7
+ EXAMPLE_PEN = 1234
8
+
9
+
10
+ def test_key_creation():
11
+ """Create Private Key and CSR"""
12
+
13
+ key, csr = generate_key_and_csr(CERT_DIR / "key.pem", CERT_DIR / "csr.pem")
14
+
15
+ with open(key) as fh:
16
+ lines = fh.readlines()
17
+ assert lines[0] == "-----BEGIN PRIVATE KEY-----\n"
18
+
19
+ with open(csr) as fh:
20
+ lines = fh.readlines()
21
+ assert lines[0] == "-----BEGIN CERTIFICATE REQUEST-----\n"
22
+
23
+ key.unlink() # Delete to cleanup
24
+ csr.unlink() # Delete to cleanup
@@ -0,0 +1,34 @@
1
+ from sep2tools import get_lfdi, get_sfdi
2
+
3
+ EXAMPLE_FINGERPRINT = (
4
+ "3E4F-45AB-31ED-FE5B-67E3-43E5-E456-2E31-984E-23E5-349E-2AD7-4567-2ED1-45EE-213A"
5
+ )
6
+
7
+
8
+
9
+ def test_lfdi():
10
+ """Test the LFDI calculation"""
11
+ lfdi = get_lfdi(EXAMPLE_FINGERPRINT)
12
+ assert lfdi == "3E4F-45AB-31ED-FE5B-67E3-43E5-E456-2E31-984E-23E5"
13
+
14
+ lfdi = get_lfdi(EXAMPLE_FINGERPRINT, group=False)
15
+ assert lfdi == "3E4F45AB31EDFE5B67E343E5E4562E31984E23E5"
16
+
17
+
18
+ def test_sfdi():
19
+ """Test the SFDI calculation"""
20
+
21
+ sfdi = get_sfdi(EXAMPLE_FINGERPRINT, group=False)
22
+ assert sfdi == "167261211391"
23
+
24
+ sfdi = get_sfdi(EXAMPLE_FINGERPRINT)
25
+ assert sfdi == "167-261-211-391"
26
+
27
+ # Ensure checksum is valid
28
+ digits = list(map(int, sfdi.replace("-", "")))
29
+ assert sum(digits) % 10 == 0
30
+
31
+ # Should also work if you pass the LFDI
32
+ lfdi = get_lfdi(EXAMPLE_FINGERPRINT)
33
+ sfdi = get_sfdi(lfdi)
34
+ assert sfdi == "167-261-211-391"
@@ -0,0 +1,15 @@
1
+ from pathlib import Path
2
+
3
+ from sep2tools.cert_create import convert_pem_to_der
4
+ from sep2tools.cert_id import get_der_certificate_lfdi, get_pem_certificate_lfdi
5
+
6
+ EXAMPLE_CERT = Path("certs/example_cert.pem")
7
+
8
+
9
+ def test_example_cert_lfdi():
10
+ """Test the LFDI calculation"""
11
+
12
+ lfdi1 = get_pem_certificate_lfdi(EXAMPLE_CERT)
13
+ der = convert_pem_to_der(EXAMPLE_CERT)
14
+ lfdi2 = get_der_certificate_lfdi(der)
15
+ assert lfdi1 == lfdi2
@@ -0,0 +1,71 @@
1
+ from sep2tools.hexmaps import (
2
+ get_connect_status,
3
+ get_doe_modes_supported,
4
+ get_modes_supported,
5
+ get_quality_flag,
6
+ get_role_flag,
7
+ )
8
+
9
+
10
+ def test_role_flags():
11
+ """Test the role flag mappings"""
12
+
13
+ # Site Reading
14
+ binval, hexval = get_role_flag(is_mirror=1, is_premise=1)
15
+ assert hexval == "03"
16
+
17
+ # DER Reading
18
+ binval, hexval = get_role_flag(is_mirror=1, is_der=1, is_submeter=1)
19
+ assert hexval == "49"
20
+
21
+
22
+ def test_quality_flags():
23
+ """Test the quality mappings"""
24
+
25
+ # Valid
26
+ binval, hexval = get_quality_flag(valid=1)
27
+ assert hexval == "01"
28
+
29
+ # Questionable
30
+ binval, hexval = get_quality_flag(questionable=1)
31
+ assert hexval == "10"
32
+
33
+
34
+ def test_connect_status():
35
+ """Test the connect status mappings"""
36
+
37
+ # Connected
38
+ binval, hexval = get_connect_status(connected=1, available=1, operating=1)
39
+ assert hexval == "07"
40
+
41
+ # Offline
42
+ binval, hexval = get_connect_status(connected=0, available=0, operating=0)
43
+ assert hexval == "00"
44
+
45
+ # Fault
46
+ binval, hexval = get_connect_status(connected=1, fault=1)
47
+ assert hexval == "11"
48
+
49
+
50
+ def test_modes_supported():
51
+ """Test the modes supported mappings"""
52
+
53
+ modes = ["opModMaxLimW"]
54
+ binval, hexval = get_modes_supported(modes=modes)
55
+ assert hexval == "00100000"
56
+
57
+ modes = ["opModEnergize", "opConnect"]
58
+ binval, hexval = get_modes_supported(modes=modes)
59
+ assert hexval == "00000008"
60
+
61
+
62
+ def test_doe_modes_supported():
63
+ """Test the modes supported mappings"""
64
+
65
+ modes = ["opModExpLimW"]
66
+ binval, hexval = get_doe_modes_supported(modes=modes)
67
+ assert hexval == "00000001"
68
+
69
+ modes = ["opModExpLimW", "opModImpLimW", "opModGenLimW", "opModLoadLimW"]
70
+ binval, hexval = get_doe_modes_supported(modes=modes)
71
+ assert hexval == "0000000F"
@@ -0,0 +1,27 @@
1
+ from sep2tools.ids import generate_mrid, proxy_device_lfdi
2
+
3
+ EXAMPLE_PEN = 1234
4
+ EXAMPLE_CPID = "NMI0001234"
5
+
6
+
7
+ def test_proxy_lfdi():
8
+ """Test the LFDI used by a proxy"""
9
+ lfdi = proxy_device_lfdi(EXAMPLE_PEN, EXAMPLE_CPID)
10
+ assert lfdi == "B538-D994-2C7B-5B83-1AED-81A1-FEC4-6B3D-0000-1234"
11
+
12
+ lfdi = proxy_device_lfdi(EXAMPLE_PEN, EXAMPLE_CPID, group=False)
13
+ assert lfdi == "B538D9942C7B5B831AED81A1FEC46B3D00001234"
14
+
15
+
16
+ def test_mrid_generation():
17
+ """Test the LFDI used by a proxy"""
18
+
19
+ mrid1 = generate_mrid(EXAMPLE_PEN)
20
+ assert mrid1[-9:] == "0000-1234"
21
+ mrid2 = generate_mrid(EXAMPLE_PEN)
22
+ assert mrid2[-9:] == "0000-1234"
23
+
24
+ assert mrid1 != mrid2
25
+
26
+ mrid3 = generate_mrid(EXAMPLE_PEN, group=False)
27
+ assert mrid3[-8:] == "00001234"