Add Ohme integration (#132574)

This commit is contained in:
Dan Raper 2024-12-14 17:12:44 +00:00 committed by GitHub
parent 980b8a91e6
commit 9e2a3ea0e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1125 additions and 0 deletions

View File

@ -1053,6 +1053,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/octoprint/ @rfleming71
/tests/components/octoprint/ @rfleming71
/homeassistant/components/ohmconnect/ @robbiet480
/homeassistant/components/ohme/ @dan-r
/tests/components/ohme/ @dan-r
/homeassistant/components/ollama/ @synesthesiam
/tests/components/ollama/ @synesthesiam
/homeassistant/components/ombi/ @larssont

View File

@ -0,0 +1,65 @@
"""Set up ohme integration."""
from dataclasses import dataclass
from ohme import ApiException, AuthException, OhmeApiClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from .const import DOMAIN, PLATFORMS
from .coordinator import OhmeAdvancedSettingsCoordinator, OhmeChargeSessionCoordinator
type OhmeConfigEntry = ConfigEntry[OhmeRuntimeData]
@dataclass()
class OhmeRuntimeData:
"""Dataclass to hold ohme coordinators."""
charge_session_coordinator: OhmeChargeSessionCoordinator
advanced_settings_coordinator: OhmeAdvancedSettingsCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: OhmeConfigEntry) -> bool:
"""Set up Ohme from a config entry."""
client = OhmeApiClient(entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD])
try:
await client.async_login()
if not await client.async_update_device_info():
raise ConfigEntryNotReady(
translation_key="device_info_failed", translation_domain=DOMAIN
)
except AuthException as e:
raise ConfigEntryError(
translation_key="auth_failed", translation_domain=DOMAIN
) from e
except ApiException as e:
raise ConfigEntryNotReady(
translation_key="api_failed", translation_domain=DOMAIN
) from e
coordinators = (
OhmeChargeSessionCoordinator(hass, client),
OhmeAdvancedSettingsCoordinator(hass, client),
)
for coordinator in coordinators:
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = OhmeRuntimeData(*coordinators)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: OhmeConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -0,0 +1,64 @@
"""Config flow for ohme integration."""
from typing import Any
from ohme import ApiException, AuthException, OhmeApiClient
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import DOMAIN
USER_SCHEMA = vol.Schema(
{
vol.Required(CONF_EMAIL): TextSelector(
TextSelectorConfig(
type=TextSelectorType.EMAIL,
autocomplete="email",
),
),
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
autocomplete="current-password",
),
),
}
)
class OhmeConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""First config step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]})
instance = OhmeApiClient(user_input[CONF_EMAIL], user_input[CONF_PASSWORD])
try:
await instance.async_login()
except AuthException:
errors["base"] = "invalid_auth"
except ApiException:
errors["base"] = "unknown"
if not errors:
return self.async_create_entry(
title=user_input[CONF_EMAIL], data=user_input
)
return self.async_show_form(
step_id="user", data_schema=USER_SCHEMA, errors=errors
)

View File

@ -0,0 +1,6 @@
"""Component constants."""
from homeassistant.const import Platform
DOMAIN = "ohme"
PLATFORMS = [Platform.SENSOR]

View File

@ -0,0 +1,68 @@
"""Ohme coordinators."""
from abc import abstractmethod
from datetime import timedelta
import logging
from ohme import ApiException, OhmeApiClient
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class OhmeBaseCoordinator(DataUpdateCoordinator[None]):
"""Base for all Ohme coordinators."""
client: OhmeApiClient
_default_update_interval: timedelta | None = timedelta(minutes=1)
coordinator_name: str = ""
def __init__(self, hass: HomeAssistant, client: OhmeApiClient) -> None:
"""Initialise coordinator."""
super().__init__(
hass,
_LOGGER,
name="",
update_interval=self._default_update_interval,
)
self.name = f"Ohme {self.coordinator_name}"
self.client = client
async def _async_update_data(self) -> None:
"""Fetch data from API endpoint."""
try:
await self._internal_update_data()
except ApiException as e:
raise UpdateFailed(
translation_key="api_failed", translation_domain=DOMAIN
) from e
@abstractmethod
async def _internal_update_data(self) -> None:
"""Update coordinator data."""
class OhmeChargeSessionCoordinator(OhmeBaseCoordinator):
"""Coordinator to pull all updates from the API."""
coordinator_name = "Charge Sessions"
_default_update_interval = timedelta(seconds=30)
async def _internal_update_data(self):
"""Fetch data from API endpoint."""
await self.client.async_get_charge_session()
class OhmeAdvancedSettingsCoordinator(OhmeBaseCoordinator):
"""Coordinator to pull settings and charger state from the API."""
coordinator_name = "Advanced Settings"
async def _internal_update_data(self):
"""Fetch data from API endpoint."""
await self.client.async_get_advanced_settings()

View File

@ -0,0 +1,42 @@
"""Base class for entities."""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import OhmeBaseCoordinator
class OhmeEntity(CoordinatorEntity[OhmeBaseCoordinator]):
"""Base class for all Ohme entities."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: OhmeBaseCoordinator,
entity_description: EntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self.entity_description = entity_description
client = coordinator.client
self._attr_unique_id = f"{client.serial}_{entity_description.key}"
device_info = client.device_info
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, client.serial)},
name=device_info["name"],
manufacturer="Ohme",
model=device_info["model"],
sw_version=device_info["sw_version"],
serial_number=client.serial,
)
@property
def available(self) -> bool:
"""Return if charger reporting as online."""
return super().available and self.coordinator.client.available

View File

@ -0,0 +1,18 @@
{
"entity": {
"sensor": {
"status": {
"default": "mdi:car",
"state": {
"unplugged": "mdi:power-plug-off",
"plugged_in": "mdi:power-plug",
"charging": "mdi:battery-charging-100",
"pending_approval": "mdi:alert-decagram"
}
},
"ct_current": {
"default": "mdi:gauge"
}
}
}
}

View File

@ -0,0 +1,11 @@
{
"domain": "ohme",
"name": "Ohme",
"codeowners": ["@dan-r"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ohme/",
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["ohme==1.1.1"]
}

View File

@ -0,0 +1,83 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration has no custom actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration has no custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
This integration has no explicit subscriptions to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
This integration has no custom actions and read-only platform only.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: |
This integration has no options flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery:
status: exempt
comment: |
All supported devices are cloud connected over mobile data. Discovery is not possible.
discovery-update-info:
status: exempt
comment: |
All supported devices are cloud connected over mobile data. Discovery is not possible.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: done
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
This integration currently has no repairs.
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo

View File

@ -0,0 +1,107 @@
"""Platform for sensor."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from ohme import ChargerStatus, OhmeApiClient
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfElectricCurrent, UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OhmeConfigEntry
from .entity import OhmeEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class OhmeSensorDescription(SensorEntityDescription):
"""Class describing Ohme sensor entities."""
value_fn: Callable[[OhmeApiClient], str | int | float]
is_supported_fn: Callable[[OhmeApiClient], bool] = lambda _: True
SENSOR_CHARGE_SESSION = [
OhmeSensorDescription(
key="status",
translation_key="status",
device_class=SensorDeviceClass.ENUM,
options=[e.value for e in ChargerStatus],
value_fn=lambda client: client.status.value,
),
OhmeSensorDescription(
key="current",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
value_fn=lambda client: client.power.amps,
),
OhmeSensorDescription(
key="power",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=1,
value_fn=lambda client: client.power.watts,
),
OhmeSensorDescription(
key="energy",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda client: client.energy,
),
]
SENSOR_ADVANCED_SETTINGS = [
OhmeSensorDescription(
key="ct_current",
translation_key="ct_current",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
value_fn=lambda client: client.power.ct_amps,
is_supported_fn=lambda client: client.ct_connected,
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OhmeConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensors."""
coordinators = config_entry.runtime_data
coordinator_map = [
(SENSOR_CHARGE_SESSION, coordinators.charge_session_coordinator),
(SENSOR_ADVANCED_SETTINGS, coordinators.advanced_settings_coordinator),
]
async_add_entities(
OhmeSensor(coordinator, description)
for entities, coordinator in coordinator_map
for description in entities
if description.is_supported_fn(coordinator.client)
)
class OhmeSensor(OhmeEntity, SensorEntity):
"""Generic sensor for Ohme."""
entity_description: OhmeSensorDescription
@property
def native_value(self) -> str | int | float:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.client)

View File

@ -0,0 +1,51 @@
{
"config": {
"step": {
"user": {
"description": "Configure your Ohme account. If you signed up to Ohme with a third party account like Google, please reset your password via Ohme before configuring this integration.",
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"email": "Enter the email address associated with your Ohme account.",
"password": "Enter the password for your Ohme account"
}
}
},
"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": {
"status": {
"name": "Status",
"state": {
"unplugged": "Unplugged",
"plugged_in": "Plugged in",
"charging": "Charging",
"pending_approval": "Pending approval"
}
},
"ct_current": {
"name": "CT current"
}
}
},
"exceptions": {
"auth_failed": {
"message": "Unable to login to Ohme"
},
"device_info_failed": {
"message": "Unable to get Ohme device information"
},
"api_failed": {
"message": "Error communicating with Ohme API"
}
}
}

View File

@ -423,6 +423,7 @@ FLOWS = {
"nzbget",
"obihai",
"octoprint",
"ohme",
"ollama",
"omnilogic",
"oncue",

View File

@ -4329,6 +4329,12 @@
"config_flow": false,
"iot_class": "cloud_polling"
},
"ohme": {
"name": "Ohme",
"integration_type": "device",
"config_flow": true,
"iot_class": "cloud_polling"
},
"ollama": {
"name": "Ollama",
"integration_type": "service",

View File

@ -1522,6 +1522,9 @@ odp-amsterdam==6.0.2
# homeassistant.components.oem
oemthermostat==1.1.1
# homeassistant.components.ohme
ohme==1.1.1
# homeassistant.components.ollama
ollama==0.3.3

View File

@ -1270,6 +1270,9 @@ objgraph==3.5.0
# homeassistant.components.garages_amsterdam
odp-amsterdam==6.0.2
# homeassistant.components.ohme
ohme==1.1.1
# homeassistant.components.ollama
ollama==0.3.3

View File

@ -0,0 +1,14 @@
"""Tests for the Ohme integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Set up the Ohme 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()

View File

@ -0,0 +1,64 @@
"""Provide common fixtures."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
from ohme import ChargerPower, ChargerStatus
import pytest
from homeassistant.components.ohme.const import DOMAIN
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.ohme.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
title="test@example.com",
domain=DOMAIN,
version=1,
data={
CONF_EMAIL: "test@example.com",
CONF_PASSWORD: "hunter2",
},
)
@pytest.fixture
def mock_client():
"""Fixture to mock the OhmeApiClient."""
with (
patch(
"homeassistant.components.ohme.config_flow.OhmeApiClient",
autospec=True,
) as client,
patch(
"homeassistant.components.ohme.OhmeApiClient",
new=client,
),
):
client = client.return_value
client.async_login.return_value = True
client.status = ChargerStatus.CHARGING
client.power = ChargerPower(0, 0, 0, 0)
client.serial = "chargerid"
client.ct_connected = True
client.energy = 1000
client.device_info = {
"name": "Ohme Home Pro",
"model": "Home Pro",
"sw_version": "v2.65",
}
yield client

View File

@ -0,0 +1,33 @@
# serializer version: 1
# name: test_device
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'ohme',
'chargerid',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Ohme',
'model': 'Home Pro',
'model_id': None,
'name': 'Ohme Home Pro',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': 'chargerid',
'suggested_area': None,
'sw_version': 'v2.65',
'via_device_id': None,
})
# ---

View File

@ -0,0 +1,268 @@
# serializer version: 1
# name: test_sensors[sensor.ohme_home_pro_ct_current-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': None,
'entity_id': 'sensor.ohme_home_pro_ct_current',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.CURRENT: 'current'>,
'original_icon': None,
'original_name': 'CT current',
'platform': 'ohme',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'ct_current',
'unique_id': 'chargerid_ct_current',
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
})
# ---
# name: test_sensors[sensor.ohme_home_pro_ct_current-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'current',
'friendly_name': 'Ohme Home Pro CT current',
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
}),
'context': <ANY>,
'entity_id': 'sensor.ohme_home_pro_ct_current',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_sensors[sensor.ohme_home_pro_current-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': None,
'entity_id': 'sensor.ohme_home_pro_current',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.CURRENT: 'current'>,
'original_icon': None,
'original_name': 'Current',
'platform': 'ohme',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'chargerid_current',
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
})
# ---
# name: test_sensors[sensor.ohme_home_pro_current-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'current',
'friendly_name': 'Ohme Home Pro Current',
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
}),
'context': <ANY>,
'entity_id': 'sensor.ohme_home_pro_current',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_sensors[sensor.ohme_home_pro_energy-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': None,
'entity_id': 'sensor.ohme_home_pro_energy',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
'original_name': 'Energy',
'platform': 'ohme',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'chargerid_energy',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# name: test_sensors[sensor.ohme_home_pro_energy-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'energy',
'friendly_name': 'Ohme Home Pro Energy',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.ohme_home_pro_energy',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1.0',
})
# ---
# name: test_sensors[sensor.ohme_home_pro_power-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': None,
'entity_id': 'sensor.ohme_home_pro_power',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfPower.KILO_WATT: 'kW'>,
}),
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'original_icon': None,
'original_name': 'Power',
'platform': 'ohme',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'chargerid_power',
'unit_of_measurement': <UnitOfPower.KILO_WATT: 'kW'>,
})
# ---
# name: test_sensors[sensor.ohme_home_pro_power-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'power',
'friendly_name': 'Ohme Home Pro Power',
'unit_of_measurement': <UnitOfPower.KILO_WATT: 'kW'>,
}),
'context': <ANY>,
'entity_id': 'sensor.ohme_home_pro_power',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
})
# ---
# name: test_sensors[sensor.ohme_home_pro_status-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'unplugged',
'pending_approval',
'charging',
'plugged_in',
]),
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.ohme_home_pro_status',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Status',
'platform': 'ohme',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'status',
'unique_id': 'chargerid_status',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor.ohme_home_pro_status-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Ohme Home Pro Status',
'options': list([
'unplugged',
'pending_approval',
'charging',
'plugged_in',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.ohme_home_pro_status',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'charging',
})
# ---

View File

@ -0,0 +1,110 @@
"""Tests for the config flow."""
from unittest.mock import AsyncMock, MagicMock
from ohme import ApiException, AuthException
import pytest
from homeassistant.components.ohme.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
async def test_config_flow_success(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_client: MagicMock
) -> None:
"""Test config flow."""
# Initial form load
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert not result["errors"]
# Successful login
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_EMAIL: "test@example.com", CONF_PASSWORD: "hunter2"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "test@example.com"
assert result["data"] == {
CONF_EMAIL: "test@example.com",
CONF_PASSWORD: "hunter2",
}
@pytest.mark.parametrize(
("test_exception", "expected_error"),
[(AuthException, "invalid_auth"), (ApiException, "unknown")],
)
async def test_config_flow_fail(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_client: MagicMock,
test_exception: Exception,
expected_error: str,
) -> None:
"""Test config flow errors."""
# Initial form load
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert not result["errors"]
# Failed login
mock_client.async_login.side_effect = test_exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_EMAIL: "test@example.com", CONF_PASSWORD: "hunter1"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": expected_error}
# End with CREATE_ENTRY
mock_client.async_login.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_EMAIL: "test@example.com", CONF_PASSWORD: "hunter1"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "test@example.com"
assert result["data"] == {
CONF_EMAIL: "test@example.com",
CONF_PASSWORD: "hunter1",
}
async def test_already_configured(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Ensure we can't add the same account twice."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_EMAIL: "test@example.com",
CONF_PASSWORD: "hunter3",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"

View File

@ -0,0 +1,47 @@
"""Test init of Ohme integration."""
from unittest.mock import MagicMock
from syrupy import SnapshotAssertion
from homeassistant.components.ohme.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from . import setup_integration
from tests.common import MockConfigEntry
async def test_load_unload_config_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_client: MagicMock,
) -> None:
"""Test loading and unloading the integration."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
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
async def test_device(
mock_client: MagicMock,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Snapshot the device from registry."""
await setup_integration(hass, mock_config_entry)
device = device_registry.async_get_device({(DOMAIN, mock_client.serial)})
assert device
assert device == snapshot

View File

@ -0,0 +1,59 @@
"""Tests for sensors."""
from datetime import timedelta
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from ohme import ApiException
from syrupy import SnapshotAssertion
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
async def test_sensors(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
mock_client: MagicMock,
) -> None:
"""Test the Ohme sensors."""
with patch("homeassistant.components.ohme.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_sensors_unavailable(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_client: MagicMock,
) -> None:
"""Test that sensors show as unavailable after a coordinator failure."""
await setup_integration(hass, mock_config_entry)
state = hass.states.get("sensor.ohme_home_pro_energy")
assert state.state == "1.0"
mock_client.async_get_charge_session.side_effect = ApiException
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get("sensor.ohme_home_pro_energy")
assert state.state == STATE_UNAVAILABLE
mock_client.async_get_charge_session.side_effect = None
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get("sensor.ohme_home_pro_energy")
assert state.state == "1.0"