Skip to content

Commit 1ce890b

Browse files
authored
Add repair issue for Shelly devices with open WiFi access point (home-assistant#157086)
1 parent 3e7bef7 commit 1ce890b

File tree

6 files changed

+314
-1
lines changed

6 files changed

+314
-1
lines changed

homeassistant/components/shelly/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
from .repairs import (
6161
async_manage_ble_scanner_firmware_unsupported_issue,
6262
async_manage_deprecated_firmware_issue,
63+
async_manage_open_wifi_ap_issue,
6364
async_manage_outbound_websocket_incorrectly_enabled_issue,
6465
)
6566
from .utils import (
@@ -347,6 +348,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry)
347348
hass,
348349
entry,
349350
)
351+
async_manage_open_wifi_ap_issue(hass, entry)
350352
remove_empty_sub_devices(hass, entry)
351353
elif (
352354
sleep_period is None

homeassistant/components/shelly/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ class BLEScannerMode(StrEnum):
254254
"outbound_websocket_incorrectly_enabled_{unique}"
255255
)
256256
DEPRECATED_FIRMWARE_ISSUE_ID = "deprecated_firmware_{unique}"
257+
OPEN_WIFI_AP_ISSUE_ID = "open_wifi_ap_{unique}"
257258

258259

259260
class DeprecatedFirmwareInfo(TypedDict):

