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 <joostlek@outlook.com>
This commit is contained in:
Tomasz 2024-04-17 14:24:34 +02:00 committed by GitHub
parent 92aae4d368
commit 864c80fa55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 549 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,9 @@
{
"entity": {
"sensor": {
"fill_perc": {
"default": "mdi:water-percent"
}
}
}
}

View file

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

View file

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

View file

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

View file

@ -457,6 +457,7 @@ FLOWS = {
"rympro",
"sabnzbd",
"samsungtv",
"sanix",
"schlage",
"scrape",
"screenlogic",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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