Skip to content

Commit

Permalink
Merge branch 'dev' into dev
Browse files Browse the repository at this point in the history
  • Loading branch information
azerty9971 authored Jun 13, 2024
2 parents 27c14f6 + 40b98b7 commit 89467e8
Show file tree
Hide file tree
Showing 13 changed files with 104 additions and 89 deletions.
2 changes: 1 addition & 1 deletion homeassistant/components/frontend/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20240610.0"]
"requirements": ["home-assistant-frontend==20240610.1"]
}
39 changes: 19 additions & 20 deletions homeassistant/components/reolink/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import Literal

from reolink_aio.api import RETRY_ATTEMPTS
from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError
from reolink_aio.software_version import NewSoftwareVersion

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
Expand Down Expand Up @@ -47,9 +45,7 @@ class ReolinkData:

host: ReolinkHost
device_coordinator: DataUpdateCoordinator[None]
firmware_coordinator: DataUpdateCoordinator[
str | Literal[False] | NewSoftwareVersion
]
firmware_coordinator: DataUpdateCoordinator[None]


async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
Expand Down Expand Up @@ -93,24 +89,19 @@ async def async_device_config_update() -> None:
async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)):
await host.renew()

async def async_check_firmware_update() -> (
str | Literal[False] | NewSoftwareVersion
):
async def async_check_firmware_update() -> None:
"""Check for firmware updates."""
if not host.api.supported(None, "update"):
return False

async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)):
try:
return await host.api.check_new_firmware()
await host.api.check_new_firmware()
except ReolinkError as err:
if starting:
_LOGGER.debug(
"Error checking Reolink firmware update at startup "
"from %s, possibly internet access is blocked",
host.api.nvr_name,
)
return False
return

raise UpdateFailed(
f"Error checking Reolink firmware update from {host.api.nvr_name}, "
Expand Down Expand Up @@ -151,13 +142,7 @@ async def async_check_firmware_update() -> (
)

cleanup_disconnected_cams(hass, config_entry.entry_id, host)

# Can be remove in HA 2024.6.0
entity_reg = er.async_get(hass)
entities = er.async_entries_for_config_entry(entity_reg, config_entry.entry_id)
for entity in entities:
if entity.domain == "light" and entity.unique_id.endswith("ir_lights"):
entity_reg.async_remove(entity.entity_id)
migrate_entity_ids(hass, config_entry.entry_id, host)

await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)

Expand Down Expand Up @@ -234,3 +219,17 @@ def cleanup_disconnected_cams(

# clean device registry and associated entities
device_reg.async_remove_device(device.id)


def migrate_entity_ids(
hass: HomeAssistant, config_entry_id: str, host: ReolinkHost
) -> None:
"""Migrate entity IDs if needed."""
entity_reg = er.async_get(hass)
entities = er.async_entries_for_config_entry(entity_reg, config_entry_id)
for entity in entities:
# Can be remove in HA 2025.1.0
if entity.domain == "update" and entity.unique_id == host.unique_id:
entity_reg.async_update_entity(
entity.entity_id, new_unique_id=f"{host.unique_id}_firmware"
)
37 changes: 14 additions & 23 deletions homeassistant/components/reolink/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,22 +34,28 @@ class ReolinkHostEntityDescription(EntityDescription):
supported: Callable[[Host], bool] = lambda api: True


class ReolinkBaseCoordinatorEntity[_DataT](
CoordinatorEntity[DataUpdateCoordinator[_DataT]]
):
"""Parent class for Reolink entities."""
class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]]):
"""Parent class for entities that control the Reolink NVR itself, without a channel.
A camera connected directly to HomeAssistant without using a NVR is in the reolink API
basically a NVR with a single channel that has the camera connected to that channel.
"""

_attr_has_entity_name = True
entity_description: ReolinkHostEntityDescription | ReolinkChannelEntityDescription

def __init__(
self,
reolink_data: ReolinkData,
coordinator: DataUpdateCoordinator[_DataT],
coordinator: DataUpdateCoordinator[None] | None = None,
) -> None:
"""Initialize ReolinkBaseCoordinatorEntity."""
"""Initialize ReolinkHostCoordinatorEntity."""
if coordinator is None:
coordinator = reolink_data.device_coordinator
super().__init__(coordinator)

self._host = reolink_data.host
self._attr_unique_id = f"{self._host.unique_id}_{self.entity_description.key}"

http_s = "https" if self._host.api.use_https else "http"
self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}"
Expand All @@ -70,22 +76,6 @@ def available(self) -> bool:
"""Return True if entity is available."""
return self._host.api.session_active and super().available


