├── .github └── workflows │ └── pythonpublish.yml ├── LICENSE ├── README.md ├── requirements.txt ├── setup.py └── sonoff ├── README ├── __init__.py └── sonoff.py /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: Set up Python 13 | uses: actions/setup-python@v1 14 | with: 15 | python-version: '3.x' 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install setuptools wheel twine 20 | - name: Build and publish 21 | env: 22 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 23 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 24 | run: | 25 | python setup.py sdist bdist_wheel 26 | twine upload dist/* 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 lucien2k 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sonoff-python 2 | Make use of your sonoff smart switches without flashing them via the cloud APIs, this should work in Python 2 or Python 3. 3 | 4 | This project is heavily inspired (read: almost entirely borrowed) by the work that Peter Buga did on a Simple Home Assistant integration for Ewelink https://github.com/peterbuga/HASS-sonoff-ewelink 5 | 6 | I spent a day looking into various ways to work with Sonoff switches and drew a bit of a blank. There seeem to be quite a few projects that are designed to replace the Ewelink cloud platform either by flashing the Sonoff switches with new firmware, or hijacking the setup process and running a fake cloud service locally on a Raspberry Pi or similar. 7 | 8 | I tried this approach but it didn't work for me as I was using a 4 channel switch, and it seems that most of them had only been tested with single channel switches. Also many of these were not maintained actively or had clearly been written for a specific use case. 9 | 10 | I finally came across Peter's work written in python and it was exactly what I was looking for, a class that I could instantiate and control my switch remotely without flashing or having to use Charles to sniff my authentication code etc. 11 | 12 | ## Installation 13 | 14 | Use pip or easy_install 15 | 16 | > pip install sonoff-python 17 | 18 | The requirements are requests and websocket-client, see _requirements.txt_ 19 | 20 | ## Configuration 21 | 22 | Configuration is simple and basically passed to the class when you instantiate it. Username is either the email address you use to log in to Ewelink, or your phone number with the country code in front. 23 | 24 | > **username** - The email address or phone number you signed up with on Ewelink. Preface phone number with the country code 25 | 26 | > **password** - Your password to Ewelink. 27 | 28 | > **api_region** - The API region you use, valid ones are apparently 'us', 'eu' and 'cn' 29 | 30 | > **user_apikey** - The API key of authenticated user, defaults to None 31 | 32 | > **bearer_token** - The Bearer token of authenticated user, defaults to None 33 | 34 | > **grace_period** - This defaults to 600, I don't know why yet. 35 | 36 | ## Usage 37 | Here's a really simple example of how you can use this library. 38 | 39 | ``` 40 | import sonoff 41 | import config 42 | 43 | s = sonoff.Sonoff(config.username, config.password, config.api_region) 44 | devices = s.get_devices() 45 | if devices: 46 | # We found a device, lets turn something on 47 | device_id = devices[0]['deviceid'] 48 | s.switch('on', device_id, None) 49 | 50 | # update config 51 | config.api_region = s.get_api_region 52 | config.user_apikey = s.get_user_apikey 53 | config.bearer_token = s.get_bearer_token 54 | ``` 55 | 56 | ## Support 57 | 58 | I have tested in Python 2 and Python 3, however as we all know there may be some library weirdness. 59 | 60 | I mainly put this together for my own use, I have learned a little about how the Sonoff kit works but for support it might be better to look at the library Peter Buga put together. I'm happy to look at any issues though. 61 | 62 | ## Troubleshooting 63 | 64 | ### Ewelink registration for 4 channel switches 65 | The Sonoff switches have one of the most non-intuitive installation processes I have encountered. For registering my 4 channel switch I had to: 66 | * Hold one of the buttons until it flashed quick, quick, slow. 67 | * Hold a second time until it rapidly flashed in a constant pattern. I did not see the ITEAD-xx access point until it rapidly flashed. 68 | * Once it is rapidly flashing, connect to the ITEAD-xx network. 69 | * Choose the Compatible Pairing Mode (AP) option, then press Next. (This looks like a help page, but it is actually a fourth option (and the one you want!!)). 70 | * Follow the onscreen instructions. 71 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests>=2.12.4 2 | websocket-client>=0.54.0 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setup(name='sonoff-python', 7 | version='0.2.1', 8 | description='Make use of your sonoff smart switches without flashing them via the cloud APIs', 9 | long_description=long_description, 10 | long_description_content_type="text/markdown", 11 | url='https://github.com/lucien2k/sonoff-python', 12 | author='Alex Cowan', 13 | author_email='acowan@gmail.com', 14 | license='MIT', 15 | classifiers=[ 16 | "Programming Language :: Python :: 3", 17 | "License :: OSI Approved :: MIT License", 18 | "Operating System :: OS Independent", 19 | ], 20 | packages=['sonoff'], 21 | install_requires=[ 22 | 'requests>=2.12.4', 23 | 'websocket-client>=0.54.0' 24 | ], 25 | zip_safe=False) 26 | -------------------------------------------------------------------------------- /sonoff/README: -------------------------------------------------------------------------------- 1 | # sonoff-python 2 | Make use of your sonoff smart switches without flashing them via the cloud APIs, this should work in Python 2 or Python 3. 3 | 4 | This project is heavily inspired (read: almost entirely borrowed) by the work that Peter Buga did on a Simple Home Assistant integration for Ewelink https://github.com/peterbuga/HASS-sonoff-ewelink 5 | 6 | ## Configuration 7 | 8 | Configuration is simple and basically passed to the class when you instantiate it. Username is either the email address you use to log in to Ewelink, or your phone number with the country code in front. 9 | 10 | > **username** - The email address or phone number you signed up with on Ewelink. Preface phone number with the country code 11 | > **password** - Your password to Ewelink. 12 | > **api_region** - The API region you use, valid ones are apparently 'us', 'eu' and 'cn' 13 | > **user_apikey** - The API key of authenticated user, defaults to None 14 | > **bearer_token** - The Bearer token of authenticated user, defaults to None 15 | > **grace_period** - This defaults to 600, I don't know why yet. 16 | 17 | 18 | ## Usage 19 | Here's a really simple example of how you can use this library. 20 | 21 | import sonoff 22 | import config 23 | 24 | s = sonoff.Sonoff(config.username, config.password, config.api_region, config.user_apikey, config.bearer_token) 25 | devices = s.get_devices() 26 | if devices: 27 | # We found a device, lets turn something on 28 | device_id = devices[0]['deviceid'] 29 | s.switch('on', device_id, 0) 30 | 31 | # update config 32 | config.api_region = s.get_api_region 33 | config.user_apikey = s.get_user_apikey 34 | config.bearer_token = s.get_bearer_token 35 | -------------------------------------------------------------------------------- /sonoff/__init__.py: -------------------------------------------------------------------------------- 1 | name = "sonoff-python" 2 | 3 | import sys 4 | 5 | if sys.version_info.major >= 3: 6 | from sonoff.sonoff import Sonoff 7 | else: 8 | from sonoff import Sonoff -------------------------------------------------------------------------------- /sonoff/sonoff.py: -------------------------------------------------------------------------------- 1 | # The domain of your component. Should be equal to the name of your component. 2 | import logging, time, hmac, hashlib, random, base64, json, socket, requests, re, uuid 3 | from datetime import timedelta 4 | 5 | SCAN_INTERVAL = timedelta(seconds=60) 6 | HTTP_MOVED_PERMANENTLY, HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED, HTTP_NOT_FOUND = 301,400,401,404 7 | 8 | _LOGGER = logging.getLogger(__name__) 9 | 10 | 11 | def gen_nonce(length=8): 12 | """Generate pseudorandom number.""" 13 | return ''.join([str(random.randint(0, 9)) for i in range(length)]) 14 | 15 | class Sonoff(): 16 | # def __init__(self, hass, email, password, api_region, grace_period): 17 | def __init__(self, username, password, api_region, user_apikey=None, bearer_token=None, grace_period=600): 18 | 19 | self._username = username 20 | self._password = password 21 | self._api_region = api_region 22 | self._wshost = None 23 | 24 | self._skipped_login = 0 25 | self._grace_period = timedelta(seconds=grace_period) 26 | 27 | self._user_apikey = user_apikey 28 | self._bearer_token = bearer_token 29 | self._devices = [] 30 | self._ws = None 31 | 32 | # app details 33 | self._app_version = '3.5.3' 34 | self._appid = 'oeVkj2lYFGnJu5XUtWisfW4utiN4u9Mq' 35 | self._model = 'iPhone10,6' 36 | self._os = 'iOS' 37 | self._rom_version = '11.1.2' 38 | self._version = '6' 39 | 40 | if user_apikey and bearer_token: 41 | self.do_reconnect() 42 | else: 43 | self.do_login() 44 | 45 | def do_reconnect(self): 46 | self._headers = { 47 | 'Authorization' : 'Bearer ' + self._bearer_token, 48 | 'Content-Type' : 'application/json;charset=UTF-8' 49 | } 50 | 51 | try: 52 | # get the websocket host 53 | if not self._wshost: 54 | self.set_wshost() 55 | 56 | self.update_devices() # to get the devices list 57 | except: 58 | self.do_login() 59 | 60 | def do_login(self): 61 | 62 | # reset the grace period 63 | self._skipped_login = 0 64 | 65 | app_details = { 66 | 'password' : self._password, 67 | 'version' : self._version, 68 | 'ts' : int(time.time()), 69 | 'nonce' : gen_nonce(15), 70 | 'appid' : self._appid, 71 | 'imei' : str(uuid.uuid4()), 72 | 'os' : self._os, 73 | 'model' : self._model, 74 | 'romVersion': self._rom_version, 75 | 'appVersion': self._app_version 76 | } 77 | 78 | if re.match(r'[^@]+@[^@]+\.[^@]+', self._username): 79 | app_details['email'] = self._username 80 | else: 81 | app_details['phoneNumber'] = self._username 82 | 83 | decryptedAppSecret = b'6Nz4n0xA8s8qdxQf2GqurZj2Fs55FUvM' 84 | 85 | hex_dig = hmac.new( 86 | decryptedAppSecret, 87 | str.encode(json.dumps(app_details)), 88 | digestmod=hashlib.sha256).digest() 89 | 90 | sign = base64.b64encode(hex_dig).decode() 91 | 92 | self._headers = { 93 | 'Authorization' : 'Sign ' + sign, 94 | 'Content-Type' : 'application/json;charset=UTF-8' 95 | } 96 | 97 | r = requests.post('https://{}-api.coolkit.cc:8080/api/user/login'.format(self._api_region), 98 | headers=self._headers, json=app_details) 99 | 100 | resp = r.json() 101 | 102 | # get a new region to login 103 | if 'error' in resp and 'region' in resp and resp['error'] == HTTP_MOVED_PERMANENTLY: 104 | self._api_region = resp['region'] 105 | 106 | _LOGGER.warning("found new region: >>> %s <<< (you should change api_region option to this value in configuration.yaml)", self._api_region) 107 | 108 | # re-login using the new localized endpoint 109 | self.do_login() 110 | return 111 | 112 | elif 'error' in resp and resp['error'] in [HTTP_NOT_FOUND, HTTP_BAD_REQUEST]: 113 | # (most likely) login with +86... phone number and region != cn 114 | if '@' not in self._username and self._api_region != 'cn': 115 | self._api_region = 'cn' 116 | self.do_login() 117 | 118 | else: 119 | _LOGGER.error("Couldn't authenticate using the provided credentials!") 120 | 121 | return 122 | 123 | self._bearer_token = resp['at'] 124 | self._user_apikey = resp['user']['apikey'] 125 | self._headers.update({'Authorization' : 'Bearer ' + self._bearer_token}) 126 | 127 | # get the websocket host 128 | if not self._wshost: 129 | self.set_wshost() 130 | 131 | self.update_devices() # to get the devices list 132 | 133 | def set_wshost(self): 134 | r = requests.post('https://%s-disp.coolkit.cc:8080/dispatch/app' % self._api_region, headers=self._headers) 135 | resp = r.json() 136 | 137 | if 'error' in resp and resp['error'] == 0 and 'domain' in resp: 138 | self._wshost = resp['domain'] 139 | _LOGGER.info("Found websocket address: %s", self._wshost) 140 | else: 141 | raise Exception('No websocket domain') 142 | 143 | def is_grace_period(self): 144 | grace_time_elapsed = self._skipped_login * int(SCAN_INTERVAL.total_seconds()) 145 | grace_status = grace_time_elapsed < int(self._grace_period.total_seconds()) 146 | 147 | if grace_status: 148 | self._skipped_login += 1 149 | 150 | return grace_status 151 | 152 | def update_devices(self): 153 | 154 | # the login failed, nothing to update 155 | if not self._wshost: 156 | return [] 157 | 158 | # we are in the grace period, no updates to the devices 159 | if self._skipped_login and self.is_grace_period(): 160 | _LOGGER.info("Grace period active") 161 | return self._devices 162 | 163 | query_params = { 164 | 'lang': 'en', 165 | 'version': self._version, 166 | 'ts': int(time.time()), 167 | 'nonce': gen_nonce(15), 168 | 'appid': self._appid, 169 | 'imei': str(uuid.uuid4()), 170 | 'os': self._os, 171 | 'model': self._model, 172 | 'romVersion': self._rom_version, 173 | 'appVersion': self._app_version 174 | } 175 | r = requests.get('https://{}-api.coolkit.cc:8080/api/user/device'.format(self._api_region), 176 | params=query_params, 177 | headers=self._headers) 178 | 179 | resp = r.json() 180 | if 'error' in resp and resp['error'] in [HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED]: 181 | # @IMPROVE add maybe a service call / switch to deactivate sonoff component 182 | if self.is_grace_period(): 183 | _LOGGER.warning("Grace period activated!") 184 | 185 | # return the current (and possible old) state of devices 186 | # in this period any change made with the mobile app (on/off) won't be shown in HA 187 | return self._devices 188 | 189 | _LOGGER.info("Re-login component") 190 | self.do_login() 191 | 192 | self._devices = resp.get('devicelist', []) 193 | return self._devices 194 | 195 | def get_devices(self, force_update = False): 196 | if force_update: 197 | return self.update_devices() 198 | 199 | return self._devices 200 | 201 | def get_device(self, deviceid): 202 | for device in self.get_devices(): 203 | if 'deviceid' in device and device['deviceid'] == deviceid: 204 | return device 205 | 206 | def get_api_region(self): 207 | return self._api_region 208 | 209 | def get_bearer_token(self): 210 | return self._bearer_token 211 | 212 | def get_user_apikey(self): 213 | return self._user_apikey 214 | 215 | def _get_ws(self): 216 | """Check if the websocket is setup and connected.""" 217 | try: 218 | create_connection 219 | except: 220 | from websocket import create_connection 221 | 222 | if self._ws is None: 223 | try: 224 | self._ws = create_connection(('wss://{}:8080/api/ws'.format(self._wshost)), timeout=10) 225 | 226 | payload = { 227 | 'action' : "userOnline", 228 | 'userAgent' : 'app', 229 | 'version' : 6, 230 | 'nonce' : gen_nonce(15), 231 | 'apkVesrion': "1.8", 232 | 'os' : 'ios', 233 | 'at' : self.get_bearer_token(), 234 | 'apikey' : self.get_user_apikey(), 235 | 'ts' : str(int(time.time())), 236 | 'model' : 'iPhone10,6', 237 | 'romVersion': '11.1.2', 238 | 'sequence' : str(time.time()).replace('.','') 239 | } 240 | 241 | self._ws.send(json.dumps(payload)) 242 | wsresp = self._ws.recv() 243 | # _LOGGER.error("open socket: %s", wsresp) 244 | 245 | except (socket.timeout, ConnectionRefusedError, ConnectionResetError): 246 | _LOGGER.error('failed to create the websocket') 247 | self._ws = None 248 | 249 | return self._ws 250 | 251 | def switch(self, new_state, deviceid, outlet=None): 252 | """Switch on or off.""" 253 | 254 | # we're in the grace period, no state change 255 | if self._skipped_login: 256 | _LOGGER.info("Grace period, no state change") 257 | return (not new_state) 258 | 259 | self._ws = self._get_ws() 260 | 261 | if not self._ws: 262 | _LOGGER.warning('invalid websocket, state cannot be changed') 263 | return (not new_state) 264 | 265 | # convert from True/False to on/off 266 | if isinstance(new_state, (bool)): 267 | new_state = 'on' if new_state else 'off' 268 | 269 | device = self.get_device(deviceid) 270 | 271 | if outlet is not None: 272 | _LOGGER.debug("Switching `%s - %s` on outlet %d to state: %s", \ 273 | device['deviceid'], device['name'] , (outlet+1) , new_state) 274 | else: 275 | _LOGGER.debug("Switching `%s` to state: %s", deviceid, new_state) 276 | 277 | if not device: 278 | _LOGGER.error('unknown device to be updated') 279 | return False 280 | 281 | # the payload rule is like this: 282 | # normal device (non-shared) 283 | # apikey = login apikey (= device apikey too) 284 | # 285 | # shared device 286 | # apikey = device apikey 287 | # selfApiKey = login apikey (yes, it's typed corectly selfApikey and not selfApiKey :|) 288 | 289 | if outlet is not None: 290 | params = { 'switches' : device['params']['switches'] } 291 | params['switches'][outlet]['switch'] = new_state 292 | 293 | else: 294 | params = { 'switch' : new_state } 295 | 296 | payload = { 297 | 'action' : 'update', 298 | 'userAgent' : 'app', 299 | 'params' : params, 300 | 'apikey' : device['apikey'], 301 | 'deviceid' : str(deviceid), 302 | 'sequence' : str(time.time()).replace('.',''), 303 | 'controlType' : device['params']['controlType'] if 'controlType' in device['params'] else 4, 304 | 'ts' : 0 305 | } 306 | 307 | # this key is needed for a shared device 308 | if device['apikey'] != self.get_user_apikey(): 309 | payload['selfApikey'] = self.get_user_apikey() 310 | 311 | self._ws.send(json.dumps(payload)) 312 | wsresp = self._ws.recv() 313 | # _LOGGER.debug("switch socket: %s", wsresp) 314 | 315 | self._ws.close() # no need to keep websocket open (for now) 316 | self._ws = None 317 | 318 | # set also te pseudo-internal state of the device until the real refresh kicks in 319 | for idx, device in enumerate(self._devices): 320 | if device['deviceid'] == deviceid: 321 | if outlet is not None: 322 | self._devices[idx]['params']['switches'][outlet]['switch'] = new_state 323 | else: 324 | self._devices[idx]['params']['switch'] = new_state 325 | 326 | 327 | # @TODO add some sort of validation here, maybe call the devices status 328 | # only IF MAIN STATUS is done over websocket exclusively 329 | 330 | return new_state 331 | 332 | --------------------------------------------------------------------------------