homeassistant/components/shelly/repairs.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
DEPRECATED_FIRMWARE_ISSUE_ID,
2323
DEPRECATED_FIRMWARES,
2424
DOMAIN,
25+
OPEN_WIFI_AP_ISSUE_ID,
2526
OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID,
2627
BLEScannerMode,
2728
)
@@ -149,6 +150,45 @@ def async_manage_outbound_websocket_incorrectly_enabled_issue(
149150
ir.async_delete_issue(hass, DOMAIN, issue_id)
150151

151152

153+
@callback
154+
def async_manage_open_wifi_ap_issue(
155+
hass: HomeAssistant,
156+
entry: ShellyConfigEntry,
157+
) -> None:
158+
"""Manage the open WiFi AP issue."""
159+
issue_id = OPEN_WIFI_AP_ISSUE_ID.format(unique=entry.unique_id)
160+
161+
if TYPE_CHECKING:
162+
assert entry.runtime_data.rpc is not None
163+
164+
device = entry.runtime_data.rpc.device
165+
166+
# Check if WiFi AP is enabled and is open (no password)
167+
if (
168+
(wifi_config := device.config.get("wifi"))
169+
and (ap_config := wifi_config.get("ap"))
170+
and ap_config.get("enable")
171+
and ap_config.get("is_open")
172+
):
173+
ir.async_create_issue(
174+
hass,
175+
DOMAIN,
176+
issue_id,
177+
is_fixable=True,
178+
is_persistent=False,
179+
severity=ir.IssueSeverity.WARNING,
180+
translation_key="open_wifi_ap",
181+
translation_placeholders={
182+
"device_name": device.name,
183+
"ip_address": device.ip_address,
184+
},
185+
data={"entry_id": entry.entry_id},
186+
)
187+
return
188+
189+
ir.async_delete_issue(hass, DOMAIN, issue_id)
190+
191+
152192
class ShellyRpcRepairsFlow(RepairsFlow):
153193
"""Handler for an issue fixing flow."""
154194

@@ -229,6 +269,49 @@ async def async_step_disable_outbound_websocket(
229269
return self.async_create_entry(title="", data={})
230270

231271

272+
class DisableOpenWiFiApFlow(RepairsFlow):
273+
"""Handler for Disable Open WiFi AP flow."""
274+
275+
def __init__(self, device: RpcDevice, issue_id: str) -> None:
276+
"""Initialize."""
277+
self._device = device
278+
self.issue_id = issue_id
279+
280+
async def async_step_init(
281+
self, user_input: dict[str, str] | None = None
282+
) -> data_entry_flow.FlowResult:
283+
"""Handle the first step of a fix flow."""
284+
issue_registry = ir.async_get(self.hass)
285+
description_placeholders = None
286+
if issue := issue_registry.async_get_issue(DOMAIN, self.issue_id):
287+
description_placeholders = issue.translation_placeholders
288+
289+
return self.async_show_menu(
290+
menu_options=["confirm", "ignore"],
291+
description_placeholders=description_placeholders,
292+
)
293+
294+
async def async_step_confirm(
295+
self, user_input: dict[str, str] | None = None
296+
) -> data_entry_flow.FlowResult:
297+
"""Handle the confirm step of a fix flow."""
298+
try:
299+
result = await self._device.wifi_setconfig(ap_enable=False)
300+
if result.get("restart_required"):
301+
await self._device.trigger_reboot()
302+
except (DeviceConnectionError, RpcCallError):
303+
return self.async_abort(reason="cannot_connect")
304+
305+
return self.async_create_entry(title="", data={})
306+
307+
async def async_step_ignore(
308+
self, user_input: dict[str, str] | None = None
309+
) -> data_entry_flow.FlowResult:
310+
"""Handle the ignore step of a fix flow."""
311+
ir.async_ignore_issue(self.hass, DOMAIN, self.issue_id, True)
312+
return self.async_abort(reason="issue_ignored")
313+
314+
232315
async def async_create_fix_flow(
233316
hass: HomeAssistant, issue_id: str, data: dict[str, str] | None
234317
) -> RepairsFlow:
@@ -253,4 +336,7 @@ async def async_create_fix_flow(
253336
if "outbound_websocket_incorrectly_enabled" in issue_id:
254337
return DisableOutboundWebSocketFlow(device)
255338

339+
if "open_wifi_ap" in issue_id:
340+
return DisableOpenWiFiApFlow(device, issue_id)
341+
256342
return ConfirmRepairFlow()

homeassistant/components/shelly/strings.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,25 @@
664664
"description": "Shelly device {device_name} with IP address {ip_address} requires calibration. To calibrate the device, it must be rebooted after proper installation on the valve. You can reboot the device in its web panel, go to 'Settings' > 'Device Reboot'.",
665665
"title": "Shelly device {device_name} is not calibrated"
666666
},
667+
"open_wifi_ap": {
668+
"fix_flow": {
669+
"abort": {
670+
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
671+
"issue_ignored": "Issue ignored"
672+
},
673+
"step": {
674+
"init": {
675+
"description": "Your Shelly device {device_name} with IP address {ip_address} has an open WiFi access point enabled without a password. This is a security risk as anyone nearby can connect to the device.\n\nNote: If you disable the access point, the device may need to restart.",
676+
"menu_options": {
677+
"confirm": "Disable WiFi access point",
678+
"ignore": "Ignore"
679+
},
680+
"title": "[%key:component::shelly::issues::open_wifi_ap::title%]"
681+
}
682+
}
683+
},
684+
"title": "Open WiFi access point on {device_name}"
685+
},
667686
"outbound_websocket_incorrectly_enabled": {
668687
"fix_flow": {
669688
"abort": {

tests/components/shelly/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -576,7 +576,7 @@ def _mock_rpc_device(version: str | None = None):
576576
zigbee_enabled=False,
577577
zigbee_firmware=False,
578578
ip_address="10.10.10.10",
579-
wifi_setconfig=AsyncMock(return_value={}),
579+
wifi_setconfig=AsyncMock(return_value={"restart_required": True}),
580580
ble_setconfig=AsyncMock(return_value={"restart_required": False}),
581581
shutdown=AsyncMock(),
582582
)

tests/components/shelly/test_repairs.py

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
CONF_BLE_SCANNER_MODE,
1212
DEPRECATED_FIRMWARE_ISSUE_ID,
1313
DOMAIN,
14+
OPEN_WIFI_AP_ISSUE_ID,
1415
OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID,
1516
BLEScannerMode,
1617
DeprecatedFirmwareInfo,
@@ -254,3 +255,207 @@ async def test_deprecated_firmware_issue(
254255
# Assert the issue is no longer present
255256
assert not issue_registry.async_get_issue(DOMAIN, issue_id)
256257
assert len(issue_registry.issues) == 0
258+
259+
260+
async def test_open_wifi_ap_issue(
261+
hass: HomeAssistant,
262+
hass_client: ClientSessionGenerator,
263+
mock_rpc_device: Mock,
264+
issue_registry: ir.IssueRegistry,
265+
monkeypatch: pytest.MonkeyPatch,
266+
) -> None:
267+
"""Test repair issues handling for open WiFi AP."""
268+
monkeypatch.setitem(
269+
mock_rpc_device.config,
270+
"wifi",
271+
{"ap": {"enable": True, "is_open": True}},
272+
)
273+
274+
issue_id = OPEN_WIFI_AP_ISSUE_ID.format(unique=MOCK_MAC)
275+
assert await async_setup_component(hass, "repairs", {})
276+
await hass.async_block_till_done()
277+
await init_integration(hass, 2)
278+
279+
assert issue_registry.async_get_issue(DOMAIN, issue_id)
280+
assert len(issue_registry.issues) == 1
281+
282+
await async_process_repairs_platforms(hass)
283+
client = await hass_client()
284+
result = await start_repair_fix_flow(client, DOMAIN, issue_id)
285+
286+
flow_id = result["flow_id"]
287+
assert result["step_id"] == "init"
288+
assert result["type"] == "menu"
289+
290+
result = await process_repair_fix_flow(client, flow_id, {"next_step_id": "confirm"})
291+
assert result["type"] == "create_entry"
292+
assert mock_rpc_device.wifi_setconfig.call_count == 1
293+
assert mock_rpc_device.wifi_setconfig.call_args[1] == {"ap_enable": False}
294+
assert mock_rpc_device.trigger_reboot.call_count == 1
295+
296+
assert not issue_registry.async_get_issue(DOMAIN, issue_id)
297+
assert len(issue_registry.issues) == 0
298+
299+
300+
async def test_open_wifi_ap_issue_no_restart(
301+
hass: HomeAssistant,
302+
hass_client: ClientSessionGenerator,
303+
mock_rpc_device: Mock,
304+
issue_registry: ir.IssueRegistry,
305+
monkeypatch: pytest.MonkeyPatch,
306+
) -> None:
307+
"""Test repair issues handling for open WiFi AP when restart not required."""
308+
monkeypatch.setitem(
309+
mock_rpc_device.config,
310+
"wifi",
311+
{"ap": {"enable": True, "is_open": True}},
312+
)
313+
314+
issue_id = OPEN_WIFI_AP_ISSUE_ID.format(unique=MOCK_MAC)
315+
assert await async_setup_component(hass, "repairs", {})
316+
await hass.async_block_till_done()
317+
await init_integration(hass, 2)
318+
319+
assert issue_registry.async_get_issue(DOMAIN, issue_id)
320+
assert len(issue_registry.issues) == 1
321+
322+
await async_process_repairs_platforms(hass)
323+
client = await hass_client()
324+
result = await start_repair_fix_flow(client, DOMAIN, issue_id)
325+
326+
flow_id = result["flow_id"]
327+
assert result["step_id"] == "init"
328+
assert result["type"] == "menu"
329+
330+
mock_rpc_device.wifi_setconfig.return_value = {"restart_required": False}
331+
332+
result = await process_repair_fix_flow(client, flow_id, {"next_step_id": "confirm"})
333+
assert result["type"] == "create_entry"
334+
assert mock_rpc_device.wifi_setconfig.call_count == 1
335+
assert mock_rpc_device.wifi_setconfig.call_args[1] == {"ap_enable": False}
336+
assert mock_rpc_device.trigger_reboot.call_count == 0
337+
338+
assert not issue_registry.async_get_issue(DOMAIN, issue_id)
339+
assert len(issue_registry.issues) == 0
340+
341+
342+
@pytest.mark.parametrize(
343+
"exception", [DeviceConnectionError, RpcCallError(999, "Unknown error")]
344+
)
345+
async def test_open_wifi_ap_issue_exc(
346+
hass: HomeAssistant,
347+
hass_client: ClientSessionGenerator,
348+
mock_rpc_device: Mock,
349+
issue_registry: ir.IssueRegistry,
350+
monkeypatch: pytest.MonkeyPatch,
351+
exception: Exception,
352+
) -> None:
353+
"""Test repair issues handling when wifi_setconfig ends with an exception."""
354+
monkeypatch.setitem(
355+
mock_rpc_device.config,
356+
"wifi",
357+
{"ap": {"enable": True, "is_open": True}},
358+
)
359+
360+
issue_id = OPEN_WIFI_AP_ISSUE_ID.format(unique=MOCK_MAC)
361+
assert await async_setup_component(hass, "repairs", {})
362+
await hass.async_block_till_done()
363+
await init_integration(hass, 2)
364+
365+
assert issue_registry.async_get_issue(DOMAIN, issue_id)
366+
assert len(issue_registry.issues) == 1
367+
368+
await async_process_repairs_platforms(hass)
369+
client = await hass_client()
370+
result = await start_repair_fix_flow(client, DOMAIN, issue_id)
371+
372+
flow_id = result["flow_id"]
373+
assert result["step_id"] == "init"
374+
assert result["type"] == "menu"
375+
376+
mock_rpc_device.wifi_setconfig.side_effect = exception
377+
result = await process_repair_fix_flow(client, flow_id, {"next_step_id": "confirm"})
378+
assert result["type"] == "abort"
379+
assert result["reason"] == "cannot_connect"
380+
assert mock_rpc_device.wifi_setconfig.call_count == 1
381+
382+
assert issue_registry.async_get_issue(DOMAIN, issue_id)
383+
assert len(issue_registry.issues) == 1
384+
385+
386+
async def test_no_open_wifi_ap_issue_with_password(
387+
hass: HomeAssistant,
388+
mock_rpc_device: Mock,
389+
issue_registry: ir.IssueRegistry,
390+
monkeypatch: pytest.MonkeyPatch,
391+
) -> None:
392+
"""Test no repair issue is created when WiFi AP has a password."""
393+
monkeypatch.setitem(
394+
mock_rpc_device.config,
395+
"wifi",
396+
{"ap": {"enable": True, "is_open": False}},
397+
)
398+
399+
issue_id = OPEN_WIFI_AP_ISSUE_ID.format(unique=MOCK_MAC)
400+
await init_integration(hass, 2)
401+
402+
assert not issue_registry.async_get_issue(DOMAIN, issue_id)
403+
assert len(issue_registry.issues) == 0
404+
405+
406+
async def test_no_open_wifi_ap_issue_when_disabled(
407+
hass: HomeAssistant,
408+
mock_rpc_device: Mock,
409+
issue_registry: ir.IssueRegistry,
410+
monkeypatch: pytest.MonkeyPatch,
411+
) -> None:
412+
"""Test no repair issue is created when WiFi AP is disabled."""
413+
monkeypatch.setitem(
414+
mock_rpc_device.config,
415+
"wifi",
416+
{"ap": {"enable": False, "is_open": True}},
417+
)
418+
419+
issue_id = OPEN_WIFI_AP_ISSUE_ID.format(unique=MOCK_MAC)
420+
await init_integration(hass, 2)
421+
422+
assert not issue_registry.async_get_issue(DOMAIN, issue_id)
423+
assert len(issue_registry.issues) == 0
424+
425+
426+
async def test_open_wifi_ap_issue_ignore(
427+
hass: HomeAssistant,
428+
hass_client: ClientSessionGenerator,
429+
mock_rpc_device: Mock,
430+
issue_registry: ir.IssueRegistry,
431+
monkeypatch: pytest.MonkeyPatch,
432+
) -> None:
433+
"""Test ignoring the open WiFi AP issue."""
434+
monkeypatch.setitem(
435+
mock_rpc_device.config,
436+
"wifi",
437+
{"ap": {"enable": True, "is_open": True}},
438+
)
439+
440+
issue_id = OPEN_WIFI_AP_ISSUE_ID.format(unique=MOCK_MAC)
441+
assert await async_setup_component(hass, "repairs", {})
442+
await hass.async_block_till_done()
443+
await init_integration(hass, 2)
444+
445+
assert issue_registry.async_get_issue(DOMAIN, issue_id)
446+
assert len(issue_registry.issues) == 1
447+
448+
await async_process_repairs_platforms(hass)
449+
client = await hass_client()
450+
result = await start_repair_fix_flow(client, DOMAIN, issue_id)
451+
452+
flow_id = result["flow_id"]
453+
assert result["step_id"] == "init"
454+
assert result["type"] == "menu"
455+
456+
result = await process_repair_fix_flow(client, flow_id, {"next_step_id": "ignore"})
457+
assert result["type"] == "abort"
458+
assert result["reason"] == "issue_ignored"
459+
assert mock_rpc_device.wifi_setconfig.call_count == 0
460+
461+
assert issue_registry.async_get_issue(DOMAIN, issue_id).dismissed_version

0 commit comments

Comments
 (0)