Skip to content

Commit 05ad0f7

Browse files
authored
Fix BLE RPC data read when device needs time to prepare response (#992)
* Fix BLE RPC data read when device needs time to prepare response * tweak
1 parent df5e829 commit 05ad0f7

File tree

2 files changed

+73
-0
lines changed

2 files changed

+73
-0
lines changed

aioshelly/rpc_device/blerpc.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ async def _receive_response(self) -> bytes:
347347
# Large responses may be split across multiple reads
348348
data_bytes = bytearray()
349349
chunk_num = 0
350+
empty_reads = 0
350351
while len(data_bytes) < frame_length:
351352
chunk = cast(
352353
bytes, await self._client.read_gatt_char(DATA_CHARACTERISTIC_UUID)
@@ -360,6 +361,18 @@ async def _receive_response(self) -> bytes:
360361
frame_length,
361362
)
362363
if not chunk:
364+
empty_reads += 1
365+
# If we haven't received any data yet, device may not be ready
366+
# Retry with backoff up to RX_POLL_MAX_ATTEMPTS times
367+
if len(data_bytes) == 0 and empty_reads < RX_POLL_MAX_ATTEMPTS:
368+
_LOGGER.debug(
369+
"Chunk empty (attempt %d/%d), retrying after %ss",
370+
empty_reads,
371+
RX_POLL_MAX_ATTEMPTS,
372+
RX_POLL_INTERVAL,
373+
)
374+
await asyncio.sleep(RX_POLL_INTERVAL)
375+
continue
363376
# No more data available
364377
break
365378
data_bytes.extend(chunk)

tests/rpc_device/test_blerpc.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,37 @@ async def test_blerpc_call_corrupted_frame_length_valid_json(
356356
assert result == {"name": "Test Device"}
357357

358358

359+
@pytest.mark.asyncio
360+
@pytest.mark.usefixtures("mock_establish_connection")
361+
async def test_blerpc_call_first_chunk_empty_retry(
362+
ble_device: BLEDevice, mock_ble_client: MagicMock
363+
) -> None:
364+
"""Test BLE RPC call with multiple empty chunks, then data on retry."""
365+
ble_rpc = BleRPC(ble_device)
366+
367+
mock_ble_client.write_gatt_char = AsyncMock()
368+
mock_ble_client.read_gatt_char = AsyncMock()
369+
370+
response = {"id": 1, "src": "test", "result": {"name": "Test Device"}}
371+
response_bytes = json.dumps(response).encode()
372+
373+
# Multiple empty reads (device not ready), then data arrives
374+
mock_ble_client.read_gatt_char.side_effect = [
375+
len(response_bytes).to_bytes(4, "big"), # Frame length
376+
b"", # First chunk empty - device not ready
377+
b"", # Second chunk empty - still not ready
378+
b"", # Third chunk empty - still not ready
379+
response_bytes, # Fourth chunk has data after retries
380+
b"", # End of data
381+
]
382+
383+
await ble_rpc.connect()
384+
385+
# Should succeed after multiple retries
386+
result = await ble_rpc.call("Shelly.GetDeviceInfo")
387+
assert result == {"name": "Test Device"}
388+
389+
359390
@pytest.mark.asyncio
360391
@pytest.mark.usefixtures("mock_establish_connection")
361392
async def test_blerpc_call_invalid_json(
@@ -597,6 +628,35 @@ async def test_blerpc_call_zero_frame_length_then_success(
597628
assert result == {"name": "Test Device"}
598629

599630

631+
@pytest.mark.asyncio
632+
@pytest.mark.usefixtures("mock_establish_connection")
633+
async def test_blerpc_call_first_chunk_empty_timeout(
634+
ble_device: BLEDevice, mock_ble_client: MagicMock
635+
) -> None:
636+
"""Test BLE RPC call times out after max empty chunk retries."""
637+
ble_rpc = BleRPC(ble_device)
638+
639+
mock_ble_client.write_gatt_char = AsyncMock()
640+
mock_ble_client.read_gatt_char = AsyncMock()
641+
642+
# Frame length indicates data, but reads always return empty
643+
mock_ble_client.read_gatt_char.side_effect = [
644+
(100).to_bytes(4, "big"), # Frame length = 100 bytes
645+
*[b"" for _ in range(60)], # More than RX_POLL_MAX_ATTEMPTS empty reads
646+
]
647+
648+
await ble_rpc.connect()
649+
650+
# Patch RX_POLL_INTERVAL to speed up test
651+
with (
652+
patch("aioshelly.rpc_device.blerpc.RX_POLL_INTERVAL", 0.001),
653+
pytest.raises(
654+
DeviceConnectionError, match="Incomplete data received: expected 100 bytes"
655+
),
656+
):
657+
await ble_rpc.call("Shelly.GetDeviceInfo")
658+
659+
600660
@pytest.mark.asyncio
601661
@pytest.mark.usefixtures("mock_establish_connection")
602662
async def test_blerpc_call_invalid_frame_length_data(

0 commit comments

Comments
 (0)