Skip to content

Commit

Permalink
Add Viam image processing integration (#101786)
Browse files Browse the repository at this point in the history
* feat: scaffold integration, configure client

* feat: register services, allow API key auth flow

* feat: register detection, classification services

* test(viam): add test coverage

* chore(viam): update viam-sdk version

* fix(viam): add service schemas and translation keys

* test(viam): update config flow to use new selector values

* chore(viam): update viam-sdk to 0.11.0

* feat: add exceptions translation stings

* refactor(viam): use constant keys, defer filesystem IO execution

* fix(viam): add missing constants, resolve correct image for services

* fix(viam): use lokalize string refs, resolve more constant strings

* fix(viam): move service registration to async_setup

* refactor: abstract services into separate module outside of manager

* refactor(viam): extend common vol schemas

* refactor(viam): consolidate common service values

* refactor(viam): replace FlowResult with ConfigFlowResult

* chore(viam): add icons.json for services

* refactor(viam): use org API key to connect to robot

* fix(viam): close app client if user abort config flow

* refactor(viam): run ruff formatter

* test(viam): confirm 100% coverage of config_flow

* refactor(viam): simplify manager, clean up config flow methods

* refactor(viam): split auth step into auth_api & auth_location

* refactor(viam): remove use of SelectOptionDict for auth choice, update strings

* fix(viam): use sentence case for translation strings

* test(viam): create mock_viam_client fixture for reusable mock
  • Loading branch information
HipsterBrown committed May 14, 2024
1 parent 746cfd3 commit 55bf0b6
Show file tree
Hide file tree
Showing 18 changed files with 1,299 additions and 0 deletions.
4 changes: 4 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -1581,6 +1581,10 @@ omit =
homeassistant/components/vesync/sensor.py
homeassistant/components/vesync/switch.py
homeassistant/components/viaggiatreno/sensor.py
homeassistant/components/viam/__init__.py
homeassistant/components/viam/const.py
homeassistant/components/viam/manager.py
homeassistant/components/viam/services.py
homeassistant/components/vicare/__init__.py
homeassistant/components/vicare/button.py
homeassistant/components/vicare/climate.py
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -1523,6 +1523,8 @@ build.json @home-assistant/supervisor
/tests/components/version/ @ludeeus
/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja
/homeassistant/components/viam/ @hipsterbrown
/tests/components/viam/ @hipsterbrown
/homeassistant/components/vicare/ @CFenner
/tests/components/vicare/ @CFenner
/homeassistant/components/vilfo/ @ManneW
Expand Down
59 changes: 59 additions & 0 deletions homeassistant/components/viam/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""The viam integration."""

from __future__ import annotations

from viam.app.viam_client import ViamClient
from viam.rpc.dial import Credentials, DialOptions

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType

from .const import (
CONF_API_ID,
CONF_CREDENTIAL_TYPE,
CONF_SECRET,
CRED_TYPE_API_KEY,
DOMAIN,
)
from .manager import ViamManager
from .services import async_setup_services

CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Viam services."""

async_setup_services(hass)

return True


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up viam from a config entry."""
credential_type = entry.data[CONF_CREDENTIAL_TYPE]
payload = entry.data[CONF_SECRET]
auth_entity = entry.data[CONF_ADDRESS]
if credential_type == CRED_TYPE_API_KEY:
payload = entry.data[CONF_API_KEY]
auth_entity = entry.data[CONF_API_ID]

credentials = Credentials(type=credential_type, payload=payload)
dial_options = DialOptions(auth_entity=auth_entity, credentials=credentials)
viam_client = await ViamClient.create_from_dial_options(dial_options=dial_options)
manager = ViamManager(hass, viam_client, entry.entry_id, dict(entry.data))

hass.data.setdefault(DOMAIN, {})[entry.entry_id] = manager

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
manager: ViamManager = hass.data[DOMAIN].pop(entry.entry_id)
manager.unload()

return True
212 changes: 212 additions & 0 deletions homeassistant/components/viam/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
"""Config flow for viam integration."""

from __future__ import annotations

import logging
from typing import Any

from viam.app.viam_client import ViamClient
from viam.rpc.dial import Credentials, DialOptions
import voluptuous as vol

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS, CONF_API_KEY
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
)

from .const import (
CONF_API_ID,
CONF_CREDENTIAL_TYPE,
CONF_ROBOT,
CONF_ROBOT_ID,
CONF_SECRET,
CRED_TYPE_API_KEY,
CRED_TYPE_LOCATION_SECRET,
DOMAIN,
)

_LOGGER = logging.getLogger(__name__)


STEP_AUTH_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_CREDENTIAL_TYPE): SelectSelector(
SelectSelectorConfig(
options=[
CRED_TYPE_API_KEY,
CRED_TYPE_LOCATION_SECRET,
],
translation_key=CONF_CREDENTIAL_TYPE,
)
)
}
)
STEP_AUTH_ROBOT_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_ADDRESS): str,
vol.Required(CONF_SECRET): str,
}
)
STEP_AUTH_ORG_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_ID): str,
vol.Required(CONF_API_KEY): str,
}
)


async def validate_input(data: dict[str, Any]) -> tuple[str, ViamClient]:
"""Validate the user input allows us to connect.
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
"""
credential_type = data[CONF_CREDENTIAL_TYPE]
auth_entity = data.get(CONF_API_ID)
secret = data.get(CONF_API_KEY)
if credential_type == CRED_TYPE_LOCATION_SECRET:
auth_entity = data.get(CONF_ADDRESS)
secret = data.get(CONF_SECRET)

if not secret:
raise CannotConnect

creds = Credentials(type=credential_type, payload=secret)
opts = DialOptions(auth_entity=auth_entity, credentials=creds)
client = await ViamClient.create_from_dial_options(opts)

# If you cannot connect:
# throw CannotConnect
if client:
locations = await client.app_client.list_locations()
location = await client.app_client.get_location(next(iter(locations)).id)

# Return info that you want to store in the config entry.
return (location.name, client)

raise CannotConnect


class ViamFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for viam."""

VERSION = 1

def __init__(self) -> None:
"""Initialize."""
self._title = ""
self._client: ViamClient
self._data: dict[str, Any] = {}

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._data.update(user_input)

if self._data.get(CONF_CREDENTIAL_TYPE) == CRED_TYPE_API_KEY:
return await self.async_step_auth_api_key()

return await self.async_step_auth_robot_location()

return self.async_show_form(
step_id="user", data_schema=STEP_AUTH_USER_DATA_SCHEMA, errors=errors
)

async def async_step_auth_api_key(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the API Key authentication."""
errors = await self.__handle_auth_input(user_input)
if errors is None:
return await self.async_step_robot()

return self.async_show_form(
step_id="auth_api_key",
data_schema=STEP_AUTH_ORG_DATA_SCHEMA,
errors=errors,
)

async def async_step_auth_robot_location(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the robot location authentication."""
errors = await self.__handle_auth_input(user_input)
if errors is None:
return await self.async_step_robot()

return self.async_show_form(
step_id="auth_robot_location",
data_schema=STEP_AUTH_ROBOT_DATA_SCHEMA,
errors=errors,
)

async def async_step_robot(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Select robot from location."""
if user_input is not None:
self._data.update({CONF_ROBOT_ID: user_input[CONF_ROBOT]})
return self.async_create_entry(title=self._title, data=self._data)

app_client = self._client.app_client
locations = await app_client.list_locations()
robots = await app_client.list_robots(next(iter(locations)).id)

return self.async_show_form(
step_id="robot",
data_schema=vol.Schema(
{
vol.Required(CONF_ROBOT): SelectSelector(
SelectSelectorConfig(
options=[
SelectOptionDict(value=robot.id, label=robot.name)
for robot in robots
]
)
)
}
),
)

@callback
def async_remove(self) -> None:
"""Notification that the flow has been removed."""
if self._client is not None:
self._client.close()

async def __handle_auth_input(
self, user_input: dict[str, Any] | None = None
) -> dict[str, str] | None:
"""Validate user input for the common authentication logic.
Returns:
A dictionary with any handled errors if any occurred, or None
"""
errors: dict[str, str] | None = None
if user_input is not None:
try:
self._data.update(user_input)
(title, client) = await validate_input(self._data)
self._title = title
self._client = client
except CannotConnect:
errors = {"base": "cannot_connect"}
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors = {"base": "unknown"}
else:
errors = {}

return errors


class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
12 changes: 12 additions & 0 deletions homeassistant/components/viam/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Constants for the viam integration."""

DOMAIN = "viam"

CONF_API_ID = "api_id"
CONF_SECRET = "secret"
CONF_CREDENTIAL_TYPE = "credential_type"
CONF_ROBOT = "robot"
CONF_ROBOT_ID = "robot_id"

CRED_TYPE_API_KEY = "api-key"
CRED_TYPE_LOCATION_SECRET = "robot-location-secret"
8 changes: 8 additions & 0 deletions homeassistant/components/viam/icons.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"services": {
"capture_image": "mdi:camera",
"capture_data": "mdi:data-matrix",
"get_classifications": "mdi:cctv",
"get_detections": "mdi:cctv"
}
}

0 comments on commit 55bf0b6

Please sign in to comment.