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

Added SSL verification to Home Assistant driver #3153

Open
wants to merge 9 commits into
base: releases/9.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pytest-web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ jobs:

# Run the specified tests and save the results to a unique file that can be archived for later analysis.
- name: Run pytest on ${{ matrix.python-version }}, ${{ matrix.os }}
uses: volttron/volttron-build-action@v7
uses: volttron/volttron-build-action@v6
with:
python_version: ${{ matrix.python-version }}
os: ${{ matrix.os }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,31 +41,44 @@ 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"
Device configuration file contains the connection details to you home assistant instance.

- **url**:
Replace ``[Your Home Assistant IP]`` and ``[Your Port]`` with your Home Assistant's IP address and port number, respectively, removing the brackets ``[]``. Ensure you specify the protocol (``http`` or ``https``) based on your setup. Refer to the `Home Assistant documentation <https://developers.home-assistant.io/docs/auth_api/#long-lived-access-token>`_ for finding your IP address.

- **access_token**:
Substitute ``[Your Home Assistant Access Token]`` with your actual access token, again removing the brackets ``[]``. For instructions on obtaining your access token, visit `this guide <https://www.raycast.com/tonka3000/homeassistant>`_.

- **verify_ssl**:
Set to true to enable SSL certificate verification or false to bypass it. Default is true. Disabling verification may pose security risks.

- **ssl_cert_path**:
Enter the path to your SSL certificate if you are using a custom certificate for verification. Leave this field empty if you are not using a custom certificate. This field is empty by default.

.. code-block:: json

{
"driver_config": {
"ip_address": "Your Home Assistant IP",
"access_token": "Your Home Assistant Access Token",
"port": "Your Port"
},
"driver_type": "home_assistant",
"registry_config": "config://light.example.json",
"interval": 30,
"timezone": "UTC"
}
{
"driver_config": {
"url": "http://[Your Home Assistant IP]:[Your Port]",
"access_token": "[Your Home Assistant Access Token]",
"verify_ssl": true,
"ssl_cert_path": ""
},
"driver_type": "home_assistant",
"registry_config": "config://light.example.json",
"interval": 30,
"timezone": "UTC"
}

Registry Configuration
+++++++++++++++++++++++

Registry file can contain one single device and its attributes or a logical group of devices and its
attributes. Each entry should include the full entity id of the device, including but not limited to home assistant provided prefix
attributes. Each entry should include the full entity id of the device, including but not limited to home assistant provided prefix
such as "light.", "climate." etc. 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 'Entity Point' and a unique value for 'Volttron Point Name'. The 'Entity ID' maps to the device instance, the 'Entity Point' extracts the attribute or state, and 'Volttron Point Name' determines the name of that point as it appears in VOLTTRON.

Attributes can be located in the developer tools in the Home Assistant GUI.
Expand Down Expand Up @@ -108,7 +121,7 @@ id 'light.example':
.. note::

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.
"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
Expand Down Expand Up @@ -174,13 +187,4 @@ Upon completion, initiate the platform driver. Utilize the listener agent to ver
2023-09-12 11:37:00,226 (listeneragent-3.3 211531) __main__ INFO: Peer: pubsub, Sender: platform.driver:, Bus: , Topic: devices/BUILDING/ROOM/light.example/all, Headers: {'Date': '2023-09-12T18:37:00.224648+00:00', 'TimeStamp': '2023-09-12T18:37:00.224648+00:00', 'SynchronizedTimeStamp': '2023-09-12T18:37:00.000000+00:00', 'min_compatible_version': '3.0', 'max_compatible_version': ''}, Message:
[{'light_brightness': 254, 'state': 'on'},
{'light_brightness': {'type': 'integer', 'tz': 'UTC', 'units': 'int'},
'state': {'type': 'integer', 'tz': 'UTC', 'units': 'On / Off'}}]

Running Tests
+++++++++++++++++++++++
To run tests on the VOLTTRON home assistant driver you need to create a helper in your home assistant instance. This can be done by going to **Settings > Devices & services > Helpers > Create Helper > Toggle**. Name this new toggle **volttrontest**. After that run the pytest from the root of your VOLTTRON file.

.. code-block:: bash
pytest volttron/services/core/PlatformDriverAgent/tests/test_home_assistant.py

If everything works, you will see 6 passed tests.
'state': {'type': 'integer', 'tz': 'UTC', 'units': 'On / Off'}}]
13 changes: 0 additions & 13 deletions pyproject.toml

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
#
# ===----------------------------------------------------------------------===
#
# Copyright 2023 Battelle Memorial Institute
# Copyright 2024 Battelle Memorial Institute
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy
Expand Down Expand Up @@ -35,16 +35,20 @@
from requests import get

_log = logging.getLogger(__name__)
type_mapping = {"string": str,
"int": int,
"integer": int,
"float": float,
"bool": bool,
"boolean": bool}
type_mapping = {"string": str, "int": int, "integer": int, "float": float, "bool": bool, "boolean": bool}


class HomeAssistantRegister(BaseRegister):
def __init__(self, read_only, pointName, units, reg_type, attributes, entity_id, entity_point, default_value=None,

def __init__(self,
read_only,
pointName,
units,
reg_type,
attributes,
entity_id,
entity_point,
default_value=None,
description=''):
super(HomeAssistantRegister, self).__init__("byte", read_only, pointName, units, description='')
self.reg_type = reg_type
Expand All @@ -57,7 +61,7 @@ def __init__(self, read_only, pointName, units, reg_type, attributes, entity_id,
def _post_method(url, headers, data, operation_description):
err = None
try:
response = requests.post(url, headers=headers, json=data)
response = requests.post(url, headers=headers, json=data, verify=self.verify_option)
riley206 marked this conversation as resolved.
Show resolved Hide resolved
if response.status_code == 200:
_log.info(f"Success: {operation_description}")
else:
Expand All @@ -72,29 +76,39 @@ def _post_method(url, headers, data, operation_description):


class Interface(BasicRevert, BaseInterface):

def __init__(self, **kwargs):
super(Interface, self).__init__(**kwargs)
self.point_name = None
self.ip_address = None
self.url = None
self.access_token = None
self.port = None
self.verify_ssl = True # Default to True for security
self.units = None

def configure(self, config_dict, registry_config_str):
self.ip_address = config_dict.get("ip_address", None)
self.url = config_dict.get("url", None)
self.access_token = config_dict.get("access_token", None)
self.port = config_dict.get("port", None)
self.verify_ssl = config_dict.get("verify_ssl", False)
self.ssl_cert_path = config_dict.get("ssl_cert_path", "")

# Check for None values
if self.ip_address is None:
_log.error("IP address is not set.")
raise ValueError("IP address is required.")
if self.url is None:
_log.error("URL address is not set.")
raise ValueError("URL is required.")
if self.access_token is None:
_log.error("Access token is not set.")
raise ValueError("Access token is required.")
if self.port is None:
_log.error("Port is not set.")
raise ValueError("Port is required.")

if not self.verify_ssl:
import urllib3
riley206 marked this conversation as resolved.
Show resolved Hide resolved
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

if self.ssl_cert_path:
self.verify_option = self.ssl_cert_path
else:
self.verify_option = self.verify_ssl

_log.info(f"using verify option: {self.verify_option}")

self.parse_config(registry_config_str)

Expand All @@ -112,9 +126,8 @@ def get_point(self, point_name):
def _set_point(self, point_name, value):
register = self.get_register_by_name(point_name)
if register.read_only:
raise IOError(
"Trying to write to a point configured read only: " + point_name)
register.value = register.reg_type(value) # setting the value
raise IOError("Trying to write to a point configured read only: " + point_name)
register.value = register.reg_type(value) # setting the value
entity_point = register.entity_point
# Changing lights values in home assistant based off of register value.
if "light." in register.entity_id:
Expand All @@ -130,7 +143,8 @@ def _set_point(self, point_name, value):
raise ValueError(error_msg)

elif entity_point == "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 @@ -191,10 +205,10 @@ def get_entity_data(self, point_name):
"Content-Type": "application/json",
}
# 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)
url = f"{self.url}/api/states/{point_name}"
response = requests.get(url, headers=headers, verify=self.verify_option)
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}, " \
f"response: {response.text}"
Expand All @@ -210,8 +224,8 @@ def _scrape_all(self):
entity_id = register.entity_id
entity_point = register.entity_point
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 entity_point == "state":
state = entity_data.get("state", None)
# Giving thermostat states an equivalent number.
Expand All @@ -237,7 +251,7 @@ def _scrape_all(self):
register.value = attribute
result[register.point_name] = attribute
# handling light states
elif "light." or "input_boolean." in entity_id: # Checks for lights or input bools since they have the same states.
elif "light." or "input_boolean." in entity_id: # Checks for lights or input bools since they have the same states.
if entity_point == "state":
state = entity_data.get("state", None)
# Converting light states to numbers.
Expand All @@ -251,7 +265,7 @@ def _scrape_all(self):
attribute = entity_data.get("attributes", {}).get(f"{entity_point}", 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 entity_point == "state":

state = entity_data.get("state", None)
Expand Down Expand Up @@ -288,24 +302,23 @@ def parse_config(self, config_dict):
attributes = regDef.get('Attributes', {})
register_type = HomeAssistantRegister

register = register_type(
read_only,
self.point_name,
self.units,
reg_type,
attributes,
entity_id,
entity_point,
default_value=default_value,
description=description)
register = register_type(read_only,
self.point_name,
self.units,
reg_type,
attributes,
entity_id,
entity_point,
default_value=default_value,
description=description)

if default_value is not None:
self.set_default(self.point_name, register.value)

self.insert_register(register)

def turn_off_lights(self, entity_id):
url = f"http://{self.ip_address}:{self.port}/api/services/light/turn_off"
url = f"{self.url}/api/services/light/turn_off"
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
Expand All @@ -316,15 +329,13 @@ def turn_off_lights(self, entity_id):
_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"
url = f"{self.url}/api/services/light/turn_on"
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
}

payload = {
"entity_id": f"{entity_id}"
}
payload = {"entity_id": f"{entity_id}"}
_post_method(url, headers, payload, f"turn on {entity_id}")

def change_thermostat_mode(self, entity_id, mode):
Expand All @@ -333,10 +344,10 @@ def change_thermostat_mode(self, entity_id, mode):
_log.error(f"{entity_id} is not a valid thermostat entity ID.")
return
# Build header
url = f"http://{self.ip_address}:{self.port}/api/services/climate/set_hvac_mode"
url = f"{self.url}/api/services/climate/set_hvac_mode"
headers = {
"Authorization": f"Bearer {self.access_token}",
"content-type": "application/json",
"Authorization": f"Bearer {self.access_token}",
"content-type": "application/json",
}
# Build data
data = {
Expand All @@ -352,14 +363,14 @@ def set_thermostat_temperature(self, entity_id, temperature):
_log.error(f"{entity_id} is not a valid thermostat entity ID.")
return

url = f"http://{self.ip_address}:{self.port}/api/services/climate/set_temperature"
url = f"{self.url}/api/services/climate/set_temperature"
headers = {
"Authorization": f"Bearer {self.access_token}",
"content-type": "application/json",
}

if self.units == "C":
converted_temp = round((temperature - 32) * 5/9, 1)
converted_temp = round((temperature - 32) * 5 / 9, 1)
_log.info(f"Converted temperature {converted_temp}")
data = {
"entity_id": entity_id,
Expand All @@ -373,10 +384,10 @@ def set_thermostat_temperature(self, entity_id, temperature):
_post_method(url, headers, data, f"set temperature of {entity_id} to {temperature}")

def change_brightness(self, entity_id, value):
url = f"http://{self.ip_address}:{self.port}/api/services/light/turn_on"
url = f"{self.url}/api/services/light/turn_on"
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
}
# ranges from 0 - 255
payload = {
Expand All @@ -388,20 +399,18 @@ def change_brightness(self, entity_id, value):

def set_input_boolean(self, entity_id, state):
service = 'turn_on' if state == 'on' else 'turn_off'
url = f"http://{self.ip_address}:{self.port}/api/services/input_boolean/{service}"
url = f"{self.url}/api/services/input_boolean/{service}"
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
}

payload = {
"entity_id": entity_id
}
payload = {"entity_id": entity_id}

response = requests.post(url, headers=headers, json=payload)
response = requests.post(url, headers=headers, json=payload, verify=self.verify_option)

# Optionally check for a successful response
if response.status_code == 200:
print(f"Successfully set {entity_id} to {state}")
else:
print(f"Failed to set {entity_id} to {state}: {response.text}")
print(f"Failed to set {entity_id} to {state}: {response.text}")
Loading
Loading