Create repair issue if RTSP is disabled for Shelly Camera (#179733)

This commit is contained in:
Maciej Bieniek
2026-08-22 12:27:36 +02:00
committed by GitHub
parent 22de432d88
commit 41d1fe77f3
7 changed files with 299 additions and 6 deletions
@@ -64,6 +64,7 @@ from .repairs import (
async_manage_deprecated_firmware_issue,
async_manage_open_wifi_ap_issue,
async_manage_outbound_websocket_incorrectly_enabled_issue,
async_manage_rtsp_disabled_issue,
)
from .services import async_setup_services
from .utils import (
@@ -393,6 +394,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry)
entry,
)
async_manage_open_wifi_ap_issue(hass, entry)
async_manage_rtsp_disabled_issue(hass, entry)
remove_empty_sub_devices(hass, entry)
elif (
sleep_period is None
+3 -2
View File
@@ -37,6 +37,7 @@ RPC_CAMERA_ENTITIES: Final = {
stream=0,
translation_key="stream",
translation_placeholders={"stream_id": "0"},
removal_condition=lambda config, _, key: not config[key]["rtsp"]["enable"],
),
"stream_1": RpcCameraEntityDescription(
key="camera",
@@ -44,6 +45,7 @@ RPC_CAMERA_ENTITIES: Final = {
translation_key="stream",
translation_placeholders={"stream_id": "1"},
entity_registry_enabled_default=False,
removal_condition=lambda config, _, key: not config[key]["rtsp"]["enable"],
),
}
@@ -94,8 +96,7 @@ class ShellyCameraEntity(ShellyRpcAttributeEntity, Camera):
if not available:
return False
config = self.coordinator.device.config[self.key]
return not self.status["privacy"] and config["rtsp"]["enable"]
return not self.status["privacy"]
@override
@property
+1
View File
@@ -247,6 +247,7 @@ OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID = (
)
DEPRECATED_FIRMWARE_ISSUE_ID = "deprecated_firmware_{unique}"
OPEN_WIFI_AP_ISSUE_ID = "open_wifi_ap_{unique}"
RTSP_DISABLED_ISSUE_ID = "rtsp_disabled_{unique}"
COIOT_UNCONFIGURED_ISSUE_ID = "coiot_unconfigured_{unique}"
@@ -26,6 +26,7 @@ from .const import (
DOMAIN,
OPEN_WIFI_AP_ISSUE_ID,
OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID,
RTSP_DISABLED_ISSUE_ID,
BLEScannerMode,
)
from .coordinator import ShellyConfigEntry
@@ -33,6 +34,8 @@ from .utils import (
get_coiot_address,
get_coiot_port,
get_device_entry_gen,
get_rpc_key_id,
get_rpc_key_instances,
get_rpc_ws_url,
)
@@ -201,6 +204,53 @@ def async_manage_open_wifi_ap_issue(
ir.async_delete_issue(hass, DOMAIN, issue_id)
@callback
def async_manage_rtsp_disabled_issue(
hass: HomeAssistant,
entry: ShellyConfigEntry,
) -> None:
"""Manage the RTSP disabled issue."""
issue_id = RTSP_DISABLED_ISSUE_ID.format(unique=entry.unique_id)
if TYPE_CHECKING:
assert entry.runtime_data.rpc is not None
device = entry.runtime_data.rpc.device
if not device.initialized:
return
camera_keys = get_rpc_key_instances(device.status, "camera")
if not camera_keys:
ir.async_delete_issue(hass, DOMAIN, issue_id)
return
disabled = [
key
for key in camera_keys
if key in device.config and not device.config[key]["rtsp"]["enable"]
]
if disabled:
ir.async_create_issue(
hass,
DOMAIN,
issue_id,
is_fixable=True,
is_persistent=False,
severity=ir.IssueSeverity.WARNING,
translation_key="rtsp_disabled",
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 ShellyBlockRepairsFlow(RepairsFlow):
"""Handler for an issue fixing flow."""
@@ -375,6 +425,52 @@ class DisableOpenWiFiApFlow(RepairsFlow):
return self.async_abort(reason="issue_ignored")
class EnableRtspFlow(RepairsFlow):
"""Handler for Enable RTSP 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
) -> RepairsFlowResult:
"""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
) -> RepairsFlowResult:
"""Handle the confirm step of a fix flow."""
try:
for key in get_rpc_key_instances(self._device.status, "camera"):
if (
key in self._device.config
and not self._device.config[key]["rtsp"]["enable"]
):
await self._device.set_camera_rtsp(get_rpc_key_id(key), True)
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
) -> RepairsFlowResult:
"""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:
@@ -408,4 +504,7 @@ async def async_create_fix_flow(
if "open_wifi_ap" in issue_id:
return DisableOpenWiFiApFlow(device, issue_id)
if "rtsp_disabled" in issue_id:
return EnableRtspFlow(device, issue_id)
return ConfirmRepairFlow()
+21 -2
View File
@@ -765,14 +765,14 @@
"fix_flow": {
"abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"issue_ignored": "Issue ignored"
"issue_ignored": "[%key:component::shelly::issues::coiot_unconfigured::fix_flow::abort::issue_ignored%]"
},
"step": {
"init": {
"description": "Your Shelly device {device_name} with IP address {ip_address} has an open Wi-Fi 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 Wi-Fi access point",
"ignore": "Ignore"
"ignore": "[%key:component::shelly::issues::coiot_unconfigured::fix_flow::step::init::menu_options::ignore%]"
},
"title": "[%key:component::shelly::issues::open_wifi_ap::title%]"
}
@@ -798,6 +798,25 @@
"description": "Home Assistant is not receiving push updates from the Shelly device {device_name} with IP address {ip_address}. Check the CoIoT configuration in the web panel of the device and your network configuration.",
"title": "Shelly device {device_name} push update failure"
},
"rtsp_disabled": {
"fix_flow": {
"abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"issue_ignored": "[%key:component::shelly::issues::coiot_unconfigured::fix_flow::abort::issue_ignored%]"
},
"step": {
"init": {
"description": "Your Shelly device {device_name} with IP address {ip_address} has camera RTSP streams disabled. RTSP must be enabled for camera entities to be created.\n\nSelect **Enable RTSP streams** to enable RTSP for all camera streams.",
"menu_options": {
"confirm": "Enable RTSP streams",
"ignore": "[%key:component::shelly::issues::coiot_unconfigured::fix_flow::step::init::menu_options::ignore%]"
},
"title": "[%key:component::shelly::issues::rtsp_disabled::title%]"
}
}
},
"title": "RTSP streams disabled on {device_name}"
},
"unsupported_firmware": {
"description": "Your Shelly device {device_name} with IP address {ip_address} is running an unsupported firmware. Please update the firmware.\n\nIf the device does not offer an update, check internet connectivity (gateway, DNS, time) and restart the device.",
"title": "Unsupported firmware for device {device_name}"
+42 -1
View File
@@ -10,6 +10,7 @@ from syrupy.assertion import SnapshotAssertion
from homeassistant.components.camera import (
DATA_COMPONENT,
DOMAIN as CAMERA_DOMAIN,
CameraState,
get_camera_from_entity_id,
)
@@ -24,7 +25,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_registry import EntityRegistry
from . import MOCK_MAC, init_integration, patch_platforms
from . import MOCK_MAC, init_integration, patch_platforms, register_entity
from tests.common import snapshot_platform
@@ -191,3 +192,43 @@ async def test_camera_properties_when_device_not_initialized(
assert camera.is_on is False
assert camera.available is False
async def test_camera_not_created_when_rtsp_disabled(
hass: HomeAssistant,
mock_camera_rpc_device: Mock,
monkeypatch: pytest.MonkeyPatch,
entity_registry: EntityRegistry,
) -> None:
"""Test camera entities are not created when RTSP is disabled."""
new_config = deepcopy(mock_camera_rpc_device.config)
new_config["camera:0"]["rtsp"]["enable"] = False
monkeypatch.setattr(mock_camera_rpc_device, "config", new_config)
await init_integration(hass, 3, model=MODEL_CAMERA)
assert hass.states.get(CAMERA_ENTITY_ID) is None
assert entity_registry.async_get(CAMERA_ENTITY_ID) is None
async def test_rpc_camera_removal_when_rtsp_disabled(
hass: HomeAssistant,
mock_camera_rpc_device: Mock,
monkeypatch: pytest.MonkeyPatch,
entity_registry: EntityRegistry,
) -> None:
"""Test RPC camera is removed due to removal_condition when RTSP disabled."""
entity_id = register_entity(
hass, CAMERA_DOMAIN, "test_name_stream_0", "camera:0-stream_0"
)
assert entity_registry.async_get(entity_id) is not None
new_config = deepcopy(mock_camera_rpc_device.config)
new_config["camera:0"]["rtsp"]["enable"] = False
monkeypatch.setattr(mock_camera_rpc_device, "config", new_config)
await init_integration(hass, 3, model=MODEL_CAMERA)
assert entity_registry.async_get(entity_id) is None
assert hass.states.get(entity_id) is None
+131 -1
View File
@@ -3,7 +3,7 @@
from typing import Any
from unittest.mock import Mock, patch
from aioshelly.const import MODEL_PLUG, MODEL_WALL_DISPLAY
from aioshelly.const import MODEL_CAMERA, MODEL_PLUG, MODEL_WALL_DISPLAY
from aioshelly.exceptions import DeviceConnectionError, NotInitialized, RpcCallError
import pytest
@@ -16,6 +16,7 @@ from homeassistant.components.shelly.const import (
OPEN_WIFI_AP_ISSUE_ID,
OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID,
PUSH_UPDATE_ISSUE_ID,
RTSP_DISABLED_ISSUE_ID,
BLEScannerMode,
DeprecatedFirmwareInfo,
)
@@ -761,3 +762,132 @@ async def test_plug_1_push_update_issue_created(
assert issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 1
async def test_rtsp_disabled_issue(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_camera_rpc_device: Mock,
issue_registry: ir.IssueRegistry,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test repair issue when camera RTSP is disabled."""
monkeypatch.setitem(
mock_camera_rpc_device.config["camera:0"]["rtsp"], "enable", False
)
issue_id = RTSP_DISABLED_ISSUE_ID.format(unique=MOCK_MAC)
assert await async_setup_component(hass, "repairs", {})
await hass.async_block_till_done()
await init_integration(hass, 3, MODEL_CAMERA)
assert issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 1
client = await hass_client()
result = await start_repair_fix_flow(client, DOMAIN, issue_id)
assert result["step_id"] == "init"
assert result["type"] == "menu"
result = await process_repair_fix_flow(
client, result["flow_id"], {"next_step_id": "confirm"}
)
assert result["type"] == "create_entry"
assert mock_camera_rpc_device.set_camera_rtsp.call_count == 1
assert mock_camera_rpc_device.set_camera_rtsp.call_args[0] == (0, True)
assert not issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 0
async def test_no_rtsp_disabled_issue_when_enabled(
hass: HomeAssistant,
mock_camera_rpc_device: Mock,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test no repair issue when camera RTSP is enabled."""
issue_id = RTSP_DISABLED_ISSUE_ID.format(unique=MOCK_MAC)
await init_integration(hass, 3, MODEL_CAMERA)
assert not issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 0
async def test_rtsp_disabled_issue_ignore(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_camera_rpc_device: Mock,
issue_registry: ir.IssueRegistry,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test ignoring the RTSP disabled issue."""
monkeypatch.setitem(
mock_camera_rpc_device.config["camera:0"]["rtsp"], "enable", False
)
issue_id = RTSP_DISABLED_ISSUE_ID.format(unique=MOCK_MAC)
assert await async_setup_component(hass, "repairs", {})
await hass.async_block_till_done()
await init_integration(hass, 3, MODEL_CAMERA)
assert issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 1
client = await hass_client()
result = await start_repair_fix_flow(client, DOMAIN, issue_id)
assert result["step_id"] == "init"
assert result["type"] == "menu"
result = await process_repair_fix_flow(
client, result["flow_id"], {"next_step_id": "ignore"}
)
assert result["type"] == "abort"
assert result["reason"] == "issue_ignored"
assert mock_camera_rpc_device.set_camera_rtsp.call_count == 0
assert (issue := issue_registry.async_get_issue(DOMAIN, issue_id))
assert issue.dismissed_version
@pytest.mark.parametrize(
"exception", [DeviceConnectionError, RpcCallError(999, "Unknown error")]
)
async def test_rtsp_disabled_issue_exc(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_camera_rpc_device: Mock,
issue_registry: ir.IssueRegistry,
monkeypatch: pytest.MonkeyPatch,
exception: Exception,
) -> None:
"""Test repair issue handling when set_camera_rtsp ends with an exception."""
mock_camera_rpc_device.set_camera_rtsp.side_effect = exception
monkeypatch.setitem(
mock_camera_rpc_device.config["camera:0"]["rtsp"], "enable", False
)
issue_id = RTSP_DISABLED_ISSUE_ID.format(unique=MOCK_MAC)
assert await async_setup_component(hass, "repairs", {})
await hass.async_block_till_done()
await init_integration(hass, 3, MODEL_CAMERA)
assert issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 1
client = await hass_client()
result = await start_repair_fix_flow(client, DOMAIN, issue_id)
assert result["step_id"] == "init"
assert result["type"] == "menu"
result = await process_repair_fix_flow(
client, result["flow_id"], {"next_step_id": "confirm"}
)
assert result["type"] == "abort"
assert result["reason"] == "cannot_connect"
assert mock_camera_rpc_device.set_camera_rtsp.call_count == 1
assert issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 1