Compare commits

...
Sign in to create a new pull request.

3 commits
dev ... viam

Author SHA1 Message Date
HipsterBrown
5b91d92225 test(viam): refactor patches for error handling, remove async_block_till_done 2024-08-16 16:25:24 -04:00
HipsterBrown
d5bfe49d1b refactor(viam): create custom ConfigEntry type, using runtime data 2024-08-16 16:25:24 -04:00
HipsterBrown
7f7637c8eb feat: create Viam integration 2024-08-16 16:25:24 -04:00
17 changed files with 1272 additions and 0 deletions

View file

@ -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

View 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

View 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."""

View 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"

View file

@ -0,0 +1,8 @@
{
"services": {
"capture_image": "mdi:camera",
"capture_data": "mdi:data-matrix",
"get_classifications": "mdi:cctv",
"get_detections": "mdi:cctv"
}
}

View 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)

View 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"]
}

View 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,
)

View 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

View 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."
}
}
}
}
}

View file

@ -624,6 +624,7 @@ FLOWS = {
"verisure",
"version",
"vesync",
"viam",
"vicare",
"vilfo",
"vizio",

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -0,0 +1 @@
"""Tests for the viam integration."""

View 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

View 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"}