├── .gitignore ├── README.rst ├── setup.py └── sleepyq └── __init__.py /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | dist/ 3 | sleepyq.egg-info/ 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Sleepyq 3 | ========== 4 | 5 | Sleepyq is an library for accessing the SleepIQ API from Python. `SleepIQ `__ is an addon for `SleepNumber beds `__. 6 | 7 | To install: 8 | 9 | .. code:: bash 10 | 11 | python3 -m pip install sleepyq 12 | 13 | To get started using the library, here's the full usage: 14 | 15 | >>> from sleepyq import Sleepyq 16 | >>> from pprint import pprint 17 | >>> 18 | >>> client = Sleepyq('your-login', 'your-password') 19 | >>> client.login() 20 | >>> pprint(client.sleepers()) 21 | >>> pprint(client.beds()) 22 | >>> pprint(client.bed_family_status()) 23 | >>> client.set_light(lightNumber, setting, bedId='') 24 | >>> pprint(client.get_light(lightNumber, bedId='')) 25 | >>> client.preset(preset, side, bedId='', slowSpeed=False) 26 | >>> client.set_foundation_position(bedNumber, actuator, position, side, bedId='', slowSpeed=False) 27 | >>> client.set_foundation_massage(bedNumber, footSpeed, headSpeed, side, timer=0, mode=0, bedId='') 28 | >>> client.set_sleepnumber(side, sleepnumber, bedId='') 29 | >>> client.set_favsleepnumber(side, sleepnumber, bedId='') 30 | >>> pprint(client.get_favsleepnumber(bedId='')) 31 | >>> client.stop_motion(bedId='', side) 32 | >>> client.stop_pump(bedId='') 33 | >>> pprint(client.foundation_status(bedId='')) 34 | >>> pprint(client.foundation_system(bedId='')) 35 | >>> pprint(client.foundation_features(bedId='')) 36 | 37 | The API is undocumented, so this library does not make much attempt to structure the data from the API into objects. 38 | 39 | Development Notes 40 | ----------------- 41 | 42 | The SleepIQ API was `announced at CES 2016 `__ but there has yet to be any public documentation. 43 | 44 | https://sleepiq.sleepnumber.com appears to use the SleepIQ API internally, and methods here were written based on observing use of the site with Chrome Developer Tools and by running the Android app through a proxy. There was also prior art at https://github.com/erichelgeson/sleepiq (the API has changed since then) and https://github.com/natecj/sleepiq-php 45 | 46 | The first request to happen is to login. This returns a key (_k) that needs to be used on subsequent requests as a parameter. Subsequent requests also need to be part of the same 'session', since those calls expect some cookies to be set. 47 | 48 | Todo 49 | ----- 50 | 51 | - Error check response for non-200 code, or errors returned as JSON 52 | - Explore API more. There are a few more API calls out there, like updating profile, modifying sleep for previous night, but they seem less immediately useful for automation. 53 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | def readme(): 4 | with open('README.rst') as f: 5 | return f.read() 6 | 7 | setup(name='sleepyq', 8 | version='0.8.1', 9 | description='SleepIQ API for Python', 10 | long_description=readme(), 11 | url='http://github.com/technicalpickles/sleepyq', 12 | author='Josh Nichols', 13 | author_email='josh@technicalpickles.com', 14 | license='MIT', 15 | packages=['sleepyq'], 16 | install_requires=[ 17 | 'requests', 18 | 'inflection' 19 | ], 20 | include_package_data=True, 21 | zip_safe=False) 22 | -------------------------------------------------------------------------------- /sleepyq/__init__.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import inflection 3 | import random 4 | 5 | 6 | RIGHT_NIGHT_STAND = 1 7 | LEFT_NIGHT_STAND = 2 8 | RIGHT_NIGHT_LIGHT = 3 9 | LEFT_NIGHT_LIGHT = 4 10 | 11 | BED_LIGHTS = [ 12 | RIGHT_NIGHT_STAND, 13 | LEFT_NIGHT_STAND, 14 | RIGHT_NIGHT_LIGHT, 15 | LEFT_NIGHT_LIGHT 16 | ] 17 | 18 | FAVORITE = 1 19 | READ = 2 20 | WATCH_TV = 3 21 | FLAT = 4 22 | ZERO_G = 5 23 | SNORE = 6 24 | 25 | BED_PRESETS = [ 26 | FAVORITE, 27 | READ, 28 | WATCH_TV, 29 | FLAT, 30 | ZERO_G, 31 | SNORE 32 | ] 33 | 34 | OFF = 0 35 | LOW = 1 36 | MEDIUM = 2 37 | HIGH = 3 38 | 39 | MASSAGE_SPEED = [ 40 | OFF, 41 | LOW, 42 | MEDIUM, 43 | HIGH 44 | ] 45 | 46 | SOOTHE = 1 47 | REVITILIZE = 2 48 | WAVE = 3 49 | 50 | MASSAGE_MODE = [ 51 | OFF, 52 | SOOTHE, 53 | REVITILIZE, 54 | WAVE 55 | ] 56 | 57 | 58 | """Return a randomly generated sorta valid User Agent string. 59 | 60 | These values were generated in Nov-2020 and will expire in 1 year. 61 | """ 62 | class GenUserAgent(object): 63 | uas = { 64 | "Edge": ("AppleWebKit/537.36 (KHTML, like Gecko) " 65 | "Chrome/87.0.4280.67 Safari/537.36 Edg/87.0.664.47"), 66 | "Chrome": ("AppleWebKit/537.36 (KHTML, like Gecko) " 67 | "Chrome/86.0.4240.198 Safari/537.36"), 68 | "Firefox": "Gecko/20100101 Firefox/85.0", 69 | "ipad": ("AppleWebKit/605.1.15 (KHTML, like Gecko) " 70 | "Version/14.0.1 Mobile/15E148 Safari/604.1"), 71 | "Safari": ("AppleWebKit/605.1.15 (KHTML, like Gecko) " 72 | "Version/11.1.2 Safari/605.1.15"), 73 | } 74 | os = { 75 | "windows": "Windows NT 10.0; Win64; x64; rv:85.0", 76 | "ipad": "iPad; CPU OS 14_2 like Mac OS X", 77 | "mac": "Macintosh; Intel Mac OS X 10_11_6", 78 | } 79 | template = "Mozilla/5.0 ({os}) {ua}" 80 | 81 | def ua(self): 82 | return self.template.format( 83 | os=random.choice(list(self.os.values())), 84 | ua=random.choice(list(self.uas.values())) 85 | ) 86 | 87 | 88 | class APIobject(object): 89 | def __init__(self, data): 90 | self.data = data 91 | 92 | def __getattr__(self, name): 93 | adjusted_name = inflection.camelize(name, False) 94 | return self.data[adjusted_name] if self.data is not None else None 95 | 96 | class Bed(APIobject): 97 | def __init__(self, data): 98 | super(Bed, self).__init__(data) 99 | self.left = None 100 | self.right = None 101 | 102 | class FamilyStatus(APIobject): 103 | def __init__(self, data): 104 | super(FamilyStatus, self).__init__(data) 105 | self.bed = None 106 | 107 | self.left = SideStatus(data['leftSide']) 108 | self.right = SideStatus(data['rightSide']) 109 | 110 | class SideStatus(APIobject): 111 | def __init__(self, data): 112 | super(SideStatus, self).__init__(data) 113 | self.bed = None 114 | self.sleeper = None 115 | 116 | class Sleeper(APIobject): 117 | def __init__(self, data): 118 | super(Sleeper, self).__init__(data) 119 | self.bed = None 120 | 121 | class FavSleepNumber(APIobject): 122 | def __init__(self, data): 123 | super(FavSleepNumber, self).__init__(data) 124 | self.left = None 125 | self.right = None 126 | 127 | class Status(APIobject): 128 | def __init__(self, data): 129 | super(Status, self).__init__(data) 130 | 131 | 132 | class Sleepyq: 133 | def __init__(self, login, password): 134 | self._login = login 135 | self._password = password 136 | self._session = requests.Session() 137 | self._session.headers.update({'User-Agent': GenUserAgent().ua()}) 138 | self._api = "https://prod-api.sleepiq.sleepnumber.com/rest" 139 | 140 | def __make_request(self, url, mode="get", data="", attempt=0): 141 | if attempt < 4: 142 | try: 143 | if mode == 'put': 144 | r = self._session.put(self._api+url, json=data, timeout=2) 145 | else: 146 | r = self._session.get(self._api+url, timeout=2) 147 | if r.status_code == 401: # HTTP error 401 Unauthorized 148 | # Login 149 | self.login() 150 | elif r.status_code == 404: # HTTP error 404 Not Found 151 | # Login 152 | self.login() 153 | elif r.status_code == 503: # HTTP error 503 Server Error 154 | r.raise_for_status() 155 | if r.status_code != 200: # If status code is not 200 OK 156 | retry = self.__make_request(url, mode, data, attempt+1) 157 | if type(retry) == requests.models.Response: 158 | r = retry 159 | r.raise_for_status() 160 | return r 161 | except requests.exceptions.ReadTimeout: 162 | retry = self.__make_request(url, mode, data, attempt+1) 163 | if type(retry) == requests.models.Response: 164 | retry.raise_for_status() 165 | return retry 166 | print('Request timed out to', url) 167 | 168 | def __feature_check(self, value, digit): 169 | return ((1 << digit) & value) > 0 170 | 171 | def login(self): 172 | if '_k' in self._session.params: 173 | del self._session.params['_k'] 174 | if not self._login or not self._password: 175 | raise ValueError("username/password not set") 176 | data = {'login': self._login, 'password': self._password} 177 | r = self._session.put(self._api+'/login', json=data) 178 | if r.status_code == 401: 179 | raise ValueError("Incorect username or password") 180 | if r.status_code == 403: 181 | raise ValueError("User Agent is blocked. May need to update GenUserAgent data?") 182 | if r.status_code not in (200, 201): 183 | raise ValueError("Unexpected response code: {code}\n{body}".format( 184 | code=r.status_code, 185 | body=r.text, 186 | )) 187 | self._session.params['_k'] = r.json()['key'] 188 | return True 189 | 190 | def sleepers(self): 191 | r=self.__make_request('/sleeper') 192 | sleepers = [Sleeper(sleeper) for sleeper in r.json()['sleepers']] 193 | return sleepers 194 | 195 | def beds(self): 196 | r=self.__make_request('/bed') 197 | beds = [Bed(bed) for bed in r.json()['beds']] 198 | return beds 199 | 200 | def beds_with_sleeper_status(self): 201 | beds = self.beds() 202 | sleepers = self.sleepers() 203 | family_statuses = self.bed_family_status() 204 | sleepers_by_id = {sleeper.sleeper_id: sleeper for sleeper in sleepers} 205 | bed_family_statuses_by_bed_id = {family_status.bed_id: family_status for family_status in family_statuses} 206 | for bed in beds: 207 | family_status = bed_family_statuses_by_bed_id.get(bed.bed_id) 208 | for side in ['left', 'right']: 209 | sleeper_key = 'sleeper_' + side + '_id' 210 | sleeper_id = getattr(bed, sleeper_key) 211 | if sleeper_id == "0": # if no sleeper 212 | continue 213 | sleeper = sleepers_by_id.get(sleeper_id) 214 | status = getattr(family_status, side) 215 | status.sleeper = sleeper 216 | setattr(bed, side, status) 217 | return beds 218 | 219 | 220 | def bed_family_status(self): 221 | r=self.__make_request('/bed/familyStatus') 222 | statuses = [FamilyStatus(status) for status in r.json()['beds']] 223 | return statuses 224 | 225 | def default_bed_id(self, bedId): 226 | if not bedId: 227 | if len(self.beds()) == 1: 228 | bedId = self.beds()[0].data['bedId'] 229 | else: 230 | raise ValueError("Bed ID must be specified if there is more than one bed") 231 | return bedId 232 | 233 | def set_light(self, light, setting, bedId = ''): 234 | # 235 | # light 1-4 236 | # setting False=off, True=on 237 | # 238 | if light in BED_LIGHTS: 239 | data = {'outletId': light, 'setting': 1 if setting else 0} 240 | r=self.__make_request('/bed/'+self.default_bed_id(bedId)+'/foundation/outlet', "put", data) 241 | return True 242 | else: 243 | raise ValueError("Invalid light") 244 | 245 | def get_light(self, light, bedId = ''): 246 | # 247 | # same light numbering as set_light 248 | # 249 | if light in BED_LIGHTS: 250 | self._session.params['outletId'] = light 251 | r=self.__make_request('/bed/'+self.default_bed_id(bedId)+'/foundation/outlet') 252 | del self._session.params['outletId'] 253 | return Status(r.json()) 254 | else: 255 | raise ValueError("Invalid light") 256 | 257 | def preset(self, preset, side, bedId = '', slowSpeed=False): 258 | # 259 | # preset 1-6 260 | # side "R" or "L" 261 | # slowSpeed False=fast, True=slow 262 | # 263 | if side.lower() in ('r', 'right'): 264 | side = "R" 265 | elif side.lower() in ('l', 'left'): 266 | side = "L" 267 | else: 268 | raise ValueError("Side mut be one of the following: left, right, L or R") 269 | if preset in BED_PRESETS: 270 | data = {'preset':preset,'side':side,'speed':1 if slowSpeed else 0} 271 | r=self.__make_request('/bed/'+self.default_bed_id(bedId)+'/foundation/preset', "put", data) 272 | return True 273 | else: 274 | raise ValueError("Invalid preset") 275 | 276 | def set_foundation_massage(self, footSpeed, headSpeed, side, timer=0, mode=0, bedId = ''): 277 | # 278 | # footSpeed 0-3 279 | # headSpeed 0-3 280 | # mode 0-3 281 | # side "R" or "L" 282 | # 283 | if mode in MASSAGE_MODE: 284 | if mode != 0: 285 | footSpeed = 0 286 | headSpeed = 0 287 | if all(speed in MASSAGE_SPEED for speed in [footSpeed, headSpeed]): 288 | data = {'footMassageMotor':footSpeed,'headMassageMotor':headSpeed,'massageTimer':timer,'massageWaveMode':mode,'side':side} 289 | r=self.__make_request('/bed/'+self.default_bed_id(bedId)+'/foundation/adjustment', "put", data) 290 | return True 291 | else: 292 | raise ValueError("Invalid head or foot speed") 293 | else: 294 | raise ValueError("Invalid mode") 295 | 296 | def set_sleepnumber(self, side, setting, bedId = ''): 297 | # 298 | # side "R" or "L" 299 | # setting 0-100 (rounds to nearest multiple of 5) 300 | # 301 | if 0 > setting or setting > 100 : 302 | raise ValueError("Invalid SleepNumber, must be between 0 and 100") 303 | if side.lower() in ('r', 'right'): 304 | side = "R" 305 | elif side.lower() in ('l', 'left'): 306 | side = "L" 307 | else: 308 | raise ValueError("Side mut be one of the following: left, right, L or R") 309 | data = {'bed': self.default_bed_id(bedId), 'side': side, "sleepNumber": int(round(setting/5))*5} 310 | self._session.params['side']=side 311 | r=self.__make_request('/bed/'+self.default_bed_id(bedId)+'/sleepNumber', "put", data) 312 | del self._session.params['side'] 313 | return True 314 | 315 | def set_favsleepnumber(self, side, setting, bedId = ''): 316 | # 317 | # side "R" or "L" 318 | # setting 0-100 (rounds to nearest multiple of 5) 319 | # 320 | if 0 > setting or setting > 100: 321 | raise ValueError("Invalid SleepNumber, must be between 0 and 100") 322 | if side.lower() in ('r', 'right'): 323 | side = "R" 324 | elif side.lower() in ('l', 'left'): 325 | side = "L" 326 | else: 327 | raise ValueError("Side mut be one of the following: left, right, L or R") 328 | data = {'side': side, "sleepNumberFavorite": int(round(setting/5))*5} 329 | r=self.__make_request('/bed/'+self.default_bed_id(bedId)+'/sleepNumberFavorite', "put", data) 330 | return True 331 | 332 | def get_favsleepnumber(self, bedId = ''): 333 | r=self.__make_request('/bed/'+self.default_bed_id(bedId)+'/sleepNumberFavorite') 334 | fav_sleepnumber = FavSleepNumber(r.json()) 335 | for side in ['Left', 'Right']: 336 | side_key = 'sleepNumberFavorite'+ side 337 | fav_sleepnumber_side = fav_sleepnumber.data[side_key] 338 | setattr(fav_sleepnumber, side.lower(), fav_sleepnumber_side) 339 | return fav_sleepnumber 340 | 341 | def stop_motion(self, side, bedId = ''): 342 | # 343 | # side "R" or "L" 344 | # 345 | if side.lower() in ('r', 'right'): 346 | side = "R" 347 | elif side.lower() in ('l', 'left'): 348 | side = "L" 349 | else: 350 | raise ValueError("Side mut be one of the following: left, right, L or R") 351 | data = {"footMotion":1, "headMotion":1, "massageMotion":1, "side":side} 352 | r=self.__make_request('/bed/'+self.default_bed_id(bedId)+'/foundation/motion', "put", data) 353 | return True 354 | 355 | def stop_pump(self, bedId = ''): 356 | r=self.__make_request('/bed/'+self.default_bed_id(bedId)+'/pump/forceIdle', "put") 357 | return True 358 | 359 | def foundation_status(self, bedId = ''): 360 | r = self.__make_request('/bed/'+self.default_bed_id(bedId)+'/foundation/status') 361 | try: 362 | result = Status(r.json()) 363 | except AttributeError: 364 | result = None 365 | return result 366 | 367 | def foundation_system(self, bedId = ''): 368 | r=self.__make_request('/bed/'+self.default_bed_id(bedId)+'/foundation/system') 369 | return Status(r.json()) 370 | 371 | def foundation_features(self, bedId = ''): 372 | fs = self.foundation_system(self.default_bed_id(bedId)) 373 | fs_board_features = getattr(fs, 'fsBoardFeatures') 374 | fs_bed_type = getattr(fs, 'fsBedType') 375 | 376 | feature = {} 377 | 378 | feature['single'] = feature['splitHead'] = feature['splitKing'] = feature['easternKing'] = False 379 | if fs_bed_type == 0: 380 | feature['single'] = True 381 | elif fs_bed_type == 1: 382 | feature['splitHead'] = True 383 | elif fs_bed_type == 2: 384 | feature['splitKing'] = True 385 | elif fs_bed_type == 3: 386 | feature['easternKing'] = True 387 | 388 | feature['boardIsASingle'] = self.__feature_check(fs_board_features, 0) 389 | feature['hasMassageAndLight'] = self.__feature_check(fs_board_features, 1) 390 | feature['hasFootControl'] = self.__feature_check(fs_board_features, 2) 391 | feature['hasFootWarming'] = self.__feature_check(fs_board_features, 3) 392 | feature['hasUnderbedLight'] = self.__feature_check(fs_board_features, 4) 393 | feature['leftUnderbedLightPMW'] = getattr(fs, 'fsLeftUnderbedLightPWM') 394 | feature['rightUnderbedLightPMW'] = getattr(fs, 'fsRightUnderbedLightPWM') 395 | 396 | if feature['hasMassageAndLight']: 397 | feature['hasUnderbedLight'] = True 398 | if feature['splitKing'] or feature['splitHead']: 399 | feature['boardIsASingle'] = False 400 | 401 | return Status(feature) 402 | 403 | def set_foundation_position(self, side, actuator, position, bedId = '', slowSpeed=False): 404 | # 405 | # side "R" or "L" 406 | # actuator "H" or "F" (head or foot) 407 | # position 0-100 408 | # slowSpeed False=fast, True=slow 409 | # 410 | if 0 > position or position > 100: 411 | raise ValueError("Invalid position, must be between 0 and 100") 412 | if side.lower() in ('r', 'right'): 413 | side = "R" 414 | elif side.lower() in ('l', 'left'): 415 | side = "L" 416 | else: 417 | raise ValueError("Side mut be one of the following: left, right, L or R") 418 | if actuator.lower() in ('h', 'head'): 419 | actuator = 'H' 420 | elif actuator.lower() in ('f', 'foot'): 421 | actuator = 'F' 422 | else: 423 | raise ValueError("Actuator must be one of the following: head, foot, H or F") 424 | data = {'position':position,'side':side,'actuator':actuator,'speed':1 if slowSpeed else 0} 425 | r=self.__make_request('/bed/'+self.default_bed_id(bedId)+'/foundation/adjustment/micro', "put", data) 426 | return True 427 | --------------------------------------------------------------------------------