├── .gitignore ├── requirements.txt ├── .travis.yml ├── pebbletime-cli ├── README.rst ├── setup.py ├── test.py └── pebbletime.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | .coverage 3 | pebbletime.egg-info/ 4 | .*.sw[op] 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Recipe from: https://caremad.io/blog/setup-vs-requirement/ 2 | -e . 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.4" 4 | install: 5 | - pip install green coverage coveralls 6 | - pip install -r requirements.txt 7 | script: 8 | - green -r -v -s 8 9 | after_success: coveralls 10 | -------------------------------------------------------------------------------- /pebbletime-cli: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # vim:fenc=utf-8 4 | # 5 | # Copyright © 2015 Adrian Perez 6 | # 7 | # Distributed under terms of the MIT license. 8 | 9 | import click 10 | 11 | 12 | @click.group() 13 | def cli(): 14 | raise SystemExit("The CLI tool is currently unimplemented") 15 | 16 | 17 | if __name__ == "__main__": cli() 18 | 19 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================================== 2 | Pebble Time(line) API for Python 3 | ================================== 4 | 5 | .. image:: https://img.shields.io/travis/aperezdc/pebbletime-python.svg?style=flat 6 | :target: https://travis-ci.org/aperezdc/pebbletime-python 7 | :alt: Build Status 8 | 9 | .. image:: https://img.shields.io/coveralls/aperezdc/pebbletime-python/master.svg?style=flat 10 | :target: https://coveralls.io/r/aperezdc/pebbletime-python?branch=master 11 | :alt: Code Coverage 12 | 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # vim:fenc=utf-8 4 | # 5 | # Copyright © 2015 Adrian Perez 6 | # 7 | # Distributed under terms of the MIT license. 8 | 9 | from setuptools import setup, find_packages 10 | from setuptools import find_packages 11 | from codecs import open 12 | from os import path 13 | import sys 14 | 15 | 16 | def file_contents(*relpath): 17 | with open(path.join(path.dirname(__file__), *relpath), "rU", 18 | encoding="utf-8") as f: 19 | return f.read() 20 | 21 | 22 | if __name__ == "__main__": 23 | setup( 24 | name="pebbletime", 25 | version="0.1.1", 26 | description="Module to access the Pebble Timeline API asynchronously", 27 | long_description=file_contents("README.rst"), 28 | author="Adrián Pérez de Castro", 29 | author_email="adrian@perezdecastro.org", 30 | url="https://github.com/aperezdc/pebbletime-python", 31 | py_modules=["pebbletime"], 32 | scripts=["pebbletime-cli"], 33 | install_requires=[ 34 | "aiohttp>=0.16.0", 35 | "gnarl>=0.1.0a3", 36 | ], 37 | extras_require={ 38 | "cli": ["click>=4.0.0"], 39 | }, 40 | license="MIT", 41 | classifiers=[ 42 | "Development Status :: 3 - Alpha", 43 | "Intended Audience :: Developers", 44 | "Natural Language :: English", 45 | "Programming Language :: Python :: 3.4", 46 | "Programming Language :: Python", 47 | "Operating System :: OS Independent" 48 | ], 49 | test_suite="test", 50 | include_package_data=True, 51 | ) 52 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # vim:fenc=utf-8 4 | # 5 | # Copyright © 2015 Adrian Perez 6 | # 7 | # Distributed under terms of the MIT license. 8 | 9 | import unittest 10 | 11 | 12 | class TestLayoutInstantiation(unittest.TestCase): 13 | def test_valid_generic(self): 14 | from pebbletime import Layout 15 | ll = Layout("Test", tinyIcon = Layout.Icon.GENERIC) 16 | self.assertEqual("Test", ll.title) 17 | self.assertEqual(Layout.Type.GENERIC, ll.type) 18 | self.assertEqual(Layout.Icon.GENERIC, ll.tinyIcon) 19 | 20 | def test_valid_calendar_with_extra_fields(self): 21 | from pebbletime import Layout 22 | ll = Layout("Calendar", Layout.Type.CALENDAR, 23 | tinyIcon = Layout.Icon.REMINDER) 24 | self.assertEqual("Calendar", ll.title) 25 | self.assertEqual(Layout.Type.CALENDAR, ll.type) 26 | self.assertEqual(Layout.Icon.REMINDER, ll.tinyIcon) 27 | 28 | def test_valid_calendar(self): 29 | from pebbletime import Layout 30 | ll = Layout("Calendar", Layout.Type.CALENDAR) 31 | self.assertEqual("Calendar", ll.title) 32 | self.assertEqual(Layout.Type.CALENDAR, ll.type) 33 | 34 | def test_valid_reminder(self): 35 | from pebbletime import Layout 36 | ll = Layout("Remind", Layout.Type.REMINDER, 37 | tinyIcon = Layout.Icon.FLAG) 38 | self.assertEqual("Remind", ll.title) 39 | self.assertEqual(Layout.Type.REMINDER, ll.type) 40 | self.assertEqual(Layout.Icon.FLAG, ll.tinyIcon) 41 | 42 | def test_valid_notification(self): 43 | from pebbletime import Layout 44 | ll = Layout("Notif", Layout.Type.NOTIFICATION, 45 | tinyIcon = Layout.Icon.FLAG) 46 | self.assertEqual("Notif", ll.title) 47 | self.assertEqual(Layout.Type.NOTIFICATION, ll.type) 48 | self.assertEqual(Layout.Icon.FLAG, ll.tinyIcon) 49 | 50 | def test_valid_comm_notification(self): 51 | from pebbletime import Layout 52 | ll = Layout("Got mail!", Layout.Type.COMM, 53 | tinyIcon = Layout.Icon.GMAIL, sender = "Peter Parker") 54 | self.assertEqual("Got mail!", ll.title) 55 | self.assertEqual(Layout.Type.COMM, ll.type) 56 | self.assertEqual(Layout.Icon.GMAIL, ll.tinyIcon) 57 | self.assertEqual("Peter Parker", ll.sender) 58 | 59 | def test_valid_weather(self): 60 | from pebbletime import Layout 61 | ll = Layout("It's Sunny", Layout.Type.WEATHER, 62 | tinyIcon = Layout.Icon.TIMELINE_WEATHER, 63 | largeIcon = Layout.Icon.TIMELINE_SUN, 64 | locationName = "London") 65 | self.assertEqual("It's Sunny", ll.title) 66 | self.assertEqual(Layout.Type.WEATHER, ll.type) 67 | self.assertEqual(Layout.Icon.TIMELINE_WEATHER, ll.tinyIcon) 68 | self.assertEqual(Layout.Icon.TIMELINE_SUN, ll.largeIcon) 69 | self.assertEqual("London", ll.locationName) 70 | 71 | def test_valid_sports(self): 72 | from pebbletime import Layout 73 | ll = Layout("Cricket", Layout.Type.SPORTS, 74 | tinyIcon = Layout.Icon.CRICKET_GAME, 75 | largeIcon = Layout.Icon.CRICKET_GAME) 76 | self.assertEqual("Cricket", ll.title) 77 | self.assertEqual(Layout.Type.SPORTS, ll.type) 78 | self.assertEqual(Layout.Icon.CRICKET_GAME, ll.tinyIcon) 79 | self.assertEqual(Layout.Icon.CRICKET_GAME, ll.largeIcon) 80 | 81 | def test_invalid_missing_fields(self): 82 | from pebbletime import Layout 83 | # These layouts need extra parameters. 84 | for layout_type in ( 85 | Layout.Type.GENERIC, 86 | Layout.Type.REMINDER, 87 | Layout.Type.NOTIFICATION, 88 | Layout.Type.COMM, 89 | Layout.Type.WEATHER, 90 | Layout.Type.SPORTS): 91 | with self.assertRaises(ValueError): 92 | ll = Layout("Test", layout_type) 93 | 94 | -------------------------------------------------------------------------------- /pebbletime.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # vim:fenc=utf-8 4 | # 5 | # Copyright © 2015 Adrian Perez 6 | # 7 | # Distributed under terms of the MIT license. 8 | 9 | import aiohttp 10 | import gnarl 11 | 12 | 13 | class BasaltColor(object): 14 | pass 15 | 16 | 17 | BASE_URL = "https://timeline-api.getpebble.com/v1" 18 | 19 | 20 | SchemaString = gnarl.And(str, len) 21 | 22 | 23 | 24 | class PinLayoutType(gnarl.Enum): 25 | GENERIC = "genericPin" 26 | CALENDAR = "calendarPin" 27 | REMINDER = "genericReminder" 28 | NOTIFICATION = "genericNotification" 29 | COMM = "commNotification" 30 | WEATHER = "weatherPin" 31 | SPORTS = "sportsPin" 32 | 33 | 34 | class PinIcon(gnarl.Enum): 35 | GENERIC = "system://images/NOTIFICATION_GENERIC" 36 | REMINDER = "system://images/NOTIFICATION_REMINDER" 37 | FLAG = "system://images/NOTIFICATION_FLAG" 38 | FBMESSENGER = "system://images/NOTIFICATION_FACEBOOK_MESSENGER" 39 | WHATSAPP = "system://images/NOTIFICATION_WHATSAPP" 40 | GMAIL = "system://images/NOTIFICATION_GMAIL" 41 | FACEBOOK = "system://images/NOTIFICATION_FACEBOOK" 42 | HANGOUTS = "system://images/NOTIFICATION_GOOGLE_HANGOUTS" 43 | TELEGRAM = "system://images/NOTIFICATION_TELEGRAM" 44 | TWITTER = "system://images/NOTIFICATION_TWITTER" 45 | GINBOX = "system://images/NOTIFICATION_GOOGLE_INBOX" 46 | MAILBOX = "system://images/NOTIFICATION_MAILBOX" 47 | OUTLOOK = "system://images/NOTIFICATION_OUTLOOK" 48 | INSTAGRAM = "system://images/NOTIFICATION_INSTAGRAM" 49 | BBM = "system://images/NOTIFICATION_BLACKBERRY_MESSENGER" 50 | LINE = "system://images/NOTIFICATION_LINE" 51 | SNAPCHAT = "system://images/NOTIFICATION_SNAPCHAT" 52 | WECHAT = "system://images/NOTIFICATION_WECHAT" 53 | VIBER = "system://images/NOTIFICATION_VIBER" 54 | SKYPE = "system://images/NOTIFICATION_SKYPE" 55 | YAHOOMAIL = "system://images/NOTIFICATION_YAHOO_MAIL" 56 | 57 | # Generic icons. 58 | GENERIC_EMAIL = "system://images/GENERIC_EMAIL" 59 | GENERIC_SMS = "system://images/GENERIC_SMS" 60 | GENERIC_WARNING = "system://images/GENERIC_WARNING" 61 | GENERIC_CONFIRM = "system://images/GENERIC_CONFIRMATION" 62 | GENERIC_QUESTION = "system://images/GENERIC_QUESTION" 63 | 64 | # Weather icons. 65 | PARTLY_CLOUDY = "system://images/PARTLY_CLOUDY" 66 | CLOUDY_DAY = "system://images/CLOUDY_DAY" 67 | LIGHT_SNOW = "system://images/LIGHT_SNOW" 68 | LIGHT_RAIN = "system://images/LIGHT_RAIN" 69 | HEAVY_RAIN = "system://images/HEAVY_RAIN" 70 | HEAVY_SNOW = "system://images/HEAVY_SNOW" 71 | TIMELINE_WEATHER = "system://images/TIMELINE_WEATHER" 72 | TIMELINE_SUN = "system://images/TIMELINE_SUN" 73 | RAINING_AND_SNOWING = "system://images/RAINING_AND_SNOWING" 74 | TIDE_IS_HIGH = "system://images/TIDE_IS_HIGH" 75 | 76 | # Timeline item icons. 77 | TIMELINE_MISSED_CALL = "system://images/TIMELINE_MISSED_CALL" 78 | TIMELINE_CALENDAR = "system://images/TIMELINE_CALENDAR" 79 | TIMELINE_SPORTS = "system://images/TIMELINE_SPORTS" 80 | TIMELINE_BASEBALL = "system://images/TIMELINE_BASEBALL" 81 | 82 | # Sports. 83 | AMERICAN_FOOTBALL = "system://images/AMERICAN_FOOTBALL" 84 | CRICKET_GAME = "system://images/CRICKET_GAME" 85 | SOCCER_GAME = "system://images/SOCCER_GAME" 86 | HOCKEY_GAME = "system://images/HOCKEY_GAME" 87 | 88 | RESULT_DISMISSED = "system://images/RESULT_DISMISSED" 89 | RESULT_DELETED = "system://images/RESULT_DELETED" 90 | RESULT_MUTE = "system://images/RESULT_MUTE" 91 | RESULT_SENT = "system://images/RESULT_SENT" 92 | RESULT_FAILED = "system://images/RESULT_FAILED" 93 | 94 | STOCKS_EVENT = "system://images/STOCKS_EVENT" 95 | MUSIC_EVENT = "system://images/MUSIC_EVENT" 96 | BIRTHDAY_EVENT = "system://images/BIRTHDAY_EVENT" 97 | SCHEDULED_EVENT = "system://images/SCHEDULED_EVENT" 98 | MOVIE_EVENT = "system://images/MOVIE_EVENT" 99 | 100 | PAY_BILL = "system://images/PAY_BILL" 101 | HOTEL_RESERVATION = "system://images/HOTEL_RESERVATION" 102 | NEWS_EVENT = "system://images/NEWS_EVENT" 103 | DURING_PHONE_CALL = "system://images/DURING_PHONE_CALL" 104 | CHECK_INTERNET_CONNECTION = "system://images/CHECK_INTERNET_CONNECTION" 105 | GLUCOSE_MONITOR = "system://images/GLUCOSE_MONITOR" 106 | ALARM_CLOCK = "system://images/ALARM_CLOCK" 107 | CAR_RENTAL = "system://images/CAR_RENTAL" 108 | DINNER_RESERVATION = "system://images/DINNER_RESERVATION" 109 | RADIO_SHOW = "system://images/RADIO_SHOW" 110 | AUDIO_CASSETTE = "system://images/AUDIO_CASSETTE" 111 | SCHEDULED_FLIGHT = "system://images/SCHEDULED_FLIGHT" 112 | NO_EVENTS = "system://images/NO_EVENTS" 113 | REACHED_FITNESS_GOAL = "system://images/REACHED_FITNESS_GOAL" 114 | DAY_SEPARATOR = "system://images/DAY_SEPARATOR" 115 | WATCH_DISCONNECTED = "system://images/WATCH_DISCONNECTED" 116 | TV_SHOW = "system://images/TV_SHOW" 117 | 118 | 119 | class Layout(gnarl.Schemed): 120 | Type = PinLayoutType 121 | Icon = PinIcon 122 | 123 | __schema__ = { 124 | "type" : Type, 125 | "title" : SchemaString, 126 | 127 | # The rest are all kind-of optional, and their required presence 128 | # depends on the type (which is the only mandatory field) 129 | "shortTitle" : gnarl.Optional(SchemaString), 130 | "subtitle" : gnarl.Optional(SchemaString), 131 | "body" : gnarl.Optional(SchemaString), 132 | "tinyIcon" : gnarl.Optional(Icon), 133 | "largeIcon" : gnarl.Optional(Icon), 134 | "primaryColor" : gnarl.Optional(gnarl.Use(BasaltColor)), 135 | "secondaryColor" : gnarl.Optional(gnarl.Use(BasaltColor)), 136 | "backgroundColor" : gnarl.Optional(gnarl.Use(BasaltColor)), 137 | "headings" : gnarl.Optional([SchemaString]), 138 | "paragraphs" : gnarl.Optional([SchemaString]), 139 | "lastUpdated" : gnarl.Optional(gnarl.Timestamp), 140 | "locationName" : gnarl.Optional(SchemaString), 141 | "sender" : gnarl.Optional(SchemaString), 142 | "broadcaster" : gnarl.Optional(SchemaString), 143 | "rankAway" : gnarl.Optional(SchemaString), 144 | "rankHome" : gnarl.Optional(SchemaString), 145 | "nameAway" : gnarl.Optional(SchemaString), 146 | "nameHome" : gnarl.Optional(SchemaString), 147 | "recordAway" : gnarl.Optional(SchemaString), 148 | "recordHome" : gnarl.Optional(SchemaString), 149 | "scoreAway" : gnarl.Optional(SchemaString), 150 | "scoreHome" : gnarl.Optional(SchemaString), 151 | "sportsGameState" : gnarl.Optional(SchemaString), 152 | } 153 | 154 | TYPE_REQUIRED_FIELDS = { 155 | Type.GENERIC : ("tinyIcon",), 156 | Type.CALENDAR : (), 157 | Type.REMINDER : ("tinyIcon",), 158 | Type.NOTIFICATION : ("tinyIcon",), 159 | Type.COMM : ("tinyIcon", "sender",), 160 | Type.WEATHER : ("tinyIcon", "largeIcon", "locationName",), 161 | Type.SPORTS : ("tinyIcon", "largeIcon",) 162 | } 163 | 164 | def __check_fields(self): 165 | for field in self.TYPE_REQUIRED_FIELDS.get(self.type, ()): 166 | if not hasattr(self, field): 167 | raise ValueError("'{}' is required by layout {}".format( 168 | field, self.type.name)) 169 | 170 | def __init__(self, title, type=Type.GENERIC, *arg, **kw): 171 | super(Layout, self).__init__(title=title, type=type, *arg, **kw) 172 | 173 | # Check required fields depending on the layout type. 174 | self.__check_fields() 175 | 176 | # Check that both headings and paragraphs are provided together. 177 | if hasattr(self, "paragraphs") or hasattr(self, "headings"): 178 | if not hasattr(self, "paragraphs"): 179 | raise ValueError("Expected paragraphs") 180 | if not hasattr(self, "headings"): 181 | raise ValueError("Expected headings") 182 | if len(self.headings) != len(self.paragraphs): 183 | raise ValueError("Number of headings must be the " 184 | "same as number of paragraphs") 185 | 186 | 187 | class Action(gnarl.Schemed): 188 | __schema__ = { 189 | "type" : SchemaString, 190 | "launchCode" : gnarl.Optional(int), 191 | "title" : gnarl.Optional(SchemaString), 192 | } 193 | 194 | def __init__(self, type, title=None, launchCode=None): 195 | params = { "type" : type } 196 | if title is not None: 197 | params["title"] = str(title) 198 | if launchCode is not None: 199 | params["launchCode"] = int(launchCode) 200 | super(Action, self).__init__(**params) 201 | 202 | 203 | class Reminder(gnarl.Schemed): 204 | __schema__ = { 205 | "time" : gnarl.Timestamp, 206 | "layout" : gnarl.Optional(Layout), 207 | } 208 | 209 | def __init__(self, time, layout=None): 210 | params = { "time" : time } 211 | if layout is not None: 212 | params["layout"] = layout 213 | super(Reminder, self).__init__(**params) 214 | 215 | 216 | class Notification(gnarl.Schemed): 217 | __schema__ = { 218 | "layout" : Layout, 219 | "time" : gnarl.Optional(gnarl.Timestamp), 220 | } 221 | 222 | def __init__(self, time, layout): 223 | super(Notification, self).__init__(time=time, layout=layout) 224 | 225 | 226 | class Pin(gnarl.Schemed): 227 | Icon = PinIcon 228 | 229 | __schema__ = { 230 | "id" : SchemaString, 231 | "time" : gnarl.Timestamp, 232 | "layout" : Layout, 233 | 234 | "icon" : gnarl.Optional(Icon), 235 | "actions" : gnarl.Optional([Action]), 236 | "duration" : gnarl.Optional(int), 237 | "reminders" : gnarl.Optional([Reminder]), 238 | "createNotification" : gnarl.Optional(Notification), 239 | "updateNotification" : gnarl.Optional(Notification), 240 | } 241 | 242 | def __init__(self, id, time, layout, **kw): 243 | super(Pin, self).__init__(id=id, time=time, layout=layout, **kw) 244 | 245 | 246 | class Timeline(object): 247 | error_code = { 248 | 400: 'The pin object submitted was invalid.', 249 | 403: 'The API key submitted was invalid.', 250 | 410: 'The user token submitted was invalid or does not exist.', 251 | 429: 'Server is sending updates too quickly.', 252 | 503: 'Could not save pin due to a temporary server error.', 253 | } 254 | 255 | def __init__(self, api_key, base_url=BASE_URL): 256 | self.base_url = str(base_url) 257 | self._session = aiohttp.ClientSession(headers = { 258 | "Content-Type" : "application/json", 259 | "X-API-Key" : str(api_key), 260 | }) 261 | 262 | def __del__(self): 263 | self._session.close() 264 | 265 | def __check_response(self, response, json=False): 266 | if json: 267 | data = yield from response.json() 268 | else: 269 | data = yield from response.text() 270 | return response.status, data 271 | 272 | def send_shared_pind(self, topics, pin): 273 | response = yield from self._session.put( 274 | "{.base_url!s}/shared/pins/{.id!s}".format(self, pin), 275 | headers = { "X-PIN-Topics": ",".join(str(t) for t in topics) }, 276 | data = pin.to_json()) 277 | _ = yield from self.__check_response(response) 278 | 279 | def send_user_pin(self, user_token, pin): 280 | response = yield from self._session.put( 281 | "{.base_url!s}/user/pins/{.id!s}".format(self, pin), 282 | headers = { "X-User-Token": str(user_token) }, 283 | data = pin.to_json()) 284 | _ = yield from self.__check_response(response) 285 | 286 | def delete_user_pin(self, user_token, pin=None, pin_id=None): 287 | if pin_id is None: 288 | pin_id = pin.id 289 | response = yield from self._session.delete( 290 | "{.base_url!s}/user/pins/{!s}".format(self, pin_id), 291 | headers = { "X-User-Token": str(user_token) }) 292 | _ = yield from self.__check_response(response) 293 | 294 | def subscribe(self, user_token, topic): 295 | response = yield from self._session.post( 296 | "{.base_url!s}/subscription/{!s}".format(self, topic), 297 | headers = { "X-User-Token": str(user_token) }) 298 | _ = yield from self.__check_response(response) 299 | 300 | def unsubscribe(self, user_token, topic): 301 | response = yield from self._session.delete( 302 | "{.base_url!s}/subscription/{!s}".format(self, topic), 303 | headers = { "X-User-Token": str(user_token) }) 304 | _ = yield from self.__check_response(response) 305 | --------------------------------------------------------------------------------