├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── examples ├── homeassistant │ ├── README.md │ ├── __init__.py │ ├── device_tracker.py │ └── manifest.json └── test.py ├── pywhistle ├── __init__.py └── client.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | pywhistle.egg-info 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 The Python Packaging Authority 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #~/P/W/aio ❯❯❯ twine upload dist/* ⏎ 2 | #~/P/W/aio ❯❯❯ python3 setup.py sdist bdist_wheel ⏎ 3 | #~/P/W/aio ❯❯❯ rm -rf pywhistle.egg-info build dist 4 | 5 | all: pywhistle.egg-info 6 | 7 | pywhistle.egg-info: 8 | python3 setup.py sdist bdist_wheel 9 | 10 | clean: 11 | rm -rf pywhistle.egg-info build dist 12 | 13 | push: 14 | twine upload dist/* 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![PyPI - Downloads](https://img.shields.io/pypi/dd/pywhistle) 2 | 3 | This is a very basic library to query Whistle.app's API for Whistle 3 devices. It is also compatible with all new devices. 4 | 5 | The API is not published, so it may break eventually (although compatibility has not broken in several years) 6 | 7 | In the `examples/homeassistant` folder you will find a custom component to integrate this information in your dashboard. 8 | 9 | Available information for each pet, including dailies: 10 | 11 | - Activity goal, in minutes 12 | - Activity streak 13 | - Activity, in minutes 14 | - Rest time, in minutes 15 | - Distance walked 16 | - Calories 17 | - Battery level 18 | - Battery wifi usage 19 | - Battery cell usage 20 | - Battery days left 21 | 22 | To use the library itself in your project: 23 | 24 | ``` 25 | pip install pywhistle 26 | ``` 27 | 28 | You can specify it in `requirements.txt` as well: 29 | 30 | ``` 31 | pywhistle==0.0.4 32 | ``` 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /examples/homeassistant/README.md: -------------------------------------------------------------------------------- 1 | # What 2 | 3 | ![](https://community-home-assistant-assets.s3.dualstack.us-west-2.amazonaws.com/original/3X/0/b/0bad2a8e1b4494b8c7ae209a103c8cf142c7543f.jpeg) 4 | 5 | # Setup 6 | 7 | This was tested and works with Home Assistant 0.96. 8 | 9 | You need to clone this folder to '{config path}/custom_components'. 10 | The commands below should create the correct hierarchy while not downloading the whole code, yet allowing you to upgrade. 11 | 12 | ``` 13 | # Modify the following line to replace the path after "cd" to match your folder structure 14 | cd home-assistant/config/custom_components 15 | git clone --depth=1 git@github.com:Fusion/pywhistle.git --no-checkout 16 | cd pywhistle 17 | git checkout master -- examples/homeassistant 18 | cd .. 19 | ln -s pywhistle/examples/homeassistant whistle 20 | ``` 21 | 22 | Note that the organization suggested below for your yaml files may not match your own. In that case, I trust that you will know which files to modify. 23 | 24 | Add the device tracker to 'configuration.yaml': 25 | 26 | ``` 27 | device_tracker: 28 | - platform: whistle 29 | username: !secret whistle_username 30 | password: !secret whistle_password 31 | monitored_variables: 32 | - WHISTLE 33 | ``` 34 | 35 | Provide the correct credentials in 'secrets.yaml': 36 | 37 | ``` 38 | whistle_username: {your email address} 39 | whistle_password: {your password} 40 | ``` 41 | 42 | Declare the following template in 'configuration.yaml', in order to retrieve the tracker's attributes. 43 | You will have to replace 'whistle_charlie' with your tracker's name as found in the 'states' tab: 44 | 45 | ``` 46 | sensor: 47 | - platform: template 48 | sensors: 49 | charlie_goal_minutes: 50 | friendly_name: "Activity Goal" 51 | icon_template: mdi:trophy-outline 52 | value_template: '{{ state_attr("device_tracker.whistle_charlie", "activity_goal") }}' 53 | unit_of_measurement: "minutes" 54 | charlie_goal_streak: 55 | friendly_name: "Activity Streak" 56 | icon_template: mdi:chart-timeline 57 | value_template: '{{ state_attr("device_tracker.whistle_charlie", "activity_streak") }}' 58 | unit_of_measurement: "days" 59 | charlie_active_minutes: 60 | friendly_name: "Active For" 61 | icon_template: mdi:dog-side 62 | value_template: '{{ state_attr("device_tracker.whistle_charlie", "activity_minutes_active") }}' 63 | unit_of_measurement: "minutes" 64 | charlie_rest_minutes: 65 | friendly_name: "Rest For" 66 | icon_template: mdi:sleep 67 | value_template: '{{ state_attr("device_tracker.whistle_charlie", "activity_minutes_rest") }}' 68 | unit_of_measurement: "minutes" 69 | charlie_battery_level: 70 | friendly_name: "Battery Level" 71 | icon_template: mdi:battery 72 | value_template: '{{ state_attr("device_tracker.whistle_charlie", "battery_level") }}' 73 | unit_of_measurement: '%' 74 | charlie_distance: 75 | friendly_name: "Distance" 76 | icon_template: mdi:map-marker-distance 77 | value_template: '{{ state_attr("device_tracker.whistle_charlie", "activity_distance") | round(1) }}' 78 | unit_of_measurement: 'miles' 79 | charlie_calories: 80 | friendly_name: "Calories" 81 | icon_template: mdi:run 82 | value_template: '{{ state_attr("device_tracker.whistle_charlie", "activity_calories") | round(1) }}' 83 | unit_of_measurement: 'calories' 84 | charlie_battery_days_left: 85 | friendly_name: "Battery Days Left" 86 | icon_template: mdi:calendar-clock 87 | value_template: '{{ state_attr("device_tracker.whistle_charlie", "battery_days_left") }}' 88 | unit_of_measurement: 'days' 89 | charlie_battery_24h_wifi_usage: 90 | friendly_name: "Battery WiFi Usage" 91 | icon_template: mdi:wifi 92 | value_template: '{{ state_attr("device_tracker.whistle_charlie", "24h_battery_wifi_usage") }}' 93 | unit_of_measurement: '%' 94 | charlie_battery_24h_cell_usage: 95 | friendly_name: "Battery Cellular Usage" 96 | icon_template: mdi:cellphone-basic 97 | value_template: '{{ state_attr("device_tracker.whistle_charlie", "24h_battery_cellular_usage") }}' 98 | unit_of_measurement: '%' 99 | ``` 100 | 101 | Then, use these templates in 'ui-lovelace.yaml' -- 102 | I am using the 'fold-entity-row' custom card, but you do not have to: 103 | 104 | ``` 105 | views: 106 | ... 107 | ... 108 | ... 109 | ... 110 | ... 111 | ... 112 | - type: custom:fold-entity-row 113 | head: device_tracker.whistle_charlie 114 | items: 115 | - sensor.charlie_battery_level 116 | - sensor.charlie_goal_minutes 117 | - sensor.charlie_goal_streak 118 | - sensor.charlie_active_minutes 119 | - sensor.charlie_rest_minutes 120 | - sensor.charlie_distance 121 | - sensor.charlie_calories 122 | - sensor.charlie_battery_24h_wifi_usage 123 | - sensor.charlie_battery_24h_cell_usage 124 | - sensor.charlie_battery_days_left 125 | 126 | ``` 127 | -------------------------------------------------------------------------------- /examples/homeassistant/__init__.py: -------------------------------------------------------------------------------- 1 | """ whistle device tracker """ 2 | -------------------------------------------------------------------------------- /examples/homeassistant/device_tracker.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import timedelta 3 | import voluptuous as vol 4 | from homeassistant.helpers import aiohttp_client, config_validation as cv 5 | from homeassistant.components.device_tracker import PLATFORM_SCHEMA 6 | from homeassistant.const import ( 7 | CONF_USERNAME, CONF_MONITORED_VARIABLES, CONF_PASSWORD, ATTR_ENTITY_PICTURE) 8 | from homeassistant.helpers.event import async_track_time_interval 9 | 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | DEVICE_TYPES = ['WHISTLE'] 13 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 14 | vol.Required(CONF_USERNAME): cv.string, 15 | vol.Required(CONF_PASSWORD): cv.string, 16 | vol.Optional(ATTR_ENTITY_PICTURE, default='60x60'): cv.string, 17 | vol.Optional(CONF_MONITORED_VARIABLES, default=DEVICE_TYPES): 18 | vol.All(cv.ensure_list, [vol.In(DEVICE_TYPES)]), 19 | }) 20 | 21 | 22 | async def async_setup_scanner(hass, config, async_see, discovery_info=None): 23 | from pywhistle import Client 24 | 25 | websession = aiohttp_client.async_get_clientsession(hass) 26 | client = Client( 27 | config[CONF_USERNAME], 28 | config[CONF_PASSWORD], 29 | websession 30 | ) 31 | scanner = WhistleScanner( 32 | client, 33 | hass, 34 | async_see, 35 | config[ATTR_ENTITY_PICTURE] 36 | ) 37 | return await scanner.async_init() 38 | 39 | 40 | class WhistleScanner: 41 | 42 | 43 | def __init__(self, client, hass, async_see, preferred_picture): 44 | self._preferred_picture = preferred_picture 45 | self._async_see = async_see 46 | self._client = client 47 | self._hass = hass 48 | 49 | 50 | async def async_init(self): 51 | try: 52 | await self._client.async_init() 53 | except Exception as e: 54 | _LOGGER.error('Unable to set up Tile scanner: %s', e) 55 | return False 56 | await self._async_update() 57 | async_track_time_interval( 58 | self._hass, self._async_update, timedelta(minutes=2)) 59 | return True 60 | 61 | 62 | async def _async_update(self, now=None): 63 | _LOGGER.info('Updating Whistle data') 64 | try: 65 | await self._client.async_init() 66 | pets = await self._client.get_pets() 67 | except Exception as e: 68 | _LOGGER.error("There was an error while updating: %s", e) 69 | _LOGGER.debug("Retrieved data:") 70 | _LOGGER.debug(pets) 71 | if not pets: 72 | _LOGGER.warning("No Pets found") 73 | return 74 | for pet in pets['pets']: 75 | dailies = await self._client.get_dailies(pet['id']) 76 | device = await self._client.get_device(pet['device']['serial_number']) 77 | attributes = { 78 | 'name': pet['name'], 79 | 'battery_level': pet['device']['battery_level'], 80 | 'battery_status': pet['device']['battery_status'], 81 | 'pending_locate': pet['device']['pending_locate'], 82 | 'activity_streak': pet['activity_summary']['current_streak'], 83 | 'activity_minutes_active': pet['activity_summary']['current_minutes_active'], 84 | 'activity_minutes_rest': pet['activity_summary']['current_minutes_rest'], 85 | 'activity_goal': pet['activity_summary']['current_activity_goal']['minutes'], 86 | 'activity_distance': dailies['dailies'][0]['distance'], 87 | 'activity_calories': dailies['dailies'][0]['calories'], 88 | 'battery_days_left': device['device']['battery_stats']['battery_days_left'], 89 | '24h_battery_wifi_usage': round(((float(device['device']['battery_stats']['prior_usage_minutes']['24h']['power_save_mode']) / 1440) * 100), 0), 90 | '24h_battery_cellular_usage': round(((float(device['device']['battery_stats']['prior_usage_minutes']['24h']['cellular']) / 1440) * 100), 0) 91 | } 92 | if self._preferred_picture in pet['profile_photo_url_sizes']: 93 | attributes['picture'] = pet['profile_photo_url_sizes'][self._preferred_picture] 94 | await self._async_see( 95 | dev_id = "whistle_%s" % ''.join(c for c in pet['name'] if c.isalnum()), 96 | gps = ( 97 | pet['last_location']['latitude'], 98 | pet['last_location']['longitude'] 99 | ), 100 | attributes = attributes, 101 | icon='mdi:dog') 102 | -------------------------------------------------------------------------------- /examples/homeassistant/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "whistle", 3 | "name": "Whistle GPS and Activity Tracker", 4 | "version": "1.1.1", 5 | "documentation": "https://github.com/Fusion/pywhistle/tree/master/examples/homeassistant", 6 | "dependencies": [], 7 | "codeowners": ["@fusion"], 8 | "requirements": ["pywhistle==0.0.4"] 9 | } 10 | -------------------------------------------------------------------------------- /examples/test.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from aiohttp import ClientSession 3 | from pywhistle import Client 4 | 5 | 6 | async def main() -> None: 7 | async with ClientSession() as websession: 8 | client = Client(USERNAME, PASSWORD, websession) 9 | await client.async_init() 10 | print(await client.get_pets()) 11 | print(await client.get_places()) 12 | 13 | 14 | asyncio.get_event_loop().run_until_complete(main()) 15 | -------------------------------------------------------------------------------- /pywhistle/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import Client 2 | -------------------------------------------------------------------------------- /pywhistle/client.py: -------------------------------------------------------------------------------- 1 | from aiohttp import ClientSession, client_exceptions 2 | 3 | WHISTLE_CONST = { 4 | "proto": "https", 5 | "remote_host": "app.whistle.com", 6 | "endpoint": "api" 7 | } 8 | 9 | 10 | class Client: 11 | 12 | 13 | """ 14 | Returns a string: URL(host, endpoint, resource) 15 | """ 16 | def url(self, config, resource) -> str: 17 | return "%s://%s/%s/%s" % (config["proto"], config["remote_host"], config["endpoint"], resource) 18 | 19 | 20 | """ 21 | Returns default headers as understood by the Whistle API 22 | Not invoked when retrieving a token. 23 | """ 24 | def headers(self, config, token): 25 | return { 26 | "Host": config['remote_host'], 27 | "Content-Type": "application/json", 28 | "Connection": "keep-alive", 29 | "Accept": "application/vnd.whistle.com.v5+json", 30 | "Accept-Language": "en", 31 | "Accept-Encoding": "br, gzip, deflate", 32 | "User-Agent": "Winston/3.9.0 (iPhone; iOS 13.5; Build:2399; Scale/3.0)", 33 | "Authorization": "Bearer %s" % token 34 | } 35 | 36 | 37 | """ 38 | Performs AIO request. Covers all verbs. 39 | Returns json payload. 40 | Raises exception if received http error code. 41 | """ 42 | async def request( 43 | self, 44 | config, 45 | method: str, 46 | resource: str, 47 | headers: dict = None, 48 | data: dict = None 49 | ) -> dict: 50 | if not headers: 51 | headers = {} 52 | """Need to specify encoding when getting Achievements or Places endpoint""" 53 | if "achievements" or "places" in resource: 54 | async with self._websession.request( 55 | method, 56 | self.url(config, resource), 57 | headers=headers, 58 | data=data) as r: 59 | r.raise_for_status() 60 | return await r.json(encoding='UTF-8') 61 | async with self._websession.request( 62 | method, 63 | self.url(config, resource), 64 | headers=headers, 65 | data=data) as r: 66 | r.raise_for_status() 67 | return await r.json() 68 | 69 | 70 | """ 71 | Helper to retrieve a single resource, such as '/pet' 72 | """ 73 | async def get_resource(self, config, token, resource): 74 | return await self.request( 75 | config, 76 | method='get', 77 | resource=resource, 78 | headers=self.headers(config, token) 79 | ) 80 | 81 | 82 | """ 83 | Attempts login with credentials provided in init() 84 | Returns authorization token for future requests. 85 | """ 86 | async def login(self, config) -> str: 87 | return (await self.request( 88 | config, 89 | method='post', 90 | resource='login', 91 | data={ 92 | "email": self._username, 93 | "password": self._password 94 | }))['auth_token'] 95 | 96 | 97 | """ 98 | Returns: 99 | pets: array of 100 | id, gender, name, 101 | profile_photo_url_sizes: dict of size(wxh):url, 102 | profile/breed, dob, address, etc. 103 | """ 104 | async def get_pets(self): 105 | return await self.get_resource(self._config, self._token, 'pets') 106 | 107 | """ 108 | Returns: 109 | pet: dictionary of 110 | id, gender, name, etc for single pet 111 | """ 112 | 113 | async def get_pet(self, pet_id): 114 | return await self.get_resource(self._config, self._token, 'pets/%s' % pet_id) 115 | 116 | """ 117 | Returns: 118 | device: dictionary of 119 | model_id, serial_number, battery_stats, etc 120 | """ 121 | async def get_device(self, serial_number): 122 | return await self.get_resource(self._config, self._token, 'devices/%s' % serial_number) 123 | 124 | """ 125 | Returns: 126 | owners: array of 127 | id, first_name, last_name, current_user, searchable, email, 128 | profile_photo_url_sizes': dict of size (wxh): url 129 | """ 130 | async def get_owners(self, pet_id): 131 | return await self.get_resource(self._config, self._token, "pets/%s/owners" % pet_id) 132 | 133 | 134 | """ 135 | Returns: 136 | array of 137 | address, name, 138 | id, 139 | latitude, longitude, radius_meters, 140 | shape, 141 | outline: array of lat/long if shape == polygon, 142 | per_ids: array of pet ids, 143 | wifi network information 144 | """ 145 | async def get_places(self): 146 | return await self.get_resource(self._config, self._token, "places") 147 | 148 | 149 | """ 150 | Returns: 151 | stats: dict of 152 | average_minutes_active, average_minutes_rest, average_calories, average_distance, current_streak, longest_streak, most_active_day 153 | """ 154 | async def get_stats(self, pet_id): 155 | return await self.get_resource(self._config, self._token, "pets/%s/stats" % pet_id) 156 | 157 | 158 | """ 159 | Returns: 160 | timeline_items: array of 161 | type ('inside'), 162 | data: dict of 163 | place: array of 164 | id, name 165 | start_time, end_time 166 | - or - 167 | type('outside'), 168 | data: dict of 169 | static_map_url: a google map url, 170 | origin, destination 171 | """ 172 | async def get_timeline(self, pet_id): 173 | return await self.get_resource(self._config, self._token, "pets/%s/timelines/location" % pet_id) 174 | 175 | 176 | """ 177 | Returns: 178 | dailies: array of 179 | activity_goal, minutes_active, minutes_rest, 180 | calories, distance, 181 | day_number, excluded, timestamp, updated_at 182 | """ 183 | async def get_dailies(self, pet_id): 184 | return await self.get_resource(self._config, self._token, "pets/%s/dailies" % pet_id) 185 | 186 | 187 | """ 188 | Returns: 189 | daily: dict of 190 | activities_goal, etc, 191 | bar_chart_18min: array of values 192 | """ 193 | async def get_dailies_day(self, pet_id, day_id): 194 | return await self.get_resource(self._config, self._token, "pets/%s/dailies/%s" % (pet_id, day_id)) 195 | 196 | """ 197 | Returns: 198 | daily_items: array of dictionaries 199 | type: event type 200 | title: event title 201 | start_time: in UTC 202 | end_time: string in UTC 203 | """ 204 | async def get_dailies_daily_items(self, pet_id, day_id): 205 | return await self.get_resource(self._config, self._token, "pets/%s/dailies/%s/daily_items" % (pet_id, day_id)) 206 | 207 | 208 | """ 209 | This one is lots of fun. Gamification for the win! 210 | 211 | Returns: 212 | achievements: array of 213 | id, earned_achievement_id, actionable, type, 214 | title, short_name, 215 | background_color, strike_color, 216 | badge_images: dict of size (wxh): url, 217 | template_type, template_properties: dict of 218 | header, footer, body, description (full text), 219 | earned, earned_timestamp, 220 | type_properties: dict of 221 | progressive_type, unit, goal_value, current_value, decimal_places 222 | """ 223 | async def get_achievements(self, pet_id): 224 | return await self.get_resource(self._config, self._token, "pets/%s/achievements" % pet_id) 225 | 226 | 227 | async def async_init(self, whistle_const = WHISTLE_CONST) -> None: 228 | self._config = whistle_const 229 | if self._token is None: 230 | self._token = await self.login(self._config) 231 | 232 | 233 | def __init__( 234 | self, 235 | email: str, 236 | password: str, 237 | websession: ClientSession 238 | ) -> None: 239 | self._config = None 240 | self._token = None 241 | self._username = email 242 | self._password = password 243 | self._websession = websession 244 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup 3 | 4 | 5 | def get_readme_content(): 6 | return open(os.path.join(os.path.dirname(__file__), 'README.md')).read() 7 | 8 | 9 | setup( 10 | name = 'pywhistle', 11 | version = '0.0.4', 12 | description = 'Unofficial Whistle 3 Device API', 13 | author = 'Chris F Ravenscroft', 14 | author_email = 'chris@voilaweb.com', 15 | url = 'https://github.com/Fusion/pywhistle', 16 | license = 'MIT', 17 | long_description = get_readme_content(), 18 | packages = ['pywhistle',], 19 | classifiers = [ 20 | 'License :: OSI Approved :: MIT License', 21 | 'Development Status :: 4 - Beta', 22 | 'Programming Language :: Python', 23 | 'Programming Language :: Python :: 3', 24 | 'Programming Language :: Python :: 3.5', 25 | 'Programming Language :: Python :: 3.6', 26 | 'Programming Language :: Python :: 3.7', 27 | 'Programming Language :: Python :: Implementation :: CPython', 28 | 'Programming Language :: Python :: Implementation :: PyPy' 29 | ], 30 | install_requires = ['aiodns', 'aiohttp'], 31 | ) 32 | 33 | --------------------------------------------------------------------------------