Skip to content

Commit

Permalink
Add Ohme integration (#132574)
Browse files Browse the repository at this point in the history
  • Loading branch information
dan-r authored Dec 14, 2024
1 parent 980b8a9 commit 9e2a3ea
Show file tree
Hide file tree
Showing 22 changed files with 1,125 additions and 0 deletions.
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions homeassistant/components/ohme/__init__.py
Original file line number Diff line number Diff line change
@@ -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 changes: 64 additions & 0 deletions homeassistant/components/ohme/config_flow.py
Original file line number Diff line number Diff line change
@@ -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 changes: 6 additions & 0 deletions homeassistant/components/ohme/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Component constants."""

from homeassistant.const import Platform

DOMAIN = "ohme"
PLATFORMS = [Platform.SENSOR]
68 changes: 68 additions & 0 deletions homeassistant/components/ohme/coordinator.py
Original file line number Diff line number Diff line change
@@ -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 changes: 42 additions & 0 deletions homeassistant/components/ohme/entity.py
Original file line number Diff line number Diff line change
@@ -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 changes: 18 additions & 0 deletions homeassistant/components/ohme/icons.json
Original file line number Diff line number Diff line change
@@ -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 changes: 11 additions & 0 deletions homeassistant/components/ohme/manifest.json
Original file line number Diff line number Diff line change
@@ -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 changes: 83 additions & 0 deletions homeassistant/components/ohme/quality_scale.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 9e2a3ea

Please sign in to comment.