mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 02:24:51 -05:00
Create repair issue if RTSP is disabled for Shelly Camera (#179733)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user