From bff37d8c356a054b12efe2b68cf89d64804064b2 Mon Sep 17 00:00:00 2001 From: "J. Nilo" Date: Sat, 4 Apr 2026 23:45:52 +0200 Subject: [PATCH 1/2] Emit camelCase keys in as_json() to match OTBR REST API spec Commit 5e5197a added _normalize_keys() to accept camelCase in GET responses, but as_json() still emitted PascalCase in PUT requests. The upstream ot-br-posix REST API uses cJSON_GetObjectItemCaseSensitive to parse incoming payloads, so PascalCase keys are silently rejected. Add _to_camel_keys() (inverse of _normalize_keys) and apply it in as_json() for Timestamp, SecurityPolicy, ActiveDataSet and PendingDataSet. GET parsing is unchanged (both cases still accepted). Fixes #238 --- python_otbr_api/models.py | 26 +++++++++-- tests/test_init.py | 93 ++++++++++++++++++++------------------- tests/test_models.py | 60 +++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 49 deletions(-) diff --git a/python_otbr_api/models.py b/python_otbr_api/models.py index b1e9b93..cd07dbd 100644 --- a/python_otbr_api/models.py +++ b/python_otbr_api/models.py @@ -44,6 +44,9 @@ } +_PASCAL_TO_CAMEL: dict[str, str] = {v: k for k, v in _CAMEL_TO_PASCAL.items()} + + def _normalize_keys(data: Any) -> Any: """Normalize camelCase JSON keys to PascalCase. @@ -59,6 +62,21 @@ def _normalize_keys(data: Any) -> Any: } +def _to_camel_keys(data: dict) -> dict: + """Convert PascalCase JSON keys to camelCase for serialization. + + The OTBR REST API expects camelCase keys (per the OpenAPI spec in + ot-br-posix). This function converts the internal PascalCase keys to + camelCase. Unknown keys and non-dict values pass through unchanged. + """ + return { + _PASCAL_TO_CAMEL.get(k, k): ( + _to_camel_keys(v) if isinstance(v, dict) else v + ) + for k, v in data.items() + } + + @dataclass class Timestamp: """Timestamp.""" @@ -84,7 +102,7 @@ def as_json(self) -> dict: result["Seconds"] = self.seconds if self.ticks is not None: result["Ticks"] = self.ticks - return result + return _to_camel_keys(result) @classmethod def from_json(cls, json_data: Any) -> Timestamp: @@ -151,7 +169,7 @@ def as_json(self) -> dict: result["Routers"] = self.routers if self.to_ble_link is not None: result["TobleLink"] = self.to_ble_link - return result + return _to_camel_keys(result) @classmethod def from_json(cls, json_data: Any) -> SecurityPolicy: @@ -225,7 +243,7 @@ def as_json(self) -> dict: result["PSKc"] = self.psk_c if self.security_policy is not None: result["SecurityPolicy"] = self.security_policy.as_json() - return result + return _to_camel_keys(result) @classmethod def from_json(cls, json_data: Any) -> ActiveDataSet: @@ -278,7 +296,7 @@ def as_json(self) -> dict: result["Delay"] = self.delay if self.pending_timestamp is not None: result["PendingTimestamp"] = self.pending_timestamp.as_json() - return result + return _to_camel_keys(result) @classmethod def from_json(cls, json_data: Any) -> PendingDataSet: diff --git a/tests/test_init.py b/tests/test_init.py index 94e8e16..40a4e07 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -170,7 +170,31 @@ async def test_get_active_dataset(aioclient_mock: AiohttpClientMocker): DATASET_JSON["PSKc"], security_policy, ) - assert active_dataset.as_json() == DATASET_JSON + # as_json() now emits camelCase keys matching the OTBR REST API spec + camel_dataset = { + "activeTimestamp": {"authoritative": False, "seconds": 1, "ticks": 0}, + "channelMask": 134215680, + "channel": 15, + "extPanId": "8478E3379E047B92", + "meshLocalPrefix": "fd89:bde7:42ed:a901::/64", + "networkKey": "96271D6ECC78749114AB6A591E0D06F1", + "networkName": "OpenThread HA", + "panId": 33991, + "pskc": "9760C89414D461AC717DCD105EB87E5B", + "securityPolicy": { + "autonomousEnrollment": False, + "commercialCommissioning": False, + "externalCommissioning": True, + "nativeCommissioning": True, + "networkKeyProvisioning": False, + "nonCcmRouters": False, + "obtainNetworkKey": True, + "rotationTime": 672, + "routers": True, + "tobleLink": True, + }, + } + assert active_dataset.as_json() == camel_dataset async def test_get_active_dataset_empty(aioclient_mock: AiohttpClientMocker): @@ -245,7 +269,7 @@ async def test_create_active_dataset(aioclient_mock: AiohttpClientMocker): assert aioclient_mock.call_count == 2 assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/dataset/active" - assert aioclient_mock.mock_calls[-1][2] == {"NetworkName": "OpenThread HA"} + assert aioclient_mock.mock_calls[-1][2] == {"networkName": "OpenThread HA"} await otbr.create_active_dataset( python_otbr_api.ActiveDataSet(network_name="OpenThread HA", channel=15) @@ -254,8 +278,8 @@ async def test_create_active_dataset(aioclient_mock: AiohttpClientMocker): assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/dataset/active" assert aioclient_mock.mock_calls[-1][2] == { - "NetworkName": "OpenThread HA", - "Channel": 15, + "networkName": "OpenThread HA", + "channel": 15, } @@ -295,11 +319,11 @@ async def test_create_pending_dataset(aioclient_mock: AiohttpClientMocker): assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/dataset/pending" assert aioclient_mock.mock_calls[-1][2] == { - "ActiveDataset": { - "NetworkName": "OpenThread HA", + "activeDataset": { + "networkName": "OpenThread HA", }, - "Delay": 12345, - "PendingTimestamp": {}, + "delay": 12345, + "pendingTimestamp": {}, } await otbr.create_pending_dataset( @@ -312,11 +336,11 @@ async def test_create_pending_dataset(aioclient_mock: AiohttpClientMocker): assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/dataset/pending" assert aioclient_mock.mock_calls[-1][2] == { - "ActiveDataset": { - "Channel": 15, - "NetworkName": "OpenThread HA", + "activeDataset": { + "channel": 15, + "networkName": "OpenThread HA", }, - "Delay": 23456, + "delay": 23456, } @@ -340,16 +364,6 @@ async def test_set_channel(aioclient_mock: AiohttpClientMocker) -> None: aioclient_mock.get(f"{BASE_URL}/node/dataset/active", json=DATASET_JSON) aioclient_mock.put(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.CREATED) new_channel = 16 - expected_active_timestamp = DATASET_JSON["ActiveTimestamp"] | {"Seconds": 2} - expected_pending_dataset = { - "ActiveDataset": DATASET_JSON - | { - "ActiveTimestamp": expected_active_timestamp, - "Channel": new_channel, - }, - "Delay": 1234, - } - assert new_channel != DATASET_JSON["Channel"] await otbr.set_channel(new_channel, 1234) assert aioclient_mock.call_count == 2 @@ -357,7 +371,10 @@ async def test_set_channel(aioclient_mock: AiohttpClientMocker) -> None: assert aioclient_mock.mock_calls[0][1].path == "/node/dataset/active" assert aioclient_mock.mock_calls[1][0] == "PUT" assert aioclient_mock.mock_calls[1][1].path == "/node/dataset/pending" - assert aioclient_mock.mock_calls[1][2] == expected_pending_dataset + pending = aioclient_mock.mock_calls[1][2] + assert pending["delay"] == 1234 + assert pending["activeDataset"]["channel"] == new_channel + assert pending["activeDataset"]["activeTimestamp"]["seconds"] == 2 async def test_set_channel_default_delay(aioclient_mock: AiohttpClientMocker) -> None: @@ -367,16 +384,6 @@ async def test_set_channel_default_delay(aioclient_mock: AiohttpClientMocker) -> aioclient_mock.get(f"{BASE_URL}/node/dataset/active", json=DATASET_JSON) aioclient_mock.put(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.CREATED) new_channel = 16 - expected_active_timestamp = DATASET_JSON["ActiveTimestamp"] | {"Seconds": 2} - expected_pending_dataset = { - "ActiveDataset": DATASET_JSON - | { - "ActiveTimestamp": expected_active_timestamp, - "Channel": new_channel, - }, - "Delay": 300000, - } - assert new_channel != DATASET_JSON["Channel"] await otbr.set_channel(new_channel) assert aioclient_mock.call_count == 2 @@ -384,7 +391,10 @@ async def test_set_channel_default_delay(aioclient_mock: AiohttpClientMocker) -> assert aioclient_mock.mock_calls[0][1].path == "/node/dataset/active" assert aioclient_mock.mock_calls[1][0] == "PUT" assert aioclient_mock.mock_calls[1][1].path == "/node/dataset/pending" - assert aioclient_mock.mock_calls[1][2] == expected_pending_dataset + pending = aioclient_mock.mock_calls[1][2] + assert pending["delay"] == 300000 + assert pending["activeDataset"]["channel"] == new_channel + assert pending["activeDataset"]["activeTimestamp"]["seconds"] == 2 async def test_set_channel_no_timestamp(aioclient_mock: AiohttpClientMocker) -> None: @@ -397,16 +407,6 @@ async def test_set_channel_no_timestamp(aioclient_mock: AiohttpClientMocker) -> aioclient_mock.get(f"{BASE_URL}/node/dataset/active", json=dataset_json) aioclient_mock.put(f"{BASE_URL}/node/dataset/pending", status=HTTPStatus.CREATED) new_channel = 16 - expected_active_timestamp = {"Authoritative": False, "Seconds": 1, "Ticks": 0} - expected_pending_dataset = { - "ActiveDataset": DATASET_JSON - | { - "ActiveTimestamp": expected_active_timestamp, - "Channel": new_channel, - }, - "Delay": 300000, - } - assert new_channel != DATASET_JSON["Channel"] await otbr.set_channel(new_channel) assert aioclient_mock.call_count == 2 @@ -414,7 +414,10 @@ async def test_set_channel_no_timestamp(aioclient_mock: AiohttpClientMocker) -> assert aioclient_mock.mock_calls[0][1].path == "/node/dataset/active" assert aioclient_mock.mock_calls[1][0] == "PUT" assert aioclient_mock.mock_calls[1][1].path == "/node/dataset/pending" - assert aioclient_mock.mock_calls[1][2] == expected_pending_dataset + pending = aioclient_mock.mock_calls[1][2] + assert pending["delay"] == 300000 + assert pending["activeDataset"]["channel"] == new_channel + assert pending["activeDataset"]["activeTimestamp"]["seconds"] == 1 async def test_set_channel_invalid_channel(aioclient_mock: AiohttpClientMocker) -> None: diff --git a/tests/test_models.py b/tests/test_models.py index 152056b..6256a17 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,6 +1,7 @@ """Test data models.""" import python_otbr_api +from python_otbr_api.models import SecurityPolicy def test_deserialize_pending_dataset(): @@ -66,3 +67,62 @@ def test_deserialize_pending_dataset_camelcase(): 12345, python_otbr_api.Timestamp(), ) + + +def test_serialize_active_dataset_camelcase(): + """Test that as_json() emits camelCase keys matching the OTBR REST API.""" + dataset = python_otbr_api.ActiveDataSet( + active_timestamp=python_otbr_api.Timestamp( + authoritative=False, seconds=1, ticks=0 + ), + network_key="00112233445566778899aabbccddeeff", + network_name="OpenThread-1234", + extended_pan_id="dead00beef00cafe", + mesh_local_prefix="fd11:2222:3333::/64", + pan_id=12345, + channel=15, + psk_c="aabbccddeeff00112233445566778899", + security_policy=SecurityPolicy( + rotation_time=672, obtain_network_key=True, routers=True + ), + channel_mask=134215680, + ) + result = dataset.as_json() + # All top-level keys must be camelCase + assert "activeTimestamp" in result + assert "networkKey" in result + assert "networkName" in result + assert "extPanId" in result + assert "meshLocalPrefix" in result + assert "panId" in result + assert "channel" in result + assert "pskc" in result + assert "securityPolicy" in result + assert "channelMask" in result + # Nested keys must also be camelCase + assert "seconds" in result["activeTimestamp"] + assert "rotationTime" in result["securityPolicy"] + assert "obtainNetworkKey" in result["securityPolicy"] + + +def test_serialize_pending_dataset_camelcase(): + """Test that PendingDataSet.as_json() emits camelCase keys.""" + pending = python_otbr_api.PendingDataSet( + active_dataset=python_otbr_api.ActiveDataSet(network_name="OpenThread HA"), + delay=30000, + pending_timestamp=python_otbr_api.Timestamp(seconds=2, ticks=0), + ) + result = pending.as_json() + assert "activeDataset" in result + assert "delay" in result + assert "pendingTimestamp" in result + assert "networkName" in result["activeDataset"] + + +def test_roundtrip_camelcase(): + """Test that from_json(as_json(x)) preserves data.""" + original = python_otbr_api.ActiveDataSet( + network_name="Test", channel=15, pan_id=4660 + ) + roundtripped = python_otbr_api.ActiveDataSet.from_json(original.as_json()) + assert roundtripped == original From 0609b15154b11e06d6d192dc40ae922854d1feef Mon Sep 17 00:00:00 2001 From: Jacques Nilo Date: Thu, 16 Apr 2026 18:41:44 +0200 Subject: [PATCH 2/2] Emit dual PascalCase+camelCase keys for backward compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback: emit BOTH capitalizations in as_json() so the library works with both older ot-br-posix (PascalCase) and newer versions (camelCase per the OpenAPI spec). Both REST API versions parse payloads case-sensitively and silently ignore unknown keys, so each version reads its preferred capitalization. - Rename _to_camel_keys → _to_dual_keys, emit both variants per known key - Update tests to assert both capitalizations are present at every level - Add roundtrip test covering dual-key input to from_json() --- python_otbr_api/models.py | 36 +++++++------ tests/test_init.py | 108 ++++++++++++++++++++++---------------- tests/test_models.py | 75 +++++++++++++++++--------- 3 files changed, 136 insertions(+), 83 deletions(-) diff --git a/python_otbr_api/models.py b/python_otbr_api/models.py index cd07dbd..700fbea 100644 --- a/python_otbr_api/models.py +++ b/python_otbr_api/models.py @@ -62,19 +62,25 @@ def _normalize_keys(data: Any) -> Any: } -def _to_camel_keys(data: dict) -> dict: - """Convert PascalCase JSON keys to camelCase for serialization. +def _to_dual_keys(data: dict) -> dict: + """Emit both PascalCase and camelCase variants for known keys. - The OTBR REST API expects camelCase keys (per the OpenAPI spec in - ot-br-posix). This function converts the internal PascalCase keys to - camelCase. Unknown keys and non-dict values pass through unchanged. + Recent ot-br-posix versions switched the REST API to camelCase (per the + OpenAPI spec), but older versions still expect PascalCase. Both versions + parse incoming payloads case-sensitively and silently ignore unknown keys, + so we emit both capitalization formats for every known key. This keeps the + library compatible with both old and new border routers. + + Recursion is not needed: nested dicts come from nested ``as_json()`` calls + that already return dual-key dicts. """ - return { - _PASCAL_TO_CAMEL.get(k, k): ( - _to_camel_keys(v) if isinstance(v, dict) else v - ) - for k, v in data.items() - } + result: dict = {} + for k, v in data.items(): + result[k] = v + other = _PASCAL_TO_CAMEL.get(k) or _CAMEL_TO_PASCAL.get(k) + if other is not None and other != k: + result[other] = v + return result @dataclass @@ -102,7 +108,7 @@ def as_json(self) -> dict: result["Seconds"] = self.seconds if self.ticks is not None: result["Ticks"] = self.ticks - return _to_camel_keys(result) + return _to_dual_keys(result) @classmethod def from_json(cls, json_data: Any) -> Timestamp: @@ -169,7 +175,7 @@ def as_json(self) -> dict: result["Routers"] = self.routers if self.to_ble_link is not None: result["TobleLink"] = self.to_ble_link - return _to_camel_keys(result) + return _to_dual_keys(result) @classmethod def from_json(cls, json_data: Any) -> SecurityPolicy: @@ -243,7 +249,7 @@ def as_json(self) -> dict: result["PSKc"] = self.psk_c if self.security_policy is not None: result["SecurityPolicy"] = self.security_policy.as_json() - return _to_camel_keys(result) + return _to_dual_keys(result) @classmethod def from_json(cls, json_data: Any) -> ActiveDataSet: @@ -296,7 +302,7 @@ def as_json(self) -> dict: result["Delay"] = self.delay if self.pending_timestamp is not None: result["PendingTimestamp"] = self.pending_timestamp.as_json() - return _to_camel_keys(result) + return _to_dual_keys(result) @classmethod def from_json(cls, json_data: Any) -> PendingDataSet: diff --git a/tests/test_init.py b/tests/test_init.py index 40a4e07..f5f47d9 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -5,9 +5,20 @@ import pytest import python_otbr_api +from python_otbr_api.models import _to_dual_keys from tests.test_util.aiohttp import AiohttpClientMocker + +def _dual(data: dict) -> dict: + """Expand a camelCase payload to the dual-key format emitted by as_json().""" + result = _to_dual_keys(data) + for key in list(result): + if isinstance(result[key], dict): + result[key] = _dual(result[key]) + return result + + BASE_URL = "http://core-silabs-multiprotocol:8081" DATASET_JSON: dict[str, Any] = { "ActiveTimestamp": { @@ -170,31 +181,34 @@ async def test_get_active_dataset(aioclient_mock: AiohttpClientMocker): DATASET_JSON["PSKc"], security_policy, ) - # as_json() now emits camelCase keys matching the OTBR REST API spec - camel_dataset = { - "activeTimestamp": {"authoritative": False, "seconds": 1, "ticks": 0}, - "channelMask": 134215680, - "channel": 15, - "extPanId": "8478E3379E047B92", - "meshLocalPrefix": "fd89:bde7:42ed:a901::/64", - "networkKey": "96271D6ECC78749114AB6A591E0D06F1", - "networkName": "OpenThread HA", - "panId": 33991, - "pskc": "9760C89414D461AC717DCD105EB87E5B", - "securityPolicy": { - "autonomousEnrollment": False, - "commercialCommissioning": False, - "externalCommissioning": True, - "nativeCommissioning": True, - "networkKeyProvisioning": False, - "nonCcmRouters": False, - "obtainNetworkKey": True, - "rotationTime": 672, - "routers": True, - "tobleLink": True, - }, - } - assert active_dataset.as_json() == camel_dataset + # as_json() emits both PascalCase and camelCase keys for compatibility with + # both older and newer ot-br-posix REST API versions. + expected = _dual( + { + "activeTimestamp": {"authoritative": False, "seconds": 1, "ticks": 0}, + "channelMask": 134215680, + "channel": 15, + "extPanId": "8478E3379E047B92", + "meshLocalPrefix": "fd89:bde7:42ed:a901::/64", + "networkKey": "96271D6ECC78749114AB6A591E0D06F1", + "networkName": "OpenThread HA", + "panId": 33991, + "pskc": "9760C89414D461AC717DCD105EB87E5B", + "securityPolicy": { + "autonomousEnrollment": False, + "commercialCommissioning": False, + "externalCommissioning": True, + "nativeCommissioning": True, + "networkKeyProvisioning": False, + "nonCcmRouters": False, + "obtainNetworkKey": True, + "rotationTime": 672, + "routers": True, + "tobleLink": True, + }, + } + ) + assert active_dataset.as_json() == expected async def test_get_active_dataset_empty(aioclient_mock: AiohttpClientMocker): @@ -269,7 +283,7 @@ async def test_create_active_dataset(aioclient_mock: AiohttpClientMocker): assert aioclient_mock.call_count == 2 assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/dataset/active" - assert aioclient_mock.mock_calls[-1][2] == {"networkName": "OpenThread HA"} + assert aioclient_mock.mock_calls[-1][2] == _dual({"networkName": "OpenThread HA"}) await otbr.create_active_dataset( python_otbr_api.ActiveDataSet(network_name="OpenThread HA", channel=15) @@ -277,10 +291,12 @@ async def test_create_active_dataset(aioclient_mock: AiohttpClientMocker): assert aioclient_mock.call_count == 3 assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/dataset/active" - assert aioclient_mock.mock_calls[-1][2] == { - "networkName": "OpenThread HA", - "channel": 15, - } + assert aioclient_mock.mock_calls[-1][2] == _dual( + { + "networkName": "OpenThread HA", + "channel": 15, + } + ) async def test_delete_active_dataset(aioclient_mock: AiohttpClientMocker): @@ -318,13 +334,15 @@ async def test_create_pending_dataset(aioclient_mock: AiohttpClientMocker): assert aioclient_mock.call_count == 2 assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/dataset/pending" - assert aioclient_mock.mock_calls[-1][2] == { - "activeDataset": { - "networkName": "OpenThread HA", - }, - "delay": 12345, - "pendingTimestamp": {}, - } + assert aioclient_mock.mock_calls[-1][2] == _dual( + { + "activeDataset": { + "networkName": "OpenThread HA", + }, + "delay": 12345, + "pendingTimestamp": {}, + } + ) await otbr.create_pending_dataset( python_otbr_api.PendingDataSet( @@ -335,13 +353,15 @@ async def test_create_pending_dataset(aioclient_mock: AiohttpClientMocker): assert aioclient_mock.call_count == 3 assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/dataset/pending" - assert aioclient_mock.mock_calls[-1][2] == { - "activeDataset": { - "channel": 15, - "networkName": "OpenThread HA", - }, - "delay": 23456, - } + assert aioclient_mock.mock_calls[-1][2] == _dual( + { + "activeDataset": { + "channel": 15, + "networkName": "OpenThread HA", + }, + "delay": 23456, + } + ) async def test_delete_pending_dataset(aioclient_mock: AiohttpClientMocker): diff --git a/tests/test_models.py b/tests/test_models.py index 6256a17..c6d2b01 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -69,8 +69,14 @@ def test_deserialize_pending_dataset_camelcase(): ) -def test_serialize_active_dataset_camelcase(): - """Test that as_json() emits camelCase keys matching the OTBR REST API.""" +def test_serialize_active_dataset_dual_keys(): + """Test that as_json() emits both PascalCase and camelCase keys. + + ot-br-posix's REST API parses incoming payloads case-sensitively but + silently ignores unknown keys. Emitting both capitalizations keeps the + library compatible with both older routers (PascalCase) and newer ones + (camelCase, per the OpenAPI spec). + """ dataset = python_otbr_api.ActiveDataSet( active_timestamp=python_otbr_api.Timestamp( authoritative=False, seconds=1, ticks=0 @@ -88,39 +94,60 @@ def test_serialize_active_dataset_camelcase(): channel_mask=134215680, ) result = dataset.as_json() - # All top-level keys must be camelCase - assert "activeTimestamp" in result - assert "networkKey" in result - assert "networkName" in result - assert "extPanId" in result - assert "meshLocalPrefix" in result - assert "panId" in result - assert "channel" in result - assert "pskc" in result - assert "securityPolicy" in result - assert "channelMask" in result - # Nested keys must also be camelCase - assert "seconds" in result["activeTimestamp"] - assert "rotationTime" in result["securityPolicy"] - assert "obtainNetworkKey" in result["securityPolicy"] + # Top-level keys must be present in BOTH capitalizations. + for camel, pascal in ( + ("activeTimestamp", "ActiveTimestamp"), + ("networkKey", "NetworkKey"), + ("networkName", "NetworkName"), + ("extPanId", "ExtPanId"), + ("meshLocalPrefix", "MeshLocalPrefix"), + ("panId", "PanId"), + ("channel", "Channel"), + ("pskc", "PSKc"), + ("securityPolicy", "SecurityPolicy"), + ("channelMask", "ChannelMask"), + ): + assert camel in result, f"missing camelCase key {camel}" + assert pascal in result, f"missing PascalCase key {pascal}" + assert result[camel] == result[pascal] + # Nested dicts must also expose both capitalizations. + for camel, pascal in (("seconds", "Seconds"), ("ticks", "Ticks")): + assert camel in result["activeTimestamp"] + assert pascal in result["activeTimestamp"] + for camel, pascal in ( + ("rotationTime", "RotationTime"), + ("obtainNetworkKey", "ObtainNetworkKey"), + ("routers", "Routers"), + ): + assert camel in result["securityPolicy"] + assert pascal in result["securityPolicy"] -def test_serialize_pending_dataset_camelcase(): - """Test that PendingDataSet.as_json() emits camelCase keys.""" +def test_serialize_pending_dataset_dual_keys(): + """Test that PendingDataSet.as_json() emits both capitalizations.""" pending = python_otbr_api.PendingDataSet( active_dataset=python_otbr_api.ActiveDataSet(network_name="OpenThread HA"), delay=30000, pending_timestamp=python_otbr_api.Timestamp(seconds=2, ticks=0), ) result = pending.as_json() - assert "activeDataset" in result - assert "delay" in result - assert "pendingTimestamp" in result + for camel, pascal in ( + ("activeDataset", "ActiveDataset"), + ("delay", "Delay"), + ("pendingTimestamp", "PendingTimestamp"), + ): + assert camel in result + assert pascal in result assert "networkName" in result["activeDataset"] + assert "NetworkName" in result["activeDataset"] + +def test_roundtrip_dual_keys(): + """Test that from_json(as_json(x)) preserves data. -def test_roundtrip_camelcase(): - """Test that from_json(as_json(x)) preserves data.""" + from_json() must cope with dual-key input (both PascalCase and camelCase + present at once), which is what as_json() now emits. + """ original = python_otbr_api.ActiveDataSet( network_name="Test", channel=15, pan_id=4660 )