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.
@@ -71,4 +71,4 @@ dmypy.json
71
71
  .DS_Store
72
72
 
73
73
  # VCS version file (generated by hatch-vcs)
74
- smgw_obis/_version.py
74
+ obis_parser/_version.py
@@ -1,11 +1,10 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: obis-parser
3
- Version: 0.0.4
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:** 39 standard German electricity registers (DIN EN 62056-6-1 / DIN 43863-3) with units, device classes, state classes, and icons.
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:** 39 standard German electricity registers (DIN EN 62056-6-1 / DIN 43863-3) with units, device classes, state classes, and icons.
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.4'
22
- __version_tuple__ = version_tuple = (0, 0, 4)
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("Voltage L1", "voltage_l1", "voltage", "measurement", "V", "mdi:sine-wave", 1),
494
- (52, 7): OBISMeasurementInfo("Voltage L2", "voltage_l2", "voltage", "measurement", "V", "mdi:sine-wave", 1),
495
- (72, 7): OBISMeasurementInfo("Voltage L3", "voltage_l3", "voltage", "measurement", "V", "mdi:sine-wave", 1),
496
- (12, 7): OBISMeasurementInfo("Voltage", "voltage", "voltage", "measurement", "V", "mdi:sine-wave", 1),
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("Current L1", "current_l1", "current", "measurement", "A", "mdi:current-ac", 2),
499
- (51, 7): OBISMeasurementInfo("Current L2", "current_l2", "current", "measurement", "A", "mdi:current-ac", 2),
500
- (71, 7): OBISMeasurementInfo("Current L3", "current_l3", "current", "measurement", "A", "mdi:current-ac", 2),
501
- (11, 7): OBISMeasurementInfo("Current", "current", "current", "measurement", "A", "mdi:current-ac", 2),
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("Phase angle", "phase_angle", None, "measurement", "°", "mdi:angle-acute", 1),
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)", "phase_angle_u_l2_l1", None, "measurement", "°", "mdi:angle-acute", 1
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)", "phase_angle_u_l3_l1", None, "measurement", "°", "mdi:angle-acute", 1
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