diff --git a/.strict-typing b/.strict-typing index d0b47db2d59869..89df0a0bdc184d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -403,6 +403,7 @@ homeassistant.components.tailwind.* homeassistant.components.tami4.* homeassistant.components.tautulli.* homeassistant.components.tcp.* +homeassistant.components.technove.* homeassistant.components.tedee.* homeassistant.components.text.* homeassistant.components.threshold.* diff --git a/CODEOWNERS b/CODEOWNERS index 1288ea535911a8..d0b5b338108b97 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1321,6 +1321,8 @@ build.json @home-assistant/supervisor /tests/components/tasmota/ @emontnemery /homeassistant/components/tautulli/ @ludeeus @tkdrob /tests/components/tautulli/ @ludeeus @tkdrob +/homeassistant/components/technove/ @Moustachauve +/tests/components/technove/ @Moustachauve /homeassistant/components/tedee/ @patrickhilker @zweckj /tests/components/tedee/ @patrickhilker @zweckj /homeassistant/components/tellduslive/ @fredrike diff --git a/homeassistant/components/technove/__init__.py b/homeassistant/components/technove/__init__.py new file mode 100644 index 00000000000000..12ca604af45521 --- /dev/null +++ b/homeassistant/components/technove/__init__.py @@ -0,0 +1,31 @@ +"""The TechnoVE integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import TechnoVEDataUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up TechnoVE from a config entry.""" + coordinator = TechnoVEDataUpdateCoordinator(hass) + 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/technove/config_flow.py b/homeassistant/components/technove/config_flow.py new file mode 100644 index 00000000000000..a08d3030018d0f --- /dev/null +++ b/homeassistant/components/technove/config_flow.py @@ -0,0 +1,50 @@ +"""Config flow for TechnoVE.""" + +from typing import Any + +from technove import Station as TechnoVEStation, TechnoVE, TechnoVEConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + + +class TechnoVEConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for TechnoVE.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors = {} + if user_input is not None: + try: + station = await self._async_get_station(user_input[CONF_HOST]) + except TechnoVEConnectionError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(station.info.mac_address) + self._abort_if_unique_id_configured( + updates={CONF_HOST: user_input[CONF_HOST]} + ) + return self.async_create_entry( + title=station.info.name, + data={ + CONF_HOST: user_input[CONF_HOST], + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors, + ) + + async def _async_get_station(self, host: str) -> TechnoVEStation: + """Get information from a TechnoVE station.""" + api = TechnoVE(host, session=async_get_clientsession(self.hass)) + return await api.update() diff --git a/homeassistant/components/technove/const.py b/homeassistant/components/technove/const.py new file mode 100644 index 00000000000000..6dd7d567353854 --- /dev/null +++ b/homeassistant/components/technove/const.py @@ -0,0 +1,8 @@ +"""Constants for the TechnoVE integration.""" +from datetime import timedelta +import logging + +DOMAIN = "technove" + +LOGGER = logging.getLogger(__package__) +SCAN_INTERVAL = timedelta(seconds=5) diff --git a/homeassistant/components/technove/coordinator.py b/homeassistant/components/technove/coordinator.py new file mode 100644 index 00000000000000..66ec7d979f327b --- /dev/null +++ b/homeassistant/components/technove/coordinator.py @@ -0,0 +1,40 @@ +"""DataUpdateCoordinator for TechnoVE.""" +from __future__ import annotations + +from technove import Station as TechnoVEStation, TechnoVE, TechnoVEError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +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 DOMAIN, LOGGER, SCAN_INTERVAL + + +class TechnoVEDataUpdateCoordinator(DataUpdateCoordinator[TechnoVEStation]): + """Class to manage fetching TechnoVE data from single endpoint.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize global TechnoVE data updater.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.technove = TechnoVE( + self.config_entry.data[CONF_HOST], + session=async_get_clientsession(hass), + ) + + async def _async_update_data(self) -> TechnoVEStation: + """Fetch data from TechnoVE.""" + try: + station = await self.technove.update() + except TechnoVEError as error: + raise UpdateFailed(f"Invalid response from API: {error}") from error + + return station diff --git a/homeassistant/components/technove/entity.py b/homeassistant/components/technove/entity.py new file mode 100644 index 00000000000000..964f2941301757 --- /dev/null +++ b/homeassistant/components/technove/entity.py @@ -0,0 +1,26 @@ +"""Entity for TechnoVE.""" +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TechnoVEDataUpdateCoordinator + + +class TechnoVEEntity(CoordinatorEntity[TechnoVEDataUpdateCoordinator]): + """Defines a base TechnoVE entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: TechnoVEDataUpdateCoordinator, key: str) -> None: + """Initialize a base TechnoVE entity.""" + super().__init__(coordinator) + info = self.coordinator.data.info + self._attr_unique_id = f"{info.mac_address}_{key}" + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, info.mac_address)}, + identifiers={(DOMAIN, info.mac_address)}, + name=info.name, + manufacturer="TechnoVE", + model=f"TechnoVE i{info.max_station_current}", + sw_version=info.version, + ) diff --git a/homeassistant/components/technove/manifest.json b/homeassistant/components/technove/manifest.json new file mode 100644 index 00000000000000..c5177d047f9140 --- /dev/null +++ b/homeassistant/components/technove/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "technove", + "name": "TechnoVE", + "codeowners": ["@Moustachauve"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/technove", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["python-technove==1.1.1"] +} diff --git a/homeassistant/components/technove/sensor.py b/homeassistant/components/technove/sensor.py new file mode 100644 index 00000000000000..99cdc62ceee6c3 --- /dev/null +++ b/homeassistant/components/technove/sensor.py @@ -0,0 +1,161 @@ +"""Platform for sensor integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from technove import Station as TechnoVEStation, Status + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DOMAIN +from .coordinator import TechnoVEDataUpdateCoordinator +from .entity import TechnoVEEntity + +STATUS_TYPE = [s.value for s in Status] + + +@dataclass(frozen=True, kw_only=True) +class TechnoVESensorEntityDescription(SensorEntityDescription): + """Describes TechnoVE sensor entity.""" + + value_fn: Callable[[TechnoVEStation], StateType] + + +SENSORS: tuple[TechnoVESensorEntityDescription, ...] = ( + TechnoVESensorEntityDescription( + key="voltage_in", + translation_key="voltage_in", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.voltage_in, + ), + TechnoVESensorEntityDescription( + key="voltage_out", + translation_key="voltage_out", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.voltage_out, + ), + TechnoVESensorEntityDescription( + key="max_current", + translation_key="max_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.max_current, + ), + TechnoVESensorEntityDescription( + key="max_station_current", + translation_key="max_station_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.max_station_current, + ), + TechnoVESensorEntityDescription( + key="current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.current, + ), + TechnoVESensorEntityDescription( + key="energy_total", + translation_key="energy_total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.energy_total, + ), + TechnoVESensorEntityDescription( + key="energy_session", + translation_key="energy_session", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.energy_session, + ), + TechnoVESensorEntityDescription( + key="rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda station: station.info.rssi, + ), + TechnoVESensorEntityDescription( + key="ssid", + translation_key="ssid", + icon="mdi:wifi", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda station: station.info.network_ssid, + ), + TechnoVESensorEntityDescription( + key="status", + translation_key="status", + device_class=SensorDeviceClass.ENUM, + options=STATUS_TYPE, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.status.value, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + coordinator: TechnoVEDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TechnoVESensorEntity(coordinator, description) for description in SENSORS + ) + + +class TechnoVESensorEntity(TechnoVEEntity, SensorEntity): + """Defines a TechnoVE sensor entity.""" + + entity_description: TechnoVESensorEntityDescription + + def __init__( + self, + coordinator: TechnoVEDataUpdateCoordinator, + description: TechnoVESensorEntityDescription, + ) -> None: + """Initialize a TechnoVE sensor entity.""" + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/technove/strings.json b/homeassistant/components/technove/strings.json new file mode 100644 index 00000000000000..98813fd3cc8da0 --- /dev/null +++ b/homeassistant/components/technove/strings.json @@ -0,0 +1,56 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "description": "Set up your TechnoVE station to integrate with Home Assistant.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your TechnoVE station." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "entity": { + "sensor": { + "voltage_in": { + "name": "Input voltage" + }, + "voltage_out": { + "name": "Output voltage" + }, + "max_current": { + "name": "Max current" + }, + "max_station_current": { + "name": "Max station current" + }, + "energy_total": { + "name": "Total energy usage" + }, + "energy_session": { + "name": "Last session energy usage" + }, + "ssid": { + "name": "Wi-Fi network name" + }, + "status": { + "name": "Status", + "state": { + "unplugged": "Unplugged", + "plugged_waiting": "Plugged, waiting", + "plugged_charging": "Plugged, charging" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c62203b4d6cd9c..1951f49833a569 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -504,6 +504,7 @@ "tankerkoenig", "tasmota", "tautulli", + "technove", "tedee", "tellduslive", "tesla_wall_connector", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 49527ba6dd05b2..09e4f42434cff5 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5814,6 +5814,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "technove": { + "name": "TechnoVE", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "ted5000": { "name": "The Energy Detective TED5000", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 87829af666bfea..dc5ce5af4850ea 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3792,6 +3792,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.technove.*] +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.tedee.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 5d0fd2f36d9e70..0d7ec7c78ad8c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2255,6 +2255,9 @@ python-songpal==0.16 # homeassistant.components.tado python-tado==0.17.3 +# homeassistant.components.technove +python-technove==1.1.1 + # homeassistant.components.telegram_bot python-telegram-bot==13.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7fbc37f8323862..f26e2cd2140cd2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1713,6 +1713,9 @@ python-songpal==0.16 # homeassistant.components.tado python-tado==0.17.3 +# homeassistant.components.technove +python-technove==1.1.1 + # homeassistant.components.telegram_bot python-telegram-bot==13.1 diff --git a/tests/components/technove/__init__.py b/tests/components/technove/__init__.py new file mode 100644 index 00000000000000..e98470b8e2ad90 --- /dev/null +++ b/tests/components/technove/__init__.py @@ -0,0 +1 @@ +"""Tests for the TechnoVE integration.""" diff --git a/tests/components/technove/conftest.py b/tests/components/technove/conftest.py new file mode 100644 index 00000000000000..03ee9fd96635be --- /dev/null +++ b/tests/components/technove/conftest.py @@ -0,0 +1,66 @@ +"""Fixtures for TechnoVE integration tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from technove import Station as TechnoVEStation + +from homeassistant.components.technove.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.123"}, + unique_id="AA:AA:AA:AA:AA:BB", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.technove.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def device_fixture() -> TechnoVEStation: + """Return the device fixture for a specific device.""" + return TechnoVEStation(load_json_object_fixture("station_charging.json", DOMAIN)) + + +@pytest.fixture +def mock_technove(device_fixture: TechnoVEStation) -> Generator[MagicMock, None, None]: + """Return a mocked TechnoVE client.""" + with patch( + "homeassistant.components.technove.coordinator.TechnoVE", autospec=True + ) as technove_mock, patch( + "homeassistant.components.technove.config_flow.TechnoVE", new=technove_mock + ): + technove = technove_mock.return_value + technove.update.return_value = device_fixture + technove.ip_address = "127.0.0.1" + yield technove + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_technove: MagicMock, +) -> MockConfigEntry: + """Set up the TechnoVE 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/technove/fixtures/station_charging.json b/tests/components/technove/fixtures/station_charging.json new file mode 100644 index 00000000000000..ea98dc0b071e44 --- /dev/null +++ b/tests/components/technove/fixtures/station_charging.json @@ -0,0 +1,27 @@ +{ + "voltageIn": 238, + "voltageOut": 238, + "maxStationCurrent": 32, + "maxCurrent": 24, + "current": 23.75, + "network_ssid": "Connecting...", + "id": "AA:AA:AA:AA:AA:BB", + "auto_charge": true, + "highChargePeriodActive": false, + "normalPeriodActive": false, + "maxChargePourcentage": 0.9, + "isBatteryProtected": false, + "inSharingMode": true, + "energySession": 12.34, + "energyTotal": 1234, + "version": "1.82", + "rssi": -82, + "name": "TechnoVE Station", + "lastCharge": "1701072080,0,17.39\n", + "time": 1701000000, + "isUpToDate": true, + "isSessionActive": true, + "conflictInSharingConfig": false, + "isStaticIp": false, + "status": 67 +} diff --git a/tests/components/technove/snapshots/test_sensor.ambr b/tests/components/technove/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..e0549c1dad184a --- /dev/null +++ b/tests/components/technove/snapshots/test_sensor.ambr @@ -0,0 +1,635 @@ +# serializer version: 1 +# name: test_sensors[sensor.technove_station_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:AA:AA:AA:AA:BB_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.technove_station_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'TechnoVE Station Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_current', + 'last_changed': , + 'last_updated': , + 'state': '23.75', + }) +# --- +# name: test_sensors[sensor.technove_station_current] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'TechnoVE Station Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_current', + 'last_changed': , + 'last_updated': , + 'state': '23.75', + }) +# --- +# name: test_sensors[sensor.technove_station_input_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_input_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input voltage', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_in', + 'unique_id': 'AA:AA:AA:AA:AA:BB_voltage_in', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.technove_station_input_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'TechnoVE Station Input voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_input_voltage', + 'last_changed': , + 'last_updated': , + 'state': '238', + }) +# --- +# name: test_sensors[sensor.technove_station_input_voltage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'TechnoVE Station Input voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_input_voltage', + 'last_changed': , + 'last_updated': , + 'state': '238', + }) +# --- +# name: test_sensors[sensor.technove_station_last_session_energy_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_last_session_energy_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last session energy usage', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_session', + 'unique_id': 'AA:AA:AA:AA:AA:BB_energy_session', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.technove_station_last_session_energy_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'TechnoVE Station Last session energy usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_last_session_energy_usage', + 'last_changed': , + 'last_updated': , + 'state': '12.34', + }) +# --- +# name: test_sensors[sensor.technove_station_last_session_energy_usage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'TechnoVE Station Last session energy usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_last_session_energy_usage', + 'last_changed': , + 'last_updated': , + 'state': '12.34', + }) +# --- +# name: test_sensors[sensor.technove_station_max_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_max_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max current', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'max_current', + 'unique_id': 'AA:AA:AA:AA:AA:BB_max_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.technove_station_max_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'TechnoVE Station Max current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_max_current', + 'last_changed': , + 'last_updated': , + 'state': '24', + }) +# --- +# name: test_sensors[sensor.technove_station_max_current] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'TechnoVE Station Max current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_max_current', + 'last_changed': , + 'last_updated': , + 'state': '24', + }) +# --- +# name: test_sensors[sensor.technove_station_max_station_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_max_station_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max station current', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'max_station_current', + 'unique_id': 'AA:AA:AA:AA:AA:BB_max_station_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.technove_station_max_station_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'TechnoVE Station Max station current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_max_station_current', + 'last_changed': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_sensors[sensor.technove_station_max_station_current] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'TechnoVE Station Max station current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_max_station_current', + 'last_changed': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_sensors[sensor.technove_station_output_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_output_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output voltage', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_out', + 'unique_id': 'AA:AA:AA:AA:AA:BB_voltage_out', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.technove_station_output_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'TechnoVE Station Output voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_output_voltage', + 'last_changed': , + 'last_updated': , + 'state': '238', + }) +# --- +# name: test_sensors[sensor.technove_station_output_voltage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'TechnoVE Station Output voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_output_voltage', + 'last_changed': , + 'last_updated': , + 'state': '238', + }) +# --- +# name: test_sensors[sensor.technove_station_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:AA:AA:AA:AA:BB_rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_sensors[sensor.technove_station_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'TechnoVE Station Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.technove_station_signal_strength', + 'last_changed': , + 'last_updated': , + 'state': '-82', + }) +# --- +# name: test_sensors[sensor.technove_station_signal_strength] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'TechnoVE Station Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.technove_station_signal_strength', + 'last_changed': , + 'last_updated': , + 'state': '-82', + }) +# --- +# name: test_sensors[sensor.technove_station_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged_waiting', + 'plugged_charging', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'AA:AA:AA:AA:AA:BB_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.technove_station_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'TechnoVE Station Status', + 'options': list([ + 'unplugged', + 'plugged_waiting', + 'plugged_charging', + ]), + }), + 'context': , + 'entity_id': 'sensor.technove_station_status', + 'last_changed': , + 'last_updated': , + 'state': 'plugged_charging', + }) +# --- +# name: test_sensors[sensor.technove_station_status] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'TechnoVE Station Status', + 'options': list([ + 'unplugged', + 'plugged_waiting', + 'plugged_charging', + ]), + }), + 'context': , + 'entity_id': 'sensor.technove_station_status', + 'last_changed': , + 'last_updated': , + 'state': 'plugged_charging', + }) +# --- +# name: test_sensors[sensor.technove_station_total_energy_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_total_energy_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy usage', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_total', + 'unique_id': 'AA:AA:AA:AA:AA:BB_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.technove_station_total_energy_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'TechnoVE Station Total energy usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_total_energy_usage', + 'last_changed': , + 'last_updated': , + 'state': '1234', + }) +# --- +# name: test_sensors[sensor.technove_station_total_energy_usage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'TechnoVE Station Total energy usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_total_energy_usage', + 'last_changed': , + 'last_updated': , + 'state': '1234', + }) +# --- +# name: test_sensors[sensor.technove_station_wi_fi_network_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_wi_fi_network_name', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi network name', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ssid', + 'unique_id': 'AA:AA:AA:AA:AA:BB_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.technove_station_wi_fi_network_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TechnoVE Station Wi-Fi network name', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'sensor.technove_station_wi_fi_network_name', + 'last_changed': , + 'last_updated': , + 'state': 'Connecting...', + }) +# --- +# name: test_sensors[sensor.technove_station_wi_fi_network_name] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TechnoVE Station Wi-Fi network name', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'sensor.technove_station_wi_fi_network_name', + 'last_changed': , + 'last_updated': , + 'state': 'Connecting...', + }) +# --- diff --git a/tests/components/technove/test_config_flow.py b/tests/components/technove/test_config_flow.py new file mode 100644 index 00000000000000..7a631580ff40da --- /dev/null +++ b/tests/components/technove/test_config_flow.py @@ -0,0 +1,104 @@ +"""Tests for the TechnoVE config flow.""" + +from unittest.mock import MagicMock + +import pytest +from technove import TechnoVEConnectionError + +from homeassistant.components.technove.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_technove") +async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: + """Test the full manual user flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result.get("step_id") == "user" + assert result.get("type") == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "192.168.1.123"} + ) + + assert result.get("title") == "TechnoVE Station" + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_HOST] == "192.168.1.123" + assert "result" in result + assert result["result"].unique_id == "AA:AA:AA:AA:AA:BB" + + +@pytest.mark.usefixtures("mock_technove") +async def test_user_device_exists_abort( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_technove: MagicMock, +) -> None: + """Test we abort the config flow if TechnoVE station is already configured.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: "192.168.1.123"}, + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +async def test_connection_error(hass: HomeAssistant, mock_technove: MagicMock) -> None: + """Test we show user form on TechnoVE connection error.""" + mock_technove.update.side_effect = TechnoVEConnectionError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: "example.com"}, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" + assert result.get("errors") == {"base": "cannot_connect"} + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_technove") +async def test_full_user_flow_with_error( + hass: HomeAssistant, mock_technove: MagicMock +) -> None: + """Test the full manual user flow from start to finish with some errors in the middle.""" + mock_technove.update.side_effect = TechnoVEConnectionError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result.get("step_id") == "user" + assert result.get("type") == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "192.168.1.123"} + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" + assert result.get("errors") == {"base": "cannot_connect"} + + mock_technove.update.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "192.168.1.123"} + ) + + assert result.get("title") == "TechnoVE Station" + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_HOST] == "192.168.1.123" + assert "result" in result + assert result["result"].unique_id == "AA:AA:AA:AA:AA:BB" diff --git a/tests/components/technove/test_init.py b/tests/components/technove/test_init.py new file mode 100644 index 00000000000000..0b5d68e405d77a --- /dev/null +++ b/tests/components/technove/test_init.py @@ -0,0 +1,36 @@ +"""Tests for the TechnoVE integration.""" + +from unittest.mock import MagicMock + +from technove import TechnoVEConnectionError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_async_setup_entry( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test a successful setup entry and unload.""" + + init_integration.add_to_hass(hass) + assert init_integration.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(init_integration.entry_id) + await hass.async_block_till_done() + assert init_integration.state is ConfigEntryState.NOT_LOADED + + +async def test_async_setup_connection_error( + hass: HomeAssistant, + mock_technove: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a connection error after setup.""" + mock_technove.update.side_effect = TechnoVEConnectionError + 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 diff --git a/tests/components/technove/test_sensor.py b/tests/components/technove/test_sensor.py new file mode 100644 index 00000000000000..d7010b9451c49c --- /dev/null +++ b/tests/components/technove/test_sensor.py @@ -0,0 +1,97 @@ +"""Tests for the TechnoVE sensor platform.""" +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion +from technove import Status, TechnoVEError + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_technove") +async def test_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the creation and values of the TechnoVE sensors.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + entity_registry = er.async_get(hass) + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=f"{entity_entry.entity_id}-state" + ) + + +@pytest.mark.parametrize( + "entity_id", + ( + "sensor.technove_station_signal_strength", + "sensor.technove_station_wi_fi_network_name", + ), +) +@pytest.mark.usefixtures("init_integration") +async def test_disabled_by_default_sensors( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_id: str +) -> None: + """Test the disabled by default TechnoVE sensors.""" + assert hass.states.get(entity_id) is None + + assert (entry := entity_registry.async_get(entity_id)) + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_no_wifi_support( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_technove: MagicMock, +) -> None: + """Test missing Wi-Fi information from TechnoVE device.""" + # Remove Wi-Fi info + device = mock_technove.update.return_value + device.info.network_ssid = None + + # Setup + 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 (state := hass.states.get("sensor.technove_station_wi_fi_network_name")) + assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("init_integration") +async def test_sensor_update_failure( + hass: HomeAssistant, + mock_technove: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test coordinator update failure.""" + entity_id = "sensor.technove_station_status" + + assert hass.states.get(entity_id).state == Status.PLUGGED_CHARGING.value + + freezer.tick(timedelta(minutes=5, seconds=1)) + async_fire_time_changed(hass) + mock_technove.update.side_effect = TechnoVEError("Test error") + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE