diff --git a/haaska/.gitignore b/haaska/.gitignore new file mode 100644 index 0000000..5570802 --- /dev/null +++ b/haaska/.gitignore @@ -0,0 +1,104 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so +.noseids +# Distribution / packaging +.vscode/ +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# dotenv +.env + +# virtualenv +.venv +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +build +.DS_Store +haaska.zip +/.vs +/.idea/ diff --git a/haaska/CHANGELOG.md b/haaska/CHANGELOG.md new file mode 100644 index 0000000..2c96823 --- /dev/null +++ b/haaska/CHANGELOG.md @@ -0,0 +1,61 @@ +# haaska Changelog + +## [0.5] - 2018-09-18 +### +- Breaking Change: Remove support for the legacy_auth provider in homeassistant +- Implemented authentication using Long-Lived Access Tokens + +## [0.4] - 2018-01-24 +### +- Changed code to work with Hass 0.62, please note this skill will break on any version older than this. + +## [0.3.1] - 2017-06-24 +### Changed +- Hotfix for a logic error in exposed/hidden entities. +- Fixed a few formatting and link errors in the changelog. + +## [0.3] - 2017-06-24 +### Added +- There's now a `discover` target in the `Makefile`, which will send a discovery + request to your running haaska instance using the AWS CLI and print the + results using `jq`. This is helpful for debugging configuration changes. +- Color temperature can now be incremented and decremented (*"Alexa, make lamp + cooler"*, *"Alexa, make lamp warmer"*). +- `input_slider`, `automation`, and `alert` entities are now supported. +- There's now a configuration option to hide Home Assistant entities by default. + +### Changed +- haaska will no longer wait for a response from a POST to Home Assistant. This + reduces the delay between issuing a command and getting a confirmation from + Alexa on some devices. +- haaska will now accept numbers encoded as strings for thermostat commands. + This makes haaska a bit more robust, since these commands seem to have + inconsistent types at times. + +## [0.2] - 2017-05-07 +### Added +- Support for controlling the color (*"Alexa, turn kitchen green"*) and color + temperature (*"Alexa, set lamp to cool white"*) of lights. +- Support for controlling fans. + +### Changed +- The format of `config.json` has changed, though old formats are still + supported. To migrate to the new format, run `make modernize_config`. +- Instead of the hardcoded "Group" and "Scene" suffixes on entities in those + domains, the suffix is now configurable on a per-domain basis using the + `entity_suffixes` key in the configuration file. +- Entities hidden in Home Assistant (via the `hidden` attribute) are now hidden + from haaska. +- Improved logging, and added a way to increase verbosity for + debugging. Set the `debug` key in the configuration to `true` + to enable more verbose logging to CloudWatch. + +## [0.1] - 2017-03-19 + +First tagged release. + +[unreleased]: https://github.com/auchter/haaska/tree/dev +[0.3.1]: https://github.com/auchter/haaska/tree/0.3.1 +[0.3]: https://github.com/auchter/haaska/tree/0.3 +[0.2]: https://github.com/auchter/haaska/tree/0.2 +[0.1]: https://github.com/auchter/haaska/tree/0.1 diff --git a/haaska/Dockerfile b/haaska/Dockerfile new file mode 100644 index 0000000..466d9a8 --- /dev/null +++ b/haaska/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.10 + +RUN \ + apt-get update && \ + apt-get install -y jq zip && \ + pip install awscli && \ + apt-get clean && \ + cd /var/lib/apt/lists && rm -fr *Release* *Sources* *Packages* && \ + truncate -s 0 /var/log/*log + +RUN mkdir -p /usr/src/app + +WORKDIR /usr/src/app + +COPY . /usr/src/app + +CMD ["make"] diff --git a/haaska/LICENSE b/haaska/LICENSE new file mode 100644 index 0000000..063efb3 --- /dev/null +++ b/haaska/LICENSE @@ -0,0 +1,23 @@ +MIT License + +Copyright (c) 2015 Michael Auchter +Copyright (c) 2018 Phil Frost (bitglue) +Copyright (c) 2018 Mike Grant and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/haaska/Makefile b/haaska/Makefile new file mode 100644 index 0000000..8141ab9 --- /dev/null +++ b/haaska/Makefile @@ -0,0 +1,75 @@ + +SHELL := /bin/bash + +# Function name in AWS Lambda: +FUNCTION_NAME=haaska + +BUILD_DIR=build + +PIPVERSIONEQ9 := $(shell expr `pip3 -V | cut -d ' ' -f 2` \= 9.0.1) + +ifneq (,$(wildcard /etc/debian_version)) + ifeq "$(PIPVERSIONEQ9)" "1" + PIP_VER=3 + PIP_EXTRA = --system + endif +else + PIP_VER = + PIP_EXTRA = +endif + +haaska.zip: haaska.py config/* + mkdir -p $(BUILD_DIR) + cp $^ $(BUILD_DIR) + pip$(PIP_VER) install $(PIP_EXTRA) -t $(BUILD_DIR) requests + chmod 755 $(BUILD_DIR)/haaska.py + cd $(BUILD_DIR); zip ../$@ -r * + +.PHONY: deploy +deploy: haaska.zip + aws lambda update-function-configuration \ + --function-name $(FUNCTION_NAME) \ + --handler haaska.event_handler + aws lambda update-function-code \ + --function-name $(FUNCTION_NAME) \ + --zip-file fileb://$< + +DISCOVERY_PAYLOAD:=' \ +{ \ + "directive": { \ + "header": { \ + "namespace": "Alexa.Discovery", \ + "name": "Discover", \ + "payloadVersion": "3", \ + "messageId": "1bd5d003-31b9-476f-ad03-71d471922820" \ + }, \ + "payload": { \ + "scope": { \ + "type": "BearerToken", \ + "token": "access-token-from-skill" \ + } \ + } \ + } \ +}' + +.PHONY: discover +discover: + @aws lambda invoke \ + --function-name $(FUNCTION_NAME) \ + --payload ${DISCOVERY_PAYLOAD} \ + /dev/fd/3 3>&1 >/dev/null | jq '.' + + +.PHONY: clean +clean: + rm -rf $(BUILD_DIR) haaska.zip + +.PHONY: sample_config +sample_config: + python -c 'from haaska import Configuration; print(Configuration().dump())' > config/config.json.sample + +.PHONY: modernize_config +modernize_config: config/config.json + @python -c 'from haaska import Configuration; print(Configuration("config/config.json").dump())' > config/config.json.modernized + @echo Generated config/config.json.modernized from your existing config/config.json + @echo Inspect that file and replace config/config.json with it to update your configuration diff --git a/haaska/README.md b/haaska/README.md new file mode 100644 index 0000000..fb39679 --- /dev/null +++ b/haaska/README.md @@ -0,0 +1,33 @@ +# haaska: Home Assistant Alexa Skill Adapter + +[![Main](https://github.com/mike-grant/haaska/actions/workflows/main.yml/badge.svg)](https://github.com/mike-grant/haaska/actions/workflows/main.yml) + +--- + +haaska implements a bridge between the [Home Assistant Smart Home API](https://www.home-assistant.io/components/alexa/#smart-home) and the [Alexa Smart Home Skill API](https://developer.amazon.com/alexa/smart-home) from Amazon. + +This provides voice control for a connected home managed by Home Assistant, through any Alexa-enabled device. + +### Getting Started +To get started, head over to the [haaska Wiki](https://github.com/mike-grant/haaska/wiki). + +### Development + +Run tests + +``` +python -m pytest test.py +``` + +### Thanks and Acknowledgement + +Thanks to [@auchter](https://github.com/auchter) for creating the original haaska. + +Thanks to [@bitglue](https://github.com/bitglue) for his work in getting the Smart Home API exposed via HTTP, making this slimmed down version possible. + +This fork of haaska was created by [@mike-grant](https://github.com/mike-grant). + +Documentation and additional maintenance is done by [@anthonylavado](https://github.com/anthonylavado), and contributors like you. + +### License +haaska is provided under the [MIT License](LICENSE). diff --git a/haaska/config/config.json.sample b/haaska/config/config.json.sample new file mode 100644 index 0000000..2becfc1 --- /dev/null +++ b/haaska/config/config.json.sample @@ -0,0 +1,7 @@ +{ + "url": "http://localhost:8123/api", + "bearer_token": "", + "debug": false, + "ssl_verify": true, + "ssl_client": [] +} diff --git a/haaska/haaska.py b/haaska/haaska.py new file mode 100644 index 0000000..b18d1a2 --- /dev/null +++ b/haaska/haaska.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python +# coding: utf-8 + +# Copyright (c) 2015 Michael Auchter +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import os +import json +import logging +import requests + +logger = logging.getLogger() + + +class HomeAssistant(object): + def __init__(self, config): + self.config = config + + self.session = requests.Session() + self.session.headers = { + 'Authorization': f'Bearer {config.bearer_token}', + 'content-type': 'application/json', + 'User-Agent': self.get_user_agent() + } + self.session.verify = config.ssl_verify + self.session.cert = config.ssl_client + + def build_url(self, endpoint): + return f'{self.config.url}/api/{endpoint}' + + def get_user_agent(self): + library = "Home Assistant Alexa Smart Home Skill" + aws_region = os.environ.get("AWS_DEFAULT_REGION") + logger.debug( + f'AWS_DEFAULT_REGION is { aws_region}') + default_user_agent = requests.utils.default_user_agent() + return f"{library} - {aws_region} - {default_user_agent}" + + def get(self, endpoint): + r = self.session.get(self.build_url(endpoint)) + r.raise_for_status() + return r.json() + + def post(self, endpoint, data, wait=False): + read_timeout = None if wait else 0.01 + try: + logger.debug(f'calling {endpoint} with {data}') + r = self.session.post(self.build_url(endpoint), + data=json.dumps(data), + timeout=(None, read_timeout)) + r.raise_for_status() + return r.json() + except requests.exceptions.ReadTimeout: + # Allow response timeouts after request was sent + logger.debug( + f'request for {endpoint} sent without waiting for response') + return None + + +class Configuration(object): + def __init__(self, filename=None, opts_dict=None): + self._json = {} + if filename is not None: + with open(filename) as f: + self._json = json.load(f) + + if opts_dict is not None: + self._json = opts_dict + + self.url = os.environ.get("HA_URL") + self.bearer_token = os.environ.get("HA_TOKEN") + self.ssl_verify = os.environ.get("SSL_VERIFY", default=False) + self.ssl_client = os.environ.get("SSL_CLIENT", default='') + self.debug = os.environ.get("DEBUG", default=False) + + logger.debug( + f'HA_URL is { self.url}') + + if self.debug: + logger.setLevel(logging.DEBUG) + + def get(self, keys, default=None): + for key in keys: + if key in self._json: + return self._json[key] + return default + + def get_url(self, url): + """Returns Home Assistant base url without '/api' or trailing slash""" + if not url: + raise ValueError('Property "url" is missing in config') + + return url.replace("/api", "").rstrip("/") + + +def event_handler(event, context): + config = Configuration('config.json') + if config.debug: + logger.setLevel(logging.DEBUG) + ha = HomeAssistant(config) + + return ha.post('alexa/smart_home', event, wait=True) diff --git a/haaska/images/108x108.png b/haaska/images/108x108.png new file mode 100644 index 0000000..f1a56e1 Binary files /dev/null and b/haaska/images/108x108.png differ diff --git a/haaska/images/512x512.png b/haaska/images/512x512.png new file mode 100644 index 0000000..e48e6ea Binary files /dev/null and b/haaska/images/512x512.png differ diff --git a/haaska/requirements-test.txt b/haaska/requirements-test.txt new file mode 100644 index 0000000..bb2d7d8 --- /dev/null +++ b/haaska/requirements-test.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==7.1.2 +flake8==4.0.1 diff --git a/haaska/requirements.txt b/haaska/requirements.txt new file mode 100644 index 0000000..566083c --- /dev/null +++ b/haaska/requirements.txt @@ -0,0 +1 @@ +requests==2.22.0 diff --git a/haaska/test.py b/haaska/test.py new file mode 100644 index 0000000..c2d81d5 --- /dev/null +++ b/haaska/test.py @@ -0,0 +1,48 @@ +import os +import pytest + +from haaska import HomeAssistant, Configuration + + +@pytest.fixture +def configuration(): + return Configuration(opts_dict={ + "url": "http://localhost:8123", + "bearer_token": "", + "debug": False, + "ssl_verify": True, + "ssl_client": [] + }) + + +@pytest.fixture +def home_assistant(configuration): + return HomeAssistant(configuration) + + +def test_ha_build_url(home_assistant): + url = home_assistant.build_url("test") + assert url == "http://localhost:8123/api/test" + + +def test_get_user_agent(home_assistant): + os.environ["AWS_DEFAULT_REGION"] = "test" + user_agent = home_assistant.get_user_agent() + assert user_agent.startswith("Home Assistant Alexa Smart Home Skill - test - python-requests/") + + +def test_config_get(configuration): + assert configuration.get(["debug"]) is False + assert configuration.get(["test"]) is None + assert configuration.get(["test"], default="default") == "default" + + +def test_config_get_url(configuration): + test_urls = [ + "http://hass.example.com:8123", + "http://hass.example.app" + ] + for expected_url in test_urls: + assert configuration.get_url(expected_url + "/") == expected_url + assert configuration.get_url(expected_url + "/api") == expected_url + assert configuration.get_url(expected_url + "/api/") == expected_url