Implement TechnoVE integration (#106029)

* Implement TechnoVE integration

Only the basic sensors for now.

* Add technoVE to strict typing

* Implement TechnoVE PR suggestions

* Remove Diagnostic from TechnoVE initial PR

* Switch status sensor to Enum device class

* Revert zeroconf for adding it back in subsequent PR

* Implement changes from feedback in TechnoVE PR

* Update homeassistant/components/technove/models.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/technove/sensor.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/technove/models.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Remove unnecessary translation keys

* Fix existing technoVE tests

* Use snapshot testing for TechnoVE sensors

* Improve unit tests for TechnoVE

* Add missing coverage for technoVE config flow

* Add TechnoVE coordinator tests

* Modify device_fixture for TechnoVE from PR Feedback

* Change CONF_IP_ADDRESS to CONF_HOST for TechnoVE

* Update homeassistant/components/technove/config_flow.py

Co-authored-by: Sid <27780930+autinerd@users.noreply.github.com>

* Update homeassistant/components/technove/models.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/technove/models.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Implement feedback from TechnoVE PR

* Add test_sensor_update_failure to TechnoVE sensor tests

* Add test for error recovery during config flow of TechnoVE

* Remove test_coordinator.py from TechnoVE

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Sid <27780930+autinerd@users.noreply.github.com>
This commit is contained in:
Christophe Gagnier 2024-01-17 05:04:35 -05:00 committed by GitHub
parent a8b67d5a0a
commit 44f2b8e6a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1374 additions and 0 deletions

View File

@ -404,6 +404,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.*

View File

@ -1328,6 +1328,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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -508,6 +508,7 @@ FLOWS = {
"tankerkoenig",
"tasmota",
"tautulli",
"technove",
"tedee",
"tellduslive",
"tesla_wall_connector",

View File

@ -5837,6 +5837,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",

View File

@ -3802,6 +3802,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

View File

@ -2264,6 +2264,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

View File

@ -1725,6 +1725,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

View File

@ -0,0 +1 @@
"""Tests for the TechnoVE integration."""

View File

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

View File

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

View File

@ -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': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.technove_station_current',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.CURRENT: 'current'>,
'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': <UnitOfElectricCurrent.AMPERE: 'A'>,
})
# ---
# name: test_sensors[sensor.technove_station_current-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'current',
'friendly_name': 'TechnoVE Station Current',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
}),
'context': <ANY>,
'entity_id': 'sensor.technove_station_current',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '23.75',
})
# ---
# name: test_sensors[sensor.technove_station_current]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'current',
'friendly_name': 'TechnoVE Station Current',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
}),
'context': <ANY>,
'entity_id': 'sensor.technove_station_current',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '23.75',
})
# ---
# name: test_sensors[sensor.technove_station_input_voltage-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.technove_station_input_voltage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.VOLTAGE: 'voltage'>,
'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': <UnitOfElectricPotential.VOLT: 'V'>,
})
# ---
# name: test_sensors[sensor.technove_station_input_voltage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'voltage',
'friendly_name': 'TechnoVE Station Input voltage',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
}),
'context': <ANY>,
'entity_id': 'sensor.technove_station_input_voltage',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '238',
})
# ---
# name: test_sensors[sensor.technove_station_input_voltage]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'voltage',
'friendly_name': 'TechnoVE Station Input voltage',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
}),
'context': <ANY>,
'entity_id': 'sensor.technove_station_input_voltage',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '238',
})
# ---
# name: test_sensors[sensor.technove_station_last_session_energy_usage-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.technove_station_last_session_energy_usage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'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': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# 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': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.technove_station_last_session_energy_usage',
'last_changed': <ANY>,
'last_updated': <ANY>,
'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': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.technove_station_last_session_energy_usage',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '12.34',
})
# ---
# name: test_sensors[sensor.technove_station_max_current-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.technove_station_max_current',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.CURRENT: 'current'>,
'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': <UnitOfElectricCurrent.AMPERE: 'A'>,
})
# ---
# name: test_sensors[sensor.technove_station_max_current-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'current',
'friendly_name': 'TechnoVE Station Max current',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
}),
'context': <ANY>,
'entity_id': 'sensor.technove_station_max_current',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '24',
})
# ---
# name: test_sensors[sensor.technove_station_max_current]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'current',
'friendly_name': 'TechnoVE Station Max current',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
}),
'context': <ANY>,
'entity_id': 'sensor.technove_station_max_current',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '24',
})
# ---
# name: test_sensors[sensor.technove_station_max_station_current-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.technove_station_max_station_current',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.CURRENT: 'current'>,
'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': <UnitOfElectricCurrent.AMPERE: 'A'>,
})
# ---
# 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': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
}),
'context': <ANY>,
'entity_id': 'sensor.technove_station_max_station_current',
'last_changed': <ANY>,
'last_updated': <ANY>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
}),
'context': <ANY>,
'entity_id': 'sensor.technove_station_max_station_current',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '32',
})
# ---
# name: test_sensors[sensor.technove_station_output_voltage-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.technove_station_output_voltage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.VOLTAGE: 'voltage'>,
'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': <UnitOfElectricPotential.VOLT: 'V'>,
})
# ---
# name: test_sensors[sensor.technove_station_output_voltage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'voltage',
'friendly_name': 'TechnoVE Station Output voltage',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
}),
'context': <ANY>,
'entity_id': 'sensor.technove_station_output_voltage',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '238',
})
# ---
# name: test_sensors[sensor.technove_station_output_voltage]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'voltage',
'friendly_name': 'TechnoVE Station Output voltage',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
}),
'context': <ANY>,
'entity_id': 'sensor.technove_station_output_voltage',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '238',
})
# ---
# name: test_sensors[sensor.technove_station_signal_strength-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.technove_station_signal_strength',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.SIGNAL_STRENGTH: 'signal_strength'>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'dBm',
}),
'context': <ANY>,
'entity_id': 'sensor.technove_station_signal_strength',
'last_changed': <ANY>,
'last_updated': <ANY>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'dBm',
}),
'context': <ANY>,
'entity_id': 'sensor.technove_station_signal_strength',
'last_changed': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.technove_station_status',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'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': <ANY>,
'entity_id': 'sensor.technove_station_status',
'last_changed': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'entity_id': 'sensor.technove_station_status',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': 'plugged_charging',
})
# ---
# name: test_sensors[sensor.technove_station_total_energy_usage-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.technove_station_total_energy_usage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'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': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# 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': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.technove_station_total_energy_usage',
'last_changed': <ANY>,
'last_updated': <ANY>,
'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': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.technove_station_total_energy_usage',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '1234',
})
# ---
# name: test_sensors[sensor.technove_station_wi_fi_network_name-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.technove_station_wi_fi_network_name',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'sensor.technove_station_wi_fi_network_name',
'last_changed': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'entity_id': 'sensor.technove_station_wi_fi_network_name',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': 'Connecting...',
})
# ---

View File

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

View File

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

View File

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