├── hacs.json
├── pyproject.toml
├── requirements.txt
├── .pre-commit-config.yaml
├── .devcontainer
├── setup
├── devcontainer.json
└── README.md
├── .github
├── workflows
│ ├── validate.yaml
│ ├── stale.yml
│ └── release.yml
└── ISSUE_TEMPLATE
│ ├── feature_request.yml
│ └── bug_report.yml
├── custom_components
└── vivint
│ ├── manifest.json
│ ├── const.py
│ ├── lock.py
│ ├── translations
│ └── en.json
│ ├── strings.json
│ ├── cover.py
│ ├── event.py
│ ├── button.py
│ ├── light.py
│ ├── sensor.py
│ ├── update.py
│ ├── device_trigger.py
│ ├── alarm_control_panel.py
│ ├── camera.py
│ ├── switch.py
│ ├── config_flow.py
│ ├── __init__.py
│ ├── hub.py
│ ├── climate.py
│ └── binary_sensor.py
├── config
└── configuration.yaml
├── LICENSE
├── .gitignore
└── README.md
/hacs.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Vivint",
3 | "homeassistant": "2024.11.0",
4 | "render_readme": true,
5 | "zip_release": true,
6 | "filename": "vivint.zip"
7 | }
8 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.ruff.lint.isort]
2 | force-sort-within-sections = true
3 | known-first-party = ["homeassistant", "tests"]
4 | forced-separate = ["tests"]
5 | combine-as-imports = true
6 | split-on-trailing-comma = false
7 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | # Home Assistant
2 | homeassistant>=2025.1
3 | python-dateutil
4 | numpy
5 | PyTurboJPEG
6 |
7 | # Integration
8 | vivintpy
9 |
10 | # Development
11 | colorlog
12 | pip>=21.0
13 | pre-commit
14 | ruff
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/astral-sh/ruff-pre-commit
3 | # Ruff version.
4 | rev: v0.9.6
5 | hooks:
6 | # Run the linter.
7 | - id: ruff
8 | args: [--fix]
9 | # Run the formatter.
10 | - id: ruff-format
11 |
--------------------------------------------------------------------------------
/.devcontainer/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | sudo apt-get update && sudo apt-get install ffmpeg libturbojpeg0 -y
4 |
5 | set -e
6 |
7 | cd "$(dirname "$0")/.."
8 |
9 | python3 -m pip install --requirement requirements.txt --upgrade
10 |
11 | pre-commit install
12 |
13 | mkdir -p config
14 |
--------------------------------------------------------------------------------
/.github/workflows/validate.yaml:
--------------------------------------------------------------------------------
1 | name: Validate repo
2 |
3 | on:
4 | push:
5 | pull_request:
6 | schedule:
7 | - cron: "0 0 * * *"
8 |
9 | jobs:
10 | hassfest:
11 | name: Validate with hassfest
12 | runs-on: "ubuntu-latest"
13 | steps:
14 | - uses: "actions/checkout@v4"
15 | - uses: "home-assistant/actions/hassfest@master"
16 | hacs:
17 | name: Validate with HACS
18 | runs-on: "ubuntu-latest"
19 | steps:
20 | - uses: "hacs/action@main"
21 | with:
22 | category: "integration"
23 |
--------------------------------------------------------------------------------
/custom_components/vivint/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "domain": "vivint",
3 | "name": "Vivint",
4 | "codeowners": ["@natekspencer"],
5 | "config_flow": true,
6 | "dependencies": ["ffmpeg"],
7 | "documentation": "https://github.com/natekspencer/hacs-vivint",
8 | "integration_type": "hub",
9 | "iot_class": "cloud_push",
10 | "issue_tracker": "https://github.com/natekspencer/hacs-vivint/issues",
11 | "loggers": ["custom_components.vivint", "vivintpy"],
12 | "requirements": ["vivintpy==2025.0.2"],
13 | "version": "0.0.0",
14 | "zeroconf": ["_vivint-ODC300._tcp.local.", "_vivint-DBC350._tcp.local."]
15 | }
16 |
--------------------------------------------------------------------------------
/config/configuration.yaml:
--------------------------------------------------------------------------------
1 | # Limited configuration instead of default_config
2 | # https://www.home-assistant.io/integrations/default_config
3 | automation:
4 | frontend:
5 | history:
6 | isal:
7 | logbook:
8 | zeroconf:
9 |
10 | homeassistant:
11 | name: HACS-Vivint
12 | auth_providers:
13 | - type: trusted_networks
14 | trusted_networks:
15 | - 127.0.0.1
16 | - 192.0.0.0/8
17 | - ::1
18 | allow_bypass_login: true
19 | - type: homeassistant
20 |
21 | logger:
22 | default: info
23 | logs:
24 | custom_components.vivint: debug
25 | vivintpy: debug
26 | pubnub: debug
27 |
--------------------------------------------------------------------------------
/custom_components/vivint/const.py:
--------------------------------------------------------------------------------
1 | """Constants for the Vivint integration."""
2 |
3 | DOMAIN = "vivint"
4 | EVENT_TYPE = f"{DOMAIN}_event"
5 |
6 | RTSP_STREAM_DIRECT = 0
7 | RTSP_STREAM_INTERNAL = 1
8 | RTSP_STREAM_EXTERNAL = 2
9 | RTSP_STREAM_TYPES = {
10 | RTSP_STREAM_DIRECT: "Direct (falls back to internal if direct access is not available)",
11 | RTSP_STREAM_INTERNAL: "Internal",
12 | RTSP_STREAM_EXTERNAL: "External",
13 | }
14 |
15 | CONF_MFA = "code"
16 | CONF_REFRESH_TOKEN = "refresh_token"
17 | CONF_DISARM_CODE = "disarm_code"
18 | CONF_HD_STREAM = "hd_stream"
19 | CONF_RTSP_STREAM = "rtsp_stream"
20 | CONF_RTSP_URL_LOGGING = "rtsp_url_logging"
21 | DEFAULT_HD_STREAM = True
22 | DEFAULT_RTSP_STREAM = RTSP_STREAM_DIRECT
23 | DEFAULT_RTSP_URL_LOGGING = False
24 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.yml:
--------------------------------------------------------------------------------
1 | name: Request a feature
2 | description: Suggest an idea or request a missing feature.
3 | body:
4 | - type: textarea
5 | validations:
6 | required: true
7 | attributes:
8 | label: The request
9 | description: >-
10 | Is there a feature available in the Vivint app that is missing here or you wish was done differently? Let me know!
11 | - type: textarea
12 | attributes:
13 | label: Screenshots
14 | placeholder: "drag-and-drop relevant screenshots here"
15 | description: >-
16 | Please provide any relevant screenshots from the Vivint app for the requested feature.
17 | - type: textarea
18 | attributes:
19 | label: Additional information
20 | description: >
21 | If you have any additional information, such as device type or model numbers, use the field below.
22 |
--------------------------------------------------------------------------------
/.github/workflows/stale.yml:
--------------------------------------------------------------------------------
1 | # This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time.
2 | #
3 | # You can adjust the behavior by modifying this file.
4 | # For more information, see:
5 | # https://github.com/actions/stale
6 | name: Close stale issues and PRs
7 |
8 | on:
9 | schedule:
10 | - cron: "30 19 * * *"
11 |
12 | jobs:
13 | stale:
14 | runs-on: ubuntu-latest
15 | permissions:
16 | issues: write
17 | pull-requests: write
18 |
19 | steps:
20 | - uses: actions/stale@v9
21 | with:
22 | repo-token: ${{ secrets.GITHUB_TOKEN }}
23 | stale-issue-message: "This issue has now been marked as stale and will be closed if no further activity occurs."
24 | stale-pr-message: "This PR has now been marked as stale and will be closed if no further activity occurs."
25 | exempt-issue-labels: "enhancement"
26 | days-before-stale: 30
27 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | permissions: {}
8 |
9 | jobs:
10 | release:
11 | name: Release
12 | runs-on: ubuntu-latest
13 | permissions:
14 | contents: write
15 | steps:
16 | - name: Checkout repository
17 | uses: actions/checkout@v4
18 |
19 | - name: Set version number
20 | shell: bash
21 | run: |
22 | yq -i -o json '.version="${{ github.event.release.tag_name }}"' \
23 | "${{ github.workspace }}/custom_components/vivint/manifest.json"
24 |
25 | - name: ZIP integration directory
26 | shell: bash
27 | run: |
28 | cd "${{ github.workspace }}/custom_components/vivint"
29 | zip vivint.zip -r ./
30 |
31 | - name: Upload ZIP file to release
32 | uses: softprops/action-gh-release@v2
33 | with:
34 | files: ${{ github.workspace }}/custom_components/vivint/vivint.zip
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Nathan Spencer
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | // See https://aka.ms/vscode-remote/devcontainer.json for format details.
2 | {
3 | "name": "Home Assistant integration development",
4 | "image": "mcr.microsoft.com/devcontainers/python:1-3.13-bookworm",
5 | "postCreateCommand": ".devcontainer/setup",
6 | "postAttachCommand": ".devcontainer/setup",
7 | "forwardPorts": [8123],
8 | "customizations": {
9 | "vscode": {
10 | "extensions": [
11 | "ms-python.python",
12 | "ms-python.vscode-pylance",
13 | "esbenp.prettier-vscode",
14 | "github.vscode-pull-request-github",
15 | "ryanluker.vscode-coverage-gutters",
16 | "charliermarsh.ruff"
17 | ],
18 | "settings": {
19 | "files.eol": "\n",
20 | "editor.tabSize": 4,
21 | "python.pythonPath": "/usr/bin/python3",
22 | "python.analysis.autoSearchPaths": false,
23 | "editor.formatOnPaste": false,
24 | "editor.formatOnSave": true,
25 | "editor.formatOnType": true,
26 | "editor.codeActionsOnSave": {
27 | "source.organizeImports": "always"
28 | },
29 | "files.trimTrailingWhitespace": true,
30 | "[python]": {
31 | "editor.defaultFormatter": "charliermarsh.ruff"
32 | }
33 | }
34 | }
35 | },
36 | "remoteUser": "vscode",
37 | "features": {
38 | "rust": "latest"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/custom_components/vivint/lock.py:
--------------------------------------------------------------------------------
1 | """Support for Vivint door locks."""
2 |
3 | from typing import Any
4 |
5 | from vivintpy.devices.door_lock import DoorLock
6 |
7 | from homeassistant.components.lock import LockEntity
8 | from homeassistant.core import HomeAssistant
9 | from homeassistant.helpers.entity_platform import AddEntitiesCallback
10 |
11 | from . import VivintConfigEntry
12 | from .hub import VivintEntity, VivintHub
13 |
14 |
15 | async def async_setup_entry(
16 | hass: HomeAssistant,
17 | entry: VivintConfigEntry,
18 | async_add_entities: AddEntitiesCallback,
19 | ) -> None:
20 | """Set up Vivint door locks using config entry."""
21 | entities = []
22 | hub: VivintHub = entry.runtime_data
23 |
24 | for system in hub.account.systems:
25 | for alarm_panel in system.alarm_panels:
26 | for device in alarm_panel.devices:
27 | if isinstance(device, DoorLock):
28 | entities.append(VivintLockEntity(device=device, hub=hub))
29 |
30 | if not entities:
31 | return
32 |
33 | async_add_entities(entities)
34 |
35 |
36 | class VivintLockEntity(VivintEntity, LockEntity):
37 | """Vivint Lock."""
38 |
39 | device: DoorLock
40 |
41 | @property
42 | def is_locked(self) -> bool:
43 | """Return true if the lock is locked."""
44 | return self.device.is_locked
45 |
46 | @property
47 | def unique_id(self) -> str:
48 | """Return a unique ID."""
49 | return f"{self.device.alarm_panel.id}-{self.device.id}"
50 |
51 | async def async_lock(self, **kwargs: Any) -> None:
52 | """Lock the lock."""
53 | await self.device.lock()
54 |
55 | async def async_unlock(self, **kwargs: Any) -> None:
56 | """Unlock the lock."""
57 | await self.device.unlock()
58 |
--------------------------------------------------------------------------------
/custom_components/vivint/translations/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "config": {
3 | "step": {
4 | "user": {
5 | "data": {
6 | "username": "Username",
7 | "password": "Password"
8 | },
9 | "data_description": {
10 | "username": "The email address you use to login to Vivint"
11 | }
12 | },
13 | "mfa": {
14 | "title": "Enter your MFA code from Vivint",
15 | "data": {
16 | "code": "MFA code"
17 | }
18 | },
19 | "reauth_confirm": {
20 | "description": "Reauthenticate Integration",
21 | "data": {
22 | "username": "Username",
23 | "password": "Password"
24 | }
25 | }
26 | },
27 | "error": {
28 | "cannot_connect": "Failed to connect",
29 | "invalid_auth": "Invalid authentication",
30 | "unknown": "Unexpected error"
31 | },
32 | "abort": {
33 | "already_configured": "Account is already configured",
34 | "reauth_successful": "Re-authentication was successful"
35 | }
36 | },
37 | "options": {
38 | "step": {
39 | "init": {
40 | "data": {
41 | "disarm_code": "Disarm code",
42 | "hd_stream": "Stream camera in HD",
43 | "rtsp_stream": "Select which RTSP camera stream to use",
44 | "rtsp_url_logging": "Log camera RTSP URLs (this contains potentially sensitive information)"
45 | }
46 | }
47 | },
48 | "error": { "disarm_code_invalid": "Disarm code is invalid" }
49 | },
50 | "device_automation": {
51 | "trigger_type": {
52 | "doorbell_ding": "Doorbell pressed",
53 | "motion_detected": "Motion detected"
54 | }
55 | },
56 | "entity": {
57 | "event": {
58 | "doorbell": {
59 | "state_attributes": {
60 | "event_type": {
61 | "state": {
62 | "doorbell_ding": "Doorbell pressed"
63 | }
64 | }
65 | }
66 | }
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/.devcontainer/README.md:
--------------------------------------------------------------------------------
1 | ## Developing with Visual Studio Code + devcontainer
2 |
3 | The easiest way to get started with custom integration development is to use Visual Studio Code with devcontainers. This approach will create a preconfigured development environment with all the tools you need.
4 |
5 | In the container you will have a dedicated Home Assistant core instance running with your custom component code. You can configure this instance by updating the `./config/configuration.yaml` file.
6 |
7 | **Prerequisites**
8 |
9 | - [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
10 | - Docker
11 | - For Linux, macOS, or Windows 10 Pro/Enterprise/Education use the [current release version of Docker](https://docs.docker.com/install/)
12 | - Windows 10 Home requires [WSL 2](https://docs.microsoft.com/windows/wsl/wsl2-install) and the current Edge version of Docker Desktop (see instructions [here](https://docs.docker.com/docker-for-windows/wsl-tech-preview/)). This can also be used for Windows Pro/Enterprise/Education.
13 | - [Visual Studio code](https://code.visualstudio.com/)
14 | - [Remote - Containers (VSC Extension)][extension-link]
15 |
16 | [More info about requirements and devcontainer in general](https://code.visualstudio.com/docs/remote/containers#_getting-started)
17 |
18 | [extension-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers
19 |
20 | **Getting started:**
21 |
22 | 1. Fork the repository.
23 | 2. Clone the repository to your computer.
24 | 3. Open the repository using Visual Studio code.
25 |
26 | When you open this repository with Visual Studio code you are asked to "Reopen in Container", this will start the build of the container.
27 |
28 | _If you don't see this notification, open the command palette and select `Remote-Containers: Reopen Folder in Container`._
29 |
30 | ### Step by Step debugging
31 |
32 | With the development container,
33 | you can test your custom component in Home Assistant with step by step debugging.
34 |
35 | Launch the debugger with the existing debugging configuration `Home Assistant`.
36 |
--------------------------------------------------------------------------------
/custom_components/vivint/strings.json:
--------------------------------------------------------------------------------
1 | {
2 | "config": {
3 | "step": {
4 | "user": {
5 | "data": {
6 | "username": "[%key:common::config_flow::data::username%]",
7 | "password": "[%key:common::config_flow::data::password%]"
8 | },
9 | "data_description": {
10 | "username": "The email address you use to login to Vivint"
11 | }
12 | },
13 | "mfa": {
14 | "title": "Enter your MFA code from Vivint",
15 | "data": {
16 | "code": "MFA code"
17 | }
18 | },
19 | "reauth_confirm": {
20 | "description": "[%key:common::config_flow::title::reauth%]",
21 | "data": {
22 | "username": "[%key:common::config_flow::data::username%]",
23 | "password": "[%key:common::config_flow::data::password%]"
24 | }
25 | }
26 | },
27 | "error": {
28 | "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
29 | "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
30 | "unknown": "[%key:common::config_flow::error::unknown%]"
31 | },
32 | "abort": {
33 | "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
34 | "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
35 | }
36 | },
37 | "options": {
38 | "step": {
39 | "init": {
40 | "data": {
41 | "disarm_code": "Disarm code",
42 | "hd_stream": "Stream camera in HD",
43 | "rtsp_stream": "Select which RTSP camera stream to use",
44 | "rtsp_url_logging": "Log camera RTSP URLs (this contains potentially sensitive information)"
45 | }
46 | }
47 | },
48 | "error": { "disarm_code_invalid": "Disarm code is invalid" }
49 | },
50 | "device_automation": {
51 | "trigger_type": {
52 | "doorbell_ding": "Doorbell pressed",
53 | "motion_detected": "Motion detected"
54 | }
55 | },
56 | "entity": {
57 | "event": {
58 | "doorbell": {
59 | "state_attributes": {
60 | "event_type": {
61 | "state": {
62 | "doorbell_ding": "Doorbell pressed"
63 | }
64 | }
65 | }
66 | }
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/custom_components/vivint/cover.py:
--------------------------------------------------------------------------------
1 | """Support for Vivint garage doors."""
2 |
3 | from typing import Any
4 |
5 | from vivintpy.devices.garage_door import GarageDoor
6 |
7 | from homeassistant.components.cover import (
8 | CoverDeviceClass,
9 | CoverEntity,
10 | CoverEntityFeature,
11 | )
12 | from homeassistant.core import HomeAssistant
13 | from homeassistant.helpers.entity_platform import AddEntitiesCallback
14 |
15 | from . import VivintConfigEntry
16 | from .hub import VivintEntity, VivintHub
17 |
18 |
19 | async def async_setup_entry(
20 | hass: HomeAssistant,
21 | entry: VivintConfigEntry,
22 | async_add_entities: AddEntitiesCallback,
23 | ) -> None:
24 | """Set up Vivint garage doors using config entry."""
25 | entities = []
26 | hub: VivintHub = entry.runtime_data
27 |
28 | for system in hub.account.systems:
29 | for alarm_panel in system.alarm_panels:
30 | for device in alarm_panel.devices:
31 | if isinstance(device, GarageDoor):
32 | entities.append(VivintGarageDoorEntity(device=device, hub=hub))
33 |
34 | if not entities:
35 | return
36 |
37 | async_add_entities(entities)
38 |
39 |
40 | class VivintGarageDoorEntity(VivintEntity, CoverEntity):
41 | """Vivint Garage Door."""
42 |
43 | device: GarageDoor
44 |
45 | _attr_device_class = CoverDeviceClass.GARAGE
46 | _attr_supported_features = CoverEntityFeature.CLOSE | CoverEntityFeature.OPEN
47 |
48 | @property
49 | def is_opening(self) -> bool:
50 | """Return whether this device is opening."""
51 | return self.device.is_opening
52 |
53 | @property
54 | def is_closing(self) -> bool:
55 | """Return whether this device is closing."""
56 | return self.device.is_closing
57 |
58 | @property
59 | def is_closed(self) -> bool:
60 | """Return whether this device is closed."""
61 | return self.device.is_closed
62 |
63 | @property
64 | def unique_id(self) -> str:
65 | """Return a unique ID."""
66 | return f"{self.device.alarm_panel.id}-{self.device.id}"
67 |
68 | async def async_close_cover(self, **kwargs: Any) -> None:
69 | """Close cover."""
70 | await self.device.close()
71 |
72 | async def async_open_cover(self, **kwargs: Any) -> None:
73 | """Open the cover."""
74 | await self.device.open()
75 |
--------------------------------------------------------------------------------
/custom_components/vivint/event.py:
--------------------------------------------------------------------------------
1 | """Support for Vivint events."""
2 |
3 | from __future__ import annotations
4 |
5 | import logging
6 |
7 | from vivintpy.devices.camera import DOORBELL_DING, Camera
8 | from vivintpy.enums import CapabilityCategoryType
9 |
10 | from homeassistant.components.event import (
11 | EventDeviceClass,
12 | EventEntity,
13 | EventEntityDescription,
14 | )
15 | from homeassistant.core import HomeAssistant, callback
16 | from homeassistant.helpers.entity_platform import AddEntitiesCallback
17 |
18 | from . import VivintConfigEntry
19 | from .hub import VivintBaseEntity, VivintHub
20 |
21 | _LOGGER = logging.getLogger(__name__)
22 |
23 |
24 | DOORBELL_DESCRIPTION = EventEntityDescription(
25 | key="doorbell",
26 | translation_key="doorbell",
27 | device_class=EventDeviceClass.DOORBELL,
28 | event_types=[DOORBELL_DING],
29 | )
30 |
31 |
32 | async def async_setup_entry(
33 | hass: HomeAssistant,
34 | entry: VivintConfigEntry,
35 | async_add_entities: AddEntitiesCallback,
36 | ) -> None:
37 | """Set up Vivint events using config entry."""
38 | entities = []
39 | hub: VivintHub = entry.runtime_data
40 |
41 | for system in hub.account.systems:
42 | for alarm_panel in system.alarm_panels:
43 | for device in alarm_panel.get_devices([Camera]):
44 | if CapabilityCategoryType.DOORBELL in device.capabilities:
45 | entities.append(
46 | VivintEventEntity(
47 | device=device,
48 | hub=hub,
49 | entity_description=DOORBELL_DESCRIPTION,
50 | )
51 | )
52 |
53 | if not entities:
54 | return
55 |
56 | async_add_entities(entities)
57 |
58 |
59 | class VivintEventEntity(VivintBaseEntity, EventEntity):
60 | """Vivint event entity."""
61 |
62 | @callback
63 | def _async_handle_event(self, *args, **kwargs) -> None:
64 | """Handle the event."""
65 | self._trigger_event(self.event_types[0])
66 | self.async_write_ha_state()
67 |
68 | async def async_added_to_hass(self) -> None:
69 | """Register callbacks."""
70 | await super().async_added_to_hass()
71 | self.async_on_remove(
72 | self.device.on(self.event_types[0], self._async_handle_event)
73 | )
74 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | pip-wheel-metadata/
24 | share/python-wheels/
25 | *.egg-info/
26 | .installed.cfg
27 | *.egg
28 | MANIFEST
29 |
30 | # PyInstaller
31 | # Usually these files are written by a python script from a template
32 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
33 | *.manifest
34 | *.spec
35 |
36 | # Installer logs
37 | pip-log.txt
38 | pip-delete-this-directory.txt
39 |
40 | # Unit test / coverage reports
41 | htmlcov/
42 | .tox/
43 | .nox/
44 | .coverage
45 | .coverage.*
46 | .cache
47 | nosetests.xml
48 | coverage.xml
49 | *.cover
50 | *.py,cover
51 | .hypothesis/
52 | .pytest_cache/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | target/
76 |
77 | # Jupyter Notebook
78 | .ipynb_checkpoints
79 |
80 | # IPython
81 | profile_default/
82 | ipython_config.py
83 |
84 | # pyenv
85 | .python-version
86 |
87 | # pipenv
88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
91 | # install all needed dependencies.
92 | #Pipfile.lock
93 |
94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
95 | __pypackages__/
96 |
97 | # Celery stuff
98 | celerybeat-schedule
99 | celerybeat.pid
100 |
101 | # SageMath parsed files
102 | *.sage.py
103 |
104 | # Environments
105 | .env
106 | .venv
107 | env/
108 | venv/
109 | ENV/
110 | env.bak/
111 | venv.bak/
112 |
113 | # Spyder project settings
114 | .spyderproject
115 | .spyproject
116 |
117 | # Rope project settings
118 | .ropeproject
119 |
120 | # mkdocs documentation
121 | /site
122 |
123 | # mypy
124 | .mypy_cache/
125 | .dmypy.json
126 | dmypy.json
127 |
128 | # Pyre type checker
129 | .pyre/
130 |
131 | # VS Code Settings / Launch files
132 | .vscode/
133 |
134 | # MacOS
135 | .DS_Store
136 |
137 | # Home Assistant
138 | config/
139 |
--------------------------------------------------------------------------------
/custom_components/vivint/button.py:
--------------------------------------------------------------------------------
1 | """Support for Vivint buttons."""
2 |
3 | from __future__ import annotations
4 |
5 | from vivintpy.devices.alarm_panel import AlarmPanel
6 | from vivintpy.devices.camera import Camera as VivintCamera
7 | from vivintpy.entity import UPDATE
8 | from vivintpy.enums import (
9 | CapabilityCategoryType as Category,
10 | CapabilityType as Capability,
11 | )
12 |
13 | from homeassistant.components.button import (
14 | ButtonDeviceClass,
15 | ButtonEntity,
16 | ButtonEntityDescription,
17 | )
18 | from homeassistant.core import HomeAssistant
19 | from homeassistant.helpers.entity_platform import AddEntitiesCallback
20 |
21 | from . import VivintConfigEntry
22 | from .hub import VivintBaseEntity, VivintHub, has_capability
23 |
24 | REBOOT_ENTITY = ButtonEntityDescription(
25 | key="reboot", device_class=ButtonDeviceClass.RESTART
26 | )
27 |
28 |
29 | async def async_setup_entry(
30 | hass: HomeAssistant,
31 | entry: VivintConfigEntry,
32 | async_add_entities: AddEntitiesCallback,
33 | ) -> None:
34 | """Set up Vivint button platform."""
35 | hub: VivintHub = entry.runtime_data
36 | entities = [
37 | VivintButtonEntity(
38 | device=alarm_panel, hub=hub, entity_description=REBOOT_ENTITY
39 | )
40 | for system in hub.account.systems
41 | if system.is_admin
42 | for alarm_panel in system.alarm_panels
43 | ]
44 | entities.extend(
45 | VivintButtonEntity(device=device, hub=hub, entity_description=REBOOT_ENTITY)
46 | for system in hub.account.systems
47 | for alarm_panel in system.alarm_panels
48 | for device in alarm_panel.devices
49 | if isinstance(device, VivintCamera)
50 | and has_capability(device, Category.CAMERA, Capability.REBOOT_CAMERA)
51 | )
52 | async_add_entities(entities)
53 |
54 |
55 | class VivintButtonEntity(VivintBaseEntity, ButtonEntity):
56 | """A class that describes device button entities."""
57 |
58 | device: AlarmPanel | VivintCamera
59 |
60 | @property
61 | def available(self) -> bool:
62 | """Return if entity is available."""
63 | if isinstance(self.device, AlarmPanel):
64 | return (
65 | super().available and self.device._AlarmPanel__panel.data["can_reboot"]
66 | )
67 | return super().available
68 |
69 | async def async_press(self) -> None:
70 | """Handle the button press."""
71 | await self.device.reboot()
72 |
73 | async def async_added_to_hass(self) -> None:
74 | """Set up a listener for the entity."""
75 | await super().async_added_to_hass()
76 | if isinstance(self.device, AlarmPanel):
77 | self.async_on_remove(
78 | self.device._AlarmPanel__panel.on(
79 | UPDATE, lambda _: self.async_write_ha_state()
80 | )
81 | )
82 |
--------------------------------------------------------------------------------
/custom_components/vivint/light.py:
--------------------------------------------------------------------------------
1 | """Support for Vivint lights."""
2 |
3 | from typing import Any
4 |
5 | from vivintpy.devices.switch import MultilevelSwitch
6 |
7 | from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
8 | from homeassistant.core import HomeAssistant
9 | from homeassistant.helpers.entity_platform import AddEntitiesCallback
10 |
11 | from . import VivintConfigEntry
12 | from .hub import VivintEntity, VivintHub
13 |
14 |
15 | async def async_setup_entry(
16 | hass: HomeAssistant,
17 | entry: VivintConfigEntry,
18 | async_add_entities: AddEntitiesCallback,
19 | ) -> None:
20 | """Set up Vivint lights using config entry."""
21 | entities = []
22 | hub: VivintHub = entry.runtime_data
23 |
24 | for system in hub.account.systems:
25 | for alarm_panel in system.alarm_panels:
26 | for device in alarm_panel.devices:
27 | if isinstance(device, MultilevelSwitch):
28 | entities.append(VivintLightEntity(device=device, hub=hub))
29 |
30 | if not entities:
31 | return
32 |
33 | async_add_entities(entities)
34 |
35 |
36 | class VivintLightEntity(VivintEntity, LightEntity):
37 | """Vivint Light."""
38 |
39 | device: MultilevelSwitch
40 |
41 | _attr_color_mode: ColorMode.BRIGHTNESS
42 | _attr_supported_color_modes = {ColorMode.BRIGHTNESS}
43 |
44 | @property
45 | def is_on(self) -> bool:
46 | """Return True if the light is on."""
47 | return self.device.is_on
48 |
49 | @property
50 | def brightness(self) -> int:
51 | """Return the brightness of the light between 0..255.
52 |
53 | Vivint multilevel switches use a range of 0..100 to control brightness.
54 | """
55 | if self.device.level is not None:
56 | return round((self.device.level / 100) * 255)
57 | return 0
58 |
59 | @property
60 | def unique_id(self) -> str:
61 | """Return a unique ID."""
62 | return f"{self.device.alarm_panel.id}-{self.device.id}"
63 |
64 | async def async_turn_on(self, **kwargs: Any) -> None:
65 | """Turn on the light."""
66 | brightness = kwargs.get(ATTR_BRIGHTNESS)
67 |
68 | if brightness is None:
69 | # Just turn on the light, which will restore previous brightness.
70 | await self.device.turn_on()
71 | else:
72 | # Vivint multilevel switches use a range of 0..100 to control brightness.
73 | level = byte_to_vivint_level(brightness)
74 | await self.device.set_level(level)
75 |
76 | async def async_turn_off(self, **kwargs: Any) -> None:
77 | """Turn off the light."""
78 | await self.device.turn_off()
79 |
80 |
81 | def byte_to_vivint_level(value: int) -> int:
82 | """Convert brightness from 0..255 scale to 0..100 scale.
83 |
84 | `value` -- (int) Brightness byte value from 0-255.
85 | """
86 | if value > 0:
87 | return max(1, round((value / 255) * 100))
88 | return 0
89 |
--------------------------------------------------------------------------------
/custom_components/vivint/sensor.py:
--------------------------------------------------------------------------------
1 | """Support for Vivint sensors."""
2 |
3 | from vivintpy.devices import VivintDevice
4 |
5 | from homeassistant.components.sensor import (
6 | DOMAIN as SENSOR_DOMAIN,
7 | SensorDeviceClass,
8 | SensorEntity,
9 | SensorStateClass,
10 | )
11 | from homeassistant.const import PERCENTAGE
12 | from homeassistant.core import HomeAssistant, callback
13 | from homeassistant.helpers.dispatcher import async_dispatcher_connect
14 | from homeassistant.helpers.entity import EntityCategory
15 | from homeassistant.helpers.entity_platform import AddEntitiesCallback
16 | from homeassistant.helpers.typing import StateType
17 |
18 | from . import VivintConfigEntry
19 | from .const import DOMAIN
20 | from .hub import VivintEntity, VivintHub
21 |
22 |
23 | async def async_setup_entry(
24 | hass: HomeAssistant,
25 | entry: VivintConfigEntry,
26 | async_add_entities: AddEntitiesCallback,
27 | ) -> None:
28 | """Set up Vivint sensors using config entry."""
29 | entities = []
30 | hub: VivintHub = entry.runtime_data
31 |
32 | for system in hub.account.systems:
33 | for alarm_panel in system.alarm_panels:
34 | for device in alarm_panel.devices:
35 | if (
36 | not device.is_subdevice
37 | and getattr(device, "battery_level", None) is not None
38 | ):
39 | entities.append(VivintBatterySensorEntity(device=device, hub=hub))
40 |
41 | if not entities:
42 | return
43 |
44 | async_add_entities(entities)
45 |
46 | @callback
47 | def async_add_sensor(device: VivintDevice) -> None:
48 | """Add Vivint sensor."""
49 | entities: list[VivintBatterySensorEntity] = []
50 | if (
51 | not device.is_subdevice
52 | and getattr(device, "battery_level", None) is not None
53 | ):
54 | entities.append(VivintBatterySensorEntity(device=device, hub=hub))
55 |
56 | async_add_entities(entities)
57 |
58 | entry.async_on_unload(
59 | async_dispatcher_connect(
60 | hass,
61 | f"{DOMAIN}_{entry.entry_id}_add_{SENSOR_DOMAIN}",
62 | async_add_sensor,
63 | )
64 | )
65 |
66 |
67 | class VivintBatterySensorEntity(VivintEntity, SensorEntity):
68 | """Vivint Battery Sensor."""
69 |
70 | _attr_device_class = SensorDeviceClass.BATTERY
71 | _attr_entity_category = EntityCategory.DIAGNOSTIC
72 | _attr_native_unit_of_measurement = PERCENTAGE
73 | _attr_state_class = SensorStateClass.MEASUREMENT
74 |
75 | @property
76 | def name(self) -> str:
77 | """Return the name of this entity."""
78 | return f"{self.device.name} Battery Level"
79 |
80 | @property
81 | def unique_id(self) -> str:
82 | """Return a unique ID."""
83 | return f"{self.device.alarm_panel.id}-{self.device.id}"
84 |
85 | @property
86 | def native_value(self) -> StateType:
87 | """Return the value reported by the sensor."""
88 | return self.device.battery_level
89 |
--------------------------------------------------------------------------------
/custom_components/vivint/update.py:
--------------------------------------------------------------------------------
1 | """Support for Vivint updates."""
2 |
3 | from __future__ import annotations
4 |
5 | from datetime import timedelta
6 | from typing import Any
7 |
8 | from vivintpy.devices.alarm_panel import AlarmPanel
9 | from vivintpy.entity import UPDATE
10 |
11 | from homeassistant.components.update import (
12 | UpdateDeviceClass,
13 | UpdateEntity,
14 | UpdateEntityDescription,
15 | UpdateEntityFeature as Feature,
16 | )
17 | from homeassistant.core import HomeAssistant
18 | from homeassistant.exceptions import HomeAssistantError
19 | from homeassistant.helpers.entity_platform import AddEntitiesCallback
20 |
21 | from . import VivintConfigEntry
22 | from .hub import VivintBaseEntity, VivintHub
23 |
24 | SCAN_INTERVAL = timedelta(days=1)
25 |
26 | FIRMWARE_UPDATE_ENTITY = UpdateEntityDescription(
27 | key="firmware", name="Firmware", device_class=UpdateDeviceClass.FIRMWARE
28 | )
29 |
30 |
31 | async def async_setup_entry(
32 | hass: HomeAssistant,
33 | entry: VivintConfigEntry,
34 | async_add_entities: AddEntitiesCallback,
35 | ) -> None:
36 | """Set up Vivint update platform."""
37 | hub: VivintHub = entry.runtime_data
38 | entities = [
39 | VivintUpdateEntity(
40 | device=alarm_panel, hub=hub, entity_description=FIRMWARE_UPDATE_ENTITY
41 | )
42 | for system in hub.account.systems
43 | if system.is_admin
44 | for alarm_panel in system.alarm_panels
45 | ]
46 | async_add_entities(entities, True)
47 |
48 |
49 | class VivintUpdateEntity(VivintBaseEntity, UpdateEntity):
50 | """A class that describes device update entities."""
51 |
52 | device: AlarmPanel
53 |
54 | _attr_supported_features = Feature.INSTALL | Feature.PROGRESS
55 |
56 | @property
57 | def in_progress(self) -> bool:
58 | """Update installation progress."""
59 | return self.device._AlarmPanel__panel.data["sus"] not in (
60 | "Idle",
61 | "Reboot Pending",
62 | )
63 |
64 | @property
65 | def installed_version(self) -> str:
66 | """Version installed and in use."""
67 | return self.device.software_version
68 |
69 | @property
70 | def should_poll(self) -> bool:
71 | """Set polling to True."""
72 | return True
73 |
74 | async def async_update(self) -> None:
75 | """Update the entity."""
76 | software_update = await self.device.get_software_update_details()
77 | if software_update.get("available"):
78 | latest_version = software_update["available_version"]
79 | else:
80 | latest_version = self.device.software_version
81 | self._attr_latest_version = latest_version
82 |
83 | async def async_install(
84 | self, version: str | None, backup: bool, **kwargs: Any
85 | ) -> None:
86 | """Install an update."""
87 | if (await self.device.get_software_update_details()).get("available"):
88 | if not await self.device.update_software():
89 | message = f"Unable to start firmware update on {self.device.name}"
90 | raise HomeAssistantError(message)
91 |
92 | async def async_added_to_hass(self) -> None:
93 | """Set up a listener for the entity."""
94 | await super().async_added_to_hass()
95 | self.async_on_remove(
96 | self.device._AlarmPanel__panel.on(
97 | UPDATE, lambda _: self.async_write_ha_state()
98 | )
99 | )
100 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
1 | name: Report an issue with the Vivint integration
2 | description: Report an issue with the Vivint integration.
3 | body:
4 | - type: markdown
5 | attributes:
6 | value: |
7 | Before submitting an issue:
8 | 1. Validate you are running the [latest version of the integration](https://github.com/natekspencer/hacs-vivint/releases)
9 | 2. Open the official Vivint app and test if the same functionality works there
10 | 3. Search [existing issues](https://github.com/natekspencer/hacs-vivint/issues) (open and recently closed) to see if what you are experiencing has already been reported
11 | - type: textarea
12 | validations:
13 | required: true
14 | attributes:
15 | label: The problem
16 | description: >-
17 | Describe the issue you are experiencing here. What were you trying to do and what happened?
18 |
19 | Provide a clear and concise description of what the problem is.
20 | - type: markdown
21 | attributes:
22 | value: |
23 | ## Environment
24 | - type: input
25 | id: version
26 | validations:
27 | required: true
28 | attributes:
29 | label: What version of Home Assistant Core are you running?
30 | placeholder: core-
31 | description: >
32 | Can be found in: [Settings ⇒ System ⇒ Repairs ⇒ Three Dots in Upper Right ⇒ System information](https://my.home-assistant.io/redirect/system_health/).
33 | [](https://my.home-assistant.io/redirect/system_health/)
34 | - type: input
35 | attributes:
36 | label: What was the last working version of Home Assistant Core?
37 | placeholder: core-
38 | description: >
39 | If known, otherwise leave blank.
40 | - type: input
41 | attributes:
42 | label: What version of the Vivint integration do you have installed?
43 | description: >
44 | Can be found in HACS
45 | [](https://my.home-assistant.io/redirect/hacs_repository/?owner=natekspencer&repository=hacs-vivint&category=integration)
46 | You can find the latest release at: https://github.com/natekspencer/hacs-vivint/releases
47 | If you do not have the latest version installed, do not submit a ticket until you have updated.
48 | If that still does not work, then proceed with creating a ticket.
49 | - type: markdown
50 | attributes:
51 | value: |
52 | ## Details
53 | # - type: textarea
54 | # attributes:
55 | # label: Diagnostics information
56 | # placeholder: "drag-and-drop the diagnostics data file here (do not copy-and-paste the content)"
57 | # description: >-
58 | # This integration provides the ability to [download diagnostic data](https://www.home-assistant.io/docs/configuration/troubleshooting/#debug-logs-and-diagnostics).
59 |
60 | # **It would really help if you could download the diagnostics data for the device you are having issues with,
61 | # and drag-and-drop that file into the textbox below.**
62 |
63 | # It generally allows pinpointing defects and thus resolving issues faster.
64 | - type: textarea
65 | attributes:
66 | label: Anything in the logs that might be useful?
67 | description: >-
68 | For example, error message, or stack traces.
69 | If you [enable debug logging](https://www.home-assistant.io/docs/configuration/troubleshooting/#debug-logs-and-diagnostics), this will provide more details.
70 | render: txt
71 | - type: textarea
72 | attributes:
73 | label: Additional information
74 | description: >
75 | If you have any additional information, use the field below.
76 |
--------------------------------------------------------------------------------
/custom_components/vivint/device_trigger.py:
--------------------------------------------------------------------------------
1 | """Provides device triggers for Vivint."""
2 |
3 | from __future__ import annotations
4 |
5 | from typing import Any
6 |
7 | from vivintpy.devices import VivintDevice
8 | from vivintpy.devices.camera import DOORBELL_DING, MOTION_DETECTED, Camera
9 | from vivintpy.enums import CapabilityCategoryType
10 | import voluptuous as vol
11 |
12 | from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA
13 | from homeassistant.components.homeassistant.triggers import event as event_trigger
14 | from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE
15 | from homeassistant.core import CALLBACK_TYPE, HomeAssistant
16 | from homeassistant.helpers import config_entry_flow, device_registry as dr
17 | from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
18 | from homeassistant.helpers.typing import ConfigType
19 |
20 | from . import VivintConfigEntry
21 | from .const import DOMAIN, EVENT_TYPE
22 | from .hub import VivintHub
23 |
24 | TRIGGER_TYPES = {MOTION_DETECTED, DOORBELL_DING}
25 |
26 | TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
27 | {
28 | vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES),
29 | }
30 | )
31 |
32 |
33 | async def async_get_vivint_device(
34 | hass: HomeAssistant, device_id: str
35 | ) -> VivintDevice | None:
36 | """Get a Vivint device for the given device registry id."""
37 | dev_reg = dr.async_get(hass)
38 | if not (device_entry := dev_reg.async_get(device_id)):
39 | raise ValueError(f"Device ID {device_id} is not valid")
40 |
41 | identifiers = device_entry.identifiers
42 | if not (identifier := next((id[1] for id in identifiers if id[0] == DOMAIN), None)):
43 | return None
44 |
45 | [panel_id, vivint_device_id] = [int(item) for item in identifier.split("-")]
46 | for config_entry_id in device_entry.config_entries:
47 | config_entry: VivintConfigEntry | None
48 | if not (config_entry := hass.config_entries.async_get_entry(config_entry_id)):
49 | continue
50 |
51 | hub: VivintHub = config_entry.runtime_data
52 | for system in hub.account.systems:
53 | if system.id != panel_id:
54 | continue
55 | for alarm_panel in system.alarm_panels:
56 | for device in alarm_panel.devices:
57 | if device.id == vivint_device_id:
58 | return device
59 | return None
60 |
61 |
62 | async def async_get_triggers(
63 | hass: HomeAssistant, device_id: str
64 | ) -> list[dict[str, Any]]:
65 | """List device triggers for Vivint devices."""
66 | device = await async_get_vivint_device(hass, device_id)
67 |
68 | triggers = []
69 |
70 | if device and isinstance(device, Camera):
71 | triggers.append(
72 | {
73 | CONF_PLATFORM: "device",
74 | CONF_DOMAIN: DOMAIN,
75 | CONF_DEVICE_ID: device_id,
76 | CONF_TYPE: MOTION_DETECTED,
77 | }
78 | )
79 | if CapabilityCategoryType.DOORBELL in device.capabilities:
80 | triggers.append(
81 | {
82 | CONF_PLATFORM: "device",
83 | CONF_DOMAIN: DOMAIN,
84 | CONF_DEVICE_ID: device_id,
85 | CONF_TYPE: DOORBELL_DING,
86 | }
87 | )
88 |
89 | return triggers
90 |
91 |
92 | async def async_attach_trigger(
93 | hass: HomeAssistant,
94 | config: ConfigType,
95 | action: TriggerActionType,
96 | automation_info: TriggerInfo,
97 | ) -> CALLBACK_TYPE:
98 | """Attach a trigger."""
99 | event_config = event_trigger.TRIGGER_SCHEMA(
100 | {
101 | event_trigger.CONF_PLATFORM: "event",
102 | event_trigger.CONF_EVENT_TYPE: EVENT_TYPE,
103 | event_trigger.CONF_EVENT_DATA: {
104 | CONF_DEVICE_ID: config[CONF_DEVICE_ID],
105 | CONF_TYPE: config[CONF_TYPE],
106 | },
107 | }
108 | )
109 | return await event_trigger.async_attach_trigger(
110 | hass, event_config, action, automation_info, platform_type="device"
111 | )
112 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 | 
3 | 
4 | [](https://ko-fi.com/natekspencer)
5 |
6 |
7 |
8 |
9 |
10 |
11 | # Vivint for Home Assistant
12 |
13 | Home Assistant integration for a Vivint home security system.
14 |
15 | # Installation
16 |
17 | There are two main ways to install this custom component within your Home Assistant instance:
18 |
19 | 1. Using HACS (see https://hacs.xyz/ for installation instructions if you do not already have it installed):
20 |
21 | 1. From within Home Assistant, click on the link to **HACS**
22 | 2. Click on **Integrations**
23 | 3. Click on the vertical ellipsis in the top right and select **Custom repositories**
24 | 4. Enter the URL for this repository in the section that says _Add custom repository URL_ and select **Integration** in the _Category_ dropdown list
25 | 5. Click the **ADD** button
26 | 6. Close the _Custom repositories_ window
27 | 7. You should now be able to see the _Vivint_ card on the HACS Integrations page. Click on **INSTALL** and proceed with the installation instructions.
28 | 8. Restart your Home Assistant instance and then proceed to the _Configuration_ section below.
29 |
30 | 2. Manual Installation:
31 | 1. Download or clone this repository
32 | 2. Copy the contents of the folder **custom_components/vivint** into the same file structure on your Home Assistant instance
33 | - An easy way to do this is using the [Samba add-on](https://www.home-assistant.io/getting-started/configuration/#editing-configuration-via-sambawindows-networking), but feel free to do so however you want
34 | 3. Restart your Home Assistant instance and then proceed to the _Configuration_ section below.
35 |
36 | While the manual installation above seems like less steps, it's important to note that you will not be able to see updates to this custom component unless you are subscribed to the watch list. You will then have to repeat each step in the process. By using HACS, you'll be able to see that an update is available and easily update the custom component.
37 |
38 | # Configuration
39 |
40 | There is a config flow for this Vivint integration. After installing the custom component:
41 |
42 | 1. Go to **Configuration**->**Integrations**
43 | 2. Click **+ ADD INTEGRATION** to setup a new integration
44 | 3. Search for **Vivint** and click on it
45 | 4. You will be guided through the rest of the setup process via the config flow
46 |
47 | # Options
48 |
49 | After this integration is set up, you can configure a couple of options relating to the camera streams:
50 |
51 | - **HD Stream** - indicates whether to stream the camera in high definition or not, defaults to `True`
52 | - **RTSP Stream** - which RTSP stream source to use, defaults to `Direct`. Can be one of:
53 | - _Direct_ - falls back to the internal RTSP stream if direct access is unavailable
54 | - _Internal_ - use this if, for some reason, you have a camera that doesn't seem to stream despite the Vivint API indicating direct access is available for it
55 | - _External_ - use this option if your Vivint system and Home Assistant installation are on separate networks without access to each other
56 |
57 | ---
58 |
59 | ## Support Me
60 |
61 | I'm not employed by Vivint, and provide this custom component purely for your own enjoyment and home automation needs.
62 |
63 | If you don't already own a Vivint system, please consider using [my referal code (kaf164)](https://www.vivint.com/get?refCode=kaf164&exid=165211vivint.com/get?refCode=kaf164&exid=165211) to get $50 off your bill (as well as a tip to me in appreciation)!
64 |
65 | If you already own a Vivint system and still want to donate, consider buying me a coffee ☕ (or beer 🍺) instead by using the link below:
66 |
67 |
68 |
--------------------------------------------------------------------------------
/custom_components/vivint/alarm_control_panel.py:
--------------------------------------------------------------------------------
1 | """Support for Vivint alarm control panel."""
2 |
3 | from __future__ import annotations
4 |
5 | from typing import Iterable
6 |
7 | from vivintpy.devices.alarm_panel import AlarmPanel
8 | from vivintpy.enums import ArmedState
9 |
10 | from homeassistant.components.alarm_control_panel import (
11 | DOMAIN as PLATFORM,
12 | AlarmControlPanelEntity,
13 | AlarmControlPanelEntityFeature as Feature,
14 | AlarmControlPanelState,
15 | CodeFormat,
16 | )
17 | from homeassistant.core import HomeAssistant
18 | from homeassistant.helpers import entity_registry as er
19 | from homeassistant.helpers.entity_platform import AddEntitiesCallback
20 |
21 | from . import VivintConfigEntry
22 | from .const import CONF_DISARM_CODE, DOMAIN
23 | from .hub import VivintEntity, VivintHub
24 |
25 | ARMED_STATE_MAP = {
26 | ArmedState.DISARMED: AlarmControlPanelState.DISARMED,
27 | ArmedState.ARMING_AWAY_IN_EXIT_DELAY: AlarmControlPanelState.ARMING,
28 | ArmedState.ARMING_STAY_IN_EXIT_DELAY: AlarmControlPanelState.ARMING,
29 | ArmedState.ARMED_STAY: AlarmControlPanelState.ARMED_HOME,
30 | ArmedState.ARMED_AWAY: AlarmControlPanelState.ARMED_AWAY,
31 | ArmedState.ARMED_STAY_IN_ENTRY_DELAY: AlarmControlPanelState.PENDING,
32 | ArmedState.ARMED_AWAY_IN_ENTRY_DELAY: AlarmControlPanelState.PENDING,
33 | ArmedState.ALARM: AlarmControlPanelState.TRIGGERED,
34 | ArmedState.ALARM_FIRE: AlarmControlPanelState.TRIGGERED,
35 | ArmedState.DISABLED: AlarmControlPanelState.DISARMED,
36 | ArmedState.WALK_TEST: AlarmControlPanelState.DISARMED,
37 | }
38 |
39 |
40 | async def async_setup_entry(
41 | hass: HomeAssistant,
42 | entry: VivintConfigEntry,
43 | async_add_entities: AddEntitiesCallback,
44 | ) -> None:
45 | """Set up Vivint alarm control panel using config entry."""
46 | entities = []
47 | hub: VivintHub = entry.runtime_data
48 | disarm_code = entry.options.get(CONF_DISARM_CODE)
49 |
50 | for system in hub.account.systems:
51 | for device in system.alarm_panels:
52 | entities.append(
53 | VivintAlarmControlPanelEntity(
54 | device=device, hub=hub, disarm_code=disarm_code
55 | )
56 | )
57 |
58 | if not entities:
59 | return
60 |
61 | # Migrate unique ids
62 | async_update_unique_id(hass, PLATFORM, entities)
63 |
64 | async_add_entities(entities)
65 |
66 |
67 | class VivintAlarmControlPanelEntity(VivintEntity, AlarmControlPanelEntity):
68 | """Vivint Alarm Control Panel."""
69 |
70 | device: AlarmPanel
71 |
72 | _attr_changed_by = None
73 | _attr_code_arm_required = False
74 | _attr_supported_features = Feature.ARM_HOME | Feature.ARM_AWAY | Feature.TRIGGER
75 |
76 | def __init__(
77 | self, device: AlarmPanel, hub: VivintHub, disarm_code: str | None
78 | ) -> None:
79 | """Create the entity."""
80 | super().__init__(device, hub)
81 | self._attr_unique_id = str(self.device.id)
82 | if disarm_code:
83 | self._attr_code_format = CodeFormat.NUMBER
84 | self._disarm_code = disarm_code
85 |
86 | @property
87 | def alarm_state(self) -> AlarmControlPanelState | None:
88 | """Return the current alarm control panel entity state."""
89 | return ARMED_STATE_MAP.get(self.device.state)
90 |
91 | async def async_alarm_disarm(self, code: str | None = None) -> None:
92 | """Send disarm command."""
93 | if not self.code_format or code == self._disarm_code:
94 | await self.device.disarm()
95 |
96 | async def async_alarm_arm_home(self, code: str | None = None) -> None:
97 | """Send arm home command."""
98 | await self.device.arm_stay()
99 |
100 | async def async_alarm_arm_away(self, code: str | None = None) -> None:
101 | """Send arm away command."""
102 | await self.device.arm_away()
103 |
104 | async def async_alarm_trigger(self, code: str | None = None) -> None:
105 | """Send alarm trigger command."""
106 | await self.device.trigger_alarm()
107 |
108 |
109 | # to be removed 2025-01
110 | def async_update_unique_id(
111 | hass: HomeAssistant, domain: str, entities: Iterable[VivintAlarmControlPanelEntity]
112 | ) -> None:
113 | """Update unique ID to be based on VIN and entity description key instead of name."""
114 | ent_reg = er.async_get(hass)
115 | for entity in entities:
116 | old_unique_id = int(entity.unique_id)
117 | if entity_id := ent_reg.async_get_entity_id(domain, DOMAIN, old_unique_id):
118 | if existing_entity_id := ent_reg.async_get_entity_id(
119 | domain, DOMAIN, entity.unique_id
120 | ):
121 | ent_reg.async_remove(existing_entity_id)
122 | ent_reg.async_update_entity(entity_id, new_unique_id=entity.unique_id)
123 |
--------------------------------------------------------------------------------
/custom_components/vivint/camera.py:
--------------------------------------------------------------------------------
1 | """Support for Vivint cameras."""
2 |
3 | from __future__ import annotations
4 |
5 | import logging
6 |
7 | from vivintpy.devices.camera import Camera as VivintCamera
8 |
9 | from homeassistant.components.camera import Camera, CameraEntityFeature
10 | from homeassistant.components.ffmpeg import async_get_image
11 | from homeassistant.core import HomeAssistant
12 | from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
13 | from homeassistant.helpers.entity_platform import AddEntitiesCallback
14 |
15 | from . import VivintConfigEntry
16 | from .const import (
17 | CONF_HD_STREAM,
18 | CONF_RTSP_STREAM,
19 | CONF_RTSP_URL_LOGGING,
20 | DEFAULT_HD_STREAM,
21 | DEFAULT_RTSP_STREAM,
22 | DEFAULT_RTSP_URL_LOGGING,
23 | RTSP_STREAM_DIRECT,
24 | RTSP_STREAM_INTERNAL,
25 | )
26 | from .hub import VivintEntity, VivintHub
27 |
28 | _LOGGER = logging.getLogger(__name__)
29 |
30 |
31 | async def async_setup_entry(
32 | hass: HomeAssistant,
33 | entry: VivintConfigEntry,
34 | async_add_entities: AddEntitiesCallback,
35 | ) -> None:
36 | """Set up Vivint cameras using config entry."""
37 | entities = []
38 | hub: VivintHub = entry.runtime_data
39 |
40 | hd_stream = entry.options.get(CONF_HD_STREAM, DEFAULT_HD_STREAM)
41 | rtsp_stream = entry.options.get(CONF_RTSP_STREAM, DEFAULT_RTSP_STREAM)
42 | rtsp_url_logging = entry.options.get(
43 | CONF_RTSP_URL_LOGGING, DEFAULT_RTSP_URL_LOGGING
44 | )
45 |
46 | for system in hub.account.systems:
47 | for alarm_panel in system.alarm_panels:
48 | for device in alarm_panel.devices:
49 | if isinstance(device, VivintCamera):
50 | if rtsp_url_logging:
51 | await log_rtsp_urls(device)
52 |
53 | entities.append(
54 | VivintCameraEntity(
55 | device=device,
56 | hub=hub,
57 | hd_stream=hd_stream,
58 | rtsp_stream=rtsp_stream,
59 | )
60 | )
61 |
62 | if not entities:
63 | return
64 |
65 | async_add_entities(entities)
66 |
67 |
68 | async def log_rtsp_urls(device: VivintCamera) -> None:
69 | """Logs the rtsp urls of a Vivint camera."""
70 | _LOGGER.info(
71 | "%s rtsp urls:\n direct hd: %s\n direct sd: %s\n internal hd: %s\n internal sd: %s\n external hd: %s\n external sd: %s",
72 | device.name,
73 | await device.get_direct_rtsp_url(hd=True),
74 | await device.get_direct_rtsp_url(hd=False),
75 | await device.get_rtsp_url(internal=True, hd=True),
76 | await device.get_rtsp_url(internal=True, hd=False),
77 | await device.get_rtsp_url(internal=False, hd=True),
78 | await device.get_rtsp_url(internal=False, hd=False),
79 | )
80 |
81 |
82 | class VivintCameraEntity(VivintEntity, Camera):
83 | """Vivint camera entity."""
84 |
85 | device: VivintCamera
86 |
87 | _attr_supported_features = CameraEntityFeature.STREAM
88 |
89 | def __init__(
90 | self,
91 | device: VivintCamera,
92 | hub: VivintHub,
93 | hd_stream: bool = DEFAULT_HD_STREAM,
94 | rtsp_stream: int = DEFAULT_RTSP_STREAM,
95 | ) -> None:
96 | """Initialize a Vivint camera."""
97 | super().__init__(device=device, hub=hub)
98 | Camera.__init__(self)
99 |
100 | self._attr_device_info.setdefault("connections", set()).add(
101 | (CONNECTION_NETWORK_MAC, format_mac(device.mac_address))
102 | )
103 |
104 | self.__hd_stream = hd_stream
105 | self.__rtsp_stream = rtsp_stream
106 | self.__last_image = None
107 |
108 | @property
109 | def unique_id(self) -> str:
110 | """Return a unique ID."""
111 | return f"{self.device.alarm_panel.id}-{self.device.id}"
112 |
113 | async def stream_source(self) -> str | None:
114 | """Return the source of the stream."""
115 | await self.device.alarm_panel.get_panel_credentials()
116 | url = self.device.get_rtsp_access_url(self.__rtsp_stream, self.__hd_stream)
117 | if not url and self.__rtsp_stream == RTSP_STREAM_DIRECT:
118 | url = self.device.get_rtsp_access_url(
119 | RTSP_STREAM_INTERNAL, self.__hd_stream
120 | )
121 | return url
122 |
123 | async def async_camera_image(
124 | self, width: int | None = None, height: int | None = None
125 | ) -> bytes | None:
126 | """Return a frame from the camera stream."""
127 | try:
128 | self.__last_image = await async_get_image(
129 | hass=self.hass,
130 | input_source=await self.stream_source(),
131 | width=width,
132 | height=height,
133 | )
134 | except: # pylint:disable=bare-except
135 | _LOGGER.debug("Could not retrieve latest image for %s", self.name)
136 |
137 | return self.__last_image
138 |
--------------------------------------------------------------------------------
/custom_components/vivint/switch.py:
--------------------------------------------------------------------------------
1 | """Support for Vivint switches."""
2 |
3 | from __future__ import annotations
4 |
5 | from collections.abc import Callable
6 | from dataclasses import dataclass
7 | from typing import Any
8 |
9 | from vivintpy.devices.camera import Camera
10 | from vivintpy.devices.switch import BinarySwitch
11 | from vivintpy.enums import (
12 | CapabilityCategoryType as Category,
13 | CapabilityType as Capability,
14 | FeatureType as Feature,
15 | )
16 |
17 | from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
18 | from homeassistant.core import HomeAssistant
19 | from homeassistant.helpers.entity import EntityCategory
20 | from homeassistant.helpers.entity_platform import AddEntitiesCallback
21 |
22 | from . import VivintConfigEntry
23 | from .hub import VivintBaseEntity, VivintHub, has_capability, has_feature
24 |
25 |
26 | async def async_setup_entry(
27 | hass: HomeAssistant,
28 | entry: VivintConfigEntry,
29 | async_add_entities: AddEntitiesCallback,
30 | ) -> None:
31 | """Set up Vivint switches using config entry."""
32 | entities = []
33 | hub: VivintHub = entry.runtime_data
34 |
35 | for system in hub.account.systems:
36 | for alarm_panel in system.alarm_panels:
37 | for device in alarm_panel.devices:
38 | if isinstance(device, BinarySwitch):
39 | entities.append(
40 | VivintSwitchEntity(
41 | device=device, hub=hub, entity_description=IS_ON
42 | )
43 | )
44 | if has_capability(device, Category.CAMERA, Capability.CHIME_EXTENDER):
45 | entities.append(
46 | VivintSwitchEntity(
47 | device=device,
48 | hub=hub,
49 | entity_description=CAMERA_CHIME_EXTENDER,
50 | )
51 | )
52 | if has_capability(device, Category.CAMERA, Capability.PRIVACY_MODE):
53 | entities.append(
54 | VivintSwitchEntity(
55 | device=device, hub=hub, entity_description=PRIVACY_MODE
56 | )
57 | )
58 | if has_feature(device, Feature.DETER):
59 | entities.append(
60 | VivintSwitchEntity(
61 | device=device, hub=hub, entity_description=DETER_MODE
62 | )
63 | )
64 |
65 | if not entities:
66 | return
67 |
68 | async_add_entities(entities)
69 |
70 |
71 | @dataclass
72 | class VivintSwitchMixin:
73 | """Vivint switch required keys."""
74 |
75 | is_on: Callable[[BinarySwitch | Camera], bool | None]
76 | turn_on: Callable[[BinarySwitch | Camera], bool | None]
77 | turn_off: Callable[[BinarySwitch | Camera], bool | None]
78 |
79 |
80 | @dataclass
81 | class VivintSwitchEntityDescription(SwitchEntityDescription, VivintSwitchMixin):
82 | """Vivint binary sensor entity description."""
83 |
84 |
85 | IS_ON = VivintSwitchEntityDescription(
86 | key="is_on",
87 | is_on=lambda device: device.is_on,
88 | turn_on=lambda device: device.turn_on(),
89 | turn_off=lambda device: device.turn_off(),
90 | )
91 | CAMERA_CHIME_EXTENDER = VivintSwitchEntityDescription(
92 | key="chime_extender",
93 | entity_category=EntityCategory.CONFIG,
94 | name="Chime extender",
95 | is_on=lambda device: device.extend_chime_enabled,
96 | turn_on=lambda device: device.set_as_doorbell_chime_extender(True),
97 | turn_off=lambda device: device.set_as_doorbell_chime_extender(False),
98 | )
99 | PRIVACY_MODE = VivintSwitchEntityDescription(
100 | key="privacy_mode",
101 | entity_category=EntityCategory.CONFIG,
102 | name="Privacy mode",
103 | is_on=lambda device: device.is_in_privacy_mode,
104 | turn_on=lambda device: device.set_privacy_mode(True),
105 | turn_off=lambda device: device.set_privacy_mode(False),
106 | )
107 | DETER_MODE = VivintSwitchEntityDescription(
108 | key="deter_mode",
109 | entity_category=EntityCategory.CONFIG,
110 | name="Deter Mode",
111 | is_on=lambda device: device.is_in_deter_mode,
112 | turn_on=lambda device: device.set_deter_mode(True),
113 | turn_off=lambda device: device.set_deter_mode(False),
114 | )
115 |
116 |
117 | class VivintSwitchEntity(VivintBaseEntity, SwitchEntity):
118 | """Vivint Switch."""
119 |
120 | device: BinarySwitch | Camera
121 | entity_description: VivintSwitchEntityDescription
122 |
123 | @property
124 | def is_on(self) -> bool:
125 | """Return True if the switch is on."""
126 | return self.entity_description.is_on(self.device)
127 |
128 | async def async_turn_on(self, **kwargs: Any) -> None:
129 | """Turn on the switch."""
130 | await self.entity_description.turn_on(self.device)
131 |
132 | async def async_turn_off(self, **kwargs: Any) -> None:
133 | """Turn off the switch."""
134 | await self.entity_description.turn_off(self.device)
135 |
--------------------------------------------------------------------------------
/custom_components/vivint/config_flow.py:
--------------------------------------------------------------------------------
1 | """Config flow for Vivint integration."""
2 |
3 | from __future__ import annotations
4 |
5 | import logging
6 | from typing import Any
7 |
8 | from aiohttp import ClientResponseError
9 | from aiohttp.client_exceptions import ClientConnectorError
10 | from vivintpy.exceptions import (
11 | VivintSkyApiAuthenticationError,
12 | VivintSkyApiError,
13 | VivintSkyApiMfaRequiredError,
14 | )
15 | import voluptuous as vol
16 |
17 | from homeassistant.config_entries import ConfigEntry, ConfigFlow
18 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
19 | from homeassistant.core import callback
20 | from homeassistant.data_entry_flow import FlowResult
21 | from homeassistant.helpers import config_validation as cv
22 | from homeassistant.helpers.schema_config_entry_flow import (
23 | SchemaCommonFlowHandler,
24 | SchemaFlowError,
25 | SchemaFlowFormStep,
26 | SchemaOptionsFlowHandler,
27 | )
28 |
29 | from .const import (
30 | CONF_DISARM_CODE,
31 | CONF_HD_STREAM,
32 | CONF_MFA,
33 | CONF_REFRESH_TOKEN,
34 | CONF_RTSP_STREAM,
35 | CONF_RTSP_URL_LOGGING,
36 | DEFAULT_HD_STREAM,
37 | DEFAULT_RTSP_STREAM,
38 | DEFAULT_RTSP_URL_LOGGING,
39 | DOMAIN,
40 | RTSP_STREAM_TYPES,
41 | )
42 | from .hub import VivintHub
43 |
44 | _LOGGER = logging.getLogger(__name__)
45 |
46 |
47 | async def _validate_options(
48 | handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
49 | ) -> dict[str, Any]:
50 | """Validate options config."""
51 | try:
52 | cv.matches_regex("^[0-9]*$")(user_input[CONF_DISARM_CODE])
53 | except vol.Invalid as exc:
54 | raise SchemaFlowError("disarm_code_invalid") from exc
55 |
56 | return user_input
57 |
58 |
59 | STEP_USER_DATA_SCHEMA = vol.Schema(
60 | {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
61 | )
62 | STEP_MFA_DATA_SCHEMA = vol.Schema({vol.Required(CONF_MFA): str})
63 | OPTIONS_SCHEMA = vol.Schema(
64 | {
65 | vol.Optional(CONF_DISARM_CODE, default=""): str,
66 | vol.Optional(CONF_HD_STREAM, default=DEFAULT_HD_STREAM): bool,
67 | vol.Optional(CONF_RTSP_STREAM, default=DEFAULT_RTSP_STREAM): vol.In(
68 | RTSP_STREAM_TYPES
69 | ),
70 | vol.Optional(CONF_RTSP_URL_LOGGING, default=DEFAULT_RTSP_URL_LOGGING): bool,
71 | }
72 | )
73 | OPTIONS_FLOW = {
74 | "init": SchemaFlowFormStep(OPTIONS_SCHEMA, validate_user_input=_validate_options)
75 | }
76 |
77 |
78 | class VivintConfigFlow(ConfigFlow, domain=DOMAIN):
79 | """Handle a config flow for Vivint."""
80 |
81 | VERSION = 1
82 | MINOR_VERSION = 2
83 |
84 | def __init__(self) -> None:
85 | """Initialize a config flow."""
86 | self._hub: VivintHub = None
87 |
88 | async def _async_create_entry(self) -> FlowResult:
89 | """Create the config entry."""
90 | existing_entry = await self.async_set_unique_id(DOMAIN)
91 |
92 | # pylint: disable=protected-access
93 | config_data = {
94 | CONF_USERNAME: self._hub._data[CONF_USERNAME],
95 | CONF_PASSWORD: self._hub._data[CONF_PASSWORD],
96 | CONF_REFRESH_TOKEN: self._hub.account.refresh_token,
97 | }
98 |
99 | await self._hub.disconnect()
100 | if existing_entry:
101 | self.hass.config_entries.async_update_entry(
102 | existing_entry, data=config_data
103 | )
104 | await self.hass.config_entries.async_reload(existing_entry.entry_id)
105 | return self.async_abort(reason="reauth_successful")
106 |
107 | return self.async_create_entry(
108 | title=config_data[CONF_USERNAME], data=config_data
109 | )
110 |
111 | async def async_vivint_login(
112 | self, step_id, user_input: dict[str, Any] | None, schema: vol.Schema
113 | ) -> FlowResult:
114 | """Attempt a login with Vivint."""
115 | errors = {}
116 |
117 | self._hub = VivintHub(self.hass, user_input)
118 | try:
119 | await self._hub.login(load_devices=True)
120 | except VivintSkyApiMfaRequiredError:
121 | return await self.async_step_mfa()
122 | except VivintSkyApiAuthenticationError:
123 | errors["base"] = "invalid_auth"
124 | except (VivintSkyApiError, ClientResponseError, ClientConnectorError):
125 | errors["base"] = "cannot_connect"
126 | except Exception: # pylint: disable=broad-except
127 | _LOGGER.exception("Unexpected exception")
128 | errors["base"] = "unknown"
129 |
130 | if not errors:
131 | return await self._async_create_entry()
132 |
133 | return self.async_show_form(step_id=step_id, data_schema=schema, errors=errors)
134 |
135 | async def async_step_user(
136 | self, user_input: dict[str, Any] | None = None
137 | ) -> FlowResult:
138 | """Handle the initial step."""
139 | errors = {}
140 |
141 | if user_input is not None:
142 | for entry in self._async_current_entries():
143 | if entry.data[CONF_USERNAME] == user_input[CONF_USERNAME]:
144 | return self.async_abort(reason="already_configured")
145 |
146 | return await self.async_vivint_login(
147 | step_id="user", user_input=user_input, schema=STEP_USER_DATA_SCHEMA
148 | )
149 |
150 | return self.async_show_form(
151 | step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
152 | )
153 |
154 | async def async_step_mfa(
155 | self, user_input: dict[str, Any] | None = None
156 | ) -> FlowResult:
157 | """Handle a multi-factor authentication (MFA) flow."""
158 | await super().async_step_user()
159 | if user_input is None:
160 | return self.async_show_form(step_id="mfa", data_schema=STEP_MFA_DATA_SCHEMA)
161 |
162 | try:
163 | await self._hub.verify_mfa(user_input[CONF_MFA])
164 | except VivintSkyApiAuthenticationError as err: # pylint: disable=broad-except
165 | _LOGGER.error(err)
166 | return self.async_show_form(
167 | step_id="mfa",
168 | data_schema=STEP_MFA_DATA_SCHEMA,
169 | errors={"base": str(err)},
170 | )
171 | except Exception as ex: # pylint: disable=broad-except
172 | _LOGGER.error(ex)
173 | return self.async_show_form(
174 | step_id="mfa",
175 | data_schema=STEP_MFA_DATA_SCHEMA,
176 | errors={"base": "unknown"},
177 | )
178 |
179 | return await self._async_create_entry()
180 |
181 | async def async_step_reauth(
182 | self, user_input: dict[str, Any] | None = None
183 | ) -> FlowResult:
184 | """Perform reauth upon an API authentication error."""
185 | return await self.async_step_reauth_confirm(user_input)
186 |
187 | async def async_step_reauth_confirm(
188 | self, user_input: dict[str, Any] | None = None
189 | ) -> FlowResult:
190 | """Dialog that informs the user that reauth is required."""
191 | if user_input is None:
192 | return self.async_show_form(
193 | step_id="reauth_confirm", data_schema=STEP_USER_DATA_SCHEMA
194 | )
195 |
196 | return await self.async_vivint_login(
197 | step_id="reauth_confirm",
198 | user_input=user_input,
199 | schema=STEP_USER_DATA_SCHEMA,
200 | )
201 |
202 | @staticmethod
203 | @callback
204 | def async_get_options_flow(entry: ConfigEntry) -> SchemaOptionsFlowHandler:
205 | """Get the options flow for this handler."""
206 | return SchemaOptionsFlowHandler(entry, OPTIONS_FLOW)
207 |
--------------------------------------------------------------------------------
/custom_components/vivint/__init__.py:
--------------------------------------------------------------------------------
1 | """The Vivint integration."""
2 |
3 | import logging
4 | import os
5 |
6 | from aiohttp import ClientResponseError
7 | from aiohttp.client_exceptions import ClientConnectorError
8 | from vivintpy.devices import VivintDevice
9 | from vivintpy.devices.alarm_panel import DEVICE_DELETED, DEVICE_DISCOVERED
10 | from vivintpy.devices.camera import DOORBELL_DING, MOTION_DETECTED, Camera
11 | from vivintpy.devices.wireless_sensor import WirelessSensor
12 | from vivintpy.enums import CapabilityCategoryType
13 | from vivintpy.exceptions import (
14 | VivintSkyApiAuthenticationError,
15 | VivintSkyApiError,
16 | VivintSkyApiMfaRequiredError,
17 | )
18 |
19 | from homeassistant.config_entries import ConfigEntry
20 | from homeassistant.const import (
21 | ATTR_DEVICE_ID,
22 | ATTR_DOMAIN,
23 | EVENT_HOMEASSISTANT_STOP,
24 | Platform,
25 | )
26 | from homeassistant.core import Event, HomeAssistant, callback
27 | from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
28 | from homeassistant.helpers import device_registry
29 | from homeassistant.helpers.dispatcher import async_dispatcher_send
30 |
31 | from .const import CONF_REFRESH_TOKEN, DOMAIN, EVENT_TYPE
32 | from .hub import VivintHub, get_device_id
33 |
34 | type VivintConfigEntry = ConfigEntry[VivintHub]
35 |
36 |
37 | _LOGGER = logging.getLogger(__name__)
38 |
39 | PLATFORMS = [
40 | Platform.ALARM_CONTROL_PANEL,
41 | Platform.BINARY_SENSOR,
42 | Platform.BUTTON,
43 | Platform.CAMERA,
44 | Platform.CLIMATE,
45 | Platform.COVER,
46 | Platform.EVENT,
47 | Platform.LIGHT,
48 | Platform.LOCK,
49 | Platform.SENSOR,
50 | Platform.SWITCH,
51 | Platform.UPDATE,
52 | ]
53 |
54 | ATTR_TYPE = "type"
55 |
56 |
57 | async def async_setup_entry(hass: HomeAssistant, entry: VivintConfigEntry) -> bool:
58 | """Set up Vivint from a config entry."""
59 | undo_listener = entry.add_update_listener(update_listener)
60 |
61 | hub = VivintHub(hass, entry.data, undo_listener)
62 | entry.runtime_data = hub
63 |
64 | try:
65 | await hub.login(load_devices=True, subscribe_for_realtime_updates=True)
66 | except (VivintSkyApiMfaRequiredError, VivintSkyApiAuthenticationError) as ex:
67 | raise ConfigEntryAuthFailed(ex) from ex
68 | except (VivintSkyApiError, ClientResponseError, ClientConnectorError) as ex:
69 | raise ConfigEntryNotReady(ex) from ex
70 |
71 | dev_reg = device_registry.async_get(hass)
72 |
73 | @callback
74 | def async_on_device_discovered(device: VivintDevice) -> None:
75 | if getattr(device, "battery_level", None) is not None:
76 | async_dispatcher_send(hass, f"{DOMAIN}_{entry.entry_id}_add_sensor", device)
77 | if isinstance(device, WirelessSensor):
78 | async_dispatcher_send(
79 | hass, f"{DOMAIN}_{entry.entry_id}_add_binary_sensor", device
80 | )
81 |
82 | @callback
83 | def async_on_device_deleted(device: VivintDevice) -> None:
84 | _LOGGER.debug("Device deleted: %s", device)
85 | device = dev_reg.async_get_device({get_device_id(device)})
86 | if device:
87 | dev_reg.async_remove_device(device.id)
88 |
89 | @callback
90 | def async_on_device_event(event_type: str, viv_device: VivintDevice) -> None:
91 | """Relay Vivint device event to hass."""
92 | device = dev_reg.async_get_device({get_device_id(viv_device)})
93 | hass.bus.async_fire(
94 | EVENT_TYPE,
95 | {
96 | ATTR_TYPE: event_type,
97 | ATTR_DOMAIN: DOMAIN,
98 | ATTR_DEVICE_ID: device.id,
99 | },
100 | )
101 |
102 | for system in hub.account.systems:
103 | for alarm_panel in system.alarm_panels:
104 | entry.async_on_unload(
105 | alarm_panel.on(
106 | DEVICE_DISCOVERED,
107 | lambda event: async_on_device_discovered(event["device"]),
108 | )
109 | )
110 | entry.async_on_unload(
111 | alarm_panel.on(
112 | DEVICE_DELETED,
113 | lambda event: async_on_device_deleted(event["device"]),
114 | )
115 | )
116 | for device in alarm_panel.get_devices([Camera]):
117 | entry.async_on_unload(
118 | device.on(
119 | MOTION_DETECTED,
120 | lambda event: async_on_device_event(
121 | MOTION_DETECTED, event["device"]
122 | ),
123 | )
124 | )
125 | if CapabilityCategoryType.DOORBELL in device.capabilities:
126 | entry.async_on_unload(
127 | device.on(
128 | DOORBELL_DING,
129 | lambda event: async_on_device_event(
130 | DOORBELL_DING, event["device"]
131 | ),
132 | )
133 | )
134 |
135 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
136 |
137 | # Check for devices that no longer exist and remove them
138 | stored_devices = device_registry.async_entries_for_config_entry(
139 | dev_reg, entry.entry_id
140 | )
141 | alarm_panels = [
142 | alarm_panel
143 | for system in hub.account.systems
144 | for alarm_panel in system.alarm_panels
145 | ]
146 | all_devices = alarm_panels + [
147 | device for alarm_panel in alarm_panels for device in alarm_panel.devices
148 | ]
149 | known_devices = [
150 | dev_reg.async_get_device({get_device_id(device)}) for device in all_devices
151 | ]
152 |
153 | # Devices that are in the device registry that are not known by the hub can be removed
154 | for device in stored_devices:
155 | if device not in known_devices:
156 | dev_reg.async_remove_device(device.id)
157 |
158 | @callback
159 | async def _async_save_tokens(ev: Event) -> None:
160 | """Save tokens to the config entry data."""
161 | await entry.runtime_data.disconnect()
162 | hass.config_entries.async_update_entry(
163 | entry, data=entry.data | {CONF_REFRESH_TOKEN: hub.account.refresh_token}
164 | )
165 |
166 | hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_save_tokens)
167 |
168 | return True
169 |
170 |
171 | async def async_unload_entry(hass: HomeAssistant, entry: VivintConfigEntry) -> bool:
172 | """Unload config entry."""
173 | await entry.runtime_data.disconnect()
174 | return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
175 |
176 |
177 | async def async_migrate_entry(hass: HomeAssistant, entry: VivintConfigEntry) -> bool:
178 | """Migrate old entry."""
179 | _LOGGER.debug(
180 | "Migrating configuration from version %s.%s", entry.version, entry.minor_version
181 | )
182 |
183 | if entry.version > 1:
184 | # This means the user has downgraded from a future version
185 | return False
186 |
187 | if entry.version == 1:
188 | if entry.minor_version < 2:
189 | for filename in (".vivintpy_cache.pickle", ".vivintpy_cache_1.pickle"):
190 | try:
191 | os.remove(hass.config.path(filename))
192 | except Exception:
193 | pass
194 |
195 | hass.config_entries.async_update_entry(entry, minor_version=2)
196 |
197 | _LOGGER.debug(
198 | "Migration to version %s.%s successful",
199 | entry.version,
200 | entry.minor_version,
201 | )
202 |
203 | return True
204 |
205 |
206 | async def update_listener(hass: HomeAssistant, entry: VivintConfigEntry) -> None:
207 | """Handle options update."""
208 | await hass.config_entries.async_reload(entry.entry_id)
209 |
--------------------------------------------------------------------------------
/custom_components/vivint/hub.py:
--------------------------------------------------------------------------------
1 | """A wrapper 'hub' for the Vivint API and base entity for common attributes."""
2 |
3 | from __future__ import annotations
4 |
5 | import asyncio
6 | from collections.abc import Callable
7 | from datetime import timedelta
8 | import logging
9 |
10 | from aiohttp import ClientResponseError
11 | from aiohttp.client import ClientSession
12 | from aiohttp.client_exceptions import ClientConnectorError
13 | from vivintpy.account import Account
14 | from vivintpy.devices import VivintDevice
15 | from vivintpy.devices.alarm_panel import AlarmPanel
16 | from vivintpy.entity import UPDATE
17 | from vivintpy.enums import (
18 | CapabilityCategoryType as Category,
19 | CapabilityType as Capability,
20 | FeatureType as Feature,
21 | )
22 | from vivintpy.exceptions import (
23 | VivintSkyApiAuthenticationError,
24 | VivintSkyApiError,
25 | VivintSkyApiMfaRequiredError,
26 | )
27 |
28 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
29 | from homeassistant.core import HomeAssistant, callback
30 | from homeassistant.helpers.entity import DeviceInfo, EntityDescription
31 | from homeassistant.helpers.update_coordinator import (
32 | CoordinatorEntity,
33 | DataUpdateCoordinator,
34 | )
35 |
36 | from .const import CONF_REFRESH_TOKEN, DOMAIN
37 |
38 | _LOGGER = logging.getLogger(__name__)
39 |
40 | UPDATE_INTERVAL = 300
41 |
42 |
43 | @callback
44 | def get_device_id(device: VivintDevice) -> tuple[str, str]:
45 | """Get device registry identifier for device."""
46 | return (
47 | DOMAIN,
48 | f"{device.panel_id}-{device.parent.id if device.is_subdevice else device.id}",
49 | )
50 |
51 |
52 | def has_capability(device: VivintDevice, category: Category, capability: Capability):
53 | """Check if a device has a capability."""
54 | if capability in (device.capabilities or {}).get(category, []):
55 | return True
56 | return False
57 |
58 |
59 | def has_feature(device: VivintDevice, feature: Feature):
60 | """Check if a device has a feature."""
61 | return feature in (device.features or [])
62 |
63 |
64 | class VivintHub:
65 | """A Vivint hub wrapper class."""
66 |
67 | def __init__(
68 | self, hass: HomeAssistant, data: dict, undo_listener: Callable | None = None
69 | ) -> None:
70 | """Initialize the Vivint hub."""
71 | self._data = data
72 | self.__undo_listener = undo_listener
73 | self.account: Account = None
74 | self.logged_in = False
75 | self.session = ClientSession()
76 | self._lock = asyncio.Lock()
77 |
78 | async def _async_update_data() -> None:
79 | """Update all device states from the Vivint API."""
80 | return await self.account.refresh()
81 |
82 | self.coordinator = DataUpdateCoordinator(
83 | hass,
84 | _LOGGER,
85 | name=DOMAIN,
86 | update_method=_async_update_data,
87 | update_interval=timedelta(seconds=UPDATE_INTERVAL),
88 | )
89 |
90 | async def login(
91 | self, load_devices: bool = False, subscribe_for_realtime_updates: bool = False
92 | ) -> bool:
93 | """Login to Vivint."""
94 | self.logged_in = False
95 |
96 | self.account = Account(
97 | username=self._data[CONF_USERNAME],
98 | password=self._data[CONF_PASSWORD],
99 | refresh_token=self._data.get(CONF_REFRESH_TOKEN),
100 | client_session=self.session,
101 | )
102 | try:
103 | await self.account.connect(
104 | load_devices=load_devices,
105 | subscribe_for_realtime_updates=subscribe_for_realtime_updates,
106 | )
107 | return self.save_session()
108 | except VivintSkyApiMfaRequiredError as ex:
109 | raise ex
110 | except VivintSkyApiAuthenticationError as ex:
111 | _LOGGER.error("Invalid credentials")
112 | raise ex
113 | except (VivintSkyApiError, ClientResponseError, ClientConnectorError) as ex:
114 | _LOGGER.error("Unable to connect to the Vivint API")
115 | raise ex
116 |
117 | async def disconnect(self) -> None:
118 | """Disconnect from Vivint, close the session and stop listener."""
119 | async with self._lock:
120 | if self.account.connected:
121 | await self.account.disconnect()
122 | if not self.session.closed:
123 | await self.session.close()
124 | if self.__undo_listener:
125 | self.__undo_listener()
126 | self.__undo_listener = None
127 |
128 | async def verify_mfa(self, code: str) -> bool:
129 | """Verify MFA."""
130 | try:
131 | await self.account.verify_mfa(code)
132 | return self.save_session()
133 | except Exception as ex:
134 | raise ex
135 |
136 | def save_session(self) -> bool:
137 | """Save session for reuse."""
138 | self.logged_in = True
139 | return self.logged_in
140 |
141 |
142 | class VivintBaseEntity(CoordinatorEntity):
143 | """Generic Vivint entity representing common data and methods."""
144 |
145 | device: VivintDevice
146 |
147 | _attr_has_entity_name = True
148 |
149 | def __init__(
150 | self,
151 | device: VivintDevice,
152 | hub: VivintHub,
153 | entity_description: EntityDescription,
154 | ) -> None:
155 | """Pass coordinator to CoordinatorEntity."""
156 | super().__init__(hub.coordinator)
157 | self.device = device
158 | self.hub = hub
159 | self.entity_description = entity_description
160 |
161 | prefix = f"{device.alarm_panel.id}-" if device.alarm_panel else ""
162 | self._attr_unique_id = f"{prefix}{device.id}-{entity_description.key}"
163 | device = self.device.parent if self.device.is_subdevice else self.device
164 | self._attr_device_info = DeviceInfo(
165 | identifiers={get_device_id(device)},
166 | name=device.name if device.name else type(device).__name__,
167 | manufacturer=device.manufacturer,
168 | model=device.model,
169 | sw_version=device.software_version,
170 | via_device=(
171 | None
172 | if isinstance(device, AlarmPanel)
173 | else get_device_id(device.alarm_panel)
174 | ),
175 | )
176 |
177 | async def async_added_to_hass(self) -> None:
178 | """Set up a listener for the entity."""
179 | await super().async_added_to_hass()
180 | self.async_on_remove(
181 | self.device.on(UPDATE, lambda _: self.async_write_ha_state())
182 | )
183 |
184 |
185 | class VivintEntity(CoordinatorEntity):
186 | """Generic Vivint entity representing common data and methods."""
187 |
188 | device: VivintDevice
189 |
190 | def __init__(self, device: VivintDevice, hub: VivintHub) -> None:
191 | """Pass coordinator to CoordinatorEntity."""
192 | super().__init__(hub.coordinator)
193 | self.device = device
194 | self.hub = hub
195 |
196 | device = self.device.parent if self.device.is_subdevice else self.device
197 | self._attr_device_info = DeviceInfo(
198 | identifiers={get_device_id(device)},
199 | name=device.name if device.name else type(device).__name__,
200 | manufacturer=device.manufacturer,
201 | model=device.model,
202 | sw_version=device.software_version,
203 | via_device=(
204 | None
205 | if isinstance(device, AlarmPanel)
206 | else get_device_id(device.alarm_panel)
207 | ),
208 | )
209 |
210 | async def async_added_to_hass(self) -> None:
211 | """Set up a listener for the entity."""
212 | await super().async_added_to_hass()
213 | self.async_on_remove(
214 | self.device.on(UPDATE, lambda _: self.async_write_ha_state())
215 | )
216 |
217 | @property
218 | def name(self) -> str:
219 | """Return the name of this entity."""
220 | return self.device.name
221 |
--------------------------------------------------------------------------------
/custom_components/vivint/climate.py:
--------------------------------------------------------------------------------
1 | """Support for Vivint thermostats."""
2 |
3 | from __future__ import annotations
4 |
5 | from typing import Any
6 |
7 | from vivintpy.const import ThermostatAttribute
8 | from vivintpy.devices.thermostat import Thermostat
9 | from vivintpy.enums import (
10 | CapabilityCategoryType,
11 | CapabilityType,
12 | FanMode,
13 | OperatingMode,
14 | OperatingState,
15 | )
16 |
17 | from homeassistant.components.climate import (
18 | ATTR_TARGET_TEMP_HIGH,
19 | ATTR_TARGET_TEMP_LOW,
20 | FAN_AUTO,
21 | FAN_ON,
22 | ClimateEntity,
23 | ClimateEntityFeature,
24 | HVACAction,
25 | HVACMode,
26 | )
27 | from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
28 | from homeassistant.core import HomeAssistant
29 | from homeassistant.helpers.entity_platform import AddEntitiesCallback
30 |
31 | from . import VivintConfigEntry
32 | from .hub import VivintEntity, VivintHub
33 |
34 | # Map Vivint HVAC Mode to Home Assistant value
35 | VIVINT_HVAC_MODE_MAP = {
36 | OperatingMode.OFF: HVACMode.OFF,
37 | OperatingMode.HEAT: HVACMode.HEAT,
38 | OperatingMode.COOL: HVACMode.COOL,
39 | OperatingMode.AUTO: HVACMode.HEAT_COOL,
40 | OperatingMode.EMERGENCY_HEAT: HVACMode.HEAT,
41 | OperatingMode.RESUME: HVACMode.AUTO, # maybe?
42 | OperatingMode.FAN_ONLY: HVACMode.FAN_ONLY,
43 | OperatingMode.FURNACE: HVACMode.HEAT,
44 | OperatingMode.DRY_AIR: HVACMode.DRY,
45 | OperatingMode.MOIST_AIR: HVACMode.DRY, # maybe?
46 | OperatingMode.AUTO_CHANGEOVER: HVACMode.HEAT_COOL,
47 | OperatingMode.ENERGY_SAVE_HEAT: HVACMode.HEAT,
48 | OperatingMode.ENERGY_SAVE_COOL: HVACMode.COOL,
49 | OperatingMode.AWAY: HVACMode.HEAT_COOL,
50 | OperatingMode.ECO: HVACMode.HEAT_COOL,
51 | }
52 |
53 | # Map Home Assistant HVAC Mode to Vivint value
54 | VIVINT_HVAC_INV_MODE_MAP = {
55 | HVACMode.OFF: OperatingMode.OFF,
56 | HVACMode.HEAT: OperatingMode.HEAT,
57 | HVACMode.COOL: OperatingMode.COOL,
58 | HVACMode.HEAT_COOL: OperatingMode.AUTO,
59 | }
60 |
61 | VIVINT_CAPABILITY_FAN_MODE_MAP = {
62 | CapabilityType.FAN15_MINUTE: FanMode.TIMER_15,
63 | CapabilityType.FAN30_MINUTE: FanMode.TIMER_30,
64 | CapabilityType.FAN45_MINUTE: FanMode.TIMER_45,
65 | CapabilityType.FAN60_MINUTE: FanMode.TIMER_60,
66 | CapabilityType.FAN120_MINUTE: FanMode.TIMER_120,
67 | CapabilityType.FAN240_MINUTE: FanMode.TIMER_240,
68 | CapabilityType.FAN480_MINUTE: FanMode.TIMER_480,
69 | CapabilityType.FAN960_MINUTE: FanMode.TIMER_960,
70 | }
71 |
72 | VIVINT_FAN_MODE_MAP = {
73 | FanMode.AUTO_LOW: FAN_AUTO,
74 | FanMode.ON_LOW: FAN_ON,
75 | FanMode.TIMER_15: "15 minutes",
76 | FanMode.TIMER_30: "30 minutes",
77 | FanMode.TIMER_45: "45 minutes",
78 | FanMode.TIMER_60: "1 hour",
79 | FanMode.TIMER_120: "2 hours",
80 | FanMode.TIMER_240: "4 hours",
81 | FanMode.TIMER_480: "8 hours",
82 | FanMode.TIMER_720: "12 hours",
83 | FanMode.TIMER_960: "16 hours",
84 | }
85 |
86 | VIVINT_FAN_INV_MODE_MAP = {v: k for k, v in VIVINT_FAN_MODE_MAP.items()}
87 |
88 | VIVINT_HVAC_STATUS_MAP = {
89 | OperatingState.IDLE: HVACAction.IDLE,
90 | OperatingState.HEATING: HVACAction.HEATING,
91 | OperatingState.COOLING: HVACAction.COOLING,
92 | }
93 |
94 |
95 | async def async_setup_entry(
96 | hass: HomeAssistant,
97 | entry: VivintConfigEntry,
98 | async_add_entities: AddEntitiesCallback,
99 | ) -> None:
100 | """Set up Vivint climate using config entry."""
101 | entities = []
102 | hub: VivintHub = entry.runtime_data
103 |
104 | for system in hub.account.systems:
105 | for alarm_panel in system.alarm_panels:
106 | for device in alarm_panel.devices:
107 | if isinstance(device, Thermostat):
108 | entities.append(VivintClimate(device=device, hub=hub))
109 |
110 | if not entities:
111 | return
112 |
113 | async_add_entities(entities)
114 |
115 |
116 | class VivintClimate(VivintEntity, ClimateEntity):
117 | """Vivint Climate."""
118 |
119 | device: Thermostat
120 |
121 | _attr_hvac_modes = [HVACMode.COOL, HVACMode.HEAT, HVACMode.HEAT_COOL, HVACMode.OFF]
122 | _attr_supported_features = (
123 | ClimateEntityFeature.TARGET_TEMPERATURE
124 | | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
125 | | ClimateEntityFeature.FAN_MODE
126 | | ClimateEntityFeature.TURN_ON
127 | | ClimateEntityFeature.TURN_OFF
128 | )
129 | _attr_temperature_unit = UnitOfTemperature.CELSIUS
130 | _enable_turn_on_off_backwards_compatibility = False
131 |
132 | def __init__(self, device: Thermostat, hub: VivintHub) -> None:
133 | """Pass coordinator to CoordinatorEntity."""
134 | super().__init__(device=device, hub=hub)
135 | self._attr_fan_modes = [FAN_AUTO, FAN_ON] + [
136 | VIVINT_FAN_MODE_MAP[VIVINT_CAPABILITY_FAN_MODE_MAP[x]]
137 | for k, v in device.capabilities.items()
138 | if k == CapabilityCategoryType.THERMOSTAT
139 | for x in v
140 | if x in VIVINT_CAPABILITY_FAN_MODE_MAP
141 | ]
142 |
143 | @property
144 | def unique_id(self) -> str:
145 | """Return a unique ID."""
146 | return f"{self.device.alarm_panel.id}-{self.device.id}"
147 |
148 | @property
149 | def current_temperature(self) -> float | None:
150 | """Return the current temperature."""
151 | return self.device.temperature
152 |
153 | @property
154 | def current_humidity(self) -> int | None:
155 | """Return the current humidity level."""
156 | return self.device.humidity
157 |
158 | @property
159 | def target_temperature(self) -> float | None:
160 | """Return the temperature we try to reach."""
161 | if self.hvac_mode == HVACMode.HEAT:
162 | return self.device.heat_set_point
163 | if self.hvac_mode == HVACMode.COOL:
164 | return self.device.cool_set_point
165 | return None
166 |
167 | @property
168 | def target_temperature_high(self) -> float | None:
169 | """Return the highbound target temperature we try to reach."""
170 | return (
171 | None if self.hvac_mode != HVACMode.HEAT_COOL else self.device.cool_set_point
172 | )
173 |
174 | @property
175 | def target_temperature_low(self) -> float | None:
176 | """Return the lowbound target temperature we try to reach."""
177 | return (
178 | None if self.hvac_mode != HVACMode.HEAT_COOL else self.device.heat_set_point
179 | )
180 |
181 | @property
182 | def max_temp(self) -> float | None:
183 | """Return the maximum temperature."""
184 | return self.device.maximum_temperature
185 |
186 | @property
187 | def min_temp(self) -> float | None:
188 | """Return the minimum temperature."""
189 | return self.device.minimum_temperature
190 |
191 | @property
192 | def hvac_mode(self) -> HVACMode:
193 | """Return hvac operation ie. heat, cool mode."""
194 | return VIVINT_HVAC_MODE_MAP.get(self.device.operating_mode, HVACMode.HEAT_COOL)
195 |
196 | @property
197 | def hvac_action(self) -> HVACAction:
198 | """Return the current running hvac operation if supported."""
199 | return VIVINT_HVAC_STATUS_MAP.get(self.device.operating_mode, HVACAction.IDLE)
200 |
201 | @property
202 | def fan_mode(self) -> str:
203 | """Return the fan mode."""
204 | return VIVINT_FAN_MODE_MAP.get(self.device.fan_mode, FAN_ON)
205 |
206 | async def async_set_fan_mode(self, fan_mode: str) -> None:
207 | """Set new target fan mode."""
208 | await self.device.set_state(
209 | **{
210 | ThermostatAttribute.FAN_MODE: VIVINT_FAN_INV_MODE_MAP.get(
211 | fan_mode, VIVINT_FAN_INV_MODE_MAP.get(self.fan_modes[-1])
212 | )
213 | }
214 | )
215 |
216 | async def async_set_hvac_mode(self, hvac_mode: str) -> None:
217 | """Set new target hvac mode."""
218 | await self.device.set_state(
219 | **{ThermostatAttribute.OPERATING_MODE: VIVINT_HVAC_INV_MODE_MAP[hvac_mode]}
220 | )
221 |
222 | async def async_turn_on(self) -> None:
223 | """Turn the entity on."""
224 | await self.async_set_hvac_mode(HVACMode.HEAT_COOL)
225 |
226 | async def async_turn_off(self) -> None:
227 | """Turn the entity off."""
228 | await self.async_set_hvac_mode(HVACMode.OFF)
229 |
230 | async def async_set_temperature(self, **kwargs: Any) -> None:
231 | """Set new target temperature."""
232 | temp = kwargs.get(ATTR_TEMPERATURE)
233 | low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW)
234 | high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH)
235 |
236 | await self.change_target_temperature(
237 | ThermostatAttribute.COOL_SET_POINT,
238 | temp if self.hvac_mode == HVACMode.COOL else high_temp,
239 | self.device.cool_set_point,
240 | )
241 | await self.change_target_temperature(
242 | ThermostatAttribute.HEAT_SET_POINT,
243 | temp if self.hvac_mode == HVACMode.HEAT else low_temp,
244 | self.device.heat_set_point,
245 | )
246 |
247 | async def change_target_temperature(
248 | self, attribute: str, target: float, current: float
249 | ) -> bool:
250 | """Change target temperature."""
251 | if target is not None and abs(target - current) >= 0.5:
252 | await self.device.set_state(**{attribute: target})
253 |
--------------------------------------------------------------------------------
/custom_components/vivint/binary_sensor.py:
--------------------------------------------------------------------------------
1 | """Support for Vivint binary sensors."""
2 |
3 | from __future__ import annotations
4 |
5 | from collections.abc import Callable
6 | from dataclasses import dataclass
7 | from datetime import datetime, timedelta
8 |
9 | from vivintpy.devices import BypassTamperDevice, VivintDevice
10 | from vivintpy.devices.camera import MOTION_DETECTED, Camera
11 | from vivintpy.devices.wireless_sensor import WirelessSensor
12 | from vivintpy.enums import EquipmentType, SensorType
13 |
14 | from homeassistant.components.binary_sensor import (
15 | DOMAIN as PLATFORM_DOMAIN,
16 | BinarySensorDeviceClass,
17 | BinarySensorEntity,
18 | BinarySensorEntityDescription,
19 | )
20 | from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
21 | from homeassistant.helpers.dispatcher import async_dispatcher_connect
22 | from homeassistant.helpers.entity import EntityCategory
23 | from homeassistant.helpers.entity_platform import AddEntitiesCallback
24 | from homeassistant.helpers.event import async_call_later
25 | from homeassistant.util.dt import utcnow
26 |
27 | from . import VivintConfigEntry
28 | from .const import DOMAIN
29 | from .hub import VivintBaseEntity, VivintEntity, VivintHub
30 |
31 | MOTION_STOPPED_SECONDS = 30
32 |
33 | ENTITY_DESCRIPTION_MOTION = BinarySensorEntityDescription(
34 | "motion", device_class=BinarySensorDeviceClass.MOTION
35 | )
36 |
37 |
38 | async def async_setup_entry(
39 | hass: HomeAssistant,
40 | entry: VivintConfigEntry,
41 | async_add_entities: AddEntitiesCallback,
42 | ) -> None:
43 | """Set up Vivint binary sensors using config entry."""
44 | entities = []
45 | hub: VivintHub = entry.runtime_data
46 |
47 | for system in hub.account.systems:
48 | for alarm_panel in system.alarm_panels:
49 | for device in alarm_panel.devices:
50 | entities.extend(
51 | VivintBinarySensorEntity(
52 | device=device, hub=hub, entity_description=description
53 | )
54 | for cls, descriptions in BINARY_SENSORS.items()
55 | if isinstance(device, cls)
56 | for description in descriptions
57 | )
58 | if isinstance(device, WirelessSensor):
59 | entities.append(VivintBinarySensorEntityOld(device=device, hub=hub))
60 | elif isinstance(device, Camera):
61 | entities.append(
62 | VivintCameraBinarySensorEntity(
63 | device=device,
64 | hub=hub,
65 | entity_description=ENTITY_DESCRIPTION_MOTION,
66 | )
67 | )
68 | if hasattr(device, "is_online"):
69 | entities.append(
70 | VivintBinarySensorEntity(
71 | device=device,
72 | hub=hub,
73 | entity_description=ONLINE_SENSOR_ENTITY_DESCRIPTION,
74 | )
75 | )
76 |
77 | if not entities:
78 | return
79 |
80 | async_add_entities(entities)
81 |
82 | @callback
83 | def async_add_sensor(device: VivintDevice) -> None:
84 | """Add Vivint binary sensor."""
85 | entities: list[VivintBinarySensorEntityOld] = []
86 | if isinstance(device, WirelessSensor):
87 | entities.append(VivintBinarySensorEntityOld(device=device, hub=hub))
88 |
89 | async_add_entities(entities)
90 |
91 | entry.async_on_unload(
92 | async_dispatcher_connect(
93 | hass,
94 | f"{DOMAIN}_{entry.entry_id}_add_{PLATFORM_DOMAIN}",
95 | async_add_sensor,
96 | )
97 | )
98 |
99 |
100 | @dataclass
101 | class VivintBinarySensorMixin:
102 | """Vivint binary sensor required keys."""
103 |
104 | is_on: Callable[[BypassTamperDevice], bool]
105 |
106 |
107 | @dataclass
108 | class VivintBinarySensorEntityDescription(
109 | BinarySensorEntityDescription, VivintBinarySensorMixin
110 | ):
111 | """Vivint binary sensor entity description."""
112 |
113 |
114 | BINARY_SENSORS = {
115 | BypassTamperDevice: (
116 | VivintBinarySensorEntityDescription(
117 | key="bypassed",
118 | device_class=BinarySensorDeviceClass.SAFETY,
119 | entity_category=EntityCategory.DIAGNOSTIC,
120 | name="Bypassed",
121 | is_on=lambda device: device.is_bypassed,
122 | ),
123 | VivintBinarySensorEntityDescription(
124 | key="tampered",
125 | device_class=BinarySensorDeviceClass.TAMPER,
126 | entity_category=EntityCategory.DIAGNOSTIC,
127 | name="Tampered",
128 | is_on=lambda device: device.is_tampered,
129 | ),
130 | )
131 | }
132 | ONLINE_SENSOR_ENTITY_DESCRIPTION = VivintBinarySensorEntityDescription(
133 | key="online",
134 | device_class=BinarySensorDeviceClass.CONNECTIVITY,
135 | entity_category=EntityCategory.DIAGNOSTIC,
136 | name="Online",
137 | is_on=lambda device: getattr(device, "is_online"),
138 | )
139 |
140 |
141 | class VivintBinarySensorEntity(VivintBaseEntity, BinarySensorEntity):
142 | """Vivint binary sensor."""
143 |
144 | entity_description: VivintBinarySensorEntityDescription
145 |
146 | @property
147 | def is_on(self) -> bool:
148 | """Return true if the binary sensor is on."""
149 | return self.entity_description.is_on(self.device)
150 |
151 |
152 | class VivintBinarySensorEntityOld(VivintEntity, BinarySensorEntity):
153 | """Vivint Binary Sensor."""
154 |
155 | @property
156 | def unique_id(self) -> str:
157 | """Return a unique ID."""
158 | return f"{self.device.alarm_panel.id}-{self.device.id}"
159 |
160 | @property
161 | def is_on(self) -> bool:
162 | """Return true if the binary sensor is on."""
163 | return self.device.is_on
164 |
165 | @property
166 | def device_class(self) -> BinarySensorDeviceClass:
167 | """Return the class of this device."""
168 | equipment_type = self.device.equipment_type
169 |
170 | if equipment_type == EquipmentType.MOTION:
171 | return BinarySensorDeviceClass.MOTION
172 |
173 | elif equipment_type == EquipmentType.FREEZE:
174 | return BinarySensorDeviceClass.COLD
175 |
176 | elif equipment_type == EquipmentType.WATER:
177 | return BinarySensorDeviceClass.MOISTURE
178 |
179 | elif equipment_type == EquipmentType.TEMPERATURE:
180 | return BinarySensorDeviceClass.HEAT
181 |
182 | elif equipment_type == EquipmentType.CONTACT:
183 | sensor_type = self.device.sensor_type
184 |
185 | if sensor_type == SensorType.EXIT_ENTRY_1:
186 | return (
187 | BinarySensorDeviceClass.GARAGE_DOOR
188 | if "TILT" in self.device.equipment_code.name
189 | else BinarySensorDeviceClass.DOOR
190 | )
191 |
192 | elif sensor_type == SensorType.PERIMETER:
193 | return (
194 | BinarySensorDeviceClass.SAFETY
195 | if "GLASS_BREAK" in self.device.equipment_code.name
196 | else BinarySensorDeviceClass.WINDOW
197 | )
198 |
199 | elif sensor_type in [SensorType.FIRE, SensorType.FIRE_WITH_VERIFICATION]:
200 | return BinarySensorDeviceClass.SMOKE
201 |
202 | elif sensor_type == SensorType.CARBON_MONOXIDE:
203 | return BinarySensorDeviceClass.GAS
204 |
205 | else:
206 | return BinarySensorDeviceClass.SAFETY
207 |
208 |
209 | class VivintCameraBinarySensorEntity(VivintEntity, BinarySensorEntity):
210 | """Vivint Camera Binary Sensor."""
211 |
212 | def __init__(
213 | self,
214 | device: VivintDevice,
215 | hub: VivintHub,
216 | entity_description: BinarySensorEntityDescription,
217 | ) -> None:
218 | """Pass coordinator to CoordinatorEntity."""
219 | super().__init__(device=device, hub=hub)
220 | self.entity_description = entity_description
221 | self._last_motion_event: datetime | None = None
222 | self._motion_stopped_callback: CALLBACK_TYPE = None
223 |
224 | @property
225 | def name(self) -> str:
226 | """Return the name of this entity."""
227 | return f"{self.device.name} Motion"
228 |
229 | @property
230 | def unique_id(self) -> str:
231 | """Return a unique ID."""
232 | return f"{self.device.alarm_panel.id}-{self.device.id}"
233 |
234 | @property
235 | def is_on(self) -> bool:
236 | """Return true if the binary sensor is on."""
237 | return (
238 | self._last_motion_event is not None
239 | and self._last_motion_event >= utcnow() - timedelta(seconds=30)
240 | )
241 |
242 | async def async_added_to_hass(self) -> None:
243 | """Register callbacks."""
244 | await super().async_added_to_hass()
245 | self.async_on_remove(self.device.on(MOTION_DETECTED, self._motion_callback))
246 |
247 | async def async_will_remove_from_hass(self) -> None:
248 | """Disconnect callbacks."""
249 | await super().async_will_remove_from_hass()
250 | self.async_cancel_motion_stopped_callback()
251 |
252 | @callback
253 | def _motion_callback(self, _) -> None:
254 | """Call motion method."""
255 | self.async_cancel_motion_stopped_callback()
256 |
257 | self._last_motion_event = utcnow()
258 | self.async_write_ha_state()
259 |
260 | self._motion_stopped_callback = async_call_later(
261 | self.hass, MOTION_STOPPED_SECONDS, self.async_motion_stopped_callback
262 | )
263 |
264 | async def async_motion_stopped_callback(self, *_) -> None:
265 | """Motion stopped callback."""
266 | self._motion_stopped_callback = None
267 | self._last_motion_event = None
268 | self.async_write_ha_state()
269 |
270 | @callback
271 | def async_cancel_motion_stopped_callback(self) -> None:
272 | """Clear the motion stopped callback if it has not already fired."""
273 | if self._motion_stopped_callback is not None:
274 | self._motion_stopped_callback()
275 | self._motion_stopped_callback = None
276 |
--------------------------------------------------------------------------------