Compare commits
3 commits
Author | SHA1 | Date | |
---|---|---|---|
|
5b91d92225 | ||
|
d5bfe49d1b | ||
|
7f7637c8eb |
17 changed files with 1272 additions and 0 deletions
|
@ -1567,6 +1567,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
|
||||
|
|
57
homeassistant/components/viam/__init__.py
Normal file
57
homeassistant/components/viam/__init__.py
Normal file
|
@ -0,0 +1,57 @@
|
|||
"""The viam integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from viam.app.viam_client import ViamClient
|
||||
from viam.rpc.dial import Credentials, DialOptions
|
||||
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_API_KEY
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import (
|
||||
CONF_API_ID,
|
||||
CONF_CREDENTIAL_TYPE,
|
||||
CONF_SECRET,
|
||||
CRED_TYPE_API_KEY,
|
||||
DOMAIN,
|
||||
)
|
||||
from .manager import ViamConfigEntry, ViamManager
|
||||
from .services import async_setup_services
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ViamConfigEntry) -> bool:
|
||||
"""Set up the Viam services."""
|
||||
|
||||
async_setup_services(hass, config)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ViamConfigEntry) -> 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))
|
||||
|
||||
entry.runtime_data = manager
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ViamConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
manager: ViamManager = entry.runtime_data
|
||||
manager.unload()
|
||||
|
||||
return True
|
212
homeassistant/components/viam/config_flow.py
Normal file
212
homeassistant/components/viam/config_flow.py
Normal file
|
@ -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
homeassistant/components/viam/const.py
Normal file
12
homeassistant/components/viam/const.py
Normal file
|
@ -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
homeassistant/components/viam/icons.json
Normal file
8
homeassistant/components/viam/icons.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"services": {
|
||||
"capture_image": "mdi:camera",
|
||||
"capture_data": "mdi:data-matrix",
|
||||
"get_classifications": "mdi:cctv",
|
||||
"get_detections": "mdi:cctv"
|
||||
}
|
||||
}
|
89
homeassistant/components/viam/manager.py
Normal file
89
homeassistant/components/viam/manager.py
Normal file
|
@ -0,0 +1,89 @@
|
|||
"""Manage Viam client connection."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from viam.app.app_client import RobotPart
|
||||
from viam.app.viam_client import ViamClient
|
||||
from viam.robot.client import RobotClient
|
||||
from viam.rpc.dial import Credentials, DialOptions
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
|
||||
from .const import (
|
||||
CONF_API_ID,
|
||||
CONF_CREDENTIAL_TYPE,
|
||||
CONF_ROBOT_ID,
|
||||
CONF_SECRET,
|
||||
CRED_TYPE_API_KEY,
|
||||
CRED_TYPE_LOCATION_SECRET,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
type ViamConfigEntry = ConfigEntry[ViamManager]
|
||||
|
||||
|
||||
class ViamManager:
|
||||
"""Manage Viam client and entry data."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
viam: ViamClient,
|
||||
entry_id: str,
|
||||
data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Store initialized client and user input data."""
|
||||
self.address: str = data.get(CONF_ADDRESS, "")
|
||||
self.auth_entity: str = data.get(CONF_API_ID, "")
|
||||
self.cred_type: str = data.get(CONF_CREDENTIAL_TYPE, CRED_TYPE_API_KEY)
|
||||
self.entry_id = entry_id
|
||||
self.hass = hass
|
||||
self.robot_id: str = data.get(CONF_ROBOT_ID, "")
|
||||
self.secret: str = data.get(CONF_SECRET, "")
|
||||
self.viam = viam
|
||||
|
||||
def unload(self) -> None:
|
||||
"""Clean up any open clients."""
|
||||
self.viam.close()
|
||||
|
||||
async def get_robot_client(
|
||||
self, robot_secret: str | None, robot_address: str | None
|
||||
) -> RobotClient:
|
||||
"""Check initialized data to create robot client."""
|
||||
address = self.address
|
||||
payload = self.secret
|
||||
cred_type = self.cred_type
|
||||
auth_entity: str | None = self.auth_entity
|
||||
|
||||
if robot_secret is not None:
|
||||
if robot_address is None:
|
||||
raise ServiceValidationError(
|
||||
"The robot address is required for this connection type.",
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="robot_credentials_required",
|
||||
)
|
||||
cred_type = CRED_TYPE_LOCATION_SECRET
|
||||
auth_entity = None
|
||||
address = robot_address
|
||||
payload = robot_secret
|
||||
|
||||
if address is None or payload is None:
|
||||
raise ServiceValidationError(
|
||||
"The necessary credentials for the RobotClient could not be found.",
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="robot_credentials_not_found",
|
||||
)
|
||||
|
||||
credentials = Credentials(type=cred_type, payload=payload)
|
||||
robot_options = RobotClient.Options(
|
||||
refresh_interval=0,
|
||||
dial_options=DialOptions(auth_entity=auth_entity, credentials=credentials),
|
||||
)
|
||||
return await RobotClient.at_address(address, robot_options)
|
||||
|
||||
async def get_robot_parts(self) -> list[RobotPart]:
|
||||
"""Retrieve list of robot parts."""
|
||||
return await self.viam.app_client.get_robot_parts(robot_id=self.robot_id)
|
10
homeassistant/components/viam/manifest.json
Normal file
10
homeassistant/components/viam/manifest.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"domain": "viam",
|
||||
"name": "Viam",
|
||||
"codeowners": ["@hipsterbrown"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/viam",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["viam-sdk==0.25.2"]
|
||||
}
|
308
homeassistant/components/viam/services.py
Normal file
308
homeassistant/components/viam/services.py
Normal file
|
@ -0,0 +1,308 @@
|
|||
"""Services for Viam integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
|
||||
from PIL import Image
|
||||
from viam.app.app_client import RobotPart
|
||||
from viam.media.video import CameraMimeType, ViamImage
|
||||
from viam.services.vision import VisionClient
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import camera
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.helpers import selector
|
||||
|
||||
from .const import DOMAIN
|
||||
from .manager import ViamConfigEntry, ViamManager
|
||||
|
||||
ATTR_CONFIG_ENTRY = "config_entry"
|
||||
|
||||
DATA_CAPTURE_SERVICE_NAME = "capture_data"
|
||||
CAPTURE_IMAGE_SERVICE_NAME = "capture_image"
|
||||
CLASSIFICATION_SERVICE_NAME = "get_classifications"
|
||||
DETECTIONS_SERVICE_NAME = "get_detections"
|
||||
|
||||
SERVICE_VALUES = "values"
|
||||
SERVICE_COMPONENT_NAME = "component_name"
|
||||
SERVICE_COMPONENT_TYPE = "component_type"
|
||||
SERVICE_FILEPATH = "filepath"
|
||||
SERVICE_CAMERA = "camera"
|
||||
SERVICE_CONFIDENCE = "confidence_threshold"
|
||||
SERVICE_ROBOT_ADDRESS = "robot_address"
|
||||
SERVICE_ROBOT_SECRET = "robot_secret"
|
||||
SERVICE_FILE_NAME = "file_name"
|
||||
SERVICE_CLASSIFIER_NAME = "classifier_name"
|
||||
SERVICE_COUNT = "count"
|
||||
SERVICE_DETECTOR_NAME = "detector_name"
|
||||
|
||||
ENTRY_SERVICE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector(
|
||||
{
|
||||
"integration": DOMAIN,
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
DATA_CAPTURE_SERVICE_SCHEMA = ENTRY_SERVICE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(SERVICE_VALUES): vol.All(dict),
|
||||
vol.Required(SERVICE_COMPONENT_NAME): vol.All(str),
|
||||
vol.Required(SERVICE_COMPONENT_TYPE, default="sensor"): vol.All(str),
|
||||
}
|
||||
)
|
||||
|
||||
IMAGE_SERVICE_FIELDS = ENTRY_SERVICE_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(SERVICE_FILEPATH): vol.All(str, vol.IsFile),
|
||||
vol.Optional(SERVICE_CAMERA): vol.All(str),
|
||||
}
|
||||
)
|
||||
VISION_SERVICE_FIELDS = IMAGE_SERVICE_FIELDS.extend(
|
||||
{
|
||||
vol.Optional(SERVICE_CONFIDENCE, default="0.6"): vol.All(
|
||||
str, vol.Coerce(float), vol.Range(min=0, max=1)
|
||||
),
|
||||
vol.Optional(SERVICE_ROBOT_ADDRESS): vol.All(str),
|
||||
vol.Optional(SERVICE_ROBOT_SECRET): vol.All(str),
|
||||
}
|
||||
)
|
||||
|
||||
CAPTURE_IMAGE_SERVICE_SCHEMA = IMAGE_SERVICE_FIELDS.extend(
|
||||
{
|
||||
vol.Optional(SERVICE_FILE_NAME, default="camera"): vol.All(str),
|
||||
vol.Optional(SERVICE_COMPONENT_NAME): vol.All(str),
|
||||
}
|
||||
)
|
||||
|
||||
CLASSIFICATION_SERVICE_SCHEMA = VISION_SERVICE_FIELDS.extend(
|
||||
{
|
||||
vol.Required(SERVICE_CLASSIFIER_NAME): vol.All(str),
|
||||
vol.Optional(SERVICE_COUNT, default="2"): vol.All(str, vol.Coerce(int)),
|
||||
}
|
||||
)
|
||||
|
||||
DETECTIONS_SERVICE_SCHEMA = VISION_SERVICE_FIELDS.extend(
|
||||
{
|
||||
vol.Required(SERVICE_DETECTOR_NAME): vol.All(str),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def __fetch_image(filepath: str | None) -> Image.Image | None:
|
||||
if filepath is None:
|
||||
return None
|
||||
return Image.open(filepath)
|
||||
|
||||
|
||||
def __encode_image(image: Image.Image | ViamImage) -> str:
|
||||
"""Create base64-encoded Image string."""
|
||||
if isinstance(image, Image.Image):
|
||||
image_bytes = image.tobytes()
|
||||
else: # ViamImage
|
||||
image_bytes = image.data
|
||||
|
||||
image_string = base64.b64encode(image_bytes).decode()
|
||||
return f"data:image/jpeg;base64,{image_string}"
|
||||
|
||||
|
||||
async def __get_image(
|
||||
hass: HomeAssistant, filepath: str | None, camera_entity: str | None
|
||||
) -> ViamImage | None:
|
||||
"""Retrieve image type from camera entity or file system."""
|
||||
if filepath is not None:
|
||||
local_image = await hass.async_add_executor_job(__fetch_image, filepath)
|
||||
if local_image is not None:
|
||||
return ViamImage(
|
||||
local_image.tobytes(),
|
||||
CameraMimeType.from_string(Image.MIME[local_image.format or "JPEG"]),
|
||||
)
|
||||
if camera_entity is not None:
|
||||
camera_image = await camera.async_get_image(hass, camera_entity)
|
||||
return ViamImage(
|
||||
camera_image.content, CameraMimeType.from_string(camera_image.content_type)
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def __capture_data(config: ViamConfigEntry, call: ServiceCall) -> None:
|
||||
"""Accept input from service call to send to Viam."""
|
||||
manager: ViamManager = config.runtime_data
|
||||
parts: list[RobotPart] = await manager.get_robot_parts()
|
||||
values = [call.data.get(SERVICE_VALUES, {})]
|
||||
component_type = call.data.get(SERVICE_COMPONENT_TYPE, "sensor")
|
||||
component_name = call.data.get(SERVICE_COMPONENT_NAME, "")
|
||||
|
||||
await manager.viam.data_client.tabular_data_capture_upload(
|
||||
tabular_data=values,
|
||||
part_id=parts.pop().id,
|
||||
component_type=component_type,
|
||||
component_name=component_name,
|
||||
method_name="capture_data",
|
||||
data_request_times=[(datetime.now(), datetime.now())],
|
||||
)
|
||||
|
||||
|
||||
async def __capture_image(
|
||||
hass: HomeAssistant, config: ViamConfigEntry, call: ServiceCall
|
||||
) -> None:
|
||||
"""Accept input from service call to send to Viam."""
|
||||
manager: ViamManager = config.runtime_data
|
||||
parts: list[RobotPart] = await manager.get_robot_parts()
|
||||
filepath = call.data.get(SERVICE_FILEPATH)
|
||||
camera_entity = call.data.get(SERVICE_CAMERA)
|
||||
component_name = call.data.get(SERVICE_COMPONENT_NAME)
|
||||
file_name = call.data.get(SERVICE_FILE_NAME, "camera")
|
||||
|
||||
if filepath is not None:
|
||||
await manager.viam.data_client.file_upload_from_path(
|
||||
filepath=filepath,
|
||||
part_id=parts.pop().id,
|
||||
component_name=component_name,
|
||||
)
|
||||
if camera_entity is not None:
|
||||
image = await camera.async_get_image(hass, camera_entity)
|
||||
await manager.viam.data_client.file_upload(
|
||||
part_id=parts.pop().id,
|
||||
component_name=component_name,
|
||||
file_name=file_name,
|
||||
file_extension=".jpeg",
|
||||
data=image.content,
|
||||
)
|
||||
|
||||
|
||||
async def __get_service_values(
|
||||
hass: HomeAssistant,
|
||||
config: ViamConfigEntry,
|
||||
call: ServiceCall,
|
||||
service_config_name: str,
|
||||
):
|
||||
"""Create common values for vision services."""
|
||||
manager: ViamManager = config.runtime_data
|
||||
filepath = call.data.get(SERVICE_FILEPATH)
|
||||
camera_entity = call.data.get(SERVICE_CAMERA)
|
||||
service_name = call.data.get(service_config_name, "")
|
||||
count = int(call.data.get(SERVICE_COUNT, 2))
|
||||
confidence_threshold = float(call.data.get(SERVICE_CONFIDENCE, 0.6))
|
||||
|
||||
async with await manager.get_robot_client(
|
||||
call.data.get(SERVICE_ROBOT_SECRET), call.data.get(SERVICE_ROBOT_ADDRESS)
|
||||
) as robot:
|
||||
service: VisionClient = VisionClient.from_robot(robot, service_name)
|
||||
image = await __get_image(hass, filepath, camera_entity)
|
||||
|
||||
return manager, service, image, filepath, confidence_threshold, count
|
||||
|
||||
|
||||
async def __get_classifications(
|
||||
hass: HomeAssistant, config: ViamConfigEntry, call: ServiceCall
|
||||
) -> ServiceResponse:
|
||||
"""Accept input configuration to request classifications."""
|
||||
(
|
||||
_manager,
|
||||
classifier,
|
||||
image,
|
||||
filepath,
|
||||
confidence_threshold,
|
||||
count,
|
||||
) = await __get_service_values(hass, config, call, SERVICE_CLASSIFIER_NAME)
|
||||
|
||||
if image is None:
|
||||
return {
|
||||
"classifications": [],
|
||||
"img_src": filepath or None,
|
||||
}
|
||||
|
||||
img_src = filepath or __encode_image(image)
|
||||
classifications = await classifier.get_classifications(image, count)
|
||||
|
||||
return {
|
||||
"classifications": [
|
||||
{"name": c.class_name, "confidence": c.confidence}
|
||||
for c in classifications
|
||||
if c.confidence >= confidence_threshold
|
||||
],
|
||||
"img_src": img_src,
|
||||
}
|
||||
|
||||
|
||||
async def __get_detections(
|
||||
hass: HomeAssistant, config: ViamConfigEntry, call: ServiceCall
|
||||
) -> ServiceResponse:
|
||||
"""Accept input configuration to request detections."""
|
||||
(
|
||||
_manager,
|
||||
detector,
|
||||
image,
|
||||
filepath,
|
||||
confidence_threshold,
|
||||
_count,
|
||||
) = await __get_service_values(hass, config, call, SERVICE_DETECTOR_NAME)
|
||||
|
||||
if image is None:
|
||||
return {
|
||||
"detections": [],
|
||||
"img_src": filepath or None,
|
||||
}
|
||||
|
||||
img_src = filepath or __encode_image(image)
|
||||
detections = await detector.get_detections(image)
|
||||
|
||||
return {
|
||||
"detections": [
|
||||
{
|
||||
"name": c.class_name,
|
||||
"confidence": c.confidence,
|
||||
"x_min": c.x_min,
|
||||
"y_min": c.y_min,
|
||||
"x_max": c.x_max,
|
||||
"y_max": c.y_max,
|
||||
}
|
||||
for c in detections
|
||||
if c.confidence >= confidence_threshold
|
||||
],
|
||||
"img_src": img_src,
|
||||
}
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant, config: ViamConfigEntry) -> None:
|
||||
"""Set up services for Viam integration."""
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
DATA_CAPTURE_SERVICE_NAME,
|
||||
partial(__capture_data, config),
|
||||
DATA_CAPTURE_SERVICE_SCHEMA,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
CAPTURE_IMAGE_SERVICE_NAME,
|
||||
partial(__capture_image, hass, config),
|
||||
CAPTURE_IMAGE_SERVICE_SCHEMA,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
CLASSIFICATION_SERVICE_NAME,
|
||||
partial(__get_classifications, hass, config),
|
||||
CLASSIFICATION_SERVICE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
DETECTIONS_SERVICE_NAME,
|
||||
partial(__get_detections, hass, config),
|
||||
DETECTIONS_SERVICE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
98
homeassistant/components/viam/services.yaml
Normal file
98
homeassistant/components/viam/services.yaml
Normal file
|
@ -0,0 +1,98 @@
|
|||
capture_data:
|
||||
fields:
|
||||
values:
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
component_name:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
component_type:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
capture_image:
|
||||
fields:
|
||||
filepath:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
camera:
|
||||
required: false
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
domain: camera
|
||||
file_name:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
component_name:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
get_classifications:
|
||||
fields:
|
||||
classifier_name:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
confidence:
|
||||
required: false
|
||||
default: 0.6
|
||||
selector:
|
||||
text:
|
||||
type: number
|
||||
count:
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
robot_address:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
robot_secret:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
filepath:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
camera:
|
||||
required: false
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
domain: camera
|
||||
get_detections:
|
||||
fields:
|
||||
detector_name:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
confidence:
|
||||
required: false
|
||||
default: 0.6
|
||||
selector:
|
||||
text:
|
||||
type: number
|
||||
robot_address:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
robot_secret:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
filepath:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
camera:
|
||||
required: false
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
domain: camera
|
171
homeassistant/components/viam/strings.json
Normal file
171
homeassistant/components/viam/strings.json
Normal file
|
@ -0,0 +1,171 @@
|
|||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Authenticate with Viam",
|
||||
"description": "Select which credential type to use.",
|
||||
"data": {
|
||||
"credential_type": "Credential type"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "[%key:component::viam::config::step::user::title%]",
|
||||
"description": "Provide the credentials for communicating with the Viam service.",
|
||||
"data": {
|
||||
"api_id": "API key ID",
|
||||
"api_key": "API key",
|
||||
"address": "Robot address",
|
||||
"secret": "Robot secret"
|
||||
},
|
||||
"data_description": {
|
||||
"address": "Find this under the Code Sample tab in the app.",
|
||||
"secret": "Find this under the Code Sample tab in the app when 'include secret' is enabled."
|
||||
}
|
||||
},
|
||||
"robot": {
|
||||
"data": {
|
||||
"robot": "Select a robot"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"credential_type": {
|
||||
"options": {
|
||||
"api-key": "Org API key",
|
||||
"robot-location-secret": "Robot location secret"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"entry_not_found": {
|
||||
"message": "No Viam config entries found"
|
||||
},
|
||||
"entry_not_loaded": {
|
||||
"message": "{config_entry_title} is not loaded"
|
||||
},
|
||||
"invalid_config_entry": {
|
||||
"message": "Invalid config entry provided. Got {config_entry}"
|
||||
},
|
||||
"unloaded_config_entry": {
|
||||
"message": "Invalid config entry provided. {config_entry} is not loaded."
|
||||
},
|
||||
"robot_credentials_required": {
|
||||
"message": "The robot address is required for this connection type."
|
||||
},
|
||||
"robot_credentials_not_found": {
|
||||
"message": "The necessary credentials for the RobotClient could not be found."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"capture_data": {
|
||||
"name": "Capture data",
|
||||
"description": "Send arbitrary tabular data to Viam for analytics and model training.",
|
||||
"fields": {
|
||||
"values": {
|
||||
"name": "Values",
|
||||
"description": "List of tabular data to send to Viam."
|
||||
},
|
||||
"component_name": {
|
||||
"name": "Component name",
|
||||
"description": "The name of the configured robot component to use."
|
||||
},
|
||||
"component_type": {
|
||||
"name": "Component type",
|
||||
"description": "The type of the associated component."
|
||||
}
|
||||
}
|
||||
},
|
||||
"capture_image": {
|
||||
"name": "Capture image",
|
||||
"description": "Send images to Viam for analytics and model training.",
|
||||
"fields": {
|
||||
"filepath": {
|
||||
"name": "Filepath",
|
||||
"description": "Local file path to the image you wish to reference."
|
||||
},
|
||||
"camera": {
|
||||
"name": "Camera entity",
|
||||
"description": "The camera entity from which an image is captured."
|
||||
},
|
||||
"file_name": {
|
||||
"name": "File name",
|
||||
"description": "The name of the file that will be displayed in the metadata within Viam."
|
||||
},
|
||||
"component_name": {
|
||||
"name": "Component name",
|
||||
"description": "The name of the configured robot component to use."
|
||||
}
|
||||
}
|
||||
},
|
||||
"get_classifications": {
|
||||
"name": "Classify images",
|
||||
"description": "Get a list of classifications from an image.",
|
||||
"fields": {
|
||||
"classifier_name": {
|
||||
"name": "Classifier name",
|
||||
"description": "Name of classifier vision service configured in Viam"
|
||||
},
|
||||
"confidence": {
|
||||
"name": "Confidence",
|
||||
"description": "Threshold for filtering results returned by the service"
|
||||
},
|
||||
"count": {
|
||||
"name": "Classification count",
|
||||
"description": "Number of classifications to return from the service"
|
||||
},
|
||||
"robot_address": {
|
||||
"name": "Robot address",
|
||||
"description": "If authenticated using an Org API key, provide the robot address associated with the configured vision service."
|
||||
},
|
||||
"robot_secret": {
|
||||
"name": "Robot secret",
|
||||
"description": "If authenticated using an Org API key, provide the robot location secret associated with the configured vision service."
|
||||
},
|
||||
"filepath": {
|
||||
"name": "Filepath",
|
||||
"description": "Local file path to the image you wish to reference."
|
||||
},
|
||||
"camera": {
|
||||
"name": "Camera entity",
|
||||
"description": "The camera entity from which an image is captured."
|
||||
}
|
||||
}
|
||||
},
|
||||
"get_detections": {
|
||||
"name": "Detect objects in images",
|
||||
"description": "Get a list of detected objects from an image.",
|
||||
"fields": {
|
||||
"detector_name": {
|
||||
"name": "Detector name",
|
||||
"description": "Name of detector vision service configured in Viam"
|
||||
},
|
||||
"confidence": {
|
||||
"name": "Confidence",
|
||||
"description": "Threshold for filtering results returned by the service"
|
||||
},
|
||||
"robot_address": {
|
||||
"name": "Robot address",
|
||||
"description": "If authenticated using an Org API key, provide the robot address associated with the configured vision service."
|
||||
},
|
||||
"robot_secret": {
|
||||
"name": "Robot secret",
|
||||
"description": "If authenticated using an Org API key, provide the robot location secret associated with the configured vision service."
|
||||
},
|
||||
"filepath": {
|
||||
"name": "Filepath",
|
||||
"description": "Local file path to the image you wish to reference."
|
||||
},
|
||||
"camera": {
|
||||
"name": "Camera entity",
|
||||
"description": "The camera entity from which an image is captured."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -624,6 +624,7 @@ FLOWS = {
|
|||
"verisure",
|
||||
"version",
|
||||
"vesync",
|
||||
"viam",
|
||||
"vicare",
|
||||
"vilfo",
|
||||
"vizio",
|
||||
|
|
|
@ -6634,6 +6634,12 @@
|
|||
"config_flow": false,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"viam": {
|
||||
"name": "Viam",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"vicare": {
|
||||
"name": "Viessmann ViCare",
|
||||
"integration_type": "hub",
|
||||
|
|
|
@ -2865,6 +2865,9 @@ velbus-aio==2024.7.6
|
|||
# homeassistant.components.venstar
|
||||
venstarcolortouch==0.19
|
||||
|
||||
# homeassistant.components.viam
|
||||
viam-sdk==0.25.2
|
||||
|
||||
# homeassistant.components.vilfo
|
||||
vilfo-api-client==0.5.0
|
||||
|
||||
|
|
|
@ -2260,6 +2260,9 @@ velbus-aio==2024.7.6
|
|||
# homeassistant.components.venstar
|
||||
venstarcolortouch==0.19
|
||||
|
||||
# homeassistant.components.viam
|
||||
viam-sdk==0.25.2
|
||||
|
||||
# homeassistant.components.vilfo
|
||||
vilfo-api-client==0.5.0
|
||||
|
||||
|
|
1
tests/components/viam/__init__.py
Normal file
1
tests/components/viam/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Tests for the viam integration."""
|
60
tests/components/viam/conftest.py
Normal file
60
tests/components/viam/conftest.py
Normal file
|
@ -0,0 +1,60 @@
|
|||
"""Common fixtures for the viam tests."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Generator
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from viam.app.viam_client import ViamClient
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockLocation:
|
||||
"""Fake location for testing."""
|
||||
|
||||
id: str = "13"
|
||||
name: str = "home"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockRobot:
|
||||
"""Fake robot for testing."""
|
||||
|
||||
id: str = "1234"
|
||||
name: str = "test"
|
||||
|
||||
|
||||
def async_return(result):
|
||||
"""Allow async return value with MagicMock."""
|
||||
|
||||
future = asyncio.Future()
|
||||
future.set_result(result)
|
||||
return future
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
|
||||
"""Override async_setup_entry."""
|
||||
with patch(
|
||||
"homeassistant.components.viam.async_setup_entry", return_value=True
|
||||
) as mock_setup_entry:
|
||||
yield mock_setup_entry
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_viam_client")
|
||||
def mock_viam_client_fixture() -> Generator[tuple[MagicMock, MockRobot], None, None]:
|
||||
"""Override ViamClient from Viam SDK."""
|
||||
with (
|
||||
patch("viam.app.viam_client.ViamClient") as MockClient,
|
||||
patch.object(ViamClient, "create_from_dial_options") as mock_create_client,
|
||||
):
|
||||
instance: MagicMock = MockClient.return_value
|
||||
mock_create_client.return_value = instance
|
||||
|
||||
mock_location = MockLocation()
|
||||
mock_robot = MockRobot()
|
||||
instance.app_client.list_locations.return_value = async_return([mock_location])
|
||||
instance.app_client.get_location.return_value = async_return(mock_location)
|
||||
instance.app_client.list_robots.return_value = async_return([mock_robot])
|
||||
yield instance, mock_robot
|
231
tests/components/viam/test_config_flow.py
Normal file
231
tests/components/viam/test_config_flow.py
Normal file
|
@ -0,0 +1,231 @@
|
|||
"""Test the viam config flow."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from viam.app.viam_client import ViamClient
|
||||
|
||||
from homeassistant.components.viam.config_flow import CannotConnect
|
||||
from homeassistant.components.viam.const import (
|
||||
CONF_API_ID,
|
||||
CONF_CREDENTIAL_TYPE,
|
||||
CONF_ROBOT,
|
||||
CONF_ROBOT_ID,
|
||||
CONF_SECRET,
|
||||
CRED_TYPE_API_KEY,
|
||||
CRED_TYPE_LOCATION_SECRET,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_API_KEY
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from .conftest import MockRobot
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
|
||||
|
||||
|
||||
async def test_user_form(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_viam_client: Generator[tuple[MagicMock, MockRobot], None, None],
|
||||
) -> None:
|
||||
"""Test that the form is served with no input."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {}
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_CREDENTIAL_TYPE: CRED_TYPE_API_KEY,
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "auth_api_key"
|
||||
assert result["errors"] == {}
|
||||
|
||||
_client, mock_robot = mock_viam_client
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_API_ID: "someTestId",
|
||||
CONF_API_KEY: "randomSecureAPIKey",
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] is None
|
||||
assert result["step_id"] == "robot"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_ROBOT: mock_robot.id,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "home"
|
||||
assert result["data"] == {
|
||||
CONF_API_ID: "someTestId",
|
||||
CONF_API_KEY: "randomSecureAPIKey",
|
||||
CONF_ROBOT_ID: mock_robot.id,
|
||||
CONF_CREDENTIAL_TYPE: CRED_TYPE_API_KEY,
|
||||
}
|
||||
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_user_form_with_location_secret(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_viam_client: Generator[tuple[MagicMock, MockRobot], None, None],
|
||||
) -> None:
|
||||
"""Test that the form is served with no input."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {}
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_CREDENTIAL_TYPE: CRED_TYPE_LOCATION_SECRET,
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "auth_robot_location"
|
||||
assert result["errors"] == {}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_ADDRESS: "my.robot.cloud",
|
||||
CONF_SECRET: "randomSecreteForRobot",
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] is None
|
||||
assert result["step_id"] == "robot"
|
||||
|
||||
_client, mock_robot = mock_viam_client
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_ROBOT: mock_robot.id,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "home"
|
||||
assert result["data"] == {
|
||||
CONF_ADDRESS: "my.robot.cloud",
|
||||
CONF_SECRET: "randomSecreteForRobot",
|
||||
CONF_ROBOT_ID: mock_robot.id,
|
||||
CONF_CREDENTIAL_TYPE: CRED_TYPE_LOCATION_SECRET,
|
||||
}
|
||||
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_form_missing_secret(hass: HomeAssistant) -> None:
|
||||
"""Test we handle cannot connect error."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_CREDENTIAL_TYPE: CRED_TYPE_API_KEY,
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "auth_api_key"
|
||||
|
||||
with patch(
|
||||
"viam.app.viam_client.ViamClient.create_from_dial_options",
|
||||
side_effect=CannotConnect,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_API_ID: "someTestId",
|
||||
CONF_API_KEY: "",
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "auth_api_key"
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_form_cannot_connect(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test we handle cannot connect error."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_CREDENTIAL_TYPE: CRED_TYPE_API_KEY,
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "auth_api_key"
|
||||
|
||||
with patch.object(ViamClient, "create_from_dial_options", return_value=None):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_API_ID: "someTestId",
|
||||
CONF_API_KEY: "randomSecureAPIKey",
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "auth_api_key"
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_form_exception(hass: HomeAssistant) -> None:
|
||||
"""Test we handle cannot connect error."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_CREDENTIAL_TYPE: CRED_TYPE_API_KEY,
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "auth_api_key"
|
||||
|
||||
with patch(
|
||||
"viam.app.viam_client.ViamClient.create_from_dial_options",
|
||||
side_effect=Exception,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_API_ID: "someTestId",
|
||||
CONF_API_KEY: "randomSecureAPIKey",
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "auth_api_key"
|
||||
assert result["errors"] == {"base": "unknown"}
|
Loading…
Add table
Reference in a new issue