obis-parser 0.0.1__tar.gz → 0.0.4__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.
- {obis_parser-0.0.1 → obis_parser-0.0.4}/PKG-INFO +27 -21
- {obis_parser-0.0.1 → obis_parser-0.0.4}/README.md +22 -16
- {obis_parser-0.0.1 → obis_parser-0.0.4}/obis_parser/__init__.py +2 -0
- {obis_parser-0.0.1 → obis_parser-0.0.4}/obis_parser/_version.py +2 -2
- {obis_parser-0.0.1 → obis_parser-0.0.4}/obis_parser/core.py +77 -9
- {obis_parser-0.0.1 → obis_parser-0.0.4}/pyproject.toml +4 -4
- obis_parser-0.0.4/tests/test_catalog.py +72 -0
- obis_parser-0.0.4/tests/test_decode.py +188 -0
- obis_parser-0.0.4/tests/test_parsing.py +156 -0
- obis_parser-0.0.1/tests/test_catalog.py +0 -74
- obis_parser-0.0.1/tests/test_decode.py +0 -88
- obis_parser-0.0.1/tests/test_parsing.py +0 -97
- {obis_parser-0.0.1 → obis_parser-0.0.4}/.gitignore +0 -0
- {obis_parser-0.0.1 → obis_parser-0.0.4}/.python-version +0 -0
- {obis_parser-0.0.1 → obis_parser-0.0.4}/LICENSE +0 -0
- {obis_parser-0.0.1 → obis_parser-0.0.4}/obis_parser/py.typed +0 -0
- {obis_parser-0.0.1 → obis_parser-0.0.4}/renovate.json +0 -0
- {obis_parser-0.0.1 → obis_parser-0.0.4}/tests/__init__.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: obis-parser
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.4
|
|
4
4
|
Summary: Dependency-free OBIS parser and measurement catalog for smart meters and energy gateways
|
|
5
|
-
Project-URL: Homepage, https://
|
|
6
|
-
Project-URL: Repository, https://
|
|
7
|
-
Project-URL: Issues, https://
|
|
8
|
-
Project-URL: Changelog, https://
|
|
5
|
+
Project-URL: Homepage, https://pkg.jf-projects.de/obis-parser
|
|
6
|
+
Project-URL: Repository, https://pkg.jf-projects.de/obis-parser
|
|
7
|
+
Project-URL: Issues, https://pkg.jf-projects.de/obis-parser/issues
|
|
8
|
+
Project-URL: Changelog, https://pkg.jf-projects.de/obis-parser/blob/main/CHANGELOG.md
|
|
9
9
|
Author-email: Jannick Fahlbusch <git@jf-projects.de>
|
|
10
10
|
License-Expression: Apache-2.0
|
|
11
11
|
License-File: LICENSE
|
|
@@ -41,13 +41,14 @@ Extracts, parses, and normalizes OBIS (*Object Identification System*) codes (`A
|
|
|
41
41
|
* **Zero External Dependencies:** Built entirely with standard library Python (`dataclasses`, `re`, `string`, `typing`).
|
|
42
42
|
* **Universal Ingestion:** Auto-detects and parses:
|
|
43
43
|
* Canonical strings: `1-0:1.8.0`, `1-0:1.8.0*255`, `1-1:1.7.0`
|
|
44
|
+
* Shorthand display strings: `1.8.0`, `16.7.0`, `81.7.4` (defaults to electricity A=1, channel B=0)
|
|
44
45
|
* Bare COSEM 12-hex: `0100010800ff`, `0100100700ff`, `010001080000` (F=0)
|
|
45
46
|
* COSEM logical names with meter suffixes: `0100010800ff.1lgz0067285558.sm`
|
|
46
47
|
* Dot-separated hex: `01.00.01.08.00.FF`
|
|
47
48
|
* **Single Domain Model (`OBIS`):** Everything is accessed directly on the `OBIS` instance (`.canonical`, `.name`, `.slug`, `.placeholders`, `.decode()`).
|
|
48
49
|
* **Channel & Tariff Qualifiers:** Treats Channel ($B \neq 0$) and Tariff ($1 \le E < 255$) as name qualifiers on $(C, D)$ base measurements.
|
|
49
50
|
* **4-Variant Localization Slugs:** Generates variant slugs (`base`, `_channel`, `_tariff`, `_channel_tariff`) with placeholder maps, ready for Home Assistant, Prometheus, or UI localization.
|
|
50
|
-
* **Curated Catalog:**
|
|
51
|
+
* **Curated Catalog:** 39 standard German electricity registers (DIN EN 62056-6-1 / DIN 43863-3) with units, device classes, state classes, and icons.
|
|
51
52
|
|
|
52
53
|
---
|
|
53
54
|
|
|
@@ -75,12 +76,12 @@ from obis_parser import OBIS
|
|
|
75
76
|
# Parses strings, bare-hex, dot-hex, or COSEM logical names
|
|
76
77
|
obis = OBIS.parse("0100010800ff")
|
|
77
78
|
if obis:
|
|
78
|
-
print(obis.canonical)
|
|
79
|
-
print(obis.name)
|
|
80
|
-
print(obis.slug)
|
|
81
|
-
print(obis.is_electricity)
|
|
82
|
-
print(obis.channel)
|
|
83
|
-
print(obis.tariff)
|
|
79
|
+
print(obis.canonical) # "1-0:1.8.0" (or str(obis))
|
|
80
|
+
print(obis.name) # "Active energy import"
|
|
81
|
+
print(obis.slug) # "active_energy_import"
|
|
82
|
+
print(obis.is_electricity) # True
|
|
83
|
+
print(obis.channel) # 0
|
|
84
|
+
print(obis.tariff) # 0
|
|
84
85
|
```
|
|
85
86
|
|
|
86
87
|
### 2. Multi-Meter Channels & Tariffs
|
|
@@ -89,10 +90,10 @@ if obis:
|
|
|
89
90
|
# Multi-meter behind one gateway (Channel 1, Tariff 2)
|
|
90
91
|
obis = OBIS.parse("1-1:1.8.2")
|
|
91
92
|
if obis:
|
|
92
|
-
print(obis.canonical)
|
|
93
|
-
print(obis.name)
|
|
94
|
-
print(obis.slug)
|
|
95
|
-
print(obis.placeholders)
|
|
93
|
+
print(obis.canonical) # "1-1:1.8.2"
|
|
94
|
+
print(obis.name) # "Active energy import (Channel 1, Tariff 2)"
|
|
95
|
+
print(obis.slug) # "active_energy_import_channel_tariff"
|
|
96
|
+
print(obis.placeholders) # {"channel": "1", "tariff": "2"}
|
|
96
97
|
```
|
|
97
98
|
|
|
98
99
|
### 3. Full Metadata Decode (`obis.decode()`)
|
|
@@ -101,18 +102,18 @@ if obis:
|
|
|
101
102
|
obis = OBIS.parse("0100200700ff") # Voltage L1 (32.7.0)
|
|
102
103
|
if obis:
|
|
103
104
|
m = obis.decode()
|
|
104
|
-
print(m.name)
|
|
105
|
-
print(m.unit)
|
|
106
|
-
print(m.device_class)
|
|
105
|
+
print(m.name) # "Voltage L1"
|
|
106
|
+
print(m.unit) # "V"
|
|
107
|
+
print(m.device_class) # "voltage"
|
|
107
108
|
print(m.state_class) # "measurement"
|
|
108
|
-
print(m.icon)
|
|
109
|
+
print(m.icon) # "mdi:sine-wave"
|
|
109
110
|
```
|
|
110
111
|
|
|
111
112
|
---
|
|
112
113
|
|
|
113
114
|
## Supported Registers (Catalog Overview)
|
|
114
115
|
|
|
115
|
-
| (C, D) | Measurement | Slug | Unit | Device Class | State Class |
|
|
116
|
+
| (C, D, E) | Measurement | Slug | Unit | Device Class | State Class |
|
|
116
117
|
|---|---|---|---|---|---|
|
|
117
118
|
| **(1, 8)** | Active energy import | `active_energy_import` | `kWh` | `energy` | `total_increasing` |
|
|
118
119
|
| **(2, 8)** | Active energy export | `active_energy_export` | `kWh` | `energy` | `total_increasing` |
|
|
@@ -135,6 +136,11 @@ if obis:
|
|
|
135
136
|
| **(11, 7)** | Current (neutral/total) | `current` | `A` | `current` | `measurement` |
|
|
136
137
|
| **(13, 7)** | Power factor ($\cos \varphi$) | `power_factor` | `None` | `power_factor` | `measurement` |
|
|
137
138
|
| **(14, 7)** | Frequency | `frequency` | `Hz` | `frequency` | `measurement` |
|
|
139
|
+
| **(81, 7, 0)** | Phase angle | `phase_angle` | `°` | `None` | `measurement` |
|
|
140
|
+
| **(81, 7, 1/2)** | Phase angle U(L2)/U(L3) to U(L1) | `phase_angle_u_l2..3_l1` | `°` | `None` | `measurement` |
|
|
141
|
+
| **(81, 7, 4/15/26)** | Phase angle L1/L2/L3 (U to I) | `phase_angle_l1..3` | `°` | `None` | `measurement` |
|
|
142
|
+
| **(0, 2, 0)** | Firmware version | `firmware_version` | `None` | `None` | `None` |
|
|
143
|
+
| **(96, 1, 0)** | Meter identification / Serial | `meter_identification` | `None` | `None` | `None` |
|
|
138
144
|
|
|
139
145
|
---
|
|
140
146
|
|
|
@@ -15,13 +15,14 @@ Extracts, parses, and normalizes OBIS (*Object Identification System*) codes (`A
|
|
|
15
15
|
* **Zero External Dependencies:** Built entirely with standard library Python (`dataclasses`, `re`, `string`, `typing`).
|
|
16
16
|
* **Universal Ingestion:** Auto-detects and parses:
|
|
17
17
|
* Canonical strings: `1-0:1.8.0`, `1-0:1.8.0*255`, `1-1:1.7.0`
|
|
18
|
+
* Shorthand display strings: `1.8.0`, `16.7.0`, `81.7.4` (defaults to electricity A=1, channel B=0)
|
|
18
19
|
* Bare COSEM 12-hex: `0100010800ff`, `0100100700ff`, `010001080000` (F=0)
|
|
19
20
|
* COSEM logical names with meter suffixes: `0100010800ff.1lgz0067285558.sm`
|
|
20
21
|
* Dot-separated hex: `01.00.01.08.00.FF`
|
|
21
22
|
* **Single Domain Model (`OBIS`):** Everything is accessed directly on the `OBIS` instance (`.canonical`, `.name`, `.slug`, `.placeholders`, `.decode()`).
|
|
22
23
|
* **Channel & Tariff Qualifiers:** Treats Channel ($B \neq 0$) and Tariff ($1 \le E < 255$) as name qualifiers on $(C, D)$ base measurements.
|
|
23
24
|
* **4-Variant Localization Slugs:** Generates variant slugs (`base`, `_channel`, `_tariff`, `_channel_tariff`) with placeholder maps, ready for Home Assistant, Prometheus, or UI localization.
|
|
24
|
-
* **Curated Catalog:**
|
|
25
|
+
* **Curated Catalog:** 39 standard German electricity registers (DIN EN 62056-6-1 / DIN 43863-3) with units, device classes, state classes, and icons.
|
|
25
26
|
|
|
26
27
|
---
|
|
27
28
|
|
|
@@ -49,12 +50,12 @@ from obis_parser import OBIS
|
|
|
49
50
|
# Parses strings, bare-hex, dot-hex, or COSEM logical names
|
|
50
51
|
obis = OBIS.parse("0100010800ff")
|
|
51
52
|
if obis:
|
|
52
|
-
print(obis.canonical)
|
|
53
|
-
print(obis.name)
|
|
54
|
-
print(obis.slug)
|
|
55
|
-
print(obis.is_electricity)
|
|
56
|
-
print(obis.channel)
|
|
57
|
-
print(obis.tariff)
|
|
53
|
+
print(obis.canonical) # "1-0:1.8.0" (or str(obis))
|
|
54
|
+
print(obis.name) # "Active energy import"
|
|
55
|
+
print(obis.slug) # "active_energy_import"
|
|
56
|
+
print(obis.is_electricity) # True
|
|
57
|
+
print(obis.channel) # 0
|
|
58
|
+
print(obis.tariff) # 0
|
|
58
59
|
```
|
|
59
60
|
|
|
60
61
|
### 2. Multi-Meter Channels & Tariffs
|
|
@@ -63,10 +64,10 @@ if obis:
|
|
|
63
64
|
# Multi-meter behind one gateway (Channel 1, Tariff 2)
|
|
64
65
|
obis = OBIS.parse("1-1:1.8.2")
|
|
65
66
|
if obis:
|
|
66
|
-
print(obis.canonical)
|
|
67
|
-
print(obis.name)
|
|
68
|
-
print(obis.slug)
|
|
69
|
-
print(obis.placeholders)
|
|
67
|
+
print(obis.canonical) # "1-1:1.8.2"
|
|
68
|
+
print(obis.name) # "Active energy import (Channel 1, Tariff 2)"
|
|
69
|
+
print(obis.slug) # "active_energy_import_channel_tariff"
|
|
70
|
+
print(obis.placeholders) # {"channel": "1", "tariff": "2"}
|
|
70
71
|
```
|
|
71
72
|
|
|
72
73
|
### 3. Full Metadata Decode (`obis.decode()`)
|
|
@@ -75,18 +76,18 @@ if obis:
|
|
|
75
76
|
obis = OBIS.parse("0100200700ff") # Voltage L1 (32.7.0)
|
|
76
77
|
if obis:
|
|
77
78
|
m = obis.decode()
|
|
78
|
-
print(m.name)
|
|
79
|
-
print(m.unit)
|
|
80
|
-
print(m.device_class)
|
|
79
|
+
print(m.name) # "Voltage L1"
|
|
80
|
+
print(m.unit) # "V"
|
|
81
|
+
print(m.device_class) # "voltage"
|
|
81
82
|
print(m.state_class) # "measurement"
|
|
82
|
-
print(m.icon)
|
|
83
|
+
print(m.icon) # "mdi:sine-wave"
|
|
83
84
|
```
|
|
84
85
|
|
|
85
86
|
---
|
|
86
87
|
|
|
87
88
|
## Supported Registers (Catalog Overview)
|
|
88
89
|
|
|
89
|
-
| (C, D) | Measurement | Slug | Unit | Device Class | State Class |
|
|
90
|
+
| (C, D, E) | Measurement | Slug | Unit | Device Class | State Class |
|
|
90
91
|
|---|---|---|---|---|---|
|
|
91
92
|
| **(1, 8)** | Active energy import | `active_energy_import` | `kWh` | `energy` | `total_increasing` |
|
|
92
93
|
| **(2, 8)** | Active energy export | `active_energy_export` | `kWh` | `energy` | `total_increasing` |
|
|
@@ -109,6 +110,11 @@ if obis:
|
|
|
109
110
|
| **(11, 7)** | Current (neutral/total) | `current` | `A` | `current` | `measurement` |
|
|
110
111
|
| **(13, 7)** | Power factor ($\cos \varphi$) | `power_factor` | `None` | `power_factor` | `measurement` |
|
|
111
112
|
| **(14, 7)** | Frequency | `frequency` | `Hz` | `frequency` | `measurement` |
|
|
113
|
+
| **(81, 7, 0)** | Phase angle | `phase_angle` | `°` | `None` | `measurement` |
|
|
114
|
+
| **(81, 7, 1/2)** | Phase angle U(L2)/U(L3) to U(L1) | `phase_angle_u_l2..3_l1` | `°` | `None` | `measurement` |
|
|
115
|
+
| **(81, 7, 4/15/26)** | Phase angle L1/L2/L3 (U to I) | `phase_angle_l1..3` | `°` | `None` | `measurement` |
|
|
116
|
+
| **(0, 2, 0)** | Firmware version | `firmware_version` | `None` | `None` | `None` |
|
|
117
|
+
| **(96, 1, 0)** | Meter identification / Serial | `meter_identification` | `None` | `None` | `None` |
|
|
112
118
|
|
|
113
119
|
---
|
|
114
120
|
|
|
@@ -31,6 +31,7 @@ Usage examples:
|
|
|
31
31
|
|
|
32
32
|
from ._version import __version__, __version_tuple__
|
|
33
33
|
from .core import (
|
|
34
|
+
ABSTRACT_OBJECTS,
|
|
34
35
|
ELECTRICITY_MEDIUM,
|
|
35
36
|
OBIS,
|
|
36
37
|
OBIS_CATALOG,
|
|
@@ -50,6 +51,7 @@ from .core import (
|
|
|
50
51
|
)
|
|
51
52
|
|
|
52
53
|
__all__ = [
|
|
54
|
+
"ABSTRACT_OBJECTS",
|
|
53
55
|
"ELECTRICITY_MEDIUM",
|
|
54
56
|
"OBIS",
|
|
55
57
|
"OBIS_CATALOG",
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 0,
|
|
21
|
+
__version__ = version = '0.0.4'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 0, 4)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
This module is dependency-free (standard library only) and provides:
|
|
4
4
|
- Object-oriented `OBIS` domain class with `OBIS.parse()`, canonicalization, and metadata.
|
|
5
|
-
- Full catalog of
|
|
5
|
+
- Full catalog of 39 German smart meter electricity registers (IEC 62056-6-1 / DIN 43863-3).
|
|
6
6
|
- Resolved measurement metadata via `obis.decode()` with 4-variant localization slugs.
|
|
7
7
|
"""
|
|
8
8
|
|
|
@@ -15,6 +15,7 @@ from typing import Literal
|
|
|
15
15
|
|
|
16
16
|
_HEXDIGITS = set(hexdigits)
|
|
17
17
|
_OBIS_STRING_RE = re.compile(r"^(\d+)\s*-\s*(\d+)\s*:\s*(\d+)\s*\.\s*(\d+)\s*\.\s*(\d+)(?:\s*\*\s*(\d+))?$")
|
|
18
|
+
_OBIS_SHORT_STRING_RE = re.compile(r"^(\d+)\s*\.\s*(\d+)\s*\.\s*(\d+)(?:\s*\*\s*(\d+))?$")
|
|
18
19
|
_OBIS_DOT_HEX_RE = re.compile(
|
|
19
20
|
r"^([0-9a-fA-F]{2})\.([0-9a-fA-F]{2})\.([0-9a-fA-F]{2})\."
|
|
20
21
|
r"([0-9a-fA-F]{2})\.([0-9a-fA-F]{2})\.([0-9a-fA-F]{2})$"
|
|
@@ -48,19 +49,25 @@ type OBISDeviceClass = Literal[
|
|
|
48
49
|
"apparent_power",
|
|
49
50
|
"power_factor",
|
|
50
51
|
"frequency",
|
|
52
|
+
"signal_strength",
|
|
51
53
|
]
|
|
54
|
+
|
|
52
55
|
type OBISStateClass = Literal["measurement", "total_increasing"]
|
|
53
56
|
|
|
57
|
+
#: Value group A code for abstract objects (IEC 62056-6-1).
|
|
58
|
+
ABSTRACT_OBJECTS = 0
|
|
59
|
+
|
|
54
60
|
#: Medium code for electricity in the OBIS A field.
|
|
55
61
|
ELECTRICITY_MEDIUM = 1
|
|
56
62
|
|
|
57
63
|
#: F (billing period) values meaning "current period": absent, 0, or 255.
|
|
58
64
|
_CURRENT_PERIOD_F = (0, 255)
|
|
59
65
|
|
|
60
|
-
#: A measurement's identity: the (C, D) OBIS value groups
|
|
61
|
-
#:
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
#: A measurement's identity: either the (C, D) OBIS value groups or an exact (C, D, E)
|
|
67
|
+
#: triplet for registers where E represents a specific phase/angle (e.g. phase angles 81.7.x).
|
|
68
|
+
type MeasurementKey = (
|
|
69
|
+
tuple[OBISPhysicalQuantity, OBISMeasurementType] | tuple[OBISPhysicalQuantity, OBISMeasurementType, OBISTariff]
|
|
70
|
+
)
|
|
64
71
|
|
|
65
72
|
|
|
66
73
|
@dataclass(frozen=True)
|
|
@@ -97,6 +104,16 @@ class OBIS:
|
|
|
97
104
|
f=int(match.group(6)) if match.group(6) is not None else None,
|
|
98
105
|
)
|
|
99
106
|
|
|
107
|
+
if match := _OBIS_SHORT_STRING_RE.match(code):
|
|
108
|
+
return cls(
|
|
109
|
+
a=ELECTRICITY_MEDIUM,
|
|
110
|
+
b=0,
|
|
111
|
+
c=int(match.group(1)),
|
|
112
|
+
d=int(match.group(2)),
|
|
113
|
+
e=int(match.group(3)),
|
|
114
|
+
f=int(match.group(4)) if match.group(4) is not None else None,
|
|
115
|
+
)
|
|
116
|
+
|
|
100
117
|
if match := _OBIS_DOT_HEX_RE.match(code):
|
|
101
118
|
return cls(
|
|
102
119
|
a=int(match.group(1), 16),
|
|
@@ -175,6 +192,11 @@ class OBIS:
|
|
|
175
192
|
"""Return True if the code addresses the electricity medium (A=1)."""
|
|
176
193
|
return self.a == ELECTRICITY_MEDIUM
|
|
177
194
|
|
|
195
|
+
@property
|
|
196
|
+
def is_abstract(self) -> bool:
|
|
197
|
+
"""Return True if the code addresses abstract objects (A=0)."""
|
|
198
|
+
return self.a == ABSTRACT_OBJECTS
|
|
199
|
+
|
|
178
200
|
@property
|
|
179
201
|
def info(self) -> OBISMeasurementInfo | None:
|
|
180
202
|
"""Look up catalog metadata for this code's (C, D) measurement."""
|
|
@@ -247,7 +269,7 @@ class OBISNameDescriptor:
|
|
|
247
269
|
|
|
248
270
|
@dataclass(frozen=True)
|
|
249
271
|
class OBISMeasurement:
|
|
250
|
-
"""A fully-resolved OBIS measurement with
|
|
272
|
+
"""A fully-resolved OBIS measurement descriptor with units, device classes, and localized metadata."""
|
|
251
273
|
|
|
252
274
|
code: OBIS
|
|
253
275
|
canonical: str
|
|
@@ -488,17 +510,62 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
488
510
|
2,
|
|
489
511
|
),
|
|
490
512
|
(14, 7): OBISMeasurementInfo("Frequency", "frequency", "frequency", "measurement", "Hz", "mdi:sine-wave", 2),
|
|
513
|
+
# Phase angles (C=81, D=7)
|
|
514
|
+
(81, 7, 0): OBISMeasurementInfo("Phase angle", "phase_angle", None, "measurement", "°", "mdi:angle-acute", 1),
|
|
515
|
+
(81, 7, 1): OBISMeasurementInfo(
|
|
516
|
+
"Phase angle U(L2)-U(L1)", "phase_angle_u_l2_l1", None, "measurement", "°", "mdi:angle-acute", 1
|
|
517
|
+
),
|
|
518
|
+
(81, 7, 2): OBISMeasurementInfo(
|
|
519
|
+
"Phase angle U(L3)-U(L1)", "phase_angle_u_l3_l1", None, "measurement", "°", "mdi:angle-acute", 1
|
|
520
|
+
),
|
|
521
|
+
(81, 7, 4): OBISMeasurementInfo("Phase angle L1", "phase_angle_l1", None, "measurement", "°", "mdi:angle-acute", 1),
|
|
522
|
+
(81, 7, 15): OBISMeasurementInfo(
|
|
523
|
+
"Phase angle L2", "phase_angle_l2", None, "measurement", "°", "mdi:angle-acute", 1
|
|
524
|
+
),
|
|
525
|
+
(81, 7, 26): OBISMeasurementInfo(
|
|
526
|
+
"Phase angle L3", "phase_angle_l3", None, "measurement", "°", "mdi:angle-acute", 1
|
|
527
|
+
),
|
|
528
|
+
# Device metadata & identification (exact E=0)
|
|
529
|
+
(0, 2, 0): OBISMeasurementInfo(
|
|
530
|
+
"Firmware version",
|
|
531
|
+
"firmware_version",
|
|
532
|
+
None,
|
|
533
|
+
None,
|
|
534
|
+
None,
|
|
535
|
+
"mdi:chip",
|
|
536
|
+
),
|
|
537
|
+
(96, 1, 0): OBISMeasurementInfo(
|
|
538
|
+
"Meter identification",
|
|
539
|
+
"meter_identification",
|
|
540
|
+
None,
|
|
541
|
+
None,
|
|
542
|
+
None,
|
|
543
|
+
"mdi:identifier",
|
|
544
|
+
),
|
|
545
|
+
# EMH CASA specific: Cellular Radio Signal
|
|
546
|
+
(96, 99, 0): OBISMeasurementInfo(
|
|
547
|
+
"RSSI",
|
|
548
|
+
"rssi",
|
|
549
|
+
"signal_strength",
|
|
550
|
+
"measurement",
|
|
551
|
+
"dBm",
|
|
552
|
+
"mdi:signal",
|
|
553
|
+
0,
|
|
554
|
+
),
|
|
491
555
|
}
|
|
492
556
|
|
|
493
557
|
|
|
494
558
|
def _get_obis_info(obis: OBIS) -> OBISMeasurementInfo | None:
|
|
495
|
-
"""Look up measurement metadata from the catalog by (C, D)."""
|
|
496
|
-
if not obis.is_electricity:
|
|
559
|
+
"""Look up measurement metadata from the catalog by (C, D, E) or (C, D)."""
|
|
560
|
+
if not (obis.is_electricity or obis.is_abstract):
|
|
497
561
|
return None
|
|
498
562
|
|
|
499
563
|
if obis.f is not None and obis.f not in _CURRENT_PERIOD_F:
|
|
500
564
|
return None
|
|
501
565
|
|
|
566
|
+
if info := OBIS_CATALOG.get((obis.c, obis.d, obis.e)):
|
|
567
|
+
return info
|
|
568
|
+
|
|
502
569
|
return OBIS_CATALOG.get((obis.c, obis.d))
|
|
503
570
|
|
|
504
571
|
|
|
@@ -518,7 +585,8 @@ def _describe_obis(obis: OBIS, info: OBISMeasurementInfo | None) -> OBISNameDesc
|
|
|
518
585
|
)
|
|
519
586
|
|
|
520
587
|
has_channel = obis.b != 0
|
|
521
|
-
|
|
588
|
+
is_exact_e = (obis.c, obis.d, obis.e) in OBIS_CATALOG
|
|
589
|
+
has_tariff = not is_exact_e and _has_tariff(obis.e)
|
|
522
590
|
|
|
523
591
|
placeholders: dict[str, str] = {}
|
|
524
592
|
fallback = info.name
|
|
@@ -30,10 +30,10 @@ classifiers = [
|
|
|
30
30
|
dependencies = []
|
|
31
31
|
|
|
32
32
|
[project.urls]
|
|
33
|
-
Homepage = "https://
|
|
34
|
-
Repository = "https://
|
|
35
|
-
Issues = "https://
|
|
36
|
-
Changelog = "https://
|
|
33
|
+
Homepage = "https://pkg.jf-projects.de/obis-parser"
|
|
34
|
+
Repository = "https://pkg.jf-projects.de/obis-parser"
|
|
35
|
+
Issues = "https://pkg.jf-projects.de/obis-parser/issues"
|
|
36
|
+
Changelog = "https://pkg.jf-projects.de/obis-parser/blob/main/CHANGELOG.md"
|
|
37
37
|
|
|
38
38
|
[tool.hatch.version]
|
|
39
39
|
source = "vcs"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Tests for the OBIS catalog and metadata lookup."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from obis_parser import (
|
|
6
|
+
OBIS,
|
|
7
|
+
OBIS_CATALOG,
|
|
8
|
+
OBISMeasurementInfo,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestCatalog:
|
|
13
|
+
def test_catalog_size_and_uniqueness(self) -> None:
|
|
14
|
+
"""Verify catalog size and slug uniqueness."""
|
|
15
|
+
slugs = [info.slug for info in OBIS_CATALOG.values()]
|
|
16
|
+
assert len(slugs) == len(set(slugs)), "All catalog slugs must be unique"
|
|
17
|
+
assert all(slug for slug in slugs), "Catalog slugs must not be empty"
|
|
18
|
+
|
|
19
|
+
def test_catalog_schema_invariants(self) -> None:
|
|
20
|
+
"""Verify all catalog entries satisfy structural domain invariants."""
|
|
21
|
+
for key, info in OBIS_CATALOG.items():
|
|
22
|
+
assert isinstance(info, OBISMeasurementInfo)
|
|
23
|
+
assert info.name.strip(), f"Empty name for key {key}"
|
|
24
|
+
assert info.slug.strip(), f"Empty slug for key {key}"
|
|
25
|
+
assert info.icon.startswith("mdi:"), f"Invalid icon '{info.icon}' for key {key}"
|
|
26
|
+
assert info.translation_key == info.slug
|
|
27
|
+
|
|
28
|
+
if info.suggested_display_precision is not None:
|
|
29
|
+
assert info.suggested_display_precision >= 0
|
|
30
|
+
|
|
31
|
+
@pytest.mark.parametrize(
|
|
32
|
+
"code",
|
|
33
|
+
[
|
|
34
|
+
"2-0:1.8.0", # Medium != 1
|
|
35
|
+
"7-0:3.0.0", # Gas
|
|
36
|
+
"1-0:1.8.0*1", # Historic billing period
|
|
37
|
+
"1-0:81.7.99", # Uncataloged phase angle E
|
|
38
|
+
"1-0:0.2.1", # Uncataloged metadata E
|
|
39
|
+
"1-0:96.1.1", # Uncataloged serial E
|
|
40
|
+
],
|
|
41
|
+
)
|
|
42
|
+
def test_unmatched_codes_return_none(self, code: str) -> None:
|
|
43
|
+
"""Non-electricity, historic periods, and uncataloged exact E return info=None."""
|
|
44
|
+
obis = OBIS.parse(code)
|
|
45
|
+
assert obis is not None
|
|
46
|
+
assert obis.info is None
|
|
47
|
+
|
|
48
|
+
@pytest.mark.parametrize(
|
|
49
|
+
("code", "expected_slug"),
|
|
50
|
+
[
|
|
51
|
+
# Base (C, D) lookups ignore channel and tariff
|
|
52
|
+
("1-0:1.8.0", "active_energy_import"),
|
|
53
|
+
("1-1:1.8.0", "active_energy_import"),
|
|
54
|
+
("1-0:1.8.1", "active_energy_import"),
|
|
55
|
+
("1-2:1.8.3", "active_energy_import"),
|
|
56
|
+
# Current billing periods (F=None, 0, 255) resolve base info
|
|
57
|
+
("1-0:1.8.0*0", "active_energy_import"),
|
|
58
|
+
("1-0:1.8.0*255", "active_energy_import"),
|
|
59
|
+
# Exact (C, D, E) lookups for phase angles and device metadata
|
|
60
|
+
("1-0:81.7.4", "phase_angle_l1"),
|
|
61
|
+
("1-1:81.7.4", "phase_angle_l1"),
|
|
62
|
+
("1-0:0.2.0", "firmware_version"),
|
|
63
|
+
("1-0:96.1.0", "meter_identification"),
|
|
64
|
+
],
|
|
65
|
+
)
|
|
66
|
+
def test_get_obis_info_lookup_resolution(self, code: str, expected_slug: str) -> None:
|
|
67
|
+
"""info lookup resolves base (C, D) or exact (C, D, E) entries."""
|
|
68
|
+
obis = OBIS.parse(code)
|
|
69
|
+
assert obis is not None
|
|
70
|
+
info = obis.info
|
|
71
|
+
assert info is not None
|
|
72
|
+
assert info.slug == expected_slug
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Tests for OBIS.decode() and OBIS naming methods."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from obis_parser import (
|
|
6
|
+
OBIS,
|
|
7
|
+
OBISMeasurement,
|
|
8
|
+
OBISNameDescriptor,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestOBISNaming:
|
|
13
|
+
@pytest.mark.parametrize(
|
|
14
|
+
("code", "expected"),
|
|
15
|
+
[
|
|
16
|
+
# 1. Base register without modifiers
|
|
17
|
+
(
|
|
18
|
+
"1-0:1.8.0",
|
|
19
|
+
OBISNameDescriptor(
|
|
20
|
+
slug="active_energy_import",
|
|
21
|
+
placeholders={},
|
|
22
|
+
fallback_name="Active energy import",
|
|
23
|
+
),
|
|
24
|
+
),
|
|
25
|
+
# 2. Channel qualifier on base register
|
|
26
|
+
(
|
|
27
|
+
"1-1:1.8.0",
|
|
28
|
+
OBISNameDescriptor(
|
|
29
|
+
slug="active_energy_import_channel",
|
|
30
|
+
placeholders={"channel": "1"},
|
|
31
|
+
fallback_name="Active energy import (Channel 1)",
|
|
32
|
+
),
|
|
33
|
+
),
|
|
34
|
+
# 3. Tariff qualifier on base register
|
|
35
|
+
(
|
|
36
|
+
"1-0:1.8.2",
|
|
37
|
+
OBISNameDescriptor(
|
|
38
|
+
slug="active_energy_import_tariff",
|
|
39
|
+
placeholders={"tariff": "2"},
|
|
40
|
+
fallback_name="Active energy import (Tariff 2)",
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
# 4. Channel + tariff on base register
|
|
44
|
+
(
|
|
45
|
+
"1-3:1.8.4",
|
|
46
|
+
OBISNameDescriptor(
|
|
47
|
+
slug="active_energy_import_channel_tariff",
|
|
48
|
+
placeholders={"channel": "3", "tariff": "4"},
|
|
49
|
+
fallback_name="Active energy import (Channel 3, Tariff 4)",
|
|
50
|
+
),
|
|
51
|
+
),
|
|
52
|
+
# 5. Tariff total / unspecified (E=255) generates no tariff suffix
|
|
53
|
+
(
|
|
54
|
+
"1-0:1.8.255",
|
|
55
|
+
OBISNameDescriptor(
|
|
56
|
+
slug="active_energy_import",
|
|
57
|
+
placeholders={},
|
|
58
|
+
fallback_name="Active energy import",
|
|
59
|
+
),
|
|
60
|
+
),
|
|
61
|
+
# 6. Exact (C, D, E) register ignores tariff E (no _tariff suffix despite E=4)
|
|
62
|
+
(
|
|
63
|
+
"1-0:81.7.4",
|
|
64
|
+
OBISNameDescriptor(
|
|
65
|
+
slug="phase_angle_l1",
|
|
66
|
+
placeholders={},
|
|
67
|
+
fallback_name="Phase angle L1",
|
|
68
|
+
),
|
|
69
|
+
),
|
|
70
|
+
# 7. Channel on exact (C, D, E) register generates _channel, NOT _channel_tariff
|
|
71
|
+
(
|
|
72
|
+
"1-1:81.7.4",
|
|
73
|
+
OBISNameDescriptor(
|
|
74
|
+
slug="phase_angle_l1_channel",
|
|
75
|
+
placeholders={"channel": "1"},
|
|
76
|
+
fallback_name="Phase angle L1 (Channel 1)",
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
],
|
|
80
|
+
)
|
|
81
|
+
def test_describe_qualifier_patterns(
|
|
82
|
+
self,
|
|
83
|
+
code: str,
|
|
84
|
+
expected: OBISNameDescriptor,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Verify naming, slug variants, and placeholders across all qualifier patterns."""
|
|
87
|
+
obis = OBIS.parse(code)
|
|
88
|
+
assert obis is not None
|
|
89
|
+
assert obis.describe() == expected
|
|
90
|
+
|
|
91
|
+
def test_naming_convenience_properties(self) -> None:
|
|
92
|
+
"""Verify OBIS properties forward to OBISNameDescriptor fields."""
|
|
93
|
+
obis = OBIS.parse("1-1:1.8.2")
|
|
94
|
+
assert obis is not None
|
|
95
|
+
desc = obis.describe()
|
|
96
|
+
assert obis.name == desc.fallback_name == "Active energy import (Channel 1, Tariff 2)"
|
|
97
|
+
assert obis.slug == desc.slug == "active_energy_import_channel_tariff"
|
|
98
|
+
assert obis.translation_key == desc.translation_key == "active_energy_import_channel_tariff"
|
|
99
|
+
assert obis.placeholders == desc.placeholders == {"channel": "1", "tariff": "2"}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestOBISDecode:
|
|
103
|
+
def test_decode_direct_dataclass_comparison(self) -> None:
|
|
104
|
+
"""Verify fully-resolved OBISMeasurement dataclass construction directly."""
|
|
105
|
+
obis_energy = OBIS(1, 0, 1, 8, 0)
|
|
106
|
+
assert obis_energy.decode() == OBISMeasurement(
|
|
107
|
+
code=obis_energy,
|
|
108
|
+
canonical="1-0:1.8.0",
|
|
109
|
+
slug="active_energy_import",
|
|
110
|
+
name="Active energy import",
|
|
111
|
+
unit="kWh",
|
|
112
|
+
device_class="energy",
|
|
113
|
+
state_class="total_increasing",
|
|
114
|
+
icon="mdi:home-import-outline",
|
|
115
|
+
suggested_display_precision=5,
|
|
116
|
+
placeholders={},
|
|
117
|
+
info=obis_energy.info,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
obis_angle = OBIS(1, 0, 81, 7, 4)
|
|
121
|
+
assert obis_angle.decode() == OBISMeasurement(
|
|
122
|
+
code=obis_angle,
|
|
123
|
+
canonical="1-0:81.7.4",
|
|
124
|
+
slug="phase_angle_l1",
|
|
125
|
+
name="Phase angle L1",
|
|
126
|
+
unit="°",
|
|
127
|
+
device_class=None,
|
|
128
|
+
state_class="measurement",
|
|
129
|
+
icon="mdi:angle-acute",
|
|
130
|
+
suggested_display_precision=1,
|
|
131
|
+
placeholders={},
|
|
132
|
+
info=obis_angle.info,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
obis_fw = OBIS(1, 0, 0, 2, 0)
|
|
136
|
+
assert obis_fw.decode() == OBISMeasurement(
|
|
137
|
+
code=obis_fw,
|
|
138
|
+
canonical="1-0:0.2.0",
|
|
139
|
+
slug="firmware_version",
|
|
140
|
+
name="Firmware version",
|
|
141
|
+
unit=None,
|
|
142
|
+
device_class=None,
|
|
143
|
+
state_class=None,
|
|
144
|
+
icon="mdi:chip",
|
|
145
|
+
suggested_display_precision=None,
|
|
146
|
+
placeholders={},
|
|
147
|
+
info=obis_fw.info,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
obis_rssi = OBIS(0, 0, 96, 99, 0)
|
|
151
|
+
assert obis_rssi.decode() == OBISMeasurement(
|
|
152
|
+
code=obis_rssi,
|
|
153
|
+
canonical="0-0:96.99.0",
|
|
154
|
+
slug="rssi",
|
|
155
|
+
name="RSSI",
|
|
156
|
+
unit="dBm",
|
|
157
|
+
device_class="signal_strength",
|
|
158
|
+
state_class="measurement",
|
|
159
|
+
icon="mdi:signal",
|
|
160
|
+
suggested_display_precision=0,
|
|
161
|
+
placeholders={},
|
|
162
|
+
info=obis_rssi.info,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
@pytest.mark.parametrize(
|
|
166
|
+
("code", "expected_canonical"),
|
|
167
|
+
[
|
|
168
|
+
("1-0:99.99.99", "1-0:99.99.99"),
|
|
169
|
+
("1-0:81.7.99", "1-0:81.7.99"),
|
|
170
|
+
("1-0:0.2.1", "1-0:0.2.1"),
|
|
171
|
+
("1-0:96.1.1", "1-0:96.1.1"),
|
|
172
|
+
("1-0:1.8.0*1", "1-0:1.8.0*1"),
|
|
173
|
+
("7-0:3.0.0", "7-0:3.0.0"),
|
|
174
|
+
],
|
|
175
|
+
)
|
|
176
|
+
def test_decode_fallback_codes_as_unknown(self, code: str, expected_canonical: str) -> None:
|
|
177
|
+
"""Uncataloged codes, historic periods, and non-electricity mediums decode as unknown_code."""
|
|
178
|
+
obis = OBIS.parse(code)
|
|
179
|
+
assert obis is not None
|
|
180
|
+
m = obis.decode()
|
|
181
|
+
assert m.canonical == expected_canonical
|
|
182
|
+
assert m.slug == "unknown_code"
|
|
183
|
+
assert m.translation_key == "unknown_code"
|
|
184
|
+
assert m.name == f"OBIS {expected_canonical}"
|
|
185
|
+
assert m.placeholders == {"code": expected_canonical}
|
|
186
|
+
assert m.info is None
|
|
187
|
+
assert m.unit is None
|
|
188
|
+
assert m.icon == "mdi:gauge"
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Tests for OBIS code parsing and normalization."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from obis_parser import (
|
|
6
|
+
OBIS,
|
|
7
|
+
ParsedOBIS,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestParseObis:
|
|
12
|
+
@pytest.mark.parametrize(
|
|
13
|
+
("raw", "expected"),
|
|
14
|
+
[
|
|
15
|
+
# Canonical standard notations
|
|
16
|
+
("1-0:1.8.0", OBIS(1, 0, 1, 8, 0, None)),
|
|
17
|
+
("1-0:1.8.0*255", OBIS(1, 0, 1, 8, 0, 255)),
|
|
18
|
+
("1-0:1.8.0*0", OBIS(1, 0, 1, 8, 0, 0)),
|
|
19
|
+
("1-0:1.8.0*1", OBIS(1, 0, 1, 8, 0, 1)),
|
|
20
|
+
("1-1:16.7.0", OBIS(1, 1, 16, 7, 0, None)),
|
|
21
|
+
("1-0:1.8.1", OBIS(1, 0, 1, 8, 1, None)),
|
|
22
|
+
("1-2:1.8.3", OBIS(1, 2, 1, 8, 3, None)),
|
|
23
|
+
("1-12:1.8.128*50", OBIS(1, 12, 1, 8, 128, 50)),
|
|
24
|
+
("0-0:96.99.0", OBIS(0, 0, 96, 99, 0, None)),
|
|
25
|
+
# Shorthand 3-group notation (defaults to A=1, B=0)
|
|
26
|
+
("1.8.0", OBIS(1, 0, 1, 8, 0, None)),
|
|
27
|
+
("1.8.0*255", OBIS(1, 0, 1, 8, 0, 255)),
|
|
28
|
+
("1.8.0*1", OBIS(1, 0, 1, 8, 0, 1)),
|
|
29
|
+
("1.8.1", OBIS(1, 0, 1, 8, 1, None)),
|
|
30
|
+
("16.7.0", OBIS(1, 0, 16, 7, 0, None)),
|
|
31
|
+
("81.7.4", OBIS(1, 0, 81, 7, 4, None)),
|
|
32
|
+
("0.2.0", OBIS(1, 0, 0, 2, 0, None)),
|
|
33
|
+
("96.1.0", OBIS(1, 0, 96, 1, 0, None)),
|
|
34
|
+
# Dot-separated hex
|
|
35
|
+
("01.00.01.08.00.ff", OBIS(1, 0, 1, 8, 0, 255)),
|
|
36
|
+
("01.00.01.08.00.FF", OBIS(1, 0, 1, 8, 0, 255)),
|
|
37
|
+
("01.01.10.07.00.00", OBIS(1, 1, 16, 7, 0, 0)),
|
|
38
|
+
# Bare 12-char COSEM hex
|
|
39
|
+
("0100010800ff", OBIS(1, 0, 1, 8, 0, 255)),
|
|
40
|
+
("0100010800FF", OBIS(1, 0, 1, 8, 0, 255)),
|
|
41
|
+
("010001080000", OBIS(1, 0, 1, 8, 0, 0)),
|
|
42
|
+
("010051070fff", OBIS(1, 0, 81, 7, 15, 255)),
|
|
43
|
+
("010051071aff", OBIS(1, 0, 81, 7, 26, 255)),
|
|
44
|
+
# Logical name with meter suffix
|
|
45
|
+
("0100010800ff.1test000000001.sm", OBIS(1, 0, 1, 8, 0, 255)),
|
|
46
|
+
("0100200700ff.1lgz0067285558.sm", OBIS(1, 0, 32, 7, 0, 255)),
|
|
47
|
+
# Whitespace handling
|
|
48
|
+
(" 1-0:1.8.0 ", OBIS(1, 0, 1, 8, 0, None)),
|
|
49
|
+
("1-0:1.8.0 * 255", OBIS(1, 0, 1, 8, 0, 255)),
|
|
50
|
+
("1 - 0 : 1 . 8 . 0", OBIS(1, 0, 1, 8, 0, None)),
|
|
51
|
+
(" 1.8.0 ", OBIS(1, 0, 1, 8, 0, None)),
|
|
52
|
+
("1.8.0 * 255", OBIS(1, 0, 1, 8, 0, 255)),
|
|
53
|
+
("1 . 8 . 0", OBIS(1, 0, 1, 8, 0, None)),
|
|
54
|
+
("\t1.8.0\n", OBIS(1, 0, 1, 8, 0, None)),
|
|
55
|
+
],
|
|
56
|
+
)
|
|
57
|
+
def test_valid_forms(self, raw: str, expected: OBIS) -> None:
|
|
58
|
+
assert OBIS.parse(raw) == expected
|
|
59
|
+
assert OBIS.from_string(raw) == expected
|
|
60
|
+
|
|
61
|
+
@pytest.mark.parametrize(
|
|
62
|
+
"raw",
|
|
63
|
+
[
|
|
64
|
+
"",
|
|
65
|
+
" ",
|
|
66
|
+
"not-an-obis-code",
|
|
67
|
+
"1-0:1.8",
|
|
68
|
+
"1-0:1.8.0.0",
|
|
69
|
+
"1-0:1.8.0*",
|
|
70
|
+
"1-0:1.8.0*abc",
|
|
71
|
+
"1-0:1.8.0*1*2",
|
|
72
|
+
"1.8",
|
|
73
|
+
"1.8.0.0",
|
|
74
|
+
"1.8.0*",
|
|
75
|
+
"1.8.0*abc",
|
|
76
|
+
"1.8.0*1*2",
|
|
77
|
+
"-1.8.0",
|
|
78
|
+
"1.-8.0",
|
|
79
|
+
"1.8.-0",
|
|
80
|
+
".1.8.0",
|
|
81
|
+
"1.8.0.",
|
|
82
|
+
"1:8:0",
|
|
83
|
+
"0100010800",
|
|
84
|
+
"0100010800ff11",
|
|
85
|
+
"0100010800gg",
|
|
86
|
+
"zzgg00010800ff",
|
|
87
|
+
"01.00.01.08.00",
|
|
88
|
+
"01.00.01.08.00.00.00",
|
|
89
|
+
"01.00.01.08.00.gg",
|
|
90
|
+
"0100010800.meter.sm",
|
|
91
|
+
],
|
|
92
|
+
)
|
|
93
|
+
def test_invalid_returns_none(self, raw: str) -> None:
|
|
94
|
+
assert OBIS.parse(raw) is None
|
|
95
|
+
assert OBIS.from_string(raw) is None
|
|
96
|
+
|
|
97
|
+
def test_backwards_compat_alias(self) -> None:
|
|
98
|
+
assert ParsedOBIS is OBIS
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TestOBISProperties:
|
|
102
|
+
@pytest.mark.parametrize(
|
|
103
|
+
("obis", "expected_str"),
|
|
104
|
+
[
|
|
105
|
+
(OBIS(1, 0, 1, 8, 0, None), "1-0:1.8.0"),
|
|
106
|
+
(OBIS(1, 1, 16, 7, 0, None), "1-1:16.7.0"),
|
|
107
|
+
(OBIS(1, 0, 81, 7, 4, None), "1-0:81.7.4"),
|
|
108
|
+
(OBIS(1, 0, 1, 8, 0, 255), "1-0:1.8.0"),
|
|
109
|
+
(OBIS(1, 0, 1, 8, 0, 0), "1-0:1.8.0"),
|
|
110
|
+
(OBIS(1, 0, 1, 8, 0, 1), "1-0:1.8.0*1"),
|
|
111
|
+
(OBIS(1, 0, 81, 7, 4, 1), "1-0:81.7.4*1"),
|
|
112
|
+
],
|
|
113
|
+
)
|
|
114
|
+
def test_string_representations_and_f_suppression(self, obis: OBIS, expected_str: str) -> None:
|
|
115
|
+
assert str(obis) == expected_str
|
|
116
|
+
assert obis.canonical == expected_str
|
|
117
|
+
assert obis.to_obis_string() == expected_str
|
|
118
|
+
|
|
119
|
+
def test_convenience_property_aliases(self) -> None:
|
|
120
|
+
"""Verify property aliases mirror positional fields."""
|
|
121
|
+
obis = OBIS(1, 2, 3, 4, 5, 6)
|
|
122
|
+
assert obis.is_electricity is True
|
|
123
|
+
assert obis.is_abstract is False
|
|
124
|
+
assert (
|
|
125
|
+
(
|
|
126
|
+
obis.medium,
|
|
127
|
+
obis.channel,
|
|
128
|
+
obis.physical_quantity,
|
|
129
|
+
obis.measurement_type,
|
|
130
|
+
obis.tariff,
|
|
131
|
+
obis.billing_period,
|
|
132
|
+
)
|
|
133
|
+
== (obis.a, obis.b, obis.c, obis.d, obis.e, obis.f)
|
|
134
|
+
== (1, 2, 3, 4, 5, 6)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
abstract = OBIS(0, 0, 96, 99, 0, None)
|
|
138
|
+
assert abstract.is_abstract is True
|
|
139
|
+
assert abstract.is_electricity is False
|
|
140
|
+
assert abstract.medium == abstract.a == 0
|
|
141
|
+
|
|
142
|
+
gas = OBIS(7, 0, 3, 0, 0, None)
|
|
143
|
+
assert gas.is_electricity is False
|
|
144
|
+
assert gas.is_abstract is False
|
|
145
|
+
assert gas.medium == gas.a == 7
|
|
146
|
+
|
|
147
|
+
def test_obis_dataclass_immutability_and_hashability(self) -> None:
|
|
148
|
+
"""OBIS is a frozen dataclass and can be hashed/stored in sets."""
|
|
149
|
+
obis1 = OBIS(1, 0, 1, 8, 0)
|
|
150
|
+
obis2 = OBIS(1, 0, 1, 8, 0)
|
|
151
|
+
assert obis1 == obis2
|
|
152
|
+
assert hash(obis1) == hash(obis2)
|
|
153
|
+
assert len({obis1, obis2}) == 1
|
|
154
|
+
|
|
155
|
+
with pytest.raises(AttributeError):
|
|
156
|
+
obis1.a = 2 # type: ignore[misc]
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
"""Tests for the OBIS catalog and metadata lookup."""
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
|
|
5
|
-
from obis_parser import (
|
|
6
|
-
OBIS,
|
|
7
|
-
OBIS_CATALOG,
|
|
8
|
-
)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class TestCatalog:
|
|
12
|
-
def test_catalog_size_and_uniqueness(self) -> None:
|
|
13
|
-
assert len(OBIS_CATALOG) == 31
|
|
14
|
-
slugs = [info.slug for info in OBIS_CATALOG.values()]
|
|
15
|
-
assert len(slugs) == len(set(slugs)), "All catalog slugs must be unique"
|
|
16
|
-
assert all(slug for slug in slugs), "Catalog slugs must not be empty"
|
|
17
|
-
|
|
18
|
-
@pytest.mark.parametrize(
|
|
19
|
-
("code", "expected_cd", "expected_slug"),
|
|
20
|
-
[
|
|
21
|
-
("1-0:1.8.0", (1, 8), "active_energy_import"),
|
|
22
|
-
("1-0:2.8.0", (2, 8), "active_energy_export"),
|
|
23
|
-
("1-0:16.7.0", (16, 7), "active_power_total"),
|
|
24
|
-
("1-0:15.7.0", (15, 7), "absolute_active_power"),
|
|
25
|
-
("1-0:32.7.0", (32, 7), "voltage_l1"),
|
|
26
|
-
("1-0:52.7.0", (52, 7), "voltage_l2"),
|
|
27
|
-
("1-0:72.7.0", (72, 7), "voltage_l3"),
|
|
28
|
-
("1-0:31.7.0", (31, 7), "current_l1"),
|
|
29
|
-
("1-0:21.7.0", (21, 7), "active_power_import_l1"),
|
|
30
|
-
("1-0:22.7.0", (22, 7), "active_power_export_l1"),
|
|
31
|
-
("1-0:13.7.0", (13, 7), "power_factor"),
|
|
32
|
-
("1-0:14.7.0", (14, 7), "frequency"),
|
|
33
|
-
("1-0:3.8.0", (3, 8), "reactive_energy_import"),
|
|
34
|
-
("1-0:4.8.0", (4, 8), "reactive_energy_export"),
|
|
35
|
-
("1-0:9.8.0", (9, 8), "apparent_energy"),
|
|
36
|
-
],
|
|
37
|
-
)
|
|
38
|
-
def test_catalog_entries(self, code: str, expected_cd: tuple[int, int], expected_slug: str) -> None:
|
|
39
|
-
obis = OBIS.parse(code)
|
|
40
|
-
assert obis is not None
|
|
41
|
-
info = obis.info
|
|
42
|
-
assert info is not None
|
|
43
|
-
assert (obis.c, obis.d) == expected_cd
|
|
44
|
-
assert info.slug == expected_slug
|
|
45
|
-
assert info.translation_key == expected_slug
|
|
46
|
-
|
|
47
|
-
def test_non_electricity_has_no_catalog_match(self) -> None:
|
|
48
|
-
obis = OBIS.parse("2-0:1.8.0")
|
|
49
|
-
assert obis is not None
|
|
50
|
-
assert obis.info is None
|
|
51
|
-
|
|
52
|
-
obis_gas = OBIS.parse("7-0:3.0.0")
|
|
53
|
-
assert obis_gas is not None
|
|
54
|
-
assert obis_gas.info is None
|
|
55
|
-
|
|
56
|
-
def test_non_current_billing_period_has_no_catalog_match(self) -> None:
|
|
57
|
-
obis = OBIS.parse("1-0:1.8.0*1")
|
|
58
|
-
assert obis is not None
|
|
59
|
-
assert obis.info is None
|
|
60
|
-
|
|
61
|
-
@pytest.mark.parametrize(
|
|
62
|
-
("code", "expected_cd"),
|
|
63
|
-
[
|
|
64
|
-
("1-1:1.8.0", (1, 8)), # Channel 1
|
|
65
|
-
("1-0:1.8.1", (1, 8)), # Tariff 1
|
|
66
|
-
("1-2:1.8.3", (1, 8)), # Channel 2, Tariff 3
|
|
67
|
-
],
|
|
68
|
-
)
|
|
69
|
-
def test_get_obis_info_ignores_channel_and_tariff(self, code: str, expected_cd: tuple[int, int]) -> None:
|
|
70
|
-
obis = OBIS.parse(code)
|
|
71
|
-
assert obis is not None
|
|
72
|
-
info = obis.info
|
|
73
|
-
assert info is not None
|
|
74
|
-
assert (obis.c, obis.d) == expected_cd
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
"""Tests for OBIS.decode() and OBIS naming methods."""
|
|
2
|
-
|
|
3
|
-
from obis_parser import OBIS
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class TestOBISDecode:
|
|
7
|
-
def test_decode_basic_code(self) -> None:
|
|
8
|
-
obis = OBIS.parse("1-0:1.8.0")
|
|
9
|
-
assert obis is not None
|
|
10
|
-
m = obis.decode()
|
|
11
|
-
assert m.canonical == "1-0:1.8.0"
|
|
12
|
-
assert m.slug == "active_energy_import"
|
|
13
|
-
assert m.translation_key == "active_energy_import"
|
|
14
|
-
assert m.name == "Active energy import"
|
|
15
|
-
assert m.unit == "kWh"
|
|
16
|
-
assert m.device_class == "energy"
|
|
17
|
-
assert m.state_class == "total_increasing"
|
|
18
|
-
assert m.placeholders == {}
|
|
19
|
-
assert m.info is not None
|
|
20
|
-
|
|
21
|
-
def test_decode_with_channel(self) -> None:
|
|
22
|
-
obis = OBIS.parse("1-1:1.7.0")
|
|
23
|
-
assert obis is not None
|
|
24
|
-
m = obis.decode()
|
|
25
|
-
assert m.canonical == "1-1:1.7.0"
|
|
26
|
-
assert m.slug == "active_power_import_channel"
|
|
27
|
-
assert m.name == "Active power import (Channel 1)"
|
|
28
|
-
assert m.placeholders == {"channel": "1"}
|
|
29
|
-
|
|
30
|
-
def test_decode_with_tariff(self) -> None:
|
|
31
|
-
obis = OBIS.parse("1-0:1.8.2")
|
|
32
|
-
assert obis is not None
|
|
33
|
-
m = obis.decode()
|
|
34
|
-
assert m.canonical == "1-0:1.8.2"
|
|
35
|
-
assert m.slug == "active_energy_import_tariff"
|
|
36
|
-
assert m.name == "Active energy import (Tariff 2)"
|
|
37
|
-
assert m.placeholders == {"tariff": "2"}
|
|
38
|
-
|
|
39
|
-
def test_decode_with_channel_and_tariff(self) -> None:
|
|
40
|
-
obis = OBIS.parse("1-3:1.8.4")
|
|
41
|
-
assert obis is not None
|
|
42
|
-
m = obis.decode()
|
|
43
|
-
assert m.canonical == "1-3:1.8.4"
|
|
44
|
-
assert m.slug == "active_energy_import_channel_tariff"
|
|
45
|
-
assert m.name == "Active energy import (Channel 3, Tariff 4)"
|
|
46
|
-
assert m.placeholders == {"channel": "3", "tariff": "4"}
|
|
47
|
-
|
|
48
|
-
def test_decode_direct_instance(self) -> None:
|
|
49
|
-
obis = OBIS(1, 0, 16, 7, 0)
|
|
50
|
-
m = obis.decode()
|
|
51
|
-
assert m.canonical == "1-0:16.7.0"
|
|
52
|
-
assert m.slug == "active_power_total"
|
|
53
|
-
|
|
54
|
-
def test_decode_unknown_code(self) -> None:
|
|
55
|
-
obis = OBIS.parse("1-0:99.99.99")
|
|
56
|
-
assert obis is not None
|
|
57
|
-
m = obis.decode()
|
|
58
|
-
assert m.canonical == "1-0:99.99.99"
|
|
59
|
-
assert m.slug == "unknown_code"
|
|
60
|
-
assert m.name == "OBIS 1-0:99.99.99"
|
|
61
|
-
assert m.placeholders == {"code": "1-0:99.99.99"}
|
|
62
|
-
assert m.info is None
|
|
63
|
-
assert m.unit is None
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
class TestOBISNaming:
|
|
67
|
-
def test_describe_and_properties(self) -> None:
|
|
68
|
-
obis = OBIS.parse("1-2:1.8.5")
|
|
69
|
-
assert obis is not None
|
|
70
|
-
|
|
71
|
-
# Object properties
|
|
72
|
-
assert obis.name == "Active energy import (Channel 2, Tariff 5)"
|
|
73
|
-
assert obis.slug == "active_energy_import_channel_tariff"
|
|
74
|
-
assert obis.translation_key == "active_energy_import_channel_tariff"
|
|
75
|
-
assert obis.placeholders == {"channel": "2", "tariff": "5"}
|
|
76
|
-
|
|
77
|
-
# describe() method
|
|
78
|
-
d = obis.describe()
|
|
79
|
-
assert d.slug == "active_energy_import_channel_tariff"
|
|
80
|
-
assert d.translation_key == "active_energy_import_channel_tariff"
|
|
81
|
-
assert d.placeholders == {"channel": "2", "tariff": "5"}
|
|
82
|
-
assert d.fallback_name == "Active energy import (Channel 2, Tariff 5)"
|
|
83
|
-
|
|
84
|
-
def test_name_property_simple(self) -> None:
|
|
85
|
-
obis = OBIS.parse("1-0:32.7.0")
|
|
86
|
-
assert obis is not None
|
|
87
|
-
assert obis.name == "Voltage L1"
|
|
88
|
-
assert obis.slug == "voltage_l1"
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
"""Tests for OBIS code parsing and normalization."""
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
|
|
5
|
-
from obis_parser import (
|
|
6
|
-
OBIS,
|
|
7
|
-
ParsedOBIS,
|
|
8
|
-
)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class TestParseObis:
|
|
12
|
-
@pytest.mark.parametrize(
|
|
13
|
-
("raw", "expected"),
|
|
14
|
-
[
|
|
15
|
-
("1-0:1.8.0", OBIS(1, 0, 1, 8, 0, None)),
|
|
16
|
-
("1-0:1.8.0*255", OBIS(1, 0, 1, 8, 0, 255)),
|
|
17
|
-
("1-1:16.7.0", OBIS(1, 1, 16, 7, 0, None)),
|
|
18
|
-
("1-0:1.8.1", OBIS(1, 0, 1, 8, 1, None)),
|
|
19
|
-
("1-2:1.8.3", OBIS(1, 2, 1, 8, 3, None)),
|
|
20
|
-
("01.00.01.08.00.ff", OBIS(1, 0, 1, 8, 0, 255)),
|
|
21
|
-
("01.00.01.08.00.FF", OBIS(1, 0, 1, 8, 0, 255)),
|
|
22
|
-
("0100010800ff", OBIS(1, 0, 1, 8, 0, 255)),
|
|
23
|
-
("010001080000", OBIS(1, 0, 1, 8, 0, 0)),
|
|
24
|
-
# Logical name with meter suffix
|
|
25
|
-
("0100010800ff.1test000000001.sm", OBIS(1, 0, 1, 8, 0, 255)),
|
|
26
|
-
("0100200700ff.1lgz0067285558.sm", OBIS(1, 0, 32, 7, 0, 255)),
|
|
27
|
-
# Spaces
|
|
28
|
-
(" 1-0:1.8.0 ", OBIS(1, 0, 1, 8, 0, None)),
|
|
29
|
-
("1-0:1.8.0 * 255", OBIS(1, 0, 1, 8, 0, 255)),
|
|
30
|
-
],
|
|
31
|
-
)
|
|
32
|
-
def test_valid_forms(self, raw: str, expected: OBIS) -> None:
|
|
33
|
-
assert OBIS.parse(raw) == expected
|
|
34
|
-
assert OBIS.from_string(raw) == expected
|
|
35
|
-
|
|
36
|
-
@pytest.mark.parametrize(
|
|
37
|
-
"raw",
|
|
38
|
-
[
|
|
39
|
-
"",
|
|
40
|
-
" ",
|
|
41
|
-
"not-an-obis-code",
|
|
42
|
-
"1-0:1.8",
|
|
43
|
-
"0100010800",
|
|
44
|
-
"zzgg00010800ff",
|
|
45
|
-
],
|
|
46
|
-
)
|
|
47
|
-
def test_invalid_returns_none(self, raw: str) -> None:
|
|
48
|
-
assert OBIS.parse(raw) is None
|
|
49
|
-
assert OBIS.from_string(raw) is None
|
|
50
|
-
|
|
51
|
-
def test_backwards_compat_alias(self) -> None:
|
|
52
|
-
assert ParsedOBIS is OBIS
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
class TestOBISProperties:
|
|
56
|
-
def test_string_representations(self) -> None:
|
|
57
|
-
obis = OBIS(1, 0, 1, 8, 0, None)
|
|
58
|
-
assert str(obis) == "1-0:1.8.0"
|
|
59
|
-
assert obis.canonical == "1-0:1.8.0"
|
|
60
|
-
assert obis.to_obis_string() == "1-0:1.8.0"
|
|
61
|
-
|
|
62
|
-
def test_f_suppression(self) -> None:
|
|
63
|
-
assert str(OBIS(1, 0, 1, 8, 0, 255)) == "1-0:1.8.0"
|
|
64
|
-
assert str(OBIS(1, 0, 1, 8, 0, 0)) == "1-0:1.8.0"
|
|
65
|
-
assert str(OBIS(1, 0, 1, 8, 0, 1)) == "1-0:1.8.0*1"
|
|
66
|
-
|
|
67
|
-
def test_convenience_properties(self) -> None:
|
|
68
|
-
obis = OBIS(1, 2, 1, 8, 3, 255)
|
|
69
|
-
assert obis.is_electricity is True
|
|
70
|
-
assert obis.medium == 1
|
|
71
|
-
assert obis.channel == 2
|
|
72
|
-
assert obis.physical_quantity == 1
|
|
73
|
-
assert obis.measurement_type == 8
|
|
74
|
-
assert obis.tariff == 3
|
|
75
|
-
assert obis.billing_period == 255
|
|
76
|
-
assert obis.info is not None
|
|
77
|
-
|
|
78
|
-
def test_non_electricity(self) -> None:
|
|
79
|
-
obis = OBIS(7, 0, 3, 0, 0, None)
|
|
80
|
-
assert obis.is_electricity is False
|
|
81
|
-
assert obis.medium == 7
|
|
82
|
-
|
|
83
|
-
@pytest.mark.parametrize(
|
|
84
|
-
"raw",
|
|
85
|
-
[
|
|
86
|
-
"1-0:1.8.0",
|
|
87
|
-
" 1-0:1.8.0*255 ",
|
|
88
|
-
"0100010800ff",
|
|
89
|
-
"010001080000",
|
|
90
|
-
"01.00.01.08.00.FF",
|
|
91
|
-
"0100010800ff.meter.sm",
|
|
92
|
-
],
|
|
93
|
-
)
|
|
94
|
-
def test_canonical_property_on_parsed_forms(self, raw: str) -> None:
|
|
95
|
-
obis = OBIS.parse(raw)
|
|
96
|
-
assert obis is not None
|
|
97
|
-
assert obis.canonical == "1-0:1.8.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|