├── .gitignore ├── LICENSE ├── README.md ├── dev_requirements.txt ├── examples └── on_off.py ├── lazylights.py ├── setup.py └── test_lazylights.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | bin/ 10 | build/ 11 | develop-eggs/ 12 | dist/ 13 | eggs/ 14 | lib/ 15 | lib64/ 16 | parts/ 17 | sdist/ 18 | var/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | 23 | # Installer logs 24 | pip-log.txt 25 | pip-delete-this-directory.txt 26 | 27 | # Unit test / coverage reports 28 | .tox/ 29 | .coverage 30 | .cache 31 | nosetests.xml 32 | coverage.xml 33 | 34 | # Translations 35 | *.mo 36 | 37 | # Mr Developer 38 | .mr.developer.cfg 39 | .project 40 | .pydevproject 41 | 42 | # Rope 43 | .ropeproject 44 | 45 | # Django stuff: 46 | *.log 47 | *.pot 48 | 49 | # Sphinx documentation 50 | docs/_build/ 51 | 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Matt Papi 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 | # Lazylights 2 | 3 | Lazylights is a Python API for controlling Lifx bulbs. 4 | 5 | # Support for 2.0 is on a branch 6 | 7 | What's running on the `master` branch is deprecated -- see the `2.0` branch 8 | with a much simpler API for working with bulbs with the 2.0 firmware. 9 | 10 | Until that's merged and on PyPI: 11 | 12 | ```shell 13 | pip install git+https://github.com/mpapi/lazylights@2.0 14 | ``` 15 | 16 | # Quick start 17 | 18 | To install, 19 | 20 | ```shell 21 | pip install git+https://github.com/mpapi/lazylights 22 | ``` 23 | 24 | Then, in Python, 25 | 26 | ```python 27 | from lazylights import Lifx 28 | import time 29 | 30 | lifx = Lifx(num_bulbs=2) # so it knows how many to wait for when connecting 31 | 32 | @lifx.on_connected 33 | def _connected(): 34 | print "Connected!" 35 | 36 | with lifx.run(): 37 | lifx.set_power_state(True) 38 | time.sleep(1) 39 | lifx.set_power_state(False) 40 | ``` 41 | 42 | 43 | # Features 44 | 45 | * connection management 46 | * high- and low-level interfaces for sending and receiving data 47 | * callback-based, non-blocking, and blocking APIs 48 | * no dependencies other than Python 49 | 50 | 51 | # Documentation 52 | 53 | Not much here yet, sadly, but the code is fairly well-documented. See the 54 | docstrings, or check out the examples directory. 55 | 56 | 57 | # Hacking 58 | 59 | ```shell 60 | pip install -r dev_requirements.txt 61 | flake8 *.py && nosetests 62 | ``` 63 | 64 | # Credits 65 | 66 | The [`lifxjs` Protocol wiki page][lifxjs_protocol] was particularly helpful in 67 | the creation of this package. 68 | 69 | 70 | # License 71 | 72 | Licensed under the MIT license. See the LICENSE file for the full text. 73 | 74 | 75 | [lifxjs_protocol]: https://github.com/magicmonkey/lifxjs/blob/master/Protocol.md 76 | -------------------------------------------------------------------------------- /dev_requirements.txt: -------------------------------------------------------------------------------- 1 | flake8==2.1.0 2 | nose==1.3.0 3 | -------------------------------------------------------------------------------- /examples/on_off.py: -------------------------------------------------------------------------------- 1 | from lazylights import Lifx 2 | import time 3 | 4 | lifx = Lifx(num_bulbs=2) # so it knows how many to wait for when connecting 5 | 6 | @lifx.on_connected 7 | def _connected(): 8 | print "Connected!" 9 | 10 | with lifx.run(): 11 | lifx.set_power_state(True) 12 | time.sleep(1) 13 | lifx.set_power_state(False) 14 | -------------------------------------------------------------------------------- /lazylights.py: -------------------------------------------------------------------------------- 1 | from contextlib import closing, contextmanager 2 | import socket 3 | import struct 4 | from threading import Thread, Event, Lock 5 | from collections import namedtuple 6 | import Queue 7 | 8 | 9 | BASE_FORMAT = '>> _bytes('\x12\x34\x56') 113 | '123456' 114 | """ 115 | return ''.join('%02x' % ord(c) for c in packet) 116 | 117 | 118 | def _unbytes(bytestr): 119 | """ 120 | Returns a bytestring from the human-friendly string returned by `_bytes`. 121 | 122 | >>> _unbytes('123456') 123 | '\x12\x34\x56' 124 | """ 125 | return ''.join(chr(int(bytestr[k:k + 2], 16)) 126 | for k in range(0, len(bytestr), 2)) 127 | 128 | 129 | def _spawn(func, *args, **kwargs): 130 | """ 131 | Calls `func(*args, **kwargs)` in a daemon thread, and returns the (started) 132 | Thread object. 133 | """ 134 | thr = Thread(target=func, args=args, kwargs=kwargs) 135 | thr.daemon = True 136 | thr.start() 137 | return thr 138 | 139 | 140 | def _retry(event, attempts, delay): 141 | """ 142 | An iterator of pairs of (attempt number, event set), checking whether 143 | `event` is set up to `attempts` number of times, and delaying `delay` 144 | seconds in between. 145 | 146 | Terminates as soon as `event` is set, or until `attempts` have been made. 147 | 148 | Intended to be used in a loop, as in: 149 | 150 | for num, ok in _retry(event_to_wait_for, 10, 1.0): 151 | do_async_thing_that_sets_event() 152 | _log('tried %d time(s) to set event', num) 153 | if not ok: 154 | raise Exception('failed to set event') 155 | """ 156 | event.clear() 157 | attempted = 0 158 | while attempted < attempts and not event.is_set(): 159 | yield attempted, event.is_set() 160 | if event.wait(delay): 161 | break 162 | yield attempted, event.is_set() 163 | 164 | 165 | @contextmanager 166 | def _blocking(lock, state_dict, event, timeout=None): 167 | """ 168 | A contextmanager that clears `state_dict` and `event`, yields, and waits 169 | for the event to be set. Clearing an yielding are done within `lock`. 170 | 171 | Used for blocking request/response semantics on the request side, as in: 172 | 173 | with _blocking(lock, state, event): 174 | send_request() 175 | 176 | The response side would then do something like: 177 | 178 | with lock: 179 | state['data'] = '...' 180 | event.set() 181 | """ 182 | with lock: 183 | state_dict.clear() 184 | event.clear() 185 | yield 186 | event.wait(timeout) 187 | 188 | 189 | class Callbacks(object): 190 | """ 191 | An object to manage callbacks. It exposes a queue to schedule callbacks, 192 | and a `run` function to be run in a separate thread to consume the queue 193 | and run the callback functions. 194 | """ 195 | def __init__(self, logger): 196 | self._logger = logger 197 | self._callbacks = {} 198 | self._queue = Queue.Queue() 199 | 200 | def register(self, event, fn): 201 | """ 202 | Tell the object to run `fn` whenever a message of type `event` is 203 | received. 204 | """ 205 | self._callbacks.setdefault(event, []).append(fn) 206 | return fn 207 | 208 | def put(self, event, *args, **kwargs): 209 | """ 210 | Schedule a callback for `event`, passing `args` and `kwargs` to each 211 | registered callback handler. 212 | """ 213 | self._queue.put((event, args, kwargs)) 214 | 215 | def stop(self): 216 | """ 217 | Stop processing callbacks (once the queue is empty). 218 | """ 219 | self._queue.put(_SHUTDOWN) 220 | 221 | def run(self): 222 | """ 223 | Process all callbacks, until `stop()` is called. Intended to run in 224 | its own thread. 225 | """ 226 | while True: 227 | msg = self._queue.get() 228 | if msg is _SHUTDOWN: 229 | break 230 | event, args, kwargs = msg 231 | self._logger('<< %s', event) 232 | for func in self._callbacks.get(event, []): 233 | func(*args, **kwargs) 234 | 235 | 236 | class PacketReceiver(object): 237 | """ 238 | An object to process incoming packets. It parses the data in the packets 239 | and schedules callbacks (on a `Callback` object) according to the packet's 240 | type. The `is_shutdown` event can be used to wait for the receiver to shut 241 | down after calling `stop`. 242 | """ 243 | def __init__(self, addr, callbacks, buffer_size=65536, timeout=0.5): 244 | self._addr = addr 245 | self._shutdown = Event() 246 | self._callbacks = callbacks 247 | self._buffer_size = buffer_size 248 | self._timeout = timeout 249 | 250 | @property 251 | def is_shutdown(self): 252 | """ 253 | An `Event` that is set when the receiver starts shutting down. 254 | """ 255 | return self._shutdown 256 | 257 | def stop(self): 258 | """ 259 | Stop processing incoming packets. 260 | """ 261 | self._shutdown.set() 262 | 263 | def run(self): 264 | """ 265 | Process all incoming packets, until `stop()` is called. Intended to run 266 | in its own thread. 267 | """ 268 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 269 | sock.bind(self._addr) 270 | sock.settimeout(self._timeout) 271 | with closing(sock): 272 | while not self._shutdown.is_set(): 273 | try: 274 | data, addr = sock.recvfrom(self._buffer_size) 275 | except socket.timeout: 276 | continue 277 | 278 | header, rest = parse_packet(data) 279 | if header.packet_type in _PAYLOADS: 280 | payload = parse_payload(rest, 281 | *_PAYLOADS[header.packet_type]) 282 | self._callbacks.put(header.packet_type, 283 | header, payload, None, addr) 284 | else: 285 | self._callbacks.put(EVENT_UNKNOWN, 286 | header, None, rest, addr) 287 | 288 | 289 | class PacketSender(object): 290 | """ 291 | An object to manage outgoing packets. It exposes a queue to send packets, 292 | and a `run` function to be run in a separate thread to consume the queue 293 | while maintaining a connection to a gateway. 294 | """ 295 | def __init__(self): 296 | self._queue = Queue.Queue() 297 | self._connected = Event() 298 | self._gateway = None 299 | 300 | @property 301 | def is_connected(self): 302 | """ 303 | An `Event` that is set once the sender has connected to a gateway. 304 | """ 305 | return self._connected 306 | 307 | def put(self, packet): 308 | """ 309 | Schedules a packet to be sent to the gateway. 310 | """ 311 | self._queue.put(packet) 312 | 313 | def stop(self): 314 | """ 315 | Stop processing outgoing packets (once the queue is empty). 316 | """ 317 | self._queue.put(_SHUTDOWN) 318 | 319 | def run(self): 320 | """ 321 | Process all outgoing packets, until `stop()` is called. Intended to run 322 | in its own thread. 323 | """ 324 | while True: 325 | to_send = self._queue.get() 326 | if to_send is _SHUTDOWN: 327 | break 328 | 329 | # If we get a gateway object, connect to it. Otherwise, assume 330 | # it's a bytestring and send it out on the socket. 331 | if isinstance(to_send, Gateway): 332 | self._gateway = to_send 333 | self._connected.set() 334 | else: 335 | if not self._gateway: 336 | raise SendException('no gateway') 337 | dest = (self._gateway.addr, self._gateway.port) 338 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 339 | sock.sendto(to_send, dest) 340 | 341 | 342 | class Logger(object): 343 | """ 344 | An object to manage sequential logging. 345 | """ 346 | def __init__(self, enabled=True): 347 | self._enabled = enabled 348 | self._queue = Queue.Queue() 349 | 350 | def __call__(self, msg, *args): 351 | """ 352 | Queue a log message, formatting `msg` with `args`. 353 | """ 354 | self._queue.put(msg % args) 355 | 356 | def stop(self): 357 | """ 358 | Stop processing log messages (once the queue is empty). 359 | """ 360 | self._queue.put(_SHUTDOWN) 361 | 362 | def run(self): 363 | """ 364 | Process all log messages, until `stop()` is called. Intended to run 365 | in its own thread. 366 | """ 367 | while True: 368 | msg = self._queue.get() 369 | if msg is _SHUTDOWN: 370 | break 371 | if self._enabled: 372 | print msg 373 | 374 | 375 | class ConnectException(Exception): 376 | """ 377 | An Exception raised when a gateway can't be found or connected to. 378 | """ 379 | pass 380 | 381 | 382 | class SendException(Exception): 383 | """ 384 | An Exception raised when attempting to send data to a connected gateway. 385 | """ 386 | pass 387 | 388 | 389 | class Lifx(object): 390 | """ 391 | Manages connecting to, sending requests to, and receiving responses from 392 | Lifx bulbs. 393 | """ 394 | 395 | def __init__(self, num_bulbs=None): 396 | # Number of bulbs to wait for when connecting. 397 | self.num_bulbs = 1 if num_bulbs is None else num_bulbs 398 | 399 | # Connection/bulb state. 400 | self.gateway = None 401 | self.bulbs = {} 402 | self.power_state = {} 403 | self.light_state = {} 404 | 405 | # Connection/state events. 406 | self.gateway_found_event = Event() 407 | self.bulbs_found_event = Event() 408 | self.power_state_event = Event() 409 | self.light_state_event = Event() 410 | self.lock = Lock() 411 | 412 | # Logging. 413 | self.logger = Logger(False) 414 | 415 | # Callbacks. 416 | self.callbacks = Callbacks(self.logger) 417 | self.callbacks.register(RESP_GATEWAY, self._on_gateway) 418 | self.callbacks.register(RESP_POWER_STATE, self._on_power_state) 419 | self.callbacks.register(RESP_LIGHT_STATE, self._on_light_state) 420 | 421 | # Sending and receiving. 422 | self.receiver = PacketReceiver(('0.0.0.0', LIFX_PORT), self.callbacks) 423 | self.sender = PacketSender() 424 | 425 | ### Built-in callbacks 426 | 427 | def _on_gateway(self, header, payload, rest, addr): 428 | """ 429 | Records a discovered gateway, for connecting to later. 430 | """ 431 | if payload.get('service') == SERVICE_UDP: 432 | self.gateway = Gateway(addr[0], payload['port'], header.gateway) 433 | self.gateway_found_event.set() 434 | 435 | def _on_power_state(self, header, payload, rest, addr): 436 | """ 437 | Records the power (on/off) state of bulbs, and forwards to a high-level 438 | callback with human-friendlier arguments. 439 | """ 440 | with self.lock: 441 | self.power_state[header.mac] = payload 442 | if len(self.power_state) >= self.num_bulbs: 443 | self.power_state_event.set() 444 | 445 | self.callbacks.put(EVENT_POWER_STATE, self.get_bulb(header.mac), 446 | is_on=bool(payload['is_on'])) 447 | 448 | def _on_light_state(self, header, payload, rest, addr): 449 | """ 450 | Records the light state of bulbs, and forwards to a high-level callback 451 | with human-friendlier arguments. 452 | """ 453 | with self.lock: 454 | label = payload['label'].strip('\x00') 455 | self.bulbs[header.mac] = bulb = Bulb(label, header.mac) 456 | if len(self.bulbs) >= self.num_bulbs: 457 | self.bulbs_found_event.set() 458 | 459 | self.light_state[header.mac] = payload 460 | if len(self.light_state) >= self.num_bulbs: 461 | self.light_state_event.set() 462 | 463 | self.callbacks.put(EVENT_LIGHT_STATE, bulb, 464 | raw=payload, 465 | hue=(payload['hue'] / float(0xffff) * 360) % 360.0, 466 | saturation=payload['sat'] / float(0xffff), 467 | brightness=payload['bright'] / float(0xffff), 468 | kelvin=payload['kelvin'], 469 | is_on=bool(payload['power'])) 470 | 471 | ### State methods 472 | 473 | def get_bulb(self, mac): 474 | """ 475 | Returns a Bulb object corresponding to the bulb with the mac address 476 | `mac` (a 6-byte bytestring). 477 | """ 478 | return self.bulbs.get(mac, Bulb('Bulb %s' % _bytes(mac), mac)) 479 | 480 | ### Sender methods 481 | 482 | def send(self, packet_type, bulb, packet_fmt, *packet_args): 483 | """ 484 | Builds and sends a packet to one or more bulbs. 485 | """ 486 | packet = build_packet(packet_type, self.gateway.mac, bulb, 487 | packet_fmt, *packet_args) 488 | self.logger('>> %s', _bytes(packet)) 489 | self.sender.put(packet) 490 | 491 | def set_power_state(self, is_on, bulb=ALL_BULBS, timeout=None): 492 | """ 493 | Sets the power state of one or more bulbs. 494 | """ 495 | with _blocking(self.lock, self.power_state, self.light_state_event, 496 | timeout): 497 | self.send(REQ_SET_POWER_STATE, 498 | bulb, '2s', '\x00\x01' if is_on else '\x00\x00') 499 | self.send(REQ_GET_LIGHT_STATE, ALL_BULBS, '') 500 | return self.power_state 501 | 502 | def set_light_state_raw(self, hue, saturation, brightness, kelvin, 503 | bulb=ALL_BULBS, timeout=None): 504 | """ 505 | Sets the (low-level) light state of one or more bulbs. 506 | """ 507 | with _blocking(self.lock, self.light_state, self.light_state_event, 508 | timeout): 509 | self.send(REQ_SET_LIGHT_STATE, bulb, 'xHHHHI', 510 | hue, saturation, brightness, kelvin, 0) 511 | self.send(REQ_GET_LIGHT_STATE, ALL_BULBS, '') 512 | return self.light_state 513 | 514 | def set_light_state(self, hue, saturation, brightness, kelvin, 515 | bulb=ALL_BULBS, timeout=None): 516 | """ 517 | Sets the light state of one or more bulbs. 518 | 519 | Hue is a float from 0 to 360, saturation and brightness are floats from 520 | 0 to 1, and kelvin is an integer. 521 | """ 522 | raw_hue = int((hue % 360) / 360.0 * 0xffff) & 0xffff 523 | raw_sat = int(saturation * 0xffff) & 0xffff 524 | raw_bright = int(brightness * 0xffff) & 0xffff 525 | return self.set_light_state_raw(raw_hue, raw_sat, raw_bright, kelvin, 526 | bulb, timeout) 527 | 528 | ### Callback helpers 529 | 530 | def on_discovered(self, fn): 531 | """ 532 | Registers a function to be called when a gateway is discovered. 533 | """ 534 | return self.callbacks.register(EVENT_DISCOVERED, fn) 535 | 536 | def on_connected(self, fn): 537 | """ 538 | Registers a function to be called when a gateway connection is made. 539 | """ 540 | return self.callbacks.register(EVENT_CONNECTED, fn) 541 | 542 | def on_bulbs_found(self, fn): 543 | """ 544 | Registers a function to be called when the expected number of bulbs are 545 | found. 546 | """ 547 | return self.callbacks.register(EVENT_BULBS_FOUND, fn) 548 | 549 | def on_light_state(self, fn): 550 | """ 551 | Registers a function to be called when light state data is received. 552 | """ 553 | return self.callbacks.register(EVENT_LIGHT_STATE, fn) 554 | 555 | def on_power_state(self, fn): 556 | """ 557 | Registers a function to be called when power state data is received. 558 | """ 559 | return self.callbacks.register(EVENT_POWER_STATE, fn) 560 | 561 | def on_unknown(self, fn): 562 | """ 563 | Registers a function to be called when packet data is received with a 564 | type that has no explicitly registered callbacks. 565 | """ 566 | return self.callbacks.register(EVENT_UNKNOWN, fn) 567 | # TODO event constants 568 | 569 | def on_packet(self, packet_type): 570 | """ 571 | Registers a function to be called when packet data is received with a 572 | specific type. 573 | """ 574 | def _wrapper(fn): 575 | return self.callbacks.register(packet_type, fn) 576 | return _wrapper 577 | 578 | ### Connection methods 579 | 580 | def connect(self, attempts=20, delay=0.5): 581 | """ 582 | Connects to a gateway, blocking until a connection is made and bulbs 583 | are found. 584 | 585 | Step 1: send a gateway discovery packet to the broadcast address, wait 586 | until we've received some info about the gateway. 587 | 588 | Step 2: connect to a discovered gateway, wait until the connection has 589 | been completed. 590 | 591 | Step 3: ask for info about bulbs, wait until we've found the number of 592 | bulbs we expect. 593 | 594 | Raises a ConnectException if any of the steps fail. 595 | """ 596 | # Broadcast discovery packets until we find a gateway. 597 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 598 | with closing(sock): 599 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 600 | discover_packet = build_packet(REQ_GATEWAY, 601 | ALL_BULBS, ALL_BULBS, '', 602 | protocol=DISCOVERY_PROTOCOL) 603 | 604 | for _, ok in _retry(self.gateway_found_event, attempts, delay): 605 | sock.sendto(discover_packet, BROADCAST_ADDRESS) 606 | if not ok: 607 | raise ConnectException('discovery failed') 608 | self.callbacks.put(EVENT_DISCOVERED) 609 | 610 | # Tell the sender to connect to the gateway until it does. 611 | for _, ok in _retry(self.sender.is_connected, 1, 3): 612 | self.sender.put(self.gateway) 613 | if not ok: 614 | raise ConnectException('connection failed') 615 | self.callbacks.put(EVENT_CONNECTED) 616 | 617 | # Send light state packets to the gateway until we find bulbs. 618 | for _, ok in _retry(self.bulbs_found_event, attempts, delay): 619 | self.send(REQ_GET_LIGHT_STATE, ALL_BULBS, '') 620 | if not ok: 621 | raise ConnectException('only found %d of %d bulbs' % ( 622 | len(self.bulbs), self.num_bulbs)) 623 | self.callbacks.put(EVENT_BULBS_FOUND) 624 | 625 | @contextmanager 626 | def run(self): 627 | """ 628 | A context manager starting up threads to send and receive data from a 629 | gateway and handle callbacks. Yields when a connection has been made, 630 | and cleans up connections and threads when it's done. 631 | """ 632 | listener_thr = _spawn(self.receiver.run) 633 | callback_thr = _spawn(self.callbacks.run) 634 | sender_thr = _spawn(self.sender.run) 635 | logger_thr = _spawn(self.logger.run) 636 | 637 | self.connect() 638 | try: 639 | yield 640 | finally: 641 | self.stop() 642 | 643 | # Wait for the listener to finish. 644 | listener_thr.join() 645 | self.callbacks.put('shutdown') 646 | 647 | # Tell the other threads to finish, and wait for them. 648 | for obj in [self.callbacks, self.sender, self.logger]: 649 | obj.stop() 650 | for thr in [callback_thr, sender_thr, logger_thr]: 651 | thr.join() 652 | 653 | def run_forever(self): 654 | """ 655 | Starts a connection and blocks until `stop` is called. 656 | """ 657 | with self.run(): 658 | self.receiver.is_shutdown.wait() 659 | 660 | def stop(self): 661 | """ 662 | Gracefully terminates a connection. 663 | """ 664 | self.receiver.stop() 665 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | setup(name='lazylights', 4 | version='0.1.0', 5 | author='Matt Papi', 6 | author_email='matt@mpapi.net', 7 | py_modules=['lazylights'], 8 | scripts=[], 9 | url='http://github.com/mpapi/lazylights/', 10 | license='LICENSE', 11 | description='Python tools for controlling Lifx bulbs.', 12 | install_requires=[]) 13 | -------------------------------------------------------------------------------- /test_lazylights.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for lazylights. 3 | """ 4 | from nose.tools import eq_ 5 | 6 | import lazylights 7 | from lazylights import parse_packet, parse_payload, build_packet 8 | 9 | 10 | OFF_PACKET = lazylights._unbytes("26000034000000000000000000000000" 11 | "99887766554400000000000000000000" 12 | "150000000000") 13 | GATEWAY = '\x99\x88\x77\x66\x55\x44' 14 | 15 | 16 | def test_parse_packet(): 17 | header, data = parse_packet(OFF_PACKET) 18 | eq_(0x26, header.size) 19 | eq_(lazylights.COMMAND_PROTOCOL, header.protocol) 20 | eq_(lazylights.ALL_BULBS, header.mac) 21 | eq_(GATEWAY, header.gateway) 22 | eq_(lazylights.REQ_SET_POWER_STATE, header.packet_type) 23 | eq_('\x00\x00', data) 24 | 25 | 26 | def test_parse_payload(): 27 | payload = parse_payload('\x00\x01', '>H', 'is_on') 28 | eq_(['is_on'], payload.keys()) 29 | eq_(1, payload['is_on']) 30 | 31 | 32 | def test_build_packet(): 33 | packet = build_packet(lazylights.REQ_SET_POWER_STATE, 34 | GATEWAY, lazylights.ALL_BULBS, 35 | '2s', '\x00\x00') 36 | eq_(packet, OFF_PACKET) 37 | --------------------------------------------------------------------------------