class ReolinkHostCoordinatorEntity(ReolinkBaseCoordinatorEntity[None]):
"""Parent class for entities that control the Reolink NVR itself, without a channel.
A camera connected directly to HomeAssistant without using a NVR is in the reolink API
basically a NVR with a single channel that has the camera connected to that channel.
"""

entity_description: ReolinkHostEntityDescription | ReolinkChannelEntityDescription

def __init__(self, reolink_data: ReolinkData) -> None:
"""Initialize ReolinkHostCoordinatorEntity."""
super().__init__(reolink_data, reolink_data.device_coordinator)

self._attr_unique_id = f"{self._host.unique_id}_{self.entity_description.key}"

async def async_added_to_hass(self) -> None:
"""Entity created."""
await super().async_added_to_hass()
Expand Down Expand Up @@ -116,9 +106,10 @@ def __init__(
self,
reolink_data: ReolinkData,
channel: int,
coordinator: DataUpdateCoordinator[None] | None = None,
) -> None:
"""Initialize ReolinkChannelCoordinatorEntity for a hardware camera connected to a channel of the NVR."""
super().__init__(reolink_data)
super().__init__(reolink_data, coordinator)

self._channel = channel
self._attr_unique_id = (
Expand Down
67 changes: 46 additions & 21 deletions homeassistant/components/reolink/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime
import logging
from typing import Any, Literal
from typing import Any

from reolink_aio.exceptions import ReolinkError
from reolink_aio.software_version import NewSoftwareVersion

from homeassistant.components.update import (
UpdateDeviceClass,
UpdateEntity,
UpdateEntityDescription,
UpdateEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
Expand All @@ -22,40 +23,61 @@

from . import ReolinkData
from .const import DOMAIN
from .entity import ReolinkBaseCoordinatorEntity

LOGGER = logging.getLogger(__name__)
from .entity import ReolinkHostCoordinatorEntity, ReolinkHostEntityDescription

POLL_AFTER_INSTALL = 120


@dataclass(frozen=True, kw_only=True)
class ReolinkHostUpdateEntityDescription(
UpdateEntityDescription,
ReolinkHostEntityDescription,
):
"""A class that describes host update entities."""


HOST_UPDATE_ENTITIES = (
ReolinkHostUpdateEntityDescription(
key="firmware",
supported=lambda api: api.supported(None, "firmware"),
device_class=UpdateDeviceClass.FIRMWARE,
),
)


async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up update entities for Reolink component."""
reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities([ReolinkUpdateEntity(reolink_data)])

entities: list[ReolinkHostUpdateEntity] = [
ReolinkHostUpdateEntity(reolink_data, entity_description)
for entity_description in HOST_UPDATE_ENTITIES
if entity_description.supported(reolink_data.host.api)
]
async_add_entities(entities)

class ReolinkUpdateEntity(
ReolinkBaseCoordinatorEntity[str | Literal[False] | NewSoftwareVersion],

class ReolinkHostUpdateEntity(
ReolinkHostCoordinatorEntity,
UpdateEntity,
):
"""Update entity for a Netgear device."""
"""Update entity class for Reolink Host."""

_attr_device_class = UpdateDeviceClass.FIRMWARE
entity_description: ReolinkHostUpdateEntityDescription
_attr_release_url = "https://reolink.com/download-center/"

def __init__(
self,
reolink_data: ReolinkData,
entity_description: ReolinkHostUpdateEntityDescription,
) -> None:
"""Initialize a Netgear device."""
"""Initialize Reolink update entity."""
self.entity_description = entity_description
super().__init__(reolink_data, reolink_data.firmware_coordinator)

self._attr_unique_id = f"{self._host.unique_id}"
self._cancel_update: CALLBACK_TYPE | None = None

@property
Expand All @@ -66,32 +88,35 @@ def installed_version(self) -> str | None:
@property
def latest_version(self) -> str | None:
"""Latest version available for install."""
if not self.coordinator.data:
new_firmware = self._host.api.firmware_update_available()
if not new_firmware:
return self.installed_version

if isinstance(self.coordinator.data, str):
return self.coordinator.data
if isinstance(new_firmware, str):
return new_firmware

return self.coordinator.data.version_string
return new_firmware.version_string

@property
def supported_features(self) -> UpdateEntityFeature:
"""Flag supported features."""
supported_features = UpdateEntityFeature.INSTALL
if isinstance(self.coordinator.data, NewSoftwareVersion):
new_firmware = self._host.api.firmware_update_available()
if isinstance(new_firmware, NewSoftwareVersion):
supported_features |= UpdateEntityFeature.RELEASE_NOTES
return supported_features

async def async_release_notes(self) -> str | None:
"""Return the release notes."""
if not isinstance(self.coordinator.data, NewSoftwareVersion):
new_firmware = self._host.api.firmware_update_available()
if not isinstance(new_firmware, NewSoftwareVersion):
return None

return (
"If the install button fails, download this"
f" [firmware zip file]({self.coordinator.data.download_url})."
f" [firmware zip file]({new_firmware.download_url})."
" Then, follow the installation guide (PDF in the zip file).\n\n"
f"## Release notes\n\n{self.coordinator.data.release_notes}"
f"## Release notes\n\n{new_firmware.release_notes}"
)

async def async_install(
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/package_constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ habluetooth==3.1.1
hass-nabucasa==0.81.1
hassil==1.7.1
home-assistant-bluetooth==1.12.1
home-assistant-frontend==20240610.0
home-assistant-frontend==20240610.1
home-assistant-intents==2024.6.5
httpx==0.27.0
ifaddr==0.2.0
Expand Down
2 changes: 1 addition & 1 deletion requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1090,7 +1090,7 @@ hole==0.8.0
holidays==0.50

# homeassistant.components.frontend
home-assistant-frontend==20240610.0
home-assistant-frontend==20240610.1

# homeassistant.components.conversation
home-assistant-intents==2024.6.5
Expand Down
2 changes: 1 addition & 1 deletion requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -892,7 +892,7 @@ hole==0.8.0
holidays==0.50

# homeassistant.components.frontend
home-assistant-frontend==20240610.0
home-assistant-frontend==20240610.1

# homeassistant.components.conversation
home-assistant-intents==2024.6.5
Expand Down
1 change: 1 addition & 0 deletions tests/components/reolink/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ def reolink_connect_class() -> Generator[MagicMock]:
host_mock.camera_name.return_value = TEST_NVR_NAME
host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000"
host_mock.camera_uid.return_value = TEST_UID
host_mock.firmware_update_available.return_value = False
host_mock.session_active = True
host_mock.timeout = 60
host_mock.renewtimer.return_value = 600
Expand Down
29 changes: 14 additions & 15 deletions tests/components/reolink/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,40 +178,39 @@ async def test_cleanup_disconnected_cams(
assert sorted(device_models) == sorted(expected_models)


async def test_cleanup_deprecated_entities(
async def test_migrate_entity_ids(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test deprecated ir_lights light entity is cleaned."""
"""Test entity ids that need to be migrated."""
reolink_connect.channels = [0]
ir_id = f"{TEST_MAC}_0_ir_lights"
original_id = f"{TEST_MAC}"
new_id = f"{TEST_MAC}_firmware"
domain = Platform.UPDATE

entity_registry.async_get_or_create(
domain=Platform.LIGHT,
domain=domain,
platform=const.DOMAIN,
unique_id=ir_id,
unique_id=original_id,
config_entry=config_entry,
suggested_object_id=ir_id,
suggested_object_id=original_id,
disabled_by=None,
)

assert entity_registry.async_get_entity_id(Platform.LIGHT, const.DOMAIN, ir_id)
assert (
entity_registry.async_get_entity_id(Platform.SWITCH, const.DOMAIN, ir_id)
is None
)
assert entity_registry.async_get_entity_id(domain, const.DOMAIN, original_id)
assert entity_registry.async_get_entity_id(domain, const.DOMAIN, new_id) is None

# setup CH 0 and NVR switch entities/device
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]):
# setup CH 0 and host entities/device
with patch("homeassistant.components.reolink.PLATFORMS", [domain]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()

assert (
entity_registry.async_get_entity_id(Platform.LIGHT, const.DOMAIN, ir_id) is None
entity_registry.async_get_entity_id(domain, const.DOMAIN, original_id) is None
)
assert entity_registry.async_get_entity_id(Platform.SWITCH, const.DOMAIN, ir_id)
assert entity_registry.async_get_entity_id(domain, const.DOMAIN, new_id)


async def test_no_repair_issue(
Expand Down
2 changes: 1 addition & 1 deletion tests/components/shelly/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -1125,7 +1125,7 @@ async def test_zeroconf_sleeping_device_not_triggers_refresh(
await hass.async_block_till_done()

mock_rpc_device.mock_online()
await hass.async_block_till_done()
await hass.async_block_till_done(wait_background_tasks=True)

assert "online, resuming setup" in caplog.text
assert len(mock_rpc_device.initialize.mock_calls) == 1
Expand Down
Loading

0 comments on commit 89467e8

Please sign in to comment.