├── 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 | [](https://github.com/custom-components/hacs) 
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 |
9 |
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 | [](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 |
86 |
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 |
--------------------------------------------------------------------------------