From 660167cb1b3333f857a6ae462ffe3adacbc72772 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Mon, 28 Aug 2023 14:55:49 +0200 Subject: [PATCH] Add image platform to devolo_home_network (#98036) --- .../devolo_home_network/__init__.py | 1 + .../components/devolo_home_network/const.py | 1 + .../components/devolo_home_network/image.py | 100 ++++++++++++++++++ .../devolo_home_network/strings.json | 5 + tests/components/devolo_home_network/const.py | 7 ++ .../snapshots/test_image.ambr | 34 ++++++ .../devolo_home_network/test_image.py | 96 +++++++++++++++++ .../devolo_home_network/test_init.py | 8 +- 8 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/devolo_home_network/image.py create mode 100644 tests/components/devolo_home_network/snapshots/test_image.ambr create mode 100644 tests/components/devolo_home_network/test_image.py diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index 181c47aac61..f54fddc9a86 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -213,6 +213,7 @@ def platforms(device: Device) -> set[Platform]: supported_platforms.add(Platform.BINARY_SENSOR) if device.device and "wifi1" in device.device.features: supported_platforms.add(Platform.DEVICE_TRACKER) + supported_platforms.add(Platform.IMAGE) if device.device and "update" in device.device.features: supported_platforms.add(Platform.UPDATE) return supported_platforms diff --git a/homeassistant/components/devolo_home_network/const.py b/homeassistant/components/devolo_home_network/const.py index 53019e28a23..ba3f5e5b815 100644 --- a/homeassistant/components/devolo_home_network/const.py +++ b/homeassistant/components/devolo_home_network/const.py @@ -21,6 +21,7 @@ CONNECTED_PLC_DEVICES = "connected_plc_devices" CONNECTED_TO_ROUTER = "connected_to_router" CONNECTED_WIFI_CLIENTS = "connected_wifi_clients" IDENTIFY = "identify" +IMAGE_GUEST_WIFI = "image_guest_wifi" NEIGHBORING_WIFI_NETWORKS = "neighboring_wifi_networks" PAIRING = "pairing" REGULAR_FIRMWARE = "regular_firmware" diff --git a/homeassistant/components/devolo_home_network/image.py b/homeassistant/components/devolo_home_network/image.py new file mode 100644 index 00000000000..3670c42bc6b --- /dev/null +++ b/homeassistant/components/devolo_home_network/image.py @@ -0,0 +1,100 @@ +"""Platform for image integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from functools import partial +from typing import Any + +from devolo_plc_api import Device, wifi_qr_code +from devolo_plc_api.device_api import WifiGuestAccessGet + +from homeassistant.components.image import ImageEntity, ImageEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +import homeassistant.util.dt as dt_util + +from .const import DOMAIN, IMAGE_GUEST_WIFI, SWITCH_GUEST_WIFI +from .entity import DevoloCoordinatorEntity + + +@dataclass +class DevoloImageRequiredKeysMixin: + """Mixin for required keys.""" + + image_func: Callable[[WifiGuestAccessGet], bytes] + + +@dataclass +class DevoloImageEntityDescription( + ImageEntityDescription, DevoloImageRequiredKeysMixin +): + """Describes devolo image entity.""" + + +IMAGE_TYPES: dict[str, DevoloImageEntityDescription] = { + IMAGE_GUEST_WIFI: DevoloImageEntityDescription( + key=IMAGE_GUEST_WIFI, + entity_category=EntityCategory.DIAGNOSTIC, + image_func=partial(wifi_qr_code, omitsize=True), + ) +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Get all devices and sensors and setup them via config entry.""" + device: Device = hass.data[DOMAIN][entry.entry_id]["device"] + coordinators: dict[str, DataUpdateCoordinator[Any]] = hass.data[DOMAIN][ + entry.entry_id + ]["coordinators"] + + entities: list[ImageEntity] = [] + entities.append( + DevoloImageEntity( + entry, + coordinators[SWITCH_GUEST_WIFI], + IMAGE_TYPES[IMAGE_GUEST_WIFI], + device, + ) + ) + async_add_entities(entities) + + +class DevoloImageEntity(DevoloCoordinatorEntity[WifiGuestAccessGet], ImageEntity): + """Representation of a devolo image.""" + + _attr_content_type = "image/svg+xml" + + def __init__( + self, + entry: ConfigEntry, + coordinator: DataUpdateCoordinator[WifiGuestAccessGet], + description: DevoloImageEntityDescription, + device: Device, + ) -> None: + """Initialize entity.""" + self.entity_description: DevoloImageEntityDescription = description + super().__init__(entry, coordinator, device) + ImageEntity.__init__(self, coordinator.hass) + self._attr_image_last_updated = dt_util.utcnow() + self._data = self.coordinator.data + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if ( + self._data.ssid != self.coordinator.data.ssid + or self._data.key != self.coordinator.data.key + ): + self._data = self.coordinator.data + self._attr_image_last_updated = dt_util.utcnow() + super()._handle_coordinator_update() + + async def async_image(self) -> bytes | None: + """Return bytes of image.""" + return self.entity_description.image_func(self.coordinator.data) diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index e2954c1c7ec..55a7920ab3e 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -48,6 +48,11 @@ "name": "Start WPS" } }, + "image": { + "image_guest_wifi": { + "name": "Guest Wifi credentials as QR code" + } + }, "sensor": { "connected_plc_devices": { "name": "Connected PLC devices" diff --git a/tests/components/devolo_home_network/const.py b/tests/components/devolo_home_network/const.py index f4cc372660c..bc2ef2d87b2 100644 --- a/tests/components/devolo_home_network/const.py +++ b/tests/components/devolo_home_network/const.py @@ -92,6 +92,13 @@ GUEST_WIFI = WifiGuestAccessGet( remaining_duration=0, ) +GUEST_WIFI_CHANGED = WifiGuestAccessGet( + ssid="devolo-guest-930", + key="HMANPGAS", + enabled=False, + remaining_duration=0, +) + NEIGHBOR_ACCESS_POINTS = [ NeighborAPInfo( mac_address="AA:BB:CC:DD:EE:FF", diff --git a/tests/components/devolo_home_network/snapshots/test_image.ambr b/tests/components/devolo_home_network/snapshots/test_image.ambr new file mode 100644 index 00000000000..b00f73ca116 --- /dev/null +++ b/tests/components/devolo_home_network/snapshots/test_image.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_guest_wifi_qr + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': , + 'entity_id': 'image.mock_title_guest_wifi_credentials_as_qr_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Guest Wifi credentials as QR code', + 'platform': 'devolo_home_network', + 'supported_features': 0, + 'translation_key': 'image_guest_wifi', + 'unique_id': '1234567890_image_guest_wifi', + 'unit_of_measurement': None, + }) +# --- +# name: test_guest_wifi_qr.1 + b'\n\n' +# --- diff --git a/tests/components/devolo_home_network/test_image.py b/tests/components/devolo_home_network/test_image.py new file mode 100644 index 00000000000..b8fb491e1ec --- /dev/null +++ b/tests/components/devolo_home_network/test_image.py @@ -0,0 +1,96 @@ +"""Tests for the devolo Home Network images.""" +from http import HTTPStatus +from unittest.mock import AsyncMock + +from devolo_plc_api.exceptions.device import DeviceUnavailable +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.devolo_home_network.const import SHORT_UPDATE_INTERVAL +from homeassistant.components.image import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from . import configure_integration +from .const import GUEST_WIFI_CHANGED +from .mock import MockDevice + +from tests.common import async_fire_time_changed +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("mock_device") +async def test_image_setup(hass: HomeAssistant) -> None: + """Test default setup of the image component.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + hass.states.get(f"{DOMAIN}.{device_name}_guest_wifi_credentials_as_qr_code") + is not None + ) + + await hass.config_entries.async_unload(entry.entry_id) + + +@pytest.mark.freeze_time("2023-01-13 12:00:00+00:00") +async def test_guest_wifi_qr( + hass: HomeAssistant, + mock_device: MockDevice, + entity_registry: er.EntityRegistry, + hass_client: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Test showing a QR code of the guest wifi credentials.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key = f"{DOMAIN}.{device_name}_guest_wifi_credentials_as_qr_code" + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state.name == "Mock Title Guest Wifi credentials as QR code" + assert state.state == dt_util.utcnow().isoformat() + assert entity_registry.async_get(state_key) == snapshot + + client = await hass_client() + resp = await client.get(f"/api/image_proxy/{state_key}") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == snapshot + + # Emulate device failure + mock_device.device.async_get_wifi_guest_access.side_effect = DeviceUnavailable() + freezer.move_to(dt_util.utcnow() + SHORT_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + SHORT_UPDATE_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + # Emulate state change + mock_device.device.async_get_wifi_guest_access = AsyncMock( + return_value=GUEST_WIFI_CHANGED + ) + freezer.move_to(dt_util.utcnow() + SHORT_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + SHORT_UPDATE_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == dt_util.utcnow().isoformat() + + client = await hass_client() + resp = await client.get(f"/api/image_proxy/{state_key}") + assert resp.status == HTTPStatus.OK + assert await resp.read() != body + + await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index ba34eb18490..3c207a1aaef 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -8,6 +8,7 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR from homeassistant.components.button import DOMAIN as BUTTON from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER from homeassistant.components.devolo_home_network.const import DOMAIN +from homeassistant.components.image import DOMAIN as IMAGE from homeassistant.components.sensor import DOMAIN as SENSOR from homeassistant.components.switch import DOMAIN as SWITCH from homeassistant.components.update import DOMAIN as UPDATE @@ -87,9 +88,12 @@ async def test_hass_stop(hass: HomeAssistant, mock_device: MockDevice) -> None: [ [ "mock_device", - (BINARY_SENSOR, BUTTON, DEVICE_TRACKER, SENSOR, SWITCH, UPDATE), + (BINARY_SENSOR, BUTTON, DEVICE_TRACKER, IMAGE, SENSOR, SWITCH, UPDATE), + ], + [ + "mock_repeater_device", + (BUTTON, DEVICE_TRACKER, IMAGE, SENSOR, SWITCH, UPDATE), ], - ["mock_repeater_device", (BUTTON, DEVICE_TRACKER, SENSOR, SWITCH, UPDATE)], ["mock_nonwifi_device", (BINARY_SENSOR, BUTTON, SENSOR, SWITCH, UPDATE)], ], )