Skip to content

Commit 42e72fe

Browse files
initial commit
0 parents  commit 42e72fe

File tree

4 files changed

+272
-0
lines changed

4 files changed

+272
-0
lines changed

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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
9+
10+
s20 = S20("x.x.x.x") # Discover the IP on your own.
11+
print(s20.on) # Current state (True = ON, False = OFF).
12+
s20.on = True # Turn it on.
13+
s20.on = False # Turn it off.
14+
```
15+
16+
## Contributions
17+
18+
Pull requests are welcome. Possible areas for improvement:
19+
20+
* Discover configured devices (get IPs).
21+
* Additional Orvibo devices.
22+
* Expand S20 functions: Timers, configuration, etc
23+
24+
## Disclaimer
25+
26+
Not affiliated with Shenzhen Orvibo Electronics Co., Ltd.

orvibo/s20.py

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
""" Orbivo S20. """
2+
3+
import logging
4+
import socket
5+
6+
_LOGGER = logging.getLogger(__name__)
7+
8+
# S20 UDP port
9+
PORT = 10000
10+
11+
# UDP best-effort.
12+
RETRIES = 3
13+
TIMEOUT = 0.5
14+
15+
# Packet constants.
16+
MAGIC = b'\x68\x64'
17+
DISCOVERY = b'\x00\x06\x71\x61'
18+
DISCOVERY_RESP = b'\x00\x2a\x71\x61'
19+
SUBSCRIBE = b'\x00\x1e\x63\x6c'
20+
SUBSCRIBE_RESP = b'\x00\x18\x63\x6c'
21+
CONTROL = b'\x00\x17\x64\x63'
22+
CONTROL_RESP = b'\x00\x17\x73\x66'
23+
PADDING_1 = b'\x20\x20\x20\x20\x20\x20'
24+
PADDING_2 = b'\x00\x00\x00\x00'
25+
ON = b'\x01'
26+
OFF = b'\x00'
27+
28+
29+
class S20Exception(Exception):
30+
""" S20 exception. """
31+
pass
32+
33+
34+
class S20(object):
35+
""" Controls an Orbivo S20 WiFi Smart Socket.
36+
37+
http://www.orvibo.com/en_products_view.asp?mid=15&pid=4&id=234
38+
39+
Protocol documentation: http://pastebin.com/LfUhsbcS
40+
"""
41+
def __init__(self, host):
42+
""" Initialize S20 object.
43+
44+
:param host: IP or hostname of device.
45+
"""
46+
self.host = host
47+
self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
48+
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
49+
self._socket.bind(('', PORT))
50+
(self._mac, self._mac_reversed) = self._discover_mac()
51+
52+
@property
53+
def on(self):
54+
""" State property.
55+
56+
:returns: State of device (on/off).
57+
"""
58+
return self._subscribe()
59+
60+
@on.setter
61+
def on(self, state):
62+
""" Change device state.
63+
64+
:param state: True (on) or False (off).
65+
"""
66+
if state:
67+
self._turn_on()
68+
else:
69+
self._turn_off()
70+
71+
def _discover_mac(self):
72+
""" Discovers MAC address of device.
73+
74+
Discovery is done by sending a UDP broadcast.
75+
All configured devices reply. The response contains
76+
the MAC address in both needed formats.
77+
78+
:returns: Tuple of MAC address and reversed MAC address.
79+
"""
80+
mac = None
81+
mac_reversed = None
82+
cmd = MAGIC + DISCOVERY
83+
resp = self._udp_transact(cmd, self._discovery_resp,
84+
broadcast=True, timeout=1.0)
85+
if resp:
86+
(mac, mac_reversed) = resp
87+
if not mac:
88+
raise S20Exception("Couldn't discover {}".format(self.host))
89+
return (mac, mac_reversed)
90+
91+
def _subscribe(self):
92+
""" Subscribe to the device.
93+
94+
A subscription serves two purposes:
95+
- Returns state (on/off).
96+
- Enables state changes on the device
97+
for a short period of time.
98+
"""
99+
cmd = MAGIC + SUBSCRIBE + self._mac \
100+
+ PADDING_1 + self._mac_reversed + PADDING_1
101+
status = self._udp_transact(cmd, self._subscribe_resp)
102+
if status is not None:
103+
return status == ON
104+
else:
105+
raise S20Exception(
106+
"No status could be found for {}".format(self.host))
107+
108+
def _control(self, state):
109+
""" Control device state.
110+
111+
Possible states are ON or OFF.
112+
113+
:param state: Switch to this state.
114+
"""
115+
cmd = MAGIC + CONTROL + self._mac + PADDING_1 + PADDING_2 + state
116+
_LOGGER.debug("Sending new state to %s: %s", self.host, ord(state))
117+
ack_state = self._udp_transact(cmd, self._control_resp, state)
118+
if ack_state is None:
119+
raise S20Exception(
120+
"Device didn't acknowledge control request: {}".format(
121+
self.host))
122+
123+
def _discovery_resp(self, data, addr):
124+
""" Handle a discovery response.
125+
126+
:param data: Payload.
127+
:param addr: Address tuple.
128+
:returns: MAC address tuple.
129+
"""
130+
if self._is_discovery_response(data, addr):
131+
_LOGGER.debug("Discovered MAC of %s", self.host)
132+
return (data[7:13], data[19:25])
133+
return (None, None)
134+
135+
def _subscribe_resp(self, data, addr):
136+
""" Handle a subscribe response.
137+
138+
:param data: Payload.
139+
:param addr: Address tuple.
140+
:returns: State (ON/OFF)
141+
"""
142+
if self._is_subscribe_response(data, addr):
143+
status = bytes([data[23]])
144+
_LOGGER.debug("Successfully subscribed to %s, state: %s",
145+
self.host, ord(status))
146+
return status
147+
148+
def _control_resp(self, data, addr, state):
149+
""" Handle a control response.
150+
151+
:param data: Payload.
152+
:param addr: Address tuple.
153+
:param state: Acknowledged state.
154+
"""
155+
if self._is_control_response(data, addr):
156+
ack_state = bytes([data[22]])
157+
if state == ack_state:
158+
_LOGGER.debug("Received state ack from %s, state: %s",
159+
self.host, ord(ack_state))
160+
return ack_state
161+
162+
def _is_discovery_response(self, data, addr):
163+
""" Is this a discovery response?
164+
165+
:param data: Payload.
166+
:param addr: Address tuple.
167+
"""
168+
return data[0:6] == (MAGIC + DISCOVERY_RESP) and addr[0] == self.host
169+
170+
def _is_subscribe_response(self, data, addr):
171+
""" Is this a subscribe response?
172+
173+
:param data: Payload.
174+
:param addr: Address tuple.
175+
"""
176+
return data[0:6] == (MAGIC + SUBSCRIBE_RESP) and addr[0] == self.host
177+
178+
def _is_control_response(self, data, addr):
179+
""" Is this a control response?
180+
181+
:param data: Payload.
182+
:param addr: Address tuple.
183+
"""
184+
return data[0:6] == (MAGIC + CONTROL_RESP) and addr[0] == self.host
185+
186+
def _udp_transact(self, payload, handler, *args,
187+
broadcast=False, timeout=TIMEOUT):
188+
""" Complete a UDP transaction.
189+
190+
UDP is stateless and not guaranteed, so we have to
191+
take some mitigation steps:
192+
- Send payload multiple times.
193+
- Wait for awhile to receive response.
194+
195+
:param payload: Payload to send.
196+
:param handler: Response handler.
197+
:param args: Arguments to pass to response handler.
198+
:param broadcast: Send a broadcast instead.
199+
:param timeout: Timeout in seconds.
200+
"""
201+
host = self.host
202+
if broadcast:
203+
host = '255.255.255.255'
204+
retval = None
205+
self._socket.settimeout(timeout)
206+
for _ in range(RETRIES):
207+
self._socket.sendto(bytearray(payload), (host, PORT))
208+
while True:
209+
try:
210+
data, addr = self._socket.recvfrom(1024)
211+
retval = handler(data, addr, *args)
212+
except socket.timeout:
213+
break
214+
if retval:
215+
break
216+
return retval
217+
218+
def _turn_on(self):
219+
""" Turn on the device. """
220+
if not self._subscribe():
221+
self._control(ON)
222+
223+
def _turn_off(self):
224+
""" Turn off the device. """
225+
if self._subscribe():
226+
self._control(OFF)

setup.cfg

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[metadata]
2+
description-file = README.md

setup.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from setuptools import setup
2+
3+
setup(
4+
name='orvibo',
5+
version='1.0.0',
6+
description='Control Orvibo products.',
7+
url='https://github.com/happyleavesaoc/python-orvibo/',
8+
license='MIT',
9+
author='happyleaves',
10+
author_email='[email protected]',
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+
)

0 commit comments

Comments
 (0)