diff --git a/CODEOWNERS b/CODEOWNERS index 6593c02c8a5..c857250c8a9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/viam/__init__.py b/homeassistant/components/viam/__init__.py new file mode 100644 index 00000000000..4d76628f242 --- /dev/null +++ b/homeassistant/components/viam/__init__.py @@ -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 diff --git a/homeassistant/components/viam/config_flow.py b/homeassistant/components/viam/config_flow.py new file mode 100644 index 00000000000..5afa00769e3 --- /dev/null +++ b/homeassistant/components/viam/config_flow.py @@ -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.""" diff --git a/homeassistant/components/viam/const.py b/homeassistant/components/viam/const.py new file mode 100644 index 00000000000..9cf4932d04e --- /dev/null +++ b/homeassistant/components/viam/const.py @@ -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" diff --git a/homeassistant/components/viam/icons.json b/homeassistant/components/viam/icons.json new file mode 100644 index 00000000000..0145db44d21 --- /dev/null +++ b/homeassistant/components/viam/icons.json @@ -0,0 +1,8 @@ +{ + "services": { + "capture_image": "mdi:camera", + "capture_data": "mdi:data-matrix", + "get_classifications": "mdi:cctv", + "get_detections": "mdi:cctv" + } +} diff --git a/homeassistant/components/viam/manager.py b/homeassistant/components/viam/manager.py new file mode 100644 index 00000000000..fbe9e0bc710 --- /dev/null +++ b/homeassistant/components/viam/manager.py @@ -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) diff --git a/homeassistant/components/viam/manifest.json b/homeassistant/components/viam/manifest.json new file mode 100644 index 00000000000..6b3fdb689d2 --- /dev/null +++ b/homeassistant/components/viam/manifest.json @@ -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"] +} diff --git a/homeassistant/components/viam/services.py b/homeassistant/components/viam/services.py new file mode 100644 index 00000000000..d75ee3008d7 --- /dev/null +++ b/homeassistant/components/viam/services.py @@ -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, + ) diff --git a/homeassistant/components/viam/services.yaml b/homeassistant/components/viam/services.yaml new file mode 100644 index 00000000000..76a35e1ff06 --- /dev/null +++ b/homeassistant/components/viam/services.yaml @@ -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 diff --git a/homeassistant/components/viam/strings.json b/homeassistant/components/viam/strings.json new file mode 100644 index 00000000000..e6074749ca7 --- /dev/null +++ b/homeassistant/components/viam/strings.json @@ -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." + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c3fe4af4a76..0d3ef50c4b2 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -624,6 +624,7 @@ FLOWS = { "verisure", "version", "vesync", + "viam", "vicare", "vilfo", "vizio", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7df27aa5e68..5bcae8bfcba 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -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", diff --git a/requirements_all.txt b/requirements_all.txt index 59b8b35730e..63c3c4eafc6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 90ea1d2c806..c2f00c0c2e5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/viam/__init__.py b/tests/components/viam/__init__.py new file mode 100644 index 00000000000..f606728242e --- /dev/null +++ b/tests/components/viam/__init__.py @@ -0,0 +1 @@ +"""Tests for the viam integration.""" diff --git a/tests/components/viam/conftest.py b/tests/components/viam/conftest.py new file mode 100644 index 00000000000..3da6b272145 --- /dev/null +++ b/tests/components/viam/conftest.py @@ -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 diff --git a/tests/components/viam/test_config_flow.py b/tests/components/viam/test_config_flow.py new file mode 100644 index 00000000000..6298afc8eba --- /dev/null +++ b/tests/components/viam/test_config_flow.py @@ -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"}