obis-parser 0.0.4__tar.gz → 0.0.5__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.4 → obis_parser-0.0.5}/.gitignore +1 -1
- {obis_parser-0.0.4 → obis_parser-0.0.5}/PKG-INFO +28 -4
- {obis_parser-0.0.4 → obis_parser-0.0.5}/README.md +27 -2
- {obis_parser-0.0.4 → obis_parser-0.0.5}/obis_parser/__init__.py +2 -0
- {obis_parser-0.0.4 → obis_parser-0.0.5}/obis_parser/_version.py +2 -2
- {obis_parser-0.0.4 → obis_parser-0.0.5}/obis_parser/core.py +115 -15
- obis_parser-0.0.5/obis_parser/dlms.py +74 -0
- {obis_parser-0.0.4 → obis_parser-0.0.5}/pyproject.toml +0 -1
- {obis_parser-0.0.4 → obis_parser-0.0.5}/tests/test_decode.py +98 -0
- {obis_parser-0.0.4 → obis_parser-0.0.5}/.python-version +0 -0
- {obis_parser-0.0.4 → obis_parser-0.0.5}/LICENSE +0 -0
- {obis_parser-0.0.4 → obis_parser-0.0.5}/obis_parser/py.typed +0 -0
- {obis_parser-0.0.4 → obis_parser-0.0.5}/renovate.json +0 -0
- {obis_parser-0.0.4 → obis_parser-0.0.5}/tests/__init__.py +0 -0
- {obis_parser-0.0.4 → obis_parser-0.0.5}/tests/test_catalog.py +0 -0
- {obis_parser-0.0.4 → obis_parser-0.0.5}/tests/test_parsing.py +0 -0
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: obis-parser
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.5
|
|
4
4
|
Summary: Dependency-free OBIS parser and measurement catalog for smart meters and energy gateways
|
|
5
5
|
Project-URL: Homepage, https://pkg.jf-projects.de/obis-parser
|
|
6
6
|
Project-URL: Repository, https://pkg.jf-projects.de/obis-parser
|
|
7
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
8
|
Author-email: Jannick Fahlbusch <git@jf-projects.de>
|
|
10
9
|
License-Expression: Apache-2.0
|
|
11
10
|
License-File: LICENSE
|
|
@@ -38,7 +37,7 @@ Extracts, parses, and normalizes OBIS (*Object Identification System*) codes (`A
|
|
|
38
37
|
|
|
39
38
|
## Features
|
|
40
39
|
|
|
41
|
-
* **Zero External Dependencies:** Built entirely with standard library Python (`dataclasses`, `re`, `string`, `typing`).
|
|
40
|
+
* **Zero External Dependencies:** Built entirely with standard library Python (`dataclasses`, `enum`, `re`, `string`, `typing`).
|
|
42
41
|
* **Universal Ingestion:** Auto-detects and parses:
|
|
43
42
|
* Canonical strings: `1-0:1.8.0`, `1-0:1.8.0*255`, `1-1:1.7.0`
|
|
44
43
|
* Shorthand display strings: `1.8.0`, `16.7.0`, `81.7.4` (defaults to electricity A=1, channel B=0)
|
|
@@ -48,7 +47,8 @@ Extracts, parses, and normalizes OBIS (*Object Identification System*) codes (`A
|
|
|
48
47
|
* **Single Domain Model (`OBIS`):** Everything is accessed directly on the `OBIS` instance (`.canonical`, `.name`, `.slug`, `.placeholders`, `.decode()`).
|
|
49
48
|
* **Channel & Tariff Qualifiers:** Treats Channel ($B \neq 0$) and Tariff ($1 \le E < 255$) as name qualifiers on $(C, D)$ base measurements.
|
|
50
49
|
* **4-Variant Localization Slugs:** Generates variant slugs (`base`, `_channel`, `_tariff`, `_channel_tariff`) with placeholder maps, ready for Home Assistant, Prometheus, or UI localization.
|
|
51
|
-
* **Curated Catalog:**
|
|
50
|
+
* **Curated Catalog:** 40 standard German electricity and gateway registers (DIN EN 62056-6-1 / DIN 43863-3) with units, device classes, state classes, and icons.
|
|
51
|
+
* **DLMS/COSEM Value Scaling:** `DLMSUnit` (IEC 62056-6-2 unit codes) and `OBIS.scale_value()` turn raw register triples (`value`, `scaler`, `unit`) into values matching the catalog unit (e.g. Wh → kWh).
|
|
52
52
|
|
|
53
53
|
---
|
|
54
54
|
|
|
@@ -107,8 +107,31 @@ if obis:
|
|
|
107
107
|
print(m.device_class) # "voltage"
|
|
108
108
|
print(m.state_class) # "measurement"
|
|
109
109
|
print(m.icon) # "mdi:sine-wave"
|
|
110
|
+
print(m.dlms_unit) # 35 (DLMSUnit.VOLT)
|
|
110
111
|
```
|
|
111
112
|
|
|
113
|
+
### 4. Scaling Raw DLMS/COSEM Register Values (`obis.scale_value()`)
|
|
114
|
+
|
|
115
|
+
DLMS/COSEM gateways (e.g. EMH CASA, Theben Conexa) report registers as `value`, `scaler` (power of ten) and `unit` (IEC 62056-6-2 code).
|
|
116
|
+
`scale_value()` applies `10**scaler` and converts Wh/VAh/varh to the catalog's kWh/kVAh/kvarh. All other units are left in their base unit.
|
|
117
|
+
The unit may be passed as an `int`, numeric string, SI symbol, or `DLMSUnit`.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from obis_parser import OBIS, DLMSUnit
|
|
121
|
+
|
|
122
|
+
energy = OBIS.parse("1-0:1.8.0")
|
|
123
|
+
if energy:
|
|
124
|
+
print(energy.scale_value("12345678", -1, 30)) # 1234.5678 (kWh)
|
|
125
|
+
print(energy.scale_value("12345678", "-1", "30")) # 1234.5678 (string payloads)
|
|
126
|
+
|
|
127
|
+
voltage = OBIS.parse("1-0:32.7.0")
|
|
128
|
+
if voltage:
|
|
129
|
+
print(voltage.scale_value("2301", -1, DLMSUnit.VOLT)) # 230.1 (V)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
If the gateway does not report a recognised unit (`None`, `0`, `""`, or an unknown code), the catalog unit of the OBIS code is used as a fallback.
|
|
133
|
+
Cataloged energy registers passed without a unit are therefore assumed to be in Wh/VAh/varh.
|
|
134
|
+
|
|
112
135
|
---
|
|
113
136
|
|
|
114
137
|
## Supported Registers (Catalog Overview)
|
|
@@ -141,6 +164,7 @@ if obis:
|
|
|
141
164
|
| **(81, 7, 4/15/26)** | Phase angle L1/L2/L3 (U to I) | `phase_angle_l1..3` | `°` | `None` | `measurement` |
|
|
142
165
|
| **(0, 2, 0)** | Firmware version | `firmware_version` | `None` | `None` | `None` |
|
|
143
166
|
| **(96, 1, 0)** | Meter identification / Serial | `meter_identification` | `None` | `None` | `None` |
|
|
167
|
+
| **(96, 99, 0)** | RSSI (cellular signal strength, EMH CASA) | `rssi` | `dBm` | `signal_strength` | `measurement` |
|
|
144
168
|
|
|
145
169
|
---
|
|
146
170
|
|
|
@@ -12,7 +12,7 @@ Extracts, parses, and normalizes OBIS (*Object Identification System*) codes (`A
|
|
|
12
12
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
15
|
-
* **Zero External Dependencies:** Built entirely with standard library Python (`dataclasses`, `re`, `string`, `typing`).
|
|
15
|
+
* **Zero External Dependencies:** Built entirely with standard library Python (`dataclasses`, `enum`, `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
18
|
* Shorthand display strings: `1.8.0`, `16.7.0`, `81.7.4` (defaults to electricity A=1, channel B=0)
|
|
@@ -22,7 +22,8 @@ Extracts, parses, and normalizes OBIS (*Object Identification System*) codes (`A
|
|
|
22
22
|
* **Single Domain Model (`OBIS`):** Everything is accessed directly on the `OBIS` instance (`.canonical`, `.name`, `.slug`, `.placeholders`, `.decode()`).
|
|
23
23
|
* **Channel & Tariff Qualifiers:** Treats Channel ($B \neq 0$) and Tariff ($1 \le E < 255$) as name qualifiers on $(C, D)$ base measurements.
|
|
24
24
|
* **4-Variant Localization Slugs:** Generates variant slugs (`base`, `_channel`, `_tariff`, `_channel_tariff`) with placeholder maps, ready for Home Assistant, Prometheus, or UI localization.
|
|
25
|
-
* **Curated Catalog:**
|
|
25
|
+
* **Curated Catalog:** 40 standard German electricity and gateway registers (DIN EN 62056-6-1 / DIN 43863-3) with units, device classes, state classes, and icons.
|
|
26
|
+
* **DLMS/COSEM Value Scaling:** `DLMSUnit` (IEC 62056-6-2 unit codes) and `OBIS.scale_value()` turn raw register triples (`value`, `scaler`, `unit`) into values matching the catalog unit (e.g. Wh → kWh).
|
|
26
27
|
|
|
27
28
|
---
|
|
28
29
|
|
|
@@ -81,8 +82,31 @@ if obis:
|
|
|
81
82
|
print(m.device_class) # "voltage"
|
|
82
83
|
print(m.state_class) # "measurement"
|
|
83
84
|
print(m.icon) # "mdi:sine-wave"
|
|
85
|
+
print(m.dlms_unit) # 35 (DLMSUnit.VOLT)
|
|
84
86
|
```
|
|
85
87
|
|
|
88
|
+
### 4. Scaling Raw DLMS/COSEM Register Values (`obis.scale_value()`)
|
|
89
|
+
|
|
90
|
+
DLMS/COSEM gateways (e.g. EMH CASA, Theben Conexa) report registers as `value`, `scaler` (power of ten) and `unit` (IEC 62056-6-2 code).
|
|
91
|
+
`scale_value()` applies `10**scaler` and converts Wh/VAh/varh to the catalog's kWh/kVAh/kvarh. All other units are left in their base unit.
|
|
92
|
+
The unit may be passed as an `int`, numeric string, SI symbol, or `DLMSUnit`.
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from obis_parser import OBIS, DLMSUnit
|
|
96
|
+
|
|
97
|
+
energy = OBIS.parse("1-0:1.8.0")
|
|
98
|
+
if energy:
|
|
99
|
+
print(energy.scale_value("12345678", -1, 30)) # 1234.5678 (kWh)
|
|
100
|
+
print(energy.scale_value("12345678", "-1", "30")) # 1234.5678 (string payloads)
|
|
101
|
+
|
|
102
|
+
voltage = OBIS.parse("1-0:32.7.0")
|
|
103
|
+
if voltage:
|
|
104
|
+
print(voltage.scale_value("2301", -1, DLMSUnit.VOLT)) # 230.1 (V)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
If the gateway does not report a recognised unit (`None`, `0`, `""`, or an unknown code), the catalog unit of the OBIS code is used as a fallback.
|
|
108
|
+
Cataloged energy registers passed without a unit are therefore assumed to be in Wh/VAh/varh.
|
|
109
|
+
|
|
86
110
|
---
|
|
87
111
|
|
|
88
112
|
## Supported Registers (Catalog Overview)
|
|
@@ -115,6 +139,7 @@ if obis:
|
|
|
115
139
|
| **(81, 7, 4/15/26)** | Phase angle L1/L2/L3 (U to I) | `phase_angle_l1..3` | `°` | `None` | `measurement` |
|
|
116
140
|
| **(0, 2, 0)** | Firmware version | `firmware_version` | `None` | `None` | `None` |
|
|
117
141
|
| **(96, 1, 0)** | Meter identification / Serial | `meter_identification` | `None` | `None` | `None` |
|
|
142
|
+
| **(96, 99, 0)** | RSSI (cellular signal strength, EMH CASA) | `rssi` | `dBm` | `signal_strength` | `measurement` |
|
|
118
143
|
|
|
119
144
|
---
|
|
120
145
|
|
|
@@ -49,12 +49,14 @@ from .core import (
|
|
|
49
49
|
OBISTariff,
|
|
50
50
|
ParsedOBIS,
|
|
51
51
|
)
|
|
52
|
+
from .dlms import DLMSUnit
|
|
52
53
|
|
|
53
54
|
__all__ = [
|
|
54
55
|
"ABSTRACT_OBJECTS",
|
|
55
56
|
"ELECTRICITY_MEDIUM",
|
|
56
57
|
"OBIS",
|
|
57
58
|
"OBIS_CATALOG",
|
|
59
|
+
"DLMSUnit",
|
|
58
60
|
"MeasurementKey",
|
|
59
61
|
"OBISBillingPeriod",
|
|
60
62
|
"OBISChannel",
|
|
@@ -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.5'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 0, 5)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -13,6 +13,8 @@ from dataclasses import dataclass, field
|
|
|
13
13
|
from string import hexdigits
|
|
14
14
|
from typing import Literal
|
|
15
15
|
|
|
16
|
+
from .dlms import DLMSUnit
|
|
17
|
+
|
|
16
18
|
_HEXDIGITS = set(hexdigits)
|
|
17
19
|
_OBIS_STRING_RE = re.compile(r"^(\d+)\s*-\s*(\d+)\s*:\s*(\d+)\s*\.\s*(\d+)\s*\.\s*(\d+)(?:\s*\*\s*(\d+))?$")
|
|
18
20
|
_OBIS_SHORT_STRING_RE = re.compile(r"^(\d+)\s*\.\s*(\d+)\s*\.\s*(\d+)(?:\s*\*\s*(\d+))?$")
|
|
@@ -230,6 +232,39 @@ class OBIS:
|
|
|
230
232
|
"""Decode into a fully-resolved OBISMeasurement object."""
|
|
231
233
|
return _build_measurement(self, self.info)
|
|
232
234
|
|
|
235
|
+
@property
|
|
236
|
+
def dlms_unit(self) -> DLMSUnit | None:
|
|
237
|
+
"""Expected DLMS/COSEM physical unit code (IEC 62056-6-2) for this measurement."""
|
|
238
|
+
info = self.info
|
|
239
|
+
return info.dlms_unit if info is not None else None
|
|
240
|
+
|
|
241
|
+
def scale_value(
|
|
242
|
+
self,
|
|
243
|
+
value: str | float,
|
|
244
|
+
scaler: int | str | None = 0,
|
|
245
|
+
dlms_unit: float | str | DLMSUnit | None = None,
|
|
246
|
+
) -> float:
|
|
247
|
+
"""Scale a raw meter reading by ``10**scaler`` and convert Wh/VAh/varh to kWh/kVAh/kvarh.
|
|
248
|
+
|
|
249
|
+
The physical unit is resolved in two steps:
|
|
250
|
+
|
|
251
|
+
1. ``dlms_unit`` as reported by the gateway (DLMS code, numeric string or SI symbol,
|
|
252
|
+
see :meth:`DLMSUnit.from_raw`).
|
|
253
|
+
2. If that is missing or not a recognised ``DLMSUnit`` (e.g. ``None``, ``0``, ``""``,
|
|
254
|
+
``56``), the catalog unit of this OBIS code (:attr:`dlms_unit`) is used instead.
|
|
255
|
+
|
|
256
|
+
Consequently, raw values of cataloged energy registers passed without a unit are
|
|
257
|
+
assumed to be in Wh/VAh/varh and divided by 1000. Uncataloged codes without a
|
|
258
|
+
recognised unit are only scaled by ``10**scaler``.
|
|
259
|
+
"""
|
|
260
|
+
numeric_value = float(value)
|
|
261
|
+
numeric_scaler = int(scaler) if scaler not in (None, "") else 0
|
|
262
|
+
resolved_unit = DLMSUnit.from_raw(dlms_unit) or self.dlms_unit
|
|
263
|
+
exp = numeric_scaler - (3 if resolved_unit is not None and resolved_unit.is_kilo_energy else 0)
|
|
264
|
+
if exp >= 0:
|
|
265
|
+
return float(numeric_value * (10**exp))
|
|
266
|
+
return float(numeric_value / (10**-exp))
|
|
267
|
+
|
|
233
268
|
|
|
234
269
|
#: Backwards-compatibility alias for OBIS.
|
|
235
270
|
ParsedOBIS = OBIS
|
|
@@ -246,6 +281,7 @@ class OBISMeasurementInfo:
|
|
|
246
281
|
unit: str | None
|
|
247
282
|
icon: str
|
|
248
283
|
suggested_display_precision: int | None = None
|
|
284
|
+
dlms_unit: DLMSUnit | None = None
|
|
249
285
|
|
|
250
286
|
@property
|
|
251
287
|
def translation_key(self) -> str:
|
|
@@ -288,6 +324,11 @@ class OBISMeasurement:
|
|
|
288
324
|
"""Alias for slug (Home Assistant translation compatibility)."""
|
|
289
325
|
return self.slug
|
|
290
326
|
|
|
327
|
+
@property
|
|
328
|
+
def dlms_unit(self) -> DLMSUnit | None:
|
|
329
|
+
"""DLMS/COSEM physical unit code (IEC 62056-6-2) for this measurement."""
|
|
330
|
+
return self.info.dlms_unit if self.info is not None else None
|
|
331
|
+
|
|
291
332
|
|
|
292
333
|
OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
293
334
|
# Active energy (time-integral, D=8)
|
|
@@ -299,6 +340,7 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
299
340
|
"kWh",
|
|
300
341
|
"mdi:home-import-outline",
|
|
301
342
|
5,
|
|
343
|
+
DLMSUnit.WATT_HOUR,
|
|
302
344
|
),
|
|
303
345
|
(2, 8): OBISMeasurementInfo(
|
|
304
346
|
"Active energy export",
|
|
@@ -308,6 +350,7 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
308
350
|
"kWh",
|
|
309
351
|
"mdi:home-export-outline",
|
|
310
352
|
5,
|
|
353
|
+
DLMSUnit.WATT_HOUR,
|
|
311
354
|
),
|
|
312
355
|
# Reactive energy
|
|
313
356
|
(3, 8): OBISMeasurementInfo(
|
|
@@ -318,6 +361,7 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
318
361
|
"kvarh",
|
|
319
362
|
"mdi:home-import-outline",
|
|
320
363
|
5,
|
|
364
|
+
DLMSUnit.VAR_HOUR,
|
|
321
365
|
),
|
|
322
366
|
(4, 8): OBISMeasurementInfo(
|
|
323
367
|
"Reactive energy export",
|
|
@@ -327,6 +371,7 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
327
371
|
"kvarh",
|
|
328
372
|
"mdi:home-export-outline",
|
|
329
373
|
5,
|
|
374
|
+
DLMSUnit.VAR_HOUR,
|
|
330
375
|
),
|
|
331
376
|
# Apparent energy (no standard HA device class / unit constant exists)
|
|
332
377
|
(9, 8): OBISMeasurementInfo(
|
|
@@ -337,6 +382,7 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
337
382
|
"kVAh",
|
|
338
383
|
"mdi:flash",
|
|
339
384
|
5,
|
|
385
|
+
DLMSUnit.VOLT_AMPERE_HOUR,
|
|
340
386
|
),
|
|
341
387
|
# Active power (instantaneous, D=7)
|
|
342
388
|
(1, 7): OBISMeasurementInfo(
|
|
@@ -347,6 +393,7 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
347
393
|
"W",
|
|
348
394
|
"mdi:flash",
|
|
349
395
|
1,
|
|
396
|
+
DLMSUnit.WATT,
|
|
350
397
|
),
|
|
351
398
|
(2, 7): OBISMeasurementInfo(
|
|
352
399
|
"Active power export",
|
|
@@ -356,6 +403,7 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
356
403
|
"W",
|
|
357
404
|
"mdi:flash-outline",
|
|
358
405
|
1,
|
|
406
|
+
DLMSUnit.WATT,
|
|
359
407
|
),
|
|
360
408
|
# Reactive power
|
|
361
409
|
(3, 7): OBISMeasurementInfo(
|
|
@@ -366,6 +414,7 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
366
414
|
"var",
|
|
367
415
|
"mdi:flash",
|
|
368
416
|
1,
|
|
417
|
+
DLMSUnit.VAR,
|
|
369
418
|
),
|
|
370
419
|
(4, 7): OBISMeasurementInfo(
|
|
371
420
|
"Reactive power export",
|
|
@@ -375,6 +424,7 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
375
424
|
"var",
|
|
376
425
|
"mdi:flash-outline",
|
|
377
426
|
1,
|
|
427
|
+
DLMSUnit.VAR,
|
|
378
428
|
),
|
|
379
429
|
# Apparent power
|
|
380
430
|
(9, 7): OBISMeasurementInfo(
|
|
@@ -385,6 +435,7 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
385
435
|
"VA",
|
|
386
436
|
"mdi:flash",
|
|
387
437
|
1,
|
|
438
|
+
DLMSUnit.VOLT_AMPERE,
|
|
388
439
|
),
|
|
389
440
|
# Active power total + absolute
|
|
390
441
|
(16, 7): OBISMeasurementInfo(
|
|
@@ -395,6 +446,7 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
395
446
|
"W",
|
|
396
447
|
"mdi:flash",
|
|
397
448
|
1,
|
|
449
|
+
DLMSUnit.WATT,
|
|
398
450
|
),
|
|
399
451
|
(15, 7): OBISMeasurementInfo(
|
|
400
452
|
"Absolute active power",
|
|
@@ -404,6 +456,7 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
404
456
|
"W",
|
|
405
457
|
"mdi:flash",
|
|
406
458
|
1,
|
|
459
|
+
DLMSUnit.WATT,
|
|
407
460
|
),
|
|
408
461
|
# Active power per phase (signed vector sum)
|
|
409
462
|
(36, 7): OBISMeasurementInfo(
|
|
@@ -414,6 +467,7 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
414
467
|
"W",
|
|
415
468
|
"mdi:flash",
|
|
416
469
|
1,
|
|
470
|
+
DLMSUnit.WATT,
|
|
417
471
|
),
|
|
418
472
|
(56, 7): OBISMeasurementInfo(
|
|
419
473
|
"Active power L2",
|
|
@@ -423,6 +477,7 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
423
477
|
"W",
|
|
424
478
|
"mdi:flash",
|
|
425
479
|
1,
|
|
480
|
+
DLMSUnit.WATT,
|
|
426
481
|
),
|
|
427
482
|
(76, 7): OBISMeasurementInfo(
|
|
428
483
|
"Active power L3",
|
|
@@ -432,6 +487,7 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
432
487
|
"W",
|
|
433
488
|
"mdi:flash",
|
|
434
489
|
1,
|
|
490
|
+
DLMSUnit.WATT,
|
|
435
491
|
),
|
|
436
492
|
# Active power per phase, import (+P)
|
|
437
493
|
(21, 7): OBISMeasurementInfo(
|
|
@@ -442,6 +498,7 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
442
498
|
"W",
|
|
443
499
|
"mdi:flash",
|
|
444
500
|
1,
|
|
501
|
+
DLMSUnit.WATT,
|
|
445
502
|
),
|
|
446
503
|
(41, 7): OBISMeasurementInfo(
|
|
447
504
|
"Active power import L2",
|
|
@@ -451,6 +508,7 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
451
508
|
"W",
|
|
452
509
|
"mdi:flash",
|
|
453
510
|
1,
|
|
511
|
+
DLMSUnit.WATT,
|
|
454
512
|
),
|
|
455
513
|
(61, 7): OBISMeasurementInfo(
|
|
456
514
|
"Active power import L3",
|
|
@@ -460,6 +518,7 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
460
518
|
"W",
|
|
461
519
|
"mdi:flash",
|
|
462
520
|
1,
|
|
521
|
+
DLMSUnit.WATT,
|
|
463
522
|
),
|
|
464
523
|
# Active power per phase, export (-P)
|
|
465
524
|
(22, 7): OBISMeasurementInfo(
|
|
@@ -470,6 +529,7 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
470
529
|
"W",
|
|
471
530
|
"mdi:flash-outline",
|
|
472
531
|
1,
|
|
532
|
+
DLMSUnit.WATT,
|
|
473
533
|
),
|
|
474
534
|
(42, 7): OBISMeasurementInfo(
|
|
475
535
|
"Active power export L2",
|
|
@@ -479,6 +539,7 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
479
539
|
"W",
|
|
480
540
|
"mdi:flash-outline",
|
|
481
541
|
1,
|
|
542
|
+
DLMSUnit.WATT,
|
|
482
543
|
),
|
|
483
544
|
(62, 7): OBISMeasurementInfo(
|
|
484
545
|
"Active power export L3",
|
|
@@ -488,17 +549,34 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
488
549
|
"W",
|
|
489
550
|
"mdi:flash-outline",
|
|
490
551
|
1,
|
|
552
|
+
DLMSUnit.WATT,
|
|
491
553
|
),
|
|
492
554
|
# Voltage per phase + total
|
|
493
|
-
(32, 7): OBISMeasurementInfo(
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
(
|
|
555
|
+
(32, 7): OBISMeasurementInfo(
|
|
556
|
+
"Voltage L1", "voltage_l1", "voltage", "measurement", "V", "mdi:sine-wave", 1, DLMSUnit.VOLT
|
|
557
|
+
),
|
|
558
|
+
(52, 7): OBISMeasurementInfo(
|
|
559
|
+
"Voltage L2", "voltage_l2", "voltage", "measurement", "V", "mdi:sine-wave", 1, DLMSUnit.VOLT
|
|
560
|
+
),
|
|
561
|
+
(72, 7): OBISMeasurementInfo(
|
|
562
|
+
"Voltage L3", "voltage_l3", "voltage", "measurement", "V", "mdi:sine-wave", 1, DLMSUnit.VOLT
|
|
563
|
+
),
|
|
564
|
+
(12, 7): OBISMeasurementInfo(
|
|
565
|
+
"Voltage", "voltage", "voltage", "measurement", "V", "mdi:sine-wave", 1, DLMSUnit.VOLT
|
|
566
|
+
),
|
|
497
567
|
# Current per phase + total
|
|
498
|
-
(31, 7): OBISMeasurementInfo(
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
(
|
|
568
|
+
(31, 7): OBISMeasurementInfo(
|
|
569
|
+
"Current L1", "current_l1", "current", "measurement", "A", "mdi:current-ac", 2, DLMSUnit.AMPERE
|
|
570
|
+
),
|
|
571
|
+
(51, 7): OBISMeasurementInfo(
|
|
572
|
+
"Current L2", "current_l2", "current", "measurement", "A", "mdi:current-ac", 2, DLMSUnit.AMPERE
|
|
573
|
+
),
|
|
574
|
+
(71, 7): OBISMeasurementInfo(
|
|
575
|
+
"Current L3", "current_l3", "current", "measurement", "A", "mdi:current-ac", 2, DLMSUnit.AMPERE
|
|
576
|
+
),
|
|
577
|
+
(11, 7): OBISMeasurementInfo(
|
|
578
|
+
"Current", "current", "current", "measurement", "A", "mdi:current-ac", 2, DLMSUnit.AMPERE
|
|
579
|
+
),
|
|
502
580
|
# Power factor + frequency
|
|
503
581
|
(13, 7): OBISMeasurementInfo(
|
|
504
582
|
"Power factor",
|
|
@@ -508,22 +586,43 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
508
586
|
None,
|
|
509
587
|
"mdi:angle-acute",
|
|
510
588
|
2,
|
|
589
|
+
DLMSUnit.COUNT,
|
|
590
|
+
),
|
|
591
|
+
(14, 7): OBISMeasurementInfo(
|
|
592
|
+
"Frequency", "frequency", "frequency", "measurement", "Hz", "mdi:sine-wave", 2, DLMSUnit.HERTZ
|
|
511
593
|
),
|
|
512
|
-
(14, 7): OBISMeasurementInfo("Frequency", "frequency", "frequency", "measurement", "Hz", "mdi:sine-wave", 2),
|
|
513
594
|
# Phase angles (C=81, D=7)
|
|
514
|
-
(81, 7, 0): OBISMeasurementInfo(
|
|
595
|
+
(81, 7, 0): OBISMeasurementInfo(
|
|
596
|
+
"Phase angle", "phase_angle", None, "measurement", "°", "mdi:angle-acute", 1, DLMSUnit.DEGREE
|
|
597
|
+
),
|
|
515
598
|
(81, 7, 1): OBISMeasurementInfo(
|
|
516
|
-
"Phase angle U(L2)-U(L1)",
|
|
599
|
+
"Phase angle U(L2)-U(L1)",
|
|
600
|
+
"phase_angle_u_l2_l1",
|
|
601
|
+
None,
|
|
602
|
+
"measurement",
|
|
603
|
+
"°",
|
|
604
|
+
"mdi:angle-acute",
|
|
605
|
+
1,
|
|
606
|
+
DLMSUnit.DEGREE,
|
|
517
607
|
),
|
|
518
608
|
(81, 7, 2): OBISMeasurementInfo(
|
|
519
|
-
"Phase angle U(L3)-U(L1)",
|
|
609
|
+
"Phase angle U(L3)-U(L1)",
|
|
610
|
+
"phase_angle_u_l3_l1",
|
|
611
|
+
None,
|
|
612
|
+
"measurement",
|
|
613
|
+
"°",
|
|
614
|
+
"mdi:angle-acute",
|
|
615
|
+
1,
|
|
616
|
+
DLMSUnit.DEGREE,
|
|
617
|
+
),
|
|
618
|
+
(81, 7, 4): OBISMeasurementInfo(
|
|
619
|
+
"Phase angle L1", "phase_angle_l1", None, "measurement", "°", "mdi:angle-acute", 1, DLMSUnit.DEGREE
|
|
520
620
|
),
|
|
521
|
-
(81, 7, 4): OBISMeasurementInfo("Phase angle L1", "phase_angle_l1", None, "measurement", "°", "mdi:angle-acute", 1),
|
|
522
621
|
(81, 7, 15): OBISMeasurementInfo(
|
|
523
|
-
"Phase angle L2", "phase_angle_l2", None, "measurement", "°", "mdi:angle-acute", 1
|
|
622
|
+
"Phase angle L2", "phase_angle_l2", None, "measurement", "°", "mdi:angle-acute", 1, DLMSUnit.DEGREE
|
|
524
623
|
),
|
|
525
624
|
(81, 7, 26): OBISMeasurementInfo(
|
|
526
|
-
"Phase angle L3", "phase_angle_l3", None, "measurement", "°", "mdi:angle-acute", 1
|
|
625
|
+
"Phase angle L3", "phase_angle_l3", None, "measurement", "°", "mdi:angle-acute", 1, DLMSUnit.DEGREE
|
|
527
626
|
),
|
|
528
627
|
# Device metadata & identification (exact E=0)
|
|
529
628
|
(0, 2, 0): OBISMeasurementInfo(
|
|
@@ -551,6 +650,7 @@ OBIS_CATALOG: dict[MeasurementKey, OBISMeasurementInfo] = {
|
|
|
551
650
|
"dBm",
|
|
552
651
|
"mdi:signal",
|
|
553
652
|
0,
|
|
653
|
+
DLMSUnit.DBM,
|
|
554
654
|
),
|
|
555
655
|
}
|
|
556
656
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""DLMS/COSEM physical unit enumeration and resolution (IEC 62056-6-2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import IntEnum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DLMSUnit(IntEnum):
|
|
9
|
+
"""DLMS/COSEM physical unit enumeration (IEC 62056-6-2)."""
|
|
10
|
+
|
|
11
|
+
DEGREE = 8
|
|
12
|
+
WATT = 27
|
|
13
|
+
VOLT_AMPERE = 28
|
|
14
|
+
VAR = 29
|
|
15
|
+
WATT_HOUR = 30
|
|
16
|
+
VOLT_AMPERE_HOUR = 31
|
|
17
|
+
VAR_HOUR = 32
|
|
18
|
+
AMPERE = 33
|
|
19
|
+
VOLT = 35
|
|
20
|
+
HERTZ = 44
|
|
21
|
+
DBM = 70
|
|
22
|
+
COUNT = 255
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def is_kilo_energy(self) -> bool:
|
|
26
|
+
"""Return True if this unit is an energy unit scaled to kilo-units (kWh/kVAh/kvarh) in the catalog."""
|
|
27
|
+
return self in _KILO_ENERGY_UNITS
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_raw(cls, value: float | str | DLMSUnit | None) -> DLMSUnit | None:
|
|
31
|
+
"""Resolve a raw DLMS unit code (int, integral float or string) or SI symbol into a ``DLMSUnit``.
|
|
32
|
+
|
|
33
|
+
Returns ``None`` for missing, boolean, non-integral (e.g. ``30.7``) or unknown codes.
|
|
34
|
+
"""
|
|
35
|
+
if value is None or isinstance(value, bool):
|
|
36
|
+
return None
|
|
37
|
+
if isinstance(value, str):
|
|
38
|
+
cleaned = value.strip()
|
|
39
|
+
if symbol_unit := _SYMBOL_TO_DLMS_UNIT.get(cleaned):
|
|
40
|
+
return symbol_unit
|
|
41
|
+
raw_code: int | str = cleaned
|
|
42
|
+
elif isinstance(value, float):
|
|
43
|
+
if not value.is_integer():
|
|
44
|
+
return None
|
|
45
|
+
raw_code = int(value)
|
|
46
|
+
else:
|
|
47
|
+
raw_code = value
|
|
48
|
+
try:
|
|
49
|
+
return cls(int(raw_code))
|
|
50
|
+
except ValueError:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_SYMBOL_TO_DLMS_UNIT: dict[str, DLMSUnit] = {
|
|
55
|
+
"°": DLMSUnit.DEGREE,
|
|
56
|
+
"W": DLMSUnit.WATT,
|
|
57
|
+
"VA": DLMSUnit.VOLT_AMPERE,
|
|
58
|
+
"var": DLMSUnit.VAR,
|
|
59
|
+
"Wh": DLMSUnit.WATT_HOUR,
|
|
60
|
+
"VAh": DLMSUnit.VOLT_AMPERE_HOUR,
|
|
61
|
+
"varh": DLMSUnit.VAR_HOUR,
|
|
62
|
+
"A": DLMSUnit.AMPERE,
|
|
63
|
+
"V": DLMSUnit.VOLT,
|
|
64
|
+
"Hz": DLMSUnit.HERTZ,
|
|
65
|
+
"dBm": DLMSUnit.DBM,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
_KILO_ENERGY_UNITS: frozenset[DLMSUnit] = frozenset(
|
|
69
|
+
{
|
|
70
|
+
DLMSUnit.WATT_HOUR,
|
|
71
|
+
DLMSUnit.VOLT_AMPERE_HOUR,
|
|
72
|
+
DLMSUnit.VAR_HOUR,
|
|
73
|
+
}
|
|
74
|
+
)
|
|
@@ -33,7 +33,6 @@ dependencies = []
|
|
|
33
33
|
Homepage = "https://pkg.jf-projects.de/obis-parser"
|
|
34
34
|
Repository = "https://pkg.jf-projects.de/obis-parser"
|
|
35
35
|
Issues = "https://pkg.jf-projects.de/obis-parser/issues"
|
|
36
|
-
Changelog = "https://pkg.jf-projects.de/obis-parser/blob/main/CHANGELOG.md"
|
|
37
36
|
|
|
38
37
|
[tool.hatch.version]
|
|
39
38
|
source = "vcs"
|
|
@@ -4,6 +4,7 @@ import pytest
|
|
|
4
4
|
|
|
5
5
|
from obis_parser import (
|
|
6
6
|
OBIS,
|
|
7
|
+
DLMSUnit,
|
|
7
8
|
OBISMeasurement,
|
|
8
9
|
OBISNameDescriptor,
|
|
9
10
|
)
|
|
@@ -185,4 +186,101 @@ class TestOBISDecode:
|
|
|
185
186
|
assert m.placeholders == {"code": expected_canonical}
|
|
186
187
|
assert m.info is None
|
|
187
188
|
assert m.unit is None
|
|
189
|
+
assert m.dlms_unit is None
|
|
188
190
|
assert m.icon == "mdi:gauge"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class TestDLMSScaling:
|
|
194
|
+
@pytest.mark.parametrize(
|
|
195
|
+
("raw_unit", "expected_unit"),
|
|
196
|
+
[
|
|
197
|
+
(DLMSUnit.WATT_HOUR, DLMSUnit.WATT_HOUR),
|
|
198
|
+
(30, DLMSUnit.WATT_HOUR),
|
|
199
|
+
("30", DLMSUnit.WATT_HOUR),
|
|
200
|
+
(" 32 ", DLMSUnit.VAR_HOUR),
|
|
201
|
+
("Wh", DLMSUnit.WATT_HOUR),
|
|
202
|
+
("VAh", DLMSUnit.VOLT_AMPERE_HOUR),
|
|
203
|
+
("varh", DLMSUnit.VAR_HOUR),
|
|
204
|
+
("W", DLMSUnit.WATT),
|
|
205
|
+
("VA", DLMSUnit.VOLT_AMPERE),
|
|
206
|
+
("var", DLMSUnit.VAR),
|
|
207
|
+
("A", DLMSUnit.AMPERE),
|
|
208
|
+
("V", DLMSUnit.VOLT),
|
|
209
|
+
("Hz", DLMSUnit.HERTZ),
|
|
210
|
+
("°", DLMSUnit.DEGREE),
|
|
211
|
+
("dBm", DLMSUnit.DBM),
|
|
212
|
+
(255, DLMSUnit.COUNT),
|
|
213
|
+
(30.0, DLMSUnit.WATT_HOUR),
|
|
214
|
+
(30.7, None),
|
|
215
|
+
(None, None),
|
|
216
|
+
(0, None),
|
|
217
|
+
("0", None),
|
|
218
|
+
("", None),
|
|
219
|
+
("unknown", None),
|
|
220
|
+
(999, None),
|
|
221
|
+
(True, None),
|
|
222
|
+
],
|
|
223
|
+
)
|
|
224
|
+
def test_dlms_unit_from_raw(self, raw_unit: float | str | DLMSUnit | None, expected_unit: DLMSUnit | None) -> None:
|
|
225
|
+
assert DLMSUnit.from_raw(raw_unit) == expected_unit
|
|
226
|
+
|
|
227
|
+
@pytest.mark.parametrize(
|
|
228
|
+
("code", "expected_dlms_unit"),
|
|
229
|
+
[
|
|
230
|
+
("1-0:1.8.0", DLMSUnit.WATT_HOUR),
|
|
231
|
+
("1-0:9.8.0", DLMSUnit.VOLT_AMPERE_HOUR),
|
|
232
|
+
("1-0:3.8.0", DLMSUnit.VAR_HOUR),
|
|
233
|
+
("1-0:16.7.0", DLMSUnit.WATT),
|
|
234
|
+
("1-0:9.7.0", DLMSUnit.VOLT_AMPERE),
|
|
235
|
+
("1-0:3.7.0", DLMSUnit.VAR),
|
|
236
|
+
("1-0:31.7.0", DLMSUnit.AMPERE),
|
|
237
|
+
("1-0:32.7.0", DLMSUnit.VOLT),
|
|
238
|
+
("1-0:14.7.0", DLMSUnit.HERTZ),
|
|
239
|
+
("1-0:81.7.4", DLMSUnit.DEGREE),
|
|
240
|
+
("0-0:96.99.0", DLMSUnit.DBM),
|
|
241
|
+
("1-0:13.7.0", DLMSUnit.COUNT),
|
|
242
|
+
("1-0:0.2.0", None),
|
|
243
|
+
("1-0:99.99.99", None),
|
|
244
|
+
],
|
|
245
|
+
)
|
|
246
|
+
def test_dlms_unit_resolution(self, code: str, expected_dlms_unit: DLMSUnit | None) -> None:
|
|
247
|
+
obis = OBIS.parse(code)
|
|
248
|
+
assert obis is not None
|
|
249
|
+
assert obis.dlms_unit == expected_dlms_unit
|
|
250
|
+
assert obis.decode().dlms_unit == expected_dlms_unit
|
|
251
|
+
|
|
252
|
+
@pytest.mark.parametrize(
|
|
253
|
+
("code", "raw_value", "scaler", "dlms_unit", "expected"),
|
|
254
|
+
[
|
|
255
|
+
# Energy registers (Wh/VAh/varh -> kWh/kVAh/kvarh)
|
|
256
|
+
("1-0:1.8.0", "12345678", -1, 30, 1234.5678),
|
|
257
|
+
("1-0:1.8.0", "1337420000", "-3", "30", 1337.42),
|
|
258
|
+
("1-0:1.8.0", 10000, -1, None, 1.0),
|
|
259
|
+
("1-0:1.8.0", 12, 4, 30, 120.0),
|
|
260
|
+
("1-0:3.8.0", "100000", "-1", "32", 10.0),
|
|
261
|
+
("1-0:9.8.0", "50000", -1, "VAh", 5.0),
|
|
262
|
+
# Unrecognised gateway unit falls back to the catalog unit (Wh -> kWh)
|
|
263
|
+
("1-0:1.8.0", "1000", 0, 56, 1.0),
|
|
264
|
+
("1-0:1.8.0", "1000", 0, 30.7, 1.0),
|
|
265
|
+
# Non-energy registers (scaled by 10**scaler without /1000, exact float division)
|
|
266
|
+
("1-0:16.7.0", "450", "0", "27", 450.0),
|
|
267
|
+
("1-0:16.7.0", "450", None, None, 450.0),
|
|
268
|
+
("1-0:32.7.0", "2301", "-1", "35", 230.1),
|
|
269
|
+
("1-0:31.7.0", "512", -2, 33, 5.12),
|
|
270
|
+
("0-0:96.99.0", "-50", 0, 70, -50.0),
|
|
271
|
+
# Uncataloged register with explicit or absent DLMS unit
|
|
272
|
+
("1-0:99.8.0", "10000", -1, 30, 1.0),
|
|
273
|
+
("1-0:99.7.0", "100", -1, None, 10.0),
|
|
274
|
+
],
|
|
275
|
+
)
|
|
276
|
+
def test_scale_value(
|
|
277
|
+
self,
|
|
278
|
+
code: str,
|
|
279
|
+
raw_value: str | float,
|
|
280
|
+
scaler: int | str | None,
|
|
281
|
+
dlms_unit: float | str | DLMSUnit | None,
|
|
282
|
+
expected: float,
|
|
283
|
+
) -> None:
|
|
284
|
+
obis = OBIS.parse(code)
|
|
285
|
+
assert obis is not None
|
|
286
|
+
assert obis.scale_value(raw_value, scaler, dlms_unit) == expected
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|