├── CODEOWNERS ├── hacs.json ├── .github └── workflows │ ├── hassfest.yaml │ ├── validate.yaml │ └── release.yml ├── custom_components └── whistle │ ├── manifest.json │ ├── const.py │ ├── util.py │ ├── strings.json │ ├── translations │ └── en.json │ ├── coordinator.py │ ├── __init__.py │ ├── config_flow.py │ ├── device_tracker.py │ └── sensor.py ├── LICENSE ├── .gitignore └── README.md /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @RobertD502 2 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Whistle", 3 | "render_readme": true, 4 | "country": "US", 5 | "homeassistant": "2023.12.0", 6 | "zip_release": true, 7 | "filename": "whistle.zip" 8 | } 9 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v3" 14 | - uses: home-assistant/actions/hassfest@master 15 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v3" 14 | - name: HACS validation 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" 18 | -------------------------------------------------------------------------------- /custom_components/whistle/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "whistle", 3 | "name": "Whistle", 4 | "codeowners": ["@RobertD502"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/RobertD502/home-assistant-whistle/blob/main/README.md", 8 | "integration_type": "hub", 9 | "iot_class": "cloud_polling", 10 | "issue_tracker": "https://github.com/RobertD502/home-assistant-whistle/issues", 11 | "requirements": ["whistleaio==0.1.2", "strenum==0.4.8"], 12 | "version": "0.2.0" 13 | } 14 | -------------------------------------------------------------------------------- /custom_components/whistle/const.py: -------------------------------------------------------------------------------- 1 | """ Constants for Whistle """ 2 | 3 | import asyncio 4 | import logging 5 | 6 | from aiohttp.client_exceptions import ClientConnectionError 7 | 8 | from homeassistant.const import Platform 9 | 10 | from whistleaio.exceptions import WhistleAuthError 11 | 12 | LOGGER = logging.getLogger(__package__) 13 | 14 | DEFAULT_SCAN_INTERVAL = 60 15 | DOMAIN = "whistle" 16 | PLATFORMS = [ 17 | Platform.DEVICE_TRACKER, 18 | Platform.SENSOR, 19 | ] 20 | 21 | DEFAULT_NAME = "Whistle" 22 | TIMEOUT = 20 23 | 24 | WHISTLE_ERRORS = ( 25 | asyncio.TimeoutError, 26 | ClientConnectionError, 27 | WhistleAuthError, 28 | ) 29 | 30 | CONF_ZONE_METHOD = "zone_method" 31 | DEFAULT_ZONE_METHOD = "Whistle" 32 | ZONE_METHODS = ["Whistle", "Home Assistant"] 33 | 34 | UPDATE_LISTENER = "update_listener" 35 | WHISTLE_COORDINATOR = "whistle_coordinator" 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "Release" 2 | 3 | on: 4 | release: 5 | types: [published, edited] 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 the repository" 17 | uses: "actions/checkout@v3.5.3" 18 | 19 | - name: "Adjust version number" 20 | shell: "bash" 21 | run: | 22 | yq -i -o json '.version="${{ github.event.release.tag_name }}"' \ 23 | "${{ github.workspace }}/custom_components/whistle/manifest.json" 24 | 25 | - name: "ZIP the integration directory" 26 | shell: "bash" 27 | run: | 28 | cd "${{ github.workspace }}/custom_components/whistle" 29 | zip whistle.zip -r ./ 30 | 31 | - name: "Upload the ZIP file to the release" 32 | uses: softprops/action-gh-release@v0.1.15 33 | with: 34 | files: ${{ github.workspace }}/custom_components/whistle/whistle.zip 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 RobertD502 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 | -------------------------------------------------------------------------------- /custom_components/whistle/util.py: -------------------------------------------------------------------------------- 1 | """ Utilities for Whistle Integration """ 2 | from __future__ import annotations 3 | 4 | 5 | 6 | import async_timeout 7 | from whistleaio import WhistleClient 8 | from whistleaio.exceptions import WhistleAuthError 9 | from whistleaio.model import Pet, WhistleData 10 | 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 13 | 14 | from .const import LOGGER, WHISTLE_ERRORS, TIMEOUT 15 | 16 | async def async_validate_api(hass: HomeAssistant, email: str, password: str) -> bool: 17 | """ Get data from API. """ 18 | 19 | client = WhistleClient( 20 | email, 21 | password, 22 | session=async_get_clientsession(hass), 23 | timeout=TIMEOUT, 24 | ) 25 | 26 | try: 27 | async with async_timeout.timeout(TIMEOUT): 28 | whistle_query = await client.get_whistle_data() 29 | except WhistleAuthError as err: 30 | LOGGER.error(f'Could not authenticate on Whistle servers: {err}') 31 | raise WhistleAuthError from err 32 | except WHISTLE_ERRORS as err: 33 | LOGGER.error(f'Failed to get information from Whistle servers: {err}') 34 | raise ConnectionError from err 35 | 36 | pets: dict[str, Pet] = whistle_query.pets 37 | if not pets: 38 | LOGGER.error("Could not retrieve any pets from Whistle servers") 39 | raise NoPetsError 40 | else: 41 | return True 42 | 43 | 44 | class NoPetsError(Exception): 45 | """ No Pets from Whistle API. """ 46 | -------------------------------------------------------------------------------- /custom_components/whistle/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "title": "Whistle", 4 | "step": { 5 | "user": { 6 | "title": "Fill in your Whistle account credentials", 7 | "data": { 8 | "email": "Email", 9 | "password": "Password" 10 | } 11 | }, 12 | "reauth_confirm": { 13 | "title": "Reauthenticate with your Whistle account credentials", 14 | "data": { 15 | "email": "Email", 16 | "password": "Password" 17 | } 18 | } 19 | }, 20 | "error": { 21 | "cannot_connect": "Failed to connect", 22 | "incorrect_email_pass": "Invalid Email and/or Password for selected account", 23 | "invalid_auth": "Invalid authentication. Are your credentials correct?", 24 | "no_pets": "No pets found on account" 25 | }, 26 | "abort": { 27 | "already_configured": "Whistle account is already configured", 28 | "reauth_successful": "Re-authentication was successful" 29 | } 30 | }, 31 | "options": { 32 | "step": { 33 | "init": { 34 | "data": { 35 | "zone_method": "Use zones defined by:" 36 | } 37 | } 38 | } 39 | }, 40 | "issues": { 41 | "whistle_platform_decommission": { 42 | "title": "Whistle Platform Decommission Notice", 43 | "description": "The Whistle platform will be decommissioned on September 1st, 2025. This integration will no longer function after that date. We recommend migrating to Tractive GPS, which offers a Home Assistant integration and has a special migration offer for Whistle customers. For details see whistle.com." 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /custom_components/whistle/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Whistle account is already configured", 5 | "reauth_successful": "Re-authentication was successful" 6 | }, 7 | "error": { 8 | "cannot_connect": "Failed to connect", 9 | "incorrect_email_pass": "Invalid Email and/or Password for selected account", 10 | "invalid_auth": "Invalid authentication. Are your credentials correct?", 11 | "no_pets": "No pets found on account" 12 | }, 13 | "step": { 14 | "user": { 15 | "data": { 16 | "email": "Email", 17 | "password": "Password" 18 | }, 19 | "title": "Fill in your Whistle account credentials" 20 | }, 21 | "reauth_confirm": { 22 | "data": { 23 | "email": "Email", 24 | "password": "Password" 25 | }, 26 | "title": "Reauthenticate with your Whistle account credentials" 27 | } 28 | }, 29 | "title": "Whistle" 30 | }, 31 | "options": { 32 | "step": { 33 | "init": { 34 | "data": { 35 | "zone_method": "Use zones defined by:" 36 | } 37 | } 38 | } 39 | }, 40 | "issues": { 41 | "whistle_platform_decommission": { 42 | "title": "Whistle Platform Decommission Notice", 43 | "description": "The Whistle platform will be decommissioned on September 1st, 2025. This integration will no longer function after that date. We recommend migrating to Tractive GPS, which offers a Home Assistant integration and has a special migration offer for Whistle customers. Details can be found at whistle.com" 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /custom_components/whistle/coordinator.py: -------------------------------------------------------------------------------- 1 | """ DataUpdateCoordinator for the Whistle integration. """ 2 | from __future__ import annotations 3 | 4 | from datetime import timedelta 5 | 6 | from whistleaio import WhistleClient 7 | from whistleaio.exceptions import WhistleAuthError, WhistleError 8 | from whistleaio.model import WhistleData 9 | 10 | 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.const import CONF_EMAIL, CONF_PASSWORD 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.exceptions import ConfigEntryAuthFailed 15 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 16 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 17 | 18 | from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, TIMEOUT 19 | 20 | class WhistleDataUpdateCoordinator(DataUpdateCoordinator): 21 | """ Whistle Data Update Coordinator. """ 22 | 23 | data: WhistleData 24 | 25 | def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: 26 | """ Initialize the Whistle coordinator. """ 27 | 28 | self.client = WhistleClient( 29 | entry.data[CONF_EMAIL], 30 | entry.data[CONF_PASSWORD], 31 | session=async_get_clientsession(hass), 32 | timeout=TIMEOUT, 33 | ) 34 | super().__init__( 35 | hass, 36 | LOGGER, 37 | name=DOMAIN, 38 | update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), 39 | ) 40 | 41 | async def _async_update_data(self) -> WhistleData: 42 | """ Fetch data from Whistle. """ 43 | 44 | try: 45 | data = await self.client.get_whistle_data() 46 | except WhistleAuthError as error: 47 | raise ConfigEntryAuthFailed from error 48 | except WhistleError as error: 49 | raise UpdateFailed(error) from error 50 | except Exception as error: 51 | raise UpdateFailed(error) from error 52 | if not data.pets: 53 | raise UpdateFailed("No Pets found") 54 | return data 55 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /custom_components/whistle/__init__.py: -------------------------------------------------------------------------------- 1 | """ Whistle Component """ 2 | from __future__ import annotations 3 | 4 | from homeassistant.config_entries import ConfigEntry 5 | from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_USERNAME 6 | from homeassistant.core import HomeAssistant 7 | from homeassistant.helpers import issue_registry as ir 8 | 9 | from .const import ( 10 | CONF_ZONE_METHOD, 11 | DEFAULT_ZONE_METHOD, 12 | DOMAIN, 13 | LOGGER, 14 | PLATFORMS, 15 | UPDATE_LISTENER, 16 | WHISTLE_COORDINATOR, 17 | ) 18 | from .coordinator import WhistleDataUpdateCoordinator 19 | 20 | 21 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 22 | """ Set up Whistle from a config entry. """ 23 | 24 | # Create deprecation notification 25 | ir.async_create_issue( 26 | hass, 27 | DOMAIN, 28 | "whistle_platform_decommission", 29 | is_fixable=False, 30 | severity=ir.IssueSeverity.WARNING, 31 | translation_key="whistle_platform_decommission", 32 | ) 33 | 34 | coordinator = WhistleDataUpdateCoordinator(hass, entry) 35 | await coordinator.async_config_entry_first_refresh() 36 | hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { 37 | WHISTLE_COORDINATOR: coordinator 38 | } 39 | 40 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 41 | 42 | update_listener = entry.add_update_listener(async_update_options) 43 | hass.data[DOMAIN][entry.entry_id][UPDATE_LISTENER] = update_listener 44 | 45 | return True 46 | 47 | 48 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 49 | """ Unload Whistle config entry. """ 50 | 51 | if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): 52 | update_listener = hass.data[DOMAIN][entry.entry_id][UPDATE_LISTENER] 53 | update_listener() 54 | del hass.data[DOMAIN][entry.entry_id] 55 | if not hass.data[DOMAIN]: 56 | del hass.data[DOMAIN] 57 | return unload_ok 58 | 59 | 60 | async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 61 | """ Migrate old entry. """ 62 | 63 | if entry.version in [1,2]: 64 | if entry.version == 1: 65 | email = entry.data[CONF_USERNAME] 66 | else: 67 | email = entry.data[CONF_EMAIL] 68 | password = entry.data[CONF_PASSWORD] 69 | 70 | LOGGER.debug(f'Migrate Whistle config entry unique id to {email}') 71 | entry.version = 3 72 | 73 | hass.config_entries.async_update_entry( 74 | entry, 75 | data={ 76 | CONF_EMAIL: email, 77 | CONF_PASSWORD: password, 78 | }, 79 | options={CONF_ZONE_METHOD: DEFAULT_ZONE_METHOD}, 80 | unique_id=email, 81 | ) 82 | return True 83 | 84 | async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: 85 | """ Update options. """ 86 | 87 | await hass.config_entries.async_reload(entry.entry_id) 88 | -------------------------------------------------------------------------------- /custom_components/whistle/config_flow.py: -------------------------------------------------------------------------------- 1 | """ Config Flow for Whistle integration. """ 2 | from __future__ import annotations 3 | 4 | from collections.abc import Mapping 5 | from typing import Any 6 | 7 | from whistleaio.exceptions import WhistleAuthError 8 | import voluptuous as vol 9 | 10 | from homeassistant import config_entries 11 | from homeassistant.const import CONF_EMAIL, CONF_PASSWORD 12 | from homeassistant.core import callback 13 | from homeassistant.data_entry_flow import FlowResult 14 | import homeassistant.helpers.config_validation as cv 15 | 16 | from .const import ( 17 | CONF_ZONE_METHOD, 18 | DEFAULT_NAME, 19 | DEFAULT_ZONE_METHOD, 20 | DOMAIN, 21 | ZONE_METHODS, 22 | ) 23 | 24 | from .util import async_validate_api, NoPetsError 25 | 26 | DATA_SCHEMA = vol.Schema( 27 | { 28 | vol.Required(CONF_EMAIL): cv.string, 29 | vol.Required(CONF_PASSWORD): cv.string, 30 | } 31 | ) 32 | 33 | 34 | class WhistleConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 35 | """ Handle a config flow for Whistle integration. """ 36 | 37 | VERSION = 3 38 | 39 | entry: config_entries.ConfigEntry | None 40 | 41 | @staticmethod 42 | @callback 43 | def async_get_options_flow( 44 | config_entry: config_entries.ConfigEntry, 45 | ) -> WhistleOptionsFlowHandler: 46 | """Get the options flow for this handler.""" 47 | return WhistleOptionsFlowHandler(config_entry) 48 | 49 | async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: 50 | """ Handle re-authentication with Whistle. """ 51 | 52 | self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) 53 | return await self.async_step_reauth_confirm() 54 | 55 | async def async_step_reauth_confirm( 56 | self, user_input: dict[str, Any] | None = None 57 | ) -> FlowResult: 58 | """ Confirm re-authentication with Whistle. """ 59 | 60 | errors: dict[str, str] = {} 61 | 62 | if user_input: 63 | email = user_input[CONF_EMAIL] 64 | password = user_input[CONF_PASSWORD] 65 | try: 66 | await async_validate_api(self.hass, email, password) 67 | except WhistleAuthError: 68 | errors["base"] = "invalid_auth" 69 | except ConnectionError: 70 | errors["base"] = "cannot_connect" 71 | except NoPetsError: 72 | errors["base"] = "no_pets" 73 | else: 74 | assert self.entry is not None 75 | 76 | self.hass.config_entries.async_update_entry( 77 | self.entry, 78 | data={ 79 | CONF_EMAIL: email, 80 | CONF_PASSWORD: password, 81 | 82 | }, 83 | ) 84 | await self.hass.config_entries.async_reload(self.entry.entry_id) 85 | return self.async_abort(reason="reauth_successful") 86 | errors["base"] = "incorrect_email_pass" 87 | 88 | return self.async_show_form( 89 | step_id="reauth_confirm", 90 | data_schema=DATA_SCHEMA, 91 | errors=errors, 92 | ) 93 | 94 | async def async_step_user( 95 | self, user_input: dict[str, Any] | None = None 96 | ) -> FlowResult: 97 | """ Handle the initial step. """ 98 | 99 | errors: dict[str, str] = {} 100 | 101 | if user_input: 102 | 103 | email = user_input[CONF_EMAIL] 104 | password = user_input[CONF_PASSWORD] 105 | try: 106 | await async_validate_api(self.hass, email, password) 107 | except WhistleAuthError: 108 | errors["base"] = "invalid_auth" 109 | except ConnectionError: 110 | errors["base"] = "cannot_connect" 111 | except NoPetsError: 112 | errors["base"] = "no_pets" 113 | else: 114 | await self.async_set_unique_id(email) 115 | self._abort_if_unique_id_configured() 116 | 117 | return self.async_create_entry( 118 | title=DEFAULT_NAME, 119 | data={CONF_EMAIL: email, CONF_PASSWORD: password}, 120 | options={CONF_ZONE_METHOD: DEFAULT_ZONE_METHOD}, 121 | ) 122 | 123 | return self.async_show_form( 124 | step_id="user", 125 | data_schema=DATA_SCHEMA, 126 | errors=errors, 127 | ) 128 | 129 | class WhistleOptionsFlowHandler(config_entries.OptionsFlow): 130 | """ Handle Whistle zone options. """ 131 | 132 | def __init__(self, config_entry: config_entries.ConfigEntry) -> None: 133 | """Initialize options flow.""" 134 | self.config_entry = config_entry 135 | 136 | async def async_step_init(self, user_input=None): 137 | """ Manage options. """ 138 | if user_input is not None: 139 | return self.async_create_entry(title="", data=user_input) 140 | 141 | options = { 142 | vol.Optional( 143 | CONF_ZONE_METHOD, 144 | default=self.config_entry.options.get( 145 | CONF_ZONE_METHOD, DEFAULT_ZONE_METHOD 146 | ), 147 | ): vol.In(ZONE_METHODS) 148 | } 149 | 150 | return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) 151 | -------------------------------------------------------------------------------- /custom_components/whistle/device_tracker.py: -------------------------------------------------------------------------------- 1 | """ Device Tracker platform for Whistle integration.""" 2 | from __future__ import annotations 3 | 4 | from typing import Any 5 | 6 | from whistleaio.model import Pet 7 | 8 | from homeassistant.components.device_tracker.const import SourceType 9 | from homeassistant.components.device_tracker.config_entry import TrackerEntity 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 13 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 14 | 15 | from .const import ( 16 | CONF_ZONE_METHOD, 17 | DEFAULT_ZONE_METHOD, 18 | DOMAIN, 19 | WHISTLE_COORDINATOR, 20 | ) 21 | from .coordinator import WhistleDataUpdateCoordinator 22 | 23 | async def async_setup_entry( 24 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 25 | ) -> None: 26 | """ Set Up Whistle Device Tracker Entities. """ 27 | 28 | coordinator: WhistleDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][WHISTLE_COORDINATOR] 29 | 30 | device_trackers = [] 31 | 32 | 33 | for pet_id, pet_data in coordinator.data.pets.items(): 34 | 35 | """ Device Trackers """ 36 | if pet_data.data['device'] and pet_data.data['device']['has_gps']: 37 | device_trackers.extend(( 38 | WhistleTracker(coordinator, pet_id, entry), 39 | )) 40 | async_add_entities(device_trackers) 41 | 42 | class WhistleTracker(CoordinatorEntity, TrackerEntity): 43 | """ Representation of Whistle GPS Tracker. """ 44 | 45 | def __init__(self, coordinator, pet_id, entry): 46 | super().__init__(coordinator) 47 | self.pet_id = pet_id 48 | self.entry = entry 49 | 50 | @property 51 | def zone_method(self): 52 | """ Return the zone method. """ 53 | 54 | return self.entry.options[CONF_ZONE_METHOD] 55 | 56 | @property 57 | def pet_data(self) -> Pet: 58 | """ Handle coordinator pet data. """ 59 | 60 | return self.coordinator.data.pets[self.pet_id] 61 | 62 | @property 63 | def location_dict(self) -> dict[int, str]: 64 | """ Create a dictionary for all pre-defined Whistle 65 | locations as a dict of location id: location name. 66 | """ 67 | 68 | locations: dict[int, str] = {} 69 | for place in self.pet_data.places: 70 | locations[place['id']] = place['name'] 71 | 72 | return locations 73 | 74 | @property 75 | def device_info(self) -> dict[str, Any]: 76 | """ Return device registry information for this entity. """ 77 | 78 | return { 79 | "identifiers": {(DOMAIN, self.pet_data.id)}, 80 | "name": self.pet_data.data['name'], 81 | "manufacturer": "Whistle", 82 | "model": self.pet_data.data['device']['model_id'], 83 | "configuration_url": "https://www.whistle.com/", 84 | } 85 | 86 | @property 87 | def unique_id(self) -> str: 88 | """ Sets unique ID for this entity. """ 89 | 90 | return str(self.pet_data.id) + '_tracker' 91 | 92 | @property 93 | def name(self) -> str: 94 | """ Return name of the entity. """ 95 | 96 | return "Whistle tracker" 97 | 98 | @property 99 | def has_entity_name(self) -> bool: 100 | """ Indicate that entity has name defined. """ 101 | 102 | return True 103 | 104 | @property 105 | def icon(self): 106 | """ Determine what icon to use. """ 107 | 108 | if self.pet_data.data['profile']['species'] == 'dog': 109 | return 'mdi:dog' 110 | if self.pet_data.data['profile']['species'] == 'cat': 111 | return 'mdi:cat' 112 | 113 | @property 114 | def source_type(self) -> SourceType: 115 | """ Return GPS as the source. """ 116 | 117 | return SourceType.GPS 118 | 119 | @property 120 | def latitude(self) -> float: 121 | """ Return most recent latitude. """ 122 | 123 | return self.pet_data.data['last_location']['latitude'] 124 | 125 | @property 126 | def longitude(self) -> float: 127 | """ Return most recent longitude. """ 128 | 129 | return self.pet_data.data['last_location']['longitude'] 130 | 131 | @property 132 | def battery_level(self) -> int: 133 | """ Return tracker current battery percent. """ 134 | 135 | return self.pet_data.data['device']['battery_level'] 136 | 137 | @property 138 | def location_accuracy(self) -> int: 139 | """ Return last location gps accuracy. """ 140 | 141 | return int(self.pet_data.data['last_location']['uncertainty_meters']) 142 | 143 | @property 144 | def location_name(self) -> str | None: 145 | """Returns the Whistle location, as defined in the app, 146 | if zone method is set to Whistle.If the tracker is not in 147 | a pre-defined location, location of Away is returned. 148 | If zone method is set to Home Assistant, Home Assistant 149 | zones will be used instead. 150 | """ 151 | 152 | if self.zone_method == DEFAULT_ZONE_METHOD: 153 | if self.pet_data.data['last_location']['place']['status'] == 'outside_geofence_range': 154 | return "Away" 155 | elif self.pet_data.data['last_location']['place']['id']: 156 | location_id = self.pet_data.data['last_location']['place']['id'] 157 | return self.location_dict.get(location_id) 158 | else: 159 | return "Away" 160 | else: 161 | return None 162 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Whistle Home Assistant Integration 2 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/custom-components/hacs) ![GitHub manifest version (path)](https://img.shields.io/github/manifest-json/v/RobertD502/home-assistant-whistle?filename=custom_components%2Fwhistle%2Fmanifest.json) 3 | 4 | ## ⚠️ IMPORTANT NOTICE - Platform Decommission 5 | 6 | **The Whistle platform will be decommissioned on September 1st, 2025.** This integration will no longer function after that date. We recommend migrating to [Tractive GPS](https://tractive.com), which offers a native [Home Assistant integration](https://www.home-assistant.io/integrations/tractive/) and has a special migration offer for Whistle customers. For details see [whistle.com](https://whistle.com). 7 | 8 | Buy Me A Coffee 9 | Donate using Liberapay 10 | 11 | ### A lot of work has been put into creating the backend and this integration. If you enjoy this integration, consider donating by clicking on one of the supported methods above. 12 | 13 | ***All proceeds go towards helping a local animal rescue.** 14 | 15 | 16 | This integration was made for the `Whistle Go Explore` and `Whistle Fit`. It should also work with the new `Whistle Switch`- testers needed! 17 | 18 | **Prior To Installation** 19 | 20 | You will need credentials consisting of your Whistle `email` and `password`. 21 | 22 | ## Installation 23 | 24 | ### With HACS 25 | 1. Open HACS Settings and add this repository (https://github.com/RobertD502/home-assistant-whistle) 26 | as a Custom Repository (use **Integration** as the category). 27 | 2. The `Whistle` page should automatically load (or find it in the HACS Store) 28 | 3. Click `Install` 29 | 30 | ### Manual 31 | Copy the `whistle` directory from `custom_components` in this repository, 32 | and place inside your Home Assistant Core installation's `custom_components` directory. 33 | 34 | `Note`: If installing manually, in order to be alerted about new releases, you will need to subscribe to releases from this repository. 35 | 36 | ## Setup 37 | 1. Install this integration. 38 | 2. Navigate to the Home Assistant Integrations page (Settings --> Devices & Services) 39 | 3. Click the `+ ADD INTEGRATION` button in the lower right-hand corner 40 | 4. Search for `Whistle` 41 | 42 | Alternatively, click on the button below to add the integration: 43 | 44 | [![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=whistle) 45 | 46 | # Devices 47 | 48 | A device is created for each pet. See below for the entities available and special notes. 49 | 50 | # Entities 51 | 52 | | Entity | Entity Type | Notes | 53 | |-------------------| --- |---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 54 | | `Activity goal` | `Sensor` | Current daily goal as set up in the Whistle app. `Note`: The state is in `minutes` but the Home Assistant UI displays this sensor in `HH:MM:SS` | 55 | | `Activity streak` | `Sensor` | Current amount of days in a row pet has hit daily goal. `Note`: The state is in `days` but the Home Assistant UI displays this sensor in `HH:MM:SS` | 56 | | `Calories` | `Sensor` | Number of calories burned today. | 57 | | `Distance` | `Sensor` | Distance covered in miles today. | 58 | | `Event calories` | `Sensor` | Calories burned during the most recent event reported. `Note`: As there is no event at midnight, this entity will show as unavailable. As soon as an event is reported by Whistle servers for the current day, this entity will become available again. | 59 | | `Event distance` | `Sensor` | Amount of miles covered during the latest event. `Note`: As there is no event at midnight, this entity will show as unavailable. As soon as an event is reported by Whistle servers for the current day, this entity will become available again. | 60 | | `Event duration` | `Sensor` | How long the last event lasted. The state of this entity is in `minutes` but the Home Assistant UI displays this sensor in `HH:MM:SS`. `Note`: As there is no event at midnight, this entity will show as unavailable. As soon as an event is reported by Whistle servers for the current day, this entity will become available again. | 61 | | `Event end` | `Sensor` | When the event ended as represented in datetime format. `Note`: As there is no event at midnight, this entity will show as unavailable. As soon as an event is reported by Whistle servers for the current day, this entity will become available again. | 62 | | `Event start` | `Sensor` | When the event started as represented in datetime format. `Note`: As there is no event at midnight, this entity will show as unavailable. As soon as an event is reported by Whistle servers for the current day, this entity will become available again. | 63 | | `Last event` | `Sensor` | Most recent event reported by Whistle servers. `Note`: As there is no event at midnight, this entity will show as unavailable. As soon as an event is reported by Whistle servers for the current day, this entity will become available again. | 64 | | `Minutes active` | `Sensor` | Minutes your pet has been active today. `Note`: The state of this sensor is in `minutes` but the Home Assistant UI displays this sensor in `HH:MM:SS` | 65 | | `Minutes rest` | `Sensor` | Minutes your pet has spent resting today. `Note`: The state of this sensor is in `minutes` but the Home Assistant UI displays this sensor in `HH:MM:SS` | 66 | | `24H WiFi battery usage` | `Sensor` | `This entity is only available for GPS Whistle devices`. Displays the percent of time, during last 24 hours, that Whistle device used WiFi. | 67 | | `24H cellular battery usage` | `Sensor` | `This entity is only available for GPS Whistle devices`. Displays the percent of time, during last 24 hours, that Whistle device used cellular connection. | 68 | | `Battery` | `Sensor` | Current Whistle device battery percentage. | 69 | | `Battery days left` | `Sensor` | Estimated battery life left. `Note`: This sensor's state is in `days` but the Home Assitant UI displays the sensor in `HH:MM:SS` | 70 | | `Last check-in` | `Sensor` | Last time whistle device contacted Whistle servers. Represented as datetime. | 71 | | `Whistle tracker` | `Device Tracker` | `This entity is only available for GPS Whistle devices.` By default, zones defined within the Whistle app are used. `See Device Tracker Zones below for configuration options`. If using zones created within the Whistle app: Shows the most recent reported location using predefined places created within the Whistle app. If pet is not located in a predefined Whistle place, the device tracker has a state of `Away`. If using Home Assistant zones, location name will depend on zones created within Home Assistant by the user. | 72 | | `Scratching` | `Sensor` | Scratching rating given by Whistle. Duration (seconds) is presented as an attribute. | 73 | | `Licking` | `Sensor` | Licking rating given by Whistle. Duration (seconds) is presented as an attribute. | 74 | | `Drinking` | `Sensor` | Drinking rating given by Whistle. Duration (seconds) is presented as an attribute. | 75 | | `Sleeping` | `Sensor` | Sleeping rating given by Whistle. Duration (seconds) and number of sleep disruptions are presented as attributes. | 76 | | `Eating` | `Sensor` | Eating rating given by Whistle. Duration (seconds) is presented as an attribute. | 77 | | `Wellness index` | `Sensor` | Wellness index rating given by Whistle. Score is presented as an attribute. | 78 | 79 | ## Device Tracker Zones 80 | This section only applies to whistle devices that have GPS capabilities 81 | 82 | **By default, zones created within the Whistle app are used.** 83 | If you want to use Home Assistant zones, click on the configure button and select the option `Home Assistant`(see images below). 84 | 85 | image 86 | image 87 | 88 | 89 | -------------------------------------------------------------------------------- /custom_components/whistle/sensor.py: -------------------------------------------------------------------------------- 1 | """ Sensor platform for Whistle integration.""" 2 | from __future__ import annotations 3 | 4 | from typing import Any 5 | 6 | from datetime import datetime 7 | from zoneinfo import ZoneInfo 8 | 9 | from whistleaio.model import Pet 10 | 11 | from homeassistant.components.sensor import ( 12 | SensorDeviceClass, 13 | SensorEntity, 14 | SensorStateClass, 15 | ) 16 | 17 | from homeassistant.config_entries import ConfigEntry 18 | from homeassistant.const import( 19 | PERCENTAGE, 20 | UnitOfLength, 21 | UnitOfTime, 22 | ) 23 | 24 | from homeassistant.core import HomeAssistant 25 | from homeassistant.helpers.entity import EntityCategory 26 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 27 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 28 | 29 | from .const import DOMAIN, WHISTLE_COORDINATOR 30 | from .coordinator import WhistleDataUpdateCoordinator 31 | 32 | async def async_setup_entry( 33 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 34 | ) -> None: 35 | """ Set Up Whistle Sensor Entities. """ 36 | 37 | coordinator: WhistleDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][WHISTLE_COORDINATOR] 38 | 39 | sensors = [] 40 | 41 | 42 | for pet_id, pet_data in coordinator.data.pets.items(): 43 | """ Pet """ 44 | if pet_data.data['device']: 45 | """ Only get 24h usage if GPS device. """ 46 | if pet_data.data['device']['has_gps']: 47 | sensors.extend(( 48 | WifiUsage(coordinator, pet_id), 49 | CellUsage(coordinator, pet_id), 50 | )) 51 | sensors.extend(( 52 | Battery(coordinator, pet_id), 53 | BatteryDaysLeft(coordinator, pet_id), 54 | MinutesActive(coordinator, pet_id), 55 | MinutesRest(coordinator, pet_id), 56 | Streak(coordinator, pet_id), 57 | ActivityGoal(coordinator, pet_id), 58 | Distance(coordinator, pet_id), 59 | Calories(coordinator, pet_id), 60 | LastCheckIn(coordinator, pet_id), 61 | Event(coordinator, pet_id), 62 | EventStart(coordinator, pet_id), 63 | EventEnd(coordinator, pet_id), 64 | EventDistance(coordinator, pet_id), 65 | EventCalories(coordinator, pet_id), 66 | EventDuration(coordinator, pet_id), 67 | HealthScratching(coordinator, pet_id), 68 | HealthLicking(coordinator, pet_id), 69 | HealthDrinking(coordinator, pet_id), 70 | HealthSleeping(coordinator, pet_id), 71 | HealthEating(coordinator, pet_id), 72 | HealthWellnessIdx(coordinator, pet_id) 73 | )) 74 | 75 | async_add_entities(sensors) 76 | 77 | class Battery(CoordinatorEntity, SensorEntity): 78 | """ Representation of Whistle Device Battery. """ 79 | 80 | def __init__(self, coordinator, pet_id): 81 | super().__init__(coordinator) 82 | self.pet_id = pet_id 83 | 84 | 85 | @property 86 | def pet_data(self) -> Pet: 87 | """ Handle coordinator pet data. """ 88 | 89 | return self.coordinator.data.pets[self.pet_id] 90 | 91 | @property 92 | def device_info(self) -> dict[str, Any]: 93 | """ Return device registry information for this entity. """ 94 | 95 | return { 96 | "identifiers": {(DOMAIN, self.pet_data.id)}, 97 | "name": self.pet_data.data['name'], 98 | "manufacturer": "Whistle", 99 | "model": self.pet_data.data['device']['model_id'], 100 | "configuration_url": "https://www.whistle.com/", 101 | } 102 | 103 | @property 104 | def unique_id(self) -> str: 105 | """ Sets unique ID for this entity. """ 106 | 107 | return str(self.pet_data.id) + '_battery' 108 | 109 | @property 110 | def name(self) -> str: 111 | """ Return name of the entity. """ 112 | 113 | return "Battery" 114 | 115 | @property 116 | def has_entity_name(self) -> bool: 117 | """ Indicate that entity has name defined. """ 118 | 119 | return True 120 | 121 | @property 122 | def native_value(self) -> int: 123 | """ Return battery percentage. """ 124 | 125 | return self.pet_data.data['device']['battery_level'] 126 | 127 | @property 128 | def native_unit_of_measurement(self) -> str: 129 | """ Return percentage as the native unit. """ 130 | 131 | return PERCENTAGE 132 | 133 | @property 134 | def device_class(self) -> SensorDeviceClass: 135 | """ Return entity device class. """ 136 | 137 | return SensorDeviceClass.BATTERY 138 | 139 | @property 140 | def state_class(self) -> SensorStateClass: 141 | """ Return the type of state class. """ 142 | 143 | return SensorStateClass.MEASUREMENT 144 | 145 | @property 146 | def entity_category(self) -> EntityCategory: 147 | """ Set category to diagnostic. """ 148 | 149 | return EntityCategory.DIAGNOSTIC 150 | 151 | class BatteryDaysLeft(CoordinatorEntity, SensorEntity): 152 | """ Representation of estimated battery life left in days. """ 153 | 154 | def __init__(self, coordinator, pet_id): 155 | super().__init__(coordinator) 156 | self.pet_id = pet_id 157 | 158 | 159 | @property 160 | def pet_data(self) -> Pet: 161 | """ Handle coordinator pet data. """ 162 | 163 | return self.coordinator.data.pets[self.pet_id] 164 | 165 | @property 166 | def device_data(self) -> dict[str, Any]: 167 | """ Handle coordinator device data. """ 168 | 169 | return self.coordinator.data.pets[self.pet_id].device 170 | 171 | @property 172 | def device_info(self) -> dict[str, Any]: 173 | """ Return device registry information for this entity. """ 174 | 175 | return { 176 | "identifiers": {(DOMAIN, self.pet_data.id)}, 177 | "name": self.pet_data.data['name'], 178 | "manufacturer": "Whistle", 179 | "model": self.pet_data.data['device']['model_id'], 180 | "configuration_url": "https://www.whistle.com/", 181 | } 182 | 183 | @property 184 | def unique_id(self) -> str: 185 | """ Sets unique ID for this entity. """ 186 | 187 | return str(self.pet_data.id) + '_battery_days_left' 188 | 189 | @property 190 | def name(self) -> str: 191 | """ Return name of the entity. """ 192 | 193 | return "Battery days left" 194 | 195 | @property 196 | def has_entity_name(self) -> bool: 197 | """ Indicate that entity has name defined. """ 198 | 199 | return True 200 | 201 | @property 202 | def icon(self) -> str: 203 | """ Set icon for entity. """ 204 | 205 | return 'mdi:timer-sand' 206 | 207 | @property 208 | def native_value(self) -> float: 209 | """ Return estimated days left. """ 210 | 211 | return self.device_data['device']['battery_stats']['battery_days_left'] 212 | 213 | @property 214 | def native_unit_of_measurement(self) -> UnitOfTime: 215 | """ Return days as the native unit. """ 216 | 217 | return UnitOfTime.DAYS 218 | 219 | @property 220 | def device_class(self) -> SensorDeviceClass: 221 | """ Return entity device class. """ 222 | 223 | return SensorDeviceClass.DURATION 224 | 225 | @property 226 | def state_class(self) -> SensorStateClass: 227 | """ Return the type of state class. """ 228 | 229 | return SensorStateClass.MEASUREMENT 230 | 231 | @property 232 | def entity_category(self) -> EntityCategory: 233 | """ Set category to diagnostic. """ 234 | 235 | return EntityCategory.DIAGNOSTIC 236 | 237 | class WifiUsage(CoordinatorEntity, SensorEntity): 238 | """ Representation of Whistle Device Battery 24h WiFi usage. """ 239 | 240 | def __init__(self, coordinator, pet_id): 241 | super().__init__(coordinator) 242 | self.pet_id = pet_id 243 | 244 | 245 | @property 246 | def pet_data(self) -> Pet: 247 | """ Handle coordinator pet data. """ 248 | 249 | return self.coordinator.data.pets[self.pet_id] 250 | 251 | @property 252 | def device_data(self) -> dict[str, Any]: 253 | """ Handle coordinator device data. """ 254 | 255 | return self.coordinator.data.pets[self.pet_id].device 256 | 257 | @property 258 | def device_info(self) -> dict[str, Any]: 259 | """ Return device registry information for this entity. """ 260 | 261 | return { 262 | "identifiers": {(DOMAIN, self.pet_data.id)}, 263 | "name": self.pet_data.data['name'], 264 | "manufacturer": "Whistle", 265 | "model": self.pet_data.data['device']['model_id'], 266 | "configuration_url": "https://www.whistle.com/", 267 | } 268 | 269 | @property 270 | def unique_id(self) -> str: 271 | """ Sets unique ID for this entity. """ 272 | 273 | return str(self.pet_data.id) + '_24h_wifi_usage' 274 | 275 | @property 276 | def name(self) -> str: 277 | """ Return name of the entity. """ 278 | 279 | return "24H WiFi battery usage" 280 | 281 | @property 282 | def has_entity_name(self) -> bool: 283 | """ Indicate that entity has name defined. """ 284 | 285 | return True 286 | 287 | @property 288 | def icon(self) -> str: 289 | """ Set entity icon. """ 290 | 291 | return 'mdi:wifi' 292 | 293 | @property 294 | def native_value(self) -> int: 295 | """ Return 24h WiFi battery usage as percentage. """ 296 | 297 | return int(round(((float(self.device_data['device']['battery_stats']['prior_usage_minutes']['24h']['power_save_mode']) / 1440) * 100), 0)) 298 | 299 | @property 300 | def native_unit_of_measurement(self) -> str: 301 | """ Return percentage as the native unit. """ 302 | 303 | return PERCENTAGE 304 | 305 | @property 306 | def state_class(self) -> SensorStateClass: 307 | """ Return the type of state class. """ 308 | 309 | return SensorStateClass.MEASUREMENT 310 | 311 | @property 312 | def entity_category(self) -> EntityCategory: 313 | """ Set category to diagnostic. """ 314 | 315 | return EntityCategory.DIAGNOSTIC 316 | 317 | class CellUsage(CoordinatorEntity, SensorEntity): 318 | """ Representation of Whistle Device Battery 24h cellular usage. """ 319 | 320 | def __init__(self, coordinator, pet_id): 321 | super().__init__(coordinator) 322 | self.pet_id = pet_id 323 | 324 | 325 | @property 326 | def pet_data(self) -> Pet: 327 | """ Handle coordinator pet data. """ 328 | 329 | return self.coordinator.data.pets[self.pet_id] 330 | 331 | @property 332 | def device_data(self) -> dict[str, Any]: 333 | """ Handle coordinator device data. """ 334 | 335 | return self.coordinator.data.pets[self.pet_id].device 336 | 337 | @property 338 | def device_info(self) -> dict[str, Any]: 339 | """ Return device registry information for this entity. """ 340 | 341 | return { 342 | "identifiers": {(DOMAIN, self.pet_data.id)}, 343 | "name": self.pet_data.data['name'], 344 | "manufacturer": "Whistle", 345 | "model": self.pet_data.data['device']['model_id'], 346 | "configuration_url": "https://www.whistle.com/", 347 | } 348 | 349 | @property 350 | def unique_id(self) -> str: 351 | """ Sets unique ID for this entity. """ 352 | 353 | return str(self.pet_data.id) + '_24h_cell_usage' 354 | 355 | @property 356 | def name(self) -> str: 357 | """ Return name of the entity. """ 358 | 359 | return "24H cellular battery usage" 360 | 361 | @property 362 | def has_entity_name(self) -> bool: 363 | """ Indicate that entity has name defined. """ 364 | 365 | return True 366 | 367 | @property 368 | def icon(self) -> str: 369 | """ Set entity icon. """ 370 | 371 | return 'mdi:signal-cellular-outline' 372 | 373 | @property 374 | def native_value(self) -> int: 375 | """ Return 24h cellular battery usage as percentage. """ 376 | 377 | return int(round(((float(self.device_data['device']['battery_stats']['prior_usage_minutes']['24h']['cellular']) / 1440) * 100), 0)) 378 | 379 | @property 380 | def native_unit_of_measurement(self) -> str: 381 | """ Return percentage as the native unit. """ 382 | 383 | return PERCENTAGE 384 | 385 | @property 386 | def state_class(self) -> SensorStateClass: 387 | """ Return the type of state class. """ 388 | 389 | return SensorStateClass.MEASUREMENT 390 | 391 | @property 392 | def entity_category(self) -> EntityCategory: 393 | """ Set category to diagnostic. """ 394 | 395 | return EntityCategory.DIAGNOSTIC 396 | 397 | class MinutesActive(CoordinatorEntity, SensorEntity): 398 | """ Representation of today's active minutes. """ 399 | 400 | def __init__(self, coordinator, pet_id): 401 | super().__init__(coordinator) 402 | self.pet_id = pet_id 403 | 404 | 405 | @property 406 | def pet_data(self) -> Pet: 407 | """ Handle coordinator pet data. """ 408 | 409 | return self.coordinator.data.pets[self.pet_id] 410 | 411 | @property 412 | def device_info(self) -> dict[str, Any]: 413 | """ Return device registry information for this entity. """ 414 | 415 | return { 416 | "identifiers": {(DOMAIN, self.pet_data.id)}, 417 | "name": self.pet_data.data['name'], 418 | "manufacturer": "Whistle", 419 | "model": self.pet_data.data['device']['model_id'], 420 | "configuration_url": "https://www.whistle.com/", 421 | } 422 | 423 | @property 424 | def unique_id(self) -> str: 425 | """ Sets unique ID for this entity. """ 426 | 427 | return str(self.pet_data.id) + '_minutes_active' 428 | 429 | @property 430 | def name(self) -> str: 431 | """ Return name of the entity. """ 432 | 433 | return "Minutes active" 434 | 435 | @property 436 | def has_entity_name(self) -> bool: 437 | """ Indicate that entity has name defined. """ 438 | 439 | return True 440 | 441 | @property 442 | def icon(self) -> str: 443 | """ Set icon for entity. """ 444 | 445 | return 'mdi:run-fast' 446 | 447 | @property 448 | def native_value(self) -> int: 449 | """ Return today's active minutes. """ 450 | 451 | return self.pet_data.data['activity_summary']['current_minutes_active'] 452 | 453 | @property 454 | def native_unit_of_measurement(self) -> UnitOfTime: 455 | """ Return minutes as the native unit. """ 456 | 457 | return UnitOfTime.MINUTES 458 | 459 | @property 460 | def device_class(self) -> SensorDeviceClass: 461 | """ Return entity device class. """ 462 | 463 | return SensorDeviceClass.DURATION 464 | 465 | @property 466 | def state_class(self) -> SensorStateClass: 467 | """ Return the type of state class. """ 468 | 469 | return SensorStateClass.TOTAL_INCREASING 470 | 471 | class MinutesRest(CoordinatorEntity, SensorEntity): 472 | """ Representation of today's resting minutes. """ 473 | 474 | def __init__(self, coordinator, pet_id): 475 | super().__init__(coordinator) 476 | self.pet_id = pet_id 477 | 478 | 479 | @property 480 | def pet_data(self) -> Pet: 481 | """ Handle coordinator pet data. """ 482 | 483 | return self.coordinator.data.pets[self.pet_id] 484 | 485 | @property 486 | def device_info(self) -> dict[str, Any]: 487 | """ Return device registry information for this entity. """ 488 | 489 | return { 490 | "identifiers": {(DOMAIN, self.pet_data.id)}, 491 | "name": self.pet_data.data['name'], 492 | "manufacturer": "Whistle", 493 | "model": self.pet_data.data['device']['model_id'], 494 | "configuration_url": "https://www.whistle.com/", 495 | } 496 | 497 | @property 498 | def unique_id(self) -> str: 499 | """ Sets unique ID for this entity. """ 500 | 501 | return str(self.pet_data.id) + '_minutes_rest' 502 | 503 | @property 504 | def name(self) -> str: 505 | """ Return name of the entity. """ 506 | 507 | return "Minutes rest" 508 | 509 | @property 510 | def has_entity_name(self) -> bool: 511 | """ Indicate that entity has name defined. """ 512 | 513 | return True 514 | 515 | @property 516 | def icon(self) -> str: 517 | """ Set icon for entity. """ 518 | 519 | return 'mdi:bed-clock' 520 | 521 | @property 522 | def native_value(self) -> int: 523 | """ Return today's rest minutes. """ 524 | 525 | return self.pet_data.data['activity_summary']['current_minutes_rest'] 526 | 527 | @property 528 | def native_unit_of_measurement(self) -> UnitOfTime: 529 | """ Return minutes as the native unit. """ 530 | 531 | return UnitOfTime.MINUTES 532 | 533 | @property 534 | def device_class(self) -> SensorDeviceClass: 535 | """ Return entity device class. """ 536 | 537 | return SensorDeviceClass.DURATION 538 | 539 | @property 540 | def state_class(self) -> SensorStateClass: 541 | """ Return the type of state class. """ 542 | 543 | return SensorStateClass.TOTAL 544 | 545 | class Streak(CoordinatorEntity, SensorEntity): 546 | """ Representation of activity streak. """ 547 | 548 | def __init__(self, coordinator, pet_id): 549 | super().__init__(coordinator) 550 | self.pet_id = pet_id 551 | 552 | 553 | @property 554 | def pet_data(self) -> Pet: 555 | """ Handle coordinator pet data. """ 556 | 557 | return self.coordinator.data.pets[self.pet_id] 558 | 559 | @property 560 | def device_info(self) -> dict[str, Any]: 561 | """ Return device registry information for this entity. """ 562 | 563 | return { 564 | "identifiers": {(DOMAIN, self.pet_data.id)}, 565 | "name": self.pet_data.data['name'], 566 | "manufacturer": "Whistle", 567 | "model": self.pet_data.data['device']['model_id'], 568 | "configuration_url": "https://www.whistle.com/", 569 | } 570 | 571 | @property 572 | def unique_id(self) -> str: 573 | """ Sets unique ID for this entity. """ 574 | 575 | return str(self.pet_data.id) + '_activity_streak' 576 | 577 | @property 578 | def name(self) -> str: 579 | """ Return name of the entity. """ 580 | 581 | return "Activity streak" 582 | 583 | @property 584 | def has_entity_name(self) -> bool: 585 | """ Indicate that entity has name defined. """ 586 | 587 | return True 588 | 589 | @property 590 | def icon(self) -> str: 591 | """ Set icon for entity. """ 592 | 593 | return 'mdi:chart-timeline-variant-shimmer' 594 | 595 | @property 596 | def native_value(self) -> int: 597 | """ Return current activity streak. """ 598 | 599 | return self.pet_data.data['activity_summary']['current_streak'] 600 | 601 | @property 602 | def native_unit_of_measurement(self) -> UnitOfTime: 603 | """ Return days as the native unit. """ 604 | 605 | return UnitOfTime.DAYS 606 | 607 | @property 608 | def device_class(self) -> SensorDeviceClass: 609 | """ Return entity device class. """ 610 | 611 | return SensorDeviceClass.DURATION 612 | 613 | @property 614 | def state_class(self) -> SensorStateClass: 615 | """ Return the type of state class. """ 616 | 617 | return SensorStateClass.TOTAL_INCREASING 618 | 619 | class ActivityGoal(CoordinatorEntity, SensorEntity): 620 | """ Representation of daily activity goal in minutes. """ 621 | 622 | def __init__(self, coordinator, pet_id): 623 | super().__init__(coordinator) 624 | self.pet_id = pet_id 625 | 626 | 627 | @property 628 | def pet_data(self) -> Pet: 629 | """ Handle coordinator pet data. """ 630 | 631 | return self.coordinator.data.pets[self.pet_id] 632 | 633 | @property 634 | def device_info(self) -> dict[str, Any]: 635 | """ Return device registry information for this entity. """ 636 | 637 | return { 638 | "identifiers": {(DOMAIN, self.pet_data.id)}, 639 | "name": self.pet_data.data['name'], 640 | "manufacturer": "Whistle", 641 | "model": self.pet_data.data['device']['model_id'], 642 | "configuration_url": "https://www.whistle.com/", 643 | } 644 | 645 | @property 646 | def unique_id(self) -> str: 647 | """ Sets unique ID for this entity. """ 648 | 649 | return str(self.pet_data.id) + '_activity_goal' 650 | 651 | @property 652 | def name(self) -> str: 653 | """ Return name of the entity. """ 654 | 655 | return "Activity goal" 656 | 657 | @property 658 | def has_entity_name(self) -> bool: 659 | """ Indicate that entity has name defined. """ 660 | 661 | return True 662 | 663 | @property 664 | def icon(self) -> str: 665 | """ Set icon for entity. """ 666 | 667 | return 'mdi:flag-checkered' 668 | 669 | @property 670 | def native_value(self) -> int: 671 | """ Return today's active minutes. """ 672 | 673 | return self.pet_data.data['activity_summary']['current_activity_goal']['minutes'] 674 | 675 | @property 676 | def native_unit_of_measurement(self) -> UnitOfTime: 677 | """ Return minutes as the native unit. """ 678 | 679 | return UnitOfTime.MINUTES 680 | 681 | @property 682 | def device_class(self) -> SensorDeviceClass: 683 | """ Return entity device class. """ 684 | 685 | return SensorDeviceClass.DURATION 686 | 687 | class Distance(CoordinatorEntity, SensorEntity): 688 | """ Representation of today's distance in miles. """ 689 | 690 | def __init__(self, coordinator, pet_id): 691 | super().__init__(coordinator) 692 | self.pet_id = pet_id 693 | 694 | 695 | @property 696 | def pet_data(self) -> Pet: 697 | """ Handle coordinator pet data. """ 698 | 699 | return self.coordinator.data.pets[self.pet_id] 700 | 701 | @property 702 | def device_info(self) -> dict[str, Any]: 703 | """ Return device registry information for this entity. """ 704 | 705 | return { 706 | "identifiers": {(DOMAIN, self.pet_data.id)}, 707 | "name": self.pet_data.data['name'], 708 | "manufacturer": "Whistle", 709 | "model": self.pet_data.data['device']['model_id'], 710 | "configuration_url": "https://www.whistle.com/", 711 | } 712 | 713 | @property 714 | def unique_id(self) -> str: 715 | """ Sets unique ID for this entity. """ 716 | 717 | return str(self.pet_data.id) + '_distance' 718 | 719 | @property 720 | def name(self) -> str: 721 | """ Return name of the entity. """ 722 | 723 | return "Distance" 724 | 725 | @property 726 | def has_entity_name(self) -> bool: 727 | """ Indicate that entity has name defined. """ 728 | 729 | return True 730 | 731 | @property 732 | def icon(self) -> str: 733 | """ Set icon for entity. """ 734 | 735 | return 'mdi:map-marker-distance' 736 | 737 | @property 738 | def native_value(self) -> float: 739 | """ Return today's distance in miles. """ 740 | 741 | return self.pet_data.dailies['dailies'][00]['distance'] 742 | 743 | @property 744 | def native_unit_of_measurement(self) -> UnitOfLength: 745 | """ Return miles as the native unit. """ 746 | 747 | return UnitOfLength.MILES 748 | 749 | @property 750 | def device_class(self) -> SensorDeviceClass: 751 | """ Return entity device class. """ 752 | 753 | return SensorDeviceClass.DISTANCE 754 | 755 | @property 756 | def state_class(self) -> SensorStateClass: 757 | """ Return the type of state class. """ 758 | 759 | return SensorStateClass.TOTAL_INCREASING 760 | 761 | class Calories(CoordinatorEntity, SensorEntity): 762 | """ Representation of today's calories. """ 763 | 764 | def __init__(self, coordinator, pet_id): 765 | super().__init__(coordinator) 766 | self.pet_id = pet_id 767 | 768 | 769 | @property 770 | def pet_data(self) -> Pet: 771 | """ Handle coordinator pet data. """ 772 | 773 | return self.coordinator.data.pets[self.pet_id] 774 | 775 | @property 776 | def device_info(self) -> dict[str, Any]: 777 | """ Return device registry information for this entity. """ 778 | 779 | return { 780 | "identifiers": {(DOMAIN, self.pet_data.id)}, 781 | "name": self.pet_data.data['name'], 782 | "manufacturer": "Whistle", 783 | "model": self.pet_data.data['device']['model_id'], 784 | "configuration_url": "https://www.whistle.com/", 785 | } 786 | 787 | @property 788 | def unique_id(self) -> str: 789 | """ Sets unique ID for this entity. """ 790 | 791 | return str(self.pet_data.id) + '_calories' 792 | 793 | @property 794 | def name(self) -> str: 795 | """ Return name of the entity. """ 796 | 797 | return "Calories" 798 | 799 | @property 800 | def has_entity_name(self) -> bool: 801 | """ Indicate that entity has name defined. """ 802 | 803 | return True 804 | 805 | @property 806 | def icon(self) -> str: 807 | """ Set icon for entity. """ 808 | 809 | return 'mdi:fire' 810 | 811 | @property 812 | def native_value(self) -> int: 813 | """ Return today's calories burned. """ 814 | 815 | return int(self.pet_data.dailies['dailies'][00]['calories']) 816 | 817 | @property 818 | def native_unit_of_measurement(self) -> str: 819 | """ Return calories as the native unit. """ 820 | 821 | return 'cal' 822 | 823 | @property 824 | def state_class(self) -> SensorStateClass: 825 | """ Return the type of state class. """ 826 | 827 | return SensorStateClass.TOTAL_INCREASING 828 | 829 | class LastCheckIn(CoordinatorEntity, SensorEntity): 830 | """ Representation of last time device sent data to Whistle servers. """ 831 | 832 | def __init__(self, coordinator, pet_id): 833 | super().__init__(coordinator) 834 | self.pet_id = pet_id 835 | 836 | 837 | @property 838 | def pet_data(self) -> Pet: 839 | """ Handle coordinator pet data. """ 840 | 841 | return self.coordinator.data.pets[self.pet_id] 842 | 843 | @property 844 | def device_info(self) -> dict[str, Any]: 845 | """ Return device registry information for this entity. """ 846 | 847 | return { 848 | "identifiers": {(DOMAIN, self.pet_data.id)}, 849 | "name": self.pet_data.data['name'], 850 | "manufacturer": "Whistle", 851 | "model": self.pet_data.data['device']['model_id'], 852 | "configuration_url": "https://www.whistle.com/", 853 | } 854 | 855 | @property 856 | def unique_id(self) -> str: 857 | """ Sets unique ID for this entity. """ 858 | 859 | return str(self.pet_data.id) + '_last_check_in' 860 | 861 | @property 862 | def name(self) -> str: 863 | """ Return name of the entity. """ 864 | 865 | return "Last check-in" 866 | 867 | @property 868 | def has_entity_name(self) -> bool: 869 | """ Indicate that entity has name defined. """ 870 | 871 | return True 872 | 873 | @property 874 | def icon(self) -> str: 875 | """ Set icon for entity. """ 876 | 877 | return 'mdi:server-network' 878 | 879 | @property 880 | def native_value(self) -> datetime: 881 | """ Return last check-in as datetime. """ 882 | current_tz = self.pet_data.data['profile']['time_zone_name'] 883 | return datetime.fromisoformat(self.pet_data.data['device']['last_check_in'].replace(' ' + current_tz, '')).replace(tzinfo=ZoneInfo(current_tz)).astimezone() 884 | 885 | @property 886 | def device_class(self) -> SensorDeviceClass: 887 | """ Return entity device class. """ 888 | 889 | return SensorDeviceClass.TIMESTAMP 890 | 891 | @property 892 | def entity_category(self) -> EntityCategory: 893 | """ Set category to diagnostic. """ 894 | 895 | return EntityCategory.DIAGNOSTIC 896 | 897 | class Event(CoordinatorEntity, SensorEntity): 898 | """ Representation of latest event. """ 899 | 900 | def __init__(self, coordinator, pet_id): 901 | super().__init__(coordinator) 902 | self.pet_id = pet_id 903 | 904 | 905 | @property 906 | def pet_data(self) -> Pet: 907 | """ Handle coordinator pet data. """ 908 | 909 | return self.coordinator.data.pets[self.pet_id] 910 | 911 | @property 912 | def device_info(self) -> dict[str, Any]: 913 | """ Return device registry information for this entity. """ 914 | 915 | return { 916 | "identifiers": {(DOMAIN, self.pet_data.id)}, 917 | "name": self.pet_data.data['name'], 918 | "manufacturer": "Whistle", 919 | "model": self.pet_data.data['device']['model_id'], 920 | "configuration_url": "https://www.whistle.com/", 921 | } 922 | 923 | @property 924 | def unique_id(self) -> str: 925 | """ Sets unique ID for this entity. """ 926 | 927 | return str(self.pet_data.id) + '_event' 928 | 929 | @property 930 | def name(self) -> str: 931 | """ Return name of the entity. """ 932 | 933 | return "Latest event" 934 | 935 | @property 936 | def has_entity_name(self) -> bool: 937 | """ Indicate that entity has name defined. """ 938 | 939 | return True 940 | 941 | @property 942 | def icon(self) -> str: 943 | """ Set icon for entity. """ 944 | 945 | if self.pet_data.data['profile']['species'] == 'dog': 946 | return 'mdi:dog' 947 | if self.pet_data.data['profile']['species'] == 'cat': 948 | return 'mdi:cat' 949 | 950 | @property 951 | def native_value(self) -> str: 952 | """ Return latest event. """ 953 | 954 | return self.pet_data.events['daily_items'][00]['title'] 955 | 956 | @property 957 | def available(self) -> bool: 958 | """ Only return True if an event exists for today. """ 959 | 960 | if self.pet_data.events['daily_items']: 961 | return True 962 | else: 963 | return False 964 | 965 | class EventStart(CoordinatorEntity, SensorEntity): 966 | """ Representation of when last event started. """ 967 | 968 | def __init__(self, coordinator, pet_id): 969 | super().__init__(coordinator) 970 | self.pet_id = pet_id 971 | 972 | 973 | @property 974 | def pet_data(self) -> Pet: 975 | """ Handle coordinator pet data. """ 976 | 977 | return self.coordinator.data.pets[self.pet_id] 978 | 979 | @property 980 | def device_info(self) -> dict[str, Any]: 981 | """ Return device registry information for this entity. """ 982 | 983 | return { 984 | "identifiers": {(DOMAIN, self.pet_data.id)}, 985 | "name": self.pet_data.data['name'], 986 | "manufacturer": "Whistle", 987 | "model": self.pet_data.data['device']['model_id'], 988 | "configuration_url": "https://www.whistle.com/", 989 | } 990 | 991 | @property 992 | def unique_id(self) -> str: 993 | """ Sets unique ID for this entity. """ 994 | 995 | return str(self.pet_data.id) + '_event_start' 996 | 997 | @property 998 | def name(self) -> str: 999 | """ Return name of the entity. """ 1000 | 1001 | return "Event start" 1002 | 1003 | @property 1004 | def has_entity_name(self) -> bool: 1005 | """ Indicate that entity has name defined. """ 1006 | 1007 | return True 1008 | 1009 | @property 1010 | def icon(self) -> str: 1011 | """ Set icon for entity. """ 1012 | 1013 | return 'mdi:timer-play-outline' 1014 | 1015 | @property 1016 | def native_value(self) -> datetime: 1017 | """ Return event start time. """ 1018 | 1019 | return datetime.fromisoformat(self.pet_data.events['daily_items'][00]['start_time'].replace('Z', '+00:00')).astimezone() 1020 | 1021 | @property 1022 | def device_class(self) -> SensorDeviceClass: 1023 | """ Return entity device class. """ 1024 | 1025 | return SensorDeviceClass.TIMESTAMP 1026 | 1027 | @property 1028 | def available(self) -> bool: 1029 | """ Only return True if an event exists for today. """ 1030 | 1031 | if self.pet_data.events['daily_items']: 1032 | return True 1033 | else: 1034 | return False 1035 | 1036 | class EventEnd(CoordinatorEntity, SensorEntity): 1037 | """ Representation of when last event ended. """ 1038 | 1039 | def __init__(self, coordinator, pet_id): 1040 | super().__init__(coordinator) 1041 | self.pet_id = pet_id 1042 | 1043 | 1044 | @property 1045 | def pet_data(self) -> Pet: 1046 | """ Handle coordinator pet data. """ 1047 | 1048 | return self.coordinator.data.pets[self.pet_id] 1049 | 1050 | @property 1051 | def device_info(self) -> dict[str, Any]: 1052 | """ Return device registry information for this entity. """ 1053 | 1054 | return { 1055 | "identifiers": {(DOMAIN, self.pet_data.id)}, 1056 | "name": self.pet_data.data['name'], 1057 | "manufacturer": "Whistle", 1058 | "model": self.pet_data.data['device']['model_id'], 1059 | "configuration_url": "https://www.whistle.com/", 1060 | } 1061 | 1062 | @property 1063 | def unique_id(self) -> str: 1064 | """ Sets unique ID for this entity. """ 1065 | 1066 | return str(self.pet_data.id) + '_event_end' 1067 | 1068 | @property 1069 | def name(self) -> str: 1070 | """ Return name of the entity. """ 1071 | 1072 | return "Event end" 1073 | 1074 | @property 1075 | def has_entity_name(self) -> bool: 1076 | """ Indicate that entity has name defined. """ 1077 | 1078 | return True 1079 | 1080 | @property 1081 | def icon(self) -> str: 1082 | """ Set icon for entity. """ 1083 | 1084 | return 'mdi:timer-pause-outline' 1085 | 1086 | @property 1087 | def native_value(self) -> datetime: 1088 | """ Return event end time. """ 1089 | 1090 | return datetime.fromisoformat(self.pet_data.events['daily_items'][00]['end_time'].replace('Z', '+00:00')).astimezone() 1091 | 1092 | @property 1093 | def device_class(self) -> SensorDeviceClass: 1094 | """ Return entity device class. """ 1095 | 1096 | return SensorDeviceClass.TIMESTAMP 1097 | 1098 | @property 1099 | def available(self) -> bool: 1100 | """ Only return True if an event exists for today. """ 1101 | 1102 | if self.pet_data.events['daily_items']: 1103 | return True 1104 | else: 1105 | return False 1106 | 1107 | class EventDistance(CoordinatorEntity, SensorEntity): 1108 | """ Representation of distance covered during latest event. """ 1109 | 1110 | def __init__(self, coordinator, pet_id): 1111 | super().__init__(coordinator) 1112 | self.pet_id = pet_id 1113 | 1114 | 1115 | @property 1116 | def pet_data(self) -> Pet: 1117 | """ Handle coordinator pet data. """ 1118 | 1119 | return self.coordinator.data.pets[self.pet_id] 1120 | 1121 | @property 1122 | def device_info(self) -> dict[str, Any]: 1123 | """ Return device registry information for this entity. """ 1124 | 1125 | return { 1126 | "identifiers": {(DOMAIN, self.pet_data.id)}, 1127 | "name": self.pet_data.data['name'], 1128 | "manufacturer": "Whistle", 1129 | "model": self.pet_data.data['device']['model_id'], 1130 | "configuration_url": "https://www.whistle.com/", 1131 | } 1132 | 1133 | @property 1134 | def unique_id(self) -> str: 1135 | """ Sets unique ID for this entity. """ 1136 | 1137 | return str(self.pet_data.id) + '_event_distance' 1138 | 1139 | @property 1140 | def name(self) -> str: 1141 | """ Return name of the entity. """ 1142 | 1143 | return "Event distance" 1144 | 1145 | @property 1146 | def has_entity_name(self) -> bool: 1147 | """ Indicate that entity has name defined. """ 1148 | 1149 | return True 1150 | 1151 | @property 1152 | def icon(self) -> str: 1153 | """ Set icon for entity. """ 1154 | 1155 | return 'mdi:map-marker-distance' 1156 | 1157 | @property 1158 | def native_value(self) -> float: 1159 | """ Return event distance in miles. """ 1160 | 1161 | if 'distance' in self.pet_data.events['daily_items'][00]['data']: 1162 | return self.pet_data.events['daily_items'][00]['data']['distance'] 1163 | else: 1164 | return 0.0 1165 | 1166 | @property 1167 | def native_unit_of_measurement(self) -> UnitOfLength: 1168 | """ Return miles as the native unit. """ 1169 | 1170 | return UnitOfLength.MILES 1171 | 1172 | @property 1173 | def device_class(self) -> SensorDeviceClass: 1174 | """ Return entity device class. """ 1175 | 1176 | return SensorDeviceClass.DISTANCE 1177 | 1178 | @property 1179 | def available(self) -> bool: 1180 | """ Only return True if an event exists for today. """ 1181 | 1182 | if self.pet_data.events['daily_items']: 1183 | return True 1184 | else: 1185 | return False 1186 | 1187 | class EventCalories(CoordinatorEntity, SensorEntity): 1188 | """ Representation of calories burned during latest event. """ 1189 | 1190 | def __init__(self, coordinator, pet_id): 1191 | super().__init__(coordinator) 1192 | self.pet_id = pet_id 1193 | 1194 | 1195 | @property 1196 | def pet_data(self) -> Pet: 1197 | """ Handle coordinator pet data. """ 1198 | 1199 | return self.coordinator.data.pets[self.pet_id] 1200 | 1201 | @property 1202 | def device_info(self) -> dict[str, Any]: 1203 | """ Return device registry information for this entity. """ 1204 | 1205 | return { 1206 | "identifiers": {(DOMAIN, self.pet_data.id)}, 1207 | "name": self.pet_data.data['name'], 1208 | "manufacturer": "Whistle", 1209 | "model": self.pet_data.data['device']['model_id'], 1210 | "configuration_url": "https://www.whistle.com/", 1211 | } 1212 | 1213 | @property 1214 | def unique_id(self) -> str: 1215 | """ Sets unique ID for this entity. """ 1216 | 1217 | return str(self.pet_data.id) + '_event_calories' 1218 | 1219 | @property 1220 | def name(self) -> str: 1221 | """ Return name of the entity. """ 1222 | 1223 | return "Event calories" 1224 | 1225 | @property 1226 | def has_entity_name(self) -> bool: 1227 | """ Indicate that entity has name defined. """ 1228 | 1229 | return True 1230 | 1231 | @property 1232 | def icon(self) -> str: 1233 | """ Set icon for entity. """ 1234 | 1235 | return 'mdi:fire' 1236 | 1237 | @property 1238 | def native_value(self) -> int: 1239 | """ Return today's calories burned. """ 1240 | 1241 | if 'calories' in self.pet_data.events['daily_items'][00]['data']: 1242 | return self.pet_data.events['daily_items'][00]['data']['calories'] 1243 | else: 1244 | return 0 1245 | 1246 | @property 1247 | def native_unit_of_measurement(self) -> str: 1248 | """ Return calories as the native unit. """ 1249 | 1250 | return 'cal' 1251 | 1252 | @property 1253 | def state_class(self) -> SensorStateClass: 1254 | """ Return the type of state class. """ 1255 | 1256 | return SensorStateClass.TOTAL 1257 | 1258 | @property 1259 | def available(self) -> bool: 1260 | """ Only return True if an event exists for today. """ 1261 | 1262 | if self.pet_data.events['daily_items']: 1263 | return True 1264 | else: 1265 | return False 1266 | 1267 | class EventDuration(CoordinatorEntity, SensorEntity): 1268 | """ Representation of latest event duration in minutes. """ 1269 | 1270 | def __init__(self, coordinator, pet_id): 1271 | super().__init__(coordinator) 1272 | self.pet_id = pet_id 1273 | 1274 | 1275 | @property 1276 | def pet_data(self) -> Pet: 1277 | """ Handle coordinator pet data. """ 1278 | 1279 | return self.coordinator.data.pets[self.pet_id] 1280 | 1281 | @property 1282 | def device_info(self) -> dict[str, Any]: 1283 | """ Return device registry information for this entity. """ 1284 | 1285 | return { 1286 | "identifiers": {(DOMAIN, self.pet_data.id)}, 1287 | "name": self.pet_data.data['name'], 1288 | "manufacturer": "Whistle", 1289 | "model": self.pet_data.data['device']['model_id'], 1290 | "configuration_url": "https://www.whistle.com/", 1291 | } 1292 | 1293 | @property 1294 | def unique_id(self) -> str: 1295 | """ Sets unique ID for this entity. """ 1296 | 1297 | return str(self.pet_data.id) + '_event_duration' 1298 | 1299 | @property 1300 | def name(self) -> str: 1301 | """ Return name of the entity. """ 1302 | 1303 | return "Event duration" 1304 | 1305 | @property 1306 | def has_entity_name(self) -> bool: 1307 | """ Indicate that entity has name defined. """ 1308 | 1309 | return True 1310 | 1311 | @property 1312 | def icon(self) -> str: 1313 | """ Set icon for entity. """ 1314 | 1315 | return 'mdi:timer-outline' 1316 | 1317 | @property 1318 | def native_value(self) -> float: 1319 | """ Return latest event duration in minutes. """ 1320 | 1321 | if 'duration' in self.pet_data.events['daily_items'][00]['data']: 1322 | return self.pet_data.events['daily_items'][00]['data']['duration'] 1323 | else: 1324 | return 0.0 1325 | 1326 | @property 1327 | def native_unit_of_measurement(self) -> UnitOfTime: 1328 | """ Return minutes as the native unit. """ 1329 | 1330 | return UnitOfTime.MINUTES 1331 | 1332 | @property 1333 | def device_class(self) -> SensorDeviceClass: 1334 | """ Return entity device class. """ 1335 | 1336 | return SensorDeviceClass.DURATION 1337 | 1338 | @property 1339 | def available(self) -> bool: 1340 | """ Only return True if an event exists for today. """ 1341 | 1342 | if self.pet_data.events['daily_items']: 1343 | return True 1344 | else: 1345 | return False 1346 | 1347 | 1348 | class HealthScratching(CoordinatorEntity, SensorEntity): 1349 | """ Representation of latest scratching metric. """ 1350 | 1351 | def __init__(self, coordinator, pet_id): 1352 | super().__init__(coordinator) 1353 | self.pet_id = pet_id 1354 | 1355 | 1356 | @property 1357 | def pet_data(self) -> Pet: 1358 | """ Handle coordinator pet data. """ 1359 | 1360 | return self.coordinator.data.pets[self.pet_id] 1361 | 1362 | @property 1363 | def device_info(self) -> dict[str, Any]: 1364 | """ Return device registry information for this entity. """ 1365 | 1366 | return { 1367 | "identifiers": {(DOMAIN, self.pet_data.id)}, 1368 | "name": self.pet_data.data['name'], 1369 | "manufacturer": "Whistle", 1370 | "model": self.pet_data.data['device']['model_id'], 1371 | "configuration_url": "https://www.whistle.com/", 1372 | } 1373 | 1374 | @property 1375 | def unique_id(self) -> str: 1376 | """ Sets unique ID for this entity. """ 1377 | 1378 | return str(self.pet_data.id) + '_health_scratching' 1379 | 1380 | @property 1381 | def name(self) -> str: 1382 | """ Return name of the entity. """ 1383 | 1384 | return "Scratching" 1385 | 1386 | @property 1387 | def has_entity_name(self) -> bool: 1388 | """ Indicate that entity has name defined. """ 1389 | 1390 | return True 1391 | 1392 | @property 1393 | def icon(self) -> str: 1394 | """ Set icon for entity. """ 1395 | 1396 | return 'mdi:paw' 1397 | 1398 | @property 1399 | def native_value(self) -> str: 1400 | """ Return latest grade. """ 1401 | 1402 | formatted_string = self.pet_data.health['scratching']['status'].replace('_', ' ').capitalize() 1403 | return formatted_string 1404 | 1405 | @property 1406 | def scratching_duration(self) -> int: 1407 | """Return latest scratching time metric.""" 1408 | 1409 | return self.pet_data.health['scratching']['metrics'][0]['value'] 1410 | 1411 | @property 1412 | def extra_state_attributes(self) -> dict[str, str]: 1413 | """Return extra attributes.""" 1414 | 1415 | return { 1416 | 'duration': f'{self.scratching_duration}s' 1417 | } 1418 | 1419 | @property 1420 | def available(self) -> bool: 1421 | """ Only return True if an event exists for today. """ 1422 | 1423 | if self.pet_data.health['scratching']: 1424 | return True 1425 | else: 1426 | return False 1427 | 1428 | 1429 | class HealthLicking(CoordinatorEntity, SensorEntity): 1430 | """ Representation of latest licking metric. """ 1431 | 1432 | def __init__(self, coordinator, pet_id): 1433 | super().__init__(coordinator) 1434 | self.pet_id = pet_id 1435 | 1436 | 1437 | @property 1438 | def pet_data(self) -> Pet: 1439 | """ Handle coordinator pet data. """ 1440 | 1441 | return self.coordinator.data.pets[self.pet_id] 1442 | 1443 | @property 1444 | def device_info(self) -> dict[str, Any]: 1445 | """ Return device registry information for this entity. """ 1446 | 1447 | return { 1448 | "identifiers": {(DOMAIN, self.pet_data.id)}, 1449 | "name": self.pet_data.data['name'], 1450 | "manufacturer": "Whistle", 1451 | "model": self.pet_data.data['device']['model_id'], 1452 | "configuration_url": "https://www.whistle.com/", 1453 | } 1454 | 1455 | @property 1456 | def unique_id(self) -> str: 1457 | """ Sets unique ID for this entity. """ 1458 | 1459 | return str(self.pet_data.id) + '_health_licking' 1460 | 1461 | @property 1462 | def name(self) -> str: 1463 | """ Return name of the entity. """ 1464 | 1465 | return "Licking" 1466 | 1467 | @property 1468 | def has_entity_name(self) -> bool: 1469 | """ Indicate that entity has name defined. """ 1470 | 1471 | return True 1472 | 1473 | @property 1474 | def icon(self) -> str: 1475 | """ Set icon for entity. """ 1476 | 1477 | return 'mdi:emoticon-tongue-outline' 1478 | 1479 | @property 1480 | def native_value(self) -> str: 1481 | """ Return latest grade. """ 1482 | 1483 | formatted_string = self.pet_data.health['licking']['status'].replace('_', ' ').capitalize() 1484 | return formatted_string 1485 | 1486 | @property 1487 | def licking_duration(self) -> int: 1488 | """Return latest licking time metric.""" 1489 | 1490 | return self.pet_data.health['licking']['metrics'][0]['value'] 1491 | 1492 | @property 1493 | def extra_state_attributes(self): 1494 | """Return extra attributes.""" 1495 | 1496 | return { 1497 | 'duration': f'{self.licking_duration}s' 1498 | } 1499 | 1500 | @property 1501 | def available(self) -> bool: 1502 | """ Only return True if an event exists for today. """ 1503 | 1504 | if self.pet_data.health['licking']: 1505 | return True 1506 | else: 1507 | return False 1508 | 1509 | 1510 | class HealthDrinking(CoordinatorEntity, SensorEntity): 1511 | """ Representation of latest drinking metric. """ 1512 | 1513 | def __init__(self, coordinator, pet_id): 1514 | super().__init__(coordinator) 1515 | self.pet_id = pet_id 1516 | 1517 | 1518 | @property 1519 | def pet_data(self) -> Pet: 1520 | """ Handle coordinator pet data. """ 1521 | 1522 | return self.coordinator.data.pets[self.pet_id] 1523 | 1524 | @property 1525 | def device_info(self) -> dict[str, Any]: 1526 | """ Return device registry information for this entity. """ 1527 | 1528 | return { 1529 | "identifiers": {(DOMAIN, self.pet_data.id)}, 1530 | "name": self.pet_data.data['name'], 1531 | "manufacturer": "Whistle", 1532 | "model": self.pet_data.data['device']['model_id'], 1533 | "configuration_url": "https://www.whistle.com/", 1534 | } 1535 | 1536 | @property 1537 | def unique_id(self) -> str: 1538 | """ Sets unique ID for this entity. """ 1539 | 1540 | return str(self.pet_data.id) + '_health_drinking' 1541 | 1542 | @property 1543 | def name(self) -> str: 1544 | """ Return name of the entity. """ 1545 | 1546 | return "Drinking" 1547 | 1548 | @property 1549 | def has_entity_name(self) -> bool: 1550 | """ Indicate that entity has name defined. """ 1551 | 1552 | return True 1553 | 1554 | @property 1555 | def icon(self) -> str: 1556 | """ Set icon for entity. """ 1557 | 1558 | return 'mdi:cup' 1559 | 1560 | @property 1561 | def native_value(self) -> str: 1562 | """ Return latest grade. """ 1563 | 1564 | formatted_string = self.pet_data.health['drinking']['status'].replace('_', ' ').capitalize() 1565 | return formatted_string 1566 | 1567 | @property 1568 | def drinking_duration(self) -> int: 1569 | """Return latest drinking time metric.""" 1570 | 1571 | return self.pet_data.health['drinking']['metrics'][0]['value'] 1572 | 1573 | @property 1574 | def extra_state_attributes(self): 1575 | """Return extra attributes.""" 1576 | 1577 | return { 1578 | 'duration': f'{self.drinking_duration}s' 1579 | } 1580 | 1581 | @property 1582 | def available(self) -> bool: 1583 | """ Only return True if an event exists for today. """ 1584 | 1585 | if self.pet_data.health['drinking']: 1586 | return True 1587 | else: 1588 | return False 1589 | 1590 | 1591 | class HealthSleeping(CoordinatorEntity, SensorEntity): 1592 | """ Representation of latest sleeping metric. """ 1593 | 1594 | def __init__(self, coordinator, pet_id): 1595 | super().__init__(coordinator) 1596 | self.pet_id = pet_id 1597 | 1598 | 1599 | @property 1600 | def pet_data(self) -> Pet: 1601 | """ Handle coordinator pet data. """ 1602 | 1603 | return self.coordinator.data.pets[self.pet_id] 1604 | 1605 | @property 1606 | def device_info(self) -> dict[str, Any]: 1607 | """ Return device registry information for this entity. """ 1608 | 1609 | return { 1610 | "identifiers": {(DOMAIN, self.pet_data.id)}, 1611 | "name": self.pet_data.data['name'], 1612 | "manufacturer": "Whistle", 1613 | "model": self.pet_data.data['device']['model_id'], 1614 | "configuration_url": "https://www.whistle.com/", 1615 | } 1616 | 1617 | @property 1618 | def unique_id(self) -> str: 1619 | """ Sets unique ID for this entity. """ 1620 | 1621 | return str(self.pet_data.id) + '_health_sleeping' 1622 | 1623 | @property 1624 | def name(self) -> str: 1625 | """ Return name of the entity. """ 1626 | 1627 | return "Sleeping" 1628 | 1629 | @property 1630 | def has_entity_name(self) -> bool: 1631 | """ Indicate that entity has name defined. """ 1632 | 1633 | return True 1634 | 1635 | @property 1636 | def icon(self) -> str: 1637 | """ Set icon for entity. """ 1638 | 1639 | return 'mdi:sleep' 1640 | 1641 | @property 1642 | def native_value(self) -> str: 1643 | """ Return latest grade. """ 1644 | 1645 | formatted_string = self.pet_data.health['sleeping']['status'].replace('_', ' ').capitalize() 1646 | return formatted_string 1647 | 1648 | @property 1649 | def sleeping_duration(self) -> int: 1650 | """Return latest sleeping duration metric.""" 1651 | 1652 | return self.pet_data.health['sleeping']['metrics'][0]['value'] 1653 | 1654 | @property 1655 | def sleeping_disruptions(self) -> int: 1656 | """Return latest sleeping disruptions metric.""" 1657 | 1658 | return self.pet_data.health['sleeping']['metrics'][1]['value'] 1659 | 1660 | @property 1661 | def extra_state_attributes(self): 1662 | """Return extra attributes.""" 1663 | 1664 | return { 1665 | 'duration': f'{self.sleeping_duration}s', 1666 | 'disruptions': self.sleeping_disruptions 1667 | } 1668 | 1669 | @property 1670 | def available(self) -> bool: 1671 | """ Only return True if an event exists for today. """ 1672 | 1673 | if self.pet_data.health['sleeping']: 1674 | return True 1675 | else: 1676 | return False 1677 | 1678 | 1679 | class HealthEating(CoordinatorEntity, SensorEntity): 1680 | """ Representation of latest eating metric. """ 1681 | 1682 | def __init__(self, coordinator, pet_id): 1683 | super().__init__(coordinator) 1684 | self.pet_id = pet_id 1685 | 1686 | 1687 | @property 1688 | def pet_data(self) -> Pet: 1689 | """ Handle coordinator pet data. """ 1690 | 1691 | return self.coordinator.data.pets[self.pet_id] 1692 | 1693 | @property 1694 | def device_info(self) -> dict[str, Any]: 1695 | """ Return device registry information for this entity. """ 1696 | 1697 | return { 1698 | "identifiers": {(DOMAIN, self.pet_data.id)}, 1699 | "name": self.pet_data.data['name'], 1700 | "manufacturer": "Whistle", 1701 | "model": self.pet_data.data['device']['model_id'], 1702 | "configuration_url": "https://www.whistle.com/", 1703 | } 1704 | 1705 | @property 1706 | def unique_id(self) -> str: 1707 | """ Sets unique ID for this entity. """ 1708 | 1709 | return str(self.pet_data.id) + '_health_eating' 1710 | 1711 | @property 1712 | def name(self) -> str: 1713 | """ Return name of the entity. """ 1714 | 1715 | return "Eating" 1716 | 1717 | @property 1718 | def has_entity_name(self) -> bool: 1719 | """ Indicate that entity has name defined. """ 1720 | 1721 | return True 1722 | 1723 | @property 1724 | def icon(self) -> str: 1725 | """ Set icon for entity. """ 1726 | 1727 | return 'mdi:food-drumstick' 1728 | 1729 | @property 1730 | def native_value(self) -> str: 1731 | """ Return latest grade. """ 1732 | 1733 | formatted_string = self.pet_data.health['eating']['status'].replace('_', ' ').capitalize() 1734 | return formatted_string 1735 | 1736 | @property 1737 | def eating_duration(self) -> int: 1738 | """Return latest eating duration metric.""" 1739 | 1740 | return self.pet_data.health['eating']['metrics'][0]['value'] 1741 | 1742 | @property 1743 | def extra_state_attributes(self): 1744 | """Return extra attributes.""" 1745 | 1746 | return { 1747 | 'duration': f'{self.eating_duration}s' 1748 | } 1749 | 1750 | @property 1751 | def available(self) -> bool: 1752 | """ Only return True if an event exists for today. """ 1753 | 1754 | if self.pet_data.health['eating']: 1755 | return True 1756 | else: 1757 | return False 1758 | 1759 | 1760 | class HealthWellnessIdx(CoordinatorEntity, SensorEntity): 1761 | """ Representation of latest health wellness index. """ 1762 | 1763 | def __init__(self, coordinator, pet_id): 1764 | super().__init__(coordinator) 1765 | self.pet_id = pet_id 1766 | 1767 | 1768 | @property 1769 | def pet_data(self) -> Pet: 1770 | """ Handle coordinator pet data. """ 1771 | 1772 | return self.coordinator.data.pets[self.pet_id] 1773 | 1774 | @property 1775 | def device_info(self) -> dict[str, Any]: 1776 | """ Return device registry information for this entity. """ 1777 | 1778 | return { 1779 | "identifiers": {(DOMAIN, self.pet_data.id)}, 1780 | "name": self.pet_data.data['name'], 1781 | "manufacturer": "Whistle", 1782 | "model": self.pet_data.data['device']['model_id'], 1783 | "configuration_url": "https://www.whistle.com/", 1784 | } 1785 | 1786 | @property 1787 | def unique_id(self) -> str: 1788 | """ Sets unique ID for this entity. """ 1789 | 1790 | return str(self.pet_data.id) + '_health_wellness' 1791 | 1792 | @property 1793 | def name(self) -> str: 1794 | """ Return name of the entity. """ 1795 | 1796 | return "Wellness index" 1797 | 1798 | @property 1799 | def has_entity_name(self) -> bool: 1800 | """ Indicate that entity has name defined. """ 1801 | 1802 | return True 1803 | 1804 | @property 1805 | def icon(self) -> str: 1806 | """ Set icon for entity. """ 1807 | 1808 | return 'mdi:heart' 1809 | 1810 | @property 1811 | def native_value(self) -> str: 1812 | """ Return latest grade. """ 1813 | 1814 | formatted_string = self.pet_data.health['wellness_index']['status'].replace('_', ' ').capitalize() 1815 | return formatted_string 1816 | 1817 | @property 1818 | def wellness_score(self) -> int: 1819 | """Return latest wellness index score.""" 1820 | 1821 | return self.pet_data.health['wellness_index']['metrics'][0]['value'] 1822 | 1823 | @property 1824 | def extra_state_attributes(self): 1825 | """Return extra attributes.""" 1826 | 1827 | return { 1828 | 'score': self.wellness_score 1829 | } 1830 | 1831 | @property 1832 | def available(self) -> bool: 1833 | """ Only return True if an event exists for today. """ 1834 | 1835 | if self.pet_data.health['wellness_index']: 1836 | return True 1837 | else: 1838 | return False 1839 | --------------------------------------------------------------------------------