Add image platform to devolo_home_network (#98036)

This commit is contained in:
Guido Schmitz 2023-08-28 14:55:49 +02:00 committed by GitHub
parent 3f0a8b7a56
commit 660167cb1b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 250 additions and 2 deletions

View file

@ -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

View file

@ -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"

View file

@ -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)

View file

@ -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"

View file

@ -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",

View file

@ -0,0 +1,34 @@
# serializer version: 1
# name: test_guest_wifi_qr
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'image',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'image.mock_title_guest_wifi_credentials_as_qr_code',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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'<?xml version="1.0" encoding="utf-8"?>\n<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 37 37" class="segno"><path class="qrline" stroke="#000" d="M4 4.5h7m2 0h1m4 0h3m1 0h3m1 0h7m-29 1h1m5 0h1m2 0h2m1 0h1m2 0h1m4 0h1m1 0h1m5 0h1m-29 1h1m1 0h3m1 0h1m1 0h2m1 0h2m3 0h1m3 0h1m1 0h1m1 0h3m1 0h1m-29 1h1m1 0h3m1 0h1m1 0h1m1 0h2m2 0h1m2 0h1m2 0h1m1 0h1m1 0h3m1 0h1m-29 1h1m1 0h3m1 0h1m1 0h3m1 0h1m2 0h2m2 0h2m1 0h1m1 0h3m1 0h1m-29 1h1m5 0h1m1 0h2m3 0h6m1 0h1m1 0h1m5 0h1m-29 1h7m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h7m-21 1h1m1 0h3m1 0h2m3 0h2m-21 1h1m1 0h5m2 0h2m1 0h1m1 0h2m1 0h1m4 0h5m-26 1h1m1 0h1m3 0h3m1 0h1m3 0h2m2 0h2m1 0h1m1 0h1m1 0h2m-27 1h4m1 0h1m2 0h2m1 0h2m1 0h2m3 0h4m1 0h1m-26 1h3m1 0h2m2 0h1m2 0h1m3 0h2m1 0h1m1 0h3m1 0h1m2 0h2m-28 1h1m2 0h1m1 0h2m2 0h1m1 0h1m1 0h1m2 0h3m3 0h4m-27 1h1m1 0h2m3 0h1m2 0h1m4 0h5m1 0h4m1 0h2m-28 1h3m3 0h7m2 0h1m4 0h1m2 0h1m2 0h1m-26 1h3m4 0h1m1 0h3m1 0h2m3 0h3m3 0h1m-25 1h4m1 0h1m3 0h4m3 0h3m3 0h1m1 0h1m1 0h2m-29 1h2m7 0h1m3 0h5m1 0h4m1 0h2m1 0h1m-28 1h1m1 0h2m1 0h4m1 0h1m2 0h1m1 0h1m2 0h1m4 0h2m-25 1h1m1 0h2m3 0h1m1 0h4m1 0h1m5 0h1m3 0h1m3 0h1m-29 1h1m4 0h6m2 0h1m2 0h11m-19 1h2m2 0h3m5 0h1m3 0h1m1 0h1m-27 1h7m6 0h1m1 0h1m1 0h4m1 0h1m1 0h1m1 0h1m-27 1h1m5 0h1m1 0h3m3 0h2m3 0h2m3 0h2m1 0h2m-29 1h1m1 0h3m1 0h1m1 0h3m1 0h1m2 0h4m1 0h6m1 0h2m-29 1h1m1 0h3m1 0h1m1 0h2m1 0h3m1 0h2m2 0h2m3 0h2m1 0h2m-29 1h1m1 0h3m1 0h1m1 0h1m1 0h2m1 0h4m1 0h3m1 0h6m-28 1h1m5 0h1m4 0h4m3 0h1m1 0h1m3 0h2m1 0h1m-28 1h7m1 0h2m3 0h1m2 0h4m1 0h2m1 0h2"/></svg>\n'
# ---

View file

@ -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)

View file

@ -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)],
],
)