From 1e9ede25ad253bc42bfd764435a9d37bd4fd3a80 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Tue, 16 Aug 2022 14:08:35 -0400 Subject: [PATCH] Add Fully Kiosk Browser integration with initial binary sensor platform (#76737) Co-authored-by: Franck Nijhof --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/fully_kiosk/__init__.py | 31 ++++ .../components/fully_kiosk/binary_sensor.py | 73 ++++++++++ .../components/fully_kiosk/config_flow.py | 63 ++++++++ homeassistant/components/fully_kiosk/const.py | 13 ++ .../components/fully_kiosk/coordinator.py | 48 +++++++ .../components/fully_kiosk/entity.py | 26 ++++ .../components/fully_kiosk/manifest.json | 10 ++ .../components/fully_kiosk/strings.json | 20 +++ .../fully_kiosk/translations/en.json | 20 +++ homeassistant/generated/config_flows.py | 1 + mypy.ini | 10 ++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/fully_kiosk/__init__.py | 1 + tests/components/fully_kiosk/conftest.py | 76 ++++++++++ .../fully_kiosk/fixtures/deviceinfo.json | 79 ++++++++++ .../fully_kiosk/test_binary_sensor.py | 93 ++++++++++++ .../fully_kiosk/test_config_flow.py | 136 ++++++++++++++++++ tests/components/fully_kiosk/test_init.py | 53 +++++++ 21 files changed, 762 insertions(+) create mode 100644 homeassistant/components/fully_kiosk/__init__.py create mode 100644 homeassistant/components/fully_kiosk/binary_sensor.py create mode 100644 homeassistant/components/fully_kiosk/config_flow.py create mode 100644 homeassistant/components/fully_kiosk/const.py create mode 100644 homeassistant/components/fully_kiosk/coordinator.py create mode 100644 homeassistant/components/fully_kiosk/entity.py create mode 100644 homeassistant/components/fully_kiosk/manifest.json create mode 100644 homeassistant/components/fully_kiosk/strings.json create mode 100644 homeassistant/components/fully_kiosk/translations/en.json create mode 100644 tests/components/fully_kiosk/__init__.py create mode 100644 tests/components/fully_kiosk/conftest.py create mode 100644 tests/components/fully_kiosk/fixtures/deviceinfo.json create mode 100644 tests/components/fully_kiosk/test_binary_sensor.py create mode 100644 tests/components/fully_kiosk/test_config_flow.py create mode 100644 tests/components/fully_kiosk/test_init.py diff --git a/.strict-typing b/.strict-typing index 02d753b3994..d9cc4ffb55a 100644 --- a/.strict-typing +++ b/.strict-typing @@ -108,6 +108,7 @@ homeassistant.components.fritzbox_callmonitor.* homeassistant.components.fronius.* homeassistant.components.frontend.* homeassistant.components.fritz.* +homeassistant.components.fully_kiosk.* homeassistant.components.geo_location.* homeassistant.components.geocaching.* homeassistant.components.gios.* diff --git a/CODEOWNERS b/CODEOWNERS index 3ac50eeb1db..0c8aa94dfb8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -375,6 +375,8 @@ build.json @home-assistant/supervisor /homeassistant/components/frontend/ @home-assistant/frontend /tests/components/frontend/ @home-assistant/frontend /homeassistant/components/frontier_silicon/ @wlcrs +/homeassistant/components/fully_kiosk/ @cgarwood +/tests/components/fully_kiosk/ @cgarwood /homeassistant/components/garages_amsterdam/ @klaasnicolaas /tests/components/garages_amsterdam/ @klaasnicolaas /homeassistant/components/gdacs/ @exxamalte diff --git a/homeassistant/components/fully_kiosk/__init__.py b/homeassistant/components/fully_kiosk/__init__.py new file mode 100644 index 00000000000..943f5c69cbe --- /dev/null +++ b/homeassistant/components/fully_kiosk/__init__.py @@ -0,0 +1,31 @@ +"""The Fully Kiosk Browser integration.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import FullyKioskDataUpdateCoordinator + +PLATFORMS = [Platform.BINARY_SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Fully Kiosk Browser from a config entry.""" + + coordinator = FullyKioskDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/fully_kiosk/binary_sensor.py b/homeassistant/components/fully_kiosk/binary_sensor.py new file mode 100644 index 00000000000..6f1fccfb9d3 --- /dev/null +++ b/homeassistant/components/fully_kiosk/binary_sensor.py @@ -0,0 +1,73 @@ +"""Fully Kiosk Browser sensor.""" +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import FullyKioskDataUpdateCoordinator +from .entity import FullyKioskEntity + +SENSORS: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="kioskMode", + name="Kiosk mode", + entity_category=EntityCategory.DIAGNOSTIC, + ), + BinarySensorEntityDescription( + key="plugged", + name="Plugged in", + device_class=BinarySensorDeviceClass.PLUG, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BinarySensorEntityDescription( + key="isDeviceAdmin", + name="Device admin", + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Fully Kiosk Browser sensor.""" + coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] + + async_add_entities( + FullyBinarySensor(coordinator, description) + for description in SENSORS + if description.key in coordinator.data + ) + + +class FullyBinarySensor(FullyKioskEntity, BinarySensorEntity): + """Representation of a Fully Kiosk Browser binary sensor.""" + + def __init__( + self, + coordinator: FullyKioskDataUpdateCoordinator, + description: BinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.data['deviceID']}-{description.key}" + + @property + def is_on(self) -> bool | None: + """Return if the binary sensor is on.""" + if (value := self.coordinator.data.get(self.entity_description.key)) is None: + return None + return bool(value) diff --git a/homeassistant/components/fully_kiosk/config_flow.py b/homeassistant/components/fully_kiosk/config_flow.py new file mode 100644 index 00000000000..09eb94d6b07 --- /dev/null +++ b/homeassistant/components/fully_kiosk/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow for Fully Kiosk Browser integration.""" +from __future__ import annotations + +import asyncio +from typing import Any + +from aiohttp.client_exceptions import ClientConnectorError +from async_timeout import timeout +from fullykiosk import FullyKiosk +from fullykiosk.exceptions import FullyKioskError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DEFAULT_PORT, DOMAIN, LOGGER + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Fully Kiosk Browser.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + fully = FullyKiosk( + async_get_clientsession(self.hass), + user_input[CONF_HOST], + DEFAULT_PORT, + user_input[CONF_PASSWORD], + ) + + try: + async with timeout(15): + device_info = await fully.getDeviceInfo() + except (ClientConnectorError, FullyKioskError, asyncio.TimeoutError): + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(device_info["deviceID"]) + self._abort_if_unique_id_configured(updates=user_input) + return self.async_create_entry( + title=device_info["deviceName"], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/fully_kiosk/const.py b/homeassistant/components/fully_kiosk/const.py new file mode 100644 index 00000000000..f21906bae73 --- /dev/null +++ b/homeassistant/components/fully_kiosk/const.py @@ -0,0 +1,13 @@ +"""Constants for the Fully Kiosk Browser integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +DOMAIN: Final = "fully_kiosk" + +LOGGER = logging.getLogger(__package__) +UPDATE_INTERVAL = timedelta(seconds=30) + +DEFAULT_PORT = 2323 diff --git a/homeassistant/components/fully_kiosk/coordinator.py b/homeassistant/components/fully_kiosk/coordinator.py new file mode 100644 index 00000000000..fbd08f8d2c5 --- /dev/null +++ b/homeassistant/components/fully_kiosk/coordinator.py @@ -0,0 +1,48 @@ +"""Provides the The Fully Kiosk Browser DataUpdateCoordinator.""" +import asyncio +from typing import Any, cast + +from async_timeout import timeout +from fullykiosk import FullyKiosk +from fullykiosk.exceptions import FullyKioskError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_PORT, LOGGER, UPDATE_INTERVAL + + +class FullyKioskDataUpdateCoordinator(DataUpdateCoordinator): + """Define an object to hold Fully Kiosk Browser data.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize.""" + self.fully = FullyKiosk( + async_get_clientsession(hass), + entry.data[CONF_HOST], + DEFAULT_PORT, + entry.data[CONF_PASSWORD], + ) + super().__init__( + hass, + LOGGER, + name=entry.data[CONF_HOST], + update_interval=UPDATE_INTERVAL, + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Update data via library.""" + try: + async with timeout(15): + # Get device info and settings in parallel + result = await asyncio.gather( + self.fully.getDeviceInfo(), self.fully.getSettings() + ) + # Store settings under settings key in data + result[0]["settings"] = result[1] + return cast(dict[str, Any], result[0]) + except FullyKioskError as error: + raise UpdateFailed(error) from error diff --git a/homeassistant/components/fully_kiosk/entity.py b/homeassistant/components/fully_kiosk/entity.py new file mode 100644 index 00000000000..4e50bb6efe6 --- /dev/null +++ b/homeassistant/components/fully_kiosk/entity.py @@ -0,0 +1,26 @@ +"""Base entity for the Fully Kiosk Browser integration.""" +from __future__ import annotations + +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import FullyKioskDataUpdateCoordinator + + +class FullyKioskEntity(CoordinatorEntity[FullyKioskDataUpdateCoordinator], Entity): + """Defines a Fully Kiosk Browser entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: FullyKioskDataUpdateCoordinator) -> None: + """Initialize the Fully Kiosk Browser entity.""" + super().__init__(coordinator=coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.data["deviceID"])}, + name=coordinator.data["deviceName"], + manufacturer=coordinator.data["deviceManufacturer"], + model=coordinator.data["deviceModel"], + sw_version=coordinator.data["appVersionName"], + configuration_url=f"http://{coordinator.data['ip4']}:2323", + ) diff --git a/homeassistant/components/fully_kiosk/manifest.json b/homeassistant/components/fully_kiosk/manifest.json new file mode 100644 index 00000000000..40c7e5293e7 --- /dev/null +++ b/homeassistant/components/fully_kiosk/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "fully_kiosk", + "name": "Fully Kiosk Browser", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/fullykiosk", + "requirements": ["python-fullykiosk==0.0.11"], + "dependencies": [], + "codeowners": ["@cgarwood"], + "iot_class": "local_polling" +} diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json new file mode 100644 index 00000000000..05b9e067962 --- /dev/null +++ b/homeassistant/components/fully_kiosk/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + } +} diff --git a/homeassistant/components/fully_kiosk/translations/en.json b/homeassistant/components/fully_kiosk/translations/en.json new file mode 100644 index 00000000000..338c50514fb --- /dev/null +++ b/homeassistant/components/fully_kiosk/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ce95cb66cc5..8b5db1b45ba 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -125,6 +125,7 @@ FLOWS = { "fritzbox", "fritzbox_callmonitor", "fronius", + "fully_kiosk", "garages_amsterdam", "gdacs", "generic", diff --git a/mypy.ini b/mypy.ini index 2645cb9d107..570004a14dd 100644 --- a/mypy.ini +++ b/mypy.ini @@ -839,6 +839,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.fully_kiosk.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.geo_location.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 8b2e2349715..dfae2e4afd9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1914,6 +1914,9 @@ python-family-hub-local==0.0.2 # homeassistant.components.darksky python-forecastio==1.4.0 +# homeassistant.components.fully_kiosk +python-fullykiosk==0.0.11 + # homeassistant.components.sms # python-gammu==3.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b5cf5bccb54..0623b6c7c79 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1313,6 +1313,9 @@ python-ecobee-api==0.2.14 # homeassistant.components.darksky python-forecastio==1.4.0 +# homeassistant.components.fully_kiosk +python-fullykiosk==0.0.11 + # homeassistant.components.homewizard python-homewizard-energy==1.1.0 diff --git a/tests/components/fully_kiosk/__init__.py b/tests/components/fully_kiosk/__init__.py new file mode 100644 index 00000000000..7cdc13ace56 --- /dev/null +++ b/tests/components/fully_kiosk/__init__.py @@ -0,0 +1 @@ +"""Tests for the Fully Kiosk Browser integration.""" diff --git a/tests/components/fully_kiosk/conftest.py b/tests/components/fully_kiosk/conftest.py new file mode 100644 index 00000000000..c5476ea6a9d --- /dev/null +++ b/tests/components/fully_kiosk/conftest.py @@ -0,0 +1,76 @@ +"""Fixtures for the Fully Kiosk Browser integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.fully_kiosk.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Test device", + domain=DOMAIN, + data={CONF_HOST: "127.0.0.1", CONF_PASSWORD: "mocked-password"}, + unique_id="12345", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.fully_kiosk.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_fully_kiosk_config_flow() -> Generator[MagicMock, None, None]: + """Return a mocked Fully Kiosk client for the config flow.""" + with patch( + "homeassistant.components.fully_kiosk.config_flow.FullyKiosk", + autospec=True, + ) as client_mock: + client = client_mock.return_value + client.getDeviceInfo.return_value = { + "deviceName": "Test device", + "deviceID": "12345", + } + yield client + + +@pytest.fixture +def mock_fully_kiosk() -> Generator[MagicMock, None, None]: + """Return a mocked Fully Kiosk client.""" + with patch( + "homeassistant.components.fully_kiosk.coordinator.FullyKiosk", + autospec=True, + ) as client_mock: + client = client_mock.return_value + client.getDeviceInfo.return_value = json.loads( + load_fixture("deviceinfo.json", DOMAIN) + ) + yield client + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_fully_kiosk: MagicMock +) -> MockConfigEntry: + """Set up the Fully Kiosk Browser integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/fully_kiosk/fixtures/deviceinfo.json b/tests/components/fully_kiosk/fixtures/deviceinfo.json new file mode 100644 index 00000000000..0a530dc35c8 --- /dev/null +++ b/tests/components/fully_kiosk/fixtures/deviceinfo.json @@ -0,0 +1,79 @@ +{ + "deviceName": "Amazon Fire", + "batteryLevel": 100, + "isPlugged": true, + "SSID": "\"freewifi\"", + "Mac": "aa:bb:cc:dd:ee:ff", + "ip4": "192.168.1.234", + "ip6": "FE80::1874:2EFF:FEA2:7848", + "hostname4": "192.168.1.234", + "hostname6": "fe80::1874:2eff:fea2:7848%p2p0", + "wifiSignalLevel": 7, + "isMobileDataEnabled": true, + "screenOrientation": 90, + "screenBrightness": 9, + "screenLocked": false, + "screenOn": true, + "batteryTemperature": 27, + "plugged": true, + "keyguardLocked": false, + "locale": "en_US", + "serial": "ABCDEF1234567890", + "build": "cm_douglas-userdebug 5.1.1 LMY49M 731a881f9d test-keys", + "androidVersion": "5.1.1", + "webviewUA": "Mozilla/5.0 (Linux; Android 5.1.1; KFDOWI Build/LMY49M; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/81.0.4044.117 Safari/537.36", + "motionDetectorStatus": 0, + "isDeviceAdmin": true, + "isDeviceOwner": false, + "internalStorageFreeSpace": 11675512832, + "internalStorageTotalSpace": 12938534912, + "ramUsedMemory": 1077755904, + "ramFreeMemory": 362373120, + "ramTotalMemory": 1440129024, + "appUsedMemory": 24720592, + "appFreeMemory": 59165440, + "appTotalMemory": 83886080, + "displayHeightPixels": 800, + "displayWidthPixels": 1280, + "isMenuOpen": false, + "topFragmentTag": "", + "isInDaydream": false, + "isRooted": true, + "isLicensed": true, + "isInScreensaver": false, + "kioskLocked": true, + "isInForcedSleep": false, + "maintenanceMode": false, + "kioskMode": true, + "startUrl": "https://homeassistant.local", + "currentTabIndex": 0, + "mqttConnected": true, + "deviceID": "abcdef-123456", + "appVersionCode": 875, + "appVersionName": "1.42.5", + "androidSdk": 22, + "deviceModel": "KFDOWI", + "deviceManufacturer": "amzn", + "foregroundApp": "de.ozerov.fully", + "currentPage": "https://homeassistant.local", + "lastAppStart": "8/13/2022 1:00:47 AM", + "sensorInfo": [ + { + "type": 8, + "name": "PROXIMITY", + "vendor": "MTK", + "version": 1, + "accuracy": -1 + }, + { + "type": 5, + "name": "LIGHT", + "vendor": "MTK", + "version": 1, + "accuracy": 3, + "values": [0, 0, 0], + "lastValuesTime": 1660435566561, + "lastAccuracyTime": 1660366847543 + } + ] +} diff --git a/tests/components/fully_kiosk/test_binary_sensor.py b/tests/components/fully_kiosk/test_binary_sensor.py new file mode 100644 index 00000000000..3583a66b8e7 --- /dev/null +++ b/tests/components/fully_kiosk/test_binary_sensor.py @@ -0,0 +1,93 @@ +"""Test the Fully Kiosk Browser binary sensors.""" +from unittest.mock import MagicMock + +from fullykiosk import FullyKioskError + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.fully_kiosk.const import DOMAIN, UPDATE_INTERVAL +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity import EntityCategory +from homeassistant.util import dt + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_binary_sensors( + hass: HomeAssistant, + mock_fully_kiosk: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test standard Fully Kiosk binary sensors.""" + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("binary_sensor.amazon_fire_plugged_in") + assert state + assert state.state == STATE_ON + assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.PLUG + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Amazon Fire Plugged in" + + entry = entity_registry.async_get("binary_sensor.amazon_fire_plugged_in") + assert entry + assert entry.unique_id == "abcdef-123456-plugged" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + + state = hass.states.get("binary_sensor.amazon_fire_kiosk_mode") + assert state + assert state.state == STATE_ON + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Amazon Fire Kiosk mode" + + entry = entity_registry.async_get("binary_sensor.amazon_fire_kiosk_mode") + assert entry + assert entry.unique_id == "abcdef-123456-kioskMode" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + + state = hass.states.get("binary_sensor.amazon_fire_device_admin") + assert state + assert state.state == STATE_ON + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Amazon Fire Device admin" + + entry = entity_registry.async_get("binary_sensor.amazon_fire_device_admin") + assert entry + assert entry.unique_id == "abcdef-123456-isDeviceAdmin" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.configuration_url == "http://192.168.1.234:2323" + assert device_entry.entry_type is None + assert device_entry.hw_version is None + assert device_entry.identifiers == {(DOMAIN, "abcdef-123456")} + assert device_entry.manufacturer == "amzn" + assert device_entry.model == "KFDOWI" + assert device_entry.name == "Amazon Fire" + assert device_entry.sw_version == "1.42.5" + + # Test unknown/missing data + mock_fully_kiosk.getDeviceInfo.return_value = {} + async_fire_time_changed(hass, dt.utcnow() + UPDATE_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.amazon_fire_plugged_in") + assert state + assert state.state == STATE_UNKNOWN + + # Test failed update + mock_fully_kiosk.getDeviceInfo.side_effect = FullyKioskError("error", "status") + async_fire_time_changed(hass, dt.utcnow() + UPDATE_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.amazon_fire_plugged_in") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/fully_kiosk/test_config_flow.py b/tests/components/fully_kiosk/test_config_flow.py new file mode 100644 index 00000000000..2617a3f7adb --- /dev/null +++ b/tests/components/fully_kiosk/test_config_flow.py @@ -0,0 +1,136 @@ +"""Test the Fully Kiosk Browser config flow.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, Mock + +from aiohttp.client_exceptions import ClientConnectorError +from fullykiosk import FullyKioskError +import pytest + +from homeassistant.components.fully_kiosk.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_fully_kiosk_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full user initiated config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2.get("title") == "Test device" + assert result2.get("data") == { + CONF_HOST: "1.1.1.1", + CONF_PASSWORD: "test-password", + } + assert "result" in result2 + assert result2["result"].unique_id == "12345" + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_fully_kiosk_config_flow.getDeviceInfo.mock_calls) == 1 + + +@pytest.mark.parametrize( + "side_effect,reason", + [ + (FullyKioskError("error", "status"), "cannot_connect"), + (ClientConnectorError(None, Mock()), "cannot_connect"), + (asyncio.TimeoutError, "cannot_connect"), + (RuntimeError, "unknown"), + ], +) +async def test_errors( + hass: HomeAssistant, + mock_fully_kiosk_config_flow: MagicMock, + mock_setup_entry: AsyncMock, + side_effect: Exception, + reason: str, +) -> None: + """Test errors raised during flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert "flow_id" in result + flow_id = result["flow_id"] + + mock_fully_kiosk_config_flow.getDeviceInfo.side_effect = side_effect + result2 = await hass.config_entries.flow.async_configure( + flow_id, user_input={CONF_HOST: "1.1.1.1", CONF_PASSWORD: "test-password"} + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("step_id") == "user" + assert result2.get("errors") == {"base": reason} + + assert len(mock_fully_kiosk_config_flow.getDeviceInfo.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 0 + + mock_fully_kiosk_config_flow.getDeviceInfo.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + flow_id, user_input={CONF_HOST: "1.1.1.1", CONF_PASSWORD: "test-password"} + ) + + assert result3.get("type") == FlowResultType.CREATE_ENTRY + assert result3.get("title") == "Test device" + assert result3.get("data") == { + CONF_HOST: "1.1.1.1", + CONF_PASSWORD: "test-password", + } + assert "result" in result3 + assert result3["result"].unique_id == "12345" + + assert len(mock_fully_kiosk_config_flow.getDeviceInfo.mock_calls) == 2 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplicate_updates_existing_entry( + hass: HomeAssistant, + mock_fully_kiosk_config_flow: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test adding existing device updates existing entry.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "already_configured" + assert mock_config_entry.data == { + CONF_HOST: "1.1.1.1", + CONF_PASSWORD: "test-password", + } + + assert len(mock_fully_kiosk_config_flow.getDeviceInfo.mock_calls) == 1 diff --git a/tests/components/fully_kiosk/test_init.py b/tests/components/fully_kiosk/test_init.py new file mode 100644 index 00000000000..5960873e124 --- /dev/null +++ b/tests/components/fully_kiosk/test_init.py @@ -0,0 +1,53 @@ +"""Tests for the Fully Kiosk Browser integration.""" +import asyncio +from unittest.mock import MagicMock + +from fullykiosk import FullyKioskError +import pytest + +from homeassistant.components.fully_kiosk.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_fully_kiosk: MagicMock, +) -> None: + """Test the Fully Kiosk Browser configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert len(mock_fully_kiosk.getDeviceInfo.mock_calls) == 1 + assert len(mock_fully_kiosk.getSettings.mock_calls) == 1 + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + "side_effect", + [FullyKioskError("error", "status"), asyncio.TimeoutError], +) +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_fully_kiosk: MagicMock, + side_effect: Exception, +) -> None: + """Test the Fully Kiosk Browser configuration entry not ready.""" + mock_fully_kiosk.getDeviceInfo.side_effect = side_effect + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY