obis-parser 0.0.1__tar.gz → 0.0.2__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.2}/PKG-INFO +23 -17
- {obis_parser-0.0.1 → obis_parser-0.0.2}/README.md +22 -16
- {obis_parser-0.0.1 → obis_parser-0.0.2}/obis_parser/_version.py +2 -2
- {obis_parser-0.0.1 → obis_parser-0.0.2}/obis_parser/core.py +56 -8
- obis_parser-0.0.2/tests/test_catalog.py +81 -0
- obis_parser-0.0.2/tests/test_decode.py +218 -0
- obis_parser-0.0.2/tests/test_parsing.py +148 -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.2}/.gitignore +0 -0
- {obis_parser-0.0.1 → obis_parser-0.0.2}/.python-version +0 -0
- {obis_parser-0.0.1 → obis_parser-0.0.2}/LICENSE +0 -0
- {obis_parser-0.0.1 → obis_parser-0.0.2}/obis_parser/__init__.py +0 -0
- {obis_parser-0.0.1 → obis_parser-0.0.2}/obis_parser/py.typed +0 -0
- {obis_parser-0.0.1 → obis_parser-0.0.2}/pyproject.toml +0 -0
- {obis_parser-0.0.1 → obis_parser-0.0.2}/renovate.json +0 -0
- {obis_parser-0.0.1 → obis_parser-0.0.2}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: obis-parser
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.2
|
|
4
4
|
Summary: Dependency-free OBIS parser and measurement catalog for smart meters and energy gateways
|
|
5
5
|
Project-URL: Homepage, https://github.com/jannickfahlbusch/obis-parser
|
|
6
6
|
Project-URL: Repository, https://github.com/jannickfahlbusch/obis-parser
|
|
@@ -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
|
|
|
@@ -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.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 0, 2)
|
|
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})$"
|
|
@@ -57,10 +58,11 @@ ELECTRICITY_MEDIUM = 1
|
|
|
57
58
|
#: F (billing period) values meaning "current period": absent, 0, or 255.
|
|
58
59
|
_CURRENT_PERIOD_F = (0, 255)
|
|
59
60
|
|
|
60
|
-
#: A measurement's identity: the (C, D) OBIS value groups
|
|
61
|
-
#:
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
#: A measurement's identity: either the (C, D) OBIS value groups or an exact (C, D, E)
|
|
62
|
+
#: triplet for registers where E represents a specific phase/angle (e.g. phase angles 81.7.x).
|
|
63
|
+
type MeasurementKey = (
|
|
64
|
+
tuple[OBISPhysicalQuantity, OBISMeasurementType] | tuple[OBISPhysicalQuantity, OBISMeasurementType, OBISTariff]
|
|
65
|
+
)
|
|
64
66
|
|
|
65
67
|
|
|
66
68
|
@dataclass(frozen=True)
|
|
@@ -97,6 +99,16 @@ class OBIS:
|
|
|
97
99
|
f=int(match.group(6)) if match.group(6) is not None else None,
|
|
98
100
|
)
|
|
99
101
|
|
|
102
|
+
if match := _OBIS_SHORT_STRING_RE.match(code):
|
|
103
|
+
return cls(
|
|
104
|
+
a=ELECTRICITY_MEDIUM,
|
|
105
|
+
b=0,
|
|
106
|
+
c=int(match.group(1)),
|
|
107
|
+
d=int(match.group(2)),
|
|
108
|
+
e=int(match.group(3)),
|
|
109
|
+
f=int(match.group(4)) if match.group(4) is not None else None,
|
|
110
|
+
)
|
|
111
|
+
|
|
100
112
|
if match := _OBIS_DOT_HEX_RE.match(code):
|
|
101
113
|
return cls(
|
|
102
114
|
a=int(match.group(1), 16),
|
|
@@ -247,7 +259,7 @@ class OBISNameDescriptor:
|
|
|
247
259
|
|
|
248
260
|
@dataclass(frozen=True)
|
|
249
261
|
class OBISMeasurement:
|
|
250
|
-
"""A fully-resolved OBIS measurement with
|
|
262
|
+
"""A fully-resolved OBIS measurement descriptor with units, device classes, and localized metadata."""
|
|
251
263
|
|
|
252
264
|
code: OBIS
|
|
253
265
|
canonical: str
|
|
@@ -488,17 +500,52 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
488
500
|
2,
|
|
489
501
|
),
|
|
490
502
|
(14, 7): OBISMeasurementInfo("Frequency", "frequency", "frequency", "measurement", "Hz", "mdi:sine-wave", 2),
|
|
503
|
+
# Phase angles (C=81, D=7)
|
|
504
|
+
(81, 7, 0): OBISMeasurementInfo("Phase angle", "phase_angle", None, "measurement", "°", "mdi:angle-acute", 1),
|
|
505
|
+
(81, 7, 1): OBISMeasurementInfo(
|
|
506
|
+
"Phase angle U(L2)-U(L1)", "phase_angle_u_l2_l1", None, "measurement", "°", "mdi:angle-acute", 1
|
|
507
|
+
),
|
|
508
|
+
(81, 7, 2): OBISMeasurementInfo(
|
|
509
|
+
"Phase angle U(L3)-U(L1)", "phase_angle_u_l3_l1", None, "measurement", "°", "mdi:angle-acute", 1
|
|
510
|
+
),
|
|
511
|
+
(81, 7, 4): OBISMeasurementInfo("Phase angle L1", "phase_angle_l1", None, "measurement", "°", "mdi:angle-acute", 1),
|
|
512
|
+
(81, 7, 15): OBISMeasurementInfo(
|
|
513
|
+
"Phase angle L2", "phase_angle_l2", None, "measurement", "°", "mdi:angle-acute", 1
|
|
514
|
+
),
|
|
515
|
+
(81, 7, 26): OBISMeasurementInfo(
|
|
516
|
+
"Phase angle L3", "phase_angle_l3", None, "measurement", "°", "mdi:angle-acute", 1
|
|
517
|
+
),
|
|
518
|
+
# Device metadata & identification (exact E=0)
|
|
519
|
+
(0, 2, 0): OBISMeasurementInfo(
|
|
520
|
+
"Firmware version",
|
|
521
|
+
"firmware_version",
|
|
522
|
+
None,
|
|
523
|
+
None,
|
|
524
|
+
None,
|
|
525
|
+
"mdi:chip",
|
|
526
|
+
),
|
|
527
|
+
(96, 1, 0): OBISMeasurementInfo(
|
|
528
|
+
"Meter identification",
|
|
529
|
+
"meter_identification",
|
|
530
|
+
None,
|
|
531
|
+
None,
|
|
532
|
+
None,
|
|
533
|
+
"mdi:identifier",
|
|
534
|
+
),
|
|
491
535
|
}
|
|
492
536
|
|
|
493
537
|
|
|
494
538
|
def _get_obis_info(obis: OBIS) -> OBISMeasurementInfo | None:
|
|
495
|
-
"""Look up measurement metadata from the catalog by (C, D)."""
|
|
539
|
+
"""Look up measurement metadata from the catalog by (C, D, E) or (C, D)."""
|
|
496
540
|
if not obis.is_electricity:
|
|
497
541
|
return None
|
|
498
542
|
|
|
499
543
|
if obis.f is not None and obis.f not in _CURRENT_PERIOD_F:
|
|
500
544
|
return None
|
|
501
545
|
|
|
546
|
+
if info := OBIS_CATALOG.get((obis.c, obis.d, obis.e)):
|
|
547
|
+
return info
|
|
548
|
+
|
|
502
549
|
return OBIS_CATALOG.get((obis.c, obis.d))
|
|
503
550
|
|
|
504
551
|
|
|
@@ -518,7 +565,8 @@ def _describe_obis(obis: OBIS, info: OBISMeasurementInfo | None) -> OBISNameDesc
|
|
|
518
565
|
)
|
|
519
566
|
|
|
520
567
|
has_channel = obis.b != 0
|
|
521
|
-
|
|
568
|
+
is_exact_e = (obis.c, obis.d, obis.e) in OBIS_CATALOG
|
|
569
|
+
has_tariff = not is_exact_e and _has_tariff(obis.e)
|
|
522
570
|
|
|
523
571
|
placeholders: dict[str, str] = {}
|
|
524
572
|
fallback = info.name
|
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
assert len(OBIS_CATALOG) == 39
|
|
16
|
+
slugs = [info.slug for info in OBIS_CATALOG.values()]
|
|
17
|
+
assert len(slugs) == len(set(slugs)), "All catalog slugs must be unique"
|
|
18
|
+
assert all(slug for slug in slugs), "Catalog slugs must not be empty"
|
|
19
|
+
|
|
20
|
+
def test_catalog_schema_invariants(self) -> None:
|
|
21
|
+
"""Verify all catalog entries satisfy structural domain invariants."""
|
|
22
|
+
for key, info in OBIS_CATALOG.items():
|
|
23
|
+
assert isinstance(info, OBISMeasurementInfo)
|
|
24
|
+
assert info.name.strip(), f"Empty name for key {key}"
|
|
25
|
+
assert info.slug.strip(), f"Empty slug for key {key}"
|
|
26
|
+
assert info.icon.startswith("mdi:"), f"Invalid icon '{info.icon}' for key {key}"
|
|
27
|
+
assert info.translation_key == info.slug
|
|
28
|
+
|
|
29
|
+
d = key[1]
|
|
30
|
+
if d == 8:
|
|
31
|
+
assert info.state_class == "total_increasing", f"Energy register {key} must be total_increasing"
|
|
32
|
+
elif d == 7:
|
|
33
|
+
assert info.state_class == "measurement", f"Instantaneous register {key} must be measurement"
|
|
34
|
+
else:
|
|
35
|
+
assert info.state_class is None, f"Metadata register {key} must have state_class=None"
|
|
36
|
+
|
|
37
|
+
if info.suggested_display_precision is not None:
|
|
38
|
+
assert info.suggested_display_precision >= 0
|
|
39
|
+
|
|
40
|
+
@pytest.mark.parametrize(
|
|
41
|
+
"code",
|
|
42
|
+
[
|
|
43
|
+
"2-0:1.8.0", # Medium != 1
|
|
44
|
+
"7-0:3.0.0", # Gas
|
|
45
|
+
"1-0:1.8.0*1", # Historic billing period
|
|
46
|
+
"1-0:81.7.99", # Uncataloged phase angle E
|
|
47
|
+
"1-0:0.2.1", # Uncataloged metadata E
|
|
48
|
+
"1-0:96.1.1", # Uncataloged serial E
|
|
49
|
+
],
|
|
50
|
+
)
|
|
51
|
+
def test_unmatched_codes_return_none(self, code: str) -> None:
|
|
52
|
+
"""Non-electricity, historic periods, and uncataloged exact E return info=None."""
|
|
53
|
+
obis = OBIS.parse(code)
|
|
54
|
+
assert obis is not None
|
|
55
|
+
assert obis.info is None
|
|
56
|
+
|
|
57
|
+
@pytest.mark.parametrize(
|
|
58
|
+
("code", "expected_slug"),
|
|
59
|
+
[
|
|
60
|
+
# Base (C, D) lookups ignore channel and tariff
|
|
61
|
+
("1-0:1.8.0", "active_energy_import"),
|
|
62
|
+
("1-1:1.8.0", "active_energy_import"),
|
|
63
|
+
("1-0:1.8.1", "active_energy_import"),
|
|
64
|
+
("1-2:1.8.3", "active_energy_import"),
|
|
65
|
+
# Current billing periods (F=None, 0, 255) resolve base info
|
|
66
|
+
("1-0:1.8.0*0", "active_energy_import"),
|
|
67
|
+
("1-0:1.8.0*255", "active_energy_import"),
|
|
68
|
+
# Exact (C, D, E) lookups for phase angles and device metadata
|
|
69
|
+
("1-0:81.7.4", "phase_angle_l1"),
|
|
70
|
+
("1-1:81.7.4", "phase_angle_l1"),
|
|
71
|
+
("1-0:0.2.0", "firmware_version"),
|
|
72
|
+
("1-0:96.1.0", "meter_identification"),
|
|
73
|
+
],
|
|
74
|
+
)
|
|
75
|
+
def test_get_obis_info_lookup_resolution(self, code: str, expected_slug: str) -> None:
|
|
76
|
+
"""info lookup resolves base (C, D) or exact (C, D, E) entries."""
|
|
77
|
+
obis = OBIS.parse(code)
|
|
78
|
+
assert obis is not None
|
|
79
|
+
info = obis.info
|
|
80
|
+
assert info is not None
|
|
81
|
+
assert info.slug == expected_slug
|
|
@@ -0,0 +1,218 @@
|
|
|
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
|
+
# 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
|
+
# Channel modifier on base register
|
|
26
|
+
(
|
|
27
|
+
"1-1:1.7.0",
|
|
28
|
+
OBISNameDescriptor(
|
|
29
|
+
slug="active_power_import_channel",
|
|
30
|
+
placeholders={"channel": "1"},
|
|
31
|
+
fallback_name="Active power import (Channel 1)",
|
|
32
|
+
),
|
|
33
|
+
),
|
|
34
|
+
# Tariff modifier 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
|
+
# 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
|
+
# Tariff E=255 means total/unspecified (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
|
+
# Exact (C, D, E) phase angle registers
|
|
62
|
+
(
|
|
63
|
+
"1-0:81.7.0",
|
|
64
|
+
OBISNameDescriptor(
|
|
65
|
+
slug="phase_angle",
|
|
66
|
+
placeholders={},
|
|
67
|
+
fallback_name="Phase angle",
|
|
68
|
+
),
|
|
69
|
+
),
|
|
70
|
+
(
|
|
71
|
+
"1-0:81.7.1",
|
|
72
|
+
OBISNameDescriptor(
|
|
73
|
+
slug="phase_angle_u_l2_l1",
|
|
74
|
+
placeholders={},
|
|
75
|
+
fallback_name="Phase angle U(L2)-U(L1)",
|
|
76
|
+
),
|
|
77
|
+
),
|
|
78
|
+
(
|
|
79
|
+
"1-0:81.7.4",
|
|
80
|
+
OBISNameDescriptor(
|
|
81
|
+
slug="phase_angle_l1",
|
|
82
|
+
placeholders={},
|
|
83
|
+
fallback_name="Phase angle L1",
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
# Channel on exact (C, D, E) register (must NOT generate tariff suffix!)
|
|
87
|
+
(
|
|
88
|
+
"1-1:81.7.4",
|
|
89
|
+
OBISNameDescriptor(
|
|
90
|
+
slug="phase_angle_l1_channel",
|
|
91
|
+
placeholders={"channel": "1"},
|
|
92
|
+
fallback_name="Phase angle L1 (Channel 1)",
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
# Exact (C, D, E) device metadata registers
|
|
96
|
+
(
|
|
97
|
+
"1-0:0.2.0",
|
|
98
|
+
OBISNameDescriptor(
|
|
99
|
+
slug="firmware_version",
|
|
100
|
+
placeholders={},
|
|
101
|
+
fallback_name="Firmware version",
|
|
102
|
+
),
|
|
103
|
+
),
|
|
104
|
+
(
|
|
105
|
+
"1-0:96.1.0",
|
|
106
|
+
OBISNameDescriptor(
|
|
107
|
+
slug="meter_identification",
|
|
108
|
+
placeholders={},
|
|
109
|
+
fallback_name="Meter identification",
|
|
110
|
+
),
|
|
111
|
+
),
|
|
112
|
+
],
|
|
113
|
+
)
|
|
114
|
+
def test_describe_and_naming_properties(
|
|
115
|
+
self,
|
|
116
|
+
code: str,
|
|
117
|
+
expected: OBISNameDescriptor,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Verify naming, slug variants, and placeholders across all qualifier patterns."""
|
|
120
|
+
obis = OBIS.parse(code)
|
|
121
|
+
assert obis is not None
|
|
122
|
+
assert obis.describe() == expected
|
|
123
|
+
assert obis.name == expected.fallback_name
|
|
124
|
+
assert obis.slug == expected.slug
|
|
125
|
+
assert obis.translation_key == expected.translation_key
|
|
126
|
+
assert obis.placeholders == expected.placeholders
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class TestOBISDecode:
|
|
130
|
+
def test_decode_direct_dataclass_comparison(self) -> None:
|
|
131
|
+
"""Verify fully-resolved OBISMeasurement dataclass construction directly."""
|
|
132
|
+
obis_energy = OBIS(1, 0, 1, 8, 0)
|
|
133
|
+
assert obis_energy.decode() == OBISMeasurement(
|
|
134
|
+
code=obis_energy,
|
|
135
|
+
canonical="1-0:1.8.0",
|
|
136
|
+
slug="active_energy_import",
|
|
137
|
+
name="Active energy import",
|
|
138
|
+
unit="kWh",
|
|
139
|
+
device_class="energy",
|
|
140
|
+
state_class="total_increasing",
|
|
141
|
+
icon="mdi:home-import-outline",
|
|
142
|
+
suggested_display_precision=5,
|
|
143
|
+
placeholders={},
|
|
144
|
+
info=obis_energy.info,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
obis_angle = OBIS(1, 0, 81, 7, 4)
|
|
148
|
+
assert obis_angle.decode() == OBISMeasurement(
|
|
149
|
+
code=obis_angle,
|
|
150
|
+
canonical="1-0:81.7.4",
|
|
151
|
+
slug="phase_angle_l1",
|
|
152
|
+
name="Phase angle L1",
|
|
153
|
+
unit="°",
|
|
154
|
+
device_class=None,
|
|
155
|
+
state_class="measurement",
|
|
156
|
+
icon="mdi:angle-acute",
|
|
157
|
+
suggested_display_precision=1,
|
|
158
|
+
placeholders={},
|
|
159
|
+
info=obis_angle.info,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
obis_fw = OBIS(1, 0, 0, 2, 0)
|
|
163
|
+
assert obis_fw.decode() == OBISMeasurement(
|
|
164
|
+
code=obis_fw,
|
|
165
|
+
canonical="1-0:0.2.0",
|
|
166
|
+
slug="firmware_version",
|
|
167
|
+
name="Firmware version",
|
|
168
|
+
unit=None,
|
|
169
|
+
device_class=None,
|
|
170
|
+
state_class=None,
|
|
171
|
+
icon="mdi:chip",
|
|
172
|
+
suggested_display_precision=None,
|
|
173
|
+
placeholders={},
|
|
174
|
+
info=obis_fw.info,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
@pytest.mark.parametrize(
|
|
178
|
+
("short_code", "canonical_code"),
|
|
179
|
+
[
|
|
180
|
+
("1.8.0", "1-0:1.8.0"),
|
|
181
|
+
("1.8.2", "1-0:1.8.2"),
|
|
182
|
+
("81.7.4", "1-0:81.7.4"),
|
|
183
|
+
("0.2.0", "1-0:0.2.0"),
|
|
184
|
+
("96.1.0", "1-0:96.1.0"),
|
|
185
|
+
],
|
|
186
|
+
)
|
|
187
|
+
def test_decode_shorthand_matches_canonical(self, short_code: str, canonical_code: str) -> None:
|
|
188
|
+
"""Decoded shorthand object must be identical to canonical decoded object."""
|
|
189
|
+
m_short = OBIS.parse(short_code)
|
|
190
|
+
m_canon = OBIS.parse(canonical_code)
|
|
191
|
+
assert m_short is not None
|
|
192
|
+
assert m_canon is not None
|
|
193
|
+
assert m_short.decode() == m_canon.decode()
|
|
194
|
+
|
|
195
|
+
@pytest.mark.parametrize(
|
|
196
|
+
("code", "expected_canonical"),
|
|
197
|
+
[
|
|
198
|
+
("1-0:99.99.99", "1-0:99.99.99"),
|
|
199
|
+
("1-0:81.7.99", "1-0:81.7.99"),
|
|
200
|
+
("1-0:0.2.1", "1-0:0.2.1"),
|
|
201
|
+
("1-0:96.1.1", "1-0:96.1.1"),
|
|
202
|
+
("1-0:1.8.0*1", "1-0:1.8.0*1"),
|
|
203
|
+
("7-0:3.0.0", "7-0:3.0.0"),
|
|
204
|
+
],
|
|
205
|
+
)
|
|
206
|
+
def test_decode_fallback_codes_as_unknown(self, code: str, expected_canonical: str) -> None:
|
|
207
|
+
"""Uncataloged codes, historic periods, and non-electricity mediums decode as unknown_code."""
|
|
208
|
+
obis = OBIS.parse(code)
|
|
209
|
+
assert obis is not None
|
|
210
|
+
m = obis.decode()
|
|
211
|
+
assert m.canonical == expected_canonical
|
|
212
|
+
assert m.slug == "unknown_code"
|
|
213
|
+
assert m.translation_key == "unknown_code"
|
|
214
|
+
assert m.name == f"OBIS {expected_canonical}"
|
|
215
|
+
assert m.placeholders == {"code": expected_canonical}
|
|
216
|
+
assert m.info is None
|
|
217
|
+
assert m.unit is None
|
|
218
|
+
assert m.icon == "mdi:gauge"
|
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
# Shorthand 3-group notation (defaults to A=1, B=0)
|
|
25
|
+
("1.8.0", OBIS(1, 0, 1, 8, 0, None)),
|
|
26
|
+
("1.8.0*255", OBIS(1, 0, 1, 8, 0, 255)),
|
|
27
|
+
("1.8.0*1", OBIS(1, 0, 1, 8, 0, 1)),
|
|
28
|
+
("1.8.1", OBIS(1, 0, 1, 8, 1, None)),
|
|
29
|
+
("16.7.0", OBIS(1, 0, 16, 7, 0, None)),
|
|
30
|
+
("81.7.4", OBIS(1, 0, 81, 7, 4, None)),
|
|
31
|
+
("0.2.0", OBIS(1, 0, 0, 2, 0, None)),
|
|
32
|
+
("96.1.0", OBIS(1, 0, 96, 1, 0, None)),
|
|
33
|
+
# Dot-separated hex
|
|
34
|
+
("01.00.01.08.00.ff", OBIS(1, 0, 1, 8, 0, 255)),
|
|
35
|
+
("01.00.01.08.00.FF", OBIS(1, 0, 1, 8, 0, 255)),
|
|
36
|
+
("01.01.10.07.00.00", OBIS(1, 1, 16, 7, 0, 0)),
|
|
37
|
+
# Bare 12-char COSEM hex
|
|
38
|
+
("0100010800ff", OBIS(1, 0, 1, 8, 0, 255)),
|
|
39
|
+
("0100010800FF", OBIS(1, 0, 1, 8, 0, 255)),
|
|
40
|
+
("010001080000", OBIS(1, 0, 1, 8, 0, 0)),
|
|
41
|
+
("010051070fff", OBIS(1, 0, 81, 7, 15, 255)),
|
|
42
|
+
("010051071aff", OBIS(1, 0, 81, 7, 26, 255)),
|
|
43
|
+
# Logical name with meter suffix
|
|
44
|
+
("0100010800ff.1test000000001.sm", OBIS(1, 0, 1, 8, 0, 255)),
|
|
45
|
+
("0100200700ff.1lgz0067285558.sm", OBIS(1, 0, 32, 7, 0, 255)),
|
|
46
|
+
# Whitespace handling
|
|
47
|
+
(" 1-0:1.8.0 ", OBIS(1, 0, 1, 8, 0, None)),
|
|
48
|
+
("1-0:1.8.0 * 255", OBIS(1, 0, 1, 8, 0, 255)),
|
|
49
|
+
("1 - 0 : 1 . 8 . 0", OBIS(1, 0, 1, 8, 0, None)),
|
|
50
|
+
(" 1.8.0 ", OBIS(1, 0, 1, 8, 0, None)),
|
|
51
|
+
("1.8.0 * 255", OBIS(1, 0, 1, 8, 0, 255)),
|
|
52
|
+
("1 . 8 . 0", OBIS(1, 0, 1, 8, 0, None)),
|
|
53
|
+
("\t1.8.0\n", OBIS(1, 0, 1, 8, 0, None)),
|
|
54
|
+
],
|
|
55
|
+
)
|
|
56
|
+
def test_valid_forms(self, raw: str, expected: OBIS) -> None:
|
|
57
|
+
assert OBIS.parse(raw) == expected
|
|
58
|
+
assert OBIS.from_string(raw) == expected
|
|
59
|
+
|
|
60
|
+
@pytest.mark.parametrize(
|
|
61
|
+
"raw",
|
|
62
|
+
[
|
|
63
|
+
"",
|
|
64
|
+
" ",
|
|
65
|
+
"not-an-obis-code",
|
|
66
|
+
"1-0:1.8",
|
|
67
|
+
"1-0:1.8.0.0",
|
|
68
|
+
"1-0:1.8.0*",
|
|
69
|
+
"1-0:1.8.0*abc",
|
|
70
|
+
"1-0:1.8.0*1*2",
|
|
71
|
+
"1.8",
|
|
72
|
+
"1.8.0.0",
|
|
73
|
+
"1.8.0*",
|
|
74
|
+
"1.8.0*abc",
|
|
75
|
+
"1.8.0*1*2",
|
|
76
|
+
"-1.8.0",
|
|
77
|
+
"1.-8.0",
|
|
78
|
+
"1.8.-0",
|
|
79
|
+
".1.8.0",
|
|
80
|
+
"1.8.0.",
|
|
81
|
+
"1:8:0",
|
|
82
|
+
"0100010800",
|
|
83
|
+
"0100010800ff11",
|
|
84
|
+
"0100010800gg",
|
|
85
|
+
"zzgg00010800ff",
|
|
86
|
+
"01.00.01.08.00",
|
|
87
|
+
"01.00.01.08.00.00.00",
|
|
88
|
+
"01.00.01.08.00.gg",
|
|
89
|
+
"0100010800.meter.sm",
|
|
90
|
+
],
|
|
91
|
+
)
|
|
92
|
+
def test_invalid_returns_none(self, raw: str) -> None:
|
|
93
|
+
assert OBIS.parse(raw) is None
|
|
94
|
+
assert OBIS.from_string(raw) is None
|
|
95
|
+
|
|
96
|
+
def test_backwards_compat_alias(self) -> None:
|
|
97
|
+
assert ParsedOBIS is OBIS
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TestOBISProperties:
|
|
101
|
+
@pytest.mark.parametrize(
|
|
102
|
+
("obis", "expected_str"),
|
|
103
|
+
[
|
|
104
|
+
(OBIS(1, 0, 1, 8, 0, None), "1-0:1.8.0"),
|
|
105
|
+
(OBIS(1, 1, 16, 7, 0, None), "1-1:16.7.0"),
|
|
106
|
+
(OBIS(1, 0, 81, 7, 4, None), "1-0:81.7.4"),
|
|
107
|
+
(OBIS(1, 0, 1, 8, 0, 255), "1-0:1.8.0"),
|
|
108
|
+
(OBIS(1, 0, 1, 8, 0, 0), "1-0:1.8.0"),
|
|
109
|
+
(OBIS(1, 0, 1, 8, 0, 1), "1-0:1.8.0*1"),
|
|
110
|
+
(OBIS(1, 0, 81, 7, 4, 1), "1-0:81.7.4*1"),
|
|
111
|
+
],
|
|
112
|
+
)
|
|
113
|
+
def test_string_representations_and_f_suppression(self, obis: OBIS, expected_str: str) -> None:
|
|
114
|
+
assert str(obis) == expected_str
|
|
115
|
+
assert obis.canonical == expected_str
|
|
116
|
+
assert obis.to_obis_string() == expected_str
|
|
117
|
+
|
|
118
|
+
def test_convenience_property_aliases(self) -> None:
|
|
119
|
+
"""Verify property aliases mirror positional fields."""
|
|
120
|
+
obis = OBIS(1, 2, 3, 4, 5, 6)
|
|
121
|
+
assert obis.is_electricity is True
|
|
122
|
+
assert (
|
|
123
|
+
(
|
|
124
|
+
obis.medium,
|
|
125
|
+
obis.channel,
|
|
126
|
+
obis.physical_quantity,
|
|
127
|
+
obis.measurement_type,
|
|
128
|
+
obis.tariff,
|
|
129
|
+
obis.billing_period,
|
|
130
|
+
)
|
|
131
|
+
== (obis.a, obis.b, obis.c, obis.d, obis.e, obis.f)
|
|
132
|
+
== (1, 2, 3, 4, 5, 6)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
gas = OBIS(7, 0, 3, 0, 0, None)
|
|
136
|
+
assert gas.is_electricity is False
|
|
137
|
+
assert gas.medium == gas.a == 7
|
|
138
|
+
|
|
139
|
+
def test_obis_dataclass_immutability_and_hashability(self) -> None:
|
|
140
|
+
"""OBIS is a frozen dataclass and can be hashed/stored in sets."""
|
|
141
|
+
obis1 = OBIS(1, 0, 1, 8, 0)
|
|
142
|
+
obis2 = OBIS(1, 0, 1, 8, 0)
|
|
143
|
+
assert obis1 == obis2
|
|
144
|
+
assert hash(obis1) == hash(obis2)
|
|
145
|
+
assert len({obis1, obis2}) == 1
|
|
146
|
+
|
|
147
|
+
with pytest.raises(AttributeError):
|
|
148
|
+
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
|
|
File without changes
|
|
File without changes
|