Compare commits

...

3 Commits

Author SHA1 Message Date
J. Nick Koston
29593bb503 fix to menu 2025-11-22 21:43:27 -08:00
J. Nick Koston
85b75d47d2 tweaks 2025-11-22 21:24:51 -08:00
J. Nick Koston
d16e0a9bd9 Add repair issue for Shelly devices with open WiFi access point 2025-11-22 21:13:56 -08:00
6 changed files with 314 additions and 1 deletions

View File

@@ -60,6 +60,7 @@ from .coordinator import (
from .repairs import (
async_manage_ble_scanner_firmware_unsupported_issue,
async_manage_deprecated_firmware_issue,
async_manage_open_wifi_ap_issue,
async_manage_outbound_websocket_incorrectly_enabled_issue,
)
from .utils import (
@@ -347,6 +348,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry)
hass,
entry,
)
async_manage_open_wifi_ap_issue(hass, entry)
remove_empty_sub_devices(hass, entry)
elif (
sleep_period is None

View File

@@ -254,6 +254,7 @@ OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID = (
"outbound_websocket_incorrectly_enabled_{unique}"
)
DEPRECATED_FIRMWARE_ISSUE_ID = "deprecated_firmware_{unique}"
OPEN_WIFI_AP_ISSUE_ID = "open_wifi_ap_{unique}"
class DeprecatedFirmwareInfo(TypedDict):

View File

@@ -22,6 +22,7 @@ from .const import (
DEPRECATED_FIRMWARE_ISSUE_ID,
DEPRECATED_FIRMWARES,
DOMAIN,
OPEN_WIFI_AP_ISSUE_ID,
OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID,
BLEScannerMode,
)
@@ -149,6 +150,45 @@ def async_manage_outbound_websocket_incorrectly_enabled_issue(
ir.async_delete_issue(hass, DOMAIN, issue_id)
@callback
def async_manage_open_wifi_ap_issue(
hass: HomeAssistant,
entry: ShellyConfigEntry,
) -> None:
"""Manage the open WiFi AP issue."""
issue_id = OPEN_WIFI_AP_ISSUE_ID.format(unique=entry.unique_id)
if TYPE_CHECKING:
assert entry.runtime_data.rpc is not None
device = entry.runtime_data.rpc.device
# Check if WiFi AP is enabled and has no password (open/unsecured)
if (
(wifi_config := device.config.get("wifi"))
and (ap_config := wifi_config.get("ap"))
and ap_config.get("enable")
and not ap_config.get("pass")
):
ir.async_create_issue(
hass,
DOMAIN,
issue_id,
is_fixable=True,
is_persistent=False,
severity=ir.IssueSeverity.WARNING,
translation_key="open_wifi_ap",
translation_placeholders={
"device_name": device.name,
"ip_address": device.ip_address,
},
data={"entry_id": entry.entry_id},
)
return
ir.async_delete_issue(hass, DOMAIN, issue_id)
class ShellyRpcRepairsFlow(RepairsFlow):
"""Handler for an issue fixing flow."""
@@ -229,6 +269,49 @@ class DisableOutboundWebSocketFlow(ShellyRpcRepairsFlow):
return self.async_create_entry(title="", data={})
class DisableOpenWiFiApFlow(RepairsFlow):
"""Handler for Disable Open WiFi AP flow."""
def __init__(self, device: RpcDevice, issue_id: str) -> None:
"""Initialize."""
self._device = device
self.issue_id = issue_id
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
issue_registry = ir.async_get(self.hass)
description_placeholders = None
if issue := issue_registry.async_get_issue(DOMAIN, self.issue_id):
description_placeholders = issue.translation_placeholders
return self.async_show_menu(
menu_options=["confirm", "ignore"],
description_placeholders=description_placeholders,
)
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the confirm step of a fix flow."""
try:
result = await self._device.wifi_setconfig(ap_enable=False)
if result.get("restart_required"):
await self._device.trigger_reboot()
except (DeviceConnectionError, RpcCallError):
return self.async_abort(reason="cannot_connect")
return self.async_create_entry(title="", data={})
async def async_step_ignore(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the ignore step of a fix flow."""
ir.async_ignore_issue(self.hass, DOMAIN, self.issue_id, True)
return self.async_abort(reason="issue_ignored")
async def async_create_fix_flow(
hass: HomeAssistant, issue_id: str, data: dict[str, str] | None
) -> RepairsFlow:
@@ -253,4 +336,7 @@ async def async_create_fix_flow(
if "outbound_websocket_incorrectly_enabled" in issue_id:
return DisableOutboundWebSocketFlow(device)
if "open_wifi_ap" in issue_id:
return DisableOpenWiFiApFlow(device, issue_id)
return ConfirmRepairFlow()

View File

@@ -664,6 +664,25 @@
"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'.",
"title": "Shelly device {device_name} is not calibrated"
},
"open_wifi_ap": {
"fix_flow": {
"abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"issue_ignored": "Issue ignored"
},
"step": {
"init": {
"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.",
"menu_options": {
"confirm": "Disable WiFi access point",
"ignore": "Ignore"
},
"title": "[%key:component::shelly::issues::open_wifi_ap::title%]"
}
}
},
"title": "Open WiFi access point on {device_name}"
},
"outbound_websocket_incorrectly_enabled": {
"fix_flow": {
"abort": {

View File

@@ -576,7 +576,7 @@ def _mock_rpc_device(version: str | None = None):
zigbee_enabled=False,
zigbee_firmware=False,
ip_address="10.10.10.10",
wifi_setconfig=AsyncMock(return_value={}),
wifi_setconfig=AsyncMock(return_value={"restart_required": True}),
ble_setconfig=AsyncMock(return_value={"restart_required": False}),
shutdown=AsyncMock(),
)

View File

@@ -11,6 +11,7 @@ from homeassistant.components.shelly.const import (
CONF_BLE_SCANNER_MODE,
DEPRECATED_FIRMWARE_ISSUE_ID,
DOMAIN,
OPEN_WIFI_AP_ISSUE_ID,
OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID,
BLEScannerMode,
DeprecatedFirmwareInfo,
@@ -254,3 +255,207 @@ async def test_deprecated_firmware_issue(
# Assert the issue is no longer present
assert not issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 0
async def test_open_wifi_ap_issue(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_rpc_device: Mock,
issue_registry: ir.IssueRegistry,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test repair issues handling for open WiFi AP."""
monkeypatch.setitem(
mock_rpc_device.config,
"wifi",
{"ap": {"enable": True, "pass": ""}},
)
issue_id = OPEN_WIFI_AP_ISSUE_ID.format(unique=MOCK_MAC)
assert await async_setup_component(hass, "repairs", {})
await hass.async_block_till_done()
await init_integration(hass, 2)
assert issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 1
await async_process_repairs_platforms(hass)
client = await hass_client()
result = await start_repair_fix_flow(client, DOMAIN, issue_id)
flow_id = result["flow_id"]
assert result["step_id"] == "init"
assert result["type"] == "menu"
result = await process_repair_fix_flow(client, flow_id, {"next_step_id": "confirm"})
assert result["type"] == "create_entry"
assert mock_rpc_device.wifi_setconfig.call_count == 1
assert mock_rpc_device.wifi_setconfig.call_args[1] == {"ap_enable": False}
assert mock_rpc_device.trigger_reboot.call_count == 1
assert not issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 0
async def test_open_wifi_ap_issue_no_restart(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_rpc_device: Mock,
issue_registry: ir.IssueRegistry,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test repair issues handling for open WiFi AP when restart not required."""
monkeypatch.setitem(
mock_rpc_device.config,
"wifi",
{"ap": {"enable": True, "pass": ""}},
)
issue_id = OPEN_WIFI_AP_ISSUE_ID.format(unique=MOCK_MAC)
assert await async_setup_component(hass, "repairs", {})
await hass.async_block_till_done()
await init_integration(hass, 2)
assert issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 1
await async_process_repairs_platforms(hass)
client = await hass_client()
result = await start_repair_fix_flow(client, DOMAIN, issue_id)
flow_id = result["flow_id"]
assert result["step_id"] == "init"
assert result["type"] == "menu"
mock_rpc_device.wifi_setconfig.return_value = {"restart_required": False}
result = await process_repair_fix_flow(client, flow_id, {"next_step_id": "confirm"})
assert result["type"] == "create_entry"
assert mock_rpc_device.wifi_setconfig.call_count == 1
assert mock_rpc_device.wifi_setconfig.call_args[1] == {"ap_enable": False}
assert mock_rpc_device.trigger_reboot.call_count == 0
assert not issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 0
@pytest.mark.parametrize(
"exception", [DeviceConnectionError, RpcCallError(999, "Unknown error")]
)
async def test_open_wifi_ap_issue_exc(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_rpc_device: Mock,
issue_registry: ir.IssueRegistry,
monkeypatch: pytest.MonkeyPatch,
exception: Exception,
) -> None:
"""Test repair issues handling when wifi_setconfig ends with an exception."""
monkeypatch.setitem(
mock_rpc_device.config,
"wifi",
{"ap": {"enable": True, "pass": ""}},
)
issue_id = OPEN_WIFI_AP_ISSUE_ID.format(unique=MOCK_MAC)
assert await async_setup_component(hass, "repairs", {})
await hass.async_block_till_done()
await init_integration(hass, 2)
assert issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 1
await async_process_repairs_platforms(hass)
client = await hass_client()
result = await start_repair_fix_flow(client, DOMAIN, issue_id)
flow_id = result["flow_id"]
assert result["step_id"] == "init"
assert result["type"] == "menu"
mock_rpc_device.wifi_setconfig.side_effect = exception
result = await process_repair_fix_flow(client, flow_id, {"next_step_id": "confirm"})
assert result["type"] == "abort"
assert result["reason"] == "cannot_connect"
assert mock_rpc_device.wifi_setconfig.call_count == 1
assert issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 1
async def test_no_open_wifi_ap_issue_with_password(
hass: HomeAssistant,
mock_rpc_device: Mock,
issue_registry: ir.IssueRegistry,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test no repair issue is created when WiFi AP has a password."""
monkeypatch.setitem(
mock_rpc_device.config,
"wifi",
{"ap": {"enable": True, "pass": "secure_password"}},
)
issue_id = OPEN_WIFI_AP_ISSUE_ID.format(unique=MOCK_MAC)
await init_integration(hass, 2)
assert not issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 0
async def test_no_open_wifi_ap_issue_when_disabled(
hass: HomeAssistant,
mock_rpc_device: Mock,
issue_registry: ir.IssueRegistry,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test no repair issue is created when WiFi AP is disabled."""
monkeypatch.setitem(
mock_rpc_device.config,
"wifi",
{"ap": {"enable": False, "pass": ""}},
)
issue_id = OPEN_WIFI_AP_ISSUE_ID.format(unique=MOCK_MAC)
await init_integration(hass, 2)
assert not issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 0
async def test_open_wifi_ap_issue_ignore(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_rpc_device: Mock,
issue_registry: ir.IssueRegistry,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test ignoring the open WiFi AP issue."""
monkeypatch.setitem(
mock_rpc_device.config,
"wifi",
{"ap": {"enable": True, "pass": ""}},
)
issue_id = OPEN_WIFI_AP_ISSUE_ID.format(unique=MOCK_MAC)
assert await async_setup_component(hass, "repairs", {})
await hass.async_block_till_done()
await init_integration(hass, 2)
assert issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 1
await async_process_repairs_platforms(hass)
client = await hass_client()
result = await start_repair_fix_flow(client, DOMAIN, issue_id)
flow_id = result["flow_id"]
assert result["step_id"] == "init"
assert result["type"] == "menu"
result = await process_repair_fix_flow(client, flow_id, {"next_step_id": "ignore"})
assert result["type"] == "abort"
assert result["reason"] == "issue_ignored"
assert mock_rpc_device.wifi_setconfig.call_count == 0
assert issue_registry.async_get_issue(DOMAIN, issue_id).dismissed_version