Skip to content

Commit ac8e458

Browse files
authored
feat(gateway): surface gateway subType 0 as None (#2111)
## Summary Gateways such as the Rexel Energeasy Connect report `subType: 0`, which means "no specific sub-type". Previously this structured to `GatewaySubType.UNKNOWN` (via the `UnknownEnumMixin` fallback), conflating "no sub-type" with "an unrecognised sub-type". This maps `subType: 0` to `None` on `Gateway.sub_type` instead. `GatewaySubType.UNKNOWN` stays reserved for genuinely unrecognised **non-zero** values. ## Approach A field `converter=` won't work: cattrs structures the raw `0` into `GatewaySubType.UNKNOWN` *before* `__init__` runs, so the converter couldn't tell `subType: 0` apart from a real unknown. The fix is a cattrs **exact-type structure hook** for `GatewaySubType`, which sees the raw value. It's registered with `register_structure_hook` (direct dispatch) so it overrides cattrs' generic enum factory. No enum member for `0` is added — `None` is the honest representation and the field is already typed `GatewaySubType | None`. ## Behaviour | `subType` | `Gateway.sub_type` | |-----------|--------------------| | `0` | `None` | | `1` (known) | `GatewaySubType.TAHOMA_BASIC` | | `99` (unknown, non-zero) | `GatewaySubType.UNKNOWN` | | absent | `None` | ## Test plan - New `TestGateway` class in `tests/test_models.py` covering all four cases above, including the `setup_rexel.json` fixture. - `pytest` → 506 passed - `mypy pyoverkiz/converter.py` → clean
1 parent e4c3e0a commit ac8e458

2 files changed

Lines changed: 60 additions & 0 deletions

File tree

pyoverkiz/converter.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from cattrs.gen import make_dict_structure_fn, override
1212

1313
from pyoverkiz._case import camelize_key
14+
from pyoverkiz.enums import GatewaySubType
1415
from pyoverkiz.models import (
1516
CommandDefinition,
1617
CommandDefinitions,
@@ -64,6 +65,16 @@ def _make_converter() -> cattrs.Converter:
6465
lambda v, t: v if isinstance(v, t) else t(v),
6566
)
6667

68+
# Gateways report subType 0 to mean "no specific sub-type" — surface that as None
69+
# rather than GatewaySubType.UNKNOWN, which stays reserved for genuinely unrecognised
70+
# values. Scoped to the Optional field (GatewaySubType | None) so bare GatewaySubType
71+
# structuring keeps the generic enum behaviour; exact-type hook (direct dispatch) so it
72+
# overrides the primitive-union and generic enum hooks.
73+
c.register_structure_hook(
74+
GatewaySubType | None,
75+
lambda v, _: None if v in (0, None) else c.structure(v, GatewaySubType),
76+
)
77+
6778
# Custom container types that wrap a list in __init__
6879
def _structure_states(val: Any, _: type) -> States:
6980
if val is None:

tests/test_models.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
EventName,
1515
ExecutionState,
1616
FailureType,
17+
GatewaySubType,
1718
Protocol,
1819
StateDefinitionType,
1920
UIClassifier,
@@ -29,6 +30,7 @@
2930
Device,
3031
Event,
3132
EventState,
33+
Gateway,
3234
PersistedActionGroup,
3335
Setup,
3436
State,
@@ -137,6 +139,53 @@ def test_id_is_none_without_input_id(self):
137139
assert setup.id is None
138140

139141

142+
class TestGateway:
143+
"""Tests for Gateway model parsing, focused on sub_type handling."""
144+
145+
@staticmethod
146+
def _structure(sub_type: int | None) -> Gateway:
147+
raw: dict = {
148+
"gatewayId": "1234-5678-9012",
149+
"connectivity": {"status": "OK", "protocolVersion": "1"},
150+
}
151+
if sub_type is not None:
152+
raw["subType"] = sub_type
153+
return converter.structure(raw, Gateway)
154+
155+
def test_sub_type_zero_is_none(self):
156+
"""A subType of 0 means 'no specific sub-type' and structures as None."""
157+
assert self._structure(0).sub_type is None
158+
159+
def test_sub_type_zero_in_fixture_is_none(self):
160+
"""The Rexel gateway reports subType 0, which should surface as None."""
161+
raw_setup = json.loads(
162+
(FIXTURES_DIR / "setup_rexel.json").read_text(encoding="utf-8")
163+
)
164+
setup = converter.structure(raw_setup, Setup)
165+
166+
assert setup.gateways[0].sub_type is None
167+
168+
def test_known_sub_type_is_preserved(self):
169+
"""A known non-zero subType still maps to its enum member."""
170+
assert self._structure(1).sub_type is GatewaySubType.TAHOMA_BASIC
171+
172+
def test_unknown_non_zero_sub_type_falls_back_to_unknown(self):
173+
"""An unrecognised non-zero subType stays UNKNOWN, not None."""
174+
assert self._structure(99).sub_type is GatewaySubType.UNKNOWN
175+
176+
def test_missing_sub_type_is_none(self):
177+
"""When subType is absent, sub_type defaults to None."""
178+
assert self._structure(None).sub_type is None
179+
180+
def test_bare_enum_zero_is_unaffected(self):
181+
"""Bare GatewaySubType structuring is unaffected by the 0 -> None scoping.
182+
183+
The special-case lives on GatewaySubType | None, so structuring a plain
184+
GatewaySubType still uses the generic enum hook (0 -> UNKNOWN).
185+
"""
186+
assert converter.structure(0, GatewaySubType) is GatewaySubType.UNKNOWN
187+
188+
140189
class TestDevice:
141190
"""Tests for Device model parsing and property extraction."""
142191

0 commit comments

Comments
 (0)