|
| 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) |
0 commit comments