obis-parser 0.0.1__tar.gz → 0.0.4__tar.gz

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