├── LICENSE ├── README.md ├── orvibo ├── __init__.py └── s20.py ├── setup.cfg └── setup.py /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 happyleavesaoc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-orvibo 2 | 3 | Control Orvibo devices with Python 3. Currently supports the S20 WiFi Smart Switch. 4 | 5 | ## Usage 6 | 7 | ```python 8 | from orvibo.s20 import S20, discover 9 | 10 | hosts = discover() # Discover devices on your local network. 11 | s20 = S20("x.x.x.x") # Use a discovered host, or supply a known host. 12 | print(s20.on) # Current state (True = ON, False = OFF). 13 | s20.on = True # Turn it on. 14 | s20.on = False # Turn it off. 15 | ``` 16 | 17 | ## Contributions 18 | 19 | Pull requests are welcome. Possible areas for improvement: 20 | 21 | * Additional Orvibo devices. 22 | * Expand S20 functions: Timers, configuration, etc 23 | 24 | ## Disclaimer 25 | 26 | Not affiliated with Shenzhen Orvibo Electronics Co., Ltd. 27 | -------------------------------------------------------------------------------- /orvibo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/happyleavesaoc/python-orvibo/601c058c00e052b81b6956bb063524a451f7432c/orvibo/__init__.py -------------------------------------------------------------------------------- /orvibo/s20.py: -------------------------------------------------------------------------------- 1 | """ Orbivo S20. """ 2 | 3 | import binascii 4 | import struct 5 | import logging 6 | import socket 7 | import threading 8 | import time 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | # S20 UDP port 13 | PORT = 10000 14 | 15 | # UDP best-effort. 16 | RETRIES = 3 17 | TIMEOUT = 1.0 18 | DISCOVERY_TIMEOUT = 1.0 19 | 20 | # Timeout after which to renew device subscriptions 21 | SUBSCRIPTION_TIMEOUT = 60 22 | 23 | # Packet constants. 24 | MAGIC = b'\x68\x64' 25 | DISCOVERY = b'\x00\x06\x71\x61' 26 | DISCOVERY_RESP = b'\x00\x2a\x71\x61' 27 | SUBSCRIBE = b'\x00\x1e\x63\x6c' 28 | SUBSCRIBE_RESP = b'\x00\x18\x63\x6c' 29 | CONTROL = b'\x00\x17\x64\x63' 30 | CONTROL_RESP = b'\x00\x17\x73\x66' 31 | PADDING_1 = b'\x20\x20\x20\x20\x20\x20' 32 | PADDING_2 = b'\x00\x00\x00\x00' 33 | ON = b'\x01' 34 | OFF = b'\x00' 35 | 36 | # Socket 37 | _SOCKET = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 38 | 39 | # Buffer 40 | _BUFFER = {} 41 | 42 | 43 | def _listen(): 44 | """ Listen on socket. """ 45 | while True: 46 | data, addr = _SOCKET.recvfrom(1024) 47 | _BUFFER[addr[0]] = data 48 | 49 | 50 | def _setup(): 51 | """ Set up module. 52 | 53 | Open a UDP socket, and listen in a thread. 54 | """ 55 | _SOCKET.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 56 | _SOCKET.bind(('', PORT)) 57 | udp = threading.Thread(target=_listen, daemon=True) 58 | udp.start() 59 | 60 | 61 | def _device_time(tab): 62 | ts = struct.unpack(' time.time() - SUBSCRIPTION_TIMEOUT 214 | 215 | def _control(self, state): 216 | """ Control device state. 217 | 218 | Possible states are ON or OFF. 219 | 220 | :param state: Switch to this state. 221 | """ 222 | 223 | # Renew subscription if necessary 224 | if not self._subscription_is_recent(): 225 | self._subscribe() 226 | 227 | cmd = MAGIC + CONTROL + self._mac + PADDING_1 + PADDING_2 + state 228 | _LOGGER.debug("Sending new state to %s: %s", self.host, ord(state)) 229 | ack_state = self._udp_transact(cmd, self._control_resp, state) 230 | if ack_state is None: 231 | raise S20Exception( 232 | "Device didn't acknowledge control request: {}".format( 233 | self.host)) 234 | 235 | def _discovery_resp(self, data): 236 | """ Handle a discovery response. 237 | 238 | :param data: Payload. 239 | :param addr: Address tuple. 240 | :returns: MAC and reversed MAC. 241 | """ 242 | if _is_discovery_response(data): 243 | _LOGGER.debug("Discovered MAC of %s: %s", self.host, 244 | binascii.hexlify(data[7:13]).decode()) 245 | return (data[7:13], data[19:25]) 246 | 247 | def _subscribe_resp(self, data): 248 | """ Handle a subscribe response. 249 | 250 | :param data: Payload. 251 | :returns: State (ON/OFF) 252 | """ 253 | if _is_subscribe_response(data): 254 | status = bytes([data[23]]) 255 | _LOGGER.debug("Successfully subscribed to %s, state: %s", 256 | self.host, ord(status)) 257 | return status 258 | 259 | def _control_resp(self, data, state): 260 | """ Handle a control response. 261 | 262 | :param data: Payload. 263 | :param state: Requested state. 264 | :returns: Acknowledged state. 265 | """ 266 | if _is_control_response(data): 267 | ack_state = bytes([data[22]]) 268 | if state == ack_state: 269 | _LOGGER.debug("Received state ack from %s, state: %s", 270 | self.host, ord(ack_state)) 271 | return ack_state 272 | 273 | def _udp_transact(self, payload, handler, *args, 274 | broadcast=False, timeout=TIMEOUT): 275 | """ Complete a UDP transaction. 276 | 277 | UDP is stateless and not guaranteed, so we have to 278 | take some mitigation steps: 279 | - Send payload multiple times. 280 | - Wait for awhile to receive response. 281 | 282 | :param payload: Payload to send. 283 | :param handler: Response handler. 284 | :param args: Arguments to pass to response handler. 285 | :param broadcast: Send a broadcast instead. 286 | :param timeout: Timeout in seconds. 287 | """ 288 | if self.host in _BUFFER: 289 | del _BUFFER[self.host] 290 | host = self.host 291 | if broadcast: 292 | host = '255.255.255.255' 293 | retval = None 294 | for _ in range(RETRIES): 295 | _SOCKET.sendto(bytearray(payload), (host, PORT)) 296 | start = time.time() 297 | while time.time() < start + timeout: 298 | data = _BUFFER.get(self.host, None) 299 | if data: 300 | retval = handler(data, *args) 301 | # Return as soon as a response is received 302 | if retval: 303 | return retval 304 | 305 | def _turn_on(self): 306 | """ Turn on the device. """ 307 | self._control(ON) 308 | 309 | def _turn_off(self): 310 | """ Turn off the device. """ 311 | self._control(OFF) 312 | 313 | 314 | _setup() 315 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='orvibo', 5 | version='1.1.2', 6 | description='Control Orvibo products.', 7 | url='https://github.com/happyleavesaoc/python-orvibo/', 8 | license='MIT', 9 | author='happyleaves', 10 | author_email='happyleaves.tfr@gmail.com', 11 | packages=['orvibo'], 12 | install_requires=[], 13 | classifiers=[ 14 | 'License :: OSI Approved :: MIT License', 15 | 'Operating System :: OS Independent', 16 | 'Programming Language :: Python :: 3', 17 | ] 18 | ) 19 | --------------------------------------------------------------------------------