Skip to content

Commit e2e41ca

Browse files
authored
Add wifi_scan and extend wifi_setconfig for BLE provisioning (#1014)
1 parent aa37fc6 commit e2e41ca

File tree

6 files changed

+157
-53
lines changed

6 files changed

+157
-53
lines changed

aioshelly/ble/provisioning.py

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
from __future__ import annotations
44

55
from contextlib import asynccontextmanager
6-
from typing import TYPE_CHECKING, Any, cast
6+
from typing import TYPE_CHECKING
77

88
from ..common import ConnectionOptions
99
from ..rpc_device import RpcDevice
10+
from ..rpc_device.models import ShellyWiFiNetwork
1011

1112
if TYPE_CHECKING:
1213
from collections.abc import AsyncIterator
@@ -42,7 +43,9 @@ async def ble_rpc_device(ble_device: BLEDevice) -> AsyncIterator[RpcDevice]:
4243
await device.shutdown()
4344

4445

45-
async def async_scan_wifi_networks(ble_device: BLEDevice) -> list[dict[str, Any]]:
46+
async def async_scan_wifi_networks(
47+
ble_device: BLEDevice,
48+
) -> list[ShellyWiFiNetwork]:
4649
"""Scan for WiFi networks via BLE.
4750
4851
Args:
@@ -57,9 +60,7 @@ async def async_scan_wifi_networks(ble_device: BLEDevice) -> list[dict[str, Any]
5760
5861
"""
5962
async with ble_rpc_device(ble_device) as device:
60-
# WiFi scan can take up to 20 seconds - use 30s timeout to be safe
61-
scan_result = await device.call_rpc("WiFi.Scan", timeout=30)
62-
return cast(list[dict[str, Any]], scan_result.get("results", []))
63+
return await device.wifi_scan()
6364

6465

6566
async def async_provision_wifi(ble_device: BLEDevice, ssid: str, password: str) -> None:
@@ -76,15 +77,6 @@ async def async_provision_wifi(ble_device: BLEDevice, ssid: str, password: str)
7677
7778
"""
7879
async with ble_rpc_device(ble_device) as device:
79-
await device.call_rpc(
80-
"WiFi.SetConfig",
81-
{
82-
"config": {
83-
"sta": {
84-
"ssid": ssid,
85-
"pass": password,
86-
"enable": True,
87-
}
88-
}
89-
},
80+
await device.wifi_setconfig(
81+
sta_ssid=ssid, sta_password=password, sta_enable=True
9082
)

aioshelly/rpc_device/device.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
ShellyBLESetConfig,
4848
ShellyScript,
4949
ShellyScriptCode,
50+
ShellyWiFiNetwork,
5051
ShellyWiFiSetConfig,
5152
ShellyWsConfig,
5253
ShellyWsSetConfig,
@@ -720,12 +721,20 @@ async def ble_getconfig(self) -> ShellyBLEConfig:
720721
return cast(ShellyBLEConfig, await self.call_rpc("BLE.GetConfig"))
721722

722723
async def wifi_setconfig(
723-
self, *, ap_enable: bool | None = None
724+
self,
725+
*,
726+
ap_enable: bool | None = None,
727+
sta_ssid: str | None = None,
728+
sta_password: str | None = None,
729+
sta_enable: bool | None = None,
724730
) -> ShellyWiFiSetConfig:
725731
"""Configure WiFi settings with WiFi.SetConfig.
726732
727733
Args:
728734
ap_enable: Whether to enable the WiFi AP
735+
sta_ssid: WiFi station SSID to connect to
736+
sta_password: WiFi station password
737+
sta_enable: Whether to enable the WiFi station
729738
730739
Returns:
731740
Response dict, may contain "restart_required": bool
@@ -734,12 +743,39 @@ async def wifi_setconfig(
734743
config: dict[str, Any] = {}
735744
if ap_enable is not None:
736745
config["ap"] = {"enable": ap_enable}
746+
if sta_ssid is not None or sta_password is not None or sta_enable is not None:
747+
sta_config: dict[str, Any] = {}
748+
if sta_ssid is not None:
749+
sta_config["ssid"] = sta_ssid
750+
if sta_password is not None:
751+
sta_config["pass"] = sta_password
752+
if sta_enable is not None:
753+
sta_config["enable"] = sta_enable
754+
config["sta"] = sta_config
737755

738756
return cast(
739757
ShellyWiFiSetConfig,
740758
await self.call_rpc("WiFi.SetConfig", {"config": config}),
741759
)
742760

761+
async def wifi_scan(self, timeout: float = 30) -> list[ShellyWiFiNetwork]:
762+
"""Scan for WiFi networks using WiFi.Scan.
763+
764+
Args:
765+
timeout: Scan timeout in seconds (default 30s, scan takes up to 20s)
766+
767+
Returns:
768+
List of WiFi networks with ssid, rssi, auth fields
769+
770+
Raises:
771+
DeviceConnectionError: If connection to device fails
772+
RpcCallError: If RPC call fails
773+
774+
"""
775+
scan_result = await self.call_rpc("WiFi.Scan", timeout=timeout)
776+
results: list[ShellyWiFiNetwork] = scan_result.get("results", [])
777+
return results
778+
743779
async def ws_setconfig(
744780
self, enable: bool, server: str, ssl_ca: str = "*"
745781
) -> ShellyWsSetConfig:

aioshelly/rpc_device/models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ class ShellyWiFiSetConfig(TypedDict, total=False):
4545
restart_required: bool
4646

4747

48+
class ShellyWiFiNetwork(TypedDict, total=False):
49+
"""Shelly WiFi Network from scan results."""
50+
51+
ssid: str
52+
bssid: str
53+
auth: int
54+
channel: int
55+
rssi: int
56+
57+
4858
class ShellyWsConfig(TypedDict, total=False):
4959
"""Shelly Outbound Websocket Config."""
5060

tests/ble/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ def mock_rpc_device() -> AsyncMock:
2828
mock_device = AsyncMock()
2929
mock_device.initialize = AsyncMock()
3030
mock_device.call_rpc = AsyncMock()
31+
mock_device.wifi_scan = AsyncMock()
32+
mock_device.wifi_setconfig = AsyncMock()
3133
mock_device.shutdown = AsyncMock()
3234
return mock_device
3335

tests/ble/test_provisioning.py

Lines changed: 12 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,10 @@ async def test_scan_wifi_networks_success(
1616
mock_rpc_device: AsyncMock,
1717
) -> None:
1818
"""Test scanning for WiFi networks successfully."""
19-
mock_rpc_device.call_rpc.return_value = {
20-
"results": [
21-
{"ssid": "Network1", "rssi": -50, "auth": 2},
22-
{"ssid": "Network2", "rssi": -60, "auth": 3},
23-
]
24-
}
19+
mock_rpc_device.wifi_scan.return_value = [
20+
{"ssid": "Network1", "rssi": -50, "auth": 2},
21+
{"ssid": "Network2", "rssi": -60, "auth": 3},
22+
]
2523

2624
result = await async_scan_wifi_networks(mock_ble_device)
2725

@@ -30,7 +28,7 @@ async def test_scan_wifi_networks_success(
3028
{"ssid": "Network2", "rssi": -60, "auth": 3},
3129
]
3230
mock_rpc_device.initialize.assert_called_once()
33-
mock_rpc_device.call_rpc.assert_called_once_with("WiFi.Scan", timeout=30)
31+
mock_rpc_device.wifi_scan.assert_called_once()
3432
mock_rpc_device.shutdown.assert_called_once()
3533

3634

@@ -41,22 +39,7 @@ async def test_scan_wifi_networks_empty_results(
4139
mock_rpc_device: AsyncMock,
4240
) -> None:
4341
"""Test scanning for WiFi networks with empty results."""
44-
mock_rpc_device.call_rpc.return_value = {"results": []}
45-
46-
result = await async_scan_wifi_networks(mock_ble_device)
47-
48-
assert result == []
49-
mock_rpc_device.shutdown.assert_called_once()
50-
51-
52-
@pytest.mark.asyncio
53-
@pytest.mark.usefixtures("mock_rpc_device_class")
54-
async def test_scan_wifi_networks_no_results_key(
55-
mock_ble_device: MagicMock,
56-
mock_rpc_device: AsyncMock,
57-
) -> None:
58-
"""Test scanning for WiFi networks with missing results key."""
59-
mock_rpc_device.call_rpc.return_value = {}
42+
mock_rpc_device.wifi_scan.return_value = []
6043

6144
result = await async_scan_wifi_networks(mock_ble_device)
6245

@@ -71,7 +54,7 @@ async def test_scan_wifi_networks_exception_cleanup(
7154
mock_rpc_device: AsyncMock,
7255
) -> None:
7356
"""Test that device is shutdown even if scan fails."""
74-
mock_rpc_device.call_rpc.side_effect = Exception("Scan failed")
57+
mock_rpc_device.wifi_scan.side_effect = Exception("Scan failed")
7558

7659
with pytest.raises(Exception, match="Scan failed"):
7760
await async_scan_wifi_networks(mock_ble_device)
@@ -90,17 +73,10 @@ async def test_provision_wifi_success(
9073
await async_provision_wifi(mock_ble_device, "MyNetwork", "MyPassword")
9174

9275
mock_rpc_device.initialize.assert_called_once()
93-
mock_rpc_device.call_rpc.assert_called_once_with(
94-
"WiFi.SetConfig",
95-
{
96-
"config": {
97-
"sta": {
98-
"ssid": "MyNetwork",
99-
"pass": "MyPassword",
100-
"enable": True,
101-
}
102-
}
103-
},
76+
mock_rpc_device.wifi_setconfig.assert_called_once_with(
77+
sta_ssid="MyNetwork",
78+
sta_password="MyPassword", # noqa: S106
79+
sta_enable=True,
10480
)
10581
mock_rpc_device.shutdown.assert_called_once()
10682

@@ -112,7 +88,7 @@ async def test_provision_wifi_exception_cleanup(
11288
mock_rpc_device: AsyncMock,
11389
) -> None:
11490
"""Test that device is shutdown even if provisioning fails."""
115-
mock_rpc_device.call_rpc.side_effect = Exception("Provisioning failed")
91+
mock_rpc_device.wifi_setconfig.side_effect = Exception("Provisioning failed")
11692

11793
with pytest.raises(Exception, match="Provisioning failed"):
11894
await async_provision_wifi(mock_ble_device, "MyNetwork", "MyPassword")

tests/rpc_device/test_device.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,94 @@ async def test_wifi_setconfig(rpc_device: RpcDevice) -> None:
549549
}
550550

551551

552+
@pytest.mark.asyncio
553+
async def test_wifi_setconfig_sta(rpc_device: RpcDevice) -> None:
554+
"""Test RpcDevice wifi_setconfig method with STA configuration."""
555+
await rpc_device.wifi_setconfig(
556+
sta_ssid="MyNetwork",
557+
sta_password="MyPassword", # noqa: S106
558+
sta_enable=True,
559+
)
560+
561+
assert rpc_device.call_rpc_multiple.call_count == 1
562+
assert rpc_device.call_rpc_multiple.call_args[0][0][0][0] == "WiFi.SetConfig"
563+
assert rpc_device.call_rpc_multiple.call_args[0][0][0][1] == {
564+
"config": {
565+
"sta": {
566+
"ssid": "MyNetwork",
567+
"pass": "MyPassword",
568+
"enable": True,
569+
}
570+
}
571+
}
572+
573+
574+
@pytest.mark.asyncio
575+
async def test_wifi_setconfig_ap_and_sta(rpc_device: RpcDevice) -> None:
576+
"""Test RpcDevice wifi_setconfig method with both AP and STA configuration."""
577+
await rpc_device.wifi_setconfig(
578+
ap_enable=False,
579+
sta_ssid="MyNetwork",
580+
sta_password="MyPassword", # noqa: S106
581+
sta_enable=True,
582+
)
583+
584+
assert rpc_device.call_rpc_multiple.call_count == 1
585+
assert rpc_device.call_rpc_multiple.call_args[0][0][0][0] == "WiFi.SetConfig"
586+
assert rpc_device.call_rpc_multiple.call_args[0][0][0][1] == {
587+
"config": {
588+
"ap": {"enable": False},
589+
"sta": {
590+
"ssid": "MyNetwork",
591+
"pass": "MyPassword",
592+
"enable": True,
593+
},
594+
}
595+
}
596+
597+
598+
@pytest.mark.asyncio
599+
async def test_wifi_scan(rpc_device: RpcDevice) -> None:
600+
"""Test RpcDevice wifi_scan method."""
601+
rpc_device.call_rpc_multiple.return_value = [
602+
{
603+
"results": [
604+
{"ssid": "Network1", "rssi": -50, "auth": 2},
605+
{"ssid": "Network2", "rssi": -60, "auth": 3},
606+
]
607+
}
608+
]
609+
610+
result = await rpc_device.wifi_scan()
611+
612+
assert result == [
613+
{"ssid": "Network1", "rssi": -50, "auth": 2},
614+
{"ssid": "Network2", "rssi": -60, "auth": 3},
615+
]
616+
assert rpc_device.call_rpc_multiple.call_count == 1
617+
assert rpc_device.call_rpc_multiple.call_args[0][0][0][0] == "WiFi.Scan"
618+
619+
620+
@pytest.mark.asyncio
621+
async def test_wifi_scan_empty(rpc_device: RpcDevice) -> None:
622+
"""Test RpcDevice wifi_scan method with empty results."""
623+
rpc_device.call_rpc_multiple.return_value = [{"results": []}]
624+
625+
result = await rpc_device.wifi_scan()
626+
627+
assert result == []
628+
629+
630+
@pytest.mark.asyncio
631+
async def test_wifi_scan_no_results_key(rpc_device: RpcDevice) -> None:
632+
"""Test RpcDevice wifi_scan method with missing results key."""
633+
rpc_device.call_rpc_multiple.return_value = [{}]
634+
635+
result = await rpc_device.wifi_scan()
636+
637+
assert result == []
638+
639+
552640
@pytest.mark.asyncio
553641
async def test_script_stop(rpc_device: RpcDevice) -> None:
554642
"""Test RpcDevice script_stop method."""

0 commit comments

Comments
 (0)