Skip to content

Commit 1274c17

Browse files
authored
Merge pull request #59 from jfmlima/fix/rpc-result-unwrap
Read the RPC result out of the response frame
2 parents 56fca67 + e902dfa commit 1274c17

4 files changed

Lines changed: 152 additions & 1 deletion

File tree

packages/core/src/core/gateways/device/shelly_device_gateway.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from ...services.auth_state_cache import AuthStateCache
2222
from ...utils.validation import normalize_mac
2323
from ..network.network import RpcNetworkGateway
24+
from ..network.rpc_envelope import RpcError, rpc_result
2425
from .device import DeviceGateway
2526
from .legacy_device_gateway import LegacyDeviceGateway
2627
from .legacy_route import LegacyRoute
@@ -265,7 +266,15 @@ async def execute_component_action(
265266
message=(
266267
f"{action_name.method} executed successfully on {component_key}"
267268
),
268-
data=response,
269+
data=rpc_result(response),
270+
)
271+
272+
except RpcError as e:
273+
# The device answered, and refused. That is a failed action, not a
274+
# transport problem, and it carries the device's own reason.
275+
return envelope.failed(
276+
message=f"{action_name.method} failed on {component_key}",
277+
error=str(e),
269278
)
270279

271280
except Exception as e:
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Reading a Shelly JSON-RPC response frame.
2+
3+
A device answers every ``/rpc`` call with HTTP 200 and a JSON-RPC frame:
4+
``{"id": ..., "src": ..., "result": {...}}`` when the call worked, and the same
5+
frame carrying ``"error"`` instead when it did not. Neither the payload nor the
6+
failure is visible unless the frame is opened, so reading the frame as if it
7+
were the payload turns device rejections into successes and stores envelopes
8+
wherever the payload belongs.
9+
"""
10+
11+
from typing import Any
12+
13+
14+
class RpcError(Exception):
15+
"""A device answered an RPC call with an error member."""
16+
17+
def __init__(self, code: Any, message: str) -> None:
18+
self.code = code
19+
self.message = message
20+
super().__init__(f"{message} (code: {code})")
21+
22+
23+
def rpc_result(response: Any) -> Any:
24+
"""The payload carried by a JSON-RPC response frame.
25+
26+
A response that is not a frame is returned unchanged, so callers that
27+
already hold a bare payload keep working.
28+
29+
Raises:
30+
RpcError: If the device answered with an error member.
31+
"""
32+
if not isinstance(response, dict):
33+
return response
34+
35+
error = response.get("error")
36+
if isinstance(error, dict):
37+
raise RpcError(error.get("code"), str(error.get("message", "Unknown error")))
38+
39+
if "result" in response:
40+
return response["result"]
41+
return response

packages/core/tests/unit/gateways/device/test_shelly_device_gateway.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,63 @@ async def test_it_gets_device_status_with_zigbee_failure(
289289
assert calls[2] == (("192.168.1.100", "Shelly.GetStatus"), {"timeout": 10.0})
290290
assert calls[3] == (("192.168.1.100", "Shelly.ListMethods"), {"timeout": 10.0})
291291

292+
async def test_it_returns_the_rpc_payload_not_the_response_frame(
293+
self, gateway, mock_rpc_client
294+
):
295+
# Frame captured verbatim from a Gen4 device: the config a caller wants
296+
# sits under "result", alongside the request id and the device id.
297+
mock_rpc_client.make_rpc_request = AsyncMock(
298+
side_effect=[
299+
({"methods": ["Switch.GetConfig"]}, 0.1),
300+
(
301+
{
302+
"id": "68ef3f1a-b13a-490b-9ab0-d4b8f21d5580",
303+
"src": "shelly1pmminig4-7c2c676d30d4",
304+
"result": {"id": 0, "name": None, "in_mode": "flip"},
305+
},
306+
0.1,
307+
),
308+
]
309+
)
310+
311+
result = await gateway.execute_component_action(
312+
"192.168.1.100", "switch:0", "GetConfig"
313+
)
314+
315+
assert result.success is True
316+
assert result.data == {"id": 0, "name": None, "in_mode": "flip"}
317+
318+
async def test_it_reports_a_device_rejection_as_a_failed_action(
319+
self, gateway, mock_rpc_client
320+
):
321+
# Devices answer a rejected call with HTTP 200 and an "error" member,
322+
# so a frame that is never opened reads as a success.
323+
mock_rpc_client.make_rpc_request = AsyncMock(
324+
side_effect=[
325+
({"methods": ["Switch.GetConfig"]}, 0.1),
326+
(
327+
{
328+
"id": "b036bb7f-91f8-4b4b-b81b-0376cafedee0",
329+
"src": "shelly1pmminig4-7c2c676d30d4",
330+
"error": {
331+
"code": -105,
332+
"message": "Argument 'id', value 99 not found!",
333+
},
334+
},
335+
0.1,
336+
),
337+
]
338+
)
339+
340+
result = await gateway.execute_component_action(
341+
"192.168.1.100", "switch:99", "GetConfig"
342+
)
343+
344+
assert result.success is False
345+
assert result.data is None
346+
assert "value 99 not found" in result.error
347+
assert "-105" in result.error
348+
292349
async def test_it_handles_update_check_failure_gracefully(
293350
self, gateway, mock_rpc_client
294351
):
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import pytest
2+
from core.gateways.network.rpc_envelope import RpcError, rpc_result
3+
4+
# Frames captured verbatim from a Shelly Gen4 (S4SW-001P8EU, fw 1.7.1) over
5+
# POST /rpc. Both arrive as HTTP 200; only the member distinguishes them.
6+
SUCCESS_FRAME = {
7+
"id": "probe-1",
8+
"src": "shelly1pmminig4-7c2c676d30d4",
9+
"result": {"id": 0, "name": None, "in_mode": "flip", "auto_off_delay": 60.0},
10+
}
11+
ERROR_FRAME = {
12+
"id": "probe-4",
13+
"src": "shelly1pmminig4-7c2c676d30d4",
14+
"error": {"code": -105, "message": "Argument 'id', value 99 not found!"},
15+
}
16+
17+
18+
class TestRpcResult:
19+
def test_it_returns_the_payload_not_the_frame(self):
20+
assert rpc_result(SUCCESS_FRAME) == {
21+
"id": 0,
22+
"name": None,
23+
"in_mode": "flip",
24+
"auto_off_delay": 60.0,
25+
}
26+
27+
def test_it_raises_on_a_device_rejection(self):
28+
with pytest.raises(RpcError) as excinfo:
29+
rpc_result(ERROR_FRAME)
30+
31+
assert excinfo.value.code == -105
32+
assert "value 99 not found" in str(excinfo.value)
33+
34+
def test_it_returns_an_empty_result_as_is(self):
35+
# A method with no return value answers with an empty result member.
36+
assert rpc_result({"id": "x", "src": "y", "result": {}}) == {}
37+
38+
def test_it_passes_through_a_response_that_is_not_a_frame(self):
39+
assert rpc_result({"methods": ["Switch.Toggle"]}) == {
40+
"methods": ["Switch.Toggle"]
41+
}
42+
43+
def test_it_passes_through_a_non_mapping(self):
44+
assert rpc_result(None) is None

0 commit comments

Comments
 (0)