Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Home assistant updates #2

Merged
merged 12 commits into from
Oct 10, 2023
8 changes: 4 additions & 4 deletions docs/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <https://github.com/riley206/Rileys_volttron/blob/55146b78d3ab7f53d08598df272cdda2d0aa8d3d/services/core/PlatformDriverAgent/README.md>`_.
The following diagram shows interaction between platform driver agent and home assistant driver.

.. mermaid::

Expand All @@ -19,18 +20,29 @@ For further details, refer to the README for the platform driver `here <https://
HomeAssistant Driver->>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 <https://developers.home-assistant.io/docs/auth_api/#long-lived-access-token>`_.

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 <https://volttron.readthedocs.io/en/main/introduction/platform-install.html#installing-and-running-agents>`_
- `Platform driver agent <https://volttron.readthedocs.io/en/main/agent-framework/core-service-agents/platform-driver/platform-driver-agent.html?highlight=platform%20driver%20isntall#configuring-the-platform-driver>`_

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 <https://volttron.readthedocs.io/en/main/agent-framework/driver-framework/platform-driver/platform-driver.html#configuration-and-installation>`_
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

{
Expand All @@ -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

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

Expand Down Expand Up @@ -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:

Expand Down
3 changes: 2 additions & 1 deletion docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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.
Expand All @@ -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."
Expand All @@ -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}")
_post_method(url, headers, payload, f"set brightness of {entity_id} to {value}")
Loading
Loading