Compare commits
1 commit
dev
...
upgrade-se
Author | SHA1 | Date | |
---|---|---|---|
![]() |
39da707878 |
9131 changed files with 296420 additions and 635046 deletions
16
.codecov.yml
16
.codecov.yml
|
@ -1,16 +0,0 @@
|
|||
codecov:
|
||||
branch: dev
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: 90
|
||||
threshold: 0.09
|
||||
notify:
|
||||
# Notify codecov room in Discord. The webhook URL (encrypted below) ends in /slack which is why we configure a Slack notification.
|
||||
slack:
|
||||
default:
|
||||
url: "secret:TgWDUM4Jw0w7wMJxuxNF/yhSOHglIo1fGwInJnRLEVPy2P2aLimkoK1mtKCowH5TFw+baUXVXT3eAqefbdvIuM8BjRR4aRji95C6CYyD0QHy4N8i7nn1SQkWDPpS8IthYTg07rUDF7s5guurkKv2RrgoCdnnqjAMSzHoExMOF7xUmblMdhBTWJgBpWEhASJy85w/xxjlsE1xoTkzeJu9Q67pTXtRcn+5kb5/vIzPSYg="
|
||||
comment:
|
||||
require_changes: yes
|
||||
branches: master
|
1599
.coveragerc
1599
.coveragerc
File diff suppressed because it is too large
Load diff
|
@ -1,33 +0,0 @@
|
|||
{
|
||||
"name": "Home Assistant Dev",
|
||||
"context": "..",
|
||||
"dockerFile": "../Dockerfile.dev",
|
||||
"postCreateCommand": "mkdir -p config && pip3 install -e .",
|
||||
"appPort": 8123,
|
||||
"runArgs": ["-e", "GIT_EDITOR=code --wait"],
|
||||
"extensions": [
|
||||
"ms-python.python",
|
||||
"visualstudioexptteam.vscodeintellicode",
|
||||
"ms-azure-devops.azure-pipelines",
|
||||
"redhat.vscode-yaml",
|
||||
"esbenp.prettier-vscode"
|
||||
],
|
||||
"settings": {
|
||||
"python.pythonPath": "/usr/local/bin/python",
|
||||
"python.linting.pylintEnabled": true,
|
||||
"python.linting.enabled": true,
|
||||
"python.formatting.provider": "black",
|
||||
"editor.formatOnPaste": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnType": true,
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"terminal.integrated.shell.linux": "/bin/bash",
|
||||
"yaml.customTags": [
|
||||
"!secret scalar",
|
||||
"!include_dir_named scalar",
|
||||
"!include_dir_list scalar",
|
||||
"!include_dir_merge_list scalar",
|
||||
"!include_dir_merge_named scalar"
|
||||
]
|
||||
}
|
||||
}
|
8
.github/ISSUE_TEMPLATE.md
vendored
8
.github/ISSUE_TEMPLATE.md
vendored
|
@ -1,9 +1,7 @@
|
|||
<!-- READ THIS FIRST:
|
||||
- If you need additional help with this template please refer to https://www.home-assistant.io/help/reporting_issues/
|
||||
- Make sure you are running the latest version of Home Assistant before reporting an issue: https://github.com/home-assistant/home-assistant/releases
|
||||
- Frontend issues should be submitted to the home-assistant-polymer repository: https://github.com/home-assistant/home-assistant-polymer/issues
|
||||
- iOS issues should be submitted to the home-assistant-iOS repository: https://github.com/home-assistant/home-assistant-iOS/issues
|
||||
- Do not report issues for integrations if you are using custom integration: files in <config-dir>/custom_components
|
||||
- Do not report issues for components if you are using custom components: files in <config-dir>/custom_components
|
||||
- This is for bugs only. Feature and enhancement requests should go in our community forum: https://community.home-assistant.io/c/feature-requests
|
||||
- Provide as many details as possible. Paste logs, configuration sample and code into the backticks. Do not delete any text from this template!
|
||||
-->
|
||||
|
@ -23,9 +21,9 @@
|
|||
Please provide details about your environment.
|
||||
-->
|
||||
|
||||
**Integration:**
|
||||
**Component/platform:**
|
||||
<!--
|
||||
Please add the link to the documentation at https://www.home-assistant.io/integrations/ of the integration in question.
|
||||
Please add the link to the documentation at https://www.home-assistant.io/components/ of the component/platform in question.
|
||||
-->
|
||||
|
||||
|
||||
|
|
8
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
8
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
|
@ -7,9 +7,7 @@ about: Create a report to help us improve
|
|||
<!-- READ THIS FIRST:
|
||||
- If you need additional help with this template please refer to https://www.home-assistant.io/help/reporting_issues/
|
||||
- Make sure you are running the latest version of Home Assistant before reporting an issue: https://github.com/home-assistant/home-assistant/releases
|
||||
- Frontend issues should be submitted to the home-assistant-polymer repository: https://github.com/home-assistant/home-assistant-polymer/issues
|
||||
- iOS issues should be submitted to the home-assistant-iOS repository: https://github.com/home-assistant/home-assistant-iOS/issues
|
||||
- Do not report issues for integrations if you are using a custom integration: files in <config-dir>/custom_components
|
||||
- Do not report issues for components if you are using custom components: files in <config-dir>/custom_components
|
||||
- This is for bugs only. Feature and enhancement requests should go in our community forum: https://community.home-assistant.io/c/feature-requests
|
||||
- Provide as many details as possible. Paste logs, configuration sample and code into the backticks. Do not delete any text from this template!
|
||||
-->
|
||||
|
@ -29,9 +27,9 @@ about: Create a report to help us improve
|
|||
Please provide details about your environment.
|
||||
-->
|
||||
|
||||
**Integration:**
|
||||
**Component/platform:**
|
||||
<!--
|
||||
Please add the link to the documentation at https://www.home-assistant.io/integrations/ of the integration in question.
|
||||
Please add the link to the documentation at https://www.home-assistant.io/components/ of the component/platform in question.
|
||||
-->
|
||||
|
||||
|
||||
|
|
21
.github/PULL_REQUEST_TEMPLATE.md
vendored
21
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
@ -1,13 +1,9 @@
|
|||
## Breaking Change:
|
||||
|
||||
<!-- What is breaking and why we have to break it. Remove this section only if it was NOT a breaking change. -->
|
||||
|
||||
## Description:
|
||||
|
||||
|
||||
**Related issue (if applicable):** fixes #<home-assistant issue number goes here>
|
||||
|
||||
**Pull request with documentation for [home-assistant.io](https://github.com/home-assistant/home-assistant.io) (if applicable):** home-assistant/home-assistant.io#<home-assistant.io PR number goes here>
|
||||
**Pull request in [home-assistant.github.io](https://github.com/home-assistant/home-assistant.github.io) with documentation (if applicable):** home-assistant/home-assistant.github.io#<home-assistant.github.io PR number goes here>
|
||||
|
||||
## Example entry for `configuration.yaml` (if applicable):
|
||||
```yaml
|
||||
|
@ -17,19 +13,18 @@
|
|||
## Checklist:
|
||||
- [ ] The code change is tested and works locally.
|
||||
- [ ] Local tests pass with `tox`. **Your PR cannot be merged unless tests pass**
|
||||
- [ ] There is no commented out code in this PR.
|
||||
- [ ] I have followed the [development checklist][dev-checklist]
|
||||
|
||||
If user exposed functionality or configuration variables are added/changed:
|
||||
- [ ] Documentation added/updated in [home-assistant.io](https://github.com/home-assistant/home-assistant.io)
|
||||
- [ ] Documentation added/updated in [home-assistant.github.io](https://github.com/home-assistant/home-assistant.github.io)
|
||||
|
||||
If the code communicates with devices, web services, or third-party tools:
|
||||
- [ ] [_The manifest file_][manifest-docs] has all fields filled out correctly. Update and include derived files by running `python3 -m script.hassfest`.
|
||||
- [ ] New or updated dependencies have been added to `requirements_all.txt` by running `python3 -m script.gen_requirements_all`.
|
||||
- [ ] Untested files have been added to `.coveragerc`.
|
||||
- [ ] New dependencies have been added to the `REQUIREMENTS` variable ([example][ex-requir]).
|
||||
- [ ] New dependencies are only imported inside functions that use them ([example][ex-import]).
|
||||
- [ ] New or updated dependencies have been added to `requirements_all.txt` by running `script/gen_requirements_all.py`.
|
||||
- [ ] New files were added to `.coveragerc`.
|
||||
|
||||
If the code does not interact with devices:
|
||||
- [ ] Tests have been added to verify that the new code works.
|
||||
|
||||
[dev-checklist]: https://developers.home-assistant.io/docs/en/development_checklist.html
|
||||
[manifest-docs]: https://developers.home-assistant.io/docs/en/creating_integration_manifest.html
|
||||
[ex-requir]: https://github.com/home-assistant/home-assistant/blob/dev/homeassistant/components/keyboard.py#L14
|
||||
[ex-import]: https://github.com/home-assistant/home-assistant/blob/dev/homeassistant/components/keyboard.py#L54
|
||||
|
|
27
.github/lock.yml
vendored
27
.github/lock.yml
vendored
|
@ -1,27 +0,0 @@
|
|||
# Configuration for Lock Threads - https://github.com/dessant/lock-threads
|
||||
|
||||
# Number of days of inactivity before a closed issue or pull request is locked
|
||||
daysUntilLock: 1
|
||||
|
||||
# Skip issues and pull requests created before a given timestamp. Timestamp must
|
||||
# follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable
|
||||
skipCreatedBefore: 2019-07-01
|
||||
|
||||
# Issues and pull requests with these labels will be ignored. Set to `[]` to disable
|
||||
exemptLabels: []
|
||||
|
||||
# Label to add before locking, such as `outdated`. Set to `false` to disable
|
||||
lockLabel: false
|
||||
|
||||
# Comment to post before locking. Set to `false` to disable
|
||||
lockComment: false
|
||||
|
||||
# Assign `resolved` as the reason for locking. Set to `false` to disable
|
||||
setLockReason: false
|
||||
|
||||
# Limit to only `issues` or `pulls`
|
||||
only: pulls
|
||||
|
||||
# Optionally, specify configuration settings just for `issues` or `pulls`
|
||||
issues:
|
||||
daysUntilLock: 30
|
55
.github/stale.yml
vendored
55
.github/stale.yml
vendored
|
@ -1,55 +0,0 @@
|
|||
# Configuration for probot-stale - https://github.com/probot/stale
|
||||
|
||||
# Number of days of inactivity before an Issue or Pull Request becomes stale
|
||||
daysUntilStale: 90
|
||||
|
||||
# Number of days of inactivity before an Issue or Pull Request with the stale label is closed.
|
||||
# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
|
||||
daysUntilClose: 7
|
||||
|
||||
# Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled)
|
||||
onlyLabels: []
|
||||
|
||||
# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable
|
||||
exemptLabels:
|
||||
- under investigation
|
||||
- Help wanted
|
||||
|
||||
# Set to true to ignore issues in a project (defaults to false)
|
||||
exemptProjects: true
|
||||
|
||||
# Set to true to ignore issues in a milestone (defaults to false)
|
||||
exemptMilestones: true
|
||||
|
||||
# Set to true to ignore issues with an assignee (defaults to false)
|
||||
exemptAssignees: false
|
||||
|
||||
# Label to use when marking as stale
|
||||
staleLabel: stale
|
||||
|
||||
# Comment to post when marking as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
There hasn't been any activity on this issue recently. Due to the high number
|
||||
of incoming GitHub notifications, we have to clean some of the old issues,
|
||||
as many of them have already been resolved with the latest updates.
|
||||
|
||||
Please make sure to update to the latest Home Assistant version and check
|
||||
if that solves the issue. Let us know if that works for you by adding a
|
||||
comment 👍
|
||||
|
||||
This issue now has been marked as stale and will be closed if no further
|
||||
activity occurs. Thank you for your contributions.
|
||||
|
||||
# Comment to post when removing the stale label.
|
||||
# unmarkComment: >
|
||||
# Your comment here.
|
||||
|
||||
# Comment to post when closing a stale Issue or Pull Request.
|
||||
# closeComment: >
|
||||
# Your comment here.
|
||||
|
||||
# Limit the number of actions per hour, from 1-30. Default is 30
|
||||
limitPerRun: 30
|
||||
|
||||
# Limit to only `issues` or `pulls`
|
||||
only: issues
|
25
.gitignore
vendored
25
.gitignore
vendored
|
@ -1,13 +1,8 @@
|
|||
config/*
|
||||
config2/*
|
||||
|
||||
tests/testing_config/deps
|
||||
tests/testing_config/home-assistant.log
|
||||
|
||||
# hass-release
|
||||
data/
|
||||
.token
|
||||
|
||||
# Hide sublime text stuff
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
@ -50,7 +45,6 @@ develop-eggs
|
|||
.installed.cfg
|
||||
lib
|
||||
lib64
|
||||
pip-wheel-metadata
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
@ -59,12 +53,8 @@ pip-log.txt
|
|||
# Unit test / coverage reports
|
||||
.coverage
|
||||
.tox
|
||||
coverage.xml
|
||||
nosetests.xml
|
||||
htmlcov/
|
||||
test-reports/
|
||||
test-results.xml
|
||||
test-output.xml
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
|
@ -88,12 +78,11 @@ venv
|
|||
.venv
|
||||
Pipfile*
|
||||
share/*
|
||||
Scripts/
|
||||
|
||||
# vimmy stuff
|
||||
*.swp
|
||||
*.swo
|
||||
tags
|
||||
|
||||
ctags.tmp
|
||||
|
||||
# vagrant stuff
|
||||
|
@ -102,10 +91,7 @@ virtualization/vagrant/.vagrant
|
|||
virtualization/vagrant/config
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/cSpell.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/tasks.json
|
||||
.vscode
|
||||
|
||||
# Built docs
|
||||
docs/build
|
||||
|
@ -118,16 +104,9 @@ desktop.ini
|
|||
|
||||
# mypy
|
||||
/.mypy_cache/*
|
||||
/.dmypy.json
|
||||
|
||||
# Secrets
|
||||
.lokalise_token
|
||||
|
||||
# monkeytype
|
||||
monkeytype.sqlite3
|
||||
|
||||
# This is left behind by Azure Restore Cache
|
||||
tmp_cache
|
||||
|
||||
# python-language-server / Rope
|
||||
.ropeproject
|
||||
|
|
2
.isort.cfg
Normal file
2
.isort.cfg
Normal file
|
@ -0,0 +1,2 @@
|
|||
[settings]
|
||||
multi_line_output=4
|
|
@ -1,59 +0,0 @@
|
|||
# This configuration includes the full set of hooks we use. In
|
||||
# addition to the defaults (see .pre-commit-config.yaml), this
|
||||
# includes hooks that require our development and test dependencies
|
||||
# installed and the virtualenv containing them active by the time
|
||||
# pre-commit runs to produce correct results.
|
||||
#
|
||||
# If this is not a problem for your workflow, using this config is
|
||||
# recommended, install it with
|
||||
# pre-commit install --config .pre-commit-config-all.yaml
|
||||
# Otherwise, see the default .pre-commit-config.yaml for a lighter one.
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 19.10b0
|
||||
hooks:
|
||||
- id: black
|
||||
args:
|
||||
- --safe
|
||||
- --quiet
|
||||
files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 3.7.9
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies:
|
||||
- flake8-docstrings==1.5.0
|
||||
- pydocstyle==5.0.1
|
||||
files: ^(homeassistant|script|tests)/.+\.py$
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: 1.6.2
|
||||
hooks:
|
||||
- id: bandit
|
||||
args:
|
||||
- --quiet
|
||||
- --format=custom
|
||||
- --configfile=tests/bandit.yaml
|
||||
files: ^(homeassistant|script|tests)/.+\.py$
|
||||
- repo: https://github.com/pre-commit/mirrors-isort
|
||||
rev: v4.3.21
|
||||
hooks:
|
||||
- id: isort
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v2.4.0
|
||||
hooks:
|
||||
- id: check-json
|
||||
# Using a local "system" mypy instead of the mypy hook, because its
|
||||
# results depend on what is installed. And the mypy hook runs in a
|
||||
# virtualenv of its own, meaning we'd need to install and maintain
|
||||
# another set of our dependencies there... no. Use the "system" one
|
||||
# and reuse the environment that is set up anyway already instead.
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: mypy
|
||||
name: mypy
|
||||
entry: mypy
|
||||
language: system
|
||||
types: [python]
|
||||
require_serial: true
|
||||
files: ^homeassistant/.+\.py$
|
|
@ -1,41 +0,0 @@
|
|||
# This configuration includes the default, minimal set of hooks to be
|
||||
# run on all commits. It requires no specific setup and one can just
|
||||
# start using pre-commit with it.
|
||||
#
|
||||
# See .pre-commit-config-all.yaml for a more complete one that comes
|
||||
# with a better coverage at the cost of some specific setup needed.
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 19.10b0
|
||||
hooks:
|
||||
- id: black
|
||||
args:
|
||||
- --safe
|
||||
- --quiet
|
||||
files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$
|
||||
- repo: https://gitlab.com/pycqa/flake8
|
||||
rev: 3.7.9
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies:
|
||||
- flake8-docstrings==1.5.0
|
||||
- pydocstyle==5.0.1
|
||||
files: ^(homeassistant|script|tests)/.+\.py$
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: 1.6.2
|
||||
hooks:
|
||||
- id: bandit
|
||||
args:
|
||||
- --quiet
|
||||
- --format=custom
|
||||
- --configfile=tests/bandit.yaml
|
||||
files: ^(homeassistant|script|tests)/.+\.py$
|
||||
- repo: https://github.com/pre-commit/mirrors-isort
|
||||
rev: v4.3.21
|
||||
hooks:
|
||||
- id: isort
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v2.4.0
|
||||
hooks:
|
||||
- id: check-json
|
|
@ -1,10 +0,0 @@
|
|||
# .readthedocs.yml
|
||||
|
||||
build:
|
||||
image: latest
|
||||
|
||||
python:
|
||||
version: 3.7
|
||||
setup_py_install: true
|
||||
|
||||
requirements_file: requirements_docs.txt
|
50
.travis.yml
50
.travis.yml
|
@ -1,32 +1,48 @@
|
|||
sudo: false
|
||||
dist: bionic
|
||||
addons:
|
||||
apt:
|
||||
packages:
|
||||
- libudev-dev
|
||||
- libavformat-dev
|
||||
- libavcodec-dev
|
||||
- libavdevice-dev
|
||||
- libavutil-dev
|
||||
- libswscale-dev
|
||||
- libswresample-dev
|
||||
- libavfilter-dev
|
||||
matrix:
|
||||
fast_finish: true
|
||||
include:
|
||||
- python: "3.7.0"
|
||||
- python: "3.5.3"
|
||||
env: TOXENV=lint
|
||||
- python: "3.7.0"
|
||||
env: TOXENV=pylint PYLINT_ARGS=--jobs=0 TRAVIS_WAIT=30
|
||||
- python: "3.7.0"
|
||||
- python: "3.5.3"
|
||||
env: TOXENV=pylint
|
||||
- python: "3.5.3"
|
||||
env: TOXENV=typing
|
||||
- python: "3.7.0"
|
||||
- python: "3.5.3"
|
||||
env: TOXENV=cov
|
||||
after_success: coveralls
|
||||
- python: "3.6"
|
||||
env: TOXENV=py36
|
||||
- python: "3.7"
|
||||
env: TOXENV=py37
|
||||
dist: xenial
|
||||
- python: "3.8-dev"
|
||||
env: TOXENV=py38
|
||||
dist: xenial
|
||||
if: branch = dev AND type = push
|
||||
allow_failures:
|
||||
- python: "3.8-dev"
|
||||
env: TOXENV=py38
|
||||
dist: xenial
|
||||
|
||||
cache:
|
||||
pip: true
|
||||
directories:
|
||||
- $HOME/.cache/pre-commit
|
||||
install: pip install -U tox
|
||||
- $HOME/.cache/pip
|
||||
install: pip install -U tox coveralls
|
||||
language: python
|
||||
script: ${TRAVIS_WAIT:+travis_wait $TRAVIS_WAIT} tox --develop
|
||||
script: travis_wait 30 tox --develop
|
||||
services:
|
||||
- docker
|
||||
before_deploy:
|
||||
- docker pull lokalise/lokalise-cli@sha256:2198814ebddfda56ee041a4b427521757dd57f75415ea9693696a64c550cef21
|
||||
deploy:
|
||||
skip_cleanup: true
|
||||
provider: script
|
||||
script: script/travis_deploy
|
||||
on:
|
||||
branch: dev
|
||||
condition: $TOXENV = lint
|
||||
|
|
105
.vscode/tasks.json
vendored
105
.vscode/tasks.json
vendored
|
@ -1,105 +0,0 @@
|
|||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Preview",
|
||||
"type": "shell",
|
||||
"command": "hass -c ./config",
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": true
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Pytest",
|
||||
"type": "shell",
|
||||
"command": "pytest --timeout=10 tests",
|
||||
"dependsOn": ["Install all Test Requirements"],
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": true
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Flake8",
|
||||
"type": "shell",
|
||||
"command": "pre-commit run flake8 --all-files",
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": true
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Pylint",
|
||||
"type": "shell",
|
||||
"command": "pylint homeassistant",
|
||||
"dependsOn": ["Install all Requirements"],
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": true
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Generate Requirements",
|
||||
"type": "shell",
|
||||
"command": "./script/gen_requirements_all.py",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Install all Requirements",
|
||||
"type": "shell",
|
||||
"command": "pip3 install -r requirements_all.txt -c homeassistant/package_constraints.txt",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Install all Test Requirements",
|
||||
"type": "shell",
|
||||
"command": "pip3 install -r requirements_test_all.txt -c homeassistant/package_constraints.txt",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
489
CODEOWNERS
489
CODEOWNERS
|
@ -1,391 +1,122 @@
|
|||
# This file is generated by script/hassfest/codeowners.py
|
||||
# People marked here will be automatically requested for a review
|
||||
# when the code that they own is touched.
|
||||
# https://github.com/blog/2392-introducing-code-owners
|
||||
|
||||
# Home Assistant Core
|
||||
setup.py @home-assistant/core
|
||||
homeassistant/*.py @home-assistant/core
|
||||
homeassistant/helpers/* @home-assistant/core
|
||||
homeassistant/util/* @home-assistant/core
|
||||
|
||||
# Other code
|
||||
homeassistant/scripts/check_config.py @kellerza
|
||||
|
||||
# Integrations
|
||||
homeassistant/components/abode/* @shred86
|
||||
homeassistant/components/adguard/* @frenck
|
||||
homeassistant/components/airly/* @bieniu
|
||||
homeassistant/components/airvisual/* @bachya
|
||||
homeassistant/components/alexa/* @home-assistant/cloud @ochlocracy
|
||||
homeassistant/components/almond/* @gcampax @balloob
|
||||
homeassistant/components/alpha_vantage/* @fabaff
|
||||
homeassistant/components/amazon_polly/* @robbiet480
|
||||
homeassistant/components/ambiclimate/* @danielhiversen
|
||||
homeassistant/components/ambient_station/* @bachya
|
||||
homeassistant/components/androidtv/* @JeffLIrion
|
||||
homeassistant/components/apache_kafka/* @bachya
|
||||
homeassistant/components/api/* @home-assistant/core
|
||||
homeassistant/components/apprise/* @caronc
|
||||
homeassistant/components/aprs/* @PhilRW
|
||||
homeassistant/components/arcam_fmj/* @elupus
|
||||
homeassistant/components/arduino/* @fabaff
|
||||
homeassistant/components/arest/* @fabaff
|
||||
homeassistant/components/asuswrt/* @kennedyshead
|
||||
homeassistant/components/aten_pe/* @mtdcr
|
||||
homeassistant/components/atome/* @baqs
|
||||
homeassistant/components/aurora_abb_powerone/* @davet2001
|
||||
homeassistant/components/auth/* @home-assistant/core
|
||||
homeassistant/components/automatic/* @armills
|
||||
homeassistant/components/api.py @home-assistant/core
|
||||
homeassistant/components/automation/* @home-assistant/core
|
||||
homeassistant/components/avea/* @pattyland
|
||||
homeassistant/components/awair/* @danielsjf
|
||||
homeassistant/components/aws/* @awarecan @robbiet480
|
||||
homeassistant/components/axis/* @kane610
|
||||
homeassistant/components/azure_event_hub/* @eavanvalkenburg
|
||||
homeassistant/components/azure_service_bus/* @hfurubotten
|
||||
homeassistant/components/beewi_smartclim/* @alemuro
|
||||
homeassistant/components/bitcoin/* @fabaff
|
||||
homeassistant/components/bizkaibus/* @UgaitzEtxebarria
|
||||
homeassistant/components/blink/* @fronzbot
|
||||
homeassistant/components/bmw_connected_drive/* @gerard33
|
||||
homeassistant/components/braviatv/* @robbiet480
|
||||
homeassistant/components/broadlink/* @danielhiversen @felipediel
|
||||
homeassistant/components/brunt/* @eavanvalkenburg
|
||||
homeassistant/components/bt_smarthub/* @jxwolstenholme
|
||||
homeassistant/components/buienradar/* @mjj4791 @ties
|
||||
homeassistant/components/cert_expiry/* @Cereal2nd @jjlawren
|
||||
homeassistant/components/cisco_ios/* @fbradyirl
|
||||
homeassistant/components/cisco_mobility_express/* @fbradyirl
|
||||
homeassistant/components/cisco_webex_teams/* @fbradyirl
|
||||
homeassistant/components/ciscospark/* @fbradyirl
|
||||
homeassistant/components/cloud/* @home-assistant/cloud
|
||||
homeassistant/components/cloudflare/* @ludeeus
|
||||
homeassistant/components/comfoconnect/* @michaelarnauts
|
||||
homeassistant/components/config/* @home-assistant/core
|
||||
homeassistant/components/configurator/* @home-assistant/core
|
||||
homeassistant/components/conversation/* @home-assistant/core
|
||||
homeassistant/components/coolmaster/* @OnFreund
|
||||
homeassistant/components/counter/* @fabaff
|
||||
homeassistant/components/cover/* @home-assistant/core
|
||||
homeassistant/components/cpuspeed/* @fabaff
|
||||
homeassistant/components/cups/* @fabaff
|
||||
homeassistant/components/daikin/* @fredrike @rofrantz
|
||||
homeassistant/components/darksky/* @fabaff
|
||||
homeassistant/components/deconz/* @kane610
|
||||
homeassistant/components/delijn/* @bollewolle
|
||||
homeassistant/components/demo/* @home-assistant/core
|
||||
homeassistant/components/device_automation/* @home-assistant/core
|
||||
homeassistant/components/digital_ocean/* @fabaff
|
||||
homeassistant/components/discogs/* @thibmaek
|
||||
homeassistant/components/doorbird/* @oblogic7
|
||||
homeassistant/components/dsmr_reader/* @depl0y
|
||||
homeassistant/components/dweet/* @fabaff
|
||||
homeassistant/components/ecobee/* @marthoc
|
||||
homeassistant/components/ecovacs/* @OverloadUT
|
||||
homeassistant/components/egardia/* @jeroenterheerdt
|
||||
homeassistant/components/eight_sleep/* @mezz64
|
||||
homeassistant/components/elgato/* @frenck
|
||||
homeassistant/components/elv/* @majuss
|
||||
homeassistant/components/emby/* @mezz64
|
||||
homeassistant/components/emulated_hue/* @NobleKangaroo
|
||||
homeassistant/components/enigma2/* @fbradyirl
|
||||
homeassistant/components/enocean/* @bdurrer
|
||||
homeassistant/components/entur_public_transport/* @hfurubotten
|
||||
homeassistant/components/environment_canada/* @michaeldavie
|
||||
homeassistant/components/ephember/* @ttroy50
|
||||
homeassistant/components/epsonworkforce/* @ThaStealth
|
||||
homeassistant/components/eq3btsmart/* @rytilahti
|
||||
homeassistant/components/esphome/* @OttoWinter
|
||||
homeassistant/components/essent/* @TheLastProject
|
||||
homeassistant/components/evohome/* @zxdavb
|
||||
homeassistant/components/fastdotcom/* @rohankapoorcom
|
||||
homeassistant/components/file/* @fabaff
|
||||
homeassistant/components/filter/* @dgomes
|
||||
homeassistant/components/fitbit/* @robbiet480
|
||||
homeassistant/components/fixer/* @fabaff
|
||||
homeassistant/components/flock/* @fabaff
|
||||
homeassistant/components/flume/* @ChrisMandich
|
||||
homeassistant/components/flunearyou/* @bachya
|
||||
homeassistant/components/fortigate/* @kifeo
|
||||
homeassistant/components/fortios/* @kimfrellsen
|
||||
homeassistant/components/foscam/* @skgsergio
|
||||
homeassistant/components/foursquare/* @robbiet480
|
||||
homeassistant/components/freebox/* @snoof85
|
||||
homeassistant/components/fronius/* @nielstron
|
||||
homeassistant/components/frontend/* @home-assistant/frontend
|
||||
homeassistant/components/gearbest/* @HerrHofrat
|
||||
homeassistant/components/geniushub/* @zxdavb
|
||||
homeassistant/components/geo_rss_events/* @exxamalte
|
||||
homeassistant/components/geonetnz_quakes/* @exxamalte
|
||||
homeassistant/components/geonetnz_volcano/* @exxamalte
|
||||
homeassistant/components/gitter/* @fabaff
|
||||
homeassistant/components/glances/* @fabaff @engrbm87
|
||||
homeassistant/components/gntp/* @robbiet480
|
||||
homeassistant/components/google_assistant/* @home-assistant/cloud
|
||||
homeassistant/components/google_cloud/* @lufton
|
||||
homeassistant/components/google_translate/* @awarecan
|
||||
homeassistant/components/google_travel_time/* @robbiet480
|
||||
homeassistant/components/gpsd/* @fabaff
|
||||
homeassistant/components/group/* @home-assistant/core
|
||||
homeassistant/components/growatt_server/* @indykoning
|
||||
homeassistant/components/gtfs/* @robbiet480
|
||||
homeassistant/components/harmony/* @ehendrix23
|
||||
homeassistant/components/hassio/* @home-assistant/hass-io
|
||||
homeassistant/components/heatmiser/* @andylockran
|
||||
homeassistant/components/heos/* @andrewsayre
|
||||
homeassistant/components/here_travel_time/* @eifinger
|
||||
homeassistant/components/hikvision/* @mezz64
|
||||
homeassistant/components/hikvisioncam/* @fbradyirl
|
||||
homeassistant/components/hisense_aehw4a1/* @bannhead
|
||||
homeassistant/components/history/* @home-assistant/core
|
||||
homeassistant/components/history_graph/* @andrey-git
|
||||
homeassistant/components/hive/* @Rendili @KJonline
|
||||
homeassistant/components/homeassistant/* @home-assistant/core
|
||||
homeassistant/components/homekit_controller/* @Jc2k
|
||||
homeassistant/components/homematic/* @pvizeli @danielperna84
|
||||
homeassistant/components/homematicip_cloud/* @SukramJ
|
||||
homeassistant/components/honeywell/* @zxdavb
|
||||
homeassistant/components/html5/* @robbiet480
|
||||
homeassistant/components/configurator.py @home-assistant/core
|
||||
homeassistant/components/group.py @home-assistant/core
|
||||
homeassistant/components/history.py @home-assistant/core
|
||||
homeassistant/components/http/* @home-assistant/core
|
||||
homeassistant/components/huawei_lte/* @scop
|
||||
homeassistant/components/huawei_router/* @abmantis
|
||||
homeassistant/components/hue/* @balloob
|
||||
homeassistant/components/iaqualink/* @flz
|
||||
homeassistant/components/icloud/* @Quentame
|
||||
homeassistant/components/ign_sismologia/* @exxamalte
|
||||
homeassistant/components/incomfort/* @zxdavb
|
||||
homeassistant/components/influxdb/* @fabaff
|
||||
homeassistant/components/input_boolean/* @home-assistant/core
|
||||
homeassistant/components/input_datetime/* @home-assistant/core
|
||||
homeassistant/components/input_number/* @home-assistant/core
|
||||
homeassistant/components/input_select/* @home-assistant/core
|
||||
homeassistant/components/input_text/* @home-assistant/core
|
||||
homeassistant/components/integration/* @dgomes
|
||||
homeassistant/components/intent/* @home-assistant/core
|
||||
homeassistant/components/intesishome/* @jnimmo
|
||||
homeassistant/components/ios/* @robbiet480
|
||||
homeassistant/components/iperf3/* @rohankapoorcom
|
||||
homeassistant/components/ipma/* @dgomes
|
||||
homeassistant/components/iqvia/* @bachya
|
||||
homeassistant/components/irish_rail_transport/* @ttroy50
|
||||
homeassistant/components/izone/* @Swamp-Ig
|
||||
homeassistant/components/jewish_calendar/* @tsvi
|
||||
homeassistant/components/juicenet/* @jesserockz
|
||||
homeassistant/components/kaiterra/* @Michsior14
|
||||
homeassistant/components/keba/* @dannerph
|
||||
homeassistant/components/keenetic_ndms2/* @foxel
|
||||
homeassistant/components/keyboard_remote/* @bendavid
|
||||
homeassistant/components/knx/* @Julius2342
|
||||
homeassistant/components/kodi/* @armills
|
||||
homeassistant/components/konnected/* @heythisisnate
|
||||
homeassistant/components/lametric/* @robbiet480
|
||||
homeassistant/components/launch_library/* @ludeeus
|
||||
homeassistant/components/lcn/* @alengwenus
|
||||
homeassistant/components/life360/* @pnbruckner
|
||||
homeassistant/components/linky/* @Quentame
|
||||
homeassistant/components/linux_battery/* @fabaff
|
||||
homeassistant/components/liveboxplaytv/* @pschmitt
|
||||
homeassistant/components/logger/* @home-assistant/core
|
||||
homeassistant/components/logi_circle/* @evanjd
|
||||
homeassistant/components/lovelace/* @home-assistant/frontend
|
||||
homeassistant/components/luci/* @fbradyirl @mzdrale
|
||||
homeassistant/components/luftdaten/* @fabaff
|
||||
homeassistant/components/lupusec/* @majuss
|
||||
homeassistant/components/lutron/* @JonGilmore
|
||||
homeassistant/components/mastodon/* @fabaff
|
||||
homeassistant/components/matrix/* @tinloaf
|
||||
homeassistant/components/mcp23017/* @jardiamj
|
||||
homeassistant/components/mediaroom/* @dgomes
|
||||
homeassistant/components/melissa/* @kennedyshead
|
||||
homeassistant/components/met/* @danielhiversen
|
||||
homeassistant/components/meteo_france/* @victorcerutti @oncleben31
|
||||
homeassistant/components/meteoalarm/* @rolfberkenbosch
|
||||
homeassistant/components/miflora/* @danielhiversen @ChristianKuehnel
|
||||
homeassistant/components/mill/* @danielhiversen
|
||||
homeassistant/components/min_max/* @fabaff
|
||||
homeassistant/components/minio/* @tkislan
|
||||
homeassistant/components/mobile_app/* @robbiet480
|
||||
homeassistant/components/modbus/* @adamchengtkc
|
||||
homeassistant/components/monoprice/* @etsinko
|
||||
homeassistant/components/moon/* @fabaff
|
||||
homeassistant/components/mpd/* @fabaff
|
||||
homeassistant/components/input_*.py @home-assistant/core
|
||||
homeassistant/components/introduction.py @home-assistant/core
|
||||
homeassistant/components/logger.py @home-assistant/core
|
||||
homeassistant/components/mqtt/* @home-assistant/core
|
||||
homeassistant/components/msteams/* @peroyvind
|
||||
homeassistant/components/mysensors/* @MartinHjelmare
|
||||
homeassistant/components/mystrom/* @fabaff
|
||||
homeassistant/components/neato/* @dshokouhi @Santobert
|
||||
homeassistant/components/nello/* @pschmitt
|
||||
homeassistant/components/ness_alarm/* @nickw444
|
||||
homeassistant/components/nest/* @awarecan
|
||||
homeassistant/components/netdata/* @fabaff
|
||||
homeassistant/components/nextbus/* @vividboarder
|
||||
homeassistant/components/nilu/* @hfurubotten
|
||||
homeassistant/components/nissan_leaf/* @filcole
|
||||
homeassistant/components/nmbs/* @thibmaek
|
||||
homeassistant/components/no_ip/* @fabaff
|
||||
homeassistant/components/notify/* @home-assistant/core
|
||||
homeassistant/components/notion/* @bachya
|
||||
homeassistant/components/nsw_fuel_station/* @nickw444
|
||||
homeassistant/components/nsw_rural_fire_service_feed/* @exxamalte
|
||||
homeassistant/components/nuki/* @pvizeli
|
||||
homeassistant/components/nws/* @MatthewFlamm
|
||||
homeassistant/components/nzbget/* @chriscla
|
||||
homeassistant/components/obihai/* @dshokouhi
|
||||
homeassistant/components/ohmconnect/* @robbiet480
|
||||
homeassistant/components/ombi/* @larssont
|
||||
homeassistant/components/onboarding/* @home-assistant/core
|
||||
homeassistant/components/opentherm_gw/* @mvn23
|
||||
homeassistant/components/openuv/* @bachya
|
||||
homeassistant/components/openweathermap/* @fabaff
|
||||
homeassistant/components/orangepi_gpio/* @pascallj
|
||||
homeassistant/components/oru/* @bvlaicu
|
||||
homeassistant/components/owlet/* @oblogic7
|
||||
homeassistant/components/panel_custom/* @home-assistant/frontend
|
||||
homeassistant/components/panel_iframe/* @home-assistant/frontend
|
||||
homeassistant/components/pcal9535a/* @Shulyaka
|
||||
homeassistant/components/persistent_notification/* @home-assistant/core
|
||||
homeassistant/components/philips_js/* @elupus
|
||||
homeassistant/components/pi_hole/* @fabaff @johnluetke
|
||||
homeassistant/components/plaato/* @JohNan
|
||||
homeassistant/components/plant/* @ChristianKuehnel
|
||||
homeassistant/components/plex/* @jjlawren
|
||||
homeassistant/components/plugwise/* @laetificat @CoMPaTech @bouwew
|
||||
homeassistant/components/point/* @fredrike
|
||||
homeassistant/components/proxmoxve/* @k4ds3
|
||||
homeassistant/components/ps4/* @ktnrg45
|
||||
homeassistant/components/ptvsd/* @swamp-ig
|
||||
homeassistant/components/push/* @dgomes
|
||||
homeassistant/components/pvoutput/* @fabaff
|
||||
homeassistant/components/qld_bushfire/* @exxamalte
|
||||
homeassistant/components/qnap/* @colinodell
|
||||
homeassistant/components/quantum_gateway/* @cisasteelersfan
|
||||
homeassistant/components/qwikswitch/* @kellerza
|
||||
homeassistant/components/rainbird/* @konikvranik
|
||||
homeassistant/components/raincloud/* @vanstinator
|
||||
homeassistant/components/rainforest_eagle/* @gtdiehl
|
||||
homeassistant/components/rainmachine/* @bachya
|
||||
homeassistant/components/random/* @fabaff
|
||||
homeassistant/components/repetier/* @MTrab
|
||||
homeassistant/components/rfxtrx/* @danielhiversen
|
||||
homeassistant/components/rmvtransport/* @cgtobi
|
||||
homeassistant/components/roomba/* @pschmitt
|
||||
homeassistant/components/saj/* @fredericvl
|
||||
homeassistant/components/samsungtv/* @escoand
|
||||
homeassistant/components/scene/* @home-assistant/core
|
||||
homeassistant/components/scrape/* @fabaff
|
||||
homeassistant/components/script/* @home-assistant/core
|
||||
homeassistant/components/sense/* @kbickar
|
||||
homeassistant/components/sensibo/* @andrey-git
|
||||
homeassistant/components/serial/* @fabaff
|
||||
homeassistant/components/seventeentrack/* @bachya
|
||||
homeassistant/components/shell_command/* @home-assistant/core
|
||||
homeassistant/components/shiftr/* @fabaff
|
||||
homeassistant/components/shodan/* @fabaff
|
||||
homeassistant/components/signal_messenger/* @bbernhard
|
||||
homeassistant/components/simplisafe/* @bachya
|
||||
homeassistant/components/sinch/* @bendikrb
|
||||
homeassistant/components/slide/* @ualex73
|
||||
homeassistant/components/sma/* @kellerza
|
||||
homeassistant/components/smarthab/* @outadoc
|
||||
homeassistant/components/smartthings/* @andrewsayre
|
||||
homeassistant/components/smarty/* @z0mbieprocess
|
||||
homeassistant/components/smtp/* @fabaff
|
||||
homeassistant/components/solaredge_local/* @drobtravels @scheric
|
||||
homeassistant/components/solarlog/* @Ernst79
|
||||
homeassistant/components/solax/* @squishykid
|
||||
homeassistant/components/soma/* @ratsept
|
||||
homeassistant/components/somfy/* @tetienne
|
||||
homeassistant/components/songpal/* @rytilahti
|
||||
homeassistant/components/spaceapi/* @fabaff
|
||||
homeassistant/components/speedtestdotnet/* @rohankapoorcom
|
||||
homeassistant/components/spider/* @peternijssen
|
||||
homeassistant/components/sql/* @dgomes
|
||||
homeassistant/components/starline/* @anonym-tsk
|
||||
homeassistant/components/statistics/* @fabaff
|
||||
homeassistant/components/stiebel_eltron/* @fucm
|
||||
homeassistant/components/stream/* @hunterjm
|
||||
homeassistant/components/stt/* @pvizeli
|
||||
homeassistant/components/suez_water/* @ooii
|
||||
homeassistant/components/sun/* @Swamp-Ig
|
||||
homeassistant/components/supla/* @mwegrzynek
|
||||
homeassistant/components/swiss_hydrological_data/* @fabaff
|
||||
homeassistant/components/swiss_public_transport/* @fabaff
|
||||
homeassistant/components/switchbot/* @danielhiversen
|
||||
homeassistant/components/switcher_kis/* @tomerfi
|
||||
homeassistant/components/switchmate/* @danielhiversen
|
||||
homeassistant/components/syncthru/* @nielstron
|
||||
homeassistant/components/synology_srm/* @aerialls
|
||||
homeassistant/components/syslog/* @fabaff
|
||||
homeassistant/components/tado/* @michaelarnauts
|
||||
homeassistant/components/tahoma/* @philklei
|
||||
homeassistant/components/tautulli/* @ludeeus
|
||||
homeassistant/components/tellduslive/* @fredrike
|
||||
homeassistant/components/template/* @PhracturedBlue
|
||||
homeassistant/components/tesla/* @zabuldon
|
||||
homeassistant/components/tfiac/* @fredrike @mellado
|
||||
homeassistant/components/thethingsnetwork/* @fabaff
|
||||
homeassistant/components/threshold/* @fabaff
|
||||
homeassistant/components/tibber/* @danielhiversen
|
||||
homeassistant/components/tile/* @bachya
|
||||
homeassistant/components/time_date/* @fabaff
|
||||
homeassistant/components/todoist/* @boralyl
|
||||
homeassistant/components/toon/* @frenck
|
||||
homeassistant/components/tplink/* @rytilahti
|
||||
homeassistant/components/traccar/* @ludeeus
|
||||
homeassistant/components/tradfri/* @ggravlingen
|
||||
homeassistant/components/trafikverket_train/* @endor-force
|
||||
homeassistant/components/transmission/* @engrbm87 @JPHutchins
|
||||
homeassistant/components/tts/* @robbiet480
|
||||
homeassistant/components/twentemilieu/* @frenck
|
||||
homeassistant/components/twilio_call/* @robbiet480
|
||||
homeassistant/components/twilio_sms/* @robbiet480
|
||||
homeassistant/components/unifi/* @kane610
|
||||
homeassistant/components/unifiled/* @florisvdk
|
||||
homeassistant/components/upc_connect/* @pvizeli
|
||||
homeassistant/components/upcloud/* @scop
|
||||
homeassistant/components/updater/* @home-assistant/core
|
||||
homeassistant/components/upnp/* @robbiet480
|
||||
homeassistant/components/uptimerobot/* @ludeeus
|
||||
homeassistant/components/usgs_earthquakes_feed/* @exxamalte
|
||||
homeassistant/components/utility_meter/* @dgomes
|
||||
homeassistant/components/velbus/* @cereal2nd
|
||||
homeassistant/components/velux/* @Julius2342
|
||||
homeassistant/components/versasense/* @flamm3blemuff1n
|
||||
homeassistant/components/version/* @fabaff
|
||||
homeassistant/components/vesync/* @markperdue @webdjoe
|
||||
homeassistant/components/vicare/* @oischinger
|
||||
homeassistant/components/vivotek/* @HarlemSquirrel
|
||||
homeassistant/components/vizio/* @raman325
|
||||
homeassistant/components/vlc_telnet/* @rodripf
|
||||
homeassistant/components/waqi/* @andrey-git
|
||||
homeassistant/components/watson_tts/* @rutkai
|
||||
homeassistant/components/weather/* @fabaff
|
||||
homeassistant/components/weblink/* @home-assistant/core
|
||||
homeassistant/components/websocket_api/* @home-assistant/core
|
||||
homeassistant/components/wemo/* @sqldiablo
|
||||
homeassistant/components/withings/* @vangorra
|
||||
homeassistant/components/wled/* @frenck
|
||||
homeassistant/components/worldclock/* @fabaff
|
||||
homeassistant/components/wwlln/* @bachya
|
||||
homeassistant/components/xbox_live/* @MartinHjelmare
|
||||
homeassistant/components/xfinity/* @cisasteelersfan
|
||||
homeassistant/components/xiaomi_aqara/* @danielhiversen @syssi
|
||||
homeassistant/components/xiaomi_miio/* @rytilahti @syssi
|
||||
homeassistant/components/xiaomi_tv/* @simse
|
||||
homeassistant/components/xmpp/* @fabaff @flowolf
|
||||
homeassistant/components/yamaha_musiccast/* @jalmeroth
|
||||
homeassistant/components/yandex_transport/* @rishatik92
|
||||
homeassistant/components/yeelight/* @rytilahti @zewelor
|
||||
homeassistant/components/yeelightsunflower/* @lindsaymarkward
|
||||
homeassistant/components/yessssms/* @flowolf
|
||||
homeassistant/components/yi/* @bachya
|
||||
homeassistant/components/yr/* @danielhiversen
|
||||
homeassistant/components/zeroconf/* @robbiet480 @Kane610
|
||||
homeassistant/components/zha/* @dmulcahey @adminiuga
|
||||
homeassistant/components/zone/* @home-assistant/core
|
||||
homeassistant/components/zoneminder/* @rohankapoorcom
|
||||
homeassistant/components/zwave/* @home-assistant/z-wave
|
||||
homeassistant/components/panel_custom.py @home-assistant/core
|
||||
homeassistant/components/panel_iframe.py @home-assistant/core
|
||||
homeassistant/components/persistent_notification.py @home-assistant/core
|
||||
homeassistant/components/scene/__init__.py @home-assistant/core
|
||||
homeassistant/components/scene/hass.py @home-assistant/core
|
||||
homeassistant/components/script.py @home-assistant/core
|
||||
homeassistant/components/shell_command.py @home-assistant/core
|
||||
homeassistant/components/sun.py @home-assistant/core
|
||||
homeassistant/components/updater.py @home-assistant/core
|
||||
homeassistant/components/weblink.py @home-assistant/core
|
||||
homeassistant/components/websocket_api.py @home-assistant/core
|
||||
homeassistant/components/zone.py @home-assistant/core
|
||||
|
||||
# Individual files
|
||||
homeassistant/components/demo/weather @fabaff
|
||||
# HomeAssistant developer Teams
|
||||
Dockerfile @home-assistant/docker
|
||||
virtualization/Docker/* @home-assistant/docker
|
||||
|
||||
homeassistant/components/zwave/* @home-assistant/z-wave
|
||||
homeassistant/components/*/zwave.py @home-assistant/z-wave
|
||||
|
||||
homeassistant/components/hassio.py @home-assistant/hassio
|
||||
|
||||
# Individual components
|
||||
homeassistant/components/alarm_control_panel/egardia.py @jeroenterheerdt
|
||||
homeassistant/components/alarm_control_panel/manual_mqtt.py @colinodell
|
||||
homeassistant/components/binary_sensor/hikvision.py @mezz64
|
||||
homeassistant/components/bmw_connected_drive.py @ChristianKuehnel
|
||||
homeassistant/components/camera/yi.py @bachya
|
||||
homeassistant/components/climate/ephember.py @ttroy50
|
||||
homeassistant/components/climate/eq3btsmart.py @rytilahti
|
||||
homeassistant/components/climate/sensibo.py @andrey-git
|
||||
homeassistant/components/cover/group.py @cdce8p
|
||||
homeassistant/components/cover/template.py @PhracturedBlue
|
||||
homeassistant/components/device_tracker/automatic.py @armills
|
||||
homeassistant/components/device_tracker/tile.py @bachya
|
||||
homeassistant/components/history_graph.py @andrey-git
|
||||
homeassistant/components/light/tplink.py @rytilahti
|
||||
homeassistant/components/light/yeelight.py @rytilahti
|
||||
homeassistant/components/lock/nello.py @pschmitt
|
||||
homeassistant/components/lock/nuki.py @pschmitt
|
||||
homeassistant/components/media_player/emby.py @mezz64
|
||||
homeassistant/components/media_player/kodi.py @armills
|
||||
homeassistant/components/media_player/liveboxplaytv.py @pschmitt
|
||||
homeassistant/components/media_player/mediaroom.py @dgomes
|
||||
homeassistant/components/media_player/monoprice.py @etsinko
|
||||
homeassistant/components/media_player/sonos.py @amelchio
|
||||
homeassistant/components/media_player/xiaomi_tv.py @fattdev
|
||||
homeassistant/components/media_player/yamaha_musiccast.py @jalmeroth
|
||||
homeassistant/components/plant.py @ChristianKuehnel
|
||||
homeassistant/components/sensor/airvisual.py @bachya
|
||||
homeassistant/components/sensor/filter.py @dgomes
|
||||
homeassistant/components/sensor/gearbest.py @HerrHofrat
|
||||
homeassistant/components/sensor/irish_rail_transport.py @ttroy50
|
||||
homeassistant/components/sensor/miflora.py @danielhiversen @ChristianKuehnel
|
||||
homeassistant/components/sensor/nsw_fuel_station.py @nickw444
|
||||
homeassistant/components/sensor/pollen.py @bachya
|
||||
homeassistant/components/sensor/qnap.py @colinodell
|
||||
homeassistant/components/sensor/sma.py @kellerza
|
||||
homeassistant/components/sensor/sql.py @dgomes
|
||||
homeassistant/components/sensor/sytadin.py @gautric
|
||||
homeassistant/components/sensor/tibber.py @danielhiversen
|
||||
homeassistant/components/sensor/upnp.py @dgomes
|
||||
homeassistant/components/sensor/waqi.py @andrey-git
|
||||
homeassistant/components/switch/tplink.py @rytilahti
|
||||
homeassistant/components/vacuum/roomba.py @pschmitt
|
||||
homeassistant/components/xiaomi_aqara.py @danielhiversen @syssi
|
||||
|
||||
homeassistant/components/*/axis.py @kane610
|
||||
homeassistant/components/*/bmw_connected_drive.py @ChristianKuehnel
|
||||
homeassistant/components/*/broadlink.py @danielhiversen
|
||||
homeassistant/components/*/deconz.py @kane610
|
||||
homeassistant/components/ecovacs.py @OverloadUT
|
||||
homeassistant/components/*/ecovacs.py @OverloadUT
|
||||
homeassistant/components/eight_sleep.py @mezz64
|
||||
homeassistant/components/*/eight_sleep.py @mezz64
|
||||
homeassistant/components/hive.py @Rendili @KJonline
|
||||
homeassistant/components/*/hive.py @Rendili @KJonline
|
||||
homeassistant/components/homekit/* @cdce8p
|
||||
homeassistant/components/knx.py @Julius2342
|
||||
homeassistant/components/*/knx.py @Julius2342
|
||||
homeassistant/components/konnected.py @heythisisnate
|
||||
homeassistant/components/*/konnected.py @heythisisnate
|
||||
homeassistant/components/matrix.py @tinloaf
|
||||
homeassistant/components/*/matrix.py @tinloaf
|
||||
homeassistant/components/openuv.py @bachya
|
||||
homeassistant/components/*/openuv.py @bachya
|
||||
homeassistant/components/qwikswitch.py @kellerza
|
||||
homeassistant/components/*/qwikswitch.py @kellerza
|
||||
homeassistant/components/rainmachine/* @bachya
|
||||
homeassistant/components/*/rainmachine.py @bachya
|
||||
homeassistant/components/*/rfxtrx.py @danielhiversen
|
||||
homeassistant/components/tahoma.py @philklei
|
||||
homeassistant/components/*/tahoma.py @philklei
|
||||
homeassistant/components/tesla.py @zabuldon
|
||||
homeassistant/components/*/tesla.py @zabuldon
|
||||
homeassistant/components/tellduslive.py @molobrakos @fredrike
|
||||
homeassistant/components/*/tellduslive.py @molobrakos @fredrike
|
||||
homeassistant/components/*/tradfri.py @ggravlingen
|
||||
homeassistant/components/velux.py @Julius2342
|
||||
homeassistant/components/*/velux.py @Julius2342
|
||||
homeassistant/components/*/xiaomi_aqara.py @danielhiversen @syssi
|
||||
homeassistant/components/*/xiaomi_miio.py @rytilahti @syssi
|
||||
|
||||
homeassistant/scripts/check_config.py @kellerza
|
||||
|
|
|
@ -10,5 +10,5 @@ The process is straight-forward.
|
|||
- Ensure tests work.
|
||||
- Create a Pull Request against the [**dev**](https://github.com/home-assistant/home-assistant/tree/dev) branch of Home Assistant.
|
||||
|
||||
Still interested? Then you should take a peek at the [developer documentation](https://developers.home-assistant.io/) to get more details.
|
||||
Still interested? Then you should take a peek at the [developer documentation](https://home-assistant.io/developers/) to get more details.
|
||||
|
||||
|
|
35
Dockerfile
Normal file
35
Dockerfile
Normal file
|
@ -0,0 +1,35 @@
|
|||
# Notice:
|
||||
# When updating this file, please also update virtualization/Docker/Dockerfile.dev
|
||||
# This way, the development image and the production image are kept in sync.
|
||||
|
||||
FROM python:3.6
|
||||
LABEL maintainer="Paulus Schoutsen <Paulus@PaulusSchoutsen.nl>"
|
||||
|
||||
# Uncomment any of the following lines to disable the installation.
|
||||
#ENV INSTALL_TELLSTICK no
|
||||
#ENV INSTALL_OPENALPR no
|
||||
#ENV INSTALL_FFMPEG no
|
||||
#ENV INSTALL_LIBCEC no
|
||||
#ENV INSTALL_SSOCR no
|
||||
#ENV INSTALL_IPERF3 no
|
||||
|
||||
VOLUME /config
|
||||
|
||||
RUN mkdir -p /usr/src/app
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Copy build scripts
|
||||
COPY virtualization/Docker/ virtualization/Docker/
|
||||
RUN virtualization/Docker/setup_docker_prereqs
|
||||
|
||||
# Install hass component dependencies
|
||||
COPY requirements_all.txt requirements_all.txt
|
||||
# Uninstall enum34 because some dependencies install it but breaks Python 3.4+.
|
||||
# See PR #8103 for more info.
|
||||
RUN pip3 install --no-cache-dir -r requirements_all.txt && \
|
||||
pip3 install --no-cache-dir mysqlclient psycopg2 uvloop cchardet cython
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
CMD [ "python", "-m", "homeassistant", "--config", "/config" ]
|
|
@ -1,32 +0,0 @@
|
|||
FROM python:3.7
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
libudev-dev \
|
||||
libavformat-dev \
|
||||
libavcodec-dev \
|
||||
libavdevice-dev \
|
||||
libavutil-dev \
|
||||
libswscale-dev \
|
||||
libswresample-dev \
|
||||
libavfilter-dev \
|
||||
git \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /usr/src
|
||||
|
||||
# Setup hass-release
|
||||
RUN git clone --depth 1 https://github.com/home-assistant/hass-release \
|
||||
&& cd hass-release \
|
||||
&& pip3 install -e .
|
||||
|
||||
WORKDIR /workspaces
|
||||
|
||||
# Install Python dependencies from requirements
|
||||
COPY requirements_test.txt requirements_test_pre_commit.txt homeassistant/package_constraints.txt ./
|
||||
RUN pip3 install -r requirements_test.txt -c package_constraints.txt \
|
||||
&& rm -f requirements_test.txt package_constraints.txt requirements_test_pre_commit.txt
|
||||
|
||||
# Set the default shell to bash instead of sh
|
||||
ENV SHELL /bin/bash
|
331
LICENSE.md
331
LICENSE.md
|
@ -1,201 +1,194 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
Apache License
|
||||
==============
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
_Version 2.0, January 2004_
|
||||
_<<http://www.apache.org/licenses/>>_
|
||||
|
||||
1. Definitions.
|
||||
### Terms and Conditions for use, reproduction, and distribution
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
#### 1. Definitions
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
“License” shall mean the terms and conditions for use, reproduction, and
|
||||
distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
“Licensor” shall mean the copyright owner or entity authorized by the copyright
|
||||
owner that is granting the License.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
“Legal Entity” shall mean the union of the acting entity and all other entities
|
||||
that control, are controlled by, or are under common control with that entity.
|
||||
For the purposes of this definition, “control” means **(i)** the power, direct or
|
||||
indirect, to cause the direction or management of such entity, whether by
|
||||
contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or **(iii)** beneficial ownership of such entity.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
“You” (or “Your”) shall mean an individual or Legal Entity exercising
|
||||
permissions granted by this License.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
“Source” form shall mean the preferred form for making modifications, including
|
||||
but not limited to software source code, documentation source, and configuration
|
||||
files.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
“Object” form shall mean any form resulting from mechanical transformation or
|
||||
translation of a Source form, including but not limited to compiled object code,
|
||||
generated documentation, and conversions to other media types.
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
“Work” shall mean the work of authorship, whether in Source or Object form, made
|
||||
available under the License, as indicated by a copyright notice that is included
|
||||
in or attached to the work (an example is provided in the Appendix below).
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
“Derivative Works” shall mean any work, whether in Source or Object form, that
|
||||
is based on (or derived from) the Work and for which the editorial revisions,
|
||||
annotations, elaborations, or other modifications represent, as a whole, an
|
||||
original work of authorship. For the purposes of this License, Derivative Works
|
||||
shall not include works that remain separable from, or merely link (or bind by
|
||||
name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
“Contribution” shall mean any work of authorship, including the original version
|
||||
of the Work and any modifications or additions to that Work or Derivative Works
|
||||
thereof, that is intentionally submitted to Licensor for inclusion in the Work
|
||||
by the copyright owner or by an individual or Legal Entity authorized to submit
|
||||
on behalf of the copyright owner. For the purposes of this definition,
|
||||
“submitted” means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems, and
|
||||
issue tracking systems that are managed by, or on behalf of, the Licensor for
|
||||
the purpose of discussing and improving the Work, but excluding communication
|
||||
that is conspicuously marked or otherwise designated in writing by the copyright
|
||||
owner as “Not a Contribution.”
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
“Contributor” shall mean Licensor and any individual or Legal Entity on behalf
|
||||
of whom a Contribution has been received by Licensor and subsequently
|
||||
incorporated within the Work.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
#### 2. Grant of Copyright License
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
Subject to the terms and conditions of this License, each Contributor hereby
|
||||
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
|
||||
irrevocable copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the Work and such
|
||||
Derivative Works in Source or Object form.
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
#### 3. Grant of Patent License
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
Subject to the terms and conditions of this License, each Contributor hereby
|
||||
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
|
||||
irrevocable (except as stated in this section) patent license to make, have
|
||||
made, use, offer to sell, sell, import, and otherwise transfer the Work, where
|
||||
such license applies only to those patent claims licensable by such Contributor
|
||||
that are necessarily infringed by their Contribution(s) alone or by combination
|
||||
of their Contribution(s) with the Work to which such Contribution(s) was
|
||||
submitted. If You institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work or a
|
||||
Contribution incorporated within the Work constitutes direct or contributory
|
||||
patent infringement, then any patent licenses granted to You under this License
|
||||
for that Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
#### 4. Redistribution
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
You may reproduce and distribute copies of the Work or Derivative Works thereof
|
||||
in any medium, with or without modifications, and in Source or Object form,
|
||||
provided that You meet the following conditions:
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
* **(a)** You must give any other recipients of the Work or Derivative Works a copy of
|
||||
this License; and
|
||||
* **(b)** You must cause any modified files to carry prominent notices stating that You
|
||||
changed the files; and
|
||||
* **(c)** You must retain, in the Source form of any Derivative Works that You distribute,
|
||||
all copyright, patent, trademark, and attribution notices from the Source form
|
||||
of the Work, excluding those notices that do not pertain to any part of the
|
||||
Derivative Works; and
|
||||
* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any
|
||||
Derivative Works that You distribute must include a readable copy of the
|
||||
attribution notices contained within such NOTICE file, excluding those notices
|
||||
that do not pertain to any part of the Derivative Works, in at least one of the
|
||||
following places: within a NOTICE text file distributed as part of the
|
||||
Derivative Works; within the Source form or documentation, if provided along
|
||||
with the Derivative Works; or, within a display generated by the Derivative
|
||||
Works, if and wherever such third-party notices normally appear. The contents of
|
||||
the NOTICE file are for informational purposes only and do not modify the
|
||||
License. You may add Your own attribution notices within Derivative Works that
|
||||
You distribute, alongside or as an addendum to the NOTICE text from the Work,
|
||||
provided that such additional attribution notices cannot be construed as
|
||||
modifying the License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
You may add Your own copyright statement to Your modifications and may provide
|
||||
additional or different license terms and conditions for use, reproduction, or
|
||||
distribution of Your modifications, or for any such Derivative Works as a whole,
|
||||
provided Your use, reproduction, and distribution of the Work otherwise complies
|
||||
with the conditions stated in this License.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
#### 5. Submission of Contributions
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
Unless You explicitly state otherwise, any Contribution intentionally submitted
|
||||
for inclusion in the Work by You to the Licensor shall be under the terms and
|
||||
conditions of this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify the terms of
|
||||
any separate license agreement you may have executed with Licensor regarding
|
||||
such Contributions.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
#### 6. Trademarks
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
This License does not grant permission to use the trade names, trademarks,
|
||||
service marks, or product names of the Licensor, except as required for
|
||||
reasonable and customary use in describing the origin of the Work and
|
||||
reproducing the content of the NOTICE file.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
#### 7. Disclaimer of Warranty
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
Unless required by applicable law or agreed to in writing, Licensor provides the
|
||||
Work (and each Contributor provides its Contributions) on an “AS IS” BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,
|
||||
including, without limitation, any warranties or conditions of TITLE,
|
||||
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are
|
||||
solely responsible for determining the appropriateness of using or
|
||||
redistributing the Work and assume any risks associated with Your exercise of
|
||||
permissions under this License.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
#### 8. Limitation of Liability
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
In no event and under no legal theory, whether in tort (including negligence),
|
||||
contract, or otherwise, unless required by applicable law (such as deliberate
|
||||
and grossly negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special, incidental,
|
||||
or consequential damages of any character arising as a result of this License or
|
||||
out of the use or inability to use the Work (including but not limited to
|
||||
damages for loss of goodwill, work stoppage, computer failure or malfunction, or
|
||||
any and all other commercial damages or losses), even if such Contributor has
|
||||
been advised of the possibility of such damages.
|
||||
|
||||
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 of the License at
|
||||
#### 9. Accepting Warranty or Additional Liability
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
While redistributing the Work or Derivative Works thereof, You may choose to
|
||||
offer, and charge a fee for, acceptance of support, warranty, indemnity, or
|
||||
other liability obligations and/or rights consistent with this License. However,
|
||||
in accepting such obligations, You may act only on Your own behalf and on Your
|
||||
sole responsibility, not on behalf of any other Contributor, and only if You
|
||||
agree to indemnify, defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason of your
|
||||
accepting any such warranty or additional liability.
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
_END OF TERMS AND CONDITIONS_
|
||||
|
||||
### APPENDIX: How to apply the Apache License to your work
|
||||
|
||||
To apply the Apache License to your work, attach the following boilerplate
|
||||
notice, with the fields enclosed by brackets `[]` replaced with your own
|
||||
identifying information. (Don't include the brackets!) The text should be
|
||||
enclosed in the appropriate comment syntax for the file format. We also
|
||||
recommend that a file or class name and description of purpose be included on
|
||||
the same “printed page” as the copyright notice for easier identification within
|
||||
third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
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 of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
|
23
README.rst
23
README.rst
|
@ -1,7 +1,14 @@
|
|||
Home Assistant |Chat Status|
|
||||
Home Assistant |Build Status| |Coverage Status| |Chat Status| |Reviewed by Hound|
|
||||
=================================================================================
|
||||
|
||||
Open source home automation that puts local control and privacy first. Powered by a worldwide community of tinkerers and DIY enthusiasts. Perfect to run on a Raspberry Pi or a local server.
|
||||
Home Assistant is a home automation platform running on Python 3. It is able to track and control all devices at home and offer a platform for automating control.
|
||||
|
||||
To get started:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
python3 -m pip install homeassistant
|
||||
hass --open-ui
|
||||
|
||||
Check out `home-assistant.io <https://home-assistant.io>`__ for `a
|
||||
demo <https://home-assistant.io/demo/>`__, `installation instructions <https://home-assistant.io/getting-started/>`__,
|
||||
|
@ -14,15 +21,21 @@ Featured integrations
|
|||
|
||||
|screenshot-components|
|
||||
|
||||
The system is built using a modular approach so support for other devices or actions can be implemented easily. See also the `section on architecture <https://developers.home-assistant.io/docs/en/architecture_index.html>`__ and the `section on creating your own
|
||||
components <https://developers.home-assistant.io/docs/en/creating_component_index.html>`__.
|
||||
The system is built using a modular approach so support for other devices or actions can be implemented easily. See also the `section on architecture <https://home-assistant.io/developers/architecture/>`__ and the `section on creating your own
|
||||
components <https://home-assistant.io/developers/creating_components/>`__.
|
||||
|
||||
If you run into issues while using Home Assistant or during development
|
||||
of a component, check the `Home Assistant help section <https://home-assistant.io/help/>`__ of our website for further help and information.
|
||||
|
||||
.. |Build Status| image:: https://travis-ci.org/home-assistant/home-assistant.svg?branch=master
|
||||
:target: https://travis-ci.org/home-assistant/home-assistant
|
||||
.. |Coverage Status| image:: https://img.shields.io/coveralls/home-assistant/home-assistant.svg
|
||||
:target: https://coveralls.io/r/home-assistant/home-assistant?branch=master
|
||||
.. |Chat Status| image:: https://img.shields.io/discord/330944238910963714.svg
|
||||
:target: https://discord.gg/c5DvZ4e
|
||||
.. |Reviewed by Hound| image:: https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg
|
||||
:target: https://houndci.com
|
||||
.. |screenshot-states| image:: https://raw.github.com/home-assistant/home-assistant/master/docs/screenshots.png
|
||||
:target: https://home-assistant.io/demo/
|
||||
.. |screenshot-components| image:: https://raw.github.com/home-assistant/home-assistant/dev/docs/screenshot-components.png
|
||||
:target: https://home-assistant.io/integrations/
|
||||
:target: https://home-assistant.io/components/
|
||||
|
|
|
@ -1,197 +0,0 @@
|
|||
# https://dev.azure.com/home-assistant
|
||||
|
||||
trigger:
|
||||
batch: true
|
||||
branches:
|
||||
include:
|
||||
- rc
|
||||
- dev
|
||||
- master
|
||||
pr:
|
||||
- rc
|
||||
- dev
|
||||
- master
|
||||
|
||||
resources:
|
||||
containers:
|
||||
- container: 37
|
||||
image: homeassistant/ci-azure:3.7
|
||||
repositories:
|
||||
- repository: azure
|
||||
type: github
|
||||
name: 'home-assistant/ci-azure'
|
||||
endpoint: 'home-assistant'
|
||||
variables:
|
||||
- name: PythonMain
|
||||
value: '37'
|
||||
- group: codecov
|
||||
|
||||
stages:
|
||||
|
||||
- stage: 'Overview'
|
||||
jobs:
|
||||
- job: 'Lint'
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
container: $[ variables['PythonMain'] ]
|
||||
steps:
|
||||
- template: templates/azp-step-cache.yaml@azure
|
||||
parameters:
|
||||
keyfile: 'requirements_test.txt | homeassistant/package_constraints.txt'
|
||||
build: |
|
||||
python -m venv venv
|
||||
|
||||
. venv/bin/activate
|
||||
pip install -r requirements_test.txt -c homeassistant/package_constraints.txt
|
||||
pre-commit install-hooks --config .pre-commit-config-all.yaml
|
||||
- script: |
|
||||
. venv/bin/activate
|
||||
pre-commit run flake8 --all-files
|
||||
displayName: 'Run flake8'
|
||||
- script: |
|
||||
. venv/bin/activate
|
||||
pre-commit run bandit --all-files
|
||||
displayName: 'Run bandit'
|
||||
- script: |
|
||||
. venv/bin/activate
|
||||
pre-commit run isort --all-files --show-diff-on-failure
|
||||
displayName: 'Run isort'
|
||||
- script: |
|
||||
. venv/bin/activate
|
||||
pre-commit run check-json --all-files
|
||||
displayName: 'Run check-json'
|
||||
- job: 'Validate'
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
container: $[ variables['PythonMain'] ]
|
||||
steps:
|
||||
- template: templates/azp-step-cache.yaml@azure
|
||||
parameters:
|
||||
keyfile: 'homeassistant/package_constraints.txt'
|
||||
build: |
|
||||
python -m venv venv
|
||||
|
||||
. venv/bin/activate
|
||||
pip install -e .
|
||||
- script: |
|
||||
. venv/bin/activate
|
||||
python -m script.hassfest validate
|
||||
displayName: 'Validate manifests'
|
||||
- script: |
|
||||
. venv/bin/activate
|
||||
./script/gen_requirements_all.py validate
|
||||
displayName: 'requirements_all validate'
|
||||
- job: 'CheckFormat'
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
container: $[ variables['PythonMain'] ]
|
||||
steps:
|
||||
- template: templates/azp-step-cache.yaml@azure
|
||||
parameters:
|
||||
keyfile: 'requirements_test.txt | homeassistant/package_constraints.txt'
|
||||
build: |
|
||||
python -m venv venv
|
||||
|
||||
. venv/bin/activate
|
||||
pip install -r requirements_test.txt -c homeassistant/package_constraints.txt
|
||||
pre-commit install-hooks --config .pre-commit-config-all.yaml
|
||||
- script: |
|
||||
. venv/bin/activate
|
||||
pre-commit run black --all-files --show-diff-on-failure
|
||||
displayName: 'Check Black formatting'
|
||||
|
||||
- stage: 'Tests'
|
||||
dependsOn:
|
||||
- 'Overview'
|
||||
jobs:
|
||||
- job: 'PyTest'
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
strategy:
|
||||
maxParallel: 3
|
||||
matrix:
|
||||
Python37:
|
||||
python.container: '37'
|
||||
container: $[ variables['python.container'] ]
|
||||
steps:
|
||||
- template: templates/azp-step-cache.yaml@azure
|
||||
parameters:
|
||||
keyfile: 'requirements_test_all.txt | homeassistant/package_constraints.txt'
|
||||
build: |
|
||||
set -e
|
||||
python -m venv venv
|
||||
|
||||
. venv/bin/activate
|
||||
pip install -U pip setuptools pytest-azurepipelines pytest-xdist -c homeassistant/package_constraints.txt
|
||||
pip install -r requirements_test_all.txt -c homeassistant/package_constraints.txt
|
||||
# This is a TEMP. Eventually we should make sure our 4 dependencies drop typing.
|
||||
# Find offending deps with `pipdeptree -r -p typing`
|
||||
pip uninstall -y typing
|
||||
- script: |
|
||||
. venv/bin/activate
|
||||
pip install -e .
|
||||
displayName: 'Install Home Assistant'
|
||||
- script: |
|
||||
set -e
|
||||
|
||||
. venv/bin/activate
|
||||
pytest --timeout=9 --durations=10 -n auto --dist=loadfile -qq -o console_output_style=count -p no:sugar tests
|
||||
script/check_dirty
|
||||
displayName: 'Run pytest for python $(python.container)'
|
||||
condition: and(succeeded(), ne(variables['python.container'], variables['PythonMain']))
|
||||
- script: |
|
||||
set -e
|
||||
|
||||
. venv/bin/activate
|
||||
pytest --timeout=9 --durations=10 -n auto --dist=loadfile --cov homeassistant --cov-report html -qq -o console_output_style=count -p no:sugar tests
|
||||
codecov --token $(codecovToken)
|
||||
script/check_dirty
|
||||
displayName: 'Run pytest for python $(python.container) / coverage'
|
||||
condition: and(succeeded(), eq(variables['python.container'], variables['PythonMain']))
|
||||
|
||||
- stage: 'FullCheck'
|
||||
dependsOn:
|
||||
- 'Overview'
|
||||
jobs:
|
||||
- job: 'Pylint'
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
container: $[ variables['PythonMain'] ]
|
||||
steps:
|
||||
- template: templates/azp-step-cache.yaml@azure
|
||||
parameters:
|
||||
keyfile: 'requirements_all.txt | requirements_test.txt | homeassistant/package_constraints.txt'
|
||||
build: |
|
||||
set -e
|
||||
python -m venv venv
|
||||
|
||||
. venv/bin/activate
|
||||
pip install -U pip setuptools wheel
|
||||
pip install -r requirements_all.txt -c homeassistant/package_constraints.txt
|
||||
pip install -r requirements_test.txt -c homeassistant/package_constraints.txt
|
||||
- script: |
|
||||
. venv/bin/activate
|
||||
pip install -e .
|
||||
displayName: 'Install Home Assistant'
|
||||
- script: |
|
||||
. venv/bin/activate
|
||||
pylint homeassistant
|
||||
displayName: 'Run pylint'
|
||||
- job: 'Mypy'
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
container: $[ variables['PythonMain'] ]
|
||||
steps:
|
||||
- template: templates/azp-step-cache.yaml@azure
|
||||
parameters:
|
||||
keyfile: 'requirements_test.txt | setup.py | homeassistant/package_constraints.txt'
|
||||
build: |
|
||||
python -m venv venv
|
||||
|
||||
. venv/bin/activate
|
||||
pip install -e . -r requirements_test.txt -c homeassistant/package_constraints.txt
|
||||
pre-commit install-hooks --config .pre-commit-config-all.yaml
|
||||
- script: |
|
||||
. venv/bin/activate
|
||||
pre-commit run --config .pre-commit-config-all.yaml mypy --all-files
|
||||
displayName: 'Run mypy'
|
|
@ -1,277 +0,0 @@
|
|||
# https://dev.azure.com/home-assistant
|
||||
|
||||
trigger:
|
||||
tags:
|
||||
include:
|
||||
- '*'
|
||||
pr: none
|
||||
schedules:
|
||||
- cron: "0 1 * * *"
|
||||
displayName: "nightly builds"
|
||||
branches:
|
||||
include:
|
||||
- dev
|
||||
always: true
|
||||
variables:
|
||||
- name: versionBuilder
|
||||
value: '6.3'
|
||||
- group: docker
|
||||
- group: github
|
||||
- group: twine
|
||||
resources:
|
||||
repositories:
|
||||
- repository: azure
|
||||
type: github
|
||||
name: 'home-assistant/ci-azure'
|
||||
endpoint: 'home-assistant'
|
||||
|
||||
stages:
|
||||
|
||||
- stage: 'Validate'
|
||||
jobs:
|
||||
- template: templates/azp-job-version.yaml@azure
|
||||
parameters:
|
||||
ignoreDev: true
|
||||
- job: 'Permission'
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
steps:
|
||||
- script: |
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
jq curl
|
||||
|
||||
release="$(Build.SourceBranchName)"
|
||||
created_by="$(curl -s https://api.github.com/repos/home-assistant/home-assistant/releases/tags/${release} | jq --raw-output '.author.login')"
|
||||
|
||||
if [[ "${created_by}" =~ ^(balloob|pvizeli|fabaff|robbiet480|bramkragten)$ ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "${created_by} is not allowed to create an release!"
|
||||
exit 1
|
||||
displayName: 'Check rights'
|
||||
condition: and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags'))
|
||||
|
||||
- stage: 'Build'
|
||||
jobs:
|
||||
- job: 'ReleasePython'
|
||||
condition: startsWith(variables['Build.SourceBranch'], 'refs/tags')
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
steps:
|
||||
- task: UsePythonVersion@0
|
||||
displayName: 'Use Python 3.7'
|
||||
inputs:
|
||||
versionSpec: '3.7'
|
||||
- script: pip install twine wheel
|
||||
displayName: 'Install tools'
|
||||
- script: python setup.py sdist bdist_wheel
|
||||
displayName: 'Build package'
|
||||
- script: |
|
||||
export TWINE_USERNAME="$(twineUser)"
|
||||
export TWINE_PASSWORD="$(twinePassword)"
|
||||
|
||||
twine upload dist/* --skip-existing
|
||||
displayName: 'Upload pypi'
|
||||
- job: 'ReleaseDocker'
|
||||
timeoutInMinutes: 240
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
strategy:
|
||||
maxParallel: 5
|
||||
matrix:
|
||||
amd64:
|
||||
buildArch: 'amd64'
|
||||
buildMachine: 'qemux86-64,intel-nuc'
|
||||
i386:
|
||||
buildArch: 'i386'
|
||||
buildMachine: 'qemux86'
|
||||
armhf:
|
||||
buildArch: 'armhf'
|
||||
buildMachine: 'qemuarm,raspberrypi'
|
||||
armv7:
|
||||
buildArch: 'armv7'
|
||||
buildMachine: 'raspberrypi2,raspberrypi3,raspberrypi4,odroid-xu,tinker'
|
||||
aarch64:
|
||||
buildArch: 'aarch64'
|
||||
buildMachine: 'qemuarm-64,raspberrypi3-64,raspberrypi4-64,odroid-c2,orangepi-prime'
|
||||
steps:
|
||||
- template: templates/azp-step-ha-version.yaml@azure
|
||||
- script: |
|
||||
docker login -u $(dockerUser) -p $(dockerPassword)
|
||||
displayName: 'Docker hub login'
|
||||
- script: docker pull homeassistant/amd64-builder:$(versionBuilder)
|
||||
displayName: 'Install Builder'
|
||||
- script: |
|
||||
set -e
|
||||
|
||||
docker run --rm --privileged \
|
||||
-v ~/.docker:/root/.docker:rw \
|
||||
-v /run/docker.sock:/run/docker.sock:rw \
|
||||
-v $(pwd):/homeassistant:ro \
|
||||
homeassistant/amd64-builder:$(versionBuilder) \
|
||||
--homeassistant $(homeassistantRelease) "--$(buildArch)" \
|
||||
-r https://github.com/home-assistant/hassio-homeassistant \
|
||||
-t generic --docker-hub homeassistant
|
||||
|
||||
docker run --rm --privileged \
|
||||
-v ~/.docker:/root/.docker \
|
||||
-v /run/docker.sock:/run/docker.sock:rw \
|
||||
homeassistant/amd64-builder:$(versionBuilder) \
|
||||
--homeassistant-machine "$(homeassistantRelease)=$(buildMachine)" \
|
||||
-r https://github.com/home-assistant/hassio-homeassistant \
|
||||
-t machine --docker-hub homeassistant
|
||||
displayName: 'Build Release'
|
||||
|
||||
- stage: 'Publish'
|
||||
jobs:
|
||||
- job: 'ReleaseHassio'
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
steps:
|
||||
- template: templates/azp-step-ha-version.yaml@azure
|
||||
- script: |
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
git jq curl
|
||||
|
||||
git config --global user.name "Pascal Vizeli"
|
||||
git config --global user.email "pvizeli@syshack.ch"
|
||||
git config --global credential.helper store
|
||||
|
||||
echo "https://$(githubToken):x-oauth-basic@github.com" > $HOME/.git-credentials
|
||||
displayName: 'Install requirements'
|
||||
- script: |
|
||||
set -e
|
||||
|
||||
version="$(homeassistantRelease)"
|
||||
|
||||
git clone https://github.com/home-assistant/hassio-version
|
||||
cd hassio-version
|
||||
|
||||
dev_version="$(jq --raw-output '.homeassistant.default' dev.json)"
|
||||
beta_version="$(jq --raw-output '.homeassistant.default' beta.json)"
|
||||
stable_version="$(jq --raw-output '.homeassistant.default' stable.json)"
|
||||
|
||||
if [[ "$version" =~ d ]]; then
|
||||
sed -i "s|$dev_version|$version|g" dev.json
|
||||
elif [[ "$version" =~ b ]]; then
|
||||
sed -i "s|$beta_version|$version|g" beta.json
|
||||
else
|
||||
sed -i "s|$beta_version|$version|g" beta.json
|
||||
sed -i "s|$stable_version|$version|g" stable.json
|
||||
fi
|
||||
|
||||
git commit -am "Bump Home Assistant $version"
|
||||
git push
|
||||
displayName: 'Update version files'
|
||||
- job: 'ReleaseDocker'
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
steps:
|
||||
- template: templates/azp-step-ha-version.yaml@azure
|
||||
- script: |
|
||||
docker login -u $(dockerUser) -p $(dockerPassword)
|
||||
displayName: 'Docker login'
|
||||
- script: |
|
||||
set -e
|
||||
export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||
|
||||
function create_manifest() {
|
||||
local tag_l=$1
|
||||
local tag_r=$2
|
||||
|
||||
docker manifest create homeassistant/home-assistant:${tag_l} \
|
||||
homeassistant/amd64-homeassistant:${tag_r} \
|
||||
homeassistant/i386-homeassistant:${tag_r} \
|
||||
homeassistant/armhf-homeassistant:${tag_r} \
|
||||
homeassistant/armv7-homeassistant:${tag_r} \
|
||||
homeassistant/aarch64-homeassistant:${tag_r}
|
||||
|
||||
docker manifest annotate homeassistant/home-assistant:${tag_l} \
|
||||
homeassistant/amd64-homeassistant:${tag_r} \
|
||||
--os linux --arch amd64
|
||||
|
||||
docker manifest annotate homeassistant/home-assistant:${tag_l} \
|
||||
homeassistant/i386-homeassistant:${tag_r} \
|
||||
--os linux --arch 386
|
||||
|
||||
docker manifest annotate homeassistant/home-assistant:${tag_l} \
|
||||
homeassistant/armhf-homeassistant:${tag_r} \
|
||||
--os linux --arch arm --variant=v6
|
||||
|
||||
docker manifest annotate homeassistant/home-assistant:${tag_l} \
|
||||
homeassistant/armv7-homeassistant:${tag_r} \
|
||||
--os linux --arch arm --variant=v7
|
||||
|
||||
docker manifest annotate homeassistant/home-assistant:${tag_l} \
|
||||
homeassistant/aarch64-homeassistant:${tag_r} \
|
||||
--os linux --arch arm64 --variant=v8
|
||||
|
||||
docker manifest push --purge homeassistant/home-assistant:${tag_l}
|
||||
}
|
||||
|
||||
docker pull homeassistant/amd64-homeassistant:$(homeassistantRelease)
|
||||
docker pull homeassistant/i386-homeassistant:$(homeassistantRelease)
|
||||
docker pull homeassistant/armhf-homeassistant:$(homeassistantRelease)
|
||||
docker pull homeassistant/armv7-homeassistant:$(homeassistantRelease)
|
||||
docker pull homeassistant/aarch64-homeassistant:$(homeassistantRelease)
|
||||
|
||||
# Create version tag
|
||||
create_manifest "$(homeassistantRelease)" "$(homeassistantRelease)"
|
||||
|
||||
# Create general tags
|
||||
if [[ "$(homeassistantRelease)" =~ d ]]; then
|
||||
create_manifest "dev" "$(homeassistantRelease)"
|
||||
elif [[ "$(homeassistantRelease)" =~ b ]]; then
|
||||
create_manifest "beta" "$(homeassistantRelease)"
|
||||
create_manifest "rc" "$(homeassistantRelease)"
|
||||
else
|
||||
create_manifest "stable" "$(homeassistantRelease)"
|
||||
create_manifest "latest" "$(homeassistantRelease)"
|
||||
create_manifest "beta" "$(homeassistantRelease)"
|
||||
create_manifest "rc" "$(homeassistantRelease)"
|
||||
fi
|
||||
|
||||
displayName: 'Create Meta-Image'
|
||||
|
||||
- stage: 'Addidional'
|
||||
jobs:
|
||||
- job: 'Updater'
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
variables:
|
||||
- group: gcloud
|
||||
steps:
|
||||
- template: templates/azp-step-ha-version.yaml@azure
|
||||
- script: |
|
||||
set -e
|
||||
|
||||
export CLOUDSDK_CORE_DISABLE_PROMPTS=1
|
||||
|
||||
curl -o google-cloud-sdk.tar.gz https://dl.google.com/dl/cloudsdk/release/google-cloud-sdk.tar.gz
|
||||
tar -C . -xvf google-cloud-sdk.tar.gz
|
||||
rm -f google-cloud-sdk.tar.gz
|
||||
./google-cloud-sdk/install.sh
|
||||
displayName: 'Setup gCloud'
|
||||
condition: eq(variables['homeassistantReleaseStable'], 'true')
|
||||
- script: |
|
||||
set -e
|
||||
|
||||
export CLOUDSDK_CORE_DISABLE_PROMPTS=1
|
||||
|
||||
echo "$(gcloudAnalytic)" > gcloud_auth.json
|
||||
./google-cloud-sdk/bin/gcloud auth activate-service-account --key-file gcloud_auth.json
|
||||
rm -f gcloud_auth.json
|
||||
displayName: 'Auth gCloud'
|
||||
condition: eq(variables['homeassistantReleaseStable'], 'true')
|
||||
- script: |
|
||||
set -e
|
||||
|
||||
export CLOUDSDK_CORE_DISABLE_PROMPTS=1
|
||||
|
||||
./google-cloud-sdk/bin/gcloud functions deploy Analytics-Receiver \
|
||||
--project home-assistant-analytics \
|
||||
--update-env-vars VERSION=$(homeassistantRelease) \
|
||||
--source gs://analytics-src/function-source.zip
|
||||
displayName: 'Push details to updater'
|
||||
condition: eq(variables['homeassistantReleaseStable'], 'true')
|
|
@ -1,66 +0,0 @@
|
|||
# https://dev.azure.com/home-assistant
|
||||
|
||||
trigger:
|
||||
batch: true
|
||||
branches:
|
||||
include:
|
||||
- dev
|
||||
pr: none
|
||||
schedules:
|
||||
- cron: "30 0 * * *"
|
||||
displayName: "translation update"
|
||||
branches:
|
||||
include:
|
||||
- dev
|
||||
always: true
|
||||
variables:
|
||||
- group: translation
|
||||
resources:
|
||||
repositories:
|
||||
- repository: azure
|
||||
type: github
|
||||
name: 'home-assistant/ci-azure'
|
||||
endpoint: 'home-assistant'
|
||||
|
||||
|
||||
jobs:
|
||||
|
||||
- job: 'Upload'
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
steps:
|
||||
- task: UsePythonVersion@0
|
||||
displayName: 'Use Python 3.7'
|
||||
inputs:
|
||||
versionSpec: '3.7'
|
||||
- script: |
|
||||
export LOKALISE_TOKEN="$(lokaliseToken)"
|
||||
export AZURE_BRANCH="$(Build.SourceBranchName)"
|
||||
|
||||
./script/translations_upload
|
||||
displayName: 'Upload Translation'
|
||||
|
||||
- job: 'Download'
|
||||
dependsOn:
|
||||
- 'Upload'
|
||||
condition: or(eq(variables['Build.Reason'], 'Schedule'), eq(variables['Build.Reason'], 'Manual'))
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
steps:
|
||||
- task: UsePythonVersion@0
|
||||
displayName: 'Use Python 3.7'
|
||||
inputs:
|
||||
versionSpec: '3.7'
|
||||
- template: templates/azp-step-git-init.yaml@azure
|
||||
- script: |
|
||||
export LOKALISE_TOKEN="$(lokaliseToken)"
|
||||
export AZURE_BRANCH="$(Build.SourceBranchName)"
|
||||
|
||||
./script/translations_download
|
||||
displayName: 'Download Translation'
|
||||
- script: |
|
||||
git checkout dev
|
||||
git add homeassistant
|
||||
git commit -am "[ci skip] Translation update"
|
||||
git push
|
||||
displayName: 'Update translation'
|
|
@ -1,76 +0,0 @@
|
|||
# https://dev.azure.com/home-assistant
|
||||
|
||||
trigger:
|
||||
batch: true
|
||||
branches:
|
||||
include:
|
||||
- dev
|
||||
paths:
|
||||
include:
|
||||
- requirements_all.txt
|
||||
pr: none
|
||||
schedules:
|
||||
- cron: '0 */4 * * *'
|
||||
displayName: 'daily builds'
|
||||
branches:
|
||||
include:
|
||||
- dev
|
||||
always: true
|
||||
variables:
|
||||
- name: versionWheels
|
||||
value: '1.4-3.7-alpine3.10'
|
||||
resources:
|
||||
repositories:
|
||||
- repository: azure
|
||||
type: github
|
||||
name: 'home-assistant/ci-azure'
|
||||
endpoint: 'home-assistant'
|
||||
|
||||
jobs:
|
||||
- template: templates/azp-job-wheels.yaml@azure
|
||||
parameters:
|
||||
builderVersion: '$(versionWheels)'
|
||||
builderApk: 'build-base;cmake;git;linux-headers;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;autoconf;automake;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev'
|
||||
builderPip: 'Cython;numpy'
|
||||
wheelsRequirement: 'requirements_wheels.txt'
|
||||
wheelsRequirementDiff: 'requirements_diff.txt'
|
||||
preBuild:
|
||||
- script: |
|
||||
cp requirements_all.txt requirements_wheels.txt
|
||||
if [[ "$(Build.Reason)" =~ (Schedule|Manual) ]]; then
|
||||
touch requirements_diff.txt
|
||||
else
|
||||
curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/home-assistant/master/requirements_all.txt
|
||||
fi
|
||||
|
||||
requirement_files="requirements_wheels.txt requirements_diff.txt"
|
||||
for requirement_file in ${requirement_files}; do
|
||||
sed -i "s|# pybluez|pybluez|g" ${requirement_file}
|
||||
sed -i "s|# bluepy|bluepy|g" ${requirement_file}
|
||||
sed -i "s|# beacontools|beacontools|g" ${requirement_file}
|
||||
sed -i "s|# RPi.GPIO|RPi.GPIO|g" ${requirement_file}
|
||||
sed -i "s|# raspihats|raspihats|g" ${requirement_file}
|
||||
sed -i "s|# rpi-rf|rpi-rf|g" ${requirement_file}
|
||||
sed -i "s|# blinkt|blinkt|g" ${requirement_file}
|
||||
sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file}
|
||||
sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file}
|
||||
sed -i "s|# evdev|evdev|g" ${requirement_file}
|
||||
sed -i "s|# smbus-cffi|smbus-cffi|g" ${requirement_file}
|
||||
sed -i "s|# i2csense|i2csense|g" ${requirement_file}
|
||||
sed -i "s|# python-eq3bt|python-eq3bt|g" ${requirement_file}
|
||||
sed -i "s|# pycups|pycups|g" ${requirement_file}
|
||||
sed -i "s|# homekit|homekit|g" ${requirement_file}
|
||||
sed -i "s|# decora_wifi|decora_wifi|g" ${requirement_file}
|
||||
sed -i "s|# decora|decora|g" ${requirement_file}
|
||||
sed -i "s|# avion|avion|g" ${requirement_file}
|
||||
sed -i "s|# PySwitchbot|PySwitchbot|g" ${requirement_file}
|
||||
sed -i "s|# pySwitchmate|pySwitchmate|g" ${requirement_file}
|
||||
sed -i "s|# face_recognition|face_recognition|g" ${requirement_file}
|
||||
sed -i "s|# py_noaa|py_noaa|g" ${requirement_file}
|
||||
sed -i "s|# bme680|bme680|g" ${requirement_file}
|
||||
|
||||
if [[ "$(buildArch)" =~ arm ]]; then
|
||||
sed -i "s|# VL53L1X|VL53L1X|g" ${requirement_file}
|
||||
fi
|
||||
done
|
||||
displayName: 'Prepare requirements files for Hass.io'
|
|
@ -8,6 +8,7 @@ Loosely based on https://github.com/astropy/astropy/pull/347
|
|||
import os
|
||||
import warnings
|
||||
|
||||
|
||||
__licence__ = 'BSD (3 clause)'
|
||||
|
||||
|
||||
|
|
|
@ -4,23 +4,6 @@ homeassistant.helpers package
|
|||
Submodules
|
||||
----------
|
||||
|
||||
homeassistant.helpers.aiohttp_client module
|
||||
-------------------------------------------
|
||||
|
||||
.. automodule:: homeassistant.helpers.aiohttp_client
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
|
||||
homeassistant.helpers.area_registry module
|
||||
------------------------------------------
|
||||
|
||||
.. automodule:: homeassistant.helpers.area_registry
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.condition module
|
||||
--------------------------------------
|
||||
|
||||
|
@ -29,14 +12,6 @@ homeassistant.helpers.condition module
|
|||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.config_entry_flow module
|
||||
----------------------------------------------
|
||||
|
||||
.. automodule:: homeassistant.helpers.config_entry_flow
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.config_validation module
|
||||
----------------------------------------------
|
||||
|
||||
|
@ -45,30 +20,6 @@ homeassistant.helpers.config_validation module
|
|||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.data_entry_flow module
|
||||
--------------------------------------------
|
||||
|
||||
.. automodule:: homeassistant.helpers.data_entry_flow
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.deprecation module
|
||||
----------------------------------------
|
||||
|
||||
.. automodule:: homeassistant.helpers.deprecation
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.device_registry module
|
||||
--------------------------------------------
|
||||
|
||||
.. automodule:: homeassistant.helpers.device_registry
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.discovery module
|
||||
--------------------------------------
|
||||
|
||||
|
@ -77,14 +28,6 @@ homeassistant.helpers.discovery module
|
|||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.dispatcher module
|
||||
---------------------------------------
|
||||
|
||||
.. automodule:: homeassistant.helpers.dispatcher
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.entity module
|
||||
-----------------------------------
|
||||
|
||||
|
@ -101,38 +44,6 @@ homeassistant.helpers.entity_component module
|
|||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.entity_platform module
|
||||
--------------------------------------------
|
||||
|
||||
.. automodule:: homeassistant.helpers.entity_platform
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.entity_registry module
|
||||
--------------------------------------------
|
||||
|
||||
.. automodule:: homeassistant.helpers.entity_registry
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.entity_values module
|
||||
------------------------------------------
|
||||
|
||||
.. automodule:: homeassistant.helpers.entity_values
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.entityfilter module
|
||||
-----------------------------------------
|
||||
|
||||
.. automodule:: homeassistant.helpers.entityfilter
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.event module
|
||||
----------------------------------
|
||||
|
||||
|
@ -141,26 +52,10 @@ homeassistant.helpers.event module
|
|||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.icon module
|
||||
---------------------------------
|
||||
homeassistant.helpers.event_decorators module
|
||||
---------------------------------------------
|
||||
|
||||
.. automodule:: homeassistant.helpers.icon
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.intent module
|
||||
-----------------------------------
|
||||
|
||||
.. automodule:: homeassistant.helpers.intent
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.json module
|
||||
---------------------------------
|
||||
|
||||
.. automodule:: homeassistant.helpers.json
|
||||
.. automodule:: homeassistant.helpers.event_decorators
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
@ -173,22 +68,6 @@ homeassistant.helpers.location module
|
|||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.logging module
|
||||
------------------------------------
|
||||
|
||||
.. automodule:: homeassistant.helpers.logging
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.restore_state module
|
||||
------------------------------------------
|
||||
|
||||
.. automodule:: homeassistant.helpers.restore_state
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.script module
|
||||
-----------------------------------
|
||||
|
||||
|
@ -205,14 +84,6 @@ homeassistant.helpers.service module
|
|||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.signal module
|
||||
-----------------------------------
|
||||
|
||||
.. automodule:: homeassistant.helpers.signal
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.state module
|
||||
----------------------------------
|
||||
|
||||
|
@ -221,38 +92,6 @@ homeassistant.helpers.state module
|
|||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.storage module
|
||||
------------------------------------
|
||||
|
||||
.. automodule:: homeassistant.helpers.storage
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.sun module
|
||||
--------------------------------
|
||||
|
||||
.. automodule:: homeassistant.helpers.sun
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.system_info module
|
||||
----------------------------------------
|
||||
|
||||
.. automodule:: homeassistant.helpers.system_info
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.temperature module
|
||||
----------------------------------------
|
||||
|
||||
.. automodule:: homeassistant.helpers.temperature
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.template module
|
||||
-------------------------------------
|
||||
|
||||
|
@ -261,14 +100,6 @@ homeassistant.helpers.template module
|
|||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.translation module
|
||||
-----------------------------------------
|
||||
|
||||
.. automodule:: homeassistant.helpers.translation
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
homeassistant.helpers.typing module
|
||||
-----------------------------------
|
||||
|
||||
|
|
|
@ -17,11 +17,11 @@
|
|||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
#
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
import os
|
||||
import inspect
|
||||
|
||||
from homeassistant.const import __short_version__, __version__
|
||||
from homeassistant.const import __version__, __short_version__
|
||||
|
||||
PROJECT_NAME = 'Home Assistant'
|
||||
PROJECT_PACKAGE_NAME = 'homeassistant'
|
||||
|
|
|
@ -19,4 +19,4 @@ Indices and tables
|
|||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
|
||||
.. _Home Assistant developers: https://developers.home-assistant.io/
|
||||
.. _Home Assistant developers: https://home-assistant.io/developers/
|
||||
|
|
|
@ -1,76 +1,61 @@
|
|||
"""Start Home Assistant."""
|
||||
from __future__ import print_function
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any, Dict, List
|
||||
|
||||
from homeassistant.const import REQUIRED_PYTHON_VER, RESTART_EXIT_CODE, __version__
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant import core
|
||||
from typing import List, Dict, Any # noqa pylint: disable=unused-import
|
||||
|
||||
|
||||
def set_loop() -> None:
|
||||
"""Attempt to use different loop."""
|
||||
from asyncio.events import BaseDefaultEventLoopPolicy
|
||||
from homeassistant import monkey_patch
|
||||
from homeassistant.const import (
|
||||
__version__,
|
||||
EVENT_HOMEASSISTANT_START,
|
||||
REQUIRED_PYTHON_VER,
|
||||
RESTART_EXIT_CODE,
|
||||
)
|
||||
|
||||
if sys.platform == "win32":
|
||||
if hasattr(asyncio, "WindowsProactorEventLoopPolicy"):
|
||||
# pylint: disable=no-member
|
||||
policy = asyncio.WindowsProactorEventLoopPolicy()
|
||||
else:
|
||||
|
||||
class ProactorPolicy(BaseDefaultEventLoopPolicy):
|
||||
"""Event loop policy to create proactor loops."""
|
||||
def attempt_use_uvloop() -> None:
|
||||
"""Attempt to use uvloop."""
|
||||
import asyncio
|
||||
|
||||
_loop_factory = asyncio.ProactorEventLoop
|
||||
|
||||
policy = ProactorPolicy()
|
||||
|
||||
asyncio.set_event_loop_policy(policy)
|
||||
try:
|
||||
import uvloop
|
||||
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
def validate_python() -> None:
|
||||
"""Validate that the right Python version is running."""
|
||||
if sys.version_info[:3] < REQUIRED_PYTHON_VER:
|
||||
print(
|
||||
"Home Assistant requires at least Python {}.{}.{}".format(
|
||||
*REQUIRED_PYTHON_VER
|
||||
)
|
||||
)
|
||||
print("Home Assistant requires at least Python {}.{}.{}".format(
|
||||
*REQUIRED_PYTHON_VER))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def ensure_config_path(config_dir: str) -> None:
|
||||
"""Validate the configuration directory."""
|
||||
import homeassistant.config as config_util
|
||||
|
||||
lib_dir = os.path.join(config_dir, "deps")
|
||||
lib_dir = os.path.join(config_dir, 'deps')
|
||||
|
||||
# Test if configuration directory exists
|
||||
if not os.path.isdir(config_dir):
|
||||
if config_dir != config_util.get_default_config_dir():
|
||||
print(
|
||||
(
|
||||
"Fatal Error: Specified configuration directory does "
|
||||
"not exist {} "
|
||||
).format(config_dir)
|
||||
)
|
||||
print(('Fatal Error: Specified configuration directory does '
|
||||
'not exist {} ').format(config_dir))
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
os.mkdir(config_dir)
|
||||
except OSError:
|
||||
print(
|
||||
(
|
||||
"Fatal Error: Unable to create default configuration "
|
||||
"directory {} "
|
||||
).format(config_dir)
|
||||
)
|
||||
print(('Fatal Error: Unable to create default configuration '
|
||||
'directory {} ').format(config_dir))
|
||||
sys.exit(1)
|
||||
|
||||
# Test if library directory exists
|
||||
|
@ -78,22 +63,18 @@ def ensure_config_path(config_dir: str) -> None:
|
|||
try:
|
||||
os.mkdir(lib_dir)
|
||||
except OSError:
|
||||
print(
|
||||
("Fatal Error: Unable to create library " "directory {} ").format(
|
||||
lib_dir
|
||||
)
|
||||
)
|
||||
print(('Fatal Error: Unable to create library '
|
||||
'directory {} ').format(lib_dir))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
async def ensure_config_file(hass: "core.HomeAssistant", config_dir: str) -> str:
|
||||
def ensure_config_file(config_dir: str) -> str:
|
||||
"""Ensure configuration file exists."""
|
||||
import homeassistant.config as config_util
|
||||
|
||||
config_path = await config_util.async_ensure_config_exists(hass, config_dir)
|
||||
config_path = config_util.ensure_config_exists(config_dir)
|
||||
|
||||
if config_path is None:
|
||||
print("Error getting configuration path")
|
||||
print('Error getting configuration path')
|
||||
sys.exit(1)
|
||||
|
||||
return config_path
|
||||
|
@ -102,72 +83,71 @@ async def ensure_config_file(hass: "core.HomeAssistant", config_dir: str) -> str
|
|||
def get_arguments() -> argparse.Namespace:
|
||||
"""Get parsed passed in arguments."""
|
||||
import homeassistant.config as config_util
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Home Assistant: Observe, Control, Automate."
|
||||
)
|
||||
parser.add_argument("--version", action="version", version=__version__)
|
||||
description="Home Assistant: Observe, Control, Automate.")
|
||||
parser.add_argument('--version', action='version', version=__version__)
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--config",
|
||||
metavar="path_to_config_dir",
|
||||
'-c', '--config',
|
||||
metavar='path_to_config_dir',
|
||||
default=config_util.get_default_config_dir(),
|
||||
help="Directory that contains the Home Assistant configuration",
|
||||
)
|
||||
help="Directory that contains the Home Assistant configuration")
|
||||
parser.add_argument(
|
||||
"--demo-mode", action="store_true", help="Start Home Assistant in demo mode"
|
||||
)
|
||||
'--demo-mode',
|
||||
action='store_true',
|
||||
help='Start Home Assistant in demo mode')
|
||||
parser.add_argument(
|
||||
"--debug", action="store_true", help="Start Home Assistant in debug mode"
|
||||
)
|
||||
'--debug',
|
||||
action='store_true',
|
||||
help='Start Home Assistant in debug mode')
|
||||
parser.add_argument(
|
||||
"--open-ui", action="store_true", help="Open the webinterface in a browser"
|
||||
)
|
||||
'--open-ui',
|
||||
action='store_true',
|
||||
help='Open the webinterface in a browser')
|
||||
parser.add_argument(
|
||||
"--skip-pip",
|
||||
action="store_true",
|
||||
help="Skips pip install of required packages on startup",
|
||||
)
|
||||
'--skip-pip',
|
||||
action='store_true',
|
||||
help='Skips pip install of required packages on startup')
|
||||
parser.add_argument(
|
||||
"-v", "--verbose", action="store_true", help="Enable verbose logging to file."
|
||||
)
|
||||
'-v', '--verbose',
|
||||
action='store_true',
|
||||
help="Enable verbose logging to file.")
|
||||
parser.add_argument(
|
||||
"--pid-file",
|
||||
metavar="path_to_pid_file",
|
||||
'--pid-file',
|
||||
metavar='path_to_pid_file',
|
||||
default=None,
|
||||
help="Path to PID file useful for running as daemon",
|
||||
)
|
||||
help='Path to PID file useful for running as daemon')
|
||||
parser.add_argument(
|
||||
"--log-rotate-days",
|
||||
'--log-rotate-days',
|
||||
type=int,
|
||||
default=None,
|
||||
help="Enables daily log rotation and keeps up to the specified days",
|
||||
)
|
||||
help='Enables daily log rotation and keeps up to the specified days')
|
||||
parser.add_argument(
|
||||
"--log-file",
|
||||
'--log-file',
|
||||
type=str,
|
||||
default=None,
|
||||
help="Log file to write to. If not set, CONFIG/home-assistant.log " "is used",
|
||||
)
|
||||
help='Log file to write to. If not set, CONFIG/home-assistant.log '
|
||||
'is used')
|
||||
parser.add_argument(
|
||||
"--log-no-color", action="store_true", help="Disable color logs"
|
||||
)
|
||||
'--log-no-color',
|
||||
action='store_true',
|
||||
help="Disable color logs")
|
||||
parser.add_argument(
|
||||
"--runner",
|
||||
action="store_true",
|
||||
help=f"On restart exit with code {RESTART_EXIT_CODE}",
|
||||
)
|
||||
'--runner',
|
||||
action='store_true',
|
||||
help='On restart exit with code {}'.format(RESTART_EXIT_CODE))
|
||||
parser.add_argument(
|
||||
"--script", nargs=argparse.REMAINDER, help="Run one of the embedded scripts"
|
||||
)
|
||||
'--script',
|
||||
nargs=argparse.REMAINDER,
|
||||
help='Run one of the embedded scripts')
|
||||
if os.name == "posix":
|
||||
parser.add_argument(
|
||||
"--daemon", action="store_true", help="Run Home Assistant as daemon"
|
||||
)
|
||||
'--daemon',
|
||||
action='store_true',
|
||||
help='Run Home Assistant as daemon')
|
||||
|
||||
arguments = parser.parse_args()
|
||||
if os.name != "posix" or arguments.debug or arguments.runner:
|
||||
setattr(arguments, "daemon", False)
|
||||
setattr(arguments, 'daemon', False)
|
||||
|
||||
return arguments
|
||||
|
||||
|
@ -188,8 +168,8 @@ def daemonize() -> None:
|
|||
sys.exit(0)
|
||||
|
||||
# redirect standard file descriptors to devnull
|
||||
infd = open(os.devnull, "r")
|
||||
outfd = open(os.devnull, "a+")
|
||||
infd = open(os.devnull, 'r')
|
||||
outfd = open(os.devnull, 'a+')
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
os.dup2(infd.fileno(), sys.stdin.fileno())
|
||||
|
@ -201,9 +181,9 @@ def check_pid(pid_file: str) -> None:
|
|||
"""Check that Home Assistant is not already running."""
|
||||
# Check pid file
|
||||
try:
|
||||
with open(pid_file, "r") as file:
|
||||
with open(pid_file, 'r') as file:
|
||||
pid = int(file.readline())
|
||||
except OSError:
|
||||
except IOError:
|
||||
# PID File does not exist
|
||||
return
|
||||
|
||||
|
@ -216,7 +196,7 @@ def check_pid(pid_file: str) -> None:
|
|||
except OSError:
|
||||
# PID does not exist
|
||||
return
|
||||
print("Fatal Error: HomeAssistant is already running.")
|
||||
print('Fatal Error: HomeAssistant is already running.')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
|
@ -224,10 +204,10 @@ def write_pid(pid_file: str) -> None:
|
|||
"""Create a PID File."""
|
||||
pid = os.getpid()
|
||||
try:
|
||||
with open(pid_file, "w") as file:
|
||||
with open(pid_file, 'w') as file:
|
||||
file.write(str(pid))
|
||||
except OSError:
|
||||
print(f"Fatal Error: Unable to write pid file {pid_file}")
|
||||
except IOError:
|
||||
print('Fatal Error: Unable to write pid file {}'.format(pid_file))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
|
@ -245,74 +225,91 @@ def closefds_osx(min_fd: int, max_fd: int) -> None:
|
|||
val = fcntl(_fd, F_GETFD)
|
||||
if not val & FD_CLOEXEC:
|
||||
fcntl(_fd, F_SETFD, val | FD_CLOEXEC)
|
||||
except OSError:
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
|
||||
def cmdline() -> List[str]:
|
||||
"""Collect path and arguments to re-execute the current hass instance."""
|
||||
if os.path.basename(sys.argv[0]) == "__main__.py":
|
||||
if os.path.basename(sys.argv[0]) == '__main__.py':
|
||||
modulepath = os.path.dirname(sys.argv[0])
|
||||
os.environ["PYTHONPATH"] = os.path.dirname(modulepath)
|
||||
return [sys.executable] + [arg for arg in sys.argv if arg != "--daemon"]
|
||||
os.environ['PYTHONPATH'] = os.path.dirname(modulepath)
|
||||
return [sys.executable] + [arg for arg in sys.argv if
|
||||
arg != '--daemon']
|
||||
|
||||
return [arg for arg in sys.argv if arg != "--daemon"]
|
||||
return [arg for arg in sys.argv if arg != '--daemon']
|
||||
|
||||
|
||||
async def setup_and_run_hass(config_dir: str, args: argparse.Namespace) -> int:
|
||||
def setup_and_run_hass(config_dir: str,
|
||||
args: argparse.Namespace) -> int:
|
||||
"""Set up HASS and run."""
|
||||
from homeassistant import bootstrap, core
|
||||
from homeassistant import bootstrap
|
||||
|
||||
hass = core.HomeAssistant()
|
||||
# Run a simple daemon runner process on Windows to handle restarts
|
||||
if os.name == 'nt' and '--runner' not in sys.argv:
|
||||
nt_args = cmdline() + ['--runner']
|
||||
while True:
|
||||
try:
|
||||
subprocess.check_call(nt_args)
|
||||
sys.exit(0)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
if exc.returncode != RESTART_EXIT_CODE:
|
||||
sys.exit(exc.returncode)
|
||||
|
||||
if args.demo_mode:
|
||||
config: Dict[str, Any] = {"frontend": {}, "demo": {}}
|
||||
bootstrap.async_from_config_dict(
|
||||
config,
|
||||
hass,
|
||||
config_dir=config_dir,
|
||||
verbose=args.verbose,
|
||||
skip_pip=args.skip_pip,
|
||||
log_rotate_days=args.log_rotate_days,
|
||||
log_file=args.log_file,
|
||||
log_no_color=args.log_no_color,
|
||||
)
|
||||
config = {
|
||||
'frontend': {},
|
||||
'demo': {}
|
||||
} # type: Dict[str, Any]
|
||||
hass = bootstrap.from_config_dict(
|
||||
config, config_dir=config_dir, verbose=args.verbose,
|
||||
skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days,
|
||||
log_file=args.log_file, log_no_color=args.log_no_color)
|
||||
else:
|
||||
config_file = await ensure_config_file(hass, config_dir)
|
||||
print("Config directory:", config_dir)
|
||||
await bootstrap.async_from_config_file(
|
||||
config_file,
|
||||
hass,
|
||||
verbose=args.verbose,
|
||||
skip_pip=args.skip_pip,
|
||||
log_rotate_days=args.log_rotate_days,
|
||||
log_file=args.log_file,
|
||||
log_no_color=args.log_no_color,
|
||||
config_file = ensure_config_file(config_dir)
|
||||
print('Config directory:', config_dir)
|
||||
hass = bootstrap.from_config_file(
|
||||
config_file, verbose=args.verbose, skip_pip=args.skip_pip,
|
||||
log_rotate_days=args.log_rotate_days, log_file=args.log_file,
|
||||
log_no_color=args.log_no_color)
|
||||
|
||||
if hass is None:
|
||||
return -1
|
||||
|
||||
if args.open_ui:
|
||||
# Imported here to avoid importing asyncio before monkey patch
|
||||
from homeassistant.util.async_ import run_callback_threadsafe
|
||||
|
||||
def open_browser(_: Any) -> None:
|
||||
"""Open the web interface in a browser."""
|
||||
if hass.config.api is not None: # type: ignore
|
||||
import webbrowser
|
||||
webbrowser.open(hass.config.api.base_url) # type: ignore
|
||||
|
||||
run_callback_threadsafe(
|
||||
hass.loop,
|
||||
hass.bus.async_listen_once,
|
||||
EVENT_HOMEASSISTANT_START, open_browser
|
||||
)
|
||||
|
||||
if args.open_ui and hass.config.api is not None:
|
||||
import webbrowser
|
||||
|
||||
hass.add_job(webbrowser.open, hass.config.api.base_url)
|
||||
|
||||
return await hass.async_run()
|
||||
return hass.start()
|
||||
|
||||
|
||||
def try_to_restart() -> None:
|
||||
"""Attempt to clean up state and start a new Home Assistant instance."""
|
||||
# Things should be mostly shut down already at this point, now just try
|
||||
# to clean up things that may have been left behind.
|
||||
sys.stderr.write("Home Assistant attempting to restart.\n")
|
||||
sys.stderr.write('Home Assistant attempting to restart.\n')
|
||||
|
||||
# Count remaining threads, ideally there should only be one non-daemonized
|
||||
# thread left (which is us). Nothing we really do with it, but it might be
|
||||
# useful when debugging shutdown/restart issues.
|
||||
try:
|
||||
nthreads = sum(
|
||||
thread.is_alive() and not thread.daemon for thread in threading.enumerate()
|
||||
)
|
||||
nthreads = sum(thread.is_alive() and not thread.daemon
|
||||
for thread in threading.enumerate())
|
||||
if nthreads > 1:
|
||||
sys.stderr.write(f"Found {nthreads} non-daemonic threads.\n")
|
||||
sys.stderr.write(
|
||||
"Found {} non-daemonic threads.\n".format(nthreads))
|
||||
|
||||
# Somehow we sometimes seem to trigger an assertion in the python threading
|
||||
# module. It seems we find threads that have no associated OS level thread
|
||||
|
@ -326,7 +323,7 @@ def try_to_restart() -> None:
|
|||
except ValueError:
|
||||
max_fd = 256
|
||||
|
||||
if platform.system() == "Darwin":
|
||||
if platform.system() == 'Darwin':
|
||||
closefds_osx(3, max_fd)
|
||||
else:
|
||||
os.closerange(3, max_fd)
|
||||
|
@ -344,26 +341,18 @@ def main() -> int:
|
|||
"""Start Home Assistant."""
|
||||
validate_python()
|
||||
|
||||
set_loop()
|
||||
monkey_patch_needed = sys.version_info[:3] < (3, 6, 3)
|
||||
if monkey_patch_needed and os.environ.get('HASS_NO_MONKEY') != '1':
|
||||
if sys.version_info[:2] >= (3, 6):
|
||||
monkey_patch.disable_c_asyncio()
|
||||
monkey_patch.patch_weakref_tasks()
|
||||
|
||||
# Run a simple daemon runner process on Windows to handle restarts
|
||||
if os.name == "nt" and "--runner" not in sys.argv:
|
||||
nt_args = cmdline() + ["--runner"]
|
||||
while True:
|
||||
try:
|
||||
subprocess.check_call(nt_args)
|
||||
sys.exit(0)
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
if exc.returncode != RESTART_EXIT_CODE:
|
||||
sys.exit(exc.returncode)
|
||||
attempt_use_uvloop()
|
||||
|
||||
args = get_arguments()
|
||||
|
||||
if args.script is not None:
|
||||
from homeassistant import scripts
|
||||
|
||||
return scripts.run(args.script)
|
||||
|
||||
config_dir = os.path.join(os.getcwd(), args.config)
|
||||
|
@ -377,7 +366,7 @@ def main() -> int:
|
|||
if args.pid_file:
|
||||
write_pid(args.pid_file)
|
||||
|
||||
exit_code = asyncio.run(setup_and_run_hass(config_dir, args))
|
||||
exit_code = setup_and_run_hass(config_dir, args)
|
||||
if exit_code == RESTART_EXIT_CODE and not args.runner:
|
||||
try_to_restart()
|
||||
|
||||
|
|
|
@ -1,119 +1,87 @@
|
|||
"""Provide an authentication layer for Home Assistant."""
|
||||
import asyncio
|
||||
from collections import OrderedDict
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Dict, List, Optional, Tuple, cast
|
||||
|
||||
import jwt
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.auth.const import ACCESS_TOKEN_EXPIRATION
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import callback, HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import auth_store, models
|
||||
from .const import GROUP_ID_ADMIN
|
||||
from .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config
|
||||
from .providers import AuthProvider, LoginFlow, auth_provider_from_config
|
||||
|
||||
EVENT_USER_ADDED = "user_added"
|
||||
EVENT_USER_REMOVED = "user_removed"
|
||||
from .providers import auth_provider_from_config, AuthProvider
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_MfaModuleDict = Dict[str, MultiFactorAuthModule]
|
||||
_ProviderKey = Tuple[str, Optional[str]]
|
||||
_ProviderDict = Dict[_ProviderKey, AuthProvider]
|
||||
|
||||
|
||||
async def auth_manager_from_config(
|
||||
hass: HomeAssistant,
|
||||
provider_configs: List[Dict[str, Any]],
|
||||
module_configs: List[Dict[str, Any]],
|
||||
) -> "AuthManager":
|
||||
"""Initialize an auth manager from config.
|
||||
|
||||
CORE_CONFIG_SCHEMA will make sure do duplicated auth providers or
|
||||
mfa modules exist in configs.
|
||||
"""
|
||||
hass: HomeAssistant,
|
||||
provider_configs: List[Dict[str, Any]]) -> 'AuthManager':
|
||||
"""Initialize an auth manager from config."""
|
||||
store = auth_store.AuthStore(hass)
|
||||
if provider_configs:
|
||||
providers = await asyncio.gather(
|
||||
*(
|
||||
auth_provider_from_config(hass, store, config)
|
||||
for config in provider_configs
|
||||
)
|
||||
)
|
||||
*[auth_provider_from_config(hass, store, config)
|
||||
for config in provider_configs])
|
||||
else:
|
||||
providers = []
|
||||
providers = ()
|
||||
# So returned auth providers are in same order as config
|
||||
provider_hash: _ProviderDict = OrderedDict()
|
||||
provider_hash = OrderedDict() # type: _ProviderDict
|
||||
for provider in providers:
|
||||
if provider is None:
|
||||
continue
|
||||
|
||||
key = (provider.type, provider.id)
|
||||
|
||||
if key in provider_hash:
|
||||
_LOGGER.error(
|
||||
'Found duplicate provider: %s. Please add unique IDs if you '
|
||||
'want to have the same provider twice.', key)
|
||||
continue
|
||||
|
||||
provider_hash[key] = provider
|
||||
|
||||
if module_configs:
|
||||
modules = await asyncio.gather(
|
||||
*(auth_mfa_module_from_config(hass, config) for config in module_configs)
|
||||
)
|
||||
else:
|
||||
modules = []
|
||||
# So returned auth modules are in same order as config
|
||||
module_hash: _MfaModuleDict = OrderedDict()
|
||||
for module in modules:
|
||||
module_hash[module.id] = module
|
||||
|
||||
manager = AuthManager(hass, store, provider_hash, module_hash)
|
||||
manager = AuthManager(hass, store, provider_hash)
|
||||
return manager
|
||||
|
||||
|
||||
class AuthManager:
|
||||
"""Manage the authentication for Home Assistant."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
store: auth_store.AuthStore,
|
||||
providers: _ProviderDict,
|
||||
mfa_modules: _MfaModuleDict,
|
||||
) -> None:
|
||||
def __init__(self, hass: HomeAssistant, store: auth_store.AuthStore,
|
||||
providers: _ProviderDict) -> None:
|
||||
"""Initialize the auth manager."""
|
||||
self.hass = hass
|
||||
self._store = store
|
||||
self._providers = providers
|
||||
self._mfa_modules = mfa_modules
|
||||
self.login_flow = data_entry_flow.FlowManager(
|
||||
hass, self._async_create_login_flow, self._async_finish_login_flow
|
||||
)
|
||||
hass, self._async_create_login_flow,
|
||||
self._async_finish_login_flow)
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
"""Return if any auth providers are registered."""
|
||||
return bool(self._providers)
|
||||
|
||||
@property
|
||||
def support_legacy(self) -> bool:
|
||||
"""
|
||||
Return if legacy_api_password auth providers are registered.
|
||||
|
||||
Should be removed when we removed legacy_api_password auth providers.
|
||||
"""
|
||||
for provider_type, _ in self._providers:
|
||||
if provider_type == 'legacy_api_password':
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def auth_providers(self) -> List[AuthProvider]:
|
||||
"""Return a list of available auth providers."""
|
||||
return list(self._providers.values())
|
||||
|
||||
@property
|
||||
def auth_mfa_modules(self) -> List[MultiFactorAuthModule]:
|
||||
"""Return a list of available auth modules."""
|
||||
return list(self._mfa_modules.values())
|
||||
|
||||
def get_auth_provider(
|
||||
self, provider_type: str, provider_id: str
|
||||
) -> Optional[AuthProvider]:
|
||||
"""Return an auth provider, None if not found."""
|
||||
return self._providers.get((provider_type, provider_id))
|
||||
|
||||
def get_auth_providers(self, provider_type: str) -> List[AuthProvider]:
|
||||
"""Return a List of auth provider of one type, Empty if not found."""
|
||||
return [
|
||||
provider
|
||||
for (p_type, _), provider in self._providers.items()
|
||||
if p_type == provider_type
|
||||
]
|
||||
|
||||
def get_auth_mfa_module(self, module_id: str) -> Optional[MultiFactorAuthModule]:
|
||||
"""Return a multi-factor auth module, None if not found."""
|
||||
return self._mfa_modules.get(module_id)
|
||||
|
||||
async def async_get_users(self) -> List[models.User]:
|
||||
"""Retrieve all users."""
|
||||
return await self._store.async_get_users()
|
||||
|
@ -122,86 +90,53 @@ class AuthManager:
|
|||
"""Retrieve a user."""
|
||||
return await self._store.async_get_user(user_id)
|
||||
|
||||
async def async_get_owner(self) -> Optional[models.User]:
|
||||
"""Retrieve the owner."""
|
||||
users = await self.async_get_users()
|
||||
return next((user for user in users if user.is_owner), None)
|
||||
|
||||
async def async_get_group(self, group_id: str) -> Optional[models.Group]:
|
||||
"""Retrieve all groups."""
|
||||
return await self._store.async_get_group(group_id)
|
||||
|
||||
async def async_get_user_by_credentials(
|
||||
self, credentials: models.Credentials
|
||||
) -> Optional[models.User]:
|
||||
"""Get a user by credential, return None if not found."""
|
||||
for user in await self.async_get_users():
|
||||
for creds in user.credentials:
|
||||
if creds.id == credentials.id:
|
||||
return user
|
||||
|
||||
return None
|
||||
|
||||
async def async_create_system_user(
|
||||
self, name: str, group_ids: Optional[List[str]] = None
|
||||
) -> models.User:
|
||||
async def async_create_system_user(self, name: str) -> models.User:
|
||||
"""Create a system user."""
|
||||
user = await self._store.async_create_user(
|
||||
name=name, system_generated=True, is_active=True, group_ids=group_ids or []
|
||||
return await self._store.async_create_user(
|
||||
name=name,
|
||||
system_generated=True,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
self.hass.bus.async_fire(EVENT_USER_ADDED, {"user_id": user.id})
|
||||
|
||||
return user
|
||||
|
||||
async def async_create_user(self, name: str) -> models.User:
|
||||
"""Create a user."""
|
||||
kwargs: Dict[str, Any] = {
|
||||
"name": name,
|
||||
"is_active": True,
|
||||
"group_ids": [GROUP_ID_ADMIN],
|
||||
}
|
||||
kwargs = {
|
||||
'name': name,
|
||||
'is_active': True,
|
||||
} # type: Dict[str, Any]
|
||||
|
||||
if await self._user_should_be_owner():
|
||||
kwargs["is_owner"] = True
|
||||
kwargs['is_owner'] = True
|
||||
|
||||
user = await self._store.async_create_user(**kwargs)
|
||||
return await self._store.async_create_user(**kwargs)
|
||||
|
||||
self.hass.bus.async_fire(EVENT_USER_ADDED, {"user_id": user.id})
|
||||
|
||||
return user
|
||||
|
||||
async def async_get_or_create_user(
|
||||
self, credentials: models.Credentials
|
||||
) -> models.User:
|
||||
async def async_get_or_create_user(self, credentials: models.Credentials) \
|
||||
-> models.User:
|
||||
"""Get or create a user."""
|
||||
if not credentials.is_new:
|
||||
user = await self.async_get_user_by_credentials(credentials)
|
||||
if user is None:
|
||||
raise ValueError("Unable to find the user.")
|
||||
return user
|
||||
for user in await self._store.async_get_users():
|
||||
for creds in user.credentials:
|
||||
if creds.id == credentials.id:
|
||||
return user
|
||||
|
||||
raise ValueError('Unable to find the user.')
|
||||
|
||||
auth_provider = self._async_get_auth_provider(credentials)
|
||||
|
||||
if auth_provider is None:
|
||||
raise RuntimeError("Credential with unknown provider encountered")
|
||||
raise RuntimeError('Credential with unknown provider encountered')
|
||||
|
||||
info = await auth_provider.async_user_meta_for_credentials(credentials)
|
||||
info = await auth_provider.async_user_meta_for_credentials(
|
||||
credentials)
|
||||
|
||||
user = await self._store.async_create_user(
|
||||
return await self._store.async_create_user(
|
||||
credentials=credentials,
|
||||
name=info.name,
|
||||
is_active=info.is_active,
|
||||
group_ids=[GROUP_ID_ADMIN],
|
||||
)
|
||||
|
||||
self.hass.bus.async_fire(EVENT_USER_ADDED, {"user_id": user.id})
|
||||
|
||||
return user
|
||||
|
||||
async def async_link_user(
|
||||
self, user: models.User, credentials: models.Credentials
|
||||
) -> None:
|
||||
async def async_link_user(self, user: models.User,
|
||||
credentials: models.Credentials) -> None:
|
||||
"""Link credentials to an existing user."""
|
||||
await self._store.async_link_user(user, credentials)
|
||||
|
||||
|
@ -217,22 +152,6 @@ class AuthManager:
|
|||
|
||||
await self._store.async_remove_user(user)
|
||||
|
||||
self.hass.bus.async_fire(EVENT_USER_REMOVED, {"user_id": user.id})
|
||||
|
||||
async def async_update_user(
|
||||
self,
|
||||
user: models.User,
|
||||
name: Optional[str] = None,
|
||||
group_ids: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Update a user."""
|
||||
kwargs: Dict[str, Any] = {}
|
||||
if name is not None:
|
||||
kwargs["name"] = name
|
||||
if group_ids is not None:
|
||||
kwargs["group_ids"] = group_ids
|
||||
await self._store.async_update_user(user, **kwargs)
|
||||
|
||||
async def async_activate_user(self, user: models.User) -> None:
|
||||
"""Activate a user."""
|
||||
await self._store.async_activate_user(user)
|
||||
|
@ -240,156 +159,68 @@ class AuthManager:
|
|||
async def async_deactivate_user(self, user: models.User) -> None:
|
||||
"""Deactivate a user."""
|
||||
if user.is_owner:
|
||||
raise ValueError("Unable to deactive the owner")
|
||||
raise ValueError('Unable to deactive the owner')
|
||||
await self._store.async_deactivate_user(user)
|
||||
|
||||
async def async_remove_credentials(self, credentials: models.Credentials) -> None:
|
||||
async def async_remove_credentials(
|
||||
self, credentials: models.Credentials) -> None:
|
||||
"""Remove credentials."""
|
||||
provider = self._async_get_auth_provider(credentials)
|
||||
|
||||
if provider is not None and hasattr(provider, "async_will_remove_credentials"):
|
||||
if (provider is not None and
|
||||
hasattr(provider, 'async_will_remove_credentials')):
|
||||
# https://github.com/python/mypy/issues/1424
|
||||
await provider.async_will_remove_credentials( # type: ignore
|
||||
credentials
|
||||
)
|
||||
credentials)
|
||||
|
||||
await self._store.async_remove_credentials(credentials)
|
||||
|
||||
async def async_enable_user_mfa(
|
||||
self, user: models.User, mfa_module_id: str, data: Any
|
||||
) -> None:
|
||||
"""Enable a multi-factor auth module for user."""
|
||||
if user.system_generated:
|
||||
raise ValueError(
|
||||
"System generated users cannot enable multi-factor auth module."
|
||||
)
|
||||
|
||||
module = self.get_auth_mfa_module(mfa_module_id)
|
||||
if module is None:
|
||||
raise ValueError(f"Unable find multi-factor auth module: {mfa_module_id}")
|
||||
|
||||
await module.async_setup_user(user.id, data)
|
||||
|
||||
async def async_disable_user_mfa(
|
||||
self, user: models.User, mfa_module_id: str
|
||||
) -> None:
|
||||
"""Disable a multi-factor auth module for user."""
|
||||
if user.system_generated:
|
||||
raise ValueError(
|
||||
"System generated users cannot disable multi-factor auth module."
|
||||
)
|
||||
|
||||
module = self.get_auth_mfa_module(mfa_module_id)
|
||||
if module is None:
|
||||
raise ValueError(f"Unable find multi-factor auth module: {mfa_module_id}")
|
||||
|
||||
await module.async_depose_user(user.id)
|
||||
|
||||
async def async_get_enabled_mfa(self, user: models.User) -> Dict[str, str]:
|
||||
"""List enabled mfa modules for user."""
|
||||
modules: Dict[str, str] = OrderedDict()
|
||||
for module_id, module in self._mfa_modules.items():
|
||||
if await module.async_is_user_setup(user.id):
|
||||
modules[module_id] = module.name
|
||||
return modules
|
||||
|
||||
async def async_create_refresh_token(
|
||||
self,
|
||||
user: models.User,
|
||||
client_id: Optional[str] = None,
|
||||
client_name: Optional[str] = None,
|
||||
client_icon: Optional[str] = None,
|
||||
token_type: Optional[str] = None,
|
||||
access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION,
|
||||
) -> models.RefreshToken:
|
||||
async def async_create_refresh_token(self, user: models.User,
|
||||
client_id: Optional[str] = None) \
|
||||
-> models.RefreshToken:
|
||||
"""Create a new refresh token for a user."""
|
||||
if not user.is_active:
|
||||
raise ValueError("User is not active")
|
||||
raise ValueError('User is not active')
|
||||
|
||||
if user.system_generated and client_id is not None:
|
||||
raise ValueError(
|
||||
"System generated users cannot have refresh tokens connected "
|
||||
"to a client."
|
||||
)
|
||||
'System generated users cannot have refresh tokens connected '
|
||||
'to a client.')
|
||||
|
||||
if token_type is None:
|
||||
if user.system_generated:
|
||||
token_type = models.TOKEN_TYPE_SYSTEM
|
||||
else:
|
||||
token_type = models.TOKEN_TYPE_NORMAL
|
||||
if not user.system_generated and client_id is None:
|
||||
raise ValueError('Client is required to generate a refresh token.')
|
||||
|
||||
if user.system_generated != (token_type == models.TOKEN_TYPE_SYSTEM):
|
||||
raise ValueError(
|
||||
"System generated users can only have system type refresh tokens"
|
||||
)
|
||||
|
||||
if token_type == models.TOKEN_TYPE_NORMAL and client_id is None:
|
||||
raise ValueError("Client is required to generate a refresh token.")
|
||||
|
||||
if (
|
||||
token_type == models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN
|
||||
and client_name is None
|
||||
):
|
||||
raise ValueError("Client_name is required for long-lived access token")
|
||||
|
||||
if token_type == models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN:
|
||||
for token in user.refresh_tokens.values():
|
||||
if (
|
||||
token.client_name == client_name
|
||||
and token.token_type == models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN
|
||||
):
|
||||
# Each client_name can only have one
|
||||
# long_lived_access_token type of refresh token
|
||||
raise ValueError(f"{client_name} already exists")
|
||||
|
||||
return await self._store.async_create_refresh_token(
|
||||
user,
|
||||
client_id,
|
||||
client_name,
|
||||
client_icon,
|
||||
token_type,
|
||||
access_token_expiration,
|
||||
)
|
||||
return await self._store.async_create_refresh_token(user, client_id)
|
||||
|
||||
async def async_get_refresh_token(
|
||||
self, token_id: str
|
||||
) -> Optional[models.RefreshToken]:
|
||||
self, token_id: str) -> Optional[models.RefreshToken]:
|
||||
"""Get refresh token by id."""
|
||||
return await self._store.async_get_refresh_token(token_id)
|
||||
|
||||
async def async_get_refresh_token_by_token(
|
||||
self, token: str
|
||||
) -> Optional[models.RefreshToken]:
|
||||
self, token: str) -> Optional[models.RefreshToken]:
|
||||
"""Get refresh token by token."""
|
||||
return await self._store.async_get_refresh_token_by_token(token)
|
||||
|
||||
async def async_remove_refresh_token(
|
||||
self, refresh_token: models.RefreshToken
|
||||
) -> None:
|
||||
async def async_remove_refresh_token(self,
|
||||
refresh_token: models.RefreshToken) \
|
||||
-> None:
|
||||
"""Delete a refresh token."""
|
||||
await self._store.async_remove_refresh_token(refresh_token)
|
||||
|
||||
@callback
|
||||
def async_create_access_token(
|
||||
self, refresh_token: models.RefreshToken, remote_ip: Optional[str] = None
|
||||
) -> str:
|
||||
def async_create_access_token(self,
|
||||
refresh_token: models.RefreshToken) -> str:
|
||||
"""Create a new access token."""
|
||||
self._store.async_log_refresh_token_usage(refresh_token, remote_ip)
|
||||
|
||||
now = dt_util.utcnow()
|
||||
return jwt.encode(
|
||||
{
|
||||
"iss": refresh_token.id,
|
||||
"iat": now,
|
||||
"exp": now + refresh_token.access_token_expiration,
|
||||
},
|
||||
refresh_token.jwt_key,
|
||||
algorithm="HS256",
|
||||
).decode()
|
||||
# pylint: disable=no-self-use
|
||||
return jwt.encode({
|
||||
'iss': refresh_token.id,
|
||||
'iat': dt_util.utcnow(),
|
||||
'exp': dt_util.utcnow() + refresh_token.access_token_expiration,
|
||||
}, refresh_token.jwt_key, algorithm='HS256').decode()
|
||||
|
||||
async def async_validate_access_token(
|
||||
self, token: str
|
||||
) -> Optional[models.RefreshToken]:
|
||||
self, token: str) -> Optional[models.RefreshToken]:
|
||||
"""Return refresh token if an access token is valid."""
|
||||
try:
|
||||
unverif_claims = jwt.decode(token, verify=False)
|
||||
|
@ -397,18 +228,23 @@ class AuthManager:
|
|||
return None
|
||||
|
||||
refresh_token = await self.async_get_refresh_token(
|
||||
cast(str, unverif_claims.get("iss"))
|
||||
)
|
||||
cast(str, unverif_claims.get('iss')))
|
||||
|
||||
if refresh_token is None:
|
||||
jwt_key = ""
|
||||
issuer = ""
|
||||
jwt_key = ''
|
||||
issuer = ''
|
||||
else:
|
||||
jwt_key = refresh_token.jwt_key
|
||||
issuer = refresh_token.id
|
||||
|
||||
try:
|
||||
jwt.decode(token, jwt_key, leeway=10, issuer=issuer, algorithms=["HS256"])
|
||||
jwt.decode(
|
||||
token,
|
||||
jwt_key,
|
||||
leeway=10,
|
||||
issuer=issuer,
|
||||
algorithms=['HS256']
|
||||
)
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
@ -418,58 +254,38 @@ class AuthManager:
|
|||
return refresh_token
|
||||
|
||||
async def _async_create_login_flow(
|
||||
self, handler: _ProviderKey, *, context: Optional[Dict], data: Optional[Any]
|
||||
) -> data_entry_flow.FlowHandler:
|
||||
self, handler: _ProviderKey, *, context: Optional[Dict],
|
||||
data: Optional[Any]) -> data_entry_flow.FlowHandler:
|
||||
"""Create a login flow."""
|
||||
auth_provider = self._providers[handler]
|
||||
|
||||
return await auth_provider.async_login_flow(context)
|
||||
|
||||
async def _async_finish_login_flow(
|
||||
self, flow: LoginFlow, result: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
self, flow: data_entry_flow.FlowHandler, result: Dict[str, Any]) \
|
||||
-> Dict[str, Any]:
|
||||
"""Return a user as result of login flow."""
|
||||
if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
|
||||
if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
|
||||
return result
|
||||
|
||||
# we got final result
|
||||
if isinstance(result["data"], models.User):
|
||||
result["result"] = result["data"]
|
||||
return result
|
||||
|
||||
auth_provider = self._providers[result["handler"]]
|
||||
auth_provider = self._providers[result['handler']]
|
||||
credentials = await auth_provider.async_get_or_create_credentials(
|
||||
result["data"]
|
||||
)
|
||||
result['data'])
|
||||
|
||||
if flow.context.get("credential_only"):
|
||||
result["result"] = credentials
|
||||
if flow.context is not None and flow.context.get('credential_only'):
|
||||
result['result'] = credentials
|
||||
return result
|
||||
|
||||
# multi-factor module cannot enabled for new credential
|
||||
# which has not linked to a user yet
|
||||
if auth_provider.support_mfa and not credentials.is_new:
|
||||
user = await self.async_get_user_by_credentials(credentials)
|
||||
if user is not None:
|
||||
modules = await self.async_get_enabled_mfa(user)
|
||||
|
||||
if modules:
|
||||
flow.user = user
|
||||
flow.available_mfa_modules = modules
|
||||
return await flow.async_step_select_mfa_module()
|
||||
|
||||
result["result"] = await self.async_get_or_create_user(credentials)
|
||||
user = await self.async_get_or_create_user(credentials)
|
||||
result['result'] = user
|
||||
return result
|
||||
|
||||
@callback
|
||||
def _async_get_auth_provider(
|
||||
self, credentials: models.Credentials
|
||||
) -> Optional[AuthProvider]:
|
||||
"""Get auth provider from a set of credentials."""
|
||||
auth_provider_key = (
|
||||
credentials.auth_provider_type,
|
||||
credentials.auth_provider_id,
|
||||
)
|
||||
self, credentials: models.Credentials) -> Optional[AuthProvider]:
|
||||
"""Helper to get auth provider from a set of credentials."""
|
||||
auth_provider_key = (credentials.auth_provider_type,
|
||||
credentials.auth_provider_id)
|
||||
return self._providers.get(auth_provider_key)
|
||||
|
||||
async def _user_should_be_owner(self) -> bool:
|
||||
|
|
|
@ -1,25 +1,17 @@
|
|||
"""Storage for auth models."""
|
||||
import asyncio
|
||||
from collections import OrderedDict
|
||||
from datetime import timedelta
|
||||
import hmac
|
||||
from logging import getLogger
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional # noqa: F401
|
||||
import hmac
|
||||
|
||||
from homeassistant.auth.const import ACCESS_TOKEN_EXPIRATION
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import models
|
||||
from .const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY, GROUP_ID_USER
|
||||
from .permissions import PermissionLookup, system_policies
|
||||
from .permissions.types import PolicyType
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_KEY = "auth"
|
||||
GROUP_NAME_ADMIN = "Administrators"
|
||||
GROUP_NAME_USER = "Users"
|
||||
GROUP_NAME_READ_ONLY = "Read Only"
|
||||
STORAGE_KEY = 'auth'
|
||||
|
||||
|
||||
class AuthStore:
|
||||
|
@ -34,29 +26,8 @@ class AuthStore:
|
|||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the auth store."""
|
||||
self.hass = hass
|
||||
self._users: Optional[Dict[str, models.User]] = None
|
||||
self._groups: Optional[Dict[str, models.Group]] = None
|
||||
self._perm_lookup: Optional[PermissionLookup] = None
|
||||
self._store = hass.helpers.storage.Store(
|
||||
STORAGE_VERSION, STORAGE_KEY, private=True
|
||||
)
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def async_get_groups(self) -> List[models.Group]:
|
||||
"""Retrieve all users."""
|
||||
if self._groups is None:
|
||||
await self._async_load()
|
||||
assert self._groups is not None
|
||||
|
||||
return list(self._groups.values())
|
||||
|
||||
async def async_get_group(self, group_id: str) -> Optional[models.Group]:
|
||||
"""Retrieve all users."""
|
||||
if self._groups is None:
|
||||
await self._async_load()
|
||||
assert self._groups is not None
|
||||
|
||||
return self._groups.get(group_id)
|
||||
self._users = None # type: Optional[Dict[str, models.User]]
|
||||
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
|
||||
|
||||
async def async_get_users(self) -> List[models.User]:
|
||||
"""Retrieve all users."""
|
||||
|
@ -75,44 +46,27 @@ class AuthStore:
|
|||
return self._users.get(user_id)
|
||||
|
||||
async def async_create_user(
|
||||
self,
|
||||
name: Optional[str],
|
||||
is_owner: Optional[bool] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
system_generated: Optional[bool] = None,
|
||||
credentials: Optional[models.Credentials] = None,
|
||||
group_ids: Optional[List[str]] = None,
|
||||
) -> models.User:
|
||||
self, name: Optional[str], is_owner: Optional[bool] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
system_generated: Optional[bool] = None,
|
||||
credentials: Optional[models.Credentials] = None) -> models.User:
|
||||
"""Create a new user."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
assert self._users is not None
|
||||
|
||||
assert self._users is not None
|
||||
assert self._groups is not None
|
||||
|
||||
groups = []
|
||||
for group_id in group_ids or []:
|
||||
group = self._groups.get(group_id)
|
||||
if group is None:
|
||||
raise ValueError(f"Invalid group specified {group_id}")
|
||||
groups.append(group)
|
||||
|
||||
kwargs: Dict[str, Any] = {
|
||||
"name": name,
|
||||
# Until we get group management, we just put everyone in the
|
||||
# same group.
|
||||
"groups": groups,
|
||||
"perm_lookup": self._perm_lookup,
|
||||
}
|
||||
kwargs = {
|
||||
'name': name
|
||||
} # type: Dict[str, Any]
|
||||
|
||||
if is_owner is not None:
|
||||
kwargs["is_owner"] = is_owner
|
||||
kwargs['is_owner'] = is_owner
|
||||
|
||||
if is_active is not None:
|
||||
kwargs["is_active"] = is_active
|
||||
kwargs['is_active'] = is_active
|
||||
|
||||
if system_generated is not None:
|
||||
kwargs["system_generated"] = system_generated
|
||||
kwargs['system_generated'] = system_generated
|
||||
|
||||
new_user = models.User(**kwargs)
|
||||
|
||||
|
@ -126,9 +80,8 @@ class AuthStore:
|
|||
await self.async_link_user(new_user, credentials)
|
||||
return new_user
|
||||
|
||||
async def async_link_user(
|
||||
self, user: models.User, credentials: models.Credentials
|
||||
) -> None:
|
||||
async def async_link_user(self, user: models.User,
|
||||
credentials: models.Credentials) -> None:
|
||||
"""Add credentials to an existing user."""
|
||||
user.credentials.append(credentials)
|
||||
self._async_schedule_save()
|
||||
|
@ -143,33 +96,6 @@ class AuthStore:
|
|||
self._users.pop(user.id)
|
||||
self._async_schedule_save()
|
||||
|
||||
async def async_update_user(
|
||||
self,
|
||||
user: models.User,
|
||||
name: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
group_ids: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Update a user."""
|
||||
assert self._groups is not None
|
||||
|
||||
if group_ids is not None:
|
||||
groups = []
|
||||
for grid in group_ids:
|
||||
group = self._groups.get(grid)
|
||||
if group is None:
|
||||
raise ValueError("Invalid group specified.")
|
||||
groups.append(group)
|
||||
|
||||
user.groups = groups
|
||||
user.invalidate_permission_cache()
|
||||
|
||||
for attr_name, value in (("name", name), ("is_active", is_active)):
|
||||
if value is not None:
|
||||
setattr(user, attr_name, value)
|
||||
|
||||
self._async_schedule_save()
|
||||
|
||||
async def async_activate_user(self, user: models.User) -> None:
|
||||
"""Activate a user."""
|
||||
user.is_active = True
|
||||
|
@ -180,7 +106,8 @@ class AuthStore:
|
|||
user.is_active = False
|
||||
self._async_schedule_save()
|
||||
|
||||
async def async_remove_credentials(self, credentials: models.Credentials) -> None:
|
||||
async def async_remove_credentials(
|
||||
self, credentials: models.Credentials) -> None:
|
||||
"""Remove credentials."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
|
@ -201,35 +128,16 @@ class AuthStore:
|
|||
self._async_schedule_save()
|
||||
|
||||
async def async_create_refresh_token(
|
||||
self,
|
||||
user: models.User,
|
||||
client_id: Optional[str] = None,
|
||||
client_name: Optional[str] = None,
|
||||
client_icon: Optional[str] = None,
|
||||
token_type: str = models.TOKEN_TYPE_NORMAL,
|
||||
access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION,
|
||||
) -> models.RefreshToken:
|
||||
self, user: models.User, client_id: Optional[str] = None) \
|
||||
-> models.RefreshToken:
|
||||
"""Create a new token for a user."""
|
||||
kwargs: Dict[str, Any] = {
|
||||
"user": user,
|
||||
"client_id": client_id,
|
||||
"token_type": token_type,
|
||||
"access_token_expiration": access_token_expiration,
|
||||
}
|
||||
if client_name:
|
||||
kwargs["client_name"] = client_name
|
||||
if client_icon:
|
||||
kwargs["client_icon"] = client_icon
|
||||
|
||||
refresh_token = models.RefreshToken(**kwargs)
|
||||
refresh_token = models.RefreshToken(user=user, client_id=client_id)
|
||||
user.refresh_tokens[refresh_token.id] = refresh_token
|
||||
|
||||
self._async_schedule_save()
|
||||
return refresh_token
|
||||
|
||||
async def async_remove_refresh_token(
|
||||
self, refresh_token: models.RefreshToken
|
||||
) -> None:
|
||||
self, refresh_token: models.RefreshToken) -> None:
|
||||
"""Remove a refresh token."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
|
@ -241,8 +149,7 @@ class AuthStore:
|
|||
break
|
||||
|
||||
async def async_get_refresh_token(
|
||||
self, token_id: str
|
||||
) -> Optional[models.RefreshToken]:
|
||||
self, token_id: str) -> Optional[models.RefreshToken]:
|
||||
"""Get refresh token by id."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
|
@ -256,8 +163,7 @@ class AuthStore:
|
|||
return None
|
||||
|
||||
async def async_get_refresh_token_by_token(
|
||||
self, token: str
|
||||
) -> Optional[models.RefreshToken]:
|
||||
self, token: str) -> Optional[models.RefreshToken]:
|
||||
"""Get refresh token by token."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
|
@ -272,207 +178,56 @@ class AuthStore:
|
|||
|
||||
return found
|
||||
|
||||
@callback
|
||||
def async_log_refresh_token_usage(
|
||||
self, refresh_token: models.RefreshToken, remote_ip: Optional[str] = None
|
||||
) -> None:
|
||||
"""Update refresh token last used information."""
|
||||
refresh_token.last_used_at = dt_util.utcnow()
|
||||
refresh_token.last_used_ip = remote_ip
|
||||
self._async_schedule_save()
|
||||
|
||||
async def _async_load(self) -> None:
|
||||
"""Load the users."""
|
||||
async with self._lock:
|
||||
if self._users is not None:
|
||||
return
|
||||
await self._async_load_task()
|
||||
|
||||
async def _async_load_task(self) -> None:
|
||||
"""Load the users."""
|
||||
[ent_reg, dev_reg, data] = await asyncio.gather(
|
||||
self.hass.helpers.entity_registry.async_get_registry(),
|
||||
self.hass.helpers.device_registry.async_get_registry(),
|
||||
self._store.async_load(),
|
||||
)
|
||||
data = await self._store.async_load()
|
||||
|
||||
# Make sure that we're not overriding data if 2 loads happened at the
|
||||
# same time
|
||||
if self._users is not None:
|
||||
return
|
||||
|
||||
self._perm_lookup = perm_lookup = PermissionLookup(ent_reg, dev_reg)
|
||||
users = OrderedDict() # type: Dict[str, models.User]
|
||||
|
||||
if data is None:
|
||||
self._set_defaults()
|
||||
self._users = users
|
||||
return
|
||||
|
||||
users: Dict[str, models.User] = OrderedDict()
|
||||
groups: Dict[str, models.Group] = OrderedDict()
|
||||
for user_dict in data['users']:
|
||||
users[user_dict['id']] = models.User(**user_dict)
|
||||
|
||||
# Soft-migrating data as we load. We are going to make sure we have a
|
||||
# read only group and an admin group. There are two states that we can
|
||||
# migrate from:
|
||||
# 1. Data from a recent version which has a single group without policy
|
||||
# 2. Data from old version which has no groups
|
||||
has_admin_group = False
|
||||
has_user_group = False
|
||||
has_read_only_group = False
|
||||
group_without_policy = None
|
||||
for cred_dict in data['credentials']:
|
||||
users[cred_dict['user_id']].credentials.append(models.Credentials(
|
||||
id=cred_dict['id'],
|
||||
is_new=False,
|
||||
auth_provider_type=cred_dict['auth_provider_type'],
|
||||
auth_provider_id=cred_dict['auth_provider_id'],
|
||||
data=cred_dict['data'],
|
||||
))
|
||||
|
||||
# When creating objects we mention each attribute explicitly. This
|
||||
# prevents crashing if user rolls back HA version after a new property
|
||||
# was added.
|
||||
|
||||
for group_dict in data.get("groups", []):
|
||||
policy: Optional[PolicyType] = None
|
||||
|
||||
if group_dict["id"] == GROUP_ID_ADMIN:
|
||||
has_admin_group = True
|
||||
|
||||
name = GROUP_NAME_ADMIN
|
||||
policy = system_policies.ADMIN_POLICY
|
||||
system_generated = True
|
||||
|
||||
elif group_dict["id"] == GROUP_ID_USER:
|
||||
has_user_group = True
|
||||
|
||||
name = GROUP_NAME_USER
|
||||
policy = system_policies.USER_POLICY
|
||||
system_generated = True
|
||||
|
||||
elif group_dict["id"] == GROUP_ID_READ_ONLY:
|
||||
has_read_only_group = True
|
||||
|
||||
name = GROUP_NAME_READ_ONLY
|
||||
policy = system_policies.READ_ONLY_POLICY
|
||||
system_generated = True
|
||||
|
||||
else:
|
||||
name = group_dict["name"]
|
||||
policy = group_dict.get("policy")
|
||||
system_generated = False
|
||||
|
||||
# We don't want groups without a policy that are not system groups
|
||||
# This is part of migrating from state 1
|
||||
if policy is None:
|
||||
group_without_policy = group_dict["id"]
|
||||
continue
|
||||
|
||||
groups[group_dict["id"]] = models.Group(
|
||||
id=group_dict["id"],
|
||||
name=name,
|
||||
policy=policy,
|
||||
system_generated=system_generated,
|
||||
)
|
||||
|
||||
# If there are no groups, add all existing users to the admin group.
|
||||
# This is part of migrating from state 2
|
||||
migrate_users_to_admin_group = not groups and group_without_policy is None
|
||||
|
||||
# If we find a no_policy_group, we need to migrate all users to the
|
||||
# admin group. We only do this if there are no other groups, as is
|
||||
# the expected state. If not expected state, not marking people admin.
|
||||
# This is part of migrating from state 1
|
||||
if groups and group_without_policy is not None:
|
||||
group_without_policy = None
|
||||
|
||||
# This is part of migrating from state 1 and 2
|
||||
if not has_admin_group:
|
||||
admin_group = _system_admin_group()
|
||||
groups[admin_group.id] = admin_group
|
||||
|
||||
# This is part of migrating from state 1 and 2
|
||||
if not has_read_only_group:
|
||||
read_only_group = _system_read_only_group()
|
||||
groups[read_only_group.id] = read_only_group
|
||||
|
||||
if not has_user_group:
|
||||
user_group = _system_user_group()
|
||||
groups[user_group.id] = user_group
|
||||
|
||||
for user_dict in data["users"]:
|
||||
# Collect the users group.
|
||||
user_groups = []
|
||||
for group_id in user_dict.get("group_ids", []):
|
||||
# This is part of migrating from state 1
|
||||
if group_id == group_without_policy:
|
||||
group_id = GROUP_ID_ADMIN
|
||||
user_groups.append(groups[group_id])
|
||||
|
||||
# This is part of migrating from state 2
|
||||
if not user_dict["system_generated"] and migrate_users_to_admin_group:
|
||||
user_groups.append(groups[GROUP_ID_ADMIN])
|
||||
|
||||
users[user_dict["id"]] = models.User(
|
||||
name=user_dict["name"],
|
||||
groups=user_groups,
|
||||
id=user_dict["id"],
|
||||
is_owner=user_dict["is_owner"],
|
||||
is_active=user_dict["is_active"],
|
||||
system_generated=user_dict["system_generated"],
|
||||
perm_lookup=perm_lookup,
|
||||
)
|
||||
|
||||
for cred_dict in data["credentials"]:
|
||||
users[cred_dict["user_id"]].credentials.append(
|
||||
models.Credentials(
|
||||
id=cred_dict["id"],
|
||||
is_new=False,
|
||||
auth_provider_type=cred_dict["auth_provider_type"],
|
||||
auth_provider_id=cred_dict["auth_provider_id"],
|
||||
data=cred_dict["data"],
|
||||
)
|
||||
)
|
||||
|
||||
for rt_dict in data["refresh_tokens"]:
|
||||
for rt_dict in data['refresh_tokens']:
|
||||
# Filter out the old keys that don't have jwt_key (pre-0.76)
|
||||
if "jwt_key" not in rt_dict:
|
||||
if 'jwt_key' not in rt_dict:
|
||||
continue
|
||||
|
||||
created_at = dt_util.parse_datetime(rt_dict["created_at"])
|
||||
created_at = dt_util.parse_datetime(rt_dict['created_at'])
|
||||
if created_at is None:
|
||||
getLogger(__name__).error(
|
||||
"Ignoring refresh token %(id)s with invalid created_at "
|
||||
"%(created_at)s for user_id %(user_id)s",
|
||||
rt_dict,
|
||||
)
|
||||
'Ignoring refresh token %(id)s with invalid created_at '
|
||||
'%(created_at)s for user_id %(user_id)s', rt_dict)
|
||||
continue
|
||||
|
||||
token_type = rt_dict.get("token_type")
|
||||
if token_type is None:
|
||||
if rt_dict["client_id"] is None:
|
||||
token_type = models.TOKEN_TYPE_SYSTEM
|
||||
else:
|
||||
token_type = models.TOKEN_TYPE_NORMAL
|
||||
|
||||
# old refresh_token don't have last_used_at (pre-0.78)
|
||||
last_used_at_str = rt_dict.get("last_used_at")
|
||||
if last_used_at_str:
|
||||
last_used_at = dt_util.parse_datetime(last_used_at_str)
|
||||
else:
|
||||
last_used_at = None
|
||||
|
||||
token = models.RefreshToken(
|
||||
id=rt_dict["id"],
|
||||
user=users[rt_dict["user_id"]],
|
||||
client_id=rt_dict["client_id"],
|
||||
# use dict.get to keep backward compatibility
|
||||
client_name=rt_dict.get("client_name"),
|
||||
client_icon=rt_dict.get("client_icon"),
|
||||
token_type=token_type,
|
||||
id=rt_dict['id'],
|
||||
user=users[rt_dict['user_id']],
|
||||
client_id=rt_dict['client_id'],
|
||||
created_at=created_at,
|
||||
access_token_expiration=timedelta(
|
||||
seconds=rt_dict["access_token_expiration"]
|
||||
),
|
||||
token=rt_dict["token"],
|
||||
jwt_key=rt_dict["jwt_key"],
|
||||
last_used_at=last_used_at,
|
||||
last_used_ip=rt_dict.get("last_used_ip"),
|
||||
seconds=rt_dict['access_token_expiration']),
|
||||
token=rt_dict['token'],
|
||||
jwt_key=rt_dict['jwt_key']
|
||||
)
|
||||
users[rt_dict["user_id"]].refresh_tokens[token.id] = token
|
||||
users[rt_dict['user_id']].refresh_tokens[token.id] = token
|
||||
|
||||
self._groups = groups
|
||||
self._users = users
|
||||
|
||||
@callback
|
||||
|
@ -487,40 +242,25 @@ class AuthStore:
|
|||
def _data_to_save(self) -> Dict:
|
||||
"""Return the data to store."""
|
||||
assert self._users is not None
|
||||
assert self._groups is not None
|
||||
|
||||
users = [
|
||||
{
|
||||
"id": user.id,
|
||||
"group_ids": [group.id for group in user.groups],
|
||||
"is_owner": user.is_owner,
|
||||
"is_active": user.is_active,
|
||||
"name": user.name,
|
||||
"system_generated": user.system_generated,
|
||||
'id': user.id,
|
||||
'is_owner': user.is_owner,
|
||||
'is_active': user.is_active,
|
||||
'name': user.name,
|
||||
'system_generated': user.system_generated,
|
||||
}
|
||||
for user in self._users.values()
|
||||
]
|
||||
|
||||
groups = []
|
||||
for group in self._groups.values():
|
||||
g_dict: Dict[str, Any] = {
|
||||
"id": group.id,
|
||||
# Name not read for sys groups. Kept here for backwards compat
|
||||
"name": group.name,
|
||||
}
|
||||
|
||||
if not group.system_generated:
|
||||
g_dict["policy"] = group.policy
|
||||
|
||||
groups.append(g_dict)
|
||||
|
||||
credentials = [
|
||||
{
|
||||
"id": credential.id,
|
||||
"user_id": user.id,
|
||||
"auth_provider_type": credential.auth_provider_type,
|
||||
"auth_provider_id": credential.auth_provider_id,
|
||||
"data": credential.data,
|
||||
'id': credential.id,
|
||||
'user_id': user.id,
|
||||
'auth_provider_type': credential.auth_provider_type,
|
||||
'auth_provider_id': credential.auth_provider_id,
|
||||
'data': credential.data,
|
||||
}
|
||||
for user in self._users.values()
|
||||
for credential in user.credentials
|
||||
|
@ -528,71 +268,21 @@ class AuthStore:
|
|||
|
||||
refresh_tokens = [
|
||||
{
|
||||
"id": refresh_token.id,
|
||||
"user_id": user.id,
|
||||
"client_id": refresh_token.client_id,
|
||||
"client_name": refresh_token.client_name,
|
||||
"client_icon": refresh_token.client_icon,
|
||||
"token_type": refresh_token.token_type,
|
||||
"created_at": refresh_token.created_at.isoformat(),
|
||||
"access_token_expiration": refresh_token.access_token_expiration.total_seconds(),
|
||||
"token": refresh_token.token,
|
||||
"jwt_key": refresh_token.jwt_key,
|
||||
"last_used_at": refresh_token.last_used_at.isoformat()
|
||||
if refresh_token.last_used_at
|
||||
else None,
|
||||
"last_used_ip": refresh_token.last_used_ip,
|
||||
'id': refresh_token.id,
|
||||
'user_id': user.id,
|
||||
'client_id': refresh_token.client_id,
|
||||
'created_at': refresh_token.created_at.isoformat(),
|
||||
'access_token_expiration':
|
||||
refresh_token.access_token_expiration.total_seconds(),
|
||||
'token': refresh_token.token,
|
||||
'jwt_key': refresh_token.jwt_key,
|
||||
}
|
||||
for user in self._users.values()
|
||||
for refresh_token in user.refresh_tokens.values()
|
||||
]
|
||||
|
||||
return {
|
||||
"users": users,
|
||||
"groups": groups,
|
||||
"credentials": credentials,
|
||||
"refresh_tokens": refresh_tokens,
|
||||
'users': users,
|
||||
'credentials': credentials,
|
||||
'refresh_tokens': refresh_tokens,
|
||||
}
|
||||
|
||||
def _set_defaults(self) -> None:
|
||||
"""Set default values for auth store."""
|
||||
self._users = OrderedDict()
|
||||
|
||||
groups: Dict[str, models.Group] = OrderedDict()
|
||||
admin_group = _system_admin_group()
|
||||
groups[admin_group.id] = admin_group
|
||||
user_group = _system_user_group()
|
||||
groups[user_group.id] = user_group
|
||||
read_only_group = _system_read_only_group()
|
||||
groups[read_only_group.id] = read_only_group
|
||||
self._groups = groups
|
||||
|
||||
|
||||
def _system_admin_group() -> models.Group:
|
||||
"""Create system admin group."""
|
||||
return models.Group(
|
||||
name=GROUP_NAME_ADMIN,
|
||||
id=GROUP_ID_ADMIN,
|
||||
policy=system_policies.ADMIN_POLICY,
|
||||
system_generated=True,
|
||||
)
|
||||
|
||||
|
||||
def _system_user_group() -> models.Group:
|
||||
"""Create system user group."""
|
||||
return models.Group(
|
||||
name=GROUP_NAME_USER,
|
||||
id=GROUP_ID_USER,
|
||||
policy=system_policies.USER_POLICY,
|
||||
system_generated=True,
|
||||
)
|
||||
|
||||
|
||||
def _system_read_only_group() -> models.Group:
|
||||
"""Create read only group."""
|
||||
return models.Group(
|
||||
name=GROUP_NAME_READ_ONLY,
|
||||
id=GROUP_ID_READ_ONLY,
|
||||
policy=system_policies.READ_ONLY_POLICY,
|
||||
system_generated=True,
|
||||
)
|
||||
|
|
|
@ -2,8 +2,3 @@
|
|||
from datetime import timedelta
|
||||
|
||||
ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30)
|
||||
MFA_SESSION_EXPIRATION = timedelta(minutes=5)
|
||||
|
||||
GROUP_ID_ADMIN = "system-admin"
|
||||
GROUP_ID_USER = "system-users"
|
||||
GROUP_ID_READ_ONLY = "system-read-only"
|
||||
|
|
|
@ -1,170 +0,0 @@
|
|||
"""Plugable auth modules for Home Assistant."""
|
||||
import importlib
|
||||
import logging
|
||||
import types
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from homeassistant import data_entry_flow, requirements
|
||||
from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.util.decorator import Registry
|
||||
|
||||
MULTI_FACTOR_AUTH_MODULES = Registry()
|
||||
|
||||
MULTI_FACTOR_AUTH_MODULE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TYPE): str,
|
||||
vol.Optional(CONF_NAME): str,
|
||||
# Specify ID if you have two mfa auth module for same type.
|
||||
vol.Optional(CONF_ID): str,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
DATA_REQS = "mfa_auth_module_reqs_processed"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MultiFactorAuthModule:
|
||||
"""Multi-factor Auth Module of validation function."""
|
||||
|
||||
DEFAULT_TITLE = "Unnamed auth module"
|
||||
MAX_RETRY_TIME = 3
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None:
|
||||
"""Initialize an auth module."""
|
||||
self.hass = hass
|
||||
self.config = config
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""Return id of the auth module.
|
||||
|
||||
Default is same as type
|
||||
"""
|
||||
return self.config.get(CONF_ID, self.type)
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
"""Return type of the module."""
|
||||
return self.config[CONF_TYPE] # type: ignore
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the auth module."""
|
||||
return self.config.get(CONF_NAME, self.DEFAULT_TITLE)
|
||||
|
||||
# Implement by extending class
|
||||
|
||||
@property
|
||||
def input_schema(self) -> vol.Schema:
|
||||
"""Return a voluptuous schema to define mfa auth module's input."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_setup_flow(self, user_id: str) -> "SetupFlow":
|
||||
"""Return a data entry flow handler for setup module.
|
||||
|
||||
Mfa module should extend SetupFlow
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_setup_user(self, user_id: str, setup_data: Any) -> Any:
|
||||
"""Set up user for mfa auth module."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_depose_user(self, user_id: str) -> None:
|
||||
"""Remove user from mfa module."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_is_user_setup(self, user_id: str) -> bool:
|
||||
"""Return whether user is setup."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_validate(self, user_id: str, user_input: Dict[str, Any]) -> bool:
|
||||
"""Return True if validation passed."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class SetupFlow(data_entry_flow.FlowHandler):
|
||||
"""Handler for the setup flow."""
|
||||
|
||||
def __init__(
|
||||
self, auth_module: MultiFactorAuthModule, setup_schema: vol.Schema, user_id: str
|
||||
) -> None:
|
||||
"""Initialize the setup flow."""
|
||||
self._auth_module = auth_module
|
||||
self._setup_schema = setup_schema
|
||||
self._user_id = user_id
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: Optional[Dict[str, str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Handle the first step of setup flow.
|
||||
|
||||
Return self.async_show_form(step_id='init') if user_input is None.
|
||||
Return self.async_create_entry(data={'result': result}) if finish.
|
||||
"""
|
||||
errors: Dict[str, str] = {}
|
||||
|
||||
if user_input:
|
||||
result = await self._auth_module.async_setup_user(self._user_id, user_input)
|
||||
return self.async_create_entry(
|
||||
title=self._auth_module.name, data={"result": result}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init", data_schema=self._setup_schema, errors=errors
|
||||
)
|
||||
|
||||
|
||||
async def auth_mfa_module_from_config(
|
||||
hass: HomeAssistant, config: Dict[str, Any]
|
||||
) -> MultiFactorAuthModule:
|
||||
"""Initialize an auth module from a config."""
|
||||
module_name = config[CONF_TYPE]
|
||||
module = await _load_mfa_module(hass, module_name)
|
||||
|
||||
try:
|
||||
config = module.CONFIG_SCHEMA(config) # type: ignore
|
||||
except vol.Invalid as err:
|
||||
_LOGGER.error(
|
||||
"Invalid configuration for multi-factor module %s: %s",
|
||||
module_name,
|
||||
humanize_error(config, err),
|
||||
)
|
||||
raise
|
||||
|
||||
return MULTI_FACTOR_AUTH_MODULES[module_name](hass, config) # type: ignore
|
||||
|
||||
|
||||
async def _load_mfa_module(hass: HomeAssistant, module_name: str) -> types.ModuleType:
|
||||
"""Load an mfa auth module."""
|
||||
module_path = f"homeassistant.auth.mfa_modules.{module_name}"
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_path)
|
||||
except ImportError as err:
|
||||
_LOGGER.error("Unable to load mfa module %s: %s", module_name, err)
|
||||
raise HomeAssistantError(f"Unable to load mfa module {module_name}: {err}")
|
||||
|
||||
if hass.config.skip_pip or not hasattr(module, "REQUIREMENTS"):
|
||||
return module
|
||||
|
||||
processed = hass.data.get(DATA_REQS)
|
||||
if processed and module_name in processed:
|
||||
return module
|
||||
|
||||
processed = hass.data[DATA_REQS] = set()
|
||||
|
||||
# https://github.com/python/mypy/issues/1424
|
||||
await requirements.async_process_requirements(
|
||||
hass, module_path, module.REQUIREMENTS # type: ignore
|
||||
)
|
||||
|
||||
processed.add(module_name)
|
||||
return module
|
|
@ -1,94 +0,0 @@
|
|||
"""Example auth module."""
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import (
|
||||
MULTI_FACTOR_AUTH_MODULE_SCHEMA,
|
||||
MULTI_FACTOR_AUTH_MODULES,
|
||||
MultiFactorAuthModule,
|
||||
SetupFlow,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required("data"): [
|
||||
vol.Schema({vol.Required("user_id"): str, vol.Required("pin"): str})
|
||||
]
|
||||
},
|
||||
extra=vol.PREVENT_EXTRA,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@MULTI_FACTOR_AUTH_MODULES.register("insecure_example")
|
||||
class InsecureExampleModule(MultiFactorAuthModule):
|
||||
"""Example auth module validate pin."""
|
||||
|
||||
DEFAULT_TITLE = "Insecure Personal Identify Number"
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None:
|
||||
"""Initialize the user data store."""
|
||||
super().__init__(hass, config)
|
||||
self._data = config["data"]
|
||||
|
||||
@property
|
||||
def input_schema(self) -> vol.Schema:
|
||||
"""Validate login flow input data."""
|
||||
return vol.Schema({"pin": str})
|
||||
|
||||
@property
|
||||
def setup_schema(self) -> vol.Schema:
|
||||
"""Validate async_setup_user input data."""
|
||||
return vol.Schema({"pin": str})
|
||||
|
||||
async def async_setup_flow(self, user_id: str) -> SetupFlow:
|
||||
"""Return a data entry flow handler for setup module.
|
||||
|
||||
Mfa module should extend SetupFlow
|
||||
"""
|
||||
return SetupFlow(self, self.setup_schema, user_id)
|
||||
|
||||
async def async_setup_user(self, user_id: str, setup_data: Any) -> Any:
|
||||
"""Set up user to use mfa module."""
|
||||
# data shall has been validate in caller
|
||||
pin = setup_data["pin"]
|
||||
|
||||
for data in self._data:
|
||||
if data["user_id"] == user_id:
|
||||
# already setup, override
|
||||
data["pin"] = pin
|
||||
return
|
||||
|
||||
self._data.append({"user_id": user_id, "pin": pin})
|
||||
|
||||
async def async_depose_user(self, user_id: str) -> None:
|
||||
"""Remove user from mfa module."""
|
||||
found = None
|
||||
for data in self._data:
|
||||
if data["user_id"] == user_id:
|
||||
found = data
|
||||
break
|
||||
if found:
|
||||
self._data.remove(found)
|
||||
|
||||
async def async_is_user_setup(self, user_id: str) -> bool:
|
||||
"""Return whether user is setup."""
|
||||
for data in self._data:
|
||||
if data["user_id"] == user_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def async_validate(self, user_id: str, user_input: Dict[str, Any]) -> bool:
|
||||
"""Return True if validation passed."""
|
||||
for data in self._data:
|
||||
if data["user_id"] == user_id:
|
||||
# user_input has been validate in caller
|
||||
if data["pin"] == user_input["pin"]:
|
||||
return True
|
||||
|
||||
return False
|
|
@ -1,356 +0,0 @@
|
|||
"""HMAC-based One-time Password auth module.
|
||||
|
||||
Sending HOTP through notify service
|
||||
"""
|
||||
import asyncio
|
||||
from collections import OrderedDict
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import attr
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_EXCLUDE, CONF_INCLUDE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ServiceNotFound
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from . import (
|
||||
MULTI_FACTOR_AUTH_MODULE_SCHEMA,
|
||||
MULTI_FACTOR_AUTH_MODULES,
|
||||
MultiFactorAuthModule,
|
||||
SetupFlow,
|
||||
)
|
||||
|
||||
REQUIREMENTS = ["pyotp==2.3.0"]
|
||||
|
||||
CONF_MESSAGE = "message"
|
||||
|
||||
CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_INCLUDE): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_EXCLUDE): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_MESSAGE, default="{} is your Home Assistant login code"): str,
|
||||
},
|
||||
extra=vol.PREVENT_EXTRA,
|
||||
)
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_KEY = "auth_module.notify"
|
||||
STORAGE_USERS = "users"
|
||||
STORAGE_USER_ID = "user_id"
|
||||
|
||||
INPUT_FIELD_CODE = "code"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _generate_secret() -> str:
|
||||
"""Generate a secret."""
|
||||
import pyotp
|
||||
|
||||
return str(pyotp.random_base32())
|
||||
|
||||
|
||||
def _generate_random() -> int:
|
||||
"""Generate a 8 digit number."""
|
||||
import pyotp
|
||||
|
||||
return int(pyotp.random_base32(length=8, chars=list("1234567890")))
|
||||
|
||||
|
||||
def _generate_otp(secret: str, count: int) -> str:
|
||||
"""Generate one time password."""
|
||||
import pyotp
|
||||
|
||||
return str(pyotp.HOTP(secret).at(count))
|
||||
|
||||
|
||||
def _verify_otp(secret: str, otp: str, count: int) -> bool:
|
||||
"""Verify one time password."""
|
||||
import pyotp
|
||||
|
||||
return bool(pyotp.HOTP(secret).verify(otp, count))
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class NotifySetting:
|
||||
"""Store notify setting for one user."""
|
||||
|
||||
secret = attr.ib(type=str, factory=_generate_secret) # not persistent
|
||||
counter = attr.ib(type=int, factory=_generate_random) # not persistent
|
||||
notify_service = attr.ib(type=Optional[str], default=None)
|
||||
target = attr.ib(type=Optional[str], default=None)
|
||||
|
||||
|
||||
_UsersDict = Dict[str, NotifySetting]
|
||||
|
||||
|
||||
@MULTI_FACTOR_AUTH_MODULES.register("notify")
|
||||
class NotifyAuthModule(MultiFactorAuthModule):
|
||||
"""Auth module send hmac-based one time password by notify service."""
|
||||
|
||||
DEFAULT_TITLE = "Notify One-Time Password"
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None:
|
||||
"""Initialize the user data store."""
|
||||
super().__init__(hass, config)
|
||||
self._user_settings: Optional[_UsersDict] = None
|
||||
self._user_store = hass.helpers.storage.Store(
|
||||
STORAGE_VERSION, STORAGE_KEY, private=True
|
||||
)
|
||||
self._include = config.get(CONF_INCLUDE, [])
|
||||
self._exclude = config.get(CONF_EXCLUDE, [])
|
||||
self._message_template = config[CONF_MESSAGE]
|
||||
self._init_lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def input_schema(self) -> vol.Schema:
|
||||
"""Validate login flow input data."""
|
||||
return vol.Schema({INPUT_FIELD_CODE: str})
|
||||
|
||||
async def _async_load(self) -> None:
|
||||
"""Load stored data."""
|
||||
async with self._init_lock:
|
||||
if self._user_settings is not None:
|
||||
return
|
||||
|
||||
data = await self._user_store.async_load()
|
||||
|
||||
if data is None:
|
||||
data = {STORAGE_USERS: {}}
|
||||
|
||||
self._user_settings = {
|
||||
user_id: NotifySetting(**setting)
|
||||
for user_id, setting in data.get(STORAGE_USERS, {}).items()
|
||||
}
|
||||
|
||||
async def _async_save(self) -> None:
|
||||
"""Save data."""
|
||||
if self._user_settings is None:
|
||||
return
|
||||
|
||||
await self._user_store.async_save(
|
||||
{
|
||||
STORAGE_USERS: {
|
||||
user_id: attr.asdict(
|
||||
notify_setting,
|
||||
filter=attr.filters.exclude(
|
||||
attr.fields(NotifySetting).secret,
|
||||
attr.fields(NotifySetting).counter,
|
||||
),
|
||||
)
|
||||
for user_id, notify_setting in self._user_settings.items()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@callback
|
||||
def aync_get_available_notify_services(self) -> List[str]:
|
||||
"""Return list of notify services."""
|
||||
unordered_services = set()
|
||||
|
||||
for service in self.hass.services.async_services().get("notify", {}):
|
||||
if service not in self._exclude:
|
||||
unordered_services.add(service)
|
||||
|
||||
if self._include:
|
||||
unordered_services &= set(self._include)
|
||||
|
||||
return sorted(unordered_services)
|
||||
|
||||
async def async_setup_flow(self, user_id: str) -> SetupFlow:
|
||||
"""Return a data entry flow handler for setup module.
|
||||
|
||||
Mfa module should extend SetupFlow
|
||||
"""
|
||||
return NotifySetupFlow(
|
||||
self, self.input_schema, user_id, self.aync_get_available_notify_services()
|
||||
)
|
||||
|
||||
async def async_setup_user(self, user_id: str, setup_data: Any) -> Any:
|
||||
"""Set up auth module for user."""
|
||||
if self._user_settings is None:
|
||||
await self._async_load()
|
||||
assert self._user_settings is not None
|
||||
|
||||
self._user_settings[user_id] = NotifySetting(
|
||||
notify_service=setup_data.get("notify_service"),
|
||||
target=setup_data.get("target"),
|
||||
)
|
||||
|
||||
await self._async_save()
|
||||
|
||||
async def async_depose_user(self, user_id: str) -> None:
|
||||
"""Depose auth module for user."""
|
||||
if self._user_settings is None:
|
||||
await self._async_load()
|
||||
assert self._user_settings is not None
|
||||
|
||||
if self._user_settings.pop(user_id, None):
|
||||
await self._async_save()
|
||||
|
||||
async def async_is_user_setup(self, user_id: str) -> bool:
|
||||
"""Return whether user is setup."""
|
||||
if self._user_settings is None:
|
||||
await self._async_load()
|
||||
assert self._user_settings is not None
|
||||
|
||||
return user_id in self._user_settings
|
||||
|
||||
async def async_validate(self, user_id: str, user_input: Dict[str, Any]) -> bool:
|
||||
"""Return True if validation passed."""
|
||||
if self._user_settings is None:
|
||||
await self._async_load()
|
||||
assert self._user_settings is not None
|
||||
|
||||
notify_setting = self._user_settings.get(user_id, None)
|
||||
if notify_setting is None:
|
||||
return False
|
||||
|
||||
# user_input has been validate in caller
|
||||
return await self.hass.async_add_executor_job(
|
||||
_verify_otp,
|
||||
notify_setting.secret,
|
||||
user_input.get(INPUT_FIELD_CODE, ""),
|
||||
notify_setting.counter,
|
||||
)
|
||||
|
||||
async def async_initialize_login_mfa_step(self, user_id: str) -> None:
|
||||
"""Generate code and notify user."""
|
||||
if self._user_settings is None:
|
||||
await self._async_load()
|
||||
assert self._user_settings is not None
|
||||
|
||||
notify_setting = self._user_settings.get(user_id, None)
|
||||
if notify_setting is None:
|
||||
raise ValueError("Cannot find user_id")
|
||||
|
||||
def generate_secret_and_one_time_password() -> str:
|
||||
"""Generate and send one time password."""
|
||||
assert notify_setting
|
||||
# secret and counter are not persistent
|
||||
notify_setting.secret = _generate_secret()
|
||||
notify_setting.counter = _generate_random()
|
||||
return _generate_otp(notify_setting.secret, notify_setting.counter)
|
||||
|
||||
code = await self.hass.async_add_executor_job(
|
||||
generate_secret_and_one_time_password
|
||||
)
|
||||
|
||||
await self.async_notify_user(user_id, code)
|
||||
|
||||
async def async_notify_user(self, user_id: str, code: str) -> None:
|
||||
"""Send code by user's notify service."""
|
||||
if self._user_settings is None:
|
||||
await self._async_load()
|
||||
assert self._user_settings is not None
|
||||
|
||||
notify_setting = self._user_settings.get(user_id, None)
|
||||
if notify_setting is None:
|
||||
_LOGGER.error("Cannot find user %s", user_id)
|
||||
return
|
||||
|
||||
await self.async_notify(
|
||||
code,
|
||||
notify_setting.notify_service, # type: ignore
|
||||
notify_setting.target,
|
||||
)
|
||||
|
||||
async def async_notify(
|
||||
self, code: str, notify_service: str, target: Optional[str] = None
|
||||
) -> None:
|
||||
"""Send code by notify service."""
|
||||
data = {"message": self._message_template.format(code)}
|
||||
if target:
|
||||
data["target"] = [target]
|
||||
|
||||
await self.hass.services.async_call("notify", notify_service, data)
|
||||
|
||||
|
||||
class NotifySetupFlow(SetupFlow):
|
||||
"""Handler for the setup flow."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
auth_module: NotifyAuthModule,
|
||||
setup_schema: vol.Schema,
|
||||
user_id: str,
|
||||
available_notify_services: List[str],
|
||||
) -> None:
|
||||
"""Initialize the setup flow."""
|
||||
super().__init__(auth_module, setup_schema, user_id)
|
||||
# to fix typing complaint
|
||||
self._auth_module: NotifyAuthModule = auth_module
|
||||
self._available_notify_services = available_notify_services
|
||||
self._secret: Optional[str] = None
|
||||
self._count: Optional[int] = None
|
||||
self._notify_service: Optional[str] = None
|
||||
self._target: Optional[str] = None
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: Optional[Dict[str, str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Let user select available notify services."""
|
||||
errors: Dict[str, str] = {}
|
||||
|
||||
hass = self._auth_module.hass
|
||||
if user_input:
|
||||
self._notify_service = user_input["notify_service"]
|
||||
self._target = user_input.get("target")
|
||||
self._secret = await hass.async_add_executor_job(_generate_secret)
|
||||
self._count = await hass.async_add_executor_job(_generate_random)
|
||||
|
||||
return await self.async_step_setup()
|
||||
|
||||
if not self._available_notify_services:
|
||||
return self.async_abort(reason="no_available_service")
|
||||
|
||||
schema: Dict[str, Any] = OrderedDict()
|
||||
schema["notify_service"] = vol.In(self._available_notify_services)
|
||||
schema["target"] = vol.Optional(str)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init", data_schema=vol.Schema(schema), errors=errors
|
||||
)
|
||||
|
||||
async def async_step_setup(
|
||||
self, user_input: Optional[Dict[str, str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Verify user can recevie one-time password."""
|
||||
errors: Dict[str, str] = {}
|
||||
|
||||
hass = self._auth_module.hass
|
||||
if user_input:
|
||||
verified = await hass.async_add_executor_job(
|
||||
_verify_otp, self._secret, user_input["code"], self._count
|
||||
)
|
||||
if verified:
|
||||
await self._auth_module.async_setup_user(
|
||||
self._user_id,
|
||||
{"notify_service": self._notify_service, "target": self._target},
|
||||
)
|
||||
return self.async_create_entry(title=self._auth_module.name, data={})
|
||||
|
||||
errors["base"] = "invalid_code"
|
||||
|
||||
# generate code every time, no retry logic
|
||||
assert self._secret and self._count
|
||||
code = await hass.async_add_executor_job(
|
||||
_generate_otp, self._secret, self._count
|
||||
)
|
||||
|
||||
assert self._notify_service
|
||||
try:
|
||||
await self._auth_module.async_notify(
|
||||
code, self._notify_service, self._target
|
||||
)
|
||||
except ServiceNotFound:
|
||||
return self.async_abort(reason="notify_service_not_exist")
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="setup",
|
||||
data_schema=self._setup_schema,
|
||||
description_placeholders={"notify_service": self._notify_service},
|
||||
errors=errors,
|
||||
)
|
|
@ -1,236 +0,0 @@
|
|||
"""Time-based One Time Password auth module."""
|
||||
import asyncio
|
||||
from io import BytesIO
|
||||
import logging
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.auth.models import User
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import (
|
||||
MULTI_FACTOR_AUTH_MODULE_SCHEMA,
|
||||
MULTI_FACTOR_AUTH_MODULES,
|
||||
MultiFactorAuthModule,
|
||||
SetupFlow,
|
||||
)
|
||||
|
||||
REQUIREMENTS = ["pyotp==2.3.0", "PyQRCode==1.2.1"]
|
||||
|
||||
CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({}, extra=vol.PREVENT_EXTRA)
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_KEY = "auth_module.totp"
|
||||
STORAGE_USERS = "users"
|
||||
STORAGE_USER_ID = "user_id"
|
||||
STORAGE_OTA_SECRET = "ota_secret"
|
||||
|
||||
INPUT_FIELD_CODE = "code"
|
||||
|
||||
DUMMY_SECRET = "FPPTH34D4E3MI2HG"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _generate_qr_code(data: str) -> str:
|
||||
"""Generate a base64 PNG string represent QR Code image of data."""
|
||||
import pyqrcode
|
||||
|
||||
qr_code = pyqrcode.create(data)
|
||||
|
||||
with BytesIO() as buffer:
|
||||
qr_code.svg(file=buffer, scale=4)
|
||||
return "{}".format(
|
||||
buffer.getvalue()
|
||||
.decode("ascii")
|
||||
.replace("\n", "")
|
||||
.replace(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"',
|
||||
"<svg",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _generate_secret_and_qr_code(username: str) -> Tuple[str, str, str]:
|
||||
"""Generate a secret, url, and QR code."""
|
||||
import pyotp
|
||||
|
||||
ota_secret = pyotp.random_base32()
|
||||
url = pyotp.totp.TOTP(ota_secret).provisioning_uri(
|
||||
username, issuer_name="Home Assistant"
|
||||
)
|
||||
image = _generate_qr_code(url)
|
||||
return ota_secret, url, image
|
||||
|
||||
|
||||
@MULTI_FACTOR_AUTH_MODULES.register("totp")
|
||||
class TotpAuthModule(MultiFactorAuthModule):
|
||||
"""Auth module validate time-based one time password."""
|
||||
|
||||
DEFAULT_TITLE = "Time-based One Time Password"
|
||||
MAX_RETRY_TIME = 5
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None:
|
||||
"""Initialize the user data store."""
|
||||
super().__init__(hass, config)
|
||||
self._users: Optional[Dict[str, str]] = None
|
||||
self._user_store = hass.helpers.storage.Store(
|
||||
STORAGE_VERSION, STORAGE_KEY, private=True
|
||||
)
|
||||
self._init_lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def input_schema(self) -> vol.Schema:
|
||||
"""Validate login flow input data."""
|
||||
return vol.Schema({INPUT_FIELD_CODE: str})
|
||||
|
||||
async def _async_load(self) -> None:
|
||||
"""Load stored data."""
|
||||
async with self._init_lock:
|
||||
if self._users is not None:
|
||||
return
|
||||
|
||||
data = await self._user_store.async_load()
|
||||
|
||||
if data is None:
|
||||
data = {STORAGE_USERS: {}}
|
||||
|
||||
self._users = data.get(STORAGE_USERS, {})
|
||||
|
||||
async def _async_save(self) -> None:
|
||||
"""Save data."""
|
||||
await self._user_store.async_save({STORAGE_USERS: self._users})
|
||||
|
||||
def _add_ota_secret(self, user_id: str, secret: Optional[str] = None) -> str:
|
||||
"""Create a ota_secret for user."""
|
||||
import pyotp
|
||||
|
||||
ota_secret: str = secret or pyotp.random_base32()
|
||||
|
||||
self._users[user_id] = ota_secret # type: ignore
|
||||
return ota_secret
|
||||
|
||||
async def async_setup_flow(self, user_id: str) -> SetupFlow:
|
||||
"""Return a data entry flow handler for setup module.
|
||||
|
||||
Mfa module should extend SetupFlow
|
||||
"""
|
||||
user = await self.hass.auth.async_get_user(user_id) # type: ignore
|
||||
return TotpSetupFlow(self, self.input_schema, user)
|
||||
|
||||
async def async_setup_user(self, user_id: str, setup_data: Any) -> str:
|
||||
"""Set up auth module for user."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
|
||||
result = await self.hass.async_add_executor_job(
|
||||
self._add_ota_secret, user_id, setup_data.get("secret")
|
||||
)
|
||||
|
||||
await self._async_save()
|
||||
return result
|
||||
|
||||
async def async_depose_user(self, user_id: str) -> None:
|
||||
"""Depose auth module for user."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
|
||||
if self._users.pop(user_id, None): # type: ignore
|
||||
await self._async_save()
|
||||
|
||||
async def async_is_user_setup(self, user_id: str) -> bool:
|
||||
"""Return whether user is setup."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
|
||||
return user_id in self._users # type: ignore
|
||||
|
||||
async def async_validate(self, user_id: str, user_input: Dict[str, Any]) -> bool:
|
||||
"""Return True if validation passed."""
|
||||
if self._users is None:
|
||||
await self._async_load()
|
||||
|
||||
# user_input has been validate in caller
|
||||
# set INPUT_FIELD_CODE as vol.Required is not user friendly
|
||||
return await self.hass.async_add_executor_job(
|
||||
self._validate_2fa, user_id, user_input.get(INPUT_FIELD_CODE, "")
|
||||
)
|
||||
|
||||
def _validate_2fa(self, user_id: str, code: str) -> bool:
|
||||
"""Validate two factor authentication code."""
|
||||
import pyotp
|
||||
|
||||
ota_secret = self._users.get(user_id) # type: ignore
|
||||
if ota_secret is None:
|
||||
# even we cannot find user, we still do verify
|
||||
# to make timing the same as if user was found.
|
||||
pyotp.TOTP(DUMMY_SECRET).verify(code, valid_window=1)
|
||||
return False
|
||||
|
||||
return bool(pyotp.TOTP(ota_secret).verify(code, valid_window=1))
|
||||
|
||||
|
||||
class TotpSetupFlow(SetupFlow):
|
||||
"""Handler for the setup flow."""
|
||||
|
||||
def __init__(
|
||||
self, auth_module: TotpAuthModule, setup_schema: vol.Schema, user: User
|
||||
) -> None:
|
||||
"""Initialize the setup flow."""
|
||||
super().__init__(auth_module, setup_schema, user.id)
|
||||
# to fix typing complaint
|
||||
self._auth_module: TotpAuthModule = auth_module
|
||||
self._user = user
|
||||
self._ota_secret: Optional[str] = None
|
||||
self._url = None # type Optional[str]
|
||||
self._image = None # type Optional[str]
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: Optional[Dict[str, str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Handle the first step of setup flow.
|
||||
|
||||
Return self.async_show_form(step_id='init') if user_input is None.
|
||||
Return self.async_create_entry(data={'result': result}) if finish.
|
||||
"""
|
||||
import pyotp
|
||||
|
||||
errors: Dict[str, str] = {}
|
||||
|
||||
if user_input:
|
||||
verified = await self.hass.async_add_executor_job( # type: ignore
|
||||
pyotp.TOTP(self._ota_secret).verify, user_input["code"]
|
||||
)
|
||||
if verified:
|
||||
result = await self._auth_module.async_setup_user(
|
||||
self._user_id, {"secret": self._ota_secret}
|
||||
)
|
||||
return self.async_create_entry(
|
||||
title=self._auth_module.name, data={"result": result}
|
||||
)
|
||||
|
||||
errors["base"] = "invalid_code"
|
||||
|
||||
else:
|
||||
hass = self._auth_module.hass
|
||||
(
|
||||
self._ota_secret,
|
||||
self._url,
|
||||
self._image,
|
||||
) = await hass.async_add_executor_job(
|
||||
_generate_secret_and_qr_code, # type: ignore
|
||||
str(self._user.name),
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=self._setup_schema,
|
||||
description_placeholders={
|
||||
"code": self._ota_secret,
|
||||
"url": self._url,
|
||||
"qr_code": self._image,
|
||||
},
|
||||
errors=errors,
|
||||
)
|
|
@ -1,81 +1,35 @@
|
|||
"""Auth models."""
|
||||
from datetime import datetime, timedelta
|
||||
import secrets
|
||||
from typing import Dict, List, NamedTuple, Optional
|
||||
from typing import Dict, List, NamedTuple, Optional # noqa: F401
|
||||
import uuid
|
||||
|
||||
import attr
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import permissions as perm_mdl
|
||||
from .const import GROUP_ID_ADMIN
|
||||
|
||||
TOKEN_TYPE_NORMAL = "normal"
|
||||
TOKEN_TYPE_SYSTEM = "system"
|
||||
TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN = "long_lived_access_token"
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class Group:
|
||||
"""A group."""
|
||||
|
||||
name = attr.ib(type=Optional[str])
|
||||
policy = attr.ib(type=perm_mdl.PolicyType)
|
||||
id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex)
|
||||
system_generated = attr.ib(type=bool, default=False)
|
||||
from .const import ACCESS_TOKEN_EXPIRATION
|
||||
from .util import generate_secret
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class User:
|
||||
"""A user."""
|
||||
|
||||
name = attr.ib(type=Optional[str])
|
||||
perm_lookup = attr.ib(type=perm_mdl.PermissionLookup, cmp=False)
|
||||
id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex)
|
||||
name = attr.ib(type=str) # type: Optional[str]
|
||||
id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex))
|
||||
is_owner = attr.ib(type=bool, default=False)
|
||||
is_active = attr.ib(type=bool, default=False)
|
||||
system_generated = attr.ib(type=bool, default=False)
|
||||
|
||||
groups = attr.ib(type=List[Group], factory=list, cmp=False)
|
||||
|
||||
# List of credentials of a user.
|
||||
credentials = attr.ib(type=List["Credentials"], factory=list, cmp=False)
|
||||
credentials = attr.ib(
|
||||
type=list, default=attr.Factory(list), cmp=False
|
||||
) # type: List[Credentials]
|
||||
|
||||
# Tokens associated with a user.
|
||||
refresh_tokens = attr.ib(type=Dict[str, "RefreshToken"], factory=dict, cmp=False)
|
||||
|
||||
_permissions = attr.ib(
|
||||
type=Optional[perm_mdl.PolicyPermissions], init=False, cmp=False, default=None
|
||||
)
|
||||
|
||||
@property
|
||||
def permissions(self) -> perm_mdl.AbstractPermissions:
|
||||
"""Return permissions object for user."""
|
||||
if self.is_owner:
|
||||
return perm_mdl.OwnerPermissions
|
||||
|
||||
if self._permissions is not None:
|
||||
return self._permissions
|
||||
|
||||
self._permissions = perm_mdl.PolicyPermissions(
|
||||
perm_mdl.merge_policies([group.policy for group in self.groups]),
|
||||
self.perm_lookup,
|
||||
)
|
||||
|
||||
return self._permissions
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
"""Return if user is part of the admin group."""
|
||||
if self.is_owner:
|
||||
return True
|
||||
|
||||
return self.is_active and any(gr.id == GROUP_ID_ADMIN for gr in self.groups)
|
||||
|
||||
def invalidate_permission_cache(self) -> None:
|
||||
"""Invalidate permission cache."""
|
||||
self._permissions = None
|
||||
refresh_tokens = attr.ib(
|
||||
type=dict, default=attr.Factory(dict), cmp=False
|
||||
) # type: Dict[str, RefreshToken]
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
|
@ -83,24 +37,15 @@ class RefreshToken:
|
|||
"""RefreshToken for a user to grant new access tokens."""
|
||||
|
||||
user = attr.ib(type=User)
|
||||
client_id = attr.ib(type=Optional[str])
|
||||
access_token_expiration = attr.ib(type=timedelta)
|
||||
client_name = attr.ib(type=Optional[str], default=None)
|
||||
client_icon = attr.ib(type=Optional[str], default=None)
|
||||
token_type = attr.ib(
|
||||
type=str,
|
||||
default=TOKEN_TYPE_NORMAL,
|
||||
validator=attr.validators.in_(
|
||||
(TOKEN_TYPE_NORMAL, TOKEN_TYPE_SYSTEM, TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN)
|
||||
),
|
||||
)
|
||||
id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex)
|
||||
created_at = attr.ib(type=datetime, factory=dt_util.utcnow)
|
||||
token = attr.ib(type=str, factory=lambda: secrets.token_hex(64))
|
||||
jwt_key = attr.ib(type=str, factory=lambda: secrets.token_hex(64))
|
||||
|
||||
last_used_at = attr.ib(type=Optional[datetime], default=None)
|
||||
last_used_ip = attr.ib(type=Optional[str], default=None)
|
||||
client_id = attr.ib(type=str) # type: Optional[str]
|
||||
id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex))
|
||||
created_at = attr.ib(type=datetime, default=attr.Factory(dt_util.utcnow))
|
||||
access_token_expiration = attr.ib(type=timedelta,
|
||||
default=ACCESS_TOKEN_EXPIRATION)
|
||||
token = attr.ib(type=str,
|
||||
default=attr.Factory(lambda: generate_secret(64)))
|
||||
jwt_key = attr.ib(type=str,
|
||||
default=attr.Factory(lambda: generate_secret(64)))
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
|
@ -108,13 +53,14 @@ class Credentials:
|
|||
"""Credentials for a user on an auth provider."""
|
||||
|
||||
auth_provider_type = attr.ib(type=str)
|
||||
auth_provider_id = attr.ib(type=Optional[str])
|
||||
auth_provider_id = attr.ib(type=str) # type: Optional[str]
|
||||
|
||||
# Allow the auth provider to store data to represent their auth.
|
||||
data = attr.ib(type=dict)
|
||||
|
||||
id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex)
|
||||
id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex))
|
||||
is_new = attr.ib(type=bool, default=True)
|
||||
|
||||
|
||||
UserMeta = NamedTuple("UserMeta", [("name", Optional[str]), ("is_active", bool)])
|
||||
UserMeta = NamedTuple("UserMeta",
|
||||
[('name', Optional[str]), ('is_active', bool)])
|
||||
|
|
|
@ -1,75 +0,0 @@
|
|||
"""Permissions for Home Assistant."""
|
||||
import logging
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from .const import CAT_ENTITIES
|
||||
from .entities import ENTITY_POLICY_SCHEMA, compile_entities
|
||||
from .merge import merge_policies # noqa: F401
|
||||
from .models import PermissionLookup
|
||||
from .types import PolicyType
|
||||
from .util import test_all
|
||||
|
||||
POLICY_SCHEMA = vol.Schema({vol.Optional(CAT_ENTITIES): ENTITY_POLICY_SCHEMA})
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AbstractPermissions:
|
||||
"""Default permissions class."""
|
||||
|
||||
_cached_entity_func: Optional[Callable[[str, str], bool]] = None
|
||||
|
||||
def _entity_func(self) -> Callable[[str, str], bool]:
|
||||
"""Return a function that can test entity access."""
|
||||
raise NotImplementedError
|
||||
|
||||
def access_all_entities(self, key: str) -> bool:
|
||||
"""Check if we have a certain access to all entities."""
|
||||
raise NotImplementedError
|
||||
|
||||
def check_entity(self, entity_id: str, key: str) -> bool:
|
||||
"""Check if we can access entity."""
|
||||
entity_func = self._cached_entity_func
|
||||
|
||||
if entity_func is None:
|
||||
entity_func = self._cached_entity_func = self._entity_func()
|
||||
|
||||
return entity_func(entity_id, key)
|
||||
|
||||
|
||||
class PolicyPermissions(AbstractPermissions):
|
||||
"""Handle permissions."""
|
||||
|
||||
def __init__(self, policy: PolicyType, perm_lookup: PermissionLookup) -> None:
|
||||
"""Initialize the permission class."""
|
||||
self._policy = policy
|
||||
self._perm_lookup = perm_lookup
|
||||
|
||||
def access_all_entities(self, key: str) -> bool:
|
||||
"""Check if we have a certain access to all entities."""
|
||||
return test_all(self._policy.get(CAT_ENTITIES), key)
|
||||
|
||||
def _entity_func(self) -> Callable[[str, str], bool]:
|
||||
"""Return a function that can test entity access."""
|
||||
return compile_entities(self._policy.get(CAT_ENTITIES), self._perm_lookup)
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
"""Equals check."""
|
||||
return isinstance(other, PolicyPermissions) and other._policy == self._policy
|
||||
|
||||
|
||||
class _OwnerPermissions(AbstractPermissions):
|
||||
"""Owner permissions."""
|
||||
|
||||
def access_all_entities(self, key: str) -> bool:
|
||||
"""Check if we have a certain access to all entities."""
|
||||
return True
|
||||
|
||||
def _entity_func(self) -> Callable[[str, str], bool]:
|
||||
"""Return a function that can test entity access."""
|
||||
return lambda entity_id, key: True
|
||||
|
||||
|
||||
OwnerPermissions = _OwnerPermissions() # pylint: disable=invalid-name
|
|
@ -1,8 +0,0 @@
|
|||
"""Permission constants."""
|
||||
CAT_ENTITIES = "entities"
|
||||
CAT_CONFIG_ENTRIES = "config_entries"
|
||||
SUBCAT_ALL = "all"
|
||||
|
||||
POLICY_READ = "read"
|
||||
POLICY_CONTROL = "control"
|
||||
POLICY_EDIT = "edit"
|
|
@ -1,98 +0,0 @@
|
|||
"""Entity permissions."""
|
||||
from collections import OrderedDict
|
||||
from typing import Callable, Optional
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from .const import POLICY_CONTROL, POLICY_EDIT, POLICY_READ, SUBCAT_ALL
|
||||
from .models import PermissionLookup
|
||||
from .types import CategoryType, SubCategoryDict, ValueType
|
||||
from .util import SubCatLookupType, compile_policy, lookup_all
|
||||
|
||||
SINGLE_ENTITY_SCHEMA = vol.Any(
|
||||
True,
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(POLICY_READ): True,
|
||||
vol.Optional(POLICY_CONTROL): True,
|
||||
vol.Optional(POLICY_EDIT): True,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
ENTITY_DOMAINS = "domains"
|
||||
ENTITY_AREAS = "area_ids"
|
||||
ENTITY_DEVICE_IDS = "device_ids"
|
||||
ENTITY_ENTITY_IDS = "entity_ids"
|
||||
|
||||
ENTITY_VALUES_SCHEMA = vol.Any(True, vol.Schema({str: SINGLE_ENTITY_SCHEMA}))
|
||||
|
||||
ENTITY_POLICY_SCHEMA = vol.Any(
|
||||
True,
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(SUBCAT_ALL): SINGLE_ENTITY_SCHEMA,
|
||||
vol.Optional(ENTITY_AREAS): ENTITY_VALUES_SCHEMA,
|
||||
vol.Optional(ENTITY_DEVICE_IDS): ENTITY_VALUES_SCHEMA,
|
||||
vol.Optional(ENTITY_DOMAINS): ENTITY_VALUES_SCHEMA,
|
||||
vol.Optional(ENTITY_ENTITY_IDS): ENTITY_VALUES_SCHEMA,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _lookup_domain(
|
||||
perm_lookup: PermissionLookup, domains_dict: SubCategoryDict, entity_id: str
|
||||
) -> Optional[ValueType]:
|
||||
"""Look up entity permissions by domain."""
|
||||
return domains_dict.get(entity_id.split(".", 1)[0])
|
||||
|
||||
|
||||
def _lookup_area(
|
||||
perm_lookup: PermissionLookup, area_dict: SubCategoryDict, entity_id: str
|
||||
) -> Optional[ValueType]:
|
||||
"""Look up entity permissions by area."""
|
||||
entity_entry = perm_lookup.entity_registry.async_get(entity_id)
|
||||
|
||||
if entity_entry is None or entity_entry.device_id is None:
|
||||
return None
|
||||
|
||||
device_entry = perm_lookup.device_registry.async_get(entity_entry.device_id)
|
||||
|
||||
if device_entry is None or device_entry.area_id is None:
|
||||
return None
|
||||
|
||||
return area_dict.get(device_entry.area_id)
|
||||
|
||||
|
||||
def _lookup_device(
|
||||
perm_lookup: PermissionLookup, devices_dict: SubCategoryDict, entity_id: str
|
||||
) -> Optional[ValueType]:
|
||||
"""Look up entity permissions by device."""
|
||||
entity_entry = perm_lookup.entity_registry.async_get(entity_id)
|
||||
|
||||
if entity_entry is None or entity_entry.device_id is None:
|
||||
return None
|
||||
|
||||
return devices_dict.get(entity_entry.device_id)
|
||||
|
||||
|
||||
def _lookup_entity_id(
|
||||
perm_lookup: PermissionLookup, entities_dict: SubCategoryDict, entity_id: str
|
||||
) -> Optional[ValueType]:
|
||||
"""Look up entity permission by entity id."""
|
||||
return entities_dict.get(entity_id)
|
||||
|
||||
|
||||
def compile_entities(
|
||||
policy: CategoryType, perm_lookup: PermissionLookup
|
||||
) -> Callable[[str, str], bool]:
|
||||
"""Compile policy into a function that tests policy."""
|
||||
subcategories: SubCatLookupType = OrderedDict()
|
||||
subcategories[ENTITY_ENTITY_IDS] = _lookup_entity_id
|
||||
subcategories[ENTITY_DEVICE_IDS] = _lookup_device
|
||||
subcategories[ENTITY_AREAS] = _lookup_area
|
||||
subcategories[ENTITY_DOMAINS] = _lookup_domain
|
||||
subcategories[SUBCAT_ALL] = lookup_all
|
||||
|
||||
return compile_policy(policy, subcategories, perm_lookup)
|
|
@ -1,65 +0,0 @@
|
|||
"""Merging of policies."""
|
||||
from typing import Dict, List, Set, cast
|
||||
|
||||
from .types import CategoryType, PolicyType
|
||||
|
||||
|
||||
def merge_policies(policies: List[PolicyType]) -> PolicyType:
|
||||
"""Merge policies."""
|
||||
new_policy: Dict[str, CategoryType] = {}
|
||||
seen: Set[str] = set()
|
||||
for policy in policies:
|
||||
for category in policy:
|
||||
if category in seen:
|
||||
continue
|
||||
seen.add(category)
|
||||
new_policy[category] = _merge_policies(
|
||||
[policy.get(category) for policy in policies]
|
||||
)
|
||||
cast(PolicyType, new_policy)
|
||||
return new_policy
|
||||
|
||||
|
||||
def _merge_policies(sources: List[CategoryType]) -> CategoryType:
|
||||
"""Merge a policy."""
|
||||
# When merging policies, the most permissive wins.
|
||||
# This means we order it like this:
|
||||
# True > Dict > None
|
||||
#
|
||||
# True: allow everything
|
||||
# Dict: specify more granular permissions
|
||||
# None: no opinion
|
||||
#
|
||||
# If there are multiple sources with a dict as policy, we recursively
|
||||
# merge each key in the source.
|
||||
|
||||
policy: CategoryType = None
|
||||
seen: Set[str] = set()
|
||||
for source in sources:
|
||||
if source is None:
|
||||
continue
|
||||
|
||||
# A source that's True will always win. Shortcut return.
|
||||
if source is True:
|
||||
return True
|
||||
|
||||
assert isinstance(source, dict)
|
||||
|
||||
if policy is None:
|
||||
policy = cast(CategoryType, {})
|
||||
|
||||
assert isinstance(policy, dict)
|
||||
|
||||
for key in source:
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
|
||||
key_sources = []
|
||||
for src in sources:
|
||||
if isinstance(src, dict):
|
||||
key_sources.append(src.get(key))
|
||||
|
||||
policy[key] = _merge_policies(key_sources)
|
||||
|
||||
return policy
|
|
@ -1,17 +0,0 @@
|
|||
"""Models for permissions."""
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import attr
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# pylint: disable=unused-import
|
||||
from homeassistant.helpers import entity_registry as ent_reg # noqa: F401
|
||||
from homeassistant.helpers import device_registry as dev_reg # noqa: F401
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class PermissionLookup:
|
||||
"""Class to hold data for permission lookups."""
|
||||
|
||||
entity_registry = attr.ib(type="ent_reg.EntityRegistry")
|
||||
device_registry = attr.ib(type="dev_reg.DeviceRegistry")
|
|
@ -1,8 +0,0 @@
|
|||
"""System policies."""
|
||||
from .const import CAT_ENTITIES, POLICY_READ, SUBCAT_ALL
|
||||
|
||||
ADMIN_POLICY = {CAT_ENTITIES: True}
|
||||
|
||||
USER_POLICY = {CAT_ENTITIES: True}
|
||||
|
||||
READ_ONLY_POLICY = {CAT_ENTITIES: {SUBCAT_ALL: {POLICY_READ: True}}}
|
|
@ -1,28 +0,0 @@
|
|||
"""Common code for permissions."""
|
||||
from typing import Mapping, Union
|
||||
|
||||
# MyPy doesn't support recursion yet. So writing it out as far as we need.
|
||||
|
||||
ValueType = Union[
|
||||
# Example: entities.all = { read: true, control: true }
|
||||
Mapping[str, bool],
|
||||
bool,
|
||||
None,
|
||||
]
|
||||
|
||||
# Example: entities.domains = { light: … }
|
||||
SubCategoryDict = Mapping[str, ValueType]
|
||||
|
||||
SubCategoryType = Union[SubCategoryDict, bool, None]
|
||||
|
||||
CategoryType = Union[
|
||||
# Example: entities.domains
|
||||
Mapping[str, SubCategoryType],
|
||||
# Example: entities.all
|
||||
Mapping[str, ValueType],
|
||||
bool,
|
||||
None,
|
||||
]
|
||||
|
||||
# Example: { entities: … }
|
||||
PolicyType = Mapping[str, CategoryType]
|
|
@ -1,110 +0,0 @@
|
|||
"""Helpers to deal with permissions."""
|
||||
from functools import wraps
|
||||
from typing import Callable, Dict, List, Optional, cast
|
||||
|
||||
from .const import SUBCAT_ALL
|
||||
from .models import PermissionLookup
|
||||
from .types import CategoryType, SubCategoryDict, ValueType
|
||||
|
||||
LookupFunc = Callable[[PermissionLookup, SubCategoryDict, str], Optional[ValueType]]
|
||||
SubCatLookupType = Dict[str, LookupFunc]
|
||||
|
||||
|
||||
def lookup_all(
|
||||
perm_lookup: PermissionLookup, lookup_dict: SubCategoryDict, object_id: str
|
||||
) -> ValueType:
|
||||
"""Look up permission for all."""
|
||||
# In case of ALL category, lookup_dict IS the schema.
|
||||
return cast(ValueType, lookup_dict)
|
||||
|
||||
|
||||
def compile_policy(
|
||||
policy: CategoryType, subcategories: SubCatLookupType, perm_lookup: PermissionLookup
|
||||
) -> Callable[[str, str], bool]:
|
||||
"""Compile policy into a function that tests policy.
|
||||
|
||||
Subcategories are mapping key -> lookup function, ordered by highest
|
||||
priority first.
|
||||
"""
|
||||
# None, False, empty dict
|
||||
if not policy:
|
||||
|
||||
def apply_policy_deny_all(entity_id: str, key: str) -> bool:
|
||||
"""Decline all."""
|
||||
return False
|
||||
|
||||
return apply_policy_deny_all
|
||||
|
||||
if policy is True:
|
||||
|
||||
def apply_policy_allow_all(entity_id: str, key: str) -> bool:
|
||||
"""Approve all."""
|
||||
return True
|
||||
|
||||
return apply_policy_allow_all
|
||||
|
||||
assert isinstance(policy, dict)
|
||||
|
||||
funcs: List[Callable[[str, str], Optional[bool]]] = []
|
||||
|
||||
for key, lookup_func in subcategories.items():
|
||||
lookup_value = policy.get(key)
|
||||
|
||||
# If any lookup value is `True`, it will always be positive
|
||||
if isinstance(lookup_value, bool):
|
||||
return lambda object_id, key: True
|
||||
|
||||
if lookup_value is not None:
|
||||
funcs.append(_gen_dict_test_func(perm_lookup, lookup_func, lookup_value))
|
||||
|
||||
if len(funcs) == 1:
|
||||
func = funcs[0]
|
||||
|
||||
@wraps(func)
|
||||
def apply_policy_func(object_id: str, key: str) -> bool:
|
||||
"""Apply a single policy function."""
|
||||
return func(object_id, key) is True
|
||||
|
||||
return apply_policy_func
|
||||
|
||||
def apply_policy_funcs(object_id: str, key: str) -> bool:
|
||||
"""Apply several policy functions."""
|
||||
for func in funcs:
|
||||
result = func(object_id, key)
|
||||
if result is not None:
|
||||
return result
|
||||
return False
|
||||
|
||||
return apply_policy_funcs
|
||||
|
||||
|
||||
def _gen_dict_test_func(
|
||||
perm_lookup: PermissionLookup, lookup_func: LookupFunc, lookup_dict: SubCategoryDict
|
||||
) -> Callable[[str, str], Optional[bool]]:
|
||||
"""Generate a lookup function."""
|
||||
|
||||
def test_value(object_id: str, key: str) -> Optional[bool]:
|
||||
"""Test if permission is allowed based on the keys."""
|
||||
schema: ValueType = lookup_func(perm_lookup, lookup_dict, object_id)
|
||||
|
||||
if schema is None or isinstance(schema, bool):
|
||||
return schema
|
||||
|
||||
assert isinstance(schema, dict)
|
||||
|
||||
return schema.get(key)
|
||||
|
||||
return test_value
|
||||
|
||||
|
||||
def test_all(policy: CategoryType, key: str) -> bool:
|
||||
"""Test if a policy has an ALL access for a specific key."""
|
||||
if not isinstance(policy, dict):
|
||||
return bool(policy)
|
||||
|
||||
all_policy = policy.get(SUBCAT_ALL)
|
||||
|
||||
if not isinstance(all_policy, dict):
|
||||
return bool(all_policy)
|
||||
|
||||
return all_policy.get(key, False)
|
|
@ -8,47 +8,41 @@ import voluptuous as vol
|
|||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from homeassistant import data_entry_flow, requirements
|
||||
from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.core import callback, HomeAssistant
|
||||
from homeassistant.const import CONF_TYPE, CONF_NAME, CONF_ID
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.decorator import Registry
|
||||
|
||||
from ..auth_store import AuthStore
|
||||
from ..const import MFA_SESSION_EXPIRATION
|
||||
from ..models import Credentials, User, UserMeta
|
||||
from ..models import Credentials, UserMeta
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
DATA_REQS = "auth_prov_reqs_processed"
|
||||
DATA_REQS = 'auth_prov_reqs_processed'
|
||||
|
||||
AUTH_PROVIDERS = Registry()
|
||||
|
||||
AUTH_PROVIDER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TYPE): str,
|
||||
vol.Optional(CONF_NAME): str,
|
||||
# Specify ID if you have two auth providers for same type.
|
||||
vol.Optional(CONF_ID): str,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
AUTH_PROVIDER_SCHEMA = vol.Schema({
|
||||
vol.Required(CONF_TYPE): str,
|
||||
vol.Optional(CONF_NAME): str,
|
||||
# Specify ID if you have two auth providers for same type.
|
||||
vol.Optional(CONF_ID): str,
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
class AuthProvider:
|
||||
"""Provider of user authentication."""
|
||||
|
||||
DEFAULT_TITLE = "Unnamed auth provider"
|
||||
DEFAULT_TITLE = 'Unnamed auth provider'
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, store: AuthStore, config: Dict[str, Any]
|
||||
) -> None:
|
||||
def __init__(self, hass: HomeAssistant, store: AuthStore,
|
||||
config: Dict[str, Any]) -> None:
|
||||
"""Initialize an auth provider."""
|
||||
self.hass = hass
|
||||
self.store = store
|
||||
self.config = config
|
||||
|
||||
@property
|
||||
def id(self) -> Optional[str]:
|
||||
def id(self) -> Optional[str]: # pylint: disable=invalid-name
|
||||
"""Return id of the auth provider.
|
||||
|
||||
Optional, can be None.
|
||||
|
@ -65,11 +59,6 @@ class AuthProvider:
|
|||
"""Return the name of the auth provider."""
|
||||
return self.config.get(CONF_NAME, self.DEFAULT_TITLE)
|
||||
|
||||
@property
|
||||
def support_mfa(self) -> bool:
|
||||
"""Return whether multi-factor auth supported by the auth provider."""
|
||||
return True
|
||||
|
||||
async def async_credentials(self) -> List[Credentials]:
|
||||
"""Return all credentials of this provider."""
|
||||
users = await self.store.async_get_users()
|
||||
|
@ -77,22 +66,22 @@ class AuthProvider:
|
|||
credentials
|
||||
for user in users
|
||||
for credentials in user.credentials
|
||||
if (
|
||||
credentials.auth_provider_type == self.type
|
||||
and credentials.auth_provider_id == self.id
|
||||
)
|
||||
if (credentials.auth_provider_type == self.type and
|
||||
credentials.auth_provider_id == self.id)
|
||||
]
|
||||
|
||||
@callback
|
||||
def async_create_credentials(self, data: Dict[str, str]) -> Credentials:
|
||||
"""Create credentials."""
|
||||
return Credentials(
|
||||
auth_provider_type=self.type, auth_provider_id=self.id, data=data
|
||||
auth_provider_type=self.type,
|
||||
auth_provider_id=self.id,
|
||||
data=data,
|
||||
)
|
||||
|
||||
# Implement by extending class
|
||||
|
||||
async def async_login_flow(self, context: Optional[Dict]) -> "LoginFlow":
|
||||
async def async_login_flow(self, context: Optional[Dict]) -> 'LoginFlow':
|
||||
"""Return the data flow for logging in with auth provider.
|
||||
|
||||
Auth provider should extend LoginFlow and return an instance.
|
||||
|
@ -100,56 +89,50 @@ class AuthProvider:
|
|||
raise NotImplementedError
|
||||
|
||||
async def async_get_or_create_credentials(
|
||||
self, flow_result: Dict[str, str]
|
||||
) -> Credentials:
|
||||
self, flow_result: Dict[str, str]) -> Credentials:
|
||||
"""Get credentials based on the flow result."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_user_meta_for_credentials(
|
||||
self, credentials: Credentials
|
||||
) -> UserMeta:
|
||||
self, credentials: Credentials) -> UserMeta:
|
||||
"""Return extra user metadata for credentials.
|
||||
|
||||
Will be used to populate info when creating a new user.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_initialize(self) -> None:
|
||||
"""Initialize the auth provider."""
|
||||
pass
|
||||
|
||||
|
||||
async def auth_provider_from_config(
|
||||
hass: HomeAssistant, store: AuthStore, config: Dict[str, Any]
|
||||
) -> AuthProvider:
|
||||
hass: HomeAssistant, store: AuthStore,
|
||||
config: Dict[str, Any]) -> Optional[AuthProvider]:
|
||||
"""Initialize an auth provider from a config."""
|
||||
provider_name = config[CONF_TYPE]
|
||||
module = await load_auth_provider_module(hass, provider_name)
|
||||
|
||||
if module is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
config = module.CONFIG_SCHEMA(config) # type: ignore
|
||||
except vol.Invalid as err:
|
||||
_LOGGER.error(
|
||||
"Invalid configuration for auth provider %s: %s",
|
||||
provider_name,
|
||||
humanize_error(config, err),
|
||||
)
|
||||
raise
|
||||
_LOGGER.error('Invalid configuration for auth provider %s: %s',
|
||||
provider_name, humanize_error(config, err))
|
||||
return None
|
||||
|
||||
return AUTH_PROVIDERS[provider_name](hass, store, config) # type: ignore
|
||||
|
||||
|
||||
async def load_auth_provider_module(
|
||||
hass: HomeAssistant, provider: str
|
||||
) -> types.ModuleType:
|
||||
hass: HomeAssistant, provider: str) -> Optional[types.ModuleType]:
|
||||
"""Load an auth provider."""
|
||||
try:
|
||||
module = importlib.import_module(f"homeassistant.auth.providers.{provider}")
|
||||
except ImportError as err:
|
||||
_LOGGER.error("Unable to load auth provider %s: %s", provider, err)
|
||||
raise HomeAssistantError(f"Unable to load auth provider {provider}: {err}")
|
||||
module = importlib.import_module(
|
||||
'homeassistant.auth.providers.{}'.format(provider))
|
||||
except ImportError:
|
||||
_LOGGER.warning('Unable to find auth provider %s', provider)
|
||||
return None
|
||||
|
||||
if hass.config.skip_pip or not hasattr(module, "REQUIREMENTS"):
|
||||
if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'):
|
||||
return module
|
||||
|
||||
processed = hass.data.get(DATA_REQS)
|
||||
|
@ -161,9 +144,11 @@ async def load_auth_provider_module(
|
|||
|
||||
# https://github.com/python/mypy/issues/1424
|
||||
reqs = module.REQUIREMENTS # type: ignore
|
||||
await requirements.async_process_requirements(
|
||||
hass, f"auth provider {provider}", reqs
|
||||
)
|
||||
req_success = await requirements.async_process_requirements(
|
||||
hass, 'auth provider {}'.format(provider), reqs)
|
||||
|
||||
if not req_success:
|
||||
return None
|
||||
|
||||
processed.add(provider)
|
||||
return module
|
||||
|
@ -175,98 +160,22 @@ class LoginFlow(data_entry_flow.FlowHandler):
|
|||
def __init__(self, auth_provider: AuthProvider) -> None:
|
||||
"""Initialize the login flow."""
|
||||
self._auth_provider = auth_provider
|
||||
self._auth_module_id: Optional[str] = None
|
||||
self._auth_manager = auth_provider.hass.auth # type: ignore
|
||||
self.available_mfa_modules: Dict[str, str] = {}
|
||||
self.created_at = dt_util.utcnow()
|
||||
self.invalid_mfa_times = 0
|
||||
self.user: Optional[User] = None
|
||||
self.user = None
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: Optional[Dict[str, str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
self, user_input: Optional[Dict[str, str]] = None) \
|
||||
-> Dict[str, Any]:
|
||||
"""Handle the first step of login flow.
|
||||
|
||||
Return self.async_show_form(step_id='init') if user_input is None.
|
||||
Return self.async_show_form(step_id='init') if user_input == None.
|
||||
Return await self.async_finish(flow_result) if login init step pass.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_step_select_mfa_module(
|
||||
self, user_input: Optional[Dict[str, str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Handle the step of select mfa module."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
auth_module = user_input.get("multi_factor_auth_module")
|
||||
if auth_module in self.available_mfa_modules:
|
||||
self._auth_module_id = auth_module
|
||||
return await self.async_step_mfa()
|
||||
errors["base"] = "invalid_auth_module"
|
||||
|
||||
if len(self.available_mfa_modules) == 1:
|
||||
self._auth_module_id = list(self.available_mfa_modules.keys())[0]
|
||||
return await self.async_step_mfa()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="select_mfa_module",
|
||||
data_schema=vol.Schema(
|
||||
{"multi_factor_auth_module": vol.In(self.available_mfa_modules)}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_mfa(
|
||||
self, user_input: Optional[Dict[str, str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Handle the step of mfa validation."""
|
||||
assert self.user
|
||||
|
||||
errors = {}
|
||||
|
||||
auth_module = self._auth_manager.get_auth_mfa_module(self._auth_module_id)
|
||||
if auth_module is None:
|
||||
# Given an invalid input to async_step_select_mfa_module
|
||||
# will show invalid_auth_module error
|
||||
return await self.async_step_select_mfa_module(user_input={})
|
||||
|
||||
if user_input is None and hasattr(
|
||||
auth_module, "async_initialize_login_mfa_step"
|
||||
):
|
||||
try:
|
||||
await auth_module.async_initialize_login_mfa_step(self.user.id)
|
||||
except HomeAssistantError:
|
||||
_LOGGER.exception("Error initializing MFA step")
|
||||
return self.async_abort(reason="unknown_error")
|
||||
|
||||
if user_input is not None:
|
||||
expires = self.created_at + MFA_SESSION_EXPIRATION
|
||||
if dt_util.utcnow() > expires:
|
||||
return self.async_abort(reason="login_expired")
|
||||
|
||||
result = await auth_module.async_validate(self.user.id, user_input)
|
||||
if not result:
|
||||
errors["base"] = "invalid_code"
|
||||
self.invalid_mfa_times += 1
|
||||
if self.invalid_mfa_times >= auth_module.MAX_RETRY_TIME > 0:
|
||||
return self.async_abort(reason="too_many_retry")
|
||||
|
||||
if not errors:
|
||||
return await self.async_finish(self.user)
|
||||
|
||||
description_placeholders: Dict[str, Optional[str]] = {
|
||||
"mfa_module_name": auth_module.name,
|
||||
"mfa_module_id": auth_module.id,
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="mfa",
|
||||
data_schema=auth_module.input_schema,
|
||||
description_placeholders=description_placeholders,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_finish(self, flow_result: Any) -> Dict:
|
||||
"""Handle the pass of login flow."""
|
||||
return self.async_create_entry(title=self._auth_provider.name, data=flow_result)
|
||||
return self.async_create_entry(
|
||||
title=self._auth_provider.name,
|
||||
data=flow_result
|
||||
)
|
||||
|
|
|
@ -1,153 +0,0 @@
|
|||
"""Auth provider that validates credentials via an external command."""
|
||||
|
||||
import asyncio.subprocess
|
||||
import collections
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, Optional, cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow
|
||||
from ..models import Credentials, UserMeta
|
||||
|
||||
CONF_COMMAND = "command"
|
||||
CONF_ARGS = "args"
|
||||
CONF_META = "meta"
|
||||
|
||||
CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_COMMAND): vol.All(
|
||||
str, os.path.normpath, msg="must be an absolute path"
|
||||
),
|
||||
vol.Optional(CONF_ARGS, default=None): vol.Any(vol.DefaultTo(list), [str]),
|
||||
vol.Optional(CONF_META, default=False): bool,
|
||||
},
|
||||
extra=vol.PREVENT_EXTRA,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InvalidAuthError(HomeAssistantError):
|
||||
"""Raised when authentication with given credentials fails."""
|
||||
|
||||
|
||||
@AUTH_PROVIDERS.register("command_line")
|
||||
class CommandLineAuthProvider(AuthProvider):
|
||||
"""Auth provider validating credentials by calling a command."""
|
||||
|
||||
DEFAULT_TITLE = "Command Line Authentication"
|
||||
|
||||
# which keys to accept from a program's stdout
|
||||
ALLOWED_META_KEYS = ("name",)
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Extend parent's __init__.
|
||||
|
||||
Adds self._user_meta dictionary to hold the user-specific
|
||||
attributes provided by external programs.
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
self._user_meta: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
async def async_login_flow(self, context: Optional[dict]) -> LoginFlow:
|
||||
"""Return a flow to login."""
|
||||
return CommandLineLoginFlow(self)
|
||||
|
||||
async def async_validate_login(self, username: str, password: str) -> None:
|
||||
"""Validate a username and password."""
|
||||
env = {"username": username, "password": password}
|
||||
try:
|
||||
# pylint: disable=no-member
|
||||
process = await asyncio.subprocess.create_subprocess_exec(
|
||||
self.config[CONF_COMMAND],
|
||||
*self.config[CONF_ARGS],
|
||||
env=env,
|
||||
stdout=asyncio.subprocess.PIPE if self.config[CONF_META] else None,
|
||||
)
|
||||
stdout, _ = await process.communicate()
|
||||
except OSError as err:
|
||||
# happens when command doesn't exist or permission is denied
|
||||
_LOGGER.error("Error while authenticating %r: %s", username, err)
|
||||
raise InvalidAuthError
|
||||
|
||||
if process.returncode != 0:
|
||||
_LOGGER.error(
|
||||
"User %r failed to authenticate, command exited " "with code %d.",
|
||||
username,
|
||||
process.returncode,
|
||||
)
|
||||
raise InvalidAuthError
|
||||
|
||||
if self.config[CONF_META]:
|
||||
meta: Dict[str, str] = {}
|
||||
for _line in stdout.splitlines():
|
||||
try:
|
||||
line = _line.decode().lstrip()
|
||||
if line.startswith("#"):
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
except ValueError:
|
||||
# malformed line
|
||||
continue
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if key in self.ALLOWED_META_KEYS:
|
||||
meta[key] = value
|
||||
self._user_meta[username] = meta
|
||||
|
||||
async def async_get_or_create_credentials(
|
||||
self, flow_result: Dict[str, str]
|
||||
) -> Credentials:
|
||||
"""Get credentials based on the flow result."""
|
||||
username = flow_result["username"]
|
||||
for credential in await self.async_credentials():
|
||||
if credential.data["username"] == username:
|
||||
return credential
|
||||
|
||||
# Create new credentials.
|
||||
return self.async_create_credentials({"username": username})
|
||||
|
||||
async def async_user_meta_for_credentials(
|
||||
self, credentials: Credentials
|
||||
) -> UserMeta:
|
||||
"""Return extra user metadata for credentials.
|
||||
|
||||
Currently, only name is supported.
|
||||
"""
|
||||
meta = self._user_meta.get(credentials.data["username"], {})
|
||||
return UserMeta(name=meta.get("name"), is_active=True)
|
||||
|
||||
|
||||
class CommandLineLoginFlow(LoginFlow):
|
||||
"""Handler for the login flow."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: Optional[Dict[str, str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Handle the step of the form."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
user_input["username"] = user_input["username"].strip()
|
||||
try:
|
||||
await cast(
|
||||
CommandLineAuthProvider, self._auth_provider
|
||||
).async_validate_login(user_input["username"], user_input["password"])
|
||||
except InvalidAuthError:
|
||||
errors["base"] = "invalid_auth"
|
||||
|
||||
if not errors:
|
||||
user_input.pop("password")
|
||||
return await self.async_finish(user_input)
|
||||
|
||||
schema: Dict[str, type] = collections.OrderedDict()
|
||||
schema["username"] = str
|
||||
schema["password"] = str
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init", data_schema=vol.Schema(schema), errors=errors
|
||||
)
|
|
@ -1,28 +1,30 @@
|
|||
"""Home Assistant auth provider."""
|
||||
import asyncio
|
||||
import base64
|
||||
from collections import OrderedDict
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Set, cast
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import Any, Dict, List, Optional, cast
|
||||
|
||||
import bcrypt
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_ID
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import callback, HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow
|
||||
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
|
||||
from ..models import Credentials, UserMeta
|
||||
from ..util import generate_secret
|
||||
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_KEY = "auth_provider.homeassistant"
|
||||
STORAGE_KEY = 'auth_provider.homeassistant'
|
||||
|
||||
|
||||
def _disallow_id(conf: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Disallow ID in config."""
|
||||
if CONF_ID in conf:
|
||||
raise vol.Invalid("ID is not allowed for the homeassistant auth provider.")
|
||||
raise vol.Invalid(
|
||||
'ID is not allowed for the homeassistant auth provider.')
|
||||
|
||||
return conf
|
||||
|
||||
|
@ -47,130 +49,73 @@ class Data:
|
|||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the user data store."""
|
||||
self.hass = hass
|
||||
self._store = hass.helpers.storage.Store(
|
||||
STORAGE_VERSION, STORAGE_KEY, private=True
|
||||
)
|
||||
self._data: Optional[Dict[str, Any]] = None
|
||||
# Legacy mode will allow usernames to start/end with whitespace
|
||||
# and will compare usernames case-insensitive.
|
||||
# Remove in 2020 or when we launch 1.0.
|
||||
self.is_legacy = False
|
||||
|
||||
@callback
|
||||
def normalize_username(self, username: str) -> str:
|
||||
"""Normalize a username based on the mode."""
|
||||
if self.is_legacy:
|
||||
return username
|
||||
|
||||
return username.strip().casefold()
|
||||
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
|
||||
self._data = None # type: Optional[Dict[str, Any]]
|
||||
|
||||
async def async_load(self) -> None:
|
||||
"""Load stored data."""
|
||||
data = await self._store.async_load()
|
||||
|
||||
if data is None:
|
||||
data = {"users": []}
|
||||
|
||||
seen: Set[str] = set()
|
||||
|
||||
for user in data["users"]:
|
||||
username = user["username"]
|
||||
|
||||
# check if we have duplicates
|
||||
folded = username.casefold()
|
||||
|
||||
if folded in seen:
|
||||
self.is_legacy = True
|
||||
|
||||
logging.getLogger(__name__).warning(
|
||||
"Home Assistant auth provider is running in legacy mode "
|
||||
"because we detected usernames that are case-insensitive"
|
||||
"equivalent. Please change the username: '%s'.",
|
||||
username,
|
||||
)
|
||||
|
||||
break
|
||||
|
||||
seen.add(folded)
|
||||
|
||||
# check if we have unstripped usernames
|
||||
if username != username.strip():
|
||||
self.is_legacy = True
|
||||
|
||||
logging.getLogger(__name__).warning(
|
||||
"Home Assistant auth provider is running in legacy mode "
|
||||
"because we detected usernames that start or end in a "
|
||||
"space. Please change the username: '%s'.",
|
||||
username,
|
||||
)
|
||||
|
||||
break
|
||||
data = {
|
||||
'salt': generate_secret(),
|
||||
'users': []
|
||||
}
|
||||
|
||||
self._data = data
|
||||
|
||||
@property
|
||||
def users(self) -> List[Dict[str, str]]:
|
||||
"""Return users."""
|
||||
return self._data["users"] # type: ignore
|
||||
return self._data['users'] # type: ignore
|
||||
|
||||
def validate_login(self, username: str, password: str) -> None:
|
||||
"""Validate a username and password.
|
||||
|
||||
Raises InvalidAuth if auth invalid.
|
||||
"""
|
||||
username = self.normalize_username(username)
|
||||
dummy = b"$2b$12$CiuFGszHx9eNHxPuQcwBWez4CwDTOcLTX5CbOpV6gef2nYuXkY7BO"
|
||||
hashed = self.hash_password(password)
|
||||
|
||||
found = None
|
||||
|
||||
# Compare all users to avoid timing attacks.
|
||||
for user in self.users:
|
||||
if self.normalize_username(user["username"]) == username:
|
||||
if username == user['username']:
|
||||
found = user
|
||||
|
||||
if found is None:
|
||||
# check a hash to make timing the same as if user was found
|
||||
bcrypt.checkpw(b"foo", dummy)
|
||||
# Do one more compare to make timing the same as if user was found.
|
||||
hmac.compare_digest(hashed, hashed)
|
||||
raise InvalidAuth
|
||||
|
||||
user_hash = base64.b64decode(found["password"])
|
||||
|
||||
# bcrypt.checkpw is timing-safe
|
||||
if not bcrypt.checkpw(password.encode(), user_hash):
|
||||
if not hmac.compare_digest(hashed,
|
||||
base64.b64decode(found['password'])):
|
||||
raise InvalidAuth
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
def hash_password(self, password: str, for_storage: bool = False) -> bytes:
|
||||
"""Encode a password."""
|
||||
hashed: bytes = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
|
||||
|
||||
salt = self._data['salt'].encode() # type: ignore
|
||||
hashed = hashlib.pbkdf2_hmac('sha512', password.encode(), salt, 100000)
|
||||
if for_storage:
|
||||
hashed = base64.b64encode(hashed)
|
||||
return hashed
|
||||
|
||||
def add_auth(self, username: str, password: str) -> None:
|
||||
"""Add a new authenticated user/pass."""
|
||||
username = self.normalize_username(username)
|
||||
|
||||
if any(
|
||||
self.normalize_username(user["username"]) == username for user in self.users
|
||||
):
|
||||
if any(user['username'] == username for user in self.users):
|
||||
raise InvalidUser
|
||||
|
||||
self.users.append(
|
||||
{
|
||||
"username": username,
|
||||
"password": self.hash_password(password, True).decode(),
|
||||
}
|
||||
)
|
||||
self.users.append({
|
||||
'username': username,
|
||||
'password': self.hash_password(password, True).decode(),
|
||||
})
|
||||
|
||||
@callback
|
||||
def async_remove_auth(self, username: str) -> None:
|
||||
"""Remove authentication."""
|
||||
username = self.normalize_username(username)
|
||||
|
||||
index = None
|
||||
for i, user in enumerate(self.users):
|
||||
if self.normalize_username(user["username"]) == username:
|
||||
if user['username'] == username:
|
||||
index = i
|
||||
break
|
||||
|
||||
|
@ -184,11 +129,10 @@ class Data:
|
|||
|
||||
Raises InvalidUser if user cannot be found.
|
||||
"""
|
||||
username = self.normalize_username(username)
|
||||
|
||||
for user in self.users:
|
||||
if self.normalize_username(user["username"]) == username:
|
||||
user["password"] = self.hash_password(new_password, True).decode()
|
||||
if user['username'] == username:
|
||||
user['password'] = self.hash_password(
|
||||
new_password, True).decode()
|
||||
break
|
||||
else:
|
||||
raise InvalidUser
|
||||
|
@ -198,74 +142,64 @@ class Data:
|
|||
await self._store.async_save(self._data)
|
||||
|
||||
|
||||
@AUTH_PROVIDERS.register("homeassistant")
|
||||
@AUTH_PROVIDERS.register('homeassistant')
|
||||
class HassAuthProvider(AuthProvider):
|
||||
"""Auth provider based on a local storage of users in HASS config dir."""
|
||||
|
||||
DEFAULT_TITLE = "Home Assistant Local"
|
||||
DEFAULT_TITLE = 'Home Assistant Local'
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Initialize an Home Assistant auth provider."""
|
||||
super().__init__(*args, **kwargs)
|
||||
self.data: Optional[Data] = None
|
||||
self._init_lock = asyncio.Lock()
|
||||
data = None
|
||||
|
||||
async def async_initialize(self) -> None:
|
||||
"""Initialize the auth provider."""
|
||||
async with self._init_lock:
|
||||
if self.data is not None:
|
||||
return
|
||||
if self.data is not None:
|
||||
return
|
||||
|
||||
data = Data(self.hass)
|
||||
await data.async_load()
|
||||
self.data = data
|
||||
self.data = Data(self.hass)
|
||||
await self.data.async_load()
|
||||
|
||||
async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow:
|
||||
async def async_login_flow(
|
||||
self, context: Optional[Dict]) -> LoginFlow:
|
||||
"""Return a flow to login."""
|
||||
return HassLoginFlow(self)
|
||||
|
||||
async def async_validate_login(self, username: str, password: str) -> None:
|
||||
"""Validate a username and password."""
|
||||
"""Helper to validate a username and password."""
|
||||
if self.data is None:
|
||||
await self.async_initialize()
|
||||
assert self.data is not None
|
||||
|
||||
await self.hass.async_add_executor_job(
|
||||
self.data.validate_login, username, password
|
||||
)
|
||||
self.data.validate_login, username, password)
|
||||
|
||||
async def async_get_or_create_credentials(
|
||||
self, flow_result: Dict[str, str]
|
||||
) -> Credentials:
|
||||
self, flow_result: Dict[str, str]) -> Credentials:
|
||||
"""Get credentials based on the flow result."""
|
||||
if self.data is None:
|
||||
await self.async_initialize()
|
||||
assert self.data is not None
|
||||
|
||||
norm_username = self.data.normalize_username
|
||||
username = norm_username(flow_result["username"])
|
||||
username = flow_result['username']
|
||||
|
||||
for credential in await self.async_credentials():
|
||||
if norm_username(credential.data["username"]) == username:
|
||||
if credential.data['username'] == username:
|
||||
return credential
|
||||
|
||||
# Create new credentials.
|
||||
return self.async_create_credentials({"username": username})
|
||||
return self.async_create_credentials({
|
||||
'username': username
|
||||
})
|
||||
|
||||
async def async_user_meta_for_credentials(
|
||||
self, credentials: Credentials
|
||||
) -> UserMeta:
|
||||
self, credentials: Credentials) -> UserMeta:
|
||||
"""Get extra info for this credential."""
|
||||
return UserMeta(name=credentials.data["username"], is_active=True)
|
||||
return UserMeta(name=credentials.data['username'], is_active=True)
|
||||
|
||||
async def async_will_remove_credentials(self, credentials: Credentials) -> None:
|
||||
async def async_will_remove_credentials(
|
||||
self, credentials: Credentials) -> None:
|
||||
"""When credentials get removed, also remove the auth."""
|
||||
if self.data is None:
|
||||
await self.async_initialize()
|
||||
assert self.data is not None
|
||||
|
||||
try:
|
||||
self.data.async_remove_auth(credentials.data["username"])
|
||||
self.data.async_remove_auth(credentials.data['username'])
|
||||
await self.data.async_save()
|
||||
except InvalidUser:
|
||||
# Can happen if somehow we didn't clean up a credential
|
||||
|
@ -276,27 +210,29 @@ class HassLoginFlow(LoginFlow):
|
|||
"""Handler for the login flow."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: Optional[Dict[str, str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
self, user_input: Optional[Dict[str, str]] = None) \
|
||||
-> Dict[str, Any]:
|
||||
"""Handle the step of the form."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
await cast(HassAuthProvider, self._auth_provider).async_validate_login(
|
||||
user_input["username"], user_input["password"]
|
||||
)
|
||||
await cast(HassAuthProvider, self._auth_provider)\
|
||||
.async_validate_login(user_input['username'],
|
||||
user_input['password'])
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
errors['base'] = 'invalid_auth'
|
||||
|
||||
if not errors:
|
||||
user_input.pop("password")
|
||||
user_input.pop('password')
|
||||
return await self.async_finish(user_input)
|
||||
|
||||
schema: Dict[str, type] = OrderedDict()
|
||||
schema["username"] = str
|
||||
schema["password"] = str
|
||||
schema = OrderedDict() # type: Dict[str, type]
|
||||
schema['username'] = str
|
||||
schema['password'] = str
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init", data_schema=vol.Schema(schema), errors=errors
|
||||
step_id='init',
|
||||
data_schema=vol.Schema(schema),
|
||||
errors=errors,
|
||||
)
|
||||
|
|
|
@ -5,31 +5,30 @@ from typing import Any, Dict, Optional, cast
|
|||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.core import callback
|
||||
|
||||
from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow
|
||||
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
|
||||
from ..models import Credentials, UserMeta
|
||||
|
||||
USER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("username"): str,
|
||||
vol.Required("password"): str,
|
||||
vol.Optional("name"): str,
|
||||
}
|
||||
)
|
||||
|
||||
USER_SCHEMA = vol.Schema({
|
||||
vol.Required('username'): str,
|
||||
vol.Required('password'): str,
|
||||
vol.Optional('name'): str,
|
||||
})
|
||||
|
||||
|
||||
CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend(
|
||||
{vol.Required("users"): [USER_SCHEMA]}, extra=vol.PREVENT_EXTRA
|
||||
)
|
||||
CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({
|
||||
vol.Required('users'): [USER_SCHEMA]
|
||||
}, extra=vol.PREVENT_EXTRA)
|
||||
|
||||
|
||||
class InvalidAuthError(HomeAssistantError):
|
||||
"""Raised when submitting invalid authentication."""
|
||||
|
||||
|
||||
@AUTH_PROVIDERS.register("insecure_example")
|
||||
@AUTH_PROVIDERS.register('insecure_example')
|
||||
class ExampleAuthProvider(AuthProvider):
|
||||
"""Example auth provider based on hardcoded usernames and passwords."""
|
||||
|
||||
|
@ -39,52 +38,51 @@ class ExampleAuthProvider(AuthProvider):
|
|||
|
||||
@callback
|
||||
def async_validate_login(self, username: str, password: str) -> None:
|
||||
"""Validate a username and password."""
|
||||
"""Helper to validate a username and password."""
|
||||
user = None
|
||||
|
||||
# Compare all users to avoid timing attacks.
|
||||
for usr in self.config["users"]:
|
||||
if hmac.compare_digest(
|
||||
username.encode("utf-8"), usr["username"].encode("utf-8")
|
||||
):
|
||||
for usr in self.config['users']:
|
||||
if hmac.compare_digest(username.encode('utf-8'),
|
||||
usr['username'].encode('utf-8')):
|
||||
user = usr
|
||||
|
||||
if user is None:
|
||||
# Do one more compare to make timing the same as if user was found.
|
||||
hmac.compare_digest(password.encode("utf-8"), password.encode("utf-8"))
|
||||
hmac.compare_digest(password.encode('utf-8'),
|
||||
password.encode('utf-8'))
|
||||
raise InvalidAuthError
|
||||
|
||||
if not hmac.compare_digest(
|
||||
user["password"].encode("utf-8"), password.encode("utf-8")
|
||||
):
|
||||
if not hmac.compare_digest(user['password'].encode('utf-8'),
|
||||
password.encode('utf-8')):
|
||||
raise InvalidAuthError
|
||||
|
||||
async def async_get_or_create_credentials(
|
||||
self, flow_result: Dict[str, str]
|
||||
) -> Credentials:
|
||||
self, flow_result: Dict[str, str]) -> Credentials:
|
||||
"""Get credentials based on the flow result."""
|
||||
username = flow_result["username"]
|
||||
username = flow_result['username']
|
||||
|
||||
for credential in await self.async_credentials():
|
||||
if credential.data["username"] == username:
|
||||
if credential.data['username'] == username:
|
||||
return credential
|
||||
|
||||
# Create new credentials.
|
||||
return self.async_create_credentials({"username": username})
|
||||
return self.async_create_credentials({
|
||||
'username': username
|
||||
})
|
||||
|
||||
async def async_user_meta_for_credentials(
|
||||
self, credentials: Credentials
|
||||
) -> UserMeta:
|
||||
self, credentials: Credentials) -> UserMeta:
|
||||
"""Return extra user metadata for credentials.
|
||||
|
||||
Will be used to populate info when creating a new user.
|
||||
"""
|
||||
username = credentials.data["username"]
|
||||
username = credentials.data['username']
|
||||
name = None
|
||||
|
||||
for user in self.config["users"]:
|
||||
if user["username"] == username:
|
||||
name = user.get("name")
|
||||
for user in self.config['users']:
|
||||
if user['username'] == username:
|
||||
name = user.get('name')
|
||||
break
|
||||
|
||||
return UserMeta(name=name, is_active=True)
|
||||
|
@ -94,27 +92,29 @@ class ExampleLoginFlow(LoginFlow):
|
|||
"""Handler for the login flow."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: Optional[Dict[str, str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
self, user_input: Optional[Dict[str, str]] = None) \
|
||||
-> Dict[str, Any]:
|
||||
"""Handle the step of the form."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
cast(ExampleAuthProvider, self._auth_provider).async_validate_login(
|
||||
user_input["username"], user_input["password"]
|
||||
)
|
||||
cast(ExampleAuthProvider, self._auth_provider)\
|
||||
.async_validate_login(user_input['username'],
|
||||
user_input['password'])
|
||||
except InvalidAuthError:
|
||||
errors["base"] = "invalid_auth"
|
||||
errors['base'] = 'invalid_auth'
|
||||
|
||||
if not errors:
|
||||
user_input.pop("password")
|
||||
user_input.pop('password')
|
||||
return await self.async_finish(user_input)
|
||||
|
||||
schema: Dict[str, type] = OrderedDict()
|
||||
schema["username"] = str
|
||||
schema["password"] = str
|
||||
schema = OrderedDict() # type: Dict[str, type]
|
||||
schema['username'] = str
|
||||
schema['password'] = str
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init", data_schema=vol.Schema(schema), errors=errors
|
||||
step_id='init',
|
||||
data_schema=vol.Schema(schema),
|
||||
errors=errors,
|
||||
)
|
||||
|
|
|
@ -8,55 +8,34 @@ from typing import Any, Dict, Optional, cast
|
|||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.components.http import HomeAssistantHTTP # noqa: F401
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow
|
||||
from .. import AuthManager
|
||||
from ..models import Credentials, User, UserMeta
|
||||
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
|
||||
from ..models import Credentials, UserMeta
|
||||
|
||||
AUTH_PROVIDER_TYPE = "legacy_api_password"
|
||||
CONF_API_PASSWORD = "api_password"
|
||||
|
||||
CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend(
|
||||
{vol.Required(CONF_API_PASSWORD): cv.string}, extra=vol.PREVENT_EXTRA
|
||||
)
|
||||
USER_SCHEMA = vol.Schema({
|
||||
vol.Required('username'): str,
|
||||
})
|
||||
|
||||
LEGACY_USER_NAME = "Legacy API password user"
|
||||
|
||||
CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({
|
||||
}, extra=vol.PREVENT_EXTRA)
|
||||
|
||||
LEGACY_USER = 'homeassistant'
|
||||
|
||||
|
||||
class InvalidAuthError(HomeAssistantError):
|
||||
"""Raised when submitting invalid authentication."""
|
||||
|
||||
|
||||
async def async_validate_password(hass: HomeAssistant, password: str) -> Optional[User]:
|
||||
"""Return a user if password is valid. None if not."""
|
||||
auth = cast(AuthManager, hass.auth) # type: ignore
|
||||
providers = auth.get_auth_providers(AUTH_PROVIDER_TYPE)
|
||||
if not providers:
|
||||
raise ValueError("Legacy API password provider not found")
|
||||
|
||||
try:
|
||||
provider = cast(LegacyApiPasswordAuthProvider, providers[0])
|
||||
provider.async_validate_login(password)
|
||||
return await auth.async_get_or_create_user(
|
||||
await provider.async_get_or_create_credentials({})
|
||||
)
|
||||
except InvalidAuthError:
|
||||
return None
|
||||
|
||||
|
||||
@AUTH_PROVIDERS.register(AUTH_PROVIDER_TYPE)
|
||||
@AUTH_PROVIDERS.register('legacy_api_password')
|
||||
class LegacyApiPasswordAuthProvider(AuthProvider):
|
||||
"""An auth provider support legacy api_password."""
|
||||
"""Example auth provider based on hardcoded usernames and passwords."""
|
||||
|
||||
DEFAULT_TITLE = "Legacy API Password"
|
||||
|
||||
@property
|
||||
def api_password(self) -> str:
|
||||
"""Return api_password."""
|
||||
return str(self.config[CONF_API_PASSWORD])
|
||||
DEFAULT_TITLE = 'Legacy API Password'
|
||||
|
||||
async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow:
|
||||
"""Return a flow to login."""
|
||||
|
@ -64,55 +43,62 @@ class LegacyApiPasswordAuthProvider(AuthProvider):
|
|||
|
||||
@callback
|
||||
def async_validate_login(self, password: str) -> None:
|
||||
"""Validate password."""
|
||||
api_password = str(self.config[CONF_API_PASSWORD])
|
||||
"""Helper to validate a username and password."""
|
||||
hass_http = getattr(self.hass, 'http', None) # type: HomeAssistantHTTP
|
||||
|
||||
if not hmac.compare_digest(
|
||||
api_password.encode("utf-8"), password.encode("utf-8")
|
||||
):
|
||||
if not hass_http:
|
||||
raise ValueError('http component is not loaded')
|
||||
|
||||
if hass_http.api_password is None:
|
||||
raise ValueError('http component is not configured using'
|
||||
' api_password')
|
||||
|
||||
if not hmac.compare_digest(hass_http.api_password.encode('utf-8'),
|
||||
password.encode('utf-8')):
|
||||
raise InvalidAuthError
|
||||
|
||||
async def async_get_or_create_credentials(
|
||||
self, flow_result: Dict[str, str]
|
||||
) -> Credentials:
|
||||
"""Return credentials for this login."""
|
||||
credentials = await self.async_credentials()
|
||||
if credentials:
|
||||
return credentials[0]
|
||||
self, flow_result: Dict[str, str]) -> Credentials:
|
||||
"""Return LEGACY_USER always."""
|
||||
for credential in await self.async_credentials():
|
||||
if credential.data['username'] == LEGACY_USER:
|
||||
return credential
|
||||
|
||||
return self.async_create_credentials({})
|
||||
return self.async_create_credentials({
|
||||
'username': LEGACY_USER
|
||||
})
|
||||
|
||||
async def async_user_meta_for_credentials(
|
||||
self, credentials: Credentials
|
||||
) -> UserMeta:
|
||||
self, credentials: Credentials) -> UserMeta:
|
||||
"""
|
||||
Return info for the user.
|
||||
Set name as LEGACY_USER always.
|
||||
|
||||
Will be used to populate info when creating a new user.
|
||||
"""
|
||||
return UserMeta(name=LEGACY_USER_NAME, is_active=True)
|
||||
return UserMeta(name=LEGACY_USER, is_active=True)
|
||||
|
||||
|
||||
class LegacyLoginFlow(LoginFlow):
|
||||
"""Handler for the login flow."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: Optional[Dict[str, str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
self, user_input: Optional[Dict[str, str]] = None) \
|
||||
-> Dict[str, Any]:
|
||||
"""Handle the step of the form."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
cast(
|
||||
LegacyApiPasswordAuthProvider, self._auth_provider
|
||||
).async_validate_login(user_input["password"])
|
||||
cast(LegacyApiPasswordAuthProvider, self._auth_provider)\
|
||||
.async_validate_login(user_input['password'])
|
||||
except InvalidAuthError:
|
||||
errors["base"] = "invalid_auth"
|
||||
errors['base'] = 'invalid_auth'
|
||||
|
||||
if not errors:
|
||||
return await self.async_finish({})
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init", data_schema=vol.Schema({"password": str}), errors=errors
|
||||
step_id='init',
|
||||
data_schema=vol.Schema({'password': str}),
|
||||
errors=errors,
|
||||
)
|
||||
|
|
|
@ -3,47 +3,19 @@
|
|||
It shows list of users if access from trusted network.
|
||||
Abort login flow if not access from trusted network.
|
||||
"""
|
||||
from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_network
|
||||
from typing import Any, Dict, List, Optional, Union, cast
|
||||
from typing import Any, Dict, Optional, cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.http import HomeAssistantHTTP # noqa: F401
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow
|
||||
from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
|
||||
from ..models import Credentials, UserMeta
|
||||
|
||||
IPAddress = Union[IPv4Address, IPv6Address]
|
||||
IPNetwork = Union[IPv4Network, IPv6Network]
|
||||
|
||||
CONF_TRUSTED_NETWORKS = "trusted_networks"
|
||||
CONF_TRUSTED_USERS = "trusted_users"
|
||||
CONF_GROUP = "group"
|
||||
CONF_ALLOW_BYPASS_LOGIN = "allow_bypass_login"
|
||||
|
||||
CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_TRUSTED_NETWORKS): vol.All(cv.ensure_list, [ip_network]),
|
||||
vol.Optional(CONF_TRUSTED_USERS, default={}): vol.Schema(
|
||||
# we only validate the format of user_id or group_id
|
||||
{
|
||||
ip_network: vol.All(
|
||||
cv.ensure_list,
|
||||
[
|
||||
vol.Or(
|
||||
cv.uuid4_hex,
|
||||
vol.Schema({vol.Required(CONF_GROUP): cv.uuid4_hex}),
|
||||
)
|
||||
],
|
||||
)
|
||||
}
|
||||
),
|
||||
vol.Optional(CONF_ALLOW_BYPASS_LOGIN, default=False): cv.boolean,
|
||||
},
|
||||
extra=vol.PREVENT_EXTRA,
|
||||
)
|
||||
CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({
|
||||
}, extra=vol.PREVENT_EXTRA)
|
||||
|
||||
|
||||
class InvalidAuthError(HomeAssistantError):
|
||||
|
@ -54,85 +26,40 @@ class InvalidUserError(HomeAssistantError):
|
|||
"""Raised when try to login as invalid user."""
|
||||
|
||||
|
||||
@AUTH_PROVIDERS.register("trusted_networks")
|
||||
@AUTH_PROVIDERS.register('trusted_networks')
|
||||
class TrustedNetworksAuthProvider(AuthProvider):
|
||||
"""Trusted Networks auth provider.
|
||||
|
||||
Allow passwordless access from trusted network.
|
||||
"""
|
||||
|
||||
DEFAULT_TITLE = "Trusted Networks"
|
||||
|
||||
@property
|
||||
def trusted_networks(self) -> List[IPNetwork]:
|
||||
"""Return trusted networks."""
|
||||
return cast(List[IPNetwork], self.config[CONF_TRUSTED_NETWORKS])
|
||||
|
||||
@property
|
||||
def trusted_users(self) -> Dict[IPNetwork, Any]:
|
||||
"""Return trusted users per network."""
|
||||
return cast(Dict[IPNetwork, Any], self.config[CONF_TRUSTED_USERS])
|
||||
|
||||
@property
|
||||
def support_mfa(self) -> bool:
|
||||
"""Trusted Networks auth provider does not support MFA."""
|
||||
return False
|
||||
DEFAULT_TITLE = 'Trusted Networks'
|
||||
|
||||
async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow:
|
||||
"""Return a flow to login."""
|
||||
assert context is not None
|
||||
ip_addr = cast(IPAddress, context.get("ip_address"))
|
||||
users = await self.store.async_get_users()
|
||||
available_users = [
|
||||
user for user in users if not user.system_generated and user.is_active
|
||||
]
|
||||
for ip_net, user_or_group_list in self.trusted_users.items():
|
||||
if ip_addr in ip_net:
|
||||
user_list = [
|
||||
user_id
|
||||
for user_id in user_or_group_list
|
||||
if isinstance(user_id, str)
|
||||
]
|
||||
group_list = [
|
||||
group[CONF_GROUP]
|
||||
for group in user_or_group_list
|
||||
if isinstance(group, dict)
|
||||
]
|
||||
flattened_group_list = [
|
||||
group for sublist in group_list for group in sublist
|
||||
]
|
||||
available_users = [
|
||||
user
|
||||
for user in available_users
|
||||
if (
|
||||
user.id in user_list
|
||||
or any(
|
||||
[group.id in flattened_group_list for group in user.groups]
|
||||
)
|
||||
)
|
||||
]
|
||||
break
|
||||
available_users = {user.id: user.name
|
||||
for user in users
|
||||
if not user.system_generated and user.is_active}
|
||||
|
||||
return TrustedNetworksLoginFlow(
|
||||
self,
|
||||
ip_addr,
|
||||
{user.id: user.name for user in available_users},
|
||||
self.config[CONF_ALLOW_BYPASS_LOGIN],
|
||||
)
|
||||
self, cast(str, context.get('ip_address')), available_users)
|
||||
|
||||
async def async_get_or_create_credentials(
|
||||
self, flow_result: Dict[str, str]
|
||||
) -> Credentials:
|
||||
self, flow_result: Dict[str, str]) -> Credentials:
|
||||
"""Get credentials based on the flow result."""
|
||||
user_id = flow_result["user"]
|
||||
user_id = flow_result['user']
|
||||
|
||||
users = await self.store.async_get_users()
|
||||
for user in users:
|
||||
if not user.system_generated and user.is_active and user.id == user_id:
|
||||
if (not user.system_generated and
|
||||
user.is_active and
|
||||
user.id == user_id):
|
||||
for credential in await self.async_credentials():
|
||||
if credential.data["user_id"] == user_id:
|
||||
if credential.data['user_id'] == user_id:
|
||||
return credential
|
||||
cred = self.async_create_credentials({"user_id": user_id})
|
||||
cred = self.async_create_credentials({'user_id': user_id})
|
||||
await self.store.async_link_user(user, cred)
|
||||
return cred
|
||||
|
||||
|
@ -140,8 +67,7 @@ class TrustedNetworksAuthProvider(AuthProvider):
|
|||
raise InvalidUserError
|
||||
|
||||
async def async_user_meta_for_credentials(
|
||||
self, credentials: Credentials
|
||||
) -> UserMeta:
|
||||
self, credentials: Credentials) -> UserMeta:
|
||||
"""Return extra user metadata for credentials.
|
||||
|
||||
Trusted network auth provider should never create new user.
|
||||
|
@ -149,58 +75,62 @@ class TrustedNetworksAuthProvider(AuthProvider):
|
|||
raise NotImplementedError
|
||||
|
||||
@callback
|
||||
def async_validate_access(self, ip_addr: IPAddress) -> None:
|
||||
def async_validate_access(self, ip_address: str) -> None:
|
||||
"""Make sure the access from trusted networks.
|
||||
|
||||
Raise InvalidAuthError if not.
|
||||
Raise InvalidAuthError if trusted_networks is not configured.
|
||||
"""
|
||||
if not self.trusted_networks:
|
||||
raise InvalidAuthError("trusted_networks is not configured")
|
||||
hass_http = getattr(self.hass, 'http', None) # type: HomeAssistantHTTP
|
||||
|
||||
if not any(
|
||||
ip_addr in trusted_network for trusted_network in self.trusted_networks
|
||||
):
|
||||
raise InvalidAuthError("Not in trusted_networks")
|
||||
if not hass_http or not hass_http.trusted_networks:
|
||||
raise InvalidAuthError('trusted_networks is not configured')
|
||||
|
||||
if not any(ip_address in trusted_network for trusted_network
|
||||
in hass_http.trusted_networks):
|
||||
raise InvalidAuthError('Not in trusted_networks')
|
||||
|
||||
|
||||
class TrustedNetworksLoginFlow(LoginFlow):
|
||||
"""Handler for the login flow."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
auth_provider: TrustedNetworksAuthProvider,
|
||||
ip_addr: IPAddress,
|
||||
available_users: Dict[str, Optional[str]],
|
||||
allow_bypass_login: bool,
|
||||
) -> None:
|
||||
def __init__(self, auth_provider: TrustedNetworksAuthProvider,
|
||||
ip_address: str, available_users: Dict[str, Optional[str]]) \
|
||||
-> None:
|
||||
"""Initialize the login flow."""
|
||||
super().__init__(auth_provider)
|
||||
self._available_users = available_users
|
||||
self._ip_address = ip_addr
|
||||
self._allow_bypass_login = allow_bypass_login
|
||||
self._ip_address = ip_address
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: Optional[Dict[str, str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
self, user_input: Optional[Dict[str, str]] = None) \
|
||||
-> Dict[str, Any]:
|
||||
"""Handle the step of the form."""
|
||||
errors = {}
|
||||
try:
|
||||
cast(
|
||||
TrustedNetworksAuthProvider, self._auth_provider
|
||||
).async_validate_access(self._ip_address)
|
||||
cast(TrustedNetworksAuthProvider, self._auth_provider)\
|
||||
.async_validate_access(self._ip_address)
|
||||
|
||||
except InvalidAuthError:
|
||||
return self.async_abort(reason="not_whitelisted")
|
||||
|
||||
if user_input is not None:
|
||||
return await self.async_finish(user_input)
|
||||
|
||||
if self._allow_bypass_login and len(self._available_users) == 1:
|
||||
return await self.async_finish(
|
||||
{"user": next(iter(self._available_users.keys()))}
|
||||
errors['base'] = 'invalid_auth'
|
||||
return self.async_show_form(
|
||||
step_id='init',
|
||||
data_schema=None,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
if user_input is not None:
|
||||
user_id = user_input['user']
|
||||
if user_id not in self._available_users:
|
||||
errors['base'] = 'invalid_auth'
|
||||
|
||||
if not errors:
|
||||
return await self.async_finish(user_input)
|
||||
|
||||
schema = {'user': vol.In(self._available_users)}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema({"user": vol.In(self._available_users)}),
|
||||
step_id='init',
|
||||
data_schema=vol.Schema(schema),
|
||||
errors=errors,
|
||||
)
|
||||
|
|
13
homeassistant/auth/util.py
Normal file
13
homeassistant/auth/util.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
"""Auth utils."""
|
||||
import binascii
|
||||
import os
|
||||
|
||||
|
||||
def generate_secret(entropy: int = 32) -> str:
|
||||
"""Generate a secret.
|
||||
|
||||
Backport of secrets.token_hex from Python 3.6
|
||||
|
||||
Event loop friendly.
|
||||
"""
|
||||
return binascii.hexlify(os.urandom(entropy)).decode('ascii')
|
|
@ -1,58 +1,80 @@
|
|||
"""Provide methods to bootstrap a Home Assistant instance."""
|
||||
import asyncio
|
||||
from collections import OrderedDict
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
from time import time
|
||||
from typing import Any, Dict, Optional, Set
|
||||
from collections import OrderedDict
|
||||
|
||||
from typing import Any, Optional, Dict
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config as conf_util, config_entries, core, loader
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_CLOSE,
|
||||
REQUIRED_NEXT_PYTHON_DATE,
|
||||
REQUIRED_NEXT_PYTHON_VER,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant import (
|
||||
core, config as conf_util, config_entries, components as core_components)
|
||||
from homeassistant.components import persistent_notification
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util.logging import AsyncHandler
|
||||
from homeassistant.util.package import async_get_user_site, is_virtual_env
|
||||
from homeassistant.util.yaml import clear_secret_cache
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.signal import async_register_signal_handling
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ERROR_LOG_FILENAME = "home-assistant.log"
|
||||
ERROR_LOG_FILENAME = 'home-assistant.log'
|
||||
|
||||
# hass.data key for logging information.
|
||||
DATA_LOGGING = "logging"
|
||||
DATA_LOGGING = 'logging'
|
||||
|
||||
DEBUGGER_INTEGRATIONS = {"ptvsd"}
|
||||
CORE_INTEGRATIONS = ("homeassistant", "persistent_notification")
|
||||
LOGGING_INTEGRATIONS = {"logger", "system_log"}
|
||||
STAGE_1_INTEGRATIONS = {
|
||||
# To record data
|
||||
"recorder",
|
||||
# To make sure we forward data to other instances
|
||||
"mqtt_eventstream",
|
||||
# To provide account link implementations
|
||||
"cloud",
|
||||
}
|
||||
FIRST_INIT_COMPONENT = {'system_log', 'recorder', 'mqtt', 'mqtt_eventstream',
|
||||
'logger', 'introduction', 'frontend', 'history'}
|
||||
|
||||
|
||||
async def async_from_config_dict(
|
||||
config: Dict[str, Any],
|
||||
hass: core.HomeAssistant,
|
||||
config_dir: Optional[str] = None,
|
||||
enable_log: bool = True,
|
||||
verbose: bool = False,
|
||||
skip_pip: bool = False,
|
||||
log_rotate_days: Any = None,
|
||||
log_file: Any = None,
|
||||
log_no_color: bool = False,
|
||||
) -> Optional[core.HomeAssistant]:
|
||||
def from_config_dict(config: Dict[str, Any],
|
||||
hass: Optional[core.HomeAssistant] = None,
|
||||
config_dir: Optional[str] = None,
|
||||
enable_log: bool = True,
|
||||
verbose: bool = False,
|
||||
skip_pip: bool = False,
|
||||
log_rotate_days: Any = None,
|
||||
log_file: Any = None,
|
||||
log_no_color: bool = False) \
|
||||
-> Optional[core.HomeAssistant]:
|
||||
"""Try to configure Home Assistant from a configuration dictionary.
|
||||
|
||||
Dynamically loads required components and its dependencies.
|
||||
"""
|
||||
if hass is None:
|
||||
hass = core.HomeAssistant()
|
||||
if config_dir is not None:
|
||||
config_dir = os.path.abspath(config_dir)
|
||||
hass.config.config_dir = config_dir
|
||||
if not is_virtual_env():
|
||||
hass.loop.run_until_complete(
|
||||
async_mount_local_lib_path(config_dir))
|
||||
|
||||
# run task
|
||||
hass = hass.loop.run_until_complete(
|
||||
async_from_config_dict(
|
||||
config, hass, config_dir, enable_log, verbose, skip_pip,
|
||||
log_rotate_days, log_file, log_no_color)
|
||||
)
|
||||
|
||||
return hass
|
||||
|
||||
|
||||
async def async_from_config_dict(config: Dict[str, Any],
|
||||
hass: core.HomeAssistant,
|
||||
config_dir: Optional[str] = None,
|
||||
enable_log: bool = True,
|
||||
verbose: bool = False,
|
||||
skip_pip: bool = False,
|
||||
log_rotate_days: Any = None,
|
||||
log_file: Any = None,
|
||||
log_no_color: bool = False) \
|
||||
-> Optional[core.HomeAssistant]:
|
||||
"""Try to configure Home Assistant from a configuration dictionary.
|
||||
|
||||
Dynamically loads required components and its dependencies.
|
||||
|
@ -61,70 +83,113 @@ async def async_from_config_dict(
|
|||
start = time()
|
||||
|
||||
if enable_log:
|
||||
async_enable_logging(hass, verbose, log_rotate_days, log_file, log_no_color)
|
||||
|
||||
hass.config.skip_pip = skip_pip
|
||||
if skip_pip:
|
||||
_LOGGER.warning(
|
||||
"Skipping pip installation of required modules. " "This may cause issues"
|
||||
)
|
||||
async_enable_logging(hass, verbose, log_rotate_days, log_file,
|
||||
log_no_color)
|
||||
|
||||
core_config = config.get(core.DOMAIN, {})
|
||||
|
||||
try:
|
||||
await conf_util.async_process_ha_core_config(hass, core_config)
|
||||
except vol.Invalid as config_err:
|
||||
conf_util.async_log_exception(config_err, "homeassistant", core_config, hass)
|
||||
return None
|
||||
except HomeAssistantError:
|
||||
_LOGGER.error(
|
||||
"Home Assistant core failed to initialize. "
|
||||
"Further initialization aborted"
|
||||
)
|
||||
except vol.Invalid as ex:
|
||||
conf_util.async_log_exception(ex, 'homeassistant', core_config, hass)
|
||||
return None
|
||||
|
||||
await hass.async_add_executor_job(
|
||||
conf_util.process_ha_config_upgrade, hass)
|
||||
|
||||
hass.config.skip_pip = skip_pip
|
||||
if skip_pip:
|
||||
_LOGGER.warning("Skipping pip installation of required modules. "
|
||||
"This may cause issues")
|
||||
|
||||
# Make a copy because we are mutating it.
|
||||
config = OrderedDict(config)
|
||||
|
||||
# Merge packages
|
||||
await conf_util.merge_packages_config(
|
||||
hass, config, core_config.get(conf_util.CONF_PACKAGES, {})
|
||||
)
|
||||
conf_util.merge_packages_config(
|
||||
hass, config, core_config.get(conf_util.CONF_PACKAGES, {}))
|
||||
|
||||
# Ensure we have no None values after merge
|
||||
for key, value in config.items():
|
||||
if not value:
|
||||
config[key] = {}
|
||||
|
||||
hass.config_entries = config_entries.ConfigEntries(hass, config)
|
||||
await hass.config_entries.async_initialize()
|
||||
await hass.config_entries.async_load()
|
||||
|
||||
await _async_set_up_integrations(hass, config)
|
||||
# Filter out the repeating and common config section [homeassistant]
|
||||
components = set(key.split(' ')[0] for key in config.keys()
|
||||
if key != core.DOMAIN)
|
||||
components.update(hass.config_entries.async_domains())
|
||||
|
||||
# setup components
|
||||
res = await core_components.async_setup(hass, config)
|
||||
if not res:
|
||||
_LOGGER.error("Home Assistant core failed to initialize. "
|
||||
"further initialization aborted")
|
||||
return hass
|
||||
|
||||
await persistent_notification.async_setup(hass, config)
|
||||
|
||||
_LOGGER.info("Home Assistant core initialized")
|
||||
|
||||
# stage 1
|
||||
for component in components:
|
||||
if component not in FIRST_INIT_COMPONENT:
|
||||
continue
|
||||
hass.async_create_task(async_setup_component(hass, component, config))
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# stage 2
|
||||
for component in components:
|
||||
if component in FIRST_INIT_COMPONENT:
|
||||
continue
|
||||
hass.async_create_task(async_setup_component(hass, component, config))
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
stop = time()
|
||||
_LOGGER.info("Home Assistant initialized in %.2fs", stop - start)
|
||||
_LOGGER.info("Home Assistant initialized in %.2fs", stop-start)
|
||||
|
||||
if REQUIRED_NEXT_PYTHON_DATE and sys.version_info[:3] < REQUIRED_NEXT_PYTHON_VER:
|
||||
msg = (
|
||||
"Support for the running Python version "
|
||||
f"{'.'.join(str(x) for x in sys.version_info[:3])} is deprecated and will "
|
||||
f"be removed in the first release after {REQUIRED_NEXT_PYTHON_DATE}. "
|
||||
"Please upgrade Python to "
|
||||
f"{'.'.join(str(x) for x in REQUIRED_NEXT_PYTHON_VER)} or "
|
||||
"higher."
|
||||
)
|
||||
_LOGGER.warning(msg)
|
||||
hass.components.persistent_notification.async_create(
|
||||
msg, "Python version", "python_version"
|
||||
)
|
||||
async_register_signal_handling(hass)
|
||||
return hass
|
||||
|
||||
|
||||
def from_config_file(config_path: str,
|
||||
hass: Optional[core.HomeAssistant] = None,
|
||||
verbose: bool = False,
|
||||
skip_pip: bool = True,
|
||||
log_rotate_days: Any = None,
|
||||
log_file: Any = None,
|
||||
log_no_color: bool = False)\
|
||||
-> Optional[core.HomeAssistant]:
|
||||
"""Read the configuration file and try to start all the functionality.
|
||||
|
||||
Will add functionality to 'hass' parameter if given,
|
||||
instantiates a new Home Assistant object if 'hass' is not given.
|
||||
"""
|
||||
if hass is None:
|
||||
hass = core.HomeAssistant()
|
||||
|
||||
# run task
|
||||
hass = hass.loop.run_until_complete(
|
||||
async_from_config_file(
|
||||
config_path, hass, verbose, skip_pip,
|
||||
log_rotate_days, log_file, log_no_color)
|
||||
)
|
||||
|
||||
return hass
|
||||
|
||||
|
||||
async def async_from_config_file(
|
||||
config_path: str,
|
||||
hass: core.HomeAssistant,
|
||||
verbose: bool = False,
|
||||
skip_pip: bool = True,
|
||||
log_rotate_days: Any = None,
|
||||
log_file: Any = None,
|
||||
log_no_color: bool = False,
|
||||
) -> Optional[core.HomeAssistant]:
|
||||
async def async_from_config_file(config_path: str,
|
||||
hass: core.HomeAssistant,
|
||||
verbose: bool = False,
|
||||
skip_pip: bool = True,
|
||||
log_rotate_days: Any = None,
|
||||
log_file: Any = None,
|
||||
log_no_color: bool = False)\
|
||||
-> Optional[core.HomeAssistant]:
|
||||
"""Read the configuration file and try to start all the functionality.
|
||||
|
||||
Will add functionality to 'hass' parameter.
|
||||
|
@ -137,14 +202,12 @@ async def async_from_config_file(
|
|||
if not is_virtual_env():
|
||||
await async_mount_local_lib_path(config_dir)
|
||||
|
||||
async_enable_logging(hass, verbose, log_rotate_days, log_file, log_no_color)
|
||||
|
||||
await hass.async_add_executor_job(conf_util.process_ha_config_upgrade, hass)
|
||||
async_enable_logging(hass, verbose, log_rotate_days, log_file,
|
||||
log_no_color)
|
||||
|
||||
try:
|
||||
config_dict = await hass.async_add_executor_job(
|
||||
conf_util.load_yaml_config_file, config_path
|
||||
)
|
||||
conf_util.load_yaml_config_file, config_path)
|
||||
except HomeAssistantError as err:
|
||||
_LOGGER.error("Error loading %s: %s", config_path, err)
|
||||
return None
|
||||
|
@ -152,48 +215,43 @@ async def async_from_config_file(
|
|||
clear_secret_cache()
|
||||
|
||||
return await async_from_config_dict(
|
||||
config_dict, hass, enable_log=False, skip_pip=skip_pip
|
||||
)
|
||||
config_dict, hass, enable_log=False, skip_pip=skip_pip)
|
||||
|
||||
|
||||
@core.callback
|
||||
def async_enable_logging(
|
||||
hass: core.HomeAssistant,
|
||||
verbose: bool = False,
|
||||
log_rotate_days: Optional[int] = None,
|
||||
log_file: Optional[str] = None,
|
||||
log_no_color: bool = False,
|
||||
) -> None:
|
||||
def async_enable_logging(hass: core.HomeAssistant,
|
||||
verbose: bool = False,
|
||||
log_rotate_days: Optional[int] = None,
|
||||
log_file: Optional[str] = None,
|
||||
log_no_color: bool = False) -> None:
|
||||
"""Set up the logging.
|
||||
|
||||
This method must be run in the event loop.
|
||||
"""
|
||||
fmt = "%(asctime)s %(levelname)s (%(threadName)s) " "[%(name)s] %(message)s"
|
||||
datefmt = "%Y-%m-%d %H:%M:%S"
|
||||
fmt = ("%(asctime)s %(levelname)s (%(threadName)s) "
|
||||
"[%(name)s] %(message)s")
|
||||
datefmt = '%Y-%m-%d %H:%M:%S'
|
||||
|
||||
if not log_no_color:
|
||||
try:
|
||||
from colorlog import ColoredFormatter
|
||||
|
||||
# basicConfig must be called after importing colorlog in order to
|
||||
# ensure that the handlers it sets up wraps the correct streams.
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
colorfmt = f"%(log_color)s{fmt}%(reset)s"
|
||||
logging.getLogger().handlers[0].setFormatter(
|
||||
ColoredFormatter(
|
||||
colorfmt,
|
||||
datefmt=datefmt,
|
||||
reset=True,
|
||||
log_colors={
|
||||
"DEBUG": "cyan",
|
||||
"INFO": "green",
|
||||
"WARNING": "yellow",
|
||||
"ERROR": "red",
|
||||
"CRITICAL": "red",
|
||||
},
|
||||
)
|
||||
)
|
||||
colorfmt = "%(log_color)s{}%(reset)s".format(fmt)
|
||||
logging.getLogger().handlers[0].setFormatter(ColoredFormatter(
|
||||
colorfmt,
|
||||
datefmt=datefmt,
|
||||
reset=True,
|
||||
log_colors={
|
||||
'DEBUG': 'cyan',
|
||||
'INFO': 'green',
|
||||
'WARNING': 'yellow',
|
||||
'ERROR': 'red',
|
||||
'CRITICAL': 'red',
|
||||
}
|
||||
))
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
@ -202,9 +260,9 @@ def async_enable_logging(
|
|||
logging.basicConfig(format=fmt, datefmt=datefmt, level=logging.INFO)
|
||||
|
||||
# Suppress overly verbose logs from libraries that aren't helpful
|
||||
logging.getLogger("requests").setLevel(logging.WARNING)
|
||||
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||
logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
|
||||
logging.getLogger('requests').setLevel(logging.WARNING)
|
||||
logging.getLogger('urllib3').setLevel(logging.WARNING)
|
||||
logging.getLogger('aiohttp.access').setLevel(logging.WARNING)
|
||||
|
||||
# Log errors to a file if we have write access to file or config dir
|
||||
if log_file is None:
|
||||
|
@ -217,16 +275,16 @@ def async_enable_logging(
|
|||
|
||||
# Check if we can write to the error log if it exists or that
|
||||
# we can create files in the containing directory if not.
|
||||
if (err_path_exists and os.access(err_log_path, os.W_OK)) or (
|
||||
not err_path_exists and os.access(err_dir, os.W_OK)
|
||||
):
|
||||
if (err_path_exists and os.access(err_log_path, os.W_OK)) or \
|
||||
(not err_path_exists and os.access(err_dir, os.W_OK)):
|
||||
|
||||
if log_rotate_days:
|
||||
err_handler: logging.FileHandler = logging.handlers.TimedRotatingFileHandler(
|
||||
err_log_path, when="midnight", backupCount=log_rotate_days
|
||||
)
|
||||
err_handler = logging.handlers.TimedRotatingFileHandler(
|
||||
err_log_path, when='midnight',
|
||||
backupCount=log_rotate_days) # type: logging.FileHandler
|
||||
else:
|
||||
err_handler = logging.FileHandler(err_log_path, mode="w", delay=True)
|
||||
err_handler = logging.FileHandler(
|
||||
err_log_path, mode='w', delay=True)
|
||||
|
||||
err_handler.setLevel(logging.INFO if verbose else logging.WARNING)
|
||||
err_handler.setFormatter(logging.Formatter(fmt, datefmt=datefmt))
|
||||
|
@ -235,19 +293,21 @@ def async_enable_logging(
|
|||
|
||||
async def async_stop_async_handler(_: Any) -> None:
|
||||
"""Cleanup async handler."""
|
||||
logging.getLogger("").removeHandler(async_handler) # type: ignore
|
||||
logging.getLogger('').removeHandler(async_handler) # type: ignore
|
||||
await async_handler.async_close(blocking=True)
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, async_stop_async_handler)
|
||||
hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_CLOSE, async_stop_async_handler)
|
||||
|
||||
logger = logging.getLogger("")
|
||||
logger = logging.getLogger('')
|
||||
logger.addHandler(async_handler) # type: ignore
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# Save the log file location for access by other components.
|
||||
hass.data[DATA_LOGGING] = err_log_path
|
||||
else:
|
||||
_LOGGER.error("Unable to set up error log %s (access denied)", err_log_path)
|
||||
_LOGGER.error(
|
||||
"Unable to set up error log %s (access denied)", err_log_path)
|
||||
|
||||
|
||||
async def async_mount_local_lib_path(config_dir: str) -> str:
|
||||
|
@ -255,143 +315,8 @@ async def async_mount_local_lib_path(config_dir: str) -> str:
|
|||
|
||||
This function is a coroutine.
|
||||
"""
|
||||
deps_dir = os.path.join(config_dir, "deps")
|
||||
deps_dir = os.path.join(config_dir, 'deps')
|
||||
lib_dir = await async_get_user_site(deps_dir)
|
||||
if lib_dir not in sys.path:
|
||||
sys.path.insert(0, lib_dir)
|
||||
return deps_dir
|
||||
|
||||
|
||||
@core.callback
|
||||
def _get_domains(hass: core.HomeAssistant, config: Dict[str, Any]) -> Set[str]:
|
||||
"""Get domains of components to set up."""
|
||||
# Filter out the repeating and common config section [homeassistant]
|
||||
domains = set(key.split(" ")[0] for key in config.keys() if key != core.DOMAIN)
|
||||
|
||||
# Add config entry domains
|
||||
domains.update(hass.config_entries.async_domains())
|
||||
|
||||
# Make sure the Hass.io component is loaded
|
||||
if "HASSIO" in os.environ:
|
||||
domains.add("hassio")
|
||||
|
||||
return domains
|
||||
|
||||
|
||||
async def _async_set_up_integrations(
|
||||
hass: core.HomeAssistant, config: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Set up all the integrations."""
|
||||
domains = _get_domains(hass, config)
|
||||
|
||||
# Start up debuggers. Start these first in case they want to wait.
|
||||
debuggers = domains & DEBUGGER_INTEGRATIONS
|
||||
if debuggers:
|
||||
_LOGGER.debug("Starting up debuggers %s", debuggers)
|
||||
await asyncio.gather(
|
||||
*(async_setup_component(hass, domain, config) for domain in debuggers)
|
||||
)
|
||||
domains -= DEBUGGER_INTEGRATIONS
|
||||
|
||||
# Resolve all dependencies of all components so we can find the logging
|
||||
# and integrations that need faster initialization.
|
||||
resolved_domains_task = asyncio.gather(
|
||||
*(loader.async_component_dependencies(hass, domain) for domain in domains),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
# Set up core.
|
||||
_LOGGER.debug("Setting up %s", CORE_INTEGRATIONS)
|
||||
|
||||
if not all(
|
||||
await asyncio.gather(
|
||||
*(
|
||||
async_setup_component(hass, domain, config)
|
||||
for domain in CORE_INTEGRATIONS
|
||||
)
|
||||
)
|
||||
):
|
||||
_LOGGER.error(
|
||||
"Home Assistant core failed to initialize. "
|
||||
"Further initialization aborted"
|
||||
)
|
||||
return
|
||||
|
||||
_LOGGER.debug("Home Assistant core initialized")
|
||||
|
||||
# Finish resolving domains
|
||||
for dep_domains in await resolved_domains_task:
|
||||
# Result is either a set or an exception. We ignore exceptions
|
||||
# It will be properly handled during setup of the domain.
|
||||
if isinstance(dep_domains, set):
|
||||
domains.update(dep_domains)
|
||||
|
||||
# setup components
|
||||
logging_domains = domains & LOGGING_INTEGRATIONS
|
||||
stage_1_domains = domains & STAGE_1_INTEGRATIONS
|
||||
stage_2_domains = domains - logging_domains - stage_1_domains
|
||||
|
||||
if logging_domains:
|
||||
_LOGGER.info("Setting up %s", logging_domains)
|
||||
|
||||
await asyncio.gather(
|
||||
*(async_setup_component(hass, domain, config) for domain in logging_domains)
|
||||
)
|
||||
|
||||
# Kick off loading the registries. They don't need to be awaited.
|
||||
asyncio.gather(
|
||||
hass.helpers.device_registry.async_get_registry(),
|
||||
hass.helpers.entity_registry.async_get_registry(),
|
||||
hass.helpers.area_registry.async_get_registry(),
|
||||
)
|
||||
|
||||
if stage_1_domains:
|
||||
await asyncio.gather(
|
||||
*(async_setup_component(hass, domain, config) for domain in stage_1_domains)
|
||||
)
|
||||
|
||||
# Load all integrations
|
||||
after_dependencies: Dict[str, Set[str]] = {}
|
||||
|
||||
for int_or_exc in await asyncio.gather(
|
||||
*(loader.async_get_integration(hass, domain) for domain in stage_2_domains),
|
||||
return_exceptions=True,
|
||||
):
|
||||
# Exceptions are handled in async_setup_component.
|
||||
if isinstance(int_or_exc, loader.Integration) and int_or_exc.after_dependencies:
|
||||
after_dependencies[int_or_exc.domain] = set(int_or_exc.after_dependencies)
|
||||
|
||||
last_load = None
|
||||
while stage_2_domains:
|
||||
domains_to_load = set()
|
||||
|
||||
for domain in stage_2_domains:
|
||||
after_deps = after_dependencies.get(domain)
|
||||
# Load if integration has no after_dependencies or they are
|
||||
# all loaded
|
||||
if not after_deps or not after_deps - hass.config.components:
|
||||
domains_to_load.add(domain)
|
||||
|
||||
if not domains_to_load or domains_to_load == last_load:
|
||||
break
|
||||
|
||||
_LOGGER.debug("Setting up %s", domains_to_load)
|
||||
|
||||
await asyncio.gather(
|
||||
*(async_setup_component(hass, domain, config) for domain in domains_to_load)
|
||||
)
|
||||
|
||||
last_load = domains_to_load
|
||||
stage_2_domains -= domains_to_load
|
||||
|
||||
# These are stage 2 domains that never have their after_dependencies
|
||||
# satisfied.
|
||||
if stage_2_domains:
|
||||
_LOGGER.debug("Final set up: %s", stage_2_domains)
|
||||
|
||||
await asyncio.gather(
|
||||
*(async_setup_component(hass, domain, config) for domain in stage_2_domains)
|
||||
)
|
||||
|
||||
# Wrap up startup
|
||||
await hass.async_block_till_done()
|
||||
|
|
|
@ -7,14 +7,26 @@ Component design guidelines:
|
|||
format "<DOMAIN>.<OBJECT_ID>".
|
||||
- Each component should publish services only under its own domain.
|
||||
"""
|
||||
import asyncio
|
||||
import itertools as it
|
||||
import logging
|
||||
from typing import Awaitable
|
||||
|
||||
from homeassistant.core import split_entity_id
|
||||
|
||||
# mypy: allow-untyped-defs
|
||||
import homeassistant.core as ha
|
||||
import homeassistant.config as conf_util
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.service import extract_entity_ids
|
||||
from homeassistant.helpers import intent
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE,
|
||||
SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART,
|
||||
RESTART_EXIT_CODE)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SERVICE_RELOAD_CORE_CONFIG = 'reload_core_config'
|
||||
SERVICE_CHECK_CONFIG = 'check_config'
|
||||
|
||||
|
||||
def is_on(hass, entity_id=None):
|
||||
"""Load up the module to call the is_on method.
|
||||
|
@ -27,20 +39,173 @@ def is_on(hass, entity_id=None):
|
|||
entity_ids = hass.states.entity_ids()
|
||||
|
||||
for ent_id in entity_ids:
|
||||
domain = split_entity_id(ent_id)[0]
|
||||
domain = ha.split_entity_id(ent_id)[0]
|
||||
|
||||
try:
|
||||
component = getattr(hass.components, domain)
|
||||
|
||||
except ImportError:
|
||||
_LOGGER.error("Failed to call %s.is_on: component not found", domain)
|
||||
_LOGGER.error('Failed to call %s.is_on: component not found',
|
||||
domain)
|
||||
continue
|
||||
|
||||
if not hasattr(component, "is_on"):
|
||||
_LOGGER.warning("Integration %s has no is_on method.", domain)
|
||||
if not hasattr(component, 'is_on'):
|
||||
_LOGGER.warning("Component %s has no is_on method.", domain)
|
||||
continue
|
||||
|
||||
if component.is_on(ent_id):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def turn_on(hass, entity_id=None, **service_data):
|
||||
"""Turn specified entity on if possible."""
|
||||
if entity_id is not None:
|
||||
service_data[ATTR_ENTITY_ID] = entity_id
|
||||
|
||||
hass.services.call(ha.DOMAIN, SERVICE_TURN_ON, service_data)
|
||||
|
||||
|
||||
def turn_off(hass, entity_id=None, **service_data):
|
||||
"""Turn specified entity off."""
|
||||
if entity_id is not None:
|
||||
service_data[ATTR_ENTITY_ID] = entity_id
|
||||
|
||||
hass.services.call(ha.DOMAIN, SERVICE_TURN_OFF, service_data)
|
||||
|
||||
|
||||
def toggle(hass, entity_id=None, **service_data):
|
||||
"""Toggle specified entity."""
|
||||
if entity_id is not None:
|
||||
service_data[ATTR_ENTITY_ID] = entity_id
|
||||
|
||||
hass.services.call(ha.DOMAIN, SERVICE_TOGGLE, service_data)
|
||||
|
||||
|
||||
def stop(hass):
|
||||
"""Stop Home Assistant."""
|
||||
hass.services.call(ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP)
|
||||
|
||||
|
||||
def restart(hass):
|
||||
"""Stop Home Assistant."""
|
||||
hass.services.call(ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART)
|
||||
|
||||
|
||||
def check_config(hass):
|
||||
"""Check the config files."""
|
||||
hass.services.call(ha.DOMAIN, SERVICE_CHECK_CONFIG)
|
||||
|
||||
|
||||
def reload_core_config(hass):
|
||||
"""Reload the core config."""
|
||||
hass.services.call(ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_reload_core_config(hass):
|
||||
"""Reload the core config."""
|
||||
yield from hass.services.async_call(ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass: ha.HomeAssistant, config: dict) -> Awaitable[bool]:
|
||||
"""Set up general services related to Home Assistant."""
|
||||
@asyncio.coroutine
|
||||
def async_handle_turn_service(service):
|
||||
"""Handle calls to homeassistant.turn_on/off."""
|
||||
entity_ids = extract_entity_ids(hass, service)
|
||||
|
||||
# Generic turn on/off method requires entity id
|
||||
if not entity_ids:
|
||||
_LOGGER.error(
|
||||
"homeassistant/%s cannot be called without entity_id",
|
||||
service.service)
|
||||
return
|
||||
|
||||
# Group entity_ids by domain. groupby requires sorted data.
|
||||
by_domain = it.groupby(sorted(entity_ids),
|
||||
lambda item: ha.split_entity_id(item)[0])
|
||||
|
||||
tasks = []
|
||||
|
||||
for domain, ent_ids in by_domain:
|
||||
# We want to block for all calls and only return when all calls
|
||||
# have been processed. If a service does not exist it causes a 10
|
||||
# second delay while we're blocking waiting for a response.
|
||||
# But services can be registered on other HA instances that are
|
||||
# listening to the bus too. So as an in between solution, we'll
|
||||
# block only if the service is defined in the current HA instance.
|
||||
blocking = hass.services.has_service(domain, service.service)
|
||||
|
||||
# Create a new dict for this call
|
||||
data = dict(service.data)
|
||||
|
||||
# ent_ids is a generator, convert it to a list.
|
||||
data[ATTR_ENTITY_ID] = list(ent_ids)
|
||||
|
||||
tasks.append(hass.services.async_call(
|
||||
domain, service.service, data, blocking))
|
||||
|
||||
yield from asyncio.wait(tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
ha.DOMAIN, SERVICE_TURN_OFF, async_handle_turn_service)
|
||||
hass.services.async_register(
|
||||
ha.DOMAIN, SERVICE_TURN_ON, async_handle_turn_service)
|
||||
hass.services.async_register(
|
||||
ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service)
|
||||
hass.helpers.intent.async_register(intent.ServiceIntentHandler(
|
||||
intent.INTENT_TURN_ON, ha.DOMAIN, SERVICE_TURN_ON, "Turned {} on"))
|
||||
hass.helpers.intent.async_register(intent.ServiceIntentHandler(
|
||||
intent.INTENT_TURN_OFF, ha.DOMAIN, SERVICE_TURN_OFF,
|
||||
"Turned {} off"))
|
||||
hass.helpers.intent.async_register(intent.ServiceIntentHandler(
|
||||
intent.INTENT_TOGGLE, ha.DOMAIN, SERVICE_TOGGLE, "Toggled {}"))
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_handle_core_service(call):
|
||||
"""Service handler for handling core services."""
|
||||
if call.service == SERVICE_HOMEASSISTANT_STOP:
|
||||
hass.async_create_task(hass.async_stop())
|
||||
return
|
||||
|
||||
try:
|
||||
errors = yield from conf_util.async_check_ha_config_file(hass)
|
||||
except HomeAssistantError:
|
||||
return
|
||||
|
||||
if errors:
|
||||
_LOGGER.error(errors)
|
||||
hass.components.persistent_notification.async_create(
|
||||
"Config error. See dev-info panel for details.",
|
||||
"Config validating", "{0}.check_config".format(ha.DOMAIN))
|
||||
return
|
||||
|
||||
if call.service == SERVICE_HOMEASSISTANT_RESTART:
|
||||
hass.async_create_task(hass.async_stop(RESTART_EXIT_CODE))
|
||||
|
||||
hass.services.async_register(
|
||||
ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service)
|
||||
hass.services.async_register(
|
||||
ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART, async_handle_core_service)
|
||||
hass.services.async_register(
|
||||
ha.DOMAIN, SERVICE_CHECK_CONFIG, async_handle_core_service)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_handle_reload_config(call):
|
||||
"""Service handler for reloading core config."""
|
||||
try:
|
||||
conf = yield from conf_util.async_hass_config_yaml(hass)
|
||||
except HomeAssistantError as err:
|
||||
_LOGGER.error(err)
|
||||
return
|
||||
|
||||
yield from conf_util.async_process_ha_core_config(
|
||||
hass, conf.get(ha.DOMAIN) or {})
|
||||
|
||||
hass.services.async_register(
|
||||
ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG, async_handle_reload_config)
|
||||
|
||||
return True
|
||||
|
|
347
homeassistant/components/abode.py
Normal file
347
homeassistant/components/abode.py
Normal file
|
@ -0,0 +1,347 @@
|
|||
"""
|
||||
This component provides basic support for Abode Home Security system.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/abode/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from functools import partial
|
||||
from requests.exceptions import HTTPError, ConnectTimeout
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_ATTRIBUTION, ATTR_DATE, ATTR_TIME, ATTR_ENTITY_ID, CONF_USERNAME,
|
||||
CONF_PASSWORD, CONF_EXCLUDE, CONF_NAME, CONF_LIGHTS,
|
||||
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers import discovery
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
REQUIREMENTS = ['abodepy==0.13.1']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_ATTRIBUTION = "Data provided by goabode.com"
|
||||
CONF_POLLING = 'polling'
|
||||
|
||||
DOMAIN = 'abode'
|
||||
DEFAULT_CACHEDB = './abodepy_cache.pickle'
|
||||
|
||||
NOTIFICATION_ID = 'abode_notification'
|
||||
NOTIFICATION_TITLE = 'Abode Security Setup'
|
||||
|
||||
EVENT_ABODE_ALARM = 'abode_alarm'
|
||||
EVENT_ABODE_ALARM_END = 'abode_alarm_end'
|
||||
EVENT_ABODE_AUTOMATION = 'abode_automation'
|
||||
EVENT_ABODE_FAULT = 'abode_panel_fault'
|
||||
EVENT_ABODE_RESTORE = 'abode_panel_restore'
|
||||
|
||||
SERVICE_SETTINGS = 'change_setting'
|
||||
SERVICE_CAPTURE_IMAGE = 'capture_image'
|
||||
SERVICE_TRIGGER = 'trigger_quick_action'
|
||||
|
||||
ATTR_DEVICE_ID = 'device_id'
|
||||
ATTR_DEVICE_NAME = 'device_name'
|
||||
ATTR_DEVICE_TYPE = 'device_type'
|
||||
ATTR_EVENT_CODE = 'event_code'
|
||||
ATTR_EVENT_NAME = 'event_name'
|
||||
ATTR_EVENT_TYPE = 'event_type'
|
||||
ATTR_EVENT_UTC = 'event_utc'
|
||||
ATTR_SETTING = 'setting'
|
||||
ATTR_USER_NAME = 'user_name'
|
||||
ATTR_VALUE = 'value'
|
||||
|
||||
ABODE_DEVICE_ID_LIST_SCHEMA = vol.Schema([str])
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_POLLING, default=False): cv.boolean,
|
||||
vol.Optional(CONF_EXCLUDE, default=[]): ABODE_DEVICE_ID_LIST_SCHEMA,
|
||||
vol.Optional(CONF_LIGHTS, default=[]): ABODE_DEVICE_ID_LIST_SCHEMA
|
||||
}),
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
CHANGE_SETTING_SCHEMA = vol.Schema({
|
||||
vol.Required(ATTR_SETTING): cv.string,
|
||||
vol.Required(ATTR_VALUE): cv.string
|
||||
})
|
||||
|
||||
CAPTURE_IMAGE_SCHEMA = vol.Schema({
|
||||
ATTR_ENTITY_ID: cv.entity_ids,
|
||||
})
|
||||
|
||||
TRIGGER_SCHEMA = vol.Schema({
|
||||
ATTR_ENTITY_ID: cv.entity_ids,
|
||||
})
|
||||
|
||||
ABODE_PLATFORMS = [
|
||||
'alarm_control_panel', 'binary_sensor', 'lock', 'switch', 'cover',
|
||||
'camera', 'light', 'sensor'
|
||||
]
|
||||
|
||||
|
||||
class AbodeSystem:
|
||||
"""Abode System class."""
|
||||
|
||||
def __init__(self, username, password, cache,
|
||||
name, polling, exclude, lights):
|
||||
"""Initialize the system."""
|
||||
import abodepy
|
||||
self.abode = abodepy.Abode(
|
||||
username, password, auto_login=True, get_devices=True,
|
||||
get_automations=True, cache_path=cache)
|
||||
self.name = name
|
||||
self.polling = polling
|
||||
self.exclude = exclude
|
||||
self.lights = lights
|
||||
self.devices = []
|
||||
|
||||
def is_excluded(self, device):
|
||||
"""Check if a device is configured to be excluded."""
|
||||
return device.device_id in self.exclude
|
||||
|
||||
def is_automation_excluded(self, automation):
|
||||
"""Check if an automation is configured to be excluded."""
|
||||
return automation.automation_id in self.exclude
|
||||
|
||||
def is_light(self, device):
|
||||
"""Check if a switch device is configured as a light."""
|
||||
import abodepy.helpers.constants as CONST
|
||||
|
||||
return (device.generic_type == CONST.TYPE_LIGHT or
|
||||
(device.generic_type == CONST.TYPE_SWITCH and
|
||||
device.device_id in self.lights))
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""Set up Abode component."""
|
||||
from abodepy.exceptions import AbodeException
|
||||
|
||||
conf = config[DOMAIN]
|
||||
username = conf.get(CONF_USERNAME)
|
||||
password = conf.get(CONF_PASSWORD)
|
||||
name = conf.get(CONF_NAME)
|
||||
polling = conf.get(CONF_POLLING)
|
||||
exclude = conf.get(CONF_EXCLUDE)
|
||||
lights = conf.get(CONF_LIGHTS)
|
||||
|
||||
try:
|
||||
cache = hass.config.path(DEFAULT_CACHEDB)
|
||||
hass.data[DOMAIN] = AbodeSystem(
|
||||
username, password, cache, name, polling, exclude, lights)
|
||||
except (AbodeException, ConnectTimeout, HTTPError) as ex:
|
||||
_LOGGER.error("Unable to connect to Abode: %s", str(ex))
|
||||
|
||||
hass.components.persistent_notification.create(
|
||||
'Error: {}<br />'
|
||||
'You will need to restart hass after fixing.'
|
||||
''.format(ex),
|
||||
title=NOTIFICATION_TITLE,
|
||||
notification_id=NOTIFICATION_ID)
|
||||
return False
|
||||
|
||||
setup_hass_services(hass)
|
||||
setup_hass_events(hass)
|
||||
setup_abode_events(hass)
|
||||
|
||||
for platform in ABODE_PLATFORMS:
|
||||
discovery.load_platform(hass, platform, DOMAIN, {}, config)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def setup_hass_services(hass):
|
||||
"""Home assistant services."""
|
||||
from abodepy.exceptions import AbodeException
|
||||
|
||||
def change_setting(call):
|
||||
"""Change an Abode system setting."""
|
||||
setting = call.data.get(ATTR_SETTING)
|
||||
value = call.data.get(ATTR_VALUE)
|
||||
|
||||
try:
|
||||
hass.data[DOMAIN].abode.set_setting(setting, value)
|
||||
except AbodeException as ex:
|
||||
_LOGGER.warning(ex)
|
||||
|
||||
def capture_image(call):
|
||||
"""Capture a new image."""
|
||||
entity_ids = call.data.get(ATTR_ENTITY_ID)
|
||||
|
||||
target_devices = [device for device in hass.data[DOMAIN].devices
|
||||
if device.entity_id in entity_ids]
|
||||
|
||||
for device in target_devices:
|
||||
device.capture()
|
||||
|
||||
def trigger_quick_action(call):
|
||||
"""Trigger a quick action."""
|
||||
entity_ids = call.data.get(ATTR_ENTITY_ID, None)
|
||||
|
||||
target_devices = [device for device in hass.data[DOMAIN].devices
|
||||
if device.entity_id in entity_ids]
|
||||
|
||||
for device in target_devices:
|
||||
device.trigger()
|
||||
|
||||
hass.services.register(
|
||||
DOMAIN, SERVICE_SETTINGS, change_setting,
|
||||
schema=CHANGE_SETTING_SCHEMA)
|
||||
|
||||
hass.services.register(
|
||||
DOMAIN, SERVICE_CAPTURE_IMAGE, capture_image,
|
||||
schema=CAPTURE_IMAGE_SCHEMA)
|
||||
|
||||
hass.services.register(
|
||||
DOMAIN, SERVICE_TRIGGER, trigger_quick_action,
|
||||
schema=TRIGGER_SCHEMA)
|
||||
|
||||
|
||||
def setup_hass_events(hass):
|
||||
"""Home Assistant start and stop callbacks."""
|
||||
def startup(event):
|
||||
"""Listen for push events."""
|
||||
hass.data[DOMAIN].abode.events.start()
|
||||
|
||||
def logout(event):
|
||||
"""Logout of Abode."""
|
||||
if not hass.data[DOMAIN].polling:
|
||||
hass.data[DOMAIN].abode.events.stop()
|
||||
|
||||
hass.data[DOMAIN].abode.logout()
|
||||
_LOGGER.info("Logged out of Abode")
|
||||
|
||||
if not hass.data[DOMAIN].polling:
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, startup)
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, logout)
|
||||
|
||||
|
||||
def setup_abode_events(hass):
|
||||
"""Event callbacks."""
|
||||
import abodepy.helpers.timeline as TIMELINE
|
||||
|
||||
def event_callback(event, event_json):
|
||||
"""Handle an event callback from Abode."""
|
||||
data = {
|
||||
ATTR_DEVICE_ID: event_json.get(ATTR_DEVICE_ID, ''),
|
||||
ATTR_DEVICE_NAME: event_json.get(ATTR_DEVICE_NAME, ''),
|
||||
ATTR_DEVICE_TYPE: event_json.get(ATTR_DEVICE_TYPE, ''),
|
||||
ATTR_EVENT_CODE: event_json.get(ATTR_EVENT_CODE, ''),
|
||||
ATTR_EVENT_NAME: event_json.get(ATTR_EVENT_NAME, ''),
|
||||
ATTR_EVENT_TYPE: event_json.get(ATTR_EVENT_TYPE, ''),
|
||||
ATTR_EVENT_UTC: event_json.get(ATTR_EVENT_UTC, ''),
|
||||
ATTR_USER_NAME: event_json.get(ATTR_USER_NAME, ''),
|
||||
ATTR_DATE: event_json.get(ATTR_DATE, ''),
|
||||
ATTR_TIME: event_json.get(ATTR_TIME, ''),
|
||||
}
|
||||
|
||||
hass.bus.fire(event, data)
|
||||
|
||||
events = [TIMELINE.ALARM_GROUP, TIMELINE.ALARM_END_GROUP,
|
||||
TIMELINE.PANEL_FAULT_GROUP, TIMELINE.PANEL_RESTORE_GROUP,
|
||||
TIMELINE.AUTOMATION_GROUP]
|
||||
|
||||
for event in events:
|
||||
hass.data[DOMAIN].abode.events.add_event_callback(
|
||||
event,
|
||||
partial(event_callback, event))
|
||||
|
||||
|
||||
class AbodeDevice(Entity):
|
||||
"""Representation of an Abode device."""
|
||||
|
||||
def __init__(self, data, device):
|
||||
"""Initialize a sensor for Abode device."""
|
||||
self._data = data
|
||||
self._device = device
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_added_to_hass(self):
|
||||
"""Subscribe Abode events."""
|
||||
self.hass.async_add_job(
|
||||
self._data.abode.events.add_device_callback,
|
||||
self._device.device_id, self._update_callback
|
||||
)
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Return the polling state."""
|
||||
return self._data.polling
|
||||
|
||||
def update(self):
|
||||
"""Update automation state."""
|
||||
self._device.refresh()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return self._device.name
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return {
|
||||
ATTR_ATTRIBUTION: CONF_ATTRIBUTION,
|
||||
'device_id': self._device.device_id,
|
||||
'battery_low': self._device.battery_low,
|
||||
'no_response': self._device.no_response,
|
||||
'device_type': self._device.type
|
||||
}
|
||||
|
||||
def _update_callback(self, device):
|
||||
"""Update the device state."""
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
|
||||
class AbodeAutomation(Entity):
|
||||
"""Representation of an Abode automation."""
|
||||
|
||||
def __init__(self, data, automation, event=None):
|
||||
"""Initialize for Abode automation."""
|
||||
self._data = data
|
||||
self._automation = automation
|
||||
self._event = event
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_added_to_hass(self):
|
||||
"""Subscribe Abode events."""
|
||||
if self._event:
|
||||
self.hass.async_add_job(
|
||||
self._data.abode.events.add_event_callback,
|
||||
self._event, self._update_callback
|
||||
)
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Return the polling state."""
|
||||
return self._data.polling
|
||||
|
||||
def update(self):
|
||||
"""Update automation state."""
|
||||
self._automation.refresh()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return self._automation.name
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return {
|
||||
ATTR_ATTRIBUTION: CONF_ATTRIBUTION,
|
||||
'automation_id': self._automation.automation_id,
|
||||
'type': self._automation.type,
|
||||
'sub_type': self._automation.sub_type
|
||||
}
|
||||
|
||||
def _update_callback(self, device):
|
||||
"""Update the device state."""
|
||||
self._automation.refresh()
|
||||
self.schedule_update_ha_state()
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "\u0420\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0430 Abode."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 Abode.",
|
||||
"identifier_exists": "\u041f\u0440\u043e\u0444\u0438\u043b\u044a\u0442 \u0435 \u0432\u0435\u0447\u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043d.",
|
||||
"invalid_credentials": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u0438 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u043e\u043d\u043d\u0438 \u0434\u0430\u043d\u043d\u0438."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "\u041f\u0430\u0440\u043e\u043b\u0430",
|
||||
"username": "E-mail \u0430\u0434\u0440\u0435\u0441"
|
||||
},
|
||||
"title": "\u041f\u043e\u043f\u044a\u043b\u043d\u0435\u0442\u0435 \u0412\u0430\u0448\u0430\u0442\u0430 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f \u0437\u0430 \u0432\u0445\u043e\u0434 \u0432 Abode"
|
||||
}
|
||||
},
|
||||
"title": "Abode"
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Nom\u00e9s es permet una \u00fanica configuraci\u00f3 d'Abode."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "No es pot connectar amb Abode.",
|
||||
"identifier_exists": "Compte ja registrat.",
|
||||
"invalid_credentials": "Credencials inv\u00e0lides."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Contrasenya",
|
||||
"username": "Correu electr\u00f2nic"
|
||||
},
|
||||
"title": "Introdueix la teva informaci\u00f3 d'inici de sessi\u00f3 a Abode."
|
||||
}
|
||||
},
|
||||
"title": "Abode"
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Je povolena pouze jedna konfigurace Abode."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Nelze se p\u0159ipojit k Abode.",
|
||||
"identifier_exists": "\u00da\u010det je ji\u017e zaregistrov\u00e1n.",
|
||||
"invalid_credentials": "Neplatn\u00e9 p\u0159ihla\u0161ovac\u00ed \u00fadaje."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Heslo",
|
||||
"username": "E-mailov\u00e1 adresa"
|
||||
},
|
||||
"title": "Vypl\u0148te p\u0159ihla\u0161ovac\u00ed \u00fadaje Abode"
|
||||
}
|
||||
},
|
||||
"title": "Abode"
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning af Abode."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Kunne ikke oprette forbindelse til Abode.",
|
||||
"identifier_exists": "Konto er allerede registreret.",
|
||||
"invalid_credentials": "Ugyldige legitimationsoplysninger."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Adgangskode",
|
||||
"username": "Email adresse"
|
||||
},
|
||||
"title": "Udfyld dine Abode-loginoplysninger"
|
||||
}
|
||||
},
|
||||
"title": "Abode"
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Es ist nur eine einzige Konfiguration von Abode erlaubt."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Es kann keine Verbindung zu Abode hergestellt werden.",
|
||||
"identifier_exists": "Das Konto ist bereits registriert.",
|
||||
"invalid_credentials": "Ung\u00fcltige Anmeldeinformationen"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Passwort",
|
||||
"username": "E-Mail-Adresse"
|
||||
},
|
||||
"title": "Gib deine Abode-Anmeldeinformationen ein"
|
||||
}
|
||||
},
|
||||
"title": "Abode"
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Only a single configuration of Abode is allowed."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Unable to connect to Abode.",
|
||||
"identifier_exists": "Account already registered.",
|
||||
"invalid_credentials": "Invalid credentials."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Password",
|
||||
"username": "Email Address"
|
||||
},
|
||||
"title": "Fill in your Abode login information"
|
||||
}
|
||||
},
|
||||
"title": "Abode"
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Solo se permite una \u00fanica configuraci\u00f3n de Abode."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "No se puede conectar a Abode.",
|
||||
"identifier_exists": "Cuenta ya registrada.",
|
||||
"invalid_credentials": "Credenciales inv\u00e1lidas."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Contrase\u00f1a",
|
||||
"username": "Direcci\u00f3n de correo electr\u00f3nico"
|
||||
},
|
||||
"title": "Rellene la informaci\u00f3n de acceso Abode"
|
||||
}
|
||||
},
|
||||
"title": "Abode"
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Une seule configuration d'Abode est autoris\u00e9e."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Impossible de se connecter \u00e0 Abode.",
|
||||
"identifier_exists": "Compte d\u00e9j\u00e0 enregistr\u00e9.",
|
||||
"invalid_credentials": "Informations d'identification invalides."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Mot de passe",
|
||||
"username": "Adresse e-mail"
|
||||
},
|
||||
"title": "Remplissez vos informations de connexion Abode"
|
||||
}
|
||||
},
|
||||
"title": "Abode"
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "\u00c8 consentita una sola configurazione di Abode."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Impossibile connettersi ad Abode.",
|
||||
"identifier_exists": "Account gi\u00e0 registrato",
|
||||
"invalid_credentials": "Credenziali non valide"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Password",
|
||||
"username": "Indirizzo email"
|
||||
},
|
||||
"title": "Inserisci le tue informazioni di accesso Abode"
|
||||
}
|
||||
},
|
||||
"title": "Abode"
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "\ud558\ub098\uc758 Abode \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Abode \uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.",
|
||||
"identifier_exists": "\uacc4\uc815\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
|
||||
"invalid_credentials": "\uc0ac\uc6a9\uc790 \uc774\ub984 \ud639\uc740 \ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "\ube44\ubc00\ubc88\ud638",
|
||||
"username": "\uc774\uba54\uc77c \uc8fc\uc18c"
|
||||
},
|
||||
"title": "Abode \uc0ac\uc6a9\uc790 \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694"
|
||||
}
|
||||
},
|
||||
"title": "Abode"
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun ZHA ass erlaabt."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Kann sech net mat Abode verbannen.",
|
||||
"identifier_exists": "Konto ass scho registr\u00e9iert",
|
||||
"invalid_credentials": "Ong\u00eblteg Login Informatioune"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Passwuert",
|
||||
"username": "E-Mail Adress"
|
||||
},
|
||||
"title": "F\u00ebllt \u00e4r Abode Login Informatiounen aus."
|
||||
}
|
||||
},
|
||||
"title": "Abode"
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Slechts een enkele configuratie van Abode is toegestaan."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Kan geen verbinding maken met Abode.",
|
||||
"identifier_exists": "Account is al geregistreerd.",
|
||||
"invalid_credentials": "Ongeldige inloggegevens."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Wachtwoord",
|
||||
"username": "E-mailadres"
|
||||
},
|
||||
"title": "Vul uw Abode-inloggegevens in"
|
||||
}
|
||||
},
|
||||
"title": "Abode"
|
||||
}
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"title": "Abode"
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Bare en enkelt konfigurasjon av Abode er tillatt."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Kan ikke koble til Abode.",
|
||||
"identifier_exists": "Kontoen er allerede registrert.",
|
||||
"invalid_credentials": "Ugyldig brukerinformasjon"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Passord",
|
||||
"username": "E-postadresse"
|
||||
},
|
||||
"title": "Fyll ut innloggingsinformasjonen for Abode"
|
||||
}
|
||||
},
|
||||
"title": "Abode"
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Dozwolona jest tylko jedna konfiguracja Abode."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z Abode.",
|
||||
"identifier_exists": "Konto zosta\u0142o ju\u017c zarejestrowane",
|
||||
"invalid_credentials": "Nieprawid\u0142owe dane uwierzytelniaj\u0105ce"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Has\u0142o",
|
||||
"username": "Adres e-mail"
|
||||
},
|
||||
"title": "Wprowad\u017a informacje logowania Abode"
|
||||
}
|
||||
},
|
||||
"title": "Abode"
|
||||
}
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Somente uma \u00fanica configura\u00e7\u00e3o de Abode \u00e9 permitida."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "N\u00e3o foi poss\u00edvel conectar ao Abode.",
|
||||
"identifier_exists": "Conta j\u00e1 cadastrada.",
|
||||
"invalid_credentials": "Credenciais inv\u00e1lidas."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Senha",
|
||||
"username": "Endere\u00e7o de e-mail"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": ""
|
||||
}
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"error": {
|
||||
"identifier_exists": "Conta j\u00e1 registada"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Palavra-passe",
|
||||
"username": "Endere\u00e7o de e-mail"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": ""
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a Abode.",
|
||||
"identifier_exists": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0430.",
|
||||
"invalid_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "\u041f\u0430\u0440\u043e\u043b\u044c",
|
||||
"username": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b"
|
||||
},
|
||||
"title": "Abode"
|
||||
}
|
||||
},
|
||||
"title": "Abode"
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Dovoljena je samo ena konfiguracija Abode."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Ni mogo\u010de vzpostaviti povezave z Abode.",
|
||||
"identifier_exists": "Ra\u010dun je \u017ee registriran.",
|
||||
"invalid_credentials": "Neveljavne poverilnice."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Geslo",
|
||||
"username": "E-po\u0161tni naslov"
|
||||
},
|
||||
"title": "Izpolnite svoje podatke za prijavo v Abode"
|
||||
}
|
||||
},
|
||||
"title": "Abode"
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "\u50c5\u5141\u8a31\u8a2d\u5b9a\u4e00\u7d44 Abode\u3002"
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "\u7121\u6cd5\u9023\u7dda\u81f3 Abode\u3002",
|
||||
"identifier_exists": "\u5e33\u865f\u5df2\u8a3b\u518a\u3002",
|
||||
"invalid_credentials": "\u6191\u8b49\u7121\u6548\u3002"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "\u5bc6\u78bc",
|
||||
"username": "\u96fb\u5b50\u90f5\u4ef6\u5730\u5740"
|
||||
},
|
||||
"title": "\u586b\u5beb Abode \u767b\u5165\u8cc7\u8a0a"
|
||||
}
|
||||
},
|
||||
"title": "Abode"
|
||||
}
|
||||
}
|
|
@ -1,401 +0,0 @@
|
|||
"""Support for the Abode Security System."""
|
||||
from asyncio import gather
|
||||
from copy import deepcopy
|
||||
from functools import partial
|
||||
import logging
|
||||
|
||||
from abodepy import Abode
|
||||
from abodepy.exceptions import AbodeException
|
||||
import abodepy.helpers.timeline as TIMELINE
|
||||
from requests.exceptions import ConnectTimeout, HTTPError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import (
|
||||
ATTR_ATTRIBUTION,
|
||||
ATTR_DATE,
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_TIME,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.dispatcher import dispatcher_send
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import (
|
||||
ATTRIBUTION,
|
||||
DEFAULT_CACHEDB,
|
||||
DOMAIN,
|
||||
SIGNAL_CAPTURE_IMAGE,
|
||||
SIGNAL_TRIGGER_QUICK_ACTION,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_POLLING = "polling"
|
||||
|
||||
SERVICE_SETTINGS = "change_setting"
|
||||
SERVICE_CAPTURE_IMAGE = "capture_image"
|
||||
SERVICE_TRIGGER = "trigger_quick_action"
|
||||
|
||||
ATTR_DEVICE_ID = "device_id"
|
||||
ATTR_DEVICE_NAME = "device_name"
|
||||
ATTR_DEVICE_TYPE = "device_type"
|
||||
ATTR_EVENT_CODE = "event_code"
|
||||
ATTR_EVENT_NAME = "event_name"
|
||||
ATTR_EVENT_TYPE = "event_type"
|
||||
ATTR_EVENT_UTC = "event_utc"
|
||||
ATTR_SETTING = "setting"
|
||||
ATTR_USER_NAME = "user_name"
|
||||
ATTR_APP_TYPE = "app_type"
|
||||
ATTR_EVENT_BY = "event_by"
|
||||
ATTR_VALUE = "value"
|
||||
|
||||
ABODE_DEVICE_ID_LIST_SCHEMA = vol.Schema([str])
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_POLLING, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
CHANGE_SETTING_SCHEMA = vol.Schema(
|
||||
{vol.Required(ATTR_SETTING): cv.string, vol.Required(ATTR_VALUE): cv.string}
|
||||
)
|
||||
|
||||
CAPTURE_IMAGE_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
|
||||
|
||||
TRIGGER_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
|
||||
|
||||
ABODE_PLATFORMS = [
|
||||
"alarm_control_panel",
|
||||
"binary_sensor",
|
||||
"lock",
|
||||
"switch",
|
||||
"cover",
|
||||
"camera",
|
||||
"light",
|
||||
"sensor",
|
||||
]
|
||||
|
||||
|
||||
class AbodeSystem:
|
||||
"""Abode System class."""
|
||||
|
||||
def __init__(self, abode, polling):
|
||||
"""Initialize the system."""
|
||||
|
||||
self.abode = abode
|
||||
self.polling = polling
|
||||
self.entity_ids = set()
|
||||
self.logout_listener = None
|
||||
|
||||
|
||||
async def async_setup(hass, config):
|
||||
"""Set up Abode integration."""
|
||||
if DOMAIN not in config:
|
||||
return True
|
||||
|
||||
conf = config[DOMAIN]
|
||||
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=deepcopy(conf)
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry):
|
||||
"""Set up Abode integration from a config entry."""
|
||||
username = config_entry.data.get(CONF_USERNAME)
|
||||
password = config_entry.data.get(CONF_PASSWORD)
|
||||
polling = config_entry.data.get(CONF_POLLING)
|
||||
|
||||
try:
|
||||
cache = hass.config.path(DEFAULT_CACHEDB)
|
||||
abode = await hass.async_add_executor_job(
|
||||
Abode, username, password, True, True, True, cache
|
||||
)
|
||||
hass.data[DOMAIN] = AbodeSystem(abode, polling)
|
||||
|
||||
except (AbodeException, ConnectTimeout, HTTPError) as ex:
|
||||
_LOGGER.error("Unable to connect to Abode: %s", str(ex))
|
||||
return False
|
||||
|
||||
for platform in ABODE_PLATFORMS:
|
||||
hass.async_create_task(
|
||||
hass.config_entries.async_forward_entry_setup(config_entry, platform)
|
||||
)
|
||||
|
||||
await setup_hass_events(hass)
|
||||
await hass.async_add_executor_job(setup_hass_services, hass)
|
||||
await hass.async_add_executor_job(setup_abode_events, hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass, config_entry):
|
||||
"""Unload a config entry."""
|
||||
hass.services.async_remove(DOMAIN, SERVICE_SETTINGS)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_CAPTURE_IMAGE)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_TRIGGER)
|
||||
|
||||
tasks = []
|
||||
|
||||
for platform in ABODE_PLATFORMS:
|
||||
tasks.append(
|
||||
hass.config_entries.async_forward_entry_unload(config_entry, platform)
|
||||
)
|
||||
|
||||
await gather(*tasks)
|
||||
|
||||
await hass.async_add_executor_job(hass.data[DOMAIN].abode.events.stop)
|
||||
await hass.async_add_executor_job(hass.data[DOMAIN].abode.logout)
|
||||
|
||||
hass.data[DOMAIN].logout_listener()
|
||||
hass.data.pop(DOMAIN)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def setup_hass_services(hass):
|
||||
"""Home assistant services."""
|
||||
|
||||
def change_setting(call):
|
||||
"""Change an Abode system setting."""
|
||||
setting = call.data.get(ATTR_SETTING)
|
||||
value = call.data.get(ATTR_VALUE)
|
||||
|
||||
try:
|
||||
hass.data[DOMAIN].abode.set_setting(setting, value)
|
||||
except AbodeException as ex:
|
||||
_LOGGER.warning(ex)
|
||||
|
||||
def capture_image(call):
|
||||
"""Capture a new image."""
|
||||
entity_ids = call.data.get(ATTR_ENTITY_ID)
|
||||
|
||||
target_entities = [
|
||||
entity_id
|
||||
for entity_id in hass.data[DOMAIN].entity_ids
|
||||
if entity_id in entity_ids
|
||||
]
|
||||
|
||||
for entity_id in target_entities:
|
||||
signal = SIGNAL_CAPTURE_IMAGE.format(entity_id)
|
||||
dispatcher_send(hass, signal)
|
||||
|
||||
def trigger_quick_action(call):
|
||||
"""Trigger a quick action."""
|
||||
entity_ids = call.data.get(ATTR_ENTITY_ID, None)
|
||||
|
||||
target_entities = [
|
||||
entity_id
|
||||
for entity_id in hass.data[DOMAIN].entity_ids
|
||||
if entity_id in entity_ids
|
||||
]
|
||||
|
||||
for entity_id in target_entities:
|
||||
signal = SIGNAL_TRIGGER_QUICK_ACTION.format(entity_id)
|
||||
dispatcher_send(hass, signal)
|
||||
|
||||
hass.services.register(
|
||||
DOMAIN, SERVICE_SETTINGS, change_setting, schema=CHANGE_SETTING_SCHEMA
|
||||
)
|
||||
|
||||
hass.services.register(
|
||||
DOMAIN, SERVICE_CAPTURE_IMAGE, capture_image, schema=CAPTURE_IMAGE_SCHEMA
|
||||
)
|
||||
|
||||
hass.services.register(
|
||||
DOMAIN, SERVICE_TRIGGER, trigger_quick_action, schema=TRIGGER_SCHEMA
|
||||
)
|
||||
|
||||
|
||||
async def setup_hass_events(hass):
|
||||
"""Home Assistant start and stop callbacks."""
|
||||
|
||||
def logout(event):
|
||||
"""Logout of Abode."""
|
||||
if not hass.data[DOMAIN].polling:
|
||||
hass.data[DOMAIN].abode.events.stop()
|
||||
|
||||
hass.data[DOMAIN].abode.logout()
|
||||
_LOGGER.info("Logged out of Abode")
|
||||
|
||||
if not hass.data[DOMAIN].polling:
|
||||
await hass.async_add_executor_job(hass.data[DOMAIN].abode.events.start)
|
||||
|
||||
hass.data[DOMAIN].logout_listener = hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP, logout
|
||||
)
|
||||
|
||||
|
||||
def setup_abode_events(hass):
|
||||
"""Event callbacks."""
|
||||
|
||||
def event_callback(event, event_json):
|
||||
"""Handle an event callback from Abode."""
|
||||
data = {
|
||||
ATTR_DEVICE_ID: event_json.get(ATTR_DEVICE_ID, ""),
|
||||
ATTR_DEVICE_NAME: event_json.get(ATTR_DEVICE_NAME, ""),
|
||||
ATTR_DEVICE_TYPE: event_json.get(ATTR_DEVICE_TYPE, ""),
|
||||
ATTR_EVENT_CODE: event_json.get(ATTR_EVENT_CODE, ""),
|
||||
ATTR_EVENT_NAME: event_json.get(ATTR_EVENT_NAME, ""),
|
||||
ATTR_EVENT_TYPE: event_json.get(ATTR_EVENT_TYPE, ""),
|
||||
ATTR_EVENT_UTC: event_json.get(ATTR_EVENT_UTC, ""),
|
||||
ATTR_USER_NAME: event_json.get(ATTR_USER_NAME, ""),
|
||||
ATTR_APP_TYPE: event_json.get(ATTR_APP_TYPE, ""),
|
||||
ATTR_EVENT_BY: event_json.get(ATTR_EVENT_BY, ""),
|
||||
ATTR_DATE: event_json.get(ATTR_DATE, ""),
|
||||
ATTR_TIME: event_json.get(ATTR_TIME, ""),
|
||||
}
|
||||
|
||||
hass.bus.fire(event, data)
|
||||
|
||||
events = [
|
||||
TIMELINE.ALARM_GROUP,
|
||||
TIMELINE.ALARM_END_GROUP,
|
||||
TIMELINE.PANEL_FAULT_GROUP,
|
||||
TIMELINE.PANEL_RESTORE_GROUP,
|
||||
TIMELINE.AUTOMATION_GROUP,
|
||||
TIMELINE.DISARM_GROUP,
|
||||
TIMELINE.ARM_GROUP,
|
||||
TIMELINE.TEST_GROUP,
|
||||
TIMELINE.CAPTURE_GROUP,
|
||||
TIMELINE.DEVICE_GROUP,
|
||||
TIMELINE.AUTOMATION_EDIT_GROUP,
|
||||
]
|
||||
|
||||
for event in events:
|
||||
hass.data[DOMAIN].abode.events.add_event_callback(
|
||||
event, partial(event_callback, event)
|
||||
)
|
||||
|
||||
|
||||
class AbodeDevice(Entity):
|
||||
"""Representation of an Abode device."""
|
||||
|
||||
def __init__(self, data, device):
|
||||
"""Initialize Abode device."""
|
||||
self._data = data
|
||||
self._device = device
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Subscribe to device events."""
|
||||
self.hass.async_add_job(
|
||||
self._data.abode.events.add_device_callback,
|
||||
self._device.device_id,
|
||||
self._update_callback,
|
||||
)
|
||||
self.hass.data[DOMAIN].entity_ids.add(self.entity_id)
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
"""Unsubscribe from device events."""
|
||||
self.hass.async_add_job(
|
||||
self._data.abode.events.remove_all_device_callbacks, self._device.device_id
|
||||
)
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Return the polling state."""
|
||||
return self._data.polling
|
||||
|
||||
def update(self):
|
||||
"""Update device and automation states."""
|
||||
self._device.refresh()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return self._device.name
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return {
|
||||
ATTR_ATTRIBUTION: ATTRIBUTION,
|
||||
"device_id": self._device.device_id,
|
||||
"battery_low": self._device.battery_low,
|
||||
"no_response": self._device.no_response,
|
||||
"device_type": self._device.type,
|
||||
}
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return a unique ID to use for this device."""
|
||||
return self._device.device_uuid
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Return device registry information for this entity."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self._device.device_id)},
|
||||
"manufacturer": "Abode",
|
||||
"name": self._device.name,
|
||||
"device_type": self._device.type,
|
||||
}
|
||||
|
||||
def _update_callback(self, device):
|
||||
"""Update the device state."""
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
|
||||
class AbodeAutomation(Entity):
|
||||
"""Representation of an Abode automation."""
|
||||
|
||||
def __init__(self, data, automation, event=None):
|
||||
"""Initialize for Abode automation."""
|
||||
self._data = data
|
||||
self._automation = automation
|
||||
self._event = event
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Subscribe to a group of Abode timeline events."""
|
||||
if self._event:
|
||||
self.hass.async_add_job(
|
||||
self._data.abode.events.add_event_callback,
|
||||
self._event,
|
||||
self._update_callback,
|
||||
)
|
||||
self.hass.data[DOMAIN].entity_ids.add(self.entity_id)
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Return the polling state."""
|
||||
return self._data.polling
|
||||
|
||||
def update(self):
|
||||
"""Update automation state."""
|
||||
self._automation.refresh()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the automation."""
|
||||
return self._automation.name
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return {
|
||||
ATTR_ATTRIBUTION: ATTRIBUTION,
|
||||
"automation_id": self._automation.automation_id,
|
||||
"type": self._automation.type,
|
||||
"sub_type": self._automation.sub_type,
|
||||
}
|
||||
|
||||
def _update_callback(self, device):
|
||||
"""Update the automation state."""
|
||||
self._automation.refresh()
|
||||
self.schedule_update_ha_state()
|
|
@ -1,83 +0,0 @@
|
|||
"""Support for Abode Security System alarm control panels."""
|
||||
import logging
|
||||
|
||||
import homeassistant.components.alarm_control_panel as alarm
|
||||
from homeassistant.components.alarm_control_panel.const import (
|
||||
SUPPORT_ALARM_ARM_AWAY,
|
||||
SUPPORT_ALARM_ARM_HOME,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_ATTRIBUTION,
|
||||
STATE_ALARM_ARMED_AWAY,
|
||||
STATE_ALARM_ARMED_HOME,
|
||||
STATE_ALARM_DISARMED,
|
||||
)
|
||||
|
||||
from . import AbodeDevice
|
||||
from .const import ATTRIBUTION, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ICON = "mdi:security"
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
"""Platform uses config entry setup."""
|
||||
pass
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up Abode alarm control panel device."""
|
||||
data = hass.data[DOMAIN]
|
||||
async_add_entities(
|
||||
[AbodeAlarm(data, await hass.async_add_executor_job(data.abode.get_alarm))]
|
||||
)
|
||||
|
||||
|
||||
class AbodeAlarm(AbodeDevice, alarm.AlarmControlPanel):
|
||||
"""An alarm_control_panel implementation for Abode."""
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon."""
|
||||
return ICON
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the device."""
|
||||
if self._device.is_standby:
|
||||
state = STATE_ALARM_DISARMED
|
||||
elif self._device.is_away:
|
||||
state = STATE_ALARM_ARMED_AWAY
|
||||
elif self._device.is_home:
|
||||
state = STATE_ALARM_ARMED_HOME
|
||||
else:
|
||||
state = None
|
||||
return state
|
||||
|
||||
@property
|
||||
def supported_features(self) -> int:
|
||||
"""Return the list of supported features."""
|
||||
return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY
|
||||
|
||||
def alarm_disarm(self, code=None):
|
||||
"""Send disarm command."""
|
||||
self._device.set_standby()
|
||||
|
||||
def alarm_arm_home(self, code=None):
|
||||
"""Send arm home command."""
|
||||
self._device.set_home()
|
||||
|
||||
def alarm_arm_away(self, code=None):
|
||||
"""Send arm away command."""
|
||||
self._device.set_away()
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return {
|
||||
ATTR_ATTRIBUTION: ATTRIBUTION,
|
||||
"device_id": self._device.device_id,
|
||||
"battery_backup": self._device.battery,
|
||||
"cellular_backup": self._device.is_cellular,
|
||||
}
|
|
@ -1,78 +0,0 @@
|
|||
"""Support for Abode Security System binary sensors."""
|
||||
import logging
|
||||
|
||||
import abodepy.helpers.constants as CONST
|
||||
import abodepy.helpers.timeline as TIMELINE
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
from . import AbodeAutomation, AbodeDevice
|
||||
from .const import DOMAIN, SIGNAL_TRIGGER_QUICK_ACTION
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
"""Platform uses config entry setup."""
|
||||
pass
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up Abode binary sensor devices."""
|
||||
data = hass.data[DOMAIN]
|
||||
|
||||
device_types = [
|
||||
CONST.TYPE_CONNECTIVITY,
|
||||
CONST.TYPE_MOISTURE,
|
||||
CONST.TYPE_MOTION,
|
||||
CONST.TYPE_OCCUPANCY,
|
||||
CONST.TYPE_OPENING,
|
||||
]
|
||||
|
||||
entities = []
|
||||
|
||||
for device in data.abode.get_devices(generic_type=device_types):
|
||||
entities.append(AbodeBinarySensor(data, device))
|
||||
|
||||
for automation in data.abode.get_automations(generic_type=CONST.TYPE_QUICK_ACTION):
|
||||
entities.append(
|
||||
AbodeQuickActionBinarySensor(
|
||||
data, automation, TIMELINE.AUTOMATION_EDIT_GROUP
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class AbodeBinarySensor(AbodeDevice, BinarySensorDevice):
|
||||
"""A binary sensor implementation for Abode device."""
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return True if the binary sensor is on."""
|
||||
return self._device.is_on
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of the binary sensor."""
|
||||
return self._device.generic_type
|
||||
|
||||
|
||||
class AbodeQuickActionBinarySensor(AbodeAutomation, BinarySensorDevice):
|
||||
"""A binary sensor implementation for Abode quick action automations."""
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Subscribe Abode events."""
|
||||
await super().async_added_to_hass()
|
||||
signal = SIGNAL_TRIGGER_QUICK_ACTION.format(self.entity_id)
|
||||
async_dispatcher_connect(self.hass, signal, self.trigger)
|
||||
|
||||
def trigger(self):
|
||||
"""Trigger a quick automation."""
|
||||
self._automation.trigger()
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return True if the binary sensor is on."""
|
||||
return self._automation.is_active
|
|
@ -1,97 +0,0 @@
|
|||
"""Support for Abode Security System cameras."""
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
import abodepy.helpers.constants as CONST
|
||||
import abodepy.helpers.timeline as TIMELINE
|
||||
import requests
|
||||
|
||||
from homeassistant.components.camera import Camera
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from . import AbodeDevice
|
||||
from .const import DOMAIN, SIGNAL_CAPTURE_IMAGE
|
||||
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
"""Platform uses config entry setup."""
|
||||
pass
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up Abode camera devices."""
|
||||
data = hass.data[DOMAIN]
|
||||
|
||||
entities = []
|
||||
|
||||
for device in data.abode.get_devices(generic_type=CONST.TYPE_CAMERA):
|
||||
entities.append(AbodeCamera(data, device, TIMELINE.CAPTURE_IMAGE))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class AbodeCamera(AbodeDevice, Camera):
|
||||
"""Representation of an Abode camera."""
|
||||
|
||||
def __init__(self, data, device, event):
|
||||
"""Initialize the Abode device."""
|
||||
AbodeDevice.__init__(self, data, device)
|
||||
Camera.__init__(self)
|
||||
self._event = event
|
||||
self._response = None
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Subscribe Abode events."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
self.hass.async_add_job(
|
||||
self._data.abode.events.add_timeline_callback,
|
||||
self._event,
|
||||
self._capture_callback,
|
||||
)
|
||||
|
||||
signal = SIGNAL_CAPTURE_IMAGE.format(self.entity_id)
|
||||
async_dispatcher_connect(self.hass, signal, self.capture)
|
||||
|
||||
def capture(self):
|
||||
"""Request a new image capture."""
|
||||
return self._device.capture()
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||
def refresh_image(self):
|
||||
"""Find a new image on the timeline."""
|
||||
if self._device.refresh_image():
|
||||
self.get_image()
|
||||
|
||||
def get_image(self):
|
||||
"""Attempt to download the most recent capture."""
|
||||
if self._device.image_url:
|
||||
try:
|
||||
self._response = requests.get(self._device.image_url, stream=True)
|
||||
|
||||
self._response.raise_for_status()
|
||||
except requests.HTTPError as err:
|
||||
_LOGGER.warning("Failed to get camera image: %s", err)
|
||||
self._response = None
|
||||
else:
|
||||
self._response = None
|
||||
|
||||
def camera_image(self):
|
||||
"""Get a camera image."""
|
||||
self.refresh_image()
|
||||
|
||||
if self._response:
|
||||
return self._response.content
|
||||
|
||||
return None
|
||||
|
||||
def _capture_callback(self, capture):
|
||||
"""Update the image with the device then refresh device."""
|
||||
self._device.update_image_location(capture)
|
||||
self.get_image()
|
||||
self.schedule_update_ha_state()
|
|
@ -1,82 +0,0 @@
|
|||
"""Config flow for the Abode Security System component."""
|
||||
import logging
|
||||
|
||||
from abodepy import Abode
|
||||
from abodepy.exceptions import AbodeException
|
||||
from requests.exceptions import ConnectTimeout, HTTPError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
|
||||
from .const import DEFAULT_CACHEDB, DOMAIN # pylint: disable=unused-import
|
||||
|
||||
CONF_POLLING = "polling"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AbodeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for Abode."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize."""
|
||||
self.data_schema = {
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle a flow initialized by the user."""
|
||||
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
|
||||
if not user_input:
|
||||
return self._show_form()
|
||||
|
||||
username = user_input[CONF_USERNAME]
|
||||
password = user_input[CONF_PASSWORD]
|
||||
polling = user_input.get(CONF_POLLING, False)
|
||||
cache = self.hass.config.path(DEFAULT_CACHEDB)
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
Abode, username, password, True, True, True, cache
|
||||
)
|
||||
|
||||
except (AbodeException, ConnectTimeout, HTTPError) as ex:
|
||||
_LOGGER.error("Unable to connect to Abode: %s", str(ex))
|
||||
if ex.errcode == 400:
|
||||
return self._show_form({"base": "invalid_credentials"})
|
||||
return self._show_form({"base": "connection_error"})
|
||||
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_USERNAME],
|
||||
data={
|
||||
CONF_USERNAME: username,
|
||||
CONF_PASSWORD: password,
|
||||
CONF_POLLING: polling,
|
||||
},
|
||||
)
|
||||
|
||||
@callback
|
||||
def _show_form(self, errors=None):
|
||||
"""Show the form to the user."""
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(self.data_schema),
|
||||
errors=errors if errors else {},
|
||||
)
|
||||
|
||||
async def async_step_import(self, import_config):
|
||||
"""Import a config entry from configuration.yaml."""
|
||||
if self._async_current_entries():
|
||||
_LOGGER.warning("Only one configuration of abode is allowed.")
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
|
||||
return await self.async_step_user(import_config)
|
|
@ -1,8 +0,0 @@
|
|||
"""Constants for the Abode Security System component."""
|
||||
DOMAIN = "abode"
|
||||
ATTRIBUTION = "Data provided by goabode.com"
|
||||
|
||||
DEFAULT_CACHEDB = "abodepy_cache.pickle"
|
||||
|
||||
SIGNAL_CAPTURE_IMAGE = "abode_camera_capture_{}"
|
||||
SIGNAL_TRIGGER_QUICK_ACTION = "abode_trigger_quick_action_{}"
|
|
@ -1,45 +0,0 @@
|
|||
"""Support for Abode Security System covers."""
|
||||
import logging
|
||||
|
||||
import abodepy.helpers.constants as CONST
|
||||
|
||||
from homeassistant.components.cover import CoverDevice
|
||||
|
||||
from . import AbodeDevice
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
"""Platform uses config entry setup."""
|
||||
pass
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up Abode cover devices."""
|
||||
data = hass.data[DOMAIN]
|
||||
|
||||
entities = []
|
||||
|
||||
for device in data.abode.get_devices(generic_type=CONST.TYPE_COVER):
|
||||
entities.append(AbodeCover(data, device))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class AbodeCover(AbodeDevice, CoverDevice):
|
||||
"""Representation of an Abode cover."""
|
||||
|
||||
@property
|
||||
def is_closed(self):
|
||||
"""Return true if cover is closed, else False."""
|
||||
return not self._device.is_open
|
||||
|
||||
def close_cover(self, **kwargs):
|
||||
"""Issue close command to cover."""
|
||||
self._device.close_cover()
|
||||
|
||||
def open_cover(self, **kwargs):
|
||||
"""Issue open command to cover."""
|
||||
self._device.open_cover()
|
|
@ -1,103 +0,0 @@
|
|||
"""Support for Abode Security System lights."""
|
||||
import logging
|
||||
from math import ceil
|
||||
|
||||
import abodepy.helpers.constants as CONST
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP,
|
||||
ATTR_HS_COLOR,
|
||||
SUPPORT_BRIGHTNESS,
|
||||
SUPPORT_COLOR,
|
||||
SUPPORT_COLOR_TEMP,
|
||||
Light,
|
||||
)
|
||||
from homeassistant.util.color import (
|
||||
color_temperature_kelvin_to_mired,
|
||||
color_temperature_mired_to_kelvin,
|
||||
)
|
||||
|
||||
from . import AbodeDevice
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
"""Platform uses config entry setup."""
|
||||
pass
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up Abode light devices."""
|
||||
data = hass.data[DOMAIN]
|
||||
|
||||
entities = []
|
||||
|
||||
for device in data.abode.get_devices(generic_type=CONST.TYPE_LIGHT):
|
||||
entities.append(AbodeLight(data, device))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class AbodeLight(AbodeDevice, Light):
|
||||
"""Representation of an Abode light."""
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
"""Turn on the light."""
|
||||
if ATTR_COLOR_TEMP in kwargs and self._device.is_color_capable:
|
||||
self._device.set_color_temp(
|
||||
int(color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP]))
|
||||
)
|
||||
|
||||
if ATTR_HS_COLOR in kwargs and self._device.is_color_capable:
|
||||
self._device.set_color(kwargs[ATTR_HS_COLOR])
|
||||
|
||||
if ATTR_BRIGHTNESS in kwargs and self._device.is_dimmable:
|
||||
# Convert HASS brightness (0-255) to Abode brightness (0-99)
|
||||
# If 100 is sent to Abode, response is 99 causing an error
|
||||
self._device.set_level(ceil(kwargs[ATTR_BRIGHTNESS] * 99 / 255.0))
|
||||
else:
|
||||
self._device.switch_on()
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
"""Turn off the light."""
|
||||
self._device.switch_off()
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if device is on."""
|
||||
return self._device.is_on
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
"""Return the brightness of the light."""
|
||||
if self._device.is_dimmable and self._device.has_brightness:
|
||||
brightness = int(self._device.brightness)
|
||||
# Abode returns 100 during device initialization and device refresh
|
||||
if brightness == 100:
|
||||
return 255
|
||||
# Convert Abode brightness (0-99) to HASS brightness (0-255)
|
||||
return ceil(brightness * 255 / 99.0)
|
||||
|
||||
@property
|
||||
def color_temp(self):
|
||||
"""Return the color temp of the light."""
|
||||
if self._device.has_color:
|
||||
return color_temperature_kelvin_to_mired(self._device.color_temp)
|
||||
|
||||
@property
|
||||
def hs_color(self):
|
||||
"""Return the color of the light."""
|
||||
if self._device.has_color:
|
||||
return self._device.color
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag supported features."""
|
||||
if self._device.is_dimmable and self._device.is_color_capable:
|
||||
return SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_COLOR_TEMP
|
||||
if self._device.is_dimmable:
|
||||
return SUPPORT_BRIGHTNESS
|
||||
return 0
|
|
@ -1,45 +0,0 @@
|
|||
"""Support for the Abode Security System locks."""
|
||||
import logging
|
||||
|
||||
import abodepy.helpers.constants as CONST
|
||||
|
||||
from homeassistant.components.lock import LockDevice
|
||||
|
||||
from . import AbodeDevice
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
"""Platform uses config entry setup."""
|
||||
pass
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up Abode lock devices."""
|
||||
data = hass.data[DOMAIN]
|
||||
|
||||
entities = []
|
||||
|
||||
for device in data.abode.get_devices(generic_type=CONST.TYPE_LOCK):
|
||||
entities.append(AbodeLock(data, device))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class AbodeLock(AbodeDevice, LockDevice):
|
||||
"""Representation of an Abode lock."""
|
||||
|
||||
def lock(self, **kwargs):
|
||||
"""Lock the device."""
|
||||
self._device.lock()
|
||||
|
||||
def unlock(self, **kwargs):
|
||||
"""Unlock the device."""
|
||||
self._device.unlock()
|
||||
|
||||
@property
|
||||
def is_locked(self):
|
||||
"""Return true if device is on."""
|
||||
return self._device.is_locked
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"domain": "abode",
|
||||
"name": "Abode",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/abode",
|
||||
"requirements": [
|
||||
"abodepy==0.16.7"
|
||||
],
|
||||
"dependencies": [],
|
||||
"codeowners": [
|
||||
"@shred86"
|
||||
]
|
||||
}
|
|
@ -1,90 +0,0 @@
|
|||
"""Support for Abode Security System sensors."""
|
||||
import logging
|
||||
|
||||
import abodepy.helpers.constants as CONST
|
||||
|
||||
from homeassistant.const import (
|
||||
DEVICE_CLASS_HUMIDITY,
|
||||
DEVICE_CLASS_ILLUMINANCE,
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
)
|
||||
|
||||
from . import AbodeDevice
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Sensor types: Name, icon
|
||||
SENSOR_TYPES = {
|
||||
CONST.TEMP_STATUS_KEY: ["Temperature", DEVICE_CLASS_TEMPERATURE],
|
||||
CONST.HUMI_STATUS_KEY: ["Humidity", DEVICE_CLASS_HUMIDITY],
|
||||
CONST.LUX_STATUS_KEY: ["Lux", DEVICE_CLASS_ILLUMINANCE],
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
"""Platform uses config entry setup."""
|
||||
pass
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up Abode sensor devices."""
|
||||
data = hass.data[DOMAIN]
|
||||
|
||||
entities = []
|
||||
|
||||
for device in data.abode.get_devices(generic_type=CONST.TYPE_SENSOR):
|
||||
for sensor_type in SENSOR_TYPES:
|
||||
if sensor_type not in device.get_value(CONST.STATUSES_KEY):
|
||||
continue
|
||||
entities.append(AbodeSensor(data, device, sensor_type))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class AbodeSensor(AbodeDevice):
|
||||
"""A sensor implementation for Abode devices."""
|
||||
|
||||
def __init__(self, data, device, sensor_type):
|
||||
"""Initialize a sensor for an Abode device."""
|
||||
super().__init__(data, device)
|
||||
self._sensor_type = sensor_type
|
||||
self._name = "{0} {1}".format(
|
||||
self._device.name, SENSOR_TYPES[self._sensor_type][0]
|
||||
)
|
||||
self._device_class = SENSOR_TYPES[self._sensor_type][1]
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the device class."""
|
||||
return self._device_class
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return a unique ID to use for this device."""
|
||||
return f"{self._device.device_uuid}-{self._sensor_type}"
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the sensor."""
|
||||
if self._sensor_type == CONST.TEMP_STATUS_KEY:
|
||||
return self._device.temp
|
||||
if self._sensor_type == CONST.HUMI_STATUS_KEY:
|
||||
return self._device.humidity
|
||||
if self._sensor_type == CONST.LUX_STATUS_KEY:
|
||||
return self._device.lux
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self):
|
||||
"""Return the units of measurement."""
|
||||
if self._sensor_type == CONST.TEMP_STATUS_KEY:
|
||||
return self._device.temp_unit
|
||||
if self._sensor_type == CONST.HUMI_STATUS_KEY:
|
||||
return self._device.humidity_unit
|
||||
if self._sensor_type == CONST.LUX_STATUS_KEY:
|
||||
return self._device.lux_unit
|
|
@ -1,13 +0,0 @@
|
|||
capture_image:
|
||||
description: Request a new image capture from a camera device.
|
||||
fields:
|
||||
entity_id: {description: Entity id of the camera to request an image., example: camera.downstairs_motion_camera}
|
||||
change_setting:
|
||||
description: Change an Abode system setting.
|
||||
fields:
|
||||
setting: {description: Setting to change., example: beeper_mute}
|
||||
value: {description: Value of the setting., example: '1'}
|
||||
trigger_quick_action:
|
||||
description: Trigger an Abode quick action.
|
||||
fields:
|
||||
entity_id: {description: Entity id of the quick action to trigger., example: binary_sensor.home_quick_action}
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"title": "Abode",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Fill in your Abode login information",
|
||||
"data": {
|
||||
"username": "Email Address",
|
||||
"password": "Password"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"identifier_exists": "Account already registered.",
|
||||
"invalid_credentials": "Invalid credentials.",
|
||||
"connection_error": "Unable to connect to Abode."
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Only a single configuration of Abode is allowed."
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,68 +0,0 @@
|
|||
"""Support for Abode Security System switches."""
|
||||
import logging
|
||||
|
||||
import abodepy.helpers.constants as CONST
|
||||
import abodepy.helpers.timeline as TIMELINE
|
||||
|
||||
from homeassistant.components.switch import SwitchDevice
|
||||
|
||||
from . import AbodeAutomation, AbodeDevice
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
"""Platform uses config entry setup."""
|
||||
pass
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up Abode switch devices."""
|
||||
data = hass.data[DOMAIN]
|
||||
|
||||
entities = []
|
||||
|
||||
for device in data.abode.get_devices(generic_type=CONST.TYPE_SWITCH):
|
||||
entities.append(AbodeSwitch(data, device))
|
||||
|
||||
for automation in data.abode.get_automations(generic_type=CONST.TYPE_AUTOMATION):
|
||||
entities.append(
|
||||
AbodeAutomationSwitch(data, automation, TIMELINE.AUTOMATION_EDIT_GROUP)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class AbodeSwitch(AbodeDevice, SwitchDevice):
|
||||
"""Representation of an Abode switch."""
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
"""Turn on the device."""
|
||||
self._device.switch_on()
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
"""Turn off the device."""
|
||||
self._device.switch_off()
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if device is on."""
|
||||
return self._device.is_on
|
||||
|
||||
|
||||
class AbodeAutomationSwitch(AbodeAutomation, SwitchDevice):
|
||||
"""A switch implementation for Abode automations."""
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
"""Turn on the device."""
|
||||
self._automation.set_active(True)
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
"""Turn off the device."""
|
||||
self._automation.set_active(False)
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return True if the binary sensor is on."""
|
||||
return self._automation.is_active
|
|
@ -1 +0,0 @@
|
|||
"""The acer_projector component."""
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"domain": "acer_projector",
|
||||
"name": "Acer projector",
|
||||
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
|
||||
"requirements": [
|
||||
"pyserial==3.1.1"
|
||||
],
|
||||
"dependencies": [],
|
||||
"codeowners": []
|
||||
}
|
|
@ -1,170 +0,0 @@
|
|||
"""Use serial protocol of Acer projector to obtain state of the projector."""
|
||||
import logging
|
||||
import re
|
||||
|
||||
import serial
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice
|
||||
from homeassistant.const import (
|
||||
CONF_FILENAME,
|
||||
CONF_NAME,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_TIMEOUT = "timeout"
|
||||
CONF_WRITE_TIMEOUT = "write_timeout"
|
||||
|
||||
DEFAULT_NAME = "Acer Projector"
|
||||
DEFAULT_TIMEOUT = 1
|
||||
DEFAULT_WRITE_TIMEOUT = 1
|
||||
|
||||
ECO_MODE = "ECO Mode"
|
||||
|
||||
ICON = "mdi:projector"
|
||||
|
||||
INPUT_SOURCE = "Input Source"
|
||||
|
||||
LAMP = "Lamp"
|
||||
LAMP_HOURS = "Lamp Hours"
|
||||
|
||||
MODEL = "Model"
|
||||
|
||||
# Commands known to the projector
|
||||
CMD_DICT = {
|
||||
LAMP: "* 0 Lamp ?\r",
|
||||
LAMP_HOURS: "* 0 Lamp\r",
|
||||
INPUT_SOURCE: "* 0 Src ?\r",
|
||||
ECO_MODE: "* 0 IR 052\r",
|
||||
MODEL: "* 0 IR 035\r",
|
||||
STATE_ON: "* 0 IR 001\r",
|
||||
STATE_OFF: "* 0 IR 002\r",
|
||||
}
|
||||
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_FILENAME): cv.isdevice,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
|
||||
vol.Optional(
|
||||
CONF_WRITE_TIMEOUT, default=DEFAULT_WRITE_TIMEOUT
|
||||
): cv.positive_int,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
||||
"""Connect with serial port and return Acer Projector."""
|
||||
serial_port = config.get(CONF_FILENAME)
|
||||
name = config.get(CONF_NAME)
|
||||
timeout = config.get(CONF_TIMEOUT)
|
||||
write_timeout = config.get(CONF_WRITE_TIMEOUT)
|
||||
|
||||
add_entities([AcerSwitch(serial_port, name, timeout, write_timeout)], True)
|
||||
|
||||
|
||||
class AcerSwitch(SwitchDevice):
|
||||
"""Represents an Acer Projector as a switch."""
|
||||
|
||||
def __init__(self, serial_port, name, timeout, write_timeout, **kwargs):
|
||||
"""Init of the Acer projector."""
|
||||
|
||||
self.ser = serial.Serial(
|
||||
port=serial_port, timeout=timeout, write_timeout=write_timeout, **kwargs
|
||||
)
|
||||
self._serial_port = serial_port
|
||||
self._name = name
|
||||
self._state = False
|
||||
self._available = False
|
||||
self._attributes = {
|
||||
LAMP_HOURS: STATE_UNKNOWN,
|
||||
INPUT_SOURCE: STATE_UNKNOWN,
|
||||
ECO_MODE: STATE_UNKNOWN,
|
||||
}
|
||||
|
||||
def _write_read(self, msg):
|
||||
"""Write to the projector and read the return."""
|
||||
|
||||
ret = ""
|
||||
# Sometimes the projector won't answer for no reason or the projector
|
||||
# was disconnected during runtime.
|
||||
# This way the projector can be reconnected and will still work
|
||||
try:
|
||||
if not self.ser.is_open:
|
||||
self.ser.open()
|
||||
msg = msg.encode("utf-8")
|
||||
self.ser.write(msg)
|
||||
# Size is an experience value there is no real limit.
|
||||
# AFAIK there is no limit and no end character so we will usually
|
||||
# need to wait for timeout
|
||||
ret = self.ser.read_until(size=20).decode("utf-8")
|
||||
except serial.SerialException:
|
||||
_LOGGER.error("Problem communicating with %s", self._serial_port)
|
||||
self.ser.close()
|
||||
return ret
|
||||
|
||||
def _write_read_format(self, msg):
|
||||
"""Write msg, obtain answer and format output."""
|
||||
# answers are formatted as ***\answer\r***
|
||||
awns = self._write_read(msg)
|
||||
match = re.search(r"\r(.+)\r", awns)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return STATE_UNKNOWN
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return if projector is available."""
|
||||
return self._available
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return name of the projector."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return if the projector is turned on."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def state_attributes(self):
|
||||
"""Return state attributes."""
|
||||
return self._attributes
|
||||
|
||||
def update(self):
|
||||
"""Get the latest state from the projector."""
|
||||
msg = CMD_DICT[LAMP]
|
||||
awns = self._write_read_format(msg)
|
||||
if awns == "Lamp 1":
|
||||
self._state = True
|
||||
self._available = True
|
||||
elif awns == "Lamp 0":
|
||||
self._state = False
|
||||
self._available = True
|
||||
else:
|
||||
self._available = False
|
||||
|
||||
for key in self._attributes:
|
||||
msg = CMD_DICT.get(key, None)
|
||||
if msg:
|
||||
awns = self._write_read_format(msg)
|
||||
self._attributes[key] = awns
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
"""Turn the projector on."""
|
||||
msg = CMD_DICT[STATE_ON]
|
||||
self._write_read(msg)
|
||||
self._state = STATE_ON
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
"""Turn the projector off."""
|
||||
msg = CMD_DICT[STATE_OFF]
|
||||
self._write_read(msg)
|
||||
self._state = STATE_OFF
|
|
@ -1 +0,0 @@
|
|||
"""The actiontec component."""
|
|
@ -1,123 +0,0 @@
|
|||
"""Support for Actiontec MI424WR (Verizon FIOS) routers."""
|
||||
from collections import namedtuple
|
||||
import logging
|
||||
import re
|
||||
import telnetlib
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.device_tracker import (
|
||||
DOMAIN,
|
||||
PLATFORM_SCHEMA,
|
||||
DeviceScanner,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_LEASES_REGEX = re.compile(
|
||||
r"(?P<ip>([0-9]{1,3}[\.]){3}[0-9]{1,3})"
|
||||
+ r"\smac:\s(?P<mac>([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))"
|
||||
+ r"\svalid\sfor:\s(?P<timevalid>(-?\d+))"
|
||||
+ r"\ssec"
|
||||
)
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_scanner(hass, config):
|
||||
"""Validate the configuration and return an Actiontec scanner."""
|
||||
scanner = ActiontecDeviceScanner(config[DOMAIN])
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
|
||||
Device = namedtuple("Device", ["mac", "ip", "last_update"])
|
||||
|
||||
|
||||
class ActiontecDeviceScanner(DeviceScanner):
|
||||
"""This class queries an actiontec router for connected devices."""
|
||||
|
||||
def __init__(self, config):
|
||||
"""Initialize the scanner."""
|
||||
self.host = config[CONF_HOST]
|
||||
self.username = config[CONF_USERNAME]
|
||||
self.password = config[CONF_PASSWORD]
|
||||
self.last_results = []
|
||||
data = self.get_actiontec_data()
|
||||
self.success_init = data is not None
|
||||
_LOGGER.info("canner initialized")
|
||||
|
||||
def scan_devices(self):
|
||||
"""Scan for new devices and return a list with found device IDs."""
|
||||
self._update_info()
|
||||
return [client.mac for client in self.last_results]
|
||||
|
||||
def get_device_name(self, device):
|
||||
"""Return the name of the given device or None if we don't know."""
|
||||
if not self.last_results:
|
||||
return None
|
||||
for client in self.last_results:
|
||||
if client.mac == device:
|
||||
return client.ip
|
||||
return None
|
||||
|
||||
def _update_info(self):
|
||||
"""Ensure the information from the router is up to date.
|
||||
|
||||
Return boolean if scanning successful.
|
||||
"""
|
||||
_LOGGER.info("Scanning")
|
||||
if not self.success_init:
|
||||
return False
|
||||
|
||||
now = dt_util.now()
|
||||
actiontec_data = self.get_actiontec_data()
|
||||
if not actiontec_data:
|
||||
return False
|
||||
self.last_results = [
|
||||
Device(data["mac"], name, now)
|
||||
for name, data in actiontec_data.items()
|
||||
if data["timevalid"] > -60
|
||||
]
|
||||
_LOGGER.info("Scan successful")
|
||||
return True
|
||||
|
||||
def get_actiontec_data(self):
|
||||
"""Retrieve data from Actiontec MI424WR and return parsed result."""
|
||||
try:
|
||||
telnet = telnetlib.Telnet(self.host)
|
||||
telnet.read_until(b"Username: ")
|
||||
telnet.write((self.username + "\n").encode("ascii"))
|
||||
telnet.read_until(b"Password: ")
|
||||
telnet.write((self.password + "\n").encode("ascii"))
|
||||
prompt = telnet.read_until(b"Wireless Broadband Router> ").split(b"\n")[-1]
|
||||
telnet.write("firewall mac_cache_dump\n".encode("ascii"))
|
||||
telnet.write("\n".encode("ascii"))
|
||||
telnet.read_until(prompt)
|
||||
leases_result = telnet.read_until(prompt).split(b"\n")[1:-1]
|
||||
telnet.write("exit\n".encode("ascii"))
|
||||
except EOFError:
|
||||
_LOGGER.exception("Unexpected response from router")
|
||||
return
|
||||
except ConnectionRefusedError:
|
||||
_LOGGER.exception("Connection refused by router. Telnet enabled?")
|
||||
return None
|
||||
|
||||
devices = {}
|
||||
for lease in leases_result:
|
||||
match = _LEASES_REGEX.search(lease.decode("utf-8"))
|
||||
if match is not None:
|
||||
devices[match.group("ip")] = {
|
||||
"ip": match.group("ip"),
|
||||
"mac": match.group("mac").upper(),
|
||||
"timevalid": int(match.group("timevalid")),
|
||||
}
|
||||
return devices
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"domain": "actiontec",
|
||||
"name": "Actiontec",
|
||||
"documentation": "https://www.home-assistant.io/integrations/actiontec",
|
||||
"requirements": [],
|
||||
"dependencies": [],
|
||||
"codeowners": []
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"adguard_home_addon_outdated": "\u0422\u0430\u0437\u0438 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0438\u0437\u0438\u0441\u043a\u0432\u0430 AdGuard Home {minimal_version} \u0438\u043b\u0438 \u043f\u043e-\u043d\u043e\u0432\u0430 {minimal_version}, \u0438\u043c\u0430\u0442\u0435 {current_version}. \u041c\u043e\u043b\u044f, \u0430\u043a\u0442\u0443\u0430\u043b\u0438\u0437\u0438\u0440\u0430\u0439\u0442\u0435 \u0432\u0430\u0448\u0430\u0442\u0430 \u0434\u043e\u0431\u0430\u0432\u043a\u0430 \u0437\u0430 Hass.io AdGuard Home.",
|
||||
"adguard_home_outdated": "\u0422\u0430\u0437\u0438 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0438\u0437\u0438\u0441\u043a\u0432\u0430 AdGuard Home {minimal_version} \u0438\u043b\u0438 \u043f\u043e-\u043d\u043e\u0432\u0430 {minimal_version}, \u0438\u043c\u0430\u0442\u0435 {current_version}.",
|
||||
"existing_instance_updated": "\u0410\u043a\u0442\u0443\u0430\u043b\u0438\u0437\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0441\u044a\u0449\u0435\u0441\u0442\u0432\u0443\u0432\u0430\u0449\u0430\u0442\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f.",
|
||||
"single_instance_allowed": "\u0420\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0430 AdGuard Home."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435."
|
||||
},
|
||||
"step": {
|
||||
"hassio_confirm": {
|
||||
"description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0442\u0435 Home Assistant \u0434\u0430 \u0441\u0435 \u0441\u0432\u044a\u0440\u0437\u0432\u0430 \u0441 AdGuard Home, \u043f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u0435\u043d \u043e\u0442 Hass.io \u0434\u043e\u0431\u0430\u0432\u043a\u0430\u0442\u0430: {addon} ?",
|
||||
"title": "AdGuard Home \u0447\u0440\u0435\u0437 Hass.io \u0434\u043e\u0431\u0430\u0432\u043a\u0430"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "\u0410\u0434\u0440\u0435\u0441",
|
||||
"password": "\u041f\u0430\u0440\u043e\u043b\u0430",
|
||||
"port": "\u041f\u043e\u0440\u0442",
|
||||
"ssl": "AdGuard Home \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430 SSL \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442",
|
||||
"username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435",
|
||||
"verify_ssl": "AdGuard Home \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430 \u043d\u0430\u0434\u0435\u0436\u0434\u0435\u043d \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442"
|
||||
},
|
||||
"description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0412\u0430\u0448\u0438\u044f AdGuard Home, \u0437\u0430 \u0434\u0430 \u043f\u043e\u0437\u0432\u043e\u043b\u0438\u0442\u0435 \u043d\u0430\u0431\u043b\u044e\u0434\u0435\u043d\u0438\u0435 \u0438 \u043a\u043e\u043d\u0442\u0440\u043e\u043b.",
|
||||
"title": "\u0421\u0432\u044a\u0440\u0436\u0435\u0442\u0435 \u0412\u0430\u0448\u0438\u044f AdGuard Home."
|
||||
}
|
||||
},
|
||||
"title": "AdGuard Home"
|
||||
}
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"adguard_home_addon_outdated": "Aquesta integraci\u00f3 necessita la versi\u00f3 d'AdGuard Home {minimal_version} o una superior, tens la {current_version}. Actualitza el complement de Hass.io d'AdGuard Home.",
|
||||
"adguard_home_outdated": "Aquesta integraci\u00f3 necessita la versi\u00f3 d'AdGuard Home {minimal_version} o una superior, tens la {current_version}.",
|
||||
"existing_instance_updated": "S'ha actualitzat la configuraci\u00f3 existent.",
|
||||
"single_instance_allowed": "Nom\u00e9s es permet una \u00fanica configuraci\u00f3 d'AdGuard Home."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "No s'ha pogut connectar."
|
||||
},
|
||||
"step": {
|
||||
"hassio_confirm": {
|
||||
"description": "Vols configurar Home Assistant perqu\u00e8 es connecti amb l'AdGuard Home proporcionat pel complement de Hass.io: {addon}?",
|
||||
"title": "AdGuard Home (complement de Hass.io)"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "Amfitri\u00f3",
|
||||
"password": "Contrasenya",
|
||||
"port": "Port",
|
||||
"ssl": "AdGuard Home utilitza un certificat SSL",
|
||||
"username": "Nom d'usuari",
|
||||
"verify_ssl": "AdGuard Home utilitza un certificat adequat"
|
||||
},
|
||||
"description": "Configuraci\u00f3 de la inst\u00e0ncia d'AdGuard Home, permet el control i la monitoritzaci\u00f3.",
|
||||
"title": "Enlla\u00e7ar AdGuard Home."
|
||||
}
|
||||
},
|
||||
"title": "AdGuard Home"
|
||||
}
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"existing_instance_updated": "Opdaterede eksisterende konfiguration.",
|
||||
"single_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning af AdGuard Home."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Forbindelse mislykkedes."
|
||||
},
|
||||
"step": {
|
||||
"hassio_confirm": {
|
||||
"description": "Vil du konfigurere Home Assistant til at oprette forbindelse til AdGuard Home, der leveres af Hass.io add-on: {addon}?",
|
||||
"title": "AdGuard Home via Hass.io add-on"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "V\u00e6rt",
|
||||
"password": "Adgangskode",
|
||||
"port": "Port",
|
||||
"ssl": "AdGuard Home bruger et SSL-certifikat",
|
||||
"username": "Brugernavn",
|
||||
"verify_ssl": "AdGuard Home bruger et korrekt certifikat"
|
||||
},
|
||||
"description": "Konfigurer din AdGuard Home instans for at tillade overv\u00e5gning og kontrol.",
|
||||
"title": "Link AdGuard Home."
|
||||
}
|
||||
},
|
||||
"title": "AdGuard Home"
|
||||
}
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"existing_instance_updated": "Bestehende Konfiguration wurde aktualisiert.",
|
||||
"single_instance_allowed": "Es ist nur eine einzige Konfiguration von AdGuard Home zul\u00e4ssig."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Fehler beim Herstellen einer Verbindung."
|
||||
},
|
||||
"step": {
|
||||
"hassio_confirm": {
|
||||
"description": "M\u00f6chtest du Home Assistant so konfigurieren, dass eine Verbindung mit AdGuard Home als Hass.io-Add-On hergestellt wird: {addon}?",
|
||||
"title": "AdGuard Home \u00fcber das Hass.io Add-on"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"password": "Passwort",
|
||||
"port": "Port",
|
||||
"ssl": "AdGuard Home verwendet ein SSL-Zertifikat",
|
||||
"username": "Benutzername",
|
||||
"verify_ssl": "AdGuard Home verwendet ein richtiges Zertifikat"
|
||||
},
|
||||
"description": "Richte deine AdGuard Home-Instanz ein um sie zu \u00dcberwachen und zu Steuern.",
|
||||
"title": "Verkn\u00fcpfe AdGuard Home."
|
||||
}
|
||||
},
|
||||
"title": "AdGuard Home"
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue