-
-
Notifications
You must be signed in to change notification settings - Fork 28.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add Viam image processing integration (#101786)
* 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
1 parent
746cfd3
commit 55bf0b6
Showing
18 changed files
with
1,299 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Validating CODEOWNERS rules …
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.""" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
Oops, something went wrong.