├── 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 | [![Open your Home Assistant instance and show the system information.](https://my.home-assistant.io/badges/system_health.svg)](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 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](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 | ![Release](https://img.shields.io/github/v/release/natekspencer/hacs-vivint?style=for-the-badge) 2 | ![Downloads](https://img.shields.io/github/downloads/natekspencer/hacs-vivint/total?style=for-the-badge) 3 | ![Latest Downloads](https://img.shields.io/github/downloads/natekspencer/hacs-vivint/latest/total?style=for-the-badge) 4 | [![Buy Me A Coffee/Beer](https://img.shields.io/badge/Buy_Me_A_☕/🍺-F16061?style=for-the-badge&logo=ko-fi&logoColor=white&labelColor=grey)](https://ko-fi.com/natekspencer) 5 | 6 | 7 | 8 | Vivint logo 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 | Buy Me a Coffee at ko-fi.com 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 | --------------------------------------------------------------------------------