mirror of
https://github.com/home-assistant/core.git
synced 2025-04-24 01:08:12 +00:00
Add Ohme integration (#132574)
This commit is contained in:
parent
980b8a91e6
commit
9e2a3ea0e5
@ -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
|
||||
|
65
homeassistant/components/ohme/__init__.py
Normal file
65
homeassistant/components/ohme/__init__.py
Normal 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)
|
64
homeassistant/components/ohme/config_flow.py
Normal file
64
homeassistant/components/ohme/config_flow.py
Normal 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
|
||||
)
|
6
homeassistant/components/ohme/const.py
Normal file
6
homeassistant/components/ohme/const.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""Component constants."""
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "ohme"
|
||||
PLATFORMS = [Platform.SENSOR]
|
68
homeassistant/components/ohme/coordinator.py
Normal file
68
homeassistant/components/ohme/coordinator.py
Normal 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()
|
42
homeassistant/components/ohme/entity.py
Normal file
42
homeassistant/components/ohme/entity.py
Normal 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
|
18
homeassistant/components/ohme/icons.json
Normal file
18
homeassistant/components/ohme/icons.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
11
homeassistant/components/ohme/manifest.json
Normal file
11
homeassistant/components/ohme/manifest.json
Normal 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"]
|
||||
}
|
83
homeassistant/components/ohme/quality_scale.yaml
Normal file
83
homeassistant/components/ohme/quality_scale.yaml
Normal 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
|
107
homeassistant/components/ohme/sensor.py
Normal file
107
homeassistant/components/ohme/sensor.py
Normal 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)
|
51
homeassistant/components/ohme/strings.json
Normal file
51
homeassistant/components/ohme/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
@ -423,6 +423,7 @@ FLOWS = {
|
||||
"nzbget",
|
||||
"obihai",
|
||||
"octoprint",
|
||||
"ohme",
|
||||
"ollama",
|
||||
"omnilogic",
|
||||
"oncue",
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
14
tests/components/ohme/__init__.py
Normal file
14
tests/components/ohme/__init__.py
Normal 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()
|
64
tests/components/ohme/conftest.py
Normal file
64
tests/components/ohme/conftest.py
Normal 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
|
33
tests/components/ohme/snapshots/test_init.ambr
Normal file
33
tests/components/ohme/snapshots/test_init.ambr
Normal 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,
|
||||
})
|
||||
# ---
|
268
tests/components/ohme/snapshots/test_sensor.ambr
Normal file
268
tests/components/ohme/snapshots/test_sensor.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
110
tests/components/ohme/test_config_flow.py
Normal file
110
tests/components/ohme/test_config_flow.py
Normal 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"
|
47
tests/components/ohme/test_init.py
Normal file
47
tests/components/ohme/test_init.py
Normal 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
|
59
tests/components/ohme/test_sensor.py
Normal file
59
tests/components/ohme/test_sensor.py
Normal 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"
|
Loading…
x
Reference in New Issue
Block a user