diff --git a/docs/requirements.txt b/docs/requirements.txt index e4de85564c..b37591845f 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,7 +1,7 @@ -sphinx_rtd_theme -sphinx-autobuild -sphinx==3.3.0 -m2r2 +sphinx==5.1.1 +sphinx-rtd-theme==1.0.0 +m2r2==0.3.2 +sphinxcontrib-mermaid bacpypes enum34 funcsigs diff --git a/docs/source/agent-framework/driver-framework/home-assistant/HomeAssistantDriver.rst b/docs/source/agent-framework/driver-framework/home-assistant/HomeAssistantDriver.rst index ba007aecec..ff3e8afdc3 100644 --- a/docs/source/agent-framework/driver-framework/home-assistant/HomeAssistantDriver.rst +++ b/docs/source/agent-framework/driver-framework/home-assistant/HomeAssistantDriver.rst @@ -1,11 +1,12 @@ .. _HomeAssistant-Driver: Home Assistant Driver -============================== +===================== -The Home Assistant driver enables VOLTTRON to access any data point from a Home Assistant device, currently facilitating control of lights and thermostats. +The Home Assistant driver enables VOLTTRON to read any data point from any Home Assistant controlled device. +Currently control(write access) is supported only for lights and thermostats. -For further details, refer to the README for the platform driver `here `_. +The following diagram shows interaction between platform driver agent and home assistant driver. .. mermaid:: @@ -19,18 +20,29 @@ For further details, refer to the README for the platform driver `here >HomeAssistant: Send Turn Off Light Command (REST API) HomeAssistant-->>HomeAssistant Driver: Command Acknowledgement (Status Code: 200) +Pre-requisites +-------------- Before proceeding, find your Home Assistant IP address and long-lived access token from `here `_. -Clone the repository, install the listener agent, and the platform driver agent. +Clone the repository, start volttron, install the listener agent, and the platform driver agent. - `Listener agent `_ - `Platform driver agent `_ -After cloning, populate your configuration file, and registry file. Each device requires one configuration file and one registry file. Ensure your registry_config links to your device's registry file from the config store. Examples for lights and thermostats are provided below. Be sure to include the full entity id, including but not limited to "light." and "climate.". -The driver uses these prefixes to convert states into integers. Like mentioned before, the driver can only control lights and thermostats. +Configuration +-------------- + +After cloning, generate configuration files. Each device requires one device configuration file and one registry file. +Ensure your registry_config parameter in your device configuration file, links to correct registry config name in the +config store. For more details on how volttron platform driver agent works with volttron configuration store see, +`Platform driver configuration `_ +Examples for lights and thermostats are provided below. + +Device configuration +++++++++++++++++++++ + +Device configuration file contains the connection details to you home assistant instance and driver_type as "home_assistant" -Lights ------- .. code-block:: json { @@ -45,7 +57,25 @@ Lights "timezone": "UTC" } -Your registry file should contain one device, with the ability to add attributes. The Entity ID extracts data from Home Assistant, and Volttron Point Name retrieves the state or attributes defined. Below is an example file named light.example.json: +Registry Configuration ++++++++++++++++++++++++ + +Registry file can contain one single device and its attributes or collection logical group of devices and its +attributes. Each entry should include the full entity id of the device, including but not limited to "light." +and "climate.", in the registry configuration. The driver uses these prefixes to convert states into integers. +Like mentioned before, the driver can only control lights and thermostats but can get data from all devices +controlled by home assistant + +Each entry in a registry file should also have a unique value for 'Volttron Point Name'. The Entity ID extracts data +from Home Assistant, and Volttron Point Name retrieves the state or attributes defined. + +Attributes can be located in the developer tools in the Home Assistant GUI. + +.. image:: home-assistant.png + + +Below is an example file named light.example.json which has attributes of a single light instance with entity +id 'light.example': .. code-block:: json @@ -72,10 +102,18 @@ Your registry file should contain one device, with the ability to add attributes } ] -Thermostats ------------ +.. note:: -For thermostats, the state is converted into numbers as follows: "1: Off, 2: heat, 3: Cool, 4: Auto", +When using a single registry file to represent a logical group of multiple physical entities, make sure the +"Volttron Point Name" is unique within a single registry file. For example, if a registry file contains entities with +id 'light.instance1' and 'light.instance2' the entry for the attribute brightness for these two light instances could +have "Volttron Point Name" as 'light1/brightness' and 'light2/brightness' respectively. This would ensure that data +is posted to unique topic names and brightness data from light1 is not overwritten by light2 or vice-versa. + +Example Thermostat Registry +*************************** + +For thermostats, the state is converted into numbers as follows: "0: Off, 2: heat, 3: Cool, 4: Auto", .. code-block:: json @@ -112,9 +150,7 @@ For thermostats, the state is converted into numbers as follows: "1: Off, 2: hea } ] -Attributes can be located in the developer tools in the Home Assistant GUI. -.. image:: home_assistant_gui.png Transfer the registers files and the config files into the VOLTTRON config store using the commands below: diff --git a/docs/source/conf.py b/docs/source/conf.py index fe69affe91..8a265dcba0 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -71,7 +71,8 @@ def __getattr__(cls, name): # http://www.sphinx-doc.org/en/master/usage/extensions/todo.html 'sphinx.ext.todo', 'sphinx.ext.intersphinx', - 'm2r2' + 'm2r2', + 'sphinxcontrib.mermaid' ] # prefix sections with the document so that we can cross link diff --git a/requirements.py b/requirements.py index 1aea9ee560..8d0b10b04e 100644 --- a/requirements.py +++ b/requirements.py @@ -74,7 +74,8 @@ 'docutils<0.18', 'sphinx-rtd-theme==1.0.0', 'sphinx==5.1.1', - 'm2r2==0.3.2'], + 'm2r2==0.3.2', + 'sphinxcontrib-mermaid'], 'drivers': ['pymodbus==2.5.3', 'bacpypes==0.16.7', 'modbus-tk==1.1.2', diff --git a/services/core/PlatformDriverAgent/platform_driver/interfaces/home_assistant.py b/services/core/PlatformDriverAgent/platform_driver/interfaces/home_assistant.py index d9a00b7355..7f028e3d79 100644 --- a/services/core/PlatformDriverAgent/platform_driver/interfaces/home_assistant.py +++ b/services/core/PlatformDriverAgent/platform_driver/interfaces/home_assistant.py @@ -42,7 +42,7 @@ import json import sys from platform_driver.interfaces import BaseInterface, BaseRegister, BasicRevert -from volttron.platform.agent import utils #added this to pull from config store +from volttron.platform.agent import utils # added this to pull from config store from volttron.platform.vip.agent import Agent import logging import requests @@ -56,22 +56,44 @@ "bool": bool, "boolean": bool} + class HomeAssistantRegister(BaseRegister): - def __init__(self, read_only, pointName, units, reg_type, attributes, entity_id, - default_value=None, description=''): - super(HomeAssistantRegister, self).__init__("byte", read_only, pointName, units, - description='') + def __init__(self, read_only, pointName, units, reg_type, attributes, entity_id, default_value=None, + description=''): + super(HomeAssistantRegister, self).__init__("byte", read_only, pointName, units, description='') self.reg_type = reg_type self.attributes = attributes self.entity_id = entity_id self.value = None + +def _post_method(url, headers, data, operation_description): + err = None + try: + response = requests.post(url, headers=headers, json=data) + if response.status_code == 200: + _log.info(f"Success: {operation_description}") + else: + err = f"Failed to {operation_description}. Status code: {response.status_code}. " \ + f"Response: {response.text}" + + except requests.RequestException as e: + err = f"Error when attempting - {operation_description} : {e}" + if err: + _log.error(err) + raise Exception(err) + + class Interface(BasicRevert, BaseInterface): def __init__(self, **kwargs): super(Interface, self).__init__(**kwargs) self.point_name = None + self.ip_address = None + self.access_token = None + self.port = None + self.units = None - def configure(self, config_dict, registry_config_str): # grabbing from config + def configure(self, config_dict, registry_config_str): # grabbing from config self.ip_address = config_dict.get("ip_address", None) self.access_token = config_dict.get("access_token", None) self.port = config_dict.get("port", None) @@ -98,15 +120,14 @@ def get_point(self, point_name): return result else: value = entity_data.get("attributes", {}).get(f"{register.point_name}", 0) - print(value) return value def _set_point(self, point_name, value): register = self.get_register_by_name(point_name) if register.read_only: - raise RuntimeError( + raise IOError( "Trying to write to a point configured read only: " + point_name) - register.value = register.reg_type(value) # setting the value + register.value = register.reg_type(value) # setting the value # Changing lights values in home assistant based off of register value. if "light." in register.entity_id: @@ -122,7 +143,7 @@ def _set_point(self, point_name, value): raise ValueError(error_msg) elif point_name == "brightness": - if isinstance(register.value, int) and 0 <= register.value <= 255: # Make sure its int and within range + if isinstance(register.value, int) and 0 <= register.value <= 255: # Make sure its int and within range self.change_brightness(register.entity_id, register.value) else: error_msg = "Brightness value should be an integer between 0 and 255" @@ -154,10 +175,11 @@ def _set_point(self, point_name, value): self.set_thermostat_temperature(entity_id=register.entity_id, temperature=register.value) else: error_msg = f"Temperature must be an integer between 20 and 100 for {register.entity_id}" - _log.info(error_msg) - ValueError(error_msg) + _log.error(error_msg) + raise ValueError(error_msg) else: - error_msg = f"Unsupported entity_id: {register.entity_id}" + error_msg = f"Unsupported entity_id: {register.entity_id}. " \ + f"Currently set_point is supported only for thermostats and lights" _log.error(error_msg) raise ValueError(error_msg) return register.value @@ -167,12 +189,14 @@ def get_entity_data(self, point_name): "Authorization": f"Bearer {self.access_token}", "Content-Type": "application/json", } - url = f"http://{self.ip_address}:{self.port}/api/states/{point_name}" # the /states grabs current state AND attributes of a specific entity + # the /states grabs current state AND attributes of a specific entity + url = f"http://{self.ip_address}:{self.port}/api/states/{point_name}" response = requests.get(url, headers=headers) if response.status_code == 200: - return response.json() # return the json attributes from entity + return response.json() # return the json attributes from entity else: - error_msg = f"Request failed with status code {response.status_code}, Point name: {point_name}, response: {response.text}" + error_msg = f"Request failed with status code {response.status_code}, Point name: {point_name}, " \ + f"response: {response.text}" _log.error(error_msg) raise Exception(error_msg) @@ -184,12 +208,12 @@ def _scrape_all(self): for register in read_registers + write_registers: entity_id = register.entity_id try: - entity_data = self.get_entity_data(entity_id) # Using Entity ID to get data - if "climate." in entity_id: # handling thermostats. + entity_data = self.get_entity_data(entity_id) # Using Entity ID to get data + if "climate." in entity_id: # handling thermostats. if register.point_name == "state": state = entity_data.get("state", None) - # Giving thermostat states an equivilent number. + # Giving thermostat states an equivalent number. if state == "off": register.value = 0 result[register.point_name] = 0 @@ -213,7 +237,6 @@ def _scrape_all(self): result[register.point_name] = attribute # handling light states elif "light." in entity_id: - state = entity_data.get("state", None) if register.point_name == "state": state = entity_data.get("state", None) # Converting light states to numbers. @@ -227,7 +250,7 @@ def _scrape_all(self): attribute = entity_data.get("attributes", {}).get(f"{register.point_name}", 0) register.value = attribute result[register.point_name] = attribute - else: # handling all devices that are not thermostats or light states + else: # handling all devices that are not thermostats or light states if register.point_name == "state": state = entity_data.get("state", None) @@ -243,11 +266,11 @@ def _scrape_all(self): return result - def parse_config(self, configDict): + def parse_config(self, config_dict): - if configDict is None: + if config_dict is None: return - for regDef in configDict: + for regDef in config_dict: if not regDef['Entity ID']: continue @@ -290,17 +313,7 @@ def turn_off_lights(self, entity_id): payload = { "entity_id": entity_id, } - - try: - response = requests.post(url, headers=headers, data=json.dumps(payload)) - - if response.status_code == 200: - _log.info(f"Turned off {entity_id}") - else: - _log.error(f"Failed to turn off {entity_id}. Status code: {response.status_code}. Response: {response.text}") - - except requests.RequestException as e: - _log.error(f"Error when trying to change brightness of {entity_id}: {e}") + _post_method(url, headers, payload, f"turn off {entity_id}") def turn_on_lights(self, entity_id): url = f"http://{self.ip_address}:{self.port}/api/services/light/turn_on" @@ -312,16 +325,7 @@ def turn_on_lights(self, entity_id): payload = { "entity_id": f"{entity_id}" } - - try: - response = requests.post(url, headers=headers, data=json.dumps(payload)) - if response.status_code == 200: - _log.info(f"Turned on {entity_id}") - else: - _log.error(f"Failed to turn on {entity_id}. Status code: {response.status_code}. Response: {response.text}") - - except requests.RequestException as e: - _log.error(f"Error when trying to change brightness of {entity_id}: {e}") + _post_method(url, headers, payload, f"turn on {entity_id}") def change_thermostat_mode(self, entity_id, mode): # Check if enttiy_id startswith climate. @@ -340,11 +344,7 @@ def change_thermostat_mode(self, entity_id, mode): "hvac_mode": mode, } # Post data - response = requests.post(url, headers=headers, json=data) - if response.status_code == 200: - _log.info(f"Successfully changed the mode of {entity_id} to {mode}") - else: - _log.info(f"Failed to change the mode of {entity_id}. Response: {response.text}") + _post_method(url, headers, data, f"change mode of {entity_id} to {mode}") def set_thermostat_temperature(self, entity_id, temperature): # Check if the provided entity_id starts with "climate." @@ -370,34 +370,18 @@ def set_thermostat_temperature(self, entity_id, temperature): "entity_id": entity_id, "temperature": temperature, } - try: - response = requests.post(url, headers=headers, json=data) - - if response.status_code == 200: - _log.info(f"Successfully changed the temperature of {entity_id} to {temperature}") - else: - _log.error(f"Failed to change the temperature of {entity_id}. Response: {response.text}") - except requests.RequestException as e: - _log.error(f"Error when trying to change brightness of {entity_id}: {e}") + _post_method(url, headers, data, f"set temperature of {entity_id} to {temperature}") def change_brightness(self, entity_id, value): - url2 = f"http://{self.ip_address}:{self.port}/api/services/light/turn_on" + url = f"http://{self.ip_address}:{self.port}/api/services/light/turn_on" headers = { "Authorization": f"Bearer {self.access_token}", "Content-Type": "application/json", } - # ranges from 0 - 255 + # ranges from 0 - 255 payload = { "entity_id": f"{entity_id}", "brightness": value, } - try: - response = requests.post(url2, headers=headers, data=json.dumps(payload)) - if response.status_code == 200: - _log.info(f"Changed brightness of {entity_id} to {value}") - else: - _log.error(f"Failed to change brightness of {entity_id}. Status code: {response.status_code}. Response: {response.text}") - - except requests.RequestException as e: - _log.error(f"Error when trying to change brightness of {entity_id}: {e}") \ No newline at end of file + _post_method(url, headers, payload, f"set brightness of {entity_id} to {value}") diff --git a/services/core/PlatformDriverAgent/tests/test_home_assistant.py b/services/core/PlatformDriverAgent/tests/test_home_assistant.py index 29bed13e8c..562bcee44d 100644 --- a/services/core/PlatformDriverAgent/tests/test_home_assistant.py +++ b/services/core/PlatformDriverAgent/tests/test_home_assistant.py @@ -37,10 +37,8 @@ # }}} import json import logging -import os import pytest import gevent -import socket from volttron.platform.agent.known_identities import ( PLATFORM_DRIVER, @@ -54,9 +52,10 @@ utils.setup_logging() logger = logging.getLogger(__name__) -HOMEASSISTANT_TEST_IP = "" -ACCESS_TOKEN = "" -PORT = "" +HOMEASSISTANT_DEVICE_TOPIC = "devices/home_assistant" +HOMEASSISTANT_TEST_IP = "you ip" +ACCESS_TOKEN = "your access token" +PORT = "8123" skip_msg = "Some configuration variables are not set. Check HOMEASSISTANT_TEST_IP, ACCESS_TOKEN, and PORT" @@ -65,8 +64,9 @@ not (HOMEASSISTANT_TEST_IP and ACCESS_TOKEN and PORT), reason=skip_msg ) - HOMEASSISTANT_DEVICE_TOPIC = "devices/home_assistant" + + # Get the point which will should be off def test_get_point(volttron_instance, config_store): expected_values = "off" @@ -74,16 +74,18 @@ def test_get_point(volttron_instance, config_store): result = agent.vip.rpc.call(PLATFORM_DRIVER, 'get_point', 'home_assistant', 'state').get(timeout=20) assert result == expected_values, "The result does not match the expected result." + # The default value for this fake light is 3. If the test cannot reach out to home assistant, -# the value will default to 3 meking the test fail. +# the value will default to 3 meking the test fail. def test_data_poll(volttron_instance: PlatformWrapper, config_store): expected_values = [{'state': 0}, {'state': 1}] agent = volttron_instance.dynamic_agent result = agent.vip.rpc.call(PLATFORM_DRIVER, 'scrape_all', 'home_assistant').get(timeout=20) assert result in expected_values, "The result does not match the expected result." + # Turn on the light. Light is automatically turned off every 30 seconds to allow test to turn -# it on and receive the correct value. +# it on and receive the correct value. def test_set_point(volttron_instance, config_store): expected_values = {'state': 1} agent = volttron_instance.dynamic_agent @@ -92,6 +94,7 @@ def test_set_point(volttron_instance, config_store): result = agent.vip.rpc.call(PLATFORM_DRIVER, 'scrape_all', 'home_assistant').get(timeout=20) assert result == expected_values, "The result does not match the expected result." + @pytest.fixture(scope="module") def config_store(volttron_instance, platform_driver): @@ -159,4 +162,5 @@ def platform_driver(volttron_instance): yield platform_uuid volttron_instance.stop_agent(platform_uuid) - #volttron_instance.remove_agent(platform_uuid) + if not volttron_instance.debug_mode: + volttron_instance.remove_agent(platform_uuid)