From 864c80fa55d2ad4f3e3c82bc85009ff5ae3958a7 Mon Sep 17 00:00:00 2001 From: Tomasz Date: Wed, 17 Apr 2024 14:24:34 +0200 Subject: [PATCH] Add Sanix integration (#106785) * Add Sanix integration * Add Sanix integration * Add sanix pypi package * Add Sanix integration * Add Sanix integration * Add Sanix integration * Add Sanix integration * Add Sanix integration * Add Sanix integration * Add Sanix integration * Add Sanix integration * Add Sanix integration * Add Sanix integration * Add Sanix integration * Add Sanix integration * Add Sanix integration * Fix ruff * Fix * Fix --------- Co-authored-by: Joostlek --- CODEOWNERS | 2 + homeassistant/components/sanix/__init__.py | 37 ++++++ homeassistant/components/sanix/config_flow.py | 60 +++++++++ homeassistant/components/sanix/const.py | 8 ++ homeassistant/components/sanix/coordinator.py | 36 +++++ homeassistant/components/sanix/icons.json | 9 ++ homeassistant/components/sanix/manifest.json | 9 ++ homeassistant/components/sanix/sensor.py | 125 ++++++++++++++++++ homeassistant/components/sanix/strings.json | 36 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/sanix/__init__.py | 13 ++ tests/components/sanix/conftest.py | 52 ++++++++ .../sanix/fixtures/get_measurements.json | 10 ++ tests/components/sanix/test_config_flow.py | 112 ++++++++++++++++ tests/components/sanix/test_init.py | 27 ++++ 18 files changed, 549 insertions(+) create mode 100644 homeassistant/components/sanix/__init__.py create mode 100644 homeassistant/components/sanix/config_flow.py create mode 100644 homeassistant/components/sanix/const.py create mode 100644 homeassistant/components/sanix/coordinator.py create mode 100644 homeassistant/components/sanix/icons.json create mode 100644 homeassistant/components/sanix/manifest.json create mode 100644 homeassistant/components/sanix/sensor.py create mode 100644 homeassistant/components/sanix/strings.json create mode 100644 tests/components/sanix/__init__.py create mode 100644 tests/components/sanix/conftest.py create mode 100644 tests/components/sanix/fixtures/get_measurements.json create mode 100644 tests/components/sanix/test_config_flow.py create mode 100644 tests/components/sanix/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 56d42e5a3f3..a4224025acc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1186,6 +1186,8 @@ build.json @home-assistant/supervisor /homeassistant/components/saj/ @fredericvl /homeassistant/components/samsungtv/ @chemelli74 @epenet /tests/components/samsungtv/ @chemelli74 @epenet +/homeassistant/components/sanix/ @tomaszsluszniak +/tests/components/sanix/ @tomaszsluszniak /homeassistant/components/scene/ @home-assistant/core /tests/components/scene/ @home-assistant/core /homeassistant/components/schedule/ @home-assistant/core diff --git a/homeassistant/components/sanix/__init__.py b/homeassistant/components/sanix/__init__.py new file mode 100644 index 00000000000..c8c5567eedc --- /dev/null +++ b/homeassistant/components/sanix/__init__.py @@ -0,0 +1,37 @@ +"""The Sanix integration.""" + +from sanix import Sanix + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant + +from .const import CONF_SERIAL_NUMBER, DOMAIN +from .coordinator import SanixCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Sanix from a config entry.""" + + serial_no = entry.data[CONF_SERIAL_NUMBER] + token = entry.data[CONF_TOKEN] + + sanix_api = Sanix(serial_no, token) + coordinator = SanixCoordinator(hass, sanix_api) + + 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.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/sanix/config_flow.py b/homeassistant/components/sanix/config_flow.py new file mode 100644 index 00000000000..57aa5a5293a --- /dev/null +++ b/homeassistant/components/sanix/config_flow.py @@ -0,0 +1,60 @@ +"""Config flow for Sanix integration.""" + +import logging +from typing import Any + +from sanix import Sanix +from sanix.exceptions import SanixException, SanixInvalidAuthException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_TOKEN + +from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_SERIAL_NUMBER): str, + vol.Required(CONF_TOKEN): str, + } +) + + +class SanixConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Sanix.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input: + await self.async_set_unique_id(user_input[CONF_SERIAL_NUMBER]) + self._abort_if_unique_id_configured() + + sanix_api = Sanix(user_input[CONF_SERIAL_NUMBER], user_input[CONF_TOKEN]) + + try: + await self.hass.async_add_executor_job(sanix_api.fetch_data) + except SanixInvalidAuthException: + errors["base"] = "invalid_auth" + except SanixException: + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=MANUFACTURER, + data=user_input, + ) + + return self.async_show_form( + step_id="user", + description_placeholders={"dashboard_url": "https://sanix.bitcomplex.pl/"}, + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/sanix/const.py b/homeassistant/components/sanix/const.py new file mode 100644 index 00000000000..22ab33823d6 --- /dev/null +++ b/homeassistant/components/sanix/const.py @@ -0,0 +1,8 @@ +"""Constants for the Sanix integration.""" + +CONF_SERIAL_NUMBER = "serial_number" + +DOMAIN = "sanix" +MANUFACTURER = "Sanix" + +SANIX_API_HOST = "https://sanix.bitcomplex.pl" diff --git a/homeassistant/components/sanix/coordinator.py b/homeassistant/components/sanix/coordinator.py new file mode 100644 index 00000000000..d6362337a38 --- /dev/null +++ b/homeassistant/components/sanix/coordinator.py @@ -0,0 +1,36 @@ +"""Sanix Coordinator.""" + +from datetime import timedelta +import logging + +from sanix import Sanix +from sanix.exceptions import SanixException +from sanix.models import Measurement + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + + +class SanixCoordinator(DataUpdateCoordinator[Measurement]): + """Sanix coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, sanix_api: Sanix) -> None: + """Initialize coordinator.""" + super().__init__( + hass, _LOGGER, name=MANUFACTURER, update_interval=timedelta(hours=1) + ) + self._sanix_api = sanix_api + + async def _async_update_data(self) -> Measurement: + """Fetch data from API endpoint.""" + try: + return await self.hass.async_add_executor_job(self._sanix_api.fetch_data) + except SanixException as err: + raise UpdateFailed("Error while communicating with the API") from err diff --git a/homeassistant/components/sanix/icons.json b/homeassistant/components/sanix/icons.json new file mode 100644 index 00000000000..2b49cf8ea20 --- /dev/null +++ b/homeassistant/components/sanix/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "fill_perc": { + "default": "mdi:water-percent" + } + } + } +} diff --git a/homeassistant/components/sanix/manifest.json b/homeassistant/components/sanix/manifest.json new file mode 100644 index 00000000000..4e1c6d56add --- /dev/null +++ b/homeassistant/components/sanix/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "sanix", + "name": "Sanix", + "codeowners": ["@tomaszsluszniak"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/sanix", + "iot_class": "cloud_polling", + "requirements": ["sanix==1.0.5"] +} diff --git a/homeassistant/components/sanix/sensor.py b/homeassistant/components/sanix/sensor.py new file mode 100644 index 00000000000..e780c6f2df0 --- /dev/null +++ b/homeassistant/components/sanix/sensor.py @@ -0,0 +1,125 @@ +"""Platform for Sanix integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import date, datetime + +from sanix.const import ( + ATTR_API_BATTERY, + ATTR_API_DEVICE_NO, + ATTR_API_DISTANCE, + ATTR_API_FILL_PERC, + ATTR_API_SERVICE_DATE, + ATTR_API_SSID, +) +from sanix.models import Measurement + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, UnitOfLength +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import SanixCoordinator + + +@dataclass(frozen=True, kw_only=True) +class SanixSensorEntityDescription(SensorEntityDescription): + """Class describing Sanix Sensor entities.""" + + native_value_fn: Callable[[Measurement], int | datetime | date | str] + + +SENSOR_TYPES: tuple[SanixSensorEntityDescription, ...] = ( + SanixSensorEntityDescription( + key=ATTR_API_BATTERY, + translation_key=ATTR_API_BATTERY, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + native_value_fn=lambda data: data.battery, + ), + SanixSensorEntityDescription( + key=ATTR_API_DISTANCE, + translation_key=ATTR_API_DISTANCE, + native_unit_of_measurement=UnitOfLength.CENTIMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + native_value_fn=lambda data: data.distance, + ), + SanixSensorEntityDescription( + key=ATTR_API_SERVICE_DATE, + translation_key=ATTR_API_SERVICE_DATE, + device_class=SensorDeviceClass.DATE, + native_value_fn=lambda data: data.service_date, + ), + SanixSensorEntityDescription( + key=ATTR_API_FILL_PERC, + translation_key=ATTR_API_FILL_PERC, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_value_fn=lambda data: data.fill_perc, + ), + SanixSensorEntityDescription( + key=ATTR_API_SSID, + translation_key=ATTR_API_SSID, + entity_registry_enabled_default=False, + native_value_fn=lambda data: data.ssid, + ), + SanixSensorEntityDescription( + key=ATTR_API_DEVICE_NO, + translation_key=ATTR_API_DEVICE_NO, + entity_registry_enabled_default=False, + native_value_fn=lambda data: data.device_no, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Sanix Sensor entities based on a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + SanixSensorEntity(coordinator, description) for description in SENSOR_TYPES + ) + + +class SanixSensorEntity(CoordinatorEntity[SanixCoordinator], SensorEntity): + """Sanix Sensor entity.""" + + _attr_has_entity_name = True + entity_description: SanixSensorEntityDescription + + def __init__( + self, + coordinator: SanixCoordinator, + description: SanixSensorEntityDescription, + ) -> None: + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator) + serial_no = str(coordinator.config_entry.unique_id) + + self._attr_unique_id = f"{serial_no}-{description.key}" + self.entity_description = description + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial_no)}, + entry_type=DeviceEntryType.SERVICE, + manufacturer=MANUFACTURER, + serial_number=serial_no, + ) + + @property + def native_value(self) -> int | datetime | date | str: + """Return the state of the sensor.""" + return self.entity_description.native_value_fn(self.coordinator.data) diff --git a/homeassistant/components/sanix/strings.json b/homeassistant/components/sanix/strings.json new file mode 100644 index 00000000000..6bff11e36af --- /dev/null +++ b/homeassistant/components/sanix/strings.json @@ -0,0 +1,36 @@ +{ + "config": { + "step": { + "user": { + "description": "To get the Serial number and the Token you just have to sign in to the [Sanix Dashboard]({dashboard_url}) and open the Help -> System version page.", + "data": { + "serial_number": "Serial number", + "token": "[%key:common::config_flow::data::access_token%]" + } + } + }, + "error": { + "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_device%]" + } + }, + "entity": { + "sensor": { + "service_date": { + "name": "Service date" + }, + "fill_perc": { + "name": "Filled" + }, + "device_no": { + "name": "Device number" + }, + "ssid": { + "name": "SSID" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 30d580ad1ea..fd87c965db5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -457,6 +457,7 @@ FLOWS = { "rympro", "sabnzbd", "samsungtv", + "sanix", "schlage", "scrape", "screenlogic", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index fa2cec4d77a..d10cb3fdb80 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5180,6 +5180,12 @@ } } }, + "sanix": { + "name": "Sanix", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "satel_integra": { "name": "Satel Integra", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 2f8d3e43780..74b458920e8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2492,6 +2492,9 @@ samsungctl[websocket]==0.7.1 # homeassistant.components.samsungtv samsungtvws[async,encrypted]==2.6.0 +# homeassistant.components.sanix +sanix==1.0.5 + # homeassistant.components.satel_integra satel-integra==0.3.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c70de76d37e..ea115d4b29d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1929,6 +1929,9 @@ samsungctl[websocket]==0.7.1 # homeassistant.components.samsungtv samsungtvws[async,encrypted]==2.6.0 +# homeassistant.components.sanix +sanix==1.0.5 + # homeassistant.components.screenlogic screenlogicpy==0.10.0 diff --git a/tests/components/sanix/__init__.py b/tests/components/sanix/__init__.py new file mode 100644 index 00000000000..ef1a9c63fbe --- /dev/null +++ b/tests/components/sanix/__init__.py @@ -0,0 +1,13 @@ +"""Tests for Sanix.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/sanix/conftest.py b/tests/components/sanix/conftest.py new file mode 100644 index 00000000000..297416a6290 --- /dev/null +++ b/tests/components/sanix/conftest.py @@ -0,0 +1,52 @@ +"""Sanix tests configuration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from sanix.models import Measurement + +from homeassistant.components.sanix.const import CONF_SERIAL_NUMBER, DOMAIN +from homeassistant.const import CONF_TOKEN + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_sanix(): + """Build a fixture for the Sanix API that connects successfully and returns measurements.""" + fixture = load_json_object_fixture("sanix/get_measurements.json") + mock_sanix_api = MagicMock() + with ( + patch( + "homeassistant.components.sanix.config_flow.Sanix", + return_value=mock_sanix_api, + ) as mock_sanix_api, + patch( + "homeassistant.components.sanix.Sanix", + return_value=mock_sanix_api, + ), + ): + mock_sanix_api.return_value.fetch_data.return_value = Measurement(**fixture) + yield mock_sanix_api + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Sanix", + unique_id="1810088", + data={CONF_SERIAL_NUMBER: "1234", CONF_TOKEN: "abcd"}, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.sanix.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/sanix/fixtures/get_measurements.json b/tests/components/sanix/fixtures/get_measurements.json new file mode 100644 index 00000000000..de6f4c41311 --- /dev/null +++ b/tests/components/sanix/fixtures/get_measurements.json @@ -0,0 +1,10 @@ +{ + "device_no": "SANIX-1810088", + "status": "1", + "time": "30.12.2023 03:10:21", + "ssid": "Wifi", + "battery": "100", + "distance": "109", + "fill_perc": 32, + "service_date": "15.06.2024" +} diff --git a/tests/components/sanix/test_config_flow.py b/tests/components/sanix/test_config_flow.py new file mode 100644 index 00000000000..abd91ee306c --- /dev/null +++ b/tests/components/sanix/test_config_flow.py @@ -0,0 +1,112 @@ +"""Define tests for the Sanix config flow.""" + +from unittest.mock import MagicMock + +import pytest +from sanix.exceptions import SanixException, SanixInvalidAuthException + +from homeassistant.components.sanix.const import ( + CONF_SERIAL_NUMBER, + DOMAIN, + MANUFACTURER, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +CONFIG = {CONF_SERIAL_NUMBER: "1810088", CONF_TOKEN: "75868dcf8ea4c64e2063f6c4e70132d2"} + + +async def test_create_entry( + hass: HomeAssistant, mock_sanix: MagicMock, mock_setup_entry +) -> None: + """Test that the user step works.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MANUFACTURER + assert result["data"] == { + CONF_SERIAL_NUMBER: "1810088", + CONF_TOKEN: "75868dcf8ea4c64e2063f6c4e70132d2", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (SanixInvalidAuthException("Invalid auth"), "invalid_auth"), + (SanixException("Something went wrong"), "unknown"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, + exception: Exception, + error: str, + mock_sanix: MagicMock, + mock_setup_entry, +) -> None: + """Test Form exceptions.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_sanix.return_value.fetch_data.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG, + ) + + mock_sanix.return_value.fetch_data.side_effect = None + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Sanix" + assert result["data"] == { + CONF_SERIAL_NUMBER: "1810088", + CONF_TOKEN: "75868dcf8ea4c64e2063f6c4e70132d2", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplicate_error( + hass: HomeAssistant, mock_sanix: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test that errors are shown when duplicates are added.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/sanix/test_init.py b/tests/components/sanix/test_init.py new file mode 100644 index 00000000000..57e4920da11 --- /dev/null +++ b/tests/components/sanix/test_init.py @@ -0,0 +1,27 @@ +"""Test the Home Assistant analytics init module.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.sanix import setup_integration + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_sanix: